← SaaS 安全:从零到企业级
全栈开发SaaS 安全:从零到企业级

供应链:你的 CI 是你拥有的权限最高的系统

SaaS 安全系列第九篇:2025–2026 年的 npm 攻击浪潮、来源证明能证明什么和不能证明什么、只需一个下午的消费侧检查清单,以及真正藏 bug 的 CI 信任边界。

SaaSSecuritySupply ChainCI/CDnpmSLSA

2025 年 9 月的一个早晨,debugchalk 的一位维护者——两个加起来周下载量以十亿计的包——点开了一封以假乱真的 npm 2FA 重置邮件,并按它说的做了。恶意版本存活了大约两个小时。一周后,第二波攻击连钓鱼都懒得用了:Shai-Hulud,npm 历史上第一个自我传播的蠕虫,用三周前在 Nx 构建系统的 s1ngularity 事件中被盗的 token 给 500 多个包植入了木马,CISA 为此发布紧急警报,要求所有人把相关凭证一律视为已泄露。

第一到第八篇保卫的是运行中的系统——它的数据、身份、密钥和边界。这一篇保卫的是生产它的那个系统,因为 2025 年已经终结了关于边界现在在哪的争论:边界就是你的构建流水线。这一年的头条事件没有一个用了零日漏洞。每一个跑的都是信任、长寿命 token,以及所有人从所有人那里复制粘贴来的 CI 默认配置。

注册中心反击的那一年

这轮浪潮值得按顺序看,因为每一起事件都跑在前一起的遗留物上:

  • tj-actions(2025 年 3 月)——一个被攻陷的 GitHub Action 把 CI 密钥倾倒进了数千个仓库的工作流日志,而在此之前,相关的 reviewdog action 先被攻陷。第一课:第三方 action 就是一个能碰到你密钥的依赖。
  • s1ngularity(2025 年 8 月)——Nx 通过一个 pull_request_target 工作流被植入木马,攻击者的代码以提升的权限执行(一次“pwn request”)。postinstall 恶意软件收割 token、SSH 私钥和钱包——并且史无前例地利用受害者自己的 AI CLI 工具(Claude、Gemini)辅助侦察。
  • Shai-Hulud(2025 年 9 月)——蠕虫本体:一个 postinstall payload,窃取 npm/GitHub/云凭证,把它们外泄到字面意思就叫 Shai-Hulud 的公开仓库,并重新发布受害者有权发布的每一个包。没有轮换的 s1ngularity token 就是种子——这一年代价最高的一次“省了凭证轮换”。
  • Shai-Hulud 2.0(2025 年 11 月)——又 700 多个包,同一套打法。
  • TanStack(2026 年 5 月)——最该让你警惕的一起。没有任何凭证被盗。攻击者从一个 fork PR 污染了 actions/cache,等发布工作流恢复缓存,从 runner 内存里提取了 OIDC token,发布了 84 个恶意版本——带着完全有效的 SLSA Level 3 来源证明,由真正的 Sigstore 流水线签名。bug 出在 CI 设计本身:一条被共享缓存跨越的信任边界。

来源证明回答的是「在哪」,不是「是什么」

生态系统的应对是真实的,也值得采用:npm 在 Shai-Hulud 之后弃用了经典 token(细粒度 token 现在只活七天)、trusted publishing——基于 OIDC、完全没有长寿命密钥——在 2025 年中上线,维护者的 TOTP 正在被 FIDO2 取代,而 Sigstore 背书的来源证明给你一张加密收据:这个包是在托管 runner 上、从这个 commit、由这个工作流构建的。

全部用上——同时精确理解它做不到什么:

来源证明验证的是厨房,不是。一个由被污染的流水线构建的包,来自真实的仓库、真实的 commit,照样能开出漂亮的证明。TanStack 事件就是概念验证:第一个有据可查的、带着完全合法的 Level 3 证明的恶意软件。SBOM 属于同一类别——一份清单,在凌晨三点必须回答“我们依赖没依赖那个着火的玩意”时价值千金,但它不是控制。这整套东西必要但不充分;补上缺口的部分在你自己的 install 命令这一侧。

消费侧的检查清单只要一个下午

好消息:价值最高的防御都是些无聊的配置,而且大多每项一行:

  • 在 CI 里冻结 lockfile——pnpm install --frozen-lockfilenpm ci。Semver 漂移就是一个平淡周二变刺激的方式。
  • 设置最低发布年龄——pnpm 的 minimumReleaseAge(npm 在 v11.10 加了 min-release-age,Dependabot 和 Renovate 也各有冷却期)。debug/chalk 的窗口是两小时;大多数恶意版本在 24–72 小时内就被下架。一条 24 小时的年龄下限能悄悄躲过几乎整类攻击。时间是这篇文章里最便宜的控制。
  • 安装脚本白名单——pnpm 的 onlyBuiltDependencies,别处用 --ignore-scripts。postinstall 执行是 2025 年每一起事件的 payload 投递机制。合法的包几乎不需要它;恶意的几乎都需要。
  • 对重要的依赖验证来源证明——npm audit signatures,优先选带绿色对勾的包。如果你自己也发布任何包,trusted publishing 就是全部任务:再也没有可偷的 token 了。
  • 开启信任降级检测——pnpm 的 trustPolicy: no-downgrade 会拦截发布方式突然从 OIDC 证明的 CI 降级成裸凭证的包。那种降级正是 s1ngularity 的签名特征。

你的 CI 是你拥有的权限最高的系统

现在是让人不舒服的那一半。你的流水线持有生产部署凭证、铸造你的云信任的 OIDC 身份,并且——每天好几次——执行 fork 你仓库的陌生人写的代码。如果诚实地画架构图,CI 应该被涂成“紧邻生产、面向互联网、运行不可信代码”。没有人这么涂它。以下规则直接来自 2025–26 年攻击的实际运作方式:

  1. **pull_request_target 是一把上膛的枪。**它带着你仓库的密钥和权限运行;只要 job 里有任何东西 checkout 或执行 PR 的代码,你就把钥匙串交给了陌生人。不可信代码要么用 pull_request 跑(fork 自己的权限),要么在完全没有凭证的隔离 runner 上跑。
  2. **id-token: write 只给真正发布的 job。**OIDC token 是皇冠上的明珠——tj-actions 正是从过宽的授权里把它偷走的,TanStack 是从 /proc/<pid>/mem 里把它捞出来的。一个不向 npm 或你的云发布的 job,永远不该持有它。
  3. **缓存按信任级别隔离。**由 PR 触发的 job 写入的缓存条目,绝不能被发布工作流恢复。这正是 TanStack 走过的那条接缝,而它活在你的架构里,不在任何扫描器的规则集里。
  4. **PR 触发的 job 不放任何密钥,每个 action 都钉到 commit SHA。**tag 会移动,SHA 不会。Shai-Hulud 家族变现的恰恰是这两个默认值。
  5. **用 OIDC 连你的云,而不是存密钥。**一条不可能泄露长寿命 AWS key 的流水线,未来就少一起事件。

注意这些和系列其余部分的共同点:最小权限(第二篇)、审计轨迹(第三篇)、把不可信输入当敌意输入(第四篇)、在被测试之前先画好边界(第一篇)。供应链不是一门新学科——是同一门学科,用在了那个你一直在默认信任的系统上。

系列的下一站

下一扇门的当前候选:数据隐私工程——真正会删除的保留策略、数据驻留,以及 DSAR 跑步机,第八篇的加密粉碎(crypto shredding)在那里会成为主力工具。AI agent 开发工作流本身的安全——有仓库访问权的编程 agent 是你供应链的最新成员——是另一个有力候选。和之前一样:顺序到时候再定。

动手练习

  1. 花一个下午过一遍消费侧清单:CI 冻结 lockfile、24 小时最低发布年龄、安装脚本白名单、trustPolicy: no-downgrade。四个 diff,你就躲过了 2025 年每一起事件的机制。
  2. 用新鲜的眼光审计你的工作流:grep pull_request_targetid-token: write、没钉住的 action tag、PR 触发的 job 里的密钥,以及 PR 与发布工作流之间共享的任何缓存。每个命中点都是一条 TanStack 形状的接缝。
  3. 做一次桌面推演:“昨天,我们的一个依赖发布了六个小时的恶意软件。“你能枚举出包含它的每一次部署吗(这正是 SBOM 的用处)?你能在一小时内轮换它碰过的一切吗?任何一个答案是否定的,你就找到了这个季度的项目。
guest@swangnice:~$