💡 先搞懂問題
「晴空咖啡」的實習生小陳,用 Jupyter Notebook 做了一個 AI 訂位助理的雛形。為了先讓程式動起來,他把模型服務的 API 金鑰直接寫在第一個儲存格:API_KEY = "sk-…",測試成功後順手把整個資料夾推上 GitHub,方便給主管看。隔天他發現金鑰不該放在程式裡,又推了一個 commit 把那一行刪掉。一週後,公司收到模型服務的帳單,用量是平常的好幾十倍(情境為虛構)。
這個故事裡至少有四個破口。第一,金鑰寫死在程式碼(hard-coded secret):程式碼會被複製、分享、備份、貼進聊天室,金鑰就跟著散出去。第二,Git 會保留每一個版本:刪掉那一行只是新增一個版本,前一個版本裡的金鑰仍然完整存在,任何能 clone 這個倉庫的人都看得到。第三,公開倉庫會被自動化程式持續掃描,推上去之後很快就可能被找到。第四,金鑰的權限太大又沒有用量上限,被拿走之後能造成的損害沒有邊界。
新手最常卡在兩個誤解:一是以為「倉庫是私有的就沒關係」,但私有倉庫一樣會被加入新成員、被 fork、被備份到其他地方,也可能哪天改成公開;二是以為「發現之後刪掉就好」,事實上刪檔不會讓已經被看過或複製走的金鑰失效。機密管理(secrets management)要處理的,就是讓金鑰、密碼、權杖這類機密從頭到尾不進入程式碼與版本控制,並且在外洩時能快速讓它失效。
生活比喻:大樓的門禁卡
想像一棟辦公大樓,每間公司都有門禁卡。最糟的做法,是把一張萬用門禁卡用膠帶黏在一樓公告欄上,方便新同事取用;後來覺得不妥,就撕下來,但公告欄在這段時間已經被路人拍照、被清潔公司影印存檔。比較好的做法,是每位員工上班時到警衛室登記領卡,卡片不帶出大樓;更好的做法,是由保全中心統一發卡,每張卡只開得了需要的樓層,會記錄誰在什麼時候刷了哪扇門,而且每季自動換新。真的有卡片遺失時,第一件事是打電話給保全中心掛失,讓那張卡刷不開任何門,而不是回頭去找誰撿到了它。
.py、notebook 或 Dockerfile 裡的金鑰;撕下公告但照片已經流出,就是下一個 commit 刪掉金鑰、但 Git 歷史仍保留舊版本。上班時到警衛室領卡,對應程式在執行時才用 os.environ["OPENAI_API_KEY"] 從環境變數讀取,本機開發用 python-dotenv 讀 .env,而 .env 列在 .gitignore 裡不進版控。保全中心則是 AWS Secrets Manager、Azure Key Vault、Google Secret Manager 這類秘密管理服務:集中保管、依權限發放、留下存取紀錄、可以自動輪替(rotation,定期換成新的金鑰)。掛失就是到服務商後台撤銷(revoke)金鑰,讓它立刻失效。這個比喻有兩個地方和真實情況不同。第一,門禁卡是實體的,被拿走你會發現少了一張;金鑰是一串文字,被複製了你完全不會察覺,所以只要懷疑外洩,就要當成已經外洩處理。第二,大樓只有一個公告欄,程式的「公告欄」卻很多:日誌、錯誤訊息、notebook 的輸出、Docker 映像檔、打包後的前端 JavaScript、CI 的執行紀錄,都可能把金鑰原文印出來。下一個實驗室就是在練習辨認這些地方。
🎮 互動實驗室一:這個金鑰會不會外洩
每張卡描述晴空咖啡團隊處理金鑰的一種做法,附上相關的程式或設定(金鑰都是 sk-DEMO… 這種明顯的假值)。請判斷它屬於哪一類:會外洩(金鑰已經或必然會出現在別人拿得到的地方)、有風險(目前沒露出,但很容易出事或出事時損害太大),或合理做法。判斷時問自己:金鑰的原文最後存在哪裡?誰拿得到那個地方?答完會說明理由,右邊的分類框會亮起正解。
🎮 互動實驗室二:Git 歷史時間軸與處置順序
上半部模擬小陳的倉庫。按「下一個 commit」會一步步長出四個版本,點時間軸上任一個圓點,可以看那個版本的 config.py 與這次變更的差異(綠色是新增、紅色是刪除)。全部長完後,分別按「只掃目前檔案」與「掃描全部歷史」,比較兩者的結果。下半部是外洩後的處置順序排序題:電腦上可以把左邊的卡片拖到右邊的格子;手機或想用點的話,先點一張卡片,再點要放的格子。commit 編號為示意。
外洩後的處置順序
小陳的金鑰已經在公開倉庫的歷史裡。把下面五個動作排成最恰當的順序,排好後按「檢查順序」。
🎮 互動實驗室三:日誌遮蔽器
點選一行預設的日誌,或在框裡自己貼一行(請只用 sk-DEMO… 這類假值)。下方三條遮蔽規則會依序套用,上框紅底標出被判定為機密的部分,下框是遮蔽後實際寫進日誌的內容。取消勾選某一條規則,看看會漏掉什麼。規則和結果都與頁面下方的 Python 程式一致,並用 Python 實跑核對過。
import re
RULES = [ # 依序套用;每條是(樣式, 取代字串)
(re.compile(r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]{8,}"), r"\1****"), # ① Bearer 權杖
(re.compile(r"(?i)(?<![A-Za-z0-9_])([A-Za-z0-9_]*(?:api_?key|secret|token|passw(?:or)?d)"
r"[A-Za-z0-9_]*)=([^ \t&\"']+)"), r"\1=****"), # ② 名稱像機密的 key=value
(re.compile(r"(?<![A-Za-z0-9_])(sk-[A-Za-z0-9]{4})[A-Za-z0-9_-]{4,}"), r"\1****"), # ③ sk- 開頭的金鑰,只留前綴
]
def mask(line: str) -> str:
for pat, repl in RULES:
line = pat.sub(repl, line) # 前一條的結果交給下一條
return line
📘 原理補完
實驗室裡的判斷可以濃縮成一個問題:金鑰的原文最後存在哪裡、誰拿得到?機密管理的所有做法,都是在減少「存原文的地方」、縮小「拿得到的人」、縮短「拿到之後能用多久」。下面依序說明外洩的常見位置、從開發到正式環境的正確存放方式、日誌遮蔽、外洩後的處置,以及事前的掃描與權限設計。
1. 金鑰會從哪裡漏出去
| 位置 | 為什麼會外洩 | 正確做法 |
|---|---|---|
| .py 原始碼 | 程式碼會被分享、備份、推上倉庫 | 只寫「去哪裡拿」:os.environ["KEY"] |
| Git 歷史 | 刪除只是新增一個版本,舊版本仍可 checkout | 先撤銷金鑰,必要時再改寫歷史 |
| .env 檔 | 沒列進 .gitignore,git add . 就一起提交 | 列入 .gitignore,另附只有欄位名稱的 .env.example |
| Notebook 輸出 | .ipynb 是 JSON,儲存格的輸出也一起存檔 | 不要印出金鑰;提交前清除輸出 |
| Docker 映像檔 | ENV、ARG、COPY .env 會留在映像層與建置紀錄 | 執行時注入;建置需要時用 BuildKit 的 secret 掛載 |
| 前端 JavaScript | 打包後的程式碼會下載到每個使用者的瀏覽器 | 金鑰只放後端,前端呼叫自己的後端 |
| 日誌與錯誤訊息 | 日誌會集中到很多人看得到的平台,錯誤頁可能回給使用者 | 不記錄機密;加上遮蔽過濾器當安全網 |
| CI 執行紀錄 | 自動遮罩靠完全比對,轉換過的值不保證會被遮住 | 不要印出機密;衍生值也登記成 secret |
2. 環境變數與 .env:本機開發怎麼做
環境變數(environment variable)是作業系統交給每個程序的一組「名稱=值」設定,Python 用 os.environ 讀取。它讓同一份程式碼在不同地方(自己的筆電、CI、正式環境)拿到不同的金鑰,而金鑰本身不必出現在程式碼裡。兩種讀法的差別要分清楚:os.environ["KEY"] 缺值時丟 KeyError;os.environ.get("KEY")(與 os.getenv("KEY") 相同)缺值時回傳 None。必要的金鑰缺值時應該立刻報錯停下;最糟的寫法是 os.environ.get("KEY", "sk-…"),等於又把金鑰寫回程式裡。
本機開發每次都手動設定環境變數很麻煩,常見做法是把設定寫在專案根目錄的 .env 檔,再用 python-dotenv 套件的 load_dotenv() 在程式啟動時讀進環境變數。它預設不會覆蓋已經存在的同名變數(override=False),所以正式環境由平台注入的值會優先。關鍵是 .env 一定要列入 .gitignore,另外提交一份只有欄位名稱、沒有值的 .env.example,讓新同事知道要設定哪些變數。還要記得:.gitignore 只對「尚未被追蹤」的檔案有效,已經 commit 過的 .env 要先 git rm --cached .env 才會停止追蹤,而且歷史裡的那一份仍在。
import os
from pathlib import Path
from dotenv import load_dotenv # pip install python-dotenv
Path(".env").write_text("OPENAI_API_KEY=sk-DEMO0000AAAA1111BBBB2222\n") # 示範用假值;真實的 .env 不進版控
load_dotenv() # 讀 .env 放進環境變數;預設不覆蓋已存在的變數
def require(name: str) -> str:
value = os.environ.get(name)
if not value: # 缺值就立刻停下,不要退回寫死的預設金鑰
raise RuntimeError(f"缺少環境變數 {name}")
return value
api_key = require("OPENAI_API_KEY")
print("金鑰已載入:", api_key[:7] + "…(共", len(api_key), "字)") # 金鑰已載入: sk-DEMO…(共 27 字)
try:
require("PAYMENT_API_KEY")
except RuntimeError as e:
print(e) # 缺少環境變數 PAYMENT_API_KEY
os.environ。這也是為什麼程式裡不該有「缺值時退回某個預設金鑰」的寫法,因為那會讓三個環境的差異被藏起來。3. 正式環境:交給秘密管理服務
環境變數解決了「不進程式碼」,但還有限制。OWASP 的 Secrets Management Cheat Sheet 提醒,環境變數通常同一台機器上的其他程序也讀得到,也可能出現在日誌或系統傾印(dump)裡,所以建議只在沒有更好方法時使用。正式環境的做法是交給秘密管理服務:AWS Secrets Manager、Azure Key Vault、Google Cloud Secret Manager,或自建的 HashiCorp Vault。它們提供集中保管與加密、依身分授權(例如只有這個雲端服務帳號能讀這把金鑰)、存取稽核紀錄,以及自動輪替。程式在啟動或需要時才向服務取得金鑰,或由部署平台在啟動容器時把值注入成環境變數或掛載成檔案。
4. Docker 映像檔與前端程式:兩個容易忽略的公告欄
Docker 映像檔是一層一層疊起來的,每個指令產生一層。Dockerfile 裡寫 ENV OPENAI_API_KEY=…,值會留在映像的設定裡,docker inspect 就看得到;用 ARG 傳進建置過程的值,Docker 官方文件也提醒會出現在建置紀錄中,不適合傳機密;COPY . . 把 .env 一起複製進去,即使下一層 RUN rm .env,前一層裡的檔案仍在,任何拿到映像檔的人都能取出。正確做法是用 .dockerignore 排除 .env,執行時再用 docker run --env-file 或雲端平台的秘密注入功能提供;建置過程真的需要金鑰(例如下載私有套件)時,用 BuildKit 的 RUN --mount=type=secret,它只在那一步臨時掛載,不寫進任何一層。
# 建置:.dockerignore 已排除 .env,映像檔裡沒有金鑰
docker build -t cafe-bot .
# 執行:金鑰在啟動容器時才注入成環境變數
docker run --env-file .env cafe-bot
# 檢查:映像檔的設定裡不應該出現任何金鑰
docker inspect --format '{{.Config.Env}}' cafe-bot
前端程式更直接:任何送到瀏覽器的 JavaScript,使用者都能用開發者工具看到。Vite 會把 VITE_ 開頭的環境變數、Next.js 會把 NEXT_PUBLIC_ 開頭的環境變數寫進打包後的程式,這些前綴的意思就是「公開」。呼叫模型服務的金鑰只能放在後端:前端呼叫自己的後端 API,由後端帶著金鑰去呼叫外部服務,並在後端做身分驗證、用量限制與記錄。
5. 日誌與錯誤訊息:遮蔽是安全網
最好的做法是根本不把機密交給 logger:記錄「呼叫了哪個服務、結果如何」,不記錄 headers 或完整的連線字串。但日誌常常包含別人寫的訊息,例如套件把請求網址整串印出來、例外訊息帶著設定值,所以還要加一道遮蔽(masking)當安全網。Python 的 logging 可以在 Handler 上掛一個 Filter,每筆紀錄輸出前都先經過它,把符合規則的部分換成 ****,這正是實驗室三的規則。錯誤訊息同理:回給使用者的錯誤頁只說「發生錯誤,請稍後再試」,細節寫進遮蔽過的伺服器日誌,正式環境不要開除錯模式。
import logging, re
SECRET = re.compile(r"(?i)(?<![A-Za-z0-9_])([A-Za-z0-9_]*(?:api_?key|secret|token|passw(?:or)?d)[A-Za-z0-9_]*)=([^ \t&\"']+)")
BEARER = re.compile(r"(?i)(bearer\s+)[A-Za-z0-9._~+/=-]{8,}")
class MaskSecrets(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
msg = record.getMessage() # 先把 %s 參數套進訊息
msg = BEARER.sub(r"\1****", msg)
record.msg = SECRET.sub(r"\1=****", msg) # 改寫成遮蔽後的版本
record.args = () # 參數已套用,清空避免再格式化一次
return True # True:這筆紀錄照常輸出
handler = logging.StreamHandler()
handler.addFilter(MaskSecrets()) # 掛在 handler 上,所有輸出都會經過
logging.basicConfig(level=logging.INFO, handlers=[handler], format="%(levelname)s %(message)s")
log = logging.getLogger("cafe")
log.info("呼叫菜單 API url=%s", "https://api.example.com/menu?api_key=DEMO1234567890abcdef&q=latte")
log.info("headers: Authorization: Bearer %s", "sk-DEMO0000AAAA1111BBBB2222")
log.warning("登入 user=amy password=%s", "Hunter2!demo")
# INFO 呼叫菜單 API url=https://api.example.com/menu?api_key=****&q=latte
# INFO headers: Authorization: Bearer ****
# WARNING 登入 user=amy password=****
遮蔽規則一定會有漏網之魚:沒有固定前綴、也沒有 key=value 形式的機密,regex 認不出來。所以它是最後一道網,不能取代「不要記錄機密」這條原則。
6. 外洩之後:先讓金鑰失效,再清理
OWASP 的建議順序是:外洩的金鑰立即撤銷(revocation),快速產生並部署新金鑰(rotation),再把已撤銷的值從外洩的地方移除(deletion)。對應到 Git 倉庫的情境,可以排成五步:一、到服務商後台撤銷並換發新金鑰;二、查看使用紀錄與帳單,確認外洩期間有沒有被濫用,必要時依公司流程通報;三、修改程式改從環境變數或秘密管理服務讀取,部署新金鑰;四、用 git filter-repo 這類工具改寫歷史、強制推送,並請協作者重新 clone;五、加上掃描與防護,避免再發生。
git grep 在 HEAD 找不到、搜尋全部 commit 才找得到)。下半部的順序重點在第一格:改寫歷史救不回已經被複製的金鑰,只有撤銷能讓它失效。為什麼不是先清歷史?因為公開過的內容無法確定沒被複製,清理只是「之後」不再被找到;而且改寫歷史需要協作者配合、可能有 fork 與快取,不會立刻完成。撤銷則是幾秒鐘就讓外洩的值變成廢字串。把倉庫改成私有、把金鑰改用 Base64 編碼放回去,也都不能讓它失效:前者擋不住已經複製的人,後者任何人都能解碼。
7. 事前預防:在提交前就擋下來
機密掃描(secret scanning)工具用已知格式(各家金鑰的固定前綴)與字串的隨機程度,找出疑似機密。放在越前面越便宜:pre-commit 在 git commit 時就檢查,金鑰根本不會進入歷史;CI 在合併前再掃一次;GitHub 的 secret scanning 會掃描倉庫,push protection 會在推送時擋下含有支援格式機密的提交。依 GitHub 文件,個人帳號的 push protection 預設開啟,防止機密推送到公開倉庫;倉庫層級的 push protection 預設關閉,要由管理者開啟;有寫入權限的人可以填寫理由略過,略過會產生警示。gitleaks 是常用的開源工具:gitleaks git 掃描整個 Git 歷史,gitleaks dir 掃描目前的檔案,找到時預設以結束碼 1 結束,可以讓 CI 失敗;確認是假值的那一行可加註 gitleaks:allow。
# 1. 在專案根目錄建立 pre-commit 設定(rev 填當時的版本,例如 v8.24.2)
cat > .pre-commit-config.yaml <<'EOF'
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.24.2
hooks:
- id: gitleaks
EOF
pip install pre-commit
pre-commit install # 之後每次 git commit 前自動掃描,發現機密就中止提交
# 2. 一次性掃描既有的歷史與目錄(只掃自己有權限的倉庫)
gitleaks git -v # 掃描所有 commit,刪掉的內容也找得到
gitleaks dir . # 只掃目前的檔案
8. 最小權限與定期輪替:讓外洩的損害有上限
就算做好以上所有事,也要假設金鑰總有一天會外洩,並讓那一天的損害有限。最小權限(least privilege):每個專案、每個環境用不同的金鑰,權限只開需要的功能(例如只能呼叫模型、不能管理帳號),並設定用量上限與帳單警示。定期輪替:OWASP 建議定期更換機密,讓被偷走的值只在很短的時間內有效。輪替時通常讓新舊兩把金鑰短暫並存,等所有服務都換上新的,再撤銷舊的,以免服務中斷。
判斷步驟
- 程式碼、notebook、Dockerfile、前端程式裡有沒有金鑰原文?有的話改成從環境變數或秘密管理服務讀取。
- 金鑰檔(.env)有沒有列入 .gitignore 與 .dockerignore?有沒有曾經被 commit 過?
- 金鑰會不會被印出來:日誌、錯誤訊息、notebook 輸出、CI 紀錄?加上遮蔽並移除印出的程式。
- 如果已經進入版控或被印出:先撤銷換新,再查使用紀錄、改程式、清理歷史。
- 最後補上預防:pre-commit、CI 掃描、push protection、最小權限、用量上限與定期輪替。
容易寫錯或考錯的地方
git rm --cached,而歷史裡的那份仍需處理。✅ 自我檢測
以下 6 題都是原創題目,有輸出的程式都用 Python 3 或 git 實際跑過。選完會立即顯示對錯與解析,全部作答後會出現總分。目前得分:0 / 6