iT邦幫忙

2026 iThome 鐵人賽

DAY 25
0

https://ithelp.ithome.com.tw/upload/images/20260825/20183337Et7ahv4Lp0.png

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)。


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)

Part 1 — 快樂路徑

2. 第 1 步:接上 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 要。

2.1 它只能放在封裝之後

build-push ─► trivy-scan

它掃的是封裝的產物,不可能更早。而因為它在 DAG 的終點,
沒有東西能跟它並行——15 秒全部加在關鍵路徑上,三分鐘的線多 8%。

前面幾道關卡(型別檢查、單元測試)都能塞進既有的並行分支,加了幾乎不花時間。
這一道不行。

2.2 門檻現在就要設成會紅

下游如果還沒接上簽章與部署,紅燈擋的是人的判斷,不是機器的動作。
即使如此,--exit-code=5 現在就要開:

等下游接上去才開始認真,中間那段時間的紅燈會被當成雜訊習慣掉。

3. 第 2 步:讀第一次紅燈

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

3.1 先看路徑

八筆的路徑前綴一模一樣:

usr/local/lib/node_modules/npm/node_modules/<pkg>/package.json

tarbrace-expansionpicomatchsigstore 沒有一個是自己裝的。
它們是 npm 自己的相依

把 198 個 node-pkg 依來源分開:

197 個  ←  /usr/local/lib/node_modules   (npm 10.9.8 + corepack)
  1 個  ←  /opt/yarn-v1.22.22
  0 個  ←  /app/dist                     ← 自己的東西

拿到掃描報告的第一個動作是看路徑。
路徑會告訴你這東西是誰帶進來的,而那通常才是要處理的問題。

3.2 ⚠️ 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 套件中。
「系統套件已經升級過了」跟「映像是乾淨的」是兩件事。

4. 第 3 步:刪掉

八筆全在 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。

5. 第 4 步:驗證服務還起得來

刪東西最怕掃描綠了、程式死了。拿綠燈那次的映像直接部署:

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 刪掉。


Part 2 — 細節探討

6. 兩種掃描的發現可能完全不重疊

同一支 Trivy、同一份 DB、同樣的門檻,兩種掃法:

trivy fs . trivy image <ref>
掃什麼 repo 的 package-lock.json 推上去的成品映像
看得到 宣告的相依 成品裡實際存在的檔案
找到 2 筆 8 筆
交集 0

基底映像帶進來的東西不在 package-lock.json 裡,所以 SCA 那一邊看不到那 8 筆。

6.1 反過來也有盲區

0 個 node-pkg  ←  /app/dist

SSR 的產物是 bundle,所有相依都打進去了,沒有 package.json 可以比對,
Trivy 的套件偵測認不出來。「bundle 是自包的」這個特性,在這裡直接變成掃描盲區。

這塊剛好有人守——那些相依的來源就是 package-lock.json,正是 SCA 的範圍。

SCA 掃描      守 repo 端      ← dist 的相依從這裡來
映像掃描      守成品端        ← 基底映像帶進來的從這裡看得到

⚠️ 兩道關卡各守一邊,少任何一邊都有洞。

7. 一個映像兩個位址,不能統一到 svc

封裝時推的位址是 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. 「升級就好了」做得到,只是沒意義

看到 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
picomatchsigstore——升級之後還在不在,本文沒有驗過
changed 14 packages 不能當證據,它沒說改了哪些、改成什麼版本。

升級 npm 刪掉 npm
這次的 8 筆 tar 那 2 筆消失,其餘 6 筆未驗 8 筆全部消失
下次 npm 的相依再出 CVE 要再升一次 不會再發生
需要網路(建構時) 不要
執行期得到什麼 什麼都沒有 什麼都沒有

所以真正的選擇不是能不能修,是要不要為一個執行期用不到的東西持續追版本。
差別在下一次——而且升級這一欄還有六筆是問號。

9. 刪掉 21.7 MiB,映像大了 669 bytes

直接比對兩個 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。

9.1 為什麼

映像層是不可變的。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 能跟它同層。

9.2 順帶量到:buildah bud --layers 預設是 false

上面只有 5 層,但 Dockerfile 有五個會產生層的指令。原因是
--layers 預設 false(跟 docker build 不一樣),整個 Dockerfile 只 commit 一層。

5 層 = 基底映像 4 層 + 自己的 1 層

這也表示那五個指令本來就在同一層,「要不要寫在同一個 RUN」在這裡不是變數。

9.3 什麼才會真的變小

做法 掃描結果 下載量
rm -rf(單階段) ✅ 8 → 0 ❌ +669 B
multi-stage:FROM alpine + 只複製 node 二進位 ✅ 真的變小
squash ✅ 但整包變一層,失去層的共享

官方的 Best Practices 走第二條。本文沒有做,理由是要解的是掃描結果,
換 multi-stage 會把單階段設計整個推翻,還得自己處理 libstdc++
dumb-init、entrypoint。

誠實的講法是:這次的修正解決了漏洞,沒有解決體積。
這兩件事很常被混為一談,因為它們的動作長得一模一樣。

10. .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

三個要素缺一不可:上游無解、有具體理由、有到期日。

10.1 分成兩份

檔案 給誰 範圍
.trivyignore.yaml trivy fs repo 宣告的相依
.trivyignore.image.yaml trivy image 成品映像的內容

分開的理由有兩個,強度不同:

  1. 正確性:ignore 條目只寫 id: 的話是純 CVE-ID 比對,套用到所有檔案、
    所有套件——官方寫得很明確:「If paths is not set, the ignore finding is applied
    to all files.」所以共用一份的話,為 repo 相依寫的一筆放行,會連帶遮掉映像裡
    同一個編號的發現,不需要套件名撞名。而那筆放行的理由未必適用於另一邊。
    (真要共用一份,得替每一筆加上 pathspurls 來限縮範圍。)
  2. 可讀性--show-suppressed 的輸出不會混進另一個範圍的項目。

Trivy 0.74.0 把 YAML 格式的 ignore 檔和 --show-suppressed 都標成 EXPERIMENTAL。
官方文件寫明「因為是實驗性的,你必須用 --ignorefile 顯式指定 YAML 檔路徑」,
所以那個旗標現在是必要的。

第 2 個理由靠 --show-suppressed,它的輸出格式可能在未來版本改掉。
第 1 個理由不受影響——那是放行範圍的問題,跟輸出格式無關。

10.2 兩份都跟著 commit 走,而且都要過 lint

放在 repo 裡,不是掛在叢集上的一份全域設定——放行清單改了,
是一個可以 review 的 commit。

掃描前先跑一次 lint,擋三件事:檔案不存在、欄位名打錯、有已經過期的項目。

⚠️ 欄位名打錯特別值得擋。打錯了 Trivy 不會報錯,它只會安靜地不放行,
或更糟:安靜地永久放行。

11. 那個 200 證明了什麼,沒證明什麼

回應標頭裡 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 的檔案大小。

11.1 但請求確實進到了 Angular

這點值得證,因為 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 根本沒有那條路徑。

12. 這道關卡守不到什麼

守不到 誰來守
自己的 dist(bundle,沒有 manifest) SCA 掃 package-lock.json
Node runtime 本身(129 MB 編譯後二進位,沒有套件 metadata) 追 Node 安全公告、定期更新基底 digest
執行期行為 不在這條線上
執行期渲染路徑 全站預渲染的話沒人守,smoke test 也碰不到
映像體積 要 multi-stage

先講清楚一道關卡守不到什麼,比宣稱它守住了有用。

13. 這篇沒涵蓋的

  • 紅燈自動擋部署:下游的簽章與部署更新接上之後才成立。現在紅燈擋的是人的判斷。
  • multi-stage 瘦身:見 §9.3。
  • Node runtime 的 CVE:這道關卡看不到,要靠別的機制。
  • 執行期掃描:跑起來的容器的行為,不在這條線上。

14. 參考文件

buildah 的層行為

Trivy

tar 那筆與 npm 的處境

Angular 的渲染模式


上一篇
Day 24:在 OpenShift 上用 rootless buildah 封裝映像
下一篇
Day 26:數位簽章硬化——在沒有 shell 的映像裡簽章
系列文
防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言