fix(workflows): redact run and export secrets (#6591)

* fix(workflows): redact run and export secrets

* fix(workflows): preserve redacted run outputs

* fix(workflows): retain safe trace output fallback

* fix(workflows): stop deriving outputs from traces
This commit is contained in:
Theodore Li
2026-08-12 00:32:44 -04:00
committed by GitHub
parent 9923fafe5e
commit 50e5dff53e
7 changed files with 429 additions and 41 deletions
@@ -21,6 +21,7 @@ vi.mock('@/lib/execution/payloads/store', () => ({
import {
externalizeExecutionData,
materializeExecutionData,
materializeExecutionDataForDisplayWithBlockOutputs,
projectExecutionDataForDisplay,
RESOLVED_SECRET_PROVENANCE_KEY,
SECRET_PROJECTION_VERSION,
@@ -92,6 +93,170 @@ describe('execution data storage', () => {
})
describe('projectExecutionDataForDisplay', () => {
it('projects authoritative state-only block outputs without mutating execution state', async () => {
const executionData = {
secretProjectionVersion: SECRET_PROJECTION_VERSION,
traceSpans: [],
executionState: {
resolvedSecretTraceProvenance: {
version: 1 as const,
complete: true,
entries: [{ name: 'OPENAI_API_KEY', encryptedValue: 'ciphertext' }],
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
},
blockStates: {
'function-1': {
output: { token: 12345678, derived: 12345683 },
resolvedSecretTraceProvenance: {
version: 1 as const,
complete: true,
entries: [{ name: 'OPENAI_API_KEY', encryptedValue: 'ciphertext' }],
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
},
},
},
},
}
const materialized = await materializeExecutionDataForDisplayWithBlockOutputs(
executionData,
CONTEXT,
['function-1']
)
expect(materialized.executionData).not.toHaveProperty('executionState')
expect(materialized.blockOutputs).toEqual(
new Map([['function-1', { token: '{{OPENAI_API_KEY}}', derived: 12345683 }]])
)
expect(executionData.executionState.blockStates['function-1'].output).toEqual({
token: 12345678,
derived: 12345683,
})
expect(JSON.stringify(materialized.executionData)).not.toContain('12345678')
expect(JSON.stringify([...materialized.blockOutputs])).not.toContain('12345678')
})
it('does not use trace output for a requested block missing from partial state', async () => {
const emptyProvenance = {
version: 1 as const,
complete: true,
entries: [],
scope: { userId: 'user-1', workspaceId: 'workspace-1' },
}
const materialized = await materializeExecutionDataForDisplayWithBlockOutputs(
{
secretProjectionVersion: SECRET_PROJECTION_VERSION,
traceSpans: [
{
id: 'span-1',
blockId: 'trace-only',
name: 'Trace-only block',
type: 'function',
duration: 1,
startTime: '2026-08-11T00:00:00.000Z',
endTime: '2026-08-11T00:00:00.001Z',
output: { result: 'trace-output' },
},
],
executionState: {
resolvedSecretTraceProvenance: emptyProvenance,
blockStates: {
'state-only': {
output: { result: 'state-output' },
resolvedSecretTraceProvenance: emptyProvenance,
},
},
},
},
CONTEXT,
['state-only', 'trace-only']
)
expect(materialized.blockOutputs).toEqual(new Map([['state-only', { result: 'state-output' }]]))
})
it('does not derive block outputs from legacy trace spans', async () => {
const materialized = await materializeExecutionDataForDisplayWithBlockOutputs(
{
traceSpans: [
{
id: 'span-1',
blockId: 'function-1',
name: 'Function 1',
type: 'function',
duration: 1,
startTime: '2026-08-11T00:00:00.000Z',
endTime: '2026-08-11T00:00:00.001Z',
output: { token: 'raw-legacy-secret' },
},
],
},
CONTEXT,
['function-1']
)
expect(materialized.blockOutputs).toEqual(new Map())
})
it('does not mix legacy trace output into partial execution state', async () => {
const materialized = await materializeExecutionDataForDisplayWithBlockOutputs(
{
traceSpans: [
{
id: 'span-1',
blockId: 'trace-only',
name: 'Trace-only block',
type: 'function',
duration: 1,
startTime: '2026-08-11T00:00:00.000Z',
endTime: '2026-08-11T00:00:00.001Z',
output: { token: 'raw-legacy-secret' },
},
],
executionState: {
blockStates: {
'state-only': { output: { result: 'unproven-state-output' } },
},
},
},
CONTEXT,
['state-only', 'trace-only']
)
expect(materialized.blockOutputs).toEqual(new Map())
expect(JSON.stringify([...materialized.blockOutputs])).not.toContain('raw-legacy-secret')
})
it('omits state-only block outputs that lack usable secret provenance', async () => {
const materialized = await materializeExecutionDataForDisplayWithBlockOutputs(
{
secretProjectionVersion: SECRET_PROJECTION_VERSION,
traceSpans: [
{
id: 'span-1',
blockId: 'function-1',
name: 'Function 1',
type: 'function',
duration: 1,
startTime: '2026-08-11T00:00:00.000Z',
endTime: '2026-08-11T00:00:00.001Z',
output: { token: 'trace-fallback' },
},
],
executionState: {
blockStates: {
'function-1': { output: { token: 'unproven-secret' } },
},
},
},
CONTEXT,
['function-1']
)
expect(materialized.blockOutputs).toEqual(new Map())
expect(JSON.stringify(materialized)).not.toContain('unproven-secret')
})
it('retains run-global projection for legacy rows without exact value sidecars', async () => {
const executionData = {
finalOutput: { result: 12345678, derived: 12345683 },
+103 -22
View File
@@ -3,6 +3,7 @@ import { toError } from '@sim/utils/errors'
import { omit } from '@sim/utils/object'
import { isLargeValueRef } from '@/lib/execution/payloads/large-value-ref'
import { materializeLargeValueRef, storeLargeValue } from '@/lib/execution/payloads/store'
import { FunctionalOutputsUnavailableError } from '@/lib/logs/execution/functional-outputs'
import { projectTraceSpansForSecrets } from '@/lib/logs/execution/trace-secret-projection'
import type { TraceSpan } from '@/lib/logs/types'
import {
@@ -72,6 +73,11 @@ export interface TraceStoreReadContext {
userId?: string
}
export interface DisplayExecutionDataWithBlockOutputs {
executionData: Record<string, unknown>
blockOutputs: Map<string, unknown>
}
/**
* Write-path context. Requires the execution owner's `userId`: the externalized
* object is tracked in `workspace_files`, whose `user_id` column is NOT NULL
@@ -269,6 +275,100 @@ export async function materializeExecutionDataForDisplay(
return projectExecutionDataForDisplay(materialized, context)
}
/**
* Materializes one trusted row into its display envelope plus secret-safe functional outputs.
* Only requested execution-state outputs are projected and returned; trace spans remain display
* data and the raw execution state never crosses the display boundary.
*/
export async function materializeExecutionDataForDisplayWithBlockOutputs(
executionData: Record<string, unknown> | null | undefined,
context: TraceStoreReadContext,
blockIds: readonly string[]
): Promise<DisplayExecutionDataWithBlockOutputs> {
const materialized = await materializeExecutionData(executionData, context)
const displayData = await projectExecutionDataForDisplay(materialized, context)
if (blockIds.length === 0) {
return { executionData: displayData, blockOutputs: new Map() }
}
const executionState = readRecord(materialized.executionState)
const blockStates = readRecord(executionState?.blockStates)
if (!blockStates) {
if (materialized.executionDataTruncated === true) {
throw new FunctionalOutputsUnavailableError()
}
return { executionData: displayData, blockOutputs: new Map() }
}
const runRegistry = await importResolvedSecretTraceRegistry(
materialized[RESOLVED_SECRET_PROVENANCE_KEY] ??
executionState?.[RESOLVED_SECRET_PROVENANCE_KEY],
'traceStore.blockOutputRunProvenance'
)
const blockOutputs = new Map<string, unknown>()
const projectionStore = createReadOnlyProjectionStore(context)
for (const blockId of new Set(blockIds)) {
const blockState = readRecord(blockStates[blockId])
if (!blockState || blockState.output === undefined) continue
const hasExactProvenance = Object.hasOwn(blockState, RESOLVED_SECRET_PROVENANCE_KEY)
const registry = hasExactProvenance
? await importResolvedSecretTraceRegistry(
blockState[RESOLVED_SECRET_PROVENANCE_KEY],
'traceStore.blockOutputExactProvenance'
)
: runRegistry
const now = new Date().toISOString()
const [projected] = await projectTraceSpansForSecrets(
[
{
id: `${LOG_DISPLAY_PROJECTION_SPAN_ID}-block-output`,
name: 'Block Output Display Projection',
type: 'display',
duration: 0,
startTime: now,
endTime: now,
output: { value: blockState.output },
},
],
{ registry, allowLargeValueWrites: false, store: projectionStore }
)
if (projected?.output && Object.hasOwn(projected.output, 'value')) {
blockOutputs.set(blockId, projected.output.value)
}
}
return { executionData: displayData, blockOutputs }
}
function readRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined
}
async function importResolvedSecretTraceRegistry(
provenance: unknown,
origin: string
): Promise<ResolvedSecretTraceRegistry | undefined> {
if (!isResolvedSecretTraceProvenanceV1(provenance)) return undefined
const registry = new ResolvedSecretTraceRegistry([], provenance.scope)
await registry.importProvenance(provenance, { trusted: true, origin })
return registry
}
function createReadOnlyProjectionStore(context: TraceStoreReadContext) {
return {
workspaceId: context.workspaceId ?? undefined,
workflowId: context.workflowId ?? undefined,
executionId: context.executionId,
userId: context.userId,
trackReference: false,
}
}
/**
* Projects execution-log content with the encrypted provenance saved by the
* trusted executor. Current workflow input and final output values use their
@@ -284,12 +384,7 @@ export async function projectExecutionDataForDisplay(
executionData: Record<string, unknown>,
context: TraceStoreReadContext
): Promise<Record<string, unknown>> {
const executionState =
executionData.executionState &&
typeof executionData.executionState === 'object' &&
!Array.isArray(executionData.executionState)
? (executionData.executionState as Record<string, unknown>)
: undefined
const executionState = readRecord(executionData.executionState)
const hasTopLevelProvenance = Object.hasOwn(executionData, RESOLVED_SECRET_PROVENANCE_KEY)
const stateProvenance = executionState?.[RESOLVED_SECRET_PROVENANCE_KEY]
const provenance = executionData[RESOLVED_SECRET_PROVENANCE_KEY] ?? stateProvenance
@@ -302,15 +397,7 @@ export async function projectExecutionDataForDisplay(
return projectLegacyExecutionDataForDisplay(executionData)
}
let registry: ResolvedSecretTraceRegistry | undefined
if (isResolvedSecretTraceProvenanceV1(provenance)) {
registry = new ResolvedSecretTraceRegistry([], provenance.scope)
await registry.importProvenance(provenance, {
trusted: true,
origin: 'traceStore.spanProvenance',
})
}
const registry = await importResolvedSecretTraceRegistry(provenance, 'traceStore.spanProvenance')
/**
* Compaction drops `executionState`, and with it the only copy of the
@@ -339,13 +426,7 @@ export async function projectExecutionDataForDisplay(
})
}
const projectionStore = {
workspaceId: context.workspaceId ?? undefined,
workflowId: context.workflowId ?? undefined,
executionId: context.executionId,
userId: context.userId,
trackReference: false,
}
const projectionStore = createReadOnlyProjectionStore(context)
const exactValueProjections = new Map<string, unknown>()
for (const [valueKey, provenanceKey] of Object.entries(EXACT_LOG_VALUE_PROVENANCE_KEYS)) {
@@ -5,20 +5,17 @@ import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@s
import { and } from 'drizzle-orm'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetJob } = vi.hoisted(() => ({
const { mockGetJob, mockMaterializeForDisplayWithBlockOutputs } = vi.hoisted(() => ({
mockGetJob: vi.fn(),
mockMaterializeForDisplayWithBlockOutputs: vi.fn(),
}))
vi.mock('@/lib/core/async-jobs', () => ({
getJobQueue: vi.fn().mockResolvedValue({ getJob: mockGetJob }),
}))
vi.mock('@/lib/logs/execution/functional-outputs', () => ({
collectFunctionalBlockOutputs: vi.fn().mockReturnValue(new Map()),
}))
vi.mock('@/lib/logs/execution/trace-store', () => ({
materializeExecutionData: vi.fn(),
materializeExecutionDataForDisplayWithBlockOutputs: mockMaterializeForDisplayWithBlockOutputs,
}))
vi.mock('@/lib/workflows/executor/paused-execution-metadata', () => ({
@@ -38,6 +35,58 @@ describe('getWorkflowExecutionStatus queue projection', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
mockMaterializeForDisplayWithBlockOutputs.mockResolvedValue({
executionData: {},
blockOutputs: new Map(),
})
})
it('selects run outputs only from the secret-safe display projection', async () => {
queueTableRows(schemaMock.workflowExecutionLogs, [
{
executionId: 'execution-1',
workflowId: 'workflow-1',
workspaceId: 'workspace-1',
status: 'completed',
level: 'info',
trigger: 'api',
startedAt: new Date('2026-08-05T12:00:00.000Z'),
endedAt: new Date('2026-08-05T12:00:01.000Z'),
totalDurationMs: 1000,
executionData: {
executionState: {
blockStates: { 'block-1': { output: { token: 'resolved-secret' } } },
},
},
costTotal: null,
},
])
queueTableRows(schemaMock.resumeQueue, [])
queueTableRows(schemaMock.pausedExecutions, [])
mockMaterializeForDisplayWithBlockOutputs.mockResolvedValueOnce({
executionData: { finalOutput: { token: '[REDACTED]' } },
blockOutputs: new Map([['block-1', { token: '[REDACTED]' }]]),
})
const status = await getWorkflowExecutionStatus({
...input,
includeOutput: true,
selectedOutputs: ['block-1'],
})
expect(mockMaterializeForDisplayWithBlockOutputs).toHaveBeenCalledWith(
expect.objectContaining({ executionState: expect.anything() }),
{
workspaceId: 'workspace-1',
workflowId: 'workflow-1',
executionId: 'execution-1',
},
['block-1']
)
expect(status).toMatchObject({
finalOutput: { token: '[REDACTED]' },
blockOutputs: { 'block-1': { token: '[REDACTED]' } },
})
expect(JSON.stringify(status)).not.toContain('resolved-secret')
})
it('projects a queued workflow job as an execution resource', async () => {
@@ -4,11 +4,7 @@ import { and, eq, inArray, sql } from 'drizzle-orm'
import type { WorkflowExecutionStatusResponse } from '@/lib/api/contracts/workflows'
import { getJobQueue } from '@/lib/core/async-jobs'
import type { Job } from '@/lib/core/async-jobs/types'
import {
collectFunctionalBlockOutputs,
type FunctionalExecutionDataSource,
} from '@/lib/logs/execution/functional-outputs'
import { materializeExecutionData } from '@/lib/logs/execution/trace-store'
import { materializeExecutionDataForDisplayWithBlockOutputs } from '@/lib/logs/execution/trace-store'
import {
RESUME_EXECUTION_JOB_ID_PREFIX,
WORKFLOW_EXECUTION_JOB_ID_PREFIX,
@@ -27,7 +23,7 @@ import type { PausePoint } from '@/executor/types'
type LogStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
interface ExecutionDataShape extends FunctionalExecutionDataSource {
interface ExecutionDataShape {
finalOutput?: { error?: string } & Record<string, unknown>
error?: { message?: string } | string
completionFailure?: string
@@ -259,16 +255,19 @@ export async function getWorkflowExecutionStatus(
const cost = logRow.costTotal != null ? { total: Number(logRow.costTotal) } : null
// Heavy execution data may live in object storage; resolve the pointer
// before reading error / finalOutput / traceSpans (no-op for inline rows).
const executionData = (await materializeExecutionData(
const requestedBlockIds = [
...new Set(selectedOutputs.map((selector) => selector.split('.')[0]).filter(Boolean)),
]
const materialized = await materializeExecutionDataForDisplayWithBlockOutputs(
logRow.executionData as Record<string, unknown> | null,
{
workspaceId: logRow.workspaceId,
workflowId: logRow.workflowId,
executionId: logRow.executionId,
}
)) as ExecutionDataShape | undefined
},
requestedBlockIds
)
const executionData = materialized.executionData as ExecutionDataShape
const error = status === 'failed' ? extractError(executionData) : null
@@ -279,7 +278,7 @@ export async function getWorkflowExecutionStatus(
const blockOutputs =
selectedOutputs.length > 0
? pickSelectedOutputs(selectedOutputs, collectFunctionalBlockOutputs(executionData))
? pickSelectedOutputs(selectedOutputs, materialized.blockOutputs)
: null
return {
@@ -0,0 +1,91 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
loadNormalized: vi.fn(),
}))
vi.mock('@/lib/workflows/persistence/utils', () => ({
loadWorkflowFromNormalizedTables: mocks.loadNormalized,
}))
vi.mock('@/blocks/registry', () => ({
getBlock: (type: string) =>
type === 'agent'
? {
name: 'Agent',
subBlocks: [{ id: 'tools', type: 'tool-input' }],
outputs: {},
}
: {
name: 'Slack',
subBlocks: [
{ id: 'credential', type: 'oauth-input' },
{ id: 'botToken', type: 'short-input', password: true },
{ id: 'text', type: 'long-input' },
],
outputs: {},
},
}))
import { buildWorkflowExportPayload } from '@/lib/workflows/operations/export-workflow'
describe('buildWorkflowExportPayload', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.loadNormalized.mockResolvedValue({
blocks: {
agent: {
id: 'agent',
type: 'agent',
name: 'Agent',
position: { x: 0, y: 0 },
subBlocks: {
tools: {
id: 'tools',
type: 'tool-input',
value: [
{
type: 'slack',
toolId: 'slack_message',
params: {
credential: 'nested-credential-id',
botToken: 'nested-xoxb-secret',
text: 'ordinary message',
},
},
],
},
},
outputs: {},
enabled: true,
},
},
edges: [],
loops: {},
parallels: {},
})
})
it('redacts nested tool credentials from the public export payload', async () => {
const payload = await buildWorkflowExportPayload({
id: 'workflow-1',
name: 'Reports',
description: null,
workspaceId: 'workspace-1',
folderId: null,
variables: {},
})
const params = payload?.state.blocks.agent.subBlocks.tools.value[0].params
expect(params).toEqual({
credential: null,
botToken: null,
text: 'ordinary message',
})
expect(JSON.stringify(payload)).not.toContain('nested-credential-id')
expect(JSON.stringify(payload)).not.toContain('nested-xoxb-secret')
})
})
@@ -12,11 +12,13 @@ import { parseWorkflowVariables } from '@/lib/workflows/variables/parse'
*
* Unlike the admin export (`/api/v1/admin/workflows/[id]/export`), which emits
* the raw state for backup/restore, this runs the payload through
* `sanitizeForExport`, which nulls three classes of sub-block value:
* `sanitizeForExport`, which nulls five classes of sub-block value:
* - `password: true` fields, unless the value is a whole `{{ENV_VAR}}`
* reference, which is preserved so the import resolves it in the target
* workspace;
* - `oauth-input` credentials;
* - sensitive nested `tool-input` params and params without authoritative metadata;
* - opaque credential-bearing values such as arbitrary table cells;
* - **workspace-scoped bindings** — selector fields and id-keyed fields that
* point at rows that do not exist in another workspace, cleared rather than
* carried across as dangling ids.
@@ -662,6 +662,7 @@ export function sanitizeForExport(state: WorkflowState): ExportWorkflowState {
// Use unified sanitization with env var preservation for export
const sanitizedState = sanitizeWorkflowForSharing(fullState, {
preserveEnvVars: true, // Keep {{ENV_VAR}} references in exported workflows
redactOpaqueCredentialInputs: true,
}) as ExportWorkflowState['state']
return {