2026年8月29日未明(JST)、週15万ダウンロードを超えるnpmパッケージ@7nohe/openapi-react-query-codegenに、悪性コードを含む10個のバージョンが公開された。約21分間の出来事だ。侵害の起点はnpmアカウントの乗っ取りでもトークンの窃取でもない。GitHub Actionsのワークフローが、PRのコメント欄にnpm publishと書き込むだけで、誰でも発火する状態になっていた。
- ・対象:
@7nohe/openapi-react-query-codegen(npm・週151,093DL)。これ以外のパッケージは対象ではない。 - ・悪性バージョン(10個):
0.5.4/0.5.5/1.6.3/1.6.4/2.2.1/2.2.2/3.0.3/3.0.4/0.0.0-365d4eb…/0.0.0-ec7876d6… - ・安全な版:
3.0.2(2026-08-11公開)およびそれ以前 - ・危険だった時間帯:2026-08-29 05:00〜08:11 JST(約3時間11分)。この時間帯に取得していなければ、本件による直接の影響は考えにくい
- ・現在:10版とも npm から unpublish 済み。新規インストールで悪性版は入らない
- ・ただし:削除は配布を止めるだけ。露出時間帯に入れた
node_modules・lockファイル・キャッシュは残る - ・原因:
release.ymlのissue_commentトリガー。コメント本文がnpm publishに一致するかだけを見て、投稿者の権限を検査していなかった。Trusted Publishing 移行済みだったがトークンは盗まれていない——正規に発行された資格情報が攻撃者のコードに使われた - ・検知の要点:10版中8版に
binding.gypとペイロードを同梱し、うち4版はインストールスクリプトを持たない。package.jsonのscriptsだけ見ると取り逃す(--ignore-scriptsはinstall 時にはこの経路を止めるが、後のnpm rebuild/npm ciでは起動する。10条件の実測は本文) - ・発生直後は
npm auditで検知できなかった(現在はGHSA-9pvf-vcx3-x239/ CVSS 9.6 が発行済み)
npmサプライチェーン攻撃全体の防御フレームワークと恒久対策についてはサプライチェーン攻撃とは|手口・防御ツール比較・npm 12の新機構まで実践解説をご覧ください。
- ・確かめたこと:npmレジストリの packument(公開時刻・バージョン数・dist-tags)、tarball のファイル一覧と
package.json、ペイロードファイルのサイズとSHA-256、GitHub API 上の workflow・PR・Issue・コミット、npm viewによるバージョン解決、および--ignore-scriptsの挙動(自作の無害なパッケージで実施) - ・確かめていないこと:ペイロードの実行・動的解析・逆難読化。何を窃取するかの内部挙動は本記事の一次検証の対象外で、他者の解析を引用する箇所はその旨を明記する。またセキュリティベンダー各社の解析(Socket・JFrog・Endor Labs 等)は他者の解析としてそのつど出典を示して引用しており、当サイトが独立に再現したものではない(初版時点で 403 だった Socket のページは、2026-09-01 の追記時に取得できた)
- ・やっていないこと:該当パッケージのインストール(
npm install/npx/yarn/pnpmのいずれも実行していない)。攻撃コードは再現可能な形で掲載しない
npm publishコメントから最初の悪性版公開まで88秒、削除までの露出は3時間11分。筆者がGitHub APIとnpmレジストリのtimeフィールドから再構成した。初版公開の翌日以降に各社の解析が出そろい、1点の訂正と3点の追記を行いました。
・【訂正】
--ignore-scripts で導入した後に「素の npm install をやり直すと実行される」と書いていましたが、実測では再現しませんでした。実際に起動するのは npm rebuild と npm ci です(→「実測し直した節」)・【追記1】JFrog は「
--ignore-scripts では止まらない」、Endor Labs は「止まる」と正反対の記述をしました。陽性対照つきで10条件を測り直しています・【追記2】本件の呼び名(Shai-Hulud / Mini Shai-Hulud / Trinitite)の関係と、攻撃2日前のTeamPCP逮捕を踏まえた帰属の扱い
・【追記3】常駐(永続化)の痕跡を探す確認手順と、PyPI・RubyGems の「狙われた」と「汚染された」の区別
何が起きたか——21分間で10バージョンが公開されるまで
@7nohe/openapi-react-query-codegenは、OpenAPIスキーマからTanStack Query(React Query)のフックを生成するコードジェネレータだ。GitHubスター428、npmの直近1週間のダウンロード数は151,093(npm downloads APIの実測、2026-08-21〜08-27)。正規のリリースは3.0.2(2026-08-11)で止まっており、その17日後に10版が21分足らずで出現した。
流れはこうだ(JST。公開時刻はnpmレジストリのtimeフィールド、その他はGitHub APIから取得)。
・04:59:07/04:59:15 — アカウントp00pabootがPR #215を作成し、npm publish とだけコメント
・05:00:43〜05:02:08 — 1波目5版を公開(コメントから88秒)
・05:17:24/05:17:43 — PR #216で同じ手順を反復
・05:18:44 — 外部の報告者がIssue #217で通報
・05:19:29〜05:20:53 — 2波目5版を公開(106秒)
・05:29:59 — p00pabootが通報スレッドに「Seems fine to me. Stop spreading misinformation.」と書き込み、通報を否定
・07:50:03 — メンテナがワークフローを修正(コミット8330895b4)
・08:11:37 — npm側が10版を削除/08:27:18 — GHSA-9pvf-vcx3-x239(CVSS 9.6)公開
コメントから公開までが88秒・106秒という短さは、人手を介さず自動で公開まで到達していたことを示している。
本件は単発の事故として扱われていない。Socket および Endor Labs は本件を「Mini Shai-Hulud」系のキャンペーンの一部として追跡していると報告されている。同系統のキャンペーンについては、認証情報の窃取と、盗んだトークンを使った他パッケージ・他レジストリへの自己増殖につながる挙動が各社の解析で報告されている。
ただし第2段ペイロードの挙動は各社とも断定していない(Socket は解析継続中としている)。筆者はペイロードを実行も逆難読化もしていないため、この段落は他社報告の紹介であって本記事の一次検証結果ではない。 なお筆者の環境からは Socket のページが 403 で取得できず、本段落の内容を一次ソースから直接確認できていない点も付記しておく(詳細は末尾の調査方法を参照)。
そして、どちらのPRもマージされていない。 GitHub APIのmergedはfalse、merged_atはnull。レビュー欄もcoderabbitaiボットのコメント1件だけで、人間のレビューも承認も無い(PR #215)。
つまり「悪意あるPRがレビューをすり抜けてマージされた」事案ではない。マージは最初から不要で、コメント1行でワークフローが起動しPRのコードがそのまま公開された。「PRを丁寧にレビューしていれば防げた」という教訓は、この事案には当てはまらない。 見るべきだったのは差分ではなく、ワークフローの起動条件のほうだ。
攻撃者アカウントp00pabootは攻撃の約14時間半前(2026-08-28 14:28 JST)に作成され、フォロワー0・公開リポジトリ1件・bio無し。既存コントリビュータに似せるtyposquat型ではなく使い捨てで、経歴の水増しも無い。裏を返せば、この経路はアカウントの信用を一切必要としなかった。なおPRの差分はfork削除により取得できない(files・commitsとも0。「変更が無かった」ことを意味しない)。
なぜ「Trusted Publishing移行済み」でも防げなかったのか
このリポジトリは2026年3月にnpmのTrusted Publishing(OIDCベースの信頼公開)へ移行済みだった。permissionsにid-token: writeがあり、長期のnpmトークンをSecretsに置かない構成——「publishトークンの窃取」対策として推奨される、まさに正しい移行である。
それでも侵害された。Trusted Publishingが守るのは「資格情報が盗まれないこと」であって、「誰がワークフローを起動できるか」ではないからだ。
修正前のrelease.ymlは、push(タグ)に加えてissue_commentでも発火していた。ジョブの実行条件はこうだ。
on:
push:
tags: ['v*']
issue_comment:
types: [created]
jobs:
release:
if: ${{ github.event_name == 'push' || (github.event.issue.pull_request && github.event.comment.body == 'npm publish') }}
条件は「PRへのコメントであること」と「本文がnpm publishと完全一致すること」の2つだけだ。author_association(投稿者がOWNER/MEMBER/COLLABORATORか)を検査していない。issue_commentはパブリックリポジトリであれば誰のコメントでも発火し、コメントの投稿者権限はイベントのcomment.author_associationで判別できる(GitHub Docs: issue_comment)。この条件式はそれを見ていない。つまりこの時点で、publish権限は事実上インターネット全体に開放されていた。修正前のワークフロー全文はコミット8330895b4の差分で確認できる。
さらに悪いことに、ワークフローはissue_comment経由のとき、対象PRのコードを明示的にcheckoutしていた。
- name: ⬇️ Checkout PR
if: ${{ github.event_name == 'issue_comment' }}
run: |
git fetch origin pull/${{ github.event.issue.number }}/head:pr-find-commit
git checkout pr-find-commit
この2つが揃うと何が起きるか。外部の誰かが送りつけたコードを、リポジトリ自身のpublish資格情報で、npmに公開する装置になる。攻撃者はトークンを盗む必要がなかった。Trusted Publishingでは、ワークフロー実行時にOIDCトークンと引き換えに短命の公開資格情報が発行される(npm Docs: Trusted publishing)。その正規の発行手順を、攻撃者のコードが載ったジョブの中で走らせただけだ。
fork から PR を作成"] --> B["② PR に npm publish と
コメントするだけ"] B --> C["③ release.yml が発火
条件は本文の文字列一致のみ
author_association は未検査"] C --> D["④ ワークフローが
PR のコードを checkout"] D --> E["⑤ OIDC で publish 資格情報を取得
Trusted Publishing の正規手順"] E --> F["⑥ pnpm publish
npm に悪性版が公開される"]
メンテナによる修正コミットのメッセージはfix(ci): remove the unauthenticated issue_comment publish trigger(認証されていないissue_comment publishトリガーを削除)であり、原因の認識が一致している。修正後はpush(タグ)のみに戻り、issue_commentブロックとissues: write権限が削除され、checkoutにpersist-credentials: falseが追加された。
この構図は、以前に扱ったTanStackサプライチェーン攻撃の根本原因はpull_request_target|PostHog・Nx・LiteLLMも同じ穴と近縁だが、決定的な差がある。TanStackの事例では攻撃者はランナーのメモリからOIDCトークンを窃取する必要があった。今回は窃取が不要で、ワークフローが自発的に公開まで実行している。防御の難度としては今回のほうが低く、攻撃の再現性は高い。
binding.gypが選ばれた理由——install scriptなしでコードが走る
10バージョンの中身は一様ではない。筆者がtarballを取得し、展開せずにファイル一覧を取る方法(tar -tzf)と、package.jsonの読み出しだけで分類した結果が以下だ。
| バージョン | 公開時刻(JST) | binding.gyp | ペイロード同梱 | インストールスクリプト |
|---|---|---|---|---|
0.5.4 |
05:00:43 | あり | あり | なし |
1.6.3 |
05:00:48 | あり | あり | なし |
2.2.1 |
05:00:53 | あり | あり | なし |
0.0.0-365d4eb… |
05:01:03 | なし | なし | preinstall(未同梱ファイルを参照) |
3.0.3 |
05:02:08 | あり | あり | なし |
3.0.4 |
05:19:29 | あり | あり | preinstall |
1.6.4 |
05:19:38 | あり | あり | preinstall |
2.2.2 |
05:19:41 | あり | あり | preinstall |
0.0.0-ec7876d6… |
05:20:13 | なし | なし | preinstall(未同梱ファイルを参照) |
0.5.5 |
05:20:53 | あり | あり | preinstall |
package.jsonを読んで分類した結果。読み取れることが3つある。
1つ目。1波目の4版(0.5.4・1.6.3・2.2.1・3.0.3)はpackage.jsonにインストールスクリプトを一切持たない。 それでもnpm install時にコードが走りうる。npmはパッケージにbinding.gypがあると、ネイティブ拡張をビルドするための既定のinstallスクリプトとしてnode-gyp rebuildを割り当てる(npm Docs: scripts)。これはnpmの正規の挙動で、binding.gyp自体は何万ものネイティブモジュールが使う正当なファイルだ。
binding.gypはGYPの設定ファイルで、そのconditionsブロックはPython実装のGYPによって式として評価される。攻撃者はここにPythonの式を書き、外部プロセスを起動させていた。識別子はすべてUnicodeエスケープで難読化されており、単純な文字列検索を避ける作りになっている。
「
binding.gyp があれば必ずコードが実行される」わけではない。実行に至るかどうかはパッケージマネージャとそのバージョン、インストール時のオプション、ビルド設定に左右される——実際、後述のとおり --ignore-scripts を付けた npm では起動せず、pnpm 10 は既定で起動しなかった。ここで述べているのは「この悪性パッケージでは、この経路が実際に悪用された」ということであって、「binding.gyp を含むパッケージは一般に危険」ということではない。binding.gyp の存在はこの事案の検知シグナルとして使える(正規版 3.0.2 には無い)が、他パッケージの危険度判定にそのまま流用できる指標ではない。つまりpackage.jsonのscriptsを目視して「インストールスクリプトが無いから安全」と判断すると取り逃す。一方で--ignore-scriptsはこの経路を止める(後述の実測)。この手口自体は既知で、当サイトではnpmワーム「Phantom Gyp」|binding.gypで監視を回避し57パッケージを2時間で汚染で仕組みを詳述している。今回はそれがCI/CDの設定不備と組み合わされた点が新しい。
--ignore-scriptsはこの経路を止めるのか——ベンダー間で記述が割れたので測り直した
ここは本記事の公開後に、ベンダーの見解が正面から割れた論点だ。
・JFrog(2026-08-30)は「--ignore-scripts は preinstall フックをスキップするが、node-gyp は依然として binding.gyp を評価して同じファイルを実行しうる」と書いている(Shai-Hulud Trinitite)。つまりフラグでは止まらないという立場だ。
・Endor Labs(同時期)は逆に「npm install --ignore-scripts は preinstall をブロックする」とし、緩和策として「インストールスクリプトを既定で無効化せよ」と勧めている。
同じ事案の解析で、同じフラグの評価が真逆になっている。当サイトの初版は「止まる」と書いたので、名指しのベンダーと食い違ったまま放置はできない。陽性対照を付けて測り直した。
binding.gyp を持つ無害な自作パッケージをローカル tarball から導入し、node-gyp が起動したかどうかだけを見る。sources に存在しない .cc ファイルを指定してあるので、node-gyp が起動すれば必ず失敗して sh -c node-gyp rebuild と gyp info の行がログに残る——起動したことが目に見える作りにした。該当パッケージのインストールも、ペイロードの実行・逆難読化も行っていない。
| 条件 | node-gypの起動 |
意味 |
|---|---|---|
npm install(既定) |
起動した | 陽性対照。ログに sh -c node-gyp rebuild が出る |
npm install --ignore-scripts |
起動しなかった | install 時には止まる |
.npmrc に ignore-scripts=true |
起動しなかった | 同上 |
npm ci --ignore-scripts |
起動しなかった | 同上 |
npm rebuild(--ignore-scripts導入後) |
起動した | 後から実行される |
npm ci(フラグ無し・--ignore-scripts導入後) |
起動した | 木を作り直すため |
素のnpm installをやり直す(同上) |
起動しなかった | 既存の木は建て直されない |
npm install(preinstallを宣言した版) |
起動しなかった | 後述。既定の割り当て自体が起きない |
pnpm add(既定) / pnpm 10.29.3 |
起動しなかった | Ignored build scripts と表示される |
pnpm add --ignore-scripts |
起動しなかった | 同上 |
結論を分けて書く。
① npm install の時点では、--ignore-scripts はこの経路を止める。 npm は binding.gyp を持つパッケージに既定の install スクリプトとして node-gyp rebuild を割り当てる仕様で、これはライフサイクルスクリプトそのものだ。実測でもログに sh -c node-gyp rebuild と出ており、--ignore-scripts はこの既定スクリプトも対象にする。npm 11.1.0 では JFrog の記述は再現しなかった。
② ただし「止まった」は「消えた」ではない。 --ignore-scripts を付けてもtarballの展開は行われるので、binding.gyp とペイロードは node_modules に残る。その木に対して npm rebuild を打つか、npm ci で作り直すと、そこで node-gyp が起動する。フラグは無効化ではなく先送りだ。
【筆者の分析】この②が、JFrogの記述が実務的に警告として正しい部分だと考えている。「--ignore-scripts を付けたから対処済み」と締めると、後続のビルド手順で実行されうる。ただし「install 時に走る」と読める書き方は、少なくとも npm 11.1.0 では確かめられなかった——両者は「いつ走るか」で食い違っており、どちらかが全面的に誤りというより時点の限定が抜けているとみるのが妥当だ。JFrogが別のバージョンやパッケージマネージャで観測した可能性は排除できない。
本記事の初版は「後から素の
npm install をやり直したり、別のビルド手順で node-gyp を呼べば、そこで実行される」と書いていました。このうち「素の npm install をやり直す」は、実測では再現しませんでした——npm は既に展開済みの木を建て直さないため、node-gyp は起動しません。実際に起動したのは npm rebuild と npm ci(フラグ無し)です。お詫びして訂正します。③ 補足として、preinstall を宣言すると binding.gyp 経路は npm 上では使われない。 npm公式ドキュメントは「binding.gyp がパッケージのルートにあり、自分で install または preinstall スクリプトを定義していない場合、npm は install コマンドを既定で node-gyp rebuild にする」と明記している(npm Docs: scripts)。実測でも、preinstall を宣言した版では node-gyp は起動しなかった。
【筆者の分析】これを本件の版構成に当てはめると、binding.gyp と preinstall の両方を積んだ4版では、binding.gyp は npm 経由では発火しない——実行を担っていたのは preinstall の側だ、ということになる。逆にbinding.gypだけの4版では、binding.gyp が唯一の経路になる。攻撃者が両方を積んだ版を用意した理由をこれで説明できるとは限らない(npm以外のクライアントやビルド経路を狙った可能性もあり、意図を示す一次情報は無い)。ただし少なくとも、「両方あるから二重に確実」という単純な読み方は npm 上では成り立たない。なお既定の割り当てを抑止するのは install または preinstall のどちらかの宣言なので、4版が仮に install を使っていたとしても結論は変わらない(表の分類は初版時点で package.json を読み出した結果にもとづく。該当版はunpublish済みのため再取得はできない)。
2つ目。2波目の4版はbinding.gypとpreinstallの両方を持つ。 初版では「確実性を上げにいったとも読める」と書いたが、上の実測(③)のとおりnpm では両方あっても発火するのは preinstall の側だけで、少なくとも npm 上では「二重化」になっていない。【筆者の分析】npm 以外のクライアントやビルド経路を狙った可能性、あるいは単に1波目のテンプレートに preinstall を足しただけの可能性もあり、攻撃者の意図を示す一次情報は無く推測の域を出ない。
3つ目。2つの0.0.0-…版は配布物としては不発だった。 binding.gypもペイロードも含まず、悪意あるpreinstallが参照するファイルがtarballに入っていない。メンテナは、この2版が正規のfiles: ["dist"]指定のままパックされたためだと説明している。
ただし「攻撃者の意図まで不発だった」わけではない。この2版には悪意あるpreinstallとキャンペーン用の環境変数が明確に仕込まれており、publish経路とペイロード実行経路の疎通を確かめる意図があったとみるのが自然だ。配布物の中身が伴わなかっただけで、attempt自体は成立している。
検知上の要点は「どちらか片方のシグナルでは10版を網羅できない」こと。 binding.gyp/ペイロードの存在と、インストールフックの有無の両方で照合する必要がある。
さらに詳しく:ペイロードの形状と汎用ツールキットの痕跡(クリックで展開)
ペイロードのファイル名は8版すべてで共通だが、筆者がSHA-256を取ったところ8版すべてでハッシュが異なっていた(サイズも約4.4〜6.4MBとばらつく)。版ごとに姿を変える多態型で、ハッシュ照合による検知は効きにくい——ファイル名やパスで照合するほうが確実だ。
また不発だった2版に渡されていた環境変数は WORKFLOW_ID=release.yml / REPO_ID_SUFFIX=<リポジトリ名> / TARGET_PACKAGES=<パッケージ名> という形で、対象が外から与えるパラメータになっている。メンテナも独立に同じ点を指摘し「このローダーは私のリポジトリ専用ではなく、同じパターンを持つ任意のリポジトリに向けられるよう書かれていた」と警告している(Issue #217)。【筆者の分析】同型の攻撃が他にも向けられうると考えるのが妥当だが、他リポジトリでの実例は確認できていない。
ペイロード内部(多段ローダー、AES-128-GCMによる第2段の復号)については Issue #218 の報告者による解析がある。筆者は当該ファイルを実行も逆難読化もしていないため、この部分は伝聞として示すにとどめる。
影響を受けるバージョンと、自分の環境の確認手順
まず影響範囲を整理する。正規版は3.0.2以前(3.0.2を含む)のみで、以下の10版が悪性版だ。
| 系統 | 悪性版 | 同系統の最後の正規版 | 露出時間帯に危険だった範囲指定 |
|---|---|---|---|
| 0.x | 0.5.4 / 0.5.5 |
0.5.3 |
^0.5.0・~0.5.4 |
| 1.x | 1.6.3 / 1.6.4 |
1.6.2 |
^1.6.0・~1.6.3 |
| 2.x | 2.2.1 / 2.2.2 |
2.2.0 |
^2.2.0・~2.2.1 |
| 3.x | 3.0.3 / 3.0.4 |
3.0.2(推奨) |
^3.0.0・~3.0.3 |
| プレリリース | 0.0.0-365d4eb… / 0.0.0-ec7876d6… |
— | betaタグ経由での混入は未確認 |
影響を受ける条件は「2026-08-29 05:00〜08:11(JST)のあいだに上記いずれかの版を取得したこと」だ。この約3時間11分が露出時間帯で、現在はnpmから削除済みである(詳細は後述)。
逆方向は、断定を避けて言うとこうなる——上記の悪性バージョンを取得していない限り、本件による直接的な影響は考えにくい。「この時間帯にインストールしていないから絶対に安全」と言い切らないのは、取得経路がnpm installだけとは限らないからだ。
・CIの依存キャッシュ(actions/cache等)やDockerのレイヤキャッシュに、当時解決した版が焼き付いている場合がある
・monorepoでは自分が直接触っていないワークスペースが引いていることがある
・パッケージマネージャ(npm / pnpm / yarn / bun)やlockファイルの有無で解決結果が変わる
・自分の依存ではなく、別の依存パッケージが間接的に引いている可能性もある
だからこそ、心当たりの有無ではなく現物を見る手順が要る。CIで毎回インストールを回している環境が最も危険側なので、当該時間帯のジョブ実行履歴もあわせて確認しておきたい。
以下はすべてインストールを伴わない確認手順だ。上から順に実行すればよい。
① そもそも依存に入っているか
npm ls @7nohe/openapi-react-query-codegen
② lockファイルに悪性版が固定されていないか
grep -nE 'openapi-react-query-codegen.*(0\.5\.[45]|1\.6\.[34]|2\.2\.[12]|3\.0\.[34]|0\.0\.0-)' \
package-lock.json pnpm-lock.yaml yarn.lock 2>/dev/null
③ 既に展開されていないか(ペイロードのファイル名は全版共通)
# このパッケージに絞って、2つの指標を同時に見る
find node_modules/@7nohe/openapi-react-query-codegen \
\( -name '3FWCvzduYZg.js' -o -name 'binding.gyp' \) -print 2>/dev/null
# monorepo等でネストしたnode_modulesがある場合は木全体を走査する
find . -name '3FWCvzduYZg.js' -print 2>/dev/null
binding.gypと3FWCvzduYZg.jsのどちらかが出たら、その環境には悪性版が展開されている。
binding.gyp の存在が悪性の証拠になるのは、@7nohe/openapi-react-query-codegen の正規版(3.0.2)にこのファイルが存在しないと筆者が確認しているからです。このパッケージは純粋なTypeScript製のコードジェネレータで、ネイティブ拡張のビルド設定を必要とする理由がありません。「binding.gyp がある npm パッケージは悪性」ではありません——多数の正当なネイティブモジュールが日常的に使うファイルです。他のパッケージにこの判定をそのまま流用しないでください。④ 露出時間帯にインストールが走っていないか(CIログ・ファイル更新時刻)
stat -f '%Sm %N' node_modules/@7nohe/openapi-react-query-codegen 2>/dev/null # macOS
stat -c '%y %n' node_modules/@7nohe/openapi-react-query-codegen 2>/dev/null # Linux
更新時刻が 2026-08-29 05:00〜08:11(JST) の範囲に入っていれば、露出時間帯に取得した可能性が高い。CIについては同時間帯のジョブ実行履歴を確認する。
⑤ 常駐(永続化)の痕跡が残っていないか
各社の解析は、ペイロードがユーザー領域にサービス/LaunchAgentを常駐させると報告している。ここは筆者が動的解析をしていない領域なので、各社報告の転記である旨を明示する。なおサービスのファイル名は報告元で綴りが割れている(Socketはsysvinit-detect-fash、JFrogはsystemd-detect-fash)ため、どちらにも当たるよう部分一致で探す。
# ユーザー領域の常駐物(両方の綴りに当たるよう *detect-fash* で照合)
find ~/.local/bin ~/.config ~/Library/LaunchAgents ~/.local/share \
-maxdepth 3 \( -name '*detect-fash*' -o -name 'diaper' \) -print 2>/dev/null
# 一時領域の痕跡(Bunの取得先ディレクトリ・マーカーファイル)
find /tmp /var/tmp -maxdepth 1 \( -name 'trinnyyyy-*' -o -name '.shit' \) -print 2>/dev/null
何も出なければ何も表示されない(存在しないディレクトリを混ぜても中断しないことを確認済み)。1つでも出たら、露出時間帯に実行されている可能性が高い。
同じMini Shai-Hulud系の過去事案(TanStack)では、盗んだトークンが失効すると破壊動作に移る「デッドマンズスイッチ」が報告されている。常駐物を先に停止・削除してからトークンを失効させる順序が安全側だ。本件のペイロードに同じ動作があるかは筆者が確認できていないため、あるものとして扱うのが妥当だと考える。
なおnpm viewはメタデータを取得するだけで、インストールもスクリプト実行も行わない。現在の解決先を確かめたいときは安全に使える。
# 範囲に一致する版が全部並ぶので、実際に入るのは「最後の行=最大版」
npm view '@7nohe/openapi-react-query-codegen@^3.0.0' version | tail -1
露出は約3時間11分——ただし「消えたから終わり」ではない
執筆中に状況が動いた。 初回調査時(07:58 JST)は10版とも取得可能だったが、約13分後の再確認で10版すべてがunpublishされていた。packumentのバージョン数が70個→60個へ減り、time.modifiedは2026-08-28T23:11:37Z=08:11:37 JST。最初の公開が05:00:43 JSTなので、露出は約3時間11分だ。
削除の前後で、範囲指定の解決先はこう変わった(npm viewの実測)。
package.jsonの記述 |
露出時間帯(筆者実測 08:05頃) | 現在(08:19以降) |
|---|---|---|
^3.0.0 |
3.0.4(悪性) |
3.0.2(正規) |
^2.2.0 |
2.2.2(悪性) |
2.2.0(正規) |
^1.6.0 |
1.6.4(悪性) |
1.6.2(正規) |
^0.5.0 |
0.5.5(悪性) |
0.5.3(正規) |
latestタグ |
3.0.2(正規)※07:58時点。それ以前は未観測 |
3.0.2(正規) |
latestタグの扱いは観測時点で食い違う——生データを示す
latestについては、他社の報告と本記事の観測が食い違って見える。Socket・Endor Labsは「latestが悪性の3.0.4を指している」と報告していた一方、筆者の観測では3.0.2だった。これは矛盾ではなく観測時点の差であり、筆者の手元にある生データはこうだ。
# 2026-08-29 07:58 JST(= 08-28 22:58 UTC)に取得した packument
dist-tags : {'beta': '3.0.0-beta.5', 'latest': '3.0.2'}
versions : 70 ← 悪性版10個はまだ存在(3.0.4 も present)
time.modified : 2026-08-28T22:55:59.716Z (= 07:55:59 JST)
読み取れるのは次の点だ。07:58の時点で、悪性版がまだ入手可能なままlatestだけが3.0.2に戻されていた。 その2分前(07:55:59 JST)にpackumentが更新されており、このタイミングでlatestが差し戻された可能性が高い。
つまり時間軸はこう整理できる。
・05:00〜およそ07:55 JST — 3.0.4の公開によりlatestが悪性版を指していたとみられる時間帯。筆者はこの時間帯を観測していないため、Socket・Endor Labsの報告(latest=3.0.4)がこの区間に対応すると考えるのが自然だ
・07:55:59頃 — packumentが更新され、latestが3.0.2へ
・07:58〜08:11 JST — latestは3.0.2だが、悪性版は残っており範囲指定では引ける(筆者実測)
・08:11:37 JST — 10版を削除
latestが正常化してから実際の削除まで約15分の隙間があったわけで、この間「latestは綺麗なのに^3.0.0は悪性版に解決される」という状態が実在した。latestタグの健全性を安全確認に使ってはいけない理由が、そのまま出ている。
悪性版のtarballはいずれもHTTP 404を返す(3.0.2は200のまま)。これから新規に入れる分に悪性版は入らない。
npm viewの実測。左が露出時間帯、右がunpublish後。リスクは「これから入れる環境」から「すでに入れてしまった環境」へ移った。ただし全員が安全になったわけではない。 unpublishは配布を止める操作で、すでに配られたものには届かない。露出時間帯にnpm installが走った環境はそのまま残る。
・node_modules——展開済みの悪性コードは消えない。再インストールしない限りそこにある
・lockファイル——悪性版を指したままなら、次のnpm ciは404で失敗する。これは障害であると同時に、踏んだことを示す検知シグナルでもある
・npmキャッシュ——~/.npm/_cacacheにtarballが残っていると、レジストリから消えていてもローカルで解決されうる
・CIのキャッシュ層——GitHub Actionsのactions/cacheやDockerのレイヤキャッシュに焼き付いている可能性がある
論点は「これから入れてしまうか」から「すでに入れてしまっていないか」へ移った。前節の①〜④に加えてキャッシュも見ておきたい。
npm cache ls @7nohe/openapi-react-query-codegen 2>/dev/null || \
grep -rl 'openapi-react-query-codegen' ~/.npm/_cacache/index-v5 2>/dev/null | head
該当版を導入した形跡があるなら、その環境で悪性コードが実行された可能性があることを前提に動く。ペイロードが実際に何を窃取するかは筆者の一次検証の範囲外(実行・動的解析はしていない)だが、この種のnpmサプライチェーン攻撃では認証情報の窃取が定番であり、安全側に倒すのが妥当だ。
優先度をつけるとこうなる。
| 優先度 | 対象 | 該当する条件 |
|---|---|---|
| 最低限 | npmトークン、GitHubのPAT/OAuthトークン、SSH鍵 | 該当版を入れた環境すべて |
| CI/CDで使っていたなら | AWS / GCP / Azure の資格情報、デプロイ鍵、Actions の Secrets | CIジョブ内で当該版が展開された場合 |
| アプリケーション側 | .env の内容、各種APIキー、DB接続文字列、署名鍵 |
開発機・実行環境にそれらが存在した場合 |
なおローテーションはリポジトリ側の設定変更で終わらない。漏れた可能性のある値を使い回している別サービスがあれば、そちらも対象になる。
なお旧系統(0.x・1.x・2.x)にまで悪性版を配ったのは、古い版に固定している利用者を取りこぼさないためと見られる。latestだけを汚す攻撃なら範囲を狭めている利用者は助かるが、今回はその逃げ道が塞がれていた。
Shai-Hulud・Mini Shai-Hulud・Trinitite——名前の関係を整理する
本記事の公開後、各社の解析が出そろった。呼び名が媒体ごとに違うため、同じ事案が別々の事件のように見えてしまう。ここを先に整理する。
| 報告元 | 本件の呼び方 | 系譜についての明示 |
|---|---|---|
| JFrog(08-30) | Trinitite | 「Trinitite は Mini Shai-Hulud のもう一巡であって、新しいファミリーではない」 |
| Socket | Mini Shai-Hulud | 「振る舞いの重複だけでは、運用者やキャンペーンの同一性は立証できない」 |
| Endor Labs | Mini Shai-Hulud | 「Shai-Hulud 風の(-style)認証情報窃取・自己増殖ワーム」 |
| safedep / Mend.io | Mini Shai-Hulud | 既存ファミリーの再来として扱う |
| cyberpress / gbhackers(二次メディア) | Shai-Hulud Trinitite | 2つの語を連結して1つの名前にしている |
「Trinitite」はセキュリティベンダーが付けた名前ではない。 Socketの解析によれば、攻撃者が被害者アカウントで作成する公開GitHubリポジトリのdescription に書き込まれる文字列が Trinitite: Sponsored by Preview 2 Effects で、そこから取られている。JFrogはこれを波の識別子として採用し、二次メディアが「Shai-Hulud Trinitite」という複合名として広めた、という順序だ。
・Shai-Hulud=2025年9月に最初に観測されたnpmワームの原型。名前はDune由来。
・Mini Shai-Hulud=その手法を踏襲したより小型の亜種ファミリー。当サイトでは2026年5月のTanStack事案・@antv事案から追っている。
・Trinitite=Mini Shai-Hulud の今回の波に付いた識別子。攻撃者が残した文字列が出所。
つまり入れ子であって、並列の3つの事件ではない。
2026-09-07、本件(Trinitite)とは別系統の波が観測された。5月19日の@antv事案と同一のC2ドメイン(
t.m-kosche.com)を使うペイロードが、4パッケージ(feishu-docx-mcpほか)に載って再公開されたものだ。公開から約4時間25分でnpmセキュリティチームが差し押さえており、本記事が扱う@7nohe/openapi-react-query-codegenは今回の対象ではない。レジストリ実測にもとづく詳細はMini Shai-HuludがAnt Design可視化323パッケージを22分で汚染の第10節に追記した。帰属は「確定していない」——しかも今回はその理由がはっきりしている
ここは特に慎重に書く必要がある。逮捕が、この攻撃の2日前に起きているからだ。
2026年8月26日、オーストラリア連邦警察(AFP)がFBI・西オーストラリア州警察と連携し、Shai-Hulud関連のサプライチェーン攻撃でTeamPCPのメンバーとされる2名を逮捕した(21歳・23歳)。この事実はKrebs on Security・Help Net Security・The Registerなど複数の独立した媒体が報じている。
そして本件の悪性版が公開されたのは、その2日後の8月28日(UTC)だ。
JFrogはこの点について踏み込んだ断定を避けている——「残存アクセスかもしれないし、同じ猫のマスクをかぶった別人かもしれない。ペイロードでは決着しない」。Socketも「振る舞いの重複だけでは運用者の同一性は立証できない」と明言している。
【筆者の分析】攻撃者が特定しにくくなっている理由自体は、当サイトが5月に記録している。 @antv事案(2026-05-19)の時点で、TeamPCPは Mini Shai-Hulud のソースコードをBreachForumsで公開し、サプライチェーン攻撃コンテストを告知していたとWiz Researchが分析していた。ソースが出回っている以上、ペイロードが一致することは同一犯の証拠にならない。JFrogの「ペイロードでは決着しない」という言い方は、この構造をそのまま述べたものと読める。逮捕によってこの手口が止まると期待するのは早い、というのが現時点で言える範囲だ。
PyPI・RubyGems は「狙われた」が「汚染が確認された」わけではない
Endor Labsの解析はこの波を「npm・RubyGems・PyPIを横断して自己増殖する」と表現しており、二次メディアではこれが「3つのレジストリが汚染された」と要約されがちだ。この2つは別の主張なので分けて書く。
・確認されている事実:ペイロードにはhandlePypiTokensという関数があり、窃取したpypi-トークンをupload.pypi.org/legacy/に対して検証し、有効なものを保持する(Endor Labs)。JFrogも「JSワーム自身が窃取したpypi-トークンを送る」と記述している。npm・RubyGemsについても、窃取したトークンで被害者の権限のもとに再公開する経路が実装されている。
・確認されていないこと:この波で実際に汚染されたパッケージとして各社が文書化しているのは@7nohe/openapi-react-query-codegenの10版のみだ。PyPIやRubyGems上に本件由来の汚染パッケージが公開されたという報告は、筆者が確認した範囲では見当たらなかった。
つまり「3レジストリに手を伸ばす能力がある」と「3レジストリが汚染された」は違う。ただし能力がある以上、npm以外のトークンも窃取対象なので、露出時間帯に該当環境があったならPyPI・RubyGemsのトークンもローテーション対象に含めるのが妥当だ。
メンテナの回答と、npm 脆弱性としての登録状況
メンテナ本人(Urata Daiki 氏)が対応済みだ。 通報から約2時間48分後の08:06:08 JSTに回答し、報告は正確であること、そして範囲は通報で挙げられた4版より広く10版であることを確定させた。筆者がレジストリから独立に数えた10版と一致する。
さらに08:51:29 JSTの状況更新では、自身の初回説明の誤りを自分で訂正している。当初「10版すべてがpreinstallを持つ」と書いたが、実際は6版のみで、残る4版(0.5.4/1.6.3/2.2.1/3.0.3)はbinding.gypだけで実行される、という訂正だ。あわせて「どちらか片方のシグナルだけでは10版を網羅できない。binding.gypか3FWCvzduYZg.jsの存在、またはインストールフックの有無、そのどちらでも引っかかるように照合せよ」と述べ、この点を指摘したIssue #218の報告者を明記している。本記事の分類も同じ結論になった。
メンテナは他のメンテナ向けにも呼びかけている。攻撃者のローダーがWORKFLOW_ID・REPO_ID_SUFFIX・TARGET_PACKAGESをパラメータとして受け取る作りで、自分のリポジトリ専用ではなく、同じパターンを持つ任意のリポジトリに向けられるよう書かれていたという指摘だ。これは筆者がtarballから独立に観測した内容と一致する。
アドバイザリは登録された(ただし空白期間は実在した)
GHSA-9pvf-vcx3-x239 が08:27:18 JSTに公開された。CVSS 3.1 で 9.6(critical)、ベクタは AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H。対象は10版すべてで、修正版は3.0.2と明記されている。なおUI:R(利用者の操作が必要)が付くのは、攻撃が成立するには利用者側が該当パッケージをインストールする必要があるためだ。裏を返せば、その1操作以外に必要な条件は無く(AV:N=ネットワーク経由・AC:L=容易・PR:N=権限不要)、S:Cはスコープが影響を受けたコンポーネントの外——開発機やCIランナー全体——に及ぶことを示している。OSVにも MAL-2026-15494(Amazon Inspector 由来、10版を列挙)が登録された。
ただし、スキャナに頼れない空白期間は実在した。筆者が08:19 JSTにOSV・GitHub Advisory Database・npm security advisoryの3つを叩いた時点では、いずれも空の応答だった。GHSAはリポジトリのアドバイザリとして公開されても、GitHub側のレビューが完了するまでグローバルのAdvisory Databaseには反映されず(メンテナは最大3営業日かかると告知している)、Dependabotの自動アラートもその後になる。実際、本記事の確認時点でグローバルAPI(/advisories/GHSA-9pvf-vcx3-x239)はまだ404を返す。
つまり事案発生からしばらくの間、npm auditもdependabotも緑のままだった。この時間帯にnpm installを回したCIやローカル環境は、スキャナが何も言わなくても影響を受けている可能性がある。だからこそ、アドバイザリではなく現物——lockファイルの実際のバージョン、node_modulesに実在するファイル——を見る手順(前節の①〜④)が要る。
「削除できない」と「削除された」の食い違い
メンテナは08:51:29 JSTの時点で「npmはtarballを削除していない。npm unpublishは恒久的に拒否される(has dependent packages in the registry)」と書いている。一方、筆者の実測ではその約40分前の08:11:37 JSTに10版すべてが消えていた(08:19・08:31・08:56の3回とも404)。
矛盾ではなく主語が違うと読むのが自然だ。メンテナは「自分ではunpublishできない」と言っており、削除を実行できるのはnpm側だ。広く依存されたパッケージは作者が引っ込められない仕組みで、インシデント対応で「作者に消してもらう」ことは期待できない。
まとめ——npm 脆弱性対策は「トークンを守る」だけでは足りない
publishの資格情報を守ることと、publishの入口を守ることは別の問題だ。 Trusted Publishingへの移行は正しいが、それは盗まれる秘密を無くしただけで「誰が起動できるか」には答えていない。issue_comment・pull_request_target・workflow_runのように外部から発火しうるイベントでpublishやデプロイを行うワークフローがあるなら、author_associationで発火者を検査しているか確認する価値がある。コメント本文の文字列一致だけを条件にしているものは、今回と同じ穴が開いている。
npm installの実行経路がpackage.jsonのscriptsだけではないことも押さえておきたい。ただし前述のとおり「binding.gypがあれば必ず実行される」ではなく、パッケージマネージャとオプション次第だ。本件に限れば、TypeScript製のこのパッケージの正規版にbinding.gypは存在せず、その有無が有効な検知シグナルになる。
そして利用側としては、「レジストリから消えた」を「自分の環境が綺麗になった」と読み替えないこと。unpublishは配布を止めるだけで、展開済みのnode_modulesにもlockファイルにもキャッシュにも届かない。露出が3時間で収束した事案ほど「もう終わった話」として流れやすいが、その3時間にCIを回していたかは自分のログを見るまで分からない。
なお、npmのpostinstallをデフォルト無効化する動き(npm v12)についてはnpm v12でpostinstallがデフォルト無効化へ|自システム確認コマンドと段階的な移行手順で扱っている。ただし本件の1波目が示すとおり、それだけではbinding.gyp経由の実行は止まらない。
本文中の数値・状態は、筆者が2026-08-29の 07:58 JST(初回) と 08:19 JST(再確認) にnpmレジストリ・npm downloads API・GitHub APIへ直接問い合わせて得た結果にもとづく。この21分の間に10版のunpublishが発生しており、本文ではその前後を区別して記述した。アドバイザリの登録状況(GHSA・OSV)は 08:56 JST に再確認している。2026-09-01 の追記分(各社の呼称整理・帰属・常駐痕跡・`--ignore-scripts`の再測定)は同日 06:47〜07:4x JST に取得・実施した。`--ignore-scripts`の挙動は npm 11.1.0 / pnpm 10.29.3 / Node v22.13.1 / macOS (Darwin 23.5.0 arm64) での実測で、バージョンやパッケージマネージャによって変わりうる——実際、同じ論点でJFrogと当サイトの結果は食い違っている。今後も状況は動くため、判断の前に「影響を受けるバージョンと、自分の環境の確認手順」の①〜④で自分の環境を直接確認してほしい。
調査方法(Methodology)
読者が再現・検証できるよう、本記事の事実がどう得られたかを示す。
| 主張 | 取得方法 | 実行時刻(JST) |
|---|---|---|
10版の公開時刻・バージョン数・dist-tags |
registry.npmjs.org の packument を直接取得し保存 |
07:58 / 08:19 / 08:31 / 08:56 |
各版の binding.gyp・ペイロードの有無 |
tarball を取得し tar -tzf でファイル一覧のみ確認、package.json を読み出し |
08:0x |
| ペイロードのサイズ・SHA-256 | 取得済み tarball から抽出しハッシュ計算(実行はしない) | 08:0x |
| 週次ダウンロード数 151,093 | npm downloads API | 07:5x |
| ワークフロー・PR・Issue・コミット | GitHub REST API(gh api) |
07:5x〜09:0x |
| 範囲指定の解決結果 | npm view '<pkg>@<range>' version(メタデータのみ) |
08:05 / 08:19 |
--ignore-scripts の挙動 |
自作の無害なパッケージ(binding.gyp あり・script 宣言なし)で10条件を陽性対照つきに測定。node-gyp の起動はログ中の sh -c node-gyp rebuild / gyp info 行で判定 |
08-29 08:4x / 09-01 07:1x(再測定) |
preinstall 宣言時に既定の node-gyp rebuild が割り当てられないこと |
同上のパッケージに preinstall を足した版でA/B。npm公式ドキュメントの記載とも突き合わせ |
09-01 07:1x |
| 常駐痕跡の確認コマンド | 無害なダミーファイルで両方の綴り(sysvinit- / systemd-)を含む木を再現し、存在しないディレクトリを混ぜても中断しないことまで確認 |
09-01 07:2x |
| 各社の呼称・系譜・帰属 | JFrog・Socket・Endor Labs の解析ページを直接取得して記述を照合(当サイトの独立検証ではなく引用) | 09-01 06:5x〜07:0x |
| TeamPCP 逮捕の事実 | Krebs on Security・Help Net Security・The Register 等複数の独立媒体で相互確認 | 09-01 07:3x |
| 確認コマンドの動作 | 無害なダミーファイルで実際のディレクトリ構造を再現して実行 | 09:4x |
やっていないこと:該当パッケージのインストール、ペイロードの実行・動的解析・逆難読化。取得した tarball は隔離ディレクトリに置き、検証後に削除した。
取得できなかったもの:攻撃者PRの差分(fork削除により GitHub API が 0 件を返す)。本文中で未確認である旨を明示している。初版時点で HTTP 403 だった Socket のページは 2026-09-01 に取得できたため、追記分では一次の引用元として使用している。
追記分でやっていないこと:ペイロードの実行・動的解析・逆難読化(初版から一貫)。常駐痕跡(sysvinit-detect-fash 等)は各社報告の転記であって、筆者が感染環境で観測したものではない。
参照ソース
・7nohe/openapi-react-query-codegen Issue #217「[URGENT] Malicious NPM packages published」 — @CharlieEriksen 氏による最初の通報(署名は「Charlie」)
・同 Issue #218「Security: package is currently publishing malicious code」 — 影響バージョンとペイロード構造の技術解析
・修正コミット 8330895b4「fix(ci): remove the unauthenticated issue_comment publish trigger」 — メンテナによるワークフロー修正
・npmレジストリ packument(@7nohe/openapi-react-query-codegen) — 公開時刻・dist-tags・unpublish前後のバージョン数の一次データ
・npm downloads API(直近1週間) — 151,093 DL の実測値
・GHSA-9pvf-vcx3-x239 — メンテナが発行したアドバイザリ(CVSS 9.6 / critical、10版すべてが対象)
・OSV: MAL-2026-15494 — Amazon Inspector 由来のマルウェア登録
・GitHub Docs: Events that trigger workflows — issue_comment — トリガーの仕様
・npm Docs: Trusted publishing for npm packages — OIDCによる公開の仕組み
・npm Docs: scripts — life cycle scripts — binding.gyp があり install/preinstall を自分で定義していない場合に既定の install が node-gyp rebuild になる、という仕様の出典
・JFrog Security Research — Shai-Hulud Trinitite(2026-08-30) — 「Trinitite」の命名、Mini Shai-Hulud の一波であるという位置づけ、--ignore-scripts に関する記述、帰属の留保の出典
・Socket — OpenAPI React Query Codegen Compromised in Mini Shai-Hulud npm Supply Chain Attack — 攻撃者が作成するリポジトリの description 文字列(Trinitite: Sponsored by Preview 2 Effects)、常駐痕跡、帰属を断定しない旨の出典
・Endor Labs — Trojanized @7nohe/openapi-react-query-codegen adds PyPI to a self-replicating npm worm — handlePypiTokens によるPyPIトークン検証、--ignore-scripts は preinstall をブロックするという記述の出典
・Krebs on Security — Two Alleged ‘TeamPCP’ Hackers Arrested in Australia / Help Net Security / The Register — 2026-08-26 の逮捕(本件の2日前)の相互確認