Özgür Işık Damar
Yazılara dön

11 dk okuma

Tek diff, maskelenmiş diff — yapısı gereği gizlilik ve sınırları

Membrane AI, her model aşamasının maskelemeyi kendisi yapacağına güvenmek yerine hepsine diff'in maskelenmiş bir kopyasını veriyor. Nasıl çalıştığı, graceful degradation'ın neyi gizleyebildiği ve nerede bittiği.

Yazar: Özgür Işık DamarKıdemli yazılım mühendisi · Türkiye

Membrane AI'da en çok güvendiğim bulgu bir güvenlik açığı değil. Info seviyesinde bir not: local-heuristic:masked. Mesajı a secret was masked out of this diff upstream diye başlıyor; yani bu diff'teki bir secret, daha önceki bir aşamada maskelenmiş. Membrane bir kod değişikliğini aşama aşama inceliyor ve değişikliği model incelemesine yalnızca tek bir aşama aktarıyor. O aşamanın arkasındaki inceleyici varsayılan olarak sade bir heuristic; aldığı metinde, eskiden bir kimlik bilgisinin durduğu yerde bir [MASKED: yer tutucusu görünce bu notu düşüyor. Secret taramasının kendi bulgularının yanında bu not bir makbuz gibi okunuyor: modele bakan aşama değişikliği incelemiş, ama o anahtarı eline hiç almamış.

Reponun handover dokümanı bu makbuzu, bütün servislerin container'da çalıştığı uçtan uca koşumdan kaydediyor: 202 ile kabul edilen bir webhook ve rejected çıkan bir verdict; içinde iki engelleyici secret bulgusu, yanlarında da bir SQL string birleştirme uyarısı ile local-heuristic:masked. Bu yazı için container yığınını yeniden ayağa kaldırmadım, ama aşağıda adı geçen her birim ve regresyon testini yeniden koşturdum. Yazı üç şeyi anlatıyor: o makbuzun arkasındaki tasarımı, graceful degradation'ın tek bir uyarı satırına indirgediği bir sözleşme hatasını ve bu yazıyı hazırlarken keşfettiğim, garantinin üç sınırını. Bunlardan biri makbuzun ta kendisini çürütüyor.

Neler hazır, neler dublör

Membrane AI, yapay zekânın ürettiği kod için guardrail kurma denemem. Bir diff, bir webhook ya da bir gRPC stream'i üzerinden gelip Kafka'dan geçiyor; bir orchestrator da onu bir aşama zincirinde çalıştırıyor: önce secret'ları ve riskli desenleri işaretleyip bulduğu secret'ları maskeleyen deterministik bir analyzer, ardından değişikliği model incelemesi için Python'da yazılmış bir semantik servise gönderen, tavsiye niteliğindeki (advisory) semantik aşama. Bir reporter da verdict'i bir GitHub commit status'una ve bir PR yorumuna dönüştürüyor. Bu pipeline, bir membrane CLI'ı ve MCP tool çağrıları için bir HTTP gateway yazıldı ve test edildi; CLI'ı çağıran bir VS Code eklentisi derleniyor ama henüz testi yok.

Modeller takılıp çıkarılabiliyor ve varsayılan olarak yerlerinde birer dublör var. Semantik serviste iki katman bulunuyor: bir vLLM endpoint'i yapılandırılana kadar şeffaf bir heuristic olan yerel katman ve Claude ile Gemini'nin konsensüsle çalıştığı premium katman. Premium katman, biri onu etkinleştirip anahtarları verene kadar kapalı kalıyor. Kod, MIT lisansıyla GitHub'da.

Konvansiyon, her aşamanın tutması gereken bir söz

Secret'ları bir modelden uzak tutmanın bariz yolu, her model çağrısından önce maskelemek: yerel model adapter'ında bir mask(diff), premium adapter'da bir tane daha, sıradaki katman hangisiyse onda da bir tane. Bu, biri (belki aylar sonra ben) diff'in daha önce temizlendiğini varsayan bir adapter yazana kadar işe yarar. O andan itibaren garanti en özensiz adapter kadar güçlüdür; bir anahtarla iki model sağlayıcısı arasında duran tek şey de her yeni adapter'ı gözden geçiren bir insandır.

Membrane'in orchestrator'ı öbür yolu seçiyor; bu seçim D-024 numaralı karar olarak kayıtlı. Bir aşama yalnızca bulgu döndürmüyor; yeniden yazılmış bir artefakt da döndürebiliyor ve orchestrator, bir sonraki aşama çalışmadan önce onu eskisinin yerine koyuyor. internal/ports/ports.go içindeki port, sözleşmeyi büyük harflerle yazıyor: boş olmayan bir MaskedDiff "REPLACES the diff seen by all later stages", yani sonraki bütün aşamaların gördüğü diff'in YERİNE GEÇİYOR. Bunu gerçek kılan, internal/app/process.go içindeki döngü:

var findings []domain.Finding
current := sub // local copy: stages may redact the diff for later stages
for _, stage := range uc.stages {
	out, err := stage.Analyze(stageCtx, current)
	if err != nil {
		return uc.runFallback(ctx, sub)
	}
	findings = append(findings, out.Findings...)
	if out.MaskedDiff != "" {
		current.Diff = out.MaskedDiff
	}
}

Hazır gelen kablolamada analyzer ilk sırada çalışıyor ve maskelenmiş diff döndüren tek aşama o: her kimlik bilgisi eşleşmesi [MASKED:<rule>] oluyor. Semantik aşama ondan sonra çalışıyor ve sub.Diff'i hiç değiştirmeden masked_diff adlı bir istek alanına koyup iletiyor. Maskeleme kodu yok, ihtiyacı da yok: çalışmaya başladığında elinde, maskelemeyi unutabileceği ham bir diff kalmamış oluyor. Maskelenmiş diff'in zincire işlendiği günün ilerleyen saatlerinde vLLM ve premium adapter'ları eklendi; onların da maskeleme koduna ihtiyacı olmadı. TestHandle_MaskedDiffFlowsToLaterStages bunu çiviliyor: maskeleyiciden sonraki aşama yer tutucuyu görmek zorunda, yoksa test kırılıyor.

Farkın tamamı bu. Konvansiyon her tüketiciden uslu durmasını ister. Yapı ise onların eline verileni değiştirir.

Dört kutudan oluşan bir pipeline: gönderim; tespit ve maskeleme için pkg/scan çalıştıran analyzer; yerel katmanı (varsayılan olarak heuristic) ve premium katmanı (varsayılan olarak kapalı) olan semantik aşama; ve verdict. Analyzer'dan sonraki kesikli sınır, MaskedDiff'in diff'in yerini aldığı noktayı işaretliyor. Ham tarafta +key := "AKIAIOSFODNN7EXAMPLE" duruyor, maskeli tarafta +key := "[MASKED:aws-access-key-id]". Maskelemeyi yapan analyzer dışında ham diff'i yalnızca blake3 cache anahtarı ve fallback taraması okuyor, ikisi de model değil; ham diff ayrıca Kafka gönderim topic'inde taşınıyor. Maskeli tarafın altında: hiçbir model adapter'ında maskeleme kodu yok, dedektör bulguları kuralı söylüyor ama değeri asla, ve process_test.go içindeki bir test bu davranışı çiviliyor.
Maske bir kez, aşamaların arasında uygulanıyor. Sonraki her aşamanın eline anahtar yerine yer tutucu veriliyor.

Hash'i orijinalden al, maskelenmişi gönder

Ham diff'i bilerek elinde tutan bir okuyucu var: cache anahtarı. Anahtar, kural seti sürümüyle diff'in Blake3 hash'i; bir cache hit, pipeline'ın tamamını atlıyor. Bu arama herhangi bir aşama çalışmadan önce yapılıyor; o sırada maske henüz yok, onu üretmek için analyzer'a bir gRPC çağrısı gerekiyor. Bu yüzden Handle anahtarı gönderimin geldiği hâlinden hesaplıyor, yukarıdaki döngü ise bir kopya üzerinde çalışıyor. Aynı ham diff yeniden gönderilirse cache'e isabet ediyor. Anahtar tek yönlü bir özet; değer ise diff'i değil, bulguları taşıyan bir verdict.

Bir verdict cache'e girmeden önce orchestrator, tek bir gönderimi tanımlayan her şeyi (ID, organizasyon, repo, commit, PR numarası) siliyor ve her isabette bunları canlı gönderimden yeniden damgalıyor. Bulgular cache'lenir; kimlik asla. Bu da cache'i organizasyondan bağımsız kılıyor; bu ayrıntı aşağıda yeniden karşımıza çıkacak.

İkinci ham okuyucu da kasıtlı. Zorunlu bir aşama başarısız olduğunda (aşama hâlâ çalışırken bütün zincirin paylaştığı 1.200 ms'lik bütçenin dolması da buna dahil) orchestrator, orijinal gönderim üzerinde deterministik, süreç içi bir secret taramasına geri düşüyor. Taramanın bir secret'ı bulabilmesi için ham metni görmesi gerekiyor; üstelik o bir model değil. Ürettiği verdict'ler asla cache'lenmiyor; TestHandle_FallbackVerdictIsNotCached nedenini hata mesajında söylüyor: "a degraded answer would stick for 72h", yani eksik bir cevap 72 saat boyunca yapışıp kalırdı.

Tek dedektör paketi, AST taklidi yok

Maskeleme ancak tespit kadar iyi olabilir; tespit de tek bir pakette yaşıyor: pkg/scan. Analyzer da, orchestrator'ın fallback'i de, CLI da aynı paketi çağırıyor. Desenleri az ama kesinliği yüksek (yanlış pozitifler geliştiricinin güvenini aşındırır): private key blokları, AWS anahtar ID'leri, GitHub ve Slack token'ları ve tek bir genel atama kuralı. Bir dedektör bulgusu kural adını ve satır numarasını taşır, değeri asla ("potential credential detected (aws-access-key-id); remove and rotate it"); verdict de yalnızca kuralı ve bu mesajı saklar. Yani dedektörlerin bir PR yorumuna kattığı hiçbir şey secret taşımaz.

Hepsi satır taraması ve bu bir kestirme değil, bilinçli bir karar: D-019, resolver tam dosyaları sağlayabilene kadar AST dedektörlerini erteliyor, çünkü "a diff alone cannot be parsed into a meaningful AST", yani tek başına bir diff anlamlı bir AST'ye ayrıştırılamaz. Bir hunk'ı dosyaymış gibi gösteren bir parser yerine, ne olduğunu açıkça söyleyen bir regex'i yayına almayı tercih ederim. Ama bir regex yalnızca birinin ona yazdığı sözdizimini bilir; bunun sonucunu da aşağıda göreceğiz.

Çökme, vites düşür — ve vites düşürmenin gizleyebildikleri

Analyzer zorunlu; semantik aşama ise advisory, yani yalnızca tavsiye veriyor. app.Optional ile sarılı ve bu sarmalayıcı her hatayı fallback'i tetiklemek yerine bir stage-unavailable uyarısına çeviriyor, çünkü fallback, analyzer'ın hâlihazırda ürettiği bulguları çöpe atardı. Tek başına bir uyarı bile temiz bir verdict'i needs_review yapmaya yetiyor; yani vites düşüren bir koşum her zaman "bir insan baksın" tarafına yatıyor, sessiz bir onaya asla. Semantik servis de içeride aynı şekilde vites düşürüyor: vLLM kesilirse heuristic devreye giriyor, premium sağlayıcılardan birinin kesintisi bir info bulgusuna dönüşürken diğerinin bulguları geçerli kalıyor ve ancak bütün premium inceleyiciler başarısız olursa 503 dönüyor; Optional da onu yine bir uyarıya çeviriyor.

Bu tasarım gerçek bir hatayı yuttu. Semantik aşama bir "gold context" de ekleyebiliyor: resolver'ın pgvector'dan getirdiği, organizasyonun en iyi kodundan snippet'ler. Ama getirme işi best-effort: resolver'ın olmaması, UUID olmayan bir organizasyon ya da bir resolver hatası, hepsi "bağlam yok" anlamına geliyor. Go'da "bağlam yok" bir nil slice'tı ve encoding/json nil slice'ı null olarak yazar; Python tarafı ise alanı bir liste olarak tanımlamıştı. Bütün servislerin container'da çalıştığı uçtan uca koşumda orchestrator "gold_context": null gönderdi, pydantic 422 ile cevap verdi; handover da hatayı bu koşumun bulduğunu kaydediyor.

O 422'yi kodun içinde takip et. Semantik aşama 200 dışındaki her cevabı "erişilemiyor" olarak raporluyor; Optional da onu bir timeout'un üreteceği stage-unavailable uyarısının aynısına çeviriyor. Mesajın tamamı şu:

advisory stage failed; verdict produced without it: orchestrator.adapters.semanticstage: semantic service returned 422

Analyzer'ın iki kimlik bilgisi bulgusu tek başına engelliyor; dolayısıyla kodun kurallarına göre karar, semantik aşama olsa da olmasa da rejected çıkıyor. Karar bu hatayı gösteremezdi. Verdict'te local-heuristic:masked bulgusunun yokluğu, onu beklemen gerektiğini biliyorsan, bir şeylerin ters gittiğini söylüyor; bunun bir sağlayıcı kesintisi olmadığını ise yalnızca o satırın son üç hanesi söylüyor. Handover, hatayı neyin ele verdiğini kaydetmiyor.

Semantik aşamanın dört hatası (tüm zincirin paylaştığı 1.200 ms'lik bütçeye takılan bir zaman aşımı, reddedilen bir bağlantı, bütün premium inceleyiciler başarısız olduğunda dönen HTTP 503 ve null bir gold_context yüzünden 422 dönen bir sözleşme hatası) aynı app.Optional'a akıyor; o da tek bir stage-unavailable uyarı bulgusu üretiyor. Bu diff için verdict rejected, çünkü secret'ları tek başına engelliyor; temiz bir diff'te aynı uyarı needs_review veriyor. Dördünü birbirinden ayıran, mesajın sonu; burada: semanticstage: semantic service returned 422. Altta: semantik servisin içinde vLLM kapalıysa heuristic devreye giriyor, tek bir premium inceleyici kapalıysa bir info bulgusu oluşuyor.
Bir sözleşme hatası ile bir sağlayıcı kesintisi aynı kapıdan çıkıyor. İkisini ayıran tek şey, mesajın içine gömülü bir durum kodu.

Hatanın ikinci bir bedeli de vardı. Advisory aşama başarısız olsa bile ortaya çıkan verdict bir pipeline verdict'i sayılıyor, dolayısıyla eksik cevap da her cevap gibi cache'leniyor: varsayılan olarak 72 saat boyunca, o diff'in yeniden gönderimi semantik aşamanın bulguları olmadan verilmiş verdict'i alırdı. Fallback testinin önlemeye çalıştığı "72 saat yapışıp kalan eksik cevap" sonucu tam olarak bu; yalnızca testin kapsamadığı bir yoldan geliyor. Reponun bir kopyası üzerinde yapılan bir deneme, verdict'in cache'e yazıldığını doğruladı.

Düzeltme iki tarafa da girdi ve iki tarafın da bugün geçen birer regresyon testi var (Go'da TestAnalyze_NonUUIDOrgSkipsRAG, Python'da test_evaluate_tolerates_null_gold_context). Go tarafı tek bir struct tag'inden ibaret:

type evaluateRequest struct {
	SubmissionID   string        `json:"submission_id"`
	OrganizationID string        `json:"organization_id"`
	Language       string        `json:"language"`
	MaskedDiff     string        `json:"masked_diff"`
	GoldContext    []GoldContext `json:"gold_context,omitempty"`
}

Python tarafında alan list[GoldContextIn] | None = None oldu; docstring'i de bunu açıkça söylüyor: be liberal in what we accept, yani kabul ederken hoşgörülü olalım.

Yapının bittiği yer

Bütün sözü tek bir yere koymak, o yeri saldırmaya değer kılar. Bu yazıyı yazarken tam olarak bunu yaptım ve üç sınır buldum; onları bugünkü risk sırasına göre anlatıyorum.

Tespit. Buradan başlıyorum, çünkü hazır gelen kablolamada gerçekten yaşanabilen tek sınır bu. Orchestrator, analyzer'ın çıktısının tek diff olduğunu garanti ediyor; analyzer'ın her şeyi yakaladığını garanti edemiyor. pkg/scan içindeki genel kural şu:

(?i)(?:password|passwd|secret|api[_-]?key|token)\s*[:=]\s*["'][^"']{8,}["']

password = "…" ve password: "…" eşleşiyor. Go'nun kısa değişken tanımı password := "…" ise eşleşmiyor: karakter sınıfı iki nokta üst üsteyi alıyor, desen de ardından eşittir işaretinin durduğu yerde bir tırnak bekliyor. (Şekil 1'deki AWS anahtarı değerinden eşleşiyor, dolayısıyla orada := fark etmiyor.)

O satırı kodun içinde takip edersen sözün iki yarısı birden çöküyor. Eşleşme yoksa ne bulgu var ne maske: semantik aşama parolayı olduğu gibi alıyor, varsayılan heuristic yalnızca info seviyesinde bir local-heuristic:password kaydediyor (bu da hiçbir kararı değiştirmiyor) ve değişiklik onaylanıyor.

Aynı satırı bir AWS anahtarı da içeren bir diff'e koyarsan verdict rejected çıkıyor ve yazının başındaki makbuzu, local-heuristic:masked bulgusunu, semantik aşamanın düz metin olarak okuduğu bir parolanın hemen yanında taşıyor. Premium katman iki sağlayıcının anahtarlarıyla etkinleştirilmiş olsaydı, bu iki not tek başına 0,4 + 0,3 = 0,7 puan toplayıp varsayılan 0,5'lik yükseltme eşiğini aşardı ve o diff, ham parola dahil, Claude ile Gemini'ye giderdi.

Eval harness'ı kendi golden korpusunda precision ve recall değerlerini 1,000 olarak raporluyor ve yanılmıyor da: 14 vakanın içinde genel secret kuralının tek pozitif örneği bir Python ataması. Yerelde tek bir Go kısa tanımı eklediğimde recall 0,889'a düştü ve harness kendi 0,90'lık tabanının altında kalıp başarısız oldu. Kapı sağlamdı; korpusta o vaka eksikti.

Kablolama. Döngü, maskelenmiş bir diff'in sonraki aşamalara aktarıldığını garanti ediyor; ortada maskeleyen bir aşama olduğunu garanti edemiyor. cmd/orchestrator/main.go zinciri yapılandırmadan kuruyor: ANALYZER_ADDR tanımlıysa analyzer'ı, değilse süreç içi taramayı; ardından SEMANTIC_URL tanımlıysa semantik aşamayı (iki ad da MEMBRANE_ORCHESTRATOR_ önekini taşıyor). Süreç içi tarama bulgu döndürüyor ama maskelenmiş diff döndürmüyor, oysa pkg/scan bir tane hesaplayabilirdi. Dolayısıyla boş bir analyzer adresi artı bir semantik URL, semantik aşamaya ham diff'i veriyor. İkinci bir deneme bunu doğruladı: verdict yine rejected diyordu ve semantik aşama AKIAIOSFODNN7EXAMPLE değerini olduğu gibi aldı. Hazır gelen hiçbir yapılandırma bunu yapmıyor, çünkü bütün servisleri container'da koşturan compose dosyası da Helm values da iki adresi birden tanımlıyor; ama yapılandırmanın bunu ifade edebilmesi bile mümkün olmamalı.

İki çarpı iki bir tablo: satırlarda ANALYZER_ADDR tanımlı ya da boş, sütunlarda SEMANTIC_URL tanımlı ya da boş; iki değişken de MEMBRANE_ORCHESTRATOR_ önekini taşıyor. İkisi de tanımlı: analyzer, ardından semantik aşama; semantik aşama maskeyi alıyor (tam compose ve Helm values). Yalnızca analyzer: semantik aşama yok (host'ta geliştirme için .env.example). İkisi de boş: yalnızca süreç içi tarama, semantik aşama yok (koddaki varsayılanlar). Analyzer boş, semantik URL tanımlı: süreç içi tarama, ardından semantik aşama; semantik aşama ham anahtarı alıyor; hazır gelen hiçbir dosya bunu kurmuyor. Tespit edilen bir anahtar dört hücrede de yine engelliyor; değişen, semantik aşamanın onu okuyup okumadığı.
Döngü ancak kendisine verilen bir maskeyi iletebilir. Kablolamalardan biri semantik aşamaya ham diff'i veriyor ve yapılandırmada bunu durduran hiçbir şey yok.

Bağlam. İnvaryant incelenen diff'i kapsıyor, onu incelemek için getirilen bağlamı değil. Gold snippet'ler her iki model prompt'una da, yani yerel vLLM prompt'una da, iki sağlayıcılı premium prompt'a da saklandıkları hâliyle giriyor; iki prompt da ardından diff'i "Masked diff under review (secrets already redacted)" diye tanıtıyor. Bu etiket, bir string'in verdiği bir söz; "özenle seçilmiş kodda nadiren anahtar olur" ise yine bir konvansiyon.

Cache de bu açığı miras alıyor: anahtarı organizasyonu hesaba katmıyor, dolayısıyla bir modelin, prompt'unda bir organizasyonun snippet'leri varken yazdığı bulgular, aynı diff'i gönderen her organizasyona servis edilirdi. Testlerin dışında hiçbir şey gold satırı yazmadığı sürece ikisi de uykuda. Bir de ham diff hâlâ gönderim topic'inde dolaşıyor: garanti model aşamalarıyla ilgili, Kafka'yla değil.

Yapı, bir garantiyi kırılmaz hâle getirmez. Ona bir adres verir; o adres de kendi testlerini hak eder.

Daha küçük bir açık da kendi testimde. TestHandle_MaskedDiffFlowsToLaterStages içinde cache anahtarının hâlâ ham diff'ten geldiğini vaat eden bir yorum var; test ardından yalnızca verdict'in pipeline'dan geldiğini assert ediyor. Kod doğru davranıyor (cache denemesi kaydı ham diff'in anahtarı altında buldu), ama bir yorum assertion değildir.

Düzeltmeler küçük, çünkü tespit zaten tek bir pakette yaşıyor. Süreç içi aşamadan maskelenmiş kopyayı döndür ya da önünde maskeleyici olmayan bir semantik aşamayı başlatmayı reddet. Regex'i bir kez düzelt; analyzer, fallback ve CLI düzeltmeyi aynı commit'te alsın. Go vakasını korpusa ekle, gold snippet'leri bir prompt'a ulaşmadan maskele, gold context canlıya çıkmadan önce cache'i organizasyon bazında anahtarla ve o testin cache anahtarını assert etmesini sağla. Bunların hiçbiri henüz yapılmadı.

Neleri (henüz) yapmıyor

  • Gerçek modeller. Henüz gerçek bir vLLM endpoint'i ya da sağlayıcı anahtarı bağlanıp ayarlanmadı; handover bunu bir sonraki adım olarak listeliyor.
  • Gerçek retrieval. Embedding'ler deterministik bir stub'dan geliyor ve testlerin dışında henüz hiçbir şey gold satırı yazmıyor.
  • Hedef mimari. AST analizi ertelendi; mimari dokümanlarındaki Envoy gateway ve AWS dağıtımı kod değil, tasarım.
  • Otomatik uçtan uca koşumlar. Bu hesapta GitHub Actions hiç koşmuyor (D-036); bu yüzden kapılar yerelde çalışan task lint ve task test, container koşumu da bir betiğe dökülmüş değil, yalnızca belgelenmiş.

Başka projelere taşınabilecekler

Bu deseni kullanmak için Membrane'e ihtiyacın yok:

  • Maskelenmiş artefaktı bir dönüş değeri yap, yerine koyma işini orchestrator'a bırak. Tüketicilerden maskeleme yapmalarını isteme.
  • Cache anahtarını gelen girdiye ve cevabı kimin bağlamının şekillendirdiğine göre kur; aşağı akışa yalnızca sızması seni rahatsız etmeyecek şeyi gönder.
  • Taşınan alanlara invaryantın adını ver (masked_diff), sonra invaryantı addan başka bir yerde zorla.
  • Advisory aşamaların hatası insan incelemesine düşsün; konuştuklarını da assert et.
  • Başında maskeleyici olmayan bir zinciri başlatmayı reddet; test korpusunu kaçırılan her vakayla büyüt.

Her aşamaya bakmayacağına dair söz verdirme. Her birine içinde görecek hiçbir şey olmayan bir girdi ver, bütün paranoyanı da o girdiyi dağıtan tek yere yönelt.