import { AuditAction, AuditSensitivity, Prisma } from '@prisma/client'; import crypto from 'crypto'; import { encrypt, decrypt } from '../utils/encryption.js'; import prisma from '../lib/prisma.js'; /** * Vereinfachte Audit-Log-Funktion für gezielte Änderungsprotokolle. * Wird direkt in Controllern aufgerufen mit aussagekräftigen Details. */ export async function logChange(opts: { req: any; // Express Request (für userId, email, IP) action: AuditAction; resourceType: string; resourceId?: string; label: string; // Menschenlesbares Label z.B. "Vollmacht für Stefan Hacker widerrufen" details?: Record; // Zusätzliche Details z.B. { vorher: 'erteilt', nachher: 'widerrufen' } customerId?: number; }) { try { const user = opts.req?.user; await createAuditLog({ userId: user?.userId, userEmail: user?.email || 'system', userRole: user?.isCustomerPortal ? 'Kundenportal' : 'Mitarbeiter', customerId: user?.customerId, isCustomerPortal: user?.isCustomerPortal, action: opts.action, resourceType: opts.resourceType, resourceId: opts.resourceId, resourceLabel: opts.label, endpoint: opts.req?.path || '', httpMethod: opts.req?.method || '', ipAddress: opts.req?.socket?.remoteAddress || opts.req?.headers?.['x-forwarded-for'] || 'unknown', dataSubjectId: opts.customerId, changesAfter: opts.details, }); } catch (error) { console.error('[logChange] Fehler:', error); } } export interface CreateAuditLogData { userId?: number; userEmail: string; userRole?: string; customerId?: number; isCustomerPortal?: boolean; action: AuditAction; sensitivity?: AuditSensitivity; resourceType: string; resourceId?: string; resourceLabel?: string; endpoint: string; httpMethod: string; ipAddress: string; userAgent?: string; changesBefore?: Record; changesAfter?: Record; dataSubjectId?: number; legalBasis?: string; success?: boolean; errorMessage?: string; durationMs?: number; } export interface AuditLogSearchParams { userId?: number; customerId?: number; dataSubjectId?: number; action?: AuditAction; sensitivity?: AuditSensitivity; resourceType?: string; resourceId?: string; startDate?: Date; endDate?: Date; success?: boolean; search?: string; page?: number; limit?: number; } /** * Generiert einen SHA-256 Hash für einen Audit-Log-Eintrag */ function generateHash(data: { userEmail: string; action: AuditAction; resourceType: string; resourceId?: string | null; endpoint: string; createdAt: Date; previousHash?: string | null; }): string { // WICHTIG – Pentest R121 (Verify-Fehlalarm, ~73% "manipuliert"): // Beim Schreiben war `resourceId` bei middleware-generierten Einträgen // oft `undefined`. JSON.stringify LÄSST einen undefined-Wert weg – der // Hash wurde also OHNE `resourceId`-Key gebildet. In der DB landet der // Wert aber als `NULL`. verifyIntegrity/rehashAll lasen ihn als `null` // zurück, und `JSON.stringify({resourceId: null})` schreibt // `"resourceId":null` REIN → anderer Hash → falscher Tamper-Alarm für // jede Zeile mit leerem resourceId. // // Fix: nullish resourceId weglassen – reproduziert exakt das historische // Schreibverhalten (undefined → Key weg). Kein Caller hat je `null` // explizit übergeben (verifiziert), daher matchen ALLE Bestands-Hashes // ohne Rehash. Feldreihenfolge bleibt identisch zur Alt-Serialisierung. const payload: Record = { userEmail: data.userEmail, action: data.action, resourceType: data.resourceType, }; if (data.resourceId !== null && data.resourceId !== undefined) { payload.resourceId = data.resourceId; } payload.endpoint = data.endpoint; payload.createdAt = data.createdAt.toISOString(); payload.previousHash = data.previousHash || ''; return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex'); } /** * Historische Hash-Variante (Bestandsdaten ~08.02.–01.05.2026). * * Der R121-Fix ging davon aus, dass `resourceId` beim Schreiben immer * `undefined` war (→ Key faellt bei JSON.stringify weg) und daher ALLE * Bestands-Hashes ohne Rehash matchen. Das stimmt erst ab ~01.05.2026: * aeltere Zeilen wurden mit explizitem `null` serialisiert, der Key war also * DRIN. Ergebnis war ein Dauer-Fehlalarm ueber ~3100 Zeilen (67 % des Logs) – * und ein Alarm, der staendig grundlos ausloest, verdeckt echte Manipulation. * * Diese Funktion reproduziert exakt das alte Schreibverhalten, damit * verifyIntegrity Altbestand als gueltig erkennt – OHNE Rehash. Ein Rehash * waere der naheliegende Schnellfix, wuerde die Manipulations-Beweiskraft der * Vergangenheit aber unwiederbringlich zerstoeren. * * Sicherheit: Beide Varianten hashen dieselben Feldwerte, nur die * Serialisierung des nullish `resourceId` unterscheidet sich. Ein Angreifer * gewinnt dadurch keinen Spielraum, Inhalte zu aendern – nur die Kodierung * eines leeren Feldes ist doppelt zulaessig. */ function generateHashLegacy(data: { userEmail: string; action: AuditAction; resourceType: string; resourceId?: string | null; endpoint: string; createdAt: Date; previousHash?: string | null; }): string { const payload: Record = { userEmail: data.userEmail, action: data.action, resourceType: data.resourceType, resourceId: data.resourceId ?? null, endpoint: data.endpoint, createdAt: data.createdAt.toISOString(), previousHash: data.previousHash || '', }; return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex'); } /** * Hash-Version 2 (Pentest R166-02): deckt ALLE Inhaltsspalten ab. * * Version 1 hashte nur 7 Felder (userEmail, action, resourceType, resourceId, * endpoint, createdAt, previousHash). Nicht abgedeckt waren u. a. * `changesBefore`/`changesAfter` – also die eigentliche Nutzlast –, dazu * `success`, `ipAddress`, `resourceLabel`, `dataSubjectId`, `userId`, * `customerId`. Ein nachtraeglicher Einzeledit an genau diesen Feldern * (z. B. `success` false→true oder das Umschreiben von `changesAfter`) war * damit unsichtbar – exakt der Angriff, gegen den die Kette schuetzen soll. * * Bestandszeilen behalten `hashVersion = 1` und werden weiter mit dem alten * Verfahren geprueft; ein Rehash waere nicht noetig und wuerde die * Beweiskraft der Vergangenheit zerstoeren. * * `undefined` wird bewusst zu `null` normalisiert, damit die Serialisierung * deterministisch ist (die Uneindeutigkeit an dieser Stelle war die Ursache * des Dauer-Fehlalarms bei Version 1). */ export interface AuditHashV2Input { userId?: number | null; userEmail: string; userRole?: string | null; customerId?: number | null; isCustomerPortal?: boolean | null; action: AuditAction; sensitivity?: AuditSensitivity | null; resourceType: string; resourceId?: string | null; resourceLabel?: string | null; endpoint: string; httpMethod?: string | null; ipAddress?: string | null; userAgent?: string | null; changesBefore?: string | null; changesAfter?: string | null; changesEncrypted?: boolean | null; dataSubjectId?: number | null; legalBasis?: string | null; success?: boolean | null; errorMessage?: string | null; durationMs?: number | null; createdAt: Date; previousHash?: string | null; } /** * Externer Anker (Pentest R166/R167, Design-Empfehlung). * * Bis Version 2 war die Kette selbsttragend: Wer die DB schreiben kann, kann * jede Zeile aendern und saemtliche Folgehashes konsistent nachziehen – die * Pruefung meldet dann sauber "gueltig". Version 3 signiert denselben Inhalt * stattdessen per HMAC-SHA256 mit einem Schluessel, der NICHT in der Datenbank * liegt (`AUDIT_HMAC_KEY`). Ohne diesen Schluessel laesst sich keine gueltige * Signatur erzeugen – reiner DB-Schreibzugriff genuegt damit nicht mehr. * * Fail-safe: Ohne konfigurierten Schluessel wird weiterhin Version 2 * geschrieben. Audit-Logging darf nie an fehlender Konfiguration scheitern. * `AUDIT_HMAC_KEY_OLD` erlaubt einen Schluesselwechsel ohne Rehash – bei der * Pruefung wird zusaetzlich gegen den alten Schluessel getestet. */ /** * Ein nicht ersetzter Platzhalter aus `.env.example` (z. B. * ``) darf NICHT als Schluessel gelten. Sonst * wuerde mit einem oeffentlich im Repository stehenden Wert gesiegelt – das * waere schlechter als gar kein Siegel, weil es Sicherheit vortaeuscht. */ function istPlatzhalter(k: string): boolean { return k.includes('<') || k.includes('>') || /^(change|dein|your|hier)/i.test(k); } let platzhalterGemeldet = false; function auditHmacKey(): string | null { const k = process.env.AUDIT_HMAC_KEY?.trim(); if (!k) return null; if (istPlatzhalter(k)) { if (!platzhalterGemeldet) { platzhalterGemeldet = true; console.warn( '[Audit] AUDIT_HMAC_KEY enthält noch den Platzhalter aus .env.example – ' + 'das Audit-Siegel bleibt deaktiviert. Eigenen Wert erzeugen: openssl rand -hex 32', ); } return null; } return k; } /** * Frueher verwendete Schluessel. Kommagetrennt, damit auch ein ZWEITER Wechsel * moeglich ist, ohne die zuerst signierten Eintraege zu verlieren - mit nur * einem Platz wuerden die aeltesten sonst als "manipuliert" gemeldet. */ function auditHmacKeysOld(): string[] { return (process.env.AUDIT_HMAC_KEY_OLD || '') .split(',') .map((k) => k.trim()) .filter((k) => k.length > 0 && !istPlatzhalter(k)); } function generateHashV3(data: AuditHashV2Input, key: string): string { return crypto.createHmac('sha256', key) .update(JSON.stringify(buildContentPayload(data, 3))) .digest('hex'); } function generateHashV2(data: AuditHashV2Input): string { return crypto.createHash('sha256') .update(JSON.stringify(buildContentPayload(data, 2))) .digest('hex'); } /** Gemeinsamer Inhalts-Payload fuer Version 2 (SHA-256) und 3 (HMAC). */ function buildContentPayload(data: AuditHashV2Input, version: 2 | 3): Record { const n = (v: T | null | undefined): T | null => (v === undefined ? null : v); // Feldreihenfolge ist Teil des Hashes und darf nicht veraendert werden. return { v: version, userId: n(data.userId), userEmail: data.userEmail, userRole: n(data.userRole), customerId: n(data.customerId), isCustomerPortal: n(data.isCustomerPortal) ?? false, action: data.action, sensitivity: n(data.sensitivity), resourceType: data.resourceType, resourceId: n(data.resourceId), resourceLabel: n(data.resourceLabel), endpoint: data.endpoint, httpMethod: n(data.httpMethod), ipAddress: n(data.ipAddress), userAgent: n(data.userAgent), changesBefore: n(data.changesBefore), changesAfter: n(data.changesAfter), changesEncrypted: n(data.changesEncrypted) ?? false, dataSubjectId: n(data.dataSubjectId), legalBasis: n(data.legalBasis), success: n(data.success) ?? true, errorMessage: n(data.errorMessage), durationMs: n(data.durationMs), createdAt: data.createdAt.toISOString(), previousHash: data.previousHash || '', }; } /** * Bestimmt die Sensitivität basierend auf dem Ressourcentyp */ function determineSensitivity(resourceType: string): AuditSensitivity { const sensitivityMap: Record = { // CRITICAL Authentication: 'CRITICAL', BankCard: 'CRITICAL', IdentityDocument: 'CRITICAL', // Klartext-Passwort-Reads – jeder Decrypt-Vorgang muss nachvollziehbar sein PortalPassword: 'CRITICAL', ContractPassword: 'CRITICAL', SimCardCredentials: 'CRITICAL', InternetCredentials: 'CRITICAL', SipCredentials: 'CRITICAL', MailboxCredentials: 'CRITICAL', // HIGH Customer: 'HIGH', User: 'HIGH', CustomerConsent: 'HIGH', DataDeletionRequest: 'HIGH', // Abgewehrter IDOR-/Cross-Boundary-Zugriffsversuch (canAccess*-403). // HIGH, weil ein Treffer auf gezielte Fremddaten-Enumeration hindeutet. AccessDenied: 'HIGH', // MEDIUM Contract: 'MEDIUM', Address: 'MEDIUM', Meter: 'MEDIUM', MeterReading: 'MEDIUM', StressfreiEmail: 'MEDIUM', CachedEmail: 'MEDIUM', // LOW Provider: 'LOW', Tariff: 'LOW', SalesPlatform: 'LOW', AppSetting: 'LOW', ContractCategory: 'LOW', CancellationPeriod: 'LOW', ContractDuration: 'LOW', }; return sensitivityMap[resourceType] || 'MEDIUM'; } /** * Prüft ob Änderungen verschlüsselt werden sollen. * Deaktiviert - sensible Felder werden bereits von der Prisma-Middleware als [REDACTED] gefiltert. */ function shouldEncryptChanges(_resourceType: string): boolean { return false; } /** * Erstellt einen neuen Audit-Log-Eintrag mit Hash-Kette */ export async function createAuditLog(data: CreateAuditLogData): Promise { try { // Sensitivität bestimmen falls nicht angegeben const sensitivity = data.sensitivity || determineSensitivity(data.resourceType); // Änderungen serialisieren und ggf. verschlüsseln let changesBefore: string | null = null; let changesAfter: string | null = null; let changesEncrypted = false; if (data.changesBefore || data.changesAfter) { changesEncrypted = shouldEncryptChanges(data.resourceType); if (data.changesBefore) { const json = JSON.stringify(data.changesBefore); changesBefore = changesEncrypted ? encrypt(json) : json; } if (data.changesAfter) { const json = JSON.stringify(data.changesAfter); changesAfter = changesEncrypted ? encrypt(json) : json; } } // Kette atomar fortschreiben: Vorgaenger-Hash lesen UND neuen Eintrag // schreiben muessen eine Einheit sein. Vorher lagen beide Schritte offen // nebeneinander – zwei parallele Requests lasen denselben letzten Hash und // haengten sich beide daran, was die Kette zerriss (echte Bruchstellen im // Bestand, u. a. 05.05./07.05.2026). // // Serialisiert wird ueber eine Zeilensperre auf dem Einzeiler-Mutex // `AuditChainLock`. Zwei Alternativen wurden verworfen: // - `SELECT … FOR UPDATE` am Kettenende von AuditLog nimmt Gap-/Next-Key- // Locks, die mit den gleichzeitigen INSERTs kollidieren – gemessen gingen // 38 von 40 parallelen Eintraegen durch Deadlocks verloren. Ein FEHLENDER // Audit-Eintrag ist unsichtbar und damit schlimmer als ein sichtbarer // Kettenbruch. // - GET_LOCK muss auf derselben Verbindung freigegeben werden, das Release // fiel damit VOR den COMMIT. In diesem Fenster las der naechste Schreiber // ein noch nicht sichtbares Kettenende und hing sich an denselben // Vorgaenger (Fork, Pentest R166-01). // Die Zeilensperre faellt erst beim COMMIT und liegt in der DB – wirkt also // auch ueber mehrere App-Instanzen hinweg. // Alles Rechenintensive (Serialisieren/Verschluesseln) passiert bewusst // VOR der Transaktion, damit die Sperre so kurz wie moeglich gehalten wird. await prisma.$transaction(async (tx) => { // Exklusive Zeilensperre auf den Einzeiler-Mutex. Sie faellt erst beim // COMMIT – dadurch sieht der naechste Schreiber die Vorgaengerzeile // garantiert bereits festgeschrieben (Pentest R166-01). await tx.$queryRaw`SELECT id FROM AuditChainLock WHERE id = 1 FOR UPDATE`; const lastRows = await tx.$queryRaw>` SELECT hash FROM AuditLog ORDER BY id DESC LIMIT 1 `; const previousHash = lastRows[0]?.hash || null; const createdAt = new Date(); // Version 3 (HMAC-signiert) sobald ein Schluessel konfiguriert ist, // sonst Version 2 – Audit-Logging darf an fehlender Konfiguration nicht // scheitern (fail-safe, siehe generateHashV3). const hmacKey = auditHmacKey(); const inhalt = { userId: data.userId, userEmail: data.userEmail, userRole: data.userRole, customerId: data.customerId, isCustomerPortal: data.isCustomerPortal || false, action: data.action, sensitivity, resourceType: data.resourceType, resourceId: data.resourceId, resourceLabel: data.resourceLabel, endpoint: data.endpoint, httpMethod: data.httpMethod, ipAddress: data.ipAddress, userAgent: data.userAgent, changesBefore, changesAfter, changesEncrypted, dataSubjectId: data.dataSubjectId, legalBasis: data.legalBasis, success: data.success ?? true, errorMessage: data.errorMessage, durationMs: data.durationMs, createdAt, previousHash, }; const hash = hmacKey ? generateHashV3(inhalt, hmacKey) : generateHashV2(inhalt); await tx.auditLog.create({ data: { userId: data.userId, userEmail: data.userEmail, userRole: data.userRole, customerId: data.customerId, isCustomerPortal: data.isCustomerPortal || false, action: data.action, sensitivity, resourceType: data.resourceType, resourceId: data.resourceId, resourceLabel: data.resourceLabel, endpoint: data.endpoint, httpMethod: data.httpMethod, ipAddress: data.ipAddress, userAgent: data.userAgent, changesBefore, changesAfter, changesEncrypted, dataSubjectId: data.dataSubjectId, legalBasis: data.legalBasis, success: data.success ?? true, errorMessage: data.errorMessage, durationMs: data.durationMs, createdAt, hash, previousHash, hashVersion: hmacKey ? 3 : 2, }, }); }, { // READ COMMITTED: der Lesevorgang nach der Sperre muss den gerade // festgeschriebenen Stand sehen. Unter REPEATABLE READ koennte ein // Snapshot greifen, der die Vorgaengerzeile noch nicht enthaelt. isolationLevel: 'ReadCommitted', timeout: 20000, maxWait: 15000, }); } catch (error) { // Audit-Logging darf niemals die Hauptoperation blockieren console.error('[AuditService] Fehler beim Erstellen des Audit-Logs:', error); } } /** * Sucht Audit-Logs mit Filtern und Paginierung */ export async function searchAuditLogs(params: AuditLogSearchParams) { const { userId, customerId, dataSubjectId, action, sensitivity, resourceType, resourceId, startDate, endDate, success, search, page = 1, limit = 50, } = params; const where: Prisma.AuditLogWhereInput = {}; if (userId !== undefined) where.userId = userId; if (customerId !== undefined) where.customerId = customerId; if (dataSubjectId !== undefined) where.dataSubjectId = dataSubjectId; if (action) where.action = action; if (sensitivity) where.sensitivity = sensitivity; if (resourceType) where.resourceType = resourceType; if (resourceId) where.resourceId = resourceId; if (success !== undefined) where.success = success; if (startDate || endDate) { where.createdAt = {}; if (startDate) where.createdAt.gte = startDate; if (endDate) where.createdAt.lte = endDate; } if (search) { where.OR = [ { userEmail: { contains: search } }, { resourceLabel: { contains: search } }, { endpoint: { contains: search } }, ]; } const [logs, total] = await Promise.all([ prisma.auditLog.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page - 1) * limit, take: limit, }), prisma.auditLog.count({ where }), ]); // Entschlüsselung der Änderungen wenn nötig const decryptedLogs = logs.map((log) => ({ ...log, changesBefore: log.changesBefore && log.changesEncrypted ? JSON.parse(decrypt(log.changesBefore)) : log.changesBefore ? JSON.parse(log.changesBefore) : null, changesAfter: log.changesAfter && log.changesEncrypted ? JSON.parse(decrypt(log.changesAfter)) : log.changesAfter ? JSON.parse(log.changesAfter) : null, })); return { data: decryptedLogs, pagination: { page, limit, total, totalPages: Math.ceil(total / limit), }, }; } /** * Holt einen einzelnen Audit-Log-Eintrag */ export async function getAuditLogById(id: number) { const log = await prisma.auditLog.findUnique({ where: { id }, }); if (!log) return null; return { ...log, changesBefore: log.changesBefore && log.changesEncrypted ? JSON.parse(decrypt(log.changesBefore)) : log.changesBefore ? JSON.parse(log.changesBefore) : null, changesAfter: log.changesAfter && log.changesEncrypted ? JSON.parse(decrypt(log.changesAfter)) : log.changesAfter ? JSON.parse(log.changesAfter) : null, }; } /** * Holt alle Audit-Logs für eine betroffene Person (DSGVO) */ export async function getAuditLogsByDataSubject(customerId: number) { const logs = await prisma.auditLog.findMany({ where: { dataSubjectId: customerId }, orderBy: { createdAt: 'desc' }, }); return logs.map((log) => ({ ...log, changesBefore: log.changesBefore && log.changesEncrypted ? JSON.parse(decrypt(log.changesBefore)) : log.changesBefore ? JSON.parse(log.changesBefore) : null, changesAfter: log.changesAfter && log.changesEncrypted ? JSON.parse(decrypt(log.changesAfter)) : log.changesAfter ? JSON.parse(log.changesAfter) : null, })); } /** * Verifiziert die Integrität der Hash-Kette */ /** * Blattwert einer Altbestands-Zeile fuer das Bestandssiegel (Pentest R171-02). * * Bewusst ueber den VOLLEN Zeileninhalt inkl. `id` und `hashVersion` – nicht * ueber den gespeicherten 7-Feld-Hash der Version 1. Sonst lebte die Luecke im * Siegel weiter: genau die 17 ungehashten Spalten waeren wieder frei * editierbar. `hashVersion` gehoert mit hinein, damit ein Hochstufen der Zeile * (V1→V2, ohne Schluessel nachrechenbar) das Blatt bricht, statt sie klammheimlich * aus dem Siegelbereich zu schieben. */ function backlogLeaf(row: Record): string { const feld = (v: unknown) => v instanceof Date ? v.toISOString() : v === undefined ? null : v; // Feste Reihenfolge – Teil des Blattwerts. const payload = [ 'id', 'userId', 'userEmail', 'userRole', 'customerId', 'isCustomerPortal', 'action', 'sensitivity', 'resourceType', 'resourceId', 'resourceLabel', 'endpoint', 'httpMethod', 'ipAddress', 'userAgent', 'changesBefore', 'changesAfter', 'changesEncrypted', 'dataSubjectId', 'legalBasis', 'success', 'errorMessage', 'durationMs', 'createdAt', 'hash', 'previousHash', 'hashVersion', ].map((k) => [k, feld(row[k])]); return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex'); } /** Wurzel ueber die Blaetter in id-Reihenfolge. */ function backlogRoot(leaves: Array<{ auditLogId: number; leafHash: string }>): string { const sortiert = [...leaves].sort((a, b) => a.auditLogId - b.auditLogId); return crypto .createHash('sha256') .update(JSON.stringify(sortiert.map((l) => [l.auditLogId, l.leafHash]))) .digest('hex'); } /** 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. * * Der Bereich ist FIX auf `[1 … v3FromId-1]` gebunden, nicht auf eine * Live-Abfrage `hashVersion < 3`. Sonst koennte ein Angreifer die Grenzzeile * hochstufen und sie damit aus der geprueften Menge draengen – sie erschiene * dann nur als harmlose Luecke. Zeilen ab `v3FromId` deckt die Versionsgrenze * ab (sie MUESSEN V3 sein), es bleibt also keine Naht dazwischen. */ /** * Kontrollwert fuer die externe Notarisierung (Gegenbuch). * * Alle bisherigen Anker leben IM System: Hash, Versionsgrenze, Manifest, * Bestandssiegel, Siegel-Marker. Wer Schreibzugriff auf die Datenbank hat, * sitzt am Ende immer schon auf der Ebene, die beweisen soll – zuletzt gezeigt * in R174-01. Ein Gegenbuch an einem fremden Ort durchbricht das. * * Der Wert enthaelt bewusst `maxId`: Ein blosser Kopf-Hash erkennt zwar * Umschreiben, aber kein ABSCHNEIDEN am Ende. Mit ankertem `maxId` ist * „juengster beglaubigter maxId > aktueller maxId“ der Beweis, dass Zeilen * verschwunden sind. * * `atId` erlaubt der Gegenstelle, einen FRUEHER beglaubigten Kopf erneut * abzufragen und zu reproduzieren – ohne das koennte sie nur den aktuellen * Stand sehen und nichts nachrechnen. * * Enthaelt keine Geheimnisse: nur Hashes und Zaehlstaende. */ export async function getCheckpoint(atId?: number): Promise<{ ts: string; maxId: number | null; chainHead: string | null; sealRoot: string | null; sealLeafCount: number; atId?: number; atHash?: string | null; }> { const kopf = await prisma.auditLog.findFirst({ orderBy: { id: 'desc' }, select: { id: true, hash: true }, }); // BEWUSST KEIN verifyIntegrity() hier (Pentest R175-02): das laeuft ueber // alle Zeilen und kostet bei grossen Logs Sekunden je Aufruf – ein // authentifizierter Leser koennte damit die Datenbank in die Knie zwingen. // Gebraucht wird von der Vollpruefung ohnehin nur die Siegel-Wurzel, und die // steht im juengsten gueltigen Marker. Der Kopf-Hash kommt direkt aus der // Kopfzeile. const c3 = await prisma.auditLog.aggregate({ where: { hashVersion: { gte: 3 } }, _min: { id: true }, }); const v3FromId = c3._min.id; const schluessel = [auditHmacKey(), ...auditHmacKeysOld()].filter( (k): k is string => !!k, ); const markerKandidaten = await prisma.auditLog.findMany({ where: { resourceType: BACKLOG_SEAL_RESOURCE, endpoint: BACKLOG_SEAL_ENDPOINT }, orderBy: { id: 'desc' }, take: 20, }); let sealRoot: string | null = null; if (schluessel.length && v3FromId !== null) { for (const r of markerKandidaten) { if (r.id < v3FromId || r.hashVersion < 3) continue; if (!schluessel.some((k) => r.hash === generateHashV3(r as unknown as AuditHashV2Input, k))) continue; try { const m = JSON.parse(r.changesAfter || '{}'); if (typeof m.root === 'string' && m.root.length > 0) { sealRoot = m.root; break; } } catch { /* naechster Kandidat */ } } } const blattAnzahl = await prisma.auditBacklogSeal.count(); const ergebnis: { ts: string; maxId: number | null; chainHead: string | null; sealRoot: string | null; sealLeafCount: number; atId?: number; atHash?: string | null; } = { ts: new Date().toISOString(), maxId: kopf?.id ?? null, chainHead: kopf?.hash ?? null, sealRoot, // Reicht der Gegenstelle, um das Verschwinden des Siegels zu bemerken: // Blaetter ohne Wurzel = Marker entfernt. sealLeafCount: blattAnzahl, }; if (atId !== undefined && Number.isFinite(atId)) { const zeile = await prisma.auditLog.findUnique({ where: { id: atId }, select: { hash: true }, }); ergebnis.atId = atId; // null bedeutet: die beglaubigte Zeile existiert nicht mehr. ergebnis.atHash = zeile?.hash ?? null; } return ergebnis; } export async function sealBacklog( ausgeloestVon?: { userEmail?: string; ipAddress?: string }, ): Promise<{ sealedCount: number; fromId: number; toId: number; root: string }> { if (!auditHmacKey()) { throw new Error( 'Bestandssiegel erfordert einen konfigurierten AUDIT_HMAC_KEY – ohne ihn liesse sich die Wurzel nachrechnen.', ); } const c3 = await prisma.auditLog.aggregate({ where: { hashVersion: { gte: 3 } }, _min: { id: true }, }); const v3FromId = c3._min.id; if (v3FromId === null) { throw new Error( 'Noch keine signierten Einträge vorhanden – bitte zuerst AUDIT_HMAC_KEY setzen und das Backend neu starten.', ); } const toId = v3FromId - 1; if (toId < 1) { 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' }, }); const leaves = rows.map((r) => ({ auditLogId: r.id, leafHash: backlogLeaf(r as unknown as Record), })); const root = backlogRoot(leaves); await prisma.$transaction(async (tx) => { await tx.auditBacklogSeal.deleteMany({}); for (let i = 0; i < leaves.length; i += 500) { await tx.auditBacklogSeal.createMany({ data: leaves.slice(i, i + 500) }); } }, { timeout: 60000, maxWait: 20000 }); // 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: 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, }); return { sealedCount: leaves.length, fromId: 1, toId, root }; } export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{ valid: boolean; checkedCount: number; /** Alle beanstandeten Zeilen (tampered + chainGaps) – Abwaertskompatibilitaet. */ invalidEntries: number[]; /** * ERNST: Der Inhalt der Zeile passt nicht mehr zu ihrem Hash – jemand hat * einen bestehenden Eintrag nachtraeglich veraendert. */ tamperedEntries: number[]; /** * MEIST HARMLOS: Der Inhalt stimmt, aber die Verkettung zur Vorgaengerzeile * passt nicht. Ursachen: parallel geschriebene Eintraege (bis zum Fix der * Race-Condition) oder geloeschte Zeilen (Retention-Cleanup). Kein Hinweis * 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[]; /** * HMAC-signierte Zeilen, die mangels konfiguriertem `AUDIT_HMAC_KEY` nicht * geprueft werden konnten. Bewusst KEIN Manipulationsverdacht – sonst waere * eine fehlende Konfiguration ein Fehlalarm ueber das gesamte Log. */ unverifiableEntries: number[]; /** * Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand. * `kein_siegel` = nie erstellt. `entfernt` = Blaetter vorhanden, aber kein * gueltiger Marker mehr – der Anker wurde herausgeloest (Pentest R174-01). */ backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt'; /** 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 = {}; if (fromId !== undefined) where.id = { gte: fromId }; if (toId !== undefined) where.id = { ...(where.id as object || {}), lte: toId }; const logs = await prisma.auditLog.findMany({ where, orderBy: { id: 'asc' }, // Version 2 hasht alle Inhaltsspalten – daher vollstaendig laden. select: { id: true, userId: true, userEmail: true, userRole: true, customerId: true, isCustomerPortal: true, action: true, sensitivity: true, resourceType: true, resourceId: true, resourceLabel: true, endpoint: true, httpMethod: true, ipAddress: true, userAgent: true, changesBefore: true, changesAfter: true, changesEncrypted: true, dataSubjectId: true, legalBasis: true, success: true, errorMessage: true, durationMs: true, createdAt: true, hash: true, previousHash: true, hashVersion: true, }, }); // --------------------------------------------------------------------- // 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. // Grenze pro Stufe: ab der ersten je mit Version >= v geschriebenen Zeile // muss jede weitere Zeile mindestens Version v haben. Verallgemeinert auf // Version 3 (HMAC-Anker), damit auch ein Ruecksprung 3→2 auffliegt. const [c2, c3] = await Promise.all([ prisma.auditLog.aggregate({ where: { hashVersion: { gte: 2 } }, _min: { id: true } }), prisma.auditLog.aggregate({ where: { hashVersion: { gte: 3 } }, _min: { id: true } }), ]); const v2FromId = c2._min.id; const v3FromId = c3._min.id; const erwarteteVersion = (id: number): 1 | 2 | 3 => { if (v3FromId !== null && id >= v3FromId) return 3; if (v2FromId !== null && id >= v2FromId) return 2; return 1; }; // Dokumentierte Loeschungen einlesen – NUR aus signierten, selbst gueltigen // Traegerzeilen (Pentest R171-01). // // Vorher wurde jedem Manifest bedingungslos geglaubt. Das Manifest steht in // `changesAfter`, und dieses Feld ist erst ab Hash-Version 2 mitgehasht – auf // V1-Altzeilen also voellig unauthentifiziert. Ein Angreifer konnte daher in // eine BELIEBIGE V1-Zeile ein erfundenes Manifest schreiben, ohne deren Hash // zu veraendern, und damit eigene Loeschungen und Luecken als "erklaert" // ausweisen. Das hebelte zugleich die Eskalation an signierten Zeilen aus – // also Anker UND Versionsgrenze. // // Deshalb zaehlt ein Manifest nur, wenn seine Traegerzeile // 1. laut Versionsgrenze Stufe 3 sein MUSS und dies auch deklariert, und // 2. ihre HMAC-Signatur mit einem konfigurierten Schluessel aufgeht. // Ohne Schluessel gibt es keine gueltigen Traeger – dann bleiben Luecken // unerklaert, was die sichere Richtung ist. const deletionRanges: Array<{ from: number; to: number }> = []; const manifestKandidaten = await prisma.auditLog.findMany({ where: { resourceType: 'AuditLog', action: 'DELETE', endpoint: '/api/audit-logs/cleanup' }, }); const manifestSchluessel = [auditHmacKey(), ...auditHmacKeysOld()].filter( (k): k is string => !!k, ); for (const row of manifestKandidaten) { if (erwarteteVersion(row.id) !== 3 || row.hashVersion < 3) continue; if (manifestSchluessel.length === 0) continue; const signaturOk = manifestSchluessel.some( (k) => row.hash === generateHashV3(row as unknown as AuditHashV2Input, k), ); if (!signaturOk) continue; 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[] = []; const unverifiableEntries: number[] = []; // --------------------------------------------------------------------- // Bestandssiegel pruefen (Pentest R171-02) // // Der Altbestand ist nicht signierbar: bei Version 1 sind nur 7 von 24 // Spalten gehasht. Wurde er versiegelt, wird hier je Zeile geprueft: // vorhanden, immer noch Altbestand (hashVersion < 3) und Inhalt unveraendert. // 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' | 'entfernt' = 'kein_siegel'; let backlogSealRoot: string | null = null; const siegelKandidaten = await prisma.auditLog.findMany({ where: { resourceType: BACKLOG_SEAL_RESOURCE, endpoint: BACKLOG_SEAL_ENDPOINT }, orderBy: { id: 'desc' }, }); const siegelSchluessel = [auditHmacKey(), ...auditHmacKeysOld()].filter( (k): k is string => !!k, ); // 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)), ) : []; const siegel = gueltigeSiegel[0]; const backlogSealCount = gueltigeSiegel.length; // Gegen-Check (Pentest R174-01): Der Siegelzustand hing ausschliesslich am // Marker – und den kann ein DB-Schreiber OHNE Schluessel loeschen. Danach // meldete die Pruefung `kein_siegel`, also Entwarnung, ununterscheidbar von // „nie versiegelt“. Steht der Marker am Ketten-Ende (genau der Zustand // direkt nach dem einmaligen Siegeln beim Deploy), riss dabei nicht einmal // eine Luecke: das Ergebnis war `valid: true` trotz gefaelschter gesiegelter // Zeile. Die Blaetter blieben verwaist zurueck und wurden nie konsultiert. // // Deshalb gilt jetzt: Blaetter vorhanden, aber kein gueltiger Marker = // Siegel ENTFERNT und damit ein Befund – nicht „nie versiegelt“. const blattAnzahl = await prisma.auditBacklogSeal.count(); if (!siegel && blattAnzahl > 0 && siegelSchluessel.length > 0) { backlogSealStatus = 'entfernt'; } if (siegel) { try { const meta = JSON.parse(siegel.changesAfter || '{}'); const bis: number = meta.toId; const blaetter = await prisma.auditBacklogSeal.findMany(); const erwartet = new Map(blaetter.map((b) => [b.auditLogId, b.leafHash])); const altzeilen = await prisma.auditLog.findMany({ where: { id: { lte: bis } }, orderBy: { id: 'asc' }, }); const vorhanden = new Map(altzeilen.map((r) => [r.id, r])); for (const [id, blatt] of erwartet) { const zeile = vorhanden.get(id); 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); } } // 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 && backlogMissing.length === 0 && wurzelJetzt === meta.root ? 'intakt' : 'gebrochen'; } catch { backlogSealStatus = 'gebrochen'; } } tamperedEntries.push(...backlogTampered, ...backlogMissing); for (let i = 0; i < logs.length; i++) { const log = logs[i]; // Erwartete Pruefstaerke aus der Kette, NICHT aus der Selbstauskunft. const erwartet = erwarteteVersion(log.id); if (log.hashVersion !== erwartet) { // Deklarierte Version passt nicht zur Position in der Kette → // Downgrade-Versuch (oder manipulierte Version). tamperedEntries.push(log.id); continue; } // Pruefverfahren richtet sich nach der erwarteten Version: // 3 = HMAC-signiert (Schluessel ausserhalb der DB) // 2 = SHA-256 ueber alle Inhaltsspalten // 1 = SHA-256 ueber 7 Felder, zusaetzlich historische Serialisierung let hashOk: boolean; if (erwartet >= 2) { const inhalt = { userId: log.userId, userEmail: log.userEmail, userRole: log.userRole, customerId: log.customerId, isCustomerPortal: log.isCustomerPortal, action: log.action, sensitivity: log.sensitivity, resourceType: log.resourceType, resourceId: log.resourceId, resourceLabel: log.resourceLabel, endpoint: log.endpoint, httpMethod: log.httpMethod, ipAddress: log.ipAddress, userAgent: log.userAgent, changesBefore: log.changesBefore, changesAfter: log.changesAfter, changesEncrypted: log.changesEncrypted, dataSubjectId: log.dataSubjectId, legalBasis: log.legalBasis, success: log.success, errorMessage: log.errorMessage, durationMs: log.durationMs, createdAt: log.createdAt, previousHash: log.previousHash, }; if (erwartet === 3) { // HMAC-signiert: ohne JEDEN Schluessel ist die Zeile nicht pruefbar. // Das als "manipuliert" zu melden waere ein Fehlalarm – daher eigener // Topf. Ein FALSCHER Schluessel ist dagegen nicht von einer Faelschung // zu unterscheiden und wird bewusst als Befund gemeldet. const kandidaten = [auditHmacKey(), ...auditHmacKeysOld()].filter( (k): k is string => !!k, ); if (kandidaten.length === 0) { unverifiableEntries.push(log.id); continue; } hashOk = kandidaten.some((k) => log.hash === generateHashV3(inhalt, k)); } else { hashOk = log.hash === generateHashV2(inhalt); } } else { const v1 = { userEmail: log.userEmail, action: log.action, resourceType: log.resourceType, resourceId: log.resourceId, endpoint: log.endpoint, createdAt: log.createdAt, previousHash: log.previousHash, }; hashOk = log.hash === generateHash(v1) || log.hash === generateHashLegacy(v1); } if (!hashOk) { tamperedEntries.push(log.id); continue; } // Prüfen ob previousHash mit dem Hash des vorherigen Eintrags übereinstimmt if (i > 0) { const previousLog = logs[i - 1]; if (log.previousHash !== previousLog.hash) { chainGaps.push(log.id); if (!gapErklaert(previousLog.id, log.id)) { unexplainedGaps.push(log.id); // Ist die AKTUELLE Zeile HMAC-signiert, laesst sich ihr // `previousHash` ohne Schluessel nicht nachziehen. Eine unerklaerte // Luecke davor bedeutet dann zwingend: der Vorgaenger wurde // veraendert oder entfernt. Das ist kein struktureller Zufall, // sondern ein Befund. // // Dieser Pfad schliesst zugleich die Restluecke der Versionsgrenze: // Wird die FRUEHESTE V3-Zeile herabgestuft, wandert `MIN(id)` mit und // die Grenze allein wuerde den Downgrade durchwinken – der Nachfolger // erzeugt aber eine nicht faelschbare Luecke. if (erwartet === 3) { tamperedEntries.push(log.id); } } } } } // Eskalierte Luecken stehen sowohl in tamperedEntries als auch in chainGaps; // ohne Entdopplung zaehlte dieselbe Zeile zweimal (Pentest R171-03). const invalidEntries = [...new Set([...tamperedEntries, ...chainGaps])].sort((a, b) => a - b); // Ein gebrochenes oder entferntes Siegel muss `valid` kippen, auch wenn keine // einzelne Zeile beanstandet ist – sonst bliebe der stille Anker-Verlust // unsichtbar (R174-01). const siegelInOrdnung = backlogSealStatus === 'intakt' || backlogSealStatus === 'kein_siegel'; return { valid: invalidEntries.length === 0 && siegelInOrdnung, checkedCount: logs.length, invalidEntries, tamperedEntries, chainGaps, unexplainedGaps, unverifiableEntries, backlogSealStatus, backlogTampered, backlogMissing, backlogSealCount, backlogSealRoot, }; } /** * Hash-Kette komplett neu berechnen (Reparatur) */ export async function rehashAll( ausgeloestVon?: { userEmail?: string; ipAddress?: string }, ): Promise<{ rehashedCount: number }> { // ZUSTAND VOR DEM REHASH SICHERN (Pentest R170-01) // // Ein Rehash macht die Kette rechnerisch wieder stimmig – auch dann, wenn sie // vorher berechtigte Beanstandungen enthielt. Der bisherige Marker hielt nur // fest, DASS rehasht wurde, nicht WAS dabei verschwand. Wer `audit:admin` // besitzt, konnte damit Spuren glattziehen, ohne dass hinterher erkennbar // war, welche. // // Deshalb wird der Befund samt Kettenkopf VOR dem Rehash erhoben und im // Marker mitgeschrieben. Der Marker entsteht nach dem Rehash, ist selbst // Teil der neuen Kette und signiert; entfernen liesse er sich nur unter // Hinterlassung einer Luecke. const vorher = await verifyIntegrity(); const kopf = await prisma.auditLog.findFirst({ orderBy: { id: 'desc' }, select: { id: true, hash: true, hashVersion: true, createdAt: true }, }); const logs = await prisma.auditLog.findMany({ orderBy: { id: 'asc' }, select: { id: true, userId: true, userEmail: true, userRole: true, customerId: true, isCustomerPortal: true, action: true, sensitivity: true, resourceType: true, resourceId: true, resourceLabel: true, endpoint: true, httpMethod: true, ipAddress: true, userAgent: true, changesBefore: true, changesAfter: true, changesEncrypted: true, dataSubjectId: true, legalBasis: true, success: true, errorMessage: true, durationMs: true, createdAt: true, }, }); const rehashKey = auditHmacKey(); let previousHash: string | null = null; let count = 0; for (const log of logs) { // Rehash schreibt die hoechste verfuegbare Stufe – ein Rueckfall auf eine // schwaechere Version wuerde die Abdeckung nachtraeglich verkleinern. const inhalt: AuditHashV2Input = { ...log, previousHash }; const hash: string = rehashKey ? generateHashV3(inhalt, rehashKey) : generateHashV2(inhalt); await prisma.auditLog.update({ where: { id: log.id }, data: { hash, previousHash, hashVersion: rehashKey ? 3 : 2 }, }); previousHash = hash; count++; } // Marker: Ein Rehash setzt die Beweiskraft der Vergangenheit zurueck (jede // vorhandene Faelschung wuerde mitbesiegelt). Der Vorgang muss deshalb im // Log selbst sichtbar sein. Der Eintrag wird NACH dem Rehash geschrieben und // haengt sich an die neu berechnete Kette; entfernen liesse er sich nur unter // Hinterlassung einer Luecke. await createAuditLog({ userEmail: ausgeloestVon?.userEmail || 'system', userRole: 'System', action: 'UPDATE', resourceType: 'AuditLog', resourceLabel: `Hash-Kette neu berechnet (${count} Einträge) – Beweiskraft der Vergangenheit zurückgesetzt` + (vorher.valid ? ' – Kette war vorher unbeanstandet' : ` – vorher beanstandet: ${vorher.tamperedEntries.length} manipuliert, ` + `${vorher.chainGaps.length} Lücken`), endpoint: '/api/audit-logs/rehash', httpMethod: 'POST', ipAddress: ausgeloestVon?.ipAddress || 'system', sensitivity: 'CRITICAL', // Befund VOR dem Rehash – ohne das waere nach dem Rehash nicht mehr // nachvollziehbar, was uebertuencht wurde. changesBefore: { geprueft: vorher.checkedCount, manipuliert: vorher.tamperedEntries, ketten_luecken: vorher.chainGaps, luecken_ohne_dokumentierte_loeschung: vorher.unexplainedGaps, nicht_pruefbar: vorher.unverifiableEntries, kettenkopf: kopf ? { id: kopf.id, hash: kopf.hash, hashVersion: kopf.hashVersion, createdAt: kopf.createdAt } : null, }, changesAfter: { neuBerechnet: count }, success: true, }); return { rehashedCount: count }; } /** * Exportiert Audit-Logs als JSON oder CSV */ export async function exportAuditLogs( params: AuditLogSearchParams, format: 'json' | 'csv' = 'json' ): Promise { // Alle Logs ohne Paginierung const result = await searchAuditLogs({ ...params, limit: 100000, page: 1 }); const logs = result.data; if (format === 'json') { return JSON.stringify(logs, null, 2); } // CSV Export const headers = [ 'ID', 'Zeitstempel', 'Benutzer', 'Aktion', 'Ressource', 'Ressource-ID', 'Bezeichnung', 'Endpoint', 'IP-Adresse', 'Erfolg', 'Sensitivität', ]; const rows = logs.map((log) => [ log.id.toString(), log.createdAt.toISOString(), log.userEmail, log.action, log.resourceType, log.resourceId || '', log.resourceLabel || '', log.endpoint, log.ipAddress, log.success ? 'Ja' : 'Nein', log.sensitivity, ]); const csvContent = [ headers.join(';'), ...rows.map((row) => row.map((cell) => `"${cell.replace(/"/g, '""')}"`).join(';')), ].join('\n'); return csvContent; } /** * Löscht alte Audit-Logs basierend auf Retention-Policies * Hinweis: Diese Funktion sollte nur von einem autorisierten Admin-Prozess aufgerufen werden */ export async function runRetentionCleanup(): Promise<{ deletedCount: number; policies: Array<{ resourceType: string; sensitivity: string | null; deletedCount: number }>; }> { const policies = await prisma.auditRetentionPolicy.findMany({ where: { isActive: true }, }); const results: Array<{ resourceType: string; sensitivity: string | null; deletedCount: number }> = []; let totalDeleted = 0; // Loeschungs-Manifest (Pentest R166, Design): Jede geloeschte Zeile reisst die // Hash-Kette auf. Ohne Nachweis, WELCHE Bereiche legitim entfernt wurden, // koennte sich eine boeswillige Loeschung als "harmloser Gap" tarnen. Deshalb // halten wir Bereich + Anzahl fest und schreiben sie als eigenen, selbst // wieder verketteten Audit-Eintrag. Luecken ausserhalb dieser Bereiche // bleiben damit erklaerungsbeduerftig. const manifest: Array> = []; for (const policy of policies) { const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - policy.retentionDays); const where: Prisma.AuditLogWhereInput = { createdAt: { lt: cutoffDate }, }; if (policy.resourceType !== '*') { where.resourceType = policy.resourceType; } if (policy.sensitivity) { where.sensitivity = policy.sensitivity; } // Betroffenen ID-Bereich VOR dem Loeschen festhalten. const range = await prisma.auditLog.aggregate({ where, _count: true, _min: { id: true }, _max: { id: true }, }); const deleted = await prisma.auditLog.deleteMany({ where }); if (deleted.count > 0) { manifest.push({ resourceType: policy.resourceType, sensitivity: policy.sensitivity, retentionDays: policy.retentionDays, cutoff: cutoffDate.toISOString(), deletedCount: deleted.count, fromId: range._min.id, toId: range._max.id, }); } results.push({ resourceType: policy.resourceType, sensitivity: policy.sensitivity, deletedCount: deleted.count, }); totalDeleted += deleted.count; } if (totalDeleted > 0) { await createAuditLog({ userEmail: 'system', userRole: 'System', action: 'DELETE', resourceType: 'AuditLog', resourceLabel: `Retention-Cleanup: ${totalDeleted} Audit-Einträge gelöscht`, endpoint: '/api/audit-logs/cleanup', httpMethod: 'POST', ipAddress: 'system', sensitivity: 'CRITICAL', changesAfter: { manifest }, success: true, }); } return { deletedCount: totalDeleted, policies: results, }; } /** * Holt die Retention-Policies */ export async function getRetentionPolicies() { return prisma.auditRetentionPolicy.findMany({ orderBy: [{ resourceType: 'asc' }, { sensitivity: 'asc' }], }); } /** * Aktualisiert eine Retention-Policy */ export async function updateRetentionPolicy( id: number, data: { retentionDays?: number; description?: string; legalBasis?: string; isActive?: boolean } ) { return prisma.auditRetentionPolicy.update({ where: { id }, data, }); }