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)正是这一条的完整落地,面试时可以直接指着它讲。

配套的三条纪律:

  1. 仓库级 permissions: contents: read,只有需要的 job 才额外申请写权限。
  2. 第三方 action 固定 SHA,并给机器人 PR 留"同步升级"的单独流程,不与功能改动混。
  3. 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 自动合并 是否绕过任何安全检查 绕过 = 最容易被利用的路径

自测题

  1. tj-actions 事件里,攻击者是怎么让旧版本 tag 也变成恶意版本的?这对"锁定版本"这个说法意味着什么?
  2. OIDC 短期凭证把安全问题从"保管密钥"变成了什么问题?
  3. 扫描(漏洞/SBOM)与签名/provenance 分别回答什么?为什么不能互相替代?
  4. 签名为什么要基于 digest 而不是 tag?
  5. 一份署名的 SBOM 和一份未署名的 SBOM,在安全防范上的差别是什么?

进入 keel 阅读