2026年8月4日、Node.jsのキャッシュ抽象化ライブラリ keyv と、その周辺の cacheable 系パッケージがnpm上で汚染された。攻撃者がメンテナのGitHubアカウントを侵害し、preinstall スクリプト付きの悪性バージョンを大量に公開したものだ。この記事では、npmレジストリAPIから取得した一次データで汚染された全パッケージと公開・削除の正確な時刻を確定し、読者が自分の環境を確認するためのコマンドまでを通しで整理する。

2026年8月4日のkeyv系npmパッケージ汚染タイムライン。第1波18:30〜18:35 JSTで@keyv/*スコープ20本、第2波19:09〜19:28 JSTでcacheable系10本が公開され、19:39〜22:01 JSTにかけて全30本が削除された
npmレジストリAPIの time フィールドから復元した時系列。すべてJST。公開は2つの波に分かれ、構造がまったく異なる(後述)
30秒でわかる要点

・汚染されたのは広く報じられた11本ではなく、レジストリ全件照合で確認できた範囲で30本@keyv/* スコープ19本を含む)。30本すべてが同日中にnpmから削除済み
・公開は日本時間 8月4日 18:30〜19:28、削除は 19:39〜22:01。レジストリ上の露出時間は最短28分・最長3時間半(上限値)
CVE・GHSA・OSVのいずれにも登録なし。したがって npm audit では検出できない(実測で「found 0 vulnerabilities」)
keyv 6.0.0 はメジャー更新のため ^5.6.0 ではマッチしない。自動的に引き込まれる危険が高かったのは flat-cache 6.1.24 のようなパッチ更新のほう
eslintの依存ツリーは汚染系列を参照していないfile-entry-cache ^8.0.0 → flat-cache 4.x → keyv 4.x)
・削除は再インストールを止めるだけ。露出時間帯にインストールした環境では preinstall が実行済みのため、lockfileとローカルキャッシュの確認が必要
・汚染版には正規のGitHub Actions由来のprovenance署名が付いていた。来歴検証だけでは見抜けない
npm v12 なら既定でブロックされる(npm 11.1.0 と 12.0.2 で実際に比較検証。11は実行、12はブロック)。ただし公開直後を待つ min-release-age は npm では既定無効で、既定24時間待つのは pnpm 側
・ハッシュ・送出先ドメインなどのIoCは値ごとに出典を分けて掲載(3社一致か1社のみか)。二次感染は @crawlee/* など無関係な名前空間にも波及
【8/6続報】npm installなしで実行される経路が報告された。盗んだGitHubトークンで他人のリポジトリのブランチへ直接コミットし、.claude/settings.json.vscode/tasks.json に実行フックを仕込む。そのリポジトリをVS Codeで開く/Claude Codeを起動した時点で起動するため、keyvを一切使っていない開発者も対象になりうる
・この経路はレジストリ側の削除でも npm audit でもlockfile監査でも検出できない。従来のツールが見るのは「installする物」で、gitに直接コミットされたエディタ設定は射程外だからだ
・不審コミットの捜索は作者名で絞ってはいけない——帰属は3社3様(claudeCo-authored-by トレーラー / github-actions[bot])。当サイトの実測で --author=claude は3パターン中1つしか検出できなかった。ファイルパスを軸に、git fetch してから探す

読者の3つの問いへの答え
何が起きた:メンテナのGitHubアカウントが侵害され、preinstall 付きの悪性版30本が正規のリリース経路でnpmへ公開された。② 何が問題:CVEもGHSAも存在しないため npm audit が沈黙し、削除後はレジストリ側にも上流リポジトリにも証跡が残らない。③ 何をすべき:lockfile・ローカルキャッシュ・CIログの3経路で自環境を確認し、該当すれば認証情報を最優先でローテーションする。

npm サプライチェーン攻撃全般の防御設計については、当サイトのまとめ記事「サプライチェーンセキュリティ2026|攻撃手法・防御ツール・実践チェックリスト」で体系的に整理している。本記事はそのうち、2026年8月4日の個別事案を一次データで詰めるものだ。

まず自分の環境を確認する——keyv汚染版を引いたかを判定するコマンド

削除済みだからといって安全とは限らない。露出時間帯にインストールした環境では、すでに preinstall が実行されている。確認すべきは「今インストールされるもの」ではなく「過去に解決された記録」だ。

package-lock.json 確認:汚染バージョンが記録されていないか

以下は汚染された30バージョンをすべて含む検索パターンだ。リポジトリのルートで実行する。

検索はresolved のtarball URLを対象にする。package-lock.json はパッケージ名と版を別々の行に持つため、[email protected] のような「名前@版」形式では1件もヒットしない(grepは行単位で照合するため)。名前と版が1行に揃うのは resolved の行だけだ。

# 汚染30バージョンを resolved URL で照合するパターン
BAD_TGZ='(keyv|flat-cache|file-entry-cache|cacheable-request|cacheable|cache-manager|ecto)/-/[a-z0-9-]+-(6\.0\.0|6\.1\.24|11\.1\.6|13\.0\.20|2\.5\.1|7\.2\.10|5\.0\.1)\.tgz|@(keyv|cacheable)/[a-z0-9-]+/-/[a-z0-9-]+-(6\.0\.0|2\.2\.1|3\.1\.2|2\.5\.1|2\.1\.1)\.tgz'

# npm (v2/v3) と yarn classic は resolved URL を持つ
grep -nE "$BAD_TGZ" package-lock.json npm-shrinkwrap.json yarn.lock 2>/dev/null

# pnpm / yarn berry は「名前@版」形式なのでこちらで照合する
BAD_AT='(^|[/ "])(keyv|@keyv/[a-z0-9-]+)@6\.0\.0|flat-cache@6\.1\.24|file-entry-cache@11\.1\.6|cacheable-request@13\.0\.20|cacheable@2\.5\.1|cache-manager@7\.2\.10|ecto@5\.0\.1|@cacheable/(memory@2\.2\.1|node-cache@3\.1\.2|utils@2\.5\.1|net@2\.1\.1)'
grep -nE "$BAD_AT" pnpm-lock.yaml yarn.lock .yarnrc.yml 2>/dev/null

# 現在ツリーに存在するものを確認(推移的依存も含めて洗い出す)
npm ls --all 2>/dev/null | grep -E '(^|[ │├└─])(@keyv/[a-z0-9-]+|keyv)@6\.0\.0|flat-cache@6\.1\.24|file-entry-cache@11\.1\.6|cacheable-request@13\.0\.20|cacheable@2\.5\.1|cache-manager@7\.2\.10|ecto@5\.0\.1|@cacheable/[a-z-]+@(2\.2\.1|3\.1\.2|2\.5\.1|2\.1\.1)'

npm ls --all の出力は 名前@版 形式なので、こちらは「名前@版」パターンで照合する。パッケージ名を必ず含む形にしてあるため、2.5.1 のような短い版数が無関係なパッケージに一致する誤検出は起きない。

ローカルキャッシュに実物が残っていないか

レジストリからは消えても、一度ダウンロードしたtarballは手元のキャッシュに残る。ここが最も確実な証跡になる。

# npm: コンテンツキャッシュを検索(レジストリが404でも実物が残っている)
grep -rl 'flat-cache/-/flat-cache-6.1.24\|keyv/-/keyv-6.0.0\|cache-manager/-/cache-manager-7.2.10' ~/.npm/_cacache/index-v5 2>/dev/null

# pnpm: ストアの場所を確認してから検索
pnpm store path 2>/dev/null

# yarn berry: zero-installならtarballがgitにコミットされている
ls .yarn/cache 2>/dev/null | grep -E 'keyv|flat-cache|file-entry-cache|cacheable|cache-manager|ecto'

# node_modules に実際に置かれているものを直接読む
find node_modules -name package.json -path '*keyv*' -o -name package.json -path '*cacheable*' 2>/dev/null \
  | xargs grep -l '"preinstall"' 2>/dev/null

最後のコマンドは、preinstall を持つパッケージだけを抽出する。これらのパッケージは正規版では preinstall を使っていないため、ヒットした場合は精査の対象になる。

CIが「壊れた」ことが検知シグナルになる

汚染版は削除されたので、それを指すlockfileはインストール自体が失敗する。実際に確認したところ、次のエラーが出る。

$ npm install          # [email protected] を指すpackage.jsonで実行
npm error code ETARGET
npm error notarget No matching version found for [email protected].

$ npm view [email protected]
npm error code E404
npm error 404 '[email protected]' is not in this registry.

つまり、8月4日夜以降にCIが ETARGETE404 で落ち始めたなら、それは汚染版を解決していた証拠になる。エラーを「レジストリの一時障害」と判断してリトライやキャッシュ再生成で流してしまうと、本来拾えたはずのシグナルを消すことになる。ログを遡って確認してほしい。

自環境確認のフローチャート。lockfile検索、npmキャッシュ検索、CIログのETARGET/E404確認の3経路から、該当あり・なしを判定して初動対応へ分岐する図
確認は「lockfile」「ローカルキャッシュ」「CIのエラーログ」の3経路。どれか1つでも該当すれば初動対応へ進む

npm audit でkeyv汚染を検出できない理由

多くの開発者が最初に試すのが npm audit だが、本件では機能しない。理由は単純で、照会先のアドバイザリDBに該当エントリが存在しないからだ。

2026年8月4日時点で確認した結果は以下のとおり。

OSVkeyv flat-cache いずれもnpmエコシステムで該当なし(APIレスポンスが空)
GitHub Security Advisory — npmエコシステムの最新公開分を遡っても、本件のエントリなし
CVE — 採番なし

これは不備ではなく、性質の違いだ。npm audit が扱うのは「このバージョンにはこういう脆弱性がある」というライブラリ側の欠陥であり、本件は「正規のパッケージ名で悪性のコードが公開された」という配布経路の侵害にあたる。前者はバージョン範囲で表現できるが、後者は該当バージョンが削除されてしまえばDB上で指す対象すら消える。

実際にクリーンな flat-cache 6.1.23 をインストールして npm audit を走らせると、次のように出る。

$ npm audit
found 0 vulnerabilities

汚染版を引いていた環境でも、削除後にlockfileを作り直せば同じ結果になる。「0 vulnerabilities だから安全」という判断は本件では成立しない。今後 OSV に MAL- 形式の悪性パッケージアドバイザリが登録される可能性はあるため、時間をおいて再確認する価値はある。

汚染された30パッケージの全リスト

ここが本記事の中核だ。npmレジストリAPIで当該メンテナの公開パッケージ61本を全件照合したところ、2026年8月4日に公開されたバージョンは30本あり、そのすべてが削除済みだった。

判定方法は単純で、レジストリのパッケージドキュメントには公開時刻を記録する time と、現存するバージョンを列挙する versions の2つのフィールドがある。time に載っているのに versions に無いバージョンは削除されたことを意味する。以下の表はその差分から機械的に得たものだ。

第1波:@keyv/* スコープ(18:30:01〜18:35:00 JST・20本)

パッケージ 汚染版 公開時刻(JST) 攻撃前のlatest 現在のlatest
@keyv/test-suite 6.0.0 18:30:01 2.1.2 2.1.2
@keyv/serialize-msgpackr 6.0.0 18:30:06 6.0.0-alpha.3 6.0.0-alpha.3
@keyv/serialize-superjson 6.0.0 18:30:12 6.0.0-alpha.3 6.0.0-alpha.3
@keyv/compress-brotli 6.0.0 18:30:17 2.0.5 2.0.5
@keyv/compress-gzip 6.0.0 18:30:22 2.0.3 2.0.3
@keyv/compress-lz4 6.0.0 18:30:27 1.0.1 1.0.1
@keyv/encrypt-node 6.0.0 18:30:32 6.0.0-beta.2 6.0.0-beta.2
@keyv/encrypt-web 6.0.0 18:30:37 6.0.0-beta.2 6.0.0-beta.2
@keyv/cloudflare-kv 6.0.0 18:30:42 6.0.0-beta.4 6.0.0-beta.4
@keyv/dynamo 6.0.0 18:30:48 1.2.4 1.2.4
@keyv/etcd 6.0.0 18:30:53 2.1.1 2.1.1
@keyv/memcache 6.0.0 18:30:58 2.0.2 2.0.2
@keyv/bigmap 6.0.0 18:31:01 1.3.1 1.3.1
@keyv/mongo 6.0.0 18:31:03 3.1.0 3.1.0
@keyv/mysql 6.0.0 18:31:09 2.1.10 2.1.10
@keyv/postgres 6.0.0 18:31:14 2.2.3 2.2.3
@keyv/sqlite 6.0.0 18:31:24 4.0.8 4.0.8
@keyv/valkey 6.0.0 18:31:29 1.0.11 1.0.11
@keyv/redis 6.0.0 18:32:24 5.1.6 5.1.6
keyv 6.0.0 18:35:00 5.6.0 5.6.0

約5秒間隔で機械的に公開されている点に注目してほしい。@keyv/* の19本は18:30:01から18:32:24までの約2分半で順に公開されており、この均一な間隔は人間のリリース作業ではまず起きない。

版数にも痕跡がある。@keyv/serialize-msgpackr のように攻撃前が 6.0.0-alpha.3@keyv/encrypt-node のように 6.0.0-beta.2 だったパッケージにとって 6.0.0 は自然な次の版だが、@keyv/compress-lz4(攻撃前 1.0.1)や @keyv/test-suite(同 2.1.2)にとってはメジャーを5つ飛ばす不自然な跳躍になる。系列の異なるパッケージが同じ 6.0.0 に揃えられていること自体が、スクリプトによる一括公開を示唆する。

もう一点、レジストリにしか残っていない事実がある。keyv 6.0.0-rc.1 は同じ8月4日の 04:21:08 JST に公開されており、こちらは削除されず現存している。つまり攻撃の約14時間前、メンテナは正規に 6.0.0 のリリース候補を出していた。keyv5.6.0 から一気に 6.0.0 へ跳んだこと自体は、この文脈では不自然に見えなかったことになる。汚染版が「あり得そうな次の版」に見えたという点は、版数の見た目だけで異常を検知する運用の限界を示している。

第2波:cacheable ファミリー(19:09:44〜19:28:01 JST・10本)

パッケージ 汚染版 公開時刻(JST) 攻撃前のlatest セマンティクス
@cacheable/net 2.1.1 19:09:44 2.1.0 パッチ
@cacheable/node-cache 3.1.2 19:10:34 3.1.1 パッチ
cacheable 2.5.1 19:10:44 2.5.0 パッチ
flat-cache 6.1.24 19:10:55 6.1.23 パッチ
cacheable-request 13.0.20 19:11:24 13.0.19 パッチ
@cacheable/memory 2.2.1 19:11:29 2.2.0 パッチ
file-entry-cache 11.1.6 19:13:02 11.1.5 パッチ
@cacheable/utils 2.5.1 19:14:21 2.5.0 パッチ
cache-manager 7.2.10 19:14:41 7.2.9 パッチ
ecto 5.0.1 19:28:01 5.0.0 パッチ

第1波と第2波は構造がまったく異なる。第1波は既存の版数を無視した一律 6.0.0 だが、第2波はすべて正しいパッチ更新になっている。この違いが、次章で述べる「どちらが危険だったか」を分ける。

「11本」報告との突き合わせ——差分は @keyv/* の19本

多くの報告が挙げる汚染パッケージ数は11本だ。この数字と当サイトの30本はどちらかが誤りなのか。突き合わせると、そうではないことがわかる。

Snykは同事案の解析記事で、汚染された11リリースをUTCの秒単位で列挙している。これを当サイトがレジストリAPIから独立に取得したJSTの公開時刻と並べると、次のようになる。

パッケージ Snyk報告(UTC) 当サイト取得(JST) JST換算の一致
keyv 6.0.0 09:35 18:35:00
@cacheable/net 2.1.1 10:09:44 19:09:44
@cacheable/node-cache 3.1.2 10:10:34 19:10:34
cacheable 2.5.1 10:10:44 19:10:44
flat-cache 6.1.24 10:10:55 19:10:55
cacheable-request 13.0.20 10:11:24 19:11:24
@cacheable/memory 2.2.1 10:11:29 19:11:29
file-entry-cache 11.1.6 10:13:02 19:13:02
@cacheable/utils 2.5.1 10:14:21 19:14:21
cache-manager 7.2.10 10:14:41 19:14:41
ecto 5.0.1 10:28:01 19:28:01

11本すべてが秒単位で一致する(UTC+9でJSTに換算した値)。取得元も取得者も異なる2系統のデータが完全に一致したことで、当サイトの時系列そのものが第三者報告によって裏づけられた形になる。

そのうえで、両者の差は「誤り」ではなく「範囲」だ。広く報じられた11本は、当サイトの分類でいう第2波の10本+keyv 本体にあたる。当サイトが追加で確認した19本は、すべて @keyv/* スコープの第1波(@keyv/redis@keyv/postgres@keyv/mongo など)で、この19本を列挙している報告は確認できなかった。11本+19本=30本という内訳になる。

なお、@keyv/* 19本の実務上の危険度は第2波より低い(後述のとおり ^ 指定では引き込まれない)。それでも列挙する意味は、露出時間帯に @keyv/redis などを新規インストールした環境では当時の最上位公開版が 6.0.0 だったためだ。数の多さではなく、自分が触れた可能性のあるパッケージが一覧にあるかどうかで読んでほしい。

レジストリ上に露出していた時間

パッケージドキュメントの modified フィールドは、そのパッケージに対する最後の変更時刻を示す。削除もこのフィールドを更新するため、公開時刻との差から「汚染版がレジストリに存在しえた最大の時間」を求められる。ただしdist-tagの付け替えなど削除以外の操作でも modified は更新されるため、以下はあくまで上限値として読んでほしい。

パッケージ 公開(JST) 最終更新(JST) 露出時間(上限)
cacheable-request 13.0.20 19:11:24 19:39:44 28分20秒
flat-cache 6.1.24 19:10:55 19:42:48 31分53秒
cache-manager 7.2.10 19:14:41 20:11:51 57分10秒
ecto 5.0.1 19:28:01 20:32:15 1時間4分14秒
cacheable 2.5.1 19:10:44 20:27:43 1時間16分59秒
@cacheable/utils 2.5.1 19:14:21 20:36:18 1時間21分57秒
file-entry-cache 11.1.6 19:13:02 20:52:08 1時間39分07秒
keyv 6.0.0 18:35:00 20:18:50 1時間43分50秒
@keyv/* スコープ19本 18:30:01〜18:32:24 22:01前後 約3時間30分

第2波の cacheable ファミリーは19時台後半から20時台にかけて最終更新されているのに対し、第1波の @keyv/* スコープは19本すべてが22:01:04〜22:01:33 JSTの30秒足らずに集中している。この密集した形は一括操作を示すもので、削除そのものの時刻かどうかまでは modified からは判別できない(削除後のdist-tag整理でも同じフィールドが更新されるため)。

この「上限値」という留保は、外部のスナップショットと突き合わせると一段強められる。Snykは11:16 UTC(20:16 JST)時点で「8リリースが依然 latest タグのまま、3リリースが削除済み」と記録している。当サイトの modified 表で20:16 JSTまでに最終更新されている第2波のパッケージを数えると、cacheable-request(19:39:44)・flat-cache(19:42:48)・cache-manager(20:11:51)のちょうど3本だ。keyv(20:18:50)はこのスナップショットの2分後にあたり、8本側に含まれる勘定になる。

つまり、少なくともこの1時点では、第2波の modified を削除時刻として読む解釈が外部観測と矛盾しない。1スナップショット分の傍証であって証明ではないが、露出時間の表を「単なる上限」より一歩踏み込んで読める根拠にはなる。

いずれにせよ、露出時間が短いことは安全を意味しない。CIが1回でもその時間帯に依存解決を走らせていれば、それで十分だからだ。

危険だったのはメジャー更新ではなくパッチ更新だった

報道で最も目立つのは keyv(月間6億ダウンロード規模)だが、セマンティックバージョニングの観点では keyv 6.0.0 は最も自動的に取り込まれにくいバージョンだった

理由は依存関係の宣言方法にある。レジストリから取得した実際の依存指定は以下のとおりだ。

cacheable 2.5.0keyv: ^5.6.0
cache-manager 7.2.9keyv: ^5.6.0
cacheable-request 13.0.19keyv: ^5.6.0
@cacheable/memory 2.2.0keyv: ^5.6.0

^5.6.0メジャーバージョンをまたがないため、keyv 6.0.0 にはマッチしない。つまりkeyvに依存するパッケージ群は、keyv 6.0.0 を自動的には引き込まなかった。

一方、第2波のパッチ更新はどうか。

file-entry-cache 11.1.5flat-cache: ^6.1.236.1.24 にマッチする
flat-cache 6.1.23cacheable: ^2.5.02.5.1 にマッチする
cache-manager 7.2.9@cacheable/utils: ^2.5.02.5.1 にマッチする
cacheable 2.5.0@cacheable/memory: ^2.2.02.2.1 にマッチする

通常の ^ 指定でそのまま解決される。第2波は、見出しになりにくい代わりに、実際の自動取り込みリスクははるかに高かった。

graph TD A["攻撃者がメンテナの
GitHubアカウントを侵害"] --> B["リポジトリのmainへ
悪性ファイルをpush"] B --> C["package.jsonに
preinstall を追加"] C --> D["正規のリリース経路で
npmへ公開"] D --> E1["第1波 18:30-18:35 JST
@keyv/* 20本
一律 6.0.0 に書き換え"] D --> E2["第2波 19:09-19:28 JST
cacheable系 10本
正しいパッチ更新"] E1 --> F1["^5.6.0 ではマッチしない
自動取り込みは限定的"] E2 --> F2["^ 指定でそのまま解決
自動取り込みリスク大"] F1 --> G["インストール時に
preinstall が起動"] F2 --> G G --> H["認証情報を収集し
外部へ送出(報告ベース)"] H --> I["19:39-22:01 JST
全30本が削除"]

eslintの依存ツリーは汚染系列を参照していない

flat-cachefile-entry-cache の月間ダウンロード数(それぞれ5.8億・5.7億規模)の大半は、eslint経由だ。ではeslintユーザーは影響を受けたのか。レジストリで実際の依存指定を辿ると、答えは明確に「その経路では受けていない」となる。

経路 実際の依存指定 解決されるメジャー 汚染版 一致するか
eslint 9.39.1 → file-entry-cache ^8.0.0 8.x 11.1.6
eslint 10.8.0 → file-entry-cache ^8.0.0 8.x 11.1.6
file-entry-cache 8.0.0 → flat-cache ^4.0.0 4.x 6.1.24
flat-cache 4.0.1 → keyv ^4.5.4 4.x 6.0.0
file-entry-cache 11.1.5 → flat-cache ^6.1.23 6.x 6.1.24
flat-cache 6.1.23 → cacheable ^2.5.0 2.x 2.5.1

eslintは9.x・10.xとも file-entry-cache: ^8.0.0 を指定しており、そこから flat-cache 4.x → keyv 4.x へと解決される。汚染されたのはいずれも別のメジャー系列だ。

eslintの依存チェーン図。eslintはfile-entry-cache ^8.0.0を指定し、flat-cache ^4.0.0、keyv ^4.5.4へと解決されるため汚染された系列に到達しない。file-entry-cache ^11を直接使う場合のみflat-cache 6.1.24に一致する
レジストリの実際の依存指定で確認した解決経路。ダウンロード数はパッケージ名に紐づくため、メジャー系列の違いを区別しない

これは「巨大なダウンロード数=巨大な被害」という直感が成立しない典型例といえる。ダウンロード数はパッケージ名に紐づくが、汚染は特定のメジャー系列にしか及ばない。実際に影響を受けうるのは、file-entry-cache@^11flat-cache@^6cache-manager@^7 といった現行メジャーを直接使っているプロジェクトで、かつ露出時間帯に依存解決を走らせた場合に限られる。

もっとも、これは「影響がなかった」という意味ではない。cacheable ファミリーの現行メジャーを使うプロジェクトは確実に存在し、そこでは ^ 指定によって静かに汚染版が入りうる状態だった。自分がどのメジャー系列にいるかを確認することが、報道の数字を眺めるより確実だ。

攻撃の手口と何が盗まれるのか

ここからは、当サイトでレジストリAPIから直接検証できなかった部分になる。以下はセキュリティ企業Aikido Securityの調査報告に基づく内容であり、出典を明示して区別する。

同社の報告によれば、攻撃の起点はメンテナのGitHubアカウントの侵害とされる。侵害の具体的な手口(フィッシング・トークン窃取など)については、同社の報告でも特定されていない。攻撃者はリポジトリのmainブランチへ悪性ファイルをpushし、正規のGitHub Actionsによる署名付きリリースとしてnpmへ公開されたと報告されている。正規のリリース経路を通っているため、provenance(来歴)の署名検証だけでは弾けなかった点が本件の厄介さだ。

報告されている実行の流れは次のとおり。

package.json"preinstall": "node setup.mjs" が追加される
npm install 時に setup.mjs(難読化されたドロッパー)が起動する
・GitHubのリリースページからBunランタイム(v1.3.13)をダウンロードする
・Bun経由で Math_Symbol.js(約728KBの難読化ファイル)を実行する

窃取対象として報告されているのは、npmトークン(~/.npmrc)、GitHubトークン(PAT・OAuth・Actionsトークン)、AWS認証情報、Kubernetesのサービスアカウントトークン、HashiCorp Vaultのトークン、Stripe・Slackのトークン、SSH鍵、Terraformのstate、Docker認証情報など広範囲にわたる。収集した情報は暗号化のうえ、公開GitHubリポジトリへ送出されるとされる。

重要なのは実行タイミングだ。preinstallインストール時点で無条件に走る。アプリケーションのコードが当該パッケージを import する必要すらない。したがって「うちはkeyvを直接使っていない」という理由では除外できず、依存ツリーに入っていたかどうかで判断する必要がある。

ワームとして広がった二次感染——30本で終わっていない

本件を「keyv系30本の事故」として閉じると、規模を見誤る。同社は、このマルウェアが自己増殖機能を持ち、汚染パッケージをインストールした他メンテナのアカウントで見つけた公開トークンを使って、そのメンテナのパッケージを汚染すると報告している。当サイトが確認した30本は、あくまで最初のメンテナ由来の一次感染分にすぎない。

二次感染の広がりは、件数よりどの名前空間に飛んだかを見るほうが実務的だ。GMO Flatt Securityの整理では、一次感染の keyv/cacheable 系に加えて、無関係な組織の名前空間へ波及したものとして @crawlee/*(複数のbeta版)・@servicetitan/*@or-sdk/* などが列挙されている。自社が keyv も cacheable も使っていなくても、これらの名前空間を使っていれば対象になりうる

件数については、Aikidoの8月4日13:37 CEST(20:37 JST)時点の集計で434パッケージ・1,381バージョンとされていた。これは調査進行中の暫定値であり、実際その後も動いている。8月6日時点で各社が公表している数字は次のとおりで、いずれも当サイトでは一次データによる裏取りができていないため、出典付きの報告値として扱う

出典 パッケージ数 バージョン数 備考
Aikido(8/4 20:37 JST) 434 1,381 当初値
Aikido(続報) 444 1,381 月間インストール20億超とされる
SafeDep(The Hacker News 経由) 420(パッケージ名) 1,684 9つのOrganizationに関連
JFrog 400+ 1,700+ 概数表記

数え方(パッケージ名で数えるか、Organization単位で括るか)が各社で異なるため、この幅は矛盾ではなく粒度の違いと読むのが妥当だ。桁としては「400本超・1,400〜1,700バージョン」で一致している。当初の434という数字だけを覚えていると規模を過小評価するため、更新しておく。

重要なのは、二次感染分は公開・削除の時刻も汚染バージョンも当サイトの30本の表には載っていないという点だ。上の表で該当が無くても、@crawlee/* などを使っているなら、後述のIoC(ファイルハッシュ・送出先ドメイン)による確認へ進んでほしい。

攻撃の実行チェーン図。GitHubアカウント侵害からpreinstall追加、npm公開、インストール時のドロッパー起動、Bunダウンロード、認証情報収集までの流れ
報告されている実行チェーン。preinstall はimport不要でインストール時点に走るため、「直接使っていない」は除外理由にならない

IoC(侵害指標)一覧——値ごとに出典を明記する

汚染バージョンがレジストリから消えた以上、手元に残ったファイルそのものを照合するのが最も確実な確認手段になる。以下は複数の調査ソースを突き合わせて整理したIoCだ。値ごとに出典を分けて記載する——3社が独立に同じ値を挙げているものと、1社しか報告していないものでは、扱いの重みが違うためだ。

種別 対象・備考 出典
SHA-256 54dc7ea54a1317cca0e890a2770630cf7fa6c97813e0cb9d2caa93012b350668 setup.mjs(ドロッパー本体・29,918バイト) Aikido / Snyk / Flatt(3社一致)
SHA-256 9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc Math_Symbol.jsmath_init.js(727,680バイト) Aikido / Snyk / Flatt(3社一致)
SHA-256 c272bde97cecd5a4e90cfebba7a70f704e1ab583267595dea944b706a7a6fab9 改変された package.json Flatt のみ
SHA-256 fd3ca4007b225fdf8de7af4345a19179d5efa8c4bb9205f88cda806e5684b1eb setup.mjs の二次感染(拡散)版 Aikido のみ
ドメイン npm-cache[.]com 窃取データの送出先(C2)。正規のnpmキャッシュとは無関係 Aikido / Flatt
ダウンロード元 github[.]com/oven-sh/bun/releases/download/bun-v1.3.13/ Bunランタイムの取得先。Bun自体は正規のOSSで、実行環境として悪用された Aikido
外部RPC eth-mainnet.nodereal[.]io(コントラクト 0xE1f2395ee43e45A1556EC6438a88c31B83493103 正規の第三者Ethereum RPCサービス。攻撃者インフラではなく、設定取得のために悪用された経路 Aikido のみ
文字列 Shai-Hulud: Here We Go Again 攻撃者が作成する送出先リポジトリのdescription Aikido
本記事で扱わないもの
送出先URLの完全な形(パスを含む)、悪性コードそのもの、攻撃の再現手順は記載しない。上記は自環境が該当するかを判定するための照合値としてのみ掲載している。ドメインは誤クリック防止のため [.] 表記にしている。

ハッシュによる確認は、node_modules に残ったファイルを直接照合するのが早い。

# 依存ツリー内の setup.mjs / Math_Symbol.js を洗い出してハッシュ照合
find node_modules -type f \( -name 'setup.mjs' -o -name 'Math_Symbol.js' -o -name 'math_init.js' \) \
  -exec shasum -a 256 {} +

# 上の出力を既知のIoCと突き合わせる(macOS/Linux共通)
find node_modules -type f \( -name 'setup.mjs' -o -name 'Math_Symbol.js' -o -name 'math_init.js' \) \
  -exec shasum -a 256 {} + \
  | grep -E '54dc7ea54a1317cca0e890a2770630cf7fa6c97813e0cb9d2caa93012b350668|9fc2570b7cef51c1b8df116d144d11ff4096357be7d2c4c6367cfc2509cf1bcc|fd3ca4007b225fdf8de7af4345a19179d5efa8c4bb9205f88cda806e5684b1eb'

# 送出先ドメインへの通信痕跡をログから探す(実在するホスト名で照合)
grep -rn 'npm-cache\.com' ~/.npm/_logs/ 2>/dev/null

ヒットが無いことは「安全の証明」ではない点に注意してほしい。node_modules を作り直していれば痕跡は消えるし、npm-cache[.]com への通信はnpmのログではなくマルウェア自身が行うため、確実なのはネットワーク側(プロキシ・DNS・ファイアウォールのログ)で当該ドメインへの名前解決や接続を8月4日以降について検索することだ。

provenance署名は「正規」だった——来歴検証の限界

本件で特に注意が必要なのは、npmのprovenance(来歴)検証をすり抜けている点だ。

Snykの解析によれば、npmのマニフェスト上で [email protected]GitHub Actionsを信頼された発行元として記録し、正規のattestation(署名)にリンクしている。攻撃者がリポジトリのmainブランチへ悪性ファイルをpushしたうえでリリースを切ったため、正規のワークフローが悪性成果物をビルドし、それに署名したという構図になる。

同社はこの限界を、来歴は「ソースやワークフローの文脈が既に侵害されているビルドであっても、忠実に証明しうる」と表現している。provenanceが証明するのは「このパッケージが、このリポジトリのこのワークフローから作られた」ことであって、「その中身が安全である」ことではない。

実務上の含意は明確だ。

npm audit signatures によるprovenance検証は、タイポスクワッティングや野良トークンからの publish には有効だが、本件のようなアカウント/リポジトリ侵害型には効かない
・「provenance付きだから信頼できる」という運用ルールは、本件では誤った安心を与える方向に働いた
・防御の主軸は署名検証ではなく、後述のスクリプト実行の抑止公開直後バージョンの待機に置くべき

上流の確認先が現在参照できない

本件を検証するうえで無視できないのが、上流のリポジトリとメンテナアカウントが現在参照できない点だ。2026年8月4日の確認時点で、以下はいずれもHTTP 404を返す。

github.com/jaredwray/keyv
github.com/jaredwray/cacheable
github.com/jaredwray/flat-cache
github.com/jaredwray/file-entry-cache
github.com/jaredwray(メンテナのアカウント)

リダイレクトは発生しておらず(最終URLが元のURLのまま)、GitHubのリポジトリ名変更でよくある転送とは異なる挙動だ。ただし404の理由は特定できない。アカウント停止・削除・非公開化のいずれもこの結果になりうるため、当サイトとしては観測事実のみを記す。なお共同メンテナの github.com/lukechilds は200を返しており、アカウント単位での差がある。

実務上の含意は明快で、公式リポジトリのissueやセキュリティ勧告を読んで確認する、という通常の手段が現時点では使えない。だからこそ、確認の起点は自分のlockfileとキャッシュに置くべきだ。

【8月6日続報】npm installなしで実行される——gitに直接コミットされる経路

ここが本記事で最も更新の大きい部分だ。当初「インストール時に走る preinstall」として理解されていた本件には、パッケージを一切インストールしなくても実行される第二の経路があると続報で報告された。Aikido Security の続報に加え、JFrog・Datadog Security Labs・The Hacker News(SafeDep の観測を引用)が同じ構図を伝えている。

要点を先に言えば、こうだ。ワームは盗んだGitHubトークンを使って、到達できるリポジトリのブランチへ直接コミットする。 仕込まれるのはエディタとAIコーディングエージェントの設定ファイルで、次に誰かがそのリポジトリをVS Codeで開くか、Claude Codeのセッションを開始した時点で実行される。npmは一度も登場しない。

graph TD A["汚染パッケージを install した被害者A
preinstall が起動"] --> B["環境からGitHubトークンを窃取
(種別は各社で報告が割れる)"] B --> C["トークンで到達可能な
リポジトリを列挙"] C --> D["1リポあたり最大50ブランチへ直接コミット
最近アクティブな順・dependabot/copilot は除外"] D --> E[".vscode/tasks.json
folderOpen タスクを追加"] D --> F[".claude/settings.json
SessionStart フックを追加"] E --> G["開発者Bがリポジトリを
VS Codeで開く"] F --> H["開発者BがClaude Codeの
セッションを開始"] G --> I["npm install なしで実行
開発者Bはkeyvを使っていない"] H --> I I --> J["Bの認証情報を窃取し
さらに次のリポジトリへ"]

使われたトークンの種別は各社で報告が割れている

コミットに使われたGitHubトークンの種別については、調査各社の報告が一致していない。ここは読者の対応(何を回すか)に直結するため、統一せずそのまま並べる。

Aikidoghs_(GitHub App のインストールトークン。サーバー間認証用)
JFrogghp_ / gho_(classic PAT / OAuthトークン)で、workflow スコープを持つもの
Datadog — 伝播については「GitHub Actions環境の認証情報」とだけ記載。ghp_/gho_ はデータ送出のフォールバック経路として言及

この食い違いは、観測されたサンプルや時期の違いによるものと考えられるが、当サイトでは判定できない。実務上の結論は単純で、種別を絞り込む意味はない——その環境から到達できたGitHubの認証情報は、形式を問わずすべて回すのが正しい対応になる。前掲のローテーション表にGitHub Appのインストールトークンを含めているのはこのためだ。

仕込まれる2ファイルは「相手側」を指す

報告されている構造は、一見すると回りくどい。2つの設定ファイルが、互いに相手のツールのディレクトリに置かれたスクリプトを指す形になっている。

仕込まれるファイル 実行契機 呼び出す先
.vscode/tasks.json runOn: folderOpen(フォルダを開いた時点) .claude/ 配下のセットアップスクリプト
.claude/settings.json SessionStart フック(セッション開始時) .vscode/ 配下のセットアップスクリプト

この交差参照は、当初 Snyk 単独の報告だったが、JFrog・Datadog・The Hacker News も同じ組み合わせを報告しており、複数ソースで一致している。JFrog はさらに、実際にコミットされるファイルとして .vscode/tasks.json.vscode/setup.mjs.claude/settings.json.claude/setup.mjs.claude/math_init.js5点を挙げている。後述の検索では、この5つのパスすべてを対象にするのが確実だ。

交差参照の意図は報告からは断定できないが、実務上の含意ははっきりしている。片方だけを消しても、もう片方が生き残る.vscode/ を消してVS Code側を潰しても、.claude/settings.json が残っていればClaude Codeの起動で再び走る。除去は必ず両方まとめて行う必要がある。

本記事で扱わないもの(続報分)
仕込まれる tasks.json / settings.json の実際のJSON、スクリプトの中身、コミットを再現する手順は記載しない。上記は自分のリポジトリが該当するかを判定するための構造の説明にとどめている。

なぜ従来の対策が丸ごと沈黙するのか

この経路が厄介なのは、本件でこれまで有効だった確認手段がひとつも効かない点だ。

従来の手段 この経路に効くか 理由
npmレジストリからの削除(unpublish) 効かない 配送経路がnpmではなくgit push
npm audit 効かない 依存ツリーを見る。gitにコミットされた設定ファイルは対象外
lockfile監査・SBOM 効かない 依存として宣言されていないため、どこにも現れない
--ignore-scripts / npm v12 の既定ブロック 効かない ライフサイクルスクリプトではなくエディタのタスク/フック
min-release-age(公開直後を待つ) 効かない パッケージのバージョンを掴む話ではない
リポジトリの差分レビュー 効く 唯一この経路を捉えられる層

理由は一行でまとめられる。従来のサプライチェーン対策は「これからinstallする物」を検査する設計だが、この経路の成果物はもうリポジトリの中にコミットされている。防御の層が丸ごと手前を素通りしている。

だからこそ、確認の主戦場は依存関係ではなくgit履歴に移る。

自分のリポジトリを確認する——作者名で探すと取りこぼす

ここは実際に手を動かして検証した部分だ。続報では、コミットが作者 claude[email protected])・メッセージ chore: update config の形で行われ、AI支援開発による変更に紛れると報告されている。

素直に読むと、次のような検索を思いつく。

# 直感的だが、これだけでは取りこぼす(後述)
git log --since="2026-08-04" --all --author=claude --grep="update config"

ところが、コミットの帰属は調査各社で報告が食い違っている

Aikido — 作者名 claude、メールアドレス [email protected][.]com
JFrogCo-authored-by: claude <...> というトレーラー行(本文中の行であって作者ヘッダではない)
SafeDep(The Hacker News 経由) — 作者フィールドは github-actions[bot]。しかもGitHubの緑の「Verified」バッジ付き

そこで、この3パターンを実際に含むテスト用リポジトリを作り、上の検索コマンドが何件拾えるかを確かめた。結果は次のとおりだ。

検索方法 3パターン中の検出数 取りこぼすもの
--author=claude --grep="update config" 1 / 3 トレーラー型・github-actions[bot]
--author=claude のみ 1 / 3 同上
--grep="chore: update config" のみ 3 / 3 (※メッセージが同一の場合に限る)
仕込まれるファイルのパス指定 確実

--author が拾えないのは、これがコミットの作者ヘッダだけを見るオプションだからだ。JFrogが報告する Co-authored-by: はコミットメッセージ本文中のトレーラー行なので、作者ヘッダには現れない。さらに --author--grepAND条件で結合されるため、両方を指定すると取りこぼしがいっそう増える。

--grep が3件すべてを拾ったのは、テストで3つとも同じメッセージにしたからであって、実際の攻撃でメッセージが揃っている保証はない。メッセージも作者名も、攻撃者が自由に変えられるメタデータだ。

したがって確実なのは、攻撃者が変更できない「仕込まれるファイルのパス」を軸に検索することになる。

# 【最重要】まずfetchする。ローカルに無いブランチは検索対象にならない
git fetch --all --prune

# 主軸:仕込まれる5ファイルのパスで全ブランチ横断(作者・メッセージに依存しない)
git log --all --oneline --since="2026-08-01" \
  -- .vscode/tasks.json .vscode/setup.mjs \
     .claude/settings.json .claude/setup.mjs .claude/math_init.js

# 差分を実際に読む(何が追加されたかを目で確認する)
git log -p --all --since="2026-08-01" \
  -- .vscode/tasks.json .claude/settings.json

# 補助:メッセージでの絞り込み(主軸の裏取りとして。これ単独に頼らない)
git log --all --oneline --grep="chore: update config"

# 補助:報告されている3つの帰属をまとめて確認する
git log --all --since="2026-08-01" \
  --format='%h %ad %an <%ae> | %s' --date=short \
  | grep -Ei 'claude|github-actions\[bot\]'

fetchを忘れると偽陰性になる

上のコマンドで git fetch --all --prune を最初に置いたのには理由がある。ここも実測で確かめた。

git log --allチェックアウトしていないリモート追跡ブランチも検索対象に含む。これは良い性質だ。しかし --all が見るのは「すでにfetch済みのref」に限られる。試しに、cloneした後で別のブランチにコミットが追加された状況を作ると、次のようになった。

状態 git log --all --grep=... の検出数
clone直後(リモートの3ブランチぶん取得済み) 3件
その後リモートに1ブランチ追加、fetch前 3件(新しい1件を見落とす)
同じ状態で git fetch 4件

本件の攻撃は最大50ブランチ・最近アクティブな順に仕込むと報告されている。手元にチェックアウトしてあるブランチはせいぜい数本だろうから、fetchを省くと、まさに狙われたブランチを丸ごと見落とす。この点はどの調査報告にも明記されていないが、実務上いちばん踏みやすい落とし穴だ。

なお --since にも同じ注意が要る。コミットのタイムスタンプは作者名と同様にメタデータであり、攻撃者側で設定できる。日付での絞り込みは検索を軽くするための便宜であって、これを唯一の条件にはしないでほしい。

dependabotブランチは「除外」だけではない

報告されているブランチの選び方には、そのまま読むと誤解しやすい部分がある。

ワームはフックを仕込む際に dependabot/*copilot/* のブランチをスキップする(Aikido・JFrog・Datadog が一致)。自動生成ブランチは頻繁に作り直されるため、仕込んでも消えやすいからだと考えられる。

ただしJFrogは、これとは別に、GitHub ActionsのSecretsを収集する目的で dependabot/github_actions/format/setup-formatter というブランチを攻撃者自身が作成すると報告している。つまり「dependabotブランチは無関係」ではない。

実務的な含意はひとつだ。スキャン時に dependabot/* を除外フィルタで弾くと、この攻撃者作成ブランチを見落とす。ブランチ名で除外せず、全ブランチを対象にしてほしい。

# 攻撃者が作成すると報告されているブランチ名を直接確認する
git branch -a --list '*setup-formatter*'

# 8月4日以降に作られた見覚えのないリモートブランチを新しい順に一覧
git for-each-ref --sort=-committerdate \
  --format='%(committerdate:short) %(refname:short) %(authorname)' refs/remotes/ \
  | head -50

組織全体を横断で走査する

リポジトリを1つずつ見るのは現実的ではないため、組織単位でまとめて確認する。

# Organization配下を順に clone せず走査する(GitHub API で該当ファイルの有無を確認)
for repo in $(gh repo list <org> --limit 200 --json nameWithOwner --jq '.[].nameWithOwner'); do
  for f in .vscode/tasks.json .claude/settings.json; do
    if gh api "repos/$repo/contents/$f" --silent 2>/dev/null; then
      echo "FOUND: $repo/$f"
    fi
  done
done

# 手元にcloneしてある全リポジトリを一括で確認する場合
find ~/code -maxdepth 3 -name .git -type d 2>/dev/null | while read -r g; do
  d=$(dirname "$g")
  git -C "$d" fetch --all --prune --quiet 2>/dev/null
  hits=$(git -C "$d" log --all --oneline --since="2026-08-01" \
    -- .vscode/tasks.json .claude/settings.json 2>/dev/null)
  [ -n "$hits" ] && printf '=== %s ===\n%s\n' "$d" "$hits"
done

上の1つ目はファイルが存在するかしか見ないため、正規の tasks.json を持つリポジトリも当然ヒットする。ヒットしたリポジトリについて、2つ目の履歴確認や差分レビューへ進む、という二段構えで使ってほしい。

常駐化の痕跡もあわせて確認する

Snykは、常駐化のための以下のファイルを挙げている。設定ファイルを消しても、これらが残っていれば意味がない。

gh-token-monitor.sh(GitHubトークンを監視するスクリプト)
gh-token-monitor.service(Linuxのsystemdユニット)
com.user.gh-token-monitor.plist(macOSのlaunchdエージェント)

# リポジトリ内の実行フックを確認(folderOpen / SessionStart / setup.mjs 参照)
grep -rn 'folderOpen\|SessionStart\|setup\.mjs' .vscode/ .claude/ 2>/dev/null

# フックが外部スクリプトを呼び出していないか(正規の設定でも使う書式なので目視で判断する)
grep -rnE '"(command|args)"[[:space:]]*:.*\.(mjs|js|sh)' .vscode/ .claude/ 2>/dev/null

# 身に覚えのない setup.mjs がリポジトリ内に置かれていないか
ls -la .vscode/setup.mjs .claude/setup.mjs .claude/math_init.js 2>/dev/null

# 常駐化の痕跡(macOS / Linux)
ls -la ~/Library/LaunchAgents/com.user.gh-token-monitor.plist 2>/dev/null
systemctl --user list-unit-files 2>/dev/null | grep -i 'gh-token-monitor'
find ~ -maxdepth 4 -name 'gh-token-monitor*' 2>/dev/null

該当が見つかった場合の対応は、設定ファイルの除去より認証情報のローテーションが先だ(後述)。コミットのrevertは痕跡を消すだけで、すでに盗まれたトークンは無効化しない。

見つかったコミットへの対応

不審なコミットを特定できた場合、次の順で進める。

まず認証情報を回す——後述のローテーション手順を先に実行する。revertを先にやっても盗まれたトークンは有効なまま
コミットをrevertする——仕込まれたファイルを元に戻す。git revert <sha> を該当ブランチごとに行う。両方のファイルを必ず同時に(片方だけでは残る)
リポジトリを開く前にフックを止める——確認前にVS Codeで開くと実行されうる。VS Codeの自動タスク実行は設定 task.allowAutomaticTasksoff にすることで抑止できる。Claude Code側も、リポジトリの .claude/settings.json に見覚えのない SessionStart フックが無いかを開く前にエディタ以外の手段(catgit show)で確認する
トークンの発行元をたどる——コミットに使われたトークンが自組織の誰のものかを、GitHubの監査ログで特定する。そのアカウントも侵害されている可能性が高い

確認前にリポジトリを開かない
この経路は「開く」ことが実行契機になる。調査対象のリポジトリを普段どおりVS Codeで開いて中身を確認する、という手順そのものが引き金を引きうる。確認は git showcat など、エディタを起動しない手段で先に行ってほしい。

侵害が疑われる場合の初動対応と影響範囲

lockfileやキャッシュに該当が見つかった場合、あるいは露出時間帯にCIが依存解決を走らせていた場合の対応をまとめる。優先順位は「認証情報の無効化」が最上位で、パッケージの入れ替えはその後でよい。盗まれた認証情報は削除では取り消せないためだ。

1. 認証情報のローテーション(最優先)

対象は当該マシンおよびCIランナーから到達可能だったすべての認証情報だ。ただし闇雲に全部を回すと時間がかかり、その間も窃取済みトークンは有効なままになる。優先順位は「攻撃者がさらに増殖に使えるもの」からだ。本件はワームであり、npmトークンとGitHubトークンは次の被害者を作る材料に直結する。

下表は、各調査ソースが実際に窃取対象として報告しているものと、報告には無いが同一環境から到達可能で予防的に回すべきものを区別して整理したものだ。

優先 対象 報告状況 手順
1 npmトークン(~/.npmrc 報告あり npm token listnpm token revoke <id>。CI用のAutomationトークンも忘れず失効
1 GitHub PAT(classic / fine-grained) 報告あり github.com/settings/tokens で全件失効 → 必要分のみ再発行
1 GitHub App インストールトークン(ghs_ 報告あり(Aikido。※種別は各社で割れる・後述) App側で秘密鍵をローテートし、インストールを再認可。他リポジトリへのコミットに使われたと報告される種別
1 GitHub Actions secrets 報告あり(ランナーメモリから読み取り) リポジトリ・Organization・Environment の全Secretsを新値へ更新
1 GitHub OAuth / App の認可 報告あり github.com/settings/applications で不審な認可を取り消す
2 AWS IAM アクセスキー 報告あり aws iam update-access-key --status Inactive → 新キー発行 → 旧キー削除
2 AWS Secrets Manager / SSM Parameter Store 予防的 キー本体が漏れた前提で、格納中のシークレットを rotate-secret で更新
2 GCP サービスアカウントキー 報告あり(クラウド認証情報) gcloud iam service-accounts keys delete → 新キー発行。可能ならWorkload Identityへ移行
2 Azure サービスプリンシパル 報告あり(クラウド認証情報) az ad sp credential reset でシークレット再発行
3 Kubernetes ServiceAccount トークン 報告あり 該当Secretを削除して再発行し、kubeconfig を配り直す
3 HashiCorp Vault トークン 報告あり vault token revoke → AppRole の secret_id を再発行
3 SSH鍵 報告あり 新しい鍵ペアを生成し、authorized_keys とGitHubのデプロイキーを入れ替える
3 Docker レジストリ認証(~/.docker/config.json 報告あり docker logout 後、レジストリ側でトークンを再発行
4 Stripe / Slack などのSaaS APIキー 報告あり 各サービスの管理画面でキーをローテートし、旧キーの利用ログを確認
4 データベース接続文字列 報告あり 対象DBユーザーのパスワードを変更し、接続元IPを確認
4 Terraform state 報告あり state内の機微値を棚卸しし、バックエンド(S3等)の資格情報を更新
4 暗号資産ウォレットの鍵 報告あり 鍵は「ローテーション」できない。新規ウォレットを作成し、資産を速やかに移す

主要なものはコマンドで一気に棚卸しできる。

# npm: 発行済みトークンの一覧と失効
npm token list
npm token revoke <token-id>

# GitHub: 現在の認証状態とスコープを確認(失効はWeb UIで実施)
gh auth status

# AWS: アクセスキーの棚卸しと無効化(削除前に無効化して影響を確認)
aws iam list-access-keys --user-name <user>
aws iam update-access-key --access-key-id <key-id> --status Inactive --user-name <user>

# Kubernetes: ServiceAccountのトークンSecretを列挙
kubectl get secrets --all-namespaces --field-selector type=kubernetes.io/service-account-token

# Vault: 自分のトークンを失効
vault token revoke -self

~/.npmrc、環境変数、~/.aws/credentials、SSH鍵、kubeconfig、Vaultトークンを棚卸しする。CI環境で発生した場合は、リポジトリのSecretsとOIDC連携先のロールも対象になる。OIDCで短命credentialを発行している場合は、長期キーそのものが存在しないぶん被害が小さい——これは本件に限らず、恒久キーをCIに置かない運用の利点として効く。

2. 汚染版の除去とlockfileの再生成

# node_modules と lockfile を破棄して作り直す
rm -rf node_modules package-lock.json
npm install

# npmキャッシュの検証・クリア(汚染tarballの残骸を除去)
npm cache verify
npm cache clean --force

# pnpm / yarn の場合
pnpm store prune
yarn cache clean

削除済みのため、再インストールすればクリーン版に解決される。ただしlockfileを作り直す前に、必ず旧lockfileの内容を証跡として保存しておくこと。どのバージョンを引いていたかは事後の調査に必要になる。

3. 送出先の痕跡を自組織内で確認

Aikidoの報告では、窃取データの送出先は攻撃者が被害者のアカウント権限で作成する公開GitHubリポジトリであり、そのdescriptionには Shai-Hulud: Here We Go Again という文字列が入るとされる。この文字列がわかっているぶん、「身に覚えがあるか」という曖昧な判断ではなく機械的に照合できる

# 自アカウント: description が既知のマーカーに一致する公開リポジトリを直接検出
gh repo list --visibility public --limit 500 --json name,createdAt,description \
  --jq '.[] | select(.description // "" | test("Shai-Hulud")) | "\(.createdAt) \(.name) — \(.description)"'

# Organization も同様に照合
gh repo list <org> --visibility public --limit 1000 --json name,createdAt,description \
  --jq '.[] | select(.description // "" | test("Shai-Hulud")) | "\(.createdAt) \(.name) — \(.description)"'

# マーカーが変化している可能性に備え、8月4日以降に作られた公開リポジトリも一覧する
gh repo list --visibility public --limit 500 --json name,createdAt,description \
  --jq '.[] | select(.createdAt > "2026-08-04") | "\(.createdAt) \(.name) — \(.description // "")"'

マーカー文字列は攻撃者側でいつでも変更できるため、1つ目のコマンドで何も出ないことを安全の根拠にしない。3つ目の「作成日での洗い出し」まで実行して、身に覚えのないリポジトリが無いことを目視で確認してほしい。

身に覚えのないリポジトリが見つかった場合、公開状態のまま放置すると認証情報が露出し続けるため、直ちに非公開化または削除し、含まれていた認証情報をすべて失効させる。

なお、他組織の送出先リポジトリを閲覧・収集する行為は他者の認証情報に触れることになるため、確認は自分の管理下にあるアカウント・Organizationに限る。本記事では攻撃の再現手順や送出先の具体的なリポジトリURLは扱わない。

4. 監査ログの確認

# GitHub Organization の監査ログからリポジトリ作成イベントを抽出
gh api "/orgs/<org>/audit-log?phrase=action:repo.create&per_page=100" \
  --jq '.[] | "\(.created_at) \(.actor) \(.repo)"' 2>/dev/null

# npm の公開履歴(自分のパッケージが勝手に公開されていないか)
npm access list packages 2>/dev/null

影響範囲マトリクス

自分の状況がどこに当てはまるかを判断するための整理だ。

条件 汚染版を引いた可能性 必要な対応
lockfileをコミットし npm ci のみ実行 低い(lockfileが8/4に更新されていなければ) lockfileの更新日時とバージョンを確認
lockfileなしで npm install を実行(露出時間帯) 高い 認証情報のローテーション+全確認
CIでlockfileを再生成する構成 高い CIログの時刻とETARGET有無を確認
Renovate/Dependabotのパッチ自動マージ有効 高い(第2波はすべてパッチ) マージ履歴を8/4で確認
keyv^5.x で利用 低い(6.0.0はマッチしない) 念のためlockfile確認
@keyv/redis 等のアダプタを露出時間帯に新規インストール 高い(当時の最上位公開版が6.0.0) lockfileとキャッシュを確認
@keyv/* を既存lockfile経由でのみ利用 低い(推移的には到達しない※) lockfileの更新日時を確認
flat-cache@^6 / file-entry-cache@^11 を利用 高い lockfileとキャッシュを確認
eslint経由でのみ利用 低い(^8.0.0系列は無傷) 直接依存がないか確認
@crawlee/* @servicetitan/* @or-sdk/* 等を利用 二次感染の対象になりうる IoCハッシュとlockfileの8/4以降の差分を確認
.vscode/tasks.json / .claude/settings.json に覚えのない setup.mjs 参照がある 高い(IDE起動で再実行されうる) 設定を除去し認証情報をローテーション
keyv系を一切使っていないが、汚染された誰かがコミット権を持つリポジトリに参加している 対象になりうる(npm経路とは無関係に、gitコミット経由で仕込まれる) git fetch --all --prune のうえ、仕込まれる5ファイルのパスで全ブランチの履歴を確認
VS Codeの自動タスク実行が有効(既定) 仕込まれていた場合、リポジトリを開いた時点で実行 task.allowAutomaticTasksoff に。確認は開く前に git show
普段チェックアウトしないブランチが多数あるリポジトリ 見落としやすい(最大50ブランチに仕込まれると報告) git fetch してから --all で横断検索(fetch前は偽陰性)
npm v12 系でインストールしていた 低い(既定でスクリプトがブロックされる) CIログの install-scripts 警告を確認
露出時間帯にインストールしていない 低い 確認のみで可

@keyv/* アダプタを参照している既存パッケージの依存指定は @cacheable/memory 2.2.0@keyv/bigmap: ^1.3.1keyv 5.6.0@keyv/serialize: ^1.1.1 であり、いずれも 6.0.0 にはマッチしない(なお @keyv/serialize は8月4日に公開されたパッケージには含まれていない)。アダプタが引き込まれうるのは、露出時間帯にバージョン指定なしで新規インストールした場合に限られる。

依存自動更新の運用そのものについては「Renovate・Dependabotの自動PRがマルウェアを運ぶ:開発者が今すぐ確認すべきこと」で、パッチ自動マージのリスクと緩和策を扱っている。本件はパッチ自動マージが最も刺さる形の攻撃だったため、設定を見直す契機になる。

過去のnpmワーム型攻撃との比較

2026年に入ってからのnpmサプライチェーン攻撃と並べると、本件の位置づけがはっきりする。

事案 時期 侵入経路 実行契機 自己増殖
TanStack(Mini Shai-Hulud) 2026-05 CI設定不備(pull_request_target)→OIDC・トークン窃取 optionalDependencies+prepare あり
AsyncAPI 2026-07 CI設定不備(pwn request) import時 なし
本件(keyv系・Mini Shai-Hulud亜種) 2026-08 GitHubアカウント侵害 preinstall他リポジトリへのgitコミット+IDE・エージェント設定フック(install不要) あり(npm公開とgit push の2経路)

侵入経路を見ると、2026年5月・7月の事案はいずれもCIワークフローの設定不備が入口だったのに対し、本件はアカウントそのものの侵害という違いがある。CI設定を固めるだけでは本件は防げない。

実行契機の違いも防御策に直結する。AsyncAPIの事案はimport時に起動したため --ignore-scripts では防げなかったが、本件は preinstall なので --ignore-scripts が有効に働く。ただしこれはインストール時に既に設定していた場合の話で、事後の緩和策にはならない。

# 今後の緩和策として(事後の対応にはならない点に注意)
npm config set ignore-scripts true

# プロジェクト単位で設定する場合は .npmrc に記述
echo 'ignore-scripts=true' >> .npmrc

# CIでは install コマンド側で明示するのが確実(lockfile厳格解決+スクリプト無効)
npm ci --ignore-scripts

ignore-scripts=true は一部のネイティブモジュールのビルドを壊すため、有効化する場合はCIでビルドが通ることを確認してからにしてほしい。

npm v12 の既定は「スクリプト実行しない」——実際に試して確かめた

ここは記事公開後に検証を追加した部分だ。npm 12以降でライフサイクルスクリプトの既定挙動が変わったことは知られているが、本件の preinstall が実際にブロックされるのかは、バージョンによって挙動が違うため実機で確かめるのが早い。

そこで、preinstall でファイルを書き出すだけの無害なパッケージを用意し、npm 11.1.0 と npm 12.0.2 の両方で同じものをインストールして比較した。結果は明確に分かれた。

npmバージョン 追加設定 preinstall の実行 インストール自体
11.1.0 なし(既定) 実行された 成功
12.0.2 なし(既定) ブロックされた 成功(警告のみ)

npm 12.0.2 が出力した警告は次のとおりだ。この文字列はCIログでそのままgrepできる

npm warn install-scripts 1 package had install scripts blocked because they are not covered by allowScripts:
npm warn install-scripts   [email protected] (preinstall: ...)
npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow.

注目すべきは、ブロックされてもインストール自体は成功する点だ(added 1 package と表示された)。npm v12 の既定は「失敗させる」ではなく「スクリプトだけ黙って飛ばして警告する」という穏当な設計になっている。裏を返すと、警告を読み飛ばすとビルドに必要な正規スクリプトまで実行されないまま進むため、移行時は npm install-scripts ls で棚卸ししてから approve する運用が要る。

npm v12.0.0 のリリースノートでは、この変更に加えて次の既定変更も明記されている。

・依存パッケージのライフサイクルスクリプトは、ルートパッケージの allowScripts 方針で許可されない限りブロック(設定名は allow-scripts、既定は空)
allow-gitallow-remote が既定 "none" に変更。gitリポジトリ参照やtarball URL指定の依存はそのままではインストールされない

つまり npm v12 に上げるだけで、本件の preinstall 型は既定で止まる。2026年8月時点で npm の latest タグは 12.0.2 であり、v12は既に既定の配布版だ。この変更の詳細と段階的な移行手順は「npm v12でpostinstallがデフォルト無効化へ|自システム確認コマンドと段階的な移行手順」で整理している。

公開直後のバージョンを掴まない——cooldown設定は npm と pnpm で別物

本件の露出時間は最短28分だった。「公開されてから一定時間が経ったバージョンしかインストールしない」という設定があれば、この種の攻撃はそもそも掴まずに済む。ただしnpmとpnpmで既定が正反対なので、混同しないでほしい。

ツール 設定名 既定値 本件への効き方
npm v11 / v12 min-release-age null(無効) 明示的に設定しない限り効かない
pnpm v11以降 minimumReleaseAge 1440(=24時間) 既定のまま24時間待つため、削除までに掴まない

npmは「自分で書かないと無効」だ。v12でも既定は null のままで、スクリプト無効化のように自動では有効にならない。有効にするには .npmrc に明示する。

# npm: 公開から7日経過したバージョンのみ許可(Flatt Securityの推奨値)
echo 'min-release-age=7' >> .npmrc

# 特定パッケージだけ即時更新を許可したい場合
echo 'min-release-age-exclude=@myorg/*' >> .npmrc

# 現在の設定を確認(古いnpm 11.x では undefined と出る=この設定自体が未実装)
npm config get min-release-age

min-release-age の単位は日数で、「指定日数より前に公開されたバージョンのみをツリーに含める」という意味になる。一方 pnpm の minimumReleaseAge分単位(既定1440分=24時間、pnpm v10.16.0で導入・v11から既定有効)だ。単位が違う点に注意してほしい。

なお、この設定はセキュリティ修正版の取り込みも同じだけ遅らせる。緊急のパッチを即座に入れたい場合は min-release-age-exclude で対象を絞るか、一時的に無効化する運用を用意しておく必要がある。

provenance検証だけに寄りかからない

前述のとおり、本件の汚染版には正規のGitHub Actions由来のprovenance署名が付いていた。したがって次の順序で考えるのが実際的だ。

第1にスクリプトを実行させない(npm v12の既定、または --ignore-scripts)——本件の初期実行を止められる
第2に公開直後を掴まない(min-release-age / minimumReleaseAge)——露出時間が短い攻撃をやり過ごせる
第3にprovenanceを検証する(npm audit signatures)——発行元の偽装には効くが、本件のようなアカウント侵害型には効かない

3つを「どれか1つ」ではなく重ねる前提で選ぶ。本件が示したのは、署名の検証は最後の砦ではなく、最も手前で破られうる層だったということだ。

自己増殖機能を持つ点はTanStackの事案と共通する。各社は本件のペイロードをMini Shai-Hulud の亜種と位置づけており、2025年9月に最初に観測されたShai-Hulud系の手法が、メンテナ間を横断して伝播する形で継続していることを示している。

本件で新しいのは、伝播経路が「パッケージのインストール」から切り離された点だ(前述)。盗んだトークンで他人のリポジトリへ直接コミットし、IDEとAIコーディングエージェントの設定ファイルを実行面として使う——この経路では、被害者はnpmのパッケージを一度もインストールしていない。node_modules の入れ替えでもレジストリ側の削除でも対応が閉じないのはこのためだ。

keyv汚染事案から引き出せる実務的な教訓

本件を一次データで追ったうえで、再現性のある形で残せる教訓は5つある。

1つ目は、ダウンロード数と実際の影響範囲は一致しないこと。 月間5.7億ダウンロードの file-entry-cache が汚染されたと聞けば全Node.js開発者が影響を受けたように見えるが、実際にはeslintが参照する ^8.0.0 系列は無傷だった。影響範囲は「パッケージ名」ではなく「メジャー系列」の粒度で確認する必要がある。

2つ目は、削除は再インストールの防止であって、インシデント対応ではないこと。 30本すべてが同日中に削除されたのは迅速な対応だが、露出していた28分〜3時間半のあいだにインストールした環境では、すでに preinstall が実行されている。証跡は手元にしか残らない——レジストリ側からは消え、上流リポジトリも現在404を返す状況では、なおさらだ。

3つ目は、標準ツールが沈黙する種類の事案があること。 npm audit が「0 vulnerabilities」と表示するのは、DBに登録がないからであって安全だからではない。アカウント侵害型の攻撃では、脆弱性DBを起点にした確認が構造的に機能しない。lockfileとローカルキャッシュを直接見る手順を、平時から手元に持っておく価値がある。

4つ目は、署名(provenance)は最後の砦ではなく、最も手前で破られうる層だということ。 本件の汚染版には正規のGitHub Actions由来のattestationが付いていた。攻撃者がリポジトリを掌握した以上、正規のワークフローが正規に悪性成果物へ署名したからだ。「provenance付きだから安全」という運用ルールは、本件では誤った安心を与える方向に働いた。実効性の高い順は、①スクリプトを実行させない(npm v12の既定・--ignore-scripts)②公開直後を掴まない(min-release-age / minimumReleaseAge)③署名を検証する、であり、この順序は次の事案でもそのまま使える。

5つ目は、サプライチェーン対策が「installする物」しか見ていないこと。 続報で明らかになったgitコミット経由の経路は、レジストリからの削除も npm audit もlockfile監査もSBOMも --ignore-scripts も、ひとつ残らず素通りする。仕込まれた成果物が依存関係ではなく、リポジトリにコミットされた設定ファイルだからだ。エディタとAIエージェントの設定ファイルは、実行される以上コードとして扱う必要がある——レビュー対象に含め、CODEOWNERSを設定し、変更時に人が見る導線を作っておくこと。この経路を捉えられる層は差分レビューだけだった。

3つの教訓を整理した図。ダウンロード数と影響範囲は一致しない、削除はインシデント対応ではない、npm auditは構造的に沈黙する
一次データで追って初めて見える3点。いずれも次のインシデントでそのまま再利用できる観点だ

検証環境と確認時刻

本記事の一次データは、以下の方法・時刻で取得した。

初回取得日時:2026年8月4日 21:00 JST 時点
追記・再確認日時:2026年8月5日 07:00 JST 時点(IoC整理・provenance・npm防御設定の検証を追加)
続報の追記日時:2026年8月6日 09:00 JST 時点(gitコミット経由の実行経路・検索コマンドの実測検証を追加)
gitコミット検索の検証方法:報告されている3つの帰属パターン(作者名 claudeCo-authored-by: claude トレーラー / 作者 github-actions[bot])を実際に含むテスト用リポジトリをローカルに作成し、git log の各オプションが何件検出するかを比較。--author=claude --grep="update config" は3件中1件のみ検出、パス指定は仕込まれるファイルに触れたコミットを確実に検出することを確認
fetch有無の検証方法:bareリポジトリをoriginとしてcloneし、clone後にoriginへ新規ブランチとコミットを追加した状態で git log --all --grep を実行。fetch前は3件・fetch後は4件となり、未fetchのブランチは --all の検索対象に入らないことを確認(Git 2.39.3 / Apple Git-146)
取得方法:npmレジストリAPI(https://registry.npmjs.org/<package>)の time および versions フィールドの差分。当該メンテナが公開する61パッケージを全件照合
削除判定time に公開時刻の記録があり、かつ versions に当該バージョンが存在しないものを「削除済み」と判定
時系列の相互検証:Snykが公表した11リリースのUTC時刻と、当サイトがレジストリから独立取得したJST時刻を突き合わせ(11件すべて秒単位で一致)
アドバイザリ確認:OSV API(api.osv.dev/v1/query)、GitHub Security Advisory API(/advisories?ecosystem=npm)。8月4日・8月5日の2回照会し、いずれも該当なし(空レスポンス)
リポジトリ状態curl -sL によるHTTPステータスとリダイレクト先の確認
npm audit の挙動:クリーン版 flat-cache 6.1.23 を実際にインストールして実行
npmライフサイクルスクリプトの挙動preinstall でファイルを書き出すだけの無害な自作パッケージ([email protected])を npm pack し、npm 11.1.0 と npm 12.0.2 で同一手順によりインストールして実行有無を比較。実行基盤は Node.js v22.13.1

npm 12.0.2 の検証時、npm v12.0.2 does not support Node.js v22.13.1(対応は ^22.22.2 || ^24.15.0 || >=26.0.0)という警告が出た点は明記しておく。この警告下でもインストールは成功し、preinstall のブロックと install-scripts 警告は実際に観測できたため結果自体は成立しているが、サポート対象のNode.jsでの検証ではない。

汚染版のtarballはレジストリから削除済みであり(HTTP 404)、当サイトではマルウェア本体の取得・実行・解析は行っていない。したがってペイロードの内部挙動・IoCのハッシュ値・送出先ドメイン、およびgitコミット経由の伝播(ブランチ数・スキップ規則・コミットの帰属・トークン種別)に関する記述は、すべて Aikido Security・Snyk・JFrog・Datadog Security Labs・SafeDep(The Hacker News 経由)・GMO Flatt Security の報告に基づく二次情報であり、値ごとに出典を明示している。当サイトが独自に検証したのは、レジストリの時系列・依存関係の解決規則・npmの設定挙動・git log の検索挙動の4点に限られる。

とくに続報部分については、「攻撃側が何をしたか」は各社報告、「その報告を前提にしたとき自分の環境をどう確認できるか」は当サイトの実測、という切り分けで読んでほしい。検索コマンドの取りこぼしとfetch漏れの2点は、当サイトがローカルで再現して確かめたものだ。

本件は進行中の事案であり、二次感染の範囲、アドバイザリの登録、上流リポジトリの状態はいずれも変動しうる。確認の際は本記事の取得時刻を起点に、最新の状況を各自で確かめてほしい。

参照ソース

Aikido Security — Keyv and friends compromised in npm supply chain attack(ペイロード解析・IoC・二次感染範囲の出典。2026-08-05参照)
Snyk — Inside the keyv npm compromise: preinstall malware, trusted provenance, IDE hooks(11リリースのUTC時刻・provenance検証の限界・IDEフックの出典。2026-08-05参照)
GMO Flatt Security — keyv ソフトウェアサプライチェーン攻撃の概要と対応指針package.json ハッシュ・二次感染名前空間・ローテーション対象の出典。2026-08-05参照)
JFrog Security Research — Shai-Hulud is back (August)(コミットされる5ファイル・Co-authored-by トレーラー・攻撃者作成の dependabot/... ブランチ・トークン種別 ghp_/gho_ の出典。2026-08-06参照)
Datadog Security Labs — npm worm compromises popular npm packages(最大50ブランチ・dependabot/copilot除外・フックの参照構造の出典。2026-08-06参照)
The Hacker News — Keyv-linked npm worm poisons hundreds of packages(SafeDep観測の github-actions[bot] 帰属とVerifiedバッジ・パッケージ数420/1,684バージョンの出典。2026-08-06参照)
npm registry API — keyv パッケージメタデータ(公開・削除時刻の一次データ。2026-08-05参照)
npm CLI v12.0.0 リリースノートallowScriptsallow-gitallow-remote の既定変更。2026-08-05参照)
npm Docs — configmin-release-age 既定 nullallow-scripts 既定空。2026-08-05参照)
pnpm Settings — dependency resolutionminimumReleaseAge 既定1440分。2026-08-05参照)
npm Docs — npm unpublish(削除の挙動と制約。2026-08-04参照)
npm Docs — npm ci(lockfileの解決規則。2026-08-04参照)
OSV API(アドバイザリ登録状況の確認。2026-08-04・08-05の2回照会)