SLSA 构建溯源怎么验?部署门禁拦截实战

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、部署侧固定版本验证器、门禁脚本验溯源验签名、结果落库可审计。最容易做错的两点,一是只验签名不验溯源,二是只测通过路径不测拦截路径。门禁没有拦截记录,等于没有门禁。