KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
08 · 信任配置与产物晋级:把 OIDC 配对,把产物只构建一次 — keel 龙骨
这一章回答两个"配完了却更危险"的问题:短期凭证怎么配才真的更安全,以及产物为什么必须只构建一次。
这一章回答两个"配完了却更危险"的问题:短期凭证怎么配才真的更安全,以及产物为什么必须只构建一次。
第 04 章讲了"用短期凭证替代长期 token",那是方向。这一章讲落地——因为 OIDC 的风险不会消失,它会换一个位置:从"谁拿到了那串字符串",挪到"谁的 sub 能匹配上你的策略"。而这条匹配规则,是一条可以被写错的字符串。
第二个问题是它的孪生兄弟:即使凭证配对了,只要你在每个环境各构建一次,你在 staging 验证过的产物和生产上跑的产物就是两个东西。这两件事合起来,决定了"发布出去的到底是什么"。
现场
一支团队按第 04 章的建议,把发布用的长期 token 换成了 GitHub OIDC。周会上报的结论是"凭据风险已消除"。一个月后安全审计报出一条:
任何外部人员在仓库上打开一个 Pull Request,那个 PR 里的 job 都能拿到生产环境的云角色。
换了 OIDC 的这一步没做错,错在换完之后写的信任策略是 repo:my-org/payments-api:*——它看起来"已经锁死到本仓库了"。但 pull_request 事件的 sub 是 repo:my-org/payments-api:pull_request,用基仓库拼的,带着 * 的条件把它一起放行了。
于是攻击路径是:fork → 提 PR → 在 PR 的 job 里请求 OIDC → 换到生产云角色 → 用这个角色读你们的制品库。整个链路不需要任何凭据泄漏。这是"把安全做了一半"特有的失败形态:它比不做更危险,因为它让人停止检查。
一、OIDC 换凭证,云侧到底在比什么
三段,缺一段都不成立:
flowchart TD
A["job 里声明 id-token: write"] --> B["GitHub 签发 JWT<br/>iss 固定;sub 按事件生成"]
B --> C["job 把 JWT 交给云侧 STS"]
C --> D{"云侧验签后<br/>逐条比较 claim"}
D -->|"StringEquals 精确相等"| E["签发临时凭证<br/>有效期 = 这次 job"]
D -->|"StringLike 通配匹配"| F["通配写宽了<br/>可能连 fork PR 一起放行"]
D -->|"条件里只写了 aud"| G["aud 是请求方自己填的<br/>任何仓库都能填同一个值"]
D -->|"不匹配"| H["拒绝换凭证<br/>合法部署也可能被误拒"]
三件事必须分清:签发者是 GitHub,iss 固定;sub 由 GitHub 按"这次 job 是怎么被触发的"自动生成,你改不了它——你能改的只有云侧拿什么去比它;云侧做的事是"验签 + 比字符串",比有两种语义:StringEquals(精确相等)与 StringLike(大小写敏感的 glob,* 任意字符、? 单个字符)。
所以整件事的落点是一条字符串匹配规则。这就是它容易被配错的原因——它长得像配置,实际上是授权面。
二、sub 有四种格式,而且它们互斥
先把事实摆出来(原表见留档 lab/evidence/ci-pipeline-and-gates/05-oidc-claims-and-sub-formats.txt,取自官方文档):
| 触发方式 | sub 的样子 |
注意 |
|---|---|---|
| 分支 push | repo:ORG/REPO:ref:refs/heads/BRANCH |
|
| 标签 | repo:ORG/REPO:ref:refs/tags/TAG |
|
| PR 事件 | repo:ORG/REPO:pull_request |
🔴 用基仓库拼 → fork 提的 PR 与仓库内 PR 在 sub 上完全一样 |
| environment 部署 | repo:ORG/REPO:environment:ENVIRONMENT |
🔴 job 一旦引用 environment,sub 就变成这个格式,上面两种不再适用 |
三条必须记住的推论:
推论一:pull_request 这种 sub 无法区分"自己人"和"外部人"。 因为拼它用的是基仓库。所以任何以 repo:ORG/REPO: 开头又带 * 的策略,都会把 fork PR 放行。
推论二:分支格式与 environment 格式是互斥的。 你照分支格式写了一条精确策略,某天给部署 job 加上 environment: production(正是第 07 章推荐的做法),sub 立刻从 ...:ref:refs/heads/main 变成 ...:environment:production,你的合法部署会全部被拒。反过来说,加 environment 是一个会改变授权语义的动作——这句话第 07 章没讲,因为它属于凭证这一侧。
推论三: 2026 年 7 月 15 日之后创建的仓库,sub 带上了不可变的 owner/repo ID:
repo:OWNER@OWNER-ID/REPO@REPO-ID:ref:refs/heads/BRANCH
例:repo:octo-org@123456/octo-repo@456789:ref:refs/heads/main
这是为了防"组织改名 / 仓库转让后 sub 被继承"这一类问题。代价是:对着旧格式写的精确策略在新仓库上一个也匹配不上。 表现是"部署突然全挂"或"换不到凭证但云侧日志看不出原因"——因为策略语法完全合法,只是永远不相等。
三、实测:五种策略各放行了哪些 token
把上面的事实代进匹配语义,结果就不需要靠回忆了。下面这张矩阵来自本机跑的 cialab/trust-matrix.mjs(它实现了 StringEquals 与 StringLike 两种语义,喂进八种真实格式的 sub;原始输出留档在 lab/evidence/ci-pipeline-and-gates/01-trust-policy-matrix.txt)。
token 场景 P1 P2 P3 P4 P5
----------------------------------------------------------------------
main 分支 push ✅ ✅ ✅ ✅ —
功能分支 push ✅ ✅ ✅ — —
仓库内 PR ✅ ✅ ✅ — —
★ fork PR(外部人提的) ✅ ✅ ✅ — —
同组织另一个仓库 ✅ ✅ — — —
★ 完全无关的仓库 ✅ — — — —
environment 部署 ✅ ✅ ✅ — ✅
新仓库(main) ✅ — — — —
五个策略(列名对应上表)分别是:P1 只校验 aud;P2 sub StringLike: repo:my-org/*;P3 sub StringLike: repo:my-org/payments-api:*;P4 sub StringEquals: repo:my-org/payments-api:ref:refs/heads/main;P5 sub StringEquals: repo:my-org/payments-api:environment:production。
三个从表里读出来的结论:
① P1 放行"完全无关的仓库"。 aud 是请求方自己填的——任何 GitHub 仓库都能在请求 token 时填上 sts.amazonaws.com。所以 aud 不是身份,它是收件人:它只说明"这个 token 是开给谁的",不说明"谁开的"。官方文档对这一点有明确警告(信任策略里至少要有一个真条件)。只查 aud = 把角色开给全世界的 GitHub 仓库。
② P3 看起来"锁死到本仓库",却同时放行 fork PR。 这正是现场那个事故。而且它很有欺骗性——写的人做了"收窄",评审的人看到 my-org/payments-api 也认为收窄了,两边都没意识到 * 覆盖了 :pull_request 这一支。
③ P4 和 P5 都只放行 1 个,但放行的不是同一个。 P4 会把 environment 部署全部拒掉,P5 会把 main 分支的 push 全部拒掉。它们不是"一宽一窄",而是"两把只开自己那扇门的钥匙"——选错了不是不安全,是不可用。
四、把授权面收到 environment 上
有了上面的表,选择就清楚了:唯一能把授权面同时收窄到"哪个仓库"和"经过人审批的那一次部署"的,是 environment 格式的 sub。
原因正好接上第 07 章:environment: production 那次 job 必须经过 environment 的保护规则(必需审批人、等待计时、分支限制)才可能开始。于是:
外部人 fork → 提 PR → sub = repo:org/app:pull_request → ❌ 不匹配
自己 push 到 main → sub = repo:org/app:ref:refs/heads/main → ❌ 不匹配
push tag → sub = repo:org/app:ref:refs/tags/v1.0 → ❌ 不匹配
经审批的那次生产部署 → sub = repo:org/app:environment:production → ✅ 匹配
「谁能换到生产凭证」这个问题,被收敛成了「哪一次经过审批的部署」。 这是 OIDC 方案真正的形态——它不只是"短时效",它是把授权锚在人已经确认过的那一步上。
一个 AWS 形式的信任策略长这样(其它云是同一套 claim、不同语法):
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:my-org/payments-api:environment:production"
}
}
}
注意 StringEquals 这个算子本身就是证据:这里该用精确相等,不该用 StringLike。什么时候必须用通配?只有在你想让多个环境共用同一个角色时(...:environment:*)——那是一次有意的放宽,要写注释说明为什么。
配套的 workflow 侧写法(好/坏两版都留档在 cialab/workflows/,且有一份可跑的检查脚本 cialab/ci-audit.py):
deploy-prod:
needs: build
environment: production # ✅ 让 sub 变成 environment 格式,且必须过审批
permissions:
contents: read
id-token: write # ✅ 只在这一个 job 上申请
steps:
- uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
role-to-assume: arn:aws:iam::123456789012:role/gha-deploy
对应地,id-token: write 不该写在 workflow 级——写成 workflow 级等于给每个 job(包括跑 PR 代码的测试 job)都发了换凭证的能力。本机的检查脚本把这一条作为独立一项(C2)报出来:坏例 7 条问题、好例 0 条(留档 03-workflow-audit.txt)。
顺带一个固定 SHA 时的坑
核对本课好例里那四个 action 时踩到一个(留档 04-pin-sha-verification.txt):GET /repos/<owner>/<repo>/git/ref/tags/<tag> 对普通 tag 返回 commit SHA,对 annotated tag 返回的是 tag 对象的 SHA。aws-actions/configure-aws-credentials@v4.0.2 就是后者:refs API 给 5579c002bb47...,还得再查一次 /git/tags/<那个 SHA> 才拿到真正的 commit e3dd6a42...。
直接抄 refs API 的返回值会钉到一个不是 commit 的对象上,而 workflow 大概率照样能跑完——错得很安静。所以别手抄 SHA,用 pin-github-action、ratchet 或 Dependabot 的 @<sha> # vX.Y.Z 格式自动固定。
五、产物晋级:把"只构建一次"当约束,不当偏好
凭证配对了,回答的还是"谁能发"。下一个问题是"发出去的到底是什么"。
本机跑了一遍(cialab/promote-once.mjs,留档 02-promote-once.txt)——真的构建一个产物、真的算 sha256:
=== A. 构建时嵌入"构建时刻"(大多数 Dockerfile 的默认行为)===
build #1 → fc2dc6e01b65
build #2 → 880bb7f0121e
两次结果一致? ❌ 否
=== B. 可复现输入(固定 SOURCE_DATE_EPOCH)===
build #1 → 752ac336dc78
build #2 → 752ac336dc78
两次结果一致? ✅ 是
=== 对照:如果每个环境各构建一次 ===
staging 产物 digest: 6c7ec9bb9733
prod 产物 digest: d5841bc46c80
同一个 digest? ❌ 否
读法:
- A 段说明"同一份源码构建两次得到两个产物"是默认行为,只要你嵌入了构建时刻、run number、目录遍历顺序这类东西。所以"各环境各构建一次"到底对不对,不取决于你的意愿,取决于运气。
- B 段说明"可复现构建"是让"重建一次比对摘要"这个校验有意义的前提。可复现构建做不到时,你没法用摘要证明任何事。
- 对照段就是那个经典故障的成因:staging 上过了,prod 上挂——因为 prod 上跑的根本不是 staging 上验证过的那份二进制。它看起来完全正常,两边都是绿的,只有行为不一样。
于是有了这条约束:
构建一次,产物晋级(promote)。每个环境部署的是同一个 digest,不是"同一份源码重新构建出来的东西"。
晋级的操作不是"再构建一次再把结果推过去",而是给同一个 digest 贴一个新的环境标签。看实测的 C 段:
产物 fc2dc6e01b65(来自 v1.4.2)
promote fc2dc6e01b65 → staging: 写入
promote fc2dc6e01b65 → prod: 写入
promote fc2dc6e01b65 → prod: 已存在,no-op(幂等)
registry 现状: [["fc2dc6e01b65", ["staging","prod"]]]
晋级天然幂等:同一个 digest 推到同一个环境第二次是 no-op——它不产生新东西,只是往一个集合里加一个已经存在的元素。这与第 02 章的"幂等"是同一件事的两层:02 讲的是重跑作业不产生副作用,这一节讲的是晋级动作不产生新产物。
第 07 章那份 workflow 里的 needs.build.outputs.digest 就是这个设计的最小实现。关键不是它长什么样,而是这条链上不存在第二个 build。
为什么它是第 04 章签名体系的前提
第 04 章讲了 SBOM、cosign sign、provenance、部署前的 cosign verify。那些工作有一个前提:签的是一个 digest,且这个 digest 在后续每个环境里都不变。
如果每个环境各构建一次,就会出现:staging 的产物被签名、被验证——但那个 digest 再也不会被部署;prod 部署的是新建的产物,它没有签名、没有 provenance;想让它过校验,只能在 prod 路径上重新签一次——签名的人从"构建流程"变成了"部署脚本"。
最后一句是这件事最贵的代价:签名一旦在部署侧补签,provenance 证明的就不再是"经过我流水线构建的产物",第 04 章那套 --certificate-identity-regexp 校验也就失去了意义。所以"一次构建"不只是效率或一致性问题,它是签名体系能不能成立的前置条件。反过来,做对了以后有个便宜的好处:每个环境的 cosign verify 用同一条命令、同一个 digest,staging 验过的结论对 prod 依然成立。
生产边界
这一章的所有实测都跑在本机(匹配语义靠脚本复现、没有云账号、没有跑过真实 Actions),下面是每一项的教学替身 → 真实替换点:
| 教学替身 | 生产替换点 | 不变的是什么 |
|---|---|---|
| 本机的匹配矩阵脚本 | 云侧 IAM / Azure federated credential / GCP workload identity 的真实条件求值 | 要比的 claim 和两种匹配语义;本机复现的是语义,不是实现 |
ci-audit.py 的六项检查 |
CI 里对 workflow 文件的自动评审(如 zizmor、actionlint、自定义策略检查) |
检查的那六件事本身不需要变 |
本机 sha256 算的产物摘要 |
OCI registry 的 digest、制品库的不可变版本 | "摘要标识内容、内容变了摘要必变" |
promote() 往集合里加环境标签 |
docker pull && docker tag && docker push、或 Argo/GitOps 改 manifest 里的 digest |
同一个 digest 穿过环境;改的是引用,不是产物 |
| 手工核对 SHA | pin-github-action / ratchet / Dependabot 自动固定 |
固定的对象必须是 commit,不是 tag 对象 |
故障注入
| 注入方式 | 观察什么 | 说明的现象 |
|---|---|---|
把信任策略的 sub 从 environment 格式改回 repo:org/app:* |
从 fork 提一个 PR,看它的 job 能不能换到凭证 | 能换到 → 外部人拿到生产角色(现场那个事故) |
把 sub 写成精确的 ...:ref:refs/heads/main,然后给 job 加 environment: |
部署能否换到凭证 | 换不到——sub 变成 environment 格式,合法部署被自己的策略拒掉 |
信任策略里只留 aud 条件 |
用另一个仓库的 workflow 试着换凭证 | 能换到 → aud 是请求方填的,不是身份 |
故意用 2026-07 后新建的仓库 + 旧格式 sub |
部署是否换得到凭证 | 换不到,且策略语法完全合法——静默失效 |
把 id-token: write 从 job 级提到 workflow 级 |
测试 job 能否请求 OIDC token | 能 → 跑 PR 代码的 job 也拿到了换云凭证的能力 |
构建时嵌入 date,然后 staging/prod 各构建一次 |
两边的 digest | 不同 → 你验证的和你发布的不是一个东西 |
| 按 digest 晋级两次同一环境 | 第二次的行为 | no-op(幂等);重复触发不会产生第二个产物 |
| 在部署脚本里补签 prod 的产物 | cosign verify 的 --certificate-identity 还能不能指向构建 workflow |
不能 → 签名人从构建流程变成了部署脚本,provenance 失去意义 |
动手:可观察结果
| 动作 | 产出物 | 判断"做完了"的标准 |
|---|---|---|
| 打印一次 OIDC token 的 claim | 一份 claim 列表(如用 actions-oidc-debugger) |
能指出这次 job 的 sub 是四种格式里的哪一种、为什么 |
| 把发布路径的长期 token 换成 OIDC | 一条不再持有静态密钥的发布链 | 删掉那个 secret 后发布照样成功 |
| 从 fork 提一个 PR,观察它能否换到生产凭证 | 一次被拒的尝试 | 被拒才算配对;能换到就回去改 sub |
| 核对信任策略里用的是哪个算子 | 策略 JSON | 能说出这里为什么用 StringEquals 而不是 StringLike;若用了通配,有注释说明为什么放宽 |
跑一遍 ci-audit.py 检查自己的 workflow |
一份问题清单 | 坏例 7 条、好例 0 条;自己能复现这个对比 |
| 让同一次构建产出两个环境的部署 | 一条只有一个 build 的流水线 |
两个环境的镜像 digest 完全相同;registry 里没有第二个产物 |
| 触发同一个 digest 晋级两次 | 第二次的输出 | no-op,且没有产生新 tag 或新产物 |
自测题
- OIDC 把"保管长期密钥"换成了什么?为什么说风险没有消失,只是换了位置?
aud与sub分别是什么?为什么"只校验aud"等于把角色开给所有 GitHub 仓库?pull_request事件的sub为什么无法区分 fork PR 与仓库内 PR?要区分它们该靠什么?- 给部署 job 加上
environment:之后,按分支格式写的精确策略会发生什么?为什么说"加 environment 是一个会改变授权语义的动作"? - 2026-07-15 之后创建的仓库,
sub格式变了什么?旧策略的失效表现为什么是"静默"的? - "每次环境各构建一次"为什么让你验证过的产物没被发布?可复现构建与"一次构建"各解决哪一半?
现在能解释什么
- 为什么"把长期 token 换成 OIDC"只是第一步——真正的落点是云侧那条字符串匹配规则,而它可以被写宽(放行 fork PR)、写错(只查
aud)、或写死(换不到凭证)。 - 为什么
environment不只是"一个人工审批的开关",它同时是把 OIDC 授权面收窄到"经审批的那一次部署"的唯一可靠锚点。 - 为什么"一次构建、多次部署"不是风格偏好:它是"你验证过的东西就是你发布的东西"的唯一保证,也是签名与 provenance 能成立的前提。
- 面试被问"你们怎么管发布凭据"时,能讲到
sub的四种格式、两种匹配算子、以及"签名基于 digest 且 digest 必须跨环境不变"这条完整链路——而不是停在"我们用了 OIDC"。