TanStack 2026年 pull_request_target とキャッシュの汚染からの公開

保守者の誰も騙されず、npm のトークンも盗まれていない。フォークからの PR が基のリポジトリの文脈で走り、公開の CI が後で戻すキャッシュに悪いコードを置いた。正規のマージで走った公開の CI がそれを戻し、実行中の CI のメモリから OIDC のトークンを抜かれた。3つの既知の弱点が、それぞれの信頼の境界を1つずつ越えた。

CVE-2026-45321、GHSA-g7cv-rxg3-hmpx、CWE-506 埋め込まれた悪意あるコード[3]。悪い版は2026年5月11日 19:20 から 19:26 UTC に出た[1]。開発元は2026年5月15日に、Router と Start のリポジトリだけが影響を受け、今取れる版は全部安全だとする終息を出した[1]。GitHub は2026年6月26日に、外の人が起こせる契機のキャッシュを既定のブランチに対して読むだけに変えた[6]。2026年11月時点の公開資料で書いています。

要点

  • 悪い版は42の包みに2つずつ、計84。公開は CI の OIDC の信頼された公開を通り、npm から見れば正規の公開だった。
  • 入口は、PR のサイズを測る CI だった。pull_request_target で走り、フォークのコードを取り出してビルドしていた。権限は読むだけにしていたが、キャッシュの保存は権限の設定に縛られない。
  • キャッシュは、フォークの PR の実行と main への push の実行で同じ置き場を共有していた。悪いコードは、公開の CI が引く鍵の名前で pnpm の置き場を保存させた。
  • 公開の CI は、OIDC のための権限を持っていた。戻されたキャッシュの中のプログラムが、CI の実行役のプロセスのメモリからトークンを抜き、npm へ直接公開した。
  • 根本の直しは、外の人のコードを特権の文脈で走らせないこと、公開の CI で書き込まれうるキャッシュを戻さないこと、公開に人の承認を挟むこと。

何が起きたか

5月10日のフォークの作成から、5月11日の昼のキャッシュの汚染、夜の84の版の公開、20分余り後の報告と、その夜のうちの削除まで 1 用意 5月10日 名前を変えたフォーク 悪いコミット 2 汚す 5月11日 10:49から PR で CI が走り 11:29 キャッシュ保存 3 公開 19:15 公開の CI 19:20 と 19:26 84の版 4 止める 19:46 外から報告 21:03 全部を非推奨 23:55 削除を終える 時刻は UTC。汚してから公開まで約8時間、公開から報告まで約26分 始まりは5月10日、見つかったのは5月11日
図1 用意から削除までの段
日時 UTC出来事
5月10日 17:16攻撃者が TanStack/router をフォークする。フォークの一覧で探されないよう、名前を変えてあった[1]
5月10日 23:29フォークに、約3万行の JavaScript を足す悪いコミットが入る。作者は claude という偽の名前で、メッセージの頭に [skip ci] を付けて push の CI を止めていた[1]
5月11日 10:49頃「WIP: simplify history build」という PR が開かれる。pull_request_target の2つの CI が承認無しで走る。pull_request の CI は、初めての貢献者の承認待ちで走らない[1]
11:11強制 push で悪いコミットが PR の先頭に載る。サイズを測る CI がフォークのコードでビルドし、悪いコードが走る[1]
11:291.1GB の汚れた pnpm の置き場が、main の範囲のキャッシュとして保存される[1]
11:31PR を main の先頭に戻して差分を0にし、閉じてブランチを消す。キャッシュは残る[1]
19:15以前の公開の CI が再実行され、汚れたキャッシュが戻される[1]
19:16保守者が別の正規の PR をマージし、新しい公開の CI が走って、同じキャッシュを戻す[1]
19:20 と 19:262つの実行から、42の包みに1つずつ、計2回の公開が届く。どちらの実行も、試験が落ちて失敗で終わっている[1]
19:46StepSecurity の研究者が issue で報告する。この時点で見えていたのは14の包み[1]
20:10頃保守者が、他のメンバーの GitHub の push の権限を外す[1]
21:00頃から21:30295の包みを全部調べて42の包み、84の版と確定し、21:03 に全部を非推奨にする。原因を特定し、全リポジトリのキャッシュを消し、CI を直す PR をマージし、勧告を出す[1]
22:13 から 23:55npm が登録簿の側で悪い版を消す[1]
5月12日GitHub の勧告 GHSA-g7cv-rxg3-hmpx が公開される[3]。開発元が対策の記事を出す[2]
5月15日開発元が終息を出す[1]
  • 悪い版は、導入の時に optionalDependencies に足された依存を、フォークの網の中の孤立したコミットから取り、その準備のスクリプトで約2.3MBの難読化した JavaScript を走らせる[1]。
  • 集める先は、AWS と GCP のメタデータ、Kubernetes、Vault、~/.npmrc、GitHub のトークン、SSH の秘密鍵。送る先は暗号化された伝言の網で、攻撃者の指令の機械を持たない。被害者が保守する他の包みを調べ、同じ仕掛けで公開し直して広がる[1][3]。
数と呼び名

最初の報告は14の包み、28の版で、これを引いた記事がある。開発元が全部を調べた数は42の包み、84の版である[1][2]。報道では攻撃の集団や作戦の名前が付けられたが、開発元の報告と GitHub の勧告は攻撃者を名指ししていない[1][3]。

通った道筋

開発元は、3つの弱点のどれも単独では足りず、それぞれが他の弱点が前提にした信頼の境界を越えたと書く[1]。

フォークの PR のコードが基のリポジトリのキャッシュに入り、そのキャッシュが公開の CI に入り、公開の CI のトークンが npm への書き込みに使われる。3つの境界を越える フォークの PR 悪いコミット 書き込みの権限は無い 1 サイズを測る CI pull_request_target 基のリポジトリの文脈で、PR のマージ結果を取り出し ビルドする。権限は読むだけ 終わりにキャッシュを保存 Actions のキャッシュ 範囲は main。鍵は pnpm のロックファイルのハッシュ 公開の CI が引く鍵と同じ名前 2 main の CI が戻す 公開の CI release.yml id-token: write。戻した置き場のプログラムが走る 実行役のメモリから OIDC のトークン 3 npm の登録簿 信頼された公開として 受け付ける 公開の手順そのものは、試験が落ちて飛ばされていた。トークンは、試験と後片付けの間に抜かれた
図2 3つの境界を越えた道筋
越えた境界何があったか
1 フォークから基のリポジトリへサイズを測る CI は pull_request_target で走り、PR のマージの参照を取り出して、フォークのコードでビルドした。作った人は、測る仕事と PR へ書き込む仕事を分け、測る方は読むだけの権限で信頼しない扱いにするつもりだった[1]。pull_request_target は初めての貢献者の承認を通らずに走った[1]。
2 PR の実行から main のキャッシュへactions/cache の終わりの保存は、ワークフローの permissions に縛られない。書き込みは実行役の内部のトークンで行われ、contents: read にしても止まらない。pull_request_target の実行は基のリポジトリのキャッシュの範囲を使い、main への push の実行と同じ置き場を共有していた[1]。
3 公開の CI から npm へ公開の CI は、npm の信頼された公開のため id-token: write を持っていた。戻されたプログラムは、実行役のプロセスを探してそのメモリを読み、OIDC のトークンを取り出して、登録簿へ直接公開した。同じ手口と同じスクリプトは、2025年3月の tj-actions/changed-files の件で使われていた[1]。
OIDC は効いていて、それでも通った

開発元は、出どころの証明、SLSA、OIDC、2段階の認証はどれも書かれたとおりに働き、それでもこの攻撃を止めなかったと書く[2]。OIDC の信頼された公開は「どのワークフローか」を縛るが、そのワークフローの中のどの手順かは縛らない。開発元は、設定した後はワークフローの中のどのコードでも公開できるトークンを作れると書いた[1]。

仕組みを架空の作りで示す。example-org/example-lib の CI で、悪い形と直した形を並べる。

悪い例。外の人のコードを特権の文脈でビルドし、キャッシュを保存する

on:
  pull_request_target:          # 基のリポジトリの文脈で走る
jobs:
  size:
    permissions:
      contents: read             # キャッシュの保存は止めない
    steps:
      - uses: actions/checkout@...
        with:
          ref: refs/pull/${{ github.event.pull_request.number }}/merge
      - uses: actions/cache@...  # 終わりに main の範囲へ保存
        with:
          path: ~/.pnpm-store
          key: pnpm-${{ hashFiles('pnpm-lock.yaml') }}
      - run: pnpm install && pnpm build   # PR のコードが走る

直した例。PR のコードは権限の無い pull_request で走らせ、結果だけを渡す

# size.yml 外の人のコードを走らせる側
on:
  pull_request:
jobs:
  size:
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@...  # コミットのハッシュで固定
      - run: pnpm install --ignore-scripts && pnpm build
      - run: du -sb dist > size.txt
      - uses: actions/upload-artifact@...
        with: { name: size, path: size.txt }

# size-comment.yml 書き込みの権限を持つ側。PR のコードを走らせない
on:
  workflow_run:
    workflows: [size]
    types: [completed]
# 成果物を runner.temp に落とし、数字として確かめてから PR に書く

GitHub Security Lab は、信頼しない PR のコードは pull_request で権限の無い所に閉じ、結果を成果物に置いて、書き込みの権限を持つ workflow_run の側で読む形を勧める。成果物も信頼しない物として扱い、特権の側で実行しない[7]。GitHub の手引きも、PR のコードをビルドや実行に使うなら pull_request_target を避けるよう書く[5]。

どう見つかったか

  • 見つけたのは外の研究者で、最初の公開から約26分後に issue で報告した[1]。開発元は、自分の公開を見張る仕組みが無く、外から知ったと書く[1]。
  • 悪いコードが試験を壊したため、正規の公開の手順は飛ばされ、CI は失敗で終わった。開発元は、試験を壊さない攻撃者なら、もっと長く気付かれずに出せたと書く[1]。
  • 依存を持つ包みが多く、npm の決まりで開発元は悪い版を取り下げられなかった。npm の側の削除を待つ間、悪い版は入れられる状態だった[1]。非推奨にするまで公開から約1時間43分、npm の削除の終わりまで約4時間35分かかった[1]。
  • 開発元は、ペイロードの中の、無効化に反応する仕掛けに研究者が気付き、トークンの無効化が始まる前に知らせたことが、被害を大きくしなかった理由だと書く[2]。漏れた鍵を回す前に、悪い版が何を見張っているかを確かめる。

後に続いた被害

悪い版は、入った端末の GitHub の資格情報を集める。その先で次の被害が出た。

TanStack の悪い版で Nx の開発者の GitHub の資格情報が漏れ、Nx Console の悪い版が出て、GitHub の従業員の端末が侵害された TanStack の悪い版 5月11日 gh の資格情報が漏れる Nx Console 18.95.0 5月18日 約18分と約36分 GitHub の従業員 5月18日に検知 社内のリポジトリ 左から中は Nx の勧告、中から右は GitHub の発表がその勧告を結ぶ
図3 TanStack から GitHub の社内のリポジトリまで
  • Nx は、自社の開発者の1人が TanStack の件で侵害され、GitHub CLI の資格情報が漏れ、攻撃者が貢献者としてリポジトリのワークフローを走らせられたことを原因に挙げた。悪い Nx Console 18.95.0 は、Visual Studio Marketplace に約18分、OpenVSX に約36分あった[9]。
  • Nx は、それまで1人で承認無しに Nx Console を出せた形を、2人の管理者の承認が要る形に変えた[9]。
  • GitHub は5月20日に、第三者が出した毒入りの VS Code の拡張で従業員の端末が侵害され、5月18日に検知して封じ込めたと発表した。その拡張として Nx Console の勧告を結んでいる。持ち出されたのは GitHub の社内のリポジトリで、攻撃者の言う約3,800のリポジトリは調査と大筋で合うと書く。5月26日の更新では、GitHub Enterprise Server の署名の鍵を回すと告げ、調査は続いているとした[10]。

どこで止められたか

PR の CI、キャッシュ、公開の CI、公開の承認、公開の見張りの5つの段に、それぞれ止める所を置く PR の CI キャッシュ 公開の CI 公開の承認 公開の見張り 1 外のコードを特権の文脈で走らせない pull_request_target で PR のコードを取り出さない 2 公開の CI は汚されうるキャッシュを戻さない 保存と復元を分け、公開ではキャッシュを使わない 3 トークンを作る仕事を小さく分ける id-token は公開の仕事だけ。ビルドと試験に渡さない 4 公開に人の承認を挟む 承認の要る環境、npm の段階の公開 5 自分の公開を見張る 決めた手順以外からの公開に、すぐ警報を出す
図4 段ごとの止める所
止める所この件で限り
外のコードを特権の文脈で走らせない開発元は、3年以上前から GitHub が危ない形として示していた pull_request_target を使っていたことを根の原因とした[2]workflow_run の側で成果物を実行すれば、同じ穴が開く[7]
公開の CI は汚されうるキャッシュを戻さない公開の CI は、PR の実行が保存した置き場をそのまま戻した[1]GitHub は2026年6月26日に、外の人が起こせる契機の既定のブランチへのキャッシュを読むだけにした[6]。設定で書き込みを明示すれば汚染の危険が戻る[4]
トークンを作る仕事を小さく分ける戻した置き場のプログラムが、id-token の権限を持つ実行の中で走った[1]公開の仕事の中で依存を入れて動かせば、同じこと
公開に人の承認を挟むOIDC の公開に、版ごとの見直しが無かった[1]承認する人が中身を見なければ、承認は通るだけ
自分の公開を見張る失敗で終わった CI から公開が出ていたが、開発元は外の報告で知った[1]鳴っても、npm の側の削除まで時間がかかる[1]

守り方

根本の直し方
  • pull_request_target と、外の人が起こせる契機で、PR のコードを取り出してビルドや実行をしない。開発元は事故の後、CI から pull_request_target を全部外し、基のリポジトリの権限が要る処理は、閉じた pull_request の成果物を workflow_run で読む形にすると書いた[2]。
  • 公開の CI では、外の人の実行が書き込めたキャッシュを戻さない。GitHub の手引きは、キャッシュの中身は署名も検証もされず、外の人が起こせる実行で書き込みを許すと汚染の危険が戻ると書く[4]。開発元は公開の流れで pnpm のキャッシュを止め、影響を受けたワークフローのキャッシュを外した。今後は、終わりに自動で保存しない復元だけの形へ移すと書く[2]。
  • id-token: write を、公開だけをする小さな仕事に閉じる。ビルドと試験は別の仕事で権限無しに走らせ、公開の仕事は成果物を受け取って出すだけにする。
  • 公開に人の承認を挟む。npm は、承認の要る環境と、信頼された公開を段階の公開だけに絞ることを勧め、トークンを認めない設定と合わせれば最も強いと書く[8]。開発元は、OIDC の公開を見直しのある形にするか、決めた手順以外からの公開を見つける仕組みを足す、と課題に挙げた[1]。

公開の仕事を分けた架空の release.yml

jobs:
  build:
    permissions:
      contents: read             # id-token を持たない
    steps:
      - uses: actions/checkout@...
      - run: pnpm install --frozen-lockfile && pnpm test && pnpm pack
      - uses: actions/upload-artifact@...

  publish:
    needs: build
    environment: npm-publish     # 承認者の許可が要る
    permissions:
      contents: read
      id-token: write            # この仕事だけ
    steps:
      - uses: actions/download-artifact@...
      - run: npm publish ./example-lib-*.tgz   # 依存を入れず、キャッシュも戻さない
  • build の仕事が汚されても、そこに OIDC のトークンは無い。publish の仕事は依存を入れずキャッシュも戻さないので、他人のプログラムが走る余地が小さい。成果物の中身が汚されている危険は残るので、承認の前に中身の差を見る。
緩和の策
  • 外部のアクションを、コミットのハッシュで固定する。開発元は事故の後、組織の全部のアクションをハッシュで固定した[2]。考え方は A03 ソフトウェアサプライチェーンの不備 の型3にある。
  • ワークフローの静的な検査を PR の必須の確認にする。開発元は zizmor を入れ、pull_request_target とフォークの取り出しの組を事前に拾えた可能性があると書く[2]。
  • .github を CODEOWNERS で中心の保守者に限る。ワークフローは鍵を持って走るコードで、リポジトリで最も特権の強いコードとして扱う[2]。
  • npm と GitHub の2段階の認証から SMS を外し、公開の権限を持つ人の数を絞る。開発元は、7人の保守者は7つの盗まれうる的だと書いた[1][2]。
  • 使う側は、導入時のスクリプトを止め、出たばかりの版を入れない。開発元は、導入の待ち期間を持つ pnpm 11 に全リポジトリを上げ、根本の直しではないが時間を稼ぐと書く[2]。
  • 悪い版を入れた端末と CI は、届く範囲の AWS、GCP、Kubernetes、Vault、GitHub、npm、SSH の資格情報を全部回す[1]。順は 秘密の鍵の扱い の「漏れたときの順」に沿う。

AIの名前のコミット

悪いコミットの作者は claude という偽の名前で、GitHub の no-reply の宛先を使っていた。開発元は、本物の Anthropic の Claude ではないと注記している[1]。

  • コミットの作者の欄は、誰でも書ける。AIの道具の名前が付いていることは、そのコミットを信じる理由にならない。誰が push し、誰が PR を開いたかを見る。
  • AIのコーディングの道具は、CI の定義を書き、pull_request_target のような契機を選び、キャッシュを足す。CI の定義の差分は、鍵を持って走るコードの差分として、人が読んでから入れる。見方は AIの出力を確かめる にある。
  • CI の定義で使わない契機と、公開の仕事の形を、リポジトリの決まりとして文書に残す。置き方は 決まりを文書に残す にある。

取り込む物を使う直前に確かめる考え方は A08 ソフトウェアまたはデータの完全性の不備 にある。

参照

  1. Postmortem: TanStack npm supply-chain compromise TanStack、2026年5月11日、5月15日更新
  2. Hardening TanStack After the npm Compromise TanStack、2026年5月12日、5月15日更新
  3. GHSA-g7cv-rxg3-hmpx Malware in @tanstack/* packages GitHub Advisory Database、2026年5月12日。CVE-2026-45321
  4. Dependency caching reference GitHub Docs
  5. Events that trigger workflows, pull_request_target GitHub Docs
  6. Read-only Actions cache for untrusted triggers GitHub Changelog、2026年6月26日
  7. Keeping your GitHub Actions and workflows secure: Preventing pwn requests GitHub Security Lab
  8. Trusted publishing for npm packages npm Docs
  9. GHSA-c9j4-9m59-847w Compromised Nx Console version 18.95.0 Nx、2026年5月18日、5月21日に原因を追記
  10. Investigation update: GitHub Enterprise Server signing key rotation GitHub、2026年5月20日、5月26日更新