供应链验证策略、例外治理与环境晋级手册
SolarWinds Orion 被植入恶意代码后,受影响更新沿正常发布渠道抵达客户环境。CISA 的紧急指令 21-01要求机构按产品版本和部署状态采取隔离、取证与移除动作。这个事件暴露的晋级难题不是“生产是否拉到了一个叫 Orion 的包”,而是开发、签发、分发和消费各阶段能否围绕同一个不可变制品身份给出可追溯判断,以及信任条件变化后能否快速定位已经放行的副本。
XZ Utils 后门事件中,恶意逻辑进入发布 tarball,而公开源码仓库中的观察与最终发行物并不天然等价。Red Hat 的安全公告要求受影响测试发行线停止使用并降级。若预发验证的是一个摘要,生产阶段重新打包、换基础镜像或只沿用同名版本 Tag,前一环境的证据结论无法继承;真正可晋级的是同一 digest 及其政策裁决,不是版本字符串和人工印象。
晋级对象是同一摘要与一次可复算裁决
开发、预发、生产可以有不同政策强度,却必须消费同一个制品摘要。流水线首次推送 registry.example.com/payments/api@sha256:... 后,晋级记录只引用这个 digest;环境特定配置通过 ConfigMap、Secret、参数或部署清单注入,不重新生成镜像。重新压缩、重新签名包内容、换基础层、注入配置文件都会产生新摘要,必须作为新 subject重新走证据生产和验证。
一次晋级决策至少由六个不可变输入组成:subject digest、evidence set digest、policy bundle digest、trust-root snapshot digest、exception set digest、verifier version。输出包含环境、allow/deny、原因码、命中的 producer identities、required predicates、阈值计数和 trace ID。这样才能解释“昨天允许、今天拒绝”究竟是证据被撤销、根更新、政策升级、例外到期,还是验证器行为变化。
Tag仍可服务于人类发现,例如 candidate 或 release,但晋级动作先解析并锁定 digest,之后不再重新解析 Tag。Registry 跨项目复制后,目标 repository 中的 manifest digest应保持一致,相关 referrers、bundle 和根材料也要完成闭包复制。只复制镜像本体再沿用源仓库的 allow 记录,会让生产在证据缺失时消费一张历史通行证。
最小环境用 OPA 与一个验证适配器起步
先在开发机固定 OPA 或 Conftest 版本,并准备一个能输出结构化报告的密码学验证适配器。适配器负责 Cosign/Notation、证书链、透明日志、subject 和 predicate schema;OPA 只评估已验证字段,避免在 Rego 中重写密码学协议。策略 bundle、验证器容器和测试 fixture都固定 digest,构建节点只获得读取 Registry、读取根和签发当前制品所需的最小权限。
docker run --rm -p 8181:8181 \
-v "$PWD/policy:/policy:ro" \
openpolicyagent/opa@sha256:OPA_IMAGE_DIGEST \
run --server --bundle /policy
# 适配器按 digest 拉取证据并输出 verified-input.json
evidence-verify \
--subject 'registry.example.com/payments/api@sha256:SUBJECT_DIGEST' \
--trusted-root roots/production.json \
--output verified-input.json
curl --fail-with-body -sS \
-H 'content-type: application/json' \
--data-binary @verified-input.json \
http://127.0.0.1:8181/v1/data/supplychain/promotion/decision \
| tee decision.json
jq -e '.result.allow == true' decision.json生产启用前加上 TLS/mTLS、认证授权、资源限制、健康检查、审计输出和多副本;OPA bundle 下载失败时保留上一版已验证 bundle并告警,不能加载一半文件。Cosign 3.x 与 Sigstore bundle 的安装、身份参数和根格式从官方验证文档核对;Ratify 场景由 Store/Verifier 形成报告,再由唯一裁决层执行政策。
政策对象要把默认行为写出来
政策不能只是散落的 Rego 条件。下面的结构把环境、身份、声明、例外和故障模式放进同一版契约;实施可转换成 JSON Schema + Rego data、Cedar 或内部 DSL,但加载器必须拒绝未知字段和缺失必填项。
apiVersion: trust.example.io/v1alpha1
kind: PromotionPolicy
metadata:
name: payments-production
version: "git:4c1f..."
spec:
environment: production
subject:
requireDigest: true
allowedRepositories: ["registry.example.com/payments/api"]
identities:
threshold: 1
allow:
- issuer: "https://token.actions.example"
subject: "repo:payments/api:environment:release"
predicates: ["https://slsa.dev/provenance/v1"]
predicates:
required:
- type: "https://slsa.dev/provenance/v1"
threshold: 1
- type: "https://cyclonedx.org/bom"
threshold: 1
- type: "https://openvex.dev/ns/v0.2.0"
threshold: 0
exceptions:
vex:
requireOwner: true
requireExpiry: true
denyOnAmbiguousProduct: true
failures:
missingEvidence: deny
invalidEvidence: deny
verifierUnavailable: deny
ledger:
requireDecisionRecord: true
requireEvidenceSnapshot: trueidentities.threshold统计的是满足 issuer、subject、repository scope 和 predicate授权的不同身份,不能让同一 key重复签名凑数。每类 predicate有独立 threshold;两份 Provenance不能替代一份 SBOM。threshold: 0表示 VEX可选,不表示出现坏 VEX 可以忽略:存在时仍要验签、匹配产品身份并执行冲突政策。
失败字段必须分开。missingEvidence是应有材料不存在;invalidEvidence包括坏签名、错摘要、错身份、schema失败;verifierUnavailable是 Registry、KMS、根源或服务故障。开发环境可以把部分分支设置为 audit,生产若选择 deny则要配套 HA 与 break-glass。默认值不应藏在代码里,政策版本升级时必须在 diff中可见。
Identity allowlist 表达授权而非身份存在
Keyless 证书证明某 OIDC issuer把某 identity绑定到公钥,不证明该 identity可以发布所有仓库。allowlist应组合 issuer、精确 subject、repository、workflow/builder、source ref或 environment、允许生成的 predicate。个人邮箱、组织前缀和 .*正则都过宽;可重命名仓库或可由普通贡献者修改的 workflow也不应直接成为生产授权边界。
KMS/PKI 路径同样需要授权映射。公钥指纹只说明用了哪把 key,还要说明 key owner、用途、仓库 scope、轮换状态和可签 predicate。根 CA 有效不能让其下所有证书自动获得生产权。一个身份若同时能修改 policy allowlist、签发制品并批准例外,会绕过职责分离,应拆成 producer、policy reviewer、exception approver和 break-glass operator。
identity变更按迁移处理:先把新旧身份同时加入预发布政策,对固定样本双验;新 producer开始签发后比较结果;再停止旧身份签发,保留历史验证所需材料;最后从未来生产 allowlist移除旧身份。直接替换 identity会让在途制品突然拒绝,永久双信任又扩大攻击面。
Predicate 阈值防止空集与单点声明
每个 required predicate都要定义 TypeURI、最低份数、允许 producer、schema版本和字段约束。SLSA Provenance至少检查 subject、builder ID、build type、source/material digest和外部参数;SBOM检查 subject绑定、组件身份和格式有效;测试声明检查测试套件身份、目标摘要和结果。只检查 artifact type 或“存在 attestation”会接受未知 predicate。
阈值可以表达双人或双系统背书,例如高风险固件要求构建平台 Provenance + 独立复现服务声明,或发布审批要求两个不同 key owner。去重键应是授权主体或独立信任域,而不是签名条目 digest。两个证书若来自同一被攻破的 workflow,数量为二,独立性仍为一。
Ratify 等框架中还要防止零报告空集通过。政策应先断言“要求类型的成功报告数量达到 threshold”,再断言这些报告全部有效。unsupported type、verifier未安装和 Registry返回空集合都应产生明确拒绝原因。多架构镜像须说明阈值作用于 index,还是每个平台 manifest;签了 index不能自动证明每个子 manifest拥有独立 SBOM。
VEX 例外必须有 owner、expiry 与产品精确匹配
VEX不是通用漏洞豁免单。not_affected只有在发布者受信、产品身份精确命中当前 subject/SBOM组件、justification或impact完整、状态未被更新声明推翻时才可抑制告警。purl、CPE、局部 bom-ref 和 digest承担不同身份职责,不能用包名字符串前缀进行自动匹配。
组织自己的例外记录应在引用原 VEX statement digest之外,再保存 owner、approver、reason、环境、subject digest、vulnerability ID、created-by-policy、expiry、复测触发器和撤销状态。expiry到达后自动失效并转为拒绝或人工复审;owner离职、组件升级、运行路径变化、威胁情报更新也应提前触发复审。永久例外与通配 repository都应被 schema拒绝。
exceptionId: vex-EXAMPLE-001
subject: "registry.example.com/payments/api@sha256:SUBJECT_DIGEST"
vulnerability: "CVE-EXAMPLE"
statementDigest: "sha256:VEX_STATEMENT_DIGEST"
owner: "team:payments-security"
approver: "role:product-security"
reason: "vulnerable code not in execute path; evidence attached"
expiresAt: "T+30d"
retestOn: ["subject-change", "dependency-change", "runtime-path-change"]语义化的 T+30d用于示例表达相对期限,实际对象写机器可解析时间并由日期隐私与审计规则管理。消费端保存完整历史,遇到旧 not_affected与新 affected冲突时按受信发布者、产品身份和声明演进规则拒绝静默选择。转换格式若丢失 justification、产品树或版本范围,loss report必须阻止自动抑制。
第一组实验:同一摘要晋级与身份阈值
准备 candidate.json,其中包含开发阶段已经验证的 subject digest、两类 evidence digest和 producer报告。预发与生产分别加载自己的政策,但两者都不得改写 subject。正例证明同一摘要在更严格政策下通过;反例分别移动 Tag和减少身份阈值。
# 正向:开发、预发、生产消费完全相同的 digest
SUBJECT='registry.example.com/payments/api@sha256:SUBJECT_DIGEST'
for ENV in development staging production; do
promotion-evaluate \
--environment "$ENV" \
--subject "$SUBJECT" \
--policy "policies/$ENV.bundle" \
--evidence evidence-set.json \
--root roots/current.json \
> "decision-$ENV.json"
jq -e --arg s "$SUBJECT" '.allow == true and .subject == $s' "decision-$ENV.json"
done
# 预期:三个记录 subject完全一致;policyDigest不同且各自达到 identity/predicate阈值。# 反向 A:Tag被移动后得到另一个摘要,旧裁决不可复用
NEW_SUBJECT="$(registry-resolve registry.example.com/payments/api:candidate)"
test "$NEW_SUBJECT" != "$SUBJECT"
! promotion-apply --decision decision-staging.json --subject "$NEW_SUBJECT"
# 反向 B:生产要求两个独立批准身份,只提供一个
jq 'del(.verifiedIdentities[1])' evidence-set.json > evidence-one-identity.json
! promotion-evaluate --environment production --subject "$SUBJECT" \
--policy policies/production-two-party.bundle \
--evidence evidence-one-identity.json --root roots/current.json
# 预期:分别得到 SUBJECT_MISMATCH 与 IDENTITY_THRESHOLD_NOT_MET,不能自动重新签名补齐。如果环境间 subject不同,先查是否重建镜像、复制时改写 manifest、把配置烘焙进镜像或错误选择平台 manifest。阈值反例若通过,检查实现是否按签名条目计数、是否未按 identity去重,或 policy实际只要求 any。
第二组实验:required predicates 与 VEX 到期
本组让密码学验证全部成功,再故意触发业务政策失败。这样可以证明“有效签名”与“满足生产准入”是两个阶段,也能验证过期例外不会继续抑制漏洞。
# 正向:Provenance、SBOM达到阈值,VEX例外仍有效且owner存在
promotion-evaluate --environment production \
--subject "$SUBJECT" \
--policy policies/production.bundle \
--evidence evidence-with-valid-vex.json \
--exceptions exceptions/active.json \
--evaluation-time T0 > decision-valid-vex.json
jq -e '
.allow == true and
.predicates["https://slsa.dev/provenance/v1"].count >= 1 and
.predicates["https://cyclonedx.org/bom"].count >= 1 and
.exceptions[0].owner != null
' decision-valid-vex.json# 反向 A:移除 required SBOM predicate
jq 'del(.evidence[] | select(.predicateType=="https://cyclonedx.org/bom"))' \
evidence-with-valid-vex.json > evidence-missing-sbom.json
! promotion-evaluate --environment production --subject "$SUBJECT" \
--policy policies/production.bundle --evidence evidence-missing-sbom.json
# 反向 B:保持原VEX签名有效,但把评估时间推进到expiry之后
! promotion-evaluate --environment production --subject "$SUBJECT" \
--policy policies/production.bundle \
--evidence evidence-with-valid-vex.json \
--exceptions exceptions/active.json --evaluation-time T+31d
# 预期:MISSING_REQUIRED_PREDICATE 与 EXCEPTION_EXPIRED;后者不得修改原VEX或延长expiry自愈。测试夹具要覆盖无 owner、错误 product identity、旧 not_affected与新 affected冲突、under_investigation、格式转换损失和不可信发布者。每个拒绝原因都进入策略回归集,升级 verifier、VEX parser或 policy bundle时重放。
fail-open、fail-closed 与 audit 不是标签
开发环境可先用 audit-only收集缺证据比例、验证延迟、Registry限流和旧制品分布。audit结果不能标记为“已强制”;它应进入整改队列并设置转阻断门槛。生产 fail-closed能阻止验证服务故障时的未知制品,却会把 Registry、DNS、KMS、根分发、OPA/Ratify和 webhook可用性都纳入发布关键路径。
fail-open选择可用性时必须产生高优先级事件、限制允许的资源/环境、记录未经验证 subject,并在服务恢复后自动补验。无日志的旁路不是 fail-open,而是失控。对坏签名、错摘要和明确撤销通常不应 fail-open;只对已分类的基础设施故障评估是否允许短时旁路。
把失败矩阵写进政策测试:missing_evidence、invalid_signature、identity_denied、root_expired、registry_401、registry_429、verifier_timeout、policy_load_error分别如何处理。统一返回 verification failed会让值班人员无法判断该暂停发布、更新根、修复权限还是启动恢复通道。
Break-glass 是受限的另一路径
break-glass凭据和执行点不能依赖同一个故障验证链,否则 verifier不可用时连恢复操作也无法进入。它应由独立管理员身份、独立审批与短期授权保护,只允许指定 subject、指定环境、指定动作和很短的有效窗口。普通租户不得修改豁免 namespace/object标签,恢复身份也不应拥有签名或政策永久修改权限。
一次 break-glass 记录至少包含 incident ID、operator、approver、subject digest、旧决策、故障类别、授权窗口、影响资源、补偿控制与自动到期。Kubernetes Gatekeeper的紧急删除 webhook会移除全部相关准入检查,爆炸半径很大;应优先预演更窄的 selector、恢复 namespace或独立发布入口,同时理解这些机制仍需防止普通工作负载自助套用。
break-glass结束后立即回收 token/角色、恢复政策执行、清理 verifier缓存,并把窗口内所有 subject加入回补账本。不得补写一条过去时间的正常 allow记录来掩盖旁路;原始旁路事件和后续补验结论都要保留。
回补账本把临时旁路变成可关闭债务
账本不是聊天记录或工单链接集合,而是可查询对象。每条记录保存 subject、environment、deployment reference、旁路原因、policy/root/evidence snapshot、创建者、deadline、当前状态与补验结果。状态可采用 pending -> verifying -> passed/failed -> remediated -> closed,任何删除都走审计与保留政策。
服务恢复后,worker按 subject digest去重,从 Registry重新发现当前证据,同时加载旁路时应生效的 policy/root snapshot与当前政策各验一次。前者回答“当时若服务可用会怎样”,后者回答“现在是否仍允许”。任一失败都触发隔离、回滚或重建,不能因工作负载已稳定运行就自动关闭。
{
"ledgerId": "promotion-gap-001",
"subject": "registry.example.com/payments/api@sha256:SUBJECT_DIGEST",
"environment": "production",
"reasonCode": "VERIFIER_TIMEOUT",
"policyDigest": "sha256:POLICY_DIGEST",
"rootDigest": "sha256:ROOT_DIGEST",
"status": "pending",
"requiredActions": ["reverify-original", "verify-current", "invalidate-cache"]
}监控至少统计 pending年龄、失败率、环境分布、旁路原因和未找到证据的 subject。账本堆积达到容量阈值时停止新的 fail-open,而不是让补验无限落后。关闭记录时关联 verifier report、decision digest和处置结果,保留 append-only历史。
流水线、Registry 与发布入口的职责
构建流水线生成一次制品并记录 digest;受保护步骤生成 Provenance、SBOM、签名和必要 VEX;验证 job输出候选 decision。各环境 promotion job只加载该环境 policy并验证同一 digest,不调用构建命令。生产部署入口消费已签或受访问控制的 promotion record,复核 record中的 subject与部署清单一致。
Registry负责保存 subject与证据闭包。晋级前检查目标 repository是否能发现所有 required predicates,复制后比较 descriptor和blob digest。证据被 GC、复制规则遗漏附件或目标 Registry不支持相同发现方式时,生产应得到 MISSING_EVIDENCE,不能回源读取一个权限和保留都不同的开发 Registry后静默放行。
GitOps仓库中提交 digest、promotion record引用和policy version即可,调谐器仍按其既有方式收敛声明状态。供应链政策服务不承担同步、漂移修复、健康评估或自动回退;它只在发布入口提供可审计裁决。这样既避免重复调谐逻辑,也能让同一验证服务被 CLI、流水线、Registry事件和其他部署平台复用。
排障从六个摘要做差异比较
同一制品在两个环境结果不同时,先比较 subject、evidence set、policy bundle、root snapshot、exception set、verifier image六个摘要。subject不同意味着没有发生同一制品晋级;evidence set不同通常是复制、GC、分页或权限;policy不同应查看版本 diff;root不同要检查轮换窗口;exception不同可能是到期或环境 scope;输入都相同而结果不同才指向缓存、时钟、解析器或并发问题。
大量 IDENTITY_DENIED通常来自 OIDC subject格式变化、workflow重命名、issuer错误或 allowlist scope过窄,不能先改成通配。MISSING_REQUIRED_PREDICATE先确认 verifier是否支持 artifact type,再查 Registry发现与producer上传。ROOT_EXPIRED应更新受信根并验证更新链,禁止切换到 embedded key或跳过透明日志。
政策更新后全部拒绝,先在隔离 OPA实例加载上一版 bundle重放固定 fixtures;若上一版恢复,执行策略回滚;若两版都失败,检查 shared root、Registry和verifier。deny message只给用户必要原因,完整证书 identity、私有 repository和VEX细节写入受控审计存储。
权限、容量与高可用决定能否真正阻断
policy author提交变更,reviewer审批,bundle signer签发,deployment operator只选择已批准版本;producer不能修改自己的 allowlist;exception owner不能自批;break-glass operator不能擦除账本。根更新和政策更新分开授权,避免一名管理员同时替换信任根与放宽身份。
容量按发布峰值而不是日均设计。一次裁决可能触发 Referrers分页、多个 blob拉取、证书/日志验证、VEX与SBOM解析和OPA查询。通过 digest缓存可复用密码学结果,但 policy、root、exception或撤销变化必须进入 cache key或触发失效。大 SBOM不应在每次OPA请求中传输全文,适配器可输出已验证、内容寻址的规范化摘要与必要字段。
HA至少覆盖多个 verifier/OPA副本、跨故障域调度、Registry/KMS限流预算、根与bundle缓存、审计队列和账本持久化。健康检查不能只看 HTTP 200,还要用已知正负 canary检查证据发现、验签与政策分支。fail-closed前演练单区丢失、Registry 429、根源离线、OPA bundle损坏和审计存储变慢,确认恢复资源不会被同一门禁锁死。
策略升级、回滚与兼容迁移
每次 policy-as-code变更同时提交 schema、Rego/data、正负 fixtures、预期原因码和迁移说明。CI先做语法与schema检查,再对历史 decision corpus影子评估,统计 allow→deny与deny→allow差异。deny→allow是高风险变化,必须逐条解释;未知字段、空数组、零报告和过期例外是固定回归样本。
发布采用 immutable bundle digest,先在开发和预发加载,生产以小范围观察模式比较旧新决策,再原子切换。回滚对象是完整 bundle及其兼容的input schema,不是单个 .rego文件。若新 verifier改变报告字段,先做双格式适配或版本化 API;直接升级会让旧政策读到空字段并发生全拒绝或空集放行。
跨验证平台迁移时,先导出 identity映射、required predicates、阈值、VEX例外、根快照和decision corpus;在目标平台实现等价政策,双验同一 digest集合并比较原因码。新平台成为唯一裁决点后,旧平台保留只读历史查询一段治理周期,再撤除写权限、producer授权和缓存。迁移完成的证据是错误摘要、错误身份、缺 predicate、过期例外和过期根在两端均稳定失败。
清理、回退制品与安全退出
候选制品被拒绝时,不要删除唯一证据或覆盖 Tag掩盖失败。冻结 subject与decision,记录原因;若生产已部署,回退到上一份仍满足当前政策的 digest,而不是只选择“上一版 Tag”。上一制品也必须重新按当前根、撤销和例外状态验证,历史 allow记录不保证今天仍可接受。
实验环境清理包括撤除测试 identity、KMS key授权、临时policy bundle、例外、测试repository和break-glass token;保留脱敏的正负 fixture与原因码。生产退出某套政策服务时,先冻结 policy/root版本,导出决策和账本,目标服务双验,消费者切换,旧服务停止签发并转只读,最后撤除旧身份、根和持续费用。
离线或灾备环境还需携带 subject、原始 evidence、bundle、历史根、policy bundle、exception snapshot、verifier镜像及其校验值。恢复演练必须在无旧平台 API的网络条件下完成正反验证。旧根不能无限保留为未来签发授权;历史验证材料与当前 producer授权应分别管理。
机制选择取决于故障代价
低风险开发环境适合 audit-first与较宽的 evidence要求,用真实数据发现身份和格式分布;预发环境适合执行完整 predicate和identity门禁但保留快速修复窗口;生产环境适合 fail-closed、精确allowlist、强阈值和短期例外,前提是 verifier/Registry/根分发具备相称SLO、容量和恢复路径。不是所有环境都应复制同一宽松政策,也不是把生产策略下放到开发就自动提高安全。
OPA适合表达可测试的通用决策,Ratify适合编排 OCI Store与多类 verifier,平台原生 attestation服务适合利用工作流身份,Notation/Cosign负责不同签名与信任生态。它们可以组合,但最终 allow/deny必须只有一个权威裁决点。选择完成的标志是团队能解释每个字段改变的结果、每类故障的模式、每次旁路的偿还路径和每次回滚的完整对象。
SLSA 制品验证要求。in-toto Statement v1。Sigstore Cosign Verify
OpenVEX Specification。Ratify Policies。Gatekeeper Failing Closed
