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
|
# #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.
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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