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:
export const config = { paymentApiKey: "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:
- 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.
- Die Historie bereinigen.
git filter-reposchreibt jeden Commit ohne den Schlüssel um. Danngit push --force, und alle klonen neu. - 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
function paymentKey() { const key = process.env.PAYMENT_API_KEY?.trim(); if (!key) { throw new Error("PAYMENT_API_KEY ist nicht gesetzt"); } return key;}export const config = { paymentApiKey: "pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd",};function paymentKey() { const key = process.env.PAYMENT_API_KEY?.trim(); if (!key) { throw new Error("PAYMENT_API_KEY ist nicht gesetzt"); } return key;}Schritt für Schritt, in Worten
- Der Zahlungsschlüssel steht im Code. Der Commit 3a9f2d1 „Zahlungen hinzugefügt“ speichert ihn in der Historie.
- Der nächste Commit löscht diese Zeilen. Die Datei ist sauber, aber 3a9f2d1 enthält den Schlüssel weiter.
- Jemand hat eine Kopie des Repos: einen Klon, einen Fork, einen CI-Cache. git log -p holt den Schlüssel aus dem alten Commit.
- Zuerst die Rotation: Der Anbieter widerruft den alten Schlüssel. Die Kopie da draußen ist jetzt nutzlos.
- Dann die Historie: git filter-repo schreibt die Commits ohne den Schlüssel um. Die Hashes sind neu, alle klonen neu.
- Der neue Schlüssel lebt nur in der Umgebung (oder in einem Secret Store). Der Code liest ihn über seinen Namen, PAYMENT_API_KEY.
- Fehlt der Name, hält das Programm sofort an und sagt, was fehlt: Fail closed. Kein Standardschlüssel.
- 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.
# 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.txtgit push --force --allDie 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.
function paymentKey() { const key = process.env.PAYMENT_API_KEY?.trim(); if (!key) { throw new Error("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:
function masked(key) { return key.slice(0, 9) + "…" + key.slice(-4);}try { const key = paymentKey(); console.log(`Zahlungen sind bereit, Schlüssel ${masked(key)}`);} catch (error) { console.log(`Der Shop startet nicht: ${error.message}`);}Der Shop startet nicht: PAYMENT_API_KEY ist nicht gesetztWenn 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.
process.env.PAYMENT_API_KEY ist undefined, wenn der Name nicht da ist, und ?.trim() gibt dann ebenfalls undefined zurück. Hier steht catch nur für einen sauberen Satz. In einer echten Anwendung lässt du den Fehler den Start abbrechen: Der Prozess endet mit Code 1. Seit Node 20.6 lädt node --env-file=.env app.js eine .env-Datei ohne Paket in die Umgebung.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, anpassword,secretoderkey.
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.
const RULES = [ ["Zahlungsschlüssel", /pay_live_[A-Za-z0-9]{24}/], ["Passwort in einer URL", /:\/\/[^:/\s]+:[^@\s]+@/], ["Passwort oder Schlüssel", /(password|secret|key)\s*=\s*"[^"$]{8,}"/i],];
function scan(text) { let found = 0; text.split("\n").forEach((line, i) => { const rule = RULES.find(([, pattern]) => pattern.test(line)); if (rule) { console.log(`Zeile ${i + 1}: ${rule[0]}`); found++; } }); return found;}const 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}"`.trim();const found = scan(diff);console.log(found > 0 ? `Gefunden: ${found}. Der Commit hält an.` : "Sauber.");Zeile 2: ZahlungsschlüsselZeile 3: Passwort in einer URLZeile 4: Passwort oder SchlüsselGefunden: 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?
const config = `mail.user = "orders@shop.example"mail.password = "\${MAIL_PASSWORD}"db.url = "postgres://shop:Kq7vR2pX@db/shop"`.trim();const found = scan(config);console.log(found > 0 ? `Gefunden: ${found}. Der Commit hält an.` : "Sauber.");Zeile 1 ist eine E-Mail-Adresse: Sie hat kein ://, und user steht nicht in der dritten Regel. Zeile 2 ist ein Platzhalter: Die dritte Regel verlangt, dass die Zeichenkette nicht mit $ beginnt. Bleibt Zeile 3, in der das Passwort in der Datenbank-URL selbst steht.
Probier es aus
Ergänze
Woher kommt der Schlüssel?
const key = .PAYMENT_API_KEY?.trim();
if (!key) {
throw new Error("PAYMENT_API_KEY ist nicht gesetzt");
}process.env ist das Objekt der Umgebung: Jede Variable ist eine Eigenschaft mit einer Zeichenkette oder undefined.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.
Der Fallback-Schlüssel ist weiter ein Geheimnis im Code, und das Programm startet still, wenn die Einstellung fehlt: Fail open. Auf einem neuen Server läuft der Shop mit dem alten Schlüssel, der vielleicht schon geleakt ist, und niemand merkt es. Richtig ist, mit einer klaren Meldung anzuhalten.
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.
node --env-file=.env (seit Node 20.6) oder process.loadEnvFile() laden .env ohne Paket. Das Paket dotenv tut dasselbe für ältere Versionen.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.90POST /refund 200 key=pay_live_7Qm2Xc9LkT4vB8nR1sZp6wHd amount=12.00GET /health 200POST /pay 200 key=pay_live_…6wHd amount=49.90POST /refund 200 key=pay_live_…6wHd amount=12.00GET /health 200Weitere Aufgaben
- Noch eine Regel. Füge dem Scanner eine Regel für einen privaten Schlüssel hinzu: eine Zeile, die mit
-----BEGINbeginnt und mitPRIVATE 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
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: Reguläre Ausdrücke · Arrays: push, splice und sort · Pfeilfunktionen · Nicht vertrauenswürdige Eingaben
- Geht weiter in: Abhängigkeiten: fremder Code, den dein Code ausführt · Kryptografie, die du nicht selbst schreibst · Der Prompt ist Code · Guardrails: Prüfungen vor und nach dem Modell
- Aufgaben zum Thema: Sicherheit für Entwickler