前阵子一个部署在测试环境的服务,上线一个星期后收到安全通告:镜像里躺着一个CVSS 9.1的高危漏洞。团队第一反应是赶紧补丁,但真正的问题其实出在扫描链路上——CI里只有build、test、push,安全扫描是每周人工手动跑一次,还正好漏掉了这个中间版本。如果当时把GitHub Actions的自动化安全扫描接进去,每次push构建完镜像立刻跑一遍漏洞扫描,高危直接阻断合并,就不会出现这种上线两周才发现的局面。
那次之后我把“镜像构建 → 安全扫描 → 合规报告 → 门禁阻断”这一整套接入了Workflow,整个过程并不复杂。这篇文章就把这套方案完整拆开:工具怎么选、Workflow怎么写、门禁怎么设、踩过哪些坑,一次性讲清楚。不管你是刚接触容器安全的小白,还是已经在CI里跑了扫描但想做得更深的同学,都可以照着落地方案来。
1. 为什么镜像安全扫描不能只靠“等CI跑完再看”
1.1 一次上线事故给我的教训
很多人觉得容器安全扫描是“发布前手动扫一次”就够了,但我用一次实际事故的代价换来了教训。那个服务在CI里只有构建和测试,安全扫描靠每周人工跑一次。人工扫描最大的问题不是不认真,而是不持续:版本迭代太快,总有几个中间版本没被扫到;漏洞数据库每周都在更新,上周的扫描结论这周就可能失效。
镜像安全扫描真正的价值不在于“扫到漏洞”,而在于每次镜像被创建、被推送、被部署前都有一次自动拦截。GitHub Actions天然适合干这件事:push触发、pull_request触发、定时触发,机制都是现成的。接入之后,凡是不符合门禁的镜像根本走不到仓库和生产环境,完全靠流程而不是靠人的记忆力。
那次的修复其实很简单,就是加了一个workflow,但这件事让我意识到:安全扫描不能只当做一个“检查动作”,它应该成为CI流水线里的一个强制关卡,像编译失败一样直接中断交付。
1.2 镜像安全扫描到底在扫什么
很多人以为扫描是拿工具对着镜像文件整体扫一遍漏洞,其实没那么简单。一个容器镜像从底层往上大概是这样的结构:
- 基础镜像层:比如某个操作系统的精简版本,这一层往往包含大量系统包,例如OpenSSL、libc、bash;
- 依赖层:Dockerfile里执行apt-get install、pip install等命令写入的软件包;
- 应用层:自己的二进制、配置文件、静态资源。
Trivy这类扫描器会一层层解开镜像,对照漏洞数据库和系统包、编程语言依赖的版本信息做匹配。它能扫的对象不止系统包:Python的requirements、Node的package-lock、Go module、Ruby的Gemfile.lock都在覆盖范围内。更进阶的还会扫描IaC配置、明文密钥、不安全的启动参数等,这些都是合规检查里容易被忽略的点。
稍微类比一下:镜像就像千层蛋糕,每层都有各自的配料表。扫描器做的事情不是只看最上面那层奶油,而是每一层掰开看有没有过期原料。所谓逐层扫描,本质是把镜像的每个layer对应的文件系统变化关联到已知漏洞库,这样才能覆盖到基础镜像里那个藏在底层的旧OpenSSL。
1.3 合规检查的三个层次:漏洞分级、SBOM与License
合规和“扫到漏洞”是两个层面。第一层当然是漏洞分级,通常是CVSS评分加上严重级别,比如Critical、High、Medium。门禁一般用High和Critical作为阻断阈值,Medium可以先放进代码再看。
第二层是SBOM,也就是软件物料清单。SPDX或CycloneDX格式都能输出:这个镜像里每一条软件来自哪个包、什么版本、什么License。第三层是License合规,比如团队用了某个AGPL协议的包,没有经过法务评估就随镜像分发,这种风险漏洞库扫不出来,但SBOM能暴露出来。
所以完整的合规流水线并不是“运行一次扫描然后写个report”,而是漏洞扫描、SBOM、签名三个环节。后面第三章会给完整Workflow,但在此之前得先解决选型问题——用什么工具。
2. 扫描工具选型对比:Trivy、Grype、Docker Scout谁更适合Actions
2.1 四个主流方案的定位差异
先放一张对比表,把最常见的四个方案放在一起看:
| 工具 | 漏洞扫描 | SBOM | 官方Actions动作 | 报告格式 | 主要特点 |
|---|---|---|---|---|---|
| Trivy | 系统包+语言依赖+IaC | 支持SPDX/CycloneDX | aquasecurity/trivy-action | SARIF、表格、JSON | 单二进制、无守护进程、覆盖广 |
| Grype | 系统包+语言依赖 | 支持CycloneDX/SPDX | anchore/scan-action | SARIF、JSON | 与Syft配套,生态轻量 |
| Docker Scout | 镜像漏洞+策略 | 支持组织级SBOM | docker/scout-action | SARIF、JSON | 和Docker Hub/Desktop强绑定 |
| Snyk | 语言依赖+容器 | 支持 | snyk/actions | SARIF、JSON | 需要token,商业授权为主 |
这里有两个决定使用体验的关键参数:是否需要额外token、是否能直接输出SARIF。Snyk和Docker Scout对商业生态绑定比较深,如果团队本身没有这些平台的账号,配置成本会高一点;Trivy和Grype都是纯粹的开源二进制,拉到CI跑就行,不依赖厂商账号。
2.2 从Actions集成便利程度看选型
GitHub Actions的集成是否顺手,重点看三件事:
- 能否直接复用GITHUB_TOKEN,还是要单独配置Secrets;
- action是否支持缓存,关系到扫描速度和数据库下载频率;
- 输出格式是否兼容Code scanning,决定能不能直接在PR上看到结果。
Trivy这三方面都很省心:GITHUB_TOKEN用于上传SARIF即可,数据库缓存可以写到工作区里持续复用,SARIF输出能直接挂到Security tab。Grype也不错,但它本身更倾向于和Syft配合做“生成SBOM → 扫描漏洞”两步流程,链路灵活但需要多写几个step。Docker Scout的优势是策略引擎非常直观,如果你本来就用Docker Hub做镜像分发,开箱即用;缺点是一旦镜像不在Docker Hub,集成成本就上来了。
2.3 为什么我最终选择Trivy作为主扫描器
我的选择逻辑其实很朴素:一是覆盖率够,系统包和大部分语言依赖一次搞定;二是缓存机制成熟,增量扫描可以把单次耗时压缩到一分钟以内;三是社区活跃,文档里关于“在CI里安全地跑容器扫描”的常见坑都有明确说法。
说句公道话,Docker Scout如果团队本来就用Docker Hub,是很省事的选择,因为它能直接结合镜像仓库来分析。但我们的构建流程优先用GitHub Packages或者自建仓库,Docker Hub只是镜像导出通道之一,这种情况下Scout反而不如Trivy直接。所以一个折中方案是:主线用Trivy跑漏洞扫描和门禁,另外在发布到Docker Hub的job里加一个Scout步骤做策略复核,两边结果交叉验证,互补时效性。
2.4 别忘了SBOM生成与签名校验
选型只解决“扫什么”,没解决“来源是否可信”。之前我遇到过一个场景:CI里拉取的某个基础镜像标签被维护者覆盖过,本地扫描一切正常,但实际生产环境使用的层和开发机不一样。后来才意识到是镜像标签漂移问题。
因此现在流程里统一要求:从仓库拉镜像时先做digest固定,再用cosign检查签名,确认没被篡改才开始扫描。Trivy本身也支持镜像签名校验,可以把这一步并到扫描动作的前置脚本里。签名这件事看起来是额外步骤,但在供应链攻击频发的背景下,它已经是合规检查里不可跳过的一块。
3. 工作流落地方案:从基础扫描到SBOM与合规准入的完整配置
3.1 准备阶段:构建缓存与数据库缓存怎么设
写Workflow之前先理清一个容易被忽略的环节:缓存。Trivy扫描对比的是漏洞数据库,数据库如果不走缓存,每次job从零下载几十MB甚至上百MB,CI耗时立刻翻倍。我用actions/cache把trivy缓存目录存下来,key按trivy版本和漏洞库更新时间做组合,这样日常提交时数据库没更新就直接命中缓存。
另一个技巧是构建阶段就用GHA缓存(type=gha),同一批被buildx推过的层不需要重新拉取。尤其是镜像里那层几百MB的基础镜像,如果没有缓存,扫描还没开始,光构建就得几分钟。这两个缓存加下来,整个安全扫描job的时间基本能被压在1到2分钟以内。
3.2 核心Workflow:构建、扫描、生成报表三步走
下面这个Workflow是可运行的最小闭环,包含构建、Trivy漏洞扫描、SBOM生成、SARIF上传四步:
name: container-security-scan on: push: branches: [main] pull_request: workflow_dispatch: jobs: scan: runs-on: ubuntu-latest permissions: security-events: write contents: read pull-requests: write steps: - uses: actions/checkout@v4 - uses: docker/setup-buildx-action@v3 - uses: docker/build-push-action@v6 with: context: . push: false load: true tags: example/app:${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max - uses: aquasecurity/trivy-action@0.24.0 with: image-ref: example/app:${{ github.sha }} format: sarif output: trivy-results.sarif severity: HIGH,CRITICAL exit-code: '1' - uses: github/codeql-action/upload-sarif@v3 with: sarif_file: trivy-results.sarif - uses: aquasecurity/trivy-action@0.24.0 with: image-ref: example/app:${{ github.sha }} format: cyclonedx output: sbom.cdx.json - uses: actions/upload-artifact@v4 with: name: sbom path: sbom.cdx.json这里有几个值得细说的点。push: false配合load: true,表示镜像只进当前job的本地Docker上下文,不推送到远程仓库,这一下能省掉登录和推送权限。trivy-action跑两遍,一遍输出SARIF,一遍输出SBOM,很多人为了省时间会合并成一次JSON,但我建议分开保存:SARIF给人工看,SBOM给审计系统,两者生命周期不一样。
exit-code: '1'表示一旦扫到HIGH或CRITICAL级漏洞就让job失败。这里有个需要特别留意的细节:trivy-action在设置了exit-code的前提下,如果扫描结果为空也会返回非零,所以别把这个动作放在always()的汇总job里,否则每次都会误报失败。
3.3 合规门禁:如何设置阻断阈值
关于severity阈值,我推荐的分级策略是:
- PR阶段:只拦截CRITICAL;
- 合并进main:拦截HIGH及以上;
- 每周基线扫描:拦截全部HIGH与CRITICAL,且对比上一轮结果增量。
也就是说,不同事件用不同severity参数。PR里的扫描天天跑,阈值太高团队会烦,阈值太低merge流程又容易被琐碎问题卡死。合理的做法是把阻断合并留给CRITICAL,把“必须修复”留给HIGH。
具体实现上,可以在同一个Workflow里加一个条件判断,比如判断github.event_name来覆盖severity输入,也可以干脆拆成两个workflow,一个跑PR,一个跑main。我偏向拆成两个,因为PR扫描和主干扫描的关注点完全不一样,混在一起会让日志和告警都很乱。
3.4 结果展示:让PR和Security tab都可见
SARIF上传之后,GitHub的Security tab会自动展示扫描结果,但这个展现有延迟,通常需要几分钟才能同步。如果希望PR上直接有评论,可以再挂一个step把扫描结果转成PR comment。
我之前的做法是写一个很小的Python脚本读SARIF,把Critical漏洞转成Markdown表格,再用actions/github-script发到issue comment。这样维护者打开PR就能直接看到漏洞列表,而不是要去Security tab里翻。这一步需要的权限是pull-requests: write,别只给contents: read,否则会静默失败。
GITHUB_TOKEN的默认权限在团队级设置和仓库级设置都可能被覆盖,改完permissions之后要确认一下实际生效,不能只看workflow文件里的声明。这个坑我在第4章展开讲。
3.5 定时扫描:对已经发布的镜像做全量复查
只扫push产生的镜像是不够的。容器镜像在仓库里放得越久,积累的漏洞就越多,因为漏洞数据库每隔一段时间就会新增记录。我给生产仓库挂了一个schedule任务,每天凌晨两点对最近一周推送的镜像tag做全量扫描,结果和当天基线对比。
这个任务可以复用一个只读的runner镜像,直接从仓库拉取目标镜像,不需要重新构建,扫描完把JSON报告上传到Artifact保留90天。定时扫描另外要注意的一点是schedule事件有最小间隔限制,官方建议不要低于每5分钟一次,实际每天一次就足够了,频率太高只会让仓库和账单一起膨胀。
4. 踩坑实录:误报、缓存、权限、性能这些高频问题的排查链路
4.1 排查链路一:扫描时间从40秒飙升到3分钟
现象:某次改动后trivy-action单job从40秒变成3分钟,PR排队时间明显变长。
我的排查步骤是这样的:
- 先看Actions日志里每个step的耗时,确认是trivy download还是trivy扫描阶段超时;
- 发现“Downloading vulnerability database”这一步每次都在重新下载;
- 再查缓存目录,发现actions/cache的key里用了动态时间戳,导致每次都不一样,缓存永远命中不了;
- 改成基于trivy版本和漏洞库更新时间的稳定key,并把缓存路径设为
/root/.cache/trivy。
解决后单job回到50秒上下。类似的问题也可能出现在buildx缓存上,判断方法一样:看日志,确定耗时都堆在哪个step,再针对那个step优化缓存,而不是盲目去升配更贵的运行器。
4.2 排查链路二:CI扫描结果和本地不一样
现象:本地跑trivy image没有高危漏洞,但CI里同一个镜像、同一个tag却扫出了Critical。
这一步其实有几个潜在原因。先是Trivy版本不一致:本地是0.18.0,CI里的action却是随main最新更新的0.24.0,两个版本的漏洞库格式和扫描算法都有差异。再是数据库版本不一致:本地自动更新到了最新,CI却因为缓存key把数据库锁在了两周前。最后是ignore文件不一致:.trivyignore在本地被编辑器改过但没提交,CI用的是仓库里的版本,两边豁免列表不同。
排查链路是:先diff两边trivy版本和db版本,再看ignore文件,最后确认trivy有没有真的扫到同一个镜像。解决方案也很直接:action版本锁定到具体tag,不要用@master;本地扫描时固定一个和CI一致的已更新数据库;忽略规则统一放在仓库里维护,本地扫描前先git pull。
4.3 排查链路三:PR评论不显示或权限报错
现象:SARIF上传成功了,Security tab也有记录,但PR上一条评论都没有。
排查链路:先看Actions日志有没有出现“Resource not accessible”或403;如果有,多半是permissions没给全。然后看事件类型:来自fork仓库的PR默认不会读取secrets,也不会授予写权限,所以那些来自外部贡献者的PR拿不到评论权限,这是平台的安全设计,不是配置错了。如果是自己仓库内的PR,就看Workflow里permissions块和仓库设置里的“Workflow permissions”是否冲突。
我最后的配置是permissions明确声明security-events: write、pull-requests: write、contents: read。注意三个都要写,只写其中一个,GitHub会按最小权限原则把其他权限降级。
特别提醒:遇到fork PR的权限问题时,先确认是不是“预期中的未授权”,这个失败是GitHub对fork PR的安全策略,不是你的Workflow本身写错了。要区分“真的失败了”和“预期中的未授权”,别浪费时间去改一个本来就该失败的场景。
4.4 排查链路四:跑多个Job时被用量限制
现象:在同一个Workflow里定义了构建、扫描、SBOM三个job并行跑,结果经常出现“job queued”或者一段时间内全部失败。
排查链路:这通常不是内存不够,而是公共仓库的并发job数限制。GitHub Actions每个账号在同一时间能跑的job总数有限,PR一多就会排队。解决方法是把三个job合并成一个job内的多个step,避免并行消耗额度;或者用workflow concurrency来限制同一仓库同时运行的工作流数量。
顺带提一个容易被忽略的开销:多个job都拉镜像、都下载漏洞库,网络和磁盘都是重复消耗。合并成单job后用load: true只跑一次Docker,能省掉不少资源。
4.5 怎么处理误报:过滤与豁免规则
误报是安全扫描里躲不开的问题,处理不好会让团队对告警脱敏。Trivy支持.trivyignore文件:
# 这个CVE在特定版本链路上不可达,等待上游修复 CVE-2024-XXXXX但我的建议是,不要一上来就加ignore。遇到一个扫描出来的漏洞,先走一遍这样的判断:
- 这个包实际被复制进运行镜像了吗?如果只是构建阶段的中间层,镜像里没有,那可以忽略;
- 漏洞是不是只在某个特定架构或配置下可触发?比如依赖libc的溢出,但容器内没有对应入口;
- 是否已经在上游修复,只是漏洞库还没收录?可以等一个周期再决定;
- 是否真的无法通过正常升级解决?这时候才走豁免流程。
豁免不是“删掉告警”就行了,至少要留一个带有效期的例外记录,过期后重新评估。我在仓库里维护一个issues列表,每条豁免都对应一个issue,写明原因、负责人、过期时间,trivy跑的时候引用issue号。这样审计时有据可查,也不容易遗忘。
5. 让扫描结果真正可执行:门禁之外的管理闭环
5.1 分级阻断策略与例外机制
第3章已经讲过severity分级,这里补充管理维度。门禁真正落地难的不是技术,而是规则是否被认真对待。我给团队定的规则是:PR合并前没有新的Critical;main分支发布前一小时内如果有Critical,发布会自动暂停,需要人工确认后才能继续;每周一对比上周基线,增量里的HIGH漏洞必须有人认领。
例外机制上,我坚持“有记录、有截止日期、有负责人”的三有原则。豁免不是永久豁免,到期后扫描rollback时如果漏洞还在,工作流直接重新阻断。这个设计比单纯用.trivyignore要可靠,因为.trivyignore是永久生效的,很容易变成“忘了为什么ignore”的定时炸弹。
5.2 修复建议的自动化跟进
扫描结果除了“阻断”,还应该有“跟进”。我的做法是:夜间扫描job如果发现高危漏洞,自动通过GitHub API给仓库创建issue,标题包含镜像tag和CVE列表,assign给当前仓库的主要维护者。这样即使当天没人看PR,第二天也能在issue面板里看到“你昨天推的镜像有两个新曝出的高危漏洞”。
依赖更新层面,Dependabot虽然主要管源码依赖,但对Dockerfile里的基础镜像tag也有监控。配合起来效果是:某个基础镜像出了新patch,Dependabot自动开PR,PR跑了扫描且没有新增严重漏洞,就能直接合并;合并后重新触发构建,新的镜像自动通过门禁,形成闭环。
5.3 规则维护:漏洞数据库更新与基线比对的节奏
不要让扫描结果“过期”。Trivy的漏洞数据库更新很频繁,但如果你不主动同步,CI里缓存key一旦稳定,数据库就停在缓存那一刻。我给数据库更新设置的节奏是:
- 每次CI运行时如果不加
--skip-db-update,自动尝试拉最新; - 缓存key包含数据库的版本信息,保证下一次运行时数据库没变就直接命中;
- 每周一还有一次全量基线任务,对比上周的SBOM和本轮的差异,把新增的漏洞列出清单。
基线比对的核心价值不是报漏洞,而是看趋势。比如某个服务连续四周新增漏洞数量一直增加,说明它的依赖升级节奏可能出了问题,应该去看是升级流程阻塞了,还是某个依赖的上游长期没有修复。
5.4 还可以扩展的三件事:镜像签名、distroless、多架构
这套流程跑顺之后,还有三件事值得继续加。
第一是镜像签名:用cosign给镜像打签名,CI里校验签名后再扫描,防止供应链投毒。第二是distroless镜像:这类镜像没有shell也没有包管理器,很多人的第一反应是“没法扫了”,但Trivy这类工具照样能解析它的层结构,只是系统包列表为空,应用依赖仍然有效。第三是多架构镜像:扫描时要写platform参数,把amd64和arm64分别扫一遍,两个结果合并进同一个合规报告,别只扫默认架构。
按照我这几轮实际跑下来的经验,最后给一个很实用的建议:别把安全扫描当成一个“必须过的形式”,也别把workflow一次堆到最复杂。我自己的推进路径是先把trivy-action跑通,SARIF上传到Security tab;然后加severity门禁;跑一周没问题,再加SBOM、签名、定时基线;豁免规则是遇到第一个真正无法修复的漏洞时才开始建流程,而不是提前把所有过滤条件都写出来。一步一步来,这套自动化才能真正稳定运行下去。