Mert Özen Her Satırda Daha İleri
DevOps GitHub Actions — #13

GitHub Actions Güvenliği: SHA Pinning, Lockfile, pull_request_target ve Script Injection'dan Korunmak

Mert Özen 8 Kas 2026 12 dk 5 görüntülenme
GitHub Actions Güvenliği: SHA Pinning, Lockfile, pull_request_target ve Script Injection'dan Korunmak

Action'ları tam commit SHA'sına pin'le ve Dependabot/lockfile ile güncel tut, untrusted değerleri run: içine değil env: üzerinden ver, pull_request_target'ta PR kodu çalıştırma. tj-actions vakası, zizmor ve actionlint bulguları, gh actions-lock, act ile gerçek script injection çıktısı, checkout@v7 ve 2 Aralık 2026 değişikliği, güvenli workflow_run pattern'i ve read-only cache token'ları.

Sürüm notu: Bu yazıdaki örnekler act 0.2.97, actionlint 1.7.12, zizmor 1.32.0, Docker 29.2 ve .NET 10 ile denenmiştir. Lockfile, policy ve pull_request_target davranışları GitHub'da koşulmamıştır; tarihler Kasım 2026 itibarıyla GitHub changelog'u, dokümantasyonu ve CISA (Cybersecurity and Infrastructure Security Agency) duyurularından doğrulanmıştır.

Önce Tahmin Et

Bookstore'a gelen pull request'lerin başlığını kontrol eden bir step var: run: echo "Checking title: ${{ github.event.pull_request.title }}". Biri şu başlıkla bir PR (pull request) açıyor: Fix typo"; echo "INJECTED: this ran as a separate command. Log'da ne görürsün: tek satırlık garip bir başlık mı, yoksa iki ayrı komutun çıktısı mı?

İkinci soru: workflow'un uses: some-org/changed-files@v45 diyor. v45 bir tag; kodunu bir kez okudun ve güvenli buldun. Yarın aynı satır aynı kodu mu çalıştırır?

Threat Model: Workflow'a Kim Kod Sokabilir?

Bir workflow, repository'nin yazma token'ına, secret'larına ve çoğu zaman bir cloud hesabına erişen bir program. Ona dışarıdan girdi gelen her kapı bir saldırı yüzeyi. Bookstore için bu kapıları sayalım. Birincisi, kullandığımız third-party action'ların kodu: uses: satırı, başkasının repository'sindeki kodu bizim yetkilerimizle çalıştırır. İkincisi, PR'ın içeriği: fork'tan gelen kod, build sırasında çalışan test'ler, MSBuild target'ları, npm script'leri. Üçüncüsü, PR'ın metadata'sı: başlık, açıklama, branch adı, commit mesajı; hepsi saldırganın yazdığı string'ler. Dördüncüsü, job'lar arası taşınan veri: cache'ler ve artifact'lar. Beşincisi, runner'ın kendisi: özellikle self-hosted runner'lar, bir job'dan sonrakine iz taşıyabilir.

Savunma da bu kapılara göre kuruluyor: çalıştırdığın kodu sabitle, saldırganın string'lerini kod olarak değil veri olarak işle, ayrıcalıklı context ile güvenilmeyen kodu ayır, token'ların yetkisini daralt. Genel çerçeve (least privilege, kısa ömürlü credential, supply chain) CI (Continuous Integration) ve CD (Continuous Delivery) süreçlerini anlatan CI/CD Temelleri serisinin pipeline güvenliği yazısında ve OWASP (Open Worldwide Application Security Project) serisinin A03 "Software Supply Chain Failures" yazısında; burada GitHub Actions'a özgü mekaniklere bakıyoruz. Bu yazı savunma odaklı; saldırı tekniklerinin ayrıntısına girmiyoruz.

Vaka: tj-actions/changed-files (Mart 2025)

Mart 2025'te, 23.000'den fazla repository'nin kullandığı tj-actions/changed-files action'ının birden fazla sürüm tag'i, kötü niyetli bir commit'i gösterecek şekilde yeniden yazıldı. O commit, runner process'inin belleğindeki secret'ları okuyup base64 ile kodlayarak workflow log'una basıyordu. Olay CVE (Common Vulnerabilities and Exposures) sisteminde CVE-2025-30066 olarak kayda geçti; CISA'nın verdiği maruziyet penceresi 12 Mart 2025 00:00 UTC (Coordinated Universal Time) ile 15 Mart 12:00 UTC arası. CISA'ya göre saldırıyı reviewdog/action-setup@v1'in kısa süre önceki ele geçirilmesi (CVE-2025-30154) mümkün kılmış olabilir. Log'ları herkese açık public repository'ler en büyük riskteydi: secret'lar, log'u okuyabilen herkesin önündeydi.

Vakadan çıkan ders tahminin ikinci sorusunun cevabı: Git tag'i bir isimden ibaret, değişmez bir referans değil. Repository'ye yazma yetkisi olan biri v45'i istediği commit'e taşıyabilir ve senin workflow'un ertesi koşuda farklı kodu çalıştırır. @v45 yazdığında güvendiğin şey kod değil, o repository'nin hesap güvenliği.

Tag Yerine SHA: Pinning ve Dependabot

Çözüm, action'ı tam uzunluktaki commit SHA'sına (Secure Hash Algorithm) sabitlemek. Commit hash'i içeriğin kendisinden türer; tag taşınsa bile o SHA aynı kodu gösterir. SHA'yı tag'den şöyle bulursun:

$ git ls-remote https://github.com/actions/checkout refs/tags/v7.0.1
3d3c42e5aac5ba805825da76410c181273ba90b1	refs/tags/v7.0.1
$ git ls-remote https://github.com/actions/setup-dotnet refs/tags/v6.1.0
0f4a354dd5547a28c70b7853dbd95d5b67886e73	refs/tags/v6.1.0

Workflow'da SHA'yı yazıp okunabilirlik için sürümü satır sonu yorumu olarak bırakırsın:

name: ci-pinned

on:
  pull_request:
    branches: [main]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
        with:
          global-json-file: global.json
      - run: dotnet test --project api/tests/Bookstore.Api.Tests -c Release

Aynı workflow'un tag'li ve permissions:'sız hâlini zizmor ile taradım. zizmor, GitHub Actions workflow'ları için yazılmış bir statik analiz aracı; ağ erişimi istemeyen --offline modunda çalıştırdım:

error[unpinned-uses]: unpinned action reference
  --> .github/workflows/ci-tags.yml:11:15
   |
11 |       - uses: actions/checkout@v7
   |               ^^^^^^^^^^^^^^^^^^^ action is not pinned to a hash (required by blanket policy)
...
warning[excessive-permissions]: overly broad permissions
  --> .github/workflows/ci-tags.yml:8:3
...
help[artipacked]: credential persistence through GitHub Actions artifacts
  --> .github/workflows/ci-tags.yml:11:9
   |
11 |       - uses: actions/checkout@v7
   |         ^^^^^^^^^^^^^^^^^^^^^^^^^ does not set persist-credentials: false
...
7 findings (3 suppressed, 1 unsafe fixes): 0 informational, 1 low, 1 medium, 2 high

Pin'lenmiş hâli için çıktı tek satır: No findings to report. Good job! (2 suppressed). 09–11. yazılarda yazdığım workflow'ları taradığımda 20 unpinned-uses ve 5 artipacked bulgusu çıktı; okunabilirlik için tag kullandık, production repository'sinde bu satırların hepsi SHA olmalı.

SHA'nın bedeli güncellemenin elle yapılması. Bunu Dependabot'a bırakırsın; github-actions ecosystem'i SHA'yı ve yanındaki sürüm yorumunu birlikte günceller:

version: 2
updates:
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly
    groups:
      actions:
        patterns: ["*"]

Dependabot PR'ını merge etmeden önce yeni SHA'nın gerçekten action'ın yayınlanmış bir release'ine karşılık geldiğini kontrol et; otomatik güncelleme, review edilmeyen güncelleme olmamalı.

Workflow Lockfile'ları ve Policy'ler

SHA pinning'in kör noktası transitive bağımlılıklar: senin pin'lediğin composite action'ın içinde uses: other-org/tool@v2 varsa o satır tag olarak kalır. Ekim 2026'da public preview'a çıkan ve 28 Ekim 2026'da GA (General Availability) olan workflow lockfile'ları bunu çözüyor. .github/workflows/actions.lock dosyası, workflow'ların kullandığı her action'ın ve onların transitive bağımlılıklarının doğrulanmış commit'ini kaydediyor; lockfile'a alınmış bir workflow'da Actions, tag veya branch taşınmış olsa bile kayıtlı commit'i çalıştırıyor. Dosyayı GitHub CLI'nin (Command Line Interface) gh actions-lock extension'ı yönetiyor:

gh extension install github/gh-actions-lock
gh actions-lock
gh actions-lock --verify
gh actions-lock --verify-local
gh actions-lock --relock

Sırasıyla: kurulum, lockfile'ı oluşturma veya düzeltme, CI'da değişiklik yapmadan doğrulama, ağsız ve yalnızca kapsama bakan pre-commit kontrolü, taşınmış ref'leri (v4, main) güncel commit'e ilerletme. Dependabot bir action'ı güncellerken lockfile'daki kaydı aynı PR'da yeniliyor. Bilinen sınırlar README'de açıkça yazılı: başka repository'den çağrılan reusable workflow'lar kilitlenmiyor, container image'ları kilitlenmiyor ve yerel ./ action'ları takip edilmiyor (araç bunları 12. yazıdaki $/ syntax'ına çeviren bir seçenek sunuyor). Bu makinede kimliği doğrulanmış bir gh olmadığı ve token kullanmadığım için bu komutları çalıştırmadım; davranışlar changelog ve README'den.

Allowed Actions ve SHA Zorunluluğu

Organization veya repository ayarlarındaki allowed actions policy'si, hangi action'ların çalışabileceğini sınırlar. Ağustos 2025'ten beri listeye ! önekli girişlerle belirli bir action'ı veya sürümü engelleyebiliyor ve "full-length commit SHA" zorunluluğunu açabiliyorsun; açıkken tag ile çağrılan bir action içeren workflow fail olur. Şubat 2026'dan beri bu ayarlar Free, Team ve Enterprise planlarının hepsinde var. Bir action ele geçirildiğinde ilk müdahale, onu organization genelinde engellemek; sonra pin'leri ve lockfile'ı temiz bir commit'e çekmek ve maruziyet penceresindeki her secret'ı rotate etmek.

Script Injection: Expression'ı run: İçine Koyma

Tahminin ilk sorusunu act ile denedim. Workflow'da biri güvensiz, biri güvenli iki step var:

name: pr-title

on: pull_request

permissions:
  contents: read

jobs:
  check-title:
    runs-on: ubuntu-latest
    steps:
      - name: Unsafe
        run: |
          echo "Checking title: ${{ github.event.pull_request.title }}"
      - name: Safe
        env:
          TITLE: ${{ github.event.pull_request.title }}
        run: |
          echo "Checking title: $TITLE"

Event dosyasındaki başlık, zararsız bir echo taşıyan şu metin: Fix typo"; echo "INJECTED: this ran as a separate command. Çıktı:

[pr-title/check-title] ⭐ Run Main Unsafe
[pr-title/check-title]   | Checking title: Fix typo
[pr-title/check-title]   | INJECTED: this ran as a separate command
[pr-title/check-title] ⭐ Run Main Safe
[pr-title/check-title]   | Checking title: Fix typo"; echo "INJECTED: this ran as a separate command

Güvensiz step'te başlık iki komuta bölündü ve ikincisi çalıştı. Sebebi, ${{ }} ifadesinin shell çalışmadan önce, düz metin olarak script'in içine yapıştırılması: shell, tırnağı kapatan ve yeni komut başlatan bir script görüyor. Bir echo yerine aynı yere token'ı dışarı gönderen bir komut konabilirdi. Güvenli step'te değer bir environment variable; shell onu tırnaklı bir değişken olarak açıyor ve içeriği ne olursa olsun veri olarak kalıyor.

İki araç da bunu yakalıyor. actionlint: "github.event.pull_request.title" is potentially untrusted. avoid using it directly in inline scripts. instead, pass it through an environment variable. zizmor: error[template-injection]: code injection via template expansion. Kural basit: saldırganın kontrol edebileceği hiçbir değeri (github.event.* altındaki başlıklar, gövdeler, branch adları, github.head_ref, workflow_dispatch input'ları) run: içine expression olarak koyma; env: üzerinden geçir ve tırnakla kullan. Aynısı actions/github-script'in script: alanı için de geçerli.

pull_request_target, workflow_run ve Checkout

pull_request event'i fork'tan gelen PR'larda kısıtlı çalışır: secret yok, GITHUB_TOKEN salt okunur. pull_request_target ise base repository'nin context'inde çalışır: secret'lar ve yazma yetkili token var. Tasarım amacı PR'a etiket koymak veya yorum yazmak gibi kodu çalıştırmayan işler. Tehlike, bu ayrıcalıklı workflow'da PR'ın kodunu checkout edip build etmekle başlıyor; o anda saldırganın kodu senin secret'larınla çalışır. workflow_run da aynı sınıfta: güvenilmeyen bir workflow'un sonucuyla ayrıcalıklı bir workflow tetikler.

2026'da üç değişiklik bu kapıyı daralttı. Haziran 2026'da çıkan actions/checkout@v7, pull_request_target altında ve pull request kaynaklı workflow_run altında fork PR'ının kodunu checkout etmeyi varsayılan olarak reddediyor; açmak için allow-unsafe-pr-checkout: true yazman gerekiyor. 20 Temmuz 2026'da aynı davranış v6.1.0, v5.1.0, v4.4.0, v3.7.0 ve v2.8.0'a da eklendi; major tag'i takip edenler otomatik aldı, SHA'ya veya tam sürüme pin'leyenlerin güncellemesi gerekiyor. İkincisi, Eylül 2026'da GA olan workflow execution protection'ları ile organization'lar hangi event'lerin ve kimlerin workflow tetikleyebileceğini ruleset'lerle sınırlayabiliyor. Üçüncüsü ve en sert olanı: 2 Aralık 2026'dan itibaren public repository'lerde pull_request_target, bir Actions event policy'si açıkça izin vermedikçe varsayılan olarak engellenecek. Geçiş öncesinde 9 ve 16 Kasım 2026'da 16:00–18:00 UTC arasında brownout'lar planlı; bu saatlerde engellenen run'lar hata annotation'ıyla düşüyor. Private ve internal repository'ler ile GitHub Enterprise Server bundan etkilenmiyor.

Ayrıcalık gerektiren bir işin varsa (örneğin PR'a test sonucunu yorum olarak yazmak), güvenli düzen işi ikiye bölmek. Güvenilmeyen kod, secret'sız pull_request workflow'unda çalışır ve sonucu artifact olarak bırakır:

name: pr-test

on: pull_request

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
        with:
          global-json-file: global.json
      - name: Test and count
        run: |
          dotnet test --project api/tests/Bookstore.Api.Tests -c Release --report-xunit-trx --results-directory results
          grep -o 'passed="[0-9]*"' results/*.trx | head -1 | tr -dc '0-9' > results/passed.txt
      - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: test-summary
          path: results/passed.txt

Ayrıcalıklı ikinci workflow kod çalıştırmaz, artifact'ı veri olarak okur ve önce doğrular:

name: pr-comment

on:
  workflow_run: # zizmor: ignore[dangerous-triggers] artifact is validated as data
    workflows: [pr-test]
    types: [completed]

permissions:
  contents: read

jobs:
  comment:
    if: github.event.workflow_run.event == 'pull_request'
    runs-on: ubuntu-latest
    permissions:
      actions: read
      pull-requests: write
    steps:
      - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
        with:
          name: test-summary
          run-id: ${{ github.event.workflow_run.id }}
          github-token: ${{ github.token }}
      - name: Comment with the result
        env:
          GH_TOKEN: ${{ github.token }}
          PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
        run: |
          passed=$(cat passed.txt)
          if ! [[ "$passed" =~ ^[0-9]+$ ]]; then
            echo "Unexpected artifact content, refusing to use it"
            exit 1
          fi
          gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body "Unit tests passed: $passed"

Artifact saldırganın elinden geçmiş bir dosya; içeriği beklenen biçimde değilse kullanılmıyor. Test ve sayma komutlarını yerelde çalıştırdım (6 test, passed.txt içinde 6); iki workflow'un zinciri GitHub API'sine (Application Programming Interface) ve gh'ye ihtiyaç duyduğu için act'te koşulmadı, actionlint ve zizmor ile doğrulandı. zizmor yorum satırı olmadan workflow_run'ı yine işaretliyor (error[dangerous-triggers]: use of fundamentally insecure workflow trigger); bu, aracın "neden kullandığını açıkla" deme biçimi. Satır sonundaki ignore yorumu bu kararı kayda geçiriyor.

persist-credentials: false da bu bölümün parçası. actions/checkout varsayılan olarak token'ı sonraki Git komutları için saklıyor; v6'dan beri .git/config yerine $RUNNER_TEMP altındaki ayrı bir dosyada, ama job'ın sonraki step'leri için hâlâ orada. Workspace'i artifact olarak yükleyen veya güvenilmeyen kod çalıştıran bir job'da bu token'a ihtiyacın yoksa kapat.

Cache Poisoning ve Read-Only Cache Token'ları

Cache, job'lar ve hatta workflow'lar arasında taşınan bir veri; default branch scope'unda yazılan bir cache, sonraki ayrıcalıklı koşularda geri yüklenir. Güvenilmeyen bir koşunun default branch scope'una cache yazabilmesi, zehirli bir NuGet veya npm klasörünün release build'ine sızması demek. 26 Haziran 2026'dan beri pull_request_target, issue_comment ve fork PR'larından gelen workflow_run gibi güvenilmeyen event'ler default branch scope'unda salt okunur cache token'ı alıyor: restore çalışıyor, save engellenip uyarıyla geçiliyor. Workflow ve job seviyesinde yeni bir cache-mode alanı (read, write, write-only, none) da geldi; actionlint 1.7.12 bu alanı henüz tanımıyor (unexpected key "cache-mode" for "job" section), o yüzden örneklerde kullanmadım. Release build'lerinde cache'ten restore edilen içeriğe güvenme; lock dosyalı restore (--locked-mode, npm ci) bu yüzden ayrıca önemli.

Üretimde Dikkat

GITHUB_TOKEN'ı varsayılan olarak salt okunur yap. Repository ve organization ayarlarında "Read repository contents permission" seçili olsun; yazma gereken her job, permissions: ile kendisi istesin. 08. yazıda konuştuğumuz least privilege'ın uygulaması bu.

Workflow'ları CI'da tara. actionlint her PR'da, zizmor ve OpenSSF (Open Source Security Foundation) Scorecard'ın Pinned-Dependencies, Token-Permissions ve Dangerous-Workflow kontrolleri haftalık. zizmor'un çıktısı SARIF (Static Analysis Results Interchange Format) olarak code scanning'e yüklenebiliyor.

Bir action ele geçirildi haberini aldığında kronolojiyle çalış: action'ı allowed actions'ta engelle, maruziyet penceresindeki run'ları bul, log'larda base64 kodlu bloklara bak, o run'ların erişebildiği her secret'ı rotate et. Public repository'de log'lar herkese açık; orada "belki görülmemiştir" diye bir seçenek yok.

Self-hosted runner'ı public repository'ye bağlama. Herkes PR açabilir ve runner'ın bir job'dan sonra temizlendiğinin garantisi yok; 14. yazıda ayrıntısıyla.

Sık Düşülen Tuzaklar

Tag'e güvenmek. @v4 bir isim; içeriğe değil, sahibinin hesabına güveniyorsun.

SHA pin'leyip güncellemeyi unutmak. Pin'lenmiş ama iki yıllık bir action, bilinen güvenlik açıklarıyla donmuş bir action'dır. Dependabot olmadan pinning yarım iş.

Kısa SHA ile pin'lemek. Yalnızca tam uzunluktaki (40 karakter) SHA değişmezlik garantisi verir; SHA zorunluluğu policy'si de tam uzunluk ister.

Untrusted değeri run: veya script: içine expression ile koymak. Başlık, branch adı, commit mesajı, input; hepsi env: üzerinden.

pull_request_target'ta PR'ın head'ini checkout etmek. checkout@v7 artık varsayılan olarak reddediyor; allow-unsafe-pr-checkout: true yazmak, o korumayı bilerek kapatmak demek.

workflow_run'da artifact'ı güvenilir sanmak. Artifact'ı güvenilmeyen kod üretti; doğrulamadan çalıştırma, shell'e verme, path olarak kullanma.

Küçük Bir Deneme

★☆☆ Serideki bir workflow'u zizmor ile --offline tara, bütün uses: satırlarını git ls-remote ile bulduğun SHA'lara çevir ve taramayı tekrarla. Kaç bulgu kaldı?

★★☆ pr-title workflow'unun güvensiz step'ini branch adıyla (github.head_ref) tekrarla ve act'e verdiğin event dosyasında branch adını değiştir. actionlint ve zizmor hangisini yakalıyor?

★★★ pr-comment workflow'unda passed.txt yerine bir JSON (JavaScript Object Notation) artifact'ı kullan ve içeriği jq ile şemasına göre doğrula: yalnızca beklenen alanlar, yalnızca sayılar. Doğrulamadan geçemeyen bir artifact'la act'te workflow_run event dosyası hazırlayıp step'in nasıl reddettiğini gör.

Bu yazıda runner'ı sabit kabul ettik: ubuntu-latest ve onun üzerinde koşan job'lar. Oysa ubuntu-latest şu günlerde tam olarak altımızdan kayıyor. Bir sonraki yazıda hosted image'ları, Ubuntu 26.04 geçişini, arm64 ve larger runner'ları ve self-hosted runner'ların ne zaman doğru seçim olduğunu ele alıyoruz.