這個部落格專案差不多要公開了,公開前一件很現實的事:確認 git history 跟目前的檔案裡沒有不小心留下的機密金鑰。這篇記錄我怎麼選工具、在 Windows 上踩到的一個坑,以及最後怎麼把這件事接進 CI,變成自動化守門員。
這篇在講什麼
不是抽象的「資安最佳實踐」文章,而是一次真實的清理過程:選工具、跑掃描、修 bug、找到真的洩漏的金鑰、最後寫進 CI。
選工具:Gitleaks 還是 Betterleaks
搜了一下開源的 secret scanning 工具,主要就兩個候選:
- Gitleaks:老牌、單一 Go 執行檔、regex + entropy 比對,離線跑,速度快,常被拿來當 pre-commit hook。
- Betterleaks:同一位作者做的新專案,定位是 Gitleaks 的直接替代品——設定檔、ignore 檔、CLI 參數幾乎無痛沿用,規則更新比較積極。
兩者都是單一二進位檔,不需要 CI 或 Linux 環境,Windows 本機直接下載執行檔就能跑:
scoop install gitleaks
# 或直接抓 betterleaks 的 Windows 執行檔考量規則更新的積極度,我選了 Betterleaks 當主力,抓不到的話再拿 Gitleaks 對照一次。
踩坑:Windows 上的 wazero panic
第一次跑掃描時直接跑整個 git history:
betterleaks git . -v --redact結果不是掃描結果,而是一坨 panic:
rename C:\Users\...\wazero-v1.12.0-amd64-windows\...tmp ...:
The system cannot move the file to a different disk drive.
panic: runtime error: invalid memory address or nil pointer dereference根本原因
Betterleaks 預設用 WASM 版的 re2 正規表達式引擎(透過 wazero 執行),啟動時需要把編譯快取寫進暫存目錄。問題出在暫存目錄跟專案分別在不同磁碟機(暫存檔在 C:,專案在 D:),wazero 內部用 rename 搬移暫存檔案,而 Windows 不允許跨磁碟機的檔案 rename,直接把它搬崩潰了。
修法是換成 stdlib 版的 regex 引擎,繞過整個 wazero 元件:
betterleaks git . -v --redact --regex-engine stdlib這次順利跑完,git history 裡沒發現洩漏。
掃 working tree:抓到兩類結果
git history 乾淨不代表現在的檔案系統乾淨,所以接著掃了整個 working tree:
betterleaks dir . -v --redact --regex-engine stdlib掃出 14 筆結果,可以分成兩類:
第一類:.next build cache 裡的內部金鑰(previewModeSigningKey、encryptionKey 之類),這些是 Next.js build 產生的內部快取值,不是人為設定的密鑰。
第二類:真正要注意的
apps/server/.env、.env.local裡的BETTER_AUTH_SECRET、GITHUB_CLIENT_SECRET、GOOGLE_CLIENT_SECRET- 一篇還沒發布的部落格草稿
broke.md,裡面貼了一段真實明文的 Cloudflare API Token
先確認這些檔案有沒有被正確排除:
git check-ignore -v apps/server/.env apps/server/.env.local結果 .env* 和 .next/ 都已經被各自的 .gitignore 正確排除,不會進 repo,這幾個算安全。
但 broke.md 沒有被 gitignore、也還沒 commit(git status 顯示 ??),裡面那把 Token 是真的洩漏過的憑證——文章內容剛好是在記錄「這把金鑰怎麼意外印進 log 裡」的除錯過程,寫作時卻直接把真實值貼進了草稿。
教訓
在記錄「洩漏事件」的文章裡,示範用的憑證也要遮蔽或用假資料取代,不能為了敘事真實感直接貼真實金鑰——草稿檔案一樣算是攻擊面,只要沒被 gitignore,一個 git add . 就可能把它帶進 repo。
處置順序:先確認金鑰已經在服務商後台 revoke,再清理文字內容,順序不能反過來。
接進 CI:讓掃描變成自動化
本機掃過一次不夠,之後每次 push 都要自動檢查。在 .github/workflows/secret-scan.yml 加了一個 job:
name: Secret scan
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
betterleaks:
runs-on: ubuntu-latest
steps:
- name: Checkout (full history)
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install betterleaks
run: |
VERSION=$(curl -sSL https://api.github.com/repos/betterleaks/betterleaks/releases/latest | grep -oP '"tag_name":\s*"v\K[^"]+')
curl -sSL "https://github.com/betterleaks/betterleaks/releases/download/v${VERSION}/betterleaks_${VERSION}_linux_x64.tar.gz" \
| tar xz
sudo mv betterleaks /usr/local/bin/
- name: Scan git history for secrets
run: betterleaks git . -v --redact --regex-engine stdlib幾個設計上的取捨:
fetch-depth: 0:預設 checkout 只抓最後一次 commit(shallow clone),要掃完整 git history 一定要拉滿。- 動態抓最新版本號(用 GitHub API 查
tag_name),而不是寫死版本,之後 Betterleaks 出新版不用手動改 workflow。 - 直接從官方 GitHub Release 下載二進位檔安裝,沒有用市面上現成的第三方 Action(例如
dortort/betterleaks-action)——那是非官方維護的 wrapper,多一層要信任的第三方程式碼,直接抓官方 release 檔案更可控。 - 沒有加
--exit-code 0:Betterleaks 掃到 leak 預設回傳非 0 exit code,讓這個 job 直接 fail,擋下有問題的 push/PR。
第一次跑的時候下載步驟失敗過一次:
gzip: stdin: not in gzip format
tar: Child returned status 1原因是我猜錯了 release 檔名的架構命名(寫成 betterleaks_linux_amd64.tar.gz),實際上官方的命名是 betterleaks_<version>_linux_x64.tar.gz。用 curl -sSL https://api.github.com/repos/betterleaks/betterleaks/releases/latest 查一次 release assets 列表,確認正確檔名後就修好了。
小結
這一輪清理下來,真正有價值的不是「跑了一次掃描」,而是把它變成每次 push 都會自動檢查的流程——本機掃描抓一次性的漏網之魚(尤其是還沒 commit 的草稿檔案),CI 上的掃描則是長期的守門員,兩者互補:本機管「現在」,CI 管「以後」。
如果要開源一個累積了一段時間的專案,git history 裡藏著什麼,往往連自己都想不起來,跑一次完整的 history 掃描是最後上線前該做的功課。