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:
@@ -1,23 +1,27 @@
|
||||
# #005 Revisionssicheres Protokoll
|
||||
**Status:** offen
|
||||
**Status:** erledigt
|
||||
**Meilenstein:** v0.1.0
|
||||
**Labels:** sicherheit
|
||||
**Geschätzt:** 2 h **Tatsächlich:** –
|
||||
**Geschätzt:** 2 h **Tatsächlich:** 0 h 10 min
|
||||
|
||||
## Ziel
|
||||
Jede Aktion landet im Protokoll; Einträge sind unveränderlich und über eine Hash-Kette verbunden.
|
||||
|
||||
## Aufgaben
|
||||
- [ ] Modell `Protokoll` mit Akteur, Aktion, Host, Zielbenutzer, Fingerprint, Diff, Ergebnis
|
||||
- [ ] DB-Trigger verbietet UPDATE/DELETE
|
||||
- [ ] Hash-Kette (SHA-256 über Vorgänger + Inhalt), Prüffunktion
|
||||
- [ ] Redaction: Private Keys/Passwörter können nicht hineingeraten
|
||||
- [x] Modell `Protokoll` mit Akteur, Aktion, Host, Zielbenutzer, Fingerprint, Diff, Ergebnis
|
||||
- [x] DB-Trigger verbietet UPDATE/DELETE
|
||||
- [x] Hash-Kette (SHA-256 über Vorgänger + Inhalt), Prüffunktion
|
||||
- [x] Redaction: Private Keys/Passwörter können nicht hineingeraten
|
||||
|
||||
## Akzeptanzkriterien
|
||||
- [ ] Test: UPDATE/DELETE schlägt fehl
|
||||
- [ ] Test: manipulierte Zeile wird von der Prüfung gefunden
|
||||
- [x] Test: UPDATE/DELETE schlägt fehl
|
||||
- [x] Test: manipulierte Zeile wird von der Prüfung gefunden
|
||||
|
||||
## Abhängigkeiten
|
||||
#003
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -17,9 +17,17 @@ export const GESCHWAERZT = "[geschwärzt]";
|
||||
* 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 = [
|
||||
"privat",
|
||||
"privatekey",
|
||||
"privatkey",
|
||||
"privatschluessel",
|
||||
"privatverschluesselt",
|
||||
"dekverschluesselt",
|
||||
"passwort",
|
||||
"password",
|
||||
"passphrase",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import type { ProtokollErgebnis } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { schwaerzen } from "@/lib/protokoll-schwaerzen";
|
||||
|
||||
/**
|
||||
* Schreibt einen Eintrag ins revisionssichere Protokoll (#005).
|
||||
@@ -34,8 +35,18 @@ export async function protokollieren(
|
||||
hostName: eintrag.hostName ?? null,
|
||||
zielbenutzer: eintrag.zielbenutzer ?? null,
|
||||
fingerprint: eintrag.fingerprint ?? null,
|
||||
diff: eintrag.diff ?? null,
|
||||
details: (eintrag.details as Prisma.InputJsonValue | undefined) ?? undefined,
|
||||
// Auch der Diff: er stammt aus Dateien auf den Hosts, und eine authorized_keys-Datei
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -96,3 +96,19 @@ describe("schwaerzen", () => {
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user