81 lines
4 KiB
TypeScript
81 lines
4 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import type { PoolConnection } from 'mysql2/promise';
|
|
import { pool, one, query } from './db.js';
|
|
|
|
const SENSITIVE = /pass|secret|token|hash|code|key|totp/i;
|
|
/** Maskiert Geheimnisse rekursiv, bevor Zustände in das Audit-Protokoll gelangen. */
|
|
export function mask(v: unknown): unknown {
|
|
if (Array.isArray(v)) return v.map(mask);
|
|
if (v && typeof v === 'object') {
|
|
return Object.fromEntries(Object.entries(v as Record<string, unknown>).map(([k, val]) => [k, SENSITIVE.test(k) ? '***' : mask(val)]));
|
|
}
|
|
return v;
|
|
}
|
|
|
|
export interface AuditInput {
|
|
actorType: 'user' | 'system' | 'anonymous';
|
|
actorId?: string | null;
|
|
orgId?: string | null;
|
|
action: string;
|
|
resourceType?: string;
|
|
resourceId?: string;
|
|
result?: 'success' | 'denied' | 'failure';
|
|
errorClass?: string;
|
|
connector?: string;
|
|
correlationId?: string;
|
|
ip?: string;
|
|
before?: unknown;
|
|
after?: unknown;
|
|
}
|
|
|
|
const canon = (o: unknown) => JSON.stringify(o);
|
|
|
|
/** Hängt ein Ereignis an die Hash-Kette an. Serialisiert über Zeilensperre auf dem letzten Eintrag. */
|
|
export async function audit(e: AuditInput, conn?: PoolConnection): Promise<void> {
|
|
const own = !conn;
|
|
const c = conn ?? (await pool.getConnection());
|
|
try {
|
|
if (own) await c.beginTransaction();
|
|
await c.query('SELECT GET_LOCK(?, 10)', ['kc_audit_chain']);
|
|
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();
|
|
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,
|
|
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],
|
|
);
|
|
if (own) await c.commit();
|
|
} catch (err) {
|
|
if (own) await c.rollback();
|
|
throw err;
|
|
} finally {
|
|
await c.query('SELECT RELEASE_LOCK(?)', ['kc_audit_chain']).catch(() => undefined);
|
|
if (own) c.release();
|
|
}
|
|
}
|
|
|
|
/** 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');
|
|
let prev = '0'.repeat(64);
|
|
for (const r of rows) {
|
|
const body = {
|
|
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,
|
|
before: r.before_json ?? null, after: r.after_json ?? null,
|
|
};
|
|
const expect = createHash('sha256').update(prev + canon(body)).digest('hex');
|
|
if (r.prev_hash !== prev || r.hash !== expect) return { checked: rows.length, brokenAt: r.id as number };
|
|
prev = r.hash as string;
|
|
}
|
|
return { checked: rows.length, brokenAt: null };
|
|
}
|