憑證管理:機密怎麼進 pipeline,又不會漏出去

· tech

#jenkins#ci-cd#security

📑 目錄

第 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,因為它決定了爆炸半徑:

✗ 一把萬能鑰匙,放 Global prod-deploy-keyGlobal · 永久有效 · 全權限 前端 build 後端 build 工具 job 外部 PR build 能讓任何一個 build 跑起來的人 = 拿到了 Production 的鑰匙 ✓ 按 folder 切 + 短期票 folder: web只有 npm registry token folder: api只有內部 maven 憑證 folder: deployprod 憑證只在這裡 · 只能部署,不能刪叢集每次 build 現發,build 結束就失效 PR build 在 web / api folder 底下 它根本看不到 deploy folder 的憑證 看憑證只問三題:能做什麼 · 活多久 · 誰拿得到 三題都答得出來,才輪得到討論「有沒有加密」——那從來不是重點
憑證的風險不是「會不會被看到」,而是看到的人能做多少事、能做多久。放 Global 的那一刻,任何能觸發任何一個 job 的人(包含送 PR 的外部貢獻者)就都在你的信任邊界內了

我的預設是:憑證放 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_USRTOKEN_PSW 兩個變數——不知道這件事的人,常常在日誌裡看到莫名其妙的變數名。

原則很簡單:綁定要像 try-catch 一樣,包住剛好需要的那幾行。

遮蔽不等於安全

Jenkins 會在日誌裡把憑證值換成 ****,很多人因此以為「反正會被遮掉」。但遮蔽的原理只是:輸出的時候,拿憑證的字串去比對、替換掉。 它不追蹤這個值流去了哪裡。

credentials storewithCredentials $DEPLOY_TOKEN環境變數 ✓ echo $DEPLOY_TOKEN → 日誌顯示 **** ✗ echo $TOKEN | base64 / urlencode / 切兩段變形之後,字串比對就認不出來了 ✗ set -x / sh -xshell 把整行指令連參數一起印出來 ✗ 子行程寫進檔案 → 被 archiveArtifacts 帶走遮蔽只管日誌,不管你封存了什麼 ✗ curl -v / debug log / 測試報告 / core dump授權標頭、堆疊、記憶體傾印都可能夾帶它 遮蔽 = 輸出時的字串比對,不是資訊流追蹤 它擋得住「不小心印出來」,擋不住任何經過一次變形或換一個出口的情況 所以它是安全網,不是安全機制——真正的防線是那把鑰匙本身能做什麼、活多久
把遮蔽當成保護,是這個題目最常見的認知錯誤。它的定位比較接近安全帶:該繫,但你不會因為繫了安全帶就閉著眼睛開車

實際寫的時候,幾個具體的習慣就能擋掉大半:

// ✗ 反例: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。