🗺️ 程式互動式地圖
資安線・安全編碼

機密管理:API 金鑰不要寫進程式

呼叫 LLM、雲端服務都需要 API 金鑰,最順手的寫法就是把它貼進程式裡。這一頁說明金鑰會從哪些地方漏出去、為什麼刪掉那一行還不夠、正確的存放與讀取方式,以及外洩之後該先做什麼。

15 張外洩情境判斷 Git 歷史時間軸與處置順序 日誌遮蔽器

💡 先搞懂問題

「晴空咖啡」的實習生小陳,用 Jupyter Notebook 做了一個 AI 訂位助理的雛形。為了先讓程式動起來,他把模型服務的 API 金鑰直接寫在第一個儲存格:API_KEY = "sk-…",測試成功後順手把整個資料夾推上 GitHub,方便給主管看。隔天他發現金鑰不該放在程式裡,又推了一個 commit 把那一行刪掉。一週後,公司收到模型服務的帳單,用量是平常的好幾十倍(情境為虛構)。

這個故事裡至少有四個破口。第一,金鑰寫死在程式碼(hard-coded secret):程式碼會被複製、分享、備份、貼進聊天室,金鑰就跟著散出去。第二,Git 會保留每一個版本:刪掉那一行只是新增一個版本,前一個版本裡的金鑰仍然完整存在,任何能 clone 這個倉庫的人都看得到。第三,公開倉庫會被自動化程式持續掃描,推上去之後很快就可能被找到。第四,金鑰的權限太大又沒有用量上限,被拿走之後能造成的損害沒有邊界。

新手最常卡在兩個誤解:一是以為「倉庫是私有的就沒關係」,但私有倉庫一樣會被加入新成員、被 fork、被備份到其他地方,也可能哪天改成公開;二是以為「發現之後刪掉就好」,事實上刪檔不會讓已經被看過或複製走的金鑰失效。機密管理(secrets management)要處理的,就是讓金鑰、密碼、權杖這類機密從頭到尾不進入程式碼與版本控制,並且在外洩時能快速讓它失效。

NotebookAPI_KEY="sk-…" git commit進入版本歷史 git push公開倉庫 自動化掃描找到金鑰 下一個 commit 刪掉那一行 舊版本仍在歷史裡,clone 就拿得到 被濫用、帳單暴增 ① 寫死在程式碼程式被複製到哪,金鑰就跟到哪 ② Git 保留每個版本刪除只是多一個版本 ③ 公開倉庫會被掃描推上去之後很快就可能被找到 ④ 權限太大、沒有上限被拿走後損害沒有邊界
上排是金鑰從筆電一路流到攻擊者手上的路徑,中間的虛線框是最常見的誤解:刪掉那一行並不會讓它從歷史消失。下方四個紅框,是後面各節要逐一補起來的破口。

生活比喻:大樓的門禁卡

想像一棟辦公大樓,每間公司都有門禁卡。最糟的做法,是把一張萬用門禁卡用膠帶黏在一樓公告欄上,方便新同事取用;後來覺得不妥,就撕下來,但公告欄在這段時間已經被路人拍照、被清潔公司影印存檔。比較好的做法,是每位員工上班時到警衛室登記領卡,卡片不帶出大樓;更好的做法,是由保全中心統一發卡,每張卡只開得了需要的樓層,會記錄誰在什麼時候刷了哪扇門,而且每季自動換新。真的有卡片遺失時,第一件事是打電話給保全中心掛失,讓那張卡刷不開任何門,而不是回頭去找誰撿到了它。

門禁卡(比喻) 程式裡的對應 卡片黏在公告欄 金鑰寫死在程式碼 撕下了,但照片已流出 刪掉那行,Git 歷史仍在 上班時到警衛室領卡 執行時從環境變數讀取 保全中心統一發卡、記錄 秘密管理服務、稽核紀錄 只開需要的樓層、每季換卡 最小權限、定期輪替 遺失時先掛失 外洩時先撤銷金鑰
前兩列是錯誤示範,中間三列是由淺到深的正確做法,最後一列是事故處理的第一步。注意最後一列的方向:掛失與撤銷都是讓「卡片本身失效」,而不是去追回卡片。
回到程式:黏在公告欄的卡,就是寫在 .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… 這種明顯的假值)。請判斷它屬於哪一類:會外洩(金鑰已經或必然會出現在別人拿得到的地方)、有風險(目前沒露出,但很容易出事或出事時損害太大),或合理做法。判斷時問自己:金鑰的原文最後存在哪裡?誰拿得到那個地方?答完會說明理由,右邊的分類框會亮起正解。

得分 0 / 0
連續答對 0
共 15 張
🔴 會外洩原文已進入別人拿得到的地方
🟠 有風險暫時沒露出,但容易出事或損害太大
🟢 合理做法原文不進程式碼與版控,執行時才取得
畫面說明:先找出金鑰原文最後存在哪裡。

🎮 互動實驗室二:Git 歷史時間軸與處置順序

上半部模擬小陳的倉庫。按「下一個 commit」會一步步長出四個版本,點時間軸上任一個圓點,可以看那個版本的 config.py 與這次變更的差異(綠色是新增、紅色是刪除)。全部長完後,分別按「只掃目前檔案」與「掃描全部歷史」,比較兩者的結果。下半部是外洩後的處置順序排序題:電腦上可以把左邊的卡片拖到右邊的格子;手機或想用點的話,先點一張卡片,再點要放的格子。commit 編號為示意。

config.py
這次的變更

外洩後的處置順序

小陳的金鑰已經在公開倉庫的歷史裡。把下面五個動作排成最恰當的順序,排好後按「檢查順序」。

待排的動作
處置順序
畫面說明:時間軸還是空的,按「下一個 commit」開始。

🎮 互動實驗室三:日誌遮蔽器

點選一行預設的日誌,或在框裡自己貼一行(請只用 sk-DEMO… 這類假值)。下方三條遮蔽規則會依序套用,上框紅底標出被判定為機密的部分,下框是遮蔽後實際寫進日誌的內容。取消勾選某一條規則,看看會漏掉什麼。規則和結果都與頁面下方的 Python 程式一致,並用 Python 實跑核對過。

遮蔽前(紅底是被規則抓到的機密)
遮蔽後:mask(line)
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
本機開發.env + load_dotenv().env 在 .gitignore 裡 CI/自動測試平台的 secrets 設定只給需要的工作流程 正式環境Secret Manager/Key Vault執行時注入、可輪替 os.environ api_key = os.environ["OPENAI_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,它只在那一步臨時掛載,不寫進任何一層。

映像檔是疊起來的層(示意) 第 1 層 FROM python:3.13-slim 第 2 層 COPY . .(含 .env) 第 3 層 RUN rm .env 最上層:看起來沒有 .env 刪除只是在上一層「蓋掉」,第 2 層的檔案仍在映像檔裡 正確做法 ① .dockerignore 排除 .env ② 不用 ENV/ARG 放機密 ③ 執行時注入: --env-file 或平台秘密 ④ 建置要用:BuildKit --mount=type=secret
左邊由下往上是映像檔的層。紅色那一層把 .env 帶了進去,後面的刪除只是讓最上層看不到,任何能下載映像檔的人都能把每一層解開來看。右邊是不讓金鑰進入任何一層的做法。
# 建置:.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,每筆紀錄輸出前都先經過它,把符合規則的部分換成 ****,這正是實驗室三的規則。錯誤訊息同理:回給使用者的錯誤頁只說「發生錯誤,請稍後再試」,細節寫進遮蔽過的伺服器日誌,正式環境不要開除錯模式。

log.info(…)含 api_key=DEMO… MaskSecretslogging.Filter換成 **** Formatter加時間、等級 檔案/平台多人看得到 遮蔽前:url=https://api.example.com/menu?api_key=DEMO1234567890abcdef&q=latte 遮蔽後:url=https://api.example.com/menu?api_key=****&q=latte
過濾器掛在 Handler 上,所以不論哪個模組、哪個套件產生的紀錄,寫出去之前都會經過它。下方兩行是範例程式實跑的前後對照,金鑰為示意的假值。
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;五、加上掃描與防護,避免再發生。

1初始化專案 2加入金鑰 3刪除金鑰 4目前版本 掃目前檔案:沒有 掃全部歷史:在這裡找到 處置順序 ① 撤銷換新止血 ② 查使用紀錄有沒有被用 ③ 改程式部署新金鑰不落地 ④ 清理歷史filter-repo ⑤ 加上掃描防止再犯
上半部是實驗室二的倉庫:只看目前檔案找不到金鑰,掃全部歷史才會在第 2 個 commit 找到(用真實 git 驗證過,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 .              # 只掃目前的檔案
① pre-commitgit commit 時掃描金鑰不進歷史 ② CI 掃描合併前再檢查一次結束碼 1 讓流程失敗 ③ 平台防護push protectionsecret scanning 警示 越早:成本越低、金鑰越不會留下痕跡 越晚:已進歷史,仍要撤銷 三道都可能被略過或漏判,所以仍要搭配撤銷流程與最小權限
由左到右是時間順序。左邊的 pre-commit 擋下時,金鑰還只在自己的電腦上;右邊的平台防護觸發時,金鑰可能已經進入本機的歷史。任何一道都不是百分之百,所以第 6 節的撤銷流程永遠要準備好。

8. 最小權限與定期輪替:讓外洩的損害有上限

就算做好以上所有事,也要假設金鑰總有一天會外洩,並讓那一天的損害有限。最小權限(least privilege):每個專案、每個環境用不同的金鑰,權限只開需要的功能(例如只能呼叫模型、不能管理帳號),並設定用量上限與帳單警示。定期輪替:OWASP 建議定期更換機密,讓被偷走的值只在很短的時間內有效。輪替時通常讓新舊兩把金鑰短暫並存,等所有服務都換上新的,再撤銷舊的,以免服務中斷。

輪替:新舊短暫並存,再撤銷舊的(週數為示意) 第 0 週第 12 週第 13 週第 16 週 舊金鑰 新金鑰 並存切換 ✕ 撤銷 每個環境不同金鑰開發、測試、正式分開 只開需要的權限能呼叫,不能管理 用量上限與警示異常時馬上知道
上半部是一次輪替:虛線框內新舊金鑰同時有效,讓各服務依序換上新值,之後立刻撤銷舊的。下半部三格是最小權限的具體做法,目的都是讓「某一把金鑰外洩」只影響一小塊。週數只是示意,實際頻率依服務與風險而定。

判斷步驟

  1. 程式碼、notebook、Dockerfile、前端程式裡有沒有金鑰原文?有的話改成從環境變數或秘密管理服務讀取。
  2. 金鑰檔(.env)有沒有列入 .gitignore 與 .dockerignore?有沒有曾經被 commit 過?
  3. 金鑰會不會被印出來:日誌、錯誤訊息、notebook 輸出、CI 紀錄?加上遮蔽並移除印出的程式。
  4. 如果已經進入版控或被印出:先撤銷換新,再查使用紀錄、改程式、清理歷史。
  5. 最後補上預防:pre-commit、CI 掃描、push protection、最小權限、用量上限與定期輪替。

容易寫錯或考錯的地方

「再 commit 一次把那行刪掉」:舊版本仍在歷史裡。第一步永遠是撤銷並換新金鑰。
「倉庫是私有的,所以沒關係」:私有倉庫的成員、fork、備份都拿得到;改成私有也不能讓已被複製的金鑰失效。
Base64 當成加密:Base64 是編碼,任何人都能還原,不是保護機密的方法。
os.environ.get("KEY", "sk-…"):預設值把金鑰寫回程式裡,缺值時還會默默使用。必要的設定缺值應該直接報錯。
先 commit 再補 .gitignore:.gitignore 對已被追蹤的檔案無效,要 git rm --cached,而歷史裡的那份仍需處理。
Dockerfile 裡 COPY 後再 RUN rm:前一層仍保留檔案;ENV、ARG 也會留下紀錄。用 .dockerignore 與執行時注入。
前端環境變數:VITE_、NEXT_PUBLIC_ 開頭的變數會進入瀏覽器拿得到的程式碼,不能放金鑰。
以為 CI 會自動遮住所有機密:遮罩靠完全比對,轉換過或包在結構化資料裡的值不保證被遮住;不要印出機密。

✅ 自我檢測

以下 6 題都是原創題目,有輸出的程式都用 Python 3 或 git 實際跑過。選完會立即顯示對錯與解析,全部作答後會出現總分。目前得分:0 / 6