工程師與貓
ESC
Content
    ↑↓ navigate open esc close
    Published on

    從 Dependabot 換到 Renovate:為什麼我不把漏洞修完

    Authors
    • avatar
      Name
      Alex Yu

    前陣子每隔幾天就收到一批依賴漏洞的 alert。光是某一週就跳出 15 筆,再往前一次清掉的是 70 筆。

    處理方式一直是全手動:逐筆判斷 → 在 lockfile 寫版本鎖定 → 本機跑一輪 build / test / lint / audit → 開 PR → 上線。每來一波就重跑一次這個流程。

    修當下不難,難的是它不會停。資安生態每天都有新漏洞揭露,這條手動流程等於一條永遠清不完的待辦。所以我提了一個案子,想把它自動化掉——順手把 Dependabot 換成 Renovate。

    這篇記錄那個決策:為什麼換、怎麼設、以及一個我刻意做的選擇——131 筆 open alert,我沒打算全修

    (如果「依賴漏洞」「傳遞依賴」「CVE / GHSA / OSV」這些詞對你還很陌生,先讀系列的 Part 1 補背景;這篇預設你大致知道它們是什麼,直接進決策。)

    為什麼修依賴漏洞不是「點一下 merge」

    我們的漏洞絕大多數出在傳遞依賴(transitive dependency,Part 1 解釋過)——被上層套件間接帶進來、我們沒直接裝、也不能直接升的那些。

    所以修一筆 alert 真正能用的手段,大概是這五種:

    1. 漏洞套件剛好是我們直接裝的 → 直接升。最乾淨,但對傳遞依賴無效。
    2. 升它的上層父套件,連帶換掉那個傳遞依賴。最正規,但常常根本沒有這種版本,或父套件自己要跨大版。
    3. override 強制把那個傳遞依賴鎖到安全版。最快、影響面最小。
    4. 那套件其實沒在用 → 直接移除。我們真的這樣拔掉過一個 pm2,連帶清掉它整棵子樹的漏洞。
    5. 沒有修復版 → 評估後接受並記錄,不硬修(後面的 elliptic 就是這種)。

    實務上 ③ 是主力。我們用 pnpm 的 pnpm-workspace.yaml overrides,只針對有漏洞的那個版本精準下手。

    這正好戳到工具選擇的核心:Dependabot 不會維護 override,它只擅長 ①。它自動開的 PR 常常不能直接合(硬升跨大版),最後工程師還是要手工重做一次。

    順帶一提:這不是 pnpm 限定

    講到 override 可能會以為「要用 pnpm 才能這樣玩」。不是。

    「鎖死某個傳遞依賴的版本」這件事每個套件管理器都有,只是名字不同:npm 叫 overrides、yarn 叫 resolutions、pnpm 叫 pnpm.overrides

    同一套 security-only 設定,我也套到團隊裡兩個 yarn classic 的 repo——那邊鎖版用的是 package.jsonresolutions,Renovate 的 npm manager 原生就認得、會自動接手維護,不用額外設定。

    再往外,Renovate 的 manager 還涵蓋 Composer(PHP)、Go modules、Gradle 等等。所以這篇講的是「我們用 pnpm 的故事」,但換成別的套件管理器、甚至別的語言,邏輯一樣成立。

    Dependabot 卡在哪

    評估的時候列了三個結構性限制,剛好都打在痛點上:

    1. 不維護我們的修法。 多數修補是 pnpm override,Dependabot 不理解這套機制。
    2. 安全更新 PR 永遠只開向 main 這是 GitHub 的硬限制(target-branch 對 security updates 無效),配不上我們 main + testing 的分支流程。
    3. 沒有組織層級的共用設定。 每個 repo 各自維護一份,無法一處設定、全組織套用。

    第二點是真的查了官方 issue 才死心的——社群反映很久,到現在還是只打預設分支。

    Renovate 怎麼設:只修漏洞、只開 PR

    Renovate 對症的地方是它能直接維護 lockfile 的 override,還能 baseBranchPatterns 對多個環境分支各開一份 PR。

    但工具一旦自動化,最怕的是失控——首次掃描一次開幾十張 PR,或例行版本更新的噪音淹掉真正重要的安全修補。所以第一階段我把它收得很緊,只做漏洞修補:

    // renovate.jsonc — 階段一(security-only 試行)
    {
      "$schema": "https://docs.renovatebot.com/renovate-schema.json",
      "extends": ["config:recommended"],
     
      // 對 main + testing「各開一份 PR」,符合多環境 git flow(Dependabot 做不到)
      "baseBranchPatterns": ["main", "testing"],
     
      // 先把例行版本升級全部關掉,避免 keep-latest 噪音
      "packageRules": [{ "matchPackageNames": ["*"], "enabled": false }],
     
      // 漏洞修補單獨開啟(此區塊不受上面那條影響——官方 security-only 寫法)
      "vulnerabilityAlerts": {
        "enabled": true,
        "labels": ["dependencies", "security"]
      },
      "osvVulnerabilityAlerts": true, // 多查 OSV,不只 GitHub advisory
     
      "dependencyDashboard": true, // 開一個 issue 當待修總覽看板
      "prHourlyLimit": 0,          // 不對安全修補限速
      "timezone": "Asia/Taipei"
    }

    沒設 automerge,預設就是只開 PR、人工合併。

    後來把這套搬到另外兩個 repo 時,我改用官方 preset:

    {
      "extends": ["security:only-security-updates"]
    }

    這一行內部就等於上面那四項手寫設定的組合。差別是 preset 由官方維護,未來機制有變會自動跟進,比手寫那幾行不容易過期。手寫版我留在最早試行的 repo 當對照,新接的一律用 preset。

    (review 時有人建議改用根層級 "enabled": false 更乾脆,這個不能採——根層級關閉會把整個 repo 視為 disabled,連帶有把漏洞修補也關掉的風險。停用例行更新要用 packageRules,不是根層級。)

    我決定不全修

    掃完一輪,組織層級有 131 筆 open alert。

    看到這個數字第一反應通常是「那要全修嗎」。我的答案是不用,而且這是這次最重要的一個決定。

    幾個事實先攤開:

    • 這 131 筆裡,約 57% 是 development 依賴——build / test 工具,根本不進 production。
    • 數量最多的那 60 筆來自一個內部後台,對外完全不曝險。
    • CVE 存在不等於我們被打得到。漏洞的那段函式常常根本不在我們的呼叫路徑上。

    而且升級本身有風險。一次升版可能 break 功能,改動的風險有時候比漏洞本身還高。

    所以策略是偵測全部、修補擇要:工具負責「看見」跟「排序」,決定權留在人手上。判斷一筆要不要現在修的那套判準(runtime / 可達性 / 對外曝險 / 有無修復版),Part 1 已經講過——套用到這批的結論就是 alert 數字大 不等於 風險大,真正該先處理的是公開站上、會吃使用者輸入的那少數 runtime 漏洞。

    這也是為什麼設定成只開 PR、人工合併——自動化負責讓「選定的修補」變便宜,不負責替我按下 merge。

    (要託管還是自架、收費怎麼算,Part 1 講過——對我們來說那不是錢的問題,是「要不要讓第三方雲端讀私有碼」的取捨。)

    怎麼知道一個傳遞依賴「在不在上線路徑上」

    triage 裡最常用、也最容易喊口號喊過去的一條,是「它是 runtime 還是 dev-only」。實際怎麼判?用 pnpm why(yarn 是 yarn why)把它的引入路徑攤開。

    舉兩個本來分不清、攤開就清楚的例子:

    pnpm why @opentelemetry/core
    # @sentry/nextjs → @sentry/node → @opentelemetry/core
    # ← 走 Sentry 進到 production runtime,這條要認真看
     
    pnpm why form-data
    # jest-environment-jsdom → jsdom → form-data
    # ← 只在跑測試時用到,不進上線產物,可以緩

    同樣一筆 high 嚴重度的 alert,一個在 production 路徑上、一個只在 test,處理的急迫性差很多。不 why 一下,全部都會長得一樣急。

    真正的風險,未必是「使用者被 XSS」

    擴散到別的 repo 時,我發現 triage 的判準得跟著 repo 的角色換。

    對外的網站,最怕的是使用者端的問題——XSS、會被使用者輸入觸發的漏洞。

    但一個 E2E 測試 repo、或任何只在 CI 跑、不對外服務的專案,風險模型完全不同。它最怕的不是 XSS,是供應鏈:某個套件在 install 或測試執行期,把 CI 環境裡的金鑰、token 偷走。

    所以遇到 malware、惡意 install-script 這類 advisory 要往前排——它跟單純的 ReDoS、prototype pollution 不是同一個等級的東西,後者要被呼叫到才會出事,前者裝下去就跑。

    判準會因 repo 而異、又得每次重想,很煩。所以我把「這張 Renovate PR 該合還是緩、要看哪幾件事」寫成一份可重複的 SOP(一個內部 skill),不同 repo 的判準直接寫進去。下次再來一批 PR,照著走就好,不用每次從頭判斷。

    它修不到的那些坑

    上線後 Renovate 確實自動修掉了一批 undici、dompurify、ws 之類的傳遞依賴。但有一次 Dependabot 還掛著 5 筆 alert,Renovate 卻一張 PR 都沒開。

    調 log 才看懂,不是漏洞不適用,是它的結構性限制:

    # 有修復版、但 Renovate 開 0 PR 的那幾筆
    Returning 0 branch(es)

    這 5 筆是全新的傳遞依賴——不在 package.json,也沒有既有 override。Renovate 在 update-lockfile 模式下查不到可升的範圍,於是 0 flattened updates,直接不開 PR。這是上游已知的限制。

    換句話說,Renovate 會幫你維護已經存在的 override,但不會幫一個全新的傳遞依賴建第一個 override。那一步得手動:

    # pnpm-workspace.yaml,手動補上第一個 override
    overrides:
      '@opentelemetry/core': '2.8.0' # production runtime,同 major bump
      form-data: '4.0.6'
      '@babel/core': '7.29.6'
      'js-yaml@4': '4.2.0' # scoped:只釘 v4,不動同時存在的 v3.14.2

    補完這一次,神奇的是——之後這幾個套件就被 Renovate 接手了,下次有新漏洞它會自己 bump。手動那一下是 bootstrap,不是長期負擔。

    js-yaml 那條還藏了一個會嚇人的細節。tree 裡同時有 v3.14.2 跟 v4.1.1,有漏洞的是 v4,我用 js-yaml@4 範圍鎖只把 v4 拉到安全版,v3 不動——它是某個覆蓋率工具的依賴,而且 v3.14.2 本來就是 advisory 認定的安全版。

    但修完 pnpm audit 還是會報 js-yaml。一查,是它用很粗的範圍(<= 4.1.1)去比對 v3.14.2,誤判成有問題。audit 的數字不能照單全收,要對到 per-major 的修復版才算數。

    那 5 筆裡還有一個 elliptic,log 寫得很直白:

    Vulnerability alert has no firstPatchedVersion - skipping

    官方對所有受影響版本都沒出修復版,Renovate 無從升起。它只走 dev / build(透過 storybook 的 webpack polyfill),不進 production,於是評估後 dismiss 並記錄理由。沒有修復版的東西硬修不了,能做的是判斷風險、寫下來。

    同一個工具,每一端配得不一樣

    我從前端發起這件事,後端(PHP)跟 iOS 後來也各自導入了。但配法完全不同,而這個差異本身就說明了一件事。

    Renovate 的價值分兩種:版本更新幾乎所有語言都通用;漏洞修補則取決於那個生態系的 advisory 情報夠不夠豐富(GHSA / OSV 收不收)。

    前端(JS)、後端(PHP / Go)、Android(Kotlin)的漏洞情報都很豐富,所以都走 security-only,跟我前端這套對齊。

    iOS 不一樣。Swift 原生生態本身公開的 advisory 就少,「修漏洞」沒什麼可修,但「保持版本更新」仍有價值。所以 iOS 那邊反過來開了 config:recommended(做版本更新),還得寫 custom manager 去 parse SPM 在 project.pbxproj 裡的版本宣告,並把同套組的 SDK 綁成 group。

    iOS 還踩到一個前端不會遇到的坑。某個分析用的 SDK 出了新版,把最低 iOS 版本要求往上拉,但它的姐妹 media SDK 還沒跟上,兩者 platform 對不齊,SPM graph 直接解析失敗、build 掛掉。Renovate 不知道這層關係,就一直重開那張注定失敗的 PR。最後只能在設定裡把版本鎖住,擋下它的反覆嘗試:

    {
      "matchPackageNames": ["<analytics-sdk>", "<analytics-media-sdk>"],
      "groupName": "analytics",
      "allowedVersions": "<=9.2.1" // 鎖住,等 media SDK 對齊後再解
    }

    同一個工具,在不同生態系要餵不同的設定它才不會幫倒忙。這點在 onboarding 別的團隊時要先講清楚,不然對方會以為「裝上去就會自動修」。

    如果噪音還是太多:下一步是 reachability

    security-only + 人工 triage 已經把噪音壓下來很多。但如果哪天 alert 還是多到看不完,下一步是上 reachability-aware 的 SCA 工具(像 Snyk、Endor Labs、Semgrep 這類)。

    它們的賣點是:不只告訴你「有這個 CVE」,還會分析那段有漏洞的程式碼你到底碰不碰得到,把不可達的自動過濾掉,讓清單只剩真正可能被打到的。代價是商業授權的費用。

    我們現在的量還沒到那個程度,所以維持「工具偵測 + 人判斷」就夠。但知道有這條路,噪音真的失控時才不會只能硬看。

    重點整理

    • 多數漏洞在傳遞依賴,主力修法是 override;Dependabot 不維護 override、安全 PR 又只能打 main,這是換 Renovate 的根本原因。override 不是 pnpm 限定,yarn 的 resolutions、npm 的 overrides 一樣適用。
    • security-only 的精神:例行更新全關、漏洞修補單開、只開 PR 人工合併;新 repo 直接套 security:only-security-updates preset。
    • 偵測要全、修補要挑。用 pnpm why 判斷 runtime / dev,再看漏洞「是哪一種」——會在安裝期跑的供應鏈惡意套件,比要被呼叫才出事的 ReDoS 急。
    • Renovate 會維護已存在的 override,但不會替全新的傳遞依賴建第一個——手動 bootstrap 一次,之後它接手。pnpm audit 的粗範圍誤報也別照單全收。
    • 跨生態系價值不同:advisory 豐富的語言走 security-only,iOS 這種改做版本更新。噪音真的太多,再考慮 reachability-aware SCA。

    最後一個感想:自動化省掉的是「每波漏洞手動跑一輪」的重複勞動,但它沒有、也不該替你決定哪些漏洞值得現在修。那個判斷還是人的事,工具只是讓判斷變便宜。

    參考資料