Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
audit-csv.ts4.4 KB · 146 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
/**
 * Block J26 — Audit log CSV export helpers.
 *
 * Pure, IO-free functions for serialising audit rows to RFC 4180-compliant CSV
 * with CSV-injection mitigation on untrusted cell content.
 *
 * Injection mitigation: a cell whose first character is `=`, `+`, `-`, `@`,
 * tab, or CR is prefixed with a single quote so spreadsheet formula engines
 * won't evaluate it. The prefix is visible in the resulting cell but is
 * preferable to RCE on double-click in Excel / Sheets.
 *
 * RFC 4180 rules implemented:
 *   - Rows are CRLF-terminated.
 *   - Cells containing `,`, `"`, `\n`, or `\r` are wrapped in double quotes.
 *   - Internal `"` is escaped by doubling: `"` → `""`.
 *   - A cell that needs injection-prefixing and also contains any quoting
 *     trigger is still quoted correctly (prefix goes INSIDE the quotes).
 *   - A leading BOM is NOT emitted — consumers who need Excel-compatibility
 *     can prepend `\uFEFF` themselves.
 */
export const CSV_INJECTION_CHARS = new Set(["=", "+", "-", "@", "\t", "\r"]);

/**
 * Quote a single cell value. Returns a string ready to be joined into a CSV
 * row. `null`/`undefined` becomes an empty cell. Objects are `String(...)`'d
 * (callers should pre-serialise JSON blobs themselves).
 */
export function csvCell(value: unknown): string {
  if (value === null || value === undefined) return "";
  let s: string;
  if (value instanceof Date) {
    s = Number.isNaN(value.getTime()) ? "" : value.toISOString();
  } else if (typeof value === "string") {
    s = value;
  } else {
    s = String(value);
  }

  // CSV injection guard — prefix with `'` when the cell starts with a
  // spreadsheet-formula trigger. Must happen before quoting so the prefix
  // lives inside the quoted region.
  if (s.length > 0 && CSV_INJECTION_CHARS.has(s[0]!)) {
    s = "'" + s;
  }

  const needsQuoting =
    s.includes(",") || s.includes('"') || s.includes("\n") || s.includes("\r");
  if (!needsQuoting) return s;
  return `"${s.replace(/"/g, '""')}"`;
}

/** Join cells with `,`, terminate with CRLF (RFC 4180). */
export function csvRow(cells: readonly unknown[]): string {
  return cells.map(csvCell).join(",") + "\r\n";
}

/** Assemble a full CSV document from an array of row arrays. */
export function csvDocument(rows: readonly (readonly unknown[])[]): string {
  let out = "";
  for (const r of rows) out += csvRow(r);
  return out;
}

/**
 * Shape of an audit row as written by the live audit UI. Matches the select
 * in `src/routes/audit.tsx`.
 */
export interface AuditCsvRow {
  id: string;
  action: string;
  targetType: string | null;
  targetId: string | null;
  ip: string | null;
  userAgent: string | null;
  metadata: string | null;
  createdAt: Date | string;
  actor: string | null;
}

export const AUDIT_CSV_COLUMNS = [
  "id",
  "when",
  "actor",
  "action",
  "targetType",
  "targetId",
  "ip",
  "userAgent",
  "metadata",
] as const;

function normaliseCreated(v: Date | string): string {
  if (v instanceof Date) {
    return Number.isNaN(v.getTime()) ? "" : v.toISOString();
  }
  if (typeof v === "string") {
    const d = new Date(v);
    return Number.isNaN(d.getTime()) ? v : d.toISOString();
  }
  return "";
}

/**
 * Turn an array of audit rows into a CSV string with a header row. Metadata
 * is written verbatim — callers that store JSON in `metadata` should keep
 * doing so; the cell quoting handles embedded commas and quotes.
 */
export function formatAuditCsv(rows: readonly AuditCsvRow[]): string {
  const body: unknown[][] = [AUDIT_CSV_COLUMNS.slice() as unknown[]];
  for (const r of rows) {
    body.push([
      r.id,
      normaliseCreated(r.createdAt),
      r.actor ?? "",
      r.action,
      r.targetType ?? "",
      r.targetId ?? "",
      r.ip ?? "",
      r.userAgent ?? "",
      r.metadata ?? "",
    ]);
  }
  return csvDocument(body);
}

/**
 * Build a `Content-Disposition: attachment; filename="..."` value for an
 * audit-log download. Scope is a short slug (`"personal"` or `"owner-repo"`).
 */
export function auditCsvFilename(scope: string, now: Date = new Date()): string {
  const ts = now.toISOString().replace(/[:.]/g, "-");
  const safeScope = scope.replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "");
  const slug = safeScope.length > 0 ? safeScope : "audit";
  return `audit-${slug}-${ts}.csv`;
}

export const __internal = {
  CSV_INJECTION_CHARS,
  csvCell,
  csvRow,
  csvDocument,
  formatAuditCsv,
  auditCsvFilename,
  AUDIT_CSV_COLUMNS,
  normaliseCreated,
};