- Published on
容器镜像漏洞扫描结果怎么读 — 把几百个 Critical 变成 0 的实际做法
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 开篇 — 面对一份九个 Critical、总计 1,247 条的报告
- 扫描器真正做的事 — 清单与数据库的比对
- 误报的结构 — 厂商的回移补丁
- 扫描器看不见的东西
- 为什么不能只看 CVSS — EPSS 与 KEV
- 消掉那 1,247 条靠的不是打补丁,而是换基础镜像
- 放进 CI 时 — 门禁标准与带到期日的例外
- 收尾 — 请看可处理的条目,而不是报告上的数字
开篇 — 面对一份九个 Critical、总计 1,247 条的报告
第一次把镜像扫描器接进流水线,通常会看到这样的输出。
trivy image --scanners vuln registry.acme.com/payments:1.4.2
registry.acme.com/payments:1.4.2 (debian 12.6)
Total: 1247 (UNKNOWN: 0, LOW: 812, MEDIUM: 289, HIGH: 137, CRITICAL: 9)
node-pkg (node-pkg)
Total: 6 (UNKNOWN: 0, LOW: 0, MEDIUM: 4, HIGH: 2, CRITICAL: 0)
把这份报告原样贴进团队频道,结果二选一。要么所有人都无视它,要么谁也不敢动,发版就此卡住。这两种情况下安全都没有变好。
问题不在数字,而在解读。1,247 条里真正需要处理的通常是个位数,而找出这个位数的方法并不是严重级别过滤。至于剩下的一千二百多条,消掉它们的办法也不是一条条打补丁。
本文要讲的是扫描器看得见什么、看不见什么,该用哪些指标来筛,以及实务中真正能把这个数字压到两位数以下的措施是什么。
扫描器真正做的事 — 清单与数据库的比对
镜像扫描器既不运行镜像,也不分析代码。它做的事分两步。
第一,解开镜像层,生成一份已安装包的清单。Debian 系读 /var/lib/dpkg/status,RPM 系读 RPM 数据库,语言包则读锁文件或元数据目录。
trivy image --list-all-pkgs --format json node:22 \
| jq '[.Results[].Packages // [] | length] | add'
432
第二,把清单里的名字和版本拿去和漏洞数据库比对。就这些。
trivy image --format json node:22 \
| jq -r '.Results[].Vulnerabilities[]? | [.PkgName, .InstalledVersion, .VulnerabilityID, .Severity] | @tsv' \
| head -5
libssl3 3.0.14-1~deb12u2 CVE-2024-5535 CRITICAL
libc6 2.36-9+deb12u8 CVE-2025-0395 MEDIUM
perl-base 5.36.0-7+deb12u1 CVE-2023-31486 HIGH
zlib1g 1:1.2.13.dfsg-1 CVE-2023-45853 CRITICAL
tar 1.34+dfsg-1.2 CVE-2005-2541 HIGH
理解了这个结构,扫描器的一切性质都能顺着推出来。
没有包元数据就看不见。从源码直接构建塞进去的库、用 curl 拉下来复制进去的二进制、静态链接的依赖,都不会出现在清单里,因此也不会有漏洞被报出来。这就是"扫描结果干净"不等于"安全"的第一个理由。
反过来,只要在清单里,无论会不会被执行都会被报出来。tar 只要装在镜像里,哪怕应用一次都没调用过它,也会出现在漏洞清单上。上面输出的最后一行,2005 年登记的 CVE 至今还在冒出来,原因也一样。
误报的结构 — 厂商的回移补丁
最常见的误报类型就是回移。发行版为了稳定性不会抬版本号,而是只把安全修复移植到现有版本上。
docker run --rm debian:12-slim bash -lc 'dpkg -l | grep -E "^ii\s+openssl"'
ii openssl 3.0.14-1~deb12u2 amd64 Secure Sockets Layer toolkit
按上游的标准,3.0.14 对某个 CVE 是脆弱的。可 Debian 已经把修复移植进了 deb12u2 修订版。这一点可以确认。
docker run --rm debian:12-slim bash -lc \
'apt-get -qq update && apt-get -qq changelog openssl 2>/dev/null | head -12'
openssl (3.0.14-1~deb12u2) bookworm-security; urgency=medium
* Fix CVE-2024-5535: SSL_select_next_proto buffer overread
* Fix CVE-2024-4741: use-after-free in SSL_free_buffers
-- Debian Security Team Mon, 08 Jul 2026 21:14:02 +0000
只看版本字符串、拿去和上游数据库的范围比对的工具,会把这一条报成有漏洞。于是选扫描器时就多出一条标准。必须使用把发行版安全公告作为数据源的扫描器。只参考上游数据库版本范围的工具,会在 Debian、Ubuntu、RHEL 的镜像上批量制造误报。
数据源配置正确的话,就会出现状态字段。
trivy image --format json debian:12-slim \
| jq -r '.Results[].Vulnerabilities[]? | .Status' | sort | uniq -c | sort -rn
58 will_not_fix
23 affected
6 fixed
2 end_of_life
will_not_fix 是发行版安全团队审阅之后,判定在这个版本里不予修复的条目。多数情况下要么没有实际影响,要么利用条件不成立。affected 则是承认受影响但还没有修复版本的状态。
这里就引出了实务上最有用的一个选项。
trivy image --ignore-unfixed --severity CRITICAL,HIGH registry.acme.com/payments:1.4.2
Total: 4 (HIGH: 3, CRITICAL: 1)
1,247 条变成了 4 条。什么都没有变得更安全,但留下来的都是眼下能处理的条目。把没有办法修的条目留在待办队列里,只会让整个队列失去意义。
扫描器看不见的东西
扫描通过并不等于镜像是安全的。有些东西它在结构上就看不到。
应用代码。我们自己写的代码里的注入、授权缺失、不安全的反序列化,都不在镜像扫描器的范围内。在真实的入侵事故里,反而是这一侧占的入口比例更大。
配置。以 root 运行的容器、过大的权限、宿主机路径挂载、可写的根文件系统,这些都不是 CVE,因此不会出现在漏洞清单里。要靠另外的扫描器来看。
trivy image --scanners vuln,secret,misconfig registry.acme.com/payments:1.4.2
payments:1.4.2 (secrets)
HIGH: AWS Access Key ID
Dockerfile:22
ENV AWS_ACCESS_KEY_ID=AKIA****************
payments:1.4.2 (dockerfile)
HIGH: DS002 Image user should not be root
Specify at least 1 USER command with non-root user
没有元数据就进来的文件同样看不见。下面就是一个扫描绝对抓不到的脆弱二进制。
RUN curl -fsSL https://example.com/tools/legacy-cli-1.2.0.tar.gz \
| tar xz -C /usr/local/bin
从这条路径进来的东西,既不在 SBOM 里,也不在扫描结果里。往镜像里放东西时,走包管理器在可观测性上是划算的。
最后,扫描器并不知道那个库是不是在执行路径上。只要装上了它就报。用下面的命令可以大致估一估实际用到了什么。
# 进程实际打开的共享库
docker run --rm --entrypoint sh registry.acme.com/payments:1.4.2 -c \
'ldd /usr/local/bin/node | awk "{print \$1}" | sort'
libc.so.6
libcrypto.so.3
libdl.so.2
libgcc_s.so.1
libm.so.6
libpthread.so.0
libssl.so.3
libstdc++.so.6
八个。镜像里装的 432 个包当中,应用真正链接的就这么多。剩下的绝大部分是基础镜像带进来的,而这个观察会引向后面要讲的结论。
为什么不能只看 CVSS — EPSS 与 KEV
只靠 CVSS 分数来定优先级,一定会失败。CVSS 评估的是漏洞一旦被利用会造成的影响和利用难度,它是技术性评价,而不是实际被利用的概率。而在登记在案的漏洞中,真正被观测到有人利用的比例只有百分之几。
必须把两个指标一起看。EPSS 是对未来 30 天内观测到利用行为的概率估计,KEV 则是已确认存在实际利用的清单。
curl -s 'https://api.first.org/data/v1/epss?cve=CVE-2021-44228,CVE-2023-45853,CVE-2024-5535' \
| jq -r '.data[] | [.cve, .epss, .percentile] | @tsv'
CVE-2021-44228 0.944400 0.99970
CVE-2023-45853 0.004120 0.74331
CVE-2024-5535 0.001960 0.58207
第一条的利用概率是 94%,另外两条不到 1%。三条在 CVSS 上都是 Critical 级别。只看严重级别,三者看起来一模一样,但响应的紧急程度完全不同。
和 KEV 做比对更简单。
curl -s https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json \
| jq -r '.vulnerabilities[].cveID' | sort -u > /tmp/kev.txt
trivy image --format json registry.acme.com/payments:1.4.2 \
| jq -r '.Results[].Vulnerabilities[]?.VulnerabilityID' | sort -u > /tmp/found.txt
comm -12 /tmp/found.txt /tmp/kev.txt
CVE-2023-38545
1,247 条里被确认存在实际利用的只有一条。这一条就是今天必须处理的条目,其余的推到定期周期里也没关系。
总结起来,优先级的判断是三个维度的乘积。可利用性(KEV、EPSS)、暴露与否(是不是从互联网可达的服务),以及可达性(那段代码是否在执行路径上)。CVSS 对这三个问题一个都回答不了。
消掉那 1,247 条靠的不是打补丁,而是换基础镜像
这里是本文在实务上的核心。降低漏洞数量最有效的措施,不是把单个包升上去,而是让那些包一开始就不出现在镜像里。
把同一个应用放到不同基础镜像上分别扫一遍,差别一目了然。
| 基础镜像 | 镜像大小 | 检出的包数 | 可修复的 CRITICAL | 可修复的 HIGH | shell 与包管理器 |
|---|---|---|---|---|---|
| node:22 | 1.12 GB | 432 | 9 | 137 | 包含 |
| node:22-slim | 231 MB | 118 | 2 | 24 | 包含 |
| node:22-alpine | 148 MB | 41 | 0 | 3 | 包含 |
| gcr.io/distroless/nodejs22-debian12 | 187 MB | 19 | 0 | 1 | 没有 |
| 静态二进制加 scratch | 24 MB | 0 | 0 | 0 | 没有 |
数字的绝对值会随扫描时点变化,但趋势永远一样。镜像里的文件越少,漏洞就越少。听上去像废话,可实务中在得出这个结论之前,人们通常已经在逐条 CVE 上花掉了几十个小时。
迁移用多阶段构建来做。
# syntax=docker/dockerfile:1
FROM node:22 AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY . .
RUN npm run build && npm prune --omit=dev
FROM gcr.io/distroless/nodejs22-debian12:nonroot
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY /app/dist ./dist
USER nonroot
EXPOSE 8080
CMD ["dist/server.js"]
构建阶段里编译器和头文件都留着也无所谓,因为它们不会进入最终镜像。来比较一下结果。
trivy image --ignore-unfixed --severity CRITICAL,HIGH registry.acme.com/payments:1.4.2
trivy image --ignore-unfixed --severity CRITICAL,HIGH registry.acme.com/payments:1.5.0
payments:1.4.2 (debian 12.6)
Total: 4 (HIGH: 3, CRITICAL: 1)
payments:1.5.0 (debian 12.6)
Total: 0 (HIGH: 0, CRITICAL: 0)
payments:1.5.0 (node-pkg)
Total: 2 (HIGH: 2, CRITICAL: 0)
OS 包漏洞归零了,剩下的两条是我们自己应用的 npm 依赖。到这里,要处理的对象就清清楚楚了。
迁移到 distroless 时,实务上会绊住人的三个地方最好提前知道。
没有 shell,所以进不去 kubectl exec。调试方式变成挂一个临时容器。
kubectl debug -it pod/payments-7d9f -c debugger --image=busybox --target=app
CMD 写成 shell 形式就不工作了,只能用 exec 形式。而且健康检查里用 curl 或 wget 的配置全都得改。要么用语言运行时实现,要么换成 gRPC 健康检查。
如果需要 CA 证书,就得确认镜像里有没有。nonroot 标签的 distroless 基础镜像里带了,scratch 里没有。
放进 CI 时 — 门禁标准与带到期日的例外
把扫描放进 CI 的那一刻,就需要做一个策略决定:在什么条件下让构建失败。
对所有 Critical 都判失败,开发就停摆了。完全不判失败,那就没人会看。实务中能稳定维持下来的标准是下面这个组合。
trivy image \
--scanners vuln \
--severity CRITICAL,HIGH \
--ignore-unfixed \
--ignorefile .trivyignore.yaml \
--exit-code 1 \
registry.acme.com/payments:1.5.0
只把已经有修复版本的 Critical 和 High 纳入门禁。也就是说,只有"现在能修却没修的"才会失败。在此之上再加一条规则:只要条目出现在 KEV 清单里,不论严重级别一律立即判失败,这样就不会漏掉真正的风险。
if comm -12 /tmp/found.txt /tmp/kev.txt | grep -q .; then
echo "存在 KEV 清单中的漏洞。中止部署。"
comm -12 /tmp/found.txt /tmp/kev.txt
exit 1
fi
例外处理里最重要的规则只有一条:禁止无限期忽略。例外文件里必须写明理由和到期日。
# .trivyignore.yaml
vulnerabilities:
- id: CVE-2024-45491
paths:
- usr/lib/x86_64-linux-gnu/libexpat.so.1.8.10
statement: 本服务不解析不可信的 XML。计划在 8 月更换基础镜像时解决。
expired_at: 2026-09-30
- id: CVE-2023-45853
statement: zlib 未被静态链接,也没有调用路径。Debian 判定为 will_not_fix。
expired_at: 2026-10-31
一旦过了到期日,扫描器就会重新判失败。这是防止例外悄悄变成永久的唯一办法。把例外文件纳入代码评审,并定期报告临近到期的条目,就管得住了。
python3 - <<'PY'
import datetime, yaml
data = yaml.safe_load(open(".trivyignore.yaml"))
today = datetime.date.today()
for v in data.get("vulnerabilities", []):
due = v["expired_at"]
left = (due - today).days
if left < 30:
print(f"{v['id']} 还有 {left} 天到期 {v['statement'][:40]}")
PY
最后一步是把扫描结果变成强制力。给在 CI 里通过的镜像加上签名,并让集群只接受签过名的镜像。
cosign sign --yes registry.acme.com/payments:1.5.0
cosign attest --yes --type cyclonedx --predicate sbom.cdx.json registry.acme.com/payments:1.5.0
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-image-signature
spec:
validationFailureAction: Enforce
rules:
- name: check-signature
match:
any:
- resources:
kinds:
- Pod
verifyImages:
- imageReferences:
- registry.acme.com/*
attestors:
- entries:
- keyless:
subject: https://github.com/acme/payments/.github/workflows/release.yaml@refs/heads/main
issuer: https://token.actions.githubusercontent.com
有了这条策略,绕过流水线手工推上去的镜像就进不了集群。扫描门禁真正具备强制力的时刻正是在这里。在此之前,它更像一条可以绕过去的建议。
收尾 — 请看可处理的条目,而不是报告上的数字
读扫描结果可以归纳成三个步骤。
先加上 --ignore-unfixed。没有修复版本的条目今天做不了任何事,把它们留在清单里只会遮住其余的部分。仅这一个选项,通常就能把数字压到两位数以下。
接着用 KEV 和 EPSS 来定优先级。如果九个 CVSS Critical 里被确认实际利用的只有一个,那今天的工作就是那一个。严重级别作为排序依据,信息量实在太少了。
然后,在逐条给剩下的条目打补丁之前,先看基础镜像。如果应用真正链接的共享库只有八个,而镜像里却装着 432 个包,那问题就不在漏洞,而在镜像的构成。花一天迁到 distroless 或最小镜像,往往能替代掉几周的逐条 CVE 处理。
最后,例外里请务必写上到期日。一旦无理由永久忽略的条目堆积起来,例外文件本身就会变成新的盲区。扫描结果的价值不是用发现了多少条来衡量的,而是用关闭了多少条来衡量的。