From ac62198a01ed0cbf4b05c160d01f171e393f7bf1 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Fri, 21 Aug 2026 14:20:32 +0200 Subject: [PATCH] Bestandssiegel betriebstauglich gemacht (Pentest R173-01/-02/-03) R173-01 (HIGH): Das Siegel war ueber HTTP tot. Die generische auditMiddleware protokolliert den POST /seal-backlog-Request selbst als AuditLog/CREATE mit demselben endpoint - exakt die Signatur, mit der der Marker gesucht wurde, nur mit hoeherer id und ohne changesAfter. Der Selektor griff diese Zeile, root war undefined, Ergebnis: dauerhaft "gebrochen" bei null manipulierten Zeilen. Mein Testfehler: sealBacklog()/verifyIntegrity() direkt aufgerufen, nie ueber HTTP - die Middleware lief nie mit. Dieselbe Fehlerklasse wie R165. Fix: eigener Ressourcentyp AuditBacklogSeal, den die Middleware nie vergibt, zusaetzlich muss der Marker auswertbares {toId, root} tragen. R173-02 (MEDIUM): Fehlende Zeilen wurden uebersprungen, das Loeschen eines gesiegelten Einbruchsbelegs erschien nur als unerklaerte Luecke, waehrend der Indikator "intakt" meldete. Fix: fehlende gesiegelte Zeilen sind ein Siegelbruch mit eigener Liste (backlogMissing) und werden namentlich gemeldet. R173-03 (MEDIUM): Die R170-01-Haertung (Vorbefund im Marker) war auf seal-backlog nie angewandt, ein Re-Seal absorbierte Manipulationen mit weniger Spur als ein Rehash. Fix: Der Marker haelt den Befund vor dem Siegeln fest samt Status und Wurzel des vorherigen Siegels; verify weist die Anzahl gueltiger Siegel aus und warnt bei mehr als einem. Verifiziert ueber den echten HTTP-Pfad inkl. Middleware (separate Wegwerf-DB, Mischbestand 6xV1/4xV2/4xV3): Protokollzeile vorhanden, Status trotzdem intakt; Loeschen einer gesiegelten Zeile -> gebrochen mit backlogMissing[2]; erneutes Siegeln -> Anzahl 2 gemeldet, Vorbefund im Marker. tsc + vite build gruen. Co-Authored-By: Claude Opus 5 --- .../src/controllers/auditLog.controller.ts | 23 +++- backend/src/services/audit.service.ts | 102 +++++++++++++++--- docs/todo.md | 36 +++++++ 3 files changed, 143 insertions(+), 18 deletions(-) diff --git a/backend/src/controllers/auditLog.controller.ts b/backend/src/controllers/auditLog.controller.ts index de846815..a2345003 100644 --- a/backend/src/controllers/auditLog.controller.ts +++ b/backend/src/controllers/auditLog.controller.ts @@ -166,11 +166,28 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) { const siegel = result.backlogSealStatus === 'gebrochen' - ? ` ⚠ Bestandssiegel GEBROCHEN: ${result.backlogTampered.length} Alteinträge wurden nachträglich verändert.` + ? ' ⚠ Bestandssiegel GEBROCHEN:' + + (result.backlogTampered.length + ? ` ${result.backlogTampered.length} Alteinträge nachträglich verändert.` + : '') + + (result.backlogMissing.length + ? ` ${result.backlogMissing.length} gesiegelte Einträge wurden ENTFERNT (${result.backlogMissing.join(', ')}).` + : '') + + (!result.backlogTampered.length && !result.backlogMissing.length + ? ' Der Blattbestand des Siegels stimmt nicht mehr mit der signierten Wurzel überein.' + : '') : result.backlogSealStatus === 'kein_siegel' ? ' Hinweis: Der Altbestand ist nicht versiegelt – Änderungen daran wären nicht erkennbar.' : ''; + // Erneutes Siegeln kann legitim sein, verdient aber einen Blick: es + // ersetzt die zuvor beglaubigte Wurzel (Pentest R173-03). + const mehrfach = + result.backlogSealCount > 1 + ? ` Hinweis: Der Altbestand wurde ${result.backlogSealCount}× versiegelt – der jüngste Marker gilt. ` + + 'Jeder Marker enthält den Befund vor dem Siegeln; bei Zweifeln dort vergleichen.' + : ''; + const message = tampered > 0 ? `${tampered} MANIPULIERTE Einträge gefunden` + (gaps > 0 ? ` (zusätzlich ${luecken})` : '') : gaps > 0 @@ -190,11 +207,13 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) { // Nur Lücken ohne protokollierte Löschung sind erklärungsbedürftig. unexplainedGaps: result.unexplainedGaps, tampered: tampered > 0, - message: message + keinSchluessel + siegel, + message: message + keinSchluessel + siegel + mehrfach, unverifiableEntries: result.unverifiableEntries, // Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand. backlogSealStatus: result.backlogSealStatus, backlogTampered: result.backlogTampered, + backlogMissing: result.backlogMissing, + backlogSealCount: result.backlogSealCount, }, }); } catch (error) { diff --git a/backend/src/services/audit.service.ts b/backend/src/services/audit.service.ts index 70cdcfcd..81af66de 100644 --- a/backend/src/services/audit.service.ts +++ b/backend/src/services/audit.service.ts @@ -650,9 +650,23 @@ function backlogRoot(leaves: Array<{ auditLogId: number; leafHash: string }>): s .digest('hex'); } -/** Endpoint-Kennung des Siegel-Markers (dient zugleich als Suchschluessel). */ +/** Endpoint-Kennung des Siegel-Markers. */ const BACKLOG_SEAL_ENDPOINT = '/api/audit-logs/seal-backlog'; +/** + * Eigener Ressourcentyp fuer den Siegel-Marker (Pentest R173-01). + * + * Der Marker wurde urspruenglich als `AuditLog` / `CREATE` / + * `endpoint=/api/audit-logs/seal-backlog` gesucht. Genau diese Signatur + * erzeugt aber die generische Audit-Middleware, wenn jemand den Endpunkt + * aufruft – als Protokollzeile des Requests selbst, mit hoeherer id und ohne + * `changesAfter`. Der Selektor griff dann diese Zeile statt des echten + * Markers, `root` war `undefined`, und die Pruefung meldete dauerhaft + * „gebrochen“. Ein Ressourcentyp, den die Middleware nie vergibt (sie leitet + * ihn aus dem Pfad ab → `AuditLog`), macht den Marker eindeutig. + */ +const BACKLOG_SEAL_RESOURCE = 'AuditBacklogSeal'; + /** * Versiegelt den Altbestand einmalig. * @@ -685,6 +699,21 @@ export async function sealBacklog( throw new Error('Kein Altbestand vorhanden – es gibt nichts zu versiegeln.'); } + // Zustand VOR dem Siegeln festhalten (R173-03). + const vorher = await verifyIntegrity(); + const vorbefund = { + vorherigesSiegel: + vorher.backlogSealStatus === 'kein_siegel' + ? null + : { status: vorher.backlogSealStatus, root: vorher.backlogSealRoot }, + befund: { + manipuliert: vorher.tamperedEntries, + altbestand_veraendert: vorher.backlogTampered, + altbestand_fehlend: vorher.backlogMissing, + ketten_luecken: vorher.chainGaps, + }, + }; + const rows = await prisma.auditLog.findMany({ where: { id: { lte: toId } }, orderBy: { id: 'asc' }, @@ -704,16 +733,27 @@ export async function sealBacklog( // Marker ist selbst HMAC-signiert (Version 3) – damit ist die Wurzel // signiert, ohne dass es einen zweiten Signaturpfad braucht. + // + // Vorzustand mitschreiben (Pentest R173-03): Ein erneutes Siegeln wuerde + // sonst eine zwischenzeitliche Manipulation absorbieren und die gefaelschte + // Zeile als echt zertifizieren – mit noch weniger Spur als ein Rehash. Der + // Marker haelt deshalb fest, was VOR dem Siegeln galt: der Befund und die + // Wurzel des vorherigen Siegels. await createAuditLog({ userEmail: ausgeloestVon?.userEmail || 'system', userRole: 'System', action: 'CREATE', - resourceType: 'AuditLog', - resourceLabel: `Bestandssiegel erstellt: ${leaves.length} Alteinträge (id 1–${toId})`, + resourceType: BACKLOG_SEAL_RESOURCE, + resourceLabel: + `Bestandssiegel erstellt: ${leaves.length} Alteinträge (id 1–${toId})` + + (vorbefund.vorherigesSiegel + ? ' – ERNEUTES Siegeln, vorheriges Siegel wird ersetzt' + : ''), endpoint: BACKLOG_SEAL_ENDPOINT, httpMethod: 'POST', ipAddress: ausgeloestVon?.ipAddress || 'system', sensitivity: 'CRITICAL', + changesBefore: vorbefund, changesAfter: { fromId: 1, toId, count: leaves.length, root }, success: true, }); @@ -756,6 +796,12 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen'; /** Altbestands-Zeilen, deren Inhalt vom Siegel abweicht. */ backlogTampered: number[]; + /** Gesiegelte Zeilen, die nicht mehr existieren – Beweismaterial entfernt. */ + backlogMissing: number[]; + /** Anzahl gueltiger Siegel-Marker; >1 bedeutet erneutes Siegeln. */ + backlogSealCount: number; + /** Wurzel des aktuell gueltigen Siegels. */ + backlogSealRoot: string | null; }> { const where: Prisma.AuditLogWhereInput = {}; @@ -891,23 +937,39 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ // Der Marker muss selbst signiert und gueltig sein - sonst waere das Siegel // ueber denselben Weg faelschbar wie zuvor die Manifeste (R171-01). const backlogTampered: number[] = []; + const backlogMissing: number[] = []; let backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' = 'kein_siegel'; + let backlogSealRoot: string | null = null; const siegelKandidaten = await prisma.auditLog.findMany({ - where: { resourceType: 'AuditLog', action: 'CREATE', endpoint: BACKLOG_SEAL_ENDPOINT }, + where: { resourceType: BACKLOG_SEAL_RESOURCE, endpoint: BACKLOG_SEAL_ENDPOINT }, orderBy: { id: 'desc' }, }); const siegelSchluessel = [auditHmacKey(), ...auditHmacKeysOld()].filter( (k): k is string => !!k, ); - const siegel = siegelSchluessel.length - ? siegelKandidaten.find( + // Nur signierte Marker MIT auswertbarem Inhalt zaehlen. Der zweite Teil ist + // die Absicherung gegen Zeilen, die zufaellig dieselbe Signatur tragen, aber + // gar kein Siegel beschreiben (Pentest R173-01). + const hatSiegelInhalt = (r: { changesAfter: string | null }) => { + try { + const m = JSON.parse(r.changesAfter || '{}'); + return typeof m.toId === 'number' && typeof m.root === 'string' && m.root.length > 0; + } catch { + return false; + } + }; + const gueltigeSiegel = siegelSchluessel.length + ? siegelKandidaten.filter( (r) => erwarteteVersion(r.id) === 3 && r.hashVersion >= 3 && + hatSiegelInhalt(r) && siegelSchluessel.some((k) => r.hash === generateHashV3(r as unknown as AuditHashV2Input, k)), ) - : undefined; + : []; + const siegel = gueltigeSiegel[0]; + const backlogSealCount = gueltigeSiegel.length; if (siegel) { try { @@ -924,9 +986,15 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ for (const [id, blatt] of erwartet) { const zeile = vorhanden.get(id); - // Fehlende Zeile schlaegt bereits als Ketten-Luecke auf; hier zaehlt - // die inhaltliche Veraenderung. - if (!zeile) continue; + if (!zeile) { + // FEHLENDE gesiegelte Zeile ist ein Siegelbruch, keine blosse Luecke + // (Pentest R173-02). Vorher wurde hier uebersprungen: das Loeschen + // eines Einbruchsbelegs erschien nur als "unerklaerte Luecke" – + // ununterscheidbar von den als meist harmlos dokumentierten + // Race-Luecken –, waehrend der dedizierte Indikator "intakt" meldete. + backlogMissing.push(id); + continue; + } if (zeile.hashVersion >= 3 || backlogLeaf(zeile as unknown as Record) !== blatt) { backlogTampered.push(id); } @@ -935,17 +1003,16 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ // Wurzel gegenrechnen: faengt auch Zeilen ab, die dem Siegel // nachtraeglich hinzugefuegt oder daraus entfernt wurden. const wurzelJetzt = backlogRoot(blaetter); + backlogSealRoot = meta.root; backlogSealStatus = - backlogTampered.length === 0 && wurzelJetzt === meta.root ? 'intakt' : 'gebrochen'; - if (wurzelJetzt !== meta.root && backlogTampered.length === 0) { - // Blattbestand selbst veraendert, ohne dass eine Zeile abweicht. - backlogSealStatus = 'gebrochen'; - } + backlogTampered.length === 0 && backlogMissing.length === 0 && wurzelJetzt === meta.root + ? 'intakt' + : 'gebrochen'; } catch { backlogSealStatus = 'gebrochen'; } } - tamperedEntries.push(...backlogTampered); + tamperedEntries.push(...backlogTampered, ...backlogMissing); for (let i = 0; i < logs.length; i++) { const log = logs[i]; @@ -1065,6 +1132,9 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ unverifiableEntries, backlogSealStatus, backlogTampered, + backlogMissing, + backlogSealCount, + backlogSealRoot, }; } diff --git a/docs/todo.md b/docs/todo.md index 97de628b..4f8e7d1d 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -97,6 +97,42 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung ## ✅ Erledigt +- [x] **🔧 Bestandssiegel betriebstauglich gemacht (Pentest R173-01 HIGH, R173-02/-03 MEDIUM)** (2026-08-18) + - **R173-01 (HIGH) – Siegel war ueber HTTP tot.** Die generische + `auditMiddleware` protokolliert den `POST /seal-backlog`-Request SELBST als + `AuditLog / CREATE / endpoint=/api/audit-logs/seal-backlog` – exakt die + Signatur, mit der ich den Marker suchte, nur mit hoeherer id und ohne + `changesAfter`. Der Selektor griff diese Zeile, `root` war `undefined`, + Ergebnis: **dauerhaft „gebrochen“ bei null manipulierten Zeilen.** Das + komplette R171-02-Deliverable war damit im Betrieb wertlos. + **Mein Testfehler:** Ich hatte `sealBacklog()`/`verifyIntegrity()` direkt + aufgerufen, nie ueber HTTP – die Middleware lief nie mit. Dieselbe + Fehlerklasse wie R165 (Code korrekt, im Betrieb nicht erreichbar). + Fix: eigener Ressourcentyp `AuditBacklogSeal`, den die Middleware nie + vergibt (sie leitet ihn aus dem Pfad ab → `AuditLog`), zusaetzlich muss der + Marker auswertbares `{toId, root}` tragen. + - **R173-02 (MEDIUM) – Siegel war blind fuers Loeschen.** `if (!zeile) + continue` uebersprang fehlende Zeilen; das Loeschen eines gesiegelten + Einbruchsbelegs erschien nur als „unerklaerte Luecke“, waehrend der + dedizierte Indikator „intakt“ meldete – Entwarnung, waehrend Beweismaterial + verschwindet. Fix: fehlende gesiegelte Zeilen sind ein Siegelbruch mit + eigener Liste (`backlogMissing`) und werden namentlich gemeldet. Deckt + zugleich seine Frage (a) ab: geprueft wird jetzt die gesiegelte Menge + gegen die Live-Population, nicht nur der Inhalt vorhandener Zeilen. + - **R173-03 (MEDIUM) – Re-Seal wusch mit weniger Spur als ein Rehash.** Die + R170-01-Haertung (Vorbefund im Marker) war auf `seal-backlog` nie + angewandt. Fix: Der Siegel-Marker haelt jetzt den Befund VOR dem Siegeln + fest (manipulierte/fehlende Zeilen, Ketten-Luecken) samt Status und Wurzel + des vorherigen Siegels; `/verify` weist die Anzahl gueltiger Siegel aus und + weist bei >1 ausdruecklich darauf hin. + - Verifiziert **ueber den echten HTTP-Pfad inkl. Middleware** (separate + Wegwerf-DB, Mischbestand 6×V1/4×V2/4×V3): Middleware-Protokollzeile ist + vorhanden, Status trotzdem `intakt`; Loeschen einer gesiegelten Zeile → + `gebrochen`, `backlogMissing:[2]`, namentlich in der Meldung; erneutes + Siegeln → Anzahl 2 gemeldet, Marker enthaelt „vorheriges Siegel: gebrochen, + Befund fehlend [2]“. Wegwerf-DB geloescht, Dev unberuehrt. + `tsc` + `vite build` gruen. + - [x] **🧿 Bestandssiegel: Altbestand gegen stille Aenderung gesichert (Pentest R171-02, HIGH)** (2026-08-18) - Der Pentester hat den Fund live reproduziert: Bei Hash-Version 1 sind nur 7 von 24 Spalten gehasht. Ein DB-Schreibzugriff konnte eine