npm 包投毒越来越频发:TanStack 事件给前端供应链的一课
这次 TanStack 事故最刺痛人的地方,不是某个 npm token 被偷了,而是攻击者把 CI/CD 里的几段“合理信任”串成了一条发布链路。
先说结论
npm 包投毒这几年已经不再是“有人注册了一个相似包名”这么简单了。
2026 年 5 月 11 日,TanStack 生态里 42 个 @tanstack/* 包被发布了 84 个恶意版本。根据 Tanner Linsley 的事后说明,攻击发生在 19:20 到 19:26 UTC 之间,外部研究者大约 20 分钟后公开报告了异常。
最值得注意的一点是:这次不是传统意义上的 npm 账号被盗,也不是维护者把 token 泄露到了日志里。攻击者组合了三件事:
pull_request_target触发的 “Pwn Request” 模式;- fork PR 到主仓库之间的 GitHub Actions cache poisoning;
- 在 release runner 里从内存提取 OIDC token,然后直接向 npm registry 发起发布请求。
换句话说,攻击者没有偷走 npm token,却仍然借到了“合法发布”的身份。
这才是这类事件越来越让人不安的地方:供应链攻击正在从“偷凭证”变成“滥用自动化信任边界”。
这不是 TanStack 一家的问题
我不太想把这件事写成“某个项目配置错了”的复盘。那样看起来很爽,但没什么帮助。
TanStack 是一个成熟、活跃、被大量开发者依赖的开源项目。它的发布链路已经比很多个人项目和公司内部包规范得多。正因为如此,这次事故反而更有警示意义:攻击者没有选择最笨的路径,而是在 CI/CD、缓存、OIDC、npm trusted publishing 之间寻找可以拼接的缝隙。
很多团队现在都有类似配置:
- fork PR 自动跑一些检查;
- CI 缓存 pnpm / npm / yarn 目录;
- release workflow 拥有更高权限;
- npm trusted publishing 依赖 GitHub OIDC;
- 安装依赖时默认允许 lifecycle scripts 执行;
- 维护者维护很多包,发布频率很高。
这些单独看都合理。
问题是,供应链攻击最擅长的不是攻破一扇厚门,而是把几扇半开的门连起来。
攻击链里最值得记住的三段
第一段:pull_request_target 的危险不在触发器本身
pull_request_target 的初衷并不邪恶。它常被用来给 PR 打标签、发表评论、更新元信息,因为它运行在 base repository 的上下文里,可以拿到普通 fork PR 拿不到的一些权限。
危险发生在另一个动作上:在 pull_request_target 里 checkout fork 提交并执行其中的代码。
这相当于一边说“我在可信仓库上下文里运行”,一边又把不可信代码拿进来执行。GitHub Security Lab 很早就把这类模式称为 “pwn request”。这次 TanStack 事件里,相关 workflow 试图做权限拆分,但仍然漏掉了 cache 这个更隐蔽的共享面。
很多人以为:
permissions:
contents: read
就能把风险压住。现实是,actions/cache 的 post-job save 并不简单受 GITHUB_TOKEN 这类权限配置约束。也就是说,即使 job 看起来是只读的,它仍可能把污染后的依赖缓存写回主仓库作用域。
第二段:缓存是性能优化,也是信任边界
缓存平时太“工具化”了,以至于我们很少把它当成安全边界。
但在现代前端项目里,缓存目录里可能放着:
- 包管理器 store;
- 编译产物;
- native binary;
- postinstall / prepare 后生成的文件;
- 后续 workflow 会直接使用的依赖内容。
这次攻击的核心就在这里:恶意 PR 运行时污染 pnpm store,并保存到一个 release workflow 后续会命中的 key。等真正的 main 分支发布流程启动,release runner 恢复了这个缓存,攻击代码就从“不可信 PR”穿越到了“可信发布环境”。
这一步很阴险,因为它不需要 PR 被合并。
PR 可以最后变成 0 文件改动,可以关闭,可以删除分支。缓存还在。
第三段:OIDC 不是魔法护身符
npm trusted publishing 和 GitHub OIDC 本来是为了减少长期 npm token 的暴露面。这个方向是对的:短期凭证比长期 token 好,自动 provenance 也比手工发布更可审计。
但这次事故提醒我们:OIDC 解决的是“不要把长期密钥放在 CI 里”,不是“CI 运行时被拿到代码执行以后仍然绝对安全”。
如果一个 release workflow 拥有 id-token: write,而攻击者已经在这个 runner 上获得代码执行能力,就可以尝试从运行环境里拿到可用于发布的 OIDC token。TanStack 事件里,恶意代码甚至绕过了 workflow 里正常的 “Publish Packages” step:测试失败后发布步骤没有执行,但恶意进程已经在其他阶段直接向 npm registry 发起了发布。
这也是我觉得最有教育意义的部分。
我们过去常问:“npm token 有没有泄露?”
现在还要问:“任何能拿到发布身份的 workflow,是否运行了不该信任的代码?”
为什么 npm 包投毒越来越频发
因为 npm 生态天然有几个攻击者喜欢的特征。
第一,包的传播链很长。一个小包被污染,可能经由 transitive dependencies 扩散到很多项目。大多数使用者并不会逐个审查依赖 tarball。
第二,安装阶段能执行代码。postinstall、prepare、preinstall 这些 lifecycle scripts 在很多项目里是默认打开的。它们有正当用途,比如编译 native addon、生成 client、拉取二进制文件;也正因为有正当用途,完全禁用并不容易。
第三,维护者压力很大。很多关键包由少数人维护,一个人可能同时维护几十个包。攻击者只要拿到一次发布通道,就可能顺着 maintainer 关系继续扩散。
第四,CI/CD 越来越复杂。现代发布链路里有缓存、矩阵构建、preview、自动 release、provenance、OIDC、机器人评论、三方 action。每一层都在提升效率,也都在制造新的信任交界面。
第五,开源项目通常更透明。透明是开源的优势,但也意味着攻击者能公开研究 workflow、cache key、发布节奏和维护者习惯。
这不是说 npm 不安全,或者开源不安全。更准确地说:开源供应链已经变成高价值基础设施,而我们的防护习惯还停留在“别泄露 token”的阶段。
普通开发者能做什么
如果你在 2026 年 5 月 11 日安装过受影响的 TanStack 版本,官方建议把安装主机视为可能被攻陷,并轮换 AWS、GCP、Kubernetes、Vault、GitHub、npm、SSH 等可触达凭证。这个建议听起来重,但很合理:恶意 payload 的目标就是收集环境里的凭证。
对日常开发来说,我会把几个动作变成习惯。
1. 对异常版本保持敏感
如果一个包突然连续发布多个 patch,尤其是在维护者没有明显 release note 的情况下,要多看一眼。
可以用这些命令做快速检查:
npm view @tanstack/history versions --json
npm diff --diff=@tanstack/history@1.161.8 --diff=@tanstack/history@1.161.9
不要指望每次都能人工审出恶意代码,但至少能看到异常文件、异常依赖、异常 lifecycle scripts。
2. 把 lockfile 当成安全资产
pnpm-lock.yaml、package-lock.json、yarn.lock 不只是“保证安装一致”的文件,它们也是供应链审计入口。
事故发生后,第一步不是只看 package.json,而是查 lockfile 里到底解析到了哪些版本、哪些 tarball、哪些 GitHub dependency。
3. 对 install scripts 采用更细的允许策略
完全禁用 scripts 在很多项目里不现实,但默认全信也越来越危险。
在 CI 的某些阶段,可以考虑:
pnpm install --ignore-scripts
如果项目确实需要构建脚本,就把“哪些包允许执行构建脚本”变成显式策略,而不是把整个安装过程交给默认行为。pnpm 新版本的 build approval 机制就是朝这个方向走的。
4. CI 里不要放过大的横向权限
安装依赖的 job、跑测试的 job、发布包的 job,最好不要都能摸到同一批云凭证和发布身份。
一个比较朴素的原则是:能不放在 install/test 环境里的 secret,就不要放;能只在 publish job 打开的权限,就不要提前打开。
维护者更该盯住哪里
如果你维护 npm 包,尤其是有自动发布流程的包,这次事故至少值得检查这几件事。
1. 全仓库搜索 pull_request_target
先不要争论“我们应该不用它”。可以用,但要非常克制。
安全用法通常是:
- 只读 PR 元信息;
- 打标签;
- 评论;
- 不 checkout fork 代码;
- 不运行 fork 代码;
- 不共享会被 release workflow 复用的缓存。
一旦需要跑 fork 代码,优先用 pull_request,并接受第一次贡献者审批、低权限 token、隔离缓存这些约束。
2. 不要让不可信 PR 写入生产缓存
cache key 不是随手写的字符串,它决定了哪些 job 会共享状态。
对 fork PR,可以考虑:
- 禁止保存缓存,只允许读取非常低风险的缓存;
- cache key 加上 PR scope,避免命中 main/release;
- release workflow 使用独立 cache namespace;
- 定期清理 cache;
- 在事故响应预案里加入“全仓库 cache purge”。
3. 发布身份只在最后一刻出现
id-token: write 不应该像空气一样存在于整条 workflow 里。
更稳的做法是把发布拆成独立 job:只有测试、构建、包内容校验都通过后,才进入拥有 OIDC 发布权限的步骤。这个 job 不应该恢复来自不可信上下文的缓存,也不应该重新执行可能被污染的 install scripts。
这不是绝对安全,但它能减少攻击者“早早拿到发布身份”的机会。
4. 固定 third-party actions
浮动 ref 很方便,比如 @main、@v5。但对 release workflow 来说,方便往往意味着你把供应链的一部分交给了别人实时变化的分支。
关键路径上的 action 尽量 pin 到 commit SHA。升级可以慢一点,但边界要清楚一点。
5. 监控自己的发布,而不是等别人发现
TanStack 这次很幸运:外部研究者发现得非常快,社区响应也非常快。
但“被别人提醒”不应该是唯一报警机制。
维护者可以监控:
- 是否出现非预期版本发布;
- package manifest 是否新增异常
optionalDependencies; - tarball 是否出现异常大文件;
- 是否出现未知 GitHub dependency;
- npm publish provenance 是否来自预期 workflow;
- 发布后的下载量和安装错误是否异常。
这类监控不一定昂贵。哪怕只是一个发布后检查脚本,也比完全没有强。
这件事给我的真实提醒
读完整个复盘,我脑子里一直有一句话:我们把太多“自动化成功”误认为“安全”。
CI 绿了,发布成功了,provenance 有了,trusted publishing 配好了,cache 命中了,所有东西都看起来像现代工程最佳实践。
但攻击者看的不是这些标签。
攻击者看的是:
- 哪段代码能执行;
- 哪个缓存会被复用;
- 哪个 job 拥有更高权限;
- 哪个 token 会在运行时出现;
- 哪个维护者来不及手工审查;
- 哪个包的安装脚本会被默认信任。
这就是供应链安全最麻烦的地方。它不是一个单点漏洞,而是一串合理设计之间的组合风险。
写在最后
我不认为答案是“不要用 npm”。
也不认为答案是“不要用 OIDC”。
更不认为答案是“所有开源项目都应该像大型安全团队一样运转”。这不现实,也不公平。
但我们确实需要更新心智模型:npm 包投毒已经不只是包管理器问题,而是 CI/CD、身份、缓存、发布治理和维护者负载共同组成的问题。
以后看到一个自动发布流程,我会先问三个问题:
- 不可信代码有没有机会影响可信发布环境?
- 缓存有没有跨越 fork、main、release 的信任边界?
- 发布身份是不是只在真正发布的最后一步才出现?
如果这三个问题回答不清楚,供应链风险就不只是理论上的。
开源生态靠信任运转。但到了 2026 年,信任不能只靠“大家都是好人”。它还需要边界、证据、审计和一点点不厌其烦的怀疑。
这可能就是 npm 包投毒事件越来越频发以后,前端工程师必须补上的一门课。