Configuration mı, Secret mı? Ayarları Sınıflandırmak, Twelve-Factor ve Secret'ların Sızdığı Yerler
Secret, ele geçirilince birinin senin adına iş yapabildiği her ayardır ve tek doğal yeri bir secret store'dur; build-time, runtime ve environment'a özgü config ise repo'da ya da environment'ta durabilir. Bookstore üzerinde Git geçmişinde kalan key'i, docker inspect, /proc/environ ve createdump ile görünen connection string'i, Angular bundle'ındaki API key'ini gerçek çıktılarla görüyor ve serinin hedefi olan secret'sız erişime giden envanteri çıkarıyoruz.
Sürüm notu: Bu yazıdaki örnekler .NET 10 (ASP.NET Core 10.0.15 runtime), Docker 29.2, Git 2.54 ve Angular 22.2 (TypeScript 6.0) ile denenmiştir.
Önce Tahmin Et
Bir developer JWT (JSON Web Token; kullanıcının kimliğini taşıyan imzalı token) signing key'ini yanlışlıkla appsettings.Development.json ile commit'liyor. Fark edince dosyayı siliyor, .gitignore'a ekliyor ve "secret'ları repo'dan kaldırdım" mesajıyla yeni bir commit atıyor. Key artık güvende mi? İkinci soru: production'da connection string'i environment variable ile veriyorsun, appsettings dosyasında hiçbir secret yok. Container'a docker exec ile girebilen biri bu connection string'i kaç komutla okur? Son soru: Angular uygulamasının environment.ts dosyasına konan bir API key'ini görmek için kaynak koda erişim gerekir mi?
Cevapları yazının ortasında gerçek çıktılarla göreceğiz. Kısaca: key güvende değil, connection string bir komutla okunur, Angular'daki key'i görmek için tarayıcı yeter.
Dört Tür Ayar ve Her Birinin Yeri
Bu seri sitedeki serilerin ortak örnek uygulaması Bookstore (bir online kitapçı) üzerinden ilerliyor: Bookstore.Api adında bir ASP.NET Core 10 API'si ve bookstore-web adında bir Angular 22 istemcisi. İkisinin de bir sürü ayarı var ve hepsine "config" deyip aynı dosyaya koymak, secret sızıntılarının en yaygın başlangıç noktası. Önce ayarları türlerine ayıralım; her türün doğal bir yeri var.
Build-time config, artifact üretilirken kullanılan ve sonra değişmeyen ayardır: target framework, Angular'ın bundle budget'ları, AOT (Ahead-of-Time; derlemenin build sırasında yapılması) seçenekleri. Bunlar kodla birlikte repo'da yaşar ve environment'a göre değişmemelidir. Değişiyorsa aslında runtime config'tir ve yanlış yerdedir.
Runtime config, uygulama çalışırken okunan ve bütün environment'larda aynı olan varsayılanlardır: sayfa başına kitap sayısı, log seviyesi, retry sayısı. appsettings.json bunun doğal yeri; gerekirse bir environment'ta override edilir.
Environment'a özgü config her deploy hedefinde farklı olan ama gizli olmayan değerlerdir: API'nin dışarıdan görünen URL'i, ödeme sağlayıcısının sandbox ya da canlı endpoint'i, ücretsiz kargo eşiği. Bunlar environment'ın kendisinde (environment variable, App Service app setting) ya da birden çok servisin paylaştığı bir config servisinde durur.
Secret, ele geçirilince birinin senin adına bir şey yapabildiği her değerdir: veritabanı parolası içeren connection string, JWT signing key'i, e-posta servisinin API key'i, ödeme sağlayıcısının webhook secret'ı. Bunların tek doğal yeri bir secret store'dur (Azure Key Vault, HashiCorp Vault, OpenBao). Feature flag ayrı bir tür: değeri gizli değildir ama deploy'dan bağımsız değişir; Deployment Stratejileri serisinin Feature Flag'ler yazısında ayrıntısıyla anlatıldı.

Matristeki "Koşullu" hücreler serinin geri kalanının konusu. Secret satırında environment "Koşullu" çünkü environment variable bir taşıma yolu, saklama yeri değil; aşağıda neden sınırlı olduğunu göreceğiz. Config servisi de "Koşullu", çünkü App Configuration gibi bir servis secret'ın kendisini değil, Key Vault'taki yerine bir referansı tutmalı (dördüncü yazı). Pipeline sütunu ise yalnızca pipeline'ın kendi ihtiyacı olan credential'lar için var; bu konu GitHub Actions serisinin Secret, Variable ve GITHUB_TOKEN yazısında ve CI/CD Temelleri serisinin Pipeline Güvenliği yazısında işlendi.
Twelve-Factor Config İlkesi ve Sınırları
Twelve-Factor App'in config bölümü config'i deploy'lar arasında değişebilecek her şey olarak tanımlar ve bunun koddan tamamen ayrılmasını ister. Önerdiği test pratik: codebase'i şu an açık kaynak yapsan, hiçbir credential açığa çıkmamalı. Bunu sağlamak için de config'in environment variable'larda tutulmasını önerir; environment variable dilden ve işletim sisteminden bağımsızdır, kazara commit'lenmesi zordur. Bir diğer önemli tespiti, ayarları "development", "staging", "production" gibi adlandırılmış gruplara bağlamanın ölçeklenmediği; her değişken her deploy için bağımsız yönetilmeli.
İlke doğru, ama 2011'de yazıldı ve iki konuda tek başına yeterli değil. Birincisi, environment variable bir saklama yeri değil, process'e taşıma yolu. Değer bir yerden gelip environment'a yazılıyor; o "bir yer" pipeline variable'ı, bir .env dosyası ya da Kubernetes manifest'i olduğunda secret yine bir dosyada, çoğu zaman şifresiz duruyor. İkincisi, environment variable'ın görünürlüğü geniş: aynı process'in bütün kodu, child process'ler, crash dump'lar ve container metadata'sı onu görür. OWASP (Open Worldwide Application Security Project; açık bir web güvenliği topluluğu) da Secrets Management Cheat Sheet'inde environment variable'ı dosya tabanlı ya da bellek içi aktarmanın mümkün olmadığı durumlar için son çare olarak anar. Bu yüzden serinin yaklaşımı şu: environment'a özgü gizli olmayan ayarlar için environment variable iyi; secret'lar için hedef, uygulamanın secret store'dan kendi kimliğiyle okuması.
Secret Nereden Sızar? Önce Git Geçmişi
Baştaki senaryoyu gerçekten çalıştıralım. Boş bir repo'da key'i commit'liyorum, sonra dosyayı silip .gitignore'a ekliyorum:
KEY=$(openssl rand -base64 32)
printf '{\n "Jwt": {\n "SigningKey": "%s"\n }\n}\n' "$KEY" > appsettings.Development.json
git add . && git commit -qm "Add local JWT settings"
git rm -q appsettings.Development.json
echo "appsettings.Development.json" > .gitignore
git add .gitignore && git commit -qm "Remove secrets from repo"
Çalışma klasöründe artık hiçbir secret yok. Ama git log'un -S seçeneği bir metnin eklendiği ya da çıkarıldığı commit'leri bulur:
$ git log -p -S SigningKey --oneline
857fe32 Remove secrets from repo
diff --git a/appsettings.Development.json b/appsettings.Development.json
deleted file mode 100644
index 46dd054..0000000
--- a/appsettings.Development.json
+++ /dev/null
@@ -1,5 +0,0 @@
-{
- "Jwt": {
- "SigningKey": "0KlKaWZDHG6cRbfaQ2jWdEDPTNtmgriob9higWQnw18="
- }
-}
bce5f98 Add local JWT settings
...
Key iki commit'te birden duruyor: eklendiği commit'te ve silindiği commit'in diff'inde. Repo'yu clone'lamış herkesin, fork'ların ve CI sunucusunun cache'inde de duruyor. Geçmişi yeniden yazmak mümkün ama yetmez; doğru sıra önce key'i geçersiz kılmak, sonra temizlik. Bunu yedinci yazının sızıntı runbook'unda adım adım yapacağız. Secret'ları commit anında ve pipeline'da yakalayan secret scanning araçları ise DevSecOps serisinin konusu.
Çalışan Process'ten Sızıntı: Log, Environment ve Dump
Repo temiz olsa bile secret, çalışan uygulamanın etrafında birçok yerde görünür. İlk örnek iyi niyetle eklenmiş bir log satırı. ASP.NET Core Web API serisinin Configuration ve Options Pattern yazısında hangi değerin hangi provider'dan geldiğini görmek için GetDebugView()'ı kullanmıştık. Bunu startup log'una yazmak cazip geliyor:
var app = builder.Build();
// "Hangi ayar nereden geldi" görmek için eklenmiş masum bir satır
app.Logger.LogInformation("Configuration:\n{Config}", ((IConfigurationRoot)builder.Configuration).GetDebugView());
Connection string'i environment variable olarak verip uygulamayı başlattığımda log'da şu satırlar var:
info: Bookstore.Api[0]
Configuration:
...
ConnectionStrings:
Bookstore=Server=sql.internal;Database=BookstoreDb;User Id=bookstore_app;Password=7d4c323cf58cc0605e663a16;TrustServerCertificate=True (EnvironmentVariablesConfigurationProvider)
Log'lar genellikle secret'lardan çok daha geniş bir kitleye açıktır: log platformuna erişimi olan herkes, destek ekibi, log'ları dışarı aktaran entegrasyonlar. Aynı çıktıda makinedeki bütün environment variable'lar da vardı, yani PATH'ten kullanıcı adına kadar her şey. GetDebugView() yalnızca yerelde ve bir debugger içinde kullanılmalı; log'a yazılmamalı. Log'larda redaction konusu .NET'te Logging serisinin Log'larda Hassas Veri yazısında.
İkinci yer container'ın kendisi. Aynı image'ı connection string'i -e ile vererek çalıştırıyorum:
$ docker run -d --name secretseries-api -e "ConnectionStrings__Bookstore=$CONN" secretseries-api:01
$ docker inspect secretseries-api --format "{{json .Config.Env}}"
["ConnectionStrings__Bookstore=Server=sql.internal;Database=BookstoreDb;User Id=bookstore_app;Password=7d4c323cf58cc0605e663a16;TrustServerCertificate=True","PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin","APP_UID=1654","ASPNETCORE_HTTP_PORTS=8080","DOTNET_RUNNING_IN_CONTAINER=true","DOTNET_VERSION=10.0.15","ASPNET_VERSION=10.0.15"]
docker inspect çalıştırabilen biri, yani Docker socket'ine erişimi olan herkes, değeri düz metin olarak görür. Container'ın içinden bakmak da tek komut; Linux her process'in başlangıç environment'ını /proc/<pid>/environ dosyasında tutar:
$ docker exec secretseries-api sh -c 'tr "\0" "\n" < /proc/1/environ | grep -i conn'
ConnectionStrings__Bookstore=Server=sql.internal;Database=BookstoreDb;User Id=bookstore_app;Password=7d4c323cf58cc0605e663a16;TrustServerCertificate=True
Aynı PID (process ID; işletim sisteminin her process'e verdiği numara) namespace'ini paylaşan başka bir container'dan denediğimde sonuç kullanıcıya göre değişti. Root ama ek yetkisiz bir busybox container'ı Permission denied aldı; aynı container'a --cap-add SYS_PTRACE verince değer yine okundu. Yani koruma, environment'ı okuyabilen process'lerin sınırlı olmasına dayanıyor; değerin kendisi hiçbir yerde şifreli değil.
Üçüncü yer bellek dökümü. Production'da bir sorunu incelemek için alınan dump, process'in belleğindeki her şeyi içerir. .NET runtime ile gelen createdump aracıyla container içinde bir dump alıp düz bir grep yaptım:
$ docker exec secretseries-api sh -c '/usr/share/dotnet/shared/Microsoft.NETCore.App/10.0.15/createdump --withheap -f /tmp/api.dmp 1 && grep -a -o "Password=[0-9a-f]*" /tmp/api.dmp | sort | uniq -c'
[createdump] Gathering state for process 1 dotnet
[createdump] Writing minidump with heap to file /tmp/api.dmp
[createdump] Written 146894848 bytes (35863 pages) to core file
[createdump] Target process is alive
[createdump] Dump successfully written in 148ms
3 Password=7d4c323cf58cc0605e663a16
140 MB'lık dump'ta parola üç ayrı yerde duruyor. Dump'lar genellikle bir ticket'a eklenir, bir paylaşım klasörüne kopyalanır ya da destek için dışarı gönderilir; o andan sonra secret'ın kontrolü sende değildir. Bu, secret store kullanmanın da çözmediği bir risk: uygulama secret'ı bellekte tutmak zorunda. Çözümü secret'ın ömrünü kısaltmak ve mümkünse hiç secret kullanmamak; serinin son yazısının konusu.
Angular Bundle'ı Herkese Açıktır
Frontend tarafındaki en yaygın hata, bir API key'ini environment.ts'e koyup "nasılsa build ediliyor" diye düşünmek. Bookstore'da bülten aboneliğini doğrudan tarayıcıdan e-posta servisine göndermek istediğimizi düşünelim:
export const environment = {
apiBaseUrl: 'https://api.bookstore.example.com',
emailApiKey: 'em_6a92d4dee37df8e87ae4279d3cbb49d3'
};
$ npx ng build
...
Application bundle generation complete. [0.839 seconds]
$ grep -o 'emailApiKey:"[^"]*"' dist/bookstore-web/browser/main-*.js
emailApiKey:"em_6a92d4dee37df8e87ae4279d3cbb49d3"
Production build minify edildi ama string literal'lar olduğu gibi duruyor. Tarayıcıya giden her şey herkese açıktır: geliştirici araçlarındaki Network sekmesi ya da dosyayı indirip aramak yeterli. Angular'da gizli bir değer için tek doğru yer backend'dir. Tarayıcının bir servise çağrı yapması gerekiyorsa çağrıyı Bookstore.Api üzerinden yaparsın, key backend'de kalır. Angular'a giden config'in yalnızca gizli olmayan değerler içerdiğini ve build sırasında değil runtime'da nasıl verileceğini ikinci yazıda göreceğiz.
Listeyi CI log'ları ve ekran görüntüleriyle tamamlayalım. CI log'una düşen secret, GitHub Actions gibi sistemlerde maskelenir ama maskeleme yalnızca kayıtlı değerin birebir aynısını yakalar; base64'e çevrilmiş ya da parçalanmış hâli görünür. Ekran görüntüsü ise en çok unutulan kanal: bir hata ekranını Slack'e atarken connection string'in de fotoğrafını çekmek çok kolay. Bu dağınıklığa "secret sprawl" deniyor: aynı secret'ın kimsenin tam listesini bilmediği onlarca yere kopyalanmış olması. Sprawl'un en kötü sonucu, sızıntı olduğunda nerelerde değiştirmen gerektiğini bilmemek.
Bookstore'un Secret Envanteri
Bir şeyi korumadan önce listesini çıkarman gerekir. Bookstore API'sinin bugünkü secret'ları ve serinin sonunda varmak istediğimiz yer şu:
| Secret | Kim kullanır | Bugün | Hedef |
|---|---|---|---|
| SQL connection string (parolalı) | Bookstore.Api | environment variable | Parolasız: managed identity ile Azure SQL ya da Vault'tan kısa ömürlü login |
| JWT signing key | Bookstore.Api | environment variable | Key Vault, iki aktif key ile rotation |
| E-posta servisi API key'i | Notifications module'ü | environment variable | Key Vault, yalnız bu module'ün okuyabildiği secret |
| Ödeme webhook secret'ı | Payments module'ü | environment variable | Key Vault, primary ve secondary ile kesintisiz değişim |
Tablodaki her satır için üç soru sor: kim okuyabiliyor, en son ne zaman değişti, sızarsa kaç dakikada değiştirebilirim. Üçüncü soruya cevabın "bilmiyorum" ise o secret için bir rotation prosedürün yok demektir. Bookstore'un başlangıç kodunda bu ayarlar dört options class'ına bağlanmış durumda ve appsettings.json'da yalnızca gizli olmayan değerler var:
{
"Shipping": {
"FreeShippingThreshold": 500,
"StandardFee": 49.90
},
"Jwt": {
"Issuer": "https://bookstore.example.com"
},
"Email": {
"FromAddress": "orders@bookstore.example.com"
}
}
Uygulamanın hangi secret'ları aldığını kontrol etmek için değer yerine yalnızca varlık bilgisini dönen bir endpoint ekledim. Değeri dönen bir "debug" endpoint'i, sızıntının en kısa yolu:
$ curl -s localhost:18081/api/info
{"environment":"Production","freeShippingThreshold":500,"secrets":{"ConnectionStrings:Bookstore":true,"Jwt:SigningKey":false,"Email:ApiKey":false,"Payments:Webhook:Secret":false}}
Pusula: Secret'sız Erişim
Serinin geri kalanı bu tablonun "Hedef" sütununa yürüyor ve yön şu: en iyi secret, hiç olmayan secret. Azure'da uygulamaya bir managed identity verdiğinde, uygulama Key Vault'a, Azure SQL'e ya da Storage'a parola taşımadan erişir; token'ı platform üretir ve kısa sürede geçersiz olur. Vault ya da OpenBao kullanıyorsan uygulama her açılışta kendine özel, birkaç saat yaşayan bir veritabanı login'i alır. Bunların hiçbirinin mümkün olmadığı yerde secret bir secret store'da durur, erişim kayıt altındadır ve rotation otomatiktir.
Yazıların sırası da buna göre: ikinci yazı layering ve "tek artifact, çok environment", üçüncü yazı Key Vault ve managed identity, dördüncü yazı App Configuration, beşinci yazı Vault ve OpenBao ile dynamic credential, altıncı yazı Kubernetes, yedinci yazı rotation ve sızıntı müdahalesi.
Üretimde Dikkat
Ölçülebilir bir hedef koy: her secret için sahibi, son rotation tarihi ve okuyabilen kimliklerin listesi bir yerde yazılı olmalı. Key Vault ve Vault bu bilgiyi audit log'larında tutar; environment variable ve .env dosyası tutmaz. Docker socket'ine, kubectl exec'e ve dump almaya yetkili kişilerin secret'ları da görebildiğini kabul et ve bu yetkileri ona göre dağıt. Dump alma sürecinde dump'ın nerede saklanacağı, kimlerin erişeceği ve ne zaman silineceği belli olsun. Log platformuna "Password=", "AccountKey=", "Bearer " gibi kalıplar için bir alarm kurmak ucuz ve erken uyarı verir.
Ölçek büyüdükçe asıl sorun sayıdır. Bookstore'da bugün dört secret var; her yeni entegrasyon bir tane daha ekler ve her environment bu sayıyı çarpar. Dört secret, üç environment ve iki servis 24 değer eder. Bu yüzden ilk hedef secret sayısını azaltmak, ikinci hedef geri kalanını merkezde toplamak.
Sık Düşülen Tuzaklar
Dosyayı silmeyi secret'ı silmek sanmak: Git geçmişinde, fork'larda ve clone'larda kalır; önce key değişir. Environment variable'ı güvenli bir saklama yeri saymak: docker inspect, /proc/<pid>/environ ve dump'lar düz metin gösterir. appsettings.Production.json'u "zaten production'da" diye secret'larla repo'ya koymak. Angular environment.ts'e key koymak: build edilen JavaScript herkese açık. Log'a GetDebugView(), request body ya da exception'ın tamamını yazmak; bazı exception mesajları connection string parçalarını içerebilir. Bir de sprawl: aynı key'i "kolaylık olsun" diye birden çok servise ve environment'a vermek. Sızıntıda hepsini aynı anda değiştirmen gerekir.
Küçük Bir Deneme
★☆☆ Kendi projelerinden birinde git log -p -S Password --all ve git log -p -S ApiKey --all çalıştır. Çıkan her commit için şunu sor: bu değer hâlâ geçerli mi?
★★☆ Bir uygulamanın container'ını connection string'i environment variable ile vererek başlat, sonra docker inspect ve /proc/1/environ ile değeri bul. Aynı denemeyi değeri bir dosyadan okuyacak şekilde (-v ile mount edilmiş bir JSON) tekrarla ve hangi kanalların kapandığını, hangilerinin açık kaldığını not et.
★★★ Kendi uygulamanın secret envanter tablosunu çıkar: her secret için kim kullanıyor, nerede duruyor, kaç kopyası var, sızarsa değiştirme adımları neler. En az bir secret için "hedef" sütununa secret'sız bir yol yazmaya çalış.
Envanter hazır olduğuna göre sıradaki soru şu: secret'sız ayarları environment'lar arasında nasıl taşıyacağız? Bir sonraki yazıda .NET'te configuration layering'i, uygulamanın hatalı config ile hiç açılmamasını ve Angular'da build'i bozmadan runtime config vermeyi göreceğiz.