症狀

為了不讓版控裡留著可用的密碼,我把 compose 的弱預設改成必填:

# 改前:沒設變數時,靜默使用版控裡那組人人看得到的值
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-<弱預設值>}
 
# 改後:沒設就拒絕解析
- ADMIN_PASSWORD=${ADMIN_PASSWORD:?請在 env 檔設定}

改完當下是對的。但下一個跑 make check 的人會發現靜態檢查自己先紅了,而且紅的原因跟他改的東西毫無關係。

這不是只發生一次。我前後在三個專案裡依序踩到同一個坑,包括我自己拿來當範本的那一個。

原因

靜態檢查跑 docker compose config,本意是驗「這份 compose 檔寫得對不對」。但這個指令做的事是「把解析完的設定印出來」,而解析的第一步就是變數插值(Docker Compose 變數插值)。

官方文件對兩種語法的定義是:

  • ${VAR:-default}:VAR 有設定且非空就用它,否則用 default。
  • ${VAR:?error}:VAR 有設定且非空就用它,否則「exit with error」。

所以必填欄位一缺值,compose 在插值階段就退出,根本走不到結構檢查。我這次看到的訊息是 required variable ... is missing a value,exit code 非 0。

換句話說:**一個純粹讀檔的檢查,被迫要求你先有一組真祕密。**這顯然是反的。

同一個形狀還有第二版:compose 的 env_file 指向實際的環境檔,而那個檔是 gitignored 的。乾淨 clone 或新開的 git worktree 上它根本不存在,解析一樣失敗。CI 上更是一定不存在。

修法:只在檢查情境餵明顯的假值

檢查用的假值要同時滿足兩個條件:

  1. 一眼看得出是假的,不會有人誤以為那是可以拿去用的密碼。
  2. 不會讓祕密掃描器誤報。我統一用 placeholder-for-check,因為 gitleaks 的放行規則裡本來就有 (?i)placeholder 這條樣式。

而且假值只活在檢查指令的環境變數裡,不寫進任何版控的環境檔。

寫法一:env_file 路徑吃變數的專案

# 靜態檢查不該需要真祕密,也不該要求先建 env 檔:
#   1. compose 的 :? 必填欄位餵明顯的假值
#   2. APP_ENV_FILE 指向版控裡的範本檔
ENV_TEMPLATE := example.env   # 換成你 repo 裡的範本檔名
COMPOSE_STUB := DB_PASSWORD=placeholder-for-check APP_ENV_FILE=$(ENV_TEMPLATE)
 
compose-config: ## compose 檔都要解析得過
	$(COMPOSE_STUB) docker compose -f compose.yaml config -q

compose 那邊對應的寫法是 env_file: ${APP_ENV_FILE:?...},讓實際環境檔的路徑也由外部決定。

寫法二:env_file 寫死檔名、不吃變數覆蓋的專案

退一步用「不存在就建臨時檔、跑完刪掉」:

compose-config:
	@E="app.env"; TMP=0; \
	if [ ! -f "$$E" ]; then TMP=1; printf 'APP_SECRET=placeholder-for-check\n' > "$$E"; fi; \
	rc=0; \
	for f in $$(git ls-files | grep -E 'compose.*\.ya?ml$$'); do \
	  docker compose -f "$$f" config -q >/dev/null 2>&1 || { echo "compose 解析失敗:$$f"; rc=1; }; \
	done; \
	[ "$$TMP" = "1" ] && rm -f "$$E"; \
	exit $$rc

這裡的 app.env、APP_SECRET 是示意名稱,換成你專案實際的檔名與變數。已經有環境檔的人不會被覆蓋,沒有的人也跑得動。

(未查證:docker compose config 在 env_file 指向不存在的檔案時的確切行為與錯誤訊息,官方插值文件沒有寫;上面「解析失敗」是我在當時版本上觀察到的結果。新版 compose 的 env_file 支援 required: false 的長語法,能不能用來取代寫法二,我沒有驗證。)

為什麼不用別的方法

不要把必填改回弱預設。 那等於為了讓檢查通過而放棄安全強化,本末倒置。

不要讓檢查跳過 compose。 compose 檔沒人檢查的話,語法錯誤要到 up 的時候才發現。我的範本專案原本就是這樣:validate 裡有 helm lint、kubeconform、契約檢查,唯獨沒人看 compose 檔本身——因為它一直「跑得起來就代表沒問題」,直到有人動了它。

不要把假值寫進版控的環境檔。 那就回到「版控裡躺著一組值」的老問題,只是換了一個比較好聽的名字。

這件事的一般形式

安全強化幾乎都有這個形狀:把靜默的預設行為改成明確的失敗,然後你會發現有一堆東西一直在依賴那個靜默的預設。

同一輪我還撞到兩個親戚:

  • 容器改成非 root 執行之後,掛載進去的目錄寫不進去,要另外處理擁有者。
  • 拿掉啟動時自動同步 schema 的機制之後,發現有一張表從來沒有 migration。

處理順序都一樣:先找出誰在依賴那個預設,再決定是補上明確設定,還是改掉依賴。不要因為「改了東西就壞」而把改動退回去——會壞,正是它原本一直在靜默生效的證據。

怎麼預防再次發生

  • 改弱預設為必填的同一筆 commit 裡,就把靜態檢查的假值補上。
  • 檢查用的假值統一用一個明顯的字樣(例如 placeholder-for-check),祕密掃描器的放行規則放行這個樣式。
  • 新 repo 建 make check 時,第一個目標就是 compose 解析,並且假設沒有環境檔。
  • 反向驗證:拿掉假值,要看到 compose 報缺值錯誤並 exit 非 0;補上假值,要看到解析成功。只看到「檢查通過」不算驗過。

相關