2026年8月4日、Node.jsのキャッシュ抽象化ライブラリ keyv と、その周辺の cacheable 系パッケージがnpm上で汚染された。攻撃者がメンテナのGitHubアカウントを侵害し、preinstall スクリプト付きの悪性バージョンを大量に公開したものだ。この記事では、npmレジストリAPIから取得した一次データで汚染された全パッケージと公開・削除の正確な時刻を確定し、読者が自分の環境を確認するためのコマンドまでを通しで整理する。
time フィールドから復元した時系列。すべてJST。公開は2つの波に分かれ、構造がまったく異なる(後述)・汚染されたのは広く報じられた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アカウントが侵害され、
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が ETARGET や E404 で落ち始めたなら、それは汚染版を解決していた証拠になる。エラーを「レジストリの一時障害」と判断してリトライやキャッシュ再生成で流してしまうと、本来拾えたはずのシグナルを消すことになる。ログを遡って確認してほしい。
npm audit でkeyv汚染を検出できない理由
多くの開発者が最初に試すのが npm audit だが、本件では機能しない。理由は単純で、照会先のアドバイザリDBに該当エントリが存在しないからだ。
2026年8月4日時点で確認した結果は以下のとおり。
・OSV — keyv 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 に揃えられていること自体が、スクリプトによる一括公開を示唆する。
第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波はすべて正しいパッチ更新になっている。この違いが、次章で述べる「どちらが危険だったか」を分ける。
レジストリ上に露出していた時間
パッケージドキュメントの 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整理でも同じフィールドが更新されるため)。
いずれにせよ、露出時間が短いことは安全を意味しない。CIが1回でもその時間帯に依存解決を走らせていれば、それで十分だからだ。
危険だったのはメジャー更新ではなくパッチ更新だった
報道で最も目立つのは keyv(月間6億ダウンロード規模)だが、セマンティックバージョニングの観点では keyv 6.0.0 は最も自動的に取り込まれにくいバージョンだった。
理由は依存関係の宣言方法にある。レジストリから取得した実際の依存指定は以下のとおりだ。
・cacheable 2.5.0 → keyv: ^5.6.0
・cache-manager 7.2.9 → keyv: ^5.6.0
・cacheable-request 13.0.19 → keyv: ^5.6.0
・@cacheable/memory 2.2.0 → keyv: ^5.6.0
^5.6.0 はメジャーバージョンをまたがないため、keyv 6.0.0 にはマッチしない。つまりkeyvに依存するパッケージ群は、keyv 6.0.0 を自動的には引き込まなかった。
一方、第2波のパッチ更新はどうか。
・file-entry-cache 11.1.5 → flat-cache: ^6.1.23 … 6.1.24 にマッチする
・flat-cache 6.1.23 → cacheable: ^2.5.0 … 2.5.1 にマッチする
・cache-manager 7.2.9 → @cacheable/utils: ^2.5.0 … 2.5.1 にマッチする
・cacheable 2.5.0 → @cacheable/memory: ^2.2.0 … 2.2.1 にマッチする
通常の ^ 指定でそのまま解決される。第2波は、見出しになりにくい代わりに、実際の自動取り込みリスクははるかに高かった。
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-cache と file-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 へと解決される。汚染されたのはいずれも別のメジャー系列だ。
これは「巨大なダウンロード数=巨大な被害」という直感が成立しない典型例といえる。ダウンロード数はパッケージ名に紐づくが、汚染は特定のメジャー系列にしか及ばない。実際に影響を受けうるのは、file-entry-cache@^11 や flat-cache@^6、cache-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を直接使っていない」という理由では除外できず、依存ツリーに入っていたかどうかで判断する必要がある。
なお同社は、このマルウェアが自己増殖機能を持ち、汚染パッケージをインストールした他メンテナのパッケージへ感染を広げると報告している。同社の8月4日13:37 CEST(20:37 JST)時点の更新では、二次感染を含めて868パッケージ・1,381バージョンに拡大したとされる。この数値は調査進行中の暫定値であり、本記事執筆時点以降も変動しうる。当サイトでは一次データによる裏取りができていないため、数値はあくまで同社報告として扱う。
preinstall はimport不要でインストール時点に走るため、「直接使っていない」は除外理由にならない上流の確認先が現在参照できない
本件を検証するうえで無視できないのが、上流のリポジトリとメンテナアカウントが現在参照できない点だ。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とキャッシュに置くべきだ。
侵害が疑われる場合の初動対応と影響範囲
lockfileやキャッシュに該当が見つかった場合、あるいは露出時間帯にCIが依存解決を走らせていた場合の対応をまとめる。優先順位は「認証情報の無効化」が最上位で、パッケージの入れ替えはその後でよい。盗まれた認証情報は削除では取り消せないためだ。
1. 認証情報のローテーション(最優先)
# npmトークンの一覧と失効
npm token list
npm token revoke <token-id>
# GitHub: PATの棚卸し(gh CLI)
gh auth status
# → https://github.com/settings/tokens で該当トークンを失効
# AWS: アクセスキーの無効化
aws iam list-access-keys --user-name <user>
aws iam update-access-key --access-key-id <key-id> --status Inactive --user-name <user>
対象は当該マシンおよびCIランナーから到達可能だったすべての認証情報だ。~/.npmrc、環境変数、~/.aws/credentials、SSH鍵、kubeconfig、Vaultトークンを棚卸しする。CI環境で発生した場合は、リポジトリのSecretsとOIDC連携先のロールも対象になる。
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リポジトリであり、説明文に特定の文字列が入るとされる。したがって自組織・自アカウントに身に覚えのない公開リポジトリが作られていないかの確認が有効だ。
# 自アカウントの公開リポジトリを作成日の新しい順に確認
gh repo list --visibility public --limit 100 --json name,createdAt,description \
--jq 'sort_by(.createdAt) | reverse | .[:20][] | "\(.createdAt) \(.name) — \(.description // "")"'
# Organization の場合
gh repo list <org> --visibility public --limit 200 --json name,createdAt,description \
--jq '.[] | select(.createdAt > "2026-08-04") | "\(.createdAt) \(.name) — \(.description // "")"'
身に覚えのないリポジトリが見つかった場合、公開状態のまま放置すると認証情報が露出し続けるため、直ちに非公開化または削除し、含まれていた認証情報をすべて失効させる。
なお、他組織の送出先リポジトリを閲覧・収集する行為は他者の認証情報に触れることになるため、確認は自分の管理下にあるアカウント・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系列は無傷) |
直接依存がないか確認 |
| 露出時間帯にインストールしていない | 低い | 確認のみで可 |
※ @keyv/* アダプタを参照している既存パッケージの依存指定は @cacheable/memory 2.2.0 → @keyv/bigmap: ^1.3.1、keyv 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系) | 2026-08 | GitHubアカウント侵害 | preinstall | あり(報告) |
侵入経路を見ると、2026年5月・7月の事案はいずれもCIワークフローの設定不備が入口だったのに対し、本件はアカウントそのものの侵害という違いがある。CI設定を固めるだけでは本件は防げない。
実行契機の違いも防御策に直結する。AsyncAPIの事案はimport時に起動したため --ignore-scripts では防げなかったが、本件は preinstall なので --ignore-scripts が有効に働く。ただしこれはインストール時に既に設定していた場合の話で、事後の緩和策にはならない。
# 今後の緩和策として(事後の対応にはならない点に注意)
npm config set ignore-scripts true
# プロジェクト単位で設定する場合は .npmrc に記述
echo 'ignore-scripts=true' >> .npmrc
ignore-scripts=true は一部のネイティブモジュールのビルドを壊すため、有効化する場合はCIでビルドが通ることを確認してからにしてほしい。npm 12以降では postinstall の既定挙動が変更されているが、本件の preinstall を含むライフサイクルスクリプト全般の扱いは環境によって異なるため、自分のnpmバージョンで実際の挙動を確認するのが確実だ。この変更の詳細は「npm v12でpostinstallがデフォルト無効化へ|自システム確認コマンドと段階的な移行手順」で整理している。
自己増殖機能を持つ点はTanStackの事案と共通する。2025年9月に最初に観測されたShai-Hulud系の手法が、メンテナ間を横断して伝播する形で継続していることを示している。
keyv汚染事案から引き出せる実務的な教訓
本件を一次データで追ったうえで、再現性のある形で残せる教訓は3つある。
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とローカルキャッシュを直接見る手順を、平時から手元に持っておく価値がある。
検証環境と確認時刻
本記事の一次データは、以下の方法・時刻で取得した。
・取得日時:2026年8月4日 21:00 JST 時点
・取得方法:npmレジストリAPI(https://registry.npmjs.org/<package>)の time および versions フィールドの差分。当該メンテナが公開する61パッケージを全件照合
・削除判定:time に公開時刻の記録があり、かつ versions に当該バージョンが存在しないものを「削除済み」と判定
・アドバイザリ確認:OSV API(api.osv.dev/v1/query)、GitHub Security Advisory API(/advisories?ecosystem=npm)
・リポジトリ状態:curl -sL によるHTTPステータスとリダイレクト先の確認
・npm audit の挙動:クリーン版 flat-cache 6.1.23 を実際にインストールして実行
汚染版のtarballはレジストリから削除済みであり(HTTP 404)、当サイトではマルウェア本体の取得・実行は行っていない。したがってペイロードの内部挙動に関する記述は、すべてAikido Securityの報告に基づく二次情報として明示している。
本件は進行中の事案であり、二次感染の範囲、アドバイザリの登録、上流リポジトリの状態はいずれも変動しうる。確認の際は本記事の取得時刻を起点に、最新の状況を各自で確かめてほしい。
参照ソース
・Aikido Security — Keyv and friends compromised in npm supply chain attack(ペイロード解析・二次感染範囲の出典。2026-08-04参照)
・npm registry API — keyv パッケージメタデータ(公開・削除時刻の一次データ。2026-08-04参照)
・npm Docs — npm unpublish(削除の挙動と制約。2026-08-04参照)
・npm Docs — npm ci(lockfileの解決規則。2026-08-04参照)
・OSV API(アドバイザリ登録状況の確認。2026-08-04参照)