mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user