症狀

幾個 infra repo 一起接上 gitleaks 守門,裝完全綠。但「全綠」和「祕密沒進版控」是兩件事。

實際掃出來的結果是:基線掃描只抓到一筆真祕密,是一把帶標準 PEM 標頭的私鑰。而那些真正在用的值——監控面板的管理員密碼、時序資料庫的 token、教材型專案裡硬寫的範例密碼——一筆都沒被抓到。反過來,抓到的 34 筆裡有 32 筆是誤判,剩下 2 筆裡只有 1 筆是真的祕密。

守門本身是有生效的,反向驗證跑過。問題在它看得見的東西有邊界。這些案例都已經處理掉了,下面講的是處理過程學到的判斷。

掃描器抓得到什麼、抓不到什麼

gitleaks 的規則是一條條的正規表示式,前面先用關鍵字篩一輪,部分規則再加上 Shannon entropy 門檻:抓到的字串亂度不夠高,就不算祕密(gitleaks README)。所以它認得的是「長得像憑證的字串」:

容易抓到容易漏掉
有固定前綴的 token(例如 glpat-、ghp_、sk_live_ 這類)看起來像普通英文字的密碼
PEM 私鑰區塊短的、低熵的、有意義的字串
高熵的隨機字串規則庫裡沒有對應格式的小眾服務 token

我這次漏掉的三類,剛好都落在右欄:

  • 形狀不像祕密的值:人取的密碼常常是低熵的普通字串,亂度不夠,就不會被當成祕密。
  • 藏在預設值裡的值:compose 寫成 ${ADMIN_PASSWORD:-<弱預設值>},密碼以「預設值」的身分躺在版控裡。對掃描器來說這只是一行設定,不是一個憑證。
  • 格式不在規則庫裡的 token:時序資料庫的 token 不是 gitleaks 預設規則認得的樣式,寫在一份不起眼的設定檔裡,同樣沒被抓到。

這不是設定沒寫對,是這類工具的本質:它偵測的是「形狀」,不是「用途」。

為什麼大部分命中是誤判

另一頭的問題剛好相反:誤判比真陽性多十幾倍。這次的誤判來源整理起來是這幾種:

  • 套件版本號裡的雜湊段:CI 伺服器的 plugin 清單裡,版本號帶一段像雜湊的字串,被當成金鑰。這一類就佔了誤判的大多數。
  • 前端 CSS class 組合:一串顏色 utility class 被 generic-api-key 規則命中。
  • 變數引用:Makefile 裡的 $$SOME_ADMIN_PASSWORD 只是引用環境變數,不是值,但關鍵字對上了。
  • 反模式教材裡的假金鑰:一份專門示範「密碼寫在程式碼裡有多糟」的腳本,裡面的假金鑰是教材,不是事故。

generic-api-key 這條規則本來就是用「關鍵字+夠亂的字串」去猜,誤判多是預期內的。官方 README 甚至在設定範例裡示範用 disabledRules 把它整條關掉(gitleaks README)。我沒有關掉它,因為它也是唯一有機會猜中小眾 token 的規則。

補洞要三層,不能只靠掃描器

只靠掃描器,等於把「有沒有祕密」這個問題外包給一個只看形狀的工具。我最後的做法是三層:

第一層:範本裡只能有明確的 placeholder。 env 範本檔的值一律寫成一眼看得出是假的字樣,不放「反正是測試用的」真值。這一層擋的是「有人複製範本就直接拿去用」。

第二層:compose 用必填語法,缺值時直接起不來。 把 ${ADMIN_PASSWORD:-<弱預設值>} 改成 ${ADMIN_PASSWORD:?請在 env 檔設定}。Docker 官方文件的定義是:${VAR:-default} 在變數未設定或為空時用 default;${VAR:?error} 在變數未設定或為空時「exit with error」(Docker Compose 變數插值)。

弱預設值的危險在於它是靜默的:沒設變數時服務照常起來,用的是版控裡那組人人看得到的密碼。改成必填之後,缺值會直接報錯拒絕啟動。這一步也有副作用——它會先把你自己的靜態檢查弄紅,那是另一篇的主題。

第三層才是掃描器,而且要能證明它在擋。 反向驗證要跑三種:selftest 塞一個假 token 看會不會 exit 1、真的 commit 一次假 token 看 hook 擋不擋、最後掃一次 HEAD 確認是零筆。

順序是有意義的:第一、二層處理「掃描器看不見的」,第三層處理「人忘了的」。

放行清單怎麼寫才不算作弊

誤判這麼多,放行清單一定要寫。但放行寫得隨便,守門就等於沒有。我的判準是:

放行的理由要能寫成一句話,而且那句話講的是「這個東西為什麼不可能是祕密」,不是「這個東西很吵」。寫不出來就不要放行,改成修掉。

照這個判準,放行有三種形式,由窄到寬:

1. 指紋放行(最窄,單筆)。 gitleaks 每筆發現都有一個 fingerprint,格式是 <commit>:<檔案路徑>:<規則 ID>:<行號>;把它加進 repo 根目錄的 ignore 檔,就只忽略那一筆(gitleaks README,官方註明這個功能是 experimental)。適合歷史裡已確認是假值的個案。每筆旁邊寫一行註解說明理由,並且讓這個檔案的變更走 code review。

2. 樣式放行(regex)。 放行的是「一種不可能是祕密的形狀」,不是某個檔案:

[extend]
useDefault = true
 
[[allowlists]]
description = "明確的佔位字樣與變數引用,不是值"
regexes = [
  '''(?i)change-?me''',
  '''(?i)placeholder''',
  # Makefile/腳本裡的帳密是 shell 變數引用,不是值
  '''\$\$?\{?CI_ADMIN_(ID|PASSWORD)\}?''',
  # 顏色 utility class 被 generic-api-key 誤判
  '''^bg-[a-z]+-\d{2,3}$''',
]

[[allowlists]] 是 v8.25.0 起取代舊版 [allowlist] 的寫法;useDefault = true 表示在內建規則之上擴充,而不是整份取代(gitleaks README)。舊版 gitleaks 要改用 [allowlist]。

regex 要寫得夠窄:只放行「某個特定變數名的引用」,而不是「所有含 PASSWORD 的行」。寫太寬,未來真的有人把密碼貼在那一行旁邊也會一起放掉。

3. 整檔放行(最寬,盡量不用)。 只在「那個檔的格式決定了它裝不下祕密」時才用。我只用了兩處:一份由指令從執行中實例產生、每行都是 plugin-id:版本號 的 plugin 清單;以及上面那份反模式教材腳本。兩處的理由都寫在設定的 description 裡。

教材型專案的範例密碼要怎麼處理

教材型專案(lab、workshop 範例)常常有一堆硬寫在 compose 裡的範例密碼。全改成環境變數注入的話,每個 lab 都要多一層設定,「clone 下來一行就能跑」的體驗就斷了。

這類案例不一定要改程式,但一定要把判定寫下來。我的建議是在 README 開一段憑證政策:

  • 這些密碼只限本機教學使用,對外環境不得沿用。
  • 新增 lab 比照辦理,但不得貼任何真憑證。
  • 守門擋的是誤貼的真祕密,不是這些教材固定值。
  • 這是暫行判定;標準日後若收斂成「教材也走環境變數」,就回來改。

沒寫下來的話,下一個人看到滿檔案的硬寫密碼,只會覺得這裡沒人管。

一個讓守門整個失效的坑

有一個 repo 的本地 git 設定裡有 core.hooksPath,值剛好等於預設路徑。pre-commit install 只要看到這個設定就拒絕安裝,印出 Cowardly refusing to install hooks with `core.hooksPath` set. 並回傳 1(pre-commit 原始碼)——而我沒看它的輸出。

結果後續反向驗證時,那個假 token 的 commit 真的進了本地分支。還好沒 push,當場退掉了。

教訓:pre-commit install 跑完要看到 pre-commit installed at ... 那一行才算裝好。沒裝的守門跟裝了的守門,在你不去測的時候長得一模一樣。

跟 git 歷史的關係

這篇講的都是「工作目錄裡現在有什麼」。從工作目錄刪掉的祕密其實還留在 git 歷史裡,本機 --no-git 掃描看不到、CI 掃全歷史才會冒出來——那是 gitleaks 本機綠、CI 紅 那篇的主題。如果歷史裡挖到的是真憑證,順序一定是先輪替,再談要不要清歷史。

怎麼預防再次發生

  • 新 repo 裝守門時,三層一起做,不要只做掃描器那層。
  • 範本檔的值一律 placeholder;compose 的祕密欄位一律 :? 必填,不給預設。
  • 反向驗證要包含「真的 commit 一次」,不能只跑 selftest。
  • pre-commit install 的輸出要看,沒有 installed at 就是沒裝。
  • 放行清單每一條都要有一句理由,理由講的是「為什麼不可能是祕密」;能用指紋就不用 regex,能用 regex 就不整檔。
  • 掃描器抓不到的那些(普通字串密碼、小眾 token),靠第一、二層擋,並列成人工檢查項。

相關