Skip to content
Published on

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

分享
Authors

开篇 — 面对一份九个 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可修复的 HIGHshell 与包管理器
node:221.12 GB4329137包含
node:22-slim231 MB118224包含
node:22-alpine148 MB4103包含
gcr.io/distroless/nodejs22-debian12187 MB1901没有
静态二进制加 scratch24 MB000没有

数字的绝对值会随扫描时点变化,但趋势永远一样。镜像里的文件越少,漏洞就越少。听上去像废话,可实务中在得出这个结论之前,人们通常已经在逐条 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 --from=build --chown=nonroot:nonroot /app/node_modules ./node_modules
COPY --from=build --chown=nonroot:nonroot /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 形式。而且健康检查里用 curlwget 的配置全都得改。要么用语言运行时实现,要么换成 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 处理。

最后,例外里请务必写上到期日。一旦无理由永久忽略的条目堆积起来,例外文件本身就会变成新的盲区。扫描结果的价值不是用发现了多少条来衡量的,而是用关闭了多少条来衡量的。