- Published on
用 Trivy 扫描生产镜像 — 768 条结果里真正要动手的只有三个包和 Dockerfile 的一行
- Authors

- Name
- Youngju Kim
- @fjvbn20031
为什么要扫描
这个站点由一个 Python 后端承担登录、博客、语言学习和实验评分。依赖在 requirements.lock 里连哈希一起固定,"里面有什么"很清楚,但"里面有没有已知漏洞"从未测过。我用 Trivy 扫了两个对象。
- 代码仓库 —
requirements.txt里的 Python 依赖、Kubernetes 清单、密钥字符串 - 生产镜像 — 实际运行中容器的文件系统(操作系统包 + 已安装的 Python 包)
第二个是关键。只看仓库会漏掉操作系统包,只看镜像又看不出该在哪里修。
在生产镜像内部扫描的方法
镜像仓库(Harbor)需要认证,而 Pod 没有 pull secret(凭证在节点上)。所以我没有从仓库拉镜像,而是 把生产镜像本身作为容器启动,用 init 容器把 Trivy 二进制放进去,在里面扫描根文件系统。
initContainers:
- name: get-trivy
image: docker.io/aquasec/trivy:0.58.0
command: ["cp", "/usr/local/bin/trivy", "/tools/trivy"]
volumeMounts: [{ name: tools, mountPath: /tools }]
containers:
- name: scan
image: 192.168.219.202/labhub/backend:build-634 # 与生产相同的镜像
command: ["sh", "-c", "/tools/trivy rootfs --scanners vuln --format json /"]
不需要凭证就能扫描生产镜像,节点已经有镜像所以很快。第一次我加了 runAsUser: 0,撞上 CronJob 模板的 runAsNonRoot 策略,Pod 卡在 Pending——去掉就好了。根文件系统用普通用户也能读。
第一次结果:935 条 — 其中 167 条是扫描工具自己
{'LOW': 207, 'HIGH': 348, 'MEDIUM': 340, 'CRITICAL': 12, 'UNKNOWN': 28}
按目标: debian 13.6 → 720 · Python → 48 · tools/trivy → 167
看第三行。tools/trivy 167 条——是我用 init 容器放进去的 Trivy 二进制(Go 程序)的依赖。扫描工具在扫描对象里面,于是把自己也数了。12 条 CRITICAL 里有 4 条(go-git、x/crypto、grpc、Go 标准库)正是这些。去掉后重新统计:
去掉扫描工具: {'LOW': 197, 'HIGH': 275, 'MEDIUM': 264, 'CRITICAL': 8, 'UNKNOWN': 24} = 768 条
其中有修复版本的: {'CRITICAL': 1, 'HIGH': 53, 'MEDIUM': 55, 'LOW': 32} = 141 条
768 这个数字吓人,但只有按 "有没有修复版本" 划分之后,行动才会明确。141 条是能动手的,其余 627 条 Debian 还没有修复版本(其中包括 7 条 CRITICAL——glib、mbedtls、libxml2、perl-base)。
能修的 141 条归根结底是三组
| 严重度 | 位置 | 内容 | 现在 → 修复版本 |
|---|---|---|---|
| CRITICAL | Python | authlib | 1.4.0 → 1.6.12(10 条) |
| HIGH | Python | Pillow | 11.3.0 → 12.3.0(18 条) |
| HIGH | Python | python-multipart | 0.0.20 → 0.0.31(7 条) |
| HIGH | Python | starlette | 0.41.3 → 0.49.1 以上(7 条,由 fastapi 引入) |
| HIGH | 操作系统 | util-linux 系列 9 个 | 2.41-5 → 2.41.5-0+deb13u1(13 条) |
| HIGH | 操作系统 | openssl 系列 3 个 | 3.5.6 → 3.5.7(10 条) |
| MEDIUM | Python | pip | 25.0.1 → 26.x(6 条) |
authlib — 登录的签名验证绕过
最先要看的是唯一的 CRITICAL。CVE-2026-27962,JWS 的 JWK 头注入。如果用 key=None 验证令牌,库会用 令牌自带的 jwk 头里的密钥 来核对签名。攻击者用自己的密钥签名并把密钥放进头里,就能通过。1.6.9 修复。
我确认了这个站点是否走那条路径。调用 authlib 的地方只有一处:
token = await oauth.google.authorize_access_token(request)
starlette 客户端用从 Google JWKS 取回的密钥显式验证 ID 令牌,所以不会直接走 key=None 的路径。但也没有理由把这个库留在那个版本,于是升级了。
操作系统包 — 摘要固定的另一面
util-linux 和 openssl 在 Debian 里有修复版本,镜像里为什么没有?因为 Dockerfile 是这样开头的:
FROM python:3.12-slim@sha256:2c941e86… # 用摘要固定
RUN apt-get update && apt-get install -y --no-install-recommends fonts-nanum ffmpeg
用摘要固定可以让构建可复现,但 操作系统的安全修复永远进不来。 apt-get install 只添加新包,不升级已有的包。改了两行——把摘要升到当前版本,加入 apt-get upgrade -y,让每次构建都拿到当时可用的修复。
修完再测一次
用同一个 Dockerfile 在本地构建,用同样的方法再扫一遍。
| 修复前 | 修复后 | |
|---|---|---|
| 总计(去掉扫描工具) | 768 | 640 |
| 有修复版本的 | 141 | 13 |
| 其中 CRITICAL | 1 | 0 |
| 其中 HIGH | 53 | 3 |
| util-linux | 2.41-5 | 2.41.5-0+deb13u1 |
| openssl | 3.5.6-1~deb13u2 | 3.5.7-1~deb13u2 |
剩下的 3 条 HIGH 只有 starlette 1.x 才修,与 fastapi 升级一起另行处理。剩下的 7 条 CRITICAL Debian 尚无修复,这个镜像够不到——那应该记作"等待",而不是"已修复"。
怎么知道升级是无害的
升三个包并重新生成 lock,其他东西也可能跟着动。用与生产构建相同的 python:3.12 + pip-tools 7.5.1 重新生成 lock,变化的只有这三个包。
在用 --require-hashes 安装了该 lock 的容器里跑了全部 2,014 个测试,4 个失败。停在这里会被读成"升级弄坏了什么"。于是 在同一个容器里安装原来的 lock,再跑同样的 4 个——同样失败。它们是需要本地环境(运行中的服务、钩子、评分工具)的测试。没有对照组,"无关"只是猜测。
Pillow 跨了一个大版本,所以单独看了调用处:Image.open、ImageDraw、ImageFont.truetype、ImageCms、ImageOps——在 12.x 里全都还在。
密钥检测:12 条,真实泄露 0 条
Trivy 的密钥检测报了 12 条。我逐条打开看了。
| 检测到的 | 实际是什么 |
|---|---|
| RSA 私钥 2 个 | 实验教材——gen-tokens.py 生成的演示密钥(demo-key-id-001) |
| JWT 令牌 3 个 | 实验正文里的示例令牌 |
| Slack Webhook 1 个 | 工具里的示例字符串(curl 生成器的正则) |
12 条全是教材或示例。但"是实验材料所以没事"这个判断,只有打开文件之后才能做。只看列表就放过,真的混在里面也不会知道。
总结
- 仓库和生产镜像 都要 扫。只看仓库漏掉操作系统,只看镜像看不到修在哪里。
- 把扫描工具放进容器的话,结果里要 减掉工具自己。167 条就是它。
- 别被数字吓到,按 "有没有修复版本" 划分。768 变成 141,真正的动作是三个包加 Dockerfile 两行。
- 用摘要固定的基础镜像,没有
apt-get upgrade就永远收不到操作系统修复。 - 升级后失败的测试,用原版本再跑同样的测试 做对照。
- 密钥检测在打开文件之前不下判断。