npmサプライチェーン攻撃がサーバーに届く仕組み
npmの悪意あるパッチ、postinstallスクリプト、typosquatがVPS上のNodeアプリへ届く経路を解説します。固定バージョンでデプロイする具体策も紹介します。
サーバー上で npm サプライチェーン攻撃が発生する仕組み
npm サプライチェーン攻撃は、利用者がインストールすることを選んだパッケージを経由してサーバーに到達します。開放ポートやエクスプロイトの手順は必要ありません。npm(node package manager)はコードをインストールします。コードをインストールすると、そのコードが実行されます。そのため、小規模な Node アプリケーションでも、内容を確認したことのないパッケージを数百個取り込みます。そのうちのどれか1つが、1時間後に新しいバージョンを公開する可能性があります。
デプロイ時にインストールコマンドが最新の一致バージョンを要求すると、悪意のあるバージョンを取得することがあります。そのコードは、インストールを実行したユーザーの権限で動作します。以下の内容は、すべてこの2つの説明から導けます。
以下の攻撃パターンは、1台の VPS に1つの Node アプリをデプロイする場合に、個人が遭遇する頻度の高い順に並んでいます。大企業であれば、この順序にはなりません。大企業には通常、内部レジストリ、レビュー担当チーム、公開レジストリのミラーがあるためです。個人の環境にあるのは、デプロイスクリプトです。
形態 1: メンテナーのアカウントが侵害され、パッチを公開する
npm レジストリでは、すでに存在するバージョンの内容を変更できません。そのため、攻撃者がメンテナーをフィッシングにかけたり、公開トークンを盗んだりしても、4.18.2を書き換えることはできません。攻撃者は4.18.3を公開します。
package.jsonを確認してください。"express": "^4.18.2"のような行は、バージョン 4.18.2 を意味しません。キャレットは「このバージョン以上の 4.x バージョン」を意味し、~4.18.2は「任意の 4.18.x」を意味します。npm installは実行時点でその範囲を解決するため、同じ git commit を同じ日の午後に 2 回デプロイしても、異なるコード一式がインストールされる可能性があります。この差分が攻撃対象になります。これが発生するために、マシン上の何かが侵害されている必要はありません。
悪意のあるリリースは通常、報告されてから取り下げられます。ただし、取り下げは誰かがインストールした後に行われます。その期間中にデプロイした環境には、コードがディスク上に残ります。実行のたびに範囲を解決するパイプラインでは、担当者が意図しなくても、この期間に週数回、自動的に入ることになります。
形態 2: インストールスクリプトがデプロイを実行するユーザーとして動作する
パッケージの package.json では、scripts ブロック内に preinstall、install、postinstall、prepare を定義できます。npm はインストール中にこれらを実行します。これらはサンドボックス化されておらず、誰もレビューしません。インストールコマンドを入力したユーザーとして、そのユーザーのホームディレクトリ内で、そのユーザーのネットワークアクセス権とシェルの完全な環境を使って実行されるシェルコマンドです。
したがって、重要な問いはパッケージに何ができるかではありません。そのユーザーが何を読み取れるかです。通常のデプロイ用ホストでは、レジストリトークンを保持する ~/.npmrc、SSH (secure shell) のデプロイキーとして使う ~/.ssh/id_ed25519、~/.aws/credentials、~/.docker/config.json、そしてシェルでエクスポートされたすべての変数が含まれます。DATABASE_URL は通常、その中にあります。
このようなペイロードには、永続化も権限昇格も必要ありません。いくつかのファイルを読み取り、HTTPS 経由でホストへ送信し、終了ステータス 0 で終了します。npm はデフォルトでインストールスクリプトの出力を非表示にするため、何も表示されません。これを無効にして、実際に実行される内容を確認します。
npm ci --foreground-scriptsforeground-scripts は標準入力、標準出力、標準エラーを npm プロセスと共有します。そのため、ビルドスクリプトの出力は端末に表示されます。インストールが成功したときに npm が破棄するバッファには入りません。
Shape 3: タイポスクワッティングと、正確には入力していない名前
タイポスクワットとは、人気のパッケージに似た名前で公開され、入力ミスや貼り付けミスによるインストールコマンドを待ち受けるパッケージです。仕組み上の問題はコードではなくコマンドにあるため、ここでは lockfile では防げません。誤った名前を一度追加すると、その後 lockfile はその名前を忠実に固定します。
個人ではなくチームを狙う亜種が、依存関係の混同です。内部パッケージの名前が billing-utils で、プライベートレジストリに置かれているとします。パブリックレジストリに billing-utils という名前のパッケージが存在しなければ、誰でも公開できます。npm はスコープなしの名前をデフォルトのパブリックレジストリで解決するため、パブリック側のパッケージが選ばれる可能性があります。対策は、自分が管理するスコープと、そのスコープ用のレジストリマッピングを .npmrc に設定することです。
@yourorg:registry=https://npm.yourorg.example/
//npm.yourorg.example/:_authToken=${NPM_TOKEN}これで @yourorg/billing-utils は、そのホストからのみ取得されます。デフォルトレジストリより先に、スコープとレジストリの対応が参照されるためです。スコープなしの内部名にはマッピングがないため、保護されません。
新しい依存関係を追加する前に、ダウンロード数のバッジではなく、パッケージ自体を確認してください。
npm view some-lib repository.url maintainers time.created time.modified先月作成され、公開リポジトリと結び付けられないアカウントが公開したパッケージは、6 年間の履歴があるパッケージとは異なるリスクがあります。どちらの事実も、それだけで不正の証拠にはなりません。ただし、どちらも簡単に確認できます。
形 4: 所有者がいつの間にか変わった依存パッケージ
メンテナーはパッケージを引き継ぎます。誰かが燃え尽き、別の人が支援を申し出て、公開権限が移ります。しかし、そのことを知らせる通知は、そのパッケージに依存するプロジェクトへ一切届きません。侵害が発生したわけではありません。2021 年に委ねた信頼を、別の人が保持しているだけです。
これは最も進行が遅く、検出も最も困難な形です。これに直接答えるコマンドはありません。確認すべき点は 2 つです。パッケージを採用する前に、上記の npm view の行で公開できるユーザーを確認します。次に、実際に依存しているパッケージに変更が入ったら、差分を読みます。
npm diff --diff-name-only --diff=some-lib@1.4.2 --diff=some-lib@1.4.3
npm diff --diff=some-lib@1.4.2 --diff=some-lib@1.4.3最初の形式では、変更されたファイル名だけが出力されます。重要なパッケージをアップグレードするたびに実行しても十分に高速です。ビルドスクリプトに変更を加える、パッケージのルートにファイルを追加する、または scripts ブロックを編集するパッチリリースは、サーバーに到達する前に全文を読む価値があります。
コミット済みの lockfile から package-lock.json でビルドする
package-lock.json には、依存関係ツリー内の各パッケージの正確なバージョン、その取得元 URL、各 tarball の sha512 整合性ハッシュ、依存元のパッケージが記録されます。これをコミットしてください。実際にテストした内容を示す唯一のファイルです。
開発者のラップトップ以外のマシンでは、必ず npm ci を使ってインストールします。npm install は使いません。
npm ci --omit=dev --ignore-scriptsnpm ci と npm install には、この用途に関係する重要な違いがあります。npm ci は lockfile が存在することを要求します。開始前に既存の node_modules を削除するため、以前のデプロイで残ったファイルが今回のデプロイに残ることはありません。package.json や lockfile に書き込むこともないため、インストールによってバージョンが静かに更新されることはありません。lockfile と package.json の内容が一致しない場合は、差異を解決せずにエラーで終了します。
このエラーは問題ではなく、機能です。依存関係の変更には、誰かがレビューしたコミットが必要になります。02:00 のデプロイによる副作用として変更されることはありません。
取得するたびに整合性ハッシュが検証されます。バイト列が記録されたハッシュと一致しない tarball は、展開されず、code EINTEGRITY でインストールに失敗します。これによって保証される範囲を正確に理解してください。受信したファイルが lockfile で固定されたファイルと同一であることは確認できます。これは チェックサムでダウンロードを検証する 場合と同じ保証であり、制限も同じです。固定されたバージョンが公開時点で悪意のあるものだったかどうかは分かりません。
--omit=dev について、1 点注意が必要です。対象のパッケージは引き続き解決され、lockfile にも書き込まれます。ただし、ディスクには配置されません。ディスク上のパッケージが少なければ、実行されるインストールスクリプトと実行時に読み込まれるコードも減るため、実施する価値があります。依存関係ツリーから依存関係自体が削除されるわけではありません。
インストールスクリプトをコードとして扱い、拒否する方法を把握します
インストールスクリプトは無効にできます。プロジェクトの .npmrc に次の設定を追加し、lockfile と一緒にコミットします。
ignore-scripts=true
save-exact=trueignore-scripts=true により、npm が依存関係で宣言されたスクリプトを実行しなくなります。save-exact=true により、npm install some-lib は 1.4.2 を package.json ではなく ^1.4.2 に書き込むため、解決範囲が誤ってマニフェストに入ることもありません。
この設定を有効にすると問題が発生するため、有効化する前に仕組みを理解しておく必要があります。ネイティブアドオンをコンパイルするパッケージや、ビルド済みバイナリをダウンロードするパッケージは、インストールスクリプトでその処理を行います。スクリプトを無効にすると、インストール自体は成功します。その後、実行時にバインディングファイルを読み込めないモジュールとして失敗します。解決策は許可リストです。
npm ci --ignore-scripts
npm rebuild better-sqlite3npm rebuild <package> により、そのパッケージだけのビルドスクリプトが実行されます。これで、決して会うことのない数百の第三者に一括して実行権限を与えるのではなく、パッケージごとに判断できました。
現在、どれだけの許可を与えているかを確認するには、npm に問い合わせます。
npm query ":attr(scripts, [postinstall])"インストール済みツリーで postinstall スクリプトを持つすべてのパッケージが表示されます。一般的なアプリケーションでは、リストは予想より短くなります。だからこそ、許可リストが実用的になります。
ビルドとネットワークトラフィックを処理するプロセスを分離する
deploy ユーザーには node_modules への書き込み権限が必要です。HTTP リクエストに応答するプロセスには必要ありません。同じアカウントを使うと、インストール中に実行されるコードがユーザー向けのコードを書き換えられます。実行時のコードも同様です。
分離してください。一方のユーザーでビルドし、別のユーザーでサービスを提供します。サービスを提供するアカウントから、提供用ディレクトリを読み取り専用にします。
sudo useradd --system --home-dir /srv/nodeapp --shell /usr/sbin/nologin nodeapp
sudo chown -R deploy:nodeapp /srv/nodeapp
sudo chmod -R o-rwx /srv/nodeapp次に、systemd に強制させます。/etc/systemd/system/nodeapp.service を記述してください。
[Unit]
Description=Node application
After=network-online.target
[Service]
User=nodeapp
Group=nodeapp
WorkingDirectory=/srv/nodeapp/current
EnvironmentFile=/etc/nodeapp/env
ExecStart=/usr/bin/node server.js
Restart=on-failure
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadWritePaths=/srv/nodeapp/shared
NoExecPaths=/srv/nodeapp/shared
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
[Install]
WantedBy=multi-user.targetProtectSystem=strict は、このサービスのファイルシステム全体を読み取り専用でマウントします。ただし、/dev、/proc、/sys と ReadWritePaths に列挙した項目は除きます。そのため、アプリケーションが node_modules に書き込もうとすると EROFS: read-only file system で失敗します。この動作は、約 1 分で自分のログ上でも再現できます。NoExecPaths は書き込み可能なアップロードディレクトリを対象にします。サービスはそこへファイルを書き込めますが、カーネルはそのファイルの実行を拒否します。このオプションには systemd 249 以降が必要です。Ubuntu 24.04 には 255 が含まれています。
この unit ファイルには、2 つの注意点があります。1 つ目は、MemoryDenyWriteExecute=yes を追加しないことです。多くの systemd ハードニング設定に含まれていますが、Node の起動を妨げます。V8 は実行時に JavaScript をマシンコードへコンパイルするため、書き込み可能かつ実行可能なページを必要とするためです。2 つ目は、ExecStart のパスを command -v node から取得することです。Node をバージョンマネージャーでインストールした場合、Node は deploy ユーザーのホームディレクトリ以下にあります。ProtectHome=yes はそのディレクトリをサービスから隠すため、unit は直ちに失敗します。status=203/EXEC と、実行可能ファイルを見つけられないことを示すログ行が出力されます。
ファイルの内容を信頼するだけでなく、結果を確認してください。
sudo systemctl daemon-reload
sudo systemctl enable --now nodeapp
sudo systemd-analyze security nodeapp.service
sudo -u nodeapp touch /srv/nodeapp/current/probesystemd-analyze security には、すべてのハードニング設定とその公開範囲が一覧表示されます。どの設定がまだデフォルトのままかを確認できます。touch は Permission denied で失敗するはずです。nodeapp は current 以下に所有するものを持たないためです。成功する場合は、ファイルの所有権が誤っています。systemd の設定がその誤りを気付かないまま補っている状態です。
EnvironmentFile について、1 点注意があります。systemd は User=nodeapp に切り替える前に、root としてこれを読み取ります。そのため、このファイルを root:root にして mode 600 にできます。アプリケーションは引き続き変数を受け取ります。ただし、nodeapp として shell にアクセスできるユーザーは、/proc/<pid>/environ からその変数を読み取れます。したがって、これは保存時の Secret を保護するものであり、実行中のプロセスを保護するものではありません。
ビルド環境にデプロイ用認証情報を置かない
インストールスクリプトは環境を継承します。この点だけで、どこでビルドするかを決めるべきです。
最も安全なのは、本番サーバーではない場所でビルドし、完成したディレクトリをコピーする方法です。この場合、ビルドマシンに保持するのは読み取り専用のレジストリトークンだけです。SSH デプロイキー、クラウドアクセスキー、データベースパスワード、コンテナレジストリのログイン情報は置きません。
npm token create --read-only読み取り専用トークンはパッケージを取得できますが、公開はできません。ビルド環境から盗まれても、被害は公開パッケージをダウンロードされることに限られます。
サーバー上でビルドする必要がある場合は、意図的に限定した環境で deploy ユーザーとしてビルドし、実行時の Secret は /etc/nodeapp/env に保管してください。deploy からは読み取れない場所にします。同じ考え方は、自分でホストするビルド自動化にも当てはまります。自己ホスト型 GitHub Actions runner はトークンを保持し、ジョブごとに公開された任意のコードを実行します。そのため、小規模なデプロイ環境では最も価値の高いマシンになります。自分で作成していないプログラムに環境全体を渡す場合も、同じカテゴリに含めるべきです。したがって、AI agent の環境から Secret を除外することも、間に別のプログラムを置いた同じ問題です。
監査できない依存関係は固定するかベンダー管理する
固定した依存関係とは、コミットなしではバージョンを変更できない依存関係です。ツリー全体については、コミット済みの lockfile がすでにこの状態を実現します。ただし、さらに対策が必要なケースが2つあります。
最初は間接依存関係です。依存先が何に依存するかは制御できません。overrides を package.json に指定すると、ツリー内のどこにあってもバージョンを強制できます。
{
"overrides": {
"some-transitive-lib": "1.4.2"
}
}追加後に1度 npm install を実行して lockfile に結果を記録し、両方のファイルをコミットします。
2つ目は、監査できず、削除もできないパッケージです。これをベンダー管理します。npm pack はレジストリが提供する正確な tarball をダウンロードし、file: 依存関係を指定すると手元のコピーからインストールします。
npm pack some-lib@1.4.2
mkdir -p vendor && mv some-lib-1.4.2.tgz vendor/{
"dependencies": {
"some-lib": "file:vendor/some-lib-1.4.2.tgz"
}
}これで tarball はリポジトリ内に保存され、外部から勝手に変更されることはありません。ただし、以後の更新は自分で継続して対応する必要があります。そのため、やむを得ず使用している小規模な放棄済みパッケージに使い、Web フレームワークには使わないでください。
時間を置いてから更新する方法もあります。費用はかかりません。
npm install --before=2026-08-01before オプションを指定すると、その日以前に公開されたバージョンだけを使用してツリーを再構築します。依存関係を更新するときは1〜2週間前の日付を指定してください。悪意のあるリリースが公開されてから報告されるまでの期間を回避できます。ただし、本当に必要なセキュリティ修正も保留されるため、強力な方法ではありません。これを使ってバージョン範囲を解決し、変更内容を確認してから lockfile をコミットします。同じ考え方は、依存関係としてではなく npm からインストールするコマンドラインツールにも適用できます。固定されていない npx の呼び出しでは、その朝にリリースされたものが取得される可能性があります。正確な dsh バージョンを固定することで、2台のマシンが同じコードを実行できます。
実際にどのバージョンをリリースしたかを確認するにはどうすればよいですか?
git の lockfile には、インストールされるべきだった内容が記録されています。ディスクには、実際にインストールされている内容があります。証拠になるのは後者だけです。
npm ls some-lib
node -e "console.log(require('./node_modules/some-lib/package.json').version)"npm ls は node_modules を読み取るため、lockfile が指定した内容ではなく、実際に存在する内容を報告します。node -e 行は、パスからインストール済みの manifest を読み取ります。exports フィールドによってサブパスの import が制限されるパッケージでも動作し、ツリー表示なしで 1 つのバージョンを出力します。
比較対象のもう一方は、git から読み取ります。
git log --oneline -- package-lock.json
git show <commit>:package-lock.json | grep -A3 '"node_modules/some-lib"'コミットをデプロイ用のレイアウトに含めて、両者の対応関係を永続化します。/srv/nodeapp/releases/<short commit sha> にリリースし、シンボリックリンクで /srv/nodeapp/current から参照します。「現在実行されているものは何か」という問いへの答えは readlink /srv/nodeapp/current になり、デプロイした本人でない人でも 03:00 に確認できます。
最後に、registry が保証できる内容を確認します。
npm audit signaturesこれにより、インストール済みツリー内のパッケージについて registry の署名を検証し、provenance attestation が付与されたパッケージについては、その attestation も検証します。provenance は、公開された tarball を、それを生成した公開 continuous integration (CI) ビルドに結び付けます。そのため、検証済みの attestation があれば、コードを不明なノート PC ではなく、特定のコミットまで追跡できます。対応範囲は普遍的ではありません。attestation がない場合は「情報なし」と解釈し、「不正なパッケージ」とは解釈しないでください。
悪いリリースがサーバーに到達した後の対応
実行された処理と、その処理を実行したユーザーを起点に、影響範囲を外側へたどります。
インストール中にコードが実行された場合は、build user が読み取れるすべての情報が失われたと考えます。registry token、ホームディレクトリ内の SSH keys、cloud credentials、その shell で export されたすべての secret をローテーションします。ファイルが読み取られていないことを証明できないため、ローテーションだけが確実な対応です。
ロックダウンされた service account で runtime にコードが実行された場合、到達可能な範囲は大幅に小さくなります。対象は、アプリケーション自身の environment variables と、ネットワークアクセスで到達できるものだけです。これが、VPS 上でサービスを非特権ユーザーとして実行する唯一の根拠です。これによって侵害を防げるわけではありません。侵害によってマシンのどの範囲まで取得されるか、また再起動後も残るかが変わります。
次に、クリーンアップではなく再構築します。node_modules を削除し、package.json で影響を受けた package を悪い version より前に固定します。npm install を 1 回実行して lockfile を更新し、commit してから npm ci で deploy します。既存の tree をその場で修復しないでください。install script が変更した内容をすべて列挙することはできません。
影響期間も記録します。version を取得する可能性があった最初の deploy と、その version を除去した deploy を記録してください。この期間が、どの自分のログを読むべきかを示します。ただし、release 名が commit に基づいている場合に限り、この期間を特定できます。
ここまでの対策で解決できないこと
ロックファイルがあっても、依存関係が安全になるわけではありません。その依存関係を受け入れた時点を日付付きのレビュー済みの判断として記録し、デプロイの副作用として扱わないようにします。上記の対策はいずれも、偶然の結果を意図的な選択へ変えるものです。
npm audit は、この場合の防御策にはなりません。これは依存関係ツリーを、報告済みの脆弱性を収録したデータベースと照合します。そのため、すでに公開され、名称が付けられた問題を検出します。サプライチェーン攻撃は、悪用できる期間全体を通じて名称が付いていません。既知の古いバグについては npm audit を実行し、4 時間前にリリースされたものについては何も検出できないと考えてください。
依存関係の数を減らすことは、このガイドのどのツールよりも効果があります。しかし、誰もが最も聞きたがらない助言でもあります。追加しないパッケージが 1 つ増えるたびに、攻撃者があなたの代わりにフィッシングを仕掛けられない公開元が 1 つ増えます。また、deploy ユーザーとして実行されることのない install script も 1 つ増えます。
これらはいずれも npm 固有の話ではありません。同じ 4 つの形態は、PyPI、RubyGems、コンテナイメージ、ディストリビューションのパッケージマネージャーにも当てはまります。npm で特に目立つのは、依存関係のツリーが深く、インストールスクリプトがデフォルトで実行されるためです。すでに実行しているツールを拡張するものは、すべて同じ問題を引き継ぎます。そのため、インストール前に dsh プラグインが到達できる範囲を確認することは、postinstall スクリプトを読むことと同じ作業です。ただし、権限はデプロイユーザーではなく、エージェントに付与されたものになります。周辺のマシンのどこまでを自分で防御すべきかは、それが実行される場所によって異なります。これは、VPS ホスティングが安全かどうかという、より広い問題の一部です。
FAQ
npm ci を使用すれば、侵害された npm パッケージから保護できますか?
認識しないうちにバージョンが変更されることは防げます。npm ci は package-lock.json に記録された内容を正確にインストールし、各 tarball を sha512 整合性ハッシュと照合します。package.json と lockfile の内容が一致しない場合は、差異を解決せずにエラーで終了します。ただし、固定されたバージョンが安全かどうかは判断しません。悪意のあるバージョンを固定した lockfile を commit すると、npm ci は管理下にあるすべてのサーバーへ、そのバージョンを毎回忠実にインストールします。
すべてに ignore-scripts=true を設定すべきですか?
設定したうえで、allowlist に登録してください。プロジェクトの .npmrc にある ignore-scripts=true は、依存関係のインストールスクリプトの実行を停止します。これにより、悪意のあるパッケージが deploy ユーザーの認証情報へ到達する最も直接的な経路を遮断できます。native addon をコンパイルするパッケージや、事前ビルド済みのバイナリを取得するパッケージでは、インストールスクリプトが必要な場合があります。スクリプトを無効にすると、インストール時ではなく、実行時に binding ファイルがないため失敗します。npm ci --ignore-scripts を実行し、信頼すると判断した少数のパッケージに対して npm rebuild <package> を実行してください。npm query ":attr(scripts, [postinstall])" で、実際に対象となるパッケージ数を確認できます。
サーバーに実際にインストールされたパッケージのバージョンを確認するにはどうすればよいですか?
lockfile ではなく、ディスク上の内容を確認してください。npm ls <package> は node_modules に存在する内容を報告し、node -e "console.log(require('./node_modules/<package>/package.json').version)" はバージョン文字列だけを出力します。git にある lockfile が示すのは、インストールされるべき内容です。これは別の問いへの回答であり、両者を比較することに意味があります。git commit 名を付けたディレクトリへデプロイすると、数か月後に確認が必要になった場合でも、両方の情報を利用できます。
npm audit はサプライチェーン攻撃を検出しますか?
いいえ。npm audit は、インストール済みのツリーを報告済み脆弱性のデータベースと照合します。そのため、すでに公開され、識別子が付与された問題しか検出できません。悪意のあるリリースは、インストールが問題になる数時間または数日の間、報告されていない可能性があります。npm audit signatures のほうが有用なコマンドです。インストール済みツリー全体の registry 署名を検証し、公開者が生成している場合は provenance attestation も確認します。これにより、tarball が不明なマシンではなく、公開されたビルドから取得されたことを確認できます。
攻撃がインストール時に発生する場合でも、アプリを非特権ユーザーで実行することが重要なのはなぜですか?
2 つの障害では影響範囲が異なり、両方に対処する必要があるためです。インストール時のコードは deploy ユーザーとして実行され、そのユーザーの SSH 鍵、registry token、クラウド認証情報を読み取れます。実行時のコードはサービスアカウントとして動作します。User=nodeapp、ProtectSystem=strict を設定し、ディスク上に認証情報を置かなければ、読み取り可能な範囲はアプリケーション自身の環境とデータベースに限定されます。アカウントを分離すると、ネットワークトラフィックを処理するプロセスが node_modules を書き換えることも防げます。そのため、実行時の侵害が永続化せず、次回の再起動で解消されます。