Özgür Işık Damar
Zurück zur Übersicht

11 Min. Lesezeit

Der maskierte Diff ist der einzige Diff – Datenschutz per Konstruktion und seine Grenzen

Membrane AI gibt jeder Modellstufe eine maskierte Kopie des Diffs, statt jeder das Schwärzen anzuvertrauen. Wie das geht, was Degradation verbergen kann, wo es endet.

Von Özgür Işık DamarSenior Software Engineer · Türkei

Der Befund, dem ich in Membrane AI am meisten vertraue, ist keine Schwachstelle. Es ist ein Hinweis auf Info-Level, local-heuristic:masked, dessen Meldung mit a secret was masked out of this diff upstream beginnt. Membrane prüft eine Codeänderung in Stufen, und nur eine dieser Stufen reicht sie zur Prüfung an ein Modell weiter. Standardmäßig prüft hinter dieser Stufe eine schlichte Heuristik, und sie meldet diesen Hinweis, wenn der Text, den sie bekommt, einen [MASKED:-Platzhalter enthält, wo vorher Zugangsdaten standen. Neben den eigenen Secret-Befunden des Scanners liest sich der Hinweis wie eine Quittung: Die modellseitige Stufe hat die Änderung geprüft, ohne diesen Schlüssel je in der Hand gehabt zu haben.

Das Handover-Dokument im Repo hält diese Quittung aus dem End-to-End-Lauf mit allen Containern fest: ein Webhook, der mit 202 angenommen wurde, und ein Urteil rejected mit zwei blockierenden Secret-Befunden, einer Warnung wegen SQL-Konkatenation und daneben local-heuristic:masked. Den Container-Stack habe ich für diesen Beitrag nicht erneut laufen lassen, wohl aber jeden Unit- und Regressionstest, der unten vorkommt. Es geht um das Design hinter dieser Quittung, um einen Schnittstellen-Bug, den Graceful Degradation zu einer einzigen Warnzeile gemacht hat, und um drei Grenzen der Garantie, auf die ich beim Aufschreiben gestoßen bin. Eine davon nimmt die Quittung auseinander.

Was gebaut ist und was nur Ersatz ist

Membrane AI ist mein Versuch, Leitplanken für KI-generierten Code zu bauen. Ein Diff kommt über einen Webhook oder einen gRPC-Stream herein, läuft durch Kafka, und ein Orchestrator schickt ihn durch eine Kette von Stufen: zuerst ein deterministischer Analyzer, der Secrets und riskante Muster meldet und die Secrets maskiert, danach eine beratende semantische Stufe, die die Änderung zur Modellprüfung an einen semantischen Service in Python schickt. Ein Reporter macht aus dem Urteil einen GitHub-Commit-Status und einen PR-Kommentar. Diese Pipeline, eine membrane-CLI und ein HTTP-Gateway für MCP-Tool-Calls sind gebaut und getestet; eine VS-Code-Extension, die die CLI als Unterprozess aufruft, kompiliert, hat aber noch keine Tests.

Die Modelle sind austauschbar und standardmäßig nur Ersatz. Der semantische Service hat eine lokale Ebene, die bis zur Konfiguration eines vLLM-Endpunkts eine transparente Heuristik ist, und eine Premium-Ebene mit Claude und Gemini im Konsens, die aus bleibt, bis jemand sie einschaltet und Schlüssel hinterlegt. Der Code steht unter MIT-Lizenz auf GitHub.

Konvention ist ein Versprechen, das jede Stufe halten muss

Der naheliegende Weg, Secrets von einem Modell fernzuhalten, ist Maskieren vor jedem Modellaufruf: ein mask(diff) im Adapter für das lokale Modell, noch eins im Premium-Adapter, ein weiteres in der Ebene, die als Nächstes kommt. Das hält so lange, bis jemand, womöglich ich selbst Monate später, einen Adapter schreibt, der davon ausgeht, dass der Diff weiter vorne schon bereinigt wurde. Dann ist die Garantie nur so stark wie der unvorsichtigste Adapter, und zwischen einem Schlüssel und zwei Modellanbietern steht nur noch ein Mensch, der jeden neuen Adapter reviewt.

Der Orchestrator von Membrane geht den anderen Weg, festgehalten als Entscheidung D-024. Eine Stufe liefert nicht nur Befunde zurück; sie kann auch ein umgeschriebenes Artefakt zurückgeben, das der Orchestrator einsetzt, bevor die nächste Stufe läuft. Der Port in internal/ports/ports.go formuliert den Vertrag in Großbuchstaben: Ein nicht leerer MaskedDiff „REPLACES the diff seen by all later stages“. Wahr wird das durch die Schleife in internal/app/process.go:

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
	}
}

In der ausgelieferten Verdrahtung läuft der Analyzer zuerst und ist die einzige Stufe, die einen maskierten Diff zurückgibt: Jeder Treffer auf Zugangsdaten wird zu [MASKED:<rule>]. Die semantische Stufe läuft danach und reicht sub.Diff unverändert weiter, in ein Feld des Übertragungsformats namens masked_diff. Sie hat keinen Maskierungscode und braucht keinen: Wenn sie an der Reihe ist, hält sie keinen rohen Diff mehr in der Hand, bei dem sie das Maskieren vergessen könnte. Die Adapter für vLLM und die Premium-Ebene kamen später an dem Tag hinzu, an dem der maskierte Diff durch die Pipeline gereicht wurde, und auch sie brauchten keinen Code zum Schwärzen. TestHandle_MaskedDiffFlowsToLaterStages nagelt das fest: Die Stufe nach dem Maskierer muss den Platzhalter sehen, sonst schlägt der Test fehl.

Das ist der ganze Unterschied. Konvention verlangt, dass sich jeder Konsument benimmt. Konstruktion ändert, was man ihm in die Hand gibt.

Pipeline aus vier Kästen: die Einreichung, der Analyzer, der mit pkg/scan erkennt und maskiert, die semantische Stufe mit einer lokalen Ebene (standardmäßig Heuristik) und einer Premium-Ebene (standardmäßig aus) sowie das Urteil. Eine gestrichelte Grenze nach dem Analyzer markiert, wo MaskedDiff den Diff ersetzt. Auf der rohen Seite steht +key := "AKIAIOSFODNN7EXAMPLE", auf der maskierten Seite +key := "[MASKED:aws-access-key-id]". Außer dem Analyzer, der maskiert, lesen den rohen Diff nur der blake3-Cache-Key und der Fallback-Scan, beide kein Modell, und er läuft durch das Kafka-Topic der Einreichungen. Unter der maskierten Seite: Kein Modell-Adapter enthält Maskierungscode, Detektor-Befunde nennen die Regel, aber nie den Wert, und ein Test in process_test.go schreibt das Verhalten fest.
Die Maske wird einmal angewendet, zwischen den Stufen. Jede spätere Stufe bekommt den Platzhalter statt des Schlüssels.

Das Original hashen, das Maskierte weiterreichen

Ein Leser hält ganz bewusst am rohen Diff fest: der Cache-Key. Er ist ein Blake3-Hash über die Version des Regelsatzes und den Diff, und ein Cache-Treffer überspringt die gesamte Pipeline. Dieser Lookup passiert, bevor irgendeine Stufe läuft, also bevor es die Maske überhaupt gibt; um sie zu erzeugen, braucht es einen gRPC-Aufruf an den Analyzer. Deshalb berechnet Handle den Key aus der Einreichung, so wie sie angekommen ist, und die Schleife oben arbeitet auf einer Kopie. Wird derselbe rohe Diff noch einmal eingereicht, trifft er den Cache. Der Key ist ein Einweg-Digest, und der Wert ist ein Urteil, das Befunde trägt und nicht den Diff.

Bevor ein Urteil in den Cache wandert, entfernt der Orchestrator alles, was eine einzelne Einreichung identifiziert (ID, Organisation, Repository, Commit, PR-Nummer), und setzt diese Felder bei jedem Treffer aus der aktuellen Einreichung neu. Befunde werden gecacht, Identität nie. Das macht den Cache nebenbei organisationsunabhängig, was weiter unten noch wichtig wird.

Auch der zweite Leser des rohen Diffs ist gewollt. Scheitert eine Pflichtstufe (dazu zählt auch, dass ihr mitten in der Arbeit das Budget von 1.200 ms ausgeht, das sich die ganze Kette teilt), fällt der Orchestrator auf einen deterministischen, prozessinternen Secret-Scan der ursprünglichen Einreichung zurück. Der Scan muss Rohtext sehen, um ein Secret zu finden, und er ist kein Modell. Seine Urteile werden nie gecacht, und TestHandle_FallbackVerdictIsNotCached sagt in seiner Fehlermeldung, warum: „a degraded answer would stick for 72h“.

Ein Detektor-Paket und kein vorgetäuschter AST

Maskierung ist nur so gut wie die Erkennung, und die Erkennung lebt in einem einzigen Paket, pkg/scan, das der Analyzer, der Fallback des Orchestrators und die CLI gleichermaßen aufrufen. Es kennt wenige, dafür hochpräzise Muster, weil False Positives das Vertrauen von Entwicklern untergraben: Private-Key-Blöcke, AWS-Key-IDs, GitHub- und Slack-Tokens und eine generische Zuweisungsregel. Ein Detektor-Befund trägt einen Regelnamen und eine Zeilennummer, nie den Wert („potential credential detected (aws-access-key-id); remove and rotate it“), und das Urteil behält nur die Regel und diese Meldung. Was die Detektoren zu einem PR-Kommentar beitragen, enthält also kein Secret.

Das alles ist zeilenweises Scannen, und das ist eine Entscheidung, keine Abkürzung: D-019 stellt AST-Detektoren zurück, bis der Resolver vollständige Dateien liefern kann, weil „a diff alone cannot be parsed into a meaningful AST“. Ich liefere lieber eine Regex aus, die sagt, was sie ist, als einen Parser, der so tut, als wäre ein Hunk eine Datei. Eine Regex kennt allerdings nur die Syntax, die jemand in sie hineingeschrieben hat, und das zeigt sich weiter unten.

Degradieren statt sterben – und was Degradation verbergen kann

Der Analyzer ist Pflicht, die semantische Stufe nur beratend. Sie steckt in app.Optional, das jeden Fehler in eine Warnung stage-unavailable verwandelt, statt den Fallback auszulösen, denn der Fallback würde die Befunde wegwerfen, die der Analyzer schon erzeugt hat. Eine Warnung allein macht aus einem sauberen Urteil needs_review; ein degradierter Lauf neigt also zu „ein Mensch schaut drauf“, nie zu einer stillen Freigabe. Der semantische Service degradiert intern genauso: Fällt vLLM aus, springt die Heuristik ein; fällt ein Premium-Anbieter aus, wird daraus ein Info-Befund, während die Befunde des anderen stehen bleiben; und nur wenn jeder Premium-Reviewer scheitert, kommt 503 zurück, was Optional wieder in eine Warnung verwandelt.

Dieses Design hat einen echten Bug geschluckt. Die semantische Stufe kann „Gold-Kontext“ mitschicken, also Snippets aus dem besten Code der Organisation, die der Resolver aus pgvector holt. Das Abrufen läuft aber nach dem Best-Effort-Prinzip: Kein Resolver, eine Organisations-ID, die keine UUID ist, oder ein Fehler im Resolver bedeuten jeweils „kein Kontext“. In Go war „kein Kontext“ ein nil-Slice, und encoding/json schreibt ein nil-Slice als null; die Python-Seite hatte das Feld als Liste deklariert. Im Lauf mit allen Containern schickte der Orchestrator "gold_context": null, pydantic antwortete mit 422, und laut Handover hat genau dieser Lauf den Bug gefunden.

Verfolgen wir diesen 422 durch den Code. Die semantische Stufe meldet jede Antwort außer 200 als nicht verfügbar, und Optional macht daraus dieselbe Warnung stage-unavailable, die auch ein Timeout erzeugen würde. Ihre gesamte Meldung lautet:

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

Die beiden Credential-Befunde des Analyzers blockieren schon allein, also lautet die Entscheidung nach den Regeln des Codes mit oder ohne semantische Stufe rejected; an der Entscheidung hätte man diesen Bug nicht sehen können. Im Urteil verrät ein fehlendes local-heuristic:masked, dass etwas ausgefallen ist, sofern man wusste, dass es da sein müsste; erst die letzten drei Ziffern dieser Zeile sagen, dass es kein Anbieterausfall war. Was den Bug am Ende verraten hat, hält das Handover nicht fest.

Vier Ausfälle der semantischen Stufe laufen alle in app.Optional, das einen einzigen Warn-Befund stage-unavailable ausgibt: ein Timeout gegen das Budget von 1.200 ms, das sich die ganze Kette teilt, eine abgelehnte Verbindung, HTTP 503, wenn jeder Premium-Reviewer gescheitert ist, und ein Schnittstellen-Bug, der bei gold_context gleich null 422 liefert. Das Urteil für diesen Diff lautet rejected, weil seine Secrets schon allein blockieren; bei einem sauberen Diff ergibt dieselbe Warnung needs_review. Unterscheiden lassen sich die vier nur am Ende der Meldung, hier: semanticstage: semantic service returned 422. Darunter: Im semantischen Service fällt ein ausgefallenes vLLM auf die Heuristik zurück, und ein ausgefallener Premium-Reviewer wird zum Info-Befund.
Ein Schnittstellen-Bug und ein Anbieterausfall nehmen denselben Ausgang. Nur ein Statuscode tief in der Meldung unterscheidet sie.

Der Bug hatte noch einen zweiten Preis. Fällt eine beratende Stufe aus, zählt das Ergebnis trotzdem als Urteil der Pipeline, und die degradierte Antwort wird gecacht wie jede andere: Standardmäßig 72 Stunden lang hätte eine erneute Einreichung dieses Diffs das Urteil ohne die Befunde der semantischen Stufe bekommen. Das ist genau der Ausgang „degraded answer would stick for 72h“, vor dem der Fallback-Test schützt, nur kommt er hier über einen Pfad, den der Test nicht abdeckt. Ein Probelauf gegen eine Kopie des Repos hat bestätigt, dass der Cache geschrieben wird.

Der Fix landete auf beiden Seiten, jeweils mit einem Regressionstest, der heute grün ist (TestAnalyze_NonUUIDOrgSkipsRAG in Go, test_evaluate_tolerates_null_gold_context in Python). Die Go-Hälfte ist ein einziges Struct-Tag:

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"`
}

Auf der Python-Seite wurde das Feld zu list[GoldContextIn] | None = None, mit einem Docstring, der es unverblümt sagt: „be liberal in what we accept“.

Wo die Struktur endet

Wer das ganze Versprechen an eine Stelle legt, macht diese Stelle zu einem lohnenden Angriffsziel. Also habe ich sie beim Schreiben dieses Beitrags angegriffen und drei Grenzen gefunden, sortiert danach, wie akut sie sind.

Erkennung. Hier fange ich an, weil das die einzige Grenze ist, die in der ausgelieferten Verdrahtung akut ist. Der Orchestrator garantiert, dass die Ausgabe des Analyzers der einzige Diff ist; er kann nicht garantieren, dass der Analyzer alles erwischt hat. Das ist die generische Regel in pkg/scan:

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

Auf password = "…" und password: "…" passt die Regel. Auf die Kurzdeklaration von Go, password := "…", passt sie nicht: Die Zeichenklasse nimmt den Doppelpunkt, und das Muster verlangt danach ein Anführungszeichen, wo das Gleichheitszeichen steht. (Der AWS-Schlüssel in Abbildung 1 wird über seinen Wert erkannt, dort spielt := also keine Rolle.)

Verfolgt man diese Zeile durch den Code, scheitern beide Hälften des Versprechens auf einmal. Kein Treffer heißt kein Befund und keine Maske: Die semantische Stufe bekommt das Passwort im Klartext, die Standard-Heuristik meldet nur ein local-heuristic:password auf Info-Level, das nie eine Entscheidung ändert, und die Änderung wird freigegeben.

Steht dieselbe Zeile in einem Diff, der auch einen AWS-Schlüssel enthält, lautet das Urteil rejected, und es trägt die Quittung vom Anfang dieses Beitrags, local-heuristic:masked, direkt neben einem Passwort, das die semantische Stufe im Klartext gelesen hat. Wäre die Premium-Ebene mit beiden API-Schlüsseln eingeschaltet, kämen allein diese beiden Hinweise auf 0,4 + 0,3 = 0,7 und lägen damit über der standardmäßigen Eskalationsschwelle von 0,5; dieser Diff ginge samt rohem Passwort an Claude und Gemini.

Der Eval-Harness meldet auf seinem Golden-Korpus Precision und Recall von 1,000, und das ist nicht falsch: Unter seinen 14 Fällen ist das einzige Positivbeispiel für ein generisches Secret eine Zuweisung in Python. Als ich lokal eine einzige Go-Kurzdeklaration ergänzt habe, fiel der Recall auf 0,889, und der Harness verfehlte seine eigene Untergrenze von 0,90. Das Gate war in Ordnung; dem Korpus fehlte der Fall.

Verdrahtung. Die Schleife garantiert, dass ein maskierter Diff weitergereicht wird; sie kann nicht garantieren, dass es überhaupt eine maskierende Stufe gibt. cmd/orchestrator/main.go baut die Kette aus der Konfiguration: den Analyzer, wenn ANALYZER_ADDR gesetzt ist, sonst den prozessinternen Scan, danach die semantische Stufe, wenn SEMANTIC_URL gesetzt ist (beide Namen tragen das Präfix MEMBRANE_ORCHESTRATOR_). Der prozessinterne Scan liefert Befunde, aber keinen maskierten Diff, obwohl pkg/scan einen berechnen könnte. Eine leere Analyzer-Adresse zusammen mit einer gesetzten Semantic-URL reicht der semantischen Stufe also den rohen Diff. Ein zweiter Probelauf hat das bestätigt: Das Urteil lautete weiterhin rejected, und die semantische Stufe bekam AKIAIOSFODNN7EXAMPLE im Klartext. Keine ausgelieferte Konfiguration tut das, denn die Compose-Datei mit allen Containern und die Helm-Values setzen beide Adressen, aber die Konfiguration sollte das gar nicht ausdrücken können.

Raster aus zwei mal zwei Feldern: ANALYZER_ADDR gesetzt oder leer gegen SEMANTIC_URL gesetzt oder leer, beide mit dem Präfix MEMBRANE_ORCHESTRATOR_. Beide gesetzt: Analyzer, dann semantische Stufe; die semantische Stufe sieht die Maske (volles Compose und Helm-Values). Nur Analyzer: keine semantische Stufe (.env.example für die Entwicklung auf dem Host). Beide leer: nur prozessinterner Scan, keine semantische Stufe (Standardwerte im Code). Analyzer leer und Semantic-URL gesetzt: prozessinterner Scan, dann semantische Stufe; die semantische Stufe sieht den Schlüssel im Klartext, und keine ausgelieferte Datei setzt das. Ein erkannter Schlüssel blockiert in allen vier Feldern; es ändert sich nur, ob die semantische Stufe ihn liest.
Die Schleife kann nur eine Maske weiterreichen, die sie bekommt. Eine Verdrahtung reicht der semantischen Stufe den rohen Diff, und nichts in der Konfiguration verhindert das.

Kontext. Die Invariante deckt den Diff ab, der geprüft wird, nicht den Kontext, der für die Prüfung abgerufen wird. Gold-Snippets landen genau so, wie sie gespeichert sind, in beiden Modell-Prompts, dem lokalen für vLLM und dem Premium-Prompt für zwei Anbieter, und beide Prompts kündigen den Diff danach als „Masked diff under review (secrets already redacted)“ an. Dieses Etikett ist ein Versprechen, das ein String abgibt, und „kuratierter Code enthält selten einen Schlüssel“ ist wieder eine Konvention.

Der Cache erbt die Lücke: Sein Key ignoriert die Organisation, also würden Befunde, die ein Modell mit den Snippets einer Organisation im Prompt geschrieben hat, an jede Organisation ausgeliefert, die denselben Diff einreicht. Beides bleibt latent, solange außerhalb der Tests nichts Gold-Einträge schreibt. Und der rohe Diff läuft weiterhin durch das Topic der Einreichungen: Die Garantie betrifft die Modellstufen, nicht Kafka.

Konstruktion macht eine Garantie nicht unzerbrechlich. Sie gibt ihr eine Adresse, und diese Adresse verdient eigene Tests.

Eine kleinere Lücke steckt in meinem eigenen Test. TestHandle_MaskedDiffFlowsToLaterStages verspricht in einem Kommentar, dass der Cache-Key weiterhin aus dem rohen Diff kommt, prüft dann aber nur, dass das Urteil aus der Pipeline stammt. Der Code hält (der Probelauf gegen den Cache fand den Eintrag unter dem Key des rohen Diffs), aber ein Kommentar ist keine Assertion.

Die Fixes sind klein, weil die Erkennung schon in einem einzigen Paket lebt. Die prozessinterne Stufe sollte die maskierte Kopie zurückgeben, oder der Orchestrator sollte sich weigern, eine semantische Stufe ohne vorgeschalteten Maskierer zu starten. Wird die Regex einmal korrigiert, bekommen Analyzer, Fallback und CLI den Fix im selben Commit. Dazu kommen der Go-Fall im Korpus, Gold-Snippets, die maskiert werden, bevor sie einen Prompt erreichen, ein Cache-Key pro Organisation, bevor Gold-Kontext live geht, und ein Test, der den Cache-Key tatsächlich prüft. Nichts davon ist bisher umgesetzt.

Was das (noch) nicht kann

  • Echte Modelle. Bisher sind weder ein echter vLLM-Endpunkt noch Schlüssel von Anbietern angebunden und abgestimmt; das Handover führt das als nächsten Schritt.
  • Echtes Retrieval. Die Embeddings kommen aus einem deterministischen Stub, und außerhalb der Tests schreibt noch nichts Gold-Einträge.
  • Zielarchitektur. Die AST-Analyse ist zurückgestellt, und das Envoy-Gateway und das AWS-Deployment in den Architekturdokumenten sind Entwurf, kein Code.
  • Automatisierte End-to-End-Läufe. GitHub Actions läuft auf diesem Account nie (D-036), die Gates sind also ein lokales task lint und task test, und der Lauf mit allen Containern ist dokumentiert, nicht geskriptet.

Was sich übertragen lässt

Um das Muster zu nutzen, brauchst du Membrane nicht:

  • Mach das geschwärzte Artefakt zum Rückgabewert und lass den Orchestrator es einsetzen. Verlange nicht von Konsumenten, selbst zu schwärzen.
  • Nimm in den Cache-Key auf, was ankam und wessen Kontext die Antwort geprägt hat; schick stromabwärts nur, was du zur Not auch leaken könntest.
  • Benenne Felder im Übertragungsformat nach der Invariante (masked_diff) und setze sie dann an einer Stelle durch, die nicht der Name ist.
  • Lass beratende Stufen in Richtung Review scheitern und prüfe, dass sie sich gemeldet haben.
  • Weigere dich, eine Kette ohne Maskierer an der Spitze zu starten, und lass den Testkorpus mit jedem übersehenen Fall wachsen.

Lass nicht jede Stufe versprechen, nicht hinzusehen. Gib jeder etwas in die Hand, an dem es nichts zu sehen gibt, und richte deine ganze Paranoia auf die eine Stelle, die übergibt.