iT邦幫忙

2026 iThome 鐵人賽

DAY 2
0
AI Security

你該防的不是駭客,是你自己的 AI:在本機驗證你的防線系列 第 2

Day 2|.env 沒有魔法盾牌,你的 AI 工具也讀得到

  • 分享至 

  • xImage
  •  

大家好,我是中義。

昨天我們聊到把金鑰從前端搬走了,放進伺服器的環境變數,寫在 .env 裡。今天要問的是:那裡就安全了嗎?

「我 gitignore 了啊」

昨天那個朋友,代理寫完的隔天傳訊息給我。

「搞定了,key 移到後端,放 .env.gitignore 也加了。這樣總可以了吧?」

先說結論的一半:這一步是對的。

.env 不進版控,前端拿不到,DevTools 翻不出來,昨天那兩招回頭再驗一次都是乾淨的。從「金鑰躺在別人瀏覽器裡」到「金鑰躺在你自己的伺服器上」,這趟搬家關掉的是「瀏覽器直接拿得到金鑰」那條路徑。

但我還是請他順手跑一行指令,因為有個數字讓我不放心。GitGuardian 掃 2025 年的公開 commit,帶 Claude Code 共同作者標記的那些,祕密外洩率大約是全體基準的兩倍

這是相關性,不是因果。它涵蓋的只是帶標記的 commit,不能代表所有 AI 輔助寫出來的程式碼。我自己的猜測是它產得快、你看得少,但那是我的猜測不是報告的結論。

不過兩倍這個差距,足夠讓我多查一層。

就一行指令,跑完再開心。

git log --all --full-history --date=short --format='%h %ad %s' -- '*.env*'

三個參數都是必要的,少一個結果就不一樣。

--all 管的是還沒合併、你早就忘了的分支,還有遠端追蹤分支跟 tag。

--full-history 管的是那種「加進去、合併前又刪掉」的側支。合併後的結果跟主線看起來沒兩樣,預設的簡化邏輯就把整條側支跳過,而分支通常 merge 完就刪了,那個 commit 就不會出現在帶著這個 pathspec 的預設 git log 裡。它還在,只是這條指令走不到。

'*.env*' 的引號不能省,不然 shell 會先展開成你當下目錄裡的檔名。而寫死 .env 的話,檔案在 backend/.env 就查不到,它會安靜地回一個空的,看起來跟乾淨一模一樣。

--format 是為了讓日期出來。--oneline 比較好記,但它只印 hash 和訊息,這件事你要看的是時間。)

他跑完,安靜了一陣子。

8f3c1a2 2026-02-14 init: 專案初始設定

半年前,專案第一天。那時候還沒有 .gitignore

命中的意思是「這個 commit 動過符合名字的檔案」,.env.example 也符合,所以還要點開來看是什麼。先只列檔名,不要一上來就把內容倒出來:

git show --name-only --format='' 8f3c1a2 -- '*.env*'

.env 不是 .env.example 的話,再決定要不要看內容,而且在不會把輸出送給 AI 的地方看。代理工具的終端機、工作階段錄製、CI 日誌,都會把你印出來的東西收走。

他看了,是 .env,鍵和值都在。

(順帶一提,那個日期是 %ad,作者日期。它只幫你定位是哪一段時間的事,不是「從這天開始外洩」的證據。)

那份 key 現在在哪裡

我們把接下來的對話原樣寫下來,因為它很典型。

「可是我後來刪掉了啊。」

那你刪掉的是最新那份。半年前那個 commit 還在。

「那我把 .gitignore 補好,現在它不會再進去了嘛。」

對,以後不會了。但半年前那份,現在在誰的電腦上?

「……我 clone 過三次。還有一台是公司的機器。」

還有 fork 你的人、CI 跑過的快取、你自己備份的那顆硬碟。

git 的歷史不是一份會被覆蓋的檔案,是一串疊上去的快照。你刪掉的是最上面那層的內容,底下那層原封不動地待著,任何人 clone 下來都拿得到完整的一串。

一個管以後,一個管以前

所以這兩件事不能互相取代:補 ignore 跟查歷史,一個管以後,一個管以前。

.gitignore 不會動到已經寫進去的歷史,所以它不必排隊等,你現在就可以補。

要小心的是補完的那一刻,因為那裡有一個很甜的錯覺。.gitignore 補好了,git status 乾乾淨淨,看起來就是安全的樣子。

但那個乾淨只描述未來。ignore 管的是「接下來不要再進去」,它對已經進去的東西沒有任何作用。

這句話我建議記起來,因為它在三十天裡還會換皮出現好幾次:新加的防護不會回頭處理舊的爛攤子。

順帶講一個我自己踩過的。.gitignore 加了 .env,但那個檔案早就被 track 了,ignore 對已追蹤的檔案不生效,它照樣一次一次被 commit 進去。要先 git rm --cached .env 把它從索引裡拿掉,ignore 才開始管事。

加完要驗一次,但別用 git status --ignored | grep .env。檔案根本沒被忽略的時候它會出現在 Untracked 那一區,grep 照樣撈得到,你拿到的是一個假的通過。

git check-ignore -v .env,它會告訴你這個檔名匹配到哪一條規則。注意是「匹配到」不是「被擋」,拿 .env.example 去問,它照樣印出那條 ! 開頭的放行規則,看起來一樣。

順序也別反了,這一步要在 git rm --cached 之後才跑:還在追蹤的檔案它預設什麼都不印,你會看到空的,然後以為規則沒寫對。

還有一件事容易漏掉,不過一個人做的專案可以跳過下面三段git rm --cached .env 推出去之後,同事那邊會出事,而且看你們的檔案內容不同,症狀完全不一樣。

他那份跟版控裡那份一模一樣的話,pull 完檔案就消失了,而且 checkout 不回來,因為新版本裡根本沒有這個檔案了。

他那份填了自己的值(.env 本來就是各人填各人的),pull 會直接被擋下來,訊息叫他先 commit 或 stash。兩條都不好走:commit 的話他就把自己的金鑰寫進了一個本機 commit,stash 的話 pull 過得去,但 pop 回來是個未合併狀態,還要自己收。

所以推之前先講一聲,叫他 mv .env .env.bak,pull 完再搬回來。一個人的專案沒這問題。

順便把變體補齊,.env 通常不只一個:

.env*
!.env.example

先全部擋掉,再把當作範本的那份放行。這樣以後多出一個 .env.staging,你不用記得回來改,順手也蓋掉 direnv 的 .envrc

第二個洩漏面:它跑起來的時候

到這裡先把地圖攤開,因為後面還有兩條路要走。

同一份祕密的三個洩漏出口,以及各自防線的限制

要看的是右邊那一欄。同一份祕密有三個出口,每個出口的擋法都不一樣,而且每一種擋法都有它管不到的地方

剛才走完的是出口 1:.gitignore 擋得住還沒進去的,擋不住已經進去的。接下來由上往下,一條一條走。

版本庫是過去式的洩漏,還有一種是現在進行式的。

你的程式跑在伺服器上,環境變數就在那個行程的記憶體裡。只要有任何一條路徑會把它印出來,它就出得去,跟版控一點關係都沒有。

三條路徑,順著查:

第一條是框架的除錯畫面。程式一崩潰,有些框架的開發模式錯誤頁會把當下的執行環境攤在畫面上,環境變數也在裡面。哪些會、攤到什麼程度,每家不一樣,所以要自己去撞一次:故意丟一個錯,看那一頁印了什麼。這在本機很好用,部署到有外人連得到的機器上就是災難。

第二條是 log。啟動時把設定印出來確認一遍,是很自然的動作:

console.log("設定載入完成", process.env);

這行很容易在 code review 裡被當成一般的設定輸出滑過去,它看起來就是一行 log。但它會把整包環境變數寫進你的日誌服務,而日誌服務的存取權限通常比正式環境寬鬆得多。

第三條是錯誤回應,這條最不明顯,因為它藏在「把錯誤原樣往上傳」這個看起來很負責任的動作裡。

昨天那段代理有兩條回傳路徑。catch 那條只回一句 provider unavailable,那是刻意的。

但上面那條 res.status(r.status).json(await r.json()) 是把供應商回的東西原封不動轉給前端,而供應商的錯誤訊息長什麼樣你不控制。有些會在 401 的訊息裡帶回你送過去那把金鑰的一部分,方便你比對是不是貼錯了。

它就這樣一路轉到瀏覽器。

換一個 HTTP 客戶端還多一條路。axios 那類的錯誤物件帶著當初那個 request 的設定,Authorization 就在 error.config.headers 裡,錯誤處理寫成 res.status(500).json(err) 就整包送出去了。原生 fetch 拋的錯不帶 header,所以昨天那段沒有這個問題,但你哪天換掉客戶端就有。

換一把假的下去

自己驗得出來,不用猜。三步:把環境變數裡的金鑰換成一個誘餌重開伺服器、再打一次。

先講清楚,這是在本機做。正式環境跑這三步等於故意製造一次停機,所有打模型 API 的請求會全部失敗。

中間那步不能省。讀 .env 的載入器通常只在啟動時跑一次,你改了檔案不重開,跑著的那支用的還是舊的那把。

還有昨天那段程式碼裡的 api.provider.example,那是佔位網址。要換成你真的在用的那家,不然 fetch 連不上、掉進 catch 回 502,你什麼都沒驗到。

誘餌要怎麼寫,我第一次做的時候寫錯了。我把它寫成 sk-BAIT-0802-xxxx,然後去搜 BAIT-0802,搜不到,一度以為沒洩漏。

回頭看回應才知道,供應商回的是 sk-***802-xxxx有些供應商只回尾碼,因為那本來就是給你比對用的(我手上這兩家是這樣,你那家要自己看一眼)。

所以認得出來的那一段要放尾巴,sk-proj-000000000000BAIT0802 這種,前面補零湊長度,後面那串是你自己認得的。

為什麼不直接搜 sk-authorization 就好?因為那更糟。供應商回一句 Authorization header is required,一個字都沒洩漏,你也會搜到。誘餌是你自己種的,只有它出現才算數。

換好、重開,然後看你的前端收到什麼:

(
 resp=$(mktemp) || exit 1
 trap 'rm -f "$resp"' EXIT
 curl -sS -o "$resp" -w 'HTTP %{http_code}\n' \
 -X POST localhost:8787/api/chat \
 -H 'Content-Type: application/json' \
 -d '{"message":"hi"}'
 grep -c 'BAIT0802' "$resp"
)

為什麼不寫死一個 /tmp/resp.txtcurl 連不上的時候不會去覆蓋那個檔案,上一次跑的結果還躺在那裡,你 grep 到的是上一次的東西。我自己就踩過,同一支腳本第一次綠第二次紅。mktemp 每次給你一個新的。

外面那對括號也不是裝飾。EXIT 是「離開這個 shell」才觸發,直接貼進你正在用的終端機的話,那個 trap 要等你關掉視窗才會執行,而且會蓋掉你原本設的 EXIT trap。包成子 shell,離開括號就清掉,也不會動到外面。

-sS-s 多一個 S,連不上的時候它還是會講。狀態碼也要先看:服務沒通的時候它是 000,而下面那行照樣印一個很漂亮的 0 給你。

不是 0,你的誘餌就跟著錯誤回應出去了。回應寫進檔案不印在螢幕上,是因為印出來它就會被收走,終端機的捲動內容、代理工具的輸出、工作階段錄製、CI 日誌,都算,跟昨天那條 grep -rIl 同一個道理。不放心的話,把最後那行 grep -c 換成 cat 看一眼,反正裡面那把是假的。

是 0,只代表這一次、這條路徑、這個誘餌沒出去。換個輸入、換條 API 路徑,都要重來一次。

所以它是抽查,不是通過測試。真正的防線是在你自己那層把上游的錯誤收掉,換成你定義的格式往外送,而不是原樣轉出去或整包序列化。

跑完記得把金鑰改回來。回應檔 trap 會清掉,不用管。

第二個讀者

查到這裡,問題已經不是「.env 安不安全」了,是「這個專案的祕密總共住在幾個地方」。

我第一次被問到的時候答不出來,因為從來沒有數過。

而地圖上還剩出口 3 沒走。這些位置全都有第二個讀者,那才是這個系列的主題,而我原本想錯了。

我以為 AI 工具的忽略規則跟 .gitignore 是兩套獨立的東西,設了一邊不代表另一邊有效。去翻文件才發現沒那麼乾脆,而且每一家不一樣。

Cursor 的索引預設遵守 .gitignore,另外提供 .cursorignore(AI 功能和索引都排除)跟 .cursorindexingignore(只排除索引)。但它自己的文件講得很白:這是盡力而為,而且 agent 用的終端機跟 MCP 工具擋不住那條規則。

Claude Code 那邊,官方設定文件示範的擋法是在 permissions.deny 裡明確寫 Read(./.env)Read(./.env.*),換句話說你不寫它就讀得到。那個 ./ 跟開頭 git log 那個 pathspec 是同一個坑:它釘的是根目錄那份,backend/.env 不在裡面。

而那條擋的是它自己的讀檔工具,不是 cat。同一個 agent 開終端機跑一次 cat .env,走的是完全另一條規則,要擋在作業系統那層得靠沙箱。

兩家的破口是同一個形狀:規則管的是工具,不是內容。

所以別記某一家現在的行為,記這個動作:再放一次誘餌,換一個出口。剛才那把是往錯誤回應丟,這次往 AI 工具丟。在 .gitignore 擋掉的位置塞一把,然後開你的 AI 工具問它「這個專案的 API 金鑰是什麼」。

它答得出來,那條規則對它就是沒用。答不出來則什麼都不代表,剛才那個 cat 就是現成的反例。上面兩家我都是這樣驗的,你半年後換一個工具,這個動作照樣有效。(兩家行為 2026-08 查證,這種東西改版就變,所以才要自己驗。)

你自己讓它進版控的那三個

不過忽略規則就算全設對了,還有一個更容易漏掉的來源。

這次不是「它不理你的 .gitignore」,是那些你自己讓它進版控的檔案。

.env.example 裡懶得改,直接留了真的值。測試設定寫死一把 staging 金鑰,反正是測試環境。README 的快速開始貼了一段帶 token 的完整指令,方便別人複製。

這三個檔案 git 不擋,因為你親口說它們可以進版控。而任何讀你專案的東西都讀得到它們,AI 工具當然也是,第一次跑索引就一起上去了。

上面那些忽略規則其實擋得掉它們,.cursorignoreRead(...) 都吃已經追蹤的檔案。但那只是少一個讀的人:東西已經在版控裡,歷史、CI、下一個 clone 的同事全都繞得過去。

.gitignore 是你對 git 的宣告,不是對「什麼算祕密」的宣告。 這兩件事你以為是同一件,是因為過去你只把版控當成那條洩漏邊界。

找歸 grep,判斷才交出去

這一段可以交給 AI,但不是交「找」,是交「判斷」。

找的部分 grep 就夠了。先說清楚它找的是什麼:名字,不是祕密。

我拿五個真的會出事的東西試過它,連線字串、-----BEGIN RSA PRIVATE KEY-----、README 裡的 Authorization: Bearer sk-...、一個叫 STRIPE 的常數、JSON 裡的 "apiKey"。五個一個都沒撈到。前四個名字對不上,最後那個引號卡在冒號前面。

所以它是撒網,不是雷達。撈上來的是「叫這個名字的位置」,該不該擔心是下一步的事。

grep -rnIioE '[[:alnum:]_]*(api[_-]?key|secret|token|password)[[:alnum:]_]*[[:space:]]*[:=]' \
  --exclude-dir={node_modules,.git,dist} . | head -50

那個 -o 是刻意的,它只印匹配到的那一段,等號右邊的值根本不會進到輸出裡。理由跟剛才驗誘餌那條一樣:值一旦出現在輸出,就可能留在終端機的捲動內容、代理工具的輸出、工作階段錄製或 CI 日誌裡,而下一步你還要把這份清單貼給 AI。前後那兩個 [[:alnum:]_]* 是為了留下完整的變數名,不然 PROVIDER_API_KEY 只會印出 API_KEY,等一下貼給 AI 判斷就少了線索。

我原本是用 sed 把值換掉,後來發現那樣會漏。路徑裡只要有一個冒號,sed 的樣式就對不上,而 sed 對不上的行是原樣印出來的,整條金鑰就這樣出現在螢幕上,還不會報錯。-o 沒有這個問題,因為值從頭到尾沒有進去過。

.env 自己會出現在結果裡,那是預期的,跳過就好。你要看的是它以外的那些位置。

跑之前先看一眼總共幾筆(把 head -50 換成 wc -l)。超過五十筆的話,head 會安靜地砍掉後面那些,你會以為只有這些。

沒有專案可以練的話

手上沒有適合的專案的話,用這個系列的範例專案練一次(github.com/cyh7789/ai-security,MIT,隨便拿):

git clone https://github.com/cyh7789/ai-security
cd ai-security/playground

裡面那六筆散在四個位置:.env.example、測試設定、README 的快速開始、還有前端原始碼。金鑰都是假的,格式做得像真的而已。

今天講的那幾個「看起來沒事」,我也做成一支可以跑的腳本:

bash ../recipes/02-secret-scan-blind-spots/verify.sh

它在暫存目錄裡造六個最小情境,每個都把直覺的寫法跟補洞後的寫法並排跑一次,讓你看見前者漏掉或誤判了什麼。只要有 gitcurl,不用裝東西,跑完自己清掉。

難的是這一長串怎麼看。裡面有真祕密、有測試假值、有只是叫這個名字的變數,一個一個看你會看到放棄,然後開始整批無視。

這一步適合交出去,但交的不是「哪個是真的」。AI 看不到值,就算看得到也沒用,它不知道 sk-test-1234 是你三個月前隨手打的。交的是分類:

以下每一行是「檔案:行號:變數名」,值我已經拿掉了。

請只根據路徑和變數名判斷,不要去開這些檔案:哪幾個依照慣例會進版控
(設定範本、測試設定、文件、CI 設定、部署腳本),哪幾個是執行期產物
或只在本機。用表格輸出,理由各一句。

<把清單貼在這裡>

「不要去開這些檔案」那句不是客套。你手上如果是讀得到專案的 agent,它會很樂意自己回去把檔案打開對一遍,剛才那個 sed 就白遮了。

它挑出來的那幾個,就是剛才講的、會進版控而且 .gitignore 排不掉的位置。AI 工具那邊你當然可以另外設拒讀,但那不會讓已經寫進版控的東西變回祕密。至於哪個值是真的,你自己回去看,你認得出哪些是自己打的。這跟昨天那句是同一件事:能判斷的是你,工具只負責把候選遞過來。

今天結束時你手上有什麼

一份候選清單,還有一份補齊變體、而且驗證過生效的 .gitignore

候選這兩個字是認真的,理由前面講過了。清單不用漂亮,一個檔名配一句「這裡有什麼」就夠,重點是它查得到,不是記在你腦子裡。

如果你今天跑出來的結果跟我朋友一樣,歷史裡真的有一份,先不要改寫歷史,也不要 force push。但那把金鑰如果還活著,現在就去後台撤銷,這一件不能等到明天。刪 commit、刪 repo 都不會讓它失效。

反過來,查出來是空的也別急著放心。

最常見的漏法是檔名:那行指令只比對 *.env*config/secrets.jsoncredentials.ymlid_rsa 這些它一個都不認。把 pathspec 多給幾個 pattern 再跑一次:-- '*.env*' '*secret*' '*credential*' '*id_rsa*' '*.pem' '*.key'

其次是走不到的 commit。被 --amendreset --hard、rebase 丟掉的那些,git reflog 還看得到。reflog 過期之後,只要物件還沒被垃圾回收掉,git fsck --unreachable 仍然找得到;被回收掉就真的沒了。還沒抓下來的遠端分支、別人本機那份,它也看不到。

查得到代表那條路徑動過,查不到不代表沒有,跟昨天那條 grep 是同一句話。

撤銷只是第一步。而在撤銷之前先去刪 commit,不會讓那把金鑰失效一秒鐘。剩下的處置順序是明天的題目。

至於今天那份候選清單,留著別丟。過幾天我們要用它回答另一個問題:哪些東西可以貼給 AI,哪些不行。

明天見。


上一篇:AI 三十秒幫你串好的 API,金鑰在誰手上 ?

範例專案與今天那支驗證腳本:github.com/cyh7789/ai-security


上一篇
Day 1|AI 三十秒幫你串好的 API,金鑰在誰手上 ?
下一篇
Day 3|第一步是讓舊鑰失效,但送進 AI 的那些收不回來
系列文
你該防的不是駭客,是你自己的 AI:在本機驗證你的防線5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言