2026 年 5 月 11 日,TanStack 团队经历了一场教科书级别的 npm 供应链攻击。攻击者在约六分钟内通过三步漏洞链发布了 84 个恶意版本,覆盖 42 个 @tanstack/* 包。整个事件的检测与响应均由外部安全研究人员驱动,官方团队在事后通过一份详尽的公开复盘报告揭示了攻击全貌。对于工程团队而言,这份复盘的价值不仅在于理解攻击技术本身,更在于从中提取可操作的防御工程路径 —— 如何通过 CI/CD 流程加固来封堵同类漏洞,以及如何在供应链攻击发生时建立有效的事件响应闭环。
攻击时间线:从缓存投毒到恶意发布
理解这场攻击的第一步是重建完整的时间线。官方复盘将事件划分为三个阶段:预攻击阶段(缓存投毒)、引爆阶段(恶意发布)和检测响应阶段。每个阶段的时间戳精确到秒,揭示了攻击者对 GitHub Actions 工作流机制的深刻理解。
预攻击阶段始于 2026 年 5 月 10 日下午 17 时 16 分,攻击者创建了 TanStack/router 的一个派生仓库 github.com/zblgg/configuration,并将仓库名称刻意更改以规避基于派生列表的搜索。当晚 23 时 29 分,攻击者在该派生仓库中提交了一个包含约 30,000 行 JavaScript 有效载荷的恶意提交,该提交以 [skip ci] 为前缀以压制 CI 运行。关键的时间节点出现在次日:5 月 11 日 10 时 49 分,攻击者向 TanStack/router 仓库提交了一个标题为 "WIP: simplify history build" 的 Pull Request(#7378),该 PR 触发了两个使用 pull_request_target 的工作流:bundle-size.yml 和 labeler.yml。值得注意的是,由于 pull_request_target 绕过了首次贡献者审批门禁,这些工作流在攻击者提交 PR 后立即自动运行。
11 时 11 分,攻击者通过强制推送将恶意提交(commit hash 65bf499d)置于 PR 的头部。这触发了 bundle-size.yml 中的 benchmark-pr 作业,该作业检出 PR 的合并引用并在 runner 上执行 pnpm install 和构建命令 —— 这正是恶意代码执行的时刻。11 时 29 分,被污染的 pnpm store 以 1.1 GB 的缓存条目形式被保存到 GitHub Actions 缓存中,缓存键为 Linux-pnpm-store-6f9233a50def742c09fde54f56553d6b449a535adf87d4083690539f49ae4da11。11 时 31 分,攻击者将 PR 强制推送回原始 main HEAD,关闭 PR 并删除分支 —— 攻击链的缓存投毒部分完成,而恶意缓存条目在仓库的缓存命名空间中持久存在。
引爆阶段发生在当晚 19 时 15 分至 26 分之间。当 Manuel 合并 PR #7369 和 #7382 触发 push 到 main 时,release.yml 工作流开始运行并恢复被污染的缓存。被污染的 pnpm store 包含了攻击者精心构造的二进制文件,这些文件能够定位 GitHub Actions Runner 的 Worker 进程,读取其内存空间,并提取在运行时动态生成的 OIDC 令牌。19 时 20 分 39 秒,npm 注册表接收到 @tanstack/history@1.161.9 及 41 个同族包的发布请求 —— 发布通过 OIDC 信任发布者机制认证,但并非来自工作流中定义的发布步骤,而是来自恶意代码直接 POST 到 registry.npmjs.org。19 时 26 分,第二批恶意版本发布完成。
检测与响应几乎即时启动。19 时 50 分左右,外部安全研究员 carlini 在 GitHub 上创建了 issue #7383,提供了完整的恶意 optionalDependencies 指纹和初步的包列表。与此同时,该研究员直接通知了 npm 安全团队。20 时,Manuel 在 issue 中确认并启动事件响应。20 时 10 分,团队移除了所有其他团队成员的 GitHub 推送权限,以防用户机器已被入侵。20 时 30 分,Tanner 向 security@npmjs.com 发送了包含完整 IOC 列表的邮件。21 时,团队完成对全部 295 个 @tanstack/* 包的全面扫描,确认影响范围为 42 个包、84 个版本,并启动所有受影响版本的 npm 废弃流程。21 时 30 分,所有 TanStack/* 仓库的缓存条目通过 API 清除,加固 PR 合并,同时发布 GitHub 安全公告并请求 CVE 分配。
三漏洞链:为什么每一步都不可或缺
官方复盘将这次攻击的核心定性为 “三漏洞链”—— 三个各自独立存在时无害的漏洞,在特定条件下串联时构成致命的攻击路径。理解每一环的作用机制,对于评估自身项目中的类似风险至关重要。
第一环是 pull_request_target 的 “Pwn Request” 模式。bundle-size.yml 工作流使用了 pull_request_target 触发器,并在其下检出了派生 PR 的合并引用,执行了派生仓库控制的代码。这本身并非罕见模式 —— 许多项目依赖它来实现对 PR 代码的构建和基准测试。然而,问题在于工作流内部的设计缺陷:工作流尝试通过权限分离来构建信任边界,将 benchmark-pr 作业标记为 “不可信且仅读权限”,但忽略了 actions/cache@v5 的 post-job 保存操作并不受 permissions: 声明约束。缓存写入使用 runner 内部令牌,而非工作流的 GITHUB_TOKEN,因此 permissions: contents: read 无法阻止缓存变异。
第二环是 GitHub Actions 缓存跨信任边界污染。攻击者精心构造的恶意代码将数据写入 pnpm-store 目录,使用与合法 release.yml 工作流相同的缓存键计算方式。当 benchmark-pr 作业结束时,actions/cache@v5 的 post-step 将被污染的 pnpm store 保存到该键。当 release.yml 后续在 main 分支的 push 事件上运行时,其 Setup Tools 步骤恢复的正是这个被污染的缓存条目 —— 完全符合正常流程,零告警。这是 Adnan Khan 在 2024 年 5 月记录的已知 GitHub Actions 设计问题,并非 TanStack 独有的漏洞。
第三环是 OIDC 令牌运行时内存提取。release.yml 声明了 id-token: write 权限(合法用于 npm OIDC 信任发布),当被污染的 pnpm store 被恢复后,攻击者控制的二进制文件随即在 runner 上执行。这些文件通过遍历 /proc/*/cmdline 定位 GitHub Actions Runner Worker 进程,读取其内存映射并提取 OIDC 令牌,然后使用该令牌直接向 registry.npmjs.org 发起 POST 请求发布恶意包 —— 完全绕过了工作流中定义的发布步骤。官方复盘明确指出,这是 2025 年 3 月 tj-actions/changed-files 妥协事件中使用的相同时序提取技术,攻击者甚至保留了归属注释而未做修改。
三环串联的逻辑清晰:pull_request_target 使攻击者的代码能够在 base 仓库的缓存命名空间中写入数据;缓存污染使恶意数据能够跨越信任边界进入生产发布流程;OIDC 令牌提取使攻击者能够冒充合法的工作流身份发布包。每一环都是前一环的放大器,也是后一环的前置条件。
修复工程量:从临时止血到系统性加固
事件响应中的临时止血措施包括移除团队推送权限(20 时 10 分)、清除所有缓存条目(21 时 30 分)、废弃所有受影响版本(21 时)、联系 npm 安全团队拉取恶意 tarball。这些措施解决了当务之急,但系统性加固需要更深层的工程投入。
官方复盘记录的核心修复工程量体现在合并的加固 PR 中。对于 bundle-size.yml,主要改动包括:为所有第三方 action 引用添加固定 SHA 绑定,弃用浮动引用(如 @v6.0.2、@main),因为浮动引用使得攻击者能够在不触碰仓库的情况下替换依赖行为;添加 repository_owner 守卫条件,确保工作流仅在来自可信组织的 PR 上执行 pull_request_target 路径;将需要执行不可信代码的作业与包含敏感权限的作业彻底隔离,移除 benchmark-pr 作业对缓存的依赖或重构其缓存逻辑以避免跨信任边界污染。
对于整个 TanStack 组织的 CI/CD 体系,加固措施扩展到更深层:所有使用 pull_request_target 的工作流需经过专项审计,评估是否可以用 pull_request 配合路径过滤或权限约束替代;npm 发布流程从 OIDC 信任发布者模式切换为短期经典令牌配合人工审核,或引入发布来源验证机制以检测来自非预期工作流步骤的发布行为;建立内部告警机制监控自身包的发布事件,而非依赖第三方报告 —— 官方复盘坦承这是最大的改进点之一,团队是从第三方得知自身被入侵的。
对于下游受影响用户,官方给出了明确的处置清单:检查依赖中是否存在受影响版本(2.0.4 至 2.0.7 范围内的特定版本);如果曾在 2026 年 5 月 11 日安装过受影响版本,将安装主机视为潜在已陷落;轮换所有可从安装主机访问的凭据,包括 AWS IAM 凭证、GCP 元数据服务令牌、Kubernetes ServiceAccount 令牌、Vault 令牌、npmrc 中的令牌、GitHub 令牌(环境变量、gh CLI 缓存、.git-credentials)以及 SSH 私钥。
防御工程路径:可落地的参数与阈值
将官方复盘中的教训转化为工程实践,需要聚焦于可量化、可自动化的防御参数。基于攻击链分析,以下是针对同类威胁的可落地防御工程路径。
在 pull_request_target 使用策略上,核心原则是永不在其上下文中执行派生仓库控制的代码。如果工作流必须检出 PR 代码进行构建或测试,应使用 pull_request 触发器而非 pull_request_target,并通过 paths 过滤限制触发范围。若必须使用 pull_request_target,则必须确保作业运行在最小权限沙箱中 —— 禁用网络访问、禁用缓存读取和写入、使用只读文件系统挂载 —— 并通过 repository_owner 条件或 PAT 验证确保代码来源可信。
在 CI/CD 缓存安全策略上,核心参数是缓存作用域隔离和不可变声明。建议为 pull_request_target 工作流使用独立命名的缓存键空间(如添加 pr- 前缀),确保其缓存条目永远无法被生产工作流恢复。对于包含 id-token: write 权限的工作流,应禁用缓存或使用完全隔离的缓存后端。具体参数可设定为:所有 actions/cache 调用必须包含 key 参数且不允许回退到 restore-keys 自动匹配;缓存键必须包含工作流触发器类型(如 github.event_name)以防止跨触发器污染。
在 OIDC 信任发布安全加固上,核心措施是引入发布来源验证。npm 的 provenance 机制已在协议层面支持,但工程团队需要补充的是:在发布后通过 npm API 或 webhooks 验证实际发布的包内容是否来自预期的构建步骤,而非来自工作流中的恶意注入。一种可行方案是在工作流中为发布包生成不可伪造的构建 provenance 证明(如 Sigstore cosign 签名),并在发布后立即验证签名有效性。此外,对于发布权限应实施最小化原则 ——OIDC 令牌的 write 权限应仅限于发布步骤所在的作业,并使用 if: github.ref == 'refs/heads/main' 约束仅在 main 分支生效。
在监控与检测参数上,官方复盘指出的最大教训是缺乏内部告警。建议工程团队配置的最小监控集包括:npm 包发布 webhook 告警,任何非预期时间窗口或非预期工作流触发的发布立即触发安全事件;CI/CD 工作流异常指标,如缓存命中率异常、工作流执行时长异常(恶意代码通常会增加执行时间)、网络出站连接异常;GitHub Actions Runner 进程访问模式检测,检测工作流步骤对 /proc/*/mem 的异常访问。最后,供应链安全工具(如 Socket.dev、Dependabot Security Advisories)的集成应作为标准配置。
从攻击者视角审视防御盲区
官方复盘中有一个细节值得特别关注:攻击者 “运气不好” 选择了 Breaking 测试的恶意载荷。恶意代码破坏了测试,导致 release.yml 中的发布步骤被跳过,但 OIDC 令牌提取仍然成功并通过直接 POST 完成发布。这意味着攻击在测试失败的嘈杂环境中完成,反而更容易被异常检测机制捕获。官方复盘明确指出,一个更谨慎的攻击者如果选择不破坏测试,恶意发布可能在数小时内不被察觉。
这个细节揭示了防御的关键悖论:攻击者的失误救了 TanStack,但也意味着基于 “攻击者会犯错” 的防御策略不可靠。真正的防御必须假设攻击者完全了解目标系统的漏洞链,并且足够耐心和精细。回到可操作的层面:任何包含 id-token: write 权限和 OIDC 信任发布配置的 CI/CD 工作流,都应被视为高危路径,必须实施多因素验证而非单点信任。
资料来源:TanStack 官方复盘(https://tanstack.com/blog/npm-supply-chain-compromise-postmortem)
内容声明:本文无广告投放、无付费植入。
如有事实性问题,欢迎发送勘误至 i@hotdrydog.com。