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 提供客製服務。