容器镜像的成分分析:比源码扫描多出来的那几层
一个常见的困惑
团队把 SCA 接进了 CI,源码扫描干干净净,结果安全团队用镜像扫描器一扫,报出几十个高危漏洞。开发的第一反应通常是“扫描器有问题”。
扫描器没问题。两边扫的根本不是同一个东西。
镜像里到底有什么
一个典型的应用镜像包含三层性质不同的组件:
第一层:基础镜像(OS 层)——你在 Dockerfile 第一行写的 FROM。它带来一整个操作系统的用户空间:glibc、openssl、bash、coreutils、包管理器本身。这一层的组件数量通常远超应用依赖,而且完全不在任何应用清单文件里。
第二层:系统包——构建过程中 apt-get install 或 apk add 装进去的东西:curl、git、编译工具链、各种运行时库。它们经常是为了构建而装、装完忘了删,最终留在生产镜像里。
第三层:语言依赖——package.json、pom.xml、requirements.txt 里声明的那些。这一层,也只有这一层,是源码 SCA 能看到的。
所以“源码扫描干净但镜像有漏洞”根本不矛盾:问题出在你没写、但确实在运行的那两层里。
为什么基础镜像是风险重灾区
基础镜像的问题不在于它质量差,而在于三个结构性因素:
体积即攻击面。 一个完整的 ubuntu:22.04 带着几百个包,其中绝大多数你的应用根本用不到——但它们的漏洞照样会被扫出来,照样需要你解释或修复。
更新滞后。 FROM node:18 这样的写法看似锁定了版本,实际上标签是浮动的,而团队往往几个月才重建一次镜像。基础镜像的安全更新不会自动到达你的生产环境。
继承的不可见性。 你的镜像 FROM 了某个团队的中间镜像,那个镜像又 FROM 了别的。最终生产镜像里的组件,可能来自三四层之外的某个决定,而没有人完整地看过这条链。
三条治理原则
一、选择精简且有维护承诺的基础镜像。
distroless、官方 slim 变体、alpine——选择的核心不是“哪个最小”,而是“哪个在满足运行需求的前提下组件最少,且有明确的安全更新节奏”。少一个包就少一个潜在的 CVE 和一次不必要的解释。
二、镜像扫描进 CI,与源码扫描并行。
两者互补而非替代:源码 SCA 在依赖引入时拦截(更早、修复成本更低),镜像扫描在交付物层面兜底(更全、覆盖你没声明的部分)。理想的门禁位置是镜像构建之后、推送到仓库之前——此时还能重建,推上去再发现就是回滚了。
三、基础镜像版本全公司统一管理。
不是每个团队各自选 FROM,而是公司维护一组经过安全审查的基线镜像,各团队从基线派生。这样做的收益在漏洞爆发时最明显:修一次基线,所有下游重建即可,而不是二十个团队各查各的。
SBOM 应该覆盖到哪一层
如果你要给客户或监管提交容器化产品的 SBOM,只导出语言依赖是不够的——收方期待的是“这个镜像里有什么”,而不是“这个应用声明了什么”。
完整的容器 SBOM 应当包含:基础镜像标识与摘要、系统包清单(含版本)、语言依赖树、以及各层的来源信息。好在这件事是可自动化的:在 CI 里对构建产出的镜像做一次成分分析,导出 SPDX 或 CycloneDX,随镜像一起发布。
需要留意的是层的可追溯性:同一个组件可能出现在多个层,来源不同、修复路径也不同。基础镜像里的 openssl 要靠换基础镜像修,应用依赖里的要靠改依赖声明修——SBOM 如果不区分来源,收到的人无法判断该找谁修。
从“扫描通过”到“成分清楚”
容器化把交付物从“一个 jar 包”变成了“一整个运行环境”,这让软件供应链的边界向下扩展了两层。相应地,成分透明的要求也必须跟着下沉。
值得强调的是:这不只是安全问题,也是许可证问题。基础镜像里的 GPL 组件、系统包的许可证义务,同样会随着镜像分发而传递。只看应用依赖的合规审计,在容器化交付面前是不完整的。
---
延伸阅读:SBOM 字段实操指南 · 在 CI 流水线里配置 SCA 门禁 · CleanSource SCA 的容器镜像分析能力
