Compare commits
4
Commits
d599eb3702
...
c7d6b6de7e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7d6b6de7e | ||
|
|
477a850a91 | ||
|
|
89ae7b73a1 | ||
|
|
ad520e20a7 |
@@ -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);
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user