容器镜像是云原生应用交付的基本单元,一个镜像从构建到运行,往往要经历开发、测试、生产多个环节。如果镜像本身携带已知漏洞的依赖包、过时的系统组件或者不安全的配置,那么这些风险就会随着镜像的分发被复制到每一个运行实例上。将镜像扫描接入CI流水线,是当前容器安全建设中性价比最高的实践之一:它把安全检查前置到构建阶段,让每一次代码提交都能得到自动化的安全体检,问题在进入镜像仓库之前就被发现和修复。
为什么需要在CI阶段做镜像扫描
传统的安全检测通常安排在上线前,由安全团队对即将发布的应用进行一次性的渗透测试或漏洞评估。这种方式在容器时代显得力不从心,原因在于容器镜像的更新频率极高,一个团队一天可能产出几十甚至上百个镜像版本,人工逐一检查既不现实也不经济。而镜像的构成又非常复杂,一个基于Debian或Alpine的基础镜像,加上应用依赖的npm包、Python包、JAR包,任何一个环节都可能引入已知漏洞。
把扫描动作放到CI流水线中,核心价值在于“左移”。开发者在提交代码后几分钟内就能收到漏洞报告,此时修复成本最低——他刚刚写过这段代码,对依赖关系了如指掌,升级一个有漏洞的依赖包可能只需要几分钟。如果同样的问题留到上线后被安全团队发现,就需要重新走一轮开发、测试、发布的完整流程,修复成本成倍增加。
此外,CI阶段的扫描还有一个隐性好处:它可以作为质量门禁。当镜像中存在高危漏洞时直接让流水线失败,阻止问题镜像推送到仓库,从机制上杜绝“带病上线”。这比事后发通知、催修复要可靠得多。
主流镜像扫描工具选型
目前社区活跃度最高、集成体验较好的扫描工具主要有Trivy和Grype两款,此外还有Clair、Docker Scout等方案。选型时需要综合考虑扫描速度、漏洞库覆盖度、CI集成难度和离线场景支持。
Trivy是Aqua Security开源的扫描器,支持扫描镜像文件系统、IaC配置、密钥泄露等多种目标,漏洞库更新及时,单次扫描通常在几十秒内完成。它提供单二进制发行版,没有外部依赖,在CI环境中安装非常方便,同时内置了--exit-code和--severity参数,天然适合做流水线门禁。
Grype来自Anchore团队,特点是漏洞匹配引擎(Grype的匹配规则基于Syft生成的SBOM),对Java、Go等语言生态的依赖识别能力较强,输出格式支持JSON、表格等多种形式。如果团队已经在使用Syft做软件物料清单,Grype是很自然的选择。
Clair是较早的开源方案,采用API服务架构,适合搭建集中式扫描平台,但部署和维护成本较高,对中小团队不够友好。Docker Scout是Docker官方推出的服务,与Docker Hub深度集成,但对自建仓库的支持有限。综合来看,大多数团队从Trivy入手是最稳妥的选择。
在GitLab CI中配置镜像扫描
GitLab CI是目前国内使用广泛的CI系统,它的流水线定义在.gitlab-ci.yml文件中。接入扫描的基本思路是:在镜像构建任务之后增加一个扫描任务,扫描本地构建产物或已推送的镜像,根据扫描结果决定流水线是否通过。
stages:
- build
- scan
build_image:
stage: build
image: docker:24
services:
- docker:24-dind
script:
- docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
# 先推送到仓库,供扫描任务拉取
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
scan_image:
stage: scan
image:
name: aquasec/trivy:latest
entrypoint: [""]
variables:
TRIVY_USERNAME: "$CI_REGISTRY_USER"
TRIVY_PASSWORD: "$CI_REGISTRY_PASSWORD"
script:
# 只统计高危和严重级别漏洞,存在则返回退出码1
- trivy image --exit-code 1 --severity HIGH,CRITICAL
--ignore-unfixed $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
cache:
paths:
- .cache/trivy
这个配置中有几个细节值得注意。第一,扫描任务使用了官方Trivy镜像并清空了entrypoint,这样才能直接执行自己的命令。第二,--ignore-unfixed参数会过滤掉上游尚未发布修复版本的漏洞,避免流水线被无法解决的问题卡死。第三,通过cache缓存漏洞数据库目录,可以显著减少每次任务下载数据库的时间,Trivy的漏洞库有上百MB,缓存之后扫描任务通常能缩短一到两分钟。
如果希望扫描结果可追溯,可以在script中追加一行trivy image --format json -o trivy-report.json,再通过artifacts把报告文件归档,团队成员可以在流水线页面直接下载查看完整的漏洞清单。
在Jenkins中配置镜像扫描
对于使用Jenkins的团队,推荐通过声明式流水线(Jenkinsfile)集成扫描。Jenkins的优势在于可以用Groovy脚本灵活控制流程,例如根据漏洞数量做差异化处理。
pipeline {
agent any
environment {
IMAGE = 'registry.ippipp.com/myapp'
TAG = "${env.BUILD_NUMBER}"
}
stages {
stage('Build') {
steps {
sh 'docker build -t ${IMAGE}:${TAG} .'
}
}
stage('Scan') {
steps {
// 使用Trivy扫描,输出简要报告
sh 'trivy image --severity HIGH,CRITICAL ${IMAGE}:${TAG}'
}
}
stage('Gate') {
steps {
script {
def exitCode = sh(
script: 'trivy image --exit-code 1 --severity CRITICAL ${IMAGE}:${TAG}',
returnStatus: true
)
if (exitCode != 0) {
unstable('发现严重漏洞,构建标记为不稳定')
// 如需硬阻断可改为 error('安全门禁未通过')
}
}
}
}
}
}
上面的写法演示了一种常见的渐进式策略:先用unstable标记构建状态,让团队观察一段时间漏洞分布,再切换为error强制阻断。直接开启硬门禁容易导致所有流水线瞬间红掉,开发抵触情绪强烈,最终策略难以推行。先警告后阻断的分阶段落地方式在实践中被证明更有效。
另外需要注意Jenkins节点上的Trivy安装方式,推荐在节点初始化脚本中固定安装某个版本,而不是每次任务都拉取latest,这样能保证扫描行为的一致性,也便于排查误报问题。
治理策略:白名单、阈值与报告
扫描接入只是第一步,真正决定这套机制能否长期运行的是治理策略。最常见的问题是误报和无法修复的漏洞:某个基础镜像的组件官方已经停止维护,或者漏洞只影响特定运行场景。Trivy提供了.trivyignore文件来处理这类情况,把确认可接受的CVE编号写入文件并提交到代码仓库,配合注释说明豁免原因和有效期,就能实现白名单的版本化管理。
# .trivyignore 示例 # CVE-2023-XXXX: 仅影响Windows场景,容器为Linux环境,豁免至下季度复审 CVE-2023-XXXX # CVE-2024-YYYY: 基础镜像组件,等待官方补丁 CVE-2024-YYYY
阈值设置也需要分层思考。对于核心业务服务,可以要求CRITICAL级别必须清零;对于内部工具类服务,阈值可以放宽到HIGH。通过为不同项目组维护不同的扫描配置,安全要求的严格程度就能与业务重要性匹配,避免一刀切带来的效率损耗。
最后,扫描结果应该汇聚到统一平台可视化。如果团队规模较大,可以在流水线之外部署Trivy Server模式,各CI节点作为客户端共享漏洞库,减少外网依赖和重复下载;也可以把JSON格式的报告推送到漏洞管理平台,按漏洞维度统计趋势,观察整体安全状况是否随时间改善。定期(例如每周)重新扫描一次镜像仓库中的存量镜像也很重要,因为漏洞库每天都会更新,昨天干净的镜像今天可能就被标记出新漏洞。安全是一个持续的过程,而不是一次性的检查动作。