Files
sshkeyvault/src/lib/protokoll-schwaerzen.ts
T
florian.wildeandClaude Opus 5.5 364c514b04 feat(protokoll): Eintraege vor dem Schreiben schwaerzen (#005)
Details und Diff laufen durch schwaerzen(), damit kein Private Key und
kein Passwort ins unveraenderliche Protokoll geraet - was dort einmal
steht, laesst sich per Trigger nicht mehr entfernen.

Der Wortstamm privat ist dafuer zu breit: er schwaerzte auch Pfade und
Verwendungszaehler, die das Protokoll gerade zeigen soll. Jetzt greifen
nur Felder, die den Schluessel selbst tragen, und jeder PEM-Block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 09:30:51 +02:00

104 lines
4.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Schwaerzt Geheimnisse in beliebigen Werten, bevor sie in Log oder Protokoll landen (#006).
*
* Zwei Netze, weil eines allein nicht reicht:
* - Feldnamen: Alles, was nach Private Key, Passwort, Passphrase, Secret oder Token heisst,
* wird unabhaengig vom Inhalt ersetzt – auch Objekte und Puffer darunter.
* - Inhalte: Ein Private Key im PEM-Format wird auch dort erkannt, wo niemand ihn erwartet,
* etwa eingebettet in eine Fehlermeldung oder einen Stacktrace.
*
* Gibt immer eine Kopie zurueck; der Eingabewert bleibt unveraendert.
*/
export const GESCHWAERZT = "[geschwärzt]";
/**
* Wortstaemme, die einen Feldnamen als geheim markieren. Verglichen wird ohne Gross-/
* Kleinschreibung und ohne `_`/`-`, und zwar irgendwo im Namen: `clientSecret` und
* `x-api-token` sollen genauso greifen wie `privateKeyOpenssh` oder `privatVerschluesselt`.
* Lieber einmal zu viel schwaerzen als einen Key ins Log schreiben.
*
* „privat“ allein ist bewusst kein Stamm (#005): `privateVerwendungen`, `privatePfad` oder
* `privatGeloeschtAm` sind harmlose Angaben, die im Protokoll gerade gebraucht werden.
* Gemeint sind nur Felder, die den Schluessel selbst tragen.
*/
const GEHEIME_WORTSTAEMME = [
"privatekey",
"privatkey",
"privatschluessel",
"privatverschluesselt",
"dekverschluesselt",
"passwort",
"password",
"passphrase",
"secret",
"geheimnis",
"token",
"masterkey",
"authorization",
"cookie",
];
const HOECHSTE_TIEFE = 12;
/** Von BEGIN bis END eines beliebigen PEM-Private-Keys, oder bis zum Textende, falls abgeschnitten. */
const PEM_PRIVATE_KEY = /-----BEGIN[^-\n]*PRIVATE KEY-----[\s\S]*?(?:-----END[^-\n]*PRIVATE KEY-----|$)/g;
function istGeheimerName(name: string): boolean {
const normalisiert = name.toLowerCase().replace(/[_\-\s]/g, "");
return GEHEIME_WORTSTAEMME.some((stamm) => normalisiert.includes(stamm));
}
function textSchwaerzen(text: string): string {
if (!text.includes("-----BEGIN")) return text;
const ersetzt = text.replace(PEM_PRIVATE_KEY, GESCHWAERZT);
// Faengt seltsame Varianten ab, die der Ausdruck oben nicht trifft (z. B. Zeilenumbruch
// mitten im Kopf). Dann geht die ganze Zeichenkette.
return ersetzt.includes("-----BEGIN") && ersetzt.includes("PRIVATE KEY") ? GESCHWAERZT : ersetzt;
}
export function schwaerzen(wert: unknown): unknown {
return schwaerzenRekursiv(wert, new WeakSet(), 0);
}
function schwaerzenRekursiv(wert: unknown, gesehen: WeakSet<object>, tiefe: number): unknown {
if (typeof wert === "string") return textSchwaerzen(wert);
if (wert === null || typeof wert !== "object") return wert;
// Rohe Bytes koennen Schluesselmaterial sein und sind im Log ohnehin nicht lesbar.
if (wert instanceof Uint8Array || wert instanceof ArrayBuffer) return "[Binärdaten]";
if (wert instanceof Date) return new Date(wert.getTime());
if (gesehen.has(wert)) return "[zirkulär]";
if (tiefe >= HOECHSTE_TIEFE) return "[zu tief]";
gesehen.add(wert);
try {
if (Array.isArray(wert)) return wert.map((eintrag) => schwaerzenRekursiv(eintrag, gesehen, tiefe + 1));
if (wert instanceof Map) {
return Object.fromEntries(
[...wert.entries()].map(([schluessel, eintrag]) => [
String(schluessel),
istGeheimerName(String(schluessel)) ? GESCHWAERZT : schwaerzenRekursiv(eintrag, gesehen, tiefe + 1),
]),
);
}
if (wert instanceof Set)
return [...wert].map((eintrag) => schwaerzenRekursiv(eintrag, gesehen, tiefe + 1));
const ergebnis: Record<string, unknown> = {};
// Bei Fehlern sind name, message und stack nicht aufzaehlbar, tragen aber gerade den
// Text, in dem ein Key stecken kann.
if (wert instanceof Error) {
ergebnis["name"] = wert.name;
ergebnis["message"] = textSchwaerzen(wert.message);
if (wert.stack) ergebnis["stack"] = textSchwaerzen(wert.stack);
if (wert.cause !== undefined) ergebnis["cause"] = schwaerzenRekursiv(wert.cause, gesehen, tiefe + 1);
}
for (const [name, eintrag] of Object.entries(wert)) {
ergebnis[name] = istGeheimerName(name) ? GESCHWAERZT : schwaerzenRekursiv(eintrag, gesehen, tiefe + 1);
}
return ergebnis;
} finally {
// Nur Zyklen erkennen, nicht mehrfach referenzierte Teilbaeume verstecken.
gesehen.delete(wert);
}
}