こんにちは。Inflabのバックエンド開発者、Hoonyです。
最近、サプライチェーン攻撃(Supply-Chain-Attack)によるセキュリティインシデントが相次いでいます。今年の初めに最も話題になったLiteLLMのサプライチェーン攻撃事件から、
JavaScriptエコシステムで最も広く使われているHTTPクライアントライブラリであるAxiosのパッケージ配布経路の汚染まで、本当に多くのセキュリティインシデントが発生しました。
本記事では、サプライチェーン攻撃がどのように起こるのかを見たうえで、Inflabでどのような防御策を講じているのかを共有したいと思います。
ソフトウェアサプライチェーン攻撃(Software Supply-Chain-Attack)
ソフトウェアサプライチェーン攻撃(Software Supply-Chain-Attack)とは、最終的な攻撃対象となるサービスや企業のインフラを直接狙うのではなく、
対象が開発の過程で信頼して取り込んでいる外部資産(オープンソースパッケージ、ビルド環境、サードパーティーサービスなど)をまず侵害し、それを踏み台にして最終的に内部ネットワークへ侵入する、高度な迂回攻撃の手法です。
信頼できる開発サプライチェーンの一部が汚染されると、そのサプライチェーンに依存しているInflabのシステムも連鎖的に汚染されざるを得ない構造になっています。
Axiosパッケージの事例
今年3月ごろに発生したAxiosの事例を簡単に説明します。このインシデントは、開発者が自ら書いたコードや明示的に宣言した直接依存ではなく、その下に隠れた間接依存(transitive dependency)のツリーを汚染する方法で行われました。
この事例は、私たちの社内Slackでも緊急に告知されました。
この攻撃事例を簡単にまとめると、次のとおりです。
- 悪意のある攻撃者が
axiosのリードメンテナーのnpmアカウントを乗っ取る - 攻撃者が
axiosの依存ツリーに、悪意のあるコードであるplain-crypto-js@4.2.1を強制的に注入 - 乗っ取ったアカウントのトークンを使い、npm CLIから直接手動で公開
- この事実を知らなかったユーザーの環境で
npm installを実行 -
postinstallスクリプトによって、バックグラウンドで悪意のあるコード(ドロッパー)setup.jsを実行 - ドロッパーが任意のコマンドを実行できるRATをインストールし、攻撃者がそのRATを通じて認証情報を含むシステム情報を窃取できる状態になる
Axiosの攻撃事例の詳細は、こちらのリンクで確認できます。
このように悪意のあるコードがユーザーの知らないうちに自動で実行され得たのは、攻撃者がnpmのlifecycle scriptsを悪用したためです。
lifecycle scriptsの実行順序
lifecycle scriptsとは、パッケージのインストールや公開など、
特定の状況やイベントが発生したときに、その前後で自動的に連動して実行されるようあらかじめ決められた、特別なスクリプトのステップです。
npm installやnpm ciコマンドを実行すると、下の画像のような順序でスクリプトが実行されます。
例えば、次のようにpackage.jsonが書かれたパッケージをnpm installすると、各ステップのスクリプトが決められた順序で実行され、ターミナルに出力される結果も簡単に予想できます。
ここまでがlifecycle scriptsの基本的な動作です。では、この仕組みを悪用した悪意のあるバージョンのAxiosは、node_modulesをどのような構造にしていたのでしょうか?
node_modules/
├── axios/ <- 1.14.1 (悪意のある公開バージョン)
│ ├── dist/
│ ├── lib/
│ ├── index.js
│ ├── package.json ⚠️ dependenciesにplain-crypto-jsが追加された
│ ├── LICENSE
│ └── README.md
├── plain-crypto-js/ ⚠️ 4.2.1 (悪意のある依存、crypto-jsのタイポスクワッティング)
│ ├── setup.js ⚠️ ドロッパー (約4209 bytes) postinstallで自動実行
│ ├── package.json ⚠️ "postinstall": "node setup.js" を含む
│ ├── package.md ⚠️ クリーンな4.2.0用package.jsonのコピー (偽装用に待機)
│ ├── index.js (crypto-jsのオリジナルをそのままコピー)
│ ├── core.js
│ ├── aes.js
│ └── ... (残りのcrypto-jsファイル)
├── follow-redirects/ ✅ 正常なaxiosの依存
├── form-data/ ✅ 正常なaxiosの依存
└── proxy-from-env/ ✅ 正常なaxiosの依存
この構造で注目すべき点は、攻撃者がplain-crypto-js@4.2.1をコードのどこからもimportしていないことです。
もっぱらpostinstallで自動実行されることだけを狙って、悪意のある公開バージョンの依存関係にこっそり追加していました。
そして、そのpostinstallのステップで実際の攻撃を行ったのが、setup.jsドロッパーです。
setup.jsドロッパーは何をしたのか
setup.js(約4.2KB)はpostinstallのステップで自動実行され、おおよそ次のような順序で動作する典型的な認証情報窃取型ドロッパー(credential-stealing dropper) でした。
-
実行環境の判別 - OS、CI環境かどうか、解析用サンドボックスと疑われる痕跡などを確認します。解析環境と判断した場合は何もせずに静かに終了し、検知を回避します。
-
機密情報の収集 - 作業ディレクトリの
.envファイル、~/.aws/credentials、~/.sshなど、認証情報が置かれている既知のパスをくまなく調べ、トークン、キー、シークレットをかき集めます。 -
外部送信(Exfiltration) - 収集したデータをエンコードし、攻撃者が管理するC2サーバーに送信します。通常は1回きりのリクエストで終わらせ、痕跡を最小限に抑えます。
-
痕跡の隠蔽 - 同梱されていた
package.md(クリーンな4.2.0用package.jsonのコピー)で自身のpackage.jsonを上書きし、事後の点検時に正常なパッケージに見えるよう偽装します。
ここで恐ろしいのは、開発者がコードを1行も実行する前に、ただ一度npm installを実行しただけで、これらすべての攻撃がバックグラウンドで完了していたという点です。
では、Inflabはどう備えたのか?
先ほど事例として挙げたAxiosの場合、Axiosに注入された悪意のある間接依存plain-crypto-js@4.2.1は、公開からわずか6分で自動解析システムによって検知されました。
また、主要なサプライチェーン攻撃10件を分析した結果によると、そのうち8件で、悪意のあるバージョンが公開されてから発見・削除されるまでに1週間もかからなかったと報告されています。
結局、私たちが立てた防御戦略は次の2つに集約されます。
- Cool-Downの適用 - 公開されたばかりのバージョンがすぐにインストールされないよう遅延させ、その間にセキュリティコミュニティによる検知と報告が行われるようにします。
-
インストール時の任意コード実行のブロック - Cool-Down期間が過ぎて悪意のあるバージョンがインストールされたとしても、
postinstallのようなlifecycle scriptsが自動実行されないようにします。
この2つの戦略は、pnpm 10から公式の設定としてサポートされるようになりました。私たちのチームでも、Axiosの事件の直後にCool-Downの導入について素早く議論が行われました。
パッケージマネージャーの統一:pnpmへの移行
当時、InflabのNode.jsバックエンドのリポジトリは、pnpmを使っているところもあればyarnを使っているところもあるという具合で、パッケージマネージャーが統一されていませんでした。
パッケージマネージャーごとにセキュリティ設定の方法が異なるため、このままでは先ほどの戦略をすべてのリポジトリに同じように適用するのは困難でした。
そのため、セキュリティ設定を一貫して適用するにはまずパッケージマネージャーを1つに揃える必要があり、私たちはこの機会にすべてのリポジトリをpnpmに統一することに決めました。
上記の理由に加えて、次のような背景があったおかげで、パッケージマネージャー統一の決定を素早く下すことができました。
- レガシーコードが残っている一部のリポジトリを除けば、ほとんどのリポジトリですでにpnpmを使っていました。
- 議論を進めている間にpnpm 11がリリースされ、このバージョンではpnpm 10で導入されたセキュリティ設定がデフォルトでより厳格に適用されるようになりました。
- pnpm 11はNode.js 22以降が必要ですが、すでに一部のサービスでNode.js 22を本番環境に適用しており、安定性がある程度検証済みでした。
pnpm 11のセキュリティ強化機能
pnpm 11では、サプライチェーン攻撃に備えるためのセキュリティ設定がデフォルトで有効になっています。コミュニティで推奨されている設定と、各オプションの役割を紹介します。
# pnpm-workspace.yaml
minimumReleaseAge: 1440
minimumReleaseAgeStrict: true
blockExoticSubdeps: true
strictDepBuilds: true
dangerouslyAllowAllBuilds: false
trustPolicy: no-downgrade
# CIでは、installが暗黙的に走るよりも失敗させたい場合:
verifyDepsBeforeRun: error
minimumReleaseAge
| 項目 | 値 |
|---|---|
| デフォルト値 |
1440(pnpm 11以降。それ以前のバージョンは0) |
| 設定値 | 分単位の数値 |
レジストリに公開されてから指定した時間(分)が経過していないバージョンを、インストール対象から除外します。1440は24時間を意味します。
^1.14.0のようなキャレット範囲で宣言された依存関係であっても、公開から24時間が経過していないバージョンはインストールされません。
Axiosの事例のように悪意のあるバージョンが公開から6分で検知されるのであれば、24時間のCool-Downは検知と対応に十分な時間を確保してくれます。
minimumReleaseAgeStrict
| 項目 | 値 |
|---|---|
| デフォルト値 |
true(minimumReleaseAgeを明示的に設定した場合)、false(それ以外) |
| 設定値 |
true / false
|
Cool-Downの条件を満たすバージョンが1つもないときの動作を決めます。
-
true: インストールを失敗させます。安全でないバージョンが気づかないうちにインストールされるよりもインストールが失敗するほうが安全なので、trueにしておくことが推奨されます。 -
false: 警告を出力しつつ、条件を満たさないバージョンにフォールバックしてインストールを続行します。
strictDepBuilds
| 項目 | 値 |
|---|---|
| デフォルト値 |
true(pnpm 11以降) |
| 設定値 |
true / false
|
依存パッケージにビルドスクリプト(preinstall、install、postinstall)が含まれていて、まだ許可リストに登録されていない場合、インストールを異常終了(non-zero exit) させます。
Axiosの事例でplain-crypto-jsがpostinstallで悪意のあるコードを実行したような攻撃を、根本から遮断できます。
allowBuilds
| 項目 | 値 |
|---|---|
| デフォルト値 | 空のリスト(どのパッケージもビルドスクリプトを実行できない) |
| 設定値 |
パッケージ名: true形式のリスト |
strictDepBuildsはすべてのビルドスクリプトをブロックしてしまうため、ビルドスクリプトが正常な動作に欠かせないパッケージまでブロックされるという問題が起きます。
例えばesbuildやsharpのように、ネイティブバイナリを直接コンパイルするパッケージは、postinstallでバイナリを取得またはビルドしないと正常に動作しません。
こうしたパッケージをallowBuildsに登録すると、そのパッケージに限ってビルドスクリプトの実行を明示的に許可できます。
# pnpm-workspace.yaml
allowBuilds:
esbuild: true
sharp: true
つまり、strictDepBuildsがデフォルトですべての扉に鍵をかける設定だとすれば、allowBuildsは信頼できるパッケージにだけ鍵を渡すホワイトリストの役割を果たします。
ただし、注意すべき点があります。
- パッケージを登録するということは、すなわちそのパッケージのビルドスクリプトが任意のコードを実行することを許可するという意味です。登録する前に、本当にビルドスクリプトが必要なパッケージなのか、信頼できる提供元なのかを必ず確認する必要があります。
- インストールが失敗するからといって習慣的にリストに追加するのではなく、リストは最小限に保つのが望ましいです。許可する項目が増えるほど、その分だけ攻撃対象領域も広がります。
- 面倒だからといって、後述する
dangerouslyAllowAllBuilds: trueで一気に開放してしまうとstrictDepBuildsの意味がなくなるので、必ずallowBuildsでパッケージを1つずつ許可する方法をお勧めします。
dangerouslyAllowAllBuilds
| 項目 | 値 |
|---|---|
| デフォルト値 | false |
| 設定値 |
true / false
|
名前にdangerouslyが付いていることからわかるように、このオプションをtrueに設定すると、すべての依存関係のビルドスクリプトを無条件に許可します。strictDepBuildsを無効化してしまうので、必ずfalseのままにしておく必要があります。
blockExoticSubdeps
| 項目 | 値 |
|---|---|
| デフォルト値 |
true(pnpm 11以降) |
| 設定値 |
true / false
|
間接依存(transitive dependency)が、npmレジストリではない異質な(exotic)ソース(Git URL、HTTP tarballなど)からインストールされるのをブロックします。
直接依存は開発者が意図して宣言したものなので許可しつつ、下位の依存ツリーで予期しない外部ソースへ迂回する攻撃を防ぎます。
trustPolicy
| 項目 | 値 |
|---|---|
| デフォルト値 | 未設定(無効) |
| 設定値 |
no-downgrade / off
|
no-downgradeに設定すると、以前のバージョンと比べてパッケージの信頼レベルが低下した場合にインストールを失敗させます。
npmパッケージの信頼レベルは、公開方法によって大きく3段階に分かれます。上から下に行くほど信頼レベルが低くなります。
| 信頼レベル | 公開方法 | 検証範囲 |
|---|---|---|
| Trusted Publisher | GitHub ActionsなどのCI/CDから自動で公開 | 公開者の身元とビルドの出所の両方を検証 |
| Provenance | 署名・attestationはあるが、Trusted Publisherではない | ビルドの出所は検証されるが、公開者の身元は未検証 |
| None | npm CLIから手動で公開 | 何の証明もなく、誰がどこでビルドしたのかわからない |
trustPolicy: no-downgradeは、信頼レベルが維持される、または上がる変更は許可し、低下する方向の変更だけをブロックします。
例えばaxios@1.14.0からaxios@1.14.1に上がるとき、信頼レベルの変化に応じて次のように動作します。
| 変更前 → 変更後 | 信頼レベル | インストール |
|---|---|---|
| Trusted Publisher → Trusted Publisher | 維持 | 許可 |
| Provenance → Trusted Publisher | 上昇 | 許可 |
| None → Provenance | 上昇 | 許可 |
| Trusted Publisher → Provenance | 低下 | ブロック |
| Trusted Publisher → None | 低下(Axiosの攻撃事例) | ブロック |
| Provenance → None | 低下 | ブロック |
Axiosの攻撃事例では、攻撃者は乗っ取ったアカウントのトークンを使い、npm CLIから手動で公開しました。
従来のAxiosはTrusted Publisherとして公開されていたので、信頼レベルはTrusted Publisher → Noneへと急落することになります。
trustPolicy: no-downgradeが設定されていれば、この時点でインストールがブロックされ、攻撃を未然に防ぐことができたはずです。
verifyDepsBeforeRun
| 項目 | 値 |
|---|---|
| デフォルト値 | install |
| 設定値 |
install / warn / error / prompt / false
|
pnpm runやpnpm execを実行する前に、依存関係の状態を検証します。CI環境で依存関係が不完全な状態のままスクリプトが実行されるのを防ぎたい場合は、errorにしておくことが推奨されます。
-
install: 依存関係が不完全な場合、自動でpnpm installを実行します。 -
warn: 警告だけを出力して処理を続行します。 -
error: 依存関係が不完全な場合、実行を中断します。 -
prompt: インストールするかどうかを対話形式でユーザーに確認します。 -
false: 検証を行いません。
このように、pnpm 11ではほとんどのセキュリティ設定が、デフォルト値のままでも強力な保護を提供してくれます。
悪意のあるバージョンの公開そのものを防ぐことはできませんが、ライブラリを利用する側として、即座に自動で伝播する構造を 「遅延」に転換することで、その間にセキュリティコミュニティによる検知と報告が行われるよう時間を稼ぐことができます。
パッケージマネージャー移行のヒント
すでにpnpmを使っている場合(バージョンアップ)
先ほど紹介したセキュリティ設定は、pnpm 11を前提としたものです。では、すでに旧バージョンのpnpmを使っているリポジトリは、どのように11まで上げればよいのでしょうか?
ここで1つ注意点があります。pnpmの移行ガイドは、メジャーバージョンを1段階ずつ上げる場合を前提に提供されています。
例えば8 → 9、9 → 10、10 → 11のように、各段階ごとに変更された動作と移行方法がドキュメント化されています。
そのため、pnpm 8から11へ一気にジャンプするよりも、1メジャーバージョンずつ着実に上げていく方法をお勧めします。
- 各段階でどのような動作が変わったのかを、公式の移行ガイドで確認できます。
- 一度に複数のバージョンを飛ばすと、どの段階で問題が起きたのか原因を追跡しにくくなります。
- 段階的に上げれば、
pnpm installとテストを経ながら変更点を少しずつ検証できるので、ロックファイル(pnpm-lock.yaml)や依存関係の解決方法の違いによる問題を早期に発見できます。
私たちも、混在していたパッケージマネージャーをpnpmに統一して11まで上げる過程で、段階的なアップデートがデバッグの負担を大きく減らしてくれることを実感しました。
他のパッケージマネージャー(npm、yarn)を使っている場合
一方、まだyarnやnpmを使っていて、初めてpnpmに移行するリポジトリであれば、pnpm importコマンドで既存のyarn.lockやpackage-lock.jsonをもとにpnpm-lock.yamlを生成できます。
依存関係のバージョンを一から解決し直さずに既存のロック状態をそのまま引き継げるので、移行初期のバージョン変動を抑えるのに役立ちます。
おわりに
本記事で取り上げた攻撃経路は、postinstallスクリプトを悪用したインストール時の攻撃でした。
しかし、この経路を遮断するだけでサプライチェーン攻撃を完全に防げるわけではありません。
インストールスクリプトに対する防御が強化されるほど、攻撃者は次の攻撃ポイントへ移っていく可能性が高いでしょう。
いわばモグラ叩きのようなものです。インストールスクリプトのブロックとCool-Downは、最もよくあるインストール時の攻撃を効果的に減らしてくれますが、import時や実行時に動く悪意のあるコードには無力です。
例えば、パッケージの正常なモジュールコードの中に難読化された悪意のあるロジックを埋め込む手口は、インストールスクリプトのブロックだけでは検知できません。
したがって、インストール時の防御は開発者が守るべき最低限の基本的な防衛線であり、今後はより多様な防御体制を検討していく必要があります。
長文をお読みいただき、ありがとうございました。



