接手过不少镜像扫描报告,也帮团队处理过几百个漏洞工单。最常见的状态是:报告一拉出来,满屏 Critical,系统漏洞和应用漏洞两个标签各占一列,谁也不知道先动哪个。有人把系统漏洞当洪水猛兽,有人觉得应用漏洞全是开发的事,修到后面一团乱麻。其实这两类漏洞的来龙去脉、修复路径、误报概率都不一样,用同一套思路去修,结果往往是白忙活。这篇文章就从我实际处理这类问题的方法讲起,给正在盯镜像扫描报告的你一个可以直接抄的修复顺序。
1. 为什么同一次扫描结果,要分成“系统漏洞”和应用漏洞”两本账
1.1 扫描器其实只做了两件事:拆层,对版本
镜像扫描报告里的分类,不是安全团队拍脑袋定的,而是扫描器工作方式决定的。绝大多数镜像扫描器拿到一个镜像后,先做的是解包:把镜像的每一层文件系统还原出来,找到里面的包管理元数据,比如 Debian/Ubuntu 系的/var/lib/dpkg/status,CentOS 系的 RPM 数据库,Alpine 系的 APK 索引,然后把这些软件包名称和版本号拿去和漏洞库里的 CVE 对照,命中就报出来。
所以扫描器本质上是“版本比对器”,它并不知道你的业务逻辑,也不知道这个包是不是真的被调用过。它只认定两件事:这份报告里出现了哪个组件,以及这个组件的版本是否落在某个漏洞影响范围内。理解了这一点,你就明白为什么报告里的漏洞会被拆开:系统漏洞和应用漏洞的“包来源”完全不同。
系统漏洞指的是由操作系统包管理器负责安装和升级的组件,比如 glibc、openssl、bash、curl、zlib 这一类基础库,还有 nginx、redis 这类通过apt install或yum install装进系统目录的软件。它们的共同特点是:升级方式是apt-get upgrade、yum update这类系统级操作,依赖操作系统发行版的软件仓库。
应用漏洞则是指镜像里那些不属于系统包管理器管理的组件,通常包括:Python 的 site-packages、Node 的 node_modules、Java 的 jar 包、Go 编译出来的二进制里自带的依赖,以及部分通过源码编译或 COPY 进镜像的组件。它们的升级方式不是系统包管理器,而是语言生态自己的包管理工具,比如 pip、npm、maven、go mod。
1.2 系统漏洞与应用漏洞的关键差异
为什么要把这两类拆开?我建议团队里每个人先看这张对比表,再决定怎么排工单:
| 维度 | 系统漏洞 | 应用漏洞 |
|---|---|---|
| 典型组件 | openssl、glibc、curl、bash、内核工具 | Python 包、npm 包、jar 包、Go 模块 |
| 由谁管理 | OS 包管理器(apt/yum/apk) | 语言包管理器(pip/npm/maven/go) |
| 修复动作 | 换基础镜像或升级系统包 | 改依赖版本并重新构建 |
| 责任团队 | 平台/运维/基础设施 | 应用开发 |
| 误报判断 | 相对少,看发行版补丁状态 | 相对多,要看调用链和依赖关系 |
| 修复风险 | 可能连锁触发其他系统组件升级 | 可能引入编译兼容性和运行兼容性问题 |
这张表不是绝对精确,但足够指导日常决策。我见过一个团队拿到报告后,花了一天去升级系统里的 glibc,结果把运行环境搞坏了,因为他们的业务二进制是依赖旧版 glibc 编译的。如果他们先看清漏洞归属,可能就会选择换一个已经打补丁的基础镜像标签,而不是在生产容器里手动升级系统库。
1.3 不同扫描器的分类代号,别被名字带偏
每个扫描器的叫法不一样,但在逻辑上都能对应回去。Trivy 的报告里通常会区分System和Library,或者显示OS Packages和Language-specific Packages。Grype 和 Anchore 则会直接显示包类型,比如deb、apk、python、npm、jar、golang。Clair 这一类老牌扫描器也会在 feature 层面区分包来源。所以当你看到报告里出现“系统漏洞”和“应用漏洞”两个标签时,先别急着修,把它们分别映射到“操作系统包”和“语言生态依赖”这两个维度,你的修复方案才会更有针对性。
2. 修系统漏洞的正确顺序:先定位层,再换基础镜像,最后防缓存
2.1 定位漏洞藏身层:用 history 和 dive 还原现场
修复系统漏洞的第一步,不是打开报告就开始升级,而是先搞清楚这个漏洞来自镜像的哪一层。我常用的方法是先看 Dockerfile,找不到就直接看镜像构建历史:
docker history --no-trunc your-image:your-tag输出里会列出每一层执行的命令,比如apt-get install xxx还是COPY . /app。如果漏洞组件是在最底层的基础镜像里带进来的,那问题大概率出在基础镜像本身;如果是在后面的 RUN 指令里装进来的,那就需要看构建时用的是哪个软件源、当时有没有先update。
对于更复杂的排查,我会用 dive 这类工具把镜像每一层展开,直接看某一个文件是在哪一层出现或变化的。也可以按层去复扫:对基础镜像单独执行一次扫描,再把完整镜像扫一次,两次结果做 diff,就能把“基础镜像带来”和“中间层引入”的漏洞剥离开。这一步做得好,后面修复效率会显著提升。
2.2 基础镜像升级的两种姿态:拉新 tag 与固定 digest
如果漏洞确实在基础镜像里,最常见的修法是升级基础镜像版本。这里有两种做法:
第一种是简单地把 Dockerfile 里的FROM ubuntu:20.04改成FROM ubuntu:22.04或者更高版本。这样做的优点是省事,漏洞库匹配到的 Ubuntu 20.04 系统包会被整体替换成新版本。缺点是一下子跨大版本,系统库变化大,应用程序可能踩兼容问题,比如 glibc 版本变化后部分预编译二进制可能无法运行。
第二种做法是保留同一个大版本,但更新镜像源并执行系统包升级。在 Dockerfile 里增加这样一段:
RUN apt-get update && apt-get upgrade -y \ && apt-get install -y --only-upgrade openssl curl \ && rm -rf /var/lib/apt/lists/*这样做的逻辑是小步快跑:还是 Ubuntu 20.04 的框架,但把有漏洞的包升到该发行版仓库里的最新补丁版本。对依赖系统库的业务影响最小,也是我比较推荐的上线前修复方式。
需要注意的是,如果你是内网环境,apt-get update可能拉不到最新索引,镜像源必须提前同步并配置好。否则即使你写了升级命令,构建出来还是旧包,扫描报告也不会变绿。这就是系统漏洞修复里最容易踩的第一个坑:代码改了,但源是旧的。
2.3 修完复扫还是“红”:三个缓存陷阱和对应解法
修系统漏洞最让人抓狂的场景是:Dockerfile 改了,镜像也重新构建了,复扫结果居然还是原来的漏洞。根据我的经验,基本逃不出下面三个原因:
第一个是构建缓存。Docker 在构建时会尽量复用已有层,如果你的基础镜像 tag 没变,且前面的层没变,apt-get upgrade这层会被缓存命中,根本没有真正执行。解法是构建时加--pull参数,强制拉取基础镜像最新版本,或者使用--no-cache重建关键层。在 CI 里,我通常会这样写:
docker build --pull -t your-image:your-tag .第二个是扫描了相同的 tag,但 tag 指向的还是旧的镜像。很多人喜欢用latest这种可变 tag,构建完推上去,扫描器同一时间启动,可能扫到的还是上一次的 digest。复扫之前一定要确认镜像的 digest 变了,而不是只看 tag 没变。用docker inspect查看镜像 ID 或 digest,再拿新的 digest 去扫描。
第三个是扫描器本身有缓存,或者漏洞库还没有更新到最新。扫描器也好,漏洞数据库也好,都有更新周期。如果你刚修完就复扫,有可能扫描服务还在用旧索引。这种情况需要手动触发数据库更新,或者等一个同步周期再扫。想排除这个因素,可以拿一个已知无漏洞的镜像做对照扫描,如果对照组也报出相同漏洞,那基本可以断定是扫描器数据滞后。
3. 应用漏洞的修复重点:升级依赖、理清传递、揪出误报
3.1 先分清直接依赖和传递依赖
应用漏洞的修复逻辑和系统漏洞完全不同。系统漏洞往往是一次性升级包版本就能解决,应用漏洞则可能牵扯依赖树,一个顶层包升级,会带着十几个间接依赖一起变。
举个实际例子:你的 Python 应用里引用了某个库 A,这个库又依赖库 B,而漏洞出在 B 上。此时扫描报告会显示 B 存在漏洞,但你的代码并没有直接引用 B。如果你直接去升级 B,可能反而破坏了 A 对旧版 B 的兼容要求。所以我拿到应用漏洞工单,第一步永远是定位这个包是直接依赖还是传递依赖。
直接依赖比较好办,去requirements.txt、package.json、pom.xml、go.mod里改版本号,重新生成 lock 文件即可。传递依赖处理起来要谨慎一些,通常的做法是:
- 查看依赖树:
npm ls、pipdeptree、mvn dependency:tree、go mod graph - 找到漏洞包被谁依赖
- 优先升级上游直接依赖
- 如果上游没有修复版本,再考虑覆盖版本(有兼容风险)或临时替换方案
3.2 用依赖锁定和版本提升把高危压下去
给一个比较通用的实操参考,不同语言生态的修复命令有各自的习惯,我列几个常见的:
- Python:升级依赖并重新锁定版本
pip install --upgrade insecure-package==safe.version pip freeze > requirements.lock- Node.js:使用 npm audit 修复
npm audit fix # 如果传递依赖导致问题,可以加 --force,但要注意风险- Java / Maven:改 pom.xml 中的版本号,然后重新解析依赖
mvn versions:use-latest-versions mvn dependency:tree- Go:更新模块
go get -u your-module@safe.version go mod tidy这里有一个原则:尽量用 lock 文件固定依赖版本。很多团队镜像里跑的应用漏洞都是因为requirements.txt里没有精确锁版本,或者package-lock.json长期没有更新,每次构建都拉到新版本,才会出现扫描结果忽高忽低。把依赖锁死之后,扫描报告的噪声会小很多。
3.3 哪些应用漏洞可以暂不处理:误报与不可达
应用漏洞里确实存在较高的误报比例,这也是它比系统漏洞更需要人工判断的原因。我遇到过三类情况:
第一类是函数未调用。扫描器根据版本号报出某个 CVE,但实际上你的应用根本没有调用存在漏洞的那个函数。比如某个 HTTP 解析库存在漏洞,但应用只用到了它的基础工具函数,且入口处还有严格校验。这种需要通过源码分析或运行时调用链跟踪来确认。对于 Java 和 Python 生态,可以借助部分 SCA 工具做调用链可达性分析。
第二类是版本解析误判。有些组件版本号带前缀或带自定义字段,例如1.2.3-redhat.1,扫描器在匹配 CVE 时可能因为版本号格式差异,错误地报出漏洞。遇到这类情况,建议把组件本身的版本查清楚,再拿着实际版本去漏洞库确认。
第三类是扫描器漏洞库滞后或过度包含。有时候 CVE 已经声明某个版本已修复,但扫描器数据还没同步。这个可以参考供应商安全公告核对。
对这三类情况,可以在扫描器里配置豁免(allowlist),但一定要写清楚理由和截止时间。我见过最夸张的例子是,一个团队把准备上线的应用漏洞全部加进豁免,理由是“我们的代码没有调用”,结果后来代码重构,原来的调用路径变了,漏洞就被带上线了。豁免不是免责,必须有追踪机制。
3.4 豁免不是免责,要留证据
这里再展开讲一下豁免记录的规范。很多团队的扫描平台支持“suppress”或“ignore”操作,但只点击忽略、不填原因,三个月后谁都不记得为什么忽略。我的习惯是每次豁免都至少留下四项信息:
- 漏洞编号,例如 CVE 编号
- 组件名称及扫描到的版本
- 暂不修复的理由(误报、不可达、无修复版本、成本过高)
- 复核时间和负责人
这条记录既是给自己团队看的,也是给合规审查准备的。某些对漏洞管理有强要求的行业,比如医疗、金融相关的系统,上线前经常需要提供漏洞处置说明。如果你的豁免记录完整,这个环节会顺畅很多。
4. 优先级怎么排:高危不一定先修,低危也不能全放
4.1 排优先级时,真正该问的四个问题
拿到一份扫描报告,不要只看漏洞等级字段。扫描器给的 Severity 是基于漏洞本身的通用评分,不等同于你环境里的真实风险。我的习惯是对每个高危漏洞依次问四个问题:
第一,这个组件是否真的部署到了生产环境?如果只是一个构建阶段的工具镜像,从来没运行过,那风险等级可以下调。第二,这个组件是否处理外部输入?对于网络服务、文件解析、反序列化这类场景,漏洞的利用价值会更高;而纯内部计算逻辑,风险相对可控。第三,是否存在公开的利用分析?这个可以直接看漏洞库里的说明,有公开利用描述的漏洞要优先处理。第四,这个组件的版本是否真的影响业务运行?有些漏洞只在特定编译选项或特定配置下才能触发,需要结合运行参数判断。
这四个问题问完,大多数报告的优先级会自然浮出水面。
4.2 一张优先级矩阵把工单分成四类
我会建议团队维护一张简单的优先级矩阵,基于“漏洞严重程度”和“实际暴露程度”两个维度:
| 实际暴露程度 | 高危/严重漏洞 | 中危漏洞 | 低危漏洞 |
|---|---|---|---|
| 生产环境且对外暴露 | 立即修复 | 本周内修复 | 按计划修复 |
| 生产环境但仅内网访问 | 本迭代修复 | 下个迭代修复 | 汇总处理 |
| 测试/开发环境 | 尽快修复 | 定期修复 | 记录跟踪 |
| 构建阶段未运行 | 复核确认 | 记录跟踪 | 记录跟踪 |
这个矩阵的核心逻辑是:漏洞严重程度决定“要不要修”,暴露程度决定“多快修”。很多团队把顺序搞反了,拿着扫描报告逐条修,最后发现花了很大精力修了一个内网测试镜像里永远不会被访问的中危漏洞,而生产环境边界上的高危漏洞还在待办列表里躺着。
4.3 临时缓解措施能拖时间,但拖不了半年
有些高危漏洞确实一时半会修不了,比如基础组件升级影响面太大,或者依赖的上游还没有发布修复版本。这时候我会给团队推荐几层临时缓解手段:
- 网络层:调整容器网络策略,把受影响组件的对外端口收敛到最小范围
- 运行层:关闭不必要的特权,比如去掉
--privileged,配置只读文件系统 - 行为层:对容器进程加入额外的运行约束,比如禁用危险系统调用
- 备用方案:如果某个组件确认没有必要,直接在镜像里移除
但我要特别强调:临时缓解措施只能“拖时间”,不能“省事”。如果你把临时缓解当成最终方案,半年之后漏洞还在,而业务已经越来越依赖这个镜像,到时候再想升级,成本只会更高。正确的做法是给每个临时缓解项设置过期时间,到期后自动回到待办队列。
5. 修完怎么证明“真修好了”:复扫、流水线卡口和长期治理
5.1 复扫前先确认扫的是同一个镜像
这个问题听起来简单,但翻车率极高。很多人修完漏洞后,直接在扫描平台里点“重新扫描”,结果扫到的还是旧镜像。因为平台缓存了之前的镜像层和元数据,没有重新拉取。
在复扫之前,我都会做三件事:
- 用
docker images --digests确认本地新构建镜像的 digest - 在扫描平台上确认扫描目标是新的 digest,而不是同名 tag
- 如果平台支持,先删除旧的扫描记录,再发起新的扫描
还有一个容易被忽略的点:如果修复动作是“在上一层覆盖”,比如在 Dockerfile 最后加了一条RUN apt-get upgrade,表面上漏洞没了,但旧文件仍然留在下一层镜像里。对于很多扫描器来说,它解包后看到的文件版本可能是新的,但也有一些扫描器会看到历史层的旧文件,从而产生“已修复但报告仍报漏洞”的诡异情况。最稳妥的做法是重新构建整个镜像,而不是在旧镜像上叠加修复层。
5.2 把扫描卡进 CI/CD,让漏洞在源头拦下
与其每次上线前人工排查,不如把镜像扫描直接嵌入到软件交付流水线里。现在主流的镜像仓库和 CI 系统都能集成扫描能力,我建议按这样的节奏配置:
- 开发分支构建镜像后扫描,结果只通知开发,不强阻断
- 主分支或发布分支构建镜像后扫描,设有高危/严重漏洞时阻断发布,由安全负责人评估后决定是否豁免
- 定期扫描生产环境中实际运行的镜像,防止基础镜像更新后,线上镜像悄悄带上新漏洞
这个策略看起来简单,但落地时容易出现两种极端:一种是完全没有卡口,扫描报告只是“存档”,上线前没人看;另一种是卡口设置过严,任何中危都阻断发布,结果开发团队为了赶版本,开始批量构造豁免理由。比较合理的做法是只对“生产环境 + 对外暴露 + 高危以上”的组合做强阻断,其余情况走工单跟踪流程。
5.3 长期治理:基础镜像定期更新与镜像保活
最后说一个很多团队忽略的长期问题:镜像不是构建完就一劳永逸的。你上个月构建的镜像,可能这个月基础镜像就发布了安全性更新,但因为你没有重新构建,线上跑的还是旧版本。漏洞始终存在的根本原因,往往是“镜像没有保持更新”。
我建议团队做两件长期功夫。一是建立基础镜像的周期性升级计划,比如每两周或每月检查一次基础镜像的上游版本,把有更新的基础镜像重新构建并跑一遍回归测试。二是保持镜像重建的频率,不要用半年前的镜像继续叠加业务代码,而是定期从最新基础镜像开始构建。这个过程在一些团队里叫“镜像保活”,本质上是让镜像内容物持续跟上上游补丁。
如果你觉得这个节奏太重,也可以先从扫描报告的增量变化开始:固定每周对比一次线上镜像的扫描结果,只看新增的漏洞,把新增项处理掉,存量漏洞慢慢消化。这样比一次性清理几百个漏洞要现实得多。
最后聊一句实际操作中的体会
我从实际处理镜像漏洞的过程中,最大的感受是:修漏洞不是“消灭某个 CVE”,而是建立一套持续跟踪和验证的机制。系统漏洞和应用漏洞的分类,不是增加你的工作量,而是在告诉你该找谁修、用什么工具修、修的时候要注意什么。
我自己在每次修复后都会做一件事:把修复前的扫描报告和修复后的扫描报告截图存档,并记录修复动作和耗时。这不仅是汇报材料,更是一份团队经验和成本账。下次再遇到同类漏洞,直接翻历史记录,大概就知道要花多少时间、可能踩什么坑,心里有底得多。这套方法我也建议你从一个镜像开始试起来,跑通之后,再逐渐推广到团队的其他项目,会比一上来就搞大而全的漏洞治理方案更落地。