大家好,我是中義。
昨天那行指令跑完,我朋友的歷史裡真的有一份 .env,半年前那個 commit。我叫他先去供應商後台把金鑰撤掉,歷史先不要動。今天講為什麼是這個順序。順序走完之後還有一份,那份你收不回來。
他的第一個訊息是:「我把那個 commit 砍掉就好了吧?我看網路上都這樣寫。」
第二個訊息是三十秒後:「還是我直接把 repo 刪掉重開?反正也沒幾個 star。」
我完全理解這個反應。收到 GitHub 寄來那封「我們在你的公開 repo 裡偵測到憑證」的信,或是自己跑指令查出來的那一刻,腦子裡只有一個念頭:把它弄不見。
而且刪 repo 是真的做得到。三分鐘,Settings 拉到最底,打一次專案名字確認,整個東西就不見了。GitHub 上搜不到,clone 不下來,那封信講的東西從網路上消失。
看起來很乾淨。
但你有沒有想過,那把金鑰是誰發的?
不是 GitHub。是模型供應商。GitHub 上那串字只是一份拷貝,真正決定它能不能用的,是供應商後台那筆紀錄。你把 repo 刪光、把 commit 砍掉、force push 蓋掉整段歷史,供應商那邊什麼都沒發生。它還是那把可以用的鑰匙。
而在你刪掉之前,那串字在公開網路上躺了半年。
這件事有數字。GitGuardian 追蹤 2022 年在公開 repo 上確認有效的憑證,四年後還有 64% 是有效的,而且還躺在公開 repo 上。三分之二沒被撤銷。

圖裡那四根柱子是按偵測年份分的,越舊的那批撤銷比例越高,因為它們有比較長的時間被處理。這張圖不能拿來判斷整體在變好或變壞,能看的是另一件事:2022 那批放了四年,撤掉的也才三成六。
公開 repo 會被自動掃,這件事不需要有人盯著。所以推上去的那一刻就要當成它已經在別人手上,不能拿「我很快就刪掉了」當作沒事的理由。刪掉的是你這份,別人抓走的那份不會跟著消失。
所以第一個要達成的只有一件事:讓舊鑰失效。

這張流程圖右邊那條是今天要走的路:換鑰、驗證、查用量、裝 hook,四步今天做完,接在後面的清歷史排進待辦就好。左邊兩條不是「比較慢」,它們處理的是散布,不是憑證的效力。
「這把金鑰進過 AI 對話嗎」那一格先記著。它用虛線掛在查用量旁邊,因為它屬於盤點但不在主流程上,也因為它沒有「做完」這個狀態。最後一節會回來講。
動手之前先數一下有幾把。那份 .env 不會只有一行,資料庫連線字串、雲端服務的憑證、webhook 網址、第三方 API 的 token,同一份檔案裡的東西一起進了歷史,也一起躺了半年。GitHub 的告警只會指出它認得的那幾種,剩下的沒有人會替你列。
所以打開那份 .env,逐行寫下來:這是哪個服務的、在哪裡撤、撤掉會影響什麼。今天講的處置,每一把都要各走一遍。清單先列完再動手,撤到一半才發現還有三把,那時候你已經在切服務了。
然後到供應商後台處理。個人專案或還沒有人在用的服務,直接砍掉、發新的、切過去。
有正式流量在跑的話順序要反過來:先發新鑰、切過去、確認服務正常,再回頭砍舊的。先砍會停機,而停機不是必要的代價。
兩種順序的終點是同一個:能立刻撤就立刻撤,需要切換的就切完立刻撤。 中間那段是舊鑰還活著的視窗,越短越好,它不是一個可以排到明天的東西。
在那之前,刪 commit、刪 repo 這些動作減少的是散布,沒有一個讓那把鑰匙失去效力。
金鑰是在供應商那邊失效的,不是在你的 repo 裡失效的。
按下 Revoke 之後不要相信畫面上那行綠字。用舊鑰親手打一次 API,看它回什麼。
curl -sS -w '\nHTTP %{http_code}\n' \
https://api.provider.example/v1/models \
-H "Authorization: Bearer $OLD_KEY"
不要只看狀態碼,要看供應商在回應裡講了什麼。我用一把無效的鑰匙打 OpenAI,拿到的是 "code": "invalid_api_key"。這種明講憑證無效的錯誤才算數。
只看數字會誤判。401 是「這次請求沒通過驗證」,403 可能是鑰匙還活著只是權限不夠,429 是被限流,000 是根本沒連上。這幾個都不是撤銷成功。
上面那段只是 Bearer token 的示意,不要只換網址。 網址、方法、驗證標頭、必要參數,全部照你那家的文件設。這不是龜毛,是我實測踩到的:拿 Bearer 去打 Anthropic,回的是 "type": "authentication_error",訊息 Invalid bearer token;而真正無效的金鑰配上正確的 x-api-key 標頭,回的 type 一模一樣,只有訊息是 invalid x-api-key。標頭寫錯,你會看到一個跟「撤銷成功」長得幾乎一樣的畫面。
記住這個形狀,今天後面還會一再遇到:看起來跟成功一模一樣的失敗。 所以連訊息那一行都要讀完,不能只看 type。
還有一點:你要打的那個端點,必須是撤銷前用同一把鑰匙打得通的。拿一個本來就沒權限的端點去測,看到的拒絕跟撤銷無關。
curl 用 -sS 不要用 -s。差別是連不上的時候 -s 什麼都不說,-sS 會告訴你 curl: (6) Could not resolve host。你需要分得出「打不到」跟「打到了被拒絕」。
這一步聽起來多餘,但我看過兩種它不多餘的情況。一種是後台有好幾把鑰匙、名字取得很像,撤到隔壁那把去了。另一種是撤銷有快取,看起來已經 revoked,實際上還能用幾分鐘。
沒驗過的撤銷不算撤銷。 跟第一天在 DevTools 裡親眼看那個 header 一樣,是同一件事的不同場合:你要的是那台伺服器自己回的東西,不是後台畫面上的宣稱。
這一步也是整個處置流程裡最不能交出去的一步。歷史清理的指令可以叫 AI 起草,帳單怎麼看可以問 AI,但「舊鑰真的死了」必須你自己打那一發。AI 沒有你的後台,它只能根據你貼給它的敘述說「那應該已經失效了」。
新鑰發好了,接下來要問一個沒人想問的問題:那半年裡,有沒有人用過它?
外洩可能會有人通知你,開頭那封 GitHub 的信就是。被盜用就不一定了。有些供應商有異常偵測會寄信,但你不能指望它,因為盜用的長相就是一個正常的 API 請求,帶著一把有效的憑證。
用量是最容易查的那條線索,但它不是唯一一條。供應商如果有請求紀錄、稽核紀錄、預算警示,一起看;程式碼平台的 secret scanning 有時候比你先發現。
反過來說,用量正常不能證明沒被用過,只能證明沒被大量用過。
到後台把用量拉成按日或按小時的圖,看兩件事。第一是有沒有你根本沒在跑的時段冒出流量,半夜三點、你出國那一週。第二是有沒有你沒用過的模型或端點被打過。盜用的人不會照著你的使用習慣走。
要查的區間是從最早可能外洩的那天到你撤銷完成為止。以故事裡那個例子就是整整半年,不是最近七天。多數後台預設只給你看最近幾天,要自己把範圍拉開。
看完就記下來,一行字:查了哪段區間、什麼粒度、有沒有異常、幾點看的。粒度要記是因為按日看跟按小時看能發現的東西不一樣。這行的用處在後面,如果之後帳單真的爆掉,你知道自己當初查到哪裡為止。
沒發現異常也要記。「在查得到的期間與粒度裡沒看到異常」本身就是一個結論,只是要照這樣寫,不要寫成「沒被用過」。
盤點還有一條,多數人不會想到:這把金鑰有沒有進過你的 AI 對話? 貼設定檔問「這樣寫對嗎」、把錯誤訊息連同環境變數一起丟進去,都算。這條跟前面幾條不一樣,一樣記一行:想得起來的有哪幾次,還是完全沒有。
到這裡火滅了。接下來這一步才是今天真正留在專案裡的東西:裝一個在 commit 之前就攔下來的檢查。
要用到 gitleaks,brew install gitleaks 或到 releases 頁抓一個 binary 丟進 PATH 都行,它是單一執行檔沒有相依。
因為說實話,這次是你自己查出來的,下次不一定。人不會因為痛過一次就記得住,趕工的時候尤其不會。
git config core.hooksPath .githooks
mkdir -p .githooks
.git/hooks/ 不進版控,換一台機器就沒了,協作的人也不會有。改用 core.hooksPath 指到一個進版控的目錄,hook 檔案就跟著專案走。
跟著走的只有檔案。core.hooksPath 這行設定在每個人自己的 git config 裡,clone 不會帶過來,等一下會講我怎麼被這件事咬過。這行也會蓋掉你原本設的路徑,先 git config --get core.hooksPath 看一眼。
cat > .githooks/pre-commit <<'EOF'
#!/usr/bin/env bash
# 擋的是還沒進歷史的那一刻。已經在歷史裡的,這個 hook 看不到也管不著
command -v gitleaks >/dev/null || {
echo "這個 hook 需要 gitleaks,但系統上找不到它"
echo " macOS: brew install gitleaks / 其他:github.com/gitleaks/gitleaks/releases"
exit 1
}
gitleaks git --staged --redact -v --exit-code 2
case $? in
0) ;;
2) echo "偵測到疑似祕密,commit 中止"; exit 1 ;;
*) echo "gitleaks 沒跑完,這次等於沒掃,commit 中止"; exit 1 ;;
esac
EOF
chmod +x .githooks/pre-commit
第一段那個 command -v 不是防禦性寫法,是我踩過。沒有它的話,你機器上沒裝 gitleaks 的時候,command not found 讓那行回非零,你看到的是「偵測到疑似祕密,commit 中止」。跟真的攔截到一模一樣。commit 被擋了,你以為 hook 在保護你,其實它一次都沒掃過。
後面那個 --exit-code 2 是同一個坑的第二層。gitleaks 預設用離開碼 1 表示「找到祕密」,但它自己跑不起來的時候回的也是 1。我拿一個不存在的設定檔試,離開碼跟真的找到祕密一模一樣。
換成 --exit-code 2 才分得開:0 是掃過而且乾淨,2 是真的找到,其他都是工具出事。所以 case 最後一支寫的是「這次等於沒掃」,不是「有祕密」。
(gitleaks git --staged 是現在的寫法。舊版那個 protect 還能跑,但已經從 --help 的清單裡拿掉了,哪天消失不會有人通知你。)
裝完不要就這樣算了。裝了沒測,跟沒裝的差別只有你心裡比較安穩,而那份安穩正是最貴的東西。
餵一個一定會被抓的字串給它:
openssl genrsa -out leak-test.pem 2048
git add leak-test.pem
git commit -m "should be blocked"
commit 要被擋下來。擋下來之後把它清掉,兩步都要,reset 只退索引不刪檔:
git reset leak-test.pem && rm leak-test.pem
為什麼用 openssl 現生一把,而不是隨手打一串假金鑰?因為越假的字串越測不出東西。
我本來寫的是 AKIAIOSFODNN7EXAMPLE,AWS 官方文件的範例值。實測(gitleaks 8.30.1)它一個都沒被抓到,換成真實格式的 AWS key 也一樣;同一個 ghp_ 開頭的 token,全填 0 的抓不到,隨機字元的抓得到。掃描規則有熵的門檻,因為它要壓誤報。
所以你拿一眼就假的字串去測,測到的是「這個字串太假」,不是「hook 沒作用」。openssl genrsa 生的是真私鑰,只是它從沒離開過你的機器,而 private key 的規則不看熵。
跟昨天那個誘餌是同一句話:驗證用的假資料要設計,隨手編一個會讓你驗到假的結果。
我自己第一次裝的時候,測試順利被擋,我很滿意。過兩天發現同事那邊完全沒作用,因為他 clone 下來之後沒跑那行 git config。hook 目錄進版控了,設定沒有。所以這行指令要寫進 README 的環境設定那一段,或是包進安裝腳本裡,不要只留在你的機器上。
還有一件事講在前面,免得你之後誤會它壞了:hook 擋的是未來,跟昨天的 ignore 一樣。 它不會回頭掃歷史,也不該期待它掃。昨天那句換皮又出現了一次,這已經是第二次,後面還會有。
也要知道它擋不掉什麼。git commit --no-verify 一個參數就繞過去。所以它是你這台機器上的安全網,不是一道關卡;要當關卡得靠 CI 掃描或平台端的 push protection。今天先把自己這台補起來,它擋的是趕工時最容易犯的那一次。
最後才輪到清歷史。
git filter-repo 這類工具參數很細,很適合叫 AI 起草指令,但砍掉哪些路徑、哪些 commit 一定要你自己看過再按 Enter。這是今天唯一一個「AI 起草、人核對」比「人自己寫」快的地方。
在本機砍錯還有救,這份 clone 丟掉重抓就好。回不去的是 force push 之後,遠端跟每個協作者的歷史一起被改寫,那時候要收拾的就不只是你自己了。
然後你會發現一個很掃興的事實:清完歷史,別人 clone 過的那份還在,fork 出去的還在,CI 的快取可能還在,GitHub 的 pull request 頁面上那段 diff 常常也還在。
所以歷史清理的意義是什麼?是不要繼續散布,是讓下一個接手的人不會又撿到一份。它不是止血,止血在第一步就做完了。
這就是為什麼它排最後,而且今天做不完不算失敗。filter-repo 裝不起來、參數看不懂、大 repo 跑很久,不要熬夜硬幹。金鑰已經撤銷了,歷史裡那把是死的,你正在對著一串失效的字元做外科手術。
但要記得那不是免死金牌。前面撤銷的是 API 金鑰,它撤得掉。同一份 .env 如果還躺著簽章私鑰、個資、或是那種發出去就收不回的東西,歷史裡那份就不是死的,那要另外排時間,不能跟金鑰一起輕輕放下。
排序是這樣的:撤銷跟驗證今天一定要做完,接著查一眼用量,hook 今天裝好。歷史清理排進待辦,這個系列後面不會再回頭催你,所以現在就給它一個日期。
寫進待辦之前,這件事不算排好。 意思是版控裡還留著一串已經失效的字元,這是你知情之下暫時接受的狀態,不是處理完了。
前面這些做完,鑰匙撤了、驗過了,hook 也裝好了。版控那份排進待辦了。
還有一個地方,它連排進待辦都排不了。
這半年你跟 AI 講過幾次話?有沒有哪一次是把整段設定檔貼過去問「這樣寫對嗎」,或者把一串錯誤訊息連同環境變數一起丟進對話框?
那些請求送到供應商的伺服器了。前端的對話紀錄你刪得掉,那一份不歸你管。
我去翻了兩家的政策(2026-08 查證,你自己動手時要重看一次,這種東西每季在變)。
OpenAI 寫的是所有 API 使用都會產生濫用監控紀錄,預設最多留三十天,法律要求或保護服務時可以更久。
Anthropic 寫的是 API 的輸入與輸出預設三十天內從後端刪除。但被自動系統標記為違反使用政策的,輸入輸出最長留兩年,而那個判定分數本身留七年。
兩家都有 Zero Data Retention。OpenAI 那邊要事前核准並接受額外條件,官方叫你聯繫業務問資格,沒有自助申請的入口。
要注意這兩份講的都是 API。你把設定檔貼進去的那個對話框可能是聊天產品、可能是代理工具,各自的刪除與保存規則不一樣。要查就查你實際在用的那個產品跟方案,不要拿 API 文件套。
這些期限不是重點。重點是你在這裡失去的不是刪除權,是驗證能力。
金鑰你撤得掉,撤完還能自己打一發,看那台伺服器回一句 invalid_api_key。對話紀錄不行。你可以按刪除、可以去申請 ZDR、可以相信那個三十天,但你沒有一發可以打出來看。
回到今天第二節那句:你要的是伺服器自己回的東西,不是畫面上的宣稱。在這裡你只拿得到宣稱。
所以這一節的動作是把剛才那一行寫下來:這把金鑰有沒有進過 AI 對話、大概是什麼時候。萬一之後真的出事,你手上有一份「它從哪一天開始可能在外面」的時間點,而不是從零開始回想。
剩下的就是失效得夠快,快到那份紀錄裡的金鑰在還沒被誰用到之前就已經是死的。這是你在這條線上唯一還握得住的東西,也是為什麼它排在今天最前面。
你能讓金鑰失效,但你收不回已經送出去的字元。
這兩件事的落差就是明天要處理的東西。與其事後追著清,不如一開始就別貼出去。而「什麼可以貼、什麼不行」,多數人從來沒有寫下來過。
那份 .env 裡每一把鑰匙的清單,一把新金鑰,一筆舊鑰打回「憑證無效」的驗證紀錄,一行用量檢查的結論,一行這把金鑰有沒有進過 AI 對話的答案,還有一個進了版控、而且測過真的會擋的 pre-commit hook。
六樣裡面有四樣是紀錄。這不是形式,是因為這種事一年後你只會記得「好像處理過」,記不得處理到哪。
前三天走完,金鑰在你這邊的路徑清楚了:不在前端、外洩了知道怎麼處置、下次多一道本機攔截。版控裡那份還在,但它是死的,而且你已經知道什麼時候要清。送出去的那些連死沒死都驗不了,那才是明天的題目。
明天換一個方向。金鑰你守得住,因為你知道它長什麼樣子。但你昨天貼給 AI 的那段錯誤訊息裡面有什麼,你說得出來嗎?
昨天那份候選清單留著,明天要用。
明天見。
上一篇:Day 2|.env 沒有魔法盾牌,你的 AI 工具也讀得到
範例專案與可以跑的驗證腳本:github.com/cyh7789/ai-security