
trivy image 掃的是封裝完的成品映像:它會把映像拉下來、拆開每一層,列出裡面實際存在的套件(OS 的 apk/apt 和語言的 node_modules 都算),再比對漏洞資料庫。
系統套件都升級過了,憑證沒有落進任何一層,部署起來 HTTP 200。看起來很乾淨。
加上映像掃描之後,紅燈——而且紅的地方跟預期完全不同。不在系統套件,
不在自己寫的程式碼,在基底映像順手塞進來的東西裡。
讀完你能做到:把 trivy image 接進管道、讀懂第一次紅燈該從哪裡看起、
判斷該修還是該刪,以及知道這道關卡守不到什麼。
① 一條 CI 管道,已經有 task 把產物封裝成映像並推上私有 registry
② 管道裡已經有 SCA 掃描(trivy fs 或同類)—— 這篇加的是「映像掃描」,兩者不同
③ registry 用自簽憑證,需要 --insecure
④ 基底映像是 node:22-alpine,應用是 Angular SSR
第 ④ 點決定了你會掃到什麼。基底換成別的,發現的內容會不一樣,
但判讀方法一樣。
本文的管道長這樣,掃描接在封裝之後:
git-clone ──┬─► workspace-probe ─┐
├─► gitleaks-scan ───┤
├─► semgrep-scan ────┼─► npm-install ─┬─► typecheck ─► nx-build ──┐
├─► sca-scan ────────┘ ├─► eslint-check ───────────┼─► build-push ─► trivy-scan
└─► generate-sbom ─► upload-sbom └─► unit-test ──────────────┘
finally: cleanup-workspace
環境:OpenShift 4.21.14、Nexus 3.93.0、Trivy 0.74.0、基底 node:22-alpine(Alpine 3.24.1)。
① 照 sca-scan 複製一份,把 trivy fs 換成 trivy image → 兩道關卡並存,不是取代
② 拿到報告先看「路徑」,不要先查 CVE 編號 → 路徑會告訴你誰帶進來的
③ 能刪就刪,ignore 是最後手段 → 加之前先問「它為什麼在映像裡」
④ 驗證掃綠了,也驗證服務還起得來 → 刪東西最怕掃描綠了程式死了
這次的結果:
os-pkgs alpine 3.24.1 18 個套件 0 筆 ← 沒有回歸(見 §3.2)
lang-pkgs node-pkg 198 個套件 8 筆 ← 全在 npm 自己的 node_modules
⚠️ 三個反直覺的地方:
| 兩種掃描的發現可能完全不重疊 | 這次交集是 0,少一邊就有洞(§6) |
| 刪掉 21.7 MiB,映像大了 669 bytes | 層是不可變的,rm 只是加 whiteout(§9) |
| 兩個位址不能統一到 svc | *.svc.cluster.local 在節點上解析不到(§7) |
trivy image如果管道裡已經有 trivy fs 的 SCA 掃描,一半的前置作業已經做完了:
| 通常要準備的 | 已經有 SCA 的話 |
|---|---|
| Trivy DB 的內部鏡像 | 不用,SCA 那支已經走內部 proxy |
| 讀 registry 憑證的 step | 不用,用既有的 dockercfg secret |
.trivyignore 與它的 lint |
已有,但要另開一份(§10.2) |
--severity / --exit-code 門檻 |
已是這個形態 |
自簽憑證 → --insecure |
要加 |
Task 本體就是複製一份 SCA 那個 Task,把 trivy fs . 換成 trivy image <ref>——
原本那道關卡要留著,理由見 §6:
steps:
- name: download-db
script: |
trivy --cache-dir=/cache image --download-db-only \
--insecure \
--db-repository=$(params.DB_REPOSITORY) \
--no-progress
- name: scan
workingDir: $(workspaces.source.path)
script: |
sh ./lint-ignore.sh "$(params.IGNOREFILE)"
trivy --cache-dir=/cache image \
--insecure \
--skip-db-update \
--ignorefile "$(params.IGNOREFILE)" \
--severity=$(params.SEVERITY) \
--exit-code=5 \
--show-suppressed \
--no-progress \
"$(params.IMAGE)"
分兩個 step 的理由:download-db 是唯一需要碰網路的動作。獨立出來之後,
掃描本身可以 --skip-db-update 完全離線。
scan 這一步比 trivy fs 多掛一個 dockercfg volume——fs 不用拉東西,image 要。
build-push ─► trivy-scan
它掃的是封裝的產物,不可能更早。而因為它在 DAG 的終點,
沒有東西能跟它並行——15 秒全部加在關鍵路徑上,三分鐘的線多 8%。
前面幾道關卡(型別檢查、單元測試)都能塞進既有的並行分支,加了幾乎不花時間。
這一道不行。
下游如果還沒接上簽章與部署,紅燈擋的是人的判斷,不是機器的動作。
即使如此,--exit-code=5 現在就要開:
等下游接上去才開始認真,中間那段時間的紅燈會被當成雜訊習慣掉。
Detected OS family="alpine" version="3.24.1"
[alpine] Detecting vulnerabilities... pkg_num=18
Number of language-specific files num=1
[node-pkg] Detecting vulnerabilities...
Total: 8 (HIGH: 7, CRITICAL: 1)
CRITICAL CVE-2026-59873 tar 7.5.11 → 7.5.19
HIGH CVE-2026-59874 tar 7.5.11 → 7.5.18
HIGH CVE-2026-13149 brace-expansion 2.0.2 → 5.0.7 / 1.1.16 / 2.1.2
HIGH CVE-2026-14257 brace-expansion 2.0.2
HIGH CVE-2026-69152 brace-expansion 2.0.2
HIGH CVE-2026-69192 ip-address 10.1.0
HIGH CVE-2026-33671 picomatch 4.0.3
HIGH CVE-2026-48815 sigstore 3.1.0
八筆的路徑前綴一模一樣:
usr/local/lib/node_modules/npm/node_modules/<pkg>/package.json
tar、brace-expansion、picomatch、sigstore 沒有一個是自己裝的。
它們是 npm 自己的相依。
把 198 個 node-pkg 依來源分開:
197 個 ← /usr/local/lib/node_modules (npm 10.9.8 + corepack)
1 個 ← /opt/yarn-v1.22.22
0 個 ← /app/dist ← 自己的東西
拿到掃描報告的第一個動作是看路徑。
路徑會告訴你這東西是誰帶進來的,而那通常才是要處理的問題。
os-pkgs 0 筆 不能當成「apk upgrade 有效」os-pkgs alpine 3.24.1 18 個套件 0 筆
0 筆同時相容於兩種解釋:升級把東西修掉了,或者本來就沒有東西要修。
去掃沒有經過 apk upgrade 的基底映像,答案很乾脆:
$ trivy image --pkg-types os --severity HIGH,CRITICAL node:22-alpine
alpine 3.24.1 0
基底本來就是 0。所以這一欄能支撐的結論是沒有回歸,不是「升級有效」。
(Alpine 3.24.1 是新的點版本,這個結果並不意外。)
不論如何,這次的漏洞一個都不在 apk 管的範圍裡,全在 npm 隨附的 JS 套件中。
「系統套件已經升級過了」跟「映像是乾淨的」是兩件事。
八筆全在 usr/local/lib/node_modules/npm/。那就問一個問題:執行期需要 npm 嗎?
CMD ["node", "dist/apps/web/server/server.mjs"]
只要 /usr/local/bin/node。SSR bundle 是自包的,執行期不裝任何東西、
不跑任何 npm 指令。量一下映像裡的實際佔用:
docker-entrypoint.sh 提到 npm 的次數 = 0 ← 刪掉不影響啟動
/usr/local/lib/node_modules/npm 15.4M
/usr/local/lib/node_modules/corepack 1.2M
/opt/yarn-v1.22.22 5.1M
───────
21.7M 全部不會被執行到
/app/dist 2.2M ← 應用程式本體
21.7 MB 的套件管理器,服務 2.2 MB 的應用程式,一行都不會被跑到。
# 把套件管理器從生產映像拿掉。
# 執行期只需要 /usr/local/bin/node —— SSR bundle 是自包的,
# docker-entrypoint.sh 也沒有引用它們,刪掉不影響啟動。
RUN rm -rf /usr/local/lib/node_modules/npm \
/usr/local/lib/node_modules/corepack \
/usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack \
/usr/local/bin/yarn /usr/local/bin/yarnpkg \
/opt/yarn-v1.22.22
corepack 一起刪,它也是套件管理器的 shim,同樣沒有執行期用途。
結果:
Number of language-specific files num=0
┌──────────────────────────────────┬────────┬─────────────────┐
│ ...web:eb2bc7c (alpine 3.24.1) │ alpine │ 0 │
└──────────────────────────────────┴────────┴─────────────────┘
node-pkg 這個 target 整個消失了,198 → 0。
刪東西最怕掃描綠了、程式死了。拿綠燈那次的映像直接部署:
deployment "web-smoke" successfully rolled out
scc: restricted-v2
runAsUser: 1000860000
command -v npm → npm: NOT FOUND
command -v yarn → yarn: NOT FOUND
command -v node → /usr/local/bin/node
打進去:
HTTP 200 bytes=19623
title: web
ng-server-context: ssg
⚠️ 這個 200 證明的範圍比看起來窄,見 §11。驗完就把 Deployment / Service / Route 刪掉。
同一支 Trivy、同一份 DB、同樣的門檻,兩種掃法:
trivy fs . |
trivy image <ref> |
|
|---|---|---|
| 掃什麼 | repo 的 package-lock.json |
推上去的成品映像 |
| 看得到 | 宣告的相依 | 成品裡實際存在的檔案 |
| 找到 | 2 筆 | 8 筆 |
| 交集 | 0 |
基底映像帶進來的東西不在 package-lock.json 裡,所以 SCA 那一邊看不到那 8 筆。
0 個 node-pkg ← /app/dist
SSR 的產物是 bundle,所有相依都打進去了,沒有 package.json 可以比對,
Trivy 的套件偵測認不出來。「bundle 是自包的」這個特性,在這裡直接變成掃描盲區。
這塊剛好有人守——那些相依的來源就是 package-lock.json,正是 SCA 的範圍。
SCA 掃描 守 repo 端 ← dist 的相依從這裡來
映像掃描 守成品端 ← 基底映像帶進來的從這裡看得到
⚠️ 兩道關卡各守一邊,少任何一邊都有洞。
封裝時推的位址是 route:
nexus-nexus-proxy.apps-crc.testing/docker-hosted/web:<commit>
掃描時用的位址是 svc:
nexus-nexus3.nexus-proxy.svc.cluster.local:8081/docker-hosted/web:<commit>
同一台 registry、同一個映像、兩個名字。第一眼看起來就是該統一掉的髒東西。
統一到 svc 位址行不通,因為兩邊的呼叫者不同:
| 誰在拉 | 用哪個 DNS | svc 位址可用嗎 |
|---|---|---|
| trivy / buildah(跑在 pod 裡) | 叢集 DNS | ✅ |
| kubelet / CRI-O(跑在節點上) | 節點 DNS | ❌ |
把 Deployment 的 image 指向 svc 位址,實測長這樣:
Failed to pull image "nexus-nexus3.nexus-proxy.svc.cluster.local:8081/...":
dial tcp: lookup nexus-nexus3.nexus-proxy.svc.cluster.local
on 192.168.127.1:53: no such host
192.168.127.1 是節點的 DNS。*.svc.cluster.local 只有 pod 內解析得到。
所以封裝那一步必須打 route tag,否則之後的 Deployment 拉不動。
反過來的方向沒有這個限制:route 位址在 pod 內解析得到——封裝那一步就是從 pod 裡
推 route 位址的。所以掃描用 svc 是取捨不是必然:它在 pod 裡跑,用 svc 可以讓
拉 DB 和拉映像共用同一份憑證;改用 route 要多備一份憑證,流量也會走出叢集再繞回來。
⚠️ 這種東西一定要寫進註解,否則半年後會有人把封裝那一步「順手統一」成 svc,
然後部署炸掉。
看到 8 筆之後最自然的反應是升級 npm。其中 tar 那兩筆上游已經有人回報過
(npm/cli #9801、
#9824),兩張都已關閉。
其餘六筆不在那兩張單的範圍裡。
上游確實修了:npm 10.9.9(2026-07-29)把 tar 升到 7.5.22。
但 Node 22.x 還沒把它 bundle 進去,而 22.x 已進入 Maintenance LTS(到 2027-04-30),
「等一個新的基底映像 tag」這條路等不到。
在 Dockerfile 裡自己升級是行得通的。實測:
$ npm i -g npm@10.9.9
changed 14 packages in 7s
npm 10.9.8 → 10.9.9
tar 7.5.11 → 7.5.22
⚠️ 這只涵蓋 8 筆裡的 2 筆。 tar 對應 CVE-2026-59873(CRITICAL)和
CVE-2026-59874(HIGH)。剩下六筆——brace-expansion 三筆、ip-address、picomatch、sigstore——升級之後還在不在,本文沒有驗過。changed 14 packages 不能當證據,它沒說改了哪些、改成什麼版本。
| 升級 npm | 刪掉 npm | |
|---|---|---|
| 這次的 8 筆 | tar 那 2 筆消失,其餘 6 筆未驗 |
8 筆全部消失 |
| 下次 npm 的相依再出 CVE | 要再升一次 | 不會再發生 |
| 需要網路(建構時) | 要 | 不要 |
| 執行期得到什麼 | 什麼都沒有 | 什麼都沒有 |
所以真正的選擇不是能不能修,是要不要為一個執行期用不到的東西持續追版本。
差別在下一次——而且升級這一欄還有六筆是問號。
直接比對兩個 tag 的 manifest:
===== 修正前 ===== ===== 修正後 =====
config: 8577 B config: 8912 B
layer 0: 3963773 B layer 0: 3963773 B 相同
layer 1: 54454677 B layer 1: 54454677 B 相同
layer 2: 1329593 B layer 2: 1329593 B 相同
layer 3: 453 B layer 3: 453 B 相同
layer 4: 925830 B layer 4: 926164 B 不同
────────────────── ──────────────────
TOTAL: 60682903 B TOTAL: 60683572 B
delta = +669 B
⚠️ 左右兩邊量的不是同一種東西。 21.7 MiB 是 du 量的解壓後佔用,
669 B 和上面那串是 manifest 記的壓縮位元組。
解壓後的檔案系統少了 21.7 MiB,要下載的壓縮位元組多了 669。
映像層是不可變的。npm 是基底映像 layer 1(54.45 MB)裡的檔案,rm 只能在自己那層寫一個 whiteout 標記把它遮住。
layer 1 還是原封不動地被下載、被解開,只是解開之後看不到。多出來的 669 bytes:
+334 B layer 4 多了 whiteout 項目
+335 B config 多了一筆 history 記錄
一般會說「install 和 delete 要寫在同一個 RUN,才不會留下 whiteout」。
這裡沒有那個選項——npm 是 FROM 帶進來的,沒有任何一個自己寫的 RUN 能跟它同層。
buildah bud --layers 預設是 false上面只有 5 層,但 Dockerfile 有五個會產生層的指令。原因是--layers 預設 false(跟 docker build 不一樣),整個 Dockerfile 只 commit 一層。
5 層 = 基底映像 4 層 + 自己的 1 層
這也表示那五個指令本來就在同一層,「要不要寫在同一個 RUN」在這裡不是變數。
| 做法 | 掃描結果 | 下載量 |
|---|---|---|
rm -rf(單階段) |
✅ 8 → 0 | ❌ +669 B |
multi-stage:FROM alpine + 只複製 node 二進位 |
✅ | ✅ 真的變小 |
| squash | ✅ | ✅ 但整包變一層,失去層的共享 |
官方的 Best Practices 走第二條。本文沒有做,理由是要解的是掃描結果,
換 multi-stage 會把單階段設計整個推翻,還得自己處理 libstdc++、dumb-init、entrypoint。
誠實的講法是:這次的修正解決了漏洞,沒有解決體積。
這兩件事很常被混為一談,因為它們的動作長得一模一樣。
.trivyignore 什麼時候才該用這次一筆都沒用到。
.trivyignore 的語意是「知道有風險,但我接受,理由是 X,期限到 Y」。
那 8 筆不是那種東西——它們的正解是「這個套件根本不該在映像裡」。
⚠️ 加 ignore 之前先問一次:這東西為什麼在映像裡?能刪掉的就刪掉。
真正該用的長這樣:
vulnerabilities:
- id: CVE-2025-71329
statement: "上游無修法;image-size 0.5.5 由 less 的 optionalDependencies (~0.5.0) 引入,非直接相依"
expired_at: 2026-11-15
三個要素缺一不可:上游無解、有具體理由、有到期日。
| 檔案 | 給誰 | 範圍 |
|---|---|---|
.trivyignore.yaml |
trivy fs |
repo 宣告的相依 |
.trivyignore.image.yaml |
trivy image |
成品映像的內容 |
分開的理由有兩個,強度不同:
id: 的話是純 CVE-ID 比對,套用到所有檔案、paths is not set, the ignore finding is appliedpaths 或 purls 來限縮範圍。)--show-suppressed 的輸出不會混進另一個範圍的項目。Trivy 0.74.0 把 YAML 格式的 ignore 檔和
--show-suppressed都標成 EXPERIMENTAL。
官方文件寫明「因為是實驗性的,你必須用--ignorefile顯式指定 YAML 檔路徑」,
所以那個旗標現在是必要的。第 2 個理由靠
--show-suppressed,它的輸出格式可能在未來版本改掉。
第 1 個理由不受影響——那是放行範圍的問題,跟輸出格式無關。
放在 repo 裡,不是掛在叢集上的一份全域設定——放行清單改了,
是一個可以 review 的 commit。
掃描前先跑一次 lint,擋三件事:檔案不存在、欄位名打錯、有已經過期的項目。
⚠️ 欄位名打錯特別值得擋。打錯了 Trivy 不會報錯,它只會安靜地不放行,
或更糟:安靜地永久放行。
回應標頭裡 ng-server-context 的值是 ssg:
| 值 | 意思 |
|---|---|
ssg |
建構時就算好的靜態頁 |
ssr |
執行期即時渲染 |
查這個 app 的設定:
// app.routes.server.ts
export const serverRoutes: ServerRoute[] = [
{ path: '**', renderMode: RenderMode.Prerender } // 全部預渲染
];
$ cat dist/apps/web/prerendered-routes.json
{ "routes": { "/": {} } }
這個 app 沒有任何執行期 SSR 路由。實測打四條路徑:
/ HTTP 200 bytes= 19623 ng-server-context=ssg
/nope HTTP 404
/some/deep/path HTTP 404
/api/x HTTP 404
只有 / 存在,而 19623 正好等於 browser/index.html 的檔案大小。
這點值得證,因為 express.static 排在 Angular handler 前面,
很容易以為 / 是被靜態中介軟體直接吐出去的:
app.use(express.static(browserDistFolder, { maxAge: '1y', index: false, ... }));
app.use('/**', (req, res, next) => { angularApp.handle(req)... });
index: false 是關鍵——express.static 不會幫 / 找 index.html,
所以會落到下一個 handler。用回應標頭驗證,同樣 19,623 bytes 但走的是不同路:
| 路徑 | Cache-Control | ETag | 誰服務的 |
|---|---|---|---|
/ |
private |
強 ETag(SHA-256) | Angular app engine |
/index.html |
public, max-age=31536000 |
弱 W/"4ca7-..." |
express.static |
所以這次 smoke test 證明的是:node 起得來、請求進得了 Angular app engine、
engine 解析得出路由並吐出預渲染文件、在隨機 UID 下這些都成立。
⚠️ 沒證明的是執行期渲染路徑還活著——因為這個 app 根本沒有那條路徑。
| 守不到 | 誰來守 |
|---|---|
自己的 dist(bundle,沒有 manifest) |
SCA 掃 package-lock.json |
| Node runtime 本身(129 MB 編譯後二進位,沒有套件 metadata) | 追 Node 安全公告、定期更新基底 digest |
| 執行期行為 | 不在這條線上 |
| 執行期渲染路徑 | 全站預渲染的話沒人守,smoke test 也碰不到 |
| 映像體積 | 要 multi-stage |
先講清楚一道關卡守不到什麼,比宣稱它守住了有用。
--layers 預設 false,可用 BUILDAH_LAYERS 覆蓋--layers=true 才會為每個指令 commit 中間映像expired_at,以及 EXPERIMENTAL 的說明trivy image 的偵測範圍tar 那筆與 npm 的處境trivy image node:22 掃到 npm 隨附的 tar
RenderMode.Prerender 與 ng-server-context 的值