
最近折腾容器镜像安全扫描,踩了几个坑:Trivy 能卡住构建,却不会自动盯着已经上线的镜像;Amazon Inspector 能持续复扫,但成本和功能边界又跟 Trivy 不一样。这篇把 AWS 上的容器镜像扫描讲清楚,再给出一套能直接落地的 GitHub Actions 方案。
你把容器镜像推到 Amazon ECR(Amazon Elastic Container Registry,亚马逊弹性容器镜像仓库)之后,它真的可以安全运行吗?
在 AWS(Amazon Web Services,亚马逊云服务)上,常见答案有两类:一类是使用 AWS 自带的扫描能力,也就是 ECR basic scanning 或 Amazon Inspector;另一类是在 CI(Continuous Integration,持续集成)流程中运行 Trivy 这样的开源扫描器。本文会从零介绍两者,横向比较差异,并在 GitHub Actions 中搭一套实用的 Trivy 方案。这套方案还顺手解决了一个经常被忽略的问题:那些已经运行中的镜像。
不需要提前掌握 Trivy。
Trivy 是 Aqua Security 推出的开源安全扫描器。它只有一个可执行文件,你只要指定一个目标(target),它就会把问题扫出来。
它支持的扫描目标包括:
trivy image)trivy fs、trivy repo)trivy config)trivy k8s)trivy sbom)它会查找以下几类问题,对应的扫描器称为 scanners:
vuln:操作系统包(apk、apt、rpm 等)和语言包(pip、npm、Go modules、Maven、Cargo 等)里的已知 CVE(Common Vulnerabilities and Exposures,通用漏洞披露)misconfig:不安全配置,例如安全组对 0.0.0.0/0 开放,或者容器以 root 身份运行secret:遗留在文件或镜像层里的 API(应用程序接口)密钥和密码license:依赖项使用的许可证Trivy 会维护一份由公开数据源构建的漏洞数据库,包括 NVD(National Vulnerability Database,美国国家漏洞数据库)、GitHub Advisory Database、各发行版安全公告等。这个数据库每天会更新多次。每次运行时,Trivy 会:
扫描过程中不会把任何内容上传出去,镜像也不会离开本机或 CI Runner。
# macOS
brew install trivy # Scan a public image
trivy image python:3.12-slim
输出结果会按层类型拆成表格:先列操作系统包,再列语言包。每一行都会展示软件包、已安装版本、CVE 编号、严重程度和修复版本。修复版本是最值得看的字段,因为它能直接告诉你升级有没有用。
下面几个参数基本每天都会用到:
# Only what matters, and only what you can actually fix today
trivy image --severity HIGH,CRITICAL --ignore-unfixed python:3.12-slim # Fail the command (exit code 1) if anything is found: this is your CI gate
trivy image --severity CRITICAL --exit-code 1 myapp:latest # Machine-readable output
trivy image --format json --output result.json myapp:latest
trivy image --format sarif --output result.sarif myapp:latest # Generate an SBOM (list of everything inside the image)
trivy image --format cyclonedx --output sbom.json myapp:latest
如果团队明确接受某项风险,可以在执行 Trivy 的目录旁放一个 .trivyignore 文件,把对应 CVE 列进去:
# Not reachable in our usage; revisit 2027-01-01
CVE-2024-12345
扫描私有 ECR 镜像时,直接使用正常的 AWS 凭证即可。Trivy 会用这些凭证向 ECR 完成认证:
aws sso login --profile dev
AWS_PROFILE=dev trivy image 123456789012.dkr.ecr.eu-central-1.amazonaws.com/myapp:1.4.0
这项能力免费,已经集成在 ECR 中。你只需开启 scan on push,或者手动触发扫描,扫描结果就会出现在 ECR 控制台的镜像旁边。它的限制也很直接:
把 ECR 切换到 enhanced scanning 后,Amazon Inspector 会接管扫描,并补上这些能力:
Inspector 还能扫描 EC2 实例,既可以通过 SSM 部署代理,也可以通过 EBS 快照无代理扫描;它还支持扫描 Lambda 函数。
Inspector 会给出一个 Inspector score:它会结合网络可达性等上下文修正 CVSS(Common Vulnerability Scoring System,通用漏洞评分系统)分数。例如,同一个严重 CVE,如果只存在于无法从公网访问的实例上,排序会低于位于公网负载均衡器后侧的同类漏洞。
Inspector 按资源计费。ECR 的费用包括镜像首次扫描的按次收费,以及每次复扫的较低费用;EC2 则按实例和月份收费。具体价格应以所在区域的 Inspector 定价页面为准。还有个很容易漏掉的细节:使用 Auto Scaling 弹性伸缩组时,只要实例存活一段时间就会计费,账单增长速度可能超出预期。
Trivy 是时点扫描,你什么时候运行,它就什么时候扫描。Inspector 则是持续扫描,外部世界发生变化时,它会重新扫描。
这件事比功能列表更重要。假设实际情况是这样的:
myapp:1.4.0,Trivy 扫描通过,于是将它部署上线。myapp:1.4.0。Inspector 会在周三标记这个问题,而放在 CI 中运行的 Trivy 再也不会看它一眼。
所以,如果用 Trivy 替代 Inspector,就必须自己安排定期复扫。好消息是,这只需要几行 YAML,稍后直接给出。
| ECR basic | Inspector(ECR enhanced) | Trivy | |
|---|---|---|---|
| 成本 | 免费 | 按镜像扫描和复扫收费 | 免费(开源) |
| 操作系统包 | ✅ | ✅ | ✅ |
| 语言包 | ❌ | ✅(需要确认支持的生态) | ✅(支持范围更广) |
| 新 CVE 发布后复扫 | ❌ | ✅ 自动执行 | 仅在自行安排任务时执行 |
| 运行位置 | ECR | AWS 托管服务 | 任意位置:本地、CI、集群 |
| 推送前扫描/中断构建 | ❌ | 可以通过 Inspector 的 CI/CD scanning API 实现 | ✅ 原生支持(--exit-code) |
| 结果去向 | ECR 控制台 | Inspector、Security Hub、EventBridge | 由你决定:终端、JSON、SARIF、GitHub |
| IaC、密钥、许可证 | ❌ | 部分支持(Lambda 代码扫描) | ✅ |
| EC2/Lambda | ❌ | ✅ | ❌(不是托管服务) |
| 上下文感知评分 | ❌ | ✅ | ❌(仅使用基础严重程度) |
| AWS 之外可用 | ❌ | ❌ | ✅ |
下面给出两个工作流:一个在镜像发布前执行扫描,另一个每天重新扫描正在运行的镜像。
name: build on: push: branches: [main] permissions: contents: read id-token: write # OIDC to AWS, no access keys jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - name: Build image run: docker build -t myapp:${{ github.sha }} . - name: Scan image uses: aquasecurity/trivy-action@<pinned-sha> # pin to a commit SHA with: image-ref: myapp:${{ github.sha }} severity: CRITICAL,HIGH ignore-unfixed: true exit-code: "1" # fail the job, so the push below never runs - uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: arn:aws:iam::123456789012:role/github-actions-ecr-push aws-region: eu-central-1 - id: ecr uses: aws-actions/amazon-ecr-login@v2 - name: Push run: | IMAGE=${{ steps.ecr.outputs.registry }}/myapp:${{ github.sha }} docker tag myapp:${{ github.sha }} "$IMAGE" docker push "$IMAGE"
只要发现已有修复版本的 HIGH 或 CRITICAL CVE,任务就会失败,镜像也就无法进入 ECR。扫描结果表会直接打印在任务日志里,开发者不用再跳转到别的地方找。
ignore-unfixed 能让这道门禁更合理:如果某个 CVE 暂时没有修复版本,它会卡住每一次构建,却不给团队提供任何可执行的修复方案。
这里故意没有创建 Issue。提交代码的人会立刻看到任务失败,存在漏洞的镜像也不会发布。Issue 应该留给已经运行在生产环境中的问题,这正是下一个工作流要处理的。
卡构建还是只报告? 这个示例采用了最严格的方案:发现漏洞就阻止推送。很多团队并不想这么做。公共基础镜像一旦曝出新 CVE,所有构建可能在同一天集体变红,最后大家只会把漏洞全塞进
.trivyignore,先把镜像发出去再说。更温和的做法是先推送镜像,扫描失败不中断构建,再为发现的问题创建 Issue。实现时,在扫描步骤中加入
continue-on-error: true,并复用下一个工作流中的 Issue 步骤,同时添加if: steps.scan.outcome == 'failure'。还可以取个中间值:只对CRITICAL卡构建,其余级别统一用 Issue 跟踪。具体选哪种,取决于团队的风险偏好和交付节奏。
这部分用来替代 Inspector 的持续扫描。不要重新扫描 ECR 中的全部镜像,因为其中大多数都是没人再运行的旧构建。更省事的做法是让 ECS 告诉我们当前到底运行着哪些镜像,然后只扫这些。
name: scan-running-images on: schedule: - cron: "0 6 * * *" # every day workflow_dispatch: permissions: contents: read id-token: write issues: write # open an issue when a running image is vulnerable jobs: list: runs-on: ubuntu-latest outputs: images: ${{ steps.list.outputs.images }} steps: - uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: arn:aws:iam::123456789012:role/github-actions-ecs-read aws-region: eu-central-1 - id: list name: List images of running ECS tasks run: | for cluster in $(aws ecs list-clusters --query 'clusterArns[]' --output text); do tasks=$(aws ecs list-tasks --cluster "$cluster" --desired-status RUNNING \ --query 'taskArns[]' --output text) [ -n "$tasks" ] || continue # Unquoted on purpose: one argument per task ARN (max 100 per call) # shellcheck disable=SC2086 aws ecs describe-tasks --cluster "$cluster" --tasks $tasks \ --query 'tasks[].containers[].image' --output text done | tr '\t' '\n' | { grep '\.dkr\.ecr\.' || true; } | sort -u > images.txt echo "images=$(jq -Rsc 'split("\n") | map(select(length > 0))' images.txt)" >> "$GITHUB_OUTPUT" cat images.txt scan: needs: list if: needs.list.outputs.images != '[]' runs-on: ubuntu-latest strategy: fail-fast: false matrix: image: ${{ fromJson(needs.list.outputs.images) }} steps: - uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: arn:aws:iam::123456789012:role/github-actions-ecs-read aws-region: eu-central-1 - uses: aws-actions/amazon-ecr-login@v2 - name: Scan id: scan uses: aquasecurity/trivy-action@<pinned-sha> with: image-ref: ${{ matrix.image }} severity: CRITICAL,HIGH ignore-unfixed: true exit-code: "1" - name: Open an issue (once per image) if: failure() && steps.scan.outcome == 'failure' env: GH_TOKEN: ${{ github.token }} IMAGE: ${{ matrix.image }} run: | export title="Vulnerable image running in production: $IMAGE" # search narrows the list; the jq filter keeps exact title matches only open=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ --search "\"$title\" in:title" --json title \ --jq 'map(select(.title == env.title)) | length') if [ "$open" -eq 0 ]; then gh issue create --repo "$GITHUB_REPOSITORY" --title "$title" \ --body "The daily Trivy scan found fixable HIGH/CRITICAL CVEs. Details: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" fi