· Paul Lukic · 3 分钟阅读 · securitysupply-chainai-coding-agentsnpmaudit-log

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 流水线上的三个弱点串了起来:

  1. pull_request_target Pwn Request。 当 fork 发起 PR 时,GitHub 通常在 fork 的安全上下文中跑 CI —— 没有 secrets。但 pull_request_target 是一种工作流触发器,会在基础仓库的上下文中运行,拥有完整的 secret 访问权。TanStack 出于看起来合理的原因用了它。攻击者提交了一个滥用该触发器的 PR。
  2. 跨 fork/base 信任边界的缓存投毒。 GitHub Actions 在不同运行之间缓存依赖以加速构建。攻击者找到了一条从fork 构建写入基础仓库缓存的路径,埋下一个会在下次合法 CI 运行时执行的载荷。
  3. 从 runner 进程里抽取 OIDC 令牌。 GitHub CI 运行时会拿到一个短时 OIDC 令牌,用来在发布时对 npm 鉴权。攻击者的代码没有只是从环境变量里偷令牌 —— 而是从 runner 的内存里把它倒出来。

一旦拿到 OIDC 令牌,他们就开始发布。每个包发了两个恶意版本,大约相隔六分钟。第一个版本是探测,第二个才装着真正的载荷。等到 TanStack 注意到时,那个蠕虫 —— TeamPCP 称之为 Mini Shai-Hulud —— 已经流出,并通过收割任何跑了 npm install 的人的令牌向其他包扩散。

这就是过去两年里一直在啃食 JavaScript 生态的供应链攻击模式。TanStack 不是第一个。甚至并不罕见。罕见的是这条攻击链有多干净:攻击者没攻陷一个人。他们攻陷的是人类信任的 CI 系统。

受影响的包和版本

GitHub 的安全公告列出 42 个被攻陷的包,总共 84 个版本。从公告里挑一部分:

  • @tanstack/react-router1.169.51.169.8(已修复:1.169.9
  • @tanstack/router-cli1.166.461.166.49(已修复:1.166.50
  • @tanstack/vue-router1.169.51.169.8(已修复:1.169.9
  • @tanstack/solid-start1.167.651.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_TOKENGH_TOKEN
  • gh CLI 存储的凭证
  • ~/.git-credentials
  • ~/.ssh/ 下的所有 SSH 私钥

然后把收获送到一组 Session/Oxen messenger 端点 —— filev2.getsession.orgseed{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 resolvescript 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 的 —— 设计规则很短:

  1. 每一条命令,不过滤。 那条被攻陷的命令在当时不会看起来有趣。读取时再过滤,不要写入时过滤。
  2. 只追加、纯文本、本地优先。 SQLite 日志可以。JSONL 文件可以。送到别人那里的日志可以,因为涉及的凭证里就有能让攻击者删掉你日志的那些。
  3. 时间戳用 UTC。 等到下一份带六分钟窗口的安全公告到来时,你需要精确到秒匹配它。
  4. 按项目、gitignore。 你不想要开发者的命令历史被意外提交。你也不想要一份巨大的全局日志;按项目存让爆炸半径的计算更简单。
  5. 完整命令,不要摘要。 “跑了一次 install”没用。npm install 略好。npm install @tanstack/react-router 才是你真正需要的。
  6. 独立于代理厂商。 如果日志只存在于代理的 UI 里,代理厂商就控制了你的事件响应。方向错了。

Coograph 的 log-bash.py 这六条都做了。我们没发明这个想法。我们主张它应该是底线,而不是一项功能。

如果你在 2026 年 5 月 11 到 12 日之间用过 @tanstack/*

把下面这段当成行动清单。没做完这些步骤之前,别往下读。

  1. 找出坏版本。 在每个依赖 @tanstack/* 的项目里,grep node_modules/@tanstack/*/package.json 找孤立提交字符串 79ac49eedf774dd4b0cfa308722bc463cfe5885c。也检查你的包 lockfile 历史 —— git log -p package-lock.json —— 看里面有没有公告列出的版本号。CI 缓存目录也算。
  2. 轮换受影响机器能看到的每一份凭证。 AWS 密钥(如果你在 EC2 上跑,包括 instance profile)、GCP 服务账户密钥、那台机器能读到的 Kubernetes 服务账户令牌、Vault 令牌、GitHub PAT(包括 gh CLI 的)、npm 令牌(~/.npmrc)、以及 ~/.ssh/所有的 SSH 私钥。生成新的。撤销旧的。更新所有引用它们的地方。
  3. 检查你的 CI runner。 自托管 runner 是价值最高的目标。如果一个 runner 拉过坏版本,把整个 runner 都当作被攻陷,而不只是触发安装的那个项目。
  4. 检查发布的产物。 如果你的 CI 发布到 npm、Docker Hub、PyPI 或别处,审计最近的发布看有没有不对劲的东西。蠕虫通过被偷的令牌重新发布来扩散。
  5. 找横向移动。 搜索你 GitHub 组织的审计日志,看 5 月 11 日之后几天里有没有意外的工作流运行、新协作者、加到账户上的新 SSH 密钥、或新的个人访问令牌。
  6. 然后升级。 升到公告里的修复版本。修复版本本身不够 —— 凭证已经没了 —— 但你还是要升。

如果你在受影响的机器上没有代理命令日志,你就没办法有信心地完成第 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 的等效钩子表面。重点不是工具。重点是日志。

资料来源

削减你的 AI 编程账单 30–80%。Coograph 采用 MIT 许可、永久免费。Pro 提供定制服务。