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>
104 lines
4.2 KiB
TypeScript
104 lines
4.2 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|
||
}
|