Files
opencrm/backend/src/services/audit.service.ts
T
duffyduckandClaude Opus 5 1a349d142e hashVersion-Downgrade geschlossen (Pentest R167-01, HIGH)
verifyIntegrity waehlte die Pruefstaerke nach der von der Zeile selbst
deklarierten hashVersion - und die ist nicht gehasht. Angriff: hashVersion 2->1
setzen, die nur von V2 abgedeckten Felder aendern (success false->true,
errorMessage leeren, resourceLabel umschreiben) und den schwachen V1-Hash ueber
die 7 unveraenderten Felder nachziehen. Ergebnis: Fehl-Login als Erfolg
getarnt, Pruefung meldet "gueltig". An der letzten Zeile der Kette entsteht
dabei nicht einmal ein Gap - dauerhaft unsichtbar.

Fix: Version-Floor. Die erwartete Pruefstaerke leitet sich aus der Kette ab
(MIN(id) WHERE hashVersion >= 2), nicht aus der Selbstauskunft. Ab dieser
Grenze muss jede Zeile V2 sein; weicht die deklarierte Version ab, gilt die
Zeile selbst als manipuliert. Geprueft wird immer mit dem erwarteten Verfahren.
Die Grenze laesst sich durch Herabstufen einzelner Zeilen nicht verschieben.

Zusaetzlich konsultiert die Pruefung jetzt das Loeschungs-Manifest: neu
unexplainedGaps - nur Luecken ohne protokollierte Loeschung sind
erklaerungsbeduerftig. Vorher war das Manifest rein informativ, wodurch sich
eine boeswillige Loeschung als harmloser Gap tarnen konnte.

Verifiziert (PoC nachgebaut): Downgrade mit Nachfolger erkannt, Downgrade der
Tail-Zeile erkannt, Gegenrichtung (V1 faelschlich als V2) erkannt, keine
Falschmeldungen auf Bestandsdaten. tsc + vite build gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 18:12:13 +02:00

988 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown>; // 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<string, unknown>;
changesAfter?: Record<string, unknown>;
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<string, unknown> = {
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<string, unknown> = {
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 = <T>(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<string, AuditSensitivity> = {
// 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<void> {
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<Array<{ hash: string }>>`
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<string> {
// 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<Record<string, unknown>> = [];
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,
});
}