fix(audit): GET_LOCK-Ergebnis prüfen, connector/ip in Hash-Kette
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 <noreply@anthropic.com>
This commit is contained in:
parent
4976fc529e
commit
acf8751f9f
2 changed files with 19 additions and 9 deletions
|
|
@ -36,22 +36,24 @@ export async function audit(e: AuditInput, conn?: PoolConnection): Promise<void>
|
|||
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<void>
|
|||
|
||||
/** Prüft die Hash-Kette. Liefert die erste fehlerhafte ID oder null. */
|
||||
export async function verifyAuditChain(q: (sql: string) => Promise<any[]> = (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<string, unknown> = {
|
||||
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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue