Files
opencrm/backend/src/controllers/auditLog.controller.ts
T
duffyduckandClaude Opus 5 32c9efacda rehash/cleanup: Vorzustand sichern + Bestaetigung verlangen (Pentest R170-01)
POST /audit-logs/rehash rechnet die Kette mit dem HMAC-Schluessel neu und macht
sie damit wieder stimmig - auch wenn sie vorher berechtigte Beanstandungen
enthielt. Der Anker schuetzt gegen einen DB-Schreiber ohne Schluessel, nicht
gegen einen Admin mit audit:admin. Der bisherige Marker hielt nur fest, DASS
rehasht wurde, nicht WAS dabei verschwand.

Fix 1: Vor dem Rehash wird verifyIntegrity() erhoben und samt Kettenkopf im
Marker gesichert - Anzahl geprueft, Listen der manipulierten Zeilen, der
Ketten-Luecken, der Luecken ohne dokumentierte Loeschung, der nicht pruefbaren.
Dazu ausloesender Benutzer und IP statt pauschal "system". Der Marker entsteht
nach dem Rehash, ist Teil der neuen Kette und signiert.

Fix 2: rehash verlangt {"confirm":"REHASH"}, cleanup verlangt
{"confirm":"CLEANUP"}. Beide wurden bei blinder Methoden-Erkundung per POST
unbeabsichtigt ausgeloest; ein tastender Aufruf laeuft jetzt in 400.

Verifiziert: blinder POST -> 400 ohne Wirkung; mit Bestaetigung laeuft der
Rehash und der Marker enthaelt Ausloeser, Vorbefund (1 manipuliert, 7 Luecken
mit exakten IDs) und Kettenkopf.

Hinweis: Der Test hat auf der DEV-Datenbank real rehasht, die dortigen
historischen Beanstandungen sind damit geglaettet. Staging/Prod unberuehrt.

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

305 lines
10 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 {
// Ausdrueckliche Bestaetigung verlangen (Pentest R170-01).
//
// Ein Rehash macht die Kette rechnerisch stimmig und setzt damit die
// Beweiskraft der Vergangenheit zurueck - das darf kein Nebeneffekt eines
// versehentlichen oder tastenden POST sein. Genau so wurde der Endpunkt
// bei einer Methoden-Erkundung unbeabsichtigt ausgeloest.
if (req.body?.confirm !== 'REHASH') {
res.status(400).json({
success: false,
error:
'Rehash setzt die Beweiskraft der bestehenden Einträge zurück und ist nicht ' +
'umkehrbar. Zum Bestätigen {"confirm":"REHASH"} mitsenden.',
});
return;
}
const result = await auditService.rehashAll({
userEmail: req.user?.email,
ipAddress: req.ip || (req.socket as any)?.remoteAddress,
});
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 {
// Auch hier ausdrueckliche Bestaetigung: Cleanup loescht Audit-Zeilen
// endgueltig und reisst dabei die Kette auf (Pentest R170-01).
if (req.body?.confirm !== 'CLEANUP') {
res.status(400).json({
success: false,
error:
'Cleanup löscht Audit-Einträge endgültig. Zum Bestätigen ' +
'{"confirm":"CLEANUP"} mitsenden.',
});
return;
}
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' });
}
}