GitHub Artifact Attestations 生成、验证与离线信任手册
发布工程把 cli-linux-amd64 上传到 Release,验证命令显示存在有效 Attestation,值班人员便批准了推广。几小时后才发现下载站为了加入安装脚本,解压并重新打包了同名文件;网页、版本号和文件名都没变,SHA-256 已经不同。声明没有失效,失效的是“这个下载物就是被声明对象”的假设,任何跳过本地 digest 计算的检查都发现不了这次替换。
另一个团队把签名步骤放进 pull_request,内部分支运行正常,来自 Fork 的贡献却拿不到同等 GITHUB_TOKEN 权限和 OIDC 条件。有人建议改用长期云密钥补齐权限,这会让不受信代码进入签名边界。正确做法是让 PR 只产出测试结果,由受保护分支或受控 reusable workflow 重新构建发布制品并生成 Attestation,同时把仓库、工作流和源码提交纳入消费端约束。
Attestation 证明来源绑定,不替制品做安全担保
GitHub Artifact Attestations 生成的是带密码学签名的声明:某个 Actions 身份对一个或多个 subject 的名称与 digest 作出 predicate。默认构建来源模式使用 in-toto Statement 与 SLSA provenance predicate,SBOM 和自定义 predicate 则表达不同事实。subject 是被验证的文件或 OCI manifest,predicate 是关于它的陈述,Sigstore bundle 承载声明、签名材料与验证所需证据。三者缺一,都不能得出“当前拿到的字节来自指定构建链”的结论。
GitHub 的概念说明明确指出,公开仓库使用 Sigstore Public Good Instance,生成结果还会进入公开透明日志;私有仓库使用 GitHub 的 Sigstore 实例,没有同样的公开透明日志,并只与 GitHub Actions 联邦。两个路径都能形成签名声明,却有不同的审计、离线与迁移依赖,不能把公开实例的透明性承诺套到私有实例。
Attestation 也不等于“无漏洞”“可上线”或“构建参数全部可信”。工作流本身能够控制 predicate 的许多内容;若攻击者已经能修改发布 workflow,它可以在合法运行身份下构造误导性材料描述。消费策略因此先信任经证书和验证时间证明的 Actions 身份,再约束仓库、workflow、source digest 与 predicate type,最后才把 predicate 内的业务字段作为策略输入。漏洞状态、许可证与测试结论仍需各自的证据和门禁。
启用前固定平台、CLI 与权限边界
新接入使用 actions/attest。旧的 actions/attest-build-provenance 在当前主版本中保留为包装入口,但新 workflow 直接采用统一 Action,避免把 build provenance、SBOM 与 custom predicate 拆成不同的生命周期。Artifact Attestations 可用计划、私有仓库权益、Action 主版本和 GitHub CLI 参数都会变化;私有或内部仓库应确认 GitHub Enterprise Cloud 权益,而 actions/attest 当前不支持 GitHub Enterprise Server,不能把云端示例直接搬到 GHES。
先在受控 runner 上安装并固定 GitHub CLI 版本,执行 gh auth status 确认主机与账号,再用测试仓库完成一条发布。生产 workflow 应把第三方 Action 固定到审核过的提交 SHA;下文为了可读性展示 @v4,落库时仍应由依赖更新流程维护精确 SHA。权限只授予生成声明的 job,不要放在 workflow 顶层扩散给测试、打包或执行不可信输入的步骤。
name: release-attested-binary
on:
push:
tags: ['v*']
permissions: {}
jobs:
attest:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
attestations: write
artifact-metadata: write
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Build once
run: ./scripts/build-release.sh
- name: Record subject digest
run: sha256sum dist/example.bin | tee dist/example.bin.sha256
- name: Generate attestation
id: attest
uses: actions/attest@v4
with:
subject-path: dist/example.bin
- name: Preserve bundle with release evidence
run: cp "${{ steps.attest.outputs.bundle-path }}" dist/attestation.jsonid-token: write 允许 job 请求 GitHub OIDC token,再用短期身份申请 Sigstore 证书;它不是普通读权限。attestations: write 用于持久化声明,artifact-metadata: write 用于创建 artifact storage record,contents: read 只满足 checkout。GITHUB_TOKEN、OIDC token 和 Registry 凭据都不应打印、上传为普通 artifact 或传给后续不可信脚本。若组织策略不允许这些权限,workflow 应明确失败,不能自动改用长期私钥。
subject、predicate 与 bundle 构成可验证对象
文件模式由 subject-path 读取最终字节并计算摘要;OCI 模式由 subject-name 和 subject-digest 明确指定对象。镜像名称必须是完整 Registry 路径,不含 tag,身份由 sha256:... 决定。push-to-registry: true 会把 Attestation 推到 OCI Registry,适合需要从 Registry 发现证据的消费者;它要求解析到单个完整限定且带 SHA-256 digest 的 subject。构建工具输出的 digest 应直接传递,不要再用 tag 反查,因为 tag 在两步之间可能移动。
Statement 的 subject[] 允许一个声明包含多个对象,但消费端要逐个匹配名称与摘要。批量文件 glob 若意外包含 .sha256、调试包或临时文件,会扩大声明对象集,也可能触碰 Action 的当前数量或大小限制。发布 job 应先形成显式 manifest,再生成 Attestation,并把 bundle path、attestation ID、workflow run ID、source commit 与 subject digest 写入不含秘密的发布清单。
predicate type 是声明语义的类型标签,不是装饰字段。CLI 默认验证 SLSA provenance;验证 SBOM 或自定义声明时必须传入准确 URI。只验证“有一个签名 bundle”会让错误类型的声明顶替要求。例如合规门禁要求 SBOM,却拿 build provenance 通过,密码学可以完全正确,策略仍然错误。
# OCI 构建步骤产生不可变 digest 后再声明。
- name: Attest image
uses: actions/attest@v4
with:
subject-name: ghcr.io/acme/payments
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
# 不要写成 ghcr.io/acme/payments:stable,也不要从 stable 再解析 digest。在线验证要同时收紧 digest、owner、repo、workflow 与 predicate
gh attestation verify 会先计算本地文件 digest,或读取 OCI 引用的 manifest digest,再获取候选 bundle、验证签名和信任材料,并应用身份参数。--owner 适合组织级探索,发布门禁优先用 --repo OWNER/REPO;同一个 owner 下的试验仓库、Fork 和迁移仓库不应天然获得同等信任。
受控 reusable workflow 负责构建时,证书身份对应 reusable workflow,而不是只看调用方文件。此时加入 --signer-workflow 或 --signer-repo,高保证链路进一步固定 --signer-digest。--source-digest 把声明关联到准确 source commit,--source-ref 可限制分支或 tag,--predicate-type 防止语义替换。若必须使用 --cert-identity 或正则,应先从成功样本中读取完整 SAN,再以精确、可审查的模式配置,避免一个宽泛 .* 把所有 Actions 工作流都放进来。
set -euo pipefail
: "${OWNER_REPO:=acme/payments}"
: "${SOURCE_SHA:?set approved source commit}"
sha256sum dist/example.bin
gh attestation verify dist/example.bin \
--repo "$OWNER_REPO" \
--predicate-type 'https://slsa.dev/provenance/v1' \
--signer-workflow 'acme/release-workflows/.github/workflows/build.yml' \
--source-digest "$SOURCE_SHA" \
--format json > verification.json
jq '{count:length, subjects:[.[].verificationResult.statement.subject[]]}' \
verification.json
test "$(jq 'length > 0' verification.json)" = true成功证据至少保存 subject digest、linked repository、signer workflow、source digest、predicate type、verified timestamps、CLI 版本与策略版本;不要只保存一句 Verification succeeded。REST API 按 digest 返回的是候选集合,返回记录不代表已经完成密码学验证。私有仓库查询还会受读取权限过滤,空集合既可能是没有声明,也可能是调用者看不到,排障必须保留 HTTP 状态、认证主体和目标仓库。
正反实验必须暴露重打包、Fork 与身份放宽
第一组实验用同一发布 workflow 生成文件,先按精确仓库、工作流和提交验证,再改动一个字节。正例应得到至少一条匹配的验证结果;反例应在 digest 匹配阶段失败,而不是继续展示同名文件的历史声明。接着将原文件解压重打包,即使内容目录相同,压缩时间戳或文件顺序也常使摘要变化,这正是下载站二次加工不能继承原声明的原因。
set -euo pipefail
cp dist/example.bin /tmp/example.original
# 正向:原始发布字节匹配声明。
gh attestation verify /tmp/example.original \
--repo acme/payments \
--signer-workflow acme/release-workflows/.github/workflows/build.yml \
--predicate-type https://slsa.dev/provenance/v1
# 反向:同名文件只改一个字节,必须非零退出。
cp /tmp/example.original /tmp/example.bin
printf 'x' >> /tmp/example.bin
if gh attestation verify /tmp/example.bin --repo acme/payments; then
echo 'ERROR: modified artifact was accepted' >&2
exit 1
else
echo 'EXPECTED: modified digest has no valid matching attestation'
fi第二组实验专门检查 owner 过宽。让同组织的 acme/supply-chain-lab 或一个受控 Fork 为测试制品生成声明;使用 --owner acme 可能找到组织内候选,而要求 --repo acme/payments 与固定 signer workflow 必须拒绝。Fork PR 不应直接拿生产签名权限;合并后的受保护分支重新 checkout 精确 commit、重新构建并声明,才形成发布链。若团队确实允许多个仓库,显式维护仓库集合和各自 workflow digest,不能把 owner 当永久通配符。
set -euo pipefail
TEST_ARTIFACT=dist/fork-built.bin
# 观察性查询可以展示 owner 下存在候选,不能作为发布门禁。
gh attestation verify "$TEST_ARTIFACT" --owner acme --format json \
> owner-observation.json || true
# 反向:错误仓库或错误 reusable workflow 必须失败。
if gh attestation verify "$TEST_ARTIFACT" \
--repo acme/payments \
--signer-workflow acme/release-workflows/.github/workflows/build.yml; then
echo 'ERROR: foreign repository/workflow was trusted' >&2
exit 1
else
echo 'EXPECTED: repository or signer identity constraint rejected it'
fi离线 bundle 需要配套可信根刷新制度
在线区先用 gh attestation download 按文件 digest 和精确仓库下载 JSONL bundle,再用 gh attestation trusted-root 导出覆盖 GitHub 信任实例的 trusted_root.jsonl。进入隔离区的包还应包含固定版本 CLI 及其校验值、制品、策略参数、bundle 哈希、根快照哈希和交接审批。只搬一个 bundle 而没有验证器与根,不构成可恢复能力。
# 联网准备区
gh attestation download dist/example.bin --repo acme/payments
gh attestation trusted-root > trusted_root.jsonl
sha256sum dist/example.bin sha256-*.jsonl trusted_root.jsonl > transfer.sha256
# 隔离区先核对传输清单,再执行同一身份策略。
sha256sum -c transfer.sha256
gh attestation verify dist/example.bin \
--repo acme/payments \
--bundle sha256-EXAMPLE.jsonl \
--custom-trusted-root trusted_root.jsonl \
--signer-workflow acme/release-workflows/.github/workflows/build.yml \
--predicate-type https://slsa.dev/provenance/v1trusted_root.jsonl 没有一个可让隔离区自动判断“信息已陈旧”的内建到期日。旧声明可能继续验证,但导出之后发生的撤销不会自动进入隔离区;信任材料轮换后,新声明也可能无法由旧根验证。因此每次导入新签名材料时同步刷新根,记录最后撤销同步点和下一次更新窗口,并用“新 bundle + 旧根”负例确认错误能被区分为根过旧,而不是笼统报成制品损坏。
删除 GitHub API 上的 Attestation 不会让已导出的 bundle 自动消失,也不等价于全球撤销。事件响应若要求拒绝某个 signer、source commit 或 digest,在线与离线消费者都要同步 deny 规则、根或身份策略。把“API 查不到”当作所有历史副本失效,会在隔离环境留下未受控信任。
流水线接入要让构建者和消费者各自留证
生产者流水线遵循一次构建、按 digest 发布、随后声明。源码 checkout、依赖解析、编译、打包、上传和 Attestation 之间不要重新生成制品。发布清单记录 Action 版本、runner 类型、source SHA、artifact digest、bundle hash 与 Registry digest;消费者不读取 producer job 的绿色状态,而是从交付物重新计算 digest 并独立验证。
GitHub Release、对象存储和包分发站点若会重压缩、签安装器或加外层包装,应把新对象视为新的 subject,再由受控 workflow 生成独立 Attestation。可以在 predicate 的材料中关联上游 digest,但不能让下游重打包文件冒用上游声明。OCI 推广只传 name@sha256:digest;若集群侧从 Registry 验证,生产端必须启用 push-to-registry,并验证复制工具会连同 referrers/bundle 搬迁。
消费 job 建议分成“获取”“验证”“推广”三个权限域。获取域只读 Registry 或 Release;验证域可读 GitHub Attestations API 和受控根,不持有部署写权限;推广域只接收已经验证的 digest 与结构化 decision。这样验证器即使处理恶意 predicate,也不能直接修改生产,部署器也不能把 tag 重新解析成另一个 manifest。
权限、凭证与敏感数据要按 job 收缩
OIDC token 是短期凭证,却仍可用于向受信服务证明 job 身份;只在 Attestation step 所在 job 开放 id-token: write。生产环境 protection rule、tag protection、CODEOWNERS 与 reusable workflow 权限共同决定谁能触发这个身份。自托管 runner 还要考虑持久化工作区、同机进程和宿主权限,必要时在消费者使用 --deny-self-hosted-runners,或只允许经过审查的 runner group。
Registry push 凭据与 GITHUB_TOKEN 分开治理。前者只允许目标仓库的 digest 与附件写入,后者只允许当前仓库的 Attestation 操作;Fork PR、pull_request_target 和动态 checkout 组合尤其危险,因为高权限上下文可能执行贡献者代码。发布签名 job 不解释来自 PR 的任意 shell、路径、predicate 文件或 reusable workflow ref;需要传参时采用枚举和摘要校验。
bundle 和 provenance 通常可公开,但可能暴露私有仓库路径、工作流名称、source ref、builder 元数据、依赖 URI和内部 Registry 名。日志与工单保留 digest、原因码和脱敏身份即可,不打印 OIDC token、Registry authorization、完整私有 predicate 或 API 响应头。对公开仓库还要意识到透明日志具有持久可见性,生成前先审查声明内容。
排障从对象摘要、可见性和身份约束逐层收敛
no attestations found 先计算本地 SHA-256,再确认声明关联的仓库、predicate type 与调用者读取权限。文件来自压缩包时,检查验证的是下载包、解压二进制还是安装器;它们是三个不同 subject。OCI 场景确认引用已固定 digest、Registry 已认证,并区分从 GitHub API 获取与 --bundle-from-oci 获取。Registry 有镜像不表示 referrer 已复制,GitHub API 有记录也不表示 Registry 路径可发现。
签名或证书失败再检查 CLI 版本、OIDC issuer、公共/私有 Sigstore 路径、可信根快照和系统时钟。身份约束失败则输出 JSON,比对 signer workflow、source repository、source digest 和 certificate identity;不要立刻删掉约束让命令变绿。常见真实原因是 reusable workflow 才是签名者、workflow 文件路径变更、调用方与构建方身份混淆,或迁移后仍沿用旧仓库策略。
查询返回空集时保留 gh auth status、目标 hostname、repo、HTTP 状态与 token scope 的脱敏证据。私有仓库无读取权限与真正无 Attestation 必须给出不同原因码。限制、超时或服务故障应进入可重试类别,digest 不匹配、错误 predicate 与错误 signer 属于确定性拒绝,不应通过重试或降级绕过。
容量、配额与成本围绕发布峰值估算
生成 Attestation 会占用 Actions runner 时间,并产生 API、bundle 与可选 Registry 附件流量。成本模型至少包含发布频率、每次 subject 数量、bundle 大小、Release 与 OCI 双存储、在线验证 QPS、离线归档、审计日志和跨区复制。平台计划权益、Actions 分钟、存储、API 配额与未来计价属于动态信息,应在采购与扩容时读取当前 GitHub Actions 计费说明,不要在架构承诺中写成永久免费。
验证流量会在发布窗口、节点扩容和灾后恢复时形成峰值。消费者使用 digest 缓存成功 decision 时,缓存键必须包含 digest、repo、signer policy、predicate type、root snapshot 和策略版本;只以 tag 或文件名缓存会错绑。失败重试采用总预算和退避,403、digest mismatch、identity mismatch 不重试,429 与短暂 5xx 才进入有限重试。
GitHub 托管服务的 HA 由平台承担,但企业仍要为依赖它的发布路径设计降级:是暂停推广、使用已审批的离线 bundle,还是只允许已验证 digest 的历史部署。不能在 API 故障时把策略改成“存在 checksum 文件即可”。离线根、CLI 与历史 bundle 要定期恢复演练,否则托管端高可用也掩盖不了本地恢复包失效。
升级、迁移与退出必须保留可移植证据
升级 actions/attest 或 GitHub CLI 前,在候选 workflow 对同一测试制品生成声明,比对 subject、predicate type、bundle 格式、storage record、Registry push 和验证 JSON。旧 Action 迁移到统一 actions/attest 时,除了替换名称,还要核对 job 权限、输入互斥关系、输出 bundle path 与消费者约束。CLI 新增或改变默认参数时,显式策略应保持结果不变。
迁移仓库或 reusable workflow 会改变签名身份。先让消费者在有期限的窗口同时接受旧、新两组精确身份,对同一构建策略产生的不同测试制品做正反验证;随后切换生产者,观察失败率,再移除旧 repo/workflow。双信任不是永久状态,结束条件应绑定最后一个旧制品保留期和验证日志,而不是“暂时留着更安全”。
平台退出前按 digest 导出制品、原始 Statement、Sigstore bundle、可信根/TUF root 快照、Attestation ID、source repo/ref/digest、signer workflow、predicate type、策略和 decision log。OCI 证据按 subject digest 与 referrers 一起复制,在目标 Registry 重新执行正确、篡改、错误身份和缺 bundle 四类实验。GitHub 私有 Sigstore 的 bundle 还要求目标验证器能够消费对应私有信任材料,“都使用 Sigstore”不代表自动互信。
清理测试接入时,删除临时 workflow 权限、测试 tag、测试 Release、Registry subject/referrers 和测试 Attestation,并撤销临时 token;正式记录删除前先归档。退出完成后,用旧 repo 签名、旧 workflow 身份、被篡改文件和只剩 tag 的 OCI 引用再次请求推广,预期全部拒绝。只有生产者不再签发、消费者不再信任、历史证据仍按保留策略可验证,这次迁移才真正闭合。
GitHub Artifact Attestations 概念与信任实例。使用 actions/attest 生成构建来源声明
actions/attest 输入、输出与权限。GitHub CLI Attestation 验证参数。GitHub CLI 离线可信根导出
