KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
04 · 供应链安全:流水线本身也是攻击面 — keel 龙骨
这一章回答:攻击者为什么不直接打你的代码,而是打"从代码到上线"这一段——以及这一段上最该做的五件事是什么。
这一章回答:攻击者为什么不直接打你的代码,而是打"从代码到上线"这一段——以及这一段上最该做的五件事是什么。
大多数人对流水线的想象是"跑测试的地方",对攻击者来说它是一个持有全部生产凭据、能执行任意代码、且常常没人盯着的地方。这一章的所有结论都能落到具体的 yaml 行或者一条命令。
一、威胁模型:四个入口
① 第三方依赖 ← 依赖投毒 / typo-squatting / 传递依赖被劫持
② 构建机器与动作 ← 复用的 CI 动作被打补丁(真实案例见下)
③ 凭据 ← secrets 泄漏到日志、被 fork PR 读取、长期不轮换
④ 产物本身 ← 产物被替换、镜像 tag 被覆盖、产物来源无法证明
对应的防护刚好是五项:依赖锁定、动作固定、最小权限与短期凭证、产物签名与证明、部署侧校验。
二、真实事故:为什么"锁定版本标签"不够
2025 年 3 月,tj-actions/changed-files 这个被上万个仓库使用的 GitHub Action 被投毒(CVE-2025-30066)。攻击方式值得记住——不是发布了新版本,而是把已有的多个版本 tag 回溯性地指向恶意 commit(恶意 commit 0e58ed86…,连锁影响 reviewdog/action-setup@v1,CVE-2025-30154)。恶意脚本 dump 了 Runner Worker 进程内存,把里面的 PAT、npm token、云密钥做双层 base64 编码后打印到构建日志——公开仓库的日志谁都能看。影响超过 2.3 万个仓库。
由此得出三条硬结论:
| 结论 | 做法 |
|---|---|
| tag 是可以移动的字符串,不是版本 | 第三方 action 一律 SHA 固定(uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2,注释里保留可读版本号) |
| secrets 一旦進記憶體就可能被捞走 | 能不用长期 secrets 就不用(见第三节 OIDC);必须用时,限制它只出现在必要的 job 里 |
| 日志是公开的 | fork PR 不授予 secrets;不要把变量值直接 echo;必要时用 ::add-mask:: |
三、凭据:用短时效凭证替代长期令牌
传统做法: secrets.PYPI_TOKEN(长期 PAT)──► 泄漏面 = 永久、轮换靠人记
现代做法: GitHub OIDC ──► 短时效令牌 ──► 换取发布凭证(Trusted Publishing / Federated Identity)
生命周期 = 一次 job 作业
| 维度 | 长期 token | OIDC 短期凭证 |
|---|---|---|
| 泄漏后的有效期 | 直到有人轮换 | 分钟级 |
| 轮换负担 | 人工,常被遗忘 | 无(没有需要保管的东西) |
| 权限粒度 | 通常是账号级 | workflow 级,可指定仓库与分支 |
| 部署侧的假设 | "持有 secret 的人可信" | "来自特定 workflow 的构建才可信" |
这句话值得背: OIDC 方案把"谁有权发版"这个问题,从"谁拿到了那串字符串"收敛成了"哪个仓库的哪个 workflow"。你已经有的实绩(agent-feed 用 Trusted Publishing 发 PyPI、--provenance 发 npm)正是这一条的完整落地,面试时可以直接指着它讲。
配套的三条纪律:
- 仓库级
permissions: contents: read,只有需要的 job 才额外申请写权限。 - 第三方 action 固定 SHA,并给机器人 PR 留"同步升级"的单独流程,不与功能改动混。
- fork PR 默认不给 secrets;确需在 fork 上下文运行时,用最小权限的临时环境并做人工审批。
四、依赖:别让"版本范围"成为漏洞入口
| 生态 | 正确做法 | 检查手段 |
|---|---|---|
| Python(uv / pip-tools) | 锁文件 + uv sync --frozen / pip install --require-hashes |
CI 里加"锁文件是否与清单一致"的检查 |
| Node | npm ci(校验 package-lock.json 的 integrity),禁止 npm install 出现在 CI |
lockfile 完整性检查 |
| Go | go.sum 内容哈希 |
默认启用 |
| 容器基础镜像 | 按 digest 固定(FROM python:3.12-slim@sha256:…) |
tag 同样可以被移动,道理和 action 一样 |
往上再走一层是自动更新策略:Dependabot / Renovate 把升级也做成 PR 并跑同样门禁。要点是给机器人 PR 走与人工 PR 相同的安检路径——攻击者最喜欢的就是"机器人改动会自动合并"这类便利配置。
五、产物可信:SBOM、签名与来源证明
扫描和签名容易混淆,它们回答的是不同的问题:
| 回答的问题 | 代表工具 | |
|---|---|---|
| 漏洞扫描 | 这个产物里有没有已知 CVE | Trivy、Grype、Snyk、docker scout |
| SBOM | 这个产物里到底装了什么(依赖清单) | Syft、trivy sbom、BuildKit --sbom=true |
| 签名 | 这个产物有没有被人换过 | cosign(Sigstore / Fulcio / Rekor,无密钥) |
| 来源证明(provenance) | 它是哪个仓库哪个 workflow 用什么构建出来的 | GitHub artifact attestations、slsa-github-generator |
| SLSA 等级 | 上述保护的整体成熟度 | L1 有 provenance → L2 托管构建 + 签名 → L3 加固隔离构建 |
一句话区分:扫描找"是不是已知的不良品",签名与证明回答"是不是我们说好的那一个"。两者都要有。
典型命令行位:
# 生成 SBOM(SPDX 兼容性最好,CycloneDX 依赖图更丰富)
syft ghcr.io/org/app:$SHA -o spdx-json > sbom.spdx.json
# 无密钥签名:用 CI 的 OIDC 身份签发短期证书,签名事件写入 Rekor 透明日志
cosign sign --yes ghcr.io/org/app@sha256:$DIGEST
# 把 SBOM 作为证明附加到同一个产物上
cosign attest --yes --predicate sbom.spdx.json --type spdxjson ghcr.io/org/app@sha256:$DIGEST
# 部署前校验:验证签名 + 证明来自指定 workflow
cosign verify ghcr.io/org/app@sha256:$DIGEST \
--certificate-identity-regexp "https://github.com/org/app/.github/workflows/release.yml@refs/heads/main" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
注意上面命令里反复出现的 @sha256::签名与验证必须基于摘要 digest,不能基于 tag。 tag 是可移动的标签,对移动目标签名等于没签。
六、准入側校验:让集群拒绝不合规的产物
前面的工作只有在上环境时被检查才有意义。容器场景用 Kyverno 或 Sigstore policy-controller 做 admission policy:
# 只接受来自指定 workflow 且带有效签名的镜像
spec:
validationFailureAction: Enforce # 不是 Audit,Audit 只是记录
rules:
- name: require-signed-images
match: { any: [ { resources: { kinds: [Pod], namespaces: [production] } } ] }
verifyImages:
- imageReferences: ["ghcr.io/org/*"]
attestors:
- entries:
- keyless:
subject: "https://github.com/org/*"
issuer: "https://token.actions.githubusercontent.com"
同样的思想在非 K8s 环境也成立:发布脚本里加一步 cosign verify / gh attestation verify,不通过就退出不小于 0 的状态码。把校验做进发布路径,而不是写在安全文档里。
七、落地顺序(不必一次到位)
第 1 周 锁依赖 + 锁文件一致性检查 + 最小权限 permissions
第 2 周 第三方 action SHA 固定 + fork PR 不授予 secrets
第 3 周 长期 secrets → OIDC(发布路径优先)
第 4 周 产物 SBOM 生成 + 漏洞扫描并入发布前置条件
第 6 周 产物签名 + 来源证明(签名基于 digest)
第 8 周 部署侧 admission / 发布脚本内校验
动手:可观察结果
| 动作 | 产出物 | 判断标准 |
|---|---|---|
| 给 workflow 加最小权限 | permissions 块 |
删掉默认值后,发布 job 仍能跑,其它 job 无法写 |
| 把一个 action 从 tag 改成 SHA | commit diff | 再看 GITHUB Actions 面板注释里保留可读版本号 |
| 生成一个 SBOM 并查看 | sbom.spdx.json |
能回答"这个包依赖了多少传递依赖、最大的那个是谁" |
| 对构建的产物做一次校验 | 一行 cosign verify 或等价校验的输出 |
故意手改一字节后校验应当失败 |
| 检查自己项目的依赖 | --require-hashes / npm ci 的运行结果 |
出现"缺失哈希"要能定位到具体一行依赖声明 |
故障注入
| 注入方式 | 观察什么 | 说明的现象 |
|---|---|---|
在 workflow 里 echo ${{ secrets.X }} |
日志是否泄漏(注意 *** 掩码) |
掩码只覆盖直接插值,拼接/编码后可能绕过 |
| 把一个已固定的 action 改回 tag | 供应链评审能不能发现 | 通常不能——所以需要自动化检查(如 dependency-review-action) |
| 发布后手动替换镜像 tag 内容 | digest 是否变化、部署是否用 digest | 用 tag 部署 = 可能被悄悄换掉 |
| 拿一份伪造来源的产物向上发布 | admission / verify 是否拒绝 | 拒绝才算真接入了校验 |
| 让机器人 PR 自动合并 | 是否绕过任何安全检查 | 绕过 = 最容易被利用的路径 |
自测题
tj-actions事件里,攻击者是怎么让旧版本 tag 也变成恶意版本的?这对"锁定版本"这个说法意味着什么?- OIDC 短期凭证把安全问题从"保管密钥"变成了什么问题?
- 扫描(漏洞/SBOM)与签名/provenance 分别回答什么?为什么不能互相替代?
- 签名为什么要基于 digest 而不是 tag?
- 一份署名的 SBOM 和一份未署名的 SBOM,在安全防范上的差别是什么?