「依存を1つ増やす」——開発者が1日に何度も行うこの操作は、他人が書いたコードを自分の端末とCIに迎え入れる操作でもある。ソフトウェアサプライチェーン攻撃は、その受け渡しの経路そのものを乗っ取る手口だ。利用者から見ると、いつも通り正規の名前のパッケージを、いつも通りのコマンドで入れただけなのに侵害される。
- 攻撃の本質は「誰かを信頼して受け取る経路」の乗っ取り。自分のコードの品質とは無関係に成立する
- 手口は6分類——メンテナ乗っ取り・悪性ライフサイクルスクリプト・タイポスクワッティング・依存混同・CI/CD侵害・ワーム型自己増殖
npm installだけで乗っ取られるのは、インストール時に依存側のコードを自動実行する仕組みがあるから。npm v12でこれは既定オフになったnpm auditが沈黙するのは無能だからではなく、既知CVEの照合という別の仕事をしているから- 防御策は「良さそうだから全部入れる」ものではなく、チェーンのどの段を切るのかで選ぶと judgement がつく
この記事は「仕組みの理解」に振り切っている。ツールの設定値や導入チェックリストといった実装の手順は、当サイトのピラー記事サプライチェーンセキュリティ2026|攻撃手法・防御ツール・実践チェックリストにまとめてあるので、手を動かす段階になったらそちらを参照してほしい。ここでは「なぜその設定が効くのか」を先に腹落ちさせる。
① サプライチェーン攻撃は何を乗っ取るのか(=6分類)
②
npm install の裏側で何が起きているのか(=攻撃チェーン6段)③ 各防御策はどの段を切っているのか(=防御マッピング)
サプライチェーン攻撃とは——なぜnpm installだけで乗っ取られるのか
サプライチェーン攻撃とは、自分が書いていないコードを受け取る経路を侵害する攻撃を指す。一般的な脆弱性攻撃が「自分のコードやサーバーの弱点」を突くのに対し、サプライチェーン攻撃は自分のコードが完璧でも成立する。攻撃者が狙うのは、開発者が日常的に、しかもほぼ無条件に信頼している受け渡しの経路そのものだ。
現代のアプリケーションでは、直接書いた依存が数十個でも、それらが引き連れてくる推移的依存(transitive dependency)を数えると数百から数千に膨れ上がることが珍しくない。この数千のうち1つでも侵害されれば、攻撃者のコードは自分のプロジェクトの内側に入る。しかも推移的依存は自分で選んだものではないので、名前すら知らないことが多い。
「インストール」は「ダウンロード」ではない
多くの開発者が暗黙に持っているイメージ——「パッケージをインストールする=ファイルをダウンロードして置く」——は、npmに関しては正確ではない。npmにはライフサイクルスクリプトという仕組みがあり、package.json の scripts に preinstall / install / postinstall を書いておくと、そのパッケージがインストールされる際に自動で実行される。
重要なのは、これが依存パッケージの分も実行される点だ。つまり npm install を1回叩いた瞬間に、依存ツリーに含まれるパッケージの作者が書いたコマンドが、自分の端末やCIランナーの権限で走りうる。これはバグではなく、ネイティブモジュールのビルドなど正当な用途のために設計された仕様である。だからこそ長年既定で有効だった。
・実行される権限は「自分」——スクリプトはサンドボックス内ではなく、npm install を叩いた人・プロセスと同じ権限で動く
・環境変数がそのまま見える——CIであればビルド用のトークンや秘密情報が環境変数に載っていることが多い
・ホームディレクトリも読める——~/.npmrc や ~/.aws/credentials、SSH鍵といった認証情報の置き場所は事実上標準化されている
・利用者は何も気づかない——インストールは成功し、ログにも異常は出ない
この4つが揃っているために、「依存を1つ足す」という操作が、そのまま「他人のコードを自分の権限で走らせる」操作になる。npm install だけで乗っ取られる理由はここにある。
攻撃者から見た費用対効果
攻撃者の視点で見ると、この経路は極めて効率が良い。1つの人気パッケージを侵害できれば、そのパッケージに依存する全プロジェクトへ同時に到達できるからだ。標的型攻撃のように1社ずつ攻める必要がなく、上流を1つ押さえれば下流が自動的に配ってくれる。
しかも侵害の窓は短くてよい。汚染バージョンが公開されてから削除されるまでが数時間でも、その間にCIが自動でインストールした環境はすべて汚染される。実際、当サイトが扱ったkeyv系30パッケージがnpmで汚染|サプライチェーン攻撃の時系列・影響範囲・自環境の確認コマンドでも、汚染版の公開から対応までの時間軸が被害範囲を決める構図になっていた。
汚染バージョンがレジストリから削除されても、その間にインストールしてしまった環境は汚染されたままだ。さらに悪いことに、削除されるとレジストリの一覧やアドバイザリの照合対象からも消えるため、後から「自分は影響を受けたか」を確認しづらくなる。判断材料になるのは自分の手元に残っている lockfile とCIのログである。
攻撃タイプ6分類——「どこを乗っ取るか」で手口が変わる
サプライチェーン攻撃は一枚岩ではない。攻撃者が経路のどこを乗っ取るかによって、必要な準備も、効く防御も変わる。ここでは6つに分類して整理する。
① メンテナ乗っ取り(account takeover)
正規のパッケージの、正規の公開権限を奪う手口。フィッシングでnpmアカウントの認証情報を盗む、漏洩したパスワードを使い回しで試す、あるいはメンテナ本人を装って権限を譲り受ける(ソーシャルエンジニアリング)といった経路がある。
この手口が厄介なのは、攻撃の結果が「正規」そのものになる点だ。パッケージ名は正しく、公開者アカウントも正しく、署名も正しい。名前の類似を検出する仕組みも、公開者を確認する仕組みも、どちらもすり抜ける。防御は「公開できる権限」の側——2FAの必須化、publishトークンの権限と寿命を絞ること——に寄せるしかない。
② 悪性ライフサイクルスクリプト(malicious post-install)
前節で見た、インストール時の自動実行を悪用する手口。①〜④のどの手段でパッケージを掌握したとしても、実際に何かを起こすには「利用者の環境でコードが動く」必要がある。ライフサイクルスクリプトはその最短経路として使われる。
言い換えれば、これは単独の攻撃タイプというより他の手口と組み合わせて使われる起爆装置に近い。だからこそ、ここを塞ぐ防御(後述の「3段:実行」)は費用対効果が最も高い。
③ タイポスクワッティング(typosquatting)
打ち間違いを待ち構える手口。人気パッケージに酷似した名前——1文字違い、ハイフンとアンダースコアの入れ替え、単数形と複数形の違い、スコープの有無——であらかじめパッケージを公開しておく。
この手口の対象は人間のタイプミスだけではない。LLMが存在しないパッケージ名を提案し、それを攻撃者が先回りして登録するという経路も現実的な懸念として議論されている。生成されたコードをそのまま貼り付ける運用では、名前を目視確認する機会が減るためだ。
・対策の軸は「名前を人が打たない」こと——lockfileを信頼し、新規追加時だけ名前を確認する
・スコープ付きパッケージ(@org/name)は相対的に強い——組織スコープは早い者勝ちで占有できるため、同名を勝手に作られにくい
④ 依存混同(dependency confusion)
名前の解決順を奪う手口。社内でしか使っていないプライベートパッケージと同じ名前を、公開レジストリ側に登録することで成立する。
鍵になるのは、パッケージマネージャが複数のレジストリを見に行くときどちらを優先するかという設定だ。「社内レジストリと公開レジストリの両方を見て、バージョンが新しい方を採用する」という素直な設定になっていると、公開側に極端に高いバージョン番号(例:99.9.9)で同名パッケージを置かれた時点で、そちらが勝つ。
しかもこの攻撃は、社内のネットワーク内部でコードが実行されるという点で被害が重い。社内CIは往々にして本番に近い権限を持っている。防御はレジストリのスコープを明示的に固定すること(スコープごとにレジストリを指定する、公開側へのフォールバックを禁止する)に尽きる。
⑤ CI/CDパイプライン侵害
パッケージそのものではなく、それを作る工場を乗っ取る手口。ソースコードのリポジトリは綺麗なまま、ビルドの過程で悪意あるコードが混入する。
GitHub Actionsで繰り返し問題になってきたのが、フォークからのPRを特権つきで実行してしまう設定の落とし穴だ。当サイトでもAsyncAPIサプライチェーン攻撃の全手口|CI設定不備からnpm 4パッケージ汚染・自環境の確認まででこの経路を追跡した。同じ穴を突かれた事例としてTanStackサプライチェーン攻撃の根本原因はpull_request_targetもある。
この分類が独立して重要なのは、リポジトリを監査しても見つからないからだ。GitHub上のコードを何度読んでも、レジストリに置かれた成果物との差は見えない。ここを埋めるための仕組みが、後述するprovenance(来歴証明)である。
⑥ ワーム型自己増殖
盗んだ権限で、次のパッケージを汚染する手口。2025年以降に観測された Shai-Hulud 系の攻撃で広く知られるようになった型で、当サイトでも派生系を@antv npmに不正コード混入|Mini Shai-Huludなどで扱っている。
構造はシンプルだ。汚染パッケージが利用者の環境でpublishトークンを盗む→そのトークンで、その利用者がメンテナをしている別のパッケージに同じ悪性コードを仕込んで公開する→そのパッケージの利用者が感染する。以降は人手を介さずに輪が回る。
従来型との決定的な違いは増殖速度だ。攻撃者が1つずつ手作業でパッケージを侵害する必要がなくなるため、汚染範囲は指数的に広がる。逆に言えば、輪のどこか——特に「窃取」(CIから認証情報を取れないようにする)と「再公開」(publishに追加の人的確認を要求する)——を切れば連鎖は止まる。
サプライチェーン攻撃の典型的なチェーン——公開から自己増殖までの流れ
6分類は「何を乗っ取るか」の分類だった。ここでは実際に被害が発生するまでの時間的な流れを1本のチェーンとして整理する。冒頭のFVアニメーションと同じ6段だ。
メンテナのアカウント or
CIの権限を奪う"] --> B["② 公開
正規のパッケージ名のまま
悪性バージョンを publish"] B --> C["③ 実行
利用者の install 時に
ライフサイクルスクリプトが発火"] C --> D["④ 探索
環境変数・認証ファイル・
各種トークンを収集"] D --> E["⑤ 送出
集めた資格情報を
攻撃者の管理下へ送信"] E --> F["⑥ 増殖
奪った publish 権限で
別パッケージを汚染"] F -.->|人手を介さず輪が回る| B C -.->|scripts を実行しない設定なら
ここで停止| X["連鎖が止まる"] E -.->|外向き通信を塞げば
ここで停止| X
各段でどんな判断が働いているのかを、防御側の視点も添えて整理する。
| 段 | 攻撃側がやること | 成立の前提 | ここを切る防御 |
|---|---|---|---|
| ① 侵入 | 公開権限を奪う | 認証が単一要素/トークンが長寿命 | 2FA必須・トークンの権限と寿命の最小化 |
| ② 公開 | 悪性版をレジストリへ | 公開の事実が検証されない | provenance・publish attestation |
| ③ 実行 | install時に発火 | 依存のスクリプトが自動実行される | --ignore-scripts・npm v12の既定 |
| ④ 探索 | 認証情報を収集 | 秘密情報が同一プロセスから見える | CIの秘密情報の分離・最小権限 |
| ⑤ 送出 | 外部へ送信 | ビルド環境から任意の外向き通信が可能 | egress制限・許可リスト方式 |
| ⑥ 増殖 | 別パッケージを汚染 | 盗んだトークンで即publishできる | publish時の追加確認・cooldown |
本記事は仕組みの理解を目的としているため、実際の悪性コード・難読化の手法・送出先の指定方法といった再現に使える具体は一切記載しない。防御側が知る必要があるのは「どの段で何が起きるか」であって、攻撃を再現する手順ではない。
③と⑤が「切りやすい段」である理由
6段のうち、防御側が最も安く確実に切れるのは③(実行)と⑤(送出)だ。理由は、この2つが攻撃者にとって代替手段が少ないからである。
①や②は、攻撃者側に多様な選択肢がある。フィッシングが失敗すればトークン漏洩を狙い、それが駄目ならメンテナ権限の譲渡を狙う——といった具合に、防御を1つ固めても迂回されうる。一方で③は「利用者の環境でコードが動く」という、攻撃の成立に不可欠な一点だ。ここを塞ぐと、汚染パッケージがインストールされても、そのコードは走らない。
⑤も同様に不可欠だ。認証情報を集めても、攻撃者の手元に届かなければ価値がない。ビルド環境からの外向き通信を既定で塞ぎ、必要な宛先だけ許可する方式にすれば、④まで進んだ攻撃も無害化できる。
なぜnpm auditでは防げないのか
「依存の脆弱性は npm audit で見ている」——これは正しい運用だが、サプライチェーン攻撃に対しては期待した働きをしない。無能だからではなく、そもそも違う仕事をしているからだ。
npm audit の仕事は、インストール済みの依存ツリーと、公開済みのアドバイザリデータベース(CVE / GHSA)を突き合わせることだ。「このパッケージのこのバージョン範囲に既知の脆弱性がある」という登録済みの知識と照合している。この設計から、次の4つの限界が導かれる。
限界1:アドバイザリが出るまで沈黙する
配布経路の侵害は、まず誰かが気づき、調査され、アドバイザリとして登録されて初めて照合対象になる。汚染版が公開されてからアドバイザリが出るまでの時間は、当然ながらauditの守備範囲外だ。そしてサプライチェーン攻撃の被害の大半は、まさにこの窓の間に発生する。
さらに、配布経路の侵害にはそもそもCVEが振られないことも多い。CVEは「ソフトウェアの欠陥」に対して振られる番号であり、「正規のアカウントから悪性コードが公開された」という事象はソフトウェアの欠陥ではないからだ。GHSA(GitHub Security Advisory)のほうがこの種の事象を扱いやすいが、それでも登録は事後になる。
限界2:削除されると照合対象から消える
汚染バージョンは発覚後に削除(unpublish)されるのが通例だ。削除されると、そのバージョンはレジストリの一覧からも、多くの場合アドバイザリの照合対象からも消える。つまり「汚染された当時にインストールしてしまった環境」を、後からauditで検出することはできない。
事後確認で本当に頼りになるのは、自分の手元に残っている package-lock.json の履歴と、CIのビルドログである。「あの日のビルドは、どのバージョンを引いたのか」がgitに記録されているかどうかで、調査の可否が決まる。
限界3:ダウンロード数は安全性の指標ではない
「週間1000万ダウンロードのパッケージだから安全だろう」という判断は、サプライチェーン攻撃に対しては逆に危険ですらある。攻撃者から見れば、ダウンロード数が多いパッケージほど侵害の価値が高いからだ。人気度は「侵害された場合の被害の大きさ」の指標ではあっても、「侵害されにくさ」の指標ではない。
限界4:バージョンが変わった事実そのものは警告されない
auditは「既知の脆弱性があるか」を見るのであって、「昨日と違うバージョンが入ったか」は見ない。lockfileの差分——特に自分が意図していない推移的依存のバージョンが動いたこと——は、人がレビューするか、専用の仕組みを入れない限り検出されない。
既知の脆弱性を持つ古い依存を放置しないという用途では、
npm audit は今も有効に機能する。問題は「auditが通ったからサプライチェーン攻撃も大丈夫」という守備範囲の誤解にある。既知脆弱性の管理と、配布経路の侵害への備えは、別々に手当てする必要がある。
シークレットが漏れた後の検知という観点では、リポジトリ内の資格情報をスキャンする道具も別軸で役に立つ。この領域の実装差についてはBetterleaks徹底解説|Gitleaks原作者の新シークレットスキャナで扱っている。
防御策は攻撃チェーンのどの段を切るのか
防御策を「良さそうだから全部入れる」と、コストばかり増えて何が効いているのか分からなくなる。それぞれがチェーンのどの段を切るのかで整理すると、自分の環境に何が足りないかを判断できる。
3段(実行)を切る:スクリプトを走らせない
最も直接的なのが、インストール時のスクリプト実行を止めることだ。npmには従来から --ignore-scripts フラグがあり、.npmrc に ignore-scripts=true と書けば既定で無効化できる。
そして2026年、この既定そのものが変わった。npm v12(2026年7月8日リリース)では、依存パッケージのライフサイクルスクリプトが既定でブロックされる。npm公式のリリースノートは「依存のライフサイクルスクリプトは、ルートパッケージの allowScripts ポリシーで許可されない限りブロックされる」と記述している。
あわせて2つの既定も変わっている。
・allow-git の既定が none——Gitリポジトリを直接指定した依存が、明示的に許可しない限り解決されなくなった
・allow-remote の既定が none——任意のtarball URLからの依存解決にも明示的な許可が必要になった
この2つは地味に見えるが、重要な穴を塞いでいる。GitHub Changelogによれば、Git依存は依存側の .npmrc がGit実行ファイルを差し替えうるため、--ignore-scripts を指定していてもコード実行の経路になりえた。スクリプトだけ塞いでも抜けられる道があったということだ。
これらの変更は npm 11.16.0 以降で警告つきで先行して確認できるようになっていたため、v12へ上げる前に自分のプロジェクトが何を必要としているか調べられる。運用面では次のコマンドが用意されている。
# 承認待ちのスクリプトを一覧する(承認はまだしない)
npm approve-scripts --allow-scripts-pending
# 特定の依存のインストールスクリプトを承認する
npm approve-scripts
# 特定の依存のインストールスクリプトを拒否する
npm deny-scripts
# 承認状態そのものを管理する
npm install-scripts
# 承認済みスクリプトを実際に実行する(承認を記録した後)
npm rebuild
承認結果は package.json に記録されるため、バージョン管理にコミットしてチームで共有する運用になる。「どのパッケージにインストール時実行を許したか」がレビュー可能な差分として残る点が、この設計の要だ。
node-gypでのビルドを伴うパッケージは、インストール時スクリプトが実行されないとビルドが完了しない。既定オフの環境へ移行する際は、
npm approve-scripts で「何が承認を求めているか」を洗い出し、必要なものだけ許可してから npm rebuild する流れになる。いきなり本番CIで既定を変えると、ビルドが通らなくなる可能性がある。
6段(増殖)を切る:新しいバージョンを寝かせる
Dependency Cooldown(クールダウン)は、公開されたばかりのバージョンを一定期間使わないという考え方だ。サプライチェーン攻撃の汚染版は、発覚から削除までが比較的短いことが多い。であれば「公開から数日経ったバージョンしか使わない」と決めておくだけで、汚染版を掴む確率を大きく下げられる。
この設定名はツールによって異なるため、混同しやすい点に注意したい。
・Renovate:minimumReleaseAge という設定項目で指定する(かつての stabilityDays を置き換えたもの)。lockfile生成時にnpmへ --before=<date> を渡すことで、指定日以前に存在していたバージョンだけを解決させる。security:minimumReleaseAgeNpm というプリセットも用意されている
・pnpm:バージョン10.16以降で minimumReleaseAge をサポートする。値は分単位で、たとえば 1440 を指定すると「公開から1日以上経ったバージョンしかインストールしない」という意味になる。信頼するパッケージを除外する minimumReleaseAgeExclude も併用でき、@myorg/* のようなパターン指定にも対応する
RenovateやDependabotは、パッケージマネージャ側のcooldown設定とは独立して更新を評価する。パッケージマネージャ側だけ設定すると、ボットは「入れられないバージョン」へのPRを開き続けることになる。両方揃えて初めて運用が噛み合う。更新ボット経由の混入リスクそのものについてはRenovate・Dependabotの自動PRがマルウェアを運ぶで扱っている。
防御の「既定」が変わってきたタイムライン
ここ数年で動いたのは攻撃側だけではない。エコシステム側の既定そのものが、サプライチェーン攻撃を前提とした設計へ寄ってきている。防御を検討する際は、自分が使っているツールがこの流れのどこにいるかを確認するとよい。
| 時点 | 何が変わったか | 影響する段 |
|---|---|---|
| npm 9.5.0以降 | provenance(来歴証明)の生成が可能に | ②公開 |
| pnpm 10.16 | minimumReleaseAge / minimumReleaseAgeExclude を追加 |
⑥増殖 |
| npm 11.16.0以降 | v12の変更を警告つきで先行確認できるように | ③実行 |
| 2026-06-09 | GitHub Changelogでnpm v12の破壊的変更を事前告知 | ③実行 |
| 2026-07-08 | npm v12.0.0 リリース。allowScripts 既定オフ、allow-git/allow-remote 既定 none |
③実行 |
この表で注目したいのは、変更のほとんどが③(実行)に集中していることだ。エコシステムの設計者たちも、チェーンの中で最も切る価値が高い段が③だと判断している、と読める。
2段(公開)を切る:provenanceで由来を突き合わせる
provenance(来歴証明)は、「このパッケージは、どのソースコードから、どのビルド環境で作られたか」を検証可能な形で記録する仕組みだ。npmではSigstoreを使い、公開透明性ログに記録される。前述した「リポジトリは綺麗なのに成果物が汚染されている」というCI/CD侵害の型に対する直接の答えになる。
ただし限界を正確に理解しておく必要がある。npm公式ドキュメントは、provenanceが提供するのは「パッケージのソースコードとビルド手順への検証可能なリンク」であり、開発者がそれを監査して信頼するかどうかを判断するためのものだ、と説明している。つまり——
・provenanceは「悪意あるコードが無いこと」を証明しない。メンテナのアカウントが乗っ取られ、攻撃者がリポジトリにコードを push して正規のCIでビルドされた場合、provenanceは正常なまま付く
・対応CIが限られる——現時点でサポートされるのはGitHub ActionsとGitLab CI/CDのみ
・ローカルビルドでは生成できない——クラウドホストのランナーでのビルドが前提
・npm 9.5.0以降が必要で、package.json に公開リポジトリのURLが記載されている必要がある
したがってprovenanceは「①メンテナ乗っ取り」には効かず、「⑤CI/CD侵害」のうちビルド経路のすり替えに効く、という理解が正確だ。万能の印ではなく、チェーンの特定の段に対応した部品として扱うべきものである。
4段・5段(探索・送出)を切る:CIの権限と通信を絞る
④と⑤はCI環境の設計の問題だ。ポイントは2つある。
秘密情報を必要なジョブにだけ渡す——ビルドジョブに本番デプロイ用のトークンが環境変数として載っていれば、そのジョブで走った任意のコードがそれを読める。ジョブを分割し、秘密情報のスコープを最小化する。
外向き通信を既定で塞ぐ——ビルド環境から任意の宛先へ通信できる状態は、④で集めた情報を⑤で送出する経路をそのまま提供している。パッケージレジストリなど必要な宛先だけを許可リスト方式にすれば、④まで進んだ攻撃も無害化できる。
SBOMと監視:切るのではなく「後から辿れるようにする」
SBOM(ソフトウェア部品表)は、チェーンのどの段も直接は切らない。だが事後に「自分は影響を受けたか」を判定するための土台になる。前述の限界2で見たとおり、汚染版が削除された後の調査は手元の記録が頼りだ。SBOMを継続的に生成・保管していれば、「あの時点のビルドに、そのパッケージのそのバージョンが含まれていたか」を機械的に照会できる。
実装形式(SPDX / CycloneDX)や生成ツールの選び方はSBOM入門|ソフトウェア部品表でサプライチェーン攻撃を防ぐで個別に扱っている。
エコシステム別に見るサプライチェーン攻撃の違い——npm・PyPI・RubyGems・Go・Packagist
サプライチェーン攻撃はnpm固有の問題として語られがちだが、構造は他のエコシステムにも共通する。一方で「インストール時にコードが走るか」という一点には、設計思想による明確な差がある。
| エコシステム | インストール時のコード実行 | 実行される仕組み | 名前空間 | 完全性の検証 |
|---|---|---|---|---|
| npm | あり(既定はv12でオフに変更) | preinstall/install/postinstall |
スコープ(@org/name)あり |
lockfileのintegrityハッシュ・provenance |
| PyPI | sdistはあり/wheelは無し | setup.py などのビルドバックエンド |
名前空間なし(フラット) | ハッシュ固定(--require-hashes)・attestation |
| RubyGems | あり(拡張つきgem) | ネイティブ拡張がインストール時にビルドされる(extconf.rb) |
名前空間なし(フラット) | Gemfile.lock のチェックサム |
| Go | 無し | (インストール時の実行フックが存在しない) | モジュールパス=ドメイン由来 | go.sum+チェックサムDB(GOSUMDB) |
| Packagist(Composer) | 限定的 | 依存のスクリプトは実行されない。プラグインは自動ロードされるが allow-plugins で明示許可が要る |
ベンダー名で分離(vendor/package) |
composer.lock のdist参照 |
PyPI:sdistとwheelで危険度が変わる
Pythonで見落とされやすいのが、同じ pip install でも配布形式によって挙動が違う点だ。ソース配布(sdist)からインストールする場合、setup.py などのビルドバックエンドが実行されるため、任意のコードが走る。一方でビルド済みのwheelは、ファイルを配置するだけでビルドロジックを実行しない。
pipはwheelが利用可能ならそちらを優先するが、wheelが提供されていないパッケージではsdistにフォールバックする。つまり「wheelがあるから安全」と考えていても、依存ツリーのどこかにwheel未提供のパッケージがあれば、そこでsetup.pyが走る。
ただし「wheelなら安全」も言い過ぎだ。wheelはインストール時に実行しないだけで、そのコードはimportした時点で走る。当サイトで扱ったdatamodel-code-generator 脆弱性12件|生成コードのimportでRCE・修正は0.64.0は、実行のタイミングがinstallではなくimportにずれるだけで、結局コードが走るという構図を具体的に示している。
Go:そもそも実行フックが無い
Goのモジュールシステムには、インストール時に任意のコードを実行するフックが存在しない。go get や go build はソースを取得してコンパイルするだけで、npmのpostinstallに相当する仕組みを持たない。悪意あるコードがモジュールに含まれていたとしても、それが動くのは自分のプログラムがそれを呼び出したときだ。
加えてGoは完全性の検証を標準で組み込んでいる。go.sum にモジュール内容の暗号学的ハッシュが記録され、以降のビルドでは取得したモジュールがそのハッシュと一致するか検証される。一致しなければビルドが失敗する。さらに GOSUMDB が指すチェックサムデータベースと GOPROXY が指すモジュールプロキシによって、「あるバージョンの内容が後から差し替えられていないか」を確認する経路が用意されている。
これはGoが「絶対に安全」という意味ではない。メンテナ乗っ取り(①)やタイポスクワッティング(③)は同じように成立する。ただし「installしただけで発火する」という最短経路が塞がれているぶん、攻撃者はコードを呼び出させる工夫を追加で必要とする。
RubyGemsとPackagist:npmに近い構造
RubyGemsはネイティブ拡張を持つgemが、利用者のマシン上でインストール時にビルドされる。公式ガイドも、拡張はあらかじめコンパイルされているのではなく gem install の時点で extconf.rb を経てビルドされると説明している。つまりビルド設定スクリプトが利用者の権限で走る。名前空間もフラットであるため、タイポスクワッティングの余地も大きい。実際にRubyGems新規登録停止|BufferZoneCorpサプライチェーン攻撃のようなインシデントが発生している。
PHPのComposer(Packagist)は、npmに近いと誤解されやすいが実際にはより保守的だ。公式ドキュメントは「実行されるのはルートパッケージの composer.json に定義されたスクリプトだけであり、依存パッケージが自前のスクリプトを定義していてもComposerはそれを実行しない」と明言している。つまり②悪性ライフサイクルスクリプトの最短経路は、Composerでは塞がれている。
一方でプラグインは別だ。プラグインパッケージはインストールされた時点で自動的に読み込まれる。ただしComposerは allow-plugins の設定を持ち、非対話モードでは許可リストに無い新規プラグインがあると失敗する——利用者に実行の可否を明示的に判断させ、黙って動いてしまうことを避ける設計になっている。パッケージ名が vendor/package の形でベンダー名によって分離されているため、完全な同名を取られにくいという違いもある。こちらもPackagist サプライチェーン攻撃2026の事例がある。
共通するのは「名前とアカウントへの信頼」
エコシステムごとの差はあるが、共通している前提は変わらない。どのエコシステムでも、利用者は「この名前のパッケージは、自分が思っている人が作っている」と暗黙に信頼している。この信頼が成立している限り、①メンテナ乗っ取りと③タイポスクワッティングはどこでも成立する。
言い換えれば、インストール時の実行を塞ぐことは被害の即時性を下げるが、信頼そのものの検証は別の手段(provenance、cooldown、レビュー)が必要になる。複数の型の攻撃が同時に走った例としてはTrapDoor|npm・PyPI・Crates.io横断34パッケージのように、エコシステムを横断する攻撃も観測されている。
自分の環境を今すぐ確認する
仕組みを理解したうえで、自分の環境が今どうなっているかを確認する。ここでは読者が実際に実行する手順だけを挙げる。
インストール時スクリプトの現状を確認する
# npmのバージョンを確認(v12以降なら既定でスクリプトはブロックされる)
npm --version
# ignore-scripts の現在の設定値を確認
npm config get ignore-scripts
# 承認待ちのインストールスクリプトを一覧する(npm 11.16.0以降)
npm approve-scripts --allow-scripts-pending
npm config get ignore-scripts が false を返し、npmのバージョンが11系以前であれば、依存のインストール時スクリプトは現在も実行される状態にある。
依存ツリーに特定のパッケージが含まれるか調べる
# 特定パッケージが依存ツリーのどこに含まれるか(推移的依存も含む)
npm ls <パッケージ名> --all
# lockfileから該当パッケージの解決バージョンを直接引く
grep -n '"<パッケージ名>"' package-lock.json
# Python環境の場合
pip list --format=freeze | grep -i '<パッケージ名>'
npm ls はインストール済みの依存ツリーを見るため、lockfileに書かれていても未インストールなら出ない点に注意する。事後調査ではlockfileを直接grepするほうが確実だ。
lockfileが意図せず変わっていないか確認する
# 直近のlockfile変更を確認する
git log --oneline -10 -- package-lock.json
# 特定コミットでどの依存のバージョンが動いたかを見る
git diff HEAD~1 HEAD -- package-lock.json | grep -E '^[+-]\s+"(version|resolved)"'
サプライチェーン攻撃の事後調査で最も価値があるのは、このlockfileの履歴だ。「いつ、どのバージョンが入ったか」がgitに残っていれば、汚染期間との突き合わせができる。
CIの権限設定を見直す
# ワークフローで pull_request_target を使っている箇所を洗い出す
grep -rn "pull_request_target" .github/workflows/
# ワークフローに書かれた permissions の指定を確認する
grep -rn -A3 "permissions:" .github/workflows/
pull_request_target は、フォークからのPRに対してベースリポジトリの秘密情報へアクセスできる文脈でジョブを走らせる。使っていること自体が即座に問題なわけではないが、チェックアウト対象とスクリプト実行の組み合わせ次第で⑤CI/CD侵害の入口になる。該当箇所があれば、何をチェックアウトして何を実行しているかを個別に確認したい。
上のコマンドは「侵害されているかどうか」を判定するものではなく、自分がチェーンのどの段に対して無防備かを可視化するためのものだ。③(実行)が開いているなら
ignore-scripts か npm v12 への移行を、⑥(増殖)が気になるなら cooldown の設定を、というように、判明した穴に対応する段の防御へ進む。
まとめ——「信頼の経路」を分解して考える
サプライチェーン攻撃は、技術的に難解な攻撃ではない。難しいのは、普段は意識しない信頼の受け渡しが、どこで、どのように成立しているかを言語化することのほうだ。
・攻撃が狙うのは自分のコードではなく、他人からコードを受け取る経路である
・手口は6分類——乗っ取られる対象がアカウント・実行タイミング・名前・解決順・ビルド経路のどれかで整理できる
・npm install で乗っ取られるのは、インストール時に依存側のコードを自動実行する仕組みがあるから。npm v12でこの既定は変わった
・npm audit は既知CVEの照合であって、配布経路の侵害には反応しない。守備範囲を誤解しないことが重要
・防御策はチェーンのどの段を切るのかで選ぶ。1段でも確実に切れれば連鎖は止まる
・エコシステムによる差は「インストール時に実行されるか」に集約される。Goのように実行フックを持たない設計もある
具体的なツール設定や導入手順はサプライチェーンセキュリティ2026|攻撃手法・防御ツール・実践チェックリストに譲る。仕組みが分かっていれば、そこに並んだ設定が「チェーンのどの段を切るためのものか」も自ずと読めるはずだ。
参照ソース
・Release v12.0.0 · npm/cli — npm v12のリリースノート(2026年7月8日)。allowScripts によるライフサイクルスクリプトの既定ブロック、allow-git/allow-remote の既定 none 化を記載
・Upcoming breaking changes for npm v12 - GitHub Changelog — v12の破壊的変更の事前告知。npm 11.16.0以降で警告つきで先行確認できる旨、および Git依存が --ignore-scripts を回避しうる経路の説明
・Generating provenance statements | npm Docs — provenanceの仕組み・対応CI(GitHub Actions/GitLab CI/CD)・npm 9.5.0以降という要件、および「悪意あるコードが無いことの保証ではない」という限界の明示
・Minimum Release Age - Renovate Docs — Renovateの minimumReleaseAge 設定と、lockfile生成時に --before=<date> を渡す動作
・Go Modules Reference — Goモジュールの解決・go.sum によるハッシュ検証・GOPROXY/GOSUMDB の役割
・Gems with Extensions - RubyGems Guides — ネイティブ拡張つきgemが extconf.rb を経てインストール時にビルドされること
・Scripts - Composer — 実行されるのはルートパッケージのスクリプトのみで、依存パッケージのスクリプトは実行されないこと
・Plugins - Composer — プラグインの自動ロードと allow-plugins による明示許可(非対話モードでは未許可プラグインで失敗する)