Files
opencrm/backend/src/controllers/auditLog.controller.ts
T
duffyduckandClaude Opus 5 044a12f73e Externer Anker: Audit-Kette HMAC-signiert (Hash-Version 3)
Schliesst den nach R166/R167 verbliebenen Grenzfall: Bis Version 2 war die
Kette selbsttragend - wer die DB schreiben kann, konnte jede Zeile aendern und
alle Folgehashes konsistent nachziehen, die Pruefung meldete "gueltig".

Version 3 signiert denselben Inhalt per HMAC-SHA256 mit AUDIT_HMAC_KEY, einem
Schluessel ausserhalb der Datenbank. Ohne ihn laesst sich keine gueltige
Signatur erzeugen; reiner DB-Schreibzugriff genuegt nicht mehr.

Fail-safe: Ohne Schluessel wird weiter Version 2 geschrieben, es faellt nichts
aus. Signierte Zeilen gelten dann als nicht pruefbar (unverifiableEntries) und
ausdruecklich nicht als manipuliert. AUDIT_HMAC_KEY_OLD erlaubt einen
Schluesselwechsel ohne Rehash.

Restluecke der Versionsgrenze geschlossen: Wird die FRUEHESTE Zeile einer Stufe
herabgestuft, wandert MIN(id) mit - die Grenze allein haette den Downgrade
durchgewunken (der erste Testlauf fiel genau darauf durch). Der Nachfolger ist
jedoch HMAC-signiert und sein previousHash ohne Schluessel nicht faelschbar;
eine unerklaerte Luecke vor einer signierten Zeile gilt deshalb als Befund.

Verifiziert: Inhalt geaendert -> erkannt; Downgrade 3->2 auf der fruehesten
V3-Zeile -> erkannt; dasselbe auf der letzten V3-Zeile (kein Nachfolger) ->
erkannt; ohne Schluessel 0 manipuliert / 2 nicht pruefbar; 40 parallele
Schreiber -> 40/40, 0 Forks, alle V3. tsc + vite build gruen.

AUDIT_HMAC_KEY in .env.example dokumentiert. Der Schluessel selbst liegt nur
lokal in .env (gitignored).

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

274 lines
9.1 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 { Response } from 'express';
import { AuthRequest } from '../types/index.js';
import * as auditService from '../services/audit.service.js';
import { logChange } from '../services/audit.service.js';
import { AuditAction, AuditSensitivity } from '@prisma/client';
/**
* Audit-Logs mit Filtern abrufen
*/
export async function getAuditLogs(req: AuthRequest, res: Response) {
try {
const {
userId,
customerId,
dataSubjectId,
action,
sensitivity,
resourceType,
resourceId,
startDate,
endDate,
success,
search,
page,
limit,
} = req.query;
const result = await auditService.searchAuditLogs({
userId: userId ? parseInt(userId as string) : undefined,
customerId: customerId ? parseInt(customerId as string) : undefined,
dataSubjectId: dataSubjectId ? parseInt(dataSubjectId as string) : undefined,
action: action as AuditAction | undefined,
sensitivity: sensitivity as AuditSensitivity | undefined,
resourceType: resourceType as string | undefined,
resourceId: resourceId as string | undefined,
startDate: startDate ? new Date(startDate as string) : undefined,
endDate: endDate ? new Date(endDate as string) : undefined,
success: success !== undefined ? success === 'true' : undefined,
search: search as string | undefined,
page: page ? parseInt(page as string) : 1,
limit: limit ? parseInt(limit as string) : 50,
});
res.json({ success: true, ...result });
} catch (error) {
console.error('Fehler beim Abrufen der Audit-Logs:', error);
res.status(500).json({ success: false, error: 'Fehler beim Abrufen der Audit-Logs' });
}
}
/**
* Einzelnes Audit-Log abrufen
*/
export async function getAuditLogById(req: AuthRequest, res: Response) {
try {
const id = parseInt(req.params.id);
// Nicht-numerische :id (z.B. GET /audit-logs/verify statt POST /verify)
// landete sonst als NaN in Prisma findUnique → 500. Sauberer 400.
// (Pentest R120 gleiche „400 statt 500"-Klasse wie R64.1/R104.1.)
if (Number.isNaN(id)) {
return res.status(400).json({ success: false, error: 'Ungültige Audit-Log-ID' });
}
const log = await auditService.getAuditLogById(id);
if (!log) {
return res.status(404).json({ success: false, error: 'Audit-Log nicht gefunden' });
}
res.json({ success: true, data: log });
} catch (error) {
console.error('Fehler beim Abrufen des Audit-Logs:', error);
res.status(500).json({ success: false, error: 'Fehler beim Abrufen des Audit-Logs' });
}
}
/**
* Audit-Logs für einen Kunden abrufen (DSGVO)
*/
export async function getAuditLogsByCustomer(req: AuthRequest, res: Response) {
try {
const customerId = parseInt(req.params.customerId);
if (Number.isNaN(customerId)) {
return res.status(400).json({ success: false, error: 'Ungültige Kunden-ID' });
}
const logs = await auditService.getAuditLogsByDataSubject(customerId);
res.json({ success: true, data: logs });
} catch (error) {
console.error('Fehler beim Abrufen der Kunden-Audit-Logs:', error);
res.status(500).json({ success: false, error: 'Fehler beim Abrufen der Audit-Logs' });
}
}
/**
* Audit-Logs exportieren
*/
export async function exportAuditLogs(req: AuthRequest, res: Response) {
try {
const format = (req.query.format as 'json' | 'csv') || 'json';
const {
action,
sensitivity,
resourceType,
startDate,
endDate,
} = req.query;
const content = await auditService.exportAuditLogs(
{
action: action as AuditAction | undefined,
sensitivity: sensitivity as AuditSensitivity | undefined,
resourceType: resourceType as string | undefined,
startDate: startDate ? new Date(startDate as string) : undefined,
endDate: endDate ? new Date(endDate as string) : undefined,
},
format
);
if (format === 'csv') {
const filename = `audit-logs-${new Date().toISOString().split('T')[0]}.csv`;
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
// BOM für Excel UTF-8 Erkennung
res.send('\uFEFF' + content);
} else {
res.json({ success: true, data: JSON.parse(content) })
}
} catch (error) {
console.error('Fehler beim Exportieren der Audit-Logs:', error);
res.status(500).json({ success: false, error: 'Fehler beim Exportieren' });
}
}
/**
* Hash-Ketten-Integrität prüfen
*/
export async function verifyIntegrity(req: AuthRequest, res: Response) {
try {
const { fromId, toId } = req.query;
const result = await auditService.verifyIntegrity(
fromId ? parseInt(fromId as string) : undefined,
toId ? parseInt(toId as string) : undefined
);
// Zwei sehr unterschiedliche Befunde sauber trennen vorher wurde beides
// pauschal als "manipuliert" gemeldet, was strukturelle Lücken wie einen
// echten Angriff aussehen liess (und damit die Meldung entwertete).
const tampered = result.tamperedEntries.length;
const gaps = result.chainGaps.length;
const unexplained = result.unexplainedGaps.length;
const luecken = gaps > 0
? `${gaps} strukturelle Lücken` +
(unexplained === 0
? ' (alle durch protokollierte Löschungen erklärt)'
: unexplained < gaps
? `, davon ${unexplained} ohne dokumentierte Löschung`
: ' ohne dokumentierte Löschung')
: '';
const unverifiable = result.unverifiableEntries.length;
const keinSchluessel = unverifiable > 0
? ` ${unverifiable} Einträge sind HMAC-signiert und ohne konfigurierten AUDIT_HMAC_KEY nicht prüfbar.`
: '';
const message = tampered > 0
? `${tampered} MANIPULIERTE Einträge gefunden` + (gaps > 0 ? ` (zusätzlich ${luecken})` : '')
: gaps > 0
? `Keine Manipulation. ${luecken} Inhalte unverändert.`
: 'Alle Einträge sind unverändert und lückenlos verkettet';
res.json({
success: true,
data: {
valid: result.valid,
checkedCount: result.checkedCount,
invalidEntries: result.invalidEntries,
// Ernst: Inhalt einer bestehenden Zeile wurde nachträglich verändert.
tamperedEntries: result.tamperedEntries,
// Meist harmlos: Verkettung unterbrochen, Inhalte selbst unversehrt.
chainGaps: result.chainGaps,
// Nur Lücken ohne protokollierte Löschung sind erklärungsbedürftig.
unexplainedGaps: result.unexplainedGaps,
tampered: tampered > 0,
message: message + keinSchluessel,
unverifiableEntries: result.unverifiableEntries,
},
});
} catch (error) {
console.error('Fehler bei der Integritätsprüfung:', error);
res.status(500).json({ success: false, error: 'Fehler bei der Integritätsprüfung' });
}
}
/**
* Hash-Kette reparieren (alle Hashes neu berechnen)
*/
export async function rehashAll(req: AuthRequest, res: Response) {
try {
const result = await auditService.rehashAll();
res.json({
success: true,
data: result,
message: `${result.rehashedCount} Einträge neu gehasht. Kette ist jetzt intakt.`,
});
} catch (error) {
console.error('Fehler beim Re-Hashing:', error);
res.status(500).json({ success: false, error: 'Fehler beim Re-Hashing' });
}
}
/**
* Retention-Policies abrufen
*/
export async function getRetentionPolicies(req: AuthRequest, res: Response) {
try {
const policies = await auditService.getRetentionPolicies();
res.json({ success: true, data: policies });
} catch (error) {
console.error('Fehler beim Abrufen der Retention-Policies:', error);
res.status(500).json({ success: false, error: 'Fehler beim Abrufen der Policies' });
}
}
/**
* Retention-Policy aktualisieren
*/
export async function updateRetentionPolicy(req: AuthRequest, res: Response) {
try {
const id = parseInt(req.params.id);
if (Number.isNaN(id)) {
return res.status(400).json({ success: false, error: 'Ungültige Policy-ID' });
}
const { retentionDays, description, legalBasis, isActive } = req.body;
const policy = await auditService.updateRetentionPolicy(id, {
retentionDays,
description,
legalBasis,
isActive,
});
await logChange({
req, action: 'UPDATE', resourceType: 'RetentionPolicy',
resourceId: id.toString(),
label: `Aufbewahrungsrichtlinie aktualisiert`,
});
res.json({ success: true, data: policy });
} catch (error) {
console.error('Fehler beim Aktualisieren der Retention-Policy:', error);
res.status(500).json({ success: false, error: 'Fehler beim Aktualisieren' });
}
}
/**
* Retention-Cleanup manuell ausführen
*/
export async function runRetentionCleanup(req: AuthRequest, res: Response) {
try {
const result = await auditService.runRetentionCleanup();
res.json({
success: true,
data: result,
message: `${result.deletedCount} alte Audit-Logs wurden gelöscht`,
});
} catch (error) {
console.error('Fehler beim Retention-Cleanup:', error);
res.status(500).json({ success: false, error: 'Fehler beim Cleanup' });
}
}