技術 約9分で読めます

pnpm 11がminimumReleaseAgeをデフォルト化、Yarn 4.10とnpm v11.10で同じ防御を書いてShai-Hulud波のinstallを止める

いけさん目次

TL;DR

結論 Mini Shai-Hulud波のような短時間露出型のnpm汚染は、package managerの「公開からN日経つまでinstallしない」設定(release-age gate)でかなりの確率で止まる。2026年5月時点でpnpm 11.0はこの防御をデフォルトONにしている。Yarn 4.10も同様。npmは設定可能だがデフォルトOFFなので明示が要る

何をすればいい pnpmなら11以上に上げる(v10系なら pnpm-workspace.yamlminimumReleaseAge: 1440 を明示)、Yarn Berryなら4.10以上+enableScripts: false、npmなら v11.10 以上で .npmrcmin-release-age=7ignore-scripts=true

注意点 ignore-scripts: true を雑に入れるとesbuild・sharp・node-gyp依存のパッケージが壊れる。pnpmの onlyBuiltDependencies、yarnの npmPreapprovedPackages で個別許可する

限界 age gateは「公開直後の数時間〜十数時間で削除された悪意あるバージョン」には効くが、攻撃者が長期間気づかれずに居座る形(社員端末経由の長期侵害)には効かない。lockfileのバージョン固定、シークレットの最小権限、SessionStart hookと folderOpen タスクの定期点検は引き続き要る(Mini Shai-Hulud記事Shai-Hulud OSS化記事参照)


2026年5月時点で、Mini Shai-Hulud波のような短時間露出型のnpm汚染は、package managerのデフォルト設定だけでかなりの確率で止まる。pnpm 11.0と Yarn 4.10 がrelease-age gate(公開からN日経つまでinstallしない設定)をデフォルトONにしたためだ。npmは v11.10 で同じ設定が入ったが、デフォルトはOFFのままなので明示が要る。

きっかけは Lu Zaramburoのdev.to記事min-release-age 周りの設定をまとめて見かけたこと。記事内の設定値そのものは正しいが、執筆時点でpnpm 11.0・Yarn 4.10のデフォルト変更にまだ追い切れていない部分があった。各package managerでの具体的な書き方と、ignore-scripts: true で実際に壊れる依存の扱いまで補強する。

攻撃側の露出窓を先に見る

なぜ「数日待ってからinstallする」が効くのかは、過去事例の時間軸を並べると分かりやすい。

事例公開〜削除までの窓規模
Axios npm hijack(2026-03)4〜5時間月間1.2億DLのRAT埋め込み
debug + chalk + 16 packages(2025-09)約2.5時間weekly 20億+ DLの根幹パッケージ群
Shai-Hulud 2.0(2025-11)約12時間自己拡散ワーム
Mini Shai-Hulud(TanStack、2026-05-11)約3時間(22万5千DL/3hr)200近いパッケージへ拡大

「公開から24時間(あるいは7日)経つまでinstallしない」というだけのルールで、上のほぼ全てが事前ブロックされる。

攻撃側がこの窓を伸ばすことは可能だが、伸ばすほど検出されやすくなる構造になっている。npm・pnpm・Yarn側もこの非対称性を利用していて、最近の防御強化はrelease-age gateを軸に置いている。

pnpm 11はデフォルトでage gateがONになっている

2026年4月のpnpm 11.0で、minimumReleaseAge のデフォルト値が 1440(分単位、1日)に変更された。
v10系までは未設定(=0、即installできる)だったので、ここがいちばん大きい挙動変化になる。

flowchart LR
    A["pnpm 10系<br/>minimumReleaseAge=0<br/>(明示しない限り即時)"] --> B["公開直後の<br/>悪意あるバージョンも<br/>install対象"]
    C["pnpm 11.0+<br/>minimumReleaseAge=1440<br/>(1日デフォルト)"] --> D["公開24h未満は<br/>resolve対象外"]
    style A fill:#7f1d1d,color:#fff
    style C fill:#14532d,color:#fff

v11.0以上を入れていれば追加設定は要らない。逆に「インストールが急に通らなくなった」という症状で気付くケースが出てくるので、CIで --frozen-lockfile を使っていて新しいlockfile行が入っているときは、その依存が公開24時間以内になっていないか確認する。

期間を延ばすなら pnpm-workspace.yaml で明示する。workspaceでないプロジェクトなら package.jsonpnpm フィールドでも同じ効果になる。

# pnpm-workspace.yaml
minimumReleaseAge: 10080  # 分単位、7日

v10系を当分使い続ける運用なら、上の設定を明示的に入れることで同等の保護が得られる。v10では package.json"pnpm" キーに書く形が安定する。

{
  "pnpm": {
    "minimumReleaseAge": 10080
  }
}

ローカル開発で「待てない依存」が出たら、--ignore-min-release-age フラグで個別にバイパスできる。CI側ではこのフラグを禁止する運用にしておくと、迂回路が開発者ローカルだけに閉じる。

Yarn 4.10は3日デフォルト、enableScripts: false はv2から既定

Yarn Berry(v4系)も2026年に同じ方向に動いている。

設定キーデフォルト役割
npmMinimalAgeGate3d(4.10から)release-age gate
enableScriptsfalse(v2から)postinstall等を実行しない
npmPreapprovedPackages-(明示)ageゲートの例外をglobで指定

設定例は .yarnrc.yml にこう書く。

# .yarnrc.yml
npmMinimalAgeGate: "7d"
enableScripts: false
npmPreapprovedPackages:
  - "@types/*"
  - "typescript"
  - "esbuild"
  - "sharp"

enableScripts: false は強い設定で、これだけでnpmパッケージのpostinstall系攻撃面がほぼ消える。
ただし後述の通り、ネイティブビルドが必要な依存(esbuild、sharp、node-gyp系)が動かなくなるので、npmPreapprovedPackages で許可する形になる。

Yarn Classic(v1)は2024年でメンテナンス終了しており、age gate相当の設定はない。v1を使っているプロジェクトでこの防御を入れたいなら、Yarn Berry移行かpnpmへの乗り換えのどちらかが現実的になる。

npm v11.10で書く明示設定

npmは v11.10.0 で min-release-age を公式サポートしたが、デフォルトはOFFになっている。Node 24 LTSが要件だ。

# .npmrc
min-release-age=7
ignore-scripts=true

min-release-age の単位はnpm側は で、pnpmの分単位とは違う。
同じ設定ファイルにpnpmとnpmの両方を書く構成(モノレポでツールが混在しているとき)では単位の取り違えに注意する。

bulk OIDCやprovenance強制と組み合わせるなら、.npmrc に以下も足しておく。

# .npmrc
min-release-age=7
ignore-scripts=true
audit-level=high
prefer-offline=true

prefer-offline=true は副次的な効果として、ローカルキャッシュにあるバージョンを優先するため、悪意あるバージョンを新規取得しにくくなる。完全な防御ではないが、CIのfreshなrunner以外では新規取得が減る。

ignore-scripts: true で壊れる依存と回避策

ignore-scripts=true は強力だが、ネイティブバイナリのダウンロードやビルドにinstallスクリプトを使っているパッケージで問題が出る。主に詰まるのは次のあたりだ。

パッケージinstall時に何をする詰まる症状
esbuildプラットフォーム別バイナリのDLCannot find module 'esbuild-linux-64'
sharplibvipsバイナリの配置実行時に Could not load the "sharp" module
node-gyp依存系(bcrypt、canvas等)C++のコンパイル実行時にnative bindingが見つからない
CypressテストランナーバイナリのDLcypress run が落ちる
PlaywrightブラウザのDLテスト実行時にbrowser not found
husky / simple-git-hooksGit hookの登録hookが効かない

回避は package manager 別にやり方が違う。

pnpm の場合

新しめのpnpmでは onlyBuiltDependencies でビルドを許可するパッケージをホワイトリスト化する。

# pnpm-workspace.yaml
onlyBuiltDependencies:
  - esbuild
  - sharp
  - "@swc/core"

pnpm approve-builds を対話的に走らせると、最初のinstall時に許可リストの選択が出る運用にもできる。

Yarn Berry の場合

enableScripts: false を残したまま、許可したいパッケージを npmPreapprovedPackages で個別指定する。それでもpostinstallが要るときは dependenciesMeta.<package>.built: true で個別に開ける。

# .yarnrc.yml
enableScripts: false
npmPreapprovedPackages:
  - "esbuild"
  - "sharp"
// package.json
{
  "dependenciesMeta": {
    "esbuild": { "built": true },
    "sharp": { "built": true }
  }
}

npm の場合

全体は ignore-scripts=true のままで、npm rebuild <pkg> --ignore-scripts=false を必要なときに走らせる。CIなら postinstall フェーズの代わりに明示的なステップとして書く。

npm ci --ignore-scripts
npm rebuild esbuild sharp --ignore-scripts=false

ホワイトリストに載せる依存は最小化するのが原則だ。「ビルドを許可する」=「lifecycle scriptの実行を許可する」なので、ホワイトリストに載せた瞬間にその依存のサプライチェーン攻撃面がフルに戻る。esbuild、sharp、prismaあたりの「ビルドが要るがメンテナが少人数」のパッケージは、それぞれ単独のリスクとして見ておく。

DockerとCIでの適用

開発環境よりCIやコンテナ環境のほうが、release-age gateで止まる悪意あるバージョンが増える。CI runnerは毎回まっさらな状態で npm install するため、公開直後の悪意あるバージョンが取り込まれやすい。

Dockerfileに環境変数で押し込むなら以下になる。

FROM node:24-alpine

# pnpm用、分単位
ENV npm_config_min_release_age=10080
# pnpm独自の環境変数表記
ENV PNPM_MINIMUM_RELEASE_AGE=10080

ENV npm_config_ignore_scripts=true

GitHub Actionsでは、actions/setup-nodecache: 'pnpm' を使う場合、pnpm-lock.yaml が更新されたPRでだけ新規依存が解決される。lockfileに既にあるバージョンは age gate の影響を受けないため、lockfile commitとinstall時刻の差が自然なage gateとして働く構造になっている(lockfileを古いまま使っていれば悪意あるバージョンを取らない)。

その上で、lockfile更新自体を意図的に遅らせるなら、RenovateやDependabotの設定で minimumReleaseAge 相当の機能を有効化する。

// renovate.json
{
  "minimumReleaseAge": "7 days"
}

Renovateの minimumReleaseAge は「このage未満のバージョンはPRを作らない」という挙動になる。Dependabotにも近い設定があり、dependabot-core でissue #14134 として npmMinimalAgeGate 対応が進行中。lockfile更新の入口で止める形になるので、CIや本番runnerまで悪意あるバージョンが降ってくる前に弾ける。

限界と残る攻撃面

release-age gate と ignore-scripts は強力だが、これだけで全部止まるわけではない。

止まらないケースを挙げておく。

  • 公開直後ではなく数日〜数週間気づかれずに居座る悪意あるバージョン(メンテナアカウントが完全に奪われた場合)
  • 一度installした端末上で永続化された後の活動(SessionStart hook、folderOpen task、gh-token-monitor 系service)
  • すでにlockfileに入っている古いバージョンが、別経路で悪用されるケース(極めて稀)
  • npmレジストリ以外のソース。github:owner/repo#hash 形式のGit dependencyは Mini Shai-Hulud記事@tanstack/setup がorphaned commit(親コミットを持たない孤立コミット)経由で配布された例と同じ攻撃面に該当する

ignore-scripts で入口を絞っても、Mini Shai-Hulud記事とShai-Hulud OSS化記事で書いた永続化の点検は別ルートで定期的に回す。具体的には ~/.claude/settings.jsonSessionStart hook、各リポジトリの .vscode/tasks.jsonfolderOpen task、~/Library/LaunchAgents/com.user.gh-token-monitor.plist あたりを継続的に見る。

Git dependency経由の攻撃にrelease-age gateは効かない。npmレジストリ上のバージョン番号ではなくcommit hash指定で入ってくるためだ。lockfileのdiff審査で github: プレフィックスを含む新規依存が増えていないかをチェックするのが現実的な追加防御になる。


dev.to側の元記事は短くまとまっているが、設定値だけ拾ってコピーすると2026年5月時点のデフォルト変更を取りこぼす。pnpmを使っているなら、まず pnpm --version で11以上か確認する。Yarn Berryなら yarn --version で4.10以上を確認し、npmなら v11.10 以上+.npmrc の明示設定で揃える。

すでにMini Shai-Hulud波で踏んだ可能性のある環境では、入口を絞っただけでは事後対応にならない。Token revoke(トークン失効)の順序を間違えると gh-token-monitorrm -rf $HOME を走らせる話を含めて、対応の全体像はMini Shai-Hulud記事に書いた手順を使う。