iT邦幫忙

2026 iThome 鐵人賽

DAY 21
0

https://ithelp.ithome.com.tw/upload/images/20260821/201833376gUNXaQdqK.png
每個開發最不喜聞樂見的朋友,套件CVE來啦~(PM:什麼時候可以修好?)

今日目的

今天做三件事:

  1. sca-scan —— 掃第三方套件的已知漏洞(SCA)
  2. 處理這個 repo 本來就有的三個 HIGH —— 一加就紅燈,沒有跳過的選項
  3. 弄清楚「掃描通過」到底是什麼意思 —— 這才是這篇真正的內容

做完之後 d15-happy-path 會有七個 Task,其中五個並行(註:當時的,現在是四個)。

SCA 是什麼

Day 18 的 gitleaks 掃 git 歷史裡的機密,Day 19 的 semgrep 掃 apps/ 底下自己寫的邏輯。兩道防線讀的都是程式碼,而且都是自己的程式碼。

而 Day 20 的 npm ci 裝了 1727 個套件、776 MB,那些是別人寫的 —— 前兩道防線一行都沒看過。

自己寫的每一行都無懈可擊,只要引入一個含高危 CVE 的相依套件,一樣是破口。這就是 SCA(Software Composition Analysis,軟體組成分析)要補的洞。

但它補洞的方式不是去讀那 776 MB。

gitleaks / semgrep   讀程式碼   →  這段寫法有沒有問題
SCA                  讀清單     →  你裝的這個版本,有沒有人已經報過漏洞

package-lock.json 裡有每個套件的名字與確切版本,拿去跟已知漏洞資料庫對,就知道哪一個中獎。它不需要理解那些程式碼在做什麼,只需要知道你裝了什麼。

為什麼不是 npm audit

npm audit 是 npm 內建的,而 Day 15 已經有 Nexus 的 npm-group 了。讓它走內部代理看起來理所當然:

npm warn audit 400 Bad Request - POST .../npm-group/-/npm/v1/security/audits/quick

==================================================================
Nexus Lifecycle or Nexus Firewall must be configured. Please contact your NXRM administrator.
==================================================================

npm audit 靠一個特定端點 /-/npm/v1/security/audits/quickNexus OSS 沒有實作它,那是付費版才有的。

這個失敗的意義比「免費版功能不夠」大。 Nexus 的 npm-groupnpm install / npm ci 完全可用。但 npm audit 打的是同一個 registry 底下的另一種東西

npm install   →  靜態內容下載      ← 代理得了
npm audit     →  一項服務(伺服器查 DB、回傳分析結果)   ← 要看代理實作了沒有

代理一個東西,不等於代理它的全部行為。 把 registry 指向代理的當下,這兩件事看起來都被涵蓋了;要到實際呼叫那個端點,才會發現代理的範圍到哪裡為止。評估任何「換成內部代理」的方案時,先問目標工具依賴的是檔案下載還是服務端運算。(註:斷網的話,npm audit 跑不動)

Trivy 是什麼

Go 寫的 CLI,Apache-2.0。同一支工具掃很多種東西:

trivy image   掃容器映像的每一層
trivy fs      掃檔案系統(含 lockfile)   ← 這篇只用這個
trivy repo    掃 git repository
trivy k8s     掃叢集

trivy fs 對 Node.js 專案做的事:找到 package-lock.json,解析出每個套件的版本,拿去跟本地的漏洞資料庫比對。

它不打 npm registry。 這正是它能解上面那個問題的原因 —— npm audit 要伺服器幫你查,trivy fs 自己查。

代價是它要先下載那個資料庫(壓縮後 107 MiB、解開 1.2 GB)。而 trivy 的預設來源第一順位是 mirror.gcr.io/aquasec/trivy-db:2mirror.gcr.io 是 Docker Hub 的鏡像 —— Day 15 建的 docker-proxy 直接就能拉,不用另外建 CronJob 鏡射(§16)。(註:當初想用排程拉是想模擬斷網的狀況,後來想說走nexus的proxy概念上是一樣的)

這篇沒有綠燈的開場

先講清楚,因為它決定這篇怎麼寫:

image-size  CVE-2025-71329  HIGH  affected  0.5.5   Fixed Version:(無)
image-size  CVE-2025-71330  HIGH  affected  0.5.5   Fixed Version:(無)
postcss     CVE-2026-73646  HIGH  fixed     8.5.13  Fixed Version: 8.5.18

sca-scan 一加進 Pipeline 就會紅燈。 這篇沒有「跑起來就是綠燈」的快樂路徑 —— §1 第一步就是處理它們。

而且三筆都不是直接相依。package.json 裡總共 12 個 dependencies 加 34 個 devDependencies這三筆一個都不在裡面。這件事讓「把有修法的修掉」比想像中麻煩(§1)。

快樂路徑

要做的事其實很短,五步:

# 做什麼 關鍵
1 能修的先修掉 package.jsonoverrides不是 npm install <套件>@<版本> §1
2 .trivyignore.yaml 只放真的沒修法的,statement 寫具體、expired_at 一定要設 §2
3 lint-ignore.sh trivy 不會告訴你 ignore 過期了,得自己檢查 §3
4 sca-scan Task 兩個 step:下載 DB(有憑證)→ 掃描(沒憑證) §4
5 接進 Pipeline、push 一次 七個 Task 全綠,sca-scan 12 秒 §5

真正在做事的就這幾行:

# download-db step —— 關鍵是只給一個 --db-repository,且走 Service 一定要 --insecure
trivy --cache-dir=/cache image --download-db-only --insecure \
  --db-repository=nexus-nexus3.nexus-proxy.svc.cluster.local:8081/docker-proxy/aquasec/trivy-db:2 \
  --no-progress

# scan step —— 全部
sh ./lint-ignore.sh .trivyignore.yaml
trivy --cache-dir=/cache fs --scanners=vuln --skip-db-update \
  --ignorefile .trivyignore.yaml --severity=HIGH,CRITICAL \
  --exit-code=5 --show-suppressed --no-progress .

其餘篇幅是「為什麼」:第二部分逐層拆五層過濾,第三部分是四種安靜的失效,第四部分交代 Task 那八個決定各自的理由。

這篇的技術說明只講一件事:

「掃描通過」不是一個事實,是五層過濾各自被設成什麼的結果。
而讓它失效的四種方式 —— 忘了旗標、欄位打錯、ignore 到期、範圍設太寬 —— 全都不出聲。


先備知識

這篇會用到的東西
Day 13 一個 TaskRun 只能綁一顆 PVC-backed workspace(〈G〉)
Day 15 Nexus 的 docker-proxy、Service 與 Route 的適用範圍、npm-group
Day 17 DAG 與 runAfter
Day 19 兩個 step 隔開憑證的做法;busybox 沒有 date -d

開場對照表

常見的做法 這篇的做法 詳見
npm audit 走 Nexus proxy 就能離線稽核 Nexus OSS 沒有那個 API 端點,那是付費版才有的 今日目的
npm install <套件>@<修好的版本> 就能修掉相依漏洞 巢狀的那份動不到,而且指令會 exit 0 說 "up to date" §1
trivy 的漏洞 DB 要自己建 CronJob 鏡射 預設來源是 Docker Hub 的鏡像,現有的 docker-proxy 直接就能拉 §16
--db-repository 設多個做 fallback 比較保險 那會讓「離線」變成「失敗就自己出去」 §16
沒有修法的漏洞用 --ignore-unfixed 跳過 它會連未來的 unfixed CRITICAL 一起放過,而且不出聲 §8
.trivyignore.yamlexpired_at 到期會提醒 到期時一個字都不說 §13
--exit-code=1 是標準寫法 它跟 trivy 自己的 FATAL 撞號 §18

實測環境版本表

CRC 2.61.0+6eb443
OpenShift 4.21.14 / Kubernetes v1.34.6(單節點 crc)
Red Hat OpenShift Pipelines Operator 1.23.1
trivy 0.74.0(image 的 /bin/sh 是 busybox,執行身分 root)
漏洞 DB:UpdatedAt = 2026-08-15T07:02:08Z
Nexus Repository 3.93.0-06(COMMUNITY)
用戶端:Windows 11 Pro + PowerShell 5.1

CVE 會被重新分級、DB 結構會改。 本文所有跟 CVE 有關的觀察都是 2026-08-15 這一天、這個 DB 版本的結果。


第一部分:把 sca-scan 做出來

改動範圍:升一個套件、新增一個 ignore 檔與一支腳本、新增 Task/sca-scan、修改 Pipeline/d15-happy-path

Triggers、EventListener、PVC、Gitea webhook 都不動。

1. 前置:能修的先修掉

postcss 有修法。但它不是直接相依 —— package.jsondependencies(12 筆)與 devDependencies(34 筆)裡都沒有它。lock 檔裡有兩份:

8.5.13   node_modules/@angular-devkit/build-angular/node_modules/postcss   ← 有漏洞的是這份
8.5.26   node_modules/postcss                                              ← 已經 ≥ 8.5.18

最直覺的那個指令沒有用

npm install postcss@^8.5.18 --package-lock-only --no-audit --no-fund
# → exit 0,"up to date in 2s"
# → 巢狀那份仍然是 8.5.13
# → 反而把 "postcss": "^8.5.26" 加進 package.json 的 dependencies

它動的是頂層那份(本來就已經 8.5.26),而且順手把一個傳遞相依變成了直接相依。掃描結果一點都沒變。

「修好了」要用掃描結果證明,不要用指令的 exit code 證明。 這是巢狀相依特有的形態:指令成功、輸出正面、掃描結果完全沒變。 唯一可信的驗收是改完再掃一次,看 Total 有沒有變

要動巢狀的那份得用 overrides

package.json 加:

"overrides": {
  "postcss": "^8.5.18"
}

然後重算 lock 檔(在容器裡做,不要在 Windows 主機,Day 20 §0 的理由):

npm install --package-lock-only --no-audit --no-fund

三種做法實掃的對照:

lock 檔 Total 內容
原始 3 兩筆 image-size + CVE-2026-73646
npm install postcss@^8.5.18 3 完全沒變
overrides 2 CVE-2026-73646 消失,巢狀那份被合併掉

lock 檔從 1,010,347 縮到 1,009,473 bytes,node_modules/@angular-devkit/build-angular/node_modules/postcss 整個不見了。

剩下兩筆為什麼修不掉

image-size 那兩筆是 Status: affected,上游沒有修。追它的來源:

node_modules/@angular-devkit/build-angular/node_modules/less  (optionalDependencies: ~0.5.0)
node_modules/@nx/webpack/node_modules/less                    (optionalDependencies: ~0.5.0)

它是 less 的 optional dependency,而版本範圍 ~0.5.0 鎖死在 0.5.x。 就算 image-size 出了修好的 0.6,less 不改這個範圍就升不上去。這兩筆才進 ignore 清單。

順序不能反。 能修的先修掉,只列管修不掉的。ignore 清單裡混一筆有修法的,statement 裡那句「上游無修法」就變成藉口而不是事實陳述。ignore 清單的可信度來自它只放真的沒辦法的東西。

2. 建 .trivyignore.yaml

放在 repo 根目錄,跟 package.json 同層:

vulnerabilities:
  - id: CVE-2025-71329
    statement: "上游無修法;image-size 0.5.5 由 less 的 optionalDependencies (~0.5.0) 引入,非直接相依"
    expired_at: 2026-11-15
  - id: CVE-2025-71330
    statement: "同上"
    expired_at: 2026-11-15

三個欄位的作用、以及為什麼選 YAML 而不是純文字,見 §9。

這裡只先講一件事:statement 值得寫具體一點。 它會原封不動印在 --show-suppressed 的表裡(§5),寫「傳遞相依」跟寫「由 less 的 optionalDependencies ~0.5.0 引入」,三個月後回頭看的價值差很多。

3. 建 lint-ignore.sh

這支腳本是必要的,不是加分項。 expired_at 到期時 trivy 一個字都不會說 —— 完整的證據在 §13,這裡先把東西放好。

放在 repo 根目錄:

#!/bin/sh
# lint-ignore.sh <ignorefile>
#   1) 檔案存在   2) 欄位名沒打錯   3) 沒有已過期的項目
F="$1"
[ -f "$F" ] || { echo "FATAL: ignore 檔不存在: $F"; exit 2; }
RC=0

BAD=$(grep -nE '^[[:space:]]*(expires_at|expire_at|expiry|expires|expired)[[:space:]]*:' "$F")
if [ -n "$BAD" ]; then
  echo "FATAL: 欄位名打錯,正確是 expired_at:"
  echo "$BAD" | sed 's/^/    /'
  RC=2
fi

TODAY=$(date -u +%Y-%m-%d)
awk -v today="$TODAY" '
  /^[[:space:]]*-[[:space:]]*id:/ { id=$NF }
  /^[[:space:]]*expired_at[[:space:]]*:/ { d=$NF; gsub(/["\x27]/,"",d)
      if (d < today) { print "WARN: ignore 已過期 " id " expired_at=" d " (今天 " today ")"; n++ } }
  END { if (n) exit 1 }
' "$F" || { [ $RC -eq 0 ] && RC=1; }

[ $RC -eq 0 ] && echo "OK: $F 無過期、欄位名正確"
exit $RC

四種輸入的實測結果:

輸入 輸出 exit
一筆過期 WARN: ignore 已過期 CVE-2025-71329 expired_at=2020-01-01 (今天 2026-08-15) 1
欄位打錯 FATAL: 欄位名打錯,正確是 expired_at 2
兩者同時 兩行都印 2
檔案不存在 FATAL: ignore 檔不存在 2
都沒問題 OK: 無過期、欄位名正確 0

|| { [ $RC -eq 0 ] && RC=1; } 不能寫成 || RC=1 寫成後者的話,「欄位打錯 + 有過期」同時發生時,awk 那個 1 會把 grep 設好的 2 蓋掉 —— FATAL 被降級成 WARN。兩者都非 0,Task 一樣紅,但 §18 想建立的「用 exit code 分辨狀態」就破了。

沒有用 date -d —— trivy image 的 /bin/sh 是 busybox(Day 19 §11.3 踩過)。YYYY-MM-DD 的字典序等同時序,字串比較就夠。

它只認縮排在行首的 - id:expired_at: YAML 的其他合法寫法(流式、多層縮排、引號包 key)認不出來 —— 它是一道防呆,不是 YAML parser。

4. sca-scan Task:兩個 step,憑證只給第一個

apiVersion: tekton.dev/v1
kind: Task
metadata:
  name: sca-scan
  namespace: ci
spec:
  params:
    - name: SEVERITY
      type: string
      default: HIGH,CRITICAL
    - name: IGNOREFILE
      type: string
      default: .trivyignore.yaml
  workspaces:
    - name: source
  volumes:
    - name: dockercfg
      secret:
        secretName: nexus-docker-credentials
        items:
          - { key: .dockerconfigjson, path: config.json }
    - name: trivy-cache
      emptyDir: {}
  steps:
    - name: download-db
      image: nexus-nexus-proxy.apps-crc.testing/docker-proxy/aquasec/trivy:latest
      env:
        - name: DOCKER_CONFIG
          value: /dockercfg
      volumeMounts:
        - { name: dockercfg,   mountPath: /dockercfg, readOnly: true }
        - { name: trivy-cache, mountPath: /cache }
      script: |
        #!/bin/sh
        set -e
        trivy --cache-dir=/cache image --download-db-only \
          --insecure \
          --db-repository=nexus-nexus3.nexus-proxy.svc.cluster.local:8081/docker-proxy/aquasec/trivy-db:2 \
          --no-progress

    - name: scan
      image: nexus-nexus-proxy.apps-crc.testing/docker-proxy/aquasec/trivy:latest
      workingDir: $(workspaces.source.path)
      volumeMounts:
        - { name: trivy-cache, mountPath: /cache }
      script: |
        #!/bin/sh
        set -e
        sh ./lint-ignore.sh "$(params.IGNOREFILE)"

        trivy --cache-dir=/cache fs \
          --scanners=vuln \
          --skip-db-update \
          --ignorefile "$(params.IGNOREFILE)" \
          --severity=$(params.SEVERITY) \
          --exit-code=5 \
          --show-suppressed \
          --no-progress .

八個決定,第四部分逐一交代:

項目 為什麼
兩個 step,憑證只掛第一個 憑證從頭到尾沒進過 shell 變數 §17
--insecure 不只是「不驗憑證」,是允許降級到明文 §16
只給一個 --db-repository 多給一個,「離線」就變成「失敗就自己出去」 §16
--exit-code=5 不是 1 1 跟 trivy 自己的 FATAL 撞號 §18
--ignorefile 明寫 忘了會靜默失效 §11
--show-suppressed 讓「為什麼放過」從註解變成報表的一欄 §9
沒有 --skip-dirs=node_modules 省不到時間,而且會誤導 §19
set -elint-ignore.sh ignore 過期會讓 Task 紅燈,不只是印個警告 §15

5. 接進 Pipeline,push 一次

trivy fs 只解析 lockfile,不需要 node_modules。跟另外四個一樣直接接在 git-clone 之後:

[{"op":"add","path":"/spec/tasks/-","value":{
  "name":"sca-scan",
  "runAfter":["git-clone"],
  "taskRef":{"kind":"Task","name":"sca-scan"},
  "workspaces":[{"name":"source","workspace":"shared-workspace"}]
}}]
& $OC --kubeconfig $kc patch pipeline.v1.tekton.dev d15-happy-path -n ci `
    --type=json --patch-file pipeline-patch.json

JSON patch 走 --patch-file,不要用 -p '[...]'。PowerShell 5.1 會把內層雙引號吃掉。

git-clone <-
workspace-probe <- ["git-clone"]
gitleaks-scan <- ["git-clone"]
semgrep-scan <- ["git-clone"]
npm-build <- ["git-clone"]
sca-scan <- ["git-clone"]
eslint-check <- ["npm-build"]

七個 Task,五個並行(Day 20 收工時是六個 Task、四個並行,這次加一個)。

push 的順序不能顛倒

Task 與 Pipeline 要先就位,再推 commit。 這個 commit 一推上去 webhook 就會觸發,而 sca-scanscan step 第一行就是 sh ./lint-ignore.sh —— 檔案還沒推上去的話,會卡在 sh: can't open ./lint-ignore.shset -e,Task 直接紅。

四個檔案要在同一個 commit 裡。 分開推的話,每一次都會觸發一輪 Pipeline,而且中間那幾輪一定是紅的。

git add package.json package-lock.json .trivyignore.yaml lint-ignore.sh
git commit -m "chore(sca): add trivy ignore policy + lint script, override postcss to ^8.5.18"
git push origin main

package.json 很容易漏掉 —— overrides 寫在那裡。只推 lock 檔的話,下一個人跑 npm install 就會把 postcss 打回 8.5.13。

結果

七個 Task 全綠,sca-scan 花 12 秒:

d14-run-jpkq9-git-clone         Succeeded   22s
d14-run-jpkq9-gitleaks-scan     Succeeded    6s
d14-run-jpkq9-workspace-probe   Succeeded   46s
d14-run-jpkq9-semgrep-scan      Succeeded   17s
d14-run-jpkq9-sca-scan          Succeeded   12s
d14-run-jpkq9-npm-build         Succeeded   59s
d14-run-jpkq9-eslint-check      Succeeded   16s

step-download-db

INFO  [vulndb] Need to update DB
INFO  [vulndb] Downloading vulnerability DB...
INFO  [vulndb] Downloading artifact...          repo="nexus-nexus3.nexus-proxy.svc.cluster.local:8081/docker-proxy/aquasec/trivy-db:2"
INFO  [vulndb] Artifact successfully downloaded repo="nexus-nexus3.nexus-proxy.svc.cluster.local:8081/docker-proxy/aquasec/trivy-db:2"

兩行 repo= 都是 Nexus 的位址。 這是唯一能分辨「走了 Nexus」與「fallback 出去了」的地方(§16)。

step-scan

OK: .trivyignore.yaml 無過期、欄位名正確
INFO  [vuln] Vulnerability scanning is enabled
INFO  Suppressing dependencies for development and testing. To display them, try the '--include-dev-deps' flag.
INFO  Number of language-specific files  num=1
INFO  [npm] Detecting vulnerabilities...

Report Summary

┌───────────────────┬──────┬─────────────────┐
│      Target       │ Type │ Vulnerabilities │
├───────────────────┼──────┼─────────────────┤
│ package-lock.json │ npm  │        0        │
└───────────────────┴──────┴─────────────────┘

package-lock.json (npm)
=======================
Total: 0 (HIGH: 0, CRITICAL: 0)


Suppressed Vulnerabilities (Total: 2)
=====================================
┌────────────┬────────────────┬──────────┬─────────┬──────────────────────────────────────────────────────────────┬───────────────────┐
│  Library   │ Vulnerability  │ Severity │ Status  │                          Statement                           │      Source       │
├────────────┼────────────────┼──────────┼─────────┼──────────────────────────────────────────────────────────────┼───────────────────┤
│ image-size │ CVE-2025-71330 │ HIGH     │ ignored │ 同上                                                         │ .trivyignore.yaml │
│            ├────────────────┤          │         ├──────────────────────────────────────────────────────────────┤                   │
│            │ CVE-2025-71329 │          │         │ 上游無修法;image-size 0.5.5 由 less 的 optionalDependencies │                   │
│            │                │          │         │ (~0.5.0) 引入,非直接相依                                    │                   │
└────────────┴────────────────┴──────────┴─────────┴──────────────────────────────────────────────────────────────┴───────────────────┘

Total: 0 加上一張兩筆的 Suppressed 表 —— 被放過的東西看得見,而且看得到理由。

Suppressed不照 ignore 檔的順序排(71330 在 71329 前面),Library 欄是合併儲存格。寫 statement 的時候要有它會折行的心理準備。

DB 下載只花 3 秒,是因為 Nexus 已經有那顆 blob 了。第一次跑會是 100 秒起跳(§16)。


收工前檢查清單

# 1. 憑證只在第一個 step
& $OC --kubeconfig $kc get task sca-scan -n ci -o json | ConvertFrom-Json |
    ForEach-Object { $_.spec.steps } |
    Select-Object name, @{n='mounts';e={$_.volumeMounts.name -join ','}}
# 期望:download-db 有 dockercfg;scan 沒有

# 2. 只給一個 --db-repository
& $OC --kubeconfig $kc get task sca-scan -n ci -o jsonpath='{.spec.steps[0].script}' |
    Select-String -Pattern "db-repository" -AllMatches

# 3. exit-code 不是 1
& $OC --kubeconfig $kc get task sca-scan -n ci -o jsonpath='{.spec.steps[1].script}' |
    Select-String -Pattern "exit-code"

# 4. 有帶 --ignorefile
& $OC --kubeconfig $kc get task sca-scan -n ci -o jsonpath='{.spec.steps[1].script}' |
    Select-String -Pattern "ignorefile"

# 5. 沒有 --ignore-unfixed
& $OC --kubeconfig $kc get task sca-scan -n ci -o jsonpath='{.spec.steps[1].script}' |
    Select-String -Pattern "ignore-unfixed"
# 期望:無輸出

# 6. ignore 檔裡沒有已過期的
sh lint-ignore.sh .trivyignore.yaml

# 7. DB 真的走 Nexus(要指定容器)
& $OC --kubeconfig $kc logs -n ci -l tekton.dev/pipelineTask=sca-scan `
    -c step-download-db --tail=30 | Select-String -Pattern "repo="
# 期望:位址是 Nexus,不是 mirror.gcr.io

第 4 項最容易漏。 .trivyignore.yaml 不會自動載入,忘了 --ignorefile 整份清單失效,而且沒有提示(§11)。

第 7 項要看位址。 顯示 mirror.gcr.ioghcr.io 代表有人加了 fallback,離線已經破了。

第 7 項的 -c step-download-db 不能省。oc logs -l 對多容器 Pod 不會報錯,它會印一行 Defaulted container "step-download-db" out of: ... 然後只讀第一個容器。這裡剛好第一個就是要看的那個 —— 但那是巧合,step 順序一改就讀錯了。


第二部分:五層過濾 —— 一個漏洞可以在哪一層消失

6. 五層是哪五層

官方把過濾畫成一條管線:

偵測到的問題
  → 第 1 層  Severity      (--severity)
  → 第 2 層  Status        (--ignore-status / --ignore-unfixed)
  → 第 3 層  Finding IDs   (.trivyignore / .trivyignore.yaml)
  → 第 4 層  Rego          (--ignore-policy)
  → 第 5 層  VEX           (--vex)
  → 輸出

五層,每一層都能讓東西消失。 這篇用到的是第 1 層與第 3 層;第 2 層是刻意不用的,第 4、5 層完全沒碰:

旗標 這篇
1 Severity --severity ,設 HIGH,CRITICAL §7
2 Status --ignore-status / --ignore-unfixed 刻意不用 §8
3 Finding IDs .trivyignore / .trivyignore.yaml ,逐筆列管 §9
4 Rego --ignore-policy 沒用到 §10
5 VEX --vex 沒用到 §10

順序有意義。 --severity 排第一,所以設了 HIGH,CRITICAL 之後,MEDIUM 級的根本走不到 ignore 那一層。

7. 第 1 層 Severity:決定什麼進得了下一關

--severity=HIGH,CRITICAL 是這條 pipeline 的閘門位置,跟 Day 20 把 ESLint 閘門設在 error 是同一種取捨:不這樣切,管道會被一堆「知道就好」的東西卡住跑不動。

代價要說清楚:MEDIUM 與 LOW 從第一層就被丟掉了,它們不會出現在主表、不會出現在 Suppressed 表、也不會被 ignore 檔記錄。這不是「放過」,是「根本沒看」。

這個閘門位置是這篇唯一沒有實測支撐的決定 —— 它是取捨,不是發現。

8. 第 2 層 Status:--ignore-unfixed 最省事,也最危險

它看起來是這題的標準答案 —— 兩筆 image-size 加一個旗標就消失了,不用寫 ignore 檔、不用寫腳本。

官方定義它是四個狀態的簡寫:

--ignore-unfixed  ==  --ignore-status affected,will_not_fix,fix_deferred,end_of_life

問題是它不是「放過那兩筆」,是「從此放過所有沒有修法的漏洞,不分嚴重程度」。

實測:它會吃掉 unfixed CRITICAL

用 12 個舊套件造一份假 lock 檔(不是這個 repo),掃出 39 筆,Severity × Status 分布:

CRITICAL / affected     1      ← 就是這個
CRITICAL / fixed        3
HIGH     / affected     7
HIGH     / fixed       14
MEDIUM   / affected     1
MEDIUM   / fixed       11
LOW      / fixed        2

那筆 unfixed CRITICAL 是 xmldom@0.5.0 → CVE-2022-39353。只掃 CRITICAL 的對照:

Total 內容
基準 4 含 CVE-2022-39353
--ignore-unfixed 3 CVE-2022-39353 消失

而且 log 完全沒有提示 —— grep -inE "ignor|suppress|expir" 抓到 0 行。這是四種安靜的失效之一,§14 會跟另外三種放在一起看。

--ignore-status affected 在這份素材上給出相同結果,但兩者不等價--ignore-unfixed 涵蓋四種狀態,這份素材裡剛好只有 affected 這一種。其他三種狀態的行為沒有驗過。

9. 第 3 層 Finding IDs:逐筆、有理由、有期限

三個欄位

欄位 作用
id 唯一必填。指定要放過哪一筆
statement 官方註明「不用於過濾」 —— 純粹是文件欄位
expired_at 到期日。不設就是永久有效

statement 的價值在於它會出現在 --show-suppressed 的表裡(§5 那張)。「為什麼接受這個風險」從註解變成了報表的一欄。

為什麼選 YAML

.trivyignore(純文字) .trivyignore.yaml
自動載入 不會,要 --ignorefile
期限 官方文件說支援 exp:YYYY-MM-DD本次未測 expired_at
理由 只能寫註解 statement,進報表
範圍限縮 paths / purls

選 YAML 是為了 statement 代價是必須明寫 --ignorefile —— 而忘了寫的失效方式非常安靜(§11)。

10. 第 4、5 層:Rego 與 VEX

這篇完全沒有用到,也沒有測。

之所以還是要提,是因為第二部分的主張是「五層,每一層都能讓東西消失」—— 少講兩層,那句話就不完整。實務上的意義是:接手一條別人寫的 pipeline 時,--ignore-policy--vex 也要看,不能只檢查 --severity 跟 ignore 檔就宣稱知道它放過了什麼。


第三部分:四種安靜的失效

這一部分是這篇的重點。

前提:本部分所有對照組都在 §1 做 overrides 之前的原始 lock 檔上量測,基準 Total = 3(兩筆 image-size + postcss)。照第一部分做完之後手上只有兩筆,數字會不一樣。

四種失效有一個共同形態:掃描照跑、exit code 正常、log 不會說。 差別只在觸發條件。

11. 失效一:檔案在,但忘了 --ignorefile

不帶 --ignorefile   →  三筆全在,log 一個字都沒有
帶了                →  只剩 postcss,有 INFO 提示

檔案存在卻沒被讀,是完全靜默的。 掃描照跑,看起來就像「ignore 沒生效」,但沒有任何線索指向原因。

對照:檔案不存在會直接 FATAL,反而好查。

FATAL  Fatal error  flag error: unable to convert flags to options:
       ignore file not found: /work/does-not-exist.yaml

「沒設定」比「設錯」還難查,因為前者沒有任何輸出可以 grep。

12. 失效二:欄位名打錯一個字母

expired_at 打成 expires_at

Total: 2  ← CVE-2025-71329 仍然被擋掉了

id 認得所以 ignore 生效,expires_at 是未知欄位被直接丟掉 —— 沒有 WARN、沒有 ERROR。打錯一個字母,一條本來會在 2026-11-15 到期的 ignore,變成永久有效。

只測了 expired_atexpires_at 這一種打錯法。沒測 id 打錯、或整份 YAML 語法壞掉。

13. 失效三:expired_at 會過期,但過期的時候不敲門

這是這篇最需要記住的一件事。

一份檔案裡放一筆已過期、一筆未過期:

vulnerabilities:
  - id: CVE-2025-71329
    expired_at: 2020-01-01      # 已過期
  - id: CVE-2025-71330
    expired_at: 2099-01-01      # 未過期
判讀點 結果
已過期的有沒有回到表格 有。 expired_at 確實會過期
未過期的有沒有被擋掉 有。 同一份檔案裡兩者行為不同
log 有沒有提到「某條 ignore 過期了」 沒有。一個字都沒有

--show-suppressed 也看不到過期的那筆 —— 它只列還有效的。

從輸出上,「ignore 過期了」跟「這筆 CVE 是今天新冒出來的」長得一模一樣。

唯一的信號是一行 log 消失

一筆過期、一筆有效     Total 2    "ignored/suppressed" INFO 行數 = 1
兩筆都過期             Total 3    INFO 行數 = 0

所有 ignore 都過期時,那行 INFO 整個不見了。

靠「某行 log 沒有出現」來察覺 —— 沒有人會這樣讀 CI log。而且只要還有任何一筆沒過期,這個信號就失效了。

14. 失效四:--ignore-unfixed 放過的是未來

§8 已經測過了,放在這裡是因為它跟前三種是同一類東西,只是時間軸不同:

前三種是「你以為設好了,其實沒有」;這一種是「你確實設好了,但範圍涵蓋了你還沒看過的東西」。

今天放過兩筆 HIGH,明天連 unfixed CRITICAL 一起放過,而且從第一天起就沒打算告訴你。

15. 四種放在一起看

# 失效 什麼時候發生 表面看起來 有沒有訊號 防法
1 忘了 --ignorefile 一開始就忘了 像「ignore 沒生效」 完全沒有 檢查清單第 4 項
2 欄位名打錯 一開始就打錯 ignore 有效,只是永不過期 完全沒有 lint-ignore.shgrep
3 expired_at 到期 幾個月後 像一筆新冒出來的 CVE 只有「一行 log 消失」,且只在全部過期時 lint-ignore.shawk
4 --ignore-unfixed 未來出現 unfixed CRITICAL 時 綠燈 完全沒有(grep 0 行) 不要用它(§8)

第 1、2 種可以靠檢查清單一次抓掉,第 4 種靠不用它,第 3 種沒有辦法一次解決 —— 它跟時間綁在一起,每次跑都要重新問一次。所以才要有 lint-ignore.sh

過期會讓 Task 紅燈,不只是印警告

scan step 開頭有 set -e(§4),所以那支腳本回非 0 的時候,trivy 根本不會跑到

實測:另外建一個 TaskRun,IGNOREFILE 指向一份 expired_at: 2020-01-01 的檔案:

TaskRun 結果:False / StepFailed

WARN: ignore 已過期 CVE-2025-71329 expired_at=2020-01-01 (今天 2026-08-15)
WARN: ignore 已過期 CVE-2025-71330 expired_at=2020-01-01 (今天 2026-08-15)

這是刻意的。 過期的 ignore 代表「當初說好要再看一次的時間到了」—— 讓它變成一次紅燈,是唯一能保證有人真的回來看的方法。

如果只想警告不想擋,把 sh ./lint-ignore.sh 改成 sh ./lint-ignore.sh "$(params.IGNOREFILE)" || true但那就退回 trivy 原本的行為了 —— 訊息印在 log 裡,沒有人會看。


第四部分:Task 那八個決定

16. DB 走 Nexus 的三件事

一、預設來源本來就在 Docker Hub 上

trivy 0.74.0 的預設是一組 fallback 清單:

--db-repository(依序)
  1. mirror.gcr.io/aquasec/trivy-db:2
  2. ghcr.io/aquasecurity/trivy-db:2

org 名不一樣:gcr 上是 aquasec,ghcr 上是 aquasecurity。這是 2024 年底為了解 GHCR 限流改的。實測反面確認過:Docker Hub 上只有 aquasec,抄成 aquasecurity 就是 404。

mirror.gcr.io 是 Docker Hub 的鏡像,所以現有的 docker-proxy 直接就能拉。

拉下來的 manifest 三處都不是標準 image:

{
  "mediaType": "application/vnd.oci.image.manifest.v1+json",
  "artifactType": "application/vnd.aquasec.trivy.config.v1+json",
  "config": { "mediaType": "application/vnd.oci.empty.v1+json", "size": 2 },
  "layers": [{
    "mediaType": "application/vnd.aquasec.trivy.db.layer.v1.tar+gzip",
    "size": 112236371
  }]
}

docker-proxystrictContentTypeValidationtrue,沒有擋下來。

「為什麼沒擋」我沒有查證。 觀察到的只有「開著 strict 也過得去」。合理的推測是 strict 檢查 HTTP 回應標頭的 Content-Type,而自訂 mediaType 寫在 manifest 的 JSON 內容裡 —— 但沒有驗過這個機制。

二、不要設多個 --db-repository

只給一個值時,失敗就是失敗:

FATAL  DB error: failed to download vulnerability DB:
  OCI repository error: 1 error occurred:
    * Get "https://nexus-...:8081/v2/": http: server gave HTTP response to HTTPS client

注意 1 error occurred —— 清單裡只有一筆。離線是真的離線。

多給一個看起來很聰明:

--db-repository <nexus>/docker-proxy/aquasec/trivy-db:2 \
--db-repository mirror.gcr.io/aquasec/trivy-db:2        # ← 不要

它會把「離線」變成「優先內部,失敗就自己出去」。 Nexus 掛了、憑證過期、autoBlock 觸發,trivy 就去外網拿,掃描照樣通過,log 裡只有那行 repo= 的位址不同。

多值 fallback 的條件(429 / 5xx 時依序往下試)出自官方文件,本次沒有做反面實驗。 實測的只有上面那個「單值會硬失敗」。既然單值已經達成目的,就照單值寫。

三、走 Service 要 --insecure,第一次會慢 30 倍(個人手機網路問題)

條件 結果
不加 --insecure FATAL:http: server gave HTTP response to HTTPS client
--insecure 成功

跟 Day 15 §7 那張表一致:OCI 客戶端預設打 HTTPS,Service:8081 是明文 HTTP。--insecure 不只是「不驗憑證」,是「允許降級到明文」 —— 跟 skopeo 同一套。

Nexus 冷快取(要去 Docker Hub 抓)   103 秒
Nexus 已快取                          3–4 秒

差 30 倍。 只寫「DB 下載 3 秒」會誤導 —— 那是暖機之後的數字。

--cache-dir 預設在 /root/.cache/trivy不在 workspace 上 —— 1.2 GB 不會寫進 pipeline-source-pvc,每個 TaskRun 用完即丟。

17. 憑證隔離:這篇比前兩篇嚴格

Day 19 / 20 的做法是 emptyDir + 環境變數 + shell 寫檔。這裡不用 —— nexus-docker-credentials 本來就是 kubernetes.io/dockerconfigjson,可以直接投影成 config.json

憑證從頭到尾沒有進過 shell 變數、沒有被 base64 過、也沒有落在可寫的檔案系統上。

第二個 step 實測:

DOCKER_CONFIG=[]
ls: /dockercfg: No such file or directory
做法 憑證出現在 適用
emptyDir + env + shell 寫檔(Day 19/20) step 1 的 env 與 emptyDir 目標格式不是 config.json(例如 .npmrc
secret volume 掛單一 step(本篇) 只在該 step 的唯讀掛載點 目標格式就是 config.json

18. --exit-code=5 不是筆誤

--exit-code=1 會跟 trivy 自己的 FATAL 撞號:

情況 --exit-code=1 --exit-code=5
掃到漏洞 1 5
trivy 自己壞掉(FATAL) 1 1
乾淨 0 0

用 1 的話,CI 上看到 exit 1 分不出「掃到漏洞」還是「trivy 根本沒跑起來」。

Tekton 只看「非 0 就是失敗」,所以對 Pipeline 行為沒有影響 —— 差別在人讀 log 的時候。 也是為了同一個理由,lint-ignore.sh 才要保住它的 exit 2(§3 那個 RC 的坑)。

19. 並行的兩個邊界

node_modules 出現不影響掃描範圍

sca-scannpm-build 並行,npm ci 跑到一半,node_modules 裡會出現上千個 package.json

讓 trivy 在安裝期間每 6 秒掃一次、共 12 次

#   node_modules   package.json 數   Targets   耗時
1   1 MB           0                 1         0s
2   79 MB          851               1         0s
3   343 MB         1756              1         1s
5   776 MB         2047              1         2s
6   776 MB         2047              1         5s   ← 尖峰,落在 npm ci 寫檔最密集時
12  776 MB         2047              1         2s

Target 恆為 1,永遠只有 package-lock.json CVE 集合也完全相同(第 1 次 vs 第 12 次逐一比對,comm 兩邊都是空的),全程 exit code = 0。

耗時從 <1 秒變成 2 秒、尖峰 5 秒,那是走訪 2047 個目錄的成本。--skip-dirs=node_modules 不需要 —— 省不到 5 秒,卻多一個旗標,而且會讓人誤以為不加就會掃到 node_modules

這跟 semgrep 的 --exclude=node_modules 不是同一類需求。 semgrep 不排除會多掃到檔案(Day 20 驗過),trivy 不排除只是多走訪目錄,掃描標的不變。

一個 TaskRun 只能綁一顆 PVC workspace

原本想把 DB 快取放進獨立的 volumeClaimTemplate workspace:

[User error] more than one PersistentVolumeClaim is bound

TaskRun 直接 TaskRunValidationFailed連 Pod 都沒建。Day 13〈G〉記過這條規則。所以 DB 快取用 emptyDir —— 反正 Nexus 暖機後下載只要 3–4 秒。


收尾

四件值得帶走的。

代理一個東西,不等於代理它的全部行為。 Nexus 的 npm-groupnpm install 完全可用,對 npm audit 完全不可用 —— 前者是檔案下載,後者是伺服器端的服務。

「修好了」要用掃描結果證明,不要用指令的 exit code 證明。 npm install postcss@^8.5.18 回 exit 0、印 "up to date",而巢狀的 8.5.13 一動也沒動。唯一可信的驗收是改完再掃一次,看 Total 有沒有變

五層過濾,每一層都能讓漏洞無聲消失。 這篇用了第 1 層與第 3 層、刻意不用第 2 層、完全沒碰第 4、5 層 —— 而這四句話才是「掃描通過」在這條 pipeline 上的完整定義。 接手別人的 pipeline 時,這五層都要看過才能說知道它放過了什麼。

expired_at 是這系列目前唯一會自己到期的機制,但它到期的時候不敲門。 CVE 直接回到表格裡,跟一筆今天新冒出來的漏洞長得一模一樣。唯一的信號是一行 log 消失 —— 所以 Task 得自己補一道檢查。

這跟前面幾篇是同一條線:Day 16 的排程跑了但回收量為零、Day 19 的規則過期照樣通過、Day 20 的規則設在 error 卻沒有邊界可管。這次的形態是:機制正確、記錄完整、期限明確,而到期這件事本身沒有出口。


參考資料

Trivy

文件 用到的結論
Filtering 五層過濾的管線與順序;.trivyignore 支援 exp:.trivyignore.yaml 的欄位定義(statement 不用於過濾、expired_at 未設則永久有效);--ignore-unfixed 是四個狀態的簡寫;--show-suppressed 在 JSON 裡是 ExperimentalModifiedFindings
Databases --db-repository 的預設 fallback 清單與 org 名差異;多值時的 fallback 條件(429 / 5xx)
PR #7679 mirror.gcr.io 加為預設的原因(GHCR 限流)
trivy fs CLI reference 旗標與預設值

npm

文件 用到的結論
package.json — overrides 改巢狀傳遞相依的版本;npm install <pkg>@<ver> 只動頂層

Nexus

文件 用到的結論
Repositories API docker proxy 的欄位;strictContentTypeValidation

沒有做的:Rego(--ignore-policy)與 VEX 兩層完全沒用到(§10);多值 --db-repository 的 fallback 沒做反面實驗(§16);.trivyignore 純文字版的 exp: 沒有實測(§9);--ignore-statuswill_not_fix / fix_deferred / end_of_life 上的行為沒驗(§8);--java-db-repository 沒碰(這個 repo 沒有 Java)。


上一篇
Day 20:npm-build 與 ESLint
系列文
防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線21
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言