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');