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>
This commit is contained in:
2026-10-12 09:30:51 +02:00
co-authored by Claude Opus 5.5
parent b194054d30
commit 364c514b04
5 changed files with 99 additions and 11 deletions
+12 -8
View File
@@ -1,23 +1,27 @@
# #005 Revisionssicheres Protokoll # #005 Revisionssicheres Protokoll
**Status:** offen **Status:** erledigt
**Meilenstein:** v0.1.0 **Meilenstein:** v0.1.0
**Labels:** sicherheit **Labels:** sicherheit
**Geschätzt:** 2 h **Tatsächlich:** – **Geschätzt:** 2 h **Tatsächlich:** 0 h 10 min
## Ziel ## Ziel
Jede Aktion landet im Protokoll; Einträge sind unveränderlich und über eine Hash-Kette verbunden. Jede Aktion landet im Protokoll; Einträge sind unveränderlich und über eine Hash-Kette verbunden.
## Aufgaben ## Aufgaben
- [ ] Modell `Protokoll` mit Akteur, Aktion, Host, Zielbenutzer, Fingerprint, Diff, Ergebnis - [x] Modell `Protokoll` mit Akteur, Aktion, Host, Zielbenutzer, Fingerprint, Diff, Ergebnis
- [ ] DB-Trigger verbietet UPDATE/DELETE - [x] DB-Trigger verbietet UPDATE/DELETE
- [ ] Hash-Kette (SHA-256 über Vorgänger + Inhalt), Prüffunktion - [x] Hash-Kette (SHA-256 über Vorgänger + Inhalt), Prüffunktion
- [ ] Redaction: Private Keys/Passwörter können nicht hineingeraten - [x] Redaction: Private Keys/Passwörter können nicht hineingeraten
## Akzeptanzkriterien ## Akzeptanzkriterien
- [ ] Test: UPDATE/DELETE schlägt fehl - [x] Test: UPDATE/DELETE schlägt fehl
- [ ] Test: manipulierte Zeile wird von der Prüfung gefunden - [x] Test: manipulierte Zeile wird von der Prüfung gefunden
## Abhängigkeiten ## Abhängigkeiten
#003 #003
## Notizen / Entscheidungen ## Notizen / Entscheidungen
- Modell, Trigger und Hash-Kette entstanden schon mit #003 (Datenbankregeln); hier kommen Service, Schwärzen und Prüffunktion dazu.
- **Diff und Details werden geschwärzt**, bevor sie in die Datenbank gehen (`schwaerzen()` aus #006).
- **„privat“ ist kein Schwärz-Stamm mehr:** `privateVerwendungen`, `privatePfad` usw. sind Angaben, die das Protokoll gerade braucht. Geschwärzt werden nur Felder, die den Schlüssel selbst tragen (`privateKey*`, `privatVerschluesselt`, `dekVerschluesselt` …) – und jeder PEM-Block im Inhalt.
- Ansicht, CSV-Export und Syslog folgen mit #027.
+9 -1
View File
@@ -17,9 +17,17 @@ export const GESCHWAERZT = "[geschwärzt]";
* Kleinschreibung und ohne `_`/`-`, und zwar irgendwo im Namen: `clientSecret` und * Kleinschreibung und ohne `_`/`-`, und zwar irgendwo im Namen: `clientSecret` und
* `x-api-token` sollen genauso greifen wie `privateKeyOpenssh` oder `privatVerschluesselt`. * `x-api-token` sollen genauso greifen wie `privateKeyOpenssh` oder `privatVerschluesselt`.
* Lieber einmal zu viel schwaerzen als einen Key ins Log schreiben. * 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 = [ const GEHEIME_WORTSTAEMME = [
"privat", "privatekey",
"privatkey",
"privatschluessel",
"privatverschluesselt",
"dekverschluesselt",
"passwort", "passwort",
"password", "password",
"passphrase", "passphrase",
+13 -2
View File
@@ -1,6 +1,7 @@
import type { Prisma } from "@/generated/prisma/client"; import type { Prisma } from "@/generated/prisma/client";
import type { ProtokollErgebnis } from "@/generated/prisma/enums"; import type { ProtokollErgebnis } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { schwaerzen } from "@/lib/protokoll-schwaerzen";
/** /**
* Schreibt einen Eintrag ins revisionssichere Protokoll (#005). * Schreibt einen Eintrag ins revisionssichere Protokoll (#005).
@@ -34,8 +35,18 @@ export async function protokollieren(
hostName: eintrag.hostName ?? null, hostName: eintrag.hostName ?? null,
zielbenutzer: eintrag.zielbenutzer ?? null, zielbenutzer: eintrag.zielbenutzer ?? null,
fingerprint: eintrag.fingerprint ?? null, fingerprint: eintrag.fingerprint ?? null,
diff: eintrag.diff ?? null, // Auch der Diff: er stammt aus Dateien auf den Hosts, und eine authorized_keys-Datei
details: (eintrag.details as Prisma.InputJsonValue | undefined) ?? undefined, // kann versehentlich einen eingefuegten Private Key enthalten.
diff: eintrag.diff ? (schwaerzen(eintrag.diff) as string) : null,
details: eintrag.details ? (schwaerzen(eintrag.details) as Prisma.InputJsonValue) : undefined,
}, },
}); });
} }
/** Prueft die Hash-Kette in der Datenbank; liefert die erste fehlerhafte ID oder null. */
export async function protokollKettePruefen(): Promise<bigint | null> {
const [zeile] = await prisma.$queryRaw<
{ ergebnis: bigint | null }[]
>`SELECT protokoll_kette_pruefen() AS ergebnis`;
return zeile?.ergebnis ?? null;
}
+16
View File
@@ -96,3 +96,19 @@ describe("schwaerzen", () => {
expect(schwaerzen({ x: geteilt, y: geteilt })).toEqual({ x: { a: 1 }, y: { a: 1 } }); expect(schwaerzen({ x: geteilt, y: geteilt })).toEqual({ x: { a: 1 }, y: { a: 1 } });
}); });
}); });
describe("harmlose Felder mit privat im Namen (#005)", () => {
it("bleiben lesbar", () => {
expect(
schwaerzen({
privateVerwendungen: 2,
privatePfad: "~/.ssh/id_ed25519_x",
privatGeloeschtAm: "2026-10-12",
}),
).toEqual({
privateVerwendungen: 2,
privatePfad: "~/.ssh/id_ed25519_x",
privatGeloeschtAm: "2026-10-12",
});
});
});
+49
View File
@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { protokollieren, protokollKettePruefen } from "@/server/services/protokoll";
import { datenbankLeeren } from "./hilfen/db";
/** Protokoll-Service (#005): schwaerzt vor dem Schreiben und laesst die Kette pruefen. */
const KEY =
"-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n-----END OPENSSH PRIVATE KEY-----";
beforeEach(async () => {
await datenbankLeeren();
});
describe("protokollieren", () => {
it("schreibt Akteur, Aktion und Ergebnis", async () => {
await protokollieren({
akteur: { id: "b1", name: "Florian" },
aktion: "schluessel.erzeugt",
ergebnis: "ERFOLG",
hostName: "srv-pve01",
fingerprint: "SHA256:abc",
});
const [eintrag] = await prisma.protokoll.findMany();
expect(eintrag).toMatchObject({
akteurName: "Florian",
aktion: "schluessel.erzeugt",
hostName: "srv-pve01",
});
expect(await protokollKettePruefen()).toBeNull();
});
it("laesst keinen Private Key und kein Passwort hinein", async () => {
await protokollieren({
akteur: "System",
aktion: "test",
ergebnis: "FEHLER",
diff: `+ ${KEY}`,
details: { passwort: "geheim123", fehler: `Parser scheiterte an ${KEY}`, privatePfad: "~/.ssh/id_x" },
});
const [eintrag] = await prisma.protokoll.findMany();
const text = JSON.stringify(eintrag, (_, wert: unknown) =>
typeof wert === "bigint" ? String(wert) : wert,
);
expect(text).not.toContain("PRIVATE KEY");
expect(text).not.toContain("geheim123");
expect(text).toContain("~/.ssh/id_x");
});
});