/** * 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, 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 = {}; // 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); } }