Files
opencrm/backend/src/services/audit.service.ts
T
duffyduckandClaude Opus 5 89ae7b73a1 Audit-Integritaet: Dauer-Fehlalarm ueber 67 % des Logs behoben
verifyIntegrity meldete 3107 von 4630 Zeilen als "manipuliert" - davon 3100
Fehlalarme, exakt die Zeilen mit resourceId = NULL aus 08.02.-01.05.2026.

Ursache: Der R121-Fix nahm an, resourceId sei beim Schreiben immer undefined
gewesen (Key faellt bei JSON.stringify weg) und daher wuerden alle
Bestands-Hashes ohne Rehash matchen. Das gilt erst ab ~01.05.2026 - aeltere
Zeilen wurden mit explizitem null serialisiert, der Key war drin.

Sicherheitsrelevant, weil ein staendig grundlos ausloesender Alarm ignoriert
wird - echte Manipulation ginge im Laerm unter.

Fix: generateHashLegacy() reproduziert das alte Schreibverhalten,
verifyIntegrity akzeptiert Altbestand ueber diesen Fallback (nur geprueft,
wenn die aktuelle Variante nicht passt). Bewusst KEIN Rehash - der wuerde die
Manipulations-Beweiskraft der Vergangenheit zerstoeren. Gespeicherte Hashes
bleiben unangetastet.

Verifiziert: ungueltig 3107 -> 7 (echte Ketten-Brueche). Adversarial
gegengetestet: Manipulation an userEmail/action/endpoint/createdAt/resourceId
wird bei alten wie neuen Zeilen zu 100 % erkannt (10/10). tsc gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 09:32:22 +02:00

660 lines
19 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');
}
/**
* 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 {
// Letzten Hash abrufen für die Kette
const lastLog = await prisma.auditLog.findFirst({
orderBy: { id: 'desc' },
select: { hash: true },
});
const previousHash = lastLog?.hash || null;
const createdAt = new Date();
// 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;
}
}
// Hash generieren
const hash = generateHash({
userEmail: data.userEmail,
action: data.action,
resourceType: data.resourceType,
resourceId: data.resourceId,
endpoint: data.endpoint,
createdAt,
previousHash,
});
// Eintrag erstellen
await prisma.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,
},
});
} 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;
invalidEntries: 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' },
select: {
id: true,
userEmail: true,
action: true,
resourceType: true,
resourceId: true,
endpoint: true,
createdAt: true,
hash: true,
previousHash: true,
},
});
const invalidEntries: number[] = [];
for (let i = 0; i < logs.length; i++) {
const log = logs[i];
// Hash neu berechnen
const expectedHash = generateHash({
userEmail: log.userEmail,
action: log.action,
resourceType: log.resourceType,
resourceId: log.resourceId,
endpoint: log.endpoint,
createdAt: log.createdAt,
previousHash: log.previousHash,
});
// Prüfen ob Hash übereinstimmt Altbestand darf die historische
// Serialisierung nutzen (siehe generateHashLegacy). Legacy wird nur
// geprüft, wenn die aktuelle Variante nicht passt.
if (log.hash !== expectedHash) {
const legacyHash = generateHashLegacy({
userEmail: log.userEmail,
action: log.action,
resourceType: log.resourceType,
resourceId: log.resourceId,
endpoint: log.endpoint,
createdAt: log.createdAt,
previousHash: log.previousHash,
});
if (log.hash !== legacyHash) {
invalidEntries.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) {
invalidEntries.push(log.id);
}
}
}
return {
valid: invalidEntries.length === 0,
checkedCount: logs.length,
invalidEntries,
};
}
/**
* 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,
userEmail: true,
action: true,
resourceType: true,
resourceId: true,
endpoint: true,
createdAt: true,
},
});
let previousHash: string | null = null;
let count = 0;
for (const log of logs) {
const hash = generateHash({
userEmail: log.userEmail,
action: log.action,
resourceType: log.resourceType,
resourceId: log.resourceId,
endpoint: log.endpoint,
createdAt: log.createdAt,
previousHash,
});
await prisma.auditLog.update({
where: { id: log.id },
data: { hash, previousHash },
});
previousHash = hash;
count++;
}
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;
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;
}
const deleted = await prisma.auditLog.deleteMany({ where });
results.push({
resourceType: policy.resourceType,
sensitivity: policy.sensitivity,
deletedCount: deleted.count,
});
totalDeleted += deleted.count;
}
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,
});
}