0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

lockfile では「いつ入れたか」が分からない。npm 侵害の調査で気づいて CLI を作ることにした

0
Posted at

ヒーロー(記事トップ)

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 件」と表示されていた。人間はサマリしか見ません。

「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時間からでも歓迎で、いろんな案件に携われたらうれしいです。こんな相談、歓迎です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?