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はこの経路を止める。実測は本文) - ・発生直後は
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 は筆者の環境から 403 で取得できず、一次ソースとして直接確認できていない
- ・やっていないこと:該当パッケージのインストール(
npm install/npx/yarn/pnpmのいずれも実行していない)。攻撃コードは再現可能な形で掲載しない
npm publishコメントから最初の悪性版公開まで88秒、削除までの露出は3時間11分。筆者がGitHub APIとnpmレジストリのtimeフィールドから再構成した。何が起きたか——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はこの経路を止めるのか——実測した
ここは憶測で書けない部分なので、自作の無害なパッケージで確かめた。binding.gypを持ちインストールスクリプトを一切宣言しないローカルパッケージを作り、それを依存として入れてnode-gypが起動するかどうかをA/Bで見る。該当パッケージは一切使っていない。
| 条件 | node-gypの起動 |
判定 |
|---|---|---|
npm install(既定) / npm 11.1.0 |
起動した(gyp configure→make、build/生成) |
この経路で実行される |
npm install --ignore-scripts / npm 11.1.0 |
起動しなかった(build/なし) |
止まる |
pnpm install(既定) / pnpm 10.29.3 |
起動しなかった | 既定で止まる |
結論として、--ignore-scriptsはこの経路を止める。npmはbinding.gypを持つパッケージに既定のinstallスクリプトとしてnode-gyp rebuildを割り当てる実装で、--ignore-scriptsはその既定スクリプトも対象にするためだ。pnpm 10は既定でビルドスクリプトを実行せず、Ignored build scriptsと表示してpnpm approve-buildsでの承認を求める(承認済みなら実行されるので、allowlistに入れている場合は別)。
ただし「安全になる」とは違う。--ignore-scriptsを付けてもtarballの展開自体は行われるので、binding.gypとペイロードはnode_modulesの中に残る。後から素のnpm installをやり直したり、別のビルド手順でnode-gypを呼べば、そこで実行される。実行を止めるだけで、取り込みを止めるわけではない。
なお当サイトの既存記事では、別のbinding.gyp事案について「--ignore-scriptsでは止まらない」とするベンダー見解を紹介している。今回の実測はnpm 11.1.0でのものなので、自分が使うバージョンで確かめるのが確実だ。
2つ目。2波目の4版はbinding.gypと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については同時間帯のジョブ実行履歴を確認する。
なお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だけを汚す攻撃なら範囲を狭めている利用者は助かるが、今回はその逃げ道が塞がれていた。
メンテナの回答と、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 に再確認している。`--ignore-scripts`の挙動は npm 11.1.0 / pnpm 10.29.3 / Node v22.13.1 での実測で、バージョンによって変わりうる。今後も状況は動くため、判断の前に「影響を受けるバージョンと、自分の環境の確認手順」の①〜④で自分の環境を直接確認してほしい。
調査方法(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 あり・install script 無し)でA/B |
08:4x |
| 確認コマンドの動作 | 無害なダミーファイルで実際のディレクトリ構造を再現して実行 | 09:4x |
やっていないこと:該当パッケージのインストール、ペイロードの実行・動的解析・逆難読化。取得した tarball は隔離ディレクトリに置き、検証後に削除した。
取得できなかったもの:Socket のページ(筆者の環境から HTTP 403)、攻撃者PRの差分(fork削除により GitHub API が 0 件を返す)。いずれも本文中で未確認である旨を明示している。
参照ソース
・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による公開の仕組み