當一台雲端 Mac 同時負責擷取程式碼、建置、測試與發布時,最容易被忽略的邊界不是目錄,而是程序環境。流水線將權杖寫入環境變數後,xcodebuild、指令碼、測試宿主與背景子程序預設都會繼承這些變數。即使主要工作已經結束,尚未退出的輔助程序仍可能保留完整變數。正確做法不是禁用環境變數,而是將祕密的可見時間與可見程序縮減到最低限度。
先繪製變數傳播路徑
從 runner 啟動的每個程序都應歸入建置、測試、發布或輔助工具四類。建置與測試通常不需要發布權杖,輔助工具也不應繼承它。先記錄變數名稱、注入階段、使用命令與預期清除點,不要記錄真實值。
| 檢查對象 | 常見風險 | 驗收方式 |
|---|---|---|
| runner 父程序 | 啟動時取得所有祕密 | 僅檢查變數名稱是否存在 |
| 建置子程序 | 無條件繼承父程序環境 | 使用隨機標記執行繼承測試 |
| launchd 網域 | setenv 後跨工作保留 |
在工作前後分別檢查並清除 |
| 暫存檔案 | 權限過寬,或在異常退出後殘留 | 將權限設為 600,並使用 trap 刪除 |
| 日誌 | Shell 追蹤輸出命令參數 | 在處理祕密的階段關閉 set -x |
稽核指令碼只需回答「某個標記是否可見」,不要將環境快照儲存為建置附件。完整的
env、export或ps eww輸出本身就可能成為新的洩漏來源。
使用隨機標記驗證繼承行為
不要使用真實權杖進行實驗。產生一次性標記、啟動生命週期短暫的子程序,再以靜默方式判斷它是否能看到該標記。以下檢查不會將標記內容輸出到日誌,但退出碼可以告訴流水線是否發生了繼承。
set -euo pipefail
marker="$(/usr/bin/uuidgen)"
export CI_SECRET_PROBE="$marker"
/bin/sleep 20 &
child_pid=$!
if /bin/ps eww -p "$child_pid" -o command= | /usr/bin/grep -q "$marker"; then
result=1
else
result=0
fi
kill "$child_pid" 2>/dev/null || true
wait "$child_pid" 2>/dev/null || true
unset CI_SECRET_PROBE
test "$result" -eq 1
這項測試證明預設繼承確實存在,但不代表應在正式環境的日誌中反覆掃描所有程序。正式驗收可使用固定變數名稱搭配隨機值,並且只記錄「通過」或「失敗」。
檢查 launchd 殘留
不應使用 launchctl setenv 注入工作祕密,因為它的生命週期可能超過單次 Shell。若舊有指令碼曾使用這種方式,請先在不輸出值的前提下檢查狀態,接著執行:
launchctl unsetenv CI_RELEASE_TOKEN
test -z "$(launchctl getenv CI_RELEASE_TOKEN)"
應在工作開始與清理階段各執行一次檢查。如果 runner 以獨立使用者身分執行,必須在該使用者對應的 launchd 網域內操作,而不能只檢查目前的遠端工作階段。
為建置建立最小化環境
env -i 可以從空白環境啟動命令,但不能盲目清空所有設定。Xcode 工具鏈通常需要 HOME、PATH、TMPDIR、地區設定,以及明確指定的開發者目錄。請先以版本查詢進行驗收,再執行完整建置。
set -euo pipefail
job_tmp="$(/usr/bin/mktemp -d "${TMPDIR:-/tmp}/mac-ci.XXXXXX")"
trap '/bin/rm -rf "$job_tmp"' EXIT HUP INT TERM
/usr/bin/env -i \
HOME="$HOME" \
PATH="/usr/bin:/bin:/usr/sbin:/sbin" \
TMPDIR="$job_tmp/" \
LANG="en_US.UTF-8" \
DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer" \
/usr/bin/xcrun xcodebuild -version
如果實際專案依賴 Ruby、Node.js 或套件管理器,應將其可執行檔目錄逐項加入 PATH,而不是還原整個登入 Shell 環境。每新增一個變數,都要註明是哪個命令需要它。
將建置與發布拆分為兩個權限網域
編譯階段只產生封存檔與驗證檔案,不接觸發布憑證。發布階段讀取已通過驗收的產物,並且只向上傳命令注入必要變數。不要在工作開始時執行 export CI_RELEASE_TOKEN,因為後續所有指令碼都會繼承它。
set +x
CI_RELEASE_TOKEN="$release_token" \
/usr/bin/env \
RELEASE_ARTIFACT="$artifact_path" \
./ci/publish.sh
unset release_token
set -x
更穩妥的做法是讓 publish.sh 只啟動一個上傳程序,等待該程序退出後立即結束。若工具要求使用憑證檔案,請使用該工作專屬的暫存目錄,在建立檔案前設定 umask 077,並於寫入後確認權限為 600。不要將檔案放入儲存庫、使用者桌面或長期快取目錄。
限制失敗處理常式
失敗掛鉤經常收集環境、程序與工作區資訊。它不應執行未經篩選的 env,也不應封裝完整命令列。可以收集的資訊包括退出碼、工具版本、產物路徑、磁碟剩餘空間,以及經過允許清單篩選的變數名稱。需要檢查祕密時,只比較雜湊或隨機探針是否出現,不要儲存原始值。
將清理結果設為工作閘門
工作成功不代表清理成功。結束階段至少應檢查:發布變數已執行 unset、launchd 中沒有對應名稱、暫存目錄已不存在、測試與上傳子程序均已退出,以及日誌未啟用命令追蹤。只要任何一項失敗,就應將目前的 runner 標記為需要檢查,並停止讓它繼續接收含有祕密的發布工作。
建議為每次工作產生獨立的 TMPDIR,並將程序群組作為清理單位。只終止主要指令碼,可能會留下模擬器輔助程序、上傳程式或自訂常駐程式。清理後,再執行一次不含真實值的隨機探針,確認新的子程序無法觀察到上一階段的標記。
最終目標不是讓流水線完全不使用環境變數,而是建立清楚的邊界:建置階段沒有發布祕密,發布階段只有一個必要程序能看到祕密;工作結束後,程序、launchd 與磁碟都不再保留它。將這套檢查寫入 runner 的初始化與收尾指令碼後,後續專案便不必依賴操作人員的記憶。
常見問題
CI 祕密只存放在環境變數中就足夠安全嗎?
不夠。環境變數預設會傳給子程序,也可能被診斷命令、除錯輸出或常駐服務讀取。應拆分建置與發布,只把祕密注入真正需要它的單一命令。
如何確認工作完成後沒有環境變數殘留?
檢查 runner 父程序、launchd 網域與仍在執行的子程序是否含有變數名稱,並確認工作暫存目錄已刪除。稽核時不要把真正的值輸出到日誌。
使用 env -i 會讓 xcodebuild 無法執行嗎?
它會清除預設環境,因此要明確傳入 HOME、PATH、TMPDIR、LANG 與 DEVELOPER_DIR。先以版本檢查和小型建置驗證白名單,再套用到完整流程。
將下一次建置放到雲端 Mac mini
選擇配置、計費週期與節點區域,使用獨享實體機執行開發、建置與自動化工作。