Das Gegenbuch auf Prod meldete stuendlich exit=2, weil die CRM-Pruefung
wegen 6 struktureller Luecken valid:false lieferte (IDs 33, 44, 45, 922,
1434, 2583).
Diagnose: harmlos. Jede Luecke liegt innerhalb eines Schwungs von
Eintraegen mit identischer Sekunde, betrifft nur /login und /refresh,
kein Eintrag fehlt, tamperedEntries ist leer. Das ist die Signatur der
Race-Condition, die am 19.08. mit AuditChainLock geschlossen wurde
(R166-01). Die Zeilen datieren ihren eigenen Code: Eintrag 2583 stammt
vom 21.08., ist aber noch hashVersion=1.
Das eigentliche Problem war nicht die Luecke, sondern der Dauer-Alarm.
Diese Luecken sind nicht heilbar - die Verkettung ist gebrochen, die
Inhalte sind unversehrt. Ohne Aenderung haette das Gegenbuch fuer immer
Alarm gemeldet, und ein Signal, das immer schreit, warnt nicht mehr.
Dieselbe Klasse wie R162, R174, R179, R182, R183-02.
Loesung: Beglaubigung statt Unterdrueckung. Eine Luecke zaehlt nicht mehr
als offener Befund, wenn sie im versiegelten Bereich liegt UND im
Vorbefund des Siegel-Markers steht - also im Zustand, den der Betreiber
beim Siegeln ausdruecklich festgeschrieben hat. Der Vorbefund liegt in
changesBefore und ist ab Version 3 mitgehasht; ohne AUDIT_HMAC_KEY laesst
sich die Liste nicht nachtraeglich erweitern (gleiche Absicherung wie
beim Loeschungs-Manifest, R171-01). Beglaubigt heisst nicht verschwunden:
die Luecken bleiben in chainGaps, stehen zusaetzlich in attestedGaps und
werden im Bericht ausdruecklich benannt.
Nebenbefund derselben Klasse mitbehoben: backlogSealStatus meldete
"nicht_noetig" ("Es gibt keine unsignierten Alteintraege"), wenn
v3FromId === null - das bedeutet aber das Gegenteil, naemlich dass
ueberhaupt nichts signiert ist, etwa weil AUDIT_HMAC_KEY fehlt. Ein
vollstaendig unsigniertes Log bekam damit Entwarnung fuer genau den
Zustand mit der geringsten Beweiskraft. Die Bedingung haengt jetzt an der
Zahl der unsignierten Zeilen; R183-03 bleibt behoben (Regressionstest).
Getestet ueber HTTP gegen eine eigene Wegwerf-Datenbank, nicht am Service
vorbei: Prod-Zustand nachgebaut (10 v1-Zeilen, Bruch bei id 5) ->
valid:false; nach seal-backlog -> valid:true, attestedGaps:[5]. Drei
Gegenproben: neue Luecke nach dem Siegeln -> valid:false; gesiegelte
Altzeile veraendert -> Siegel gebrochen, nichts mehr beglaubigt;
Beglaubigungsliste im Marker gefaelscht (DB-Schreibrecht, kein
Schluessel) -> Marker ungueltig, Status entfernt, valid:false.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1625 lines
59 KiB
TypeScript
1625 lines
59 KiB
TypeScript
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;
|
||
/// Ueberschreibt die aus dem Ressourcentyp abgeleitete Stufe. Noetig, wenn
|
||
/// eine Aktion gefaehrlicher ist als ihr Typ vermuten laesst - etwa das
|
||
/// Verkuerzen einer Aufbewahrungsfrist, dessen Folge CRITICAL ist
|
||
/// (Pentest R183-01).
|
||
sensitivity?: AuditSensitivity;
|
||
before?: Record<string, unknown>;
|
||
}) {
|
||
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,
|
||
sensitivity: opts.sensitivity,
|
||
changesBefore: opts.before,
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Externer Anker (Pentest R166/R167, Design-Empfehlung).
|
||
*
|
||
* Bis Version 2 war die Kette selbsttragend: Wer die DB schreiben kann, kann
|
||
* jede Zeile aendern und saemtliche Folgehashes konsistent nachziehen – die
|
||
* Pruefung meldet dann sauber "gueltig". Version 3 signiert denselben Inhalt
|
||
* stattdessen per HMAC-SHA256 mit einem Schluessel, der NICHT in der Datenbank
|
||
* liegt (`AUDIT_HMAC_KEY`). Ohne diesen Schluessel laesst sich keine gueltige
|
||
* Signatur erzeugen – reiner DB-Schreibzugriff genuegt damit nicht mehr.
|
||
*
|
||
* Fail-safe: Ohne konfigurierten Schluessel wird weiterhin Version 2
|
||
* geschrieben. Audit-Logging darf nie an fehlender Konfiguration scheitern.
|
||
* `AUDIT_HMAC_KEY_OLD` erlaubt einen Schluesselwechsel ohne Rehash – bei der
|
||
* Pruefung wird zusaetzlich gegen den alten Schluessel getestet.
|
||
*/
|
||
/**
|
||
* Ein nicht ersetzter Platzhalter aus `.env.example` (z. B.
|
||
* `<hier-eigenen-wert-eintragen>`) darf NICHT als Schluessel gelten. Sonst
|
||
* wuerde mit einem oeffentlich im Repository stehenden Wert gesiegelt – das
|
||
* waere schlechter als gar kein Siegel, weil es Sicherheit vortaeuscht.
|
||
*/
|
||
function istPlatzhalter(k: string): boolean {
|
||
return k.includes('<') || k.includes('>') || /^(change|dein|your|hier)/i.test(k);
|
||
}
|
||
|
||
let platzhalterGemeldet = false;
|
||
|
||
function auditHmacKey(): string | null {
|
||
const k = process.env.AUDIT_HMAC_KEY?.trim();
|
||
if (!k) return null;
|
||
if (istPlatzhalter(k)) {
|
||
if (!platzhalterGemeldet) {
|
||
platzhalterGemeldet = true;
|
||
console.warn(
|
||
'[Audit] AUDIT_HMAC_KEY enthält noch den Platzhalter aus .env.example – ' +
|
||
'das Audit-Siegel bleibt deaktiviert. Eigenen Wert erzeugen: openssl rand -hex 32',
|
||
);
|
||
}
|
||
return null;
|
||
}
|
||
return k;
|
||
}
|
||
|
||
/**
|
||
* Frueher verwendete Schluessel. Kommagetrennt, damit auch ein ZWEITER Wechsel
|
||
* moeglich ist, ohne die zuerst signierten Eintraege zu verlieren - mit nur
|
||
* einem Platz wuerden die aeltesten sonst als "manipuliert" gemeldet.
|
||
*/
|
||
function auditHmacKeysOld(): string[] {
|
||
return (process.env.AUDIT_HMAC_KEY_OLD || '')
|
||
.split(',')
|
||
.map((k) => k.trim())
|
||
.filter((k) => k.length > 0 && !istPlatzhalter(k));
|
||
}
|
||
|
||
function generateHashV3(data: AuditHashV2Input, key: string): string {
|
||
return crypto.createHmac('sha256', key)
|
||
.update(JSON.stringify(buildContentPayload(data, 3)))
|
||
.digest('hex');
|
||
}
|
||
|
||
function generateHashV2(data: AuditHashV2Input): string {
|
||
return crypto.createHash('sha256')
|
||
.update(JSON.stringify(buildContentPayload(data, 2)))
|
||
.digest('hex');
|
||
}
|
||
|
||
/** Gemeinsamer Inhalts-Payload fuer Version 2 (SHA-256) und 3 (HMAC). */
|
||
function buildContentPayload(data: AuditHashV2Input, version: 2 | 3): Record<string, unknown> {
|
||
const n = <T>(v: T | null | undefined): T | null => (v === undefined ? null : v);
|
||
// Feldreihenfolge ist Teil des Hashes und darf nicht veraendert werden.
|
||
return {
|
||
v: version,
|
||
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 || '',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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();
|
||
|
||
// Version 3 (HMAC-signiert) sobald ein Schluessel konfiguriert ist,
|
||
// sonst Version 2 – Audit-Logging darf an fehlender Konfiguration nicht
|
||
// scheitern (fail-safe, siehe generateHashV3).
|
||
const hmacKey = auditHmacKey();
|
||
const inhalt = {
|
||
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,
|
||
};
|
||
const hash = hmacKey ? generateHashV3(inhalt, hmacKey) : generateHashV2(inhalt);
|
||
|
||
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: hmacKey ? 3 : 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
|
||
*/
|
||
/**
|
||
* Blattwert einer Altbestands-Zeile fuer das Bestandssiegel (Pentest R171-02).
|
||
*
|
||
* Bewusst ueber den VOLLEN Zeileninhalt inkl. `id` und `hashVersion` – nicht
|
||
* ueber den gespeicherten 7-Feld-Hash der Version 1. Sonst lebte die Luecke im
|
||
* Siegel weiter: genau die 17 ungehashten Spalten waeren wieder frei
|
||
* editierbar. `hashVersion` gehoert mit hinein, damit ein Hochstufen der Zeile
|
||
* (V1→V2, ohne Schluessel nachrechenbar) das Blatt bricht, statt sie klammheimlich
|
||
* aus dem Siegelbereich zu schieben.
|
||
*/
|
||
function backlogLeaf(row: Record<string, unknown>): string {
|
||
const feld = (v: unknown) =>
|
||
v instanceof Date ? v.toISOString() : v === undefined ? null : v;
|
||
// Feste Reihenfolge – Teil des Blattwerts.
|
||
const payload = [
|
||
'id', 'userId', 'userEmail', 'userRole', 'customerId', 'isCustomerPortal',
|
||
'action', 'sensitivity', 'resourceType', 'resourceId', 'resourceLabel',
|
||
'endpoint', 'httpMethod', 'ipAddress', 'userAgent', 'changesBefore',
|
||
'changesAfter', 'changesEncrypted', 'dataSubjectId', 'legalBasis', 'success',
|
||
'errorMessage', 'durationMs', 'createdAt', 'hash', 'previousHash',
|
||
'hashVersion',
|
||
].map((k) => [k, feld(row[k])]);
|
||
|
||
return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
||
}
|
||
|
||
/** Wurzel ueber die Blaetter in id-Reihenfolge. */
|
||
function backlogRoot(leaves: Array<{ auditLogId: number; leafHash: string }>): string {
|
||
const sortiert = [...leaves].sort((a, b) => a.auditLogId - b.auditLogId);
|
||
return crypto
|
||
.createHash('sha256')
|
||
.update(JSON.stringify(sortiert.map((l) => [l.auditLogId, l.leafHash])))
|
||
.digest('hex');
|
||
}
|
||
|
||
/** Endpoint-Kennung des Siegel-Markers. */
|
||
const BACKLOG_SEAL_ENDPOINT = '/api/audit-logs/seal-backlog';
|
||
|
||
/**
|
||
* Eigener Ressourcentyp fuer den Siegel-Marker (Pentest R173-01).
|
||
*
|
||
* Der Marker wurde urspruenglich als `AuditLog` / `CREATE` /
|
||
* `endpoint=/api/audit-logs/seal-backlog` gesucht. Genau diese Signatur
|
||
* erzeugt aber die generische Audit-Middleware, wenn jemand den Endpunkt
|
||
* aufruft – als Protokollzeile des Requests selbst, mit hoeherer id und ohne
|
||
* `changesAfter`. Der Selektor griff dann diese Zeile statt des echten
|
||
* Markers, `root` war `undefined`, und die Pruefung meldete dauerhaft
|
||
* „gebrochen“. Ein Ressourcentyp, den die Middleware nie vergibt (sie leitet
|
||
* ihn aus dem Pfad ab → `AuditLog`), macht den Marker eindeutig.
|
||
*/
|
||
const BACKLOG_SEAL_RESOURCE = 'AuditBacklogSeal';
|
||
|
||
/**
|
||
* Versiegelt den Altbestand einmalig.
|
||
*
|
||
* Der Bereich ist FIX auf `[1 … v3FromId-1]` gebunden, nicht auf eine
|
||
* Live-Abfrage `hashVersion < 3`. Sonst koennte ein Angreifer die Grenzzeile
|
||
* hochstufen und sie damit aus der geprueften Menge draengen – sie erschiene
|
||
* dann nur als harmlose Luecke. Zeilen ab `v3FromId` deckt die Versionsgrenze
|
||
* ab (sie MUESSEN V3 sein), es bleibt also keine Naht dazwischen.
|
||
*/
|
||
/**
|
||
* Kontrollwert fuer die externe Notarisierung (Gegenbuch).
|
||
*
|
||
* Alle bisherigen Anker leben IM System: Hash, Versionsgrenze, Manifest,
|
||
* Bestandssiegel, Siegel-Marker. Wer Schreibzugriff auf die Datenbank hat,
|
||
* sitzt am Ende immer schon auf der Ebene, die beweisen soll – zuletzt gezeigt
|
||
* in R174-01. Ein Gegenbuch an einem fremden Ort durchbricht das.
|
||
*
|
||
* Der Wert enthaelt bewusst `maxId`: Ein blosser Kopf-Hash erkennt zwar
|
||
* Umschreiben, aber kein ABSCHNEIDEN am Ende. Mit ankertem `maxId` ist
|
||
* „juengster beglaubigter maxId > aktueller maxId“ der Beweis, dass Zeilen
|
||
* verschwunden sind.
|
||
*
|
||
* `atId` erlaubt der Gegenstelle, einen FRUEHER beglaubigten Kopf erneut
|
||
* abzufragen und zu reproduzieren – ohne das koennte sie nur den aktuellen
|
||
* Stand sehen und nichts nachrechnen.
|
||
*
|
||
* Enthaelt keine Geheimnisse: nur Hashes und Zaehlstaende.
|
||
*/
|
||
export async function getCheckpoint(atId?: number): Promise<{
|
||
ts: string;
|
||
maxId: number | null;
|
||
chainHead: string | null;
|
||
sealRoot: string | null;
|
||
sealLeafCount: number;
|
||
atId?: number;
|
||
atHash?: string | null;
|
||
}> {
|
||
const kopf = await prisma.auditLog.findFirst({
|
||
orderBy: { id: 'desc' },
|
||
select: { id: true, hash: true },
|
||
});
|
||
|
||
// BEWUSST KEIN verifyIntegrity() hier (Pentest R175-02): das laeuft ueber
|
||
// alle Zeilen und kostet bei grossen Logs Sekunden je Aufruf – ein
|
||
// authentifizierter Leser koennte damit die Datenbank in die Knie zwingen.
|
||
// Gebraucht wird von der Vollpruefung ohnehin nur die Siegel-Wurzel, und die
|
||
// steht im juengsten gueltigen Marker. Der Kopf-Hash kommt direkt aus der
|
||
// Kopfzeile.
|
||
const c3 = await prisma.auditLog.aggregate({
|
||
where: { hashVersion: { gte: 3 } },
|
||
_min: { id: true },
|
||
});
|
||
const v3FromId = c3._min.id;
|
||
const schluessel = [auditHmacKey(), ...auditHmacKeysOld()].filter(
|
||
(k): k is string => !!k,
|
||
);
|
||
const markerKandidaten = await prisma.auditLog.findMany({
|
||
where: { resourceType: BACKLOG_SEAL_RESOURCE, endpoint: BACKLOG_SEAL_ENDPOINT },
|
||
orderBy: { id: 'desc' },
|
||
take: 20,
|
||
});
|
||
let sealRoot: string | null = null;
|
||
if (schluessel.length && v3FromId !== null) {
|
||
for (const r of markerKandidaten) {
|
||
if (r.id < v3FromId || r.hashVersion < 3) continue;
|
||
if (!schluessel.some((k) => r.hash === generateHashV3(r as unknown as AuditHashV2Input, k))) continue;
|
||
try {
|
||
const m = JSON.parse(r.changesAfter || '{}');
|
||
if (typeof m.root === 'string' && m.root.length > 0) {
|
||
sealRoot = m.root;
|
||
break;
|
||
}
|
||
} catch {
|
||
/* naechster Kandidat */
|
||
}
|
||
}
|
||
}
|
||
const blattAnzahl = await prisma.auditBacklogSeal.count();
|
||
|
||
const ergebnis: {
|
||
ts: string;
|
||
maxId: number | null;
|
||
chainHead: string | null;
|
||
sealRoot: string | null;
|
||
sealLeafCount: number;
|
||
atId?: number;
|
||
atHash?: string | null;
|
||
} = {
|
||
ts: new Date().toISOString(),
|
||
maxId: kopf?.id ?? null,
|
||
chainHead: kopf?.hash ?? null,
|
||
sealRoot,
|
||
// Reicht der Gegenstelle, um das Verschwinden des Siegels zu bemerken:
|
||
// Blaetter ohne Wurzel = Marker entfernt.
|
||
sealLeafCount: blattAnzahl,
|
||
};
|
||
|
||
if (atId !== undefined && Number.isFinite(atId)) {
|
||
const zeile = await prisma.auditLog.findUnique({
|
||
where: { id: atId },
|
||
select: { hash: true },
|
||
});
|
||
ergebnis.atId = atId;
|
||
// null bedeutet: die beglaubigte Zeile existiert nicht mehr.
|
||
ergebnis.atHash = zeile?.hash ?? null;
|
||
}
|
||
|
||
return ergebnis;
|
||
}
|
||
|
||
export async function sealBacklog(
|
||
ausgeloestVon?: { userEmail?: string; ipAddress?: string },
|
||
): Promise<{ sealedCount: number; fromId: number; toId: number; root: string }> {
|
||
if (!auditHmacKey()) {
|
||
throw new Error(
|
||
'Bestandssiegel erfordert einen konfigurierten AUDIT_HMAC_KEY – ohne ihn liesse sich die Wurzel nachrechnen.',
|
||
);
|
||
}
|
||
const c3 = await prisma.auditLog.aggregate({
|
||
where: { hashVersion: { gte: 3 } },
|
||
_min: { id: true },
|
||
});
|
||
const v3FromId = c3._min.id;
|
||
if (v3FromId === null) {
|
||
throw new Error(
|
||
'Noch keine signierten Einträge vorhanden – bitte zuerst AUDIT_HMAC_KEY setzen und das Backend neu starten.',
|
||
);
|
||
}
|
||
const toId = v3FromId - 1;
|
||
if (toId < 1) {
|
||
throw new Error('Kein Altbestand vorhanden – es gibt nichts zu versiegeln.');
|
||
}
|
||
|
||
// Zustand VOR dem Siegeln festhalten (R173-03).
|
||
const vorher = await verifyIntegrity();
|
||
const vorbefund = {
|
||
vorherigesSiegel:
|
||
vorher.backlogSealStatus === 'kein_siegel'
|
||
? null
|
||
: { status: vorher.backlogSealStatus, root: vorher.backlogSealRoot },
|
||
befund: {
|
||
manipuliert: vorher.tamperedEntries,
|
||
altbestand_veraendert: vorher.backlogTampered,
|
||
altbestand_fehlend: vorher.backlogMissing,
|
||
ketten_luecken: vorher.chainGaps,
|
||
},
|
||
};
|
||
|
||
const rows = await prisma.auditLog.findMany({
|
||
where: { id: { lte: toId } },
|
||
orderBy: { id: 'asc' },
|
||
});
|
||
const leaves = rows.map((r) => ({
|
||
auditLogId: r.id,
|
||
leafHash: backlogLeaf(r as unknown as Record<string, unknown>),
|
||
}));
|
||
const root = backlogRoot(leaves);
|
||
|
||
await prisma.$transaction(async (tx) => {
|
||
await tx.auditBacklogSeal.deleteMany({});
|
||
for (let i = 0; i < leaves.length; i += 500) {
|
||
await tx.auditBacklogSeal.createMany({ data: leaves.slice(i, i + 500) });
|
||
}
|
||
}, { timeout: 60000, maxWait: 20000 });
|
||
|
||
// Marker ist selbst HMAC-signiert (Version 3) – damit ist die Wurzel
|
||
// signiert, ohne dass es einen zweiten Signaturpfad braucht.
|
||
//
|
||
// Vorzustand mitschreiben (Pentest R173-03): Ein erneutes Siegeln wuerde
|
||
// sonst eine zwischenzeitliche Manipulation absorbieren und die gefaelschte
|
||
// Zeile als echt zertifizieren – mit noch weniger Spur als ein Rehash. Der
|
||
// Marker haelt deshalb fest, was VOR dem Siegeln galt: der Befund und die
|
||
// Wurzel des vorherigen Siegels.
|
||
await createAuditLog({
|
||
userEmail: ausgeloestVon?.userEmail || 'system',
|
||
userRole: 'System',
|
||
action: 'CREATE',
|
||
resourceType: BACKLOG_SEAL_RESOURCE,
|
||
resourceLabel:
|
||
`Bestandssiegel erstellt: ${leaves.length} Alteinträge (id 1–${toId})` +
|
||
(vorbefund.vorherigesSiegel
|
||
? ' – ERNEUTES Siegeln, vorheriges Siegel wird ersetzt'
|
||
: ''),
|
||
endpoint: BACKLOG_SEAL_ENDPOINT,
|
||
httpMethod: 'POST',
|
||
ipAddress: ausgeloestVon?.ipAddress || 'system',
|
||
sensitivity: 'CRITICAL',
|
||
changesBefore: vorbefund,
|
||
changesAfter: { fromId: 1, toId, count: leaves.length, root },
|
||
success: true,
|
||
});
|
||
|
||
return { sealedCount: leaves.length, fromId: 1, toId, root };
|
||
}
|
||
|
||
export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
|
||
valid: boolean;
|
||
checkedCount: number;
|
||
/**
|
||
* Alle noch offenen Beanstandungen: veraenderte Zeilen plus Luecken, die
|
||
* NICHT vom Bestandssiegel beglaubigt sind. Nur an dieser Liste haengt
|
||
* `valid` – beglaubigte Alt-Luecken stehen in `attestedGaps`.
|
||
*/
|
||
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[];
|
||
/**
|
||
* HMAC-signierte Zeilen, die mangels konfiguriertem `AUDIT_HMAC_KEY` nicht
|
||
* geprueft werden konnten. Bewusst KEIN Manipulationsverdacht – sonst waere
|
||
* eine fehlende Konfiguration ein Fehlalarm ueber das gesamte Log.
|
||
*/
|
||
unverifiableEntries: number[];
|
||
/**
|
||
* Teilmenge von `chainGaps`, die beim Versiegeln des Altbestands bereits
|
||
* bestand und im signierten Siegel-Marker als Vorbefund festgehalten ist.
|
||
* Diese Luecken sind BEGLAUBIGT: sie bleiben sichtbar, kippen `valid` aber
|
||
* nicht mehr (siehe ausfuehrliche Begruendung an der Berechnung unten).
|
||
*/
|
||
attestedGaps: number[];
|
||
/**
|
||
* Zustand des Bestandssiegels ueber den nicht signierbaren Altbestand.
|
||
* `kein_siegel` = nie erstellt. `entfernt` = Blaetter vorhanden, aber kein
|
||
* gueltiger Marker mehr – der Anker wurde herausgeloest (Pentest R174-01).
|
||
*/
|
||
backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt' | 'nicht_noetig';
|
||
/** Altbestands-Zeilen, deren Inhalt vom Siegel abweicht. */
|
||
backlogTampered: number[];
|
||
/** Gesiegelte Zeilen, die nicht mehr existieren – Beweismaterial entfernt. */
|
||
backlogMissing: number[];
|
||
/** Anzahl gueltiger Siegel-Marker; >1 bedeutet erneutes Siegeln. */
|
||
backlogSealCount: number;
|
||
/** Wurzel des aktuell gueltigen Siegels. */
|
||
backlogSealRoot: string | null;
|
||
}> {
|
||
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.
|
||
// Grenze pro Stufe: ab der ersten je mit Version >= v geschriebenen Zeile
|
||
// muss jede weitere Zeile mindestens Version v haben. Verallgemeinert auf
|
||
// Version 3 (HMAC-Anker), damit auch ein Ruecksprung 3→2 auffliegt.
|
||
const [c2, c3] = await Promise.all([
|
||
prisma.auditLog.aggregate({ where: { hashVersion: { gte: 2 } }, _min: { id: true } }),
|
||
prisma.auditLog.aggregate({ where: { hashVersion: { gte: 3 } }, _min: { id: true } }),
|
||
]);
|
||
const v2FromId = c2._min.id;
|
||
const v3FromId = c3._min.id;
|
||
const erwarteteVersion = (id: number): 1 | 2 | 3 => {
|
||
if (v3FromId !== null && id >= v3FromId) return 3;
|
||
if (v2FromId !== null && id >= v2FromId) return 2;
|
||
return 1;
|
||
};
|
||
|
||
// Dokumentierte Loeschungen einlesen – NUR aus signierten, selbst gueltigen
|
||
// Traegerzeilen (Pentest R171-01).
|
||
//
|
||
// Vorher wurde jedem Manifest bedingungslos geglaubt. Das Manifest steht in
|
||
// `changesAfter`, und dieses Feld ist erst ab Hash-Version 2 mitgehasht – auf
|
||
// V1-Altzeilen also voellig unauthentifiziert. Ein Angreifer konnte daher in
|
||
// eine BELIEBIGE V1-Zeile ein erfundenes Manifest schreiben, ohne deren Hash
|
||
// zu veraendern, und damit eigene Loeschungen und Luecken als "erklaert"
|
||
// ausweisen. Das hebelte zugleich die Eskalation an signierten Zeilen aus –
|
||
// also Anker UND Versionsgrenze.
|
||
//
|
||
// Deshalb zaehlt ein Manifest nur, wenn seine Traegerzeile
|
||
// 1. laut Versionsgrenze Stufe 3 sein MUSS und dies auch deklariert, und
|
||
// 2. ihre HMAC-Signatur mit einem konfigurierten Schluessel aufgeht.
|
||
// Ohne Schluessel gibt es keine gueltigen Traeger – dann bleiben Luecken
|
||
// unerklaert, was die sichere Richtung ist.
|
||
const deletionRanges: Array<{ from: number; to: number }> = [];
|
||
const manifestKandidaten = await prisma.auditLog.findMany({
|
||
where: { resourceType: 'AuditLog', action: 'DELETE', endpoint: '/api/audit-logs/cleanup' },
|
||
});
|
||
const manifestSchluessel = [auditHmacKey(), ...auditHmacKeysOld()].filter(
|
||
(k): k is string => !!k,
|
||
);
|
||
for (const row of manifestKandidaten) {
|
||
if (erwarteteVersion(row.id) !== 3 || row.hashVersion < 3) continue;
|
||
if (manifestSchluessel.length === 0) continue;
|
||
const signaturOk = manifestSchluessel.some(
|
||
(k) => row.hash === generateHashV3(row as unknown as AuditHashV2Input, k),
|
||
);
|
||
if (!signaturOk) continue;
|
||
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[] = [];
|
||
const unverifiableEntries: number[] = [];
|
||
|
||
// ---------------------------------------------------------------------
|
||
// Bestandssiegel pruefen (Pentest R171-02)
|
||
//
|
||
// Der Altbestand ist nicht signierbar: bei Version 1 sind nur 7 von 24
|
||
// Spalten gehasht. Wurde er versiegelt, wird hier je Zeile geprueft:
|
||
// vorhanden, immer noch Altbestand (hashVersion < 3) und Inhalt unveraendert.
|
||
// Der Marker muss selbst signiert und gueltig sein - sonst waere das Siegel
|
||
// ueber denselben Weg faelschbar wie zuvor die Manifeste (R171-01).
|
||
const backlogTampered: number[] = [];
|
||
const backlogMissing: number[] = [];
|
||
// Luecken, die das Siegel als bereits vorhanden beglaubigt (siehe unten).
|
||
const beglaubigteLuecken = new Set<number>();
|
||
let backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt' | 'nicht_noetig' = 'kein_siegel';
|
||
let backlogSealRoot: string | null = null;
|
||
|
||
const siegelKandidaten = await prisma.auditLog.findMany({
|
||
where: { resourceType: BACKLOG_SEAL_RESOURCE, endpoint: BACKLOG_SEAL_ENDPOINT },
|
||
orderBy: { id: 'desc' },
|
||
});
|
||
const siegelSchluessel = [auditHmacKey(), ...auditHmacKeysOld()].filter(
|
||
(k): k is string => !!k,
|
||
);
|
||
// Nur signierte Marker MIT auswertbarem Inhalt zaehlen. Der zweite Teil ist
|
||
// die Absicherung gegen Zeilen, die zufaellig dieselbe Signatur tragen, aber
|
||
// gar kein Siegel beschreiben (Pentest R173-01).
|
||
const hatSiegelInhalt = (r: { changesAfter: string | null }) => {
|
||
try {
|
||
const m = JSON.parse(r.changesAfter || '{}');
|
||
return typeof m.toId === 'number' && typeof m.root === 'string' && m.root.length > 0;
|
||
} catch {
|
||
return false;
|
||
}
|
||
};
|
||
const gueltigeSiegel = siegelSchluessel.length
|
||
? siegelKandidaten.filter(
|
||
(r) =>
|
||
erwarteteVersion(r.id) === 3 &&
|
||
r.hashVersion >= 3 &&
|
||
hatSiegelInhalt(r) &&
|
||
siegelSchluessel.some((k) => r.hash === generateHashV3(r as unknown as AuditHashV2Input, k)),
|
||
)
|
||
: [];
|
||
const siegel = gueltigeSiegel[0];
|
||
const backlogSealCount = gueltigeSiegel.length;
|
||
|
||
// Gegen-Check (Pentest R174-01): Der Siegelzustand hing ausschliesslich am
|
||
// Marker – und den kann ein DB-Schreiber OHNE Schluessel loeschen. Danach
|
||
// meldete die Pruefung `kein_siegel`, also Entwarnung, ununterscheidbar von
|
||
// „nie versiegelt“. Steht der Marker am Ketten-Ende (genau der Zustand
|
||
// direkt nach dem einmaligen Siegeln beim Deploy), riss dabei nicht einmal
|
||
// eine Luecke: das Ergebnis war `valid: true` trotz gefaelschter gesiegelter
|
||
// Zeile. Die Blaetter blieben verwaist zurueck und wurden nie konsultiert.
|
||
//
|
||
// Deshalb gilt jetzt: Blaetter vorhanden, aber kein gueltiger Marker =
|
||
// Siegel ENTFERNT und damit ein Befund – nicht „nie versiegelt“.
|
||
const blattAnzahl = await prisma.auditBacklogSeal.count();
|
||
const unsignierteAnzahl = await prisma.auditLog.count({ where: { hashVersion: { lt: 3 } } });
|
||
if (!siegel && blattAnzahl > 0 && siegelSchluessel.length > 0) {
|
||
backlogSealStatus = 'entfernt';
|
||
} else if (!siegel && blattAnzahl === 0 && unsignierteAnzahl === 0) {
|
||
// Es gibt gar keinen Altbestand: Entweder ist alles signiert, oder das Log
|
||
// beginnt erst mit der Signierung. Dann ist „nicht versiegelt“ kein Mangel
|
||
// (Pentest R183-03) – die bisherige Warnung liess sich nicht aufloesen,
|
||
// weil seal-backlog zu Recht ablehnte. Eine Warnung, die der Betreiber
|
||
// nicht beheben kann, lernt er zu ignorieren.
|
||
//
|
||
// Die Bedingung haengt bewusst an der Zahl der UNSIGNIERTEN Zeilen und
|
||
// nicht mehr an `v3FromId === null`. Letzteres bedeutet naemlich das
|
||
// genaue Gegenteil: dass ueberhaupt nichts signiert ist – etwa weil
|
||
// AUDIT_HMAC_KEY fehlt. Ein vollstaendig unsigniertes Log meldete damit
|
||
// „Bestandssiegel nicht noetig“, also Entwarnung fuer genau den Zustand
|
||
// mit der geringsten Beweiskraft. Jetzt bleibt es bei „kein_siegel“; die
|
||
// Fehlermeldung von seal-backlog nennt den fehlenden Schluessel als
|
||
// naechsten Schritt, die Warnung ist also aufloesbar.
|
||
backlogSealStatus = 'nicht_noetig';
|
||
}
|
||
|
||
if (siegel) {
|
||
try {
|
||
const meta = JSON.parse(siegel.changesAfter || '{}');
|
||
const bis: number = meta.toId;
|
||
const blaetter = await prisma.auditBacklogSeal.findMany();
|
||
const erwartet = new Map(blaetter.map((b) => [b.auditLogId, b.leafHash]));
|
||
|
||
const altzeilen = await prisma.auditLog.findMany({
|
||
where: { id: { lte: bis } },
|
||
orderBy: { id: 'asc' },
|
||
});
|
||
const vorhanden = new Map(altzeilen.map((r) => [r.id, r]));
|
||
|
||
for (const [id, blatt] of erwartet) {
|
||
const zeile = vorhanden.get(id);
|
||
if (!zeile) {
|
||
// FEHLENDE gesiegelte Zeile ist ein Siegelbruch, keine blosse Luecke
|
||
// (Pentest R173-02). Vorher wurde hier uebersprungen: das Loeschen
|
||
// eines Einbruchsbelegs erschien nur als "unerklaerte Luecke" –
|
||
// ununterscheidbar von den als meist harmlos dokumentierten
|
||
// Race-Luecken –, waehrend der dedizierte Indikator "intakt" meldete.
|
||
backlogMissing.push(id);
|
||
continue;
|
||
}
|
||
if (zeile.hashVersion >= 3 || backlogLeaf(zeile as unknown as Record<string, unknown>) !== blatt) {
|
||
backlogTampered.push(id);
|
||
}
|
||
}
|
||
|
||
// Wurzel gegenrechnen: faengt auch Zeilen ab, die dem Siegel
|
||
// nachtraeglich hinzugefuegt oder daraus entfernt wurden.
|
||
const wurzelJetzt = backlogRoot(blaetter);
|
||
backlogSealRoot = meta.root;
|
||
backlogSealStatus =
|
||
backlogTampered.length === 0 && backlogMissing.length === 0 && wurzelJetzt === meta.root
|
||
? 'intakt'
|
||
: 'gebrochen';
|
||
|
||
// ---------------------------------------------------------------
|
||
// Beglaubigte Alt-Luecken
|
||
//
|
||
// Die Race-Luecken aus der Zeit vor dem Sperr-Fix lassen sich nicht
|
||
// mehr heilen: die Verkettung ist gebrochen, die Inhalte sind aber
|
||
// unversehrt. Wuerden sie `valid` dauerhaft auf false halten, meldete
|
||
// das Gegenbuch stuendlich Alarm, ohne dass es je etwas zu tun gaebe -
|
||
// und genau daran stirbt jede Warnung. Ein Signal, das immer schreit,
|
||
// warnt nicht mehr.
|
||
//
|
||
// Deshalb gilt eine Luecke als BEGLAUBIGT, wenn beides zutrifft:
|
||
// 1. sie liegt im versiegelten Bereich (id <= toId), und
|
||
// 2. sie steht im Vorbefund des Siegel-Markers, also im Zustand, den
|
||
// der Betreiber beim Siegeln ausdruecklich festgeschrieben hat.
|
||
// Der Vorbefund liegt in `changesBefore` und ist ab Version 3 mit-
|
||
// gehasht - die Liste laesst sich also ohne Schluessel nicht nachtraeg-
|
||
// lich erweitern (gleiche Absicherung wie beim Manifest, R171-01).
|
||
//
|
||
// Beglaubigt heisst NICHT verschwunden: die Luecken bleiben in
|
||
// `chainGaps` und werden weiter berichtet. Sie zaehlen nur nicht mehr
|
||
// als offener Befund. Alles andere schlaegt unveraendert an - eine
|
||
// NEUE Luecke, eine veraenderte oder entfernte Altzeile, ein gebroche-
|
||
// nes Siegel. Bei nicht intaktem Siegel wird gar nichts beglaubigt.
|
||
if (backlogSealStatus === 'intakt') {
|
||
try {
|
||
const roh = siegel.changesEncrypted
|
||
? decrypt(siegel.changesBefore || '')
|
||
: siegel.changesBefore || '{}';
|
||
const vorbefund = JSON.parse(roh);
|
||
for (const id of vorbefund?.befund?.ketten_luecken || []) {
|
||
if (typeof id === 'number' && id <= bis) beglaubigteLuecken.add(id);
|
||
}
|
||
} catch {
|
||
// Unlesbarer Vorbefund beglaubigt nichts - die sichere Richtung.
|
||
}
|
||
}
|
||
} catch {
|
||
backlogSealStatus = 'gebrochen';
|
||
}
|
||
}
|
||
tamperedEntries.push(...backlogTampered, ...backlogMissing);
|
||
|
||
for (let i = 0; i < logs.length; i++) {
|
||
const log = logs[i];
|
||
|
||
// Erwartete Pruefstaerke aus der Kette, NICHT aus der Selbstauskunft.
|
||
const erwartet = erwarteteVersion(log.id);
|
||
if (log.hashVersion !== erwartet) {
|
||
// Deklarierte Version passt nicht zur Position in der Kette →
|
||
// Downgrade-Versuch (oder manipulierte Version).
|
||
tamperedEntries.push(log.id);
|
||
continue;
|
||
}
|
||
|
||
// Pruefverfahren richtet sich nach der erwarteten Version:
|
||
// 3 = HMAC-signiert (Schluessel ausserhalb der DB)
|
||
// 2 = SHA-256 ueber alle Inhaltsspalten
|
||
// 1 = SHA-256 ueber 7 Felder, zusaetzlich historische Serialisierung
|
||
let hashOk: boolean;
|
||
if (erwartet >= 2) {
|
||
const inhalt = {
|
||
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,
|
||
};
|
||
|
||
if (erwartet === 3) {
|
||
// HMAC-signiert: ohne JEDEN Schluessel ist die Zeile nicht pruefbar.
|
||
// Das als "manipuliert" zu melden waere ein Fehlalarm – daher eigener
|
||
// Topf. Ein FALSCHER Schluessel ist dagegen nicht von einer Faelschung
|
||
// zu unterscheiden und wird bewusst als Befund gemeldet.
|
||
const kandidaten = [auditHmacKey(), ...auditHmacKeysOld()].filter(
|
||
(k): k is string => !!k,
|
||
);
|
||
if (kandidaten.length === 0) {
|
||
unverifiableEntries.push(log.id);
|
||
continue;
|
||
}
|
||
hashOk = kandidaten.some((k) => log.hash === generateHashV3(inhalt, k));
|
||
} else {
|
||
hashOk = log.hash === generateHashV2(inhalt);
|
||
}
|
||
} 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);
|
||
// Ist die AKTUELLE Zeile HMAC-signiert, laesst sich ihr
|
||
// `previousHash` ohne Schluessel nicht nachziehen. Eine unerklaerte
|
||
// Luecke davor bedeutet dann zwingend: der Vorgaenger wurde
|
||
// veraendert oder entfernt. Das ist kein struktureller Zufall,
|
||
// sondern ein Befund.
|
||
//
|
||
// Dieser Pfad schliesst zugleich die Restluecke der Versionsgrenze:
|
||
// Wird die FRUEHESTE V3-Zeile herabgestuft, wandert `MIN(id)` mit und
|
||
// die Grenze allein wuerde den Downgrade durchwinken – der Nachfolger
|
||
// erzeugt aber eine nicht faelschbare Luecke.
|
||
if (erwartet === 3) {
|
||
tamperedEntries.push(log.id);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Eskalierte Luecken stehen sowohl in tamperedEntries als auch in chainGaps;
|
||
// ohne Entdopplung zaehlte dieselbe Zeile zweimal (Pentest R171-03).
|
||
//
|
||
// Beglaubigte Alt-Luecken zaehlen NICHT als offener Befund (siehe die
|
||
// Begruendung an `beglaubigteLuecken`). Sie bleiben in `chainGaps`
|
||
// sichtbar und stehen zusaetzlich in `attestedGaps`.
|
||
const attestedGaps = chainGaps.filter((id) => beglaubigteLuecken.has(id));
|
||
const offeneLuecken = chainGaps.filter((id) => !beglaubigteLuecken.has(id));
|
||
const invalidEntries = [...new Set([...tamperedEntries, ...offeneLuecken])].sort((a, b) => a - b);
|
||
|
||
// Ein gebrochenes oder entferntes Siegel muss `valid` kippen, auch wenn keine
|
||
// einzelne Zeile beanstandet ist – sonst bliebe der stille Anker-Verlust
|
||
// unsichtbar (R174-01).
|
||
const siegelInOrdnung =
|
||
backlogSealStatus === 'intakt' ||
|
||
backlogSealStatus === 'kein_siegel' ||
|
||
backlogSealStatus === 'nicht_noetig';
|
||
|
||
return {
|
||
valid: invalidEntries.length === 0 && siegelInOrdnung,
|
||
checkedCount: logs.length,
|
||
invalidEntries,
|
||
tamperedEntries,
|
||
chainGaps,
|
||
unexplainedGaps,
|
||
unverifiableEntries,
|
||
attestedGaps,
|
||
backlogSealStatus,
|
||
backlogTampered,
|
||
backlogMissing,
|
||
backlogSealCount,
|
||
backlogSealRoot,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Hash-Kette komplett neu berechnen (Reparatur)
|
||
*/
|
||
export async function rehashAll(
|
||
ausgeloestVon?: { userEmail?: string; ipAddress?: string },
|
||
): Promise<{ rehashedCount: number }> {
|
||
// ZUSTAND VOR DEM REHASH SICHERN (Pentest R170-01)
|
||
//
|
||
// Ein Rehash macht die Kette rechnerisch wieder stimmig – auch dann, wenn sie
|
||
// vorher berechtigte Beanstandungen enthielt. Der bisherige Marker hielt nur
|
||
// fest, DASS rehasht wurde, nicht WAS dabei verschwand. Wer `audit:admin`
|
||
// besitzt, konnte damit Spuren glattziehen, ohne dass hinterher erkennbar
|
||
// war, welche.
|
||
//
|
||
// Deshalb wird der Befund samt Kettenkopf VOR dem Rehash erhoben und im
|
||
// Marker mitgeschrieben. Der Marker entsteht nach dem Rehash, ist selbst
|
||
// Teil der neuen Kette und signiert; entfernen liesse er sich nur unter
|
||
// Hinterlassung einer Luecke.
|
||
const vorher = await verifyIntegrity();
|
||
const kopf = await prisma.auditLog.findFirst({
|
||
orderBy: { id: 'desc' },
|
||
select: { id: true, hash: true, hashVersion: true, createdAt: true },
|
||
});
|
||
|
||
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,
|
||
},
|
||
});
|
||
|
||
const rehashKey = auditHmacKey();
|
||
let previousHash: string | null = null;
|
||
let count = 0;
|
||
|
||
for (const log of logs) {
|
||
// Rehash schreibt die hoechste verfuegbare Stufe – ein Rueckfall auf eine
|
||
// schwaechere Version wuerde die Abdeckung nachtraeglich verkleinern.
|
||
const inhalt: AuditHashV2Input = { ...log, previousHash };
|
||
const hash: string = rehashKey ? generateHashV3(inhalt, rehashKey) : generateHashV2(inhalt);
|
||
|
||
await prisma.auditLog.update({
|
||
where: { id: log.id },
|
||
data: { hash, previousHash, hashVersion: rehashKey ? 3 : 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: ausgeloestVon?.userEmail || 'system',
|
||
userRole: 'System',
|
||
action: 'UPDATE',
|
||
resourceType: 'AuditLog',
|
||
resourceLabel:
|
||
`Hash-Kette neu berechnet (${count} Einträge) – Beweiskraft der Vergangenheit zurückgesetzt` +
|
||
(vorher.valid
|
||
? ' – Kette war vorher unbeanstandet'
|
||
: ` – vorher beanstandet: ${vorher.tamperedEntries.length} manipuliert, ` +
|
||
`${vorher.chainGaps.length} Lücken`),
|
||
endpoint: '/api/audit-logs/rehash',
|
||
httpMethod: 'POST',
|
||
ipAddress: ausgeloestVon?.ipAddress || 'system',
|
||
sensitivity: 'CRITICAL',
|
||
// Befund VOR dem Rehash – ohne das waere nach dem Rehash nicht mehr
|
||
// nachvollziehbar, was uebertuencht wurde.
|
||
changesBefore: {
|
||
geprueft: vorher.checkedCount,
|
||
manipuliert: vorher.tamperedEntries,
|
||
ketten_luecken: vorher.chainGaps,
|
||
luecken_ohne_dokumentierte_loeschung: vorher.unexplainedGaps,
|
||
nicht_pruefbar: vorher.unverifiableEntries,
|
||
kettenkopf: kopf
|
||
? { id: kopf.id, hash: kopf.hash, hashVersion: kopf.hashVersion, createdAt: kopf.createdAt }
|
||
: null,
|
||
},
|
||
changesAfter: { neuBerechnet: count },
|
||
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,
|
||
});
|
||
}
|