From acf8751f9f50b00d260be82ed8c19f5da8178850 Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Tue, 29 Sep 2026 09:56:46 +0200 Subject: [PATCH] =?UTF-8?q?fix(audit):=20GET=5FLOCK-Ergebnis=20pr=C3=BCfen?= =?UTF-8?q?,=20connector/ip=20in=20Hash-Kette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei vom Nacht-Agenten gefundene Lücken, gegen den echten Code verifiziert und behoben: 1. Das Ergebnis von GET_LOCK() wurde nie geprüft. Bei Timeout (10s) oder Fehler lief audit() trotzdem ungesperrt weiter – zwei gleichzeitige Aufrufe hätten dieselbe "letzte Zeile" lesen und beide anhängen können, was die Kettengarantie bricht. Wirft jetzt einen Fehler, wenn die Sperre nicht erlangt wurde. 2. connector und ip wurden zwar in audit_events gespeichert, aber nie mitgehasht – beide Felder ließen sich im Nachhinein unbemerkt ändern, ohne die Kette zu brechen. Neue Einträge (hash_version = 2, Migration 030) hashen sie jetzt mit. Bestehende Einträge (Version 1) bleiben mit ihrer ursprünglichen Formel gültig; verifyAuditChain() berücksichtigt die Version pro Zeile. Gegen die echte Datenbank verifiziert: Kette bleibt über alle 183 bestehenden (v1) Einträge sauber, ein neu eingefügter (v2) Eintrag mit connector/ip ebenfalls – 184/184 ohne Bruch. Co-Authored-By: Claude Sonnet 5 --- migrations/030_audit_hash_v2.sql | 6 ++++++ packages/platform/src/audit.ts | 22 +++++++++++++--------- 2 files changed, 19 insertions(+), 9 deletions(-) create mode 100644 migrations/030_audit_hash_v2.sql diff --git a/migrations/030_audit_hash_v2.sql b/migrations/030_audit_hash_v2.sql new file mode 100644 index 0000000..2425be2 --- /dev/null +++ b/migrations/030_audit_hash_v2.sql @@ -0,0 +1,6 @@ +-- Behebt zwei vom Nacht-Agent gefundene und verifizierte Lücken in der Audit-Hash-Kette: +-- 1) Das Ergebnis von GET_LOCK() wurde nie geprüft (bei Timeout/Fehler lief die Kette ungesperrt weiter). +-- 2) connector/ip wurden zwar gespeichert, aber nie mitgehasht (im Nachhinein unbemerkt änderbar). +-- Neue Einträge (hash_version = 2) hashen connector/ip mit; bestehende Einträge (Version 1) bleiben mit +-- ihrer ursprünglichen Formel gültig, damit die Kette rückwärtskompatibel bleibt. +ALTER TABLE audit_events ADD COLUMN hash_version TINYINT UNSIGNED NOT NULL DEFAULT 1; diff --git a/packages/platform/src/audit.ts b/packages/platform/src/audit.ts index fde97a3..aa98f86 100644 --- a/packages/platform/src/audit.ts +++ b/packages/platform/src/audit.ts @@ -36,22 +36,24 @@ export async function audit(e: AuditInput, conn?: PoolConnection): Promise const c = conn ?? (await pool.getConnection()); try { if (own) await c.beginTransaction(); - await c.query('SELECT GET_LOCK(?, 10)', ['kc_audit_chain']); + const [[lockRow]] = await c.query('SELECT GET_LOCK(?, 10) AS got', ['kc_audit_chain']) as any; + if (Number(lockRow?.got) !== 1) throw new Error('Audit-Sperre konnte nicht erlangt werden (Zeitüberschreitung oder Fehler)'); const last = await one<{ hash: string } & import('mysql2').RowDataPacket>('SELECT hash FROM audit_events ORDER BY id DESC LIMIT 1', [], c); const prev = last?.hash ?? '0'.repeat(64); const ts = new Date(); + // hash_version 2: connector/ip fließen mit ein (Reihenfolge muss zu verifyAuditChain passen). const body = { ts: ts.toISOString(), actor_type: e.actorType, actor_id: e.actorId ?? null, org_id: e.orgId ?? null, action: e.action, resource_type: e.resourceType ?? null, resource_id: e.resourceId ?? null, result: e.result ?? 'success', - error_class: e.errorClass ?? null, correlation_id: e.correlationId ?? null, + error_class: e.errorClass ?? null, connector: e.connector ?? null, correlation_id: e.correlationId ?? null, ip: e.ip ?? null, before: e.before === undefined ? null : mask(e.before), after: e.after === undefined ? null : mask(e.after), }; const hash = createHash('sha256').update(prev + canon(body)).digest('hex'); await c.execute( - `INSERT INTO audit_events (ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, connector, correlation_id, ip, before_json, after_json, prev_hash, hash) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - [ts, e.actorType, body.actor_id, body.org_id, e.action, body.resource_type, body.resource_id, body.result, body.error_class, e.connector ?? null, - body.correlation_id, e.ip ?? null, body.before === null ? null : JSON.stringify(body.before), body.after === null ? null : JSON.stringify(body.after), prev, hash], + `INSERT INTO audit_events (ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, connector, correlation_id, ip, before_json, after_json, prev_hash, hash, hash_version) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,2)`, + [ts, e.actorType, body.actor_id, body.org_id, e.action, body.resource_type, body.resource_id, body.result, body.error_class, body.connector, + body.correlation_id, body.ip, body.before === null ? null : JSON.stringify(body.before), body.after === null ? null : JSON.stringify(body.after), prev, hash], ); if (own) await c.commit(); } catch (err) { @@ -65,12 +67,14 @@ export async function audit(e: AuditInput, conn?: PoolConnection): Promise /** Prüft die Hash-Kette. Liefert die erste fehlerhafte ID oder null. */ export async function verifyAuditChain(q: (sql: string) => Promise = (sql) => query(sql)): Promise<{ checked: number; brokenAt: number | null }> { - const rows = await q('SELECT id, ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, correlation_id, before_json, after_json, prev_hash, hash FROM audit_events ORDER BY id'); + const rows = await q('SELECT id, ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, connector, correlation_id, ip, before_json, after_json, prev_hash, hash, hash_version FROM audit_events ORDER BY id'); let prev = '0'.repeat(64); for (const r of rows) { - const body = { + // Version 1 (vor Migration 030) hashte ohne connector/ip; nur neuere Einträge (Version 2) beziehen sie ein. + const body: Record = { ts: (r.ts as Date).toISOString(), actor_type: r.actor_type, actor_id: r.actor_id, org_id: r.org_id, action: r.action, - resource_type: r.resource_type, resource_id: r.resource_id, result: r.result, error_class: r.error_class, correlation_id: r.correlation_id, + resource_type: r.resource_type, resource_id: r.resource_id, result: r.result, error_class: r.error_class, + ...(Number(r.hash_version ?? 1) >= 2 ? { connector: r.connector, correlation_id: r.correlation_id, ip: r.ip } : { correlation_id: r.correlation_id }), before: r.before_json ?? null, after: r.after_json ?? null, }; const expect = createHash('sha256').update(prev + canon(body)).digest('hex');