pnpm 10 のサプライチェーン防御 - postinstall を止めて依存を「承認」する

pnpm 10 のサプライチェーン防御 - postinstall を止めて依存を「承認」する

作成日:
更新日:

axiosTanStackTeamPCP と、npm サプライチェーン攻撃の事件を追ってきました。これらの多くに共通するのが、インストール時に走る postinstall などのライフサイクルスクリプトを悪用する手口です。npm install した瞬間に、悪意あるコードが何の確認もなく実行される——これが攻撃の定番経路でした。

この記事は「事件解説」ではなく「具体的な防御設定」の話です。パッケージマネージャ pnpm 10 は、まさにこのライフサイクルスクリプトをデフォルトでブロックし、明示的に承認した依存だけ実行する設計に変わりました。手元の pnpm 10.12.4 で挙動を確認しながら、現実的な防御設定を整理します。

pnpm 10 のデフォルト: ビルドスクリプトは走らない

まず、pnpm 10 で実際にビルドスクリプトを持つ依存(esbuild)を入れてみます。

pnpm 10.12.4 で esbuild を追加
pnpm add esbuild

すると、こう警告が出ます(手元で確認した実際の出力です)。

╭ Warning ──────────────────────────────────────────────────────╮
│   Ignored build scripts: esbuild.                              │
│   Run "pnpm approve-builds" to pick which dependencies should  │
│   be allowed to run scripts.                                   │
╰───────────────────────────────────────────────────────────────╯

ポイントは "Ignored build scripts"。pnpm 10 は、依存のビルドスクリプト(postinstall / install / preinstall)を既定で実行しません。npm の従来挙動(入れたら即実行)とは正反対の、安全側に倒した設計です。

これが効くのは、汚染パッケージを誤って入れてしまっても、postinstall が自動実行されないため、攻撃の初動を止められるからです。「入れた瞬間にやられる」を構造的に防ぎます。

必要な依存だけ「承認」する

とはいえ、esbuild や一部のネイティブモジュールは、ビルドスクリプトを実際に走らせないと正しく動きません。そこで pnpm は「このパッケージはスクリプト実行を許可する」と明示的に承認する仕組みを用意しています。

方法1: 対話的に承認(approve-builds)

承認する依存を対話的に選ぶ
pnpm approve-builds

実行すると、ブロックされている依存の一覧が出て、スクリプト実行を許可するものを選べます。選んだ結果は設定に記録されます。

方法2: 設定に明示(onlyBuiltDependencies)

CI や複数人開発では、設定ファイルに明示するほうが再現性があります。pnpm 10 では package.json(または設定)の onlyBuiltDependencies に、スクリプト実行を許可する依存を列挙します。

package.json(pnpm 10)
{
  "pnpm": {
    "onlyBuiltDependencies": ["esbuild"]
  }
}

ここに列挙したものだけがビルドスクリプトを実行でき、それ以外はすべてブロックされます。「許可リスト方式(allowlist)」なので、新しく増えた依存が勝手にスクリプトを走らせることはありません。

逆に「このパッケージは絶対に走らせない」を明示する neverBuiltDependencies、スクリプトを無視する ignoredBuiltDependencies もあります。

WARNING

dangerouslyAllowAllBuilds(すべての依存のスクリプトを承認なしで実行)という設定もありますが、名前の通り危険です。これを有効にすると pnpm 10 のせっかくの防御が無効になります。「とりあえず動かしたい」で安易に使わないでください。

minimumReleaseAge: 「出たばかり」を避ける

もう一つ強力なのが minimumReleaseAge です。これは「公開されてから一定時間が経っていないバージョンはインストールしない」という設定です。

公開から24時間未満のバージョンを避ける
{
  "pnpm": {
    "minimumReleaseAge": 1440
  }
}

1440(分=24時間)にすると、公開直後のバージョンを掴まなくなります。これが効くのは、サプライチェーン攻撃の多くが「汚染バージョンを公開 → 数十分〜数時間で発見・削除」という短時間の出来事だからです。TanStack 事件でも汚染版は約1時間半で非推奨化されました。「出たての版をすぐ入れない」だけで、汚染版を踏む確率を大きく下げられます。

特定パッケージを除外したいときは minimumReleaseAgeExclude(自社パッケージなど)を併用します。

NOTE

minimumReleaseAge のデフォルト値はバージョンで変わります。pnpm 10 では既定 0(無効)ですが、後継の pnpm 11 では既定 1440 分になりました。pnpm 10 を使っているなら、自分で明示的に設定しておくのが安全です。

CI で「未承認ビルドがある」を失敗にする

pnpm 10 系では、承認されていないビルドスクリプトがある状態を CI で検知して失敗させられます(strictDepBuilds 系の挙動。バージョンにより既定が異なるため要確認)。これにより、「知らないうちに新しい依存がスクリプトを要求している」状態を、レビューのタイミングで気づけます。

CI の基本方針はこうです。

  • ロックファイルを固定(pnpm install --frozen-lockfile
  • ビルド許可は onlyBuiltDependencies で明示し、差分はレビュー対象にする
  • minimumReleaseAge で出たて版を避ける

現実的な防御セット(まとめ設定)

最後に、現実的な「まず入れておくとよい」設定をまとめます。

package.json(pnpm 10 の現実的な防御セット)
{
  "pnpm": {
    "onlyBuiltDependencies": ["esbuild", "sharp"],
    "minimumReleaseAge": 1440,
    "minimumReleaseAgeExclude": ["@your-org/*"]
  }
}
  • ビルドスクリプトは、本当に必要な依存だけ許可リストで明示
  • 公開から24時間未満の版は掴まない(自社パッケージは除外)
  • dangerouslyAllowAllBuilds は使わない

まとめ

  • pnpm 10 は依存のライフサイクルスクリプト(postinstall 等)をデフォルトでブロックする。汚染パッケージの自動実行を構造的に防げる
  • 必要な依存だけ pnpm approve-builds(対話)か onlyBuiltDependencies(設定)で許可リスト方式で承認する
  • minimumReleaseAge で「公開直後のバージョン」を避けると、短時間で削除される汚染版を踏みにくくなる
  • dangerouslyAllowAllBuilds は防御を無効化するので使わない
  • 設定のデフォルトはバージョンで変わる(pnpm 11 で minimumReleaseAge 既定 1440 等)。pnpm 10 では自分で明示するのが安全

npm サプライチェーン攻撃は「依存を入れる」という日常動作が攻撃面になる問題です。pnpm 10 の「デフォルトで止めて、必要なものだけ承認する」設計は、その日常動作に現実的な防壁を1枚足してくれます。まず onlyBuiltDependenciesminimumReleaseAge の2つから始めてみてください。

参考リンク