- Semgrep 1.95、Trivy 0.58、Gitleaks 8.21 与 Cosign 2.4 是 2026 年 CI 流水线的最小工具栈,任何一个缺位都会留下高危盲点。
- CI runner 凭据外泄是 2026 年最常见的供应链入口,使用 OIDC 短期令牌可以让长期 PAT 与服务账号彻底退出舞台。
- SLSA Build Level 3 需要的不只是签名,还要求隔离构建环境、不可篡改的来源声明和经过验证的构建平台。
- Trivy 适合容器与 IaC 全栈扫描,Grype 在纯 SBOM 漏洞匹配上更快;在流水线里两者可以串联以降低误报。
- 策略门禁应当在 PR 阶段以警告形式运行,在合并到主干和发布标签时升级为阻断,避免把流水线变成开发者眼中的噪音生成器。
- 所有签名、SBOM 与扫描报告必须作为构件归档,审计取证时拿不出原始证据等同于没做这些控制。
什么是 DevSecOps,它与 DevOps 有什么本质区别
DevSecOps 是 DevOps 的安全演进:把安全控制从发布前的一次性评审挪到 commit、build、test、deploy 的每一个流水线阶段,并用代码而非工单来驱动这些控制。区别不在于"加了安全工具",而在于谁拥有问题、谁能阻断发布、谁来回答审计。在 DevOps 模式里,安全团队往往是流水线的"外部消费者",拿到的是一份周报;在 DevSecOps 中,安全策略本身就是流水线里的代码,违反策略意味着 Pull Request 直接红灯。
这一点在我做渗透时反复验证:DevOps 化的团队往往把 CI runner、镜像仓库与生产凭据放在一起,但只在准入网关那一层做安全。一旦攻破任何一个开发者的 GitHub PAT,我可以在中间任何一个阶段植入恶意步骤而不被发现。DevSecOps 流水线的核心承诺是,即便单点失陷,签名、SBOM、策略门禁和不可变溯源也能让恶意构件在最后一刻被拒绝。
2026 年还出现了一个新的子集,叫 SecDevOps,它更强调安全团队主导工具链选型,而把开发者作为消费者。在大多数中型团队,我仍然推荐传统的 DevSecOps 路径:让开发者拥有规则,让安全团队拥有底线。详细的供应链证据链可以参考我们关于 Sigstore、SBOM 与 SLSA 溯源 的实战指南。
2026 年 CI/CD 流水线的真实威胁模型
过去一年里,从 PyPI 到 npm 再到 GitHub Actions Marketplace,被注入的恶意组件数量持续创新高。我把当前最常出现的攻击链梳理成八类,几乎所有 2024 至 2026 年的真实事件都能套进其中之一。
| 攻击向量 | 典型手法 | 对应硬化控制 |
| 恶意依赖 | Typosquatting、依赖混淆、被劫持的维护者账号 | SCA(Trivy/Grype)+ 锁文件 + 私有镜像缓存 |
| 密钥泄露 | commit 中的 AWS Key、.env 文件、内部 Webhook | Gitleaks + pre-commit + GitHub secret scanning |
| 恶意 Action | @v1 浮动标签被替换为恶意 tag | Action 钉死到 SHA + OIDC 最小权限 |
| Runner 投毒 | self-hosted runner 跨 job 残留 | 临时 runner + 每次 job 销毁容器 |
| 缓存中毒 | 恶意 PR 写入共享 actions/cache | 缓存按分支隔离 + 校验摘要 |
| 构建脚本注入 | postinstall、make、setup.py 钩子 | seccomp/AppArmor 限制构建容器 |
| 无签名发布 | 镜像与二进制直接 push,无溯源 | Cosign 签名 + 在线策略验证 |
| 合并保护绕过 | 仓库 admin 直接 push 主干 | 分支保护 + 强制 PR + 签名提交 |
把这八类摆在一起,你会发现没有任何单一工具可以覆盖全部,这就是 DevSecOps 强调"多重控制"的原因。下面四个章节,我会按攻击者最常用的顺序拆解对应防御:先 SAST 防止漏洞写入代码,再依赖扫描挡住引入,再密钥扫描阻止凭据外泄,最后用签名与溯源让被植入的构件无法发布。
在 GitHub Actions 中集成 Semgrep 进行 SAST 扫描
SAST(静态应用安全测试)是流水线最左侧的关卡。Semgrep 在 2026 年依然是开源 SAST 里规则丰富度、运行速度与可定制性平衡得最好的选择。Semgrep 1.95 已经对 Rust、Go 1.23 与 Python 3.13 提供了一阶语义分析,误报率比 2024 年的 1.78 显著下降。如果你只能挑一个 SAST,挑它。
下面是一个在 PR 上仅警告、在 main 上阻断的最小配置。它的关键点是用 OIDC 拉取免登录 Token,并把 SARIF 上传到 GitHub Code Scanning 以便长期追踪。
# .github/workflows/semgrep.yml
name: Semgrep SAST
on:
pull_request:
branches: [main]
push:
branches: [main]
permissions:
contents: read
security-events: write # 上传 SARIF
id-token: write # OIDC,无需长期 Token
jobs:
semgrep:
runs-on: ubuntu-24.04
container:
image: returntocorp/semgrep:1.95.0
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.2.2
- name: Semgrep scan
run: |
semgrep ci \
--config p/owasp-top-ten \
--config p/cwe-top-25 \
--config p/secrets \
--sarif --output=semgrep.sarif \
--error
env:
# PR 上仅警告,main 上 --error 触发非零退出
SEMGREP_PR_ID: ${{ github.event.pull_request.number }}
- name: Upload SARIF
if: always()
uses: github/codeql-action/upload-sarif@28deaeda66b76a05916b6923827895f2b14ab387 # v3.27.6
with:
sarif_file: semgrep.sarif
有三个细节常被忽略。第一,所有第三方 Action 都用完整 commit SHA 而不是 @v4,这能挡住 2024 年 tj-actions/changed-files 那种"tag 被改写"的攻击。第二,permissions 块要显式声明,而不是默认 write-all。第三,Semgrep 容器镜像也得钉死版本(我曾因为没钉死,在一次上游更新里多花了半天排查问题)。
规则集层面,我习惯把 p/owasp-top-ten、p/cwe-top-25 与语言特定包(如 p/python、p/golang)叠加,再加一份内部自定义规则文件覆盖业务特有反模式。更详细的规则编写文档见 Semgrep 官方规则编写指南。
如何用 Trivy 与 Grype 扫描容器镜像与依赖
软件成分分析(SCA)和容器扫描经常被合并成一步。简单一句话:Trivy 适合做"全栈"扫描,Grype 在 SBOM 上更快更稳。Trivy 0.58 既能扫文件系统、镜像、Kubernetes 集群,也能扫 Terraform、Helm 和 Dockerfile 的 IaC 配置,广度无可替代。Grype 0.86 配合 Syft 生成的 SPDX SBOM 在大型单体镜像上扫描速度快 30% 左右,而且数据库更新延迟更低。
| 维度 | Trivy 0.58 | Grype 0.86 |
| 扫描目标 | 镜像、文件系统、IaC、Kubernetes、SBOM | 镜像、SBOM |
| 漏洞库来源 | NVD + GitHub Advisory + 厂商通告聚合 | Anchore 维护的归一化漏洞库 |
| 2GB 镜像平均扫描时间 | ≈ 42 秒 | ≈ 28 秒 |
| IaC / 错误配置扫描 | 原生支持 | 不支持 |
| SBOM 格式 | SPDX、CycloneDX | SPDX、CycloneDX(通过 Syft) |
| 误报体感 | 中(可用 ignore 文件控制) | 低 |
我在生产里常用的组合是:Trivy 负责一次扫到位(代码 + IaC + 镜像),Grype 在镜像签名前做一次二次确认。命令如下。
# 步骤 1:用 Syft 生成 SPDX SBOM
syft packages ghcr.io/org/app:${SHA} \
-o spdx-json=app.sbom.spdx.json
# 步骤 2:Trivy 全栈扫,带严重级阈值与忽略文件
trivy image \
--severity HIGH,CRITICAL \
--ignore-unfixed \
--ignorefile .trivyignore \
--exit-code 1 \
--format sarif --output trivy.sarif \
ghcr.io/org/app:${SHA}
# 步骤 3:Grype 基于 SBOM 复检
grype sbom:app.sbom.spdx.json \
--fail-on high \
--output sarif=grype.sarif
--ignore-unfixed 这一项很关键。如果上游还没发布修复,把它当成阻断条件只会让团队习惯性 --no-verify,反而削弱整体安全姿态。把不可修复的 CVE 记录到 SBOM 与风险登记册,而不是流水线红灯。漏洞优先级排序方面,推荐用 EPSS + KEV 替代单纯的 CVSS,这部分我们在 Linux 漏洞管理 2026 实战指南 里讲得更细。
如何在 CI 中用 Gitleaks 阻止密钥泄露
密钥扫描是渗透测试里成本最低的发现路径。我做红队时,第一个动作几乎总是 git log -p 加 trufflehog。GitHub 自身的 secret scanning 覆盖一部分商业 Token,但对自定义内部凭据、Webhook、私有 PEM 文件几乎无能为力。Gitleaks 8.21 提供可扩展的正则与熵规则,可以在 pre-commit、CI 和定期审计三处部署。
2026 年的最佳实践是分层运行:开发者本地 pre-commit 钩子做即时拦截,CI 做全历史增量扫描,夜间任务做全仓库深扫。下面是 CI 部分的配置。
# .github/workflows/gitleaks.yml
name: Gitleaks
on:
pull_request:
push:
branches: [main]
schedule:
- cron: '0 3 * * *' # 每日深扫
permissions:
contents: read
jobs:
scan:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
with:
fetch-depth: 0 # 完整历史
- name: Gitleaks
uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.7
env:
GITLEAKS_VERSION: "8.21.2"
GITLEAKS_CONFIG: .gitleaks.toml
GITLEAKS_ENABLE_UPLOAD_ARTIFACT: "true"
一份能真正用的 .gitleaks.toml 至少要做三件事:补充内部命名约定的正则(例如 INTERNAL_API_KEY_ 前缀)、把测试 fixture 目录加入 allowlist、按 commit SHA 而非内容做 allowlist 以避免规则漂移。
# .gitleaks.toml(节选)
[[rules]]
id = "internal-api-key"
description = "公司内部签名 API Key"
regex = '''INTERNAL_API_KEY_[A-Z0-9]{32}'''
keywords = ["INTERNAL_API_KEY_"]
[allowlist]
description = "测试 fixture 与已撤销密钥"
paths = ['''tests/fixtures/.*''']
commits = [
"ab12cd34ef56...", # 已撤销,见工单 SEC-1042
]
发现泄露后的标准流程是:撤销凭据 → 在密钥管理(Vault/AWS Secrets Manager/GCP Secret Manager)轮换 → 通过 git filter-repo 清理历史 → 公告。不要只把 commit 删除,只要曾经 push 过,默认就视作泄露。我在一家电商客户那里就遇到过,一个被"删除"两年的 AWS Key 仍然能在 GitHub events API 里被翻出来,事后只能批量轮换。
用 Sigstore 与 Cosign 达到 SLSA Build Level 3
SLSA(Supply-chain Levels for Software Artifacts)v1.0 把构建完整性拆成 L1 到 L3 三个等级。L3 要求构建在隔离、可审计、不可被构建脚本伪造的平台上执行,并产出经过签名的来源声明(provenance)。GitHub Actions 加 Sigstore 是目前达到 L3 最便宜的路径,不需要自建 Fulcio/Rekor,直接用公共证书透明日志。
Cosign 2.4 支持 keyless 签名。CI 通过 OIDC 向 Fulcio 申请短期证书(默认 10 分钟过期),签名连同证书一起发布到 Rekor 透明日志。验证者拉取镜像后,把签名上的身份(如 https://github.com/org/repo/.github/workflows/release.yml@refs/tags/v1.0.0)与策略比对,任何一处不一致直接拒绝。
# 流水线节选:keyless 签名 + SBOM 附加
- name: Login to GHCR
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build & push
id: build
uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355 # v6.10.0
with:
push: true
tags: ghcr.io/org/app:${{ github.sha }}
provenance: mode=max # SLSA L3 provenance
sbom: true
- name: Sign image (keyless)
env:
COSIGN_EXPERIMENTAL: "1"
run: |
cosign sign --yes \
ghcr.io/org/app@${{ steps.build.outputs.digest }}
- name: Attest SBOM
run: |
cosign attest --yes \
--predicate app.sbom.spdx.json \
--type spdx \
ghcr.io/org/app@${{ steps.build.outputs.digest }}
消费侧的策略验证用 Cosign 内置的 policy controller,或在 Kubernetes 里使用 Kyverno / Sigstore policy controller。最小策略示例如下,只接受来自固定工作流路径的签名。
# 拉取后验证
cosign verify ghcr.io/org/app@${DIGEST} \
--certificate-identity \
"https://github.com/org/repo/.github/workflows/release.yml@refs/tags/v1.0.0" \
--certificate-oidc-issuer \
"https://token.actions.githubusercontent.com"
SLSA v1.0 规范的完整字段、provenance 序列化与威胁模型见 SLSA v1.0 Levels 官方规范。Sigstore 在 keyless 工作流上的最新最佳实践见 Sigstore Cosign 文档。如果你想了解 OIDC 在 GitHub Actions 内部如何与云厂商对接,也推荐读一遍 GitHub OIDC 安全硬化官方指南。
端到端 GitHub Actions 流水线示例
把前面四个章节的工具串成一条线性流水线,顺序是:checkout → Semgrep → Gitleaks → 构建 → Syft SBOM → Trivy + Grype → Cosign 签名 → 推送。在 main 分支或打 tag 时,任何阶段失败都阻断;PR 上失败仅作为警告并贴到 PR comment。
# .github/workflows/release.yml
name: Build, Scan, Sign, Release
on:
push:
tags: ['v*.*.*']
pull_request:
branches: [main]
permissions:
contents: read
packages: write
id-token: write # OIDC -> Fulcio / cloud
security-events: write
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
with:
fetch-depth: 0
- name: SAST (Semgrep)
uses: semgrep/semgrep-action@1d97a0a0cabe9d96a7ad6a4ef2b9c1e7a8d2f3c1
with:
config: p/owasp-top-ten p/secrets
- name: Secret scan (Gitleaks)
uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7
env:
GITLEAKS_VERSION: "8.21.2"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1
- name: Login GHCR
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build image
id: build
uses: docker/build-push-action@48aba3b46d1b1fec4febb7c5d0c644b249a11355
with:
push: ${{ startsWith(github.ref, 'refs/tags/') }}
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
provenance: mode=max
sbom: true
- name: Generate SBOM
run: |
curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh \
| sh -s -- -b /usr/local/bin v1.18.1
syft ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }} \
-o spdx-json=app.sbom.spdx.json
- name: SCA (Trivy)
uses: aquasecurity/trivy-action@18f2510ee396bbf400402947b394f2dd8c87dbb0 # v0.29.0
with:
image-ref: ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
severity: HIGH,CRITICAL
ignore-unfixed: true
exit-code: '1'
format: sarif
output: trivy.sarif
- name: SCA cross-check (Grype)
uses: anchore/scan-action@v5
with:
sbom: app.sbom.spdx.json
fail-build: true
severity-cutoff: high
- name: Install Cosign
if: startsWith(github.ref, 'refs/tags/')
uses: sigstore/cosign-installer@4959ce089c160fddf62f7b42464195ba1a56d382 # v3.7.0
with:
cosign-release: 'v2.4.1'
- name: Sign & attest
if: startsWith(github.ref, 'refs/tags/')
run: |
DIGEST=${{ steps.build.outputs.digest }}
cosign sign --yes ghcr.io/${{ github.repository }}@${DIGEST}
cosign attest --yes \
--predicate app.sbom.spdx.json \
--type spdx \
ghcr.io/${{ github.repository }}@${DIGEST}
- name: Upload SARIFs
if: always()
uses: github/codeql-action/upload-sarif@28deaeda66b76a05916b6923827895f2b14ab387
with:
sarif_file: trivy.sarif
硬化 Runner、OIDC 与最小权限
最后一道防线常被忽略:CI runner 本身。无论 GitHub-hosted 还是 self-hosted,只要 job 之间存在状态残留、凭据复用或共享缓存,前面所有扫描都可能在最后一步被旁路。我把硬化分成三块:身份、隔离、可观测。
身份:OIDC 替代长期 PAT
2026 年没有任何理由继续在 CI 里塞 AWS_SECRET_ACCESS_KEY 或长寿命 PAT。GitHub、GitLab、CircleCI 都支持 OIDC,每个 job 获得 10 分钟内有效、绑定到具体仓库与分支的 JWT。云端只需在 IAM 信任策略里限定 issuer、subject 与 audience,就能精确到"仅 v* tag 流水线可访问生产 bucket"。说实话,在我做的内部攻击演练里,把所有 PAT 改造成 OIDC 之后,从开发者笔记本横向到生产的攻击路径几乎全部失效。
隔离:临时容器与一次性 runner
self-hosted runner 上一定要用 actions/runner-controller(ARC)在 Kubernetes 中按需起 Pod,每个 job 起一个全新容器,job 结束销毁。结合 seccomp、AppArmor 与只读根文件系统,可以让构建脚本即便被注入也无法逃逸。具体的容器逃逸案例和加固清单可以参阅 Linux 容器安全纵深防御实战。
可观测:审计日志与构件归档
所有扫描报告、SBOM 与签名应当作为 workflow artifact 保留至少 90 天,生产相关的发布产物保留 18 个月。当事后调查时,你需要能回答"v1.4.2 的镜像在何时由谁的 commit、走哪条流水线、用哪个证书签名构建",否则即便完成了 SLSA L3,审计依旧无法通过。对接 SIEM 时,Falco 与 Tetragon 的 eBPF 事件可以补全 runner 内部行为,详见我们关于 运行时威胁检测 的指南。
常见问题
DevSecOps 与传统 DevOps 最大的区别是什么?
区别不在于多了几个安全工具,而在于谁拥有问题与谁能阻断发布。DevSecOps 把安全策略表达为流水线里的代码,违反策略意味着 PR 直接红灯,而不是等待安全团队事后评审。
应该选 Trivy 还是 Grype 做容器扫描?
Trivy 覆盖更广(镜像、文件系统、IaC、Kubernetes),适合做"一站式"扫描;Grype 在大型 SBOM 漏洞匹配上更快、误报更低。生产环境建议两者串联:Trivy 做主扫描,Grype 在签名前复核。
SLSA Build Level 3 与 Level 2 的关键差异在哪里?
L2 要求签名的 provenance,L3 进一步要求构建在隔离、可审计、构建脚本不可篡改溯源的平台上执行。GitHub Actions + Cosign keyless 是目前达到 L3 最低成本的方案,前提是按 SHA 钉死 Action 并启用 provenance: mode=max。
Gitleaks 在 CI 里扫到泄露密钥应该怎么处理?
顺序是:立刻在密钥管理系统中撤销并轮换、用 git filter-repo 清理历史、对受影响系统排查异常访问,最后通告。仅仅删除 commit 不够,只要密钥曾经 push 到远端,就必须按已泄露处理。
如何在 GitHub Actions 中安全使用第三方 Action?
三条铁律:用完整 commit SHA 而非浮动 tag 引用;在仓库级 settings 里只允许选定的发布者;在工作流中显式声明 permissions 最小权限,并改用 OIDC 替代长期 Secrets。