# GitHub Actionsでpnpmの脆弱性アドバイザリをパッチする — Dependabotの穴と、GitHubの認証情報が決めた設計

> Dependabotはpnpmの推移的依存を更新できません。それをパッチするGitHub Actionを作った話と、認証情報の選択が設計全体を決めた理由。

- Source: https://oharu121.com/ja/blog/dependabot-transitive-pnpm-advisories-github-action-cloud-agent/
- Published: 2026-09-04T23:45:21+09:00
- Tags: pnpm, GitHub Actions, セキュリティ, 自動化, Node.js, Claude Code

---
## はじめに

Dependabotがこのブログのリポジトリに対して**重大度「High」のセキュリティアドバイザリを4件**報告したのですが、なぜセキュリティパッチのプルリクエストを出してくれないのだろうと疑問に思いました。脆弱なパッケージが推移的依存で、Dependabotがバージョンを上げられないからではないかと推測しました。

推測は当たっていましたが、理由は構造的なものでした。**Dependabotはpnpmの推移的依存の更新を一切サポートしていません。** npmを使っていれば、この問題自体が起きなかったはずです。放っておけば、アラートは永遠に開いたままになっていました。

Claudeのルーチンを含めていくつかの方法を試しましたが、最後にたどり着いたのは**GitHub Appのボット**にスケジュール実行でパッチを当てさせる形でした。この記事では、調査の経緯と、最終的な判断がどう決まったかを扱います。

*Image: リポジトリのDependabotアラート画面をis:closedで絞り込んだところ。Openが0件、Closedが6件。fast-uriのアドバイザリ4件が前日にfixedとしてクローズされ、さらにnanoidとpath-to-regexpが3週間前にfixedとしてクローズされている。すべての行がHighと表示され、検出元はpnpm-lock.yaml*

*fast-uriのアドバイザリ4件が前日にfixedとしてクローズされ、さらにnanoidとpath-to-regexpが3週間前にfixedとしてクローズされています。そのすべてが`pnpm-lock.yaml`で検知されました。*

## Dependabotがプルリクエストを一度も出さなかった理由

```bash
gh api repos/oharu121/<repo-name>/automated-security-fixes
# {"enabled":true,"paused":false}
```

このコマンドで、セキュリティ機能自体は有効だと確認できます。それにもかかわらず、Dependabotが出したセキュリティ用のプルリクエストは**ゼロ件**でした。実はこの手の問題に遭遇したのは初めてではありません。過去にあった2件の推移的依存のアラート、`nanoid`と`path-to-regexp`は、どちらも手動でパッチを当てていました。

原因はpnpmであって、推移的依存そのものではありません。[dependabot-core#13177](https://github.com/dependabot/dependabot-core/issues/13177)はオープンなままで、Dependabotはpnpmの推移的依存の更新をサポートしていないというエラーを出します。その裏では[pnpm#12744](https://github.com/pnpm/pnpm/issues/12744)が、`pnpm update <dep>@<version>`が推移的依存に対してはno-opであることを記録しており、これはDependabotのアップデーターが実行しているまさにそのコマンドです。

*Figure — TransitiveReach: npmでは、Dependabotは修正済みの子パッケージに届くよう親のバージョンを上げます。pnpmの経路はアップデーター自身のコマンドで止まり、何も起きません。*

> **気を付けて**
>
> **アラートの一覧が静かなことは、安全である証拠にはなりません。** [dependabot-core#10534](https://github.com/dependabot/dependabot-core/issues/10534)には、pnpm v9のロックファイルがアップグレード後に静かにアラートの検出対象から外れ、そのことを知らせる通知が一切なかった事例が記録されています。

## リポジトリにはすでに直し方があった

`pnpm update`は推移的依存に対してno-opなので、これでは直らないことが分かりました。このリポジトリが以前に採用していた直し方は、オーバーライドでした。

```yaml title="pnpm-workspace.yaml"
overrides:
  nanoid@<3.3.18: ^3.3.18
  path-to-regexp@<6.3.0: ^6.3.0
```

オーバーライドは`pnpm-workspace.yaml`の中にあります。**ここで意味を持つのはすべてコロンの左側**で、2つの書き方は正反対にふるまいます。

| 書き方 | 何にマッチするか | 寿命 |
| --- | --- | --- |
| `nanoid@<3.3.18: ^3.3.18` | 3.3.18より下の解決結果だけ | ツリーが3.3.18を超えると無効になる |
| `nanoid: ^3.3.18` | ツリー内のすべての`nanoid`、永久に | 永続 |

キーは名前ではなく*セレクタ*です。**範囲でキー付けした形**は、パッチ済みバージョンより下のものが残っている間だけ効き、あとは自動的に退場します。一方、**単純な名前は依存を永久にピン留めする**ので、正当な依存先がnanoid 4.xを必要とした日に3.xへ引き戻されて壊れます。

> **ちなみに**
>
> **この記事は全体を通してpnpm 11の話です。** オーバーライドを書くファイルはメジャーバージョンによって変わっているので、上のブロックをコピーする前に自分の環境のバージョンを確認してください。11では`package.json`の`pnpm.overrides`は`[WARN]`を1行出して無視され、それでもinstallは「Already up to date」と表示します。

## 最初の試み: Claudeクラウドセッションのスケジュール実行

*Image: クラウドルーチンの編集ダイアログ。名前は「pnpm transitive advisory patcher」。指示には、Dependabotが修正できない推移的依存のアドバイザリを探し、それらにパッチを当てるプルリクエストを開くよう書かれており、背景としてdependabot-core#13177が引用され、直接依存は無視するよう指示されている。対象リポジトリはoharu121/oharu-tech-blog、モデルはSonnet 5で、毎日10:07 JSTに実行される設定になっている*

*設定された状態の最初の試み。日次で動くクラウドセッションに、調査手順のすべてを散文で書き下したもの。*

それは動きました。最初の実行で、範囲をキーとした正しいオーバーライドと、既存のファイルのスタイルに合わせたコメントブロック、そして調査結果を正確に書き写した本文を持つプルリクエストが出ました。かかった時間はおよそ15分でした。

ただ、ひとつ気になることがありました。インストールのたびに、こう表示するのです。

```text
[WARN] Unsupported engine: wanted: {"node":">=24"} (current: {"node":"v22.22.2","pnpm":"11.3.0"})
```

クラウドの環境はNode 22で動いており、リポジトリが要求しているのは`>=24`です。つまり、そこでの`pnpm check`と`pnpm build`が緑になったとしても、それは実際に出荷しているランタイムとは違うものの上での結果であり、CIが同じコマンドを再実行するまでは、その検証自体を信用できませんでした。

そして、私は設計を変えることになる問いを投げました。**本当にAIが必要なのか、それとも完全に機械的にできるのか?**

| ステップ | 人間の判断が必要か |
| --- | --- |
| `pnpm audit --json`を実行する | 不要 |
| `package.json`に宣言済みのパッケージをスキップする | 不要、単なる参照 |
| アドバイザリから`pkg@<X: ^X`を導く | 不要、バージョンはJSONの1フィールド |
| オーバーライドを書き、install、check、buildする | 不要 |
| プルリクエストの本文を書く | 不要、audit結果をそのまま書き写すだけ |
| ランタイムのオーバーライドに経路検証が必要かどうかを判断する | **必要** |
| 解決しないオーバーライドを診断する | **必要** |

判断が必要な2つは稀にしか起きず、この設計はそもそもエージェントがどちらにも直接手を出すことを許していませんでした。つまり、めったに来ない例外に備えて待機するためだけに、エージェントは毎日事務的な作業をこなしていたことになります。しかも、その例外が来たとしても結局エスカレーションしていたはずです。

## ワークフローの動き

上の表の「不要」の行はすべて[`advisory-patch.yml`](https://github.com/oharu121/oharu-tech-blog/blob/main/.github/workflows/advisory-patch.yml)のステップになり、「必要」の2行はエスカレーションになりました。順番に5ステップです。

1. **依存をインストールする。** リポジトリが実際に解決するツリーに対してauditを走らせるためです。
2. **検出して適用する。** `advisory-patch.ts`が`pnpm audit --json`を読み、`package.json`に宣言済みのものをスキップし、アドバイザリから`pkg@<X: ^X`を導いて`pnpm-workspace.yaml`に書き込み、installします。
3. **判断が必要なものをエスカレーションする。** プルリクエストではなくIssueとして起票します。これが「必要」の2行、つまりランタイムスコープのアドバイザリと、解決しないオーバーライドです。
4. **パッチを検証する。** 再度auditを実行し、オーバーライドが効いたことを確認します。
5. **プルリクエストを開く。** 本文はauditのフィールドをそのまま書き写したものです。

ステップ2とステップ4はどちらも同じ`classify()`という関数を呼んでおり、機械的な判断はすべてそこで起きています。この関数は監査対象の各パッケージを4つの判定に順番に通し、パッチする・エスカレーションする・何もしない、の3つのいずれかに振り分けます。

*Figure — ClassifyFunnel: 順番が意味を持ちます。直接依存はオーバーライドの判定まで届かず、読み取れないpatched範囲はランタイムスコープの判定まで届きません。*

`fast-uri`のように1つのパッケージが4件のアドバイザリを抱えている場合、オーバーライドは4件すべてを一度に解消しなければならないので、`classify()`はグループ内で最初に読んだものではなく**最も高い**パッチ済みバージョンを採用します。

チェインを変える呼び出し元はステップ4だけで、その理由はオーバーライドの判定にあります。ステップ4が走る時点で、ステップ2はすでにこのパッケージを`pnpm-workspace.yaml`に書き込んでいます。そのため、この判定はステップ4がまさに問い合わせているパッケージを取り除いてしまい、**`pnpm install`が実際に何を解決したかに関わらず、ステップ4は「異常なし」と報告します。** スクリプトを`--verify`で実行するとオーバーライドの判定だけが無効になり、残る3つはそのまま働きます。

## ワークフローを動かす認証情報が重要になる理由

ここではGitHub Actionのほうが明らかにClaudeのクラウドセッションより優れています。CIと同じ`NODE_VERSION: "24"`で動くので、その検証結果は言葉どおりの意味を持ちます。数秒で終わります。そしてロジックは`pnpm check`が型チェックする`.ts`ファイルの中にあり、誰でもローカルで実行できます。

ただし難しいのは、CIがチェックする対象のプルリクエストをこのワークフロー自身が*開かなければならない*点で、GitHub Actionsはあるワークフローが別のワークフローを起動することを許していません。**`GITHUB_TOKEN`によるpushはワークフロー実行を一切トリガーしません。** プルリクエスト自体は開きます。起きないのは、それをチェックする実行のほうです。GitHubがこうしているのは、pushで起動するワークフローが自分でもpushをすると、自分自身をトリガーしてループしてしまうからです。

> **ちなみに**
>
> **プルリクエストは惜しいところで違う挙動をします。** `GITHUB_TOKEN`で*作成された*プルリクエストはチェックをキューに入れますが、「Approve workflows to run」ボタンの後ろで承認待ちの状態に留まります。無人の自動化にとっては同じ行き止まりですが、pushの場合とは見た目がまったく違うので知っておく価値があります。

*Figure — ProducerConsumer: 自動マージはCIがすでに出した判定を受け取るだけなので、自分自身の無害なpushは何のコストも生みません。アドバイザリ修正ボットは上流にいて、CIが反応するイベントそのものを作らなければなりません。*

Dependabotは`GITHUB_TOKEN`ではなく独立したアプリのIDを持つため、この再帰防止のガードから除外されています。だからこそDependabotのプルリクエストにはチェックが走ります。ここで踏襲すべきなのは、まさにこのパターンです。

最初に試したのは個人アクセストークンでしたが、それはいくつか問題を持ち込みました。PATは私自身のIDを借りるため、`author.login`では自動化と私自身を区別できず、自動マージのゲートは誰でも入力できるブランチ名の一致に頼るしかありませんでした。

## 最終的に落ち着いた形: GitHub App

GitHub Appはそれ自身のIDを持つことでこれを解決します。インストール用のトークンは実行ごとに発行され、ジョブが終わると失効するので、マージのゲートは再び本来の権限確認になります。

*Image: GitHub Appの公開ページ。名前はOharu Advisory Patcher。pnpmでDependabotがパッチできない推移的依存のアドバイザリに対してプルリクエストを開く、という説明と、このAppを取り消すと自動化が止まるという注記が書かれている*

*この説明は半年後にこのページを見つける人に向けて書かれています。取り消したときに何が止まるのかという一文も含めて。*

セットアップは4ステップで、どれもセッションが代わりにやってくれるものではありません。

1. **Appを登録する。** Settings → Developer settings → GitHub Appsから、Contents・Pull requests・Issuesの書き込み権限を付与します。
2. **秘密鍵を生成する。** `.pem`ファイルがダウンロードされ、手に入るのは1部だけです。
3. **リポジトリにインストールする。** 登録とインストールは別の行為で、登録済みでもインストールされていないAppは最初のAPI呼び出しで失敗します。
4. **リポジトリシークレットを2つ追加する。** AppのClient IDと、ヘッダー行・フッター行を含む`.pem`全体です。

*Image: Oharu Advisory PatcherのGitHub Appインストールページ。アカウントoharu121に対して、グレーアウトしたInstalledボタンと設定用の歯車が表示されている*

*ステップ3。グレーアウトしたInstalledボタンが、存在するだけのAppと、このリポジトリに対して動けるAppとの違いです。*

この2つのシークレットは、以降のすべてが使うトークンを発行する1つのステップでワークフローと出会います。

```yaml title=".github/workflows/advisory-patch.yml"
permissions:
  contents: read

jobs:
  patch:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/create-github-app-token@v3.2.0
        id: app-token
        with:
          client-id: ${{ secrets.ADVISORY_APP_CLIENT_ID }}
          private-key: ${{ secrets.ADVISORY_APP_PRIVATE_KEY }}
          permission-contents: write
          permission-pull-requests: write
          permission-issues: write

      - uses: actions/checkout@v7
        with:
          token: ${{ steps.app-token.outputs.token }}
```

このスニペットには間違えやすい箇所が3つあります。まず`app-id`ではなく **`client-id`** です。v3では古い入力が非推奨とされています。次に、ワークフローレベルの`permissions`は`contents: read`のままにします。書き込みスコープはAppのトークン側に属するもので、ここに書けばどのステップも使わない`GITHUB_TOKEN`に権限を与えてしまいます。そして **`checkout`にはトークンを明示的に渡さなければなりません。** デフォルトは`GITHUB_TOKEN`だからです。デフォルトのままにすると、後続のpushが何もトリガーせず、このセクション全体が避けようとしている失敗そのものになります。

> **ちなみに**
>
> **ログイン名は`app/<slug>`で、`<slug>[bot]`ではありません。** `gh pr list --json author`はアプリボットのログイン名を正規化するので、Dependabotの生の値は`app/dependabot`です。`[bot]`という表記は、Webインターフェースと`git log`が見せている姿です。

## 出荷したもの

このワークフローは毎日10:17 JSTに動きます。何もない日は、install、audit、何も見つからず、1分ほどで終了します。

2026年9月5日、この記事の冒頭と同じ`fast-uri`のアドバイザリを検出し、オーバーライドを書き、Node 24で検証し、プルリクエストを開きました。**自動マージがそれを受け入れてsquashし、誰もリポジトリに触れていません。**

*Image: 「fix(deps): patch fast-uri advisory」というタイトルのプルリクエスト272。Mergedと表示され、github-actions botがブランチfix/advisory-dev-fast-uriからmainへマージしている。コメントの投稿者はoharu-advisory-patcherで、Botのラベルが付いている*

*作成者はAppで、マージしたのは自動マージのワークフローです。2つの別々のIDであり、認証情報の判断はこのためのものでした。*

## まとめ

Dependabotはpnpmの推移的依存の更新を一切サポートしていない、ということが分かりました。だからDependabotは私のリポジトリでセキュリティ用のプルリクエストを一度も出さなかったのです。

`GITHUB_TOKEN`によるpushはワークフロー実行をトリガーしません。だからワークフローを起動するには、`GITHUB_TOKEN`とは別の認証情報が必要になります。

私にとって一番の学びは、修正そのものではなく道具の選び方でした。最終的に落ち着いたのはGitHub Appで、今回のシナリオにはこれが一番よく合っています。GitHub Appを作ったのは初めてで、今後もっと掘ってみたいと思っています。

## 参考リンク

- [dependabot-core#13177、pnpmの推移的依存サポートを追跡しているオープンなIssue](https://github.com/dependabot/dependabot-core/issues/13177)
- [pnpm#12744、`pnpm up dep@version`が推移的依存に効かないことが確認されているIssue](https://github.com/pnpm/pnpm/issues/12744)
- [dependabot-core#10534、pnpm v9のロックファイルがDependabotのアラート検出を静かに終わらせる問題](https://github.com/dependabot/dependabot-core/issues/10534)
- [pnpmの`overrides`ドキュメント。範囲をキーとするセレクタ形式も含む](https://pnpm.io/settings#overrides)
- [actions/create-github-app-token、`app-id`が`client-id`に置き換えられ非推奨になっている](https://github.com/actions/create-github-app-token)
