在密鑰被提交至 Git 歷史紀錄前加以阻止
一個提交到 git 的密鑰會永遠存在,即使在刪除後也一樣。這裡介紹一種多層次防禦機制,能在提交前就將其阻擋,以及當密鑰洩漏時如何進行輪替。
一旦 API 金鑰、權杖、密碼或私密憑證進入了 commit,你就遇到了一個刪除也無法解決的問題。Git 的歷史紀錄在設計上就是持久的。一個被強制移除的密鑰,在你注意到之前,仍然存在於每一個 clone、每一個 fork、每一個 CI 快取,以及每一個 fetch 過該分支的編輯器中。該值必須被視為已作廢,這意味著要進行輪替,而不是清理。因此,真正的防禦不是在事後偵測到外洩的密鑰,而是在密鑰進入歷史紀錄之前就阻止該 commit。這篇文章將展示如何層層建構這種阻擋機制、為什麼單一層防禦永遠不夠,以及當密鑰無論如何還是洩漏時該怎麼辦。
為何刪除已提交的密鑰為時已晚
一個已提交的密鑰就是一個已發布的密鑰。在您推送之後到您注意到之前,任何有讀取權限的人都可以拉取該值,自動化的分支 (forks) 可以鏡像它,而爬取公開和私有主機的掃描器也可以將其索引。重寫歷史紀錄會從您的分支頂端移除該值,但它無法觸及那些已經離開您機器的副本。
清理失敗還有第二個原因。重寫歷史紀錄會改變您編輯的 commit 之後的每一個 commit 的雜湊值,這會迫使每一位協作者重設他們的本地分支,並破壞開啟中的 pull request。您付出了實際的維運成本,而密鑰仍然處於外洩狀態,因為您無法證明在暴露的空窗期內沒有人複製過它。
正確的心智模型很簡單。一旦密鑰進入了共享的歷史紀錄,它的價值就已蕩然無存。唯一安全的回應是在來源端輪換它,並使舊的密鑰失效。其他所有事情,包括重寫歷史紀錄,都只是在該值已經變得毫無價值之後的清理工作。這就是為什麼應該將精力放在上游,在 commit 的邊界上,在那裡密鑰仍然可以完全被排除在歷史紀錄之外。
分層防禦,一目了然
沒有任何單一檢查能捕捉所有問題。本機掛鉤速度快,但可以被略過。伺服器掃描是強制執行的,但在推送後才運行。檔案規則能防止整類的錯誤,但無法讀取意圖。每一層都彌補了其他層留下的空隙,所以要將它們一起運行。
| 層級 | 運行位置 | 捕捉內容 | 繞過風險 |
|---|---|---|---|
| Pre-commit 掛鉤 | 開發者機器,提交前 | 暫存的密鑰,在它們進入歷史記錄前 | 高,因為是本機的且可被略過 |
| CI 掃描 | 伺服器,推送後 | 掛鉤遺漏或被略過的掛鉤所放行的任何內容 | 低,由中央強制執行 |
| 忽略與允許清單規則 | 兩者皆是 | 絕不應被暫存的密鑰檔案,以及誤報 | 不適用 |
| 自動化防護 | 機器人和腳本 | 暫存密鑰的機器提交 | 低,在自動化內部運行 |
| 輪換 | 暴露後 | 已洩漏值的損害 | 不適用 |
將此表由上到下視為一個漏斗。Pre-commit 掛鉤以低成本在早期阻止常見情況。CI 掃描是掛鉤之下的安全網,針對那些運行 git commit --no-verify 或從未安裝掛鉤的開發者。檔案規則縮小了兩種掃描器需要處理的範圍,自動化防護涵蓋了無人審查的提交,而輪換是最後一層,是你希望永遠不會運行的一層。
使用 pre-commit hook 阻擋提交
要阻止密鑰洩漏,最具成本效益的地方就是在開發者自己的機器上,在提交物件產生之前。pre-commit hook 會對暫存的變更執行,如果它找到任何看起來像憑證的內容,便會以非零值退出,而該次提交就永遠不會發生。由於它只檢查暫存的內容,因此速度夠快,可以在每次提交時執行,而不會讓任何人察覺到延遲。
有三種信號可以捕捉到大多數真實的洩漏。首先是檔案名稱:像 .env、.pem、.key、.p12 和 .pfx 這類的檔案幾乎絕不應該存在於儲存庫中。其次是已知的憑證格式:許多供應商發行的金鑰都具有固定的前綴和長度,而私密金鑰則帶有可識別的標頭行。第三是高熵度:一長串看似隨機的字元,被指派給名為 token 或 password 的變數,這是一個強烈的跡象,即使它不符合任何已知格式。
#!/usr/bin/env bash
# .git/hooks/pre-commit (or the equivalent managed by a hook framework)
set -euo pipefail
# Patterns for well-known credential shapes and private key headers.
SECRET_PATTERNS='(-----BEGIN [A-Z ]*PRIVATE KEY-----)|(secret_[A-Za-z0-9]{24,})|([A-Za-z0-9+/]{40,}={0,2})'
# Inspect only what is staged, and only added or changed lines.
staged=$(git diff --cached --name-only --diff-filter=ACM)
fail=0
for file in $staged; do
# 1. Reject credential-bearing filenames outright.
case "$file" in
*.pem|*.key|*.p12|*.pfx|.env|.env.*)
echo "blocked: $file looks like a credential file"
fail=1
continue
;;
esac
# 2. Scan the staged content of the file for secret-shaped values.
if git show ":$file" | grep -nEq "$SECRET_PATTERNS"; then
echo "blocked: $file contains a value matching a secret pattern"
fail=1
fi
done
if [ "$fail" -ne 0 ]; then
echo "commit rejected. remove the secret, or add an allowlist entry if this is a false positive."
exit 1
fi
有兩個細節比表面上看起來更重要。這個掛鉤讀取 git show ":$file",這是內容的暫存版本,而非工作複本。這可以防止一種競爭情況:開發人員取消暫存一個修復,但工作檔案看起來仍然是乾淨的。而且它使用 --diff-filter=ACM 進行過濾,因此刪除操作不會觸發掃描,因為移除檔案絕不是密鑰進入歷史紀錄的方式。
熵是模式集的弱點,因為一個合法的二進位檔的 base64 區塊或一個長雜湊值,看起來可能跟金鑰完全一樣。上述的正規表示式使用了一個保守的長度下限來減少雜訊,而接下來的章節將涵蓋讓熵在實務中變得可行的兩件事:一個用來捕捉其遺漏的 CI 防護網,以及一個用來處理其錯誤標記的允許清單。
CI 中的第二道關卡
本機掛鉤有一個致命的弱點。它存在於開發人員的機器上,這意味著它可以被跳過、解除安裝,或是在一個全新的複製版本上根本就沒有設定。git commit --no-verify 用一個旗標就能繞過它。任何一個旗標就能停用的防禦措施都不是一種控制,而是一種建議。因此,同樣的掃描必須在沒有人可以選擇退出的地方再次執行:在 CI 中,在每一次推送時。
CI 掃描所做的,不僅僅是重複掛鉤的動作。它會掃描推送的完整差異,包含那些在從未安裝掛鉤的機器上所做的提交,並且它可以在首次執行時掃描歷史深度,以捕捉在該控制措施存在前就已提交的密鑰。因為它在伺服器上執行,所以其結果具有權威性。失敗的掃描會阻擋合併,而且沒有任何本機旗標可以讓它通過。
# A minimal CI job that fails the pipeline on a detected secret.
scan-secrets:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history, so the scan sees every commit in the push
- name: scan for secrets
run: |
# Scan the range that this push introduced, not just the tip.
base="${BEFORE_SHA:-HEAD~1}"
if git diff "$base"..HEAD | grep -nE "$SECRET_PATTERNS"; then
echo "secret detected in pushed commits. rotate the value and clean history."
exit 1
fi
分工是重點。Hook 是一條捷徑,它能讓幾乎所有密鑰遠離開發者電腦上的歷史紀錄,而修復它的成本僅需編輯一個檔案。CI 掃描是強制執行的路徑,它假設 hook 已被略過,並拒絕讓帶有密鑰的 push 成為一個合併。一個方便,另一個具權威性,而你兩者都需要。
當 CI 掃描觸發時,存在一個重要的不對稱性。當它執行時,密鑰已經被推送,這意味著它可能已經暴露。因此,CI 的攔截並不是一次完美的救援。這是一個信號,表示要開始輪換程序、將該值視為已遭洩露,然後清理歷史紀錄。
減少誤報的檔案與路徑規則
當掃描器需要猜測的內容越少時,其效能就越好。兩項檔案層級的規則承擔了大部分的工作。第一項是嚴格的 .gitignore,它從一開始就將整類的密鑰檔案排除在暫存區之外。如果 .env、金鑰檔案和本機憑證快取被忽略,開發人員就無法意外地 git add 它們,而掃描器也永遠不必去推斷其內容。
# Never track local secret material.
.env
.env.*
!.env.example
*.pem
*.key
*.p12
*.pfx
credentials.json
!.env.example 這一行值得特別提出來。你會需要一個已提交的範本,用來顯示有哪些變數存在,並附上預留位置的值,這樣掃描器的允許清單才能精確地排除該檔案。它記錄了設定檔的結構,但完全不包含任何真實的值。
第二條規則是為掃描器本身設定的明確允許清單,因為誤報是不可避免的。測試用的 fixture 會刻意包含假的密鑰。文件中會展示範例權杖。引入的相依套件會附帶一個範例憑證。如果沒有方法將這些標記為已知安全,每一個都會阻擋合法的提交,而開發者就會學著使用 --no-verify,這會讓整個系統失效。允許清單的範圍應該要小且可供審查,鎖定在特定的路徑或特定的行數,絕不能是「到處都忽略此模式」這種一概而論的規則。
# Scanner allowlist: narrow, path-scoped, reviewed in pull requests.
allowlist:
- path: testdata/fake_keys.txt # deliberately fake fixtures
- path: docs/configuration.md # example token in prose
reason: "documented placeholder, not a live credential"
這裡的原則是,每個允許清單的項目都是一個帶有理由的小型、明確的例外,並像任何其他變更一樣受到審查。一個模式層級的靜音就像一個你會忘記自己挖了的坑。一個附有明確理由的路徑層級例外是可稽核的,而審查拉取請求的人可以確切地看到什麼被豁免了以及原因為何。
防禦自動化提交
最危險的提交是那些沒有人查看的提交。一個開啟相依性更新 pull request 的機器人、一個重新產生設定檔並提交結果的腳本、一個寫入建置清單的發布作業:任何這些都可能在沒有人參與的情況下,暫存一個秘密,而沒有人注意到 diff 中的警示。自動化運作快速且不審查任何東西,所以它需要將阻擋機制直接嵌入其自身的路徑中。
規則是,自動化提交路徑要執行與人類相同的掃描,並將命中視為硬性停止,而不是記錄警告。如果機器人的掃描在即將提交的內容中發現秘密,則該提交絕不能發生。機器人會大聲地宣告其工作失敗,以便人類進行調查,而不是靜悄悄地提交秘密然後繼續前進。這與人類掛鉤的預設關閉姿態相同,應用在風險更高的地方,因為沒有審查者作為後盾。
這也是熵值檢查發揮其價值的地方。人類在撰寫程式碼時,很少會意外地貼上一串令他們驚訝的真實 40 字元隨機字串。另一方面,將設定檔範本化的自動化程式,可以從環境變數中提取一個即時值,並將其直接寫入一個受追蹤的檔案中。通用的高熵值檢查正好能捕捉到這類錯誤,也就是該值不符合任何已知的提供者格式,但從其形狀和變數名稱來看,它無疑是一個秘密。
當密鑰確實外洩時,優先輪替
密鑰遲早會外洩,而你應對的順序決定了它會造成多大的損害。直覺反應是立即清除歷史紀錄,讓該值消失。這個直覺是錯的,因為該值已經洩漏出去,而重寫歷史紀錄對於那些已離開你機器的副本毫無作用。第一步永遠是輪替。
- 在做任何其他事情之前,先在其來源處輪替或撤銷已外洩的值。在供應商端產生一個替代品,並使舊的失效。密鑰一旦公開,其價值就已死亡,所以要先將它終結。
- 確認舊值已不再有效。嘗試用它進行驗證,並確認呼叫被拒絕。在你親眼看到它失敗之前,你並未真正撤銷它。
- 透過你正常的密鑰儲存庫,將新值部署到服務需要它的任何地方,這樣系統就能以替代品繼續運行。
- 直到現在才清理歷史紀錄。重寫帶有該密鑰的提交 (commits) 並強制推送 (force-push),要明白複本 (clones)、分支 (forks) 和快取 (caches) 仍然持有舊值。這個步驟是為了保持整潔,而不是為了圍堵。
- 檢查存取日誌,看在密鑰外洩和撤銷之間是否有使用舊值的紀錄。如果該憑證授予對真實資料的存取權限,應假設它可能已被使用,並進行相應的調查。
順序就是這堂課的全部重點。輪替可以控制事件。清理歷史紀錄只是事後的整理工作。一個先重寫歷史紀錄、後輪替密鑰的團隊,會將其最寶貴的黃金時間花在毫無改變的步驟上,而同時,那個有效的、已遭洩漏的憑證對任何已經複製它的人來說仍然有效。
誤報的權衡取捨,以及為何預設阻擋是更佳選擇
每個密鑰掃描器都面臨著同樣的兩難。收緊模式會讓你錯過真正的密鑰。放寬模式則會因誤報而阻擋合法的提交。沒有任何設定可以完全消除這兩種情況,所以你必須選擇你偏好哪種錯誤,而這個選擇並非對等的。
誤報是件麻煩事。開發人員看到一個被阻擋的提交,檢查被標記的程式碼行,確認它是一個測試用的固定資料或有記載的預留位置,然後新增一個範圍狹窄的允許清單項目。成本是幾分鐘的時間和一個經過審查的例外情況。漏報則是一次外洩。一個真正的憑證溜了過去,進入了共享的歷史紀錄,現在你必須在壓力下輪替密鑰並查閱存取日誌。這兩種結果的嚴重性天差地遠,這就是為什麼掃描器應該採用預設阻擋的策略:當有疑問時,就阻擋,並讓真人來確認例外情況。
只有在處理誤報的路徑成本真的很低時,這個選擇才行得通,這也是為什麼允許清單如此重要。如果清除誤報的過程很痛苦,開發人員就會用 --no-verify 來繞過掃描器,而被繞過的控制機制什麼也保護不了。所以你要保持模式足夠積極以捕捉真正的密鑰,並投入資源使例外處理變得快速、範圍狹窄且可供審查。在偵測時預設阻擋,在例外處理上保持低成本,你就能得到一個開發人員會持續使用而非對抗的系統。
將這些層次組合起來,輪廓就很清晰了。pre-commit 掛鉤在開發人員的機器上防止密鑰進入歷史紀錄。CI 掃描強制執行相同的規則,且沒有任何旗標可以跳過它。檔案和允許清單規則減少了猜測工作,並保持低廉的誤報成本。自動化守衛涵蓋了那些沒有人審查的提交。而輪替機制隨時準備應對有東西洩漏的那一天,因為整個設計背後的誠實假設是,最終總會有東西洩漏,而一個被提交的密鑰在它落地的瞬間就失效了。