Pipeline Güvenliği ve Secret'lar: Least Privilege, Kısa Ömürlü Credential ve Supply Chain
Pipeline production anahtarlarına sahip en güçlü kullanıcıdır; secret'ları store'da tutmak, job başına least privilege, uzun ömürlü cloud key'leri yerine OIDC ile kısa ömürlü token ve action'ları commit SHA'sına pin'lemek riskin büyük kısmını kapatır. Base64'e çevrilen secret'ın maskelemeden kaçtığı gerçek act çıktısı, actionlint'in yakaladığı script injection, fork PR riskleri ve SLSA v1.2 ile SBOM'a kısa bakış.
Sürüm notu: Bu yazıdaki örnekler .NET 10 (SDK 10.0.402), act 0.2.97, actionlint 1.7.12 ve Docker 29 ile denenmiştir.
Önce Tahmin Et
Bookstore'un pipeline'ında DB_PASSWORD adlı bir secret var. Bir step bu değeri log'a düz yazdırıyor; CI (Continuous Integration) tool'u onu *** ile maskeliyor. Aynı step değeri önce base64'e çevirip sonra yazdırırsa log'da ne görünür: yine *** mu, yoksa base64 hali mi? Cevap birkaç bölüm sonra, act ile koşulmuş gerçek bir çıktıda.
Threat Model: Pipeline En Güçlü Kullanıcı
Bookstore'un deploy pipeline'ının neye eriştiğini listeleyelim: container registry'ye push yetkisi, production'a deploy yetkisi, veritabanı migration'ı için bir bağlantı bilgisi, belki bir imzalama anahtarı. Ekipteki hiçbir geliştiricinin bu yetkilerin hepsine birden sahip olması beklenmez; pipeline ise hepsine sahip. Ayrıca pipeline, repository'ye yazabilen herkesin değiştirebildiği bir kodla çalışıyor ve internetten indirilen yüzlerce action, plugin ve paketi kendi yetkileriyle koşturuyor.
Bu yüzden pipeline'ı bir saldırganın gözüyle düşünmek gerekiyor. Dört ana yol var: secret'ların log'a ya da artifact'a sızması, bir bağımlılığın (action, plugin, paket) ele geçirilmesi, güvenilmeyen kodun (örneğin bir fork'tan gelen PR'ın (pull request)) yetkili bir context'te çalışması ve runner'ın kendisinin ele geçirilmesi. Bu yazıda her biri için risk, önleme ve tespit tarafına bakacağız; saldırının nasıl yapılacağına değil.
Secret'lar Nerede Durur
Secret repository'de durmaz; appsettings.Production.json'da, bir .env dosyasında ya da "nasılsa private repo" diye bir YAML (YAML Ain't Markup Language) değişkeninde de durmaz. Git geçmişi kalıcıdır; bir kez commit'lenen secret, silinse bile geçmişte yaşar ve rotate edilmesi gerekir. Doğru yer bir secret store: CI tool'unun kendi secret deposu, Azure Key Vault ya da HashiCorp Vault gibi bir servis. Hangisinin ne zaman seçileceğini ilerideki Configuration ve Secret Management serisinde ele alacağım; secret yönetiminin uygulama tarafını Web Güvenliği (OWASP Top 10) serisinin "OWASP A04: Cryptographic Failures (2) — Parola Saklama, Data Protection ve Secret Yönetimi" yazısında anlattım.
Pipeline açısından önemli olan, secret'ın kapsamı. GitHub Actions'ta environment secret'ları sadece o environment'a referans veren job'a ve environment'ın koruma kuralları geçildikten sonra veriliyor; bir önceki yazıdaki onay mekanizması bu yüzden secret'ları da koruyor. Production veritabanı parolasının bir PR'ın test job'ında erişilebilir olması için hiçbir sebep yok.
Masking'in Sınırları
CI tool'ları secret değerlerini log'da gördüklerinde maskeliyor. Ama bu, bilinen bir string'in metin içinde aranıp değiştirilmesinden ibaret. Bunu göstermek için küçük bir workflow:
name: secrets-demo
on: workflow_dispatch
permissions:
contents: read
jobs:
demo:
runs-on: ubuntu-latest
steps:
- env:
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
run: |
echo "plain: $DB_PASSWORD"
encoded="$(printf '%s' "$DB_PASSWORD" | base64)"
echo "base64: $encoded"
echo "::add-mask::$encoded"
echo "base64 after add-mask: $encoded"
act ile, sadece bu deneme için uydurduğum bir test değeriyle koşturdum:
$ act workflow_dispatch -W .github/workflows/secrets-demo.yml -s DB_PASSWORD=bookstore-test-only-42
[secrets-demo/demo] | plain: ***
[secrets-demo/demo] | base64: Ym9va3N0b3JlLXRlc3Qtb25seS00Mg==
[secrets-demo/demo] | base64 after add-mask: ***
[secrets-demo/demo] 🏁 Job succeeded
Giriş sorusunun cevabı: düz değer maskelendi, base64 hali olduğu gibi göründü. Log'u okuyabilen herkes onu tek komutla geri çevirebilir. GitHub'ın dokümantasyonu da aynı şeyi söylüyor: redaction garanti değil; secret'ı JSON (JavaScript Object Notation) ya da YAML gibi yapılandırılmış bir verinin içine koymak maskelenme ihtimalini ciddi şekilde düşürüyor, bir secret'tan türetilen değerler (base64, URL (Uniform Resource Locator) encode edilmiş hali, imzalanmış bir token) de ayrıca secret olarak kaydedilmeli. ::add-mask:: komutu türetilmiş değeri çalışma anında maskeye ekliyor; çıktının son satırında işe yaradığını görüyorsun.
Bu çıktı act'in maskeleme davranışı; GitHub'ın runner'ı da aynı prensiple çalışıyor ama bu workflow'u GitHub'da koşturmadım. Asıl ders tool'dan bağımsız: secret'ı hiç yazdırmamak, maskelemeye güvenmekten daha iyi. Debug için set -x açmak ya da bir HTTP (Hypertext Transfer Protocol) isteğinin header'larını log'a dökmek, maskelemenin yakalayamayacağı sızıntıların en yaygın sebebi.
Least Privilege: Job Başına Yetki
GitHub Actions her job'a repository üzerinde işlem yapabilen bir GITHUB_TOKEN veriyor. Bu token'ın yetkisini en aza indirmek, ele geçirilmiş bir adımın yapabileceği zararı sınırlıyor. Bookstore'un deploy workflow'unda:
name: deploy-azure
on:
push:
branches: [main]
permissions: {}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
with:
global-json-file: global.json
- run: dotnet publish src/Bookstore.Api -c Release -o artifacts/api
deploy:
needs: build
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write
steps:
- uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
tenant-id: ${{ vars.AZURE_TENANT_ID }}
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
Üst seviyedeki permissions: {} bütün yetkileri kapatıyor; her job sadece ihtiyacı olanı açıyor. build repository'yi okumaktan fazlasına ihtiyaç duymuyor. deploy ise ek olarak id-token: write istiyor, bir sonraki bölümün konusu. Repository ya da organization ayarlarında varsayılan token yetkisini "read" yapmak da GitHub'ın önerisi; workflow'da permissions unutulsa bile token yazma yetkisiyle gelmez.
build job'ını act ile koşturdum ve SHA'ya (Secure Hash Algorithm) pin'lenmiş action'larla sorunsuz bitti:
[deploy-azure/build] ✅ Success - Main actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 [97.18375ms]
[deploy-azure/build] ✅ Success - Main actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 [13.972664208s]
[deploy-azure/build] ✅ Success - Main dotnet publish src/Bookstore.Api -c Release -o artifacts/api [5.79498175s]
[deploy-azure/build] 🏁 Job succeeded
Uzun Ömürlü Key Yerine OIDC
Klasik yöntemde bulut hesabına erişim için bir service principal oluşturulur, client secret'ı CI'ın secret deposuna konur ve yıllarca orada kalır. Bu secret sızarsa, sızdığı yerden production'a erişilebilir ve süresi dolana kadar geçerlidir.
OIDC (OpenID Connect) ile pipeline'da saklanan bir secret yok. Her job koşarken GitHub'ın OIDC sağlayıcısından (https://token.actions.githubusercontent.com) kısa ömürlü bir JWT (JSON Web Token) alıyor. Token'ın içinde job'ın kimliği claim'ler olarak yazıyor: hangi repository, hangi branch ya da environment. Bulut tarafında bir güven ilişkisi kuruluyor: "sadece sub claim'i repo:my-org/bookstore:environment:production olan token'lara bu yetkiyi ver". Bulut sağlayıcı token'ı doğrulayıp yine kısa ömürlü bir erişim token'ı dönüyor. Job bittiğinde ortada geçerli bir credential kalmıyor.
Workflow tarafında gereken tek şey id-token: write yetkisi ve sağlayıcının login action'ı; yukarıdaki deploy job'ında görüyorsun. vars ile verilen client ID ve tenant ID secret değil, sadece hangi kimliğe login olunacağını söylüyor. GitHub dokümantasyonunun altını çizdiği nokta güven koşulunun sıkılığı: en az bir koşul tanımlamak zorunlu, aksi halde başka repository'lerin token'ları da kabul edilebilir. sub claim'ini environment'a bağlamak, token'ın sadece production koruma kurallarından geçmiş bir job'da üretilebilmesini sağlıyor.
deploy job'ını yerelde koşturamadım: OIDC token'ını üreten servis GitHub'ın kendisi, act'te böyle bir sağlayıcı yok. Bu job'ı actionlint ile doğruladım; davranışı GitHub'da koşulmadı.
Güvenilmeyen Kod: Fork PR'lar ve Script Injection
Açık kaynak bir repository'de herkes fork açıp PR gönderebilir ve o PR'ın kodu senin pipeline'ında çalışır. GitHub bu yüzden fork'tan gelen pull_request olaylarında GITHUB_TOKEN dışındaki secret'ları runner'a vermiyor ve token'ı salt okunur yapıyor. Tehlikeli olan, bu korumayı aşan trigger'lar: pull_request_target ve workflow_run, hedef repository'nin yetkileri ve secret'larıyla çalışıyor. GitHub'ın önerisi net: bu trigger'larda PR'ın kodunu checkout edip çalıştırma. Yetkili işi (yorum yazmak, etiket eklemek) yetkisiz işten (PR kodunu build edip test etmek) ayrı workflow'lara böl. Self-hosted runner'ları public repository'lerde kullanmamak da aynı riskin parçası: fork'tan gelen kod runner makinesinde kalıcı bir iz bırakabilir.
Daha sinsi bir yol, PR başlığı ya da branch adı gibi kullanıcının yazdığı bir değerin script'in içine doğrudan yerleştirilmesi. ${{ }} ifadesi script çalışmadan önce metin olarak yerine konduğu için, başlığın içindeki özel karakterler shell komutunun parçası haline gelebilir. actionlint bunu yakalıyor:
.github/workflows/pr-title.yml:12:36: "github.event.pull_request.title" is potentially untrusted. avoid using it directly in inline scripts. instead, pass it through an environment variable. see https://docs.github.com/en/actions/reference/security/secure-use#good-practices-for-mitigating-script-injection-attacks for more details [expression]
|
12 | - run: echo "PR title is ${{ github.event.pull_request.title }}"
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Düzeltme, değeri bir ortam değişkeni üzerinden geçirmek; shell onu bir veri olarak görür, kod olarak değil:
jobs:
check:
runs-on: ubuntu-latest
steps:
- env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "PR title is $PR_TITLE"
Bu sürüm actionlint'ten temiz geçti.
Supply Chain: Pin'leme, SLSA ve SBOM
Üçüncü parti action ve plugin'leri pin'lemek
actions/checkout@v7 yazdığında v7 bir tag, yani hareket edebilen bir isim. Action'ın sahibi (ya da hesabını ele geçiren biri) tag'i başka bir commit'e taşırsa, bir sonraki koşuda senin pipeline'ın o commit'i kendi secret'larıyla çalıştırır. GitHub'ın önerisi, action'ı tam uzunluktaki commit SHA'sına pin'lemek ve SHA'nın action'ın kendi repository'sinden geldiğini doğrulamak. Yukarıdaki workflow'da actions/checkout@3d3c42e5… böyle; yanındaki # v7.0.1 yorumu insanların okuması için. SHA'yı tag'den şöyle buldum:
$ git ls-remote --tags https://github.com/actions/checkout 'v7.0.1*'
3d3c42e5aac5ba805825da76410c181273ba90b1 refs/tags/v7.0.1
Pin'lenmiş action'lar kendiliğinden güncellenmez; Dependabot ya da Renovate SHA pin'lerini tanıyıp yeni sürümler için PR açabiliyor, böylece güncelleme de review'dan geçiyor. Aynı ilke Jenkins plugin'leri için de geçerli: plugins.txt'te her plugin'i name:version biçiminde sabitlemek. Bu yazı için kurduğum deneme Jenkins'inde plugin'leri sürümsüz bırakmıştım; production bir Jenkins için bu, her image build'inde farklı plugin sürümleri demek.
SLSA ve SBOM'a kısa bakış
SLSA (Supply-chain Levels for Software Artifacts) v1.2, build track'i dört seviyeye ayırıyor. Seviye 0'da (L0) hiçbir garanti yok. Seviye 1'de (L1) build platformu provenance üretiyor: artifact'ı kimin, hangi süreçle, hangi girdilerle build ettiğini anlatan bir belge; hataları yakalamaya yarıyor ama kolayca taklit edilebiliyor. Seviye 2'de (L2) build hosted bir platformda koşuyor ve provenance platform tarafından imzalanıyor; build sonrasında artifact'a müdahaleye karşı koruma sağlıyor. Seviye 3'te (L3) platform koşuları birbirinden izole ediyor ve imzalama anahtarlarını kullanıcının tanımladığı build adımlarından uzak tutuyor; build sırasındaki müdahaleye karşı koruma. v1.2 ayrıca kaynak kodun kendisini kapsayan bir source track'i yeniden getiriyor.
SBOM (Software Bill of Materials), artifact'ın içindeki bütün bileşenlerin ve sürümlerinin makine tarafından okunabilir listesi; CycloneDX ve SPDX (Software Package Data Exchange) en yaygın iki format. Yeni bir güvenlik açığı duyurulduğunda "bu paket hangi image'larımızda var?" sorusunu dakikalar içinde cevaplamanı sağlıyor. SLSA seviyelerini pratikte uygulamayı ve SBOM üretmeyi ilerideki DevSecOps serisinde ele alacağım; bağımlılık zincirinin risklerini ve SRI'yi (Subresource Integrity) Web Güvenliği (OWASP Top 10) serisinin "OWASP A03: Software Supply Chain Failures — NuGetAudit, npm audit ve SRI" yazısında anlattım.
Üretimde Dikkat: Tespit
Önleme hiçbir zaman tam değil; bir şeyin ters gittiğini fark etmek de planın parçası. Pipeline dosyalarındaki değişikliklere CODEOWNERS ile zorunlu review koy ve bu dosyalara yapılan her değişikliği bir bildirim kanalına düşür. Platformun audit log'unu (secret oluşturma, environment kuralı değiştirme, runner ekleme) düzenli incele. Secret scanning ve push protection'ı aç; commit'lenmeye çalışılan bilinen formatlardaki anahtarlar push anında reddedilir. Bir secret'ın sızmış olabileceğinden şüphelendiğinde ilk iş log'u silmek değil, secret'ı rotate etmek; log'un kopyaları başka yerlere çoktan gitmiş olabilir.
Runner'ları geçici tut. Her job'dan sonra silinen runner'lar (GitHub-hosted runner'lar böyle çalışıyor), bir job'ın bıraktığı izin bir sonrakine geçmesini engelliyor. Self-hosted runner kullanıyorsan aynı davranışı ephemeral runner'larla elde etmeye çalış.
Sık Düşülen Tuzaklar
Workflow seviyesinde geniş yetki: permissions: write-all ya da hiç permissions yazmamak, bütün job'lara gereğinden fazla yetki verir.
Debug için secret'ı yazdırmak: "sadece bir kez, sonra sileceğim" diye eklenen satır log'da ve log'un kopyalarında kalır.
pull_request_target ile PR kodunu checkout etmek: secret'lara erişimi olan bir context'te, kim olduğunu bilmediğin birinin kodunu çalıştırmak demek.
Tag'e güvenmek: @v7 ya da @main ile kullanılan bir action'ın içeriği, sen hiçbir şey değiştirmeden değişebilir.
Uzun ömürlü cloud key'lerini "geçici olarak" secret deposuna koymak: geçici çözümler genellikle kalıcı olur; OIDC'ye geçiş, sızıntının etki süresini yıllardan dakikalara indiriyor.
Küçük Bir Deneme
★☆☆ Kendi workflow dosyalarını actionlint'ten geçir. Script injection ya da başka bir uyarı çıktı mı?
★★☆ Repository'ndeki her workflow'a üst seviyede permissions: {} ekle ve job'lara sadece gereken yetkileri ver. Hangi job'ların gerçekten yazma yetkisine ihtiyacı varmış?
★★★ Kullandığın üçüncü parti action'ları listele, her birini commit SHA'sına pin'le ve Dependabot'u github-actions ekosistemi için açarak güncellemelerin PR olarak gelmesini sağla.
Serinin son yazısında pipeline'ı ölçeceğiz ve hızlandıracağız: DORA (DevOps Research and Assessment) metriklerini deploy kayıtlarından hesaplamak, cache key tasarımı, paralellik ve ölçmeden yapılan optimizasyonun neden yanıltıcı olduğu.