憑證管理:機密怎麼進 pipeline,又不會漏出去
· tech
📑 目錄
第 2 篇的結論是:pipeline 要寫成程式碼、進 git、讓每個人都能 review。這是整個系列的立場,但它有一個必須同時付的代價——這條路上的每一行,所有能讀這個 repo 的人都看得到。
所以機密不是「小心一點別被看到」的問題,而是它從一開始就不能在裡面。這篇講的就是:它該住哪、怎麼進 pipeline、以及為什麼你在日誌上看到的那排 ****,遠比你以為的脆弱。
先講清楚:寫進 repo 的東西,刪掉也還在
最常見的三種錯,由淺到深:
// ✗ 直接寫在 Jenkinsfile 裡
sh 'curl -H "Authorization: Bearer sk-live-9f3a..." https://api.example.com/deploy'
// ✗ 寫在 repo 的設定檔裡,想說「反正是內部 repo」
sh './deploy.sh --token=$(cat .env.production)'
// ✗ 塞在 job 的環境變數裡(至少不在 git,但也不可 review、不可輪替、誰都看得到)
第一種最糟的地方不是被看到,是它會永遠留在 git 歷史裡。你下一個 commit 把它刪掉,它還在;改成 git rebase 重寫歷史,別人的本機 clone 裡還在;repo 有 fork、有備份、有各種同步過去的副本,你追不完。
所以憑證外洩的第一動作永遠是「換金鑰」,不是「刪 commit」。 這件事我在反思會再講一次,因為它太常被搞反。
Credentials store:憑證住的地方,以及 scope 怎麼切
Jenkins 把憑證存在自己的 store 裡,pipeline 只拿「ID」來指涉它——Jenkinsfile 裡出現的永遠只有 ID,不是值。
常用型別有幾種:Secret text(API token)、Username with password、SSH private key、Secret file(整個 kubeconfig 或 service account json)、Certificate。
比型別更重要的是 scope,因為它決定了爆炸半徑:
我的預設是:憑證放 folder,不放 Global;Production 相關的另外開一個 folder,權限跟其他專案切乾淨。System scope 留給 Jenkins 自己用(例如連 agent 的金鑰),pipeline 拿不到。
withCredentials:綁定範圍越小越好
stage('Deploy') {
steps {
withCredentials([
string(credentialsId: 'deploy-token', variable: 'DEPLOY_TOKEN'),
usernamePassword(credentialsId: 'registry', usernameVariable: 'REG_USER', passwordVariable: 'REG_PASS'),
file(credentialsId: 'kubeconfig', variable: 'KUBECONFIG')
]) {
sh './scripts/deploy.sh' // ← 只有這幾行拿得到,出了這個區塊就沒了
}
}
}
environment { TOKEN = credentials('deploy-token') } 是更短的寫法,但它有兩個要注意的地方:一是作用範圍變成整個 pipeline 或整個 stage(綁得比需要的大),二是用在 username-password 型別時會自動多產生 TOKEN_USR 與 TOKEN_PSW 兩個變數——不知道這件事的人,常常在日誌裡看到莫名其妙的變數名。
原則很簡單:綁定要像 try-catch 一樣,包住剛好需要的那幾行。
遮蔽不等於安全
Jenkins 會在日誌裡把憑證值換成 ****,很多人因此以為「反正會被遮掉」。但遮蔽的原理只是:輸出的時候,拿憑證的字串去比對、替換掉。 它不追蹤這個值流去了哪裡。
實際寫的時候,幾個具體的習慣就能擋掉大半:
// ✗ 反例:token 出現在指令列上(ps 看得到、set -x 印得出來、shell 歷史也可能留)
sh "curl -H 'Authorization: Bearer ${TOKEN}' https://api.example.com/deploy"
// ✓ 正例:讓子行程自己從環境變數讀,值不進指令列
withCredentials([string(credentialsId: 'deploy-token', variable: 'TOKEN')]) {
sh '''
set +x # 這一段不要展開指令
curl -sS -H "Authorization: Bearer ${TOKEN}" https://api.example.com/deploy
'''
}
還有一條我會在 review 直接擋下來的:archiveArtifacts 之前要確認產物裡沒有機密。最容易中的是「debug 用的設定 dump」「整包 .env」「把回應存下來的 json」——這些檔案一旦被封存,就跟著 build 紀錄躺在那,而且遮蔽完全管不到。
最小權限與短命 token:真正的防線
既然遮蔽靠不住,防線就要往前挪到憑證本身。我看憑證只問三個問題:
| 問題 | 好的答案長什麼樣 |
|---|---|
| 能做什麼? | 只能做這條 pipeline 需要的事(能推 image,不能刪 registry;能部署,不能改 IAM) |
| 活多久? | 每次 build 現發、跑完失效;而不是三年前建的、沒人記得的長命 token |
| 誰拿得到? | 特定 folder / 特定 job;外部 PR 的 build 一定拿不到部署憑證 |
第三題在開源或有外部貢獻者的專案特別要命:PR 來自 fork,而 PR build 會執行那個 PR 帶進來的程式碼——如果那次 build 拿得到部署金鑰,等於任何人送一個 PR 就能把金鑰印出來(還可以先 base64 一下)。這也是為什麼很多專案的 PR build 是不帶任何憑證的。
短命憑證的做法,現在的主流是接外部 secret manager 或雲端的身分聯合:
// 骨架示意:向 Vault 換一組「這次 build 才有效」的資料庫憑證
withVault(vaultSecrets: [[
path: 'database/creds/deployer',
secretValues: [[envVar: 'DB_USER', vaultKey: 'username'],
[envVar: 'DB_PASS', vaultKey: 'password']]
]]) {
sh './scripts/migrate.sh'
}
好處不只是安全:輪替變成常態而不是專案。長命憑證最大的問題其實是「不敢換」——換了不知道會壞掉哪些東西,於是一放三年。動態憑證從根本消滅這個恐懼。
這條線跟 K8s 的 Secret 其實沒有加密 是同一個思路:別把「存起來」當成「保護好了」,要看的是誰能讀、能用它做什麼。Ansible 那邊的對應是 Vault 管密文,同樣是把機密從程式碼裡挪出去。
反思
我處理過的洩漏,沒有一次是「有人把密碼貼在 Jenkinsfile」
真正發生過的長這樣:某個部署腳本為了 debug,把要送出的完整 request 印出來——header 裡就有 token。因為那是組合出來的字串,遮蔽沒認出來,乾乾淨淨地印在 build 日誌上。而那條日誌又被日誌聚合系統收走了,於是它同時存在三個地方。
會犯這種錯的都不是不懂安全的人,他們只是在解一個很急的問題。所以我後來不太相信「大家小心一點」這種對策——能被印出去的東西,總有一天會被印出去。 該做的是讓那個 token 就算被印出去,傷害也有限:權限最小、有效期短、範圍切乾淨。
洩漏的第一動作是換金鑰,不是刪日誌
我看過團隊發現洩漏之後,花兩小時去刪 build 紀錄、清日誌、rebase git 歷史,然後鬆一口氣。那兩小時幾乎是白費的——你不知道誰看過、備份在哪、日誌被同步到哪個系統、有沒有被爬走。
正確的順序是:先換金鑰(讓外洩的那份失效),再處理殘留,最後才檢討怎麼流出去的。 換金鑰之所以常被拖延,通常是因為「不知道換了會壞掉哪些東西」——而這恰恰證明那把金鑰的作用範圍太大、被太多地方用著。所以「能不能十分鐘內換掉一把金鑰」,其實是憑證管理做得好不好的最佳單一指標。
憑證是「可審查」這件事唯一的例外,而它必須是明確的例外
整個系列的立場是:能寫成程式碼的就寫成程式碼、進 git。 憑證是這條原則唯一不適用的東西——但正因為它是例外,更要處理得明確:Jenkinsfile 裡出現的是 ID 不是值,ID 本身是可 review 的(「這個 stage 為什麼需要 production 的憑證?」是一個好問題,而且看得到才問得出來);值住在 store 或 secret manager,有自己的稽核與輪替機制。
換句話說,機密不進 git,但「誰在哪裡用了什麼機密」要進 git。 這樣可審查性其實沒有破口——你 review 的不是那串字,是那個授權關係。這是我認為最漂亮的一個切法:例外只有一個,而且例外本身也被管理著。
下一篇處理另一個規模化的問題:十個專案十份幾乎一樣的 Jenkinsfile,怎麼抽成一份共用函式庫,又不會抽出一個沒人看得懂的 DSL。