Audit-Haerten: Fork, Feldabdeckung, Refresh-Rauschen, Route (Pentest R166)

R166-01 (HIGH): Der GET_LOCK-Ansatz gab die Sperre im finally INNERHALB des
Transaktions-Callbacks frei, also vor dem COMMIT. Im Fenster Release-Commit las
der naechste Schreiber ein noch nicht sichtbares Kettenende - zwei Zeilen hingen
am selben Vorgaenger. Meine vorherige Messung war zu schwach: sie suchte Luecken
zwischen Nachbarn, nicht Forks. Fix: einzeiliger Mutex AuditChainLock mit
FOR UPDATE (InnoDB-Zeilensperren fallen erst beim COMMIT) plus
isolationLevel ReadCommitted. Belegt im Direktvergleich mit geweitetem Fenster:
Release-vor-Commit forkt, Zeilensperre nicht.

R166-02 (MEDIUM): Der Hash deckte nur 7 Felder ab. changesBefore/After, success,
ipAddress, resourceLabel, dataSubjectId, userId/customerId waren ungeschuetzt -
ein Einzeledit dort blieb unsichtbar. Fix: hashVersion + generateHashV2 ueber
alle Inhaltsspalten. Bestandszeilen behalten Version 1 und bleiben ohne Rehash
gueltig. Verifiziert: 5/5 zuvor ungeschuetzte Felder werden jetzt erkannt.

R166-03 (LOW): "kein Cookie" (normaler Erstbesuch) wurde als HIGH/abgelehnt
gefuehrt - jetzt eigener Ausgang mit LOW. Nur echte Ablehnung bleibt HIGH.

R166-04 (LOW, pre-existing): GET /retention-policies wurde von GET /:id
verschluckt. Konkrete Routen jetzt vor der Parameter-Route.

Design-Empfehlungen: runRetentionCleanup schreibt ein Loeschungs-Manifest
(ID-Bereich, Anzahl, Policy, Cutoff) als eigenen verketteten Eintrag - Luecken
ausserhalb bleiben erklaerungsbeduerftig. rehashAll schreibt einen Marker.

Verifiziert: 50 parallele Schreiber -> 50/50, 0 Forks, alle V2, manipuliert 0,
Luecken unveraendert 7. tsc + vite build gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 14:56:10 +02:00
co-authored by Claude Opus 5
parent c7d6b6de7e
commit f2a4baacdb
7 changed files with 431 additions and 108 deletions
@@ -0,0 +1,15 @@
-- Einzeiliger Mutex fuer die Audit-Hash-Kette (Pentest R166-01).
--
-- Vorher wurde ueber GET_LOCK serialisiert. Dessen RELEASE_LOCK muss auf
-- derselben Verbindung laufen und stand daher im finally INNERHALB des
-- Transaktions-Callbacks - also VOR dem COMMIT. In diesem Fenster konnte der
-- naechste Schreiber die Sperre holen und das Kettenende lesen, bevor die
-- Vorgaengerzeile committed war: beide haengten sich an denselben Vorgaenger
-- (Fork). InnoDB-Zeilensperren fallen dagegen erst beim COMMIT.
CREATE TABLE IF NOT EXISTS `AuditChainLock` (
`id` INT NOT NULL,
`updatedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT IGNORE INTO `AuditChainLock` (`id`, `updatedAt`) VALUES (1, NOW(3));
@@ -0,0 +1,13 @@
-- Hash-Versionierung fuer Audit-Eintraege (Pentest R166-02).
--
-- Der bisherige Hash deckte nur 7 Felder ab (userEmail, action, resourceType,
-- resourceId, endpoint, createdAt, previousHash). NICHT gehasht waren u. a.
-- changesBefore/changesAfter (die eigentliche Nutzlast), success, ipAddress,
-- resourceLabel, dataSubjectId, userId/customerId - ein nachtraeglicher
-- Einzeledit an genau diesen Feldern blieb also unsichtbar.
--
-- Version 2 hasht alle Inhaltsspalten. Bestandszeilen behalten Version 1 und
-- werden weiterhin mit dem alten Verfahren geprueft - kein Rehash noetig,
-- die Beweiskraft der Vergangenheit bleibt erhalten.
ALTER TABLE `AuditLog`
ADD COLUMN IF NOT EXISTS `hashVersion` INT NOT NULL DEFAULT 1;
+21
View File
@@ -1221,6 +1221,24 @@ model CarInsuranceDetails {
// ==================== AUDIT LOGGING (DSGVO) ==================== // ==================== AUDIT LOGGING (DSGVO) ====================
/// Einzeiliger Mutex fuer die Audit-Hash-Kette (genau eine Zeile, id = 1).
///
/// Warum eine eigene Tabelle statt GET_LOCK oder `SELECT … FOR UPDATE` auf
/// AuditLog selbst:
/// - GET_LOCK muss auf derselben Verbindung freigegeben werden. Innerhalb des
/// Prisma-Transaktions-Callbacks faellt das Release damit VOR den COMMIT
/// der naechste Schreiber liest das Kettenende, bevor die Vorgaengerzeile
/// sichtbar ist, und haengt sich an denselben Vorgaenger (Fork, R166-01).
/// - `FOR UPDATE` auf das Kettenende von AuditLog nimmt Gap-/Next-Key-Locks,
/// die mit den gleichzeitigen INSERTs kollidieren (Deadlocks, dabei gingen
/// 38 von 40 Eintraegen verloren).
/// InnoDB-Zeilensperren werden erst beim COMMIT freigegeben genau das
/// schliesst das Fenster.
model AuditChainLock {
id Int @id
updatedAt DateTime @updatedAt
}
enum AuditAction { enum AuditAction {
CREATE CREATE
READ READ
@@ -1284,6 +1302,9 @@ model AuditLog {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
hash String? // SHA-256 Hash des Eintrags hash String? // SHA-256 Hash des Eintrags
previousHash String? // Hash des vorherigen Eintrags previousHash String? // Hash des vorherigen Eintrags
/// 1 = Alt-Hash ueber 7 Felder, 2 = Hash ueber alle Inhaltsspalten
/// (Pentest R166-02). Bestandszeilen bleiben mit Version 1 gueltig.
hashVersion Int @default(1)
@@index([userId]) @@index([userId])
@@index([customerId]) @@index([customerId])
+28 -5
View File
@@ -167,6 +167,22 @@ const ACTION_LABELS: Record<string, string> = {
TOKEN_REFRESH: 'Sitzung verlängert', TOKEN_REFRESH: 'Sitzung verlängert',
}; };
/**
* Unterscheidet die drei Ausgaenge von POST /auth/refresh (Pentest R166-03).
*
* "Kein Cookie vorhanden" ist KEIN Ablehnungsfall: Der Frontend-Interceptor
* ruft /refresh beim App-Start auch dann, wenn nie ein Token gesetzt war
* (normaler Erstbesuch). Das als HIGH/"abgelehnt" zu fuehren erzeugt genau das
* Rauschen, das mit der Entrauschung beseitigt werden sollte.
*/
function refreshOutcome(responseBody: unknown, success: boolean): 'ok' | 'no-token' | 'rejected' {
if (success) return 'ok';
const err = responseBody && typeof responseBody === 'object' && 'error' in responseBody
? String((responseBody as { error?: unknown }).error ?? '')
: '';
return /kein refresh-token/i.test(err) ? 'no-token' : 'rejected';
}
/** /**
* Erzeugt ein menschenlesbares Label für den Audit-Log-Eintrag * Erzeugt ein menschenlesbares Label für den Audit-Log-Eintrag
*/ */
@@ -209,8 +225,12 @@ function generateHumanLabel(
} }
if (path.includes('/auth/logout')) return 'Benutzer hat sich abgemeldet'; if (path.includes('/auth/logout')) return 'Benutzer hat sich abgemeldet';
if (path.includes('/auth/refresh')) { if (path.includes('/auth/refresh')) {
const failed = responseBody && typeof responseBody === 'object' && (responseBody as { success?: boolean }).success === false; const success = !(responseBody && typeof responseBody === 'object' && (responseBody as { success?: boolean }).success === false);
return failed ? 'Token-Refresh abgelehnt (ungültig/abgelaufen)' : 'Sitzung verlängert (Token erneuert)'; switch (refreshOutcome(responseBody, success)) {
case 'ok': return 'Sitzung verlängert (Token erneuert)';
case 'no-token': return 'Token-Refresh ohne vorliegenden Token (kein Cookie)';
default: return 'Token-Refresh abgelehnt (ungültig/abgelaufen)';
}
} }
// Kunden-Operationen // Kunden-Operationen
@@ -460,10 +480,13 @@ export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunct
isCustomerPortal: req.user?.isCustomerPortal, isCustomerPortal: req.user?.isCustomerPortal,
action, action,
// Erfolgreicher Token-Refresh ist Routine → LOW statt CRITICAL (sonst Log-Flut). // Erfolgreicher Token-Refresh ist Routine → LOW statt CRITICAL (sonst Log-Flut).
// Fehlgeschlagener Refresh (Replay/Brute-Force-Verdacht) → HIGH, damit er in // Abgelehnter Refresh (Replay/Brute-Force-Verdacht) → HIGH, damit er in der
// der Triage nicht neben legitimen Refreshes untergeht (Pentest R164-01). // Triage nicht neben legitimen Refreshes untergeht (Pentest R164-01).
// "Kein Cookie vorhanden" ist dagegen der normale Erstbesuch → LOW (R166-03).
// Andere Auth-Events behalten ihre Default-Sensitivität (Authentication → CRITICAL). // Andere Auth-Events behalten ihre Default-Sensitivität (Authentication → CRITICAL).
sensitivity: action === 'TOKEN_REFRESH' ? (responseSuccess ? 'LOW' : 'HIGH') : undefined, sensitivity: action === 'TOKEN_REFRESH'
? (refreshOutcome(responseBody, responseSuccess) === 'rejected' ? 'HIGH' : 'LOW')
: undefined,
resourceType: mapping.type, resourceType: mapping.type,
resourceId, resourceId,
resourceLabel, resourceLabel,
+13 -8
View File
@@ -7,29 +7,34 @@ const router = Router();
// Alle Routen erfordern Authentifizierung // Alle Routen erfordern Authentifizierung
router.use(authenticate); router.use(authenticate);
// ACHTUNG Reihenfolge: Alle konkreten Pfade MÜSSEN vor der Parameter-Route
// '/:id' stehen, sonst schluckt diese sie und antwortet mit
// "Ungültige Audit-Log-ID". Genau so war GET /retention-policies unerreichbar
// (Pentest R166-04).
// Audit-Logs abrufen // Audit-Logs abrufen
router.get('/', requirePermission('audit:read'), auditLogController.getAuditLogs); router.get('/', requirePermission('audit:read'), auditLogController.getAuditLogs);
// Audit-Logs exportieren (muss VOR /:id stehen!) // Audit-Logs exportieren
router.get('/export', requirePermission('audit:read'), auditLogController.exportAuditLogs); router.get('/export', requirePermission('audit:read'), auditLogController.exportAuditLogs);
// Retention-Policies
router.get('/retention-policies', requirePermission('audit:admin'), auditLogController.getRetentionPolicies);
router.put('/retention-policies/:id', requirePermission('audit:admin'), auditLogController.updateRetentionPolicy);
// Audit-Logs für einen Kunden (DSGVO) // Audit-Logs für einen Kunden (DSGVO)
router.get('/customer/:customerId', requirePermission('audit:read'), auditLogController.getAuditLogsByCustomer); router.get('/customer/:customerId', requirePermission('audit:read'), auditLogController.getAuditLogsByCustomer);
// Einzelnes Audit-Log abrufen
router.get('/:id', requirePermission('audit:read'), auditLogController.getAuditLogById);
// Hash-Ketten-Integrität prüfen // Hash-Ketten-Integrität prüfen
router.post('/verify', requirePermission('audit:read'), auditLogController.verifyIntegrity); router.post('/verify', requirePermission('audit:read'), auditLogController.verifyIntegrity);
// Hash-Kette reparieren // Hash-Kette reparieren
router.post('/rehash', requirePermission('audit:admin'), auditLogController.rehashAll); router.post('/rehash', requirePermission('audit:admin'), auditLogController.rehashAll);
// Retention-Policies
router.get('/retention-policies', requirePermission('audit:admin'), auditLogController.getRetentionPolicies);
router.put('/retention-policies/:id', requirePermission('audit:admin'), auditLogController.updateRetentionPolicy);
// Retention-Cleanup manuell ausführen // Retention-Cleanup manuell ausführen
router.post('/cleanup', requirePermission('audit:admin'), auditLogController.runRetentionCleanup); router.post('/cleanup', requirePermission('audit:admin'), auditLogController.runRetentionCleanup);
// Einzelnes Audit-Log abrufen als LETZTE GET-Route, siehe Hinweis oben
router.get('/:id', requirePermission('audit:read'), auditLogController.getAuditLogById);
export default router; export default router;
+304 -95
View File
@@ -161,6 +161,86 @@ function generateHashLegacy(data: {
return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex'); 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;
}
function generateHashV2(data: AuditHashV2Input): string {
const n = <T>(v: T | null | undefined): T | null => (v === undefined ? null : v);
// Feldreihenfolge ist Teil des Hashes und darf nicht veraendert werden.
const payload = {
v: 2,
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 || '',
};
return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
}
/** /**
* Bestimmt die Sensitivität basierend auf dem Ressourcentyp * Bestimmt die Sensitivität basierend auf dem Ressourcentyp
*/ */
@@ -215,9 +295,6 @@ function shouldEncryptChanges(_resourceType: string): boolean {
/** /**
* Erstellt einen neuen Audit-Log-Eintrag mit Hash-Kette * Erstellt einen neuen Audit-Log-Eintrag mit Hash-Kette
*/ */
// Name des DB-weiten Locks, ueber den die Hash-Kette serialisiert wird.
const AUDIT_CHAIN_LOCK = 'opencrm_audit_chain';
export async function createAuditLog(data: CreateAuditLogData): Promise<void> { export async function createAuditLog(data: CreateAuditLogData): Promise<void> {
try { try {
// Sensitivität bestimmen falls nicht angegeben // Sensitivität bestimmen falls nicht angegeben
@@ -248,78 +325,99 @@ export async function createAuditLog(data: CreateAuditLogData): Promise<void> {
// haengten sich beide daran, was die Kette zerriss (echte Bruchstellen im // haengten sich beide daran, was die Kette zerriss (echte Bruchstellen im
// Bestand, u. a. 05.05./07.05.2026). // Bestand, u. a. 05.05./07.05.2026).
// //
// Serialisiert wird ueber einen benannten MySQL-Lock (GET_LOCK), NICHT ueber // Serialisiert wird ueber eine Zeilensperre auf dem Einzeiler-Mutex
// `SELECT … FOR UPDATE` am Kettenende: letzteres nimmt Gap-/Next-Key-Locks // `AuditChainLock`. Zwei Alternativen wurden verworfen:
// am Index-Ende, die mit den gleichzeitigen INSERTs kollidieren gemessen // - `SELECT … FOR UPDATE` am Kettenende von AuditLog nimmt Gap-/Next-Key-
// gingen dabei 38 von 40 parallelen Eintraegen durch Deadlocks verloren. // Locks, die mit den gleichzeitigen INSERTs kollidieren gemessen gingen
// Ein FEHLENDER Audit-Eintrag ist unsichtbar und damit schlimmer als ein // 38 von 40 parallelen Eintraegen durch Deadlocks verloren. Ein FEHLENDER
// sichtbarer Kettenbruch. Der benannte Lock kennt keine Gap-Locks und // Audit-Eintrag ist unsichtbar und damit schlimmer als ein sichtbarer
// serialisiert sauber; er liegt in der DB und wirkt daher auch ueber // Kettenbruch.
// mehrere App-Instanzen hinweg. // - 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 // Alles Rechenintensive (Serialisieren/Verschluesseln) passiert bewusst
// VOR der Transaktion, damit die Sperre so kurz wie moeglich gehalten wird. // VOR der Transaktion, damit die Sperre so kurz wie moeglich gehalten wird.
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
// Interaktive Transaktion => alle Queries auf DERSELBEN Verbindung, // Exklusive Zeilensperre auf den Einzeiler-Mutex. Sie faellt erst beim
// Voraussetzung dafuer, dass GET_LOCK/RELEASE_LOCK zusammengehoeren. // COMMIT dadurch sieht der naechste Schreiber die Vorgaengerzeile
const got = await tx.$queryRaw<Array<Record<string, number | null>>>` // garantiert bereits festgeschrieben (Pentest R166-01).
SELECT GET_LOCK(${AUDIT_CHAIN_LOCK}, 10) AS ok await tx.$queryRaw`SELECT id FROM AuditChainLock WHERE id = 1 FOR UPDATE`;
`;
const locked = Number(Object.values(got[0] ?? {})[0] ?? 0) === 1;
try {
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();
const hash = generateHash({ 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();
// Neue Eintraege immer mit Version 2 (volle Feldabdeckung, R166-02).
const hash = generateHashV2({
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,
});
await tx.auditLog.create({
data: {
userId: data.userId,
userEmail: data.userEmail, userEmail: data.userEmail,
userRole: data.userRole,
customerId: data.customerId,
isCustomerPortal: data.isCustomerPortal || false,
action: data.action, action: data.action,
sensitivity,
resourceType: data.resourceType, resourceType: data.resourceType,
resourceId: data.resourceId, resourceId: data.resourceId,
resourceLabel: data.resourceLabel,
endpoint: data.endpoint, 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, createdAt,
hash,
previousHash, previousHash,
}); hashVersion: 2,
},
await tx.auditLog.create({ });
data: { }, {
userId: data.userId, // READ COMMITTED: der Lesevorgang nach der Sperre muss den gerade
userEmail: data.userEmail, // festgeschriebenen Stand sehen. Unter REPEATABLE READ koennte ein
userRole: data.userRole, // Snapshot greifen, der die Vorgaengerzeile noch nicht enthaelt.
customerId: data.customerId, isolationLevel: 'ReadCommitted',
isCustomerPortal: data.isCustomerPortal || false, timeout: 20000,
action: data.action, maxWait: 15000,
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,
},
});
} finally {
// Benannte Locks sind NICHT transaktional ohne explizites Release
// wandert die Sperre mit der Verbindung zurueck in den Pool und
// blockiert alle weiteren Schreiber.
if (locked) {
await tx.$queryRaw`SELECT RELEASE_LOCK(${AUDIT_CHAIN_LOCK}) AS released`;
}
}
}, { timeout: 20000, maxWait: 15000 });
} catch (error) { } catch (error) {
// Audit-Logging darf niemals die Hauptoperation blockieren // Audit-Logging darf niemals die Hauptoperation blockieren
console.error('[AuditService] Fehler beim Erstellen des Audit-Logs:', error); console.error('[AuditService] Fehler beim Erstellen des Audit-Logs:', error);
@@ -473,16 +571,35 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
const logs = await prisma.auditLog.findMany({ const logs = await prisma.auditLog.findMany({
where, where,
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
// Version 2 hasht alle Inhaltsspalten daher vollstaendig laden.
select: { select: {
id: true, id: true,
userId: true,
userEmail: true, userEmail: true,
userRole: true,
customerId: true,
isCustomerPortal: true,
action: true, action: true,
sensitivity: true,
resourceType: true, resourceType: true,
resourceId: true, resourceId: true,
resourceLabel: true,
endpoint: 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, createdAt: true,
hash: true, hash: true,
previousHash: true, previousHash: true,
hashVersion: true,
}, },
}); });
@@ -492,22 +609,39 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
for (let i = 0; i < logs.length; i++) { for (let i = 0; i < logs.length; i++) {
const log = logs[i]; const log = logs[i];
// Hash neu berechnen // Pruefverfahren richtet sich nach der Version, mit der geschrieben wurde.
const expectedHash = generateHash({ // Version 2 deckt alle Inhaltsspalten ab; Version 1 nur 7 Felder und darf
userEmail: log.userEmail, // zusaetzlich die historische Serialisierung nutzen (generateHashLegacy).
action: log.action, let hashOk: boolean;
resourceType: log.resourceType, if (log.hashVersion >= 2) {
resourceId: log.resourceId, hashOk = log.hash === generateHashV2({
endpoint: log.endpoint, userId: log.userId,
createdAt: log.createdAt, userEmail: log.userEmail,
previousHash: log.previousHash, userRole: log.userRole,
}); customerId: log.customerId,
isCustomerPortal: log.isCustomerPortal,
// Prüfen ob Hash übereinstimmt Altbestand darf die historische action: log.action,
// Serialisierung nutzen (siehe generateHashLegacy). Legacy wird nur sensitivity: log.sensitivity,
// geprüft, wenn die aktuelle Variante nicht passt. resourceType: log.resourceType,
if (log.hash !== expectedHash) { resourceId: log.resourceId,
const legacyHash = generateHashLegacy({ 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,
});
} else {
const v1 = {
userEmail: log.userEmail, userEmail: log.userEmail,
action: log.action, action: log.action,
resourceType: log.resourceType, resourceType: log.resourceType,
@@ -515,11 +649,13 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
endpoint: log.endpoint, endpoint: log.endpoint,
createdAt: log.createdAt, createdAt: log.createdAt,
previousHash: log.previousHash, previousHash: log.previousHash,
}); };
if (log.hash !== legacyHash) { hashOk = log.hash === generateHash(v1) || log.hash === generateHashLegacy(v1);
tamperedEntries.push(log.id); }
continue;
} if (!hashOk) {
tamperedEntries.push(log.id);
continue;
} }
// Prüfen ob previousHash mit dem Hash des vorherigen Eintrags übereinstimmt // Prüfen ob previousHash mit dem Hash des vorherigen Eintrags übereinstimmt
@@ -550,11 +686,28 @@ export async function rehashAll(): Promise<{ rehashedCount: number }> {
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
select: { select: {
id: true, id: true,
userId: true,
userEmail: true, userEmail: true,
userRole: true,
customerId: true,
isCustomerPortal: true,
action: true, action: true,
sensitivity: true,
resourceType: true, resourceType: true,
resourceId: true, resourceId: true,
resourceLabel: true,
endpoint: 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, createdAt: true,
}, },
}); });
@@ -563,25 +716,37 @@ export async function rehashAll(): Promise<{ rehashedCount: number }> {
let count = 0; let count = 0;
for (const log of logs) { for (const log of logs) {
const hash = generateHash({ // Rehash schreibt immer Version 2 (volle Feldabdeckung) ein Rueckfall
userEmail: log.userEmail, // auf Version 1 wuerde die Abdeckung nachtraeglich wieder verkleinern.
action: log.action, const hash = generateHashV2({ ...log, previousHash });
resourceType: log.resourceType,
resourceId: log.resourceId,
endpoint: log.endpoint,
createdAt: log.createdAt,
previousHash,
});
await prisma.auditLog.update({ await prisma.auditLog.update({
where: { id: log.id }, where: { id: log.id },
data: { hash, previousHash }, data: { hash, previousHash, hashVersion: 2 },
}); });
previousHash = hash; previousHash = hash;
count++; 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: 'system',
userRole: 'System',
action: 'UPDATE',
resourceType: 'AuditLog',
resourceLabel: `Hash-Kette neu berechnet (${count} Einträge) Beweiskraft der Vergangenheit zurückgesetzt`,
endpoint: '/api/audit-logs/rehash',
httpMethod: 'POST',
ipAddress: 'system',
sensitivity: 'CRITICAL',
success: true,
});
return { rehashedCount: count }; return { rehashedCount: count };
} }
@@ -652,6 +817,14 @@ export async function runRetentionCleanup(): Promise<{
const results: Array<{ resourceType: string; sensitivity: string | null; deletedCount: number }> = []; const results: Array<{ resourceType: string; sensitivity: string | null; deletedCount: number }> = [];
let totalDeleted = 0; 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) { for (const policy of policies) {
const cutoffDate = new Date(); const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - policy.retentionDays); cutoffDate.setDate(cutoffDate.getDate() - policy.retentionDays);
@@ -668,8 +841,28 @@ export async function runRetentionCleanup(): Promise<{
where.sensitivity = 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 }); 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({ results.push({
resourceType: policy.resourceType, resourceType: policy.resourceType,
sensitivity: policy.sensitivity, sensitivity: policy.sensitivity,
@@ -679,6 +872,22 @@ export async function runRetentionCleanup(): Promise<{
totalDeleted += 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 { return {
deletedCount: totalDeleted, deletedCount: totalDeleted,
policies: results, policies: results,
+37
View File
@@ -97,6 +97,43 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt ## ✅ Erledigt
- [x] **🛡️ Audit-Haerten: Fork, Feldabdeckung, Refresh-Rauschen, Route (Pentest R166)** (2026-08-18)
- **R166-01 (HIGH) Kette forkte weiter.** Mein GET_LOCK-Ansatz gab die
Sperre im `finally` INNERHALB des Transaktions-Callbacks frei, also VOR dem
COMMIT. Im Fenster Release↔Commit las der naechste Schreiber ein noch nicht
sichtbares Kettenende → zwei Zeilen am selben Vorgaenger. Meine
„100 parallel → 0 Brueche“-Messung war zu schwach: sie suchte Luecken
zwischen Nachbarn, nicht Forks, und das Fenster ist lokal sehr schmal.
Fix: einzeiliger Mutex `AuditChainLock` + `FOR UPDATE`; InnoDB-Zeilensperren
fallen erst beim COMMIT. Dazu `isolationLevel: ReadCommitted`, damit der
Lesevorgang den frisch festgeschriebenen Stand sieht.
**Belegt** im Direktvergleich mit kuenstlich geweitetem Fenster:
Release-vor-Commit → Fork, Zeilensperre → kein Fork.
- **R166-02 (MEDIUM) Hash deckte nur 7 Felder.** `changesBefore/After`
(die eigentliche Nutzlast), `success`, `ipAddress`, `resourceLabel`,
`dataSubjectId`, `userId`/`customerId` waren NICHT gehasht ein Einzeledit
dort blieb unsichtbar. Fix: `hashVersion` (Migration `20260818150000`) +
`generateHashV2` ueber alle Inhaltsspalten. Bestandszeilen behalten
Version 1 und bleiben ohne Rehash gueltig. `rehashAll` schreibt V2.
Verifiziert: Manipulation an success/changesAfter/ipAddress/resourceLabel/
dataSubjectId wird jetzt **5/5 erkannt**, Altbestand weiter gueltig.
- **R166-03 (LOW)** „kein Cookie“ (normaler Erstbesuch) wurde als
`HIGH / abgelehnt` gefuehrt. Jetzt eigener Ausgang: LOW + Label
„ohne vorliegenden Token“. Nur echte Ablehnung bleibt HIGH.
- **R166-04 (LOW, pre-existing)** `GET /retention-policies` wurde von
`GET /:id` verschluckt. Konkrete Routen jetzt konsequent vor der
Parameter-Route, mit Warnhinweis im Code.
- **Design-Empfehlungen umgesetzt:** `runRetentionCleanup` schreibt ein
Loeschungs-Manifest (Bereich `fromId``toId`, Anzahl, Policy, Cutoff) als
eigenen verketteten Eintrag Luecken ausserhalb bleiben damit
erklaerungsbeduerftig; `rehashAll` schreibt einen Marker, dass die
Beweiskraft der Vergangenheit zurueckgesetzt wurde.
- Verifiziert: 50 parallele Schreiber → 50/50, 0 Forks, alle V2;
manipuliert 0, Luecken unveraendert 7. `tsc` + `vite build` gruen.
- **Offen (bewusst nicht umgesetzt):** externer Anker (HMAC mit Schluessel
ausserhalb der DB). Adressiert Full-DB-Compromise, erfordert aber
Schluesselverwaltung/Rotation im Deployment → Entscheidung des Betreibers.
- [x] **🔎 Audit-Pruefung: „manipuliert“ von „Luecke“ getrennt + Retention fuer Routine-Auth** (2026-08-18) - [x] **🔎 Audit-Pruefung: „manipuliert“ von „Luecke“ getrennt + Retention fuer Routine-Auth** (2026-08-18)
- **Problem 1 (Deutbarkeit):** `verifyIntegrity` warf zwei voellig - **Problem 1 (Deutbarkeit):** `verifyIntegrity` warf zwei voellig
unterschiedliche Befunde in einen Topf und meldete beides als unterschiedliche Befunde in einen Topf und meldete beides als