SSD Nodes Learn 🎉 VPS $5.50/月〜
ガイド Matt Connor著者 Matt Connor

npmサプライチェーン攻撃からNodeサーバーを守る方法

npmの悪意あるパッチ、postinstallスクリプト、typosquatがVPS上のNodeアプリへ届く仕組みを解説します。package-lock.jsonを使うデプロイで取得先を固定し、攻撃を防ぎます。

サーバーに対する npm サプライチェーン攻撃とは

npm サプライチェーン攻撃は、インストールすることを選択したパッケージを介してサーバーに到達します。公開ポートは関係なく、エクスプロイトの手順もありません。npm (node package manager) はコードをインストールします。そして、コードをインストールするとコードが実行されます。そのため、小規模な Node アプリケーションでも、読んだことのない数百個のパッケージを取り込みます。そのうちのどれか1つが、1時間後に新しいバージョンを公開する可能性があります。

デプロイで悪意のあるバージョンが取得されるのは、インストールコマンドが条件に一致する最新バージョンを要求したためです。そのコードは、インストールを実行したユーザーの権限で実行されます。以下の内容は、すべてこの2つの文から導かれます。

以下では、1人のユーザーが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 回デプロイしても、異なるコードのセットがインストールされる可能性があります。この差が攻撃対象になります。これを発生させるために、マシン上の何かが侵害されている必要はありません。

悪意のあるリリースは通常、報告されて削除されます。しかし、削除されるのは利用者がインストールした後です。その期間中にデプロイした環境には、すでにそのコードがディスク上に存在します。実行のたびに範囲を解決するパイプラインは、誰も意図的に判断しなくても、週に数回、自動的にその期間へ入ります。

Shape 2: デプロイを実行するユーザーとしてインストールスクリプトが動作する

パッケージの package.json では、scripts ブロック内に preinstallinstallpostinstallprepare を宣言できます。npm はインストール中にこれらを実行します。これらはサンドボックス化されておらず、誰も内容をレビューしません。インストールコマンドを入力したユーザーとして、そのユーザーのホームディレクトリ内で、そのユーザーのネットワークアクセス権とシェルの完全な環境を使って実行されるシェルコマンドです。

したがって、確認すべき有用な質問は、パッケージが何を実行できるかではありません。そのユーザーが何を読み取れるかです。通常のデプロイ用ホストでは、レジストリトークンを保持する ~/.npmrc、SSH (secure shell) のデプロイキーとして使う ~/.ssh/id_ed25519~/.aws/credentials~/.docker/config.json、さらにシェルでエクスポートされたすべての変数が含まれます。DATABASE_URL は通常、この中に保存されています。

このようなペイロードには、永続化も権限昇格も必要ありません。いくつかのファイルを読み取り、HTTPS 経由でホストへ送信し、終了ステータス 0 で終了します。npm はデフォルトでインストールスクリプトの出力を隠すため、何も表示されません。これを無効にして、実際に実行される内容を監視します。

npm ci --foreground-scripts

foreground-scripts は標準入力、標準出力、標準エラー出力を npm プロセスと共有します。そのため、ビルドスクリプトの出力は、インストールが成功したときに npm が破棄するバッファではなく、端末に表示されます。

形態3: typosquat と、入力しなかった名前

typosquat とは、広く使われているパッケージに近い名前で公開され、入力ミスや貼り付けミスによるインストールコマンドを待ち受けるパッケージです。仕組みを悪用する対象はコードではなくコマンドであるため、ここでは lockfile は役に立ちません。間違った名前を一度追加すると、その後は lockfile がその名前を忠実に固定するためです。

個人ではなくチームを狙う亜種が、dependency confusion です。内部パッケージの名前が 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 から npm ci でビルドする

package-lock.json には、ツリー内のすべてのパッケージの正確なバージョン、各パッケージの取得元 URL、各 tarball の sha512 整合性ハッシュ、依存元のパッケージが記録されます。必ずコミットしてください。実際にテストした内容を示す唯一のファイルです。

開発者のノート PC 以外のマシンでは、必ず npm ci を使用し、npm install は使用しないでください。

npm ci --omit=dev --ignore-scripts

npm cinpm 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=true

ignore-scripts=true により、npm が依存関係に宣言されたスクリプトを実行しなくなります。save-exact=true により、npm install some-lib1.4.2 ではなく package.json^1.4.2 に書き込むため、解決範囲が誤ってマニフェストに入ることもありません。

この設定を有効にすると問題が発生するため、有効化する前に仕組みを理解しておく必要があります。ネイティブアドオンをコンパイルするパッケージや、ビルド済みバイナリをダウンロードするパッケージは、インストールスクリプトでその処理を行います。スクリプトを無効にするとインストール自体は成功しますが、後で実行時に、バインディングファイルを読み込めないモジュールとして失敗します。対策は許可リストです。

npm ci --ignore-scripts
npm rebuild better-sqlite3

npm rebuild <package> により、そのパッケージ1つのビルドスクリプトを実行します。これで、決して会うことのない数百の第三者に一括で実行権限を与えるのではなく、パッケージごとに判断できます。

現在、どれだけの範囲に実行許可を与えているかを確認するには、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.target

ProtectSystem=strict は、このサービスのファイルシステム全体を読み取り専用でマウントします。ただし、/dev/proc/sysReadWritePaths に列挙したものは除きます。そのため、アプリケーションが 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/probe

systemd-analyze security は、すべてのハードニング設定とその公開範囲を一覧表示します。これにより、デフォルトのままになっている設定を確認できます。touchPermission denied で失敗するはずです。nodeappcurrent 配下の何も所有していないためです。成功する場合は、ファイルの所有権が誤っています。systemd の設定がその問題を気付かれないまま補っていることになります。

EnvironmentFile について 1 点補足します。systemd は User=nodeapp に切り替える前に root としてこのファイルを読み取ります。そのため、ファイルのモードを 600 にした root:root にできます。アプリケーションは引き続き変数を受け取れます。ただし、nodeapp としてシェルを使用できるユーザーは、/proc/<pid>/environ から変数を読み取れます。したがって、これは保存中の Secret を保護するものであり、実行中のプロセスを保護するものではありません。

ビルド環境からデプロイ用認証情報を排除する

インストールスクリプトは環境を継承します。この事実だけで、どこでビルドするかを決めるべきです。

最も安全なのは、本番サーバーではない場所でビルドし、完成したディレクトリを転送する方法です。ビルドマシンには読み取り専用のレジストリトークンだけを保持します。SSH デプロイキー、クラウドアクセスキー、データベースパスワード、コンテナレジストリのログイン情報は保持しません。

npm token create --read-only

読み取り専用トークンはパッケージを取得できますが、公開はできません。ビルド環境から盗まれても、失われるのは公開パッケージをダウンロードする権限だけです。

サーバー上でビルドする必要がある場合は、deploy ユーザーとして、意図的に限定した環境でビルドします。実行時の Secret は /etc/nodeapp/env に保持し、deploy から読み取れないようにします。同じ考え方は、自分でホストするビルド自動化にも当てはまります。セルフホスト型 GitHub Actions runner はトークンを保持し、各ジョブで公開された任意のコードを実行します。そのため、小規模なデプロイ環境では最も価値の高いマシンになります。自分が作成していないプログラムに環境全体を渡す場合も、同じカテゴリーに含まれます。したがって、AI agent の環境から Secret を排除することも、間に別のプログラムを置いた同じ問題です。

監査できない依存関係は、バージョン固定またはベンダー管理にします

バージョン固定した依存関係は、コミットなしではバージョンを変更できません。ツリー全体については、コミット済みの lockfile がすでにこの役割を果たします。ただし、追加の対策が必要なケースが2つあります。

最初は推移的依存関係です。依存しているパッケージが何に依存するかは制御できません。overridespackage.json に指定すると、ツリー内のどこにある依存関係でもバージョンを強制できます。

{
  "overrides": {
    "some-transitive-lib": "1.4.2"
  }
}

追加後に npm install を1回実行して、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-01

before オプションを指定すると、その日以前に公開されたバージョンだけを使ってツリーを再構築します。依存関係を更新するときは、日付を1週間または2週間前に設定してください。悪意のあるリリースが公開されてから、報告されるまでの期間を避けられます。この方法は、本来必要なセキュリティ修正も保留するため、強力ではあるものの粗い制御です。これを使ってバージョン範囲を解決し、変更内容を確認してから、lockfile をコミットしてください。

実際にどのバージョンをリリースしたかを確認するにはどうすればよいですか?

git の lockfile には、インストールされるはずだった内容が記録されています。ディスクには、実際にインストールされている内容があります。証拠になるのは後者だけです。

npm ls some-lib
node -e "console.log(require('./node_modules/some-lib/package.json').version)"

npm lsnode_modules を読み取るため、lockfile が意図した内容ではなく、実際に存在する内容を報告します。node -e 行は、パスでインストール済みマニフェストを読み取ります。exports フィールドによってサブパスインポートが制限されるパッケージでも動作し、ツリー表示を付けずに 1 つのバージョンを出力します。

比較対象のもう一方については、git を読み取ります。

git log --oneline -- package-lock.json
git show <commit>:package-lock.json | grep -A3 '"node_modules/some-lib"'

コミットをデプロイレイアウトに含めて、2 つの情報の対応関係を永続化します。/srv/nodeapp/releases/<short commit sha> にリリースし、シンボリックリンクで /srv/nodeapp/current をそこへ向けます。「現在実行されているものは何か」という問いの答えが readlink /srv/nodeapp/current になり、デプロイした本人でない人でも 03:00 に確認できます。

最後に、レジストリが保証できる内容を確認します。

npm audit signatures

これにより、インストール済みツリー内のパッケージについてレジストリ署名を検証し、プロヴェナンス証明を持つパッケージについてはその証明も検証します。プロヴェナンスは、公開された tarball と、それを生成した公開継続的インテグレーション (CI) ビルドを結び付けます。したがって、検証済みの証明があれば、不明な laptop ではなく、コミットまでコードを追跡できます。対応範囲は普遍的ではないため、証明がない場合は「情報なし」と解釈し、「不正なパッケージ」とは解釈しないでください。

悪いリリースがサーバーに到達した後の対応

実行された処理と、その処理を実行したユーザーを起点に、影響範囲を外側へ確認します。

インストール中にコードが実行された場合は、build user が読み取れたすべての情報が流出したと考えます。registry token、対象ホームディレクトリ内の SSH keys、cloud credentials、その shell で export されたすべての secret をローテーションします。ファイルが読み取られていないことを証明できないため、ローテーションだけが適切な対応です。

制限された service account で実行時にコードが動作した場合、到達可能な範囲は大幅に小さくなります。対象は、アプリケーション自身の環境変数と、そのネットワークアクセスから到達できるものです。これが、VPS 上でサービスを非特権ユーザーとして実行する根拠です。これによって侵害を防げるわけではありません。侵害によってマシンのどこまで影響を受けるか、また再起動後も侵害が残るかが決まります。

次に、クリーニングではなく再構築します。node_modulesを削除し、影響を受けたパッケージをpackage.jsonで悪いバージョンより下に固定します。npm installを 1 回実行して lockfile を更新し、コミットしてからnpm ciでデプロイします。ツリーをその場で修復しないでください。インストールスクリプトが触れたものをすべて列挙することはできません。

影響期間も記録します。そのバージョンを取得できた最初のデプロイと、そのバージョンを除去したデプロイを記録してください。その範囲が、確認すべき自分のログを示します。リリース名が commit に基づいている場合に限り、この範囲を特定できます。

これらの対策で解決できないこと

lockfile があるだけで、依存関係が安全になるわけではありません。lockfile は、その依存関係を受け入れた時点を日付付きのレビュー済み判断として記録します。デプロイの副作用として受け入れるのではなく、明示的な選択に変えるものです。上記の各対策はすべて、偶然の結果を選択へ変えるという同じ役割を果たします。

npm audit は、ここで防御策にはなりません。報告済みの脆弱性を集めたデータベースと依存関係ツリーを比較するため、すでに公開され、名前が付けられた問題を検出するものです。サプライチェーン攻撃は、その攻撃が有効な期間全体を通じて名前のないままです。過去から知られている脆弱性を調べるために npm audit を実行し、4 時間前にリリースされたパッケージについては何も検出できないと考えてください。

依存関係の数を減らすことは、このガイドのどのツールよりも効果があります。しかし、誰もが最も勧めたがらない方法でもあります。追加しないパッケージ 1 つごとに、攻撃者があなたに代わってフィッシングできる公開者が 1 人減り、deploy user として実行される install script も 1 つ減ります。

これらは npm に固有の話でもありません。同じ 4 つのパターンは、PyPI、RubyGems、コンテナイメージ、ディストリビューションのパッケージマネージャーにも当てはまります。npm で特に目立つのは、依存関係ツリーが深く、install script がデフォルトで実行されるためです。防御対象となる周辺のマシンがどこまで自分の管理下にあるかは、どこで実行するかによって異なります。これは、VPS ホスティングが安全かどうかという より大きな問題の一部です。

FAQ

npm ci は侵害された npm パッケージから保護してくれますか?

認識しないうちにバージョンが変わることは防げます。npm cipackage-lock.json に記録された内容を正確にインストールし、各 tarball を sha512 整合性ハッシュと照合します。package.json と lockfile の内容が一致しない場合は、差異を解決せずにエラーで終了します。ただし、固定されたバージョンが安全かどうかは判断しません。悪意のあるバージョンを固定した lockfile をコミットすると、npm ci は所有するすべてのサーバーに、そのバージョンを毎回そのままインストールします。

すべてに ignore-scripts=true を設定すべきですか?

設定してから、許可リストを作成してください。プロジェクトの .npmrc にある ignore-scripts=true を設定すると、依存関係のインストールスクリプトが実行されなくなります。これにより、悪意のあるパッケージから deploy ユーザーの認証情報へ到達する最も直接的な経路を遮断できます。ネイティブアドオンをコンパイルするパッケージや、事前ビルド済みバイナリを取得するパッケージでは、スクリプトが本当に必要な場合があります。スクリプトを無効にすると、インストール時ではなく、バインディングファイルが見つからないため実行時に失敗します。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 のほうが有用なコマンドです。インストール済みのツリー全体でレジストリ署名を検証し、公開者が生成している場合は来歴の証明も確認します。これにより、tarball が不明なマシンではなく、公開ビルドから生成されたことを確認できます。

攻撃がインストール時に発生する場合でも、アプリを権限のないユーザーで実行することが重要なのはなぜですか?

2 つの攻撃は影響範囲が異なり、両方に備える必要があるためです。インストール時のコードは deploy ユーザーとして実行され、そのユーザーの SSH キー、レジストリトークン、クラウド認証情報を読み取れます。実行時のコードはサービスアカウントとして実行されます。User=nodeappProtectSystem=strict を設定し、ディスク上に認証情報を置かなければ、アクセス範囲はアプリケーション自身の環境とデータベースに限定されます。アカウントを分離すると、ネットワークトラフィックを処理するプロセスが node_modules を書き換えることも防げます。そのため、実行時にサーバーが侵害されても、次回の再起動で変更が消え、永続化しません。