Skip to content

Commit 95d4fd1

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix trace span secret sanitization
1 parent 8b199d7 commit 95d4fd1

10 files changed

Lines changed: 1025 additions & 108 deletions

File tree

apps/sim/executor/execution/block-executor.test.ts

Lines changed: 353 additions & 8 deletions
Large diffs are not rendered by default.

apps/sim/executor/execution/block-executor.ts

Lines changed: 67 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ import {
4343
type StreamingExecution,
4444
} from '@/executor/types'
4545
import { streamingResponseFormatProcessor } from '@/executor/utils'
46+
import {
47+
createEnvironmentSecretSanitizer,
48+
type EnvironmentSecretSanitizer,
49+
} from '@/executor/utils/environment-secret-sanitizer'
4650
import { buildBlockExecutionError, normalizeError } from '@/executor/utils/errors'
4751
import {
4852
buildUnifiedParentIterations,
@@ -100,6 +104,10 @@ export class BlockExecutor {
100104

101105
const blockType = block.metadata?.id ?? ''
102106
const isSentinel = isSentinelBlockType(blockType)
107+
const sanitizeEnvironmentSecrets = createEnvironmentSecretSanitizer(
108+
block.config,
109+
ctx.environmentVariables
110+
)
103111

104112
// Capture startedAt and startTime at the same synchronous instant so
105113
// blockLog.startedAt and performance.now()-derived durationMs share a
@@ -159,7 +167,11 @@ export class BlockExecutor {
159167
}
160168

161169
if (blockLog) {
162-
blockLog.input = this.sanitizeInputsForLog(inputsForLog, block.metadata?.id)
170+
blockLog.input = this.sanitizeInputsForLog(
171+
inputsForLog,
172+
sanitizeEnvironmentSecrets,
173+
block.metadata?.id
174+
)
163175
}
164176
} catch (error) {
165177
cleanupSelfReference?.()
@@ -173,6 +185,7 @@ export class BlockExecutor {
173185
blockLog,
174186
inputsForLog,
175187
isSentinel,
188+
sanitizeEnvironmentSecrets,
176189
'input_resolution'
177190
)
178191
}
@@ -277,9 +290,13 @@ export class BlockExecutor {
277290
blockLog.endedAt = endedAt
278291
blockLog.durationMs = duration
279292
blockLog.success = true
280-
blockLog.output = filterOutputForLog(block.metadata?.id || '', normalizedOutput, { block })
293+
blockLog.output = this.sanitizeOutputForLog(
294+
block,
295+
normalizedOutput,
296+
sanitizeEnvironmentSecrets
297+
)
281298
if (normalizedOutput.childTraceSpans && Array.isArray(normalizedOutput.childTraceSpans)) {
282-
blockLog.childTraceSpans = normalizedOutput.childTraceSpans
299+
blockLog.childTraceSpans = sanitizeEnvironmentSecrets(normalizedOutput.childTraceSpans)
283300
}
284301
}
285302

@@ -291,15 +308,17 @@ export class BlockExecutor {
291308
typeof normalizedOutput._childWorkflowInstanceId === 'string'
292309
? normalizedOutput._childWorkflowInstanceId
293310
: undefined
294-
const displayOutput = filterOutputForLog(block.metadata?.id || '', normalizedOutput, {
311+
const displayOutput = this.sanitizeOutputForLog(
295312
block,
296-
})
313+
normalizedOutput,
314+
sanitizeEnvironmentSecrets
315+
)
297316
this.fireBlockCompleteCallback(
298317
blockStartPromise,
299318
ctx,
300319
node,
301320
block,
302-
this.sanitizeInputsForLog(inputsForLog, block.metadata?.id),
321+
this.sanitizeInputsForLog(inputsForLog, sanitizeEnvironmentSecrets, block.metadata?.id),
303322
displayOutput,
304323
duration,
305324
blockLog.startedAt,
@@ -321,6 +340,7 @@ export class BlockExecutor {
321340
blockLog,
322341
inputsForLog,
323342
isSentinel,
343+
sanitizeEnvironmentSecrets,
324344
'execution',
325345
streamingPartialOutput
326346
)
@@ -379,12 +399,14 @@ export class BlockExecutor {
379399
blockLog: BlockLog | undefined,
380400
inputsForLog: Record<string, any>,
381401
isSentinel: boolean,
402+
sanitizeEnvironmentSecrets: EnvironmentSecretSanitizer,
382403
phase: 'input_resolution' | 'execution',
383404
streamingPartialOutput?: Record<string, any>
384405
): Promise<NormalizedBlockOutput> {
385406
const endedAt = new Date().toISOString()
386407
const duration = performance.now() - startTime
387408
const errorMessage = normalizeError(error)
409+
const sanitizedErrorMessage = sanitizeEnvironmentSecrets(errorMessage)
388410
const hasLogInputs =
389411
inputsForLog && typeof inputsForLog === 'object' && Object.keys(inputsForLog).length > 0
390412
const input = hasLogInputs
@@ -412,8 +434,12 @@ export class BlockExecutor {
412434
blockLog.durationMs = duration
413435
blockLog.success = true
414436
blockLog.error = undefined
415-
blockLog.input = this.sanitizeInputsForLog(input, block.metadata?.id)
416-
blockLog.output = filterOutputForLog(block.metadata?.id || '', softOutput, { block })
437+
blockLog.input = this.sanitizeInputsForLog(
438+
input,
439+
sanitizeEnvironmentSecrets,
440+
block.metadata?.id
441+
)
442+
blockLog.output = this.sanitizeOutputForLog(block, softOutput, sanitizeEnvironmentSecrets)
417443
}
418444

419445
this.execLogger.info('Block stream aborted by client; soft-completing', {
@@ -427,8 +453,8 @@ export class BlockExecutor {
427453
ctx,
428454
node,
429455
block,
430-
this.sanitizeInputsForLog(input, block.metadata?.id),
431-
filterOutputForLog(block.metadata?.id || '', softOutput, { block }),
456+
this.sanitizeInputsForLog(input, sanitizeEnvironmentSecrets, block.metadata?.id),
457+
this.sanitizeOutputForLog(block, softOutput, sanitizeEnvironmentSecrets),
432458
duration,
433459
blockLog.startedAt,
434460
blockLog.executionOrder,
@@ -463,12 +489,16 @@ export class BlockExecutor {
463489
blockLog.endedAt = endedAt
464490
blockLog.durationMs = duration
465491
blockLog.success = false
466-
blockLog.error = errorMessage
467-
blockLog.input = this.sanitizeInputsForLog(input, block.metadata?.id)
468-
blockLog.output = filterOutputForLog(block.metadata?.id || '', errorOutput, { block })
492+
blockLog.error = sanitizedErrorMessage
493+
blockLog.input = this.sanitizeInputsForLog(
494+
input,
495+
sanitizeEnvironmentSecrets,
496+
block.metadata?.id
497+
)
498+
blockLog.output = this.sanitizeOutputForLog(block, errorOutput, sanitizeEnvironmentSecrets)
469499

470500
if (ChildWorkflowError.isChildWorkflowError(error) && error.childTraceSpans.length > 0) {
471-
blockLog.childTraceSpans = error.childTraceSpans
501+
blockLog.childTraceSpans = sanitizeEnvironmentSecrets(error.childTraceSpans)
472502
}
473503
}
474504

@@ -477,21 +507,25 @@ export class BlockExecutor {
477507
{
478508
blockId: node.id,
479509
blockType: block.metadata?.id,
480-
error: errorMessage,
510+
error: sanitizedErrorMessage,
481511
}
482512
)
483513

484514
if (!isSentinel && blockLog) {
485515
const childWorkflowInstanceId = ChildWorkflowError.isChildWorkflowError(error)
486516
? error.childWorkflowInstanceId
487517
: undefined
488-
const displayOutput = filterOutputForLog(block.metadata?.id || '', errorOutput, { block })
518+
const displayOutput = this.sanitizeOutputForLog(
519+
block,
520+
errorOutput,
521+
sanitizeEnvironmentSecrets
522+
)
489523
this.fireBlockCompleteCallback(
490524
blockStartPromise,
491525
ctx,
492526
node,
493527
block,
494-
this.sanitizeInputsForLog(input, block.metadata?.id),
528+
this.sanitizeInputsForLog(input, sanitizeEnvironmentSecrets, block.metadata?.id),
495529
displayOutput,
496530
duration,
497531
blockLog.startedAt,
@@ -508,7 +542,7 @@ export class BlockExecutor {
508542
}
509543
this.execLogger.info('Block has error port - returning error output instead of throwing', {
510544
blockId: node.id,
511-
error: errorMessage,
545+
error: sanitizedErrorMessage,
512546
})
513547
return errorOutput
514548
}
@@ -615,6 +649,7 @@ export class BlockExecutor {
615649
*/
616650
private sanitizeInputsForLog(
617651
inputs: Record<string, any>,
652+
sanitizeEnvironmentSecrets: EnvironmentSecretSanitizer,
618653
blockType?: string
619654
): Record<string, any> {
620655
// Custom (deploy-as-block) blocks run via an internal `workflow_executor`; the
@@ -671,7 +706,20 @@ export class BlockExecutor {
671706
}
672707
}
673708

674-
return redactApiKeys(result)
709+
return sanitizeEnvironmentSecrets(redactApiKeys(result))
710+
}
711+
712+
/**
713+
* Builds the display-only output shared by persisted logs and live callbacks.
714+
* Runtime output remains untouched for state, handlers, retries, and resume.
715+
*/
716+
private sanitizeOutputForLog(
717+
block: SerializedBlock,
718+
output: NormalizedBlockOutput,
719+
sanitizeEnvironmentSecrets: EnvironmentSecretSanitizer
720+
): NormalizedBlockOutput {
721+
const filteredOutput = filterOutputForLog(block.metadata?.id ?? '', output, { block })
722+
return sanitizeEnvironmentSecrets(redactApiKeys(filteredOutput)) as NormalizedBlockOutput
675723
}
676724

677725
/**
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { createEnvironmentSecretSanitizer } from '@/executor/utils/environment-secret-sanitizer'
3+
4+
describe('createEnvironmentSecretSanitizer', () => {
5+
it('sanitizes referenced values recursively without mutating the source', () => {
6+
const secret = 'top-secret/value'
7+
const source = {
8+
message: `Bearer ${secret}`,
9+
nested: [{ [secret]: secret }],
10+
}
11+
const sanitize = createEnvironmentSecretSanitizer(
12+
{ credential: '{{API_SECRET}}' },
13+
{ API_SECRET: secret }
14+
)
15+
16+
const sanitized = sanitize(source)
17+
18+
expect(sanitized).toEqual({
19+
message: 'Bearer {{API_SECRET}}',
20+
nested: [{ '{{API_SECRET}}': '{{API_SECRET}}' }],
21+
})
22+
expect(source).toEqual({
23+
message: `Bearer ${secret}`,
24+
nested: [{ [secret]: secret }],
25+
})
26+
expect(sanitized).not.toBe(source)
27+
expect(sanitized.nested).not.toBe(source.nested)
28+
})
29+
30+
it('sanitizes URL-encoded secret values', () => {
31+
const secret = 'secret/value with spaces'
32+
const sanitize = createEnvironmentSecretSanitizer('{{API_SECRET}}', { API_SECRET: secret })
33+
34+
expect(sanitize(`token=${encodeURIComponent(secret)}`)).toBe('token={{API_SECRET}}')
35+
})
36+
37+
it('sanitizes mixed-case percent escapes without folding ordinary characters', () => {
38+
const secret = 'CaseSensitive/value:next'
39+
const sanitize = createEnvironmentSecretSanitizer('{{API_SECRET}}', { API_SECRET: secret })
40+
41+
expect(sanitize('token=CaseSensitive%2fvalue%3Anext')).toBe('token={{API_SECRET}}')
42+
expect(sanitize('token=casesensitive%2fvalue%3Anext')).toBe(
43+
'token=casesensitive%2fvalue%3Anext'
44+
)
45+
})
46+
47+
it('sanitizes form-encoded secrets that use plus for spaces', () => {
48+
const secret = 'secret value/next'
49+
const sanitize = createEnvironmentSecretSanitizer('{{API_SECRET}}', { API_SECRET: secret })
50+
51+
expect(sanitize('token=secret+value%2fnext')).toBe('token={{API_SECRET}}')
52+
})
53+
54+
it('still sanitizes literal values that cannot be URI encoded', () => {
55+
const secret = `secret-${String.fromCharCode(0xd800)}`
56+
const sanitize = createEnvironmentSecretSanitizer('{{API_SECRET}}', { API_SECRET: secret })
57+
58+
expect(sanitize(secret)).toBe('{{API_SECRET}}')
59+
})
60+
61+
it('only uses environment variables referenced by the block configuration', () => {
62+
const sanitize = createEnvironmentSecretSanitizer(
63+
{ credential: '{{REFERENCED}}' },
64+
{
65+
REFERENCED: 'replace-me',
66+
UNREFERENCED: 'ordinary-value',
67+
}
68+
)
69+
70+
expect(sanitize('replace-me ordinary-value')).toBe('{{REFERENCED}} ordinary-value')
71+
})
72+
73+
it('ignores empty and missing referenced values', () => {
74+
const sanitize = createEnvironmentSecretSanitizer(['{{EMPTY}}', '{{MISSING}}'], { EMPTY: '' })
75+
76+
expect(sanitize('unchanged')).toBe('unchanged')
77+
})
78+
79+
it('replaces overlapping values longest first', () => {
80+
const sanitize = createEnvironmentSecretSanitizer(['{{SHORT}}', '{{LONG}}'], {
81+
SHORT: 'secret',
82+
LONG: 'secret-suffix',
83+
})
84+
85+
expect(sanitize('secret-suffix secret')).toBe('{{LONG}} {{SHORT}}')
86+
})
87+
88+
it('uses the lexicographically first name for duplicate values', () => {
89+
const sanitize = createEnvironmentSecretSanitizer(['{{Z_SECRET}}', '{{A_SECRET}}'], {
90+
Z_SECRET: 'same-value',
91+
A_SECRET: 'same-value',
92+
})
93+
94+
expect(sanitize('same-value')).toBe('{{A_SECRET}}')
95+
})
96+
97+
it('does not re-sanitize placeholders inserted earlier in the same string', () => {
98+
const sanitize = createEnvironmentSecretSanitizer(['{{A_SECRET}}', '{{B_SECRET}}'], {
99+
A_SECRET: 'secret-value',
100+
B_SECRET: 'A_SECRET',
101+
})
102+
103+
expect(sanitize('secret-value A_SECRET')).toBe('{{A_SECRET}} {{B_SECRET}}')
104+
})
105+
106+
it('handles special object keys without changing the object prototype', () => {
107+
const source = Object.create(null) as Record<string, unknown>
108+
Object.defineProperty(source, '__proto__', {
109+
value: 'secret',
110+
enumerable: true,
111+
configurable: true,
112+
writable: true,
113+
})
114+
source.constructor = 'secret'
115+
const sanitize = createEnvironmentSecretSanitizer('{{SECRET}}', { SECRET: 'secret' })
116+
117+
const sanitized = sanitize(source)
118+
119+
expect(Object.getPrototypeOf(sanitized)).toBeNull()
120+
expect(Object.keys(sanitized)).toEqual(['__proto__', 'constructor'])
121+
expect(sanitized.__proto__).toBe('{{SECRET}}')
122+
expect(sanitized.constructor).toBe('{{SECRET}}')
123+
})
124+
125+
it('leaves non-plain runtime objects unchanged', () => {
126+
const date = new Date()
127+
const sanitize = createEnvironmentSecretSanitizer('{{SECRET}}', { SECRET: 'secret' })
128+
129+
expect(sanitize(date)).toBe(date)
130+
})
131+
132+
it('finds references in nested configuration keys and tolerates cycles', () => {
133+
const configured: Record<string, unknown> = {}
134+
configured['prefix-{{SECRET}}'] = configured
135+
const source: Record<string, unknown> = { value: 'secret' }
136+
source.self = source
137+
const sanitize = createEnvironmentSecretSanitizer(configured, { SECRET: 'secret' })
138+
139+
const sanitized = sanitize(source)
140+
141+
expect(sanitized.value).toBe('{{SECRET}}')
142+
expect(sanitized.self).toBe(sanitized)
143+
})
144+
})

0 commit comments

Comments
 (0)