.env dosyasından şifreli sır kasasına geçiş: küçük ekipler için uygulanabilir yöntem
Düz metin .env dosyalarından sops ve age ile şifreli sır kasasına geçiş: sarmalayıcı betik, commit öncesi sır tarayan kanca, geçmiş tarama ve rotasyon sırası.
Küçük ekiplerde sır yönetimi çoğu zaman aynı biçimde başlar: bir .env dosyası, içinde veritabanı parolası ile birkaç API anahtarı, yanında da "sakın commit'leme" notu. Sonra ikinci proje gelir, dosya sunucuya kopyalanır, bir betik parolayı "şimdilik" doğrudan içine gömer ve bir yıl sonra hangi anahtarın nerede yaşadığını kimse bilmez. Bu yazıda .env dosyasından şifreli sır kasasına geçişi kendi projelerimde uyguladığım hâliyle adım adım anlatıyorum: şifreli dosya biçimi, sırrı ekrana basmadan komuta enjekte eden küçük bir sarmalayıcı, commit öncesi sır tarayan bir kanca ve geçmişte bulunan sırlar için doğru rotasyon sırası.
Hedef, büyük kurumların kullandığı merkezi bir sır sunucusu kurmak değil. İki-üç kişilik bir ekibin bir öğleden sonra kurup yıllarca sürdürebileceği, sürüm kontrolüne girebilen ve ekibe yeni katılan birinin de okuyup anlayabileceği bir düzen istiyorum. Araç olarak sops ve age ikilisini seçtim; ikisi de tek bir çalıştırılabilir dosya olarak kurulur, arkada çalışan bir servis ya da bulut hesabı istemez.
Yazıdaki komutlar Linux kabuğu için yazıldı; Windows'ta aynı araçlar var, farklılık gösteren yerleri ayrıca belirttim.
Düz metin .env neden yetmiyor
Sorun dosyanın kendisi değil, dosyanın etrafında oluşan alışkanlıklar. Bir .env dosyası tek makinede, tek kişiyle, tek projede sorunsuz yaşar; üçünden biri ikiye çıktığında çatlamaya başlar:
- Sürüm kontrolünün dışında kalır. Kim, ne zaman, hangi değeri değiştirdi bilinmez; iki geliştiricinin dosyası sessizce ayrışır ve "bende çalışıyor" tartışması başlar.
- Kopyalanarak çoğalır. Sunucuya, yedeğe, bir arkadaşın dizüstüne giden her kopya ayrı bir sızıntı noktasıdır ve hiçbirinin rotasyonu takip edilmez.
- Betiklere sızar. Geçici diye bir PowerShell ya da Python betiğine yazılan parola, ilk commit'le kalıcı olur; dosya sonradan silinse bile geçmişte durur.
- Ekrana basılır.
cat .env,printenv, hata ayıklama çıktıları… Terminal geçmişi ve CI günlükleri bunları uzun süre saklar.
Docker Compose kullanan ekiplerde tablo biraz daha karışık; env_file ile taşınan sırlar proje adlandırmasıyla iç içe geçer ve yanlış projeye giden bir değişken saatlerce aranır. O tarafı Compose proje adlandırma ve yalıtım yazısında ayrıca ele almıştım.
Şifreli dosya biçimi: sops ve age
sops, bir YAML, JSON ya da dotenv dosyasının yalnızca değerlerini şifreler; anahtar adları açık kalır. Bu sayede dosya depoya girer, git diff hangi sırrın değiştiğini gösterir ama değerini göstermez. age ise şifreleme için kullandığım sade bir açık anahtar aracı: ekipteki her kişinin bir açık anahtarı olur, dosya bu anahtarların tamamına göre şifrelenir ve herhangi biri kendi özel anahtarıyla açabilir.
Kurulum dört adımdan ibaret:
# 1) Anahtar çifti: özel anahtar dosyaya yazılır, açık anahtar ekrana basılır
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
# 2) Depo kökünde .sops.yaml: kasa/ altındaki dosyalar bu açık anahtarla şifrelenir
# (Windows yol ayracı ters eğik çizgi olduğu için kalıp iki ayracı da kabul ediyor)
cat > .sops.yaml <<'EOF'
creation_rules:
- path_regex: 'kasa[/\\]'
age: age1... # age-keygen'in bastığı açık anahtar; virgülle birden fazla yazılabilir
EOF
# 3) Eski .env'i tek seferlik dönüştür: kuralla eşleşsin diye önce kasa/ altına taşı
mkdir -p kasa && mv .env kasa/uygulama.env
sops --encrypt --input-type dotenv --output-type yaml kasa/uygulama.env > kasa/secrets.uygulama.yaml
rm kasa/uygulama.env
# 4) Sonradan tek bir sır ekle/değiştir: düzenleyici açılmaz, değer ekrana basılmaz
sops set kasa/secrets.uygulama.yaml '["DB_PAROLA"]' '"yeni-deger"'
Bu adımda dikkat ettiğim noktalar:
- Özel anahtar dosyası (
keys.txt) depoya girmez. sops onu varsayılan olarak Linux'ta~/.config/sops/age/keys.txt, Windows'ta%AppData%\sops\age\keys.txtyolunda ya daSOPS_AGE_KEY_FILEortam değişkeninin gösterdiği yerde arar. - Kurtarma için ikinci bir age anahtar çifti üretip açık anahtarını
.sops.yaml'daki listeye ekledim; özel anahtarını yalnızca çevrimdışı bir kopyada tutuyorum. Tek anahtarla kurulan kasa, o anahtar kaybolduğunda kasa olmaktan çıkar. - Dönüştürmeden önce .env dosyasını temizleyin: sops, dotenv değerlerini tırnağıyla birlikte taşır ve Windows'ta CRLF satır sonuyla kaydedilmiş dosyada her değerin sonuna görünmez bir satır başı karakteri bulaşır. İkisini de sops 3.13 ile denedim; parolanın sonundaki o karakter saatlerce aranan "yanlış parola" hatasına dönüşüyor.
.gitignore'a.envvekasa/*.envsatırlarını ekleyin; geçişten sonra da birinin alışkanlıkla düz metin dosya üretmesi mümkündür.sops setdeğeri ekrana basmaz, ama komut satırına yazıldığı için kabuk geçmişine düşebilir. Bash'teHISTCONTROL=ignorespaceayarlayıp komutun başına boşluk koyarak ya da değerisops kasa/secrets.uygulama.yamlile açılan düzenleyiciden girerek bunu önlüyorum.
Sırrı ekrana basmadan komuta vermek
Kasa kurulduktan sonra asıl soru şu: betik parolayı nasıl alacak? En temiz cevap, betiğin parolayı hiç görmemesi. Sır kasadan çıkar, yalnızca alt sürecin ortam değişkeni olarak yaşar ve süreç bitince kaybolur. Betik tarafında değişen bir şey yok; os.environ["DB_PAROLA"] okuyan kod .env günlerindeki gibi çalışmaya devam eder.
sops exec-env bu işi yerleşik olarak yapar. Ben yine de proje adıyla çalışan küçük bir Python sarmalayıcı yazdım; hem Windows'ta kabuk davranışıyla ilgili sürprizler yaşadım hem de "hangi kasa dosyası" sorusunu her seferinde yazmak istemedim:
#!/usr/bin/env python3
"""gizli.py: sırrı ekrana basmadan komutun ortamına yükler.
Kullanım: python gizli.py <proje> -- <komut> [argümanlar]
"""
import json
import os
import subprocess
import sys
from pathlib import Path
KASA = Path(__file__).resolve().parent / "kasa"
def sirlari_coz(proje: str) -> dict:
dosya = KASA / f"secrets.{proje}.yaml"
if not dosya.exists():
sys.exit(f"kasa dosyası yok: {dosya}")
# Yalnız stdout borulanır; stderr olduğu gibi akar, sops'un hata mesajı kaybolmaz
sonuc = subprocess.run(
["sops", "--decrypt", "--output-type", "json", str(dosya)],
stdout=subprocess.PIPE, text=True, encoding="utf-8", check=True,
)
return json.loads(sonuc.stdout)
def main() -> int:
if len(sys.argv) < 4 or sys.argv[2] != "--":
print(__doc__, file=sys.stderr)
return 2
proje, komut = sys.argv[1], sys.argv[3:]
ortam = dict(os.environ)
for anahtar, deger in sirlari_coz(proje).items():
ortam[anahtar] = str(deger) # sır yalnız alt sürecin ortamında yaşar
return subprocess.run(komut, env=ortam).returncode
if __name__ == "__main__":
sys.exit(main())
Kullanımı python gizli.py uygulama -- python yedekle.py biçiminde. Sır ekrana yazılmaz, kabuk geçmişine girmez, diske inmez; sops'un olası hata mesajı ise olduğu gibi görünür, çünkü yalnızca standart çıktı borulanıyor. Cron ya da Görev Zamanlayıcı satırında da aynı komut kullanılır, sunucuya ayrıca .env dağıtmak gerekmez. SQLite üstünde çalışan küçük boru hatlarımda bağlantı bilgilerini bu yolla veriyorum; o mimariyi SQLite ile küçük ama sağlam veri boru hatları yazısında anlatmıştım.
Geçmişte sır arama ve commit öncesi kanca
Geçişten önce depoya bakmak şart. Bir sır geçmişte bir kez bile commit'lendiyse, dosya sonradan silinmiş olsa da orada duruyor. İki komut çoğu durumu yakalar: git log --all -p -G 'AKIA[0-9A-Z]{16}' gibi kalıpla arama ve gitleaks detect --source . --log-opts='--all' ile bütün dalların taranması. Aramayı sırrın değeriyle değil kalıbıyla yapın; değeri komut satırına yazmak onu kabuk geçmişine taşır. Geçmişte bulduğunuz her sırrı sızmış sayın; bir sonraki bölümdeki rotasyon sırası bunun için var.
Yeni sızıntıyı önlemek için de commit öncesi çalışan bir kanca kullanıyorum. Depoda .githooks/pre-commit olarak durur, git config core.hooksPath .githooks ile etkinleşir; böylece ekipteki herkes aynı kancayı depoyla birlikte alır:
#!/usr/bin/env python3
"""pre-commit: sahnelenen satırlarda düz metin sır arar, bulursa commit'i durdurur."""
import re
import subprocess
import sys
KALIPLAR = [
r"AKIA[0-9A-Z]{16}", # AWS erişim anahtarı kimliği
r"ghp_[A-Za-z0-9]{36}", # GitHub kişisel erişim belirteci
r"-----BEGIN [A-Z ]*PRIVATE KEY-----", # PEM özel anahtar
r"(?i)(password|passwd|secret|token|api_key)\s*[=:]\s*['\"][^'\"]{8,}['\"]",
]
IZINLI = re.compile(r"ENC\[|example|ornek", re.I) # sops çıktısı ve örnek değerler
SABLON = (".example", ".sample")
def git(*args):
return subprocess.run(
["git", *args], capture_output=True, text=True,
encoding="utf-8", errors="replace", check=True,
).stdout
def eklenen_satirlar(dosya):
for satir in git("diff", "--cached", "-U0", "--", dosya).splitlines():
if satir.startswith("+") and not satir.startswith("+++"):
yield satir[1:]
def main():
bulgular = []
dosyalar = git("diff", "--cached", "--name-only", "--diff-filter=AM").split()
for dosya in dosyalar:
if re.search(r"(^|/)\.env(\..+)?$", dosya) and not dosya.endswith(SABLON):
bulgular.append(f"{dosya}: düz metin .env dosyası commit'e giremez")
continue
for satir in eklenen_satirlar(dosya):
if IZINLI.search(satir):
continue
for kalip in KALIPLAR:
if re.search(kalip, satir):
maskeli = re.sub(kalip, "[GIZLENDI]", satir).strip()
bulgular.append(f"{dosya}: {maskeli[:80]}")
break
if bulgular:
print("COMMIT DURDURULDU, sır şüphesi:", *bulgular, sep="\n ", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
Birkaç tasarım kararı:
- Kanca sops'un ürettiği
ENC[...]değerlerini atlar; aksi hâlde kasa dosyasının kendisi commit'e giremez..env.examplegibi şablon dosyalar da serbesttir. - Bulgu ekrana basılırken eşleşen bölüm maskelenir. Sırrı yakalayan araç sırrı kendisi sızdırmamalı; terminal günlükleri de bir depolama alanıdır.
- Kalıp listesi kısa ve bilinçli: yaygın bulut anahtarı biçimleri, PEM özel anahtar başlığı ve tırnak içinde değer alan
password,tokentürü atamalar. Kod içindekitoken = os.environ[...]satırı tırnaklı değer içermediği için yanlış alarm üretmez. git commit --no-verifykancayı atlar. Bu yüzden aynı taramayı CI'da ya da sunucuda bir kez daha koşturuyorum; kanca ilk savunma hattı, tek hat değil.- Windows'ta
python3adı bulunmuyorsa ilk satırdaki yorumlayıcı adınıpythonyapın.
Rotasyon sırası
Geçmişte sır bulmak, kasaya geçişin en can sıkıcı kısmını başlatır. Burada sıra önemli; yanlış sırayla yapılan rotasyon çalışan sistemi keser ve ekip olayı "kasaya geçince her şey bozuldu" diye hatırlar:
- Envanter çıkarın. Sırrı kim tüketiyor: hangi sunucu, hangi zamanlanmış görev, hangi CI işi. Unutulan tüketici, rotasyonla birlikte sessizce durur.
- Yeni değeri üretin ve önce kasaya yazın.
sops setile; değer ekranda görünmez, commit'lenebilir, ekip arkadaşınız çekip kullanabilir. - Tüketicileri yeni değere geçirin ve her birini gerçekten çalıştırın. "Olması lazım" yeterli değil; sarmalayıcıyla bir kez koşturup çıktıyı görmek gerekir.
- Eski değeri iptal edin. Sağlayıcının panelinden ya da veritabanı kullanıcısının parolasını değiştirerek. Bu adım atlanırsa geçmişteki sır geçerliliğini korur ve yapılan iş boşa gider.
- En son geçmişi temizleyin.
git filter-repoyardımcı olur, ama her klon ve fork eski geçmişi tutmaya devam eder. Geçmiş temizliği sızıntıyı geri almaz; sızıntıyı geri alan şey rotasyondur.
Bir de belgeler için kural: "rotasyon yapıldı" cümlesi tarihsiz yazılmaz. Tarih ile doğrulama komutu yan yana dursun; aksi hâlde birkaç ay sonra o cümlenin hâlâ doğru olup olmadığını kimse bilemez ve eski bir iddia yeni bir karara dayanak olur.
Geçişin özü üç kuralda toplanıyor: sır koda ve betiğe gömülmez, üretilen her sır kasaya yazılır, kasadan çıkan sır yalnızca alt sürecin ortamında yaşar. Kanca yeni sızıntıyı engeller, rotasyon eski sızıntıyı kapatır; ikisi olmadan kasa yalnızca şifrelenmiş bir .env'dir. Ekibinizde bu düzeni kurmak ya da mevcut betiklerinizi kasaya bağlamak için birlikte çalışmak isterseniz iletişim bölümünden yazabilirsiniz; yaptığım işlerin genel çerçevesi ana sayfada.