Jenkins Credentials: withCredentials, Credential Binding ve Groovy String Interpolation Tuzağı
Secret'ı credentials() veya withCredentials ile en dar scope'ta açar, sh adımında tek tırnakla shell değişkeni olarak okursun; çift tırnakla Groovy interpolation process'e sızdırır. Credential türleri ve SYSTEM/GLOBAL/folder scope'ları, JCasC'de readFile, masking'in base64'ü yakalayıp parçayı kaçırması, sshagent, folder credential ile ekip izolasyonu ve harici secret store'lar.
Sürüm notu: Bu yazıdaki örnekler Jenkins 2.580.2 LTS (Long-Term Support), Credentials Binding plugin'i 728, SSH (Secure Shell) Agent plugin'i 433 ve Gitea 28 ile denenmiştir. Örneklerdeki secret değerleri yalnız bu deneme için üretilmiş sahte değerlerdir.
Önce Tahmin Et
Pipeline'da bir NuGet API (Application Programming Interface) key'i credentials() ile environment variable'a alıyorsun ve Jenkins onu log'da **** ile maskeliyor. Bir geliştirici "maskelendiğine göre güvende" deyip şu üç satırı ekliyor: echo "$NUGET_API_KEY", echo "$NUGET_API_KEY" | base64 ve echo "$NUGET_API_KEY" | cut -c1-6. Hangisi log'a secret'tan bir şey sızdırır? Bir de şu: sh 'echo $KEY' ile sh "echo ${KEY}" arasında log'da hiçbir fark görmüyorsan, ikisi aynı derecede güvenli midir?
Credential Türleri, Store'lar ve Scope'lar
Jenkins'te credential, pipeline'ın ihtiyaç duyduğu bir secret'ın controller'da şifreli olarak saklanmış hâlidir. En sık kullanılan türler: Secret text (API key, token), Username with password, SSH Username with private key, Secret file (bir kubeconfig ya da .npmrc gibi) ve Certificate. Credential'lar bir store'da durur: Jenkins'in kendi (system) store'u, bir folder'ın store'u ya da bir kullanıcının kişisel store'u.
Scope ise credential'ı kimin kullanabileceğini söyler. SYSTEM scope'taki bir credential'ı yalnız Jenkins'in kendisi kullanır: agent başlatmak, e-posta sunucusuna bağlanmak gibi. GLOBAL scope'taki credential'ı job'lar da kullanabilir. Bu serideki controller'da credential'lar JCasC (Jenkins Configuration as Code) ile tanımlı ve değerler dosyadan okunuyor:
credentials:
system:
domainCredentials:
- credentials:
- basicSSHUserPrivateKey:
scope: SYSTEM
id: agent-ssh-key
username: jenkins
description: "SSH key the controller uses to start ssh-agent-1"
privateKeySource:
directEntry:
privateKey: "${readFile:/run/secrets/agent_ssh_key}"
- usernamePassword:
scope: GLOBAL
id: gitea-ci
username: jenkins-bot
password: "${readFile:/run/secrets/gitea_token}"
description: "Gitea bot token for checkout and commit status"
- string:
scope: GLOBAL
id: nuget-api-key
secret: "${readFile:/run/secrets/nuget_api_key}"
description: "NuGet feed API key (demo value)"
- basicSSHUserPrivateKey:
scope: GLOBAL
id: deploy-ssh-key
username: deploy
description: "SSH key for deploying to app servers"
privateKeySource:
directEntry:
privateKey: "${readFile:/run/secrets/deploy_ssh_key}"
${readFile:...} JCasC'nin secret kaynaklarından biri: değeri container'a read-only mount edilmiş bir dosyadan okur, YAML (YAML Ain't Markup Language) dosyasının kendisinde secret yoktur. Controller diskinde ise değer şifreli durur:
<id>nuget-api-key</id>
<description>NuGet feed API key (demo value)</description>
<secret>{AQAAABAAAAAwPyA/d2M4MB8tZ6HG/QUh4+vjertSrKkpZChD8n/9krvXVPuIwhduf6TFv0VJePKuwmGHunh/zUVgsP/AXRqhLQ==}</secret>
Şifrelemenin anahtarı aynı JENKINS_HOME'un secrets/ klasöründe (master.key ve hudson.util.Secret). Yani JENKINS_HOME'un tamamını ele geçiren biri secret'ları çözebilir. Bu, yedekleri de secret gibi korumak gerektiği anlamına gelir; on birinci yazıda tekrar döneceğiz.
credentials() ile environment
En kısa kullanım, credential'ı environment bloğunda bir değişkene bağlamak:
pipeline {
agent { label 'linux' }
environment {
NUGET_API_KEY = credentials('nuget-api-key')
}
stages {
stage('Masked') {
steps {
sh 'echo "key is $NUGET_API_KEY"'
}
}
stage('Interpolated') {
steps {
sh "echo key is ${NUGET_API_KEY}"
}
}
stage('Masking limits') {
steps {
sh 'echo "$NUGET_API_KEY" | base64'
sh 'echo "$NUGET_API_KEY" | cut -c1-6'
}
}
}
}
Gerçek çıktı:
Masking supported pattern matches of $NUGET_API_KEY
+ echo key is ****
key is ****
Warning: A secret was passed to "sh" using Groovy String interpolation, which is insecure.
Affected argument(s) used the following variable(s): [NUGET_API_KEY]
See https://jenkins.io/redirect/groovy-string-interpolation for details.
+ echo key is ****
key is ****
+ + base64
echo ****
****Cg==
+ + echo ****
cut -c1-6
oy2dem
Üç ilginç şey var. Secret text için credentials() değişkene değerin kendisini koyar; Username with password türünde ise NUGET_API_KEY user:password olur ve ek olarak NUGET_API_KEY_USR ile NUGET_API_KEY_PSW değişkenleri de tanımlanır. İkincisi, Interpolated stage'inde log'da hâlâ **** görünüyor ama Jenkins açıkça uyarıyor. Üçüncüsü masking'in sınırı: base64 hâlini de yakaladı (çıktıdaki ****Cg==, sondaki satır sonu karakterinin base64 kuyruğu), ama ilk altı karakteri hiçbir maske tanımıyor ve oy2dem log'a düştü.
Groovy String Interpolation Tuzağı
Log'da iki satır aynı göründüğüne göre uyarı neden? Çünkü masking yalnızca log çıktısını korur, process'i değil. sh "echo key is ${NUGET_API_KEY}" yazdığında değeri Groovy yerleştirir; agent'a giden komut satırı secret'ın açık hâlini içerir. O komut çalışırken aynı makinedeki herhangi bir process ps ile argümanları görebilir, komut bir shell script dosyası olarak @tmp klasörüne yazılır ve hata durumunda Groovy tarafında oluşan exception mesajları da değeri taşıyabilir. Tek tırnakla yazdığında ise Groovy değeri hiç görmez: komut satırında yalnız $NUGET_API_KEY metni vardır ve değeri, environment'tan shell okur.
Kural kısa: secret içeren her sh, bat, powershell adımında tek tırnak (veya ''') kullan, secret'ı shell değişkeni olarak oku. Groovy'de başka bir değişkeni de birleştirmen gerekiyorsa onu withEnv ile environment'a koy, yine tek tırnak kullan. Jenkins'in "A secret was passed to sh using Groovy String interpolation" uyarısını asla "sadece bir warning" diye geçme; bu satır log'da görünüyorsa pipeline'da bir sızıntı yolu var demektir.
withCredentials ile Dar Scope
environment bloğundaki credential bütün pipeline boyunca açık kalır. Secret'ı yalnız ihtiyaç olan birkaç satıra açmak için withCredentials kullanırsın. Gitea API'sine bot kullanıcısının token'ıyla bağlanan bir stage:
stage('Call Gitea API') {
steps {
withCredentials([usernamePassword(credentialsId: 'gitea-ci',
usernameVariable: 'GITEA_USER',
passwordVariable: 'GITEA_TOKEN')]) {
sh '''
curl -s -H "Authorization: token $GITEA_TOKEN" \
http://jenkwork-gitea:3000/api/v1/user | grep -o '"login":"[^"]*"'
'''
}
}
}
Masking supported pattern matches of $GITEA_TOKEN
+ curl -s -H Authorization: token **** http://jenkwork-gitea:3000/api/v1/user
+ grep -o "login":"[^"]*"
"login":"jenkins-bot"
Token yalnız bu blok içinde var, blok bitince environment'tan kalkıyor. Bağlama türleri credential türleriyle eşleşir: string, usernamePassword, file (secret dosyayı geçici bir path'e yazar ve path'i değişkene koyar), sshUserPrivateKey ve certificate.
SSH ile bir sunucuya bağlanacaksan sshagent adımı private key'i diske yazmadan bir ssh-agent process'ine yükler:
sshagent(credentials: ['deploy-ssh-key']) {
sh 'ssh-add -l'
}
[ssh-agent] Using credentials deploy (SSH key for deploying to app servers)
$ ssh-agent
SSH_AUTH_SOCK=/tmp/ssh-DJAj47Qe3YV3/agent.640
SSH_AGENT_PID=643
Running ssh-add (command line suppressed)
[ssh-agent] Started.
+ ssh-add -l
256 SHA256:sRV1aiVVwjruTv9kTull+IRG64Db4ux1/IwYvXmUa5c jenkins-deploy (ED25519)
$ ssh-agent -k
unset SSH_AUTH_SOCK;
unset SSH_AGENT_PID;
echo Agent pid 643 killed;
[ssh-agent] Stopped.
Blok içindeki ssh, scp, rsync komutları anahtarı SSH_AUTH_SOCK üzerinden kullanır, blok bitince agent öldürülür. Bunu dokuzuncu yazıdaki deploy'da kullanacağız.
Scope'un Gerçek Etkisi: SYSTEM ve Folder
Aynı pipeline'a SYSTEM scope'taki agent-ssh-key'i kullanan bir stage ekledim:
stage('System scope') {
steps {
withCredentials([sshUserPrivateKey(credentialsId: 'agent-ssh-key', keyFileVariable: 'KEY')]) {
sh 'ls -l "$KEY"'
}
}
}
ERROR: Could not find credentials entry with ID 'agent-ssh-key'
Finished: FAILURE
Credential var, ama job'lar için yok. Agent'ları başlatan anahtarın bir pipeline'dan okunamaması tam istediğimiz şey; aksi hâlde herhangi bir Jenkinsfile o anahtarı başka bir sunucuya gönderebilirdi.
Ekip izolasyonu için ikinci araç folder seviyesinde credential. team-web adlı folder'a yalnız o folder'daki job'ların görebileceği bir web-deploy-token ekledim (Job DSL (Domain-Specific Language) ile, folder'ın folderCredentialsProperty'si üzerinden). Aynı Jenkinsfile'ı önce folder'ın içinde, sonra kökte çalıştırdım:
withCredentials([string(credentialsId: 'web-deploy-token', variable: 'DEPLOY_TOKEN')]) {
sh 'echo "token length: ${#DEPLOY_TOKEN}"'
}
Folder'ın içindeki team-web/web-deploy job'u:
Running on ssh-agent-1 in /home/jenkins/agent/workspace/team-web/web-deploy
Masking supported pattern matches of $DEPLOY_TOKEN
+ echo token length: 23
token length: 23
Finished: SUCCESS
Kökteki api-deploy job'u:
Running on docker-agent-1 in /home/jenkins/agent/workspace/api-deploy
ERROR: Could not find credentials entry with ID 'web-deploy-token'
Finished: FAILURE
Folder'lar yetkilendirmeyle birleşince güçlü bir sınır olur: web ekibi kendi folder'ındaki credential'ları yönetir, API ekibinin job'ları onları göremez. Folder-scoped credential'ın ikinci faydası isim çakışmalarını önlemesi; iki ekip de kendi deploy-token'ına sahip olabilir.
Harici Secret Store'lar
Credential'ları Jenkins'in kendi store'unda tutmak küçük ve orta kurulumlar için yeterli. Kurumda zaten HashiCorp Vault ya da Azure Key Vault varsa, ilgili plugin'ler secret'ı build anında oradan okur ve Jenkins'te yalnız referans kalır. Avantajı tek bir yerde rotation ve audit; bedeli bir plugin ve bir bağımlılık daha. Bu store'ların kendisini ilerideki Configuration ve Secret Management serisinde konuşacağız; bu yazıdaki kurallar (dar scope, tek tırnak, masking'e güvenmemek) hangi store'u kullanırsan kullan aynı.
Üretimde Dikkat
Credential'ları en dar scope'ta tanımla: agent ve altyapı anahtarları SYSTEM, ekip secret'ları folder'da, yalnız gerçekten ortak olanlar global. Her credential'a ne işe yaradığını ve kime ait olduğunu anlatan bir description yaz; iki yıl sonra "deploy2-new neydi?" sorusunu kimse cevaplayamaz.
Pull request build'lerinde production credential'ı kullanma. Fork'tan gelen bir PR (Pull Request) Jenkinsfile'ı değiştirebiliyorsa, withCredentials içine curl attacker.example ekleyebilir. Altıncı yazıda fork PR'larının Jenkinsfile'ı nasıl değiştiremeyeceğini göreceğiz; yine de deploy credential'larını yalnız main branch'inde açılan stage'lere koy.
Rotation planla. Jenkins'te duran bir token'ın son kullanma tarihi yoksa, sızdığında bunu fark edecek bir mekanizma da yoktur. Kısa ömürlü token'lar (GitHub App token'ları, bulut sağlayıcılarının OIDC (OpenID Connect) ile verdiği geçici credential'lar) mümkün olan her yerde statik secret'lardan iyidir.
Sık Düşülen Tuzaklar
Masking'i güvenlik sınırı sanmak. Masking yalnız tam değeri ve bazı bilinen kodlamalarını yakalar; parçası, ters çevrilmiş hâli, URL (Uniform Resource Locator) encoding'li hâli ya da bir dosyaya yazılıp arşivlenmiş hâli yakalanmaz. Pipeline'ı yazabilen herkes secret'ı okuyabilir: masking kazara sızıntıya karşı bir ağdır, kasıtlı okumaya karşı değil.
Çift tırnakla interpolation. Log'da **** görmek seni yanıltmasın; uyarı satırı varsa sorun var.
set -x ve debug çıktıları. Shell'in xtrace'i maskelenir, ama araçların kendi debug log'ları (ör. curl -v'nin yazdığı header'lar ya da bir CLI'ın (Command Line Interface) verbose modu) secret'ı farklı biçimde basabilir.
Secret'ı artifact'a ya da Docker image'ına koymak. Build sırasında bir appsettings.Production.json'a yazılan connection string, archiveArtifacts ile controller'a kopyalanır ve indiren herkes okur.
Credential ID'lerinde ortam bilgisi tutmamak. db-password yerine bookstore-db-staging ve bookstore-db-production gibi isimler, yanlış ortama deploy etme hatasını daha görünür kılar.
Küçük Bir Deneme
★☆☆ Bir Secret text credential oluştur, environment içinde credentials() ile bağla ve hem tek tırnaklı hem çift tırnaklı sh ile yazdır. Uyarı satırını log'da bul.
★★☆ Bir Username with password credential'ı credentials() ile bağla ve _USR, _PSW değişkenlerinin ne içerdiğini, ana değişkenin neye dönüştüğünü gözlemle (değerleri yazdırmadan, uzunluklarını ${#VAR} ile göstererek).
★★★ İki folder oluştur, her birine aynı ID ile farklı değerde bir credential koy ve kökte de aynı ID'li global bir credential tanımla. Folder içindeki bir job hangisini görür? Credential arama sırasını deneyerek bul.
Bir sonraki yazıda Jenkins'i repository'ye bağlıyoruz: multibranch pipeline, webhook'lar, pull request build'leri, fork PR'larına güven ayarları ve commit'e geri gönderilen status check.