症狀
幾個 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),靠第一、二層擋,並列成人工檢查項。
相關
- 資安工具概觀:secret 掃描在整體資安工具地圖裡的位置
- 安全開發生命週期(下):測試、部署與 DevSecOps:掃描器放進 pipeline 的哪一段
- 驗收假綠形態目錄:「守門裝了但沒在擋」是假綠的一種
- 把弱預設改成必填之後,第一個壞掉的是你自己的靜態檢查