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; } function generateHashV2(data: AuditHashV2Input): string { const n = (v: T | null | undefined): T | null => (v === undefined ? null : v); // Feldreihenfolge ist Teil des Hashes und darf nicht veraendert werden. const payload = { v: 2, 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 || '', }; return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex'); } /** * 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(); // Neue Eintraege immer mit Version 2 (volle Feldabdeckung, R166-02). const hash = generateHashV2({ 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, }); 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: 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 */ 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[]; }> { 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. 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 (erwarteV2) { hashOk = log.hash === generateHashV2({ 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, }); } 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); } } } } const invalidEntries = [...tamperedEntries, ...chainGaps].sort((a, b) => a - b); return { valid: invalidEntries.length === 0, checkedCount: logs.length, invalidEntries, tamperedEntries, chainGaps, unexplainedGaps, }; } /** * Hash-Kette komplett neu berechnen (Reparatur) */ export async function rehashAll(): Promise<{ rehashedCount: number }> { 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, }, }); let previousHash: string | null = null; let count = 0; for (const log of logs) { // Rehash schreibt immer Version 2 (volle Feldabdeckung) – ein Rueckfall // auf Version 1 wuerde die Abdeckung nachtraeglich wieder verkleinern. const hash = generateHashV2({ ...log, previousHash }); await prisma.auditLog.update({ where: { id: log.id }, data: { hash, previousHash, hashVersion: 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: 'system', userRole: 'System', action: 'UPDATE', resourceType: 'AuditLog', resourceLabel: `Hash-Kette neu berechnet (${count} Einträge) – Beweiskraft der Vergangenheit zurückgesetzt`, endpoint: '/api/audit-logs/rehash', httpMethod: 'POST', ipAddress: 'system', sensitivity: 'CRITICAL', 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, }); }