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
三个必须看到的结论:
- 8.3MB、7 个包的镜像仍有 59 个漏洞——镜像小 ≠ 无漏洞。漏洞几乎全来自 Go stdlib 1.23.12(工具链版本),升级基础镜像/编译器才是解法。
--exit-code返回非 0(实测 2),可以直接当 CI 门禁;不带它时扫描只是 “打印报告”,流水线照样绿。- 有修复版本(<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 常见攻击。