小Cの已经记不起来的博客

在 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 扫描能帮你发现“已知的已知”,但它不负责帮你判断风险。

我现在比较满意的状态是:构建时自动扫,高危有修复方案的直接卡,扫描结果进代码扫描面板,例外有记录,基础镜像定期更新。不是多复杂的系统,但至少不会再出现“镜像里有什么全靠猜”的情况。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放