在 CI 中扫描容器镜像漏洞并与构建流程集成
起因
之前我们的 CI 基本就是 build、push、完事。镜像能不能跑起来,靠测试;镜像里有没有已知漏洞,基本靠运气。直到有一次,一个已经上线挺久的服务被扫出来基础镜像里带了一堆高危包,我才认真去看 CI 里到底该怎么把镜像扫描这件事接进去。
一开始想得很简单:加一步扫描不就行了?真做起来发现细节还挺多,比如扫哪个镜像、什么时候扫、扫出来要不要卡构建、误报怎么处理、数据库拉不下来怎么办。下面是我自己踩过一圈之后,觉得比较能落地的一套做法。
先选一个扫描器,别在选型上耗太久
我用得比较多的是 Trivy,原因很朴素:装起来省事,CI 里一条命令就能跑,支持输出 SARIF,能直接传到 GitHub 的代码扫描里。Grype 也不错,但如果你只是想在流水线里加一道门禁,Trivy 已经够用了。
本地想先看看效果,可以这样:
docker build -t my-app:test .
trivy image --severity HIGH,CRITICAL --ignore-unfixed my-app:test
先别急着加 --exit-code,不然第一次跑很可能直接红一片,心态容易崩。先看清楚到底扫出来什么,再决定阈值。
最小可用流程:构建后扫描,再决定推不推
最直接的接入点是在镜像构建完成之后、推送到 registry 之前。以 GitHub Actions 为例,大概是这样:
name: build-and-scan
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t my-app:${{ github.sha }} .
- name: Scan image
uses: aquasecurity/trivy-action@0.28.0
with:
image-ref: my-app:${{ github.sha }}
format: sarif
output: trivy-results.sarif
severity: HIGH,CRITICAL
ignore-unfixed: true
exit-code: '1'
- name: Upload SARIF
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarif
这里有几个点是我后来才意识到的:
exit-code: '1' 会让有高危漏洞时构建失败,但这只适合在团队已经接受“先卡住”的前提下加。如果一上来就卡,大家很容易直接绕过扫描步骤,最后等于没接。
ignore-unfixed: true 是我比较建议先开的。很多漏洞在当前基础镜像里根本没有修复版本,卡着也没用,先关注“有修复方案但没升级”的那部分更实际。
if: always() 上传 SARIF 很重要。扫描失败时你也想看到结果,不然只看到一条红叉,点进去啥也没有。
阈值和例外要有人管,不然很快就变成摆设
扫描接进去之后,最怕的不是扫出漏洞,而是所有人都学会了一句“这个先忽略一下”。所以例外得有,但不能随手加。
我现在的做法是:
第一,把 CRITICAL 和 HIGH 分开看。CRITICAL 直接卡,HIGH 先只报告,给一个修复窗口。等团队习惯了,再把 HIGH 也卡上。
第二,--ignore-unfixed 先开着。不是因为不重要,而是因为 CI 门禁要解决“能修但没修”的问题,暂时没补丁的漏洞更适合放进定期盘点。
第三,如果确实要忽略,用 .trivyignore 并写清楚原因和复查时间。别只写一个 CVE 编号,过两个月没人知道为什么忽略它。
# .trivyignore
# 2024-12-31 复查:运行时未使用该组件,等基础镜像升级后移除
CVE-2024-XXXXX
这种注释看起来有点啰嗦,但真出问题时能省很多解释成本。
别只扫最终镜像,基础镜像和 Dockerfile 也得管
镜像漏洞大部分时候不是你的代码写出来的,是基础镜像和系统包带进来的。所以除了在 CI 里扫最终镜像,我还会做两件事:
一是尽量用明确版本的基础镜像,别老用 latest。不是迷信固定版本,而是至少知道每次构建变了什么。最好能固定到 digest,升级的时候单独提 PR。
二是定期重建镜像。很多漏洞不是新引入的,是漏洞库更新之后才被扫出来的。你三个月不重建,扫描结果就会越来越难看。可以在 CI 里加一个定时任务,每周重新 build 一次主分支镜像并扫描,不推送也行,至少知道当前基线。
Dockerfile 这边,多阶段构建能少装很多编译期依赖。运行时镜像里只留必要的东西,扫描结果会干净不少。这个不是万能药,但比在 CI 里对着几百个漏洞逐个加忽略要舒服。
GitLab CI 里也差不多,注意缓存和数据库
GitLab CI 里我会把 Trivy 的数据库缓存起来,不然每次跑都重新下载,内网环境很容易卡住:
scan_image:
image:
name: aquasec/trivy:0.56.2
entrypoint: [""]
variables:
TRIVY_CACHE_DIR: .trivycache
cache:
key: trivy-db
paths:
- .trivycache/
script:
- trivy image --cache-dir .trivycache --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 my-app:$CI_COMMIT_SHA
如果公司有内部镜像仓库,最好把 Trivy 的 DB 也做个镜像或者走代理。否则某天外网抽风,整个流水线全红,大家只会觉得扫描步骤很烦。
推不推镜像,顺序很重要
我倾向于先构建本地镜像,扫描通过后再 docker push。这样有问题的镜像不会进 registry。但如果你们需要保留构建产物做排查,也可以先推一个临时 tag,扫描通过后再打正式 tag。
别在扫描失败后还继续推送正式 tag,不然门禁就只是“通知”,不是“门禁”了。这个边界一开始就要说清楚。
最后一点经验:别只盯着漏洞数量
扫描结果里的数字很容易让人焦虑,但真正要看的还是:这个漏洞在不在运行路径上、有没有修复版本、容器是不是以 root 跑、服务有没有暴露到公网。CI 扫描能帮你发现“已知的已知”,但它不负责帮你判断风险。
我现在比较满意的状态是:构建时自动扫,高危有修复方案的直接卡,扫描结果进代码扫描面板,例外有记录,基础镜像定期更新。不是多复杂的系统,但至少不会再出现“镜像里有什么全靠猜”的情况。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。