fix(csv): neutralize formula-leading exports (#6993)

This commit is contained in:
Waleed
2026-08-22 15:50:21 -07:00
committed by GitHub
parent c26529a82e
commit 37590bdf73
17 changed files with 192 additions and 181 deletions
+1 -1
View File
@@ -10,8 +10,8 @@ import {
queryAuditLogs,
} from '@/lib/audit-logs/query'
import { getSession } from '@/lib/auth'
import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { formatCsvValue, toCsvRow } from '@/lib/table/export-format'
import { validateEnterpriseAuditAccess } from '@/app/api/v1/audit-logs/auth'
import { formatAuditLogEntry } from '@/app/api/v1/audit-logs/format'
+15 -24
View File
@@ -6,7 +6,7 @@ import { and, desc, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency'
import { neutralizeCsvFormula } from '@/lib/core/utils/csv'
import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store'
import { buildFilterConditions, LogFilterParamsSchema } from '@/lib/logs/filters'
@@ -17,15 +17,6 @@ const logger = createLogger('LogsExportAPI')
export const revalidate = 0
function escapeCsv(value: any): string {
if (value === null || value === undefined) return ''
const str = typeof value === 'string' ? neutralizeCsvFormula(value) : String(value)
if (/[",\n]/.test(str)) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
export const GET = withRouteHandler(async (request: NextRequest) => {
try {
const session = await getSession()
@@ -61,7 +52,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
? and(workspaceCondition, filterConditions)
: workspaceCondition
const header = [
const header = toCsvRow([
'startedAt',
'level',
'workflow',
@@ -72,7 +63,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
'executionId',
'message',
'traceSpans',
].join(',')
])
const access = await checkWorkspaceAccess(params.workspaceId, userId)
if (!access.hasAccess) {
@@ -147,18 +138,18 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
error: getErrorMessage(rowError),
})
}
const line = [
escapeCsv(r.startedAt?.toISOString?.() || r.startedAt),
escapeCsv(r.level),
escapeCsv(r.workflowName),
escapeCsv(r.trigger),
escapeCsv(r.totalDurationMs ?? ''),
escapeCsv(r.costTotal ?? ''),
escapeCsv(r.workflowId ?? ''),
escapeCsv(r.executionId ?? ''),
escapeCsv(message),
escapeCsv(tracesJson),
].join(',')
const line = toCsvRow([
formatCsvValue(r.startedAt?.toISOString?.() || r.startedAt),
formatCsvValue(r.level),
formatCsvValue(r.workflowName),
formatCsvValue(r.trigger),
formatCsvValue(r.totalDurationMs ?? ''),
formatCsvValue(r.costTotal ?? ''),
formatCsvValue(r.workflowId ?? ''),
formatCsvValue(r.executionId ?? ''),
formatCsvValue(message),
formatCsvValue(tracesJson),
])
controller.enqueue(encoder.encode(`${line}\n`))
}
@@ -6,11 +6,8 @@ import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import {
createTableExportStream,
exportContentType,
sanitizeExportFilename,
} from '@/lib/table/export-stream'
import { sanitizeExportFilename } from '@/lib/table/export-format'
import { createTableExportStream, exportContentType } from '@/lib/table/export-stream'
import { accessError, checkAccess } from '@/app/api/table/utils'
interface RouteParams {
@@ -9,8 +9,8 @@ import {
toBillingUsageLogSource,
toInternalUsageLogSources,
} from '@/lib/billing/usage-sources'
import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { formatCsvValue, toCsvRow } from '@/lib/table/export-format'
import { resolveDateRange } from '@/app/api/users/me/usage-logs/shared'
const logger = createLogger('UsageLogsExportAPI')
@@ -75,6 +75,15 @@ describe('serializeOutputForFile (csv)', () => {
expect(serializeOutputForFile(output, 'csv')).toBe('name,age\nAlice,30\nBob,40')
})
it('neutralizes formula-leading values in generated CSV', () => {
const output = {
result: [{ value: '=1+1' }],
stdout: '',
}
expect(serializeOutputForFile(output, 'csv')).toBe("value\n'=1+1")
})
it('returns the raw string when the non-envelope output is already a CSV string', () => {
expect(serializeOutputForFile('a,b\n1,2', 'csv')).toBe('a,b\n1,2')
})
+4 -12
View File
@@ -12,6 +12,7 @@ import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/r
import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types'
import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils'
import { writeCopilotWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer'
import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv'
import {
createWorkspaceFileSecretProvenanceFromRegistry,
type WorkspaceFileSecretProvenance,
@@ -99,15 +100,6 @@ export function extractTabularData(output: unknown): Record<string, unknown>[] |
return null
}
export function escapeCsvValue(value: unknown): string {
if (value === null || value === undefined) return ''
const str = typeof value === 'object' ? JSON.stringify(value) : String(value)
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
export function normalizeOutputWorkspaceFileName(outputPath: string): string {
const segments = decodeVfsPathSegments(outputPath.trim().replace(/^\/+|\/+$/g, ''))
const fileName = segments.at(-1)
@@ -180,7 +172,7 @@ function convertRowsToCsvWithProvenance(
}
}
const serializeCell = (sourceValue: unknown): string => {
const persistedValue = escapeCsvValue(sourceValue)
const persistedValue = formatCsvValue(sourceValue)
const serializedSource =
sourceValue === null || sourceValue === undefined
? ''
@@ -223,9 +215,9 @@ function convertRowsToCsvWithProvenance(
return persistedValue
}
const lines = [headers.map(serializeCell).join(',')]
const lines = [toCsvRow(headers.map(serializeCell))]
for (const row of rows) {
lines.push(headers.map((header) => serializeCell(row[header])).join(','))
lines.push(toCsvRow(headers.map((header) => serializeCell(row[header]))))
}
return {
content: lines.join('\n'),
@@ -12,6 +12,7 @@ import {
} from '@/lib/copilot/tools/secret-mount-materializer.server'
import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils'
import { isFeatureEnabled } from '@/lib/core/config/feature-flags'
import { neutralizeCsvFormula, toCsvRow } from '@/lib/core/utils/csv'
import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits'
import type { PrivateSecretProvenanceBundleV1 } from '@/lib/execution/model-input-provenance'
import {
@@ -22,7 +23,7 @@ import { MAX_PLAN_REQUIRED } from '@/lib/execution/remote-sandbox/workspace-sand
import { recordSecretUsage } from '@/lib/secrets/usage/record'
import { getColumnId } from '@/lib/table/column-keys'
import { TABLE_LIMITS } from '@/lib/table/constants'
import { formatCsvCell, neutralizeCsvFormula, toCsvRow } from '@/lib/table/export-format'
import { formatCsvCell } from '@/lib/table/export-format'
import {
isTableSnapshotSafeForModelMount,
loadTableRowSecretProvenance,
+34
View File
@@ -0,0 +1,34 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { formatCsvValue, neutralizeCsvFormula, toCsvRow } from '@/lib/core/utils/csv'
describe('CSV formatting', () => {
it.each(['=1+1', '+1+1', '-1+1', '@SUM(A1)', '\t=1+1', '\r=1+1'])(
'neutralizes formula-leading text: %j',
(value) => {
expect(neutralizeCsvFormula(value)).toBe(`'${value}`)
expect(formatCsvValue(value)).toBe(`'${value}`)
}
)
it('preserves non-string primitives', () => {
expect(formatCsvValue(-42)).toBe('-42')
expect(formatCsvValue(true)).toBe('true')
})
it('uses the provided object serializer', () => {
expect(formatCsvValue({ value: 'x' }, () => 'serialized')).toBe('serialized')
})
it('handles objects that serialize to undefined', () => {
expect(formatCsvValue({ toJSON: () => undefined })).toBe('')
})
it('escapes quotes, commas, and record separators', () => {
expect(toCsvRow(['plain', 'with,comma', 'with"quote', 'with\nnewline', 'with\rreturn'])).toBe(
'plain,"with,comma","with""quote","with\nnewline","with\rreturn"'
)
})
})
+19
View File
@@ -5,3 +5,22 @@
export function neutralizeCsvFormula(value: string): string {
return /^[=+\-@\t\r]/.test(value) ? `'${value}` : value
}
export function formatCsvValue(
value: unknown,
serializeObject: (value: object) => string | undefined = JSON.stringify
): string {
if (value === null || value === undefined) return ''
if (value instanceof Date) return value.toISOString()
if (typeof value === 'object') return serializeObject(value) ?? ''
if (typeof value === 'string') return neutralizeCsvFormula(value)
return String(value)
}
export function toCsvRow(values: string[]): string {
return values.map(escapeCsvField).join(',')
}
function escapeCsvField(field: string): string {
return /[",\n\r]/.test(field) ? `"${field.replace(/"/g, '""')}"` : field
}
+1 -31
View File
@@ -4,6 +4,7 @@
* byte-identical files.
*/
import { formatCsvValue, neutralizeCsvFormula } from '@/lib/core/utils/csv'
import { columnTypeOf } from '@/lib/table/column-types'
import { selectValueToNames } from '@/lib/table/select-values'
import type { ColumnDefinition } from '@/lib/table/types'
@@ -19,26 +20,6 @@ export function sanitizeExportFilename(name: string): string {
return cleaned || 'table'
}
/**
* Prefixes a single quote to values starting with a spreadsheet formula trigger
* (`=`, `+`, `-`, `@`, tab, CR), neutralizing CSV injection in Excel/Sheets.
*/
export function neutralizeCsvFormula(value: string): string {
return /^[=+\-@\t\r]/.test(value) ? `'${value}` : value
}
/**
* Serializes a cell for CSV. Only string cells are formula-neutralized; numbers,
* booleans, dates, and JSON objects can never form a trigger and pass through verbatim.
*/
export function formatCsvValue(value: unknown): string {
if (value === null || value === undefined) return ''
if (value instanceof Date) return value.toISOString()
if (typeof value === 'object') return JSON.stringify(value)
if (typeof value === 'string') return neutralizeCsvFormula(value)
return String(value)
}
/**
* Serializes one cell for CSV, resolving `select` option ids to their names
* (comma-joined for multi) so the file shows the enum label, not the id.
@@ -51,14 +32,3 @@ export function formatCsvCell(column: ColumnDefinition, value: unknown): string
}
return formatCsvValue(value)
}
export function toCsvRow(values: string[]): string {
return values.map(escapeCsvField).join(',')
}
function escapeCsvField(field: string): string {
if (/[",\n\r]/.test(field)) {
return `"${field.replace(/"/g, '""')}"`
}
return field
}
+2 -6
View File
@@ -1,15 +1,11 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { neutralizeCsvFormula, toCsvRow } from '@/lib/core/utils/csv'
import { namedRowMapper } from '@/lib/table/cell-format'
import { getColumnId } from '@/lib/table/column-keys'
import { appendTableEvent } from '@/lib/table/events'
import {
formatCsvCell,
neutralizeCsvFormula,
sanitizeExportFilename,
toCsvRow,
} from '@/lib/table/export-format'
import { formatCsvCell, sanitizeExportFilename } from '@/lib/table/export-format'
import {
markJobFailedInWorkspace,
markJobReadyInWorkspace,
+1 -14
View File
@@ -1,5 +1,5 @@
import { createLogger } from '@sim/logger'
import { neutralizeCsvFormula } from '@/lib/core/utils/csv'
import { neutralizeCsvFormula, toCsvRow } from '@/lib/core/utils/csv'
import { namedRowMapper } from '@/lib/table/cell-format'
import { getColumnId } from '@/lib/table/column-keys'
import { formatCsvCell } from '@/lib/table/export-format'
@@ -10,19 +10,6 @@ const logger = createLogger('TableExportStream')
const EXPORT_BATCH_SIZE = 1000
export function sanitizeExportFilename(name: string): string {
const cleaned = name.replace(/[^a-zA-Z0-9_-]+/g, '_').replace(/^_+|_+$/g, '')
return cleaned || 'table'
}
function escapeCsvField(field: string): string {
return /[",\n\r]/.test(field) ? `"${field.replace(/"/g, '""')}"` : field
}
function toCsvRow(values: string[]): string {
return values.map(escapeCsvField).join(',')
}
export function exportContentType(format: TableExportFormat): string {
return format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json'
}
+2 -1
View File
@@ -16,8 +16,9 @@ import { db } from '@sim/db'
import { userTableDefinitions } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import { neutralizeCsvFormula, toCsvRow } from '@/lib/core/utils/csv'
import { getColumnId } from '@/lib/table/column-keys'
import { formatCsvCell, neutralizeCsvFormula, toCsvRow } from '@/lib/table/export-format'
import { formatCsvCell } from '@/lib/table/export-format'
import { selectExportRowPage } from '@/lib/table/jobs/service'
import type { TableDefinition } from '@/lib/table/types'
import { createMultipartUpload, deleteFile, headObject } from '@/lib/uploads/core/storage-service'
+44
View File
@@ -3,6 +3,14 @@
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockSaveBlob } = vi.hoisted(() => ({
mockSaveBlob: vi.fn(),
}))
vi.mock('@/lib/uploads/client/download', () => ({
saveBlob: mockSaveBlob,
}))
vi.hoisted(() => {
const legacyNewestFirst = {
state: {
@@ -30,6 +38,15 @@ vi.hoisted(() => {
import { useChatStore } from '@/stores/chat/store'
function readBlob(blob: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener('load', () => resolve(String(reader.result)))
reader.addEventListener('error', () => reject(reader.error))
reader.readAsText(blob)
})
}
describe('chat store message ordering', () => {
it('migrates v0 persisted messages from newest-first to insertion order', () => {
const messages = useChatStore.getState().messages
@@ -38,6 +55,7 @@ describe('chat store message ordering', () => {
describe('addMessage', () => {
beforeEach(() => {
mockSaveBlob.mockClear()
useChatStore.setState({ messages: [] })
})
@@ -65,4 +83,30 @@ describe('chat store message ordering', () => {
expect(messages[messages.length - 1].content).toBe('m54')
})
})
describe('exportChatCSV', () => {
beforeEach(() => {
mockSaveBlob.mockClear()
useChatStore.setState({
messages: [
{
id: 'msg-formula',
content: '=1+1',
workflowId: 'wf-1',
type: 'workflow',
timestamp: '2026-08-22T12:00:00.000Z',
},
],
})
})
it('neutralizes formula-leading message content', async () => {
useChatStore.getState().exportChatCSV('wf-1')
expect(mockSaveBlob).toHaveBeenCalledOnce()
const [blob, filename] = mockSaveBlob.mock.calls[0] as [Blob, string]
expect(filename).toMatch(/^chat-wf-1-.*\.csv$/)
await expect(readBlob(blob)).resolves.toContain("workflow,'=1+1")
})
})
})
+9 -44
View File
@@ -3,6 +3,8 @@ import { generateId } from '@sim/utils/id'
import { truncate } from '@sim/utils/string'
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv'
import { saveBlob } from '@/lib/uploads/client/download'
import type { ChatMessage, ChatState } from './types'
import { MAX_CHAT_HEIGHT, MAX_CHAT_WIDTH, MIN_CHAT_HEIGHT, MIN_CHAT_WIDTH } from './utils'
@@ -99,41 +101,16 @@ export const useChatStore = create<ChatState>()(
return
}
/**
* Safely stringify and escape CSV values
*/
const formatCSVValue = (value: any): string => {
if (value === null || value === undefined) {
return ''
}
let stringValue = typeof value === 'object' ? JSON.stringify(value) : String(value)
// Truncate very long strings
stringValue = truncate(stringValue, 2000)
// Escape quotes and wrap in quotes if contains special characters
if (
stringValue.includes('"') ||
stringValue.includes(',') ||
stringValue.includes('\n')
) {
stringValue = `"${stringValue.replace(/"/g, '""')}"`
}
return stringValue
}
const headers = ['timestamp', 'type', 'content']
const csvRows = [
headers.join(','),
toCsvRow(headers),
...messages.map((message: ChatMessage) =>
[
formatCSVValue(message.timestamp),
formatCSVValue(message.type),
formatCSVValue(message.content),
].join(',')
toCsvRow([
formatCsvValue(message.timestamp),
formatCsvValue(message.type),
truncate(formatCsvValue(message.content), 2000),
])
),
]
@@ -143,19 +120,7 @@ export const useChatStore = create<ChatState>()(
const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19)
const filename = `chat-${workflowId}-${timestamp}.csv`
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
if (link.download !== undefined) {
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', filename)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
saveBlob(new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }), filename)
},
setSelectedWorkflowOutput: (workflowId, outputIds) => {
@@ -3,6 +3,14 @@
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockSaveBlob } = vi.hoisted(() => ({
mockSaveBlob: vi.fn(),
}))
vi.mock('@/lib/uploads/client/download', () => ({
saveBlob: mockSaveBlob,
}))
vi.unmock('@/stores/terminal')
vi.unmock('@/stores/terminal/console/store')
@@ -10,6 +18,7 @@ import { useTerminalConsoleStore } from '@/stores/terminal/console/store'
describe('terminal console store', () => {
beforeEach(() => {
mockSaveBlob.mockClear()
useTerminalConsoleStore.setState({
workflowEntries: {},
entryIdsByBlockExecution: {},
@@ -19,6 +28,25 @@ describe('terminal console store', () => {
})
})
it('neutralizes formula-leading text in CSV exports', async () => {
useTerminalConsoleStore.getState().addConsole({
workflowId: 'wf-1',
blockId: 'block-1',
blockName: 'Function',
blockType: 'function',
executionId: 'exec-1',
executionOrder: 1,
error: '=1+1',
})
useTerminalConsoleStore.getState().exportConsoleCSV('wf-1')
expect(mockSaveBlob).toHaveBeenCalledOnce()
const [blob, filename] = mockSaveBlob.mock.calls[0] as [Blob, string]
expect(filename).toMatch(/^terminal-console-wf-1-.*\.csv$/)
await expect(blob.text()).resolves.toContain(",'=1+1,")
})
it('normalizes oversized payloads when adding console entries', () => {
useTerminalConsoleStore.getState().addConsole({
workflowId: 'wf-1',
+18 -41
View File
@@ -10,7 +10,9 @@ import {
} from '@/components/agent-stream/tool-call-lifecycle'
import { isChatEnabled } from '@/lib/core/config/env-flags'
import { redactApiKeys } from '@/lib/core/security/redaction'
import { formatCsvValue, toCsvRow } from '@/lib/core/utils/csv'
import { sendMothershipMessage } from '@/lib/mothership/events'
import { saveBlob } from '@/lib/uploads/client/download'
import { getQueryClient } from '@/app/_shell/providers/query-provider'
import type { NormalizedBlockOutput } from '@/executor/types'
import { type GeneralSettings, generalSettingsKeys } from '@/hooks/queries/general-settings'
@@ -431,20 +433,6 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
return
}
const formatCSVValue = (value: any): string => {
if (value === null || value === undefined) {
return ''
}
let stringValue = typeof value === 'object' ? safeConsoleStringify(value) : String(value)
if (stringValue.includes('"') || stringValue.includes(',') || stringValue.includes('\n')) {
stringValue = `"${stringValue.replace(/"/g, '""')}"`
}
return stringValue
}
const headers = [
'timestamp',
'blockName',
@@ -458,23 +446,24 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
'error',
'warning',
]
const serializeValue = (value: unknown) => formatCsvValue(value, safeConsoleStringify)
const csvRows = [
headers.join(','),
toCsvRow(headers),
...entries.map((entry) =>
[
formatCSVValue(entry.timestamp),
formatCSVValue(entry.blockName),
formatCSVValue(entry.blockType),
formatCSVValue(entry.startedAt),
formatCSVValue(entry.endedAt),
formatCSVValue(entry.durationMs),
formatCSVValue(entry.success),
formatCSVValue(entry.input),
formatCSVValue(entry.output),
formatCSVValue(entry.error),
formatCSVValue(entry.warning),
].join(',')
toCsvRow([
serializeValue(entry.timestamp),
serializeValue(entry.blockName),
serializeValue(entry.blockType),
serializeValue(entry.startedAt),
serializeValue(entry.endedAt),
serializeValue(entry.durationMs),
serializeValue(entry.success),
serializeValue(entry.input),
serializeValue(entry.output),
serializeValue(entry.error),
serializeValue(entry.warning),
])
),
]
@@ -482,19 +471,7 @@ export const useTerminalConsoleStore = create<ConsoleStore>()(
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
const filename = `terminal-console-${workflowId}-${timestamp}.csv`
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
if (link.download !== undefined) {
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', filename)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
saveBlob(new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }), filename)
},
getWorkflowEntries: (workflowId) => {