🔒

脆弱性対応と minimumReleaseAge を両立しながら依存管理をクリーンに保つ

に公開

はじめに

こんにちは。PKSHA Technology で SWE をしている須藤です。

npm エコシステムを標的としたサプライチェーン攻撃はすでに現実のリスクです。2026 年 3 月には、週間 8,000 万ダウンロードを超える axios のメンテナーアカウントが乗っ取られ、悪意ある依存パッケージを通じてクロスプラットフォーム対応の RAT(遠隔操作ツール)を配布される事件も起きています。こうした攻撃への対策として、リリース直後のパッケージのインストールを遅延させる仕組み(pnpm の minimumReleaseAge など)が主要パッケージマネージャへ広がっています。

しかし、この対策は脆弱性の修正版がリリースされた直後にも適用されるため、セキュリティ修正を早く取り込みたいが、リリースクールダウンに引っかかるというジレンマが生まれます。私たちのプロジェクトでもこの問題に直面し、pnpm の overridesminimumReleaseAgeExclude を組み合わせて対応しました。

一方で、上流が修正した後も override が残り続けるという新たな課題が生まれました。そこで、overrides と minimumReleaseAgeExclude を毎日ゼロベースで再計算し、常に過不足のない状態に保つ自動同期の仕組みを作りました。

脆弱性修正と minimumReleaseAge のジレンマ

pnpm audit で脆弱性が報告されても、そのパッケージが自分たちの package.json に含まれていないケースは多いです。自分たちが直接 package.json に書いている依存ではなく、その先の「依存の依存」、いわゆる推移的依存(transitive dependency)に脆弱性があるケースです。

自分のアプリ → ライブラリ A → ライブラリ B → 脆弱なパッケージ C

パッケージ C に脆弱性があっても、自分たちが直接触れるのはライブラリ A だけです。ライブラリ A の最新版でも内部的に脆弱なバージョンの C を使っていれば、上流の対応を待つしかありません。

さらに、pnpm の minimumReleaseAge を設定している場合、脆弱性の修正版がリリースされた直後はこの制約に引っかかり、すぐにはインストールできません。

# pnpm-workspace.yaml
minimumReleaseAge: 10080 # 7日

Renovate や Dependabot が自動で PR を作ってくれても、minimumReleaseAge の制約で install が失敗し、マージできない状態になることがあります。

pnpm にはこのジレンマに対処するための仕組みが 2 つあります。

仕組み 役割
overrides 依存ツリー内のパッケージバージョンを強制的に書き換える。親のバージョン範囲外でも指定可能
minimumReleaseAgeExclude 特定パッケージを minimumReleaseAge の時間制約から除外する。バージョンの指定はしない

修正版が親パッケージのバージョン範囲内にある場合は minimumReleaseAgeExclude だけで解決できますが、推移的依存のバージョンを範囲外に強制する必要がある場合は overrides も必要です。脆弱性の緊急対応では両方を併用するのが最も確実です。

overrides の設定例を示します。

{
  "pnpm": {
    "overrides": {
      "vulnerable-package-c": ">=1.2.3"
    }
  }
}

これにより、依存チェーンのどこでパッケージ C が要求されても、指定したバージョン以上に強制できます。

overrides を外すのは追加より難しい

override の追加は明確ですが、「もう不要になった」と判断して削除するのは意外と難しい問題です。

まず、override を package.json から消しただけでは lockfile の解決結果は変わりません。pnpm は既存の lockfile の解決を尊重するため、override がなくても同じ(脆弱な)バージョンが使われ続けます。override を外した上で、依存チェーン全体を更新します。

# override を消すだけでは不十分
pnpm install --lockfile-only  # lockfile は変わらない

# 依存チェーン全体を更新する必要がある
pnpm update -r --lockfile-only

さらに、「この override はまだ必要か?」を確認するには、override を外す → 依存を更新する → audit を実行する、というステップが必要です。override が複数あると 1 つずつ検証する手間もあり、結果として「とりあえず残しておく」が常態化します。私たちのプロジェクトでも実際に棚卸しをしてみたところ、4 つの override が全て不要になっていました。

自動同期の仕組み

この問題を解決するため、GitHub Actions で pnpm audit の結果に基づいて overrides を自動で同期する仕組みを作りました。

本記事の仕組みを実際に動かせるサンプルリポジトリを用意しました。架空の TypeScript モノレポプロジェクトで、ワークフローの実行結果と自動作成された PR も確認できます。先に全体のコードを確認したい方はこちらをご覧ください。

https://github.com/Suto-Michimasa/pnpm-override-sync-example

実際に自動作成された PR はこちらです。
https://github.com/Suto-Michimasa/pnpm-override-sync-example/pull/8

全体フロー

毎回ゼロベースで必要な override を算出します。

要所の解説

audit 結果のパース

pnpm audit は脆弱性があると非ゼロで終了するため、catch 側でも stdout をパースしています。registry エラー等で JSON 自体が返らない場合は、空結果で続行すると全 override が不要と誤判定されるため、明示的に失敗させています。

function audit(): AuditResult {
  try {
    const output = execSync('pnpm audit --json', {
      encoding: 'utf8',
      stdio: ['pipe', 'pipe', 'pipe'],
    })
    return JSON.parse(output)
  } catch (e: unknown) {
    const stdout = (e as { stdout?: string }).stdout ?? ''
    try {
      return JSON.parse(stdout)
    } catch {
      throw new Error('pnpm audit did not return valid JSON')
    }
  }
}

複数 advisory から全 CVE をカバーする最小の下限を取る

advisory とは、npm registry が公開している脆弱性情報で、対象パッケージ名・影響バージョン・修正バージョン(patched_versions)などが含まれます。pnpm audit --json の結果からこの情報を取得できます。

{
  "advisories": {
    "1112455": {
      "module_name": "lodash",
      "vulnerable_versions": ">=4.0.0 <=4.17.22",
      "patched_versions": ">=4.17.23",
      "severity": "moderate",
      "title": "Prototype Pollution Vulnerability in `_.unset` and `_.omit`",
      "cves": ["CVE-2025-13465"]
    },
    "1115806": {
      "module_name": "lodash",
      "vulnerable_versions": ">=4.0.0 <=4.17.23",
      "patched_versions": ">=4.18.0",
      "severity": "high",
      "title": "Code Injection via `_.template` imports key names",
      "cves": ["CVE-2026-4800"]
    }
  }
}

このように同一パッケージに複数の advisory が存在することがあります。>=4.17.23 だけを採用すると、もう一方の CVE(>=4.18.0 で修正)が未修正になる可能性があります。

代表バージョンが最も高い advisory の patched_versions を採用することで、全 CVE を修正する最低ラインを確保しています。格納する値は patched_versions の文字列をそのまま使い、>=4.0.4 <5.0.0 || >=5.1.0 のような複合レンジも保持します。

export function collectNeeded(
  advisories: { module_name: string; patched_versions: string }[],
): Record<string, string> {
  const needed: Record<string, string> = {}
  for (const { module_name, patched_versions } of advisories) {
    if (!module_name || !patched_versions) continue
    const current = needed[module_name]
    if (!current) {
      needed[module_name] = patched_versions
    } else {
      const currentMax = extractMaxVersion(current)
      const newMax = extractMaxVersion(patched_versions)
      if (currentMax && newMax && compareSemver(newMax, currentMax) > 0) {
        needed[module_name] = patched_versions
      }
    }
  }
  return needed
}

minimumReleaseAgeExclude によるフォールバック

minimumReleaseAge でブロックされたパッケージは minimumReleaseAgeExclude に追加してリトライします。フロー図にこのフォールバックが 2 箇所あるのは、依存更新が失敗すると override 試行に到達しないため、片方のリトライだけではもう片方をカバーできないからです。

override が不要になれば minimumReleaseAgeExclude からも自動で削除されます。

try {
  execSync('pnpm install --lockfile-only', { stdio: 'pipe' })
  installable[pkg] = version
} catch {
  excluded.push(pkg)
  updateReleaseAgeExclude(excluded)
  try {
    execSync('pnpm install --lockfile-only', { stdio: 'pipe' })
    installable[pkg] = version
  } catch {
    excluded.pop()
    updateReleaseAgeExclude(excluded)
  }
}

GitHub Actions ワークフロー

毎日実行し、差分があれば PR を自動作成します。

name: Audit & Override Sync

on:
  schedule:
    - cron: '0 0 * * *' # 毎日 UTC 0:00 (JST 9:00)
  workflow_dispatch:

jobs:
  sync-overrides:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # ...node, pnpm のセットアップ

      - name: Sync overrides
        run: pnpm tsx .github/scripts/resolve-audit.ts

      - name: Create PR if changes exist
        run: |
          if git diff --quiet; then
            echo "No changes needed."
            exit 0
          fi
          # ...ブランチ作成、コミット、PR 作成
          # package.json, pnpm-lock.yaml, pnpm-workspace.yaml を含める

依存更新ツールとの棲み分け

Renovate や Dependabot などの依存更新ツールも override のバージョン更新 PR を自動作成しますが、以下のケースには対応できません。

ケース 依存更新ツール この仕組み
override のバージョンを上げる
override を新規追加する ×
依存元の更新で override を不要にする ×
不要な override を削除する ×
minimumReleaseAge に引っかかる 失敗する minimumReleaseAgeExclude で対応

この仕組みは依存更新ツールの補完として、それらが対応できないケースをカバーする位置づけです。

限界と妥協点

minimumReleaseAgeExclude の導入により、minimumReleaseAge で脆弱性修正がブロックされる問題は解消しました。ただし、minimumReleaseAgeExclude に追加されたパッケージは minimumReleaseAge の保護対象外になります。

とはいえ、脆弱性のパッチリリースを出しているメンテナーがそのリリース自体に悪意あるコードを混入させるケースは現実的に考えにくく、本システムでは検証済みのパッケージとして信頼してアップデートしています。override が不要になれば自動で除外対象から消えるので、影響は一時的です。

まとめ

  • 推移的依存の脆弱性対応では pnpm.overrides が便利だが、不要になったタイミングで消す仕組みがないと放置される
  • pnpm audit --jsonpatched_versions を使えば、必要な override の算出を自動化できる
  • minimumReleaseAge でブロックされるパッケージは minimumReleaseAgeExclude で対応し、脆弱性が残る空白期間をなくす
  • 一時的な回避策には「不要になったことを検知する仕組み」をセットで用意するのが大事
PKSHAテックブログ

Discussion