diff --git a/src/lib/protokoll-schwaerzen.ts b/src/lib/protokoll-schwaerzen.ts new file mode 100644 index 0000000..2fffbf7 --- /dev/null +++ b/src/lib/protokoll-schwaerzen.ts @@ -0,0 +1,95 @@ +/** + * 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. + */ +const GEHEIME_WORTSTAEMME = [ + "privat", + "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); + } +} diff --git a/tests/krypto/protokoll-schwaerzen.test.ts b/tests/krypto/protokoll-schwaerzen.test.ts new file mode 100644 index 0000000..b740216 --- /dev/null +++ b/tests/krypto/protokoll-schwaerzen.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { GESCHWAERZT, schwaerzen } from "@/lib/protokoll-schwaerzen"; + +const KEY = + "-----BEGIN OPENSSH PRIVATE KEY-----\n" + + "b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\n" + + "-----END OPENSSH PRIVATE KEY-----"; + +describe("schwaerzen", () => { + it("ersetzt geheime Felder in verschachtelten Objekten und Arrays", () => { + const eingabe = { + name: "backup", + privateKeyOpenssh: "irgendwas", + privatVerschluesselt: Buffer.from("x"), + einstellungen: { + passwort: "hunter2", + Password: "hunter2", + sshPassphrase: "abc", + client_secret: "s", + geheimnisWert: { tief: 1 }, + accessToken: "t", + }, + liste: [{ token: "t", erlaubt: "ja" }, "frei", 3], + }; + expect(schwaerzen(eingabe)).toEqual({ + name: "backup", + privateKeyOpenssh: GESCHWAERZT, + privatVerschluesselt: GESCHWAERZT, + einstellungen: { + passwort: GESCHWAERZT, + Password: GESCHWAERZT, + sshPassphrase: GESCHWAERZT, + client_secret: GESCHWAERZT, + geheimnisWert: GESCHWAERZT, + accessToken: GESCHWAERZT, + }, + liste: [{ token: GESCHWAERZT, erlaubt: "ja" }, "frei", 3], + }); + // Eingabe bleibt unberuehrt. + expect(eingabe.einstellungen.passwort).toBe("hunter2"); + }); + + it("findet Private Keys in beliebigen Zeichenketten", () => { + const ergebnis = schwaerzen({ notiz: `vorher ${KEY} nachher`, tief: [[KEY]] }) as { + notiz: string; + tief: string[][]; + }; + expect(ergebnis.notiz).toBe(`vorher ${GESCHWAERZT} nachher`); + expect(ergebnis.tief[0]![0]).toBe(GESCHWAERZT); + expect(JSON.stringify(ergebnis)).not.toContain("b3BlbnNzaC1rZXkt"); + }); + + it("schwärzt Keys in Fehlermeldungen und Stacktraces", () => { + const fehler = new Error(`Parser scheiterte an ${KEY}`, { cause: { passphrase: "x", text: KEY } }); + const ergebnis = schwaerzen({ fehler }) as { fehler: Record }; + expect(ergebnis.fehler["name"]).toBe("Error"); + expect(ergebnis.fehler["message"]).toBe(`Parser scheiterte an ${GESCHWAERZT}`); + expect(JSON.stringify(ergebnis)).not.toContain("b3BlbnNzaC1rZXkt"); + expect(JSON.stringify(ergebnis)).not.toContain("PRIVATE KEY"); + expect(ergebnis.fehler["cause"]).toEqual({ passphrase: GESCHWAERZT, text: GESCHWAERZT }); + }); + + it("schwärzt auch abgeschnittene Keys ohne END-Zeile", () => { + const abgeschnitten = KEY.slice(0, 60); + expect(schwaerzen(`log: ${abgeschnitten}`)).toBe(`log: ${GESCHWAERZT}`); + }); + + it("schwärzt andere PEM-Private-Keys (PKCS#1, PKCS#8)", () => { + for (const art of ["RSA PRIVATE KEY", "PRIVATE KEY", "ENCRYPTED PRIVATE KEY"]) { + const pem = `-----BEGIN ${art}-----\nMIIEow\n-----END ${art}-----`; + expect(schwaerzen(pem)).toBe(GESCHWAERZT); + } + }); + + it("lässt Public Keys und harmlose Werte stehen", () => { + const wert = { + publicKeyZeile: "ssh-ed25519 AAAAC3Nza x", + fingerprint: "SHA256:abc", + anzahl: 3, + aktiv: true, + leer: null, + zeit: new Date(0), + }; + expect(schwaerzen(wert)).toEqual(wert); + expect(schwaerzen("-----BEGIN CERTIFICATE-----")).toBe("-----BEGIN CERTIFICATE-----"); + }); + + it("ersetzt rohe Bytes und übersteht Zyklen", () => { + const zyklisch: Record = { daten: Buffer.from("geheim") }; + zyklisch["selbst"] = zyklisch; + expect(schwaerzen(zyklisch)).toEqual({ daten: "[Binärdaten]", selbst: "[zirkulär]" }); + }); + + it("zeigt mehrfach referenzierte Teilbäume vollständig", () => { + const geteilt = { a: 1 }; + expect(schwaerzen({ x: geteilt, y: geteilt })).toEqual({ x: { a: 1 }, y: { a: 1 } }); + }); +});