443 パッケージ、1381 バージョン。2026 年 8 月 4 日の npm 大規模侵害で、汚染されたと確認された数です。
手元の全リポジトリを調べました。被弾はゼロでした。それはよかったのですが、調べている最中に別のことで詰まりました。「攻撃が始まった 8 月 4 日 09:00 UTC より後に、自分は何を install したのか」が、どこを見ても分からなかったのです。
被弾はゼロだった。そこまでは 30 分で終わった
事の起こりは keyv と cacheable まわりの侵害です。メンテナの GitHub アカウントが乗っ取られ、main ブランチに直接 credential stealer が push されて、正規のリリースワークフローから provenance 署名付きで npm へ流れました。署名は本物なので、署名検証では見抜けません。
Wiz が侵害パッケージ 443 件の CSV を公開していたので、それを落としてきました(https://github.com/wiz-sec-public/wiz-research-iocs/blob/main/reports/keyv-packages.csv /解説は https://www.wiz.io/blog/keyv-and-cacheable-npm-supply-chain-attack )。
手元の 45 リポジトリから lockfile 26 本と、node_modules に実際に展開されているパッケージ 995 個を拾って突き合わせました。
結果は完全一致 0 件。使っていたのは keyv 4.5.4 と 5.3.3、flat-cache は 3.2.0 と 4.0.1 と 6.1.9 で、侵害されたのは keyv 6.0.0 と flat-cache 6.1.24 でした。旧メジャーに留まっていたのが効いた形です。
正直、ほっとしました。
本当に困ったのは、そこではなかった
問題は次のステップでした。
このマルウェアは preinstall フックで動きます。つまり「危ない版が今 lockfile に載っているか」ではなく、「攻撃開始以降に install を走らせたか」が分かれ目になります。一度実行されていれば、その後にパッケージを消しても手遅れです。
で、その記録がどこにも無かった。
lockfile は「今どの版が入っているか」しか持っていません。SBOM も同じです。「いつ入れたか」という列が、そもそも存在しない。
仕方なく node_modules フォルダの更新時刻を並べて、8 月 4 日以降に触られているものを探しました。1 つだけ引っかかって、中を開いて、入っていたのは vitest 4.1.10 系の 18 パッケージでした。侵害リストには無い。セーフ。
セーフなんですが、これは推定です。フォルダの mtime は install 以外の理由でも変わります。事故のときに推定でしか答えられないのは、けっこう嫌なものだなと思いました。
ついでに分かった。35 リポジトリで誰も見ていなかった
最初は GitHub の Dependabot アラートを一括で引こうとしました。gh CLI で 39 リポジトリを回して、返ってきたアラートは 0 件。
一瞬「全部きれいなのか」と思ったのですが、内訳を見たら 35 リポジトリで Dependabot alerts がそもそも無効でした。機能が無かったのではなく、誰も有効にしていなかった。
「アラート 0 件」と「監視していないから 0 件」が、API の応答からは同じに見える。これが後で効いてきます。
その場で全リポジトリ有効化しました。1 分で終わりました。1 分で終わることを 3 年放置していたわけです。
自前で侵害リストを持とうとして、やめた
次に、こういう事案が出るたびにベンダーの CSV を集めて手元で突き合わせる仕組みを作りかけました。advisories フォルダを作って、取得元 URL を並べて。
作りかけて、ふと確認したくなりました。OSV(https://osv.dev/ )には、この 443 件は載っているんだろうか。
API に全数を投げてみたら、443/443 が MAL エントリとして登録済みでした。二次感染で汚染された、名前も聞いたことのないパッケージまで全部。
さらに公開時刻を見たら、こうでした。
- 攻撃開始: 8/4 09:00 UTC
-
flat-cache@6.1.24の MAL 登録: 10:43 UTC(1 時間 43 分後) -
keyv@6.0.0の MAL 登録: 11:19 UTC(2 時間 19 分後)
ベンダーがブログを書くより速い。網羅性でも速報性でも負けていて、しかも自前で持つと更新を忘れたぶんだけ静かに検知漏れする。
作りかけたものを消しました。これは正しい判断だったと思っています。
3 回、偽陰性を出した
代わりに osv-scanner(https://github.com/google/osv-scanner )を入れて、全 lockfile を回すスクリプトを書きました。
ここからが本題です。
1 回目。 オフライン DB モードで走らせたら 511 件の検出。翌回 734 件。同じリポジトリなのに数字が違う。原因は、オフライン DB がエコシステムごとに別ファイルなのに、こちらが「DB ディレクトリが空かどうか」で取得要否を判定していたことでした。npm の DB だけ既にあったので、Packagist と PyPI と crates.io を取りに行かない。結果、そのエコシステムが丸ごと結果から消えていた。最優先で報告した CRITICAL が 4 件、消えていました。
2 回目。 Go の go.sum を 10 本渡していたのに、1 本も走っていなかった。osv-scanner v2 は go.sum を読みません。go.mod を見ます。渡すファイルを間違えていたので、could not determine extractor で 1 本ずつ静かに落ちていました。Go のプロジェクトが 3 つあるのに、その依存は一度も検査されていなかった。
3 回目。 Dependabot の横断集計が 0 件で返ってきました。gh repo list --json name, visibility と書いたら、PowerShell がカンマの後ろの空白を引数の区切りと解釈して、gh が黙って空を返していた。
3 つとも共通点があります。「0 件」と「未検査」が、画面上で見分けられない形で出ていた。
エラーは出ていました。ただ流れていっただけです。走査できなかった 10 本は WARNING で 1 行出ていて、その下に「検出 0 件」と表示されていた。人間はサマリしか見ません。
セキュリティツールで一番まずいのは、たぶん見逃しそのものではなくて、見逃しが「異常なし」の顔をして出てくることです。何も入れていなければ人は不安なままでいられますが、緑のチェックマークを見たら安心してしまう。
修正して回し直したら、最終的に 813 件、うち CRITICAL 28 件でした。npm しか見ていなかった範囲が Packagist と PyPI と crates.io と Go まで広がって、業務で使っているライブラリに SSRF/RCE が刺さっているのも見つかりました。
既存ツールを調べた
さて、と。ここまでで自分に足りていないものが 2 つ見えました。「いつ入れたか」の記録と、npm 以外も含めた横断です。
既にあるだろうと思って調べました。
スキャナの層は完全に飽和しています。Trivy(https://github.com/aquasecurity/trivy )、Grype(https://github.com/anchore/grype )、osv-scanner、OWASP Dependency-Check、Syft。ダッシュボードなら OWASP Dependency-Track(https://dependencytrack.org/ )が自己ホストで動きます。Windows のパッケージマネージャ横断なら UniGetUI(https://github.com/marticliment/UniGetUI )が 2 万スター超え。
ここで戦う理由は 1 ミリもありません。
ただ、次の 3 つの交差点が空いていました。
- 開発マシン単位であること(プロジェクト単位でも組織 CI 単位でもない)
-
取り込み経路を全部見ること(npm だけでなく scoop / winget / pip / cargo /
git clone/curl | shまで) - 時刻と理由を焼き付けること(SBOM は「今何が入っているか」しか持たない)
UniGetUI は複数のパッケージマネージャを 1 画面にまとめますが、セキュリティ監査はしません。公式に「セキュリティは各パッケージと publisher 次第」と書いてあります。SBOM ツールは今の状態を出しますが、履歴は持ちません。
無いなら作るか、となりました。
intakelog という名前で作ります
作るものはこうです。
外から入ってきたものを、取り込んだその場で append-only の CSV に 1 行足す。列は date,kind,name,version,source,integrity,project,why,actor の 9 つ。日時、経路、名前、版、入手元の実 URL、ハッシュ、入れた先、理由、そして人が入れたのかどのエージェントが入れたのか。
手で入れた分は落ちるので、各パッケージマネージャの現状と台帳を突き合わせる差分パスを併走させます。scoop のように自分でインストール日時を持っているものは、そこから過去の導入日時を復元できます。
リポジトリはこちらです: https://github.com/ishizakahiroshi/intakelog
現時点では npm の名前を予約して、リポジトリを立てたところまでです。実装はこれからです。
設計判断で決めたこと
スキャナは自前実装しません。 照合は osv-scanner に丸投げします。今日 3 回踏んだとおり、この領域は偽陰性を出しやすい。自分用のスクリプトなら気づいて直せばいい話ですが、配布したら他人の判断を誤らせます。その責任は上流の専門ツールに置きます。
走査できなかったものを黙って捨てません。 未検査は必ず件数を出して目立たせます。これが今日の最大の学びなので、リポジトリの CLAUDE.md にも絶対ルールとして書きました。
推測で日時を埋めません。 取れない値は空にします。推定値を事実として記録するのが、このツールで一番害の大きい振る舞いだと思っています。
実行時依存はゼロにします。 サプライチェーンを見る道具が、自分のサプライチェーンを抱えるのは筋が悪い。TypeScript の標準ライブラリだけで書きます。
照合はオフライン既定にします。 osv-scanner のオフライン DB を使えば、依存構成を外部の API に送らずに済みます。非公開プロジェクトの構成は、それ自体が情報です。
おわりに
今日やったことを並べると、被弾調査が 30 分、Dependabot の有効化が 1 分、自作スクリプトのデバッグが数時間でした。時間の使い方としては歪んでいます。
でも、その数時間で分かったことのほうが大きかった。「調べたけど何も出なかった」と「調べたつもりだった」は、出力が同じ顔をしているという話です。
まずは自分の環境で回して、実際に役に立った部分だけ残していこうと思います。動くものができたら、また書きます。
※ ヘッダー画像とインフォグラフィックは AI(画像生成)で作成しています。
※ 本文の挿絵も AI(画像生成)で作成しています。
書いた人: ishizakahiroshi
群馬の北部で、保護猫2匹と暮らす、在宅エンジニア(何でも屋)
https://ishizakahiroshi.com/
https://github.com/ishizakahiroshi
X(業務委託・各種相談はこちら):
https://x.com/ishizakahiroshi
バックエンド・インフラ・AI連携まわりで、業務委託のご相談を受け付けています。フルリモートです。スポットや週2〜3時間からでも歓迎で、いろんな案件に携われたらうれしいです。こんな相談、歓迎です。



