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

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

白いカードに青いロボットの頭部と、その下に小文字のdependabotロゴタイプ。頭部は白いバイザーに丸い目が二つ、短いアンテナと四角い耳を持つ
目次

はじめに

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

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

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

リポジトリの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がプルリクエストを一度も出さなかった理由

ターミナルウィンドウ
入力
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のアップデーターが実行しているまさにそのコマンドです。

同じ更新が npm では届き、pnpm では止まる1 つの Dependabot セキュリティ更新が 2 つの列に分かれます。npm の列では修正版を許容する親のバージョンを探し、親と子をまとめて更新してプルリクエストを作成します。pnpm の列では pnpm update dep@version を実行しますが、推移的依存に対しては何も起きないため、アラートは開いたままでプルリクエストは作成されません。Dependabot のセキュリティ更新npm修正版を許容する親のバージョンを探す親と子をまとめて更新プルリクエストが作成される修正済みpnpmpnpm updatedep@version を実行推移的依存には無効アラートは開いたまま届かない
npmでは、Dependabotは修正済みの子パッケージに届くよう親のバージョンを上げます。pnpmの経路はアップデーター自身のコマンドで止まり、何も起きません。

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

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

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.183.3.18より下の解決結果だけツリーが3.3.18を超えると無効になる
nanoid: ^3.3.18ツリー内のすべてのnanoid、永久に永続

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

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

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

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

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

  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つのいずれかに振り分けます。

classify() が監査対象のパッケージを振り分ける流れアドバイザリを持つパッケージが、4 つの判定を順番に通ります。直接依存は Dependabot の担当としてスキップされます。override に既に名前があるパッケージは、既に試したものとしてスキップされます。patched の範囲を解釈できないアドバイザリは、人間向けの Issue になります。ランタイムコードに届くパッケージも、人間向けの Issue になります。残ったものが、範囲でキー付けした override を書くという計画になります。--verify を付けて実行すると override の判定だけが無効になり、残る 3 つの判定はそのまま働きます。pnpm audit --jsonアドバイザリを持つパッケージ直接依存か?スキップDependabot の担当既に override があるか?スキップ既に試しているpatched 範囲が読めないか?Issue人間の判断が必要ランタイムに届くか?Issueランタイムスコープ--verifyはこの判定を飛ばす計画pkg@<X: ^X を書く
順番が意味を持ちます。直接依存はオーバーライドの判定まで届かず、読み取れない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をすると、自分自身をトリガーしてループしてしまうからです。

作る側には固有の ID が要り、受け取る側には要らない左から右へ、プルリクエスト、CI 実行、判定、マージと並ぶ流れです。アドバイザリ修正ボットはプルリクエストより上流にあり、そのイベント自体を作る必要があります。ここで GITHUB_TOKEN を使うとチェックが一切走りません。自動マージは判定より下流にあり、結果を受け取るだけなので、GITHUB_TOKEN による push は無害です。プルリクエストCI 実行判定マージアドバイザリ修正ボットイベントを作る側上流自動マージ判定を受け取る側下流ここで GITHUB_TOKEN:チェックが一切走らないここで GITHUB_TOKEN:無害、作業は完了済み
自動マージはCIがすでに出した判定を受け取るだけなので、自分自身の無害なpushは何のコストも生みません。アドバイザリ修正ボットは上流にいて、CIが反応するイベントそのものを作らなければなりません。

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

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

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

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

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全体です。

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

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

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

.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が何もトリガーせず、このセクション全体が避けようとしている失敗そのものになります。

出荷したもの

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

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

「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を作ったのは初めてで、今後もっと掘ってみたいと思っています。

参考リンク

この記事をシェア