let the audit log leave the box (#1133)

Retention became configurable in #1132, which only helps if entries can be moved
somewhere before they expire. Until now the only way out was two GET endpoints
built for the UI.

Adds GET /audit-logs/export, taking the same filters as the list endpoint and
streaming the whole matching set as CSV or NDJSON in batches, so an export is not
bounded by the 200-row page cap and does not buffer the result set. Reading the
entire trail is itself recorded as export_audit_logs.

CSV fields starting with =, +, - or @ are prefixed with a quote. Audit rows carry
attacker-influenced values like resource names, and spreadsheet software treats
those as formulas on open.

Adds optional live forwarding to a collector via AUDIT_LOG_FORWARD_URL, with an
optional bearer token. Delivery goes through safeOutboundFetch so a misconfigured
URL cannot be turned into an internal network probe, and it is fire-and-forget:
the local write stays the source of truth and a dead SIEM must never delay or
fail the operation being audited. Repeated failures are reported five times and
then suppressed until delivery recovers, so an outage does not bury the logs it
is supposed to appear in.
This commit is contained in:
ZacharyZcR
2026-07-28 19:26:30 +08:00
committed by GitHub
parent 81b5a6cf01
commit 1ee0edc565
7 changed files with 646 additions and 0 deletions
@@ -86,6 +86,27 @@ export class AuditLogRepository {
};
}
/**
* Reads matching entries in ascending time order for export.
*
* Paged rather than fetched whole so an export cannot pull an unbounded
* result set into memory, and ascending so a resumed or appended export
* continues where the previous one stopped.
*/
async listForExport(input: {
filters: AuditLogFilters;
limit: number;
offset: number;
}): Promise<AuditLogRecord[]> {
return this.context.drizzle
.select()
.from(auditLogs)
.where(this.buildWhere(input.filters))
.orderBy(asc(auditLogs.timestamp), asc(auditLogs.id))
.limit(input.limit)
.offset(input.offset);
}
async listDistinctActions(): Promise<string[]> {
const rows = await this.context.drizzle
.selectDistinct({ action: auditLogs.action })