Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

audit-csv.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

audit-csv.tsBlame146 lines · 1 contributor
611431dClaude1/**
2 * Block J26 — Audit log CSV export helpers.
3 *
4 * Pure, IO-free functions for serialising audit rows to RFC 4180-compliant CSV
5 * with CSV-injection mitigation on untrusted cell content.
6 *
7 * Injection mitigation: a cell whose first character is `=`, `+`, `-`, `@`,
8 * tab, or CR is prefixed with a single quote so spreadsheet formula engines
9 * won't evaluate it. The prefix is visible in the resulting cell but is
10 * preferable to RCE on double-click in Excel / Sheets.
11 *
12 * RFC 4180 rules implemented:
13 * - Rows are CRLF-terminated.
14 * - Cells containing `,`, `"`, `\n`, or `\r` are wrapped in double quotes.
15 * - Internal `"` is escaped by doubling: `"` → `""`.
16 * - A cell that needs injection-prefixing and also contains any quoting
17 * trigger is still quoted correctly (prefix goes INSIDE the quotes).
18 * - A leading BOM is NOT emitted — consumers who need Excel-compatibility
19 * can prepend `\uFEFF` themselves.
20 */
21export const CSV_INJECTION_CHARS = new Set(["=", "+", "-", "@", "\t", "\r"]);
22
23/**
24 * Quote a single cell value. Returns a string ready to be joined into a CSV
25 * row. `null`/`undefined` becomes an empty cell. Objects are `String(...)`'d
26 * (callers should pre-serialise JSON blobs themselves).
27 */
28export function csvCell(value: unknown): string {
29 if (value === null || value === undefined) return "";
30 let s: string;
31 if (value instanceof Date) {
32 s = Number.isNaN(value.getTime()) ? "" : value.toISOString();
33 } else if (typeof value === "string") {
34 s = value;
35 } else {
36 s = String(value);
37 }
38
39 // CSV injection guard — prefix with `'` when the cell starts with a
40 // spreadsheet-formula trigger. Must happen before quoting so the prefix
41 // lives inside the quoted region.
42 if (s.length > 0 && CSV_INJECTION_CHARS.has(s[0]!)) {
43 s = "'" + s;
44 }
45
46 const needsQuoting =
47 s.includes(",") || s.includes('"') || s.includes("\n") || s.includes("\r");
48 if (!needsQuoting) return s;
49 return `"${s.replace(/"/g, '""')}"`;
50}
51
52/** Join cells with `,`, terminate with CRLF (RFC 4180). */
53export function csvRow(cells: readonly unknown[]): string {
54 return cells.map(csvCell).join(",") + "\r\n";
55}
56
57/** Assemble a full CSV document from an array of row arrays. */
58export function csvDocument(rows: readonly (readonly unknown[])[]): string {
59 let out = "";
60 for (const r of rows) out += csvRow(r);
61 return out;
62}
63
64/**
65 * Shape of an audit row as written by the live audit UI. Matches the select
66 * in `src/routes/audit.tsx`.
67 */
68export interface AuditCsvRow {
69 id: string;
70 action: string;
71 targetType: string | null;
72 targetId: string | null;
73 ip: string | null;
74 userAgent: string | null;
75 metadata: string | null;
76 createdAt: Date | string;
77 actor: string | null;
78}
79
80export const AUDIT_CSV_COLUMNS = [
81 "id",
82 "when",
83 "actor",
84 "action",
85 "targetType",
86 "targetId",
87 "ip",
88 "userAgent",
89 "metadata",
90] as const;
91
92function normaliseCreated(v: Date | string): string {
93 if (v instanceof Date) {
94 return Number.isNaN(v.getTime()) ? "" : v.toISOString();
95 }
96 if (typeof v === "string") {
97 const d = new Date(v);
98 return Number.isNaN(d.getTime()) ? v : d.toISOString();
99 }
100 return "";
101}
102
103/**
104 * Turn an array of audit rows into a CSV string with a header row. Metadata
105 * is written verbatim — callers that store JSON in `metadata` should keep
106 * doing so; the cell quoting handles embedded commas and quotes.
107 */
108export function formatAuditCsv(rows: readonly AuditCsvRow[]): string {
109 const body: unknown[][] = [AUDIT_CSV_COLUMNS.slice() as unknown[]];
110 for (const r of rows) {
111 body.push([
112 r.id,
113 normaliseCreated(r.createdAt),
114 r.actor ?? "",
115 r.action,
116 r.targetType ?? "",
117 r.targetId ?? "",
118 r.ip ?? "",
119 r.userAgent ?? "",
120 r.metadata ?? "",
121 ]);
122 }
123 return csvDocument(body);
124}
125
126/**
127 * Build a `Content-Disposition: attachment; filename="..."` value for an
128 * audit-log download. Scope is a short slug (`"personal"` or `"owner-repo"`).
129 */
130export function auditCsvFilename(scope: string, now: Date = new Date()): string {
131 const ts = now.toISOString().replace(/[:.]/g, "-");
132 const safeScope = scope.replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "");
133 const slug = safeScope.length > 0 ? safeScope : "audit";
134 return `audit-${slug}-${ts}.csv`;
135}
136
137export const __internal = {
138 CSV_INJECTION_CHARS,
139 csvCell,
140 csvRow,
141 csvDocument,
142 formatAuditCsv,
143 auditCsvFilename,
144 AUDIT_CSV_COLUMNS,
145 normaliseCreated,
146};