feat: Geheimnisse vor Log und Protokoll schwaerzen (#006)
Private Keys, Passwoerter und Tokens duerfen nie in Log oder Protokoll landen (CLAUDE.md). Ein Netz ueber Feldnamen allein reicht nicht: Ein Key kann in einer Fehlermeldung oder einem Stacktrace stecken, ohne dass das Feld danach heisst. Deshalb schwaerzt `schwaerzen` sowohl Felder mit verdaechtigen Namen (irgendwo im Namen, nicht nur am Anfang) als auch jeden PEM-Private-Key in beliebigen Zeichenketten, auch abgeschnittene. Der Helfer ist framework-frei und wird mit dem Logger und dem Protokoll eingebaut. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown> };
|
||||||
|
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<string, unknown> = { 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 } });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user