Zum Inhalt
wannacode

Geheimnisse im Code und in Git

Kurz gesagt: Ein Geheimnis (ein Schlüssel oder Passwort), das in Git committet wurde, bleibt auch nach dem Löschen in der Historie: erst rotieren, dann die Historie bereinigen, und der Code liest es aus Umgebungsvariablen.

Problem

Der Schlüssel, den du gelöscht hast

Der Shop nimmt Zahlungen über einen Anbieter an. Das Programm schickt dem Anbieter einen API-Schlüssel: eine lange Zeichenkette, die beweist „das ist der Shop“. Wer den Schlüssel hat, kann im Namen des Shops Zahlungen und Erstattungen auslösen.

Ein Kollege hat es eilig und schreibt den Schlüssel direkt in den Code:

static class PaymentConfig
{
public const string ApiKey =
"pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd";
}

Im Code-Review fällt es jemandem auf. Der Kollege löscht ihn mit einem neuen Commit „Schlüssel aus dem Code entfernt“. Die Datei ist jetzt sauber.

Drei Monate später wird das Repo wegen des offenen SDKs des Shops öffentlich. In der nächsten Nacht gehen vom Konto des Shops Erstattungen auf fremde Karten los. Jemand hat einen einzigen Befehl ausgeführt:

$ git log -p -S pay_live_
commit 9c41e07 Schlüssel aus dem Code entfernt
- "pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd";
commit 3a9f2d1 Zahlungen hinzugefügt
+ "pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd";

Das Löschen hat den Schlüssel nicht entfernt. Es hat eine weitere Stelle hinzugefügt, an der er auftaucht.

Die Idee

Git merkt sich alles

Jeder Commit ist ein Schnappschuss der Dateien. Ein neuer Commit ändert den alten nicht; er liegt obendrauf. Deshalb zeigt git log -p sowohl das Hinzufügen des Schlüssels als auch das Löschen.

Ein Geheimnis, das in Git gelandet ist und deinen Rechner verlassen hat, ist geleakt. Nicht „vielleicht“: Es gibt Kopien in den Klonen deiner Kollegen, in Forks, im CI-Cache, in Mirrors. Du findest sie nicht, geschweige denn löschst du sie.

Die Reihenfolge ist deshalb immer dieselbe:

  1. Rotation. Im Dashboard des Anbieters widerrufst du den alten Schlüssel und stellst einen neuen aus. Jetzt ist der alte wertlos, egal wohin er kopiert wurde.
  2. Die Historie bereinigen. git filter-repo schreibt jeden Commit ohne den Schlüssel um. Dann git push --force, und alle klonen neu.
  3. Der neue Schlüssel kommt nie in den Code. Das Programm liest ihn von außen: aus der Umgebung (Umgebungsvariablen) oder aus einem Secret Store.

Die umgekehrte Reihenfolge ist ein Fehler: Während du die Historie bereinigst, funktioniert der alte Schlüssel noch.

Ein Geheimnis ist alles, was Zugang gewährt: ein API-Schlüssel, ein Datenbankpasswort, ein Schlüssel zum Signieren von Tokens, der private Schlüssel eines Zertifikats. In der Lektion über nicht vertrauenswürdige Eingaben hast du das Programm vor den Daten anderer geschützt. Hier schützt du andere vor deinen eigenen.

Sieh es dir an

Der Schlüssel in der Historie

static string PaymentKey()
{
var key = Environment.GetEnvironmentVariable(
"PAYMENT_API_KEY");
if (string.IsNullOrWhiteSpace(key))
{
throw new InvalidOperationException(
"PAYMENT_API_KEY ist nicht gesetzt");
}
return key;
}
Schritt für Schritt, in Worten
  1. Der Zahlungsschlüssel steht im Code. Der Commit 3a9f2d1 „Zahlungen hinzugefügt“ speichert ihn in der Historie.
  2. Der nächste Commit löscht diese Zeilen. Die Datei ist sauber, aber 3a9f2d1 enthält den Schlüssel weiter.
  3. Jemand hat eine Kopie des Repos: einen Klon, einen Fork, einen CI-Cache. git log -p holt den Schlüssel aus dem alten Commit.
  4. Zuerst die Rotation: Der Anbieter widerruft den alten Schlüssel. Die Kopie da draußen ist jetzt nutzlos.
  5. Dann die Historie: git filter-repo schreibt die Commits ohne den Schlüssel um. Die Hashes sind neu, alle klonen neu.
  6. Der neue Schlüssel lebt nur in der Umgebung (oder in einem Secret Store). Der Code liest ihn über seinen Namen, PAYMENT_API_KEY.
  7. Fehlt der Name, hält das Programm sofort an und sagt, was fehlt: Fail closed. Kein Standardschlüssel.
  8. Mit gesetzter Variable startet der Shop. Kein Geheimnis in Git, und der geleakte Schlüssel funktioniert nicht mehr.

Die Szene beginnt mit dem Code aus dem Problem und endet mit dem Code, der den Schlüssel aus der Umgebung liest. Der geleakte Schlüssel bleibt in der Kopie da draußen, aber er ist widerrufen. Die Historie ist umgeschrieben: Die Commits haben neue Hashes, und der Schlüssel steckt nicht mehr darin.

Terminal
# 1. Rotation: pay_live_…6wHd beim Anbieter widerrufen und einen neuen Schlüssel ausstellen.
# 2. Die Historie: replacements.txt enthält die Zeile
# pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd==>***REMOVED***
git filter-repo --replace-text replacements.txt
git push --force --all

Die Idee

Das Geheimnis kommt von außen, und ohne es hält das Programm an

Das Programm liest den Schlüssel aus der Umgebung: einer Tabelle aus Namen und Werten, die das Betriebssystem jedem Prozess mitgibt. Auf deinem Rechner setzt du sie mit export PAYMENT_API_KEY=… (in PowerShell $env:PAYMENT_API_KEY = "…"). Auf dem Server setzt sie das System, das die Anwendung startet, meist aus einem Secret Store.

Wichtig ist, was passiert, wenn der Schlüssel fehlt. Ein neuer Server, ein falsch geschriebener Name, eine vergessene Einstellung: Das Programm hält sofort an und sagt, was fehlt. Das heißt Fail closed. Das Gegenteil, Fail open, ist, mit einem leeren oder „Standard“-Schlüssel zu starten und beim ersten Kunden zu scheitern, oder schlimmer, mit dem falschen Schlüssel zu laufen.

static string PaymentKey()
{
var key = Environment.GetEnvironmentVariable(
"PAYMENT_API_KEY");
if (string.IsNullOrWhiteSpace(key))
{
throw new InvalidOperationException(
"PAYMENT_API_KEY ist nicht gesetzt");
}
return key;
}

Der Schlüssel wird nie vollständig ausgegeben, auch nicht im Log. Wenn du sehen willst, welcher Schlüssel geladen ist, zeige nur den Anfang und die letzten vier Zeichen:

static string Masked(string key) => key[..9] + "…" + key[^4..];
try
{
var key = PaymentKey();
Console.WriteLine($"Zahlungen sind bereit, Schlüssel {Masked(key)}");
}
catch (InvalidOperationException e)
{
Console.WriteLine($"Der Shop startet nicht: {e.Message}");
}
Ausgabe
Der Shop startet nicht: PAYMENT_API_KEY ist nicht gesetzt

Wenn die Seite den Code prüft, gibt es diese Variable nicht, deshalb ist die Ausgabe die Ablehnung. Auf deinem Rechner, mit gesetztem Schlüssel, gibt das Programm Zahlungen sind bereit, Schlüssel pay_live_…6wHd aus.

Environment.GetEnvironmentVariable gibt null zurück, wenn der Name nicht da ist. Hier fängt der Code die Exception nur, um einen sauberen Satz auszugeben. In einer echten Anwendung lässt du sie den Start abbrechen: Der Prozess endet mit einem Code ungleich 0, und das System, das ihn startet, sieht, dass er nicht gestartet ist. In ASP.NET Core passiert dasselbe mit builder.Configuration["PAYMENT_API_KEY"] und einer Prüfung beim Start.

Sieh es dir an

Ein Scanner für Geheimnisse

Am besten gelangt das Geheimnis gar nicht erst in einen Commit. Deshalb läuft vor jedem Commit ein Scanner: ein Programm, das in den Änderungen nach Zeichenketten sucht, die wie Geheimnisse aussehen. Hier ist ein kleiner mit drei Regeln (reguläre Ausdrücke):

  • der Schlüssel des Anbieters hat eine bekannte Form: pay_live_ und 24 Zeichen;
  • eine URL mit einem Passwort darin: ://user:password@;
  • die Zuweisung einer Zeichenkette von mindestens 8 Zeichen, die nicht mit $ beginnt, an password, secret oder key.

Die letzte Zeile des Commits ist "${PAYMENT_API_KEY}": Das ist ein Platzhalter, den das Konfigurationssystem aus der Umgebung füllt. Er ist kein Geheimnis, und der Scanner darf ihn nicht aufhalten.

static class SecretScanner
{
static readonly (string Name, Regex Pattern)[] Rules =
[
("Zahlungsschlüssel", new Regex(@"pay_live_[A-Za-z0-9]{24}")),
("Passwort in einer URL", new Regex(@"://[^:/\s]+:[^@\s]+@")),
("Passwort oder Schlüssel", new Regex(
@"(password|secret|key)\s*=\s*""[^""$]{8,}""",
RegexOptions.IgnoreCase)),
];
public static int Scan(string text)
{
var lines = text.Split('\n');
int found = 0;
for (int i = 0; i < lines.Length; i++)
{
foreach (var (name, pattern) in Rules)
{
if (pattern.IsMatch(lines[i]))
{
Console.WriteLine($"Zeile {i + 1}: {name}");
found++;
break;
}
}
}
return found;
}
}
var diff = """
+ payment.url = "https://api.pay.example/v1"
+ payment.key = "pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd"
+ db.url = "postgres://shop:Kq7vR2pX@db.internal/shop"
+ mail.password = "winter-sale-2026"
+ payment.key = "${PAYMENT_API_KEY}"
""";
int found = SecretScanner.Scan(diff);
Console.WriteLine(found > 0 ? $"Gefunden: {found}. Der Commit hält an." : "Sauber.");
Ausgabe
Zeile 2: Zahlungsschlüssel
Zeile 3: Passwort in einer URL
Zeile 4: Passwort oder Schlüssel
Gefunden: 3. Der Commit hält an.

Echte Scanner haben Hunderte Regeln und messen außerdem, wie „zufällig“ eine Zeichenkette ist (Entropie), um Schlüssel ohne bekannte Form zu fangen. Die Idee ist dieselbe.

Probier es aus

Sage die Ausgabe voraus

Derselbe Scanner, eine andere Konfigurationsdatei. Was gibt er aus?

var config = """
mail.user = "orders@shop.example"
mail.password = "${MAIL_PASSWORD}"
db.url = "postgres://shop:Kq7vR2pX@db/shop"
""";
int found = SecretScanner.Scan(config);
Console.WriteLine(found > 0 ? $"Gefunden: {found}. Der Commit hält an." : "Sauber.");
Antwort

Probier es aus

Ergänze

Woher kommt der Schlüssel?

var key = ("PAYMENT_API_KEY");
if (string.IsNullOrWhiteSpace(key))
{
    throw new InvalidOperationException("PAYMENT_API_KEY ist nicht gesetzt");
}

Probier es aus

Finde den Fehler

Ein Kollege will nicht, dass der Shop „wegen einer Einstellung ausfällt“, und baut einen Fallback ein. Welche Zeile ist der Fehler?

Wähle die Zeile mit dem Fehler.

Genauer, für Neugierige

.gitignore und .env.example. Liegen die lokalen Geheimnisse in einer .env-Datei, steht ihr Name vom allerersten Commit an in .gitignore. In Git kommt .env.example mit den Namen und leeren Werten: PAYMENT_API_KEY=. So weiß ein neuer Kollege, was er ausfüllen muss, ohne die Werte zu sehen.

Ein Scanner vor jedem Commit. Von Hand funktioniert das nicht: Menschen vergessen. Der Scanner läuft von allein über einen Git-Hook (pre-commit). Fertige sind gitleaks, trufflehog und detect-secrets, meist über das pre-commit-Framework. GitHub hat außerdem Push Protection: Es lehnt einen Push mit dem Schlüssel eines bekannten Dienstes ab. Der Scanner ist ein Netz unter dem Seil, kein Grund, Geheimnisse in den Code zu schreiben.

Ein Secret Store. Umgebungsvariablen sind besser als der Code, aber nicht ideal. Kindprozesse sehen sie, manchmal landen sie in einem Crash-Dump, und man gibt sie leicht aus Versehen aus. Deshalb kommen die Geheimnisse auf dem Server aus einem Store: HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, Google Secret Manager. Sie protokollieren, wer was wann gelesen hat, rotieren die Schlüssel nach Zeitplan und geben kurzlebige Schlüssel aus.

Für die Entwicklung hat .NET die User Secrets: dotnet user-secrets set "Payment:ApiKey" "…" hält den Wert außerhalb des Projektordners. In der Produktion liest IConfiguration aus der Umgebung oder aus Azure Key Vault.

Das Bereinigen im Detail. git filter-repo entfernt nach dem Umschreiben absichtlich das Remote origin, damit du die neue Historie nicht aus Versehen an den falschen Ort schickst: Du fügst es wieder hinzu und führst git push --force --all und --tags aus. Kollegen müssen neu klonen: Ein alter Klon, der zurückgemergt wird, bringt den Schlüssel zurück in die Historie. Dienste wie GitHub behalten außerdem alte Pull-Request-Referenzen, deshalb schreibst du dort auch an den Support. Das ältere Werkzeug für dieselbe Aufgabe ist BFG Repo-Cleaner.

Geheimnisse in CI (Deploy-Keys, Zugang zur Container-Registry, die Geheimnisse der Pipeline) sind ein Thema des DevOps-Kurses ZeroToOps. Diese Lektion ist der Teil, den der Programmierer schreibt.

Übung

Der Schlüssel im Log

Löse die Aufgabe in deinem eigenen Editor.

Geheimnisse leaken auch über Logs: Die Anfrage an den Anbieter wird vollständig geloggt, mit dem Schlüssel darin. Schreibe Clean, das in einer Log-Zeile jeden Schlüssel aus pay_live_ + 24 Zeichen durch seine maskierte Form ersetzt, und lass es auf diesen drei Zeilen laufen:

POST /pay 200 key=pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd amount=49.90
POST /refund 200 key=pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd amount=12.00
GET /health 200
Ausgabe
POST /pay 200 key=pay_live_…6wHd amount=49.90
POST /refund 200 key=pay_live_…6wHd amount=12.00
GET /health 200

Weitere Aufgaben

  • Noch eine Regel. Füge dem Scanner eine Regel für einen privaten Schlüssel hinzu: eine Zeile, die mit -----BEGIN beginnt und mit PRIVATE KEY----- endet.
  • Ausnahmen. Füge eine Liste erlaubter Werte hinzu (zum Beispiel der Beispielschlüssel in der Dokumentation), die der Scanner überspringt.
  • Eine Prüfung beim Start. Schreibe eine Funktion, die alle nötigen Namen auf einmal prüft (PAYMENT_API_KEY, DB_PASSWORD, MAIL_PASSWORD) und jeden fehlenden meldet, nicht nur den ersten.

Test

Teste dich

1. Der Zahlungsschlüssel lag einen Tag lang im öffentlichen Repo. Ein neuer Commit löscht ihn. Was muss noch passieren, und in welcher Reihenfolge?
2. Warum reicht eine bereinigte Historie ohne Rotation des Schlüssels nicht?
3. In der Umgebung fehlt PAYMENT_API_KEY. Was ist das richtige Verhalten des Shops?
4. Wo liegt die .env-Datei mit den echten Werten?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·