
每個開發最不喜聞樂見的朋友,套件CVE來啦~(PM:什麼時候可以修好?)
今天做三件事:
sca-scan —— 掃第三方套件的已知漏洞(SCA)做完之後 d15-happy-path 會有七個 Task,其中五個並行(註:當時的,現在是四個)。
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 auditnpm 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/quick,Nexus OSS 沒有實作它,那是付費版才有的。
這個失敗的意義比「免費版功能不夠」大。 Nexus 的 npm-group 對 npm install / npm ci 完全可用。但 npm audit 打的是同一個 registry 底下的另一種東西:
npm install → 靜態內容下載 ← 代理得了
npm audit → 一項服務(伺服器查 DB、回傳分析結果) ← 要看代理實作了沒有
代理一個東西,不等於代理它的全部行為。 把 registry 指向代理的當下,這兩件事看起來都被涵蓋了;要到實際呼叫那個端點,才會發現代理的範圍到哪裡為止。評估任何「換成內部代理」的方案時,先問目標工具依賴的是檔案下載還是服務端運算。(註:斷網的話,
npm audit跑不動)
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:2,mirror.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.json 加 overrides,不是 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.yaml 的 expired_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 都不動。
postcss 有修法。但它不是直接相依 —— package.json 的 dependencies(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 清單的可信度來自它只放真的沒辦法的東西。
.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 引入」,三個月後回頭看的價值差很多。
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。
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 -e 配 lint-ignore.sh |
ignore 過期會讓 Task 紅燈,不只是印個警告 | §15 |
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、四個並行,這次加一個)。
Task 與 Pipeline 要先就位,再推 commit。 這個 commit 一推上去 webhook 就會觸發,而 sca-scan 的 scan step 第一行就是 sh ./lint-ignore.sh —— 檔案還沒推上去的話,會卡在 sh: can't open ./lint-ignore.sh 配 set -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.io 或 ghcr.io 代表有人加了 fallback,離線已經破了。
第 7 項的
-c step-download-db不能省。oc logs -l對多容器 Pod 不會報錯,它會印一行Defaulted container "step-download-db" out of: ...然後只讀第一個容器。這裡剛好第一個就是要看的那個 —— 但那是巧合,step 順序一改就讀錯了。
官方把過濾畫成一條管線:
偵測到的問題
→ 第 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 那一層。
--severity=HIGH,CRITICAL 是這條 pipeline 的閘門位置,跟 Day 20 把 ESLint 閘門設在 error 是同一種取捨:不這樣切,管道會被一堆「知道就好」的東西卡住跑不動。
代價要說清楚:MEDIUM 與 LOW 從第一層就被丟掉了,它們不會出現在主表、不會出現在 Suppressed 表、也不會被 ignore 檔記錄。這不是「放過」,是「根本沒看」。
這個閘門位置是這篇唯一沒有實測支撐的決定 —— 它是取捨,不是發現。
--ignore-unfixed 最省事,也最危險它看起來是這題的標準答案 —— 兩筆 image-size 加一個旗標就消失了,不用寫 ignore 檔、不用寫腳本。
官方定義它是四個狀態的簡寫:
--ignore-unfixed == --ignore-status affected,will_not_fix,fix_deferred,end_of_life
問題是它不是「放過那兩筆」,是「從此放過所有沒有修法的漏洞,不分嚴重程度」。
用 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這一種。其他三種狀態的行為沒有驗過。
| 欄位 | 作用 |
|---|---|
id |
唯一必填。指定要放過哪一筆 |
statement |
官方註明「不用於過濾」 —— 純粹是文件欄位 |
expired_at |
到期日。不設就是永久有效 |
statement 的價值在於它會出現在 --show-suppressed 的表裡(§5 那張)。「為什麼接受這個風險」從註解變成了報表的一欄。
.trivyignore(純文字) |
.trivyignore.yaml |
|
|---|---|---|
| 自動載入 | 會 | 不會,要 --ignorefile |
| 期限 | 官方文件說支援 exp:YYYY-MM-DD(本次未測) |
expired_at |
| 理由 | 只能寫註解 | statement,進報表 |
| 範圍限縮 | 無 | paths / purls |
選 YAML 是為了 statement。 代價是必須明寫 --ignorefile —— 而忘了寫的失效方式非常安靜(§11)。
這篇完全沒有用到,也沒有測。
之所以還是要提,是因為第二部分的主張是「五層,每一層都能讓東西消失」—— 少講兩層,那句話就不完整。實務上的意義是:接手一條別人寫的 pipeline 時,--ignore-policy 與 --vex 也要看,不能只檢查 --severity 跟 ignore 檔就宣稱知道它放過了什麼。
這一部分是這篇的重點。
前提:本部分所有對照組都在 §1 做
overrides之前的原始 lock 檔上量測,基準Total = 3(兩筆 image-size + postcss)。照第一部分做完之後手上只有兩筆,數字會不一樣。
四種失效有一個共同形態:掃描照跑、exit code 正常、log 不會說。 差別只在觸發條件。
--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。
把 expired_at 打成 expires_at:
Total: 2 ← CVE-2025-71329 仍然被擋掉了
id 認得所以 ignore 生效,expires_at 是未知欄位被直接丟掉 —— 沒有 WARN、沒有 ERROR。打錯一個字母,一條本來會在 2026-11-15 到期的 ignore,變成永久有效。
只測了
expired_at→expires_at這一種打錯法。沒測id打錯、或整份 YAML 語法壞掉。
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 是今天新冒出來的」長得一模一樣。
一筆過期、一筆有效 Total 2 "ignored/suppressed" INFO 行數 = 1
兩筆都過期 Total 3 INFO 行數 = 0
所有 ignore 都過期時,那行 INFO 整個不見了。
靠「某行 log 沒有出現」來察覺 —— 沒有人會這樣讀 CI log。而且只要還有任何一筆沒過期,這個信號就失效了。
--ignore-unfixed 放過的是未來§8 已經測過了,放在這裡是因為它跟前三種是同一類東西,只是時間軸不同:
前三種是「你以為設好了,其實沒有」;這一種是「你確實設好了,但範圍涵蓋了你還沒看過的東西」。
今天放過兩筆 HIGH,明天連 unfixed CRITICAL 一起放過,而且從第一天起就沒打算告訴你。
| # | 失效 | 什麼時候發生 | 表面看起來 | 有沒有訊號 | 防法 |
|---|---|---|---|---|---|
| 1 | 忘了 --ignorefile |
一開始就忘了 | 像「ignore 沒生效」 | 完全沒有 | 檢查清單第 4 項 |
| 2 | 欄位名打錯 | 一開始就打錯 | ignore 有效,只是永不過期 | 完全沒有 | lint-ignore.sh 的 grep |
| 3 | expired_at 到期 |
幾個月後 | 像一筆新冒出來的 CVE | 只有「一行 log 消失」,且只在全部過期時 | lint-ignore.sh 的 awk |
| 4 | --ignore-unfixed |
未來出現 unfixed CRITICAL 時 | 綠燈 | 完全沒有(grep 0 行) | 不要用它(§8) |
第 1、2 種可以靠檢查清單一次抓掉,第 4 種靠不用它,第 3 種沒有辦法一次解決 —— 它跟時間綁在一起,每次跑都要重新問一次。所以才要有 lint-ignore.sh。
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 裡,沒有人會看。
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-proxy 的 strictContentTypeValidation 是 true,沒有擋下來。
「為什麼沒擋」我沒有查證。 觀察到的只有「開著 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 時依序往下試)出自官方文件,本次沒有做反面實驗。 實測的只有上面那個「單值會硬失敗」。既然單值已經達成目的,就照單值寫。
--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 用完即丟。
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 |
--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 的坑)。
node_modules 出現不影響掃描範圍sca-scan 跟 npm-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 不排除只是多走訪目錄,掃描標的不變。
原本想把 DB 快取放進獨立的 volumeClaimTemplate workspace:
[User error] more than one PersistentVolumeClaim is bound
TaskRun 直接 TaskRunValidationFailed,連 Pod 都沒建。Day 13〈G〉記過這條規則。所以 DB 快取用 emptyDir —— 反正 Nexus 暖機後下載只要 3–4 秒。
四件值得帶走的。
代理一個東西,不等於代理它的全部行為。 Nexus 的 npm-group 對 npm 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-status在will_not_fix/fix_deferred/end_of_life上的行為沒驗(§8);--java-db-repository沒碰(這個 repo 沒有 Java)。