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>
305 lines
10 KiB
TypeScript
305 lines
10 KiB
TypeScript
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' });
|
||
}
|
||
}
|