Files
opencrm/backend/src/services/audit.service.ts
T
duffyduckandClaude Opus 5 375d4ae1e2 Gegenbuch: verifizierender Leser statt Absichtserklaerung (Pentest R175)
R175-01 (HIGH): Die erste Fassung signierte zwar, prueft aber nie. Sie las ihre
Wahrheit per readFileSync aus der lokalen Arbeitsdatei, nirgends gab es ein
git verify-commit - das -S war write-only ohne Konsument. Live reproduziert:
DB-Tail abgeschnitten und die lokale Ledger-Zeile angepasst -> "OK, Checkpoint
beglaubigt", exit 0, kein Alarm.

Fix: Wahrheitsquelle ist der signierte Commit-Baum (bevorzugt der Remote-Kopf);
jeder Commit mit Gegenbuch-Aenderung muss eine gueltige Signatur tragen; weicht
die Arbeitsdatei vom signierten Stand ab, wird abgebrochen; lokale, nie
gepushte Commits gelten nicht als beglaubigt; die Signatur des frisch
erzeugten Commits wird gegengeprueft. Dazu ein Pruefmodus --check fuer
Auditoren.

R175-02 (MEDIUM): /checkpoint fuhr je Aufruf ein volles verifyIntegrity (O(n),
0,85 s bei 16k Zeilen) - authentifizierte DoS-Verstaerkung. Gebraucht wurde nur
die Siegel-Wurzel. Jetzt Kopf-Hash aus der Kopfzeile, Wurzel aus dem juengsten
gueltigen Marker, sealLeafCount statt des teuren Status.

R175-03: writeFileSync lief vor dem Commit, eine verwaiste Zeile wurde vom
Folgelauf zementiert. Jetzt Ruecknahme bei Fehlschlag, und NOTARY_SIGN=false
verlangt zusaetzlich NOTARY_INSECURE_ACK.

Verifiziert mit echtem SSH-Signaturschluessel: stilles Waschen -> Alarm;
erfundene Zeile -> Alarm; unsignierter Commit -> Alarm; Commit-Fehlschlag ->
zurueckgerollt; Reflex-Schalter verweigert; Pruefmodus haengt nichts an.

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

1541 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { AuditAction, AuditSensitivity, Prisma } from '@prisma/client';
import crypto from 'crypto';
import { encrypt, decrypt } from '../utils/encryption.js';
import prisma from '../lib/prisma.js';
/**
* Vereinfachte Audit-Log-Funktion für gezielte Änderungsprotokolle.
* Wird direkt in Controllern aufgerufen mit aussagekräftigen Details.
*/
export async function logChange(opts: {
req: any; // Express Request (für userId, email, IP)
action: AuditAction;
resourceType: string;
resourceId?: string;
label: string; // Menschenlesbares Label z.B. "Vollmacht für Stefan Hacker widerrufen"
details?: Record<string, unknown>; // Zusätzliche Details z.B. { vorher: 'erteilt', nachher: 'widerrufen' }
customerId?: number;
}) {
try {
const user = opts.req?.user;
await createAuditLog({
userId: user?.userId,
userEmail: user?.email || 'system',
userRole: user?.isCustomerPortal ? 'Kundenportal' : 'Mitarbeiter',
customerId: user?.customerId,
isCustomerPortal: user?.isCustomerPortal,
action: opts.action,
resourceType: opts.resourceType,
resourceId: opts.resourceId,
resourceLabel: opts.label,
endpoint: opts.req?.path || '',
httpMethod: opts.req?.method || '',
ipAddress: opts.req?.socket?.remoteAddress || opts.req?.headers?.['x-forwarded-for'] || 'unknown',
dataSubjectId: opts.customerId,
changesAfter: opts.details,
});
} catch (error) {
console.error('[logChange] Fehler:', error);
}
}
export interface CreateAuditLogData {
userId?: number;
userEmail: string;
userRole?: string;
customerId?: number;
isCustomerPortal?: boolean;
action: AuditAction;
sensitivity?: AuditSensitivity;
resourceType: string;
resourceId?: string;
resourceLabel?: string;
endpoint: string;
httpMethod: string;
ipAddress: string;
userAgent?: string;
changesBefore?: Record<string, unknown>;
changesAfter?: Record<string, unknown>;
dataSubjectId?: number;
legalBasis?: string;
success?: boolean;
errorMessage?: string;
durationMs?: number;
}
export interface AuditLogSearchParams {
userId?: number;
customerId?: number;
dataSubjectId?: number;
action?: AuditAction;
sensitivity?: AuditSensitivity;
resourceType?: string;
resourceId?: string;
startDate?: Date;
endDate?: Date;
success?: boolean;
search?: string;
page?: number;
limit?: number;
}
/**
* Generiert einen SHA-256 Hash für einen Audit-Log-Eintrag
*/
function generateHash(data: {
userEmail: string;
action: AuditAction;
resourceType: string;
resourceId?: string | null;
endpoint: string;
createdAt: Date;
previousHash?: string | null;
}): string {
// WICHTIG Pentest R121 (Verify-Fehlalarm, ~73% "manipuliert"):
// Beim Schreiben war `resourceId` bei middleware-generierten Einträgen
// oft `undefined`. JSON.stringify LÄSST einen undefined-Wert weg der
// Hash wurde also OHNE `resourceId`-Key gebildet. In der DB landet der
// Wert aber als `NULL`. verifyIntegrity/rehashAll lasen ihn als `null`
// zurück, und `JSON.stringify({resourceId: null})` schreibt
// `"resourceId":null` REIN → anderer Hash → falscher Tamper-Alarm für
// jede Zeile mit leerem resourceId.
//
// Fix: nullish resourceId weglassen reproduziert exakt das historische
// Schreibverhalten (undefined → Key weg). Kein Caller hat je `null`
// explizit übergeben (verifiziert), daher matchen ALLE Bestands-Hashes
// ohne Rehash. Feldreihenfolge bleibt identisch zur Alt-Serialisierung.
const payload: Record<string, unknown> = {
userEmail: data.userEmail,
action: data.action,
resourceType: data.resourceType,
};
if (data.resourceId !== null && data.resourceId !== undefined) {
payload.resourceId = data.resourceId;
}
payload.endpoint = data.endpoint;
payload.createdAt = data.createdAt.toISOString();
payload.previousHash = data.previousHash || '';
return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
}
/**
* Historische Hash-Variante (Bestandsdaten ~08.02.01.05.2026).
*
* Der R121-Fix ging davon aus, dass `resourceId` beim Schreiben immer
* `undefined` war (→ Key faellt bei JSON.stringify weg) und daher ALLE
* Bestands-Hashes ohne Rehash matchen. Das stimmt erst ab ~01.05.2026:
* aeltere Zeilen wurden mit explizitem `null` serialisiert, der Key war also
* DRIN. Ergebnis war ein Dauer-Fehlalarm ueber ~3100 Zeilen (67 % des Logs)
* und ein Alarm, der staendig grundlos ausloest, verdeckt echte Manipulation.
*
* Diese Funktion reproduziert exakt das alte Schreibverhalten, damit
* verifyIntegrity Altbestand als gueltig erkennt OHNE Rehash. Ein Rehash
* waere der naheliegende Schnellfix, wuerde die Manipulations-Beweiskraft der
* Vergangenheit aber unwiederbringlich zerstoeren.
*
* Sicherheit: Beide Varianten hashen dieselben Feldwerte, nur die
* Serialisierung des nullish `resourceId` unterscheidet sich. Ein Angreifer
* gewinnt dadurch keinen Spielraum, Inhalte zu aendern nur die Kodierung
* eines leeren Feldes ist doppelt zulaessig.
*/
function generateHashLegacy(data: {
userEmail: string;
action: AuditAction;
resourceType: string;
resourceId?: string | null;
endpoint: string;
createdAt: Date;
previousHash?: string | null;
}): string {
const payload: Record<string, unknown> = {
userEmail: data.userEmail,
action: data.action,
resourceType: data.resourceType,
resourceId: data.resourceId ?? null,
endpoint: data.endpoint,
createdAt: data.createdAt.toISOString(),
previousHash: data.previousHash || '',
};
return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
}
/**
* 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 beanstandeten Zeilen (tampered + chainGaps) Abwaertskompatibilitaet. */
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[];
/**
* 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';
/** 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[] = [];
let backlogSealStatus: 'kein_siegel' | 'intakt' | 'gebrochen' | 'entfernt' = '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();
if (!siegel && blattAnzahl > 0 && siegelSchluessel.length > 0) {
backlogSealStatus = 'entfernt';
}
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';
} 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).
const invalidEntries = [...new Set([...tamperedEntries, ...chainGaps])].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';
return {
valid: invalidEntries.length === 0 && siegelInOrdnung,
checkedCount: logs.length,
invalidEntries,
tamperedEntries,
chainGaps,
unexplainedGaps,
unverifiableEntries,
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,
});
}