前阵子一个部署在测试环境的服务上线一个星期后收到安全通告镜像里躺着一个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谁更适合Actions2.1 四个主流方案的定位差异先放一张对比表把最常见的四个方案放在一起看工具漏洞扫描SBOM官方Actions动作报告格式主要特点Trivy系统包语言依赖IaC支持SPDX/CycloneDXaquasecurity/trivy-actionSARIF、表格、JSON单二进制、无守护进程、覆盖广Grype系统包语言依赖支持CycloneDX/SPDXanchore/scan-actionSARIF、JSON与Syft配套生态轻量Docker Scout镜像漏洞策略支持组织级SBOMdocker/scout-actionSARIF、JSON和Docker Hub/Desktop强绑定Snyk语言依赖容器支持snyk/actionsSARIF、JSON需要token商业授权为主这里有两个决定使用体验的关键参数是否需要额外token、是否能直接输出SARIF。Snyk和Docker Scout对商业生态绑定比较深如果团队本身没有这些平台的账号配置成本会高一点Trivy和Grype都是纯粹的开源二进制拉到CI跑就行不依赖厂商账号。2.2 从Actions集成便利程度看选型GitHub Actions的集成是否顺手重点看三件事能否直接复用GITHUB_TOKEN还是要单独配置Secretsaction是否支持缓存关系到扫描速度和数据库下载频率输出格式是否兼容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甚至上百MBCI耗时立刻翻倍。我用actions/cache把trivy缓存目录存下来key按trivy版本和漏洞库更新时间做组合这样日常提交时数据库没更新就直接命中缓存。另一个技巧是构建阶段就用GHA缓存typegha同一批被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/checkoutv4 - uses: docker/setup-buildx-actionv3 - uses: docker/build-push-actionv6 with: context: . push: false load: true tags: example/app:${{ github.sha }} cache-from: typegha cache-to: typegha,modemax - uses: aquasecurity/trivy-action0.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-sarifv3 with: sarif_file: trivy-results.sarif - uses: aquasecurity/trivy-action0.24.0 with: image-ref: example/app:${{ github.sha }} format: cyclonedx output: sbom.cdx.json - uses: actions/upload-artifactv4 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.0CI里的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合并前没有新的Criticalmain分支发布前一小时内如果有Critical发布会自动暂停需要人工确认后才能继续每周一对比上周基线增量里的HIGH漏洞必须有人认领。例外机制上我坚持“有记录、有截止日期、有负责人”的三有原则。豁免不是永久豁免到期后扫描rollback时如果漏洞还在工作流直接重新阻断。这个设计比单纯用.trivyignore要可靠因为.trivyignore是永久生效的很容易变成“忘了为什么ignore”的定时炸弹。5.2 修复建议的自动化跟进扫描结果除了“阻断”还应该有“跟进”。我的做法是夜间扫描job如果发现高危漏洞自动通过GitHub API给仓库创建issue标题包含镜像tag和CVE列表assign给当前仓库的主要维护者。这样即使当天没人看PR第二天也能在issue面板里看到“你昨天推的镜像有两个新曝出的高危漏洞”。依赖更新层面Dependabot虽然主要管源码依赖但对Dockerfile里的基础镜像tag也有监控。配合起来效果是某个基础镜像出了新patchDependabot自动开PRPR跑了扫描且没有新增严重漏洞就能直接合并合并后重新触发构建新的镜像自动通过门禁形成闭环。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、签名、定时基线豁免规则是遇到第一个真正无法修复的漏洞时才开始建流程而不是提前把所有过滤条件都写出来。一步一步来这套自动化才能真正稳定运行下去。