SLSA 构建溯源验证解决的是一类很具体的问题:镜像的签名是对的,签名的人也确实是自家 CI,但构建它的源码不是仓库里那一份。2026 年多起 npm 与容器供应链投毒事件的共同特征是,攻击者没有伪造签名,他们让 CI 用恶意代码正常构建了一次,于是签名链路完全合法。只验签名挡不住这种攻击,必须验构建溯源:这份制品由哪个构建器、从哪个仓库的哪个提交、用什么参数产出。
适用场景
- 制品从 CI 流水线自动构建并推送到镜像仓库,中间环节多、可被人为替换。
- 需要通过等保、客户安全审计,证明”生产运行的镜像确实来自受控源码”。
- 发生过制品被替换或来源不明的情况,需要一条可验证、可追溯的技术约束。
- 多团队共用镜像仓库,需要区分哪些镜像来自受信任的构建通道。
如果构建和部署都在同一台机器上、由同一个人手工完成,溯源的价值有限——没有中间环节,也就没有需要证明的东西。溯源的意义在于跨越不可信环节。
前置条件
- GitHub Actions 或 GitLab CI 等支持 OIDC 身份令牌的构建平台。OIDC 是 provenance 不可伪造的前提。
- 镜像仓库支持 OCI 1.1 制品(GHCR、Harbor 2.9 以上、ECR 均可)。
- 部署侧能执行外部命令(裸机、K8s initContainer 或准入控制器均可)。
- go 1.21 以上环境或直接下载 slsa-verifier 预编译二进制。
原理说明:provenance 证明的是什么
provenance 是一段符合 in-toto 规范的 attestation,用 DSSE 信封包裹,签名密钥由 CI 的 OIDC 令牌在构建时临时签发,构建结束即失效。它声明四件事:
- subject:被描述的制品,以
sha256摘要定位,不是标签。 - builder.id:执行构建的构建器身份,例如 GitHub 托管运行器的固定标识。
- buildType 与外部参数:用什么构建定义、什么提交、什么构建参数。
- materials:构建时实际拉取的依赖,包括基础镜像与源码包的摘要。
SLSA 分级的差别就在”构建器是否可信”。L1 只是把构建过程记录下来,记录本身可以被篡改;L2 由平台托管构建并签名;L3 要求构建环境隔离、签名密钥构建期间不可导出。做门禁至少要 L2,L1 的 provenance 只能当文档看,不能当证据用。
为什么摘要比标签重要
标签是可变的,摘要不可变。攻击者最省力的做法不是伪造签名,而是把旧镜像重新打上新标签——签名依然有效,因为它们本来就是同一份被签过的制品。溯源验证会把 provenance 里的 subject digest 与实际拉取的 digest 做比对,标签重打这条路当场断掉。
操作步骤
第一步:在 CI 里生成 provenance
关键是 permissions 里必须有 id-token: write,否则无法签发 OIDC 令牌,provenance 也就失去了不可伪造性:
name: build-and-attest
on:
push:
tags: ["v*"]
permissions:
contents: read
packages: write
id-token: write # 签发 OIDC 令牌,provenance 的不可伪造性来源
attestations: write # 写入构建溯源证明
env:
IMAGE: ghcr.io/acme/payment-api
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Build and push
id: build
run: |
docker build -t $IMAGE:${{ github.ref_name }} .
docker push $IMAGE:${{ github.ref_name }}
echo "digest=$(docker inspect --format='{{index .RepoDigests 0}}' $IMAGE:${{ github.ref_name }})" >> $GITHUB_OUTPUT
- name: Generate provenance
uses: actions/attest-build-provenance@v1
with:
subject-name: $IMAGE
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
push-to-registry: true 会把 attestation 作为 OCI 制品推到镜像旁边,验证时随镜像一起拉取,不需要额外的分发通道。如果仓库不支持 OCI 制品,改用 upload-artifact 保存,验证时手动指定文件路径。
第二步:在部署侧安装验证器
验证器本身也是供应链的一环,生产环境不要用 latest,固定版本并在升级时走变更流程:
# 安装验证器(固定版本,不要用 latest 做生产门禁)
curl -sSLo slsa-verifier \
https://github.com/slsa-framework/slsa-verifier/releases/download/v2.7.0/slsa-verifier-linux-amd64
chmod +x slsa-verifier
sudo mv slsa-verifier /usr/local/bin/
# 验证镜像的构建溯源
IMAGE=ghcr.io/acme/payment-api
DIGEST=sha256:3f1c9a0e7b2d4c5f6a8b9d0e1f2a3b4c5d6e7f8091a2b3c4d5e6f7a8b9c0d1e2
slsa-verifier verify-image "$IMAGE@$DIGEST" \
--source-uri github.com/acme/payment-api \
--source-tag v1.4.2 \
--builder-id "https://github.com/actions/runner/github-hosted"
三个参数各有作用:--source-uri 锁定源码仓库,防止有人用别的仓库构建同名镜像;--source-tag 锁定源码版本,防止用旧代码构建后冒充新版本;--builder-id 锁定构建器,防止有人在自己机器上用相同流程伪造一份 provenance。
第三步:复现一次篡改,确认门禁真的会拦
只测通过路径等于没测。用旧镜像重打标签做一次反例验证:
# 反例:把旧镜像重新打标签,冒充新版本
docker pull ghcr.io/acme/payment-api@sha256:aaaa1111... # 三个月前的旧镜像
docker tag ghcr.io/acme/payment-api@sha256:aaaa1111... ghcr.io/acme/payment-api:v1.4.2
docker push ghcr.io/acme/payment-api:v1.4.2
# 溯源验证会失败:provenance 里的 subject digest 与新标签指向的 digest 不一致
slsa-verifier verify-image "ghcr.io/acme/payment-api@sha256:aaaa1111..." \
--source-uri github.com/acme/payment-api --source-tag v1.4.2
# 输出:FAILED: expected tag v1.4.2 to resolve to digest sha256:aaaa1111...
这个反例应该稳定失败。如果它通过了,说明验证命令漏了 --source-tag,或者镜像仓库允许覆盖已有标签。
第四步:把验证写进部署门禁
验证通过才允许部署,失败直接退出并返回非零码,让上层流水线中止:
#!/usr/bin/env bash
# deploy-gate.sh —— 部署前必须通过的溯源门禁
set -euo pipefail
IMAGE="${1:?用法: deploy-gate.sh ghcr.io/acme/payment-api@sha256:...}"
REPO="github.com/acme/payment-api"
TAG="${2:-}"
verify_args=(--source-uri "$REPO")
if [ -n "$TAG" ]; then
verify_args+=(--source-tag "$TAG")
fi
echo "[1/3] 校验构建溯源"
if ! slsa-verifier verify-image "$IMAGE" "${verify_args[@]}"; then
echo "BLOCKED: 构建溯源校验失败,禁止部署" >&2
exit 10
fi
echo "[2/3] 校验制品签名"
if ! cosign verify "$IMAGE" \
--certificate-identity-regexp "https://github.com/acme/payment-api/.*" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com > /dev/null; then
echo "BLOCKED: 签名主体不匹配" >&2
exit 11
fi
echo "[3/3] 溯源信息落库"
slsa-verifier verify-image "$IMAGE" "${verify_args[@]}" --print-provenance \
| jq -c '{image: "'"$IMAGE"'", builder: .predicate.builder.id, built: .predicate.metadata.buildFinishedOn}' \
>> /var/log/deploy-provenance.jsonl
echo "PASS: 允许部署 $IMAGE"
脚本里三个动作缺一不可:验溯源、验签名主体、把结果写入 JSONL 审计日志。--print-provenance 输出的内容可以直接提取 builder.id 与构建时间,作为事后追溯的依据。
第五步:确认 materials 与实际依赖一致
溯源里最有价值的部分是 materials。当出现”镜像没问题但里面的基础镜像被换过”这类情况,靠 materials 才能查出:
# 直接查看 provenance 内容,确认材料来源
slsa-verifier verify-image "$IMAGE" --source-uri github.com/acme/payment-api \
--print-provenance | jq '.predicate.materials[] | {uri, digest}'
把这份清单与内部依赖白名单做比对,比对比对镜像本身更早发现问题。
第六步:K8s 环境用准入控制器兜底
如果部署走 K8s,把校验下沉到准入层,避免有人绕过部署脚本直接 kubectl apply:
{
"apiVersion": "kyverno.io/v1",
"kind": "ClusterPolicy",
"metadata": { "name": "require-slsa-provenance" },
"spec": {
"validationFailureAction": "Enforce",
"rules": [
{
"name": "check-attestation",
"match": { "any": [{ "resources": { "kinds": ["Pod"] } }] },
"verifyImages": [
{
"imageReferences": ["ghcr.io/acme/*"],
"attestations": [
{
"predicateType": "https://slsa.dev/provenance/v1",
"conditions": [
{ "all": [
{ "key": "{{ builder.id }}", "operator": "Equals",
"value": "https://github.com/actions/runner/github-hosted" }
]}
]
}
]
}
]
}
]
}
}
准入层校验的代价是每次创建 Pod 都要拉取 attestation,建议开启缓存,或者在镜像进入生产仓库前就完成一次校验并打上内部标记。
配置验证
验证要覆盖两端:正向能通过,反向必须拦截。
- 正向:用一个真实的、由 CI 构建的镜像执行门禁脚本,输出
PASS且退出码为 0。 - 反向:用旧镜像重打标签,脚本输出
BLOCKED且退出码为 10。 - 审计:
/var/log/deploy-provenance.jsonl每行都有 image、builder、built 三个字段,字段缺失说明 jq 过滤路径写错了。
# 审计口径:按周统计被门禁拦下的部署
jq -r 'select(.result=="BLOCKED") | [.time, .image, .reason] | @tsv' \
/var/log/deploy-gate.log | awk -F'\t' '{c[$3]++} END {for (k in c) print c[k], k}'
如果被拦下的部署数量长期为零,不要高兴太早,更可能是门禁没接进主流程,而不是没人尝试绕过。门禁的有效性靠”有人被拦”来证明。
常见问题
Q1:已经有 cosign 签名了,为什么还要 provenance?
两者回答的不是同一个问题。cosign 签名回答”这份制品由持有某私钥的主体签过”,provenance 回答”这份制品由哪个构建器、从哪份源码、用什么参数产出”。签名可以给任意制品签,包括被替换过的制品;provenance 绑定的是构建事实。正确做法是两个都验:签名确认主体,溯源确认来源。
Q2:验证报 “no matching attestations”,怎么排查?
按顺序查三处:一是 CI 的 attestations: write 权限是否给了,缺权限时 attestation 根本不会写入;二是 push-to-registry 是否为 true,为 false 时 attestation 只存在 workflow 的运行产物里;三是验证命令里的 digest 是否与构建时输出的 digest 完全一致,多一个字符都会匹配失败。用 crane manifest 或 docker buildx imagetools inspect 独立核对 digest。
Q3:私有仓库的 provenance 验证需要认证吗?
需要。验证器读取 attestation 时走的是仓库 API,私有仓库要先登录。GHCR 用 docker login ghcr.io -u <user> --password-stdin,凭据用只读的 PAT;Harbor 用机器人账号。不建议把写权限的凭据放在部署机上,验证只需要读。
Q4:slsa-verifier 升级会不会导致历史镜像验证失败?
会。--builder-id 在部分版本间有语义调整,旧 provenance 可能无法通过新版本校验。因此生产门禁要固定版本,升级前先用历史镜像跑一轮回归,确认存量制品仍可通过。这也是为什么验证器镜像要一起纳入制品库管理。
总结
SLSA 构建溯源验证的价值,在于把”这份镜像可信吗”从一句判断变成一个可执行的检查。落地路径只有四步:CI 带 OIDC 权限生成 provenance、部署侧固定版本验证器、门禁脚本验溯源验签名、结果落库可审计。最容易做错的两点,一是只验签名不验溯源,二是只测通过路径不测拦截路径。门禁没有拦截记录,等于没有门禁。