Compare commits

...
4 Commits
Author SHA1 Message Date
duffyduckandClaude Opus 5 c7d6b6de7e Audit-Pruefung: "manipuliert" von "Luecke" getrennt + Retention fuer Routine-Auth
Problem 1 (Deutbarkeit): verifyIntegrity warf zwei voellig unterschiedliche
Befunde in einen Topf und meldete beides als "N manipulierte Eintraege". Eine
harmlose Verkettungsluecke sah damit aus wie ein Angriff - die Meldung war im
Alltag nicht deutbar und dadurch wertlos, dasselbe Muster wie beim
Refresh-Rauschen.

Fix: Rueckgabe um tamperedEntries (Inhalt nachtraeglich veraendert, ernst) und
chainGaps (Verkettung unterbrochen durch parallele Schreibvorgaenge oder
geloeschte Zeilen, meist harmlos) erweitert. invalidEntries bleibt als Summe
erhalten. Controller formuliert die Meldung eindeutig, Frontend-API-Typ
nachgezogen.

Problem 2 (Aufbewahrung): Token-Refreshes landen seit der Entrauschung als
Authentication/LOW. Diese Kombination traf auf keine spezifische Regel und fiel
in die Auffangregel * mit 3650 Tagen - das Rauschen waere 10 Jahre aufbewahrt
worden, echte Logins nur 2. Fix: Regel Authentication/LOW mit 90 Tagen, als
idempotente Migration und im Seed.

Sensitivitaet steuert die Aufbewahrung und ist keine Alarmstufe - normale
Logins und Zugriffe auf Bankdaten/Ausweise bleiben bewusst CRITICAL, ein
Herabstufen wuerde still die Aufbewahrungsfrist verlaengern.

Verifiziert: Live-Test gegen Dev-DB - echte Manipulation einer Zeile wird als
manipuliert erkannt und nicht mit Luecken verwechselt, Ketten-Luecken bleiben
bei 7, Originalzustand exakt wiederhergestellt. tsc + vite build gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:30:36 +02:00
duffyduckandClaude Opus 5 477a850a91 Audit-Kette: Race beim Fortschreiben behoben (parallele Requests)
createAuditLog las den Vorgaenger-Hash und schrieb den neuen Eintrag als zwei
getrennte Schritte. Zwei parallele Requests lasen denselben letzten Hash und
haengten sich beide daran - die Kette zerriss (Bruchstellen im Bestand vom
05.05. und 07.05.2026).

Fix: Lesen + Schreiben in einer Transaktion, serialisiert ueber einen benannten
MySQL-Lock (GET_LOCK). Der Lock liegt in der DB und wirkt daher auch ueber
mehrere App-Instanzen hinweg. Release im finally, weil benannte Locks nicht
transaktional sind - sonst wandert die Sperre mit der Verbindung zurueck in den
Pool und blockiert alle weiteren Schreiber.

Verworfener erster Ansatz: SELECT ... FOR UPDATE auf das Kettenende nimmt Gap-/
Next-Key-Locks, die mit den gleichzeitigen INSERTs kollidieren - gemessen gingen
38 von 40 parallelen Eintraegen durch Deadlocks verloren, still verschluckt vom
catch. Ein fehlender Audit-Eintrag ist unsichtbar und damit gefaehrlicher als
ein sichtbarer Kettenbruch.

Verifiziert: 100 parallele Schreiber -> 100/100 geschrieben, 0 neue Brueche
(444 ms); Folge-Schreiber in 6 ms, IS_FREE_LOCK frei (kein Lock-Leak).
Ungueltige Zeilen bleiben bei den 7 historischen. tsc gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 09:37:34 +02:00
duffyduckandClaude Opus 5 89ae7b73a1 Audit-Integritaet: Dauer-Fehlalarm ueber 67 % des Logs behoben
verifyIntegrity meldete 3107 von 4630 Zeilen als "manipuliert" - davon 3100
Fehlalarme, exakt die Zeilen mit resourceId = NULL aus 08.02.-01.05.2026.

Ursache: Der R121-Fix nahm an, resourceId sei beim Schreiben immer undefined
gewesen (Key faellt bei JSON.stringify weg) und daher wuerden alle
Bestands-Hashes ohne Rehash matchen. Das gilt erst ab ~01.05.2026 - aeltere
Zeilen wurden mit explizitem null serialisiert, der Key war drin.

Sicherheitsrelevant, weil ein staendig grundlos ausloesender Alarm ignoriert
wird - echte Manipulation ginge im Laerm unter.

Fix: generateHashLegacy() reproduziert das alte Schreibverhalten,
verifyIntegrity akzeptiert Altbestand ueber diesen Fallback (nur geprueft,
wenn die aktuelle Variante nicht passt). Bewusst KEIN Rehash - der wuerde die
Manipulations-Beweiskraft der Vergangenheit zerstoeren. Gespeicherte Hashes
bleiben unangetastet.

Verifiziert: ungueltig 3107 -> 7 (echte Ketten-Brueche). Adversarial
gegengetestet: Manipulation an userEmail/action/endpoint/createdAt/resourceId
wird bei alten wie neuen Zeilen zu 100 % erkannt (10/10). tsc gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 09:32:22 +02:00
duffyduckandClaude Opus 5 ad520e20a7 Audit-Log: Pfad-Matching im finish-Handler gefixt (Pentest R165-01)
Die Entrauschung aus de0d6bd war live wirkungslos - aber nicht wegen eines
Deploy-Miss, sondern weil der Code nie erreicht wurde: auditMiddleware liest
req.path erst im res.on('finish')-Handler. Express strippt beim Router-Dispatch
den Mount-Prefix aus req.url und stellt ihn nur beim next()-Durchlauf wieder
her - ein terminaler Handler (res.json()) ruft nie next(), also bleibt req.path
router-relativ (/refresh statt /api/auth/refresh). Saemtliche
path.includes('/auth/...')-Checks liefen ins Leere -> Fallback POST->CREATE mit
Default-Sensitivitaet CRITICAL.

Betraf nicht nur den neuen TOKEN_REFRESH: LOGIN/LOGOUT/LOGIN_FAILED waren im
Audit-Stream seit jeher generisch (pre-existing), ebenso das endpoint-Feld.
Der SecurityEvent-Stream war nie betroffen (eigene emit-Calls), daher lief das
Alerting korrekt.

Fix: vollen Pfad einmal synchron beim Eintritt festhalten (req.originalUrl,
wird von Express nie mutiert) und downstream ausschliesslich diesen nutzen -
determineAction, generateHumanLabel, extractDataSubjectId, manuallyLoggedPaths
und endpoint. TOKEN_REFRESH zusaetzlich in die "immer loggen"-Ausnahme.

Verifiziert (E2E mit echter Middleware gegen Dev-DB): TOKEN_REFRESH/LOW,
TOKEN_REFRESH/HIGH, LOGIN/CRITICAL, LOGIN_FAILED/CRITICAL, LOGOUT/CRITICAL,
alle mit vollem endpoint-Pfad. tsc gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 08:11:44 +02:00
7 changed files with 331 additions and 71 deletions
@@ -0,0 +1,19 @@
-- Aufbewahrungsregel fuer routinemaessige Auth-Eintraege (Token-Refresh).
--
-- Seit der Entrauschung landen erfolgreiche Token-Refreshes als
-- `Authentication / LOW`. Diese Kombination traf auf KEINE spezifische Regel
-- (es gab nur `Authentication / CRITICAL`) und fiel damit in die Auffangregel
-- `*` mit 3650 Tagen. Ergebnis: Das Rauschen waere 10 Jahre aufbewahrt worden,
-- echte Logins dagegen nur 2 Jahre - genau verkehrt herum.
--
-- 90 Tage reichen, um einen Refresh-Vorgang im Nachhinein nachzuvollziehen.
-- Idempotent: der Unique-Index (resourceType, sensitivity) verhindert Dubletten.
INSERT INTO `AuditRetentionPolicy`
(`resourceType`, `sensitivity`, `retentionDays`, `description`, `legalBasis`, `isActive`, `createdAt`, `updatedAt`)
VALUES
('Authentication', 'LOW', 90, 'Routine-Auth (stiller Token-Refresh)', 'Betriebsnotwendigkeit / Datenminimierung (DSGVO Art. 5)', 1, NOW(3), NOW(3))
ON DUPLICATE KEY UPDATE
`retentionDays` = VALUES(`retentionDays`),
`description` = VALUES(`description`),
`legalBasis` = VALUES(`legalBasis`),
`updatedAt` = NOW(3);
+10
View File
@@ -497,6 +497,16 @@ async function main() {
description: 'Allgemeine Einstellungen',
legalBasis: 'Verjährungsfrist (BGB §195)',
},
{
// Stiller Token-Refresh (Routine). Ohne eigene Regel fiele diese
// Kombination in die Auffangregel `*` mit 3650 Tagen das Rauschen
// wäre dann länger aufbewahrt als echte Logins (730 Tage).
resourceType: 'Authentication',
sensitivity: 'LOW' as const,
retentionDays: 90,
description: 'Routine-Auth (stiller Token-Refresh)',
legalBasis: 'Betriebsnotwendigkeit / Datenminimierung (DSGVO Art. 5)',
},
];
for (const policy of specificPolicies) {
+20 -3
View File
@@ -143,15 +143,32 @@ export async function verifyIntegrity(req: AuthRequest, res: Response) {
toId ? parseInt(toId as string) : undefined
);
// Zwei sehr unterschiedliche Befunde sauber trennen vorher wurde beides
// pauschal als "manipuliert" gemeldet, was strukturelle Lücken wie einen
// echten Angriff aussehen liess (und damit die Meldung entwertete).
const tampered = result.tamperedEntries.length;
const gaps = result.chainGaps.length;
const message = tampered > 0
? `${tampered} MANIPULIERTE Einträge gefunden` +
(gaps > 0 ? ` (zusätzlich ${gaps} strukturelle Lücken)` : '')
: gaps > 0
? `Keine Manipulation. ${gaps} strukturelle Lücken in der Verkettung ` +
'(parallel geschriebene oder gelöschte Einträge) Inhalte unverändert.'
: 'Alle Einträge sind unverändert und lückenlos verkettet';
res.json({
success: true,
data: {
valid: result.valid,
checkedCount: result.checkedCount,
invalidEntries: result.invalidEntries,
message: result.valid
? 'Alle Einträge sind valide'
: `${result.invalidEntries.length} manipulierte Einträge gefunden`,
// Ernst: Inhalt einer bestehenden Zeile wurde nachträglich verändert.
tamperedEntries: result.tamperedEntries,
// Meist harmlos: Verkettung unterbrochen, Inhalte selbst unversehrt.
chainGaps: result.chainGaps,
tampered: tampered > 0,
message,
},
});
} catch (error) {
+24 -13
View File
@@ -95,10 +95,10 @@ function findResourceMapping(path: string): { type: string; extractId?: (req: Au
/**
* Extrahiert die betroffene Kunden-ID für DSGVO-Tracking
*/
function extractDataSubjectId(req: AuthRequest): number | undefined {
function extractDataSubjectId(req: AuthRequest, fullPath: string): number | undefined {
// Aus Route-Parameter
const customerId = req.params.customerId || req.params.id;
if (customerId && req.path.includes('/customers')) {
if (customerId && fullPath.includes('/customers')) {
return parseInt(customerId);
}
@@ -174,7 +174,8 @@ function generateHumanLabel(
action: AuditAction,
resourceType: string,
req: AuthRequest,
responseBody: unknown
responseBody: unknown,
fullPath: string
): string {
const typeName = RESOURCE_TYPE_LABELS[resourceType] || resourceType;
const actionName = ACTION_LABELS[action] || action;
@@ -196,7 +197,8 @@ function generateHumanLabel(
}
// Spezial-Labels für bestimmte Endpunkte
const path = req.path;
// (fullPath statt req.path siehe Hinweis in auditMiddleware, Pentest R165)
const path = fullPath;
// Auth
if (path.includes('/auth/login') || path.includes('/auth/customer-login')) {
@@ -371,14 +373,23 @@ function generateHumanLabel(
export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunction): void {
const startTime = Date.now();
// WICHTIG (Pentest R165): `req.path` ist im res.on('finish')-Handler NICHT mehr der
// volle Pfad. Express strippt beim Router-Dispatch den Mount-Prefix aus `req.url`
// und stellt ihn nur beim `next()`-Durchlauf wieder her ein Handler, der die
// Response terminiert (res.json()), ruft nie `next()`, also bleibt `req.path`
// router-relativ (`/refresh` statt `/api/auth/refresh`). Deshalb den vollen Pfad
// EINMAL hier synchron festhalten und downstream ausschliesslich diesen nutzen
// sonst matchen alle Pfad-Checks (/auth/login, /auth/refresh, …) ins Leere.
const fullPath = req.originalUrl?.split('?')[0] || req.path;
// Ausgeschlossene Routen überspringen
if (EXCLUDED_ROUTES.some((route) => req.path.startsWith(route))) {
if (EXCLUDED_ROUTES.some((route) => fullPath.startsWith(route))) {
next();
return;
}
// Resource-Mapping finden
const mapping = findResourceMapping(req.path);
const mapping = findResourceMapping(fullPath);
if (!mapping) {
// Unbekannte Route - trotzdem loggen mit generischem Typ
next();
@@ -414,7 +425,7 @@ export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunct
setImmediate(async () => {
try {
const durationMs = Date.now() - startTime;
const action = determineAction(req.method, req.path, responseSuccess);
const action = determineAction(req.method, fullPath, responseSuccess);
// READ-Aktionen nicht loggen (nur Änderungen, Logins und Exporte)
if (action === 'READ') return;
@@ -427,19 +438,19 @@ export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunct
'/api/gdpr',
'/api/upload',
];
// Login/Logout immer loggen
if (action !== 'LOGIN' && action !== 'LOGOUT' && action !== 'LOGIN_FAILED') {
if (manuallyLoggedPaths.some(p => req.originalUrl?.startsWith(p) || req.baseUrl?.startsWith(p))) return;
// Login/Logout/Refresh immer loggen
if (action !== 'LOGIN' && action !== 'LOGOUT' && action !== 'LOGIN_FAILED' && action !== 'TOKEN_REFRESH') {
if (manuallyLoggedPaths.some(p => fullPath.startsWith(p))) return;
}
const resourceId = mapping.extractId?.(req);
const dataSubjectId = extractDataSubjectId(req);
const dataSubjectId = extractDataSubjectId(req, fullPath);
// Audit-Kontext nutzen (wurde vor Response-Ende erfasst)
const auditContext = capturedAuditContext;
// Menschenlesbares Label generieren
const resourceLabel = generateHumanLabel(action, mapping.type, req, responseBody);
const resourceLabel = generateHumanLabel(action, mapping.type, req, responseBody, fullPath);
await createAuditLog({
userId: req.user?.userId,
@@ -456,7 +467,7 @@ export function auditMiddleware(req: AuthRequest, res: Response, next: NextFunct
resourceType: mapping.type,
resourceId,
resourceLabel,
endpoint: req.path,
endpoint: fullPath,
httpMethod: req.method,
ipAddress: getClientIp(req),
userAgent: req.headers['user-agent'],
+158 -54
View File
@@ -119,6 +119,48 @@ function generateHash(data: {
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');
}
/**
* Bestimmt die Sensitivität basierend auf dem Ressourcentyp
*/
@@ -173,17 +215,11 @@ function shouldEncryptChanges(_resourceType: string): boolean {
/**
* 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> {
try {
// Letzten Hash abrufen für die Kette
const lastLog = await prisma.auditLog.findFirst({
orderBy: { id: 'desc' },
select: { hash: true },
});
const previousHash = lastLog?.hash || null;
const createdAt = new Date();
// Sensitivität bestimmen falls nicht angegeben
const sensitivity = data.sensitivity || determineSensitivity(data.resourceType);
@@ -206,47 +242,84 @@ export async function createAuditLog(data: CreateAuditLogData): Promise<void> {
}
}
// Hash generieren
const hash = generateHash({
userEmail: data.userEmail,
action: data.action,
resourceType: data.resourceType,
resourceId: data.resourceId,
endpoint: data.endpoint,
createdAt,
previousHash,
});
// 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 einen benannten MySQL-Lock (GET_LOCK), NICHT ueber
// `SELECT … FOR UPDATE` am Kettenende: letzteres nimmt Gap-/Next-Key-Locks
// am Index-Ende, die mit den gleichzeitigen INSERTs kollidieren gemessen
// gingen dabei 38 von 40 parallelen Eintraegen durch Deadlocks verloren.
// Ein FEHLENDER Audit-Eintrag ist unsichtbar und damit schlimmer als ein
// sichtbarer Kettenbruch. Der benannte Lock kennt keine Gap-Locks und
// serialisiert sauber; er liegt in der DB und wirkt daher 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) => {
// Interaktive Transaktion => alle Queries auf DERSELBEN Verbindung,
// Voraussetzung dafuer, dass GET_LOCK/RELEASE_LOCK zusammengehoeren.
const got = await tx.$queryRaw<Array<Record<string, number | null>>>`
SELECT GET_LOCK(${AUDIT_CHAIN_LOCK}, 10) AS ok
`;
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();
// Eintrag erstellen
await prisma.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,
},
});
const hash = generateHash({
userEmail: data.userEmail,
action: data.action,
resourceType: data.resourceType,
resourceId: data.resourceId,
endpoint: data.endpoint,
createdAt,
previousHash,
});
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,
},
});
} 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) {
// Audit-Logging darf niemals die Hauptoperation blockieren
console.error('[AuditService] Fehler beim Erstellen des Audit-Logs:', error);
@@ -377,7 +450,20 @@ export async function getAuditLogsByDataSubject(customerId: number) {
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[];
}> {
const where: Prisma.AuditLogWhereInput = {};
@@ -400,7 +486,8 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
},
});
const invalidEntries: number[] = [];
const tamperedEntries: number[] = [];
const chainGaps: number[] = [];
for (let i = 0; i < logs.length; i++) {
const log = logs[i];
@@ -416,25 +503,42 @@ export async function verifyIntegrity(fromId?: number, toId?: number): Promise<{
previousHash: log.previousHash,
});
// Prüfen ob Hash übereinstimmt
// Prüfen ob Hash übereinstimmt Altbestand darf die historische
// Serialisierung nutzen (siehe generateHashLegacy). Legacy wird nur
// geprüft, wenn die aktuelle Variante nicht passt.
if (log.hash !== expectedHash) {
invalidEntries.push(log.id);
continue;
const legacyHash = generateHashLegacy({
userEmail: log.userEmail,
action: log.action,
resourceType: log.resourceType,
resourceId: log.resourceId,
endpoint: log.endpoint,
createdAt: log.createdAt,
previousHash: log.previousHash,
});
if (log.hash !== legacyHash) {
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) {
invalidEntries.push(log.id);
chainGaps.push(log.id);
}
}
}
const invalidEntries = [...tamperedEntries, ...chainGaps].sort((a, b) => a - b);
return {
valid: invalidEntries.length === 0,
checkedCount: logs.length,
invalidEntries,
tamperedEntries,
chainGaps,
};
}
+99
View File
@@ -97,6 +97,105 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
## ✅ Erledigt
- [x] **🔎 Audit-Pruefung: „manipuliert“ von „Luecke“ getrennt + Retention fuer Routine-Auth** (2026-08-18)
- **Problem 1 (Deutbarkeit):** `verifyIntegrity` warf zwei voellig
unterschiedliche Befunde in einen Topf und meldete beides als
„N manipulierte Eintraege“. Eine harmlose Verkettungsluecke sah damit aus
wie ein Angriff die Meldung war im Alltag nicht deutbar und wurde dadurch
wertlos (dasselbe Muster wie beim Refresh-Rauschen).
- Fix: Rueckgabe um `tamperedEntries` (Inhalt einer Zeile nachtraeglich
veraendert **ernst**) und `chainGaps` (Verkettung unterbrochen durch
parallele Schreibvorgaenge oder geloeschte Zeilen **meist harmlos**)
erweitert. `invalidEntries` bleibt als Summe erhalten
(Abwaertskompatibilitaet). Controller formuliert die Meldung entsprechend
eindeutig; Frontend-API-Typ nachgezogen.
- **Problem 2 (Aufbewahrung):** Seit der Entrauschung landen Token-Refreshes
als `Authentication / LOW`. Diese Kombination traf auf keine spezifische
Regel und fiel in die Auffangregel `*` mit 3650 Tagen das **Rauschen
waere 10 Jahre** aufbewahrt worden, echte Logins nur 2 (730 Tage).
- Fix: Regel `Authentication / LOW` → 90 Tage. Als Migration
(`20260818130000`, idempotent per `ON DUPLICATE KEY`) **und** im Seed, damit
sie sowohl bestehende Installationen als auch Neuinstallationen erreicht.
- Hinweis zur Sensitivitaet: Sie steuert die Aufbewahrung, ist also **keine**
Alarmstufe. Normale Logins/Logouts sowie Zugriffe auf Bankdaten/Ausweise
bleiben bewusst CRITICAL. Ein Herabstufen „fuer eine ruhigere Liste“ wuerde
still die Aufbewahrungsfrist verlaengern daher unterlassen.
- Verifiziert: Live-Test gegen Dev-DB echte Manipulation einer Zeile
(`UPDATE … SET userEmail`) wird als **manipuliert** erkannt und nicht mit
Luecken verwechselt; Ketten-Luecken bleiben bei 7; Originalzustand exakt
wiederhergestellt (0 manipuliert danach). `tsc` + `vite build` gruen.
- [x] **🔗 Audit-Kette: Race beim Fortschreiben behoben (parallele Requests)** (2026-08-18)
- `createAuditLog` las den Vorgaenger-Hash und schrieb den neuen Eintrag als
zwei getrennte Schritte. Zwei parallele Requests lasen denselben letzten
Hash und haengten sich beide daran → Kette zerrissen (echte Bruchstellen im
Bestand: 05.05./07.05.2026).
- Fix: Lesen + Schreiben in einer Transaktion, serialisiert ueber einen
benannten MySQL-Lock (`GET_LOCK('opencrm_audit_chain')`). Liegt in der DB,
wirkt daher auch ueber mehrere App-Instanzen hinweg. Release im `finally`,
da benannte Locks nicht transaktional sind (sonst wandert die Sperre mit der
Verbindung zurueck in den Pool und blockiert alle Schreiber).
- **Verworfener erster Ansatz wichtig:** `SELECT … FOR UPDATE` auf das
Kettenende nimmt Gap-/Next-Key-Locks, die mit den gleichzeitigen INSERTs
kollidieren. Gemessen: **38 von 40** parallelen Eintraegen gingen durch
Deadlocks verloren (vom `catch` still verschluckt). Ein FEHLENDER
Audit-Eintrag ist unsichtbar und damit gefaehrlicher als ein sichtbarer
Kettenbruch deshalb der Umbau auf den benannten Lock.
- Verifiziert: 100 parallele Schreiber → **100/100 geschrieben, 0 neue
Brueche** (444 ms); Folge-Schreiber danach in 6 ms, `IS_FREE_LOCK` = frei
(kein Lock-Leak). Gesamtzahl ungueltiger Zeilen bleibt bei den 7
historischen. Rechenintensives (Serialisieren/Verschluesseln) liegt bewusst
VOR der Transaktion, damit die Sperre kurz bleibt. `tsc` gruen.
- [x] **🛡️ Audit-Integritaet: Dauer-Fehlalarm ueber 67 % des Logs behoben** (2026-08-18)
- Beim Nachpruefen aufgefallen: `verifyIntegrity` meldete **3107 von 4630**
Zeilen als „manipuliert“. Davon waren **3100 Fehlalarme** eingegrenzt auf
exakt die Zeilen mit `resourceId = NULL` aus dem Zeitraum 08.02.01.05.2026.
- Ursache: Der R121-Fix nahm an, `resourceId` sei beim Schreiben immer
`undefined` gewesen (Key faellt bei `JSON.stringify` weg) und daher wuerden
**alle** Bestands-Hashes ohne Rehash matchen. Das gilt erst ab ~01.05.2026
aeltere Zeilen wurden mit explizitem `null` serialisiert, der Key war DRIN.
- Warum das sicherheitsrelevant ist: Ein Alarm, der staendig grundlos
ausloest, wird ignoriert **echte** Manipulation ginge im Lärm unter
(gleiches Muster wie beim Refresh-Rauschen, [R165]).
- Fix: `generateHashLegacy()` reproduziert das alte Schreibverhalten;
`verifyIntegrity` akzeptiert Altbestand ueber diesen Fallback (nur geprueft,
wenn die aktuelle Variante nicht passt). **Kein Rehash** der waere der
naheliegende Schnellfix, wuerde die Manipulations-Beweiskraft der
Vergangenheit aber unwiederbringlich zerstoeren. Gespeicherte Hashes
bleiben unangetastet.
- Verifiziert: ungueltige Zeilen **3107 → 7** (die 7 sind echte Ketten-Brueche,
siehe naechster Punkt). Adversarial gegengetestet: Manipulation an
`userEmail`/`action`/`endpoint`/`createdAt`/`resourceId` wird bei ALTEN wie
NEUEN Zeilen weiterhin zu 100 % erkannt (10/10), unveraenderte Zeilen
akzeptiert. `tsc` gruen.
- [x] **🐛 Audit-Log: Pfad-Matching kaputt Auth-Actions generisch (Pentest R165-01)** (2026-08-18)
- Pentester meldete: Entrauschung (`de0d6bd`) live **nicht wirksam** jeder
`/refresh` weiter `CREATE / CRITICAL / „Anmeldung erstellt“`. Zusatzbefund:
auch `/login` und `/logout` liefen als generisches `CREATE`.
- **Kein Deploy-Miss** (Alerting aus `d599eb3` lief ja live), sondern **toter
Code**: `auditMiddleware` liest `req.path` erst im `res.on('finish')`-Handler.
Express strippt beim Router-Dispatch den Mount-Prefix aus `req.url` und stellt
ihn nur beim `next()`-Durchlauf wieder her ein terminaler Handler
(`res.json()`) ruft nie `next()`, also bleibt `req.path` router-relativ
(`/refresh` statt `/api/auth/refresh`). Alle `path.includes('/auth/...')`-Checks
liefen ins Leere → Fallback POST→CREATE + Default-Sensitivität CRITICAL.
Empirisch nachgestellt (Mini-Express: ENTRY `/api/auth/refresh` → FINISH `/refresh`).
- Betraf **nicht nur** den neuen `TOKEN_REFRESH`: `LOGIN`/`LOGOUT`/`LOGIN_FAILED`
waren im Audit-Stream **seit jeher** kaputt (pre-existing), ebenso das
`endpoint`-Feld (router-relativ statt voll). Der SecurityEvent-Stream war nie
betroffen (eigene `emit()`-Calls) daher funktionierte das Alerting korrekt.
- Fix: vollen Pfad **einmal synchron beim Eintritt** festhalten
(`req.originalUrl.split('?')[0]`, wird von Express nie mutiert) und downstream
ausschließlich diesen nutzen in `determineAction`, `generateHumanLabel`,
`extractDataSubjectId`, `manuallyLoggedPaths` und `endpoint`. `TOKEN_REFRESH`
zusätzlich in die „immer loggen“-Ausnahme aufgenommen.
- Verifiziert (E2E mit echter Middleware gegen Dev-DB, 5 Requests):
`TOKEN_REFRESH/LOW` (Erfolg), `TOKEN_REFRESH/HIGH` (Fehlschlag),
`LOGIN/CRITICAL`, `LOGIN_FAILED/CRITICAL`, `LOGOUT/CRITICAL`, alle mit vollem
`endpoint`-Pfad und korrekten Labels. `tsc` grün.
- [x] **🛡️ Refresh-Fehlschlag: Detection-Gap geschlossen (Pentest R164-01)** (2026-08-18)
- Folgefund zum Entrauschen: `determineAction` gab `/auth/refresh` bedingungslos
`TOKEN_REFRESH`/LOW → ein **fehlgeschlagener** Refresh (Replay/Brute-Force auf
+1 -1
View File
@@ -1752,7 +1752,7 @@ export const auditLogApi = {
return res.data;
},
verifyIntegrity: async () => {
const res = await api.post<ApiResponse<{ valid: boolean; checkedCount: number; invalidEntries: number[]; message: string }>>('/audit-logs/verify');
const res = await api.post<ApiResponse<{ valid: boolean; checkedCount: number; invalidEntries: number[]; tamperedEntries: number[]; chainGaps: number[]; tampered: boolean; message: string }>>('/audit-logs/verify');
return res.data;
},
rehash: async () => {