diff --git a/backend/src/controllers/auditLog.controller.ts b/backend/src/controllers/auditLog.controller.ts index 7d97eb23..2a7cf184 100644 --- a/backend/src/controllers/auditLog.controller.ts +++ b/backend/src/controllers/auditLog.controller.ts @@ -148,13 +148,21 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) { // echten Angriff aussehen liess (und damit die Meldung entwertete). const tampered = result.tamperedEntries.length; const gaps = result.chainGaps.length; + const unexplained = result.unexplainedGaps.length; + + const luecken = gaps > 0 + ? `${gaps} strukturelle Lücken` + + (unexplained === 0 + ? ' (alle durch protokollierte Löschungen erklärt)' + : unexplained < gaps + ? `, davon ${unexplained} ohne dokumentierte Löschung` + : ' ohne dokumentierte Löschung') + : ''; const message = tampered > 0 - ? `${tampered} MANIPULIERTE Einträge gefunden` + - (gaps > 0 ? ` (zusätzlich ${gaps} strukturelle Lücken)` : '') + ? `${tampered} MANIPULIERTE Einträge gefunden` + (gaps > 0 ? ` (zusätzlich ${luecken})` : '') : gaps > 0 - ? `Keine Manipulation. ${gaps} strukturelle Lücken in der Verkettung ` + - '(parallel geschriebene oder gelöschte Einträge) – Inhalte unverändert.' + ? `Keine Manipulation. ${luecken} – Inhalte unverändert.` : 'Alle Einträge sind unverändert und lückenlos verkettet'; res.json({ @@ -167,6 +175,8 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) { tamperedEntries: result.tamperedEntries, // Meist harmlos: Verkettung unterbrochen, Inhalte selbst unversehrt. chainGaps: result.chainGaps, + // Nur Lücken ohne protokollierte Löschung sind erklärungsbedürftig. + unexplainedGaps: result.unexplainedGaps, tampered: tampered > 0, message, }, diff --git a/backend/src/services/audit.service.ts b/backend/src/services/audit.service.ts index 83a36c2a..14794eb3 100644 --- a/backend/src/services/audit.service.ts +++ b/backend/src/services/audit.service.ts @@ -562,6 +562,11 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ * auf Manipulation – die Zeilen selbst sind unveraendert. */ chainGaps: number[]; + /** + * Teilmenge von `chainGaps`, die NICHT durch ein protokolliertes + * Loeschungs-Manifest erklaert ist. Nur diese sind erklaerungsbeduerftig. + */ + unexplainedGaps: number[]; }> { const where: Prisma.AuditLogWhereInput = {}; @@ -603,17 +608,78 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ }, }); + // --------------------------------------------------------------------- + // Version-Floor (Pentest R167-01) + // + // Vorher bestimmte die Zeile SELBST ueber `hashVersion`, wie streng sie + // geprueft wird – und `hashVersion` ist nicht gehasht. Ein Angreifer mit + // Schreibzugriff konnte also 2→1 zuruecksetzen, die Felder aendern, die nur + // V2 abdeckt (z. B. `success` false→true), und den schwachen V1-Hash ueber + // die 7 unveraenderten Felder nachziehen: Die Pruefung meldete "gueltig". + // + // Jetzt leitet sich die ERWARTETE Version aus der Kette ab: ab der ersten + // jemals mit V2 geschriebenen Zeile muss jede weitere Zeile V2 sein. Die + // Grenze laesst sich durch das Herabstufen einer einzelnen Zeile nicht + // verschieben (das Minimum bleibt), ein Angreifer muesste saemtliche + // V2-Zeilen ab der Grenze herabstufen und die komplette Kette neu rechnen – + // das entspricht einem vollstaendigen Rehash und damit dem bekannten + // Grenzfall "vollstaendig kompromittierter DB-Zugang". + // + // Betriebshinweis: Bei einem rollierenden Deploy, bei dem kurzzeitig alte und + // neue Instanz parallel schreiben, koennen echte V1-Zeilen nach der Grenze + // entstehen und werden dann angezeigt. Beim hier ueblichen Deploy + // (pull + rebuild + restart einer Instanz) tritt das nicht auf. + const v2Cutover = await prisma.auditLog.aggregate({ + where: { hashVersion: { gte: 2 } }, + _min: { id: true }, + }); + const v2FromId = v2Cutover._min.id; + + // Dokumentierte Loeschungen einlesen: Luecken, die auf einen protokollierten + // Retention-Cleanup zurueckgehen, sind erklaert – alle anderen nicht. Vorher + // war das Manifest rein informativ und wurde von der Pruefung ignoriert, + // wodurch sich eine boeswillige Loeschung als "harmloser Gap" tarnen konnte. + const deletionRanges: Array<{ from: number; to: number }> = []; + const manifestRows = await prisma.auditLog.findMany({ + where: { resourceType: 'AuditLog', action: 'DELETE', endpoint: '/api/audit-logs/cleanup' }, + select: { changesAfter: true }, + }); + for (const row of manifestRows) { + try { + const parsed = JSON.parse(row.changesAfter || '{}'); + for (const m of parsed.manifest || []) { + if (typeof m.fromId === 'number' && typeof m.toId === 'number') { + deletionRanges.push({ from: m.fromId, to: m.toId }); + } + } + } catch { + // Unlesbares Manifest = nicht erklaerend; Luecke bleibt unerklaert. + } + } + const gapErklaert = (prevId: number, curId: number) => + deletionRanges.some((r) => r.from <= curId && r.to >= prevId); + const tamperedEntries: number[] = []; const chainGaps: number[] = []; + const unexplainedGaps: number[] = []; for (let i = 0; i < logs.length; i++) { const log = logs[i]; + // Erwartete Pruefstaerke aus der Kette, NICHT aus der Selbstauskunft. + const erwarteV2 = v2FromId !== null && log.id >= v2FromId; + if (erwarteV2 !== (log.hashVersion >= 2)) { + // Deklarierte Version passt nicht zur Position in der Kette → + // Downgrade-Versuch (oder manipulierte Version). + tamperedEntries.push(log.id); + continue; + } + // Pruefverfahren richtet sich nach der Version, mit der geschrieben wurde. // Version 2 deckt alle Inhaltsspalten ab; Version 1 nur 7 Felder und darf // zusaetzlich die historische Serialisierung nutzen (generateHashLegacy). let hashOk: boolean; - if (log.hashVersion >= 2) { + if (erwarteV2) { hashOk = log.hash === generateHashV2({ userId: log.userId, userEmail: log.userEmail, @@ -663,6 +729,9 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ const previousLog = logs[i - 1]; if (log.previousHash !== previousLog.hash) { chainGaps.push(log.id); + if (!gapErklaert(previousLog.id, log.id)) { + unexplainedGaps.push(log.id); + } } } } @@ -675,6 +744,7 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ invalidEntries, tamperedEntries, chainGaps, + unexplainedGaps, }; } diff --git a/docs/todo.md b/docs/todo.md index df94c756..04db880d 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,39 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🔒 hashVersion-Downgrade geschlossen (Pentest R167-01, HIGH)** (2026-08-18) + - Der Pentester hat den Angriffsweg, den ich beim Uebergeben von R166-02 + selbst als naechsten benannt hatte, als **exploitbar bewiesen** (PoC gegen + Live-Code + Live-Daten): `verifyIntegrity` waehlte die Pruefstaerke nach der + von der Zeile SELBST deklarierten `hashVersion` – und die ist nicht gehasht. + Angriff: `hashVersion` 2→1 setzen, die nur von V2 abgedeckten Felder + aendern (`success` false→true, `errorMessage` leeren, `resourceLabel` + umschreiben) und den schwachen V1-Hash ueber die 7 unveraenderten Felder + nachziehen → Fehl-Login als Erfolg getarnt, Pruefung meldet „gueltig“. + Besonders kritisch an der **letzten Zeile** der Kette: dort entsteht nicht + einmal ein Gap → dauerhaft unsichtbar. + - Fix: **Version-Floor.** Die erwartete Pruefstaerke leitet sich aus der Kette + ab (`MIN(id) WHERE hashVersion >= 2`), nicht aus der Selbstauskunft. Ab + dieser Grenze muss jede Zeile V2 sein; weicht die deklarierte Version von + der erwarteten ab, gilt die Zeile selbst als manipuliert. Geprueft wird + immer mit dem ERWARTETEN Verfahren. Die Grenze laesst sich durch das + Herabstufen einzelner Zeilen nicht verschieben (Minimum bleibt) – ein + Angreifer muesste alle V2-Zeilen ab der Grenze herabstufen und die gesamte + Kette neu rechnen (= vollstaendiger Rehash, bekannter Grenzfall). + - Zusaetzlich: Die Pruefung konsultiert jetzt das **Loeschungs-Manifest**. + Neu `unexplainedGaps` – nur Luecken ohne protokollierte Loeschung sind + erklaerungsbeduerftig. Vorher war das Manifest rein informativ, wodurch sich + eine boeswillige Loeschung als „harmloser Gap“ tarnen konnte. + - Verifiziert (PoC nachgebaut): Downgrade-Angriff auf Zeile mit Nachfolger → + **erkannt**; auf die Tail-Zeile (erzeugt keinen Gap) → **erkannt**; + Gegenrichtung (V1-Altzeile faelschlich als V2 deklariert) → **erkannt**; + Ausgangslage und Zustand nach Wiederherstellung jeweils 0 manipuliert + (keine Falschmeldungen auf Bestandsdaten). `tsc` + `vite build` gruen. + - Betriebshinweis dokumentiert: Bei einem rollierenden Deploy mit kurzzeitig + parallel schreibender Alt-Instanz koennen echte V1-Zeilen nach der Grenze + entstehen und wuerden angezeigt. Beim hier ueblichen Deploy + (pull + rebuild + restart) tritt das nicht auf. + - [x] **🛡️ Audit-Haerten: Fork, Feldabdeckung, Refresh-Rauschen, Route (Pentest R166)** (2026-08-18) - **R166-01 (HIGH) – Kette forkte weiter.** Mein GET_LOCK-Ansatz gab die Sperre im `finally` INNERHALB des Transaktions-Callbacks frei, also VOR dem diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 14e5ef01..1bdc87b8 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1752,7 +1752,7 @@ export const auditLogApi = { return res.data; }, verifyIntegrity: async () => { - const res = await api.post>('/audit-logs/verify'); + const res = await api.post>('/audit-logs/verify'); return res.data; }, rehash: async () => {