cloud

從本機與遠端 Git 歷史移除誤提交的 secret

刪除檔案只是第一步:輪替憑證、改寫所有可達 ref、獨立驗證遠端,並防止下次外洩。

English繁中
從本機與遠端 Git 歷史移除誤提交的 secret

這次事件發生在一個使用外部 CSV 的 side project。我整理資料時,誤把其中一份含有機密內容的 CSV 加進 Git。發現後,即使再提交一次刪除檔案的變更,也只會讓它從最新版本消失;舊 Git object 仍可能在本機或遠端被取回。

需要處理的是兩件事:已暴露的資料,以及仍可存取的 Git 歷史。若資料是 token、密碼或憑證,要先撤銷或輪替;若是無法撤銷的機密內容,則要限制存取、檢查稽核紀錄與副本,並依事件需求處理通知。刪除歷史無法收回別人已取得的資料。

以下以 data/external/private-source.csv 示範。操作前須確認 repository 的 refs、branch protection 與協作方式。

刪除檔案會留下什麼

git rm data/external/private-source.csv
git commit -m "remove committed confidential CSV"

目前 branch 不再包含檔案,但早期 commit 仍有原始內容。把值換成 REDACTED 也一樣,Git 會保存前一個 blob。

加入 .gitignore、關閉 merge request、把 repository 改為 private,或只 force-push 目前 branch,都不能證明舊 object 已清除,也不能讓憑證失效。

先控制外洩範圍

  1. 撤銷或輪替已暴露的憑證;無法撤銷的機密資料則先限制存取,評估可能取得資料的人與系統。
  2. 檢查供應商的 access 與 audit logs。
  3. 若外洩仍在持續,暫時限制 repository 存取。
  4. 請協作者暫停 push,直到歷史改寫完成。
  5. 找出所有曾保存該資料的路徑與檔名。

同一個檔案若有多份憑證,要全部處理;同一份憑證若用在其他系統,也要納入範圍。CI logs、artifacts、package caches、forks、備份、對話與其他人的 clone 都可能留有副本。

找出受影響的 commits 與 objects

先查改動過該路徑的 commits,再列出目前 refs 能到達的 objects:

git log --all --name-status -- data/external/private-source.csv
git rev-list --objects --all | rg ' data/external/private-source\.csv$'

若檔案改過名稱或移過位置,所有舊路徑都要查。也要檢查哪些 branches、tags 與其他 refs 包含受影響的 commit:

git branch --all --contains <commit>
git tag --contains <commit>
git for-each-ref --contains <commit>

如果 secret 是混在正常檔案裡的一段字串,只查路徑不夠。可以用安全且足以辨認的片段,例如 key identifier,搜尋歷史;不要把完整 secret 輸入指令:

git log -S'<safe-identifier>' --all -p

將受影響路徑、最早的 commits 與已知 blob IDs 記在私有事件筆記中,作為改寫後的查驗目標。

在全新 clone 改寫歷史

使用全新 clone,避免把無關的工作、stash 或 reflog 混入清理。Git 官方建議使用 git-filter-repo 取代舊的 git filter-branch。整份檔案都應移除時:

git filter-repo \
  --sensitive-data-removal \
  --invert-paths \
  --path data/external/private-source.csv

有舊檔名時,將每條路徑加入同一次操作:

git filter-repo \
  --sensitive-data-removal \
  --invert-paths \
  --path data/external/private-source.csv \
  --path imports/private-source.csv

只需替換正常檔案中的特定值時,可用 --replace-text。在受保護的本機環境準備替換檔,避免原值進入 shell history、終端錄影或 CI logs。

不要習慣性加上 --forcegit-filter-repo 的 fresh-clone check 是為了避免改寫錯誤的工作目錄。舊環境中的 git filter-branch 可以作為受限情況下的替代,但還需處理 backup refs 與 reflogs。

驗證本機歷史

下面兩項查詢應找不到該路徑:

git log --all -- data/external/private-source.csv
git rev-list --objects --all | rg ' data/external/private-source\.csv$'

若已記錄暴露的 blob ID,再確認它是否仍存在於本機 object database:

git cat-file -e <exposed-blob-id>

移除成功時,git cat-file -e 應失敗。若成功,表示仍有 ref 或本機 object database 留著它,需要繼續查明。

也要執行專案原有的測試與建置,確認程式、部署或 CI 不再依賴被刪除的檔案。需要的 runtime 設定應改由環境變數、secret manager、SOPS、Vault 或部署平台提供。

協調後再更新遠端

改寫會產生新的 commit IDs。推送前確認協作者已暫停 push、未合併工作已保存、branch protection 允許預定更新,並了解供應商對隱藏 refs 與快取的清理程序。

Mirror force push 可更新一般 refs:

git push --force --mirror origin

這會覆寫 branches 與 tags,也可能丟掉清理開始後別人推送的工作。只能從已檢查的改寫 clone,在協調好的時段執行。

Pull/merge request refs、cached diffs、forks、LFS objects 與 server backups 可能不受一般 push 控制,需要供應商的清理流程。GitLab 的一般檔案 purge 也不同於 sensitive-data removal;密碼或金鑰應使用 Repository maintenance 的 Remove blobs 或 Redact text,並完成後續 housekeeping 與 unreachable-object pruning。GitLab 文件說明了這項差別。

另建 mirror clone 稽核遠端

推送完成後,從 server 重新取得一份 mirror clone:

git clone --mirror <repository-url> remote-audit.git
cd remote-audit.git
git log --all -- data/external/private-source.csv
git rev-list --objects --all | rg ' data/external/private-source\.csv$'
git cat-file -e <exposed-blob-id>

分開記錄每項結果能證明的範圍:

  • 本機:refs 已無法到達受影響路徑,已知 blob 也不再存在。
  • 遠端:全新 mirror clone 無法從 server 公開的 refs 找回該路徑或 blob。
  • 資料外洩:可撤銷憑證已失效,其他機密內容已依事件程序處理。
  • 外部副本:forks、快取、artifacts 與其他 clones 已處理,或已記錄仍存在的副本。

Mirror clone 只能驗證 server 提供給它的內容,不能證明供應商隱藏的資料或外部副本全部消失。

更換或清理舊 clone

舊 clone 可能在下次 push 時把污染的歷史帶回來。通常可先保存未推送的 patches,停止使用舊 clone,重新 clone 清理後的遠端,再把 patches 套到新歷史。

必須保留舊 clone 時,要移除舊 refs、處理 reflogs 與 unreachable objects,並確認已知的問題 IDs 不再存在。git reflog expire --expire=now --allgit gc --prune=now 會永久移除恢復資料;執行前要確認目標 clone,並保存仍需要的工作。

防止再次提交 secret

.gitignore 排除本機 secret 檔:

.env
.env.*
!.env.example
*.p8
*.p12
*.pem
*.key
*.mobileprovision

這只能避免未追蹤檔案被意外加入,不能移除已追蹤檔案,也無法偵測貼進程式碼的 secret。

CI 可以先檢查敏感檔名:

git ls-files | rg '(^|/)(\.env($|\.)|.*\.(p8|p12|pem|key|mobileprovision)$)'

實際檢查需為 .env.example 與安全的 test fixtures 設定有限的 allowlist,找到禁止追蹤的路徑時讓 CI 失敗。

另外在 pre-commit/pre-push 與 CI 掃描內容,檢查 token 格式、private-key headers 與供應商憑證。Local hooks 可以被略過,因此 CI 仍需執行。誤判要逐項核對,避免停用整個 repository 的規則。

Runtime 值可由 Vault 與 External Secrets提供;若需要把加密值版本控管,則使用 SOPS並另外管理解密金鑰。把加密檔和私鑰一起提交,仍會讓讀取 repository 的人取得原值。

結案前的檢查清單

  • 可撤銷的憑證已撤銷或輪替。
  • 不可撤銷的機密資料已依事件與通知程序處理。
  • 已檢查供應商 audit logs。
  • 所有歷史路徑、branches、tags 與其他 refs 都已納入。
  • 本機歷史不再包含該路徑,已知問題 IDs 無法解析。
  • 刪除檔案後,應用程式測試與部署檢查仍通過。
  • 已在協調好的時段更新遠端 refs。
  • 全新 mirror clone 通過獨立查驗。
  • 供應商快取、隱藏 refs、forks 與 LFS objects 已處理。
  • 舊 clone 已更換或清理。
  • 已加入忽略規則、檔名檢查與內容掃描。
  • 已記錄替代的 secret delivery 方式。

參考資料