GitHub Actionsでpnpmの脆弱性アドバイザリをパッチする — Dependabotの穴と、GitHubの認証情報が決めた設計
Dependabotはpnpmの推移的依存を更新できません。それをパッチするGitHub Actionを作った話と、認証情報の選択が設計全体を決めた理由。

目次
はじめに
Dependabotがこのブログのリポジトリに対して重大度「High」のセキュリティアドバイザリを4件報告したのですが、なぜセキュリティパッチのプルリクエストを出してくれないのだろうと疑問に思いました。脆弱なパッケージが推移的依存で、Dependabotがバージョンを上げられないからではないかと推測しました。
推測は当たっていましたが、理由は構造的なものでした。Dependabotはpnpmの推移的依存の更新を一切サポートしていません。 npmを使っていれば、この問題自体が起きなかったはずです。放っておけば、アラートは永遠に開いたままになっていました。
Claudeのルーチンを含めていくつかの方法を試しましたが、最後にたどり着いたのはGitHub Appのボットにスケジュール実行でパッチを当てさせる形でした。この記事では、調査の経緯と、最終的な判断がどう決まったかを扱います。

pnpm-lock.yamlで検知されました。Dependabotがプルリクエストを一度も出さなかった理由
入力gh api repos/oharu121/<repo-name>/automated-security-fixes出力{"enabled":true,"paused":false}このコマンドで、セキュリティ機能自体は有効だと確認できます。それにもかかわらず、Dependabotが出したセキュリティ用のプルリクエストはゼロ件でした。実はこの手の問題に遭遇したのは初めてではありません。過去にあった2件の推移的依存のアラート、nanoidとpath-to-regexpは、どちらも手動でパッチを当てていました。
原因はpnpmであって、推移的依存そのものではありません。dependabot-core#13177はオープンなままで、Dependabotはpnpmの推移的依存の更新をサポートしていないというエラーを出します。その裏ではpnpm#12744が、pnpm update <dep>@<version>が推移的依存に対してはno-opであることを記録しており、これはDependabotのアップデーターが実行しているまさにそのコマンドです。
リポジトリにはすでに直し方があった
pnpm updateは推移的依存に対してno-opなので、これでは直らないことが分かりました。このリポジトリが以前に採用していた直し方は、オーバーライドでした。
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へ引き戻されて壊れます。
最初の試み: Claudeクラウドセッションのスケジュール実行

それは動きました。最初の実行で、範囲をキーとした正しいオーバーライドと、既存のファイルのスタイルに合わせたコメントブロック、そして調査結果を正確に書き写した本文を持つプルリクエストが出ました。かかった時間はおよそ15分でした。
ただ、ひとつ気になることがありました。インストールのたびに、こう表示するのです。
[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のステップになり、「必要」の2行はエスカレーションになりました。順番に5ステップです。
- 依存をインストールする。 リポジトリが実際に解決するツリーに対してauditを走らせるためです。
- 検出して適用する。
advisory-patch.tsがpnpm audit --jsonを読み、package.jsonに宣言済みのものをスキップし、アドバイザリからpkg@<X: ^Xを導いてpnpm-workspace.yamlに書き込み、installします。 - 判断が必要なものをエスカレーションする。 プルリクエストではなくIssueとして起票します。これが「必要」の2行、つまりランタイムスコープのアドバイザリと、解決しないオーバーライドです。
- パッチを検証する。 再度auditを実行し、オーバーライドが効いたことを確認します。
- プルリクエストを開く。 本文はauditのフィールドをそのまま書き写したものです。
ステップ2とステップ4はどちらも同じclassify()という関数を呼んでおり、機械的な判断はすべてそこで起きています。この関数は監査対象の各パッケージを4つの判定に順番に通し、パッチする・エスカレーションする・何もしない、の3つのいずれかに振り分けます。
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をすると、自分自身をトリガーしてループしてしまうからです。
DependabotはGITHUB_TOKENではなく独立したアプリのIDを持つため、この再帰防止のガードから除外されています。だからこそDependabotのプルリクエストにはチェックが走ります。ここで踏襲すべきなのは、まさにこのパターンです。
最初に試したのは個人アクセストークンでしたが、それはいくつか問題を持ち込みました。PATは私自身のIDを借りるため、author.loginでは自動化と私自身を区別できず、自動マージのゲートは誰でも入力できるブランチ名の一致に頼るしかありませんでした。
最終的に落ち着いた形: GitHub App
GitHub Appはそれ自身のIDを持つことでこれを解決します。インストール用のトークンは実行ごとに発行され、ジョブが終わると失効するので、マージのゲートは再び本来の権限確認になります。

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

この2つのシークレットは、以降のすべてが使うトークンを発行する1つのステップでワークフローと出会います。
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が何もトリガーせず、このセクション全体が避けようとしている失敗そのものになります。
出荷したもの
このワークフローは毎日10:17 JSTに動きます。何もない日は、install、audit、何も見つからず、1分ほどで終了します。
2026年9月5日、この記事の冒頭と同じfast-uriのアドバイザリを検出し、オーバーライドを書き、Node 24で検証し、プルリクエストを開きました。自動マージがそれを受け入れてsquashし、誰もリポジトリに触れていません。

まとめ
Dependabotはpnpmの推移的依存の更新を一切サポートしていない、ということが分かりました。だからDependabotは私のリポジトリでセキュリティ用のプルリクエストを一度も出さなかったのです。
GITHUB_TOKENによるpushはワークフロー実行をトリガーしません。だからワークフローを起動するには、GITHUB_TOKENとは別の認証情報が必要になります。
私にとって一番の学びは、修正そのものではなく道具の選び方でした。最終的に落ち着いたのはGitHub Appで、今回のシナリオにはこれが一番よく合っています。GitHub Appを作ったのは初めてで、今後もっと掘ってみたいと思っています。




