软件供应链可观测与事件响应手册
凌晨的生产告警指向一个刚扩容的工作负载:镜像 tag 没变,Pod 里的 manifest digest 却与变更单不同。CI 页面仍是绿色,Registry 也显示签名存在。值班人员删除 tag 并重跑流水线后,原 referrer 一并被垃圾回收,谁触发了构建、哪个 builder 执行、哪份证明曾被准入采用,都只剩零散截图。一次可能的制品替换,因为先清理后取证变成了无法定界的事件。
另一次事故更隐蔽。构建器共享缓存被污染,攻击依赖只在特定参数组合下进入产物;签名身份和证书都合法,透明日志也忠实记录了恶意摘要。团队最初只轮换签名密钥,没有隔离 builder、废弃缓存或从可信材料重建,结果新密钥又给同一污染产物签了一次。供应链事件响应的起点不是“签名是否存在”,而是把一次构建、一个不可变制品及其全部消费事实锁成可调查对象。
一条调查链必须回答七个问题
调查对象不是流水线页面,也不是镜像 tag,而是一组可连接、可复核且保留原始值的事实。build_invocation_id 回答哪次运行;builder_id 回答哪个受信构建边界作出声明;subject_digest 回答具体是哪组字节;attestation 回答构建定义、外部参数、材料和运行细节;transparency entry、inclusion proof 与 checkpoint 回答签名证据何时进入哪一个追加日志视图;OCI referrer 回答 Registry 中哪些证明、SBOM 或签名以该 digest 为 subject;verification result 回答哪版策略、哪份根快照给出了何种裁决;deployment observation 最后回答这个 digest 在哪些环境、节点和工作负载中实际运行。
这七类对象不能靠一个 trace_id 代替。trace 会跨系统丢失,digest 才是制品层稳定主键;invocation ID 与 builder identity 则把相同 digest 的不同生产过程区分开。推荐事件键写成 sha256:<digest>#<invocation_id>,并保留 Registry repository,因为相同 manifest 可被跨仓库挂载,而仓库权限与 referrer 可见性未必相同。
SLSA Build Provenance把 subject、buildDefinition 和 runDetails 分开。builder.id 表示能影响构建并忠实生成 provenance 的平台边界,不只是 runner 标签;externalParameters 是调用方可控输入;resolvedDependencies 是已解析材料,但即使较高 Build Level 也不能想当然地把它视为绝对完整的依赖闭包。事件系统要保存原始 statement,并把常用字段投影为索引,不能只存搜索方便的扁平副本。
最小部署先让证据可关联
一套可工作的最小形态包含四个组件:构建端证据发射器、Registry/referrer 采集器、验证决策记录器、部署观察器。它们把事件写入支持对象存储归档和索引查询的证据仓。小团队可以先使用现有日志平台承载索引,但原始 DSSE envelope、Sigstore bundle、checkpoint 与 OCI manifest 应进入不可变对象存储,避免日志字段截断或重新解析改变证据。
启用顺序从生产端到消费端。先让 CI 为每次 invocation 产生 provenance 并签名,再按 digest 推送 OCI evidence;随后让验证器输出结构化 decision;最后由 admission、GitOps 控制器或运行时清单采集器回写实际部署 digest。观察期只告警不阻断,确认每类事件都有稳定主键、时钟和保留策略后,再把生产策略切到 fail closed。
# collector.yaml:字段名是内部事件契约,不绑定某个日志产品
collectors:
build: {source: ci-events, redact: strict}
registry: {mode: referrers, subjectKey: digest}
transparency: {storeBundle: true, storeCheckpoint: true}
verification: {includePolicyHash: true, includeRootHash: true}
deployment: {resolveTags: true, recordNodeDigest: true}
storage:
raw: {immutability: true, encryption: kms, retentionClass: investigation}
index: {partitionBy: event_day, digestLookup: true}最小权限也按边界拆开:构建发射器只能写自己的 invocation;Registry 采集器只读 manifest 和 referrers;验证器只读证据与公开验证材料、写 decision;部署观察器只读 workload 状态;响应账号另有隔离、denylist 和缓存失效权限。任何采集组件都不应同时拥有签名私钥与删除原始证据的权限。
事件模型与配置字段决定能否复盘
规范化事件至少保留 event_id、observed_at、source_system、subject.repository、subject.digest、invocation.id、builder.id、source.uri、source.revision、predicate_type、evidence.digest、transparency.log_id、transparency.checkpoint_hash、verification.decision、verification.reason_code、policy.digest、trust_root.digest、deployment.environment、deployment.workload_uid 和 correlation.trace_id。原始载荷另以内容摘要寻址,索引事件只保存引用。
observed_at 是采集时间,不可冒充签名时间、Rekor integrated time 或构建开始时间。verification.decision 应使用 allow、deny、error、not_evaluated 等互斥状态;把网络超时写成 deny 会掩盖可用性故障,把“没有报告”写成 allow 则形成空集合放行。原因码要稳定,例如 SUBJECT_MISMATCH、IDENTITY_MISMATCH、MISSING_ATTESTATION、STALE_CHECKPOINT、POLICY_ERROR,人类消息可以变化。
保留配置由调查窗口反推,而不是照抄日志平台默认周期。构建原始事件、决策日志、部署快照、Registry 删除记录和根材料的保留期至少覆盖制品仍可被使用的时段以及组织要求的调查期。透明日志是外部历史证据,不是内部证据仓替代品;Registry referrer 也可能被复制策略、生命周期规则或 GC 删除。关键证据应做跨故障域副本、对象锁和定期恢复抽样。
平台接入把发布决定写回同一个摘要
CI 在产物生成后立即计算 digest,把 invocation、builder、source revision 和 materials 写入 provenance;签名步骤只接收这个 digest,不重新解析 tag。Registry 侧监听 manifest 与 artifact manifest 变更,使用 OCI Distribution 规范的 Referrers API按 subject digest 枚举证据,并保存返回集合的采集时间与 Registry 身份。复制到另一 Registry 后必须重新枚举,不能假设 referrers 自动同行。
验证器读取 digest、证据和 trust root,输出完整裁决对象。部署系统只携带已验证 digest;admission request UID、GitOps revision、workload UID、容器名和运行时 image ID 回写关联仓。对 multi-arch 镜像,要同时记录 index digest 与节点实际拉取的 platform manifest digest,否则只验证 index 时无法回答某个被替换子 manifest 是否已经运行。
一个推荐的裁决日志如下,字段值经过脱敏但语义不可省略:
{
"decision": "deny",
"reason_code": "IDENTITY_MISMATCH",
"subject_digest": "sha256:<ARTIFACT_DIGEST>",
"evidence_digests": ["sha256:<ATTESTATION_DIGEST>"],
"builder_id": "https://build.example.test/builders/release",
"policy_digest": "sha256:<POLICY_DIGEST>",
"trust_root_digest": "sha256:<ROOT_DIGEST>",
"checkpoint_hash": "sha256:<CHECKPOINT_HASH>",
"invocation_id": "inv-<ID>",
"deployment_request_uid": "<UID>",
"trace_id": "<TRACE_ID>"
}第一组正向实验:从制品追到部署
在隔离 Registry 中推送一个测试镜像,生成 bundle 和 provenance,验证时精确约束 issuer、identity,再模拟部署记录。环境变量全部使用测试值:
set -euo pipefail
IMAGE="registry.example.test/lab/app@sha256:${ARTIFACT_DIGEST}"
cosign verify "$IMAGE" \
--certificate-identity "$EXPECTED_IDENTITY" \
--certificate-oidc-issuer "$EXPECTED_ISSUER" \
--bundle ./evidence/signature.bundle.json > ./evidence/verification.json
jq -e '.critical.image["docker-manifest-digest"] != null' \
./evidence/verification.json >/dev/null
printf '%s\t%s\t%s\n' "$INVOCATION_ID" "$ARTIFACT_DIGEST" "$DEPLOYMENT_UID" \
> ./evidence/deployment-observation.tsv预期证据不是退出码零这么简单。查询同一 digest 应返回唯一 invocation、受信 builder、原始 attestation 摘要、透明日志 bundle/checkpoint、Registry referrer、allow decision 及其 policy/root 摘要、目标 workload UID 与节点 manifest digest。任何一环只能靠 tag 连接,都应判为关联缺口。
第一组反向实验:篡改字节不能沿用旧裁决
拉取 manifest 或 blob 后改变一个字节,保留原 bundle 与裁决记录,再执行验证:
set -euo pipefail
cp artifact.bin artifact.tampered.bin
printf 'x' >> artifact.tampered.bin
if cosign verify-blob artifact.tampered.bin \
--bundle ./evidence/artifact.bundle.json \
--certificate-identity "$EXPECTED_IDENTITY" \
--certificate-oidc-issuer "$EXPECTED_ISSUER"; then
echo "unexpected allow: old evidence accepted changed bytes" >&2
exit 1
fi
sha256sum artifact.bin artifact.tampered.bin预期得到 digest 或 signature mismatch,新的摘要在关联仓中没有原 decision,也不能继承旧部署许可。若平台仍因 tag、缓存 key 或“该仓库曾验证通过”而放行,说明裁决缓存没有以 digest、policy digest 和 root digest 共同分区,应立即停用该缓存路径。
第二组正向实验:影响半径查询必须闭合
先选一个已知 invocation,用结构化查询同时展开产物、证据、决策和部署。下面以通用 JSON Lines 导出模拟查询结果,实际平台可映射到 SQL、图查询或 SIEM:
set -euo pipefail
jq -s --arg inv "$INVOCATION_ID" '
map(select(.invocation_id == $inv))
| {digests: map(.subject_digest) | unique,
builders: map(.builder_id) | unique,
evidence: map(.evidence_digest) | unique,
deployments: map(select(.workload_uid != null)
| {environment, workload_uid, subject_digest})}
| select((.digests|length) > 0 and (.builders|length) == 1)
' ./evidence/events.jsonl > ./evidence/blast-radius.json预期输出能从 invocation 正向找到所有 digest,也能从任一 digest 反向找到 invocation 和消费者。查询结果还要列出“证据未知”“部署观察缺失”和“已导出但未确认删除”集合;只返回已连接对象会制造虚假的完整性。
第二组反向实验:删除 referrer 后仍要留下调查证据
测试环境先导出 subject manifest、referrers 响应和 bundle 的摘要,再按 Registry 支持的删除流程移除一项测试证据,最后比较在线发现与归档:
set -euo pipefail
oras discover --format json "$SUBJECT_REF" > before-referrers.json
sha256sum before-referrers.json evidence/*.json > archive.sha256
# 仅在隔离仓执行;TEST_EVIDENCE_REF 必须是专用测试对象
oras manifest delete --force "$TEST_EVIDENCE_REF"
oras discover --format json "$SUBJECT_REF" > after-referrers.json
test -s archive.sha256
cmp before-referrers.json after-referrers.json && {
echo "deletion was not observed" >&2
exit 1
}预期在线 referrer 集合发生变化,归档对象、删除操作者、删除请求 ID、删除前集合摘要和 checkpoint 仍可读。若生命周期任务把原始 bundle、索引与删除审计一起清掉,立即暂停 GC;恢复顺序是先保护 Registry 与对象存储快照,再重建索引,不能先重推同名 tag 覆盖现场。
三类事故要沿不同攻击路径响应
构建器污染首先隔离 builder pool、控制平面凭据、共享缓存和其后启动的所有 invocation。按 builder identity、镜像版本、节点池、缓存 namespace 和可疑时间窗展开 digest,再与独立 builder 的干净重建结果比较。轮换签名 key 只能阻止旧 key 后续使用,不能修复已污染字节;恢复必须从可信 source revision、固定 toolchain、干净依赖镜像和空缓存重建,并产生新的 invocation 与证据。
签名身份泄露要区分 OIDC 账号、workflow 权限、Fulcio 身份映射、长期 key 与 KMS 授权。先禁用被盗身份或 key,冻结对应 signer identity 的新发布,按证书 identity、issuer、key ID、Rekor log ID 和 compromise window 枚举所有 digest。历史制品是否拒绝取决于可信时间与策略,不应粗暴删除整条旧根;对无法证明发生在安全时段的签名加入 digest denylist,并清理验证缓存。
恶意依赖从 resolvedDependencies、SBOM、锁文件、包仓下载日志和构建网络记录交叉定位。材料数组可能不完整,所以还要搜索相同缓存层、基础镜像、依赖代理响应和构建参数。撤销恶意组件版本后,受影响制品仍然是恶意字节,必须隔离并重建;仅更新 VEX、SBOM 或包仓禁用规则不会改变已发布 digest。
爆炸半径计算从确定集合逐步扩张
第一圈是直接证据:可疑 invocation 产生的 digest、被泄露 identity 签过的 digest、引用恶意依赖摘要的 provenance。第二圈是共享基础设施:同 builder 镜像、节点、缓存分区、KMS 授权或依赖代理窗口内的构建。第三圈是消费者:Registry 副本、环境晋级记录、运行中与历史 workload、离线站点、客户下载和派生镜像。每一圈都要标记 confirmed、potential、cleared 及清除依据,不能把“没有查询结果”当成未受影响。
隔离动作优先绑定 digest:暂停晋级、在验证策略加入临时 deny、停止相关 workload、阻断可疑 builder 和 signer。删除 tag 既不能召回已拉取字节,还可能破坏定位;删除在线 attestation 也不等于全球撤销。隔离后保存策略版本、审批人、作用环境和到期条件,待干净重建通过独立验证后再按新 digest 恢复。
排障先分采集失败、关联失败与验证失败
看不到 evidence 时,先用 Registry API 区分 404、401/403、429、不支持 referrers 和确实为空,再检查复制与 GC。看到 evidence 却无法验时,依次比较 subject digest、media/artifact type、证书链、identity/issuer、透明日志 proof/checkpoint、trusted root 有效窗口和 policy version。验过却找不到部署时,核对 deployment 是否保存了 digest、multi-arch 子 manifest、workload UID,以及运行时是否绕过了 admission。
关联量突降通常不是供应链突然变安全,而是 schema、采集权限或 partition 变更。升级前用黄金样本对比字段数量、reason code、原始 payload 摘要和双向查询;任何 parser 迁移都保留旧索引只读窗口。时钟偏移会造成证书、checkpoint 和 metadata 新鲜度误判,应记录各源时间语义并监控偏差,禁止用忽略过期或跳过透明日志验证作为长期修复。
敏感日志与响应权限同样属于攻击面
Provenance 的参数、材料 URI、内部路径和 builder 边界,证书的仓库与 workflow identity,deployment 的租户、节点和 Registry 路径都可能敏感。DSSE payload 只是编码,不是加密;DEBUG 日志、错误回显和 trace baggage 也可能带出 token、Authorization header、预签名 URL 或客户名。采集端采用字段 allowlist,在产生证据前剔除 secret;原始证据按敏感等级加密,查询和导出分别审计。
调查员通常需要读全量证据,却不需要签名或删除权限;平台响应者可以隔离 builder、失效缓存和更新 denylist,但不能单人修改信任根与清除审计。break-glass 账号使用独立认证路径、短时授权和双人审批,操作结束立即回收并补验窗口内所有放行对象。对外共享事件包时重新脱敏并保留内容摘要,不能直接导出生产日志桶。
容量、成本、HA 与升级要围绕调查预算
容量由构建次数、每次 subject 数、referrer 数、验证频率、部署观察频率和保留周期共同决定。原始 SBOM 与 provenance 适合压缩对象存储,digest、identity、invocation、decision 和 deployment 关系适合索引;把大 payload 全塞进搜索集群会同时抬高写入、复制和查询成本。透明日志 checkpoint、验证失败与删除事件体积小但价值高,应采用更长保留和跨区域副本。
HA 不只是多副本 collector。消息队列要幂等,事件 ID 和原始内容摘要用于去重;对象存储启用版本保护,索引可从原始事件重建;验证路径与观察路径分故障域,避免日志平台故障阻断所有发布。为 Registry、KMS、透明日志与策略服务分别设置超时、限流预算和熔断告警,记录 backlog age,使“暂未采到”不会被解释成“证据不存在”。
升级 schema、verifier、Registry 或 evidence backend 时采用双写、影子查询和黄金样本。只有新旧系统对正常、篡改、错误身份、缺证据、撤销和网络错误给出预期一致结果,才切换读取;保留回切窗口并固定 parser、policy 与 root 摘要。退出某个平台前,以 digest 导出原始证据、关系索引、决策日志、根材料、checkpoint、部署快照和验证工具镜像,离线证明仍能复核历史制品后再停止采集。
清理、回滚与干净重建有固定顺序
事件关闭前,先冻结证据与配置快照,再撤销临时访问、轮换确有暴露的凭据、清除验证和 Registry 缓存,随后从可信输入干净重建。重建产物必须拥有新 digest、新 invocation、新 provenance 和新裁决;若摘要恰好相同,也要保留独立重建记录证明构建边界已更换。恢复部署后持续搜索旧 digest,直到 Registry、集群、离线站点和派生制品都完成核销。
回滚的是策略或平台流量,不是撤销事实。新 verifier 出现误拒可回切旧版本,但旧版本必须加载同一 denylist 与最新根材料;不能通过回滚恢复已泄露 signer。清理测试对象时按 deployment、decision、referrer、artifact 的逆依赖顺序执行,并保留实验报告与哈希。生产证据达到保留期后,采用审批删除和删除证明,透明日志中的历史记录则按其追加模型处理。
机制选型看证据闭环而不是产品数量
只需要单仓库、低频发布的小团队,可以用 CI 原生 attestation、Registry referrers、对象存储和现有日志平台组成闭环。跨 Registry、跨构建平台或需要复杂影响图谱时,再引入 GUAC/Grafeas 类证据图与独立验证服务。Kubernetes admission 适合在 API 写入点执行阻断,却覆盖不了节点直跑、既有对象和所有离线消费者;运行时观察与定期重验仍不可少。
选择系统时用四个问题压测:能否按 digest 双向追到 invocation 与 deployment;证据删除或 Registry 故障后能否从独立归档恢复;身份泄露与 builder 污染能否给出不同撤销路径;原平台不可用时能否离线验证。回答不了其中任何一个,更多 dashboard 只会增加展示面,不会缩短事件定界时间。
SLSA Build Provenance定义 subject、构建定义、builder 与运行细节的语义。Sigstore bundle 格式说明签名、证书、透明日志与验证材料怎样被打包。
OCI Distribution Spec 的 Referrers API给出按 subject digest 发现关联制品的协议入口。
Gatekeeper emergency recovery说明准入故障时的紧急恢复动作及其影响面。
