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.
| 611431d | 1 | /** |
| 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 | */ | |
| 21 | export 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 | */ | |
| 28 | export 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). */ | |
| 53 | export 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. */ | |
| 58 | export 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 | */ | |
| 68 | export 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 | ||
| 80 | export 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 | ||
| 92 | function 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 | */ | |
| 108 | export 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 | */ | |
| 130 | export 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 | ||
| 137 | export const __internal = { | |
| 138 | CSV_INJECTION_CHARS, | |
| 139 | csvCell, | |
| 140 | csvRow, | |
| 141 | csvDocument, | |
| 142 | formatAuditCsv, | |
| 143 | auditCsvFilename, | |
| 144 | AUDIT_CSV_COLUMNS, | |
| 145 | normaliseCreated, | |
| 146 | }; |