Skip to content

Commit 0146274

Browse files
fix(workflows): redact run and export secrets
1 parent 380aca2 commit 0146274

5 files changed

Lines changed: 148 additions & 11 deletions

File tree

apps/sim/lib/workflows/executor/execution-status.test.ts

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,17 @@ import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@s
55
import { and } from 'drizzle-orm'
66
import { beforeEach, describe, expect, it, vi } from 'vitest'
77

8-
const { mockGetJob } = vi.hoisted(() => ({
8+
const { mockGetJob, mockMaterializeForDisplay } = vi.hoisted(() => ({
99
mockGetJob: vi.fn(),
10+
mockMaterializeForDisplay: vi.fn(),
1011
}))
1112

1213
vi.mock('@/lib/core/async-jobs', () => ({
1314
getJobQueue: vi.fn().mockResolvedValue({ getJob: mockGetJob }),
1415
}))
1516

16-
vi.mock('@/lib/logs/execution/functional-outputs', () => ({
17-
collectFunctionalBlockOutputs: vi.fn().mockReturnValue(new Map()),
18-
}))
19-
2017
vi.mock('@/lib/logs/execution/trace-store', () => ({
21-
materializeExecutionData: vi.fn(),
18+
materializeExecutionDataForDisplay: mockMaterializeForDisplay,
2219
}))
2320

2421
vi.mock('@/lib/workflows/executor/paused-execution-metadata', () => ({
@@ -38,6 +35,54 @@ describe('getWorkflowExecutionStatus queue projection', () => {
3835
beforeEach(() => {
3936
vi.clearAllMocks()
4037
resetDbChainMock()
38+
mockMaterializeForDisplay.mockResolvedValue({})
39+
})
40+
41+
it('selects run outputs only from the secret-safe display projection', async () => {
42+
queueTableRows(schemaMock.workflowExecutionLogs, [
43+
{
44+
executionId: 'execution-1',
45+
workflowId: 'workflow-1',
46+
workspaceId: 'workspace-1',
47+
status: 'completed',
48+
level: 'info',
49+
trigger: 'api',
50+
startedAt: new Date('2026-08-05T12:00:00.000Z'),
51+
endedAt: new Date('2026-08-05T12:00:01.000Z'),
52+
totalDurationMs: 1000,
53+
executionData: {
54+
executionState: {
55+
blockStates: { 'block-1': { output: { token: 'resolved-secret' } } },
56+
},
57+
},
58+
costTotal: null,
59+
},
60+
])
61+
queueTableRows(schemaMock.resumeQueue, [])
62+
queueTableRows(schemaMock.pausedExecutions, [])
63+
mockMaterializeForDisplay.mockResolvedValueOnce({
64+
finalOutput: { token: '[REDACTED]' },
65+
traceSpans: [{ blockId: 'block-1', output: { token: '[REDACTED]' } }],
66+
})
67+
const status = await getWorkflowExecutionStatus({
68+
...input,
69+
includeOutput: true,
70+
selectedOutputs: ['block-1'],
71+
})
72+
73+
expect(mockMaterializeForDisplay).toHaveBeenCalledWith(
74+
expect.objectContaining({ executionState: expect.anything() }),
75+
{
76+
workspaceId: 'workspace-1',
77+
workflowId: 'workflow-1',
78+
executionId: 'execution-1',
79+
}
80+
)
81+
expect(status).toMatchObject({
82+
finalOutput: { token: '[REDACTED]' },
83+
blockOutputs: { 'block-1': { token: '[REDACTED]' } },
84+
})
85+
expect(JSON.stringify(status)).not.toContain('resolved-secret')
4186
})
4287

4388
it('projects a queued workflow job as an execution resource', async () => {

apps/sim/lib/workflows/executor/execution-status.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
collectFunctionalBlockOutputs,
99
type FunctionalExecutionDataSource,
1010
} from '@/lib/logs/execution/functional-outputs'
11-
import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
11+
import { materializeExecutionDataForDisplay } from '@/lib/logs/execution/trace-store'
1212
import {
1313
RESUME_EXECUTION_JOB_ID_PREFIX,
1414
WORKFLOW_EXECUTION_JOB_ID_PREFIX,
@@ -259,9 +259,7 @@ export async function getWorkflowExecutionStatus(
259259

260260
const cost = logRow.costTotal != null ? { total: Number(logRow.costTotal) } : null
261261

262-
// Heavy execution data may live in object storage; resolve the pointer
263-
// before reading error / finalOutput / traceSpans (no-op for inline rows).
264-
const executionData = (await materializeExecutionData(
262+
const executionData = (await materializeExecutionDataForDisplay(
265263
logRow.executionData as Record<string, unknown> | null,
266264
{
267265
workspaceId: logRow.workspaceId,
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const mocks = vi.hoisted(() => ({
7+
loadNormalized: vi.fn(),
8+
}))
9+
10+
vi.mock('@/lib/workflows/persistence/utils', () => ({
11+
loadWorkflowFromNormalizedTables: mocks.loadNormalized,
12+
}))
13+
14+
vi.mock('@/blocks/registry', () => ({
15+
getBlock: (type: string) =>
16+
type === 'agent'
17+
? {
18+
name: 'Agent',
19+
subBlocks: [{ id: 'tools', type: 'tool-input' }],
20+
outputs: {},
21+
}
22+
: {
23+
name: 'Slack',
24+
subBlocks: [
25+
{ id: 'credential', type: 'oauth-input' },
26+
{ id: 'botToken', type: 'short-input', password: true },
27+
{ id: 'text', type: 'long-input' },
28+
],
29+
outputs: {},
30+
},
31+
}))
32+
33+
import { buildWorkflowExportPayload } from '@/lib/workflows/operations/export-workflow'
34+
35+
describe('buildWorkflowExportPayload', () => {
36+
beforeEach(() => {
37+
vi.clearAllMocks()
38+
mocks.loadNormalized.mockResolvedValue({
39+
blocks: {
40+
agent: {
41+
id: 'agent',
42+
type: 'agent',
43+
name: 'Agent',
44+
position: { x: 0, y: 0 },
45+
subBlocks: {
46+
tools: {
47+
id: 'tools',
48+
type: 'tool-input',
49+
value: [
50+
{
51+
type: 'slack',
52+
toolId: 'slack_message',
53+
params: {
54+
credential: 'nested-credential-id',
55+
botToken: 'nested-xoxb-secret',
56+
text: 'ordinary message',
57+
},
58+
},
59+
],
60+
},
61+
},
62+
outputs: {},
63+
enabled: true,
64+
},
65+
},
66+
edges: [],
67+
loops: {},
68+
parallels: {},
69+
})
70+
})
71+
72+
it('redacts nested tool credentials from the public export payload', async () => {
73+
const payload = await buildWorkflowExportPayload({
74+
id: 'workflow-1',
75+
name: 'Reports',
76+
description: null,
77+
workspaceId: 'workspace-1',
78+
folderId: null,
79+
variables: {},
80+
})
81+
82+
const params = payload?.state.blocks.agent.subBlocks.tools.value[0].params
83+
expect(params).toEqual({
84+
credential: null,
85+
botToken: null,
86+
text: 'ordinary message',
87+
})
88+
expect(JSON.stringify(payload)).not.toContain('nested-credential-id')
89+
expect(JSON.stringify(payload)).not.toContain('nested-xoxb-secret')
90+
})
91+
})

apps/sim/lib/workflows/operations/export-workflow.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@ import { parseWorkflowVariables } from '@/lib/workflows/variables/parse'
1212
*
1313
* Unlike the admin export (`/api/v1/admin/workflows/[id]/export`), which emits
1414
* the raw state for backup/restore, this runs the payload through
15-
* `sanitizeForExport`, which nulls three classes of sub-block value:
15+
* `sanitizeForExport`, which nulls five classes of sub-block value:
1616
* - `password: true` fields, unless the value is a whole `{{ENV_VAR}}`
1717
* reference, which is preserved so the import resolves it in the target
1818
* workspace;
1919
* - `oauth-input` credentials;
20+
* - sensitive nested `tool-input` params and params without authoritative metadata;
21+
* - opaque credential-bearing values such as arbitrary table cells;
2022
* - **workspace-scoped bindings** — selector fields and id-keyed fields that
2123
* point at rows that do not exist in another workspace, cleared rather than
2224
* carried across as dangling ids.

apps/sim/lib/workflows/sanitization/json-sanitizer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,7 @@ export function sanitizeForExport(state: WorkflowState): ExportWorkflowState {
662662
// Use unified sanitization with env var preservation for export
663663
const sanitizedState = sanitizeWorkflowForSharing(fullState, {
664664
preserveEnvVars: true, // Keep {{ENV_VAR}} references in exported workflows
665+
redactOpaqueCredentialInputs: true,
665666
}) as ExportWorkflowState['state']
666667

667668
return {

0 commit comments

Comments
 (0)