6.6 镜像安全与扫描

一句话

镜像安全不是 “扫一次 CVE”,而是四环链条:来源可信(谁构建的)→ 内容可查(SBOM)→ 漏洞可修(扫描 + 基线)→ 未被篡改(签名 + 准入);扫描只解决第三环,单独做它得到的是 “一堆没人处理的红色数字”。

四环:每一环拦什么

环 要回答 手段 单独做会漏什么
① 来源 这个镜像谁构建的、从哪来 只用内部 CI 构建、禁止 docker pull 任意公共镜像、镜像仓库白名单 供应链投毒、拼写相似的假镜像
② 内容 里面到底装了什么 SBOM(SPDX / CycloneDX)、docker history、基础镜像清单 隐藏的挖矿程序、被篡改的依赖
③ 漏洞 有没有已知且可修的 CVE 扫描(Trivy / Grype / Clair)+ 分诊 + 基线 无补丁的漏洞、运行期才加载的组件
④ 完整性 跑起来的还是我签的那个吗 签名(cosign)+ 准入校验(admission policy) 仓库被写、镜像被替换、tag 被覆盖

四环缺一环,其余三环都会被打折:没有 ①,扫描只证明 “这个恶意镜像没有已知 CVE”;没有 ④,扫描的是 A 镜像、跑的是 B 镜像。

扫描要放三个位置

位置 时机 能拦什么 拦不住什么
CI(构建时) 每次提交 新增依赖引入的漏洞、基础镜像过期 后来才披露的 CVE
仓库(持续) 每日 / 镜像推送时 存量镜像的新披露漏洞 已经跑在节点上的实例
准入(运行时) Pod 创建时 高危镜像、未签名镜像、latest 已在运行的旧 Pod

只做 CI 的团队,镜像在仓库里放三个月后会出现 “扫描是绿的,实际早就红了”——因为 CVE 是后来披露的。所以仓库扫描是必须的,准入是最后一道闸。

CVE 分诊:severity ≠ 该不该修

情况 判断 动作
CRITICAL / HIGH 且有修复版本 可修 升级依赖或基础镜像,设时限(如 7 天 / 30 天)
CRITICAL 但无修复版本 只能缓解 评估可达性(是否暴露、是否被加载),加补偿控制,记录接受理由
组件存在但代码路径不可达 低风险 记录 + 定期复核,不占用紧急窗口
只在构建工具里(不进运行镜像) 不在生产面 修 builder 镜像,优先级低

三条判据把噪音降下来:有没有修复版本 → 在不在运行镜像里 → 路径可不可达。把这三问答完,一个 200 条的扫描报告通常只剩 5–15 条需要立刻处理。

基线:让 “可扫的东西” 变少

基线项 做法 效果
最小基础镜像 distroless / scratch / alpine(-slim) 组件数从 300+ 降到 20 以内,漏洞面同步缩小
非 root 运行 USER nonroot + runAsNonRoot: true 逃逸后拿不到 root
只读根文件系统 readOnlyRootFilesystem: true 阻止落地 webshell / 挖矿二进制
不装 shell 与包管理器 distroless 天然没有 sh 入侵后没有 “第一把工具”
固定基础镜像来源与版本 内部镜像仓库的 base + 每月更新 供应链可控,更新有节奏

基线与构建强相关:镜像越小、工具越少,扫描结果越干净——这也是 “多阶段构建” 的安全收益(见 6.4 Dockerfile 与构建优化)。

实测:扫一个 8.3MB 的 distroless 镜像

Docker Desktop 自带 docker scout(无需额外安装),拿 6.4 构建出来的 dk-app:1 直接扫:

$ docker scout cves dk-app:1
 ✓ Indexed 7 packages
 ✗ Detected 1 vulnerable package with 59 vulnerabilities
 vulnerabilities │ 4C  30H  23M  1L  1?
 size            │ 1.3 MB        packages │ 7

 4C 30H 23M 1L 1?  stdlib 1.23.12
   ✗ CRITICAL CVE-2025-68121   Affected: <1.24.13   Fixed: 1.24.13

$ docker scout cves --only-severity critical --exit-code dk-app:1
退出码=2

三个必须看到的结论:

  1. 8.3MB、7 个包的镜像仍有 59 个漏洞——镜像小 ≠ 无漏洞。漏洞几乎全来自 Go stdlib 1.23.12(工具链版本),升级基础镜像/编译器才是解法。
  2. --exit-code 返回非 0(实测 2),可以直接当 CI 门禁;不带它时扫描只是 “打印报告”,流水线照样绿。
  3. 有修复版本(<1.24.13 → 1.24.13),所以属于 “该立刻修” 的那一档;无修复版本的才走分诊。

可抄的命令(trivy / cosign 路线,本机未装,未实测)

# 1) 扫漏洞:只报 HIGH 以上,有则让流水线失败
trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 app:1

# 2) 出 SBOM(CycloneDX),随制品一起归档
trivy image --format cyclonedx --output sbom.json app:1

# 3) 签名与验签:签 digest,不签 tag
cosign sign --key cosign.key registry.example.com/app@sha256:<digest>
cosign verify --key cosign.pub registry.example.com/app@sha256:<digest>

docker scout sbom dk-app:1 也能直接产出 SBOM(实测输出为 JSON,含 7 个组件与 provenance 来源证明),所以 “没有 trivy 就出不了 SBOM” 不成立。判据是 “命令能跑通且结果可解释”:trivy 退出码 1 = 有 HIGH/CRITICAL;scout 退出码非 0 = 命中阈值;cosign verify 输出里应包含签名者身份与 digest 匹配。签名对象必须是 digest——签 tag 等于签了一个可变的别名。

准入:把 “不合规” 挡在创建之前

规则 拒绝什么 常见误配
必须来自白名单仓库 公共镜像直连 白名单写成前缀匹配,evil-registry.com/app 被放过
必须已签名 未签名 / 签名者不对 只校验 “有签名”,不校验 “谁签的”
禁止 latest 与可变 tag 无法复现的部署 规则只作用于新 Pod,旧 Pod 永远豁免
高危漏洞拒绝 已知可利用漏洞 没配豁免机制,导致紧急修复也被卡住

准入必须有豁免通道(带 owner 与到期日的白名单),否则第一次紧急发布就会有人把整条策略关掉,之后再也开不回来。

反模式

反模式 后果
只在 CI 扫一次 三个月后漏洞早已过期,报告仍是绿的
把扫描报告当门禁,但没人负责修 团队学会用 --ignore 绕过,门禁名存实亡
追 0 漏洞 无补丁漏洞永远存在,团队被噪音拖垮后整体放弃
只签 tag 不签 digest 签名不覆盖内容,tag 被覆盖后签名仍 “有效”
用 root + 可写根文件系统 入侵后可直接落二进制,横向移动更容易
公共镜像直接上生产 供应链投毒、镜像被删、许可证风险
准入策略没有豁免通道 紧急发布时被整体关闭,长期失效
有 SBOM 但从不查 出事时无法回答 “这个 CVE 影响我们哪些镜像”

三条铁律

🔑 扫描解决 “已知可修”,签名解决 “是不是我构建的”,基线解决 “可被攻击的面”;三件事缺一件都不叫镜像安全。

🔑 按 digest 签名与部署;tag 只给人看,不能作为信任依据。

🔑 分诊看三问:有修复版本吗、在运行镜像里吗、路径可达吗;答完再排优先级。

自检

Q1 扫描报告有 180 条漏洞,其中 150 条在 builder 阶段、20 条无修复版本、10 条 HIGH 有补丁。先做什么?

▸ 答案:先修 10 条 HIGH 且有补丁的(升级依赖或换基础镜像,设 7–30 天时限);20 条无补丁的做可达性评估并记录接受理由;150 条在 builder 阶段的不进生产面,单独排期修 builder 镜像即可。关键是先按 “是否在运行镜像 + 是否有补丁” 过滤,否则 180 条会把真正要修的 10 条埋掉。

Q2 签了 tag 的镜像,为什么仍然不安全?

▸ 答案:因为 tag 是可变别名:app:1.4 今天指向 digest A,明天可以被覆盖成 digest B,而签名仍挂在 tag 上(如果签的是 tag)。攻击者或误操作把恶意镜像推成同名 tag,验签照样通过。正确做法是对 digest 签名、部署时校验 digest,让 “签名” 与 “内容” 强绑定。

Q3 团队镜像里没有 shell、以非 root 运行、根文件系统只读。这挡住了什么、没挡住什么?

▸ 答案:挡住的是入侵后的落地与提权路径(没有 sh 就没法执行下载脚本,只读根盘没法放 webshell,非 root 拿不到宿主文件);没挡住的是应用层漏洞本身(如反序列化 RCE 仍可执行代码)与共享内核的逃逸。所以基线是 “降低利用后的收益”,不是 “防止被利用”——后者要靠修复依赖、WAF、强隔离运行时(见 6.5 容器运行时 (containerd / runc))。

Q4 为什么准入策略必须有豁免通道?

▸ 答案:因为总有 “必须现在发” 的时刻(线上故障、监管截止)。没有豁免通道,团队的唯一选择是关掉整条策略,而策略一旦被关,通常几个月都不会再开。有通道 + 到期日,等于把例外变成 “有主、有时限的记录”,策略的默认状态才保得住。

Q5 一个有 SBOM 的团队,和一个没有 SBOM 但每月扫一次的团队,谁在 “新 CVE 爆发” 时更快?

▸ 答案:有 SBOM 的。新 CVE 披露时,关键问题是 “我们哪些镜像用了这个组件、哪个版本”——SBOM 能直接查;没有 SBOM 只能逐个镜像重扫(镜像多时扫不完),或者凭记忆猜。这也是监管与供应链要求 SBOM 的原因:它把 “事后排查” 从小时级降到分钟级。

跨篇相关:镜像分层与层内残留见 6.2 镜像与分层、写时复制;构建侧的瘦身与秘密管理见 6.4 Dockerfile 与构建优化;运行时的隔离边界见 6.5 容器运行时 (containerd / runc);依赖与漏洞扫描的流水线位置见 52.3 依赖与漏洞扫描;制品不可变与仓库信任边界见 54.1 不可变制品、54.2 镜像与仓库;完整供应链安全见 38.10 供应链安全;利用侧的攻击面见 38.6 常见攻击。