diff --git a/src/backend/database/repositories/audit-log-repository.ts b/src/backend/database/repositories/audit-log-repository.ts index c7682546..3f966455 100644 --- a/src/backend/database/repositories/audit-log-repository.ts +++ b/src/backend/database/repositories/audit-log-repository.ts @@ -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 { + 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 { const rows = await this.context.drizzle .selectDistinct({ action: auditLogs.action }) diff --git a/src/backend/database/routes/audit-log-routes.ts b/src/backend/database/routes/audit-log-routes.ts index 91273b91..60dea304 100644 --- a/src/backend/database/routes/audit-log-routes.ts +++ b/src/backend/database/routes/audit-log-routes.ts @@ -5,6 +5,12 @@ import { createCurrentUserRepository, } from "../repositories/factory.js"; import { apiLogger } from "../../utils/logger.js"; +import { exportFilename, toCsv, toNdjson } from "../../utils/audit-export.js"; +import { + logAudit, + getAuditUsername, + getRequestMeta, +} from "../../utils/audit-logger.js"; async function isAdminUser(userId: string | undefined): Promise { if (!userId) return false; @@ -132,4 +138,125 @@ export function registerAuditLogRoutes( .json({ error: "Failed to fetch audit log actions" }); } }); + + /** + * @openapi + * /audit-logs/export: + * get: + * summary: Export audit logs + * description: Streams the full filtered result set as CSV or NDJSON. Accepts the same filters as GET /audit-logs. Admin only. The export is itself audited. + * tags: + * - Audit + * parameters: + * - in: query + * name: format + * schema: { type: string, enum: [csv, ndjson], default: csv } + * - in: query + * name: userId + * schema: { type: string } + * - in: query + * name: action + * schema: { type: string } + * - in: query + * name: resourceType + * schema: { type: string } + * - in: query + * name: success + * schema: { type: string, enum: [true, false] } + * - in: query + * name: startDate + * schema: { type: string, format: date-time } + * - in: query + * name: endDate + * schema: { type: string, format: date-time } + * responses: + * 200: + * description: Audit log file. + * 403: + * description: Not authorized. + * 500: + * description: Failed to export audit logs. + */ + router.get("/audit-logs/export", authenticateJWT, async (req, res) => { + const authReq = req as AuthenticatedRequest; + try { + if (!(await isAdminUser(authReq.userId))) { + return res.status(403).json({ error: "Not authorized" }); + } + + const format = req.query.format === "ndjson" ? "ndjson" : "csv"; + const { userId, action, resourceType, success, startDate, endDate } = + req.query as Record; + const filters = { + userId, + action, + resourceType, + success: + success !== undefined && success !== "" + ? success === "true" + : undefined, + startDate, + endDate, + }; + + res.setHeader( + "Content-Type", + format === "csv" ? "text/csv; charset=utf-8" : "application/x-ndjson", + ); + res.setHeader( + "Content-Disposition", + `attachment; filename="${exportFilename(format, new Date())}"`, + ); + + // Streamed in batches: an export is unbounded by definition, and the + // whole point is to move data out before retention drops it. + const BATCH = 500; + let offset = 0; + let exported = 0; + + for (;;) { + const rows = await createCurrentAuditLogRepository().listForExport({ + filters, + limit: BATCH, + offset, + }); + if (rows.length === 0) break; + + if (format === "csv") { + // Header only on the first batch. + const chunk = toCsv(rows); + res.write( + offset === 0 ? chunk : chunk.slice(chunk.indexOf("\n") + 1), + ); + } else { + res.write(toNdjson(rows)); + } + + exported += rows.length; + offset += rows.length; + if (rows.length < BATCH) break; + } + + res.end(); + + // Reading the whole trail is itself worth recording. + const { ipAddress, userAgent } = getRequestMeta(req); + void logAudit({ + userId: authReq.userId!, + username: await getAuditUsername(authReq.userId!), + action: "export_audit_logs", + resourceType: "audit_log", + details: JSON.stringify({ format, exported, filters }), + ipAddress, + userAgent, + success: true, + }); + } catch (err) { + apiLogger.error("Failed to export audit logs", err); + if (!res.headersSent) { + return res.status(500).json({ error: "Failed to export audit logs" }); + } + res.end(); + } + }); } diff --git a/src/backend/tests/utils/audit-export.test.ts b/src/backend/tests/utils/audit-export.test.ts new file mode 100644 index 00000000..9e31342e --- /dev/null +++ b/src/backend/tests/utils/audit-export.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "vitest"; +import { + escapeCsvField, + exportFilename, + toCsv, + toNdjson, +} from "../../utils/audit-export.js"; +import type { AuditLogRecord } from "../../database/repositories/audit-log-repository.js"; + +function entry(overrides: Partial = {}): AuditLogRecord { + return { + id: 1, + userId: "u-1", + username: "alice", + action: "delete_host", + resourceType: "host", + resourceId: "9", + resourceName: "prod-db", + details: null, + ipAddress: "203.0.113.9", + userAgent: "Mozilla/5.0", + success: true, + errorMessage: null, + timestamp: "2026-07-28 10:00:00", + ...overrides, + } as AuditLogRecord; +} + +describe("escapeCsvField", () => { + it("leaves plain values alone", () => { + expect(escapeCsvField("prod-db")).toBe("prod-db"); + expect(escapeCsvField(42)).toBe("42"); + expect(escapeCsvField(true)).toBe("true"); + }); + + it("renders null and undefined as empty", () => { + expect(escapeCsvField(null)).toBe(""); + expect(escapeCsvField(undefined)).toBe(""); + }); + + it("quotes and doubles embedded quotes", () => { + expect(escapeCsvField('say "hi"')).toBe('"say ""hi"""'); + }); + + it("quotes values containing commas or newlines", () => { + expect(escapeCsvField("a,b")).toBe('"a,b"'); + expect(escapeCsvField("line1\nline2")).toBe('"line1\nline2"'); + }); + + it("neutralises spreadsheet formulas", () => { + // An audit entry can carry an attacker-chosen resource name; without this + // the exported file executes it when opened. + expect(escapeCsvField("=1+1")).toBe("'=1+1"); + expect(escapeCsvField("+SUM(A1)")).toBe("'+SUM(A1)"); + expect(escapeCsvField("-2+3")).toBe("'-2+3"); + expect(escapeCsvField("@import")).toBe("'@import"); + }); + + it("still quotes a formula that also contains a comma", () => { + expect(escapeCsvField("=A1,B2")).toBe(`"'=A1,B2"`); + }); +}); + +describe("toCsv", () => { + it("writes a header even with no rows", () => { + expect(toCsv([])).toBe( + "id,timestamp,username,userId,action,resourceType,resourceId,resourceName,success,ipAddress,userAgent,errorMessage,details\n", + ); + }); + + it("writes one line per entry in column order", () => { + const lines = toCsv([entry(), entry({ id: 2, username: "bob" })]) + .trim() + .split("\n"); + + expect(lines).toHaveLength(3); + expect( + lines[1].startsWith("1,2026-07-28 10:00:00,alice,u-1,delete_host"), + ).toBe(true); + expect(lines[2].startsWith("2,")).toBe(true); + }); + + it("keeps a detached entry readable", () => { + const line = toCsv([entry({ userId: null })]) + .trim() + .split("\n")[1]; + + // username survives so the row still names who acted. + expect(line).toContain("alice"); + expect(line.split(",")[3]).toBe(""); + }); +}); + +describe("toNdjson", () => { + it("emits one parseable object per line", () => { + const out = toNdjson([entry(), entry({ id: 2 })]); + const parsed = out + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + + expect(parsed).toHaveLength(2); + expect(parsed[0].action).toBe("delete_host"); + expect(parsed[1].id).toBe(2); + }); + + it("returns nothing for an empty set", () => { + expect(toNdjson([])).toBe(""); + }); +}); + +describe("exportFilename", () => { + it("is filesystem-safe and carries the timestamp", () => { + const name = exportFilename("csv", new Date("2026-07-28T10:11:12.000Z")); + + expect(name).toBe("termix-audit-2026-07-28-10-11-12.csv"); + expect(name).not.toMatch(/[:\s]/); + }); + + it("uses the ndjson extension for the streaming format", () => { + expect(exportFilename("ndjson", new Date("2026-07-28T10:11:12.000Z"))).toBe( + "termix-audit-2026-07-28-10-11-12.ndjson", + ); + }); +}); diff --git a/src/backend/tests/utils/audit-forwarder.test.ts b/src/backend/tests/utils/audit-forwarder.test.ts new file mode 100644 index 00000000..fdfa6073 --- /dev/null +++ b/src/backend/tests/utils/audit-forwarder.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const safeFetch = vi.hoisted(() => vi.fn()); +const logs = vi.hoisted(() => ({ info: vi.fn(), warn: vi.fn() })); + +vi.mock("../../utils/safe-outbound-fetch.js", () => ({ + safeOutboundFetch: safeFetch, +})); +vi.mock("../../utils/logger.js", () => ({ databaseLogger: logs })); + +const { + auditForwardTarget, + forwardAuditEntry, + forwardPayload, + resetAuditForwarderState, + AUDIT_FORWARD_URL_ENV, + AUDIT_FORWARD_TOKEN_ENV, +} = await import("../../utils/audit-forwarder.js"); + +const ENTRY = { + userId: "u-1", + username: "alice", + action: "delete_host", + resourceType: "host", + resourceId: "9", + success: true, + ipAddress: "203.0.113.9", +}; + +const NOW = new Date("2026-07-28T10:00:00.000Z"); + +beforeEach(() => { + safeFetch.mockReset(); + logs.info.mockReset(); + logs.warn.mockReset(); + resetAuditForwarderState(); +}); + +describe("auditForwardTarget", () => { + it("is off unless a URL is configured", () => { + expect(auditForwardTarget({})).toBeNull(); + expect(auditForwardTarget({ [AUDIT_FORWARD_URL_ENV]: " " })).toBeNull(); + }); + + it("carries an optional bearer token", () => { + expect( + auditForwardTarget({ [AUDIT_FORWARD_URL_ENV]: "https://siem/ingest" }), + ).toEqual({ url: "https://siem/ingest" }); + + expect( + auditForwardTarget({ + [AUDIT_FORWARD_URL_ENV]: "https://siem/ingest", + [AUDIT_FORWARD_TOKEN_ENV]: "secret", + }), + ).toEqual({ url: "https://siem/ingest", token: "secret" }); + }); +}); + +describe("forwardPayload", () => { + it("matches the export shape, with absent fields as null", () => { + expect(forwardPayload(ENTRY, NOW)).toEqual({ + timestamp: "2026-07-28T10:00:00.000Z", + userId: "u-1", + username: "alice", + action: "delete_host", + resourceType: "host", + resourceId: "9", + resourceName: null, + success: true, + ipAddress: "203.0.113.9", + userAgent: null, + errorMessage: null, + details: null, + }); + }); +}); + +describe("forwardAuditEntry", () => { + const env = { [AUDIT_FORWARD_URL_ENV]: "https://siem.example/ingest" }; + + it("does nothing when forwarding is not configured", async () => { + await expect(forwardAuditEntry(ENTRY, NOW, {})).resolves.toBe(false); + expect(safeFetch).not.toHaveBeenCalled(); + }); + + it("posts one NDJSON line through the SSRF-checked fetch", async () => { + safeFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(true); + + const [url, init] = safeFetch.mock.calls[0]; + expect(url).toBe("https://siem.example/ingest"); + expect(init.method).toBe("POST"); + expect(init.headers["Content-Type"]).toBe("application/x-ndjson"); + expect(init.headers.Authorization).toBeUndefined(); + expect(JSON.parse(init.body.trim()).action).toBe("delete_host"); + expect(init.body.endsWith("\n")).toBe(true); + }); + + it("sends the bearer token when one is set", async () => { + safeFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + await forwardAuditEntry(ENTRY, NOW, { + ...env, + [AUDIT_FORWARD_TOKEN_ENV]: "secret", + }); + + expect(safeFetch.mock.calls[0][1].headers.Authorization).toBe( + "Bearer secret", + ); + }); + + it("reports a rejected delivery without throwing", async () => { + safeFetch.mockResolvedValueOnce({ ok: false, status: 503 }); + + await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(false); + expect(logs.warn).toHaveBeenCalledWith( + "Failed to forward audit entry", + expect.objectContaining({ reason: "collector returned 503" }), + ); + }); + + it("swallows transport errors — a dead SIEM must not break auditing", async () => { + safeFetch.mockRejectedValueOnce(new Error("ECONNREFUSED")); + + await expect(forwardAuditEntry(ENTRY, NOW, env)).resolves.toBe(false); + expect(logs.warn).toHaveBeenCalledWith( + "Failed to forward audit entry", + expect.objectContaining({ reason: "ECONNREFUSED" }), + ); + }); + + it("stops repeating itself once the collector is persistently down", async () => { + safeFetch.mockResolvedValue({ ok: false, status: 500 }); + + for (let i = 0; i < 8; i++) { + await forwardAuditEntry(ENTRY, NOW, env); + } + + // 5 per-entry warnings, then one suppression notice — not 8. + const perEntry = logs.warn.mock.calls.filter( + (call) => call[0] === "Failed to forward audit entry", + ); + expect(perEntry).toHaveLength(5); + expect( + logs.warn.mock.calls.some((call) => + String(call[0]).includes("suppressing further messages"), + ), + ).toBe(true); + // It keeps trying regardless. + expect(safeFetch).toHaveBeenCalledTimes(8); + }); + + it("announces recovery after a suppressed outage", async () => { + safeFetch.mockResolvedValue({ ok: false, status: 500 }); + for (let i = 0; i < 6; i++) await forwardAuditEntry(ENTRY, NOW, env); + + safeFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + await forwardAuditEntry(ENTRY, NOW, env); + + expect(logs.info).toHaveBeenCalledWith( + "Audit forwarding recovered", + expect.objectContaining({ operation: "audit_forward_recovered" }), + ); + }); +}); diff --git a/src/backend/utils/audit-export.ts b/src/backend/utils/audit-export.ts new file mode 100644 index 00000000..5f84891c --- /dev/null +++ b/src/backend/utils/audit-export.ts @@ -0,0 +1,70 @@ +import type { AuditLogRecord } from "../database/repositories/audit-log-repository.js"; + +/** Column order for CSV export; also the header row. */ +const COLUMNS = [ + "id", + "timestamp", + "username", + "userId", + "action", + "resourceType", + "resourceId", + "resourceName", + "success", + "ipAddress", + "userAgent", + "errorMessage", + "details", +] as const; + +/** + * RFC 4180 field escaping. + * + * The leading-character guard is not part of RFC 4180: a field starting with + * `=`, `+`, `-` or `@` is treated as a formula by spreadsheet software, so an + * audit entry containing an attacker-chosen resource name could execute on + * open. Prefixing with a single quote neutralises that while keeping the value + * readable. + */ +export function escapeCsvField(value: unknown): string { + if (value === null || value === undefined) return ""; + + let text = typeof value === "boolean" ? String(value) : String(value); + if (/^[=+\-@\t\r]/.test(text)) { + text = `'${text}`; + } + + if (/[",\n\r]/.test(text)) { + return `"${text.replace(/"/g, '""')}"`; + } + return text; +} + +export function toCsv(rows: AuditLogRecord[]): string { + const lines = [COLUMNS.join(",")]; + for (const row of rows) { + lines.push( + COLUMNS.map((column) => + escapeCsvField((row as Record)[column]), + ).join(","), + ); + } + // Trailing newline so the file ends cleanly when appended to or concatenated. + return `${lines.join("\n")}\n`; +} + +/** + * Newline-delimited JSON: one entry per line, which is what log shippers and + * SIEM bulk endpoints expect, and which streams without holding the whole set. + */ +export function toNdjson(rows: AuditLogRecord[]): string { + return ( + rows.map((row) => JSON.stringify(row)).join("\n") + + (rows.length ? "\n" : "") + ); +} + +export function exportFilename(format: "csv" | "ndjson", now: Date): string { + const stamp = now.toISOString().slice(0, 19).replace(/[:T]/g, "-"); + return `termix-audit-${stamp}.${format === "csv" ? "csv" : "ndjson"}`; +} diff --git a/src/backend/utils/audit-forwarder.ts b/src/backend/utils/audit-forwarder.ts new file mode 100644 index 00000000..8b906243 --- /dev/null +++ b/src/backend/utils/audit-forwarder.ts @@ -0,0 +1,132 @@ +import { safeOutboundFetch } from "./safe-outbound-fetch.js"; +import { databaseLogger } from "./logger.js"; +import type { AuditLogParams } from "./audit-logger.js"; + +export const AUDIT_FORWARD_URL_ENV = "AUDIT_LOG_FORWARD_URL"; +export const AUDIT_FORWARD_TOKEN_ENV = "AUDIT_LOG_FORWARD_TOKEN"; + +/** + * How many consecutive failures before the forwarder stops complaining on every + * entry. It keeps trying — this only throttles the log noise, and it reports + * again once delivery recovers. + */ +const QUIET_AFTER_FAILURES = 5; + +let consecutiveFailures = 0; +let quietened = false; + +export interface AuditForwardTarget { + url: string; + token?: string; +} + +export function auditForwardTarget( + env: NodeJS.ProcessEnv = process.env, +): AuditForwardTarget | null { + const url = env[AUDIT_FORWARD_URL_ENV]?.trim(); + if (!url) return null; + const token = env[AUDIT_FORWARD_TOKEN_ENV]?.trim(); + return token ? { url, token } : { url }; +} + +/** The wire shape: one JSON object per entry, matching the export's NDJSON. */ +export function forwardPayload( + entry: AuditLogParams, + now: Date, +): Record { + return { + timestamp: now.toISOString(), + userId: entry.userId, + username: entry.username, + action: entry.action, + resourceType: entry.resourceType, + resourceId: entry.resourceId ?? null, + resourceName: entry.resourceName ?? null, + success: entry.success, + ipAddress: entry.ipAddress ?? null, + userAgent: entry.userAgent ?? null, + errorMessage: entry.errorMessage ?? null, + details: entry.details ?? null, + }; +} + +/** Exposed for tests; forwarding state is process-wide otherwise. */ +export function resetAuditForwarderState(): void { + consecutiveFailures = 0; + quietened = false; +} + +/** + * Ships one entry to the configured collector. + * + * Never throws and never blocks the audited operation: a SIEM being unreachable + * must not stop Termix from recording locally, which stays the source of truth. + * Delivery goes through safeOutboundFetch so a misconfigured URL cannot be used + * to probe the internal network. + */ +export async function forwardAuditEntry( + entry: AuditLogParams, + now: Date = new Date(), + env: NodeJS.ProcessEnv = process.env, +): Promise { + const target = auditForwardTarget(env); + if (!target) return false; + + try { + const response = await safeOutboundFetch(target.url, { + method: "POST", + headers: { + "Content-Type": "application/x-ndjson", + ...(target.token ? { Authorization: `Bearer ${target.token}` } : {}), + }, + body: `${JSON.stringify(forwardPayload(entry, now))}\n`, + signal: AbortSignal.timeout(5000), + }); + + if (!response.ok) { + noteFailure(`collector returned ${response.status}`, entry.action); + return false; + } + + noteSuccess(); + return true; + } catch (error) { + noteFailure( + error instanceof Error ? error.message : String(error), + entry.action, + ); + return false; + } +} + +function noteFailure(reason: string, action: string): void { + consecutiveFailures++; + + if (quietened) return; + + databaseLogger.warn("Failed to forward audit entry", { + operation: "audit_forward_failed", + action, + reason, + consecutiveFailures, + }); + + if (consecutiveFailures >= QUIET_AFTER_FAILURES) { + quietened = true; + databaseLogger.warn( + `Audit forwarding has failed ${consecutiveFailures} times; suppressing further messages until it recovers`, + { operation: "audit_forward_suppressed" }, + ); + } +} + +function noteSuccess(): void { + if (quietened) { + databaseLogger.info("Audit forwarding recovered", { + operation: "audit_forward_recovered", + afterFailures: consecutiveFailures, + }); + } + consecutiveFailures = 0; + quietened = false; +} diff --git a/src/backend/utils/audit-logger.ts b/src/backend/utils/audit-logger.ts index 88b0b39b..ed5e5f3b 100644 --- a/src/backend/utils/audit-logger.ts +++ b/src/backend/utils/audit-logger.ts @@ -1,4 +1,5 @@ import type { Request } from "express"; +import { forwardAuditEntry } from "./audit-forwarder.js"; import { createCurrentAuditLogRepository, createCurrentUserRepository, @@ -32,6 +33,10 @@ export interface AuditLogParams { } export async function logAudit(params: AuditLogParams): Promise { + // Local storage is the source of truth and runs first; forwarding is a copy + // and must never delay or fail the audited operation. + void forwardAuditEntry(params).catch(() => {}); + try { await createCurrentAuditLogRepository().create({ userId: params.userId,