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

GitHub Actions'ta Secret, Variable ve GITHUB_TOKEN Permission'ları

Mert Özen 8 Kas 2026 10 dk 5 görüntülenme
GitHub Actions'ta Secret, Variable ve GITHUB_TOKEN Permission'ları

Secret'lar organization, repository ve environment seviyesinde tanımlanır, en dar seviye kazanır ve log'da metin eşleştirmesiyle maskelenir; base64 gibi türetilmiş değerler ve JSON içinden okunan alanlar maskelenmez. act ile sahte secret'larla ::add-mask::, if içinde secret kullanımı, variable'lar ve boş GITHUB_TOKEN'ı gösteriyor; permissions ile least privilege'ı, Eylül 2026'da gelen vulnerability-alerts scope'unu ve PAT yerine GitHub App token'ını anlatıyoruz.

Sürüm notu: Bu yazıdaki örnekler act 0.2.97, actionlint 1.7.12 ve Docker 29.2 ile denenmiştir. Workflow'lar act ile yerelde, yalnız bu yazı için uydurulmuş sahte secret değerleriyle koşuldu; GitHub'da koşulmadı ve hiçbir gerçek token kullanılmadı. Kasım 2026 itibarıyla geçerli bilgilerdir.

Önce Tahmin Et

Bookstore'un private bir NuGet feed'i var ve CI (Continuous Integration) ona Basic authentication ile bağlanıyor. Token, repository secret'ı olarak NUGET_FEED_TOKEN adıyla tanımlı. Alan, bağlantı sorununu ayıklamak için iki satır ekliyor:

    env:
      NUGET_FEED_TOKEN: ${{ secrets.NUGET_FEED_TOKEN }}
    steps:
      - name: A secret is masked
        run: echo "token=$NUGET_FEED_TOKEN"

      - name: A value derived from it is not
        run: echo "auth=$(printf 'bookstore:%s' "$NUGET_FEED_TOKEN" | base64)"

GitHub secret'ları log'da maskeliyor; Alan bunu biliyor. Peki iki satırdan hangisi log'da secret'ı ele verir: hiçbiri mi, ilki mi, ikincisi mi?

$ act workflow_dispatch --secret-file act.secrets --var-file act.vars
[secrets-demo/secrets] ⭐ Run Main A secret is masked
[secrets-demo/secrets]   | token=***
[secrets-demo/secrets] ⭐ Run Main A value derived from it is not
[secrets-demo/secrets]   | auth=Ym9va3N0b3JlOmZha2UtZmVlZC10b2tlbi0wMDAx

İkincisi. Maskeleme secret'ın değerini log'da metin olarak arayıp yıldızla değiştirir; değerin base64'ü, URL (Uniform Resource Locator) olarak encode edilmiş hâli ya da ters çevrilmişi başka bir metindir ve olduğu gibi basılır. Yukarıdaki satırı base64'ten çözen herkes bookstore:fake-feed-token-0001'i görür (act'e verdiğimiz sahte değer). Public bir repository'de bu log'u herkes okuyabilir. Bu yazıda secret'ların ve variable'ların nerede tanımlandığına, maskelemenin nasıl çalıştığına ve sınırlarına, GITHUB_TOKEN'a ve onu permissions: ile daraltmaya bakıyoruz.

Secret'lar ve Variable'lar: Seviyeler ve Öncelik

GitHub'da hassas değerler için secret, hassas olmayan configuration için variable var. İkisi de üç seviyede tanımlanabiliyor: organization, repository ve environment. Aynı adla birden fazla seviyede tanım varsa en dar olan kazanır: environment, repository'yi; repository, organization'ı ezer. Workflow'da secret'lar secrets, variable'lar vars context'inden okunur:

      - name: Configuration variable
        run: echo "api base url=${{ vars.API_BASE_URL }}"
[secrets-demo/secrets]   | api base url=https://staging.bookstore.example

Dokümantasyonun açıkça uyardığı fark: variable'lar log'da maskelenmez. Staging URL'i, feature flag, region adı variable'dır; parola, token, connection string secret'tır. act'te bu değerleri --secret-file ve --var-file ile verdik; dosyalar çalışma klasöründe duruyor ve repository'ye girmiyor.

Dokümantasyondaki sınırlar: repository başına 100, organization başına 1.000, environment başına 100 secret; secret başına 48 KB. Secret adları yalnız harf, rakam ve alt çizgi içerebilir, GITHUB_ ile başlayamaz ve büyük/küçük harfe duyarsızdır. Organization ve repository secret'ları run kuyruğa girerken, environment secret'ları ise o environment'ı kullanan job başlarken okunur. Environment'lar ve onay kuralları dokuzuncu yazının konusu.

Bir secret'ı bir action'a ya da script'e vermenin tek yolu onu açıkça yazmak: bir input'a (with:) ya da bir environment variable'a (env:). Workflow'da adı geçmeyen bir secret runner'a hiç gönderilmez. Bu yüzden secret'ı workflow seviyesindeki env:'e değil, ona gerçekten ihtiyaç duyan job'a ya da step'e koy.

Maskeleme ve ::add-mask::

Türetilmiş bir değeri log'a basmadan önce maskelemeye eklemek için ::add-mask:: workflow komutu var:

      - name: Mask the derived value first
        run: |
          auth=$(printf 'bookstore:%s' "$NUGET_FEED_TOKEN" | base64)
          echo "::add-mask::$auth"
          echo "auth=$auth"
[secrets-demo/secrets] ⭐ Run Main Mask the derived value first
[secrets-demo/secrets]   | auth=***

Sıra önemli: ::add-mask:: satırı değer log'a ilk kez yazılmadan önce gelmeli; ondan önce basılmış satırlar geriye dönük maskelenmez. Daha iyisi türetilmiş değeri hiç basmamak; hata ayıklarken bile "değer boş mu, uzunluğu kaç" gibi sorular çoğu zaman yeterli.

Yapılandırılmış secret'lar ayrı bir tuzak. Bir veritabanı bilgisini tek bir JSON (JavaScript Object Notation) secret'ı olarak saklayıp içinden alan okumak cazip:

      - name: JSON secret
        run: echo "password=${{ fromJSON(secrets.DB_CONFIG).password }}"
[secrets-demo/secrets]   | password=Fake-Pass-0001

Maskelenen şey secret'ın tamamı, yani bütün JSON metni; içinden çıkardığın parça ayrı bir metin olduğu için act'te açıkça basıldı. Dokümantasyonun secret adlandırma bölümünde tam da bu yüzden "secret değeri olarak yapılandırılmış veri kullanmaktan kaçın" tavsiyesi var. Bunu GitHub'da denemedik; ama kural her iki ortamda da aynı mantığa dayanıyor: maskeleme metin eşleştirmesi. Her değeri ayrı bir secret yap. GitHub ayrıca secret olarak tanımlanmamış bazı biçimleri de otomatik maskeliyor (JWT'ler (JSON Web Token), NuGet API (Application Programming Interface) key'leri, connection string'ler, GitHub token'ları gibi), ama dokümantasyonun kendisi bunun garanti olmadığını yazıyor.

Secret'lar ve if: Koşulları

"Secret tanımlıysa private feed'i kullan" demek istediğinde ilk akla gelen if: ${{ secrets.NUGET_FEED_TOKEN != '' }}. actionlint bunu yakalıyor:

.github/workflows/bad-perms.yml:13:13: context "secrets" is not allowed here. available contexts are "github", "inputs", "needs", "vars". see https://docs.github.com/en/actions/learn-github-actions/contexts#context-availability for more details [expression]

Dokümantasyonun önerdiği yol secret'ı job seviyesinde bir environment variable'a koyup koşulda env'i okumak. Bizim workflow'umuzda NUGET_FEED_TOKEN job'ın env:'inde tanımlı:

      - name: Only when the secret is set
        if: ${{ env.NUGET_FEED_TOKEN != '' }}
        run: echo "private NuGet feed configured"

Secret varken step koştu (private NuGet feed configured); act'i NUGET_FEED_TOKEN olmadan çalıştırdığımızda ise atlandı:

[secrets-demo/secrets] [DEBUG] Skipping step 'Only when the secret is set' due to '${{ env.NUGET_FEED_TOKEN != '' }}'

Bu pattern özellikle fork'lardan gelen pull request'lerde işe yarıyor; çünkü orada secret'lar boş gelir.

Fork Pull Request'leri, Dependabot ve Secret'lar

Dokümantasyonun kuralı net: fork'tan tetiklenen bir workflow'a GITHUB_TOKEN dışında hiçbir secret gönderilmez ve GITHUB_TOKEN da bu durumda salt okunurdur. Sebep basit: fork'u açan kişi workflow dosyasını da değiştirebilir; secret'lar gönderilseydi, echo $SECRET | base64 yazan bir pull request onları alıp giderdi. Dependabot'un açtığı pull request'ler de varsayılan olarak salt okunur bir token alır ve yalnız Dependabot secret'larına erişebilir.

Pratik sonucu: CI'ın fork pull request'lerinde secret'sız çalışabilmeli. Private feed'e ihtiyaç duyan adımı yukarıdaki env koşuluyla atla, entegrasyon testlerini secret gerektirmeyen bir service container'la koştur. "Fork'ta da secret lazım" ihtiyacı insanları pull_request_target'a iter ve ikinci yazıda gördüğümüz gibi o kapı dikkat ister; güvenli pattern'leri güvenlik yazısında ele alacağız.

GITHUB_TOKEN: Ne, Ne Kadar Yaşar, Ne Yapabilir?

Her job başladığında GitHub, repository'ye kurulu olan GitHub Actions uygulaması adına bir installation access token üretir ve secrets.GITHUB_TOKEN (ya da github.token) olarak verir. Dokümantasyona göre token job bitince ya da en geç effective maximum lifetime dolunca geçersiz olur: GitHub-hosted runner'da en fazla 6 saat, self-hosted runner'da 24 saat. Yetkisi yalnız workflow'un bulunduğu repository ile sınırlı.

act ise GITHUB_TOKEN üretmiyor; vermediğin sürece boş:

      - name: GITHUB_TOKEN in act
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: if [ -z "$GH_TOKEN" ]; then echo "GITHUB_TOKEN is empty"; else echo "GITHUB_TOKEN is set"; fi
[secrets-demo/secrets]   | GITHUB_TOKEN is empty

Bu seride act'e hiçbir zaman gerçek bir token vermedik. Token gerektiren adımları (release oluşturmak, GHCR'a (GitHub Container Registry) image göndermek) act'te çalıştırmayıp actionlint ile doğruluyoruz.

Önemli bir ayrıntı: bir action, workflow sen ona token'ı açıkça vermesen bile github.token context'inden token'a erişebilir. Yani "bu action'a token vermedim" demek, action'ın token'a erişemediği anlamına gelmez. Token'ın ne yapabileceğini sınırlamanın tek yolu permissions:.

permissions ile Least Privilege

Bookstore'un bütün workflow'ları en üstte şu satırla başlıyor ve yazma yetkisi gereken job'lar yalnız ihtiyaç duydukları scope'u kendi seviyelerinde açıyor:

permissions:
  contents: read

jobs:
  github-release:
    permissions:
      contents: write

Kural: permissions altında bir scope bile yazarsan, yazmadığın bütün scope'lar none olur. Job seviyesindeki permissions workflow seviyesindekini o job için tamamen değiştirir, üstüne eklemez. Varsayılan yetkiler ise repository, organization ya da enterprise ayarından gelir. GitHub, 2 Şubat 2023 changelog'uyla yeni oluşturulan kişisel repository'ler ve yeni enterprise'lar için varsayılanı salt okunura çekti, organization'a ait repository'ler organization'ın ayarını izliyor; ama değişiklik mevcut repository ve organization'lara dokunmadı. Yani eski bir organization'da "Read and write permissions" varsayılanı hâlâ açık olabilir; workflow'un başına açık bir permissions: yazmak bu belirsizliği ortadan kaldırır.

Dokümantasyondaki scope listesi zamanla büyüyor. Eylül 2026'nın ilk haftasındaki Actions changelog'u yeni bir scope ekledi: vulnerability-alerts. Dependabot alert'lerini okumak için daha önce PAT (Personal Access Token) ya da GitHub App gerekiyordu; artık vulnerability-alerts: read yeterli. Yalnız read ve none kabul ediliyor. Burada araçların yeniliklerin gerisinde kaldığı bir yer daha var: actionlint 1.7.12 bu scope'u tanımıyor ve yanlış alarm veriyor. Bilerek bozduğumuz bir dosyada:

permissions:
  contents: admin
  vulnerability-alerts: read
  pull-request: write
$ actionlint .github/workflows/bad-perms.yml
.github/workflows/bad-perms.yml:6:13: "admin" is invalid as permission of scope "contents". available values are "read", "write", "none" [permissions]
.github/workflows/bad-perms.yml:7:3: unknown permission scope "vulnerability-alerts". all available permission scopes are "actions", "artifact-metadata", "attestations", "checks", "contents", "deployments", "discussions", "id-token", "issues", "models", "packages", "pages", "pull-requests", "repository-projects", "security-events", "statuses" [permissions]
.github/workflows/bad-perms.yml:8:3: unknown permission scope "pull-request". all available permission scopes are "actions", "artifact-metadata", "attestations", "checks", "contents", "deployments", "discussions", "id-token", "issues", "models", "packages", "pages", "pull-requests", "repository-projects", "security-events", "statuses" [permissions]

Birinci ve üçüncü hata gerçek: admin geçerli bir seviye değil, pull-request ise pull-requests'in yazım hatası. İkincisi yanlış alarm; GitHub'ın güncel dokümantasyonu vulnerability-alerts'ı listeliyor. actionlint'in listesinde de dokümantasyonun güncel listesinde olmayan models ve repository-projects var. Araca güven, ama tarihlerini bil: bir uyarı yeni bir GitHub özelliğiyle ilgiliyse önce dokümantasyona bak.

PAT mı, GitHub App Token'ı mı?

GITHUB_TOKEN bazı işleri yapamaz: başka bir repository'ye yazmak, yeni workflow run'larını tetikleyecek bir push yapmak (ikinci yazıda gördük), organization seviyesinde işlem yapmak. Bunlar için ya bir PAT ya da bir GitHub App kullanılır. Git & GitHub serisinin GitHub hesabına bağlanmayı ve Personal Access Token'ları anlatan 13. yazısında PAT'leri konuşmuştuk; CI'da PAT'in iki sorunu var: bir kişiye bağlı (o kişi şirketten ayrılınca workflow kırılır) ve genellikle uzun ömürlü. Dokümantasyonun da önerdiği yol GitHub App: yetkileri ince ayarlı, token'ı kısa ömürlü ve bir kişiye bağlı değil. actions/create-github-app-token (Kasım 2026 itibarıyla v3.3.0) App'in client ID'si ve private key'iyle her job'da yeni bir installation token üretir:

      - id: app-token
        uses: actions/create-github-app-token@v3
        with:
          client-id: ${{ vars.BOOKSTORE_APP_CLIENT_ID }}
          private-key: ${{ secrets.BOOKSTORE_APP_PRIVATE_KEY }}
          permission-contents: write

README'ye göre app-id input'u artık deprecated, yerine client-id kullanılıyor ve üretilen token bir saat geçerli. Client ID hassas değil, bu yüzden variable; private key secret. Bu step'i çalıştırmadık, çünkü gerçek bir GitHub App gerektiriyor; input adlarını action'ın action.yml dosyasından doğruladık.

Üretimde Dikkat

Secret'ları bir envanter gibi yönet: hangi secret hangi workflow'da, hangi job'da kullanılıyor, en son ne zaman döndürüldü (rotate)? Kullanılmayan secret'ı sil; bir secret'ın değerini değiştirdiğinde eskisini kaynak sistemde iptal et. Bir log'a secret sızdığından şüphelenirsen önce secret'ı kaynakta iptal et, sonra log'u sil; log'u silmek tek başına yeterli değil, çünkü o log'u indirmiş ya da görmüş biri olabilir.

permissions'ı her workflow'un başına yaz ve pull request review'unda bir job'ın yetkisinin artması özel bir dikkat gerektirsin. contents: write, packages: write, id-token: write gibi satırlar review'da "neden?" sorusunu hak ediyor. OpenSSF (Open Source Security Foundation) Scorecard ve zizmor gibi araçlar bu kontrolü otomatikleştiriyor; onları güvenlik yazısında kullanacağız.

Sık Düşülen Tuzaklar

Türetilmiş değeri log'a basmak. base64, URL-encode, JSON içinden alan, bir connection string'in parçası: hiçbiri otomatik maskelenmez. ::add-mask:: kullan ya da hiç basma.

Secret'ı JSON olarak saklamak. Bütün JSON maskelenir, içindeki alanlar maskelenmez. Her değer ayrı bir secret.

Secret'ları if:'te kullanmaya çalışmak. secrets context'i orada geçerli değil; secret'ı env'e koyup env'i kontrol et.

Write-all varsayılanı olan eski repository'ler. permissions: yazmadığın bir workflow, 2023 öncesi bir organization'da bütün scope'larda yazma yetkisiyle koşabilir. Her workflow'un başına contents: read.

Secret'ı workflow seviyesindeki env:'e koymak. Bütün job'lar ve bütün action'lar onu görür. En dar seviyeye, ihtiyaç duyan step'e koy.

Variable'a hassas değer koymak. Variable'lar log'da açık görünür.

Küçük Bir Deneme

★☆☆ act'e --secret-file ile sahte bir NUGET_FEED_TOKEN ver ve değeri tersine çevirerek (rev) bas. Maskelenip maskelenmediğini kontrol et, sonra ::add-mask:: ile düzelt.

★★☆ Bookstore'un üç workflow'unda (api-ci.yml, web-ci.yml, release.yml) hangi job'ın hangi scope'a gerçekten ihtiyaç duyduğunu çıkar ve en dar permissions bloklarını yaz. actionlint ile doğrula.

★★★ Bir organization için secret ve variable envanteri çıkaran küçük bir script tasarla: GitHub CLI (Command Line Interface) ile repository'leri gez, her workflow dosyasında geçen secrets.X ve vars.Y adlarını topla ve tanımlı olup kullanılmayanları listele. Script'i yalnız kendi test organization'ında çalıştır.

Secret'ları bu yazıda repository seviyesinde düşündük. Production deploy'u ise farklı kurallar ister: yalnız belirli branch'lerden, belirli kişilerin onayıyla ve production'a özel secret'larla. Bir sonraki yazıda GitHub Actions environment'larına bakıyoruz: protection rule'lar, required reviewer'lar, wait timer, deployment branch policy'leri ve deployment geçmişi.