在 GitHub Action 中修復 Dependabot 在 pnpm 上修不了的安全性警告 — GitHub 認證方式決定了解決方案

Dependabot 沒辦法更新 pnpm 的間接相依。這篇記錄如何寫出修補用的 GitHub Action,以及認證方式的選擇為什麼決定了整個設計。

白色卡片上的藍色機器人頭像,白色面罩內有兩隻圓眼,頂端有短天線、兩側有方形耳朵,下方為近黑色的小寫 dependabot 字樣
本頁目錄

引言

Dependabot 對這個部落格的 GitHub repo 回報了四筆高風險的安全性警告,我很納悶它為什麼從來沒有開出帶著安全性修補的 Pull Request。我猜原因是因為那個有問題的套件是間接相依,而 Dependabot 沒辦法把它的版本升上去。

這個猜測是正確的,但背後其實有一個結構性的原因:Dependabot 完全不支援在 pnpm 上更新間接相依。 如果我用的是 npm,這個問題根本不會出現。放著不管的話,這些警告會永遠留著。

我試過幾種做法,包含 Claude routine,但最後採用的是一個GitHub App 機器人,按排程去修補它們。這篇文章涵蓋了這次的追查過程,以及我是怎麼做出最後決定的。

repo 的 Dependabot 警告頁面以 is:closed 篩選後的畫面,顯示 0 筆 Open、6 筆 Closed。四則 fast-uri 安全性警告在前一天以 fixed 關閉,另外 nanoid 與 path-to-regexp 在三週前以 fixed 關閉。每一列都標示 High,偵測來源為 pnpm-lock.yaml

四則 fast-uri 安全性警告在前一天以 fixed 關閉,另外 nanoid 與 path-to-regexp 在三週前以 fixed 關閉。它們全部都是在 pnpm-lock.yaml 中偵測到的。

Dependabot 為什麼從來沒開過 Pull Request

終端機視窗
輸入
gh api repos/oharu121/<repo-name>/automated-security-fixes
輸出
{"enabled":true,"paused":false}

透過這個指令可以確認 Dependabot 安全性功能本身是開啟的。即便如此,Dependabot 從來沒有開出任何安全性 Pull Request 。其實這不是我第一次碰到這種問題。前兩次的間接相依警告,nanoid 和 path-to-regexp,都是手動修補的。

原因出在 pnpm,而不是間接相依本身。dependabot-core#13177 這個 issue 還開著:Dependabot 會直接報錯,說它不支援在 pnpm 上更新間接相依。pnpm#12744 記錄了 pnpm update <dep>@<version> 對間接相依來說是個 no-op,而這正是 Dependabot 的更新程式時所執行的指令。

同一個更新在 npm 抵達,在 pnpm 停住一個 Dependabot 安全性更新分成兩欄。npm 這欄會尋找可接受修補版本的上層套件版本,同時升級上層與下層,並開出 Pull Request。pnpm 這欄執行 pnpm update dep@version,但該指令對間接相依毫無作用,因此警告持續開著,也不會有任何 Pull Request。Dependabot 安全性更新npm尋找可接受修補版本的上層套件版本同時升級上層與下層開出 Pull Request已修補pnpm執行 pnpm updatedep@version對間接相依毫無作用警告持續開著永遠不會來
在 npm 上,Dependabot 會把上層套件升到能容納已修復的下層套件版本。pnpm 則卡在更新程式本身的指令上,什麼都不會發生。

repo 裡早就有修法

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 裡。冒號的左邊是關鍵,兩種寫法表達的意義完全不同:

寫法會匹配什麼存續期間
nanoid@<3.3.18: ^3.3.18只匹配解析結果低於 3.3.18 的情況版本超過 3.3.18 之後就失效
nanoid: ^3.3.18每一個 nanoid永久

這個 key 是篩選器,不是單純的名稱。如果 以版本範圍當 key ,則只有在當前版本低於修補版本的時候才生效,高於修補版本後就會自動退場;而 單純寫套件名稱 會把相依永久鎖死,等到某天依存需要 nanoid 4.x 時,它會被強制拉回 3.x 的版本而壞掉。

第一次嘗試:排程執行的 Claude 雲端 session

雲端 routine 的編輯對話框,名稱是 pnpm transitive advisory patcher。指示內容要求它找出 Dependabot 無法修復的間接相依安全性警告,並開出修補用的 Pull Request,背景說明引用了 dependabot-core#13177,並指示忽略直接相依。它綁定的 repo 是 oharu121/oharu-tech-blog,模型為 Sonnet 5,設定成每天 10:07 JST 執行

第一次嘗試:一個每日執行的雲端 session,把整套稽核流程用自然語言寫給模型照著做。

這個方法可以奏效。第一次執行就開出一個 Pull Request,以正確版本範圍的 key 覆寫、符合 repo 既有風格的註解區塊,以及一段準確轉寫稽核結果的說明文字。整個過程大約花了十五分鐘。

不過有一件事讓我很在意。它每次執行的時候都會印出這行:

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

Cloud session 跑的是 Node 22,而這個 repo 要求 node >=24。也就是說,就算 pnpm check 和 pnpm build 在 Cloud session 都是綠燈,也不代表在實際上線環境能得到一樣的結果。需要再重新跑一次 CI 才能真的驗證。

接著我問了一個改變整個設計方向的問題:這裡真的需要 AI 嗎,還是可以做成完全機械化的流程?

步驟需要人為判斷嗎?
執行 pnpm audit --json不需要
跳過 package.json 裡已經宣告的套件不需要,只是查表
導出 pkg@<X: ^X不需要,版本本來就是 JSON 裡的一個欄位
寫入覆寫、install、check、build不需要
寫 Pull Request 的說明文字不需要,只是把稽核結果轉寫出來
判斷影響執行環境的覆寫是否需要額外的確認需要
診斷一個無法解決的覆寫需要

需要判斷的那兩項很少發生,而這套設計原本就沒打算讓 Agent 直接動手處理任何一項。換句話說,它每天做的都是文書性的工作,為了那極少見的例外待命。而就算例外出現,Agent 自己也無法解決,需要呈報給人類。

工作流程怎麼跑

上面表格裡每一個「不需要」的列都成了 advisory-patch.yml 裡的一個步驟,那兩個「需要」的列則變成了 issue 起票。依序為五個步驟:

  1. 安裝相依,好讓稽核跑在 repo 實際解析出來的那棵樹上。
  2. 偵測並套用。 advisory-patch.ts 讀取 pnpm audit --json,跳過 package.json 裡已宣告的套件,從安全性警告的內容推導出 pkg@<X: ^X,寫進 pnpm-workspace.yaml,然後 install。
  3. 把需要判斷的呈報出來,開 issue 而不是 Pull Request。這就是那兩個「需要」的列:影響執行環境的安全性警告,以及無法解決的覆寫。
  4. 驗證修補。 再稽核一次,確認覆寫確實生效。
  5. 開 Pull Request,說明文字直接轉寫稽核的欄位。

步驟 2 和步驟 4 都呼叫同一個函式 classify()。它讓每個被稽核的套件依序通過四道判斷,並決定修補它、呈報它,或是不動它。

classify() 如何分類一個被稽核的套件帶有安全性警告的套件依序通過四道判斷。直接相依會被略過,因為那是 Dependabot 負責的範圍。已經列名在 overrides 裡的套件會被略過,因為已經試過了。patched 範圍無法解析的安全性警告會變成給人看的 issue。findings 會碰到執行環境的套件也會變成給人看的 issue。剩下的就成為寫入以範圍為 key 的 override 這個計畫。用 --verify 執行時,只會關掉 overrides 這道判斷,其餘三道照常運作。pnpm audit --json帶有安全性警告的套件是直接相依嗎?略過Dependabot 負責overrides 裡已經有了嗎?略過已經試過了patched 範圍讀不懂嗎?Issue需要人來判斷會碰到執行環境嗎?Issue影響執行環境--verify會跳過這道判斷計畫寫入 pkg@<X: ^X
順序是有意義的。直接相依不會走到覆寫那道判斷,而無法讀取的 patched 範圍也不會走到執行環境那道判斷。

當單一套件像 fast-uri 那樣同時帶著四則安全性警告時,覆寫必須一次解決全部四則,所以 classify() 取的是這一組裡最高的修補版本下限,而不是最先讀到的那一個。

會改變這條判斷鏈的只有步驟 4,原因就在覆寫那道判斷。步驟 4 執行的時候,步驟 2 早就把這個套件寫進 pnpm-workspace.yaml 了,於是那道判斷會把步驟 4 正在詢問的那個套件排除掉。不管 pnpm install 實際解析出的是什麼,步驟 4 都會回報「一切正常」。 用 --verify 執行這個腳本只會跳過覆寫那道判斷,其餘三道照常運作。

執行工作流程時憑證為什麼重要

在這裡 GitHub Action 很明顯比 Claude 的 cloud session 來得更好。它用的是 NODE_VERSION: "24"和 CI 一樣,所以它的驗證結果和實際環境跑的結果是一樣的。它幾秒鐘就能跑完。而它的邏輯放在一個 .ts 檔案裡。pnpm check 會對它做型別檢查任何人都能在本機直接執行。

不過困難的地方在於,這個工作流程必須自己開出自己能被 CI 檢查的 Pull Request,而 GitHub Actions 不允許一個工作流程去啟動另一個工作流程:用 GITHUB_TOKEN 去 push 不會觸發任何工作流程被執行。 Pull Request 還是會開起來,沒有發生的是那個負責檢查它的執行。GitHub 這樣設計,是因為一個由 push 觸發的工作流程如果自己也 push,就會因為自己觸發自己而陷入無窮循環。

產生事件的一方需要自己的身分,讀取結果的一方不需要由左至右的流程:Pull Request、CI 執行、判定、合併。弱點修補機器人位於 Pull Request 的上游,必須自己產生這個事件,因此在該處使用 GITHUB_TOKEN 會導致完全不觸發任何檢查。自動合併位於判定的下游,只讀取結果,所以它自己的 GITHUB_TOKEN push 是無害的,因為工作已經完成。Pull RequestCI 執行判定合併弱點修補機器人必須產生事件上游自動合併只讀取判定結果下游在這裡用 GITHUB_TOKEN:完全不會觸發檢查在這裡用 GITHUB_TOKEN:無害,工作已完成
自動合併只是讀取 CI 早就做出的判定,所以就算 push 不會觸發任何檢查也沒關係。修補安全性警告的機器人位於上游,必須自己製造出能觸發 CI 的 push。

Dependabot 擁有一個獨立的應用程式身分而並不是用 GITHUB_TOKEN,所以不受 GitHub 為了防止遞迴所設的限制。這也是為什麼它開的 Pull Request 能讓 CI 檢查跑起來。而我們該遵循的正是這個模式。

最初嘗試的是個人存取 token,但這帶來了幾個問題。PAT 借用的是我自己的身分,所以 author.login 沒辦法將這個自動化和我本人區分開來。自動合併只去比對一個任何人都有可能開出來的分支名稱。

最終採用的解決方法:GitHub App

GitHub App 因為擁有自己的身分解決了這個問題。它的安裝用 token 是每次執行時才發出,任務結束就失效,於是合併的把關又重新變成一個真正的作者身分確認。

GitHub App 的公開頁面,名稱是 Oharu Advisory Patcher,說明寫著它會針對 Dependabot 在 pnpm 上無法修補的間接相依安全性警告開出 Pull Request,並註明撤銷這個 App 會讓自動化停止

這段說明是寫給半年後找到這個頁面的人看的,包括撤銷這個 App 之後會帶來什麼後果。

建立一個 GitHub App 需要四個步驟,沒有一個是 session 能代替你做的:

  1. 註冊 GitHub App。 從 Settings → Developer settings → GitHub Apps,填寫 Contents、Pull requests 和 Issues 的寫入權限。
  2. 生成私鑰。 下載一個 .pem 檔案。
  3. 把這個 GitHub App 安裝到 repo 上。 註冊和安裝是兩件不同的事,一個註冊了但沒安裝的 App 會在第一次 API 呼叫就失敗。
  4. 設定兩個 repo secret:GitHub App 的 Client ID,以及整份 .pem,包含開頭和結尾那兩行。

Oharu Advisory Patcher 的 GitHub App 安裝頁面,顯示帳號 oharu121,旁邊是一個變灰的 Installed 按鈕和設定齒輪

步驟 3。在 repo 成功安裝 GitHub App 之後會變成灰色的 Installed 按鈕。

這兩個 secret 會在同一個步驟裡跟工作流程被使用,而那個步驟會發行後面所有東西都要用的 token:

.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 }}

這段裡有三個地方很容易出錯。第一,是 client-id,不是 app-id,因為 v3 已經把app-id標為棄用。第二,工作流程層級的 permissions 要維持在 contents: read,因為寫入權限屬於 App 的 token;寫在這裡只會把權限發給一個沒有任何步驟會用到的 GITHUB_TOKEN。第三,checkout 必須明確設定成工作流生成的 token。它的預設值是 GITHUB_TOKEN,如果用了預設值則後面的 push 就什麼 CI 檢查都不會觸發,而那正是這整個章節都在避免的事。

上線的成果

這個工作流程於每天 10:17 JST 執行。沒有安全性警告的情況它會 install、稽核、什麼都沒找到,大約一分鐘就會結束。

2026 年 9 月 5 日,它找到了這篇文章開頭的 fast-uri 安全性警告,寫入覆寫,在 Node 24 上驗證,然後開出 Pull Request。自動合併接受了它並做了 squash,全程不需要任何人參與。

標題為 fix(deps): patch fast-uri advisory 的 Pull Request 272,標示為 Merged,由 github-actions bot 從 fix/advisory-dev-fast-uri 分支合併進 main。留言者是 oharu-advisory-patcher 並帶有 Bot 標籤

建立者是 GitHub App 機器人,合併的是自動合併的工作流程。兩個各自獨立的身分。

總結

我們知道了 Dependabot 完全不支援在 pnpm 上更新間接相依。這就是為什麼 Dependabot 從來沒有在我的 repo 上開過任何有關安全性的 Pull Request。

用 GITHUB_TOKEN 推送不會觸發任何工作流程執行。所以要觸發工作流程就需要一個不是 GITHUB_TOKEN 的憑證。

對我來說最大的收穫不是這個修法本身,而是工具的選擇。我們最後落腳在 GitHub App,它最符合這個情境的需求。這是我第一次建立 GitHub App,之後想再深入研究。

參考連結

分享這篇文章