这是我们公司20年经验的DevOps提供的拿走就能用的 GitHub Actions 完整方案 包含密钥扫描、恶意软件检测、容器签名、SBOM 生成的完整工作流,直接复制粘贴即可使用。
更重要的是,我会告诉你每个配置背后的工程决策,让你知道为什么要这么写。
很多团队的 CI/CD 都是从一个简单的 npm test && docker build 开始,然后随着需求增加,逐渐堆砌各种检查和扫描,最后变成一团难以维护的配置。
这篇文章提供一个开箱即用的完整方案,涵盖了从代码检查到容器发布的全流程。你可以直接复制这个配置到自己的项目,也可以根据实际需求裁剪。更重要的是,我会解释每个设计决策的原因,让你理解为什么要这样写,而不仅仅是照抄配置。
一、依赖编排:用 needs 构建防御网
门控机制的真正价值
看这段配置:
build-and-scan-containers:
needs: [
secret-scanning,
sca-scan,
sast-scan,
iac-scan,
malware-scan,
frontend-checks,
backend-checks,
]
初看可能觉得这只是个依赖声明,但它实际上构建了一个多重门控系统。只有当所有安全检查通过后,才会触发容器构建这个昂贵的操作。
这个设计解决了三个真实痛点:
快速失败省钱又省时
如果你的代码里有个明显的密钥泄露,传统做法可能是:等待 15 分钟构建完三个容器镜像,然后在安全扫描阶段发现问题。而这里的设计是:在 2 分钟内发现密钥,立即终止,节省了 13 分钟的运行时间和对应的 Runner 费用。
并行执行最大化吞吐
注意第一阶段的七个 Job 是完全并行的,它们没有相互依赖。这意味着密钥扫描、代码质量检查、恶意软件扫描可以同时进行。如果串行执行这些检查,可能需要 20 分钟,而并行执行只需要最慢那个 Job 的时间。
反馈路径最短化
开发者推送代码后,最关心的是"能不能合并"。通过将所有阻断性检查前置到第一阶段,开发者可以在几分钟内得到明确答复,而不是等待整个流水线走完。
传递依赖的隐藏逻辑
再看第三阶段:
sbom-vulnerability-scan:
needs: build-and-scan-containers
这里只声明了一个直接依赖,但由于 build-and-scan-containers 本身依赖所有第一阶段的检查,所以 sbom-vulnerability-scan 实际上间接依赖了整个第一阶段。这种传递依赖避免了重复声明,让配置更简洁。
这背后是对 GitHub Actions DAG(有向无环图)执行模型的深刻理解。你不需要把所有上游依赖都列出来,只需要声明直接依赖,执行引擎会自动计算完整的依赖链。
二、条件执行:PR 和 Main 的差异化对待
为什么 PR 不构建镜像
这个配置值得细品:
build-and-scan-containers:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
这行代码的含义是:只有在 main 分支的 push 事件时,才执行容器构建。PR 阶段会跳过这个 Job。
这背后的权衡思考:
PR 阶段你需要的是快速反馈,而不是真实的可部署产物。安全扫描可以告诉你代码有没有问题,Lint 检查可以告诉你风格是否符合规范,但你不需要真的构建一个镜像并推送到仓库。
构建三个容器镜像(frontend、backend、router)、扫描它们、生成 SBOM、签名、推送,这整个流程可能需要 10-15 分钟。如果每个 PR 的每次提交都执行这些操作,不仅浪费资源,更重要的是拖慢了开发速度。
而 main 分支的 push 意味着代码已经通过审查并合并,这时候你需要的是可发布的产物。所有的构建、扫描、签名步骤都必须完整执行,因为这些镜像可能会被部署到生产环境。
这种设计实现了两条流水线:
PR 路径: 代码检查 → 安全扫描 → 5-10 分钟反馈
Main 路径: 代码检查 → 安全扫描 → 构建 → 发布 → 15-20 分钟完整流程
开发者在 PR 阶段快速迭代,合并后自动获得生产级产物,这是效率和质量的平衡点。
三、并发控制:自动取消过时的运行
一个被低估的功能
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
这段配置只有三行,但能节省大量资源。
真实场景: 开发者推送了 Commit A,CI 开始运行。两分钟后,开发者发现一个拼写错误,修复后推送 Commit B。如果没有并发控制,两次 CI 会同时运行,第一次的运行结果已经没有意义,但仍然会消耗完整的运行时间。
启用 cancel-in-progress 后,Commit B 的推送会立即取消 Commit A 的运行,所有正在执行的 Job 被终止,Runner 资源释放出来给新的运行使用。
group 字段的精妙之处:
group: ${{ github.workflow }}-${{ github.ref }}
这个组合会产生类似 CI - Security Scans and Build-refs/pull/42 或 CI - Security Scans and Build-refs/heads/main 的分组标识。
这意味着:
- PR #42 的多次推送会相互取消(同一个 ref)
- PR #42 和 PR #43 的运行互不影响(不同 ref)
- PR 的运行不会影响 main 分支的运行(不同 ref)
这种细粒度的并发控制,既避免了资源浪费,又不会造成误伤。
四、错误处理:区分真实威胁和环境噪音
ClamAV 扫描的智能容错
恶意软件扫描这个 Job 有段特别的错误处理逻辑:
SCAN_EXIT_CODE=$?
if [ $SCAN_EXIT_CODE -eq 1 ]; then
echo "❌ ClamAV found potential malware or suspicious files!"
exit 1
elif [ $SCAN_EXIT_CODE -eq 2 ]; then
echo "⚠️ ClamAV scan encountered errors (some files may be inaccessible)"
exit 0 # 不让权限问题阻断 CI
else
echo "✅ ClamAV scan completed successfully - no malware detected"
fi
这段代码区分了三种退出码:
0:正常,没有发现恶意软件1:发现恶意软件,CI 应该失败2:扫描过程中遇到错误,通常是权限问题
为什么 exit 2 不让 CI 失败?
GitHub Actions 的 Runner 环境中,某些系统目录(如 /proc、/sys)有访问限制,ClamAV 尝试扫描这些目录时会遇到权限拒绝。这些目录通常不包含用户代码,权限错误并不意味着存在安全风险。
如果遇到 exit 2 就让 CI 失败,会导致大量误报,开发者会逐渐对 CI 失去信任,最终可能选择禁用这个检查。这是工程实践中的经典困境:过于严格的检查会被绕过,失去意义。
这个设计体现了工程务实主义:区分真实威胁和环境噪音,只对前者采取行动。
病毒库更新的超时保护
还有个容易忽视的细节:
timeout 300 sudo freshclam || echo "Freshclam completed or timed out"
ClamAV 的病毒库更新(freshclam)会从远程服务器下载最新的病毒签名。这个过程可能因为网络问题而卡住,如果没有超时保护,整个 CI 可能会永远等待下去。
timeout 300 设置了 5 分钟的上限,如果更新超时,使用现有的病毒库继续扫描。这是另一个权衡:最新的病毒库固然好,但不能因为更新问题导致扫描完全无法进行。
五、镜像标签:可追溯性与易用性的平衡
双标签策略的实际价值
每个容器镜像都会被打上两个标签:
docker build \
-t ghcr.io/${{ steps.repo-name.outputs.repo }}-frontend:${{ env.VERSION }} \
-t ghcr.io/${{ steps.repo-name.outputs.repo }}-frontend:latest .
其中 VERSION 是 Git commit SHA 的前 7 位,比如 a1b2c3d。
为什么需要两个标签?
:a1b2c3d 提供了不可变性。当你在 Kubernetes 部署清单中写 image: frontend:a1b2c3d,你知道这个镜像的内容永远不会改变。这对于问题排查至关重要:如果生产环境出现 bug,你可以确定问题是由这个特定版本的代码引起的,而不是因为镜像被悄悄替换了。
:latest 提供了便利性。在开发环境中,你可能不关心具体版本号,只想要最新的代码。使用 image: frontend:latest 可以让你的开发环境配置更简洁,每次 CI 推送新镜像后,重启容器就能自动使用最新版本。
这两个标签服务于不同的场景:
生产环境 deployment.yaml:
image: ghcr.io/org/frontend:a1b2c3d ← 明确版本,可追溯
开发环境 docker-compose.yml:
image: ghcr.io/org/frontend:latest ← 总是最新,方便迭代
镜像签名的完整覆盖
更值得注意的是,两个标签都会被签名:
cosign sign --yes ghcr.io/.../frontend:${{ env.VERSION }}
cosign sign --yes ghcr.io/.../frontend:latest
这确保了无论你使用哪个标签,都能通过 Cosign 验证镜像的完整性和来源。如果只签名版本标签而不签名 latest,使用 latest 的开发环境就失去了签名验证的保护。
大小写转换的必要性
还有个容易踩坑的细节:
- name: Get repository name
id: repo-name
run: |
REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
echo "repo=$REPO_LOWER" >> $GITHUB_OUTPUT
GitHub 允许仓库名包含大写字母(如 MyOrg/MyRepo),但 OCI 规范要求容器镜像名必须全部小写。如果直接使用 ${{ github.repository }},推送镜像时会遇到错误。
这个步骤将仓库名转换为小写,确保生成的镜像名符合规范。这是那种"不做就会出错,做了就毫无存在感"的细节处理。
六、安全扫描:多层防御的实现
为什么要扫描两次
工作流中有个看似重复的设计:
第一次扫描(构建阶段):
- name: Scan frontend image for vulnerabilities
uses: aquasecurity/trivy-action@0.33.1
第二次扫描(SBOM 阶段):
- name: Scan frontend SBOM for vulnerabilities
run: grype sbom:./frontend-sbom.spdx.json
为什么要用两个工具扫描同一个镜像?
这两次扫描的目标层次不同:
Trivy 直接扫描容器镜像,擅长发现基础镜像和系统包的漏洞。比如你使用的 Alpine 基础镜像中的 OpenSSL 版本有已知漏洞,Trivy 会立即发现。它的优势是速度快,集成度高,能在镜像构建后立即给出反馈。
Grype 扫描 SBOM(软件物料清单),擅长分析应用层依赖的漏洞。SBOM 包含了完整的依赖树,Grype 可以发现深层的传递依赖问题。比如你的 Python 应用依赖 A,A 依赖 B,B 依赖 C,而 C 有漏洞,这种深层问题 Grype 更容易捕获。
两者结合构成了纵深防御:
┌─────────────────────────────────────┐
│ 容器层(OS、系统包) │ ← Trivy 扫描
├─────────────────────────────────────┤
│ 应用层(直接依赖) │ ← 两者都能发现
├─────────────────────────────────────┤
│ 深层依赖(传递依赖的传递依赖) │ ← Grype 更擅长
└─────────────────────────────────────┘
扫描严重级别的差异化设置
不同类型的扫描使用了不同的严重级别阈值:
SCA 和 SAST 扫描:
severity: "CRITICAL,HIGH"
exit-code: "1" # 发现问题就失败
IaC 扫描:
severity: "CRITICAL,HIGH,MEDIUM"
exit-code: "0" # 仅报告,不失败
IaC 扫描包含了 MEDIUM 级别,但不阻断 CI。这是因为 IaC 扫描报告的很多问题是配置建议而非安全漏洞。
比如 Trivy 可能会报告"Kubernetes Deployment 未设置资源限制",这是个最佳实践建议,但不是致命错误。如果让这类问题阻断 CI,会导致大量正常代码无法合并,开发者最终可能选择完全禁用 IaC 扫描。
这个设计体现了分级响应的思想:
- 确定的安全漏洞 → 阻断 CI,强制修复
- 配置最佳实践 → 报告但不阻断,团队自行决策
七、SBOM:供应链安全的关键拼图
为什么需要 SBOM
工作流为每个容器镜像生成了 SBOM:
syft ghcr.io/org/frontend:a1b2c3d -o spdx-json=frontend-sbom.spdx.json
SBOM(Software Bill of Materials)是容器的"成分表",列出了镜像中包含的所有软件包及其版本。
SBOM 的价值在三个方面:
快速响应新漏洞
假设明天爆出 Log4j 2.15.0 有重大漏洞,你需要知道哪些容器使用了这个版本。有了 SBOM,只需要搜索所有 SBOM 文件,就能立即定位受影响的镜像,而不需要重新扫描或手动检查每个容器。
合规审计的证据
某些行业(如金融、医疗)的安全合规要求你能证明软件的来源和成分。SBOM 是这种证明的标准格式,审计时可以直接提供。
供应链透明化
SBOM 让你看到容器镜像的完整依赖关系。你可能以为只安装了 10 个包,但通过 SBOM 发现实际有 50 个传递依赖。这种透明性是安全分析的基础。
Artifact 的生命周期设计
SBOM 文件被上传为 GitHub Actions Artifact:
- name: Upload frontend SBOM artifact
uses: actions/upload-artifact@v4
with:
retention-days: 30
为什么是 30 天?
这是审计需求和存储成本的平衡点。30 天足够覆盖一个完整的发布周期和问题排查窗口,但不会无限累积占用存储空间。
如果保留时间太短(比如 7 天),可能无法满足某些合规要求。如果保留时间太长(比如永久),存储成本会随着每次 CI 运行线性增长。
这个数字不是拍脑袋定的,而是在实际使用中摸索出的合理值。
八、权限管理:最小化原则的实践
每个 Job 只拿需要的权限
看这两个 Job 的权限配置:
secret-scanning:
permissions:
contents: read
pull-requests: write
build-and-scan-containers:
permissions:
contents: read
packages: write
id-token: write
密钥扫描需要 pull-requests: write 是因为它可能要在 PR 中添加评论,提醒开发者发现了密钥。但它不需要推送镜像,所以没有 packages: write。
构建容器的 Job 需要 packages: write 推送镜像到 GHCR,需要 id-token: write 使用 OIDC 进行无密钥签名。但它不需要修改 PR,所以没有 pull-requests: write。
这种细粒度的权限控制有什么意义?
如果某个 Job 的配置被恶意修改(比如通过依赖包投毒),攻击者能做的事情受限于这个 Job 的权限。即使攻击者控制了密钥扫描 Job,也无法推送恶意镜像到你的容器仓库,因为这个 Job 根本没有 packages: write 权限。
这是纵深防御在权限层面的体现:不假设单个环节是安全的,而是通过权限隔离限制潜在的破坏范围。
九、总结输出:可观察性的艺术
即使失败也要生成报告
最后的 ci-summary Job 有个关键配置:
ci-summary:
needs: [所有其他 Job]
if: always()
if: always() 确保这个 Job 无论前面的 Job 成功还是失败都会运行。
如果没有这个配置会怎样?
GitHub Actions 的默认行为是 if: success(),意味着只有所有依赖的 Job 都成功时,才会执行当前 Job。如果密钥扫描失败,其他所有依赖它的 Job 都会被跳过,包括这个总结 Job。
结果就是:开发者只能看到"密钥扫描失败",但看不到其他检查的结果。可能 IaC 扫描发现了 3 个配置问题,SAST 扫描发现了 2 个潜在漏洞,但这些信息都丢失了。
加上 if: always() 后,即使某个检查失败,总结 Job 仍会运行,开发者能看到所有检查的完整结果:
❌ Secret scanning: failure
✅ Frontend checks: success
✅ Backend checks: success
⚠️ IaC scan: 3 warnings found
❌ SAST scan: 2 issues found
这种完整的可见性显著提升了问题排查效率。开发者可以一次性修复所有问题,而不是修复一个、推送、等待 CI、发现另一个问题,陷入反复循环。
最后的思考
这个工作流的精妙之处不在于使用了多少工具,而在于对权衡的理解:
速度和安全的权衡 → PR 阶段快速检查,main 阶段完整验证
严格和实用的权衡 → 区分阻断性错误和配置建议
成本和价值的权衡 → 30 天的 Artifact 保留期
控制和灵活的权衡 → 并发取消机制
每个看似简单的配置背后,都是对真实场景的深入思考。这些细节决定了 CI/CD 是开发者的助手,还是开发者想要绕过的障碍。
好的 CI/CD 应该是透明的:大多数时候你感觉不到它的存在,只有在真正有问题时它才会发出警告。这个工作流很好地实现了这个目标。