Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/sim/app/api/workflows/[id]/execute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1174,6 +1174,15 @@ async function handleExecutePost(
loggingTriggerType,
requestId
)
/**
* Reusing a prior run's input copies that run's exposure with it, and this
* run's own provenance cannot describe a secret the source resolved. Record
* the source so the log display projection withholds the workflow-boundary
* exemption for this run.
*/
if (inputFromExecutionId) {
loggingSession.setInputSourceExecutionId(inputFromExecutionId)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Async rerun omits input source

High Severity

For async runs with inputFromExecutionId, the setInputSourceExecutionId call on the route's LoggingSession isn't persisted. The worker creates a new session that lacks this ID, resulting in incomplete provenance. This can inadvertently expose copied secrets from the source execution in logs under the workflowInput boundary.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 983be88. Configure here.

if (copilotToolCallId) {
loggingSession.setTrustedExecutionCorrelation({
executionId,
Expand Down
138 changes: 138 additions & 0 deletions apps/sim/lib/logs/execution/legacy-workflow-input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { isRecordLike, omit } from '@sim/utils/object'

/**
* The shape the executor records for a trigger block: never executed, zero
* duration, populated output (`executor.ts` `setBlockState` after
* `buildStartBlockOutput`).
*
* The shape is not unique to the trigger. A human-in-the-loop pause writes a
* placeholder block state with the same three properties (`block-executor.ts`,
* `{ url, resumeEndpoint }` output), so a run that paused has more than one
* match. Callers that cannot tolerate the wrong block must disambiguate - see
* {@link recoverLegacyWorkflowInputForDisplay}.
*/
function isLegacyTriggerBlockState(state: unknown): state is { output: unknown } {
return (
isRecordLike(state) &&
state.executed === false &&
state.executionTime === 0 &&
state.output != null
)
}

function collectLegacyTriggerOutputs(executionData: Record<string, unknown>): unknown[] {
if (!isRecordLike(executionData.executionState)) return []
const { blockStates } = executionData.executionState
if (!isRecordLike(blockStates)) return []

const outputs: unknown[] = []
for (const state of Object.values(blockStates)) {
if (isLegacyTriggerBlockState(state)) outputs.push(state.output)
}
return outputs
}

/**
* Recovers the inbound trigger payload from execution data written before
* `workflowInput` was persisted as a top-level field.
*
* Returns the first matching block state, preserving the long-standing
* behavior of the functional re-run reader. Display callers must use
* {@link recoverLegacyWorkflowInputForDisplay}, which refuses to guess.
*/
export function extractLegacyWorkflowInput(
executionData: Record<string, unknown>
): unknown | undefined {
return collectLegacyTriggerOutputs(executionData)[0]
}

/**
* Whether the persisted state carries block states at all.
*
* Callers pair this with an absent provenance key. The trace registry is
* attached before the executor runs (`execution-core.ts` installs it ahead of
* `safeStart`), so any execution that produced block states also stamped
* provenance. An absent key together with populated block states therefore
* identifies pre-stamping data, and never a post-stamping run that failed
* early enough to miss the stamp - those carry no block states to recover from.
*/
export function hasPersistedBlockStates(executionData: Record<string, unknown>): boolean {
if (!isRecordLike(executionData.executionState)) return false
const { blockStates } = executionData.executionState
return isRecordLike(blockStates) && Object.keys(blockStates).length > 0
}

/**
* Keys that the trigger block hoists next to a nested `input` payload. Blob
* forensics on pre-persistence executions show the block output is a strict
* superset of the original `workflowInput` in this shape, so the recovered
* value is projected back down to `{ input }`.
*/
const NESTED_INPUT_KEY = 'input'

/**
* Whether the nested `input` is merely a clone of its sibling keys.
*
* `buildApiOrInputOutput` records an object input as
* `{ ...finalInput, input: { ...finalInput } }`, so the original
* `workflowInput` was the FLAT object and the nested copy is redundant.
* Narrowing that shape to `{ input }` would display something the run never
* received. The superset shape the narrowing targets is distinguishable: its
* nested `input` carries keys the siblings do not.
*/
function isNestedInputSiblingClone(recovered: Record<string, unknown>): boolean {
const nested = recovered[NESTED_INPUT_KEY]
if (!isRecordLike(nested)) return false

const siblings = omit(recovered, [NESTED_INPUT_KEY])
const siblingKeys = Object.keys(siblings)
if (siblingKeys.length === 0 || siblingKeys.length !== Object.keys(nested).length) return false

return siblingKeys.every(
(key) =>
Object.hasOwn(nested, key) && JSON.stringify(siblings[key]) === JSON.stringify(nested[key])
)
}

/**
* A Slack verification token echoed into the trigger block output. It is the
* only key that diverges from the original `workflowInput` in the Slack
* envelope shape, and it is secret-shaped, so it is dropped rather than
* displayed with a value that is both wrong and sensitive.
*/
const DROPPED_RECOVERED_KEYS = ['token'] as const

/**
* Recovers `workflowInput` for the log display projection, narrowing the raw
* trigger block output to the shape the field originally held. Functional
* readers must keep using {@link extractLegacyWorkflowInput} directly - this
* narrowing is display-only and intentionally lossy.
*
* Callers must restrict this to executions written before resolved-secret
* provenance was stamped. Block state is gated content, and this routes it to
* the ungated workflow-boundary envelope; that is only sound while the matched
* block is the trigger, which holds the payload captured before any secret was
* resolved.
*
* Recovery is therefore refused when more than one block state matches the
* trigger shape - a paused run also carries a resume placeholder with the same
* shape, and showing its `{ url, resumeEndpoint }` output labelled as the
* workflow input would be both wrong and a capability-URL disclosure. An empty
* panel beats confidently wrong content.
*/
export function recoverLegacyWorkflowInputForDisplay(
executionData: Record<string, unknown>
): unknown | undefined {
const candidates = collectLegacyTriggerOutputs(executionData)
if (candidates.length !== 1) return undefined

const recovered = candidates[0]
if (!isRecordLike(recovered)) return recovered

const narrowed: Record<string, unknown> =
isRecordLike(recovered[NESTED_INPUT_KEY]) && !isNestedInputSiblingClone(recovered)
? { [NESTED_INPUT_KEY]: recovered[NESTED_INPUT_KEY] }
: recovered

return omit(narrowed, [...DROPPED_RECOVERED_KEYS])
}
22 changes: 22 additions & 0 deletions apps/sim/lib/logs/execution/logging-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,28 @@ describe('LoggingSession terminal provenance', () => {
)
})

it('stamps provenance on finalization when no registry was installed explicitly', async () => {
startWorkflowExecutionMock.mockResolvedValue({})
loadWorkflowStateForExecutionMock.mockResolvedValue({
blocks: {},
edges: [],
loops: {},
parallels: {},
})
const session = new LoggingSession('workflow-1', 'execution-implicit-registry', 'webhook')

await session.start({ userId: 'user-1', workspaceId: 'workspace-1' })
await session.completeWithError({ error: { message: 'failed' } })

expect(completeWorkflowExecutionMock).toHaveBeenCalledWith(
expect.objectContaining({
executionState: expect.objectContaining({
resolvedSecretTraceProvenance: expect.objectContaining({ version: 1 }),
}),
})
)
})

it.each(['cancellation', 'pause'] as const)(
'preserves raw execution state on %s finalization',
async (finalization) => {
Expand Down
30 changes: 27 additions & 3 deletions apps/sim/lib/logs/execution/logging-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ export class LoggingSession {
private postExecutionPromise: Promise<void> | null = null
private resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry
private traceLargeValueAccess: LargeValueStoreContext = {}
private inputSourceExecutionId?: string

constructor(
workflowId: string,
Expand All @@ -226,6 +227,20 @@ export class LoggingSession {
this.resolvedSecretTraceRegistry = registry
}

/**
* Records that this run's input was copied from a prior execution
* (`inputFromExecutionId`) rather than arriving with the trigger.
*
* The log display projection reads this to withhold the workflow-boundary
* exemption. An inherited input carries the SOURCE run's exposure - a value
* resolved inside a custom-block parent stays plaintext through the copy -
* and this run's own provenance cannot describe that resolution, so it must
* not be treated as a pre-resolution inbound payload.
*/
setInputSourceExecutionId(sourceExecutionId: string): void {
this.inputSourceExecutionId = sourceExecutionId
}

/** Adds server-validated lifecycle correlation without exposing it to executor metadata. */
setTrustedExecutionCorrelation(
correlation: NonNullable<NonNullable<ExecutionTrigger['data']>['correlation']>
Expand Down Expand Up @@ -626,9 +641,18 @@ export class LoggingSession {
}

try {
const effectiveTriggerData = this.trustedExecutionCorrelation
? { ...triggerData, correlation: this.trustedExecutionCorrelation }
: triggerData
const derivedTriggerData = {
...(this.trustedExecutionCorrelation
? { correlation: this.trustedExecutionCorrelation }
: {}),
...(this.inputSourceExecutionId
? { inputSourceExecutionId: this.inputSourceExecutionId }
: {}),
}
const effectiveTriggerData =
Object.keys(derivedTriggerData).length > 0 || triggerData
? { ...triggerData, ...derivedTriggerData }
: undefined
this.trigger = createTriggerObject(this.triggerType, effectiveTriggerData)
this.correlation = effectiveTriggerData?.correlation
this.environment = createEnvironmentObject(
Expand Down
54 changes: 54 additions & 0 deletions apps/sim/lib/logs/execution/trace-secret-projection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ vi.mock('@/lib/execution/payloads/store', () => ({
import {
enforceTraceSpanSecretInvariant,
projectTraceSpansForSecrets,
projectWorkflowBoundarySpansForSecrets,
} from '@/lib/logs/execution/trace-secret-projection'
import type { TraceSpan } from '@/lib/logs/types'
import {
Expand Down Expand Up @@ -1109,3 +1110,56 @@ describe('projectTraceSpansForSecrets', () => {
expect(cursor?.children).toEqual([])
})
})

describe('projectWorkflowBoundarySpansForSecrets', () => {
it('retains boundary content when provenance is absent', async () => {
const [result] = await projectWorkflowBoundarySpansForSecrets(
[createSpan({ output: { workflowInput: { channel: 'C123' } } })],
{ store: STORE }
)

expect(result.output).toEqual({ workflowInput: { channel: 'C123' } })
})

it('retains boundary content when provenance is incomplete', async () => {
const [result] = await projectWorkflowBoundarySpansForSecrets(
[createSpan({ output: { workflowInput: { channel: 'C123' } } })],
{ registry: createRegistry([], false), store: STORE }
)

expect(result.output).toEqual({ workflowInput: { channel: 'C123' } })
})

it('redacts resolved secrets when provenance is complete', async () => {
const [result] = await projectWorkflowBoundarySpansForSecrets(
[createSpan({ output: { workflowInput: { key: 'raw-secret' } } })],
{
registry: createRegistry([{ plaintext: 'raw-secret', replacement: '{{API_SECRET}}' }]),
store: STORE,
}
)

expect(result.output).toEqual({ workflowInput: { key: '{{API_SECRET}}' } })
})
})

describe('projectWorkflowBoundarySpansForSecrets structural fallback', () => {
it('falls back to structure when the bounded clone exceeds projection limits', async () => {
let source = createSpan({ id: 'depth-150', output: { workflowInput: { channel: 'C123' } } })
for (let depth = 149; depth >= 0; depth -= 1) {
source = createSpan({ id: `depth-${depth}`, children: [source] })
}

const [result] = await projectWorkflowBoundarySpansForSecrets([source], { store: STORE })

let projectedDepth = 0
let cursor: TraceSpan | undefined = result
while (cursor?.children?.[0]) {
expect(cursor).not.toHaveProperty('output')
projectedDepth += 1
cursor = cursor.children[0]
}
expect(projectedDepth).toBe(100)
expect(cursor).not.toHaveProperty('output')
})
})
31 changes: 31 additions & 0 deletions apps/sim/lib/logs/execution/trace-secret-projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1520,6 +1520,37 @@ export async function enforceTraceSpanSecretInvariant(
}
}

/**
* Projects workflow-boundary spans - content captured before the executor
* resolves any secret, so it structurally cannot be a resolved-secret sink.
* Collapsing it to structure whenever provenance is unavailable leaves the log
* with nothing readable at all. With a complete registry this is identical to
* {@link projectTraceSpansForSecrets}; with absent or incomplete provenance the
* content is retained through the same clone used for a complete registry that
* holds no secrets. Anything downstream of block execution - including the
* workflow's final output - must not be routed here.
*
* That clone bounds span STRUCTURE (node count and depth) but not the payload
* inside `output`, which it keeps by reference. A caller must therefore only
* route content here that it is willing to return verbatim - the same exposure
* the no-secrets path already carries for every span it clones.
*/
export async function projectWorkflowBoundarySpansForSecrets(
traceSpans: TraceSpan[],
options: ProjectTraceSpansForSecretsOptions
): Promise<TraceSpan[]> {
if (options.registry?.isComplete()) return projectTraceSpansForSecrets(traceSpans, options)

try {
return cloneTraceSpansForProjection(traceSpans)
} catch {
logger.warn(
'Workflow-boundary projection exceeded structural limits; retaining structural spans only'
)
return structuralOnlyTraceSpans(traceSpans)
}
}

/**
* Produces the only Secrets-feature-safe representation of execution TraceSpans.
* Runtime logs and outputs remain untouched; only schema-defined trace content is copied.
Expand Down
Loading
Loading