
古好佑輔
2026.7.15
2026年現在、npmエコシステムを標的としたサプライチェーン攻撃が急増しています。メンテナのアカウント乗っ取りによる悪意あるバージョンの公開や、postinstallなどのライフサイクルスクリプトを悪用したインストール時のマルウェア実行など、手口は日々巧妙になっています。
脆弱性管理サービスを提供する立場として、yamoryの開発基盤のセキュリティにも改めて向き合う必要がありました。
そこで今回、すべてのTypeScriptプロジェクトのパッケージマネージャをnpmからpnpmへ移行しました。
本記事では、移行に踏み切った理由をセキュリティの観点から整理し、実際の移行手順とpnpmの各種設定について解説します。
また、本記事では、移行時点の最新版であるpnpm@10.33.0を採用していますが、pnpmはリリースサイクルが早く、サプライチェーンセキュリティ関連の修正・新機能が継続的に取り込まれています。本記事で紹介している機能のいくつかは、最新バージョンではデフォルトで有効になっているものもあります。
そのため、各設定の挙動・破壊的変更を確認したうえで、できるだけ最新のバージョンを利用することをおすすめします。
pnpmはnpmと比較してインストールが高速であることやディスク容量を節約できることが知られていますが、今回の移行で最も重視したのは「セキュリティ関連の設定が充実している」点です。pnpmはサプライチェーン攻撃の緩和を明確な設計目標に掲げ、npmに先行して多くの防御機能が実装されています。
ここでは4つの観点を紹介します。
サプライチェーン攻撃の典型的な手口のひとつが、preinstall/postinstallといったライフサイクルスクリプトを悪用し、依存パッケージのインストール時に悪意あるコードを実行させる方法です。
pnpm v10以降では、依存関係に含まれるライフサイクルスクリプトの自動実行がデフォルトで無効化されています。これは、postinstallでクリプトマイニングマルウェアを実行したRspackのサプライチェーン攻撃を受けて導入された仕様変更です。ビルドスクリプトの実行が本当に必要なパッケージは、許可リスト方式で個別に明示します(すべてを一括許可するdangerouslyAllowAllBuildsも存在しますが、原則としてこの機能は利用しません)。
これにより、これまでビルド不要だった依存パッケージが、侵害されたバージョンの公開によって突然スクリプトを実行しはじめる、という事態を回避できます。
プロジェクトには、自分が直接インストールしている依存(直接依存)だけでなく、その依存がさらに引き込む依存(推移的依存/間接依存)が存在します。攻撃者は、開発者の目が届きにくいこの階層に悪意あるコードを潜ませることがあります。
pnpmではblockExoticSubdeps: trueを設定することで、推移的依存がgitリポジトリや直接tarball URLといった「exotic source(非標準ソース)」から解決されるのをブロックできます。推移的依存が信頼できるレジストリ経由でのみ解決されることを保証でき、攻撃コードを紛れ込ませる経路をひとつ塞げます。
多くのサプライチェーン攻撃は、公開から数時間〜数日のうちに検出され、削除やパッチ提供が行われます。逆に言えば、公開直後の最新バージョンに飛びつかず、一定期間「寝かせて」からインストールするだけで、攻撃に巻き込まれるリスクを大きく下げられます。
pnpmではminimumReleaseAgeで「公開後、そのバージョンをインストール可能になるまでの最小経過時間(分)」を指定できます。過去のサプライチェーン攻撃の多くは検出までの猶予が1週間未満であり、7日(10080分)のクールダウンがあれば大半を回避できたと考えられます。
pnpmはパッケージの「信頼レベル」を公開方法に応じて段階的に評価しています。最も強いのはTrusted Publisher(CI/CDからOIDCを使って公開され、provenanceが付与された状態)、次いでProvenance(来歴証明あり)、そしてNo Trust Evidence(ユーザー名/パスワードやトークンのみで公開され、信頼の根拠がない状態)です。
サプライチェーン攻撃では、攻撃者がメンテナのアカウントを乗っ取るケースが多くあります。このとき認証情報は盗めてもCI/CDのOIDC権限までは奪えていないことが多く、「いつもはtrusted publishingで公開されるパッケージが、急に弱い手段で公開される」という不自然な状態が生まれます。
trustPolicy: no-downgradeを設定すると、過去のリリースより信頼レベルが下がったバージョンのインストールを拒否できます。これは認証情報の侵害を、インストールする側で検知できる数少ない仕組みです。例外的に許可したい場合はtrustPolicyExcludeを利用できます。
pnpmの設定は、pnpm-workspace.yamlに集約できます。
# Security
minimumReleaseAge: 10080
trustPolicy: no-downgrade
blockExoticSubdeps: true
# Version management
engineStrict: true
packageManagerStrictVersion: true
useNodeVersion: "24.13.0"
# Build
verifyDepsBeforeRun: warn
# Dependencies
savePrefix: ''セキュリティ関連の3項目(minimumReleaseAge/trustPolicy/blockExoticSubdeps)はこれまで述べたとおりです。残りの項目について、それぞれ簡単に補足します。
依存パッケージのenginesフィールドに記載されたNode.js/pnpmのバージョン制約を厳密に検証する設定です。デフォルトでは制約を満たさない環境でも警告のみで処理が続行されますが、engineStrict: trueを設定するとエラーで停止するようになります。意図しないNode.jsバージョンで依存パッケージが動いてしまう事態を防げます。
packageManagerフィールドで指定したpnpmバージョンと、実行中のpnpmバージョンの完全一致を強制する設定です。これにより、開発者ごとに pnpmのバージョンがズレてロックファイルの差分が安定しない、といった事故を防げます。package.jsonのpackageManager: "pnpm@10.33.0"と組み合わせて利用します。
pnpm run/pnpm exec実行時に使用するNode.jsのバージョンを指定する設定です。pnpmが指定バージョンのNode.jsを自動でダウンロード・管理できます。
pnpm runの実行前に、node_modulesの内容がロックファイルと一致しているかを検証する設定です。不一致があればスクリプト実行前に検知できるため、依存関係を更新したままinstallを忘れて壊れた状態で動かしてしまう、といった事故を防げます。理想はerrorですが、現状は誤検知の不具合があるため当面はwarn運用としています。
pnpm addでパッケージを追加する際にバージョン番号の前に付与される接頭辞を指定する設定です。デフォルトは^ですが、''(空文字列)を指定することで^を付けず、バージョンをexactで固定できます。再現性のあるインストールにはexact固定が望ましいです。
これらの設定を踏まえて、実際の移行手順を見ていきます。
1プロジェクトあたりの基本的な手順は次のとおりです。
package.jsonにpackageManagerとpreinstallスクリプトを追加します。
"scripts": {
"preinstall": "npx only-allow pnpm"
},
"packageManager": "pnpm@10.33.0"preinstallのonly-allow pnpmは、誤ってnpm/yarnでinstallしてしまうのをブロックします。この設定はpnpmの公式ドキュメントにも載っています。
あわせて、pnpmの設定を集約するpnpm-workspace.yamlを作成し、従来のサードパーティ製のNode.jsのバージョン管理ツールを廃止してpnpm のuseNodeVersionでNode.jsのバージョンを管理する方法に移行しました。
useNodeVersion: '24.13.0'次のStep2で実行するpnpm importはロックファイルを生成・更新します。このとき、trustPolicyは比較対象のベースラインが存在せずエラーになってしまうため、初回はコメントアウトしておきます。
# trustPolicy: no-downgradepnpm importはnpmの既存のpackage-lock.jsonをpnpm-lock.yamlへ変換してくれます。
変換後、package-lock.jsonは削除します。
pnpm import # package-lock.json → pnpm-lock.yaml へ変換
pnpm install --frozen-lockfile # 整合性を検証し node_modules を生成Step 1 でコメントアウトしていたtrustPolicyの設定を有効にします。
trustPolicy: no-downgradepnpm run buildなどでビルドが通ることを確認し、pnpm dlx depcheckで未宣言の依存(phantom dependencies)が無いかを洗い出します。pnpmはデフォルトでnode_modulesをフラットにしないため、npmでは暗黙的に使えていた「宣言していない依存」がここで顕在化することがあります。
CIでnpmを利用している箇所もpnpmに置き換える必要があります。
GitHub Actionsを利用している場合はpnpm/action-setupを導入し、setup-nodeは削除します(Node.jsのバージョンはpnpm-workspace.yaml のuseNodeVersionが管理するため)。
npm ciはpnpm install --frozen-lockfile、npxはpnpm execに置き換えます。
steps:
# <中略>
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
package_json_file: path/to/project/package.json
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build
run: pnpm exec ng build # 旧: npx ng buildpnpmの移行によってゼロデイ攻撃を完全に防げるわけではありませんが、サプライチェーンセキュリティで重要なのは「侵害が起きたときに巻き込まれにくい状態」をいかに作るかです。
pnpmへの移行は、ライフサイクルスクリプト・推移的依存・公開からの経過時間・信頼レベルといった複数のレイヤーで、攻撃の足場を地道に削っていく取り組みでもあります。
一度設定して終わりではなく、pnpm自体のアップデートやエコシステム全体の変化に合わせて運用を見直していくことが、結果として最大の効果につながると考えています。
参考
© Assured, Inc.