mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
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:
@@ -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[]> {
|
async listDistinctActions(): Promise<string[]> {
|
||||||
const rows = await this.context.drizzle
|
const rows = await this.context.drizzle
|
||||||
.selectDistinct({ action: auditLogs.action })
|
.selectDistinct({ action: auditLogs.action })
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import {
|
|||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
} from "../repositories/factory.js";
|
} from "../repositories/factory.js";
|
||||||
import { apiLogger } from "../../utils/logger.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<boolean> {
|
async function isAdminUser(userId: string | undefined): Promise<boolean> {
|
||||||
if (!userId) return false;
|
if (!userId) return false;
|
||||||
@@ -132,4 +138,125 @@ export function registerAuditLogRoutes(
|
|||||||
.json({ error: "Failed to fetch audit log actions" });
|
.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<string, string | undefined>;
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> = {}): 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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>)[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"}`;
|
||||||
|
}
|
||||||
@@ -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<string, unknown> {
|
||||||
|
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<boolean> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Request } from "express";
|
import type { Request } from "express";
|
||||||
|
import { forwardAuditEntry } from "./audit-forwarder.js";
|
||||||
import {
|
import {
|
||||||
createCurrentAuditLogRepository,
|
createCurrentAuditLogRepository,
|
||||||
createCurrentUserRepository,
|
createCurrentUserRepository,
|
||||||
@@ -32,6 +33,10 @@ export interface AuditLogParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function logAudit(params: AuditLogParams): Promise<void> {
|
export async function logAudit(params: AuditLogParams): Promise<void> {
|
||||||
|
// 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 {
|
try {
|
||||||
await createCurrentAuditLogRepository().create({
|
await createCurrentAuditLogRepository().create({
|
||||||
userId: params.userId,
|
userId: params.userId,
|
||||||
|
|||||||
Reference in New Issue
Block a user