TanStack npm 投毒事件:为什么每个 AI 编程代理都需要审计日志
2026 年 5 月 11 日,42 个 @tanstack/* 包的 84 个恶意版本上架 npm,开始窃取 AWS、GCP、Kubernetes、Vault、GitHub 和 SSH 凭证。如果你的 AI 编程代理在那段时间内跑过 npm install,你可能永远不会知道。所以代理命令日志现在已经不是选项。
2026 年 5 月 11 日,UTC 时间 19:20 到 19:26 之间,42 个 @tanstack/* 包的 84 个恶意版本被发布到 npm registry。六分钟。这就是攻击者把全球下载量最高的 JavaScript 生态之一武器化所花的时间。
这些包窃取了 AWS 凭证、GCP 元数据、Kubernetes 服务账户令牌、Vault 令牌、npm 令牌、GitHub 令牌和 SSH 私钥。所有东西被外传到 Session/Oxen messenger 端点。GitHub 把它分配为 CVE-2026-45321,CVSS 评分 9.6 — 严重。
如果你是创始人,团队在用 AI 编程代理 —— Claude Code、Cursor、Copilot、Codex CLI、Windsurf,随便哪个 —— 而其中一个代理在那六分钟内对你的仓库跑过 npm install,那么你要面对一个不舒服的问题:
你能证明它没跑吗?
对大多数团队来说,诚实的答案是:不能。这才是更大的故事。
TanStack 到底发生了什么
值得用大白话把攻击链讲清楚,因为它告诉你为什么传统防御没用。TanStack 没被钓鱼。没有维护者泄露 npm 令牌。他们开了 2FA。官方事后报告确认了这一点。
相反,攻击者把 TanStack GitHub Actions CI 流水线上的三个弱点串了起来:
pull_request_targetPwn Request。 当 fork 发起 PR 时,GitHub 通常在 fork 的安全上下文中跑 CI —— 没有 secrets。但pull_request_target是一种工作流触发器,会在基础仓库的上下文中运行,拥有完整的 secret 访问权。TanStack 出于看起来合理的原因用了它。攻击者提交了一个滥用该触发器的 PR。- 跨 fork/base 信任边界的缓存投毒。 GitHub Actions 在不同运行之间缓存依赖以加速构建。攻击者找到了一条从fork 构建写入基础仓库缓存的路径,埋下一个会在下次合法 CI 运行时执行的载荷。
- 从 runner 进程里抽取 OIDC 令牌。 GitHub CI 运行时会拿到一个短时 OIDC 令牌,用来在发布时对 npm 鉴权。攻击者的代码没有只是从环境变量里偷令牌 —— 而是从 runner 的内存里把它倒出来。
一旦拿到 OIDC 令牌,他们就开始发布。每个包发了两个恶意版本,大约相隔六分钟。第一个版本是探测,第二个才装着真正的载荷。等到 TanStack 注意到时,那个蠕虫 —— TeamPCP 称之为 Mini Shai-Hulud —— 已经流出,并通过收割任何跑了 npm install 的人的令牌向其他包扩散。
这就是过去两年里一直在啃食 JavaScript 生态的供应链攻击模式。TanStack 不是第一个。甚至并不罕见。罕见的是这条攻击链有多干净:攻击者没攻陷一个人。他们攻陷的是人类信任的 CI 系统。
受影响的包和版本
GitHub 的安全公告列出 42 个被攻陷的包,总共 84 个版本。从公告里挑一部分:
@tanstack/react-router—1.169.5、1.169.8(已修复:1.169.9)@tanstack/router-cli—1.166.46、1.166.49(已修复:1.166.50)@tanstack/vue-router—1.169.5、1.169.8(已修复:1.169.9)@tanstack/solid-start—1.167.65、1.167.68(已修复:1.167.69)
42 个包的完整名单在 GitHub 安全公告里。已确认干净的家族是 @tanstack/query*、@tanstack/table*、@tanstack/form*、@tanstack/virtual*、@tanstack/store 和 @tanstack/start 这个 meta 包本身。
检测信号: 如果你 grep 你装好的 node_modules/@tanstack/*/package.json 文件,在 optionalDependencies 里找字符串 "@tanstack/setup": "github:tanstack/router#79ac49eedf774dd4b0cfa308722bc463cfe5885c" —— 找到了,就说明你跑过中毒版本。轮换所有东西。我们等下回来讲。
恶意软件具体做了什么
载荷是一个约 2.3 MB 的混淆 JavaScript bundle,名字叫 router_init.js,通过一个解析到分叉仓库中孤立提交的可选依赖被拉进来。可选依赖在安装时就执行。它们不需要被 import。它们不需要运行时触发。npm install 就是整条 kill chain。
一旦跑起来,载荷会遍历一台典型开发者机器或 CI 环境的每一个凭证位置:
- AWS Instance Metadata Service 和 AWS Secrets Manager
- GCP 元数据服务(在
169.254.169.254的那个) - Kubernetes 服务账户令牌(
/var/run/secrets/kubernetes.io/serviceaccount/) - HashiCorp Vault 令牌
~/.npmrc里的 npm 令牌- 环境变量(
GITHUB_TOKEN、GH_TOKEN) ghCLI 存储的凭证~/.git-credentials~/.ssh/下的所有 SSH 私钥
然后把收获送到一组 Session/Oxen messenger 端点 —— filev2.getsession.org 和 seed{1,2,3}.getsession.org。Session/Oxen 是去中心化通讯网络。防火墙难拦,传票难送,关停也难。
对一台开发者机器来说,爆炸半径已经很糟。对一台带云管理员凭证的 CI runner,爆炸半径是灾难性的。对一台开发者机器,同时跑着被授权可以不询问就执行 npm install 的 AI 编程代理 —— 而且这台机器大概率还能访问你的 .env、你的 AWS profile 和你的云 SDK 配置 —— 爆炸半径介于”轮换你拥有的一切”和”公司完了”之间。
AI 编程代理在哪里把事情搞得更糟
接下来这部分是大多数关于 TanStack 的安全分析不会涉及的,也是应该让每个读到这里的创始人坐直身子的部分。
现代 AI 编程代理是自主的。你让 Claude Code “修一下 router 分支上失败的测试”。它检出分支。它看到 lockfile 是脏的。它跑 npm install。它这么做,是因为你三周前对一个宽泛的权限提示点了”是”,或者因为你配置了它自动批准常见的开发命令,或者因为它在以一种”—yolo”等效的模式运行 —— 每个用这些工具的人最终都会默认到那个模式,等到他们厌倦了一天四十次批准同样五条命令。
代理跑了那条命令。包执行了。凭证离开了你的机器。
你不会在代理的聊天输出里看到这件事。代理不知道发生了。代理只知道安装成功了。恶意代码在 lockfile resolve 和 script run 之间干完了活 —— 这是一个聊天记录不会暴露的阶段。你的终端回滚也不太可能捕捉到,因为代理通常在子进程里跑,对其 stdout 做总结,而不是回显。
一周后,你的 AWS 账单显示一台你从没用过的区域里跑着挖矿程序。或者你的 GitHub Actions secrets 开始被用来发布你自己包的恶意版本。或者你成了下季度”本周供应链攻陷”通讯里的下一条。
你去调查。你实际上手里有什么?
- 代理的聊天记录 —— 不完整、被总结过、容易被覆盖,而且在代理厂商的服务器上
- 你的 shell 历史 —— 只有你打过的,没有代理替你打过的
- git log —— 显示结果,但不显示代理走过的路径
- package-lock.json —— 显示什么被钉住了,但你需要安装之前的 lock 才能知道改了什么
你没有的,恰恰是你最需要的那一样东西:代理在你机器上实际执行的每一条 shell 命令的完整、只追加、本地、按顺序、带时间戳的记录。
这就是缺口。今天就能用大约十行 Python 免费补上。
代理命令审计日志给你什么
每个 Coograph 安装都自带一个位于 .claude/hooks/log-bash.py 的 pre-tool-use 钩子(以及写入同一批文件的 Codex CLI + OpenCode 等效物)。它的全部工作就一件事:把代理即将执行的每条 bash 命令追加到 .coograph/ 下的项目级日志里。这些文件被 gitignore。它们和你的代码放一起。没有任何东西离开机器。代理不必请求。代理甚至不知道钩子在那里。
# .claude/hooks/log-bash.py — minimal shape
import json, sys, datetime, pathlib
event = json.load(sys.stdin)
if event.get("tool_name") == "Bash":
cmd = event.get("tool_input", {}).get("command", "")
log = pathlib.Path(".coograph/session.log")
log.parent.mkdir(parents=True, exist_ok=True)
with log.open("a", encoding="utf-8") as f:
ts = datetime.datetime.utcnow().isoformat()
f.write(f"{ts}\t{cmd}\n")
print(json.dumps({})) # don't block the tool
那个小文件,就是”我们被搞了,完全不知道什么碰过我们的凭证”和”我们知道 npm install @tanstack/react-router@1.169.8 在 2026 年 5 月 11 日 UTC 19:24:11 在 Paul 的笔记本上跑过,这是涉及的凭证范围,轮换掉,往下走”之间的差距。
审计日志不光鲜。它们不防止入侵。它们做的是让对入侵的响应成为可能。没有它,每条供应链头条就变成全公司的消防演习,因为你框不住伤害范围。有了它,消防演习变成十五分钟的 grep。
一份好的代理审计日志该记录什么
如果你自己造一份 —— 或者用 Coograph 的 —— 设计规则很短:
- 每一条命令,不过滤。 那条被攻陷的命令在当时不会看起来有趣。读取时再过滤,不要写入时过滤。
- 只追加、纯文本、本地优先。 SQLite 日志可以。JSONL 文件可以。送到别人那里的日志不可以,因为涉及的凭证里就有能让攻击者删掉你日志的那些。
- 时间戳用 UTC。 等到下一份带六分钟窗口的安全公告到来时,你需要精确到秒匹配它。
- 按项目、gitignore。 你不想要开发者的命令历史被意外提交。你也不想要一份巨大的全局日志;按项目存让爆炸半径的计算更简单。
- 完整命令,不要摘要。 “跑了一次 install”没用。
npm install略好。npm install @tanstack/react-router才是你真正需要的。 - 独立于代理厂商。 如果日志只存在于代理的 UI 里,代理厂商就控制了你的事件响应。方向错了。
Coograph 的 log-bash.py 这六条都做了。我们没发明这个想法。我们主张它应该是底线,而不是一项功能。
如果你在 2026 年 5 月 11 到 12 日之间用过 @tanstack/*
把下面这段当成行动清单。没做完这些步骤之前,别往下读。
- 找出坏版本。 在每个依赖
@tanstack/*的项目里,grepnode_modules/@tanstack/*/package.json找孤立提交字符串79ac49eedf774dd4b0cfa308722bc463cfe5885c。也检查你的包 lockfile 历史 ——git log -p package-lock.json—— 看里面有没有公告列出的版本号。CI 缓存目录也算。 - 轮换受影响机器能看到的每一份凭证。 AWS 密钥(如果你在 EC2 上跑,包括 instance profile)、GCP 服务账户密钥、那台机器能读到的 Kubernetes 服务账户令牌、Vault 令牌、GitHub PAT(包括 gh CLI 的)、npm 令牌(
~/.npmrc)、以及~/.ssh/里所有的 SSH 私钥。生成新的。撤销旧的。更新所有引用它们的地方。 - 检查你的 CI runner。 自托管 runner 是价值最高的目标。如果一个 runner 拉过坏版本,把整个 runner 都当作被攻陷,而不只是触发安装的那个项目。
- 检查发布的产物。 如果你的 CI 发布到 npm、Docker Hub、PyPI 或别处,审计最近的发布看有没有不对劲的东西。蠕虫通过被偷的令牌重新发布来扩散。
- 找横向移动。 搜索你 GitHub 组织的审计日志,看 5 月 11 日之后几天里有没有意外的工作流运行、新协作者、加到账户上的新 SSH 密钥、或新的个人访问令牌。
- 然后升级。 升到公告里的修复版本。修复版本本身不够 —— 凭证已经没了 —— 但你还是要升。
如果你在受影响的机器上没有代理命令日志,你就没办法有信心地完成第 1 步。这就是教训。
更大的格局:你的开发循环里的信任边界
TanStack 被攻陷是一条曲线上的一个数据点。两年前我们担心单个 npm 维护者的账户被钓鱼。一年前我们担心 typosquatted 的包。今年攻击者已经进到 CI 流水线里了,重放 OIDC 令牌,把构建缓存跨过没人画在图上的信任边界武器化。
与此同时,我们的开发环境强大了一个数量级。AI 编程代理现在替你读、写、执行。它们每天做出几百个原本需要一次按键的决定。每一个这样的决定都是一次潜在的信任移交:从你到代理,从代理到 shell 命令,从 shell 命令到一个包,从包到一台远程服务器。
你没法消除这个表面。AI 编程代理的生产力增益是真的。回到没有它们的世界是没竞争力的。但你可以 —— 而且到现在为止你必须 —— 确保代理采取的每一个动作都按你的方式可观察,按你的留存策略,在你的硬件上。
这就是为什么 Coograph 默认在每个项目上自带一份代理命令日志,gitignore,本地优先。我们不是在卖你一个功能。我们是在陈述一条底线。
我们没在说什么
把主张说精确:
- 我们没在说 TanStack 失职。他们开了 2FA。他们要求 MFA 发布。他们 CI 用
pull_request_target的原因,跟大多数用它的团队是一样的。他们的事后报告诚实有用。 - 我们没在说 AI 编程代理特别不安全。它们特别能干,这使得底层供应链攻陷的后果更大更快。
- 我们没在说审计日志能阻止这次攻击。你开发者机器上没有任何东西能阻止这次攻击。日志是让清理成为可能的东西。
我们在说的更简单:如果你的团队在跑 AI 编程代理,而你没有一份本地的、只追加的、按项目的、记录代理跑过每条 shell 命令的日志,你就是在没有最便宜、最乏味、最显然合理的事件响应遥测数据的情况下运营。这周就把它补上。
在你的项目上试试
如果你想要 Coograph 的版本,把仓库克隆到你项目旁边,然后在你的 AI 工具聊天里跑初始化器:
# from your project root
git clone https://github.com/paullukic/coograph.git ../coograph
然后,在你选择的 AI 工具里:
- Claude Code、Cursor、Copilot、OpenCode、Windsurf、Aider、Cline: 输入
/coograph-init - Codex CLI: 输入
$coograph-init(Codex 把/留给内置命令)
初始化器会探测你的技术栈,把 bash 审计钩子复制到 .claude/hooks/log-bash.py(外加 Codex CLI + OpenCode 用户的 .codex/hooks/log-bash.py 和 .opencode/plugin/log-bash.ts),接好斜杠命令工作流,并可选构建代码图。大约两分钟。MIT 许可。日志在你仓库的 .coograph/ 里。我们看不到。
完整流程见 coograph.com/docs/getting-started/。
如果你不想要 Coograph,把上面那十行 Python 钩子移植到你自己的 .claude/hooks/ 目录(Claude Code),或者 Cursor / Codex CLI / Windsurf 的等效钩子表面。重点不是工具。重点是日志。
资料来源
- GitHub 安全公告 GHSA-g7cv-rxg3-hmpx
- CVE-2026-45321
- TanStack 事后报告:npm 供应链攻陷
- Snyk:TanStack npm 包遭 Mini Shai-Hulud 攻击
- Endor Labs:Shai-Hulud 攻陷 @tanstack 生态
- The Hacker News:Mini Shai-Hulud 蠕虫攻陷 TanStack、Mistral AI、Guardrails AI 等包
- TanStack/router issue #7383:多个 npm latest 版本被攻陷
削减你的 AI 编程账单 30–80%。Coograph 采用 MIT 许可、永久免费。Pro 提供定制服务。