diff --git a/apps/sim/executor/execution/block-executor.test.ts b/apps/sim/executor/execution/block-executor.test.ts index 7962e466a26..c0758935e81 100644 --- a/apps/sim/executor/execution/block-executor.test.ts +++ b/apps/sim/executor/execution/block-executor.test.ts @@ -530,15 +530,15 @@ describe('BlockExecutor', () => { const resolver = new VariableResolver(workflow, {}, state) const onBlockComplete = vi.fn(async () => {}) const registry = new ResolvedSecretTraceRegistry([ - { name: 'SHORT_SECRET', plaintext: 'Test', encryptedValue: 'encrypted-test' }, + { name: 'SHORT_SECRET', plaintext: 'TestValue', encryptedValue: 'encrypted-test' }, ]) const handler: BlockHandler = { canHandle: () => true, execute: async (blockContext, block) => { if (block.id === secretBlock.id) { - blockContext.resolvedSecretTraceRegistry?.recordResolved('SHORT_SECRET', 'Test') + blockContext.resolvedSecretTraceRegistry?.recordResolved('SHORT_SECRET', 'TestValue') } - return { result: 'Test' } + return { result: 'TestValue' } }, } const executor = new BlockExecutor([handler], resolver, { onBlockComplete }, state) diff --git a/apps/sim/executor/handlers/agent/memory.test.ts b/apps/sim/executor/handlers/agent/memory.test.ts index 60f44761d0a..9a1186b7a4b 100644 --- a/apps/sim/executor/handlers/agent/memory.test.ts +++ b/apps/sim/executor/handlers/agent/memory.test.ts @@ -347,14 +347,14 @@ describe('Memory', () => { expect(result.content).toBe('foreign-secret') }) - it.each(['123'])( + it.each(['12345678'])( 'projects short secret %s only in model text and arguments', async (secret) => { const registry = new ResolvedSecretTraceRegistry([ { name: 'TOKEN', plaintext: secret, encryptedValue: 'ciphertext' }, ]) registry.recordResolved('TOKEN', secret) - const converted = secret === '123' ? 123 : true + const converted = secret === '12345678' ? 12345678 : true const message: Message = { role: 'assistant', content: `Result: ${secret}`, @@ -465,11 +465,11 @@ describe('Memory', () => { it('does not project unrelated active secrets into legacy memory', async () => { const registry = new ResolvedSecretTraceRegistry([ - { name: 'TOKEN', plaintext: 'x', encryptedValue: 'ciphertext' }, + { name: 'TOKEN', plaintext: 'unrelated-secret', encryptedValue: 'ciphertext' }, ]) - registry.recordResolved('TOKEN', 'x') + expect(registry.recordResolved('TOKEN', 'unrelated-secret')).toBe(true) vi.spyOn(memoryService as any, 'fetchMemory').mockResolvedValueOnce({ - messages: [{ role: 'assistant', content: 'Box' }], + messages: [{ role: 'assistant', content: 'Box unrelated-secret' }], provenance: { status: 'exact', entries: [] }, }) @@ -478,7 +478,7 @@ describe('Memory', () => { inputs ) - expect(messages).toEqual([{ role: 'assistant', content: 'Box' }]) + expect(messages).toEqual([{ role: 'assistant', content: 'Box unrelated-secret' }]) }) it('does not activate provenance from a message dropped by the selected window', async () => { diff --git a/apps/sim/executor/handlers/pi/search/tool.test.ts b/apps/sim/executor/handlers/pi/search/tool.test.ts index a4ba221b84e..9e339499b9a 100644 --- a/apps/sim/executor/handlers/pi/search/tool.test.ts +++ b/apps/sim/executor/handlers/pi/search/tool.test.ts @@ -36,7 +36,7 @@ function executionContext( const ctx = executionContext() function buildTool(provider: 'exa' | 'serper' | 'parallel' | 'firecrawl' = 'exa', context = ctx) { - return buildPiSearchToolSpec(context, { provider, apiKey: 'key-123' }, 'local') + return buildPiSearchToolSpec(context, { provider, apiKey: 'key-1234567' }, 'local') } async function run( @@ -69,7 +69,7 @@ describe('buildPiSearchToolSpec', () => { const [toolId, params, options] = mockExecuteTool.mock.calls[0] expect(toolId).toBe('exa_search') - expect(params.apiKey).toBe('key-123') + expect(params.apiKey).toBe('key-1234567') expect(params.timeout).toBe(10_000) expect(options.executionContext).toBe(ctx) expect(options.resolvedSecretTraceRegistry).toBeInstanceOf(ResolvedSecretTraceRegistry) @@ -83,7 +83,7 @@ describe('buildPiSearchToolSpec', () => { const [toolId, params] = mockExecuteTool.mock.calls[0] expect(toolId).toBe('serper_search') - expect(params).toEqual({ query: 'pi', num: 2, apiKey: 'key-123', timeout: 10_000 }) + expect(params).toEqual({ query: 'pi', num: 2, apiKey: 'key-1234567', timeout: 10_000 }) }) it('normalizes a successful provider response into the envelope', async () => { @@ -193,17 +193,17 @@ describe('buildPiSearchToolSpec', () => { it('projects only the exact resolver-recorded search key and leaves the raw result unchanged', async () => { const registry = new ResolvedSecretTraceRegistry([ - { name: 'SEARCH_KEY', plaintext: 'key-123', encryptedValue: 'search-ciphertext' }, + { name: 'SEARCH_KEY', plaintext: 'key-1234567', encryptedValue: 'search-ciphertext' }, { name: 'UNRELATED', plaintext: 'Test', encryptedValue: 'unrelated-ciphertext' }, ]) - registry.recordResolvedAtInputPath('SEARCH_KEY', 'key-123', ['searchApiKey']) - registry.recordResolvedInputProjection(['searchApiKey'], 'key-123', '{{SEARCH_KEY}}') + registry.recordResolvedAtInputPath('SEARCH_KEY', 'key-1234567', ['searchApiKey']) + registry.recordResolvedInputProjection(['searchApiKey'], 'key-1234567', '{{SEARCH_KEY}}') registry.recordResolvedAtInputPath('UNRELATED', 'Test', ['task']) registry.recordResolvedInputProjection(['task'], 'Test', '{{UNRELATED}}') const output = { results: [ { - title: 'key-123', + title: 'key-1234567', url: 'https://example.com/docs', text: 'Test', }, @@ -213,7 +213,7 @@ describe('buildPiSearchToolSpec', () => { const result = await buildPiSearchToolSpec( executionContext(registry), - { provider: 'exa', apiKey: 'key-123' }, + { provider: 'exa', apiKey: 'key-1234567' }, 'local', '{{SEARCH_KEY}}' ).execute({ query: 'pi' }) @@ -231,7 +231,7 @@ describe('buildPiSearchToolSpec', () => { expect(output).toEqual({ results: [ { - title: 'key-123', + title: 'key-1234567', url: 'https://example.com/docs', text: 'Test', }, diff --git a/apps/sim/executor/utils/resolved-secret-content-projection.test.ts b/apps/sim/executor/utils/resolved-secret-content-projection.test.ts index 0879a60b166..ce930f53005 100644 --- a/apps/sim/executor/utils/resolved-secret-content-projection.test.ts +++ b/apps/sim/executor/utils/resolved-secret-content-projection.test.ts @@ -161,17 +161,17 @@ describe('projectResolvedSecretModelContent', () => { it('keeps longest-match semantics when a known opaque placeholder is nested in a secret', () => { const registry = new ResolvedSecretTraceRegistry([ - { name: 'Test', plaintext: 'Test', encryptedValue: 'test-ciphertext' }, + { name: 'TestName', plaintext: 'TestName', encryptedValue: 'test-ciphertext' }, { name: 'COMPOSITE', - plaintext: 'x{{Test}}y', + plaintext: 'x{{TestName}}y', encryptedValue: 'composite-ciphertext', }, ]) - registry.recordResolved('Test', 'Test') - registry.recordResolved('COMPOSITE', 'x{{Test}}y') + registry.recordResolved('TestName', 'TestName') + registry.recordResolved('COMPOSITE', 'x{{TestName}}y') - expect(projectResolvedSecretModelContent('x{{Test}}y', registry)).toEqual({ + expect(projectResolvedSecretModelContent('x{{TestName}}y', registry)).toEqual({ safe: true, value: '{{COMPOSITE}}', }) @@ -179,19 +179,19 @@ describe('projectResolvedSecretModelContent', () => { it('projects exact typed numeric secrets, leaving booleans and null identifying nothing', () => { const registry = new ResolvedSecretTraceRegistry([ - { name: 'NUMBER', plaintext: '123', encryptedValue: 'number-ciphertext' }, + { name: 'NUMBER', plaintext: '12345678', encryptedValue: 'number-ciphertext' }, { name: 'BOOLEAN', plaintext: 'true', encryptedValue: 'boolean-ciphertext' }, { name: 'NULL', plaintext: 'null', encryptedValue: 'null-ciphertext' }, ]) - registry.recordResolved('NUMBER', '123') + registry.recordResolved('NUMBER', '12345678') registry.recordResolved('BOOLEAN', 'true') registry.recordResolved('NULL', 'null') expect( projectResolvedSecretModelContent( { - strings: ['123', 'true', 'null'], - number: 123, + strings: ['12345678', 'true', 'null'], + number: 12345678, boolean: true, nothing: null, unrelatedNumber: 1234, @@ -212,12 +212,12 @@ describe('projectResolvedSecretModelContent', () => { }) }) - it.each(['123'])('keeps projected JSON argument strings valid (%s)', (secret) => { + it.each(['12345678'])('keeps projected JSON argument strings valid (%s)', (secret) => { const registry = new ResolvedSecretTraceRegistry([ { name: 'TOKEN', plaintext: secret, encryptedValue: 'ciphertext' }, ]) registry.recordResolved('TOKEN', secret) - const typedValue = secret === '123' ? 123 : true + const typedValue = secret === '12345678' ? 12345678 : true const projection = projectResolvedSecretModelJsonStrings( [JSON.stringify({ secret, converted: typedValue, nested: [typedValue] })], @@ -255,11 +255,11 @@ describe('projectResolvedSecretModelContent', () => { it('is stable when a secret literal overlaps its own provenance alias', () => { const registry = new ResolvedSecretTraceRegistry([ - { name: 'TOKEN', plaintext: 'TOKEN', encryptedValue: 'ciphertext' }, + { name: 'TOKEN', plaintext: 'TOKENTOKEN', encryptedValue: 'ciphertext' }, ]) - registry.recordResolved('TOKEN', 'TOKEN') + registry.recordResolved('TOKEN', 'TOKENTOKEN') - const first = projectResolvedSecretModelContent('Bearer TOKEN', registry) + const first = projectResolvedSecretModelContent('Bearer TOKENTOKEN', registry) expect(first).toEqual({ safe: true, value: 'Bearer {{TOKEN}}' }) if (!first.safe) return expect(projectResolvedSecretModelContent(first.value, registry)).toEqual(first) @@ -267,38 +267,47 @@ describe('projectResolvedSecretModelContent', () => { it('preserves the canonical provenance label when its name equals the secret plaintext', () => { const registry = new ResolvedSecretTraceRegistry([ - { name: 'Test', plaintext: 'Test', encryptedValue: 'ciphertext' }, + { name: 'TestName', plaintext: 'TestName', encryptedValue: 'ciphertext' }, ]) - registry.recordResolved('Test', 'Test') + registry.recordResolved('TestName', 'TestName') expect( projectResolvedSecretModelContent( { - result: 'Test', - source: 'return {{Test}}', - error: "NameError: name 'Test' is not defined", + result: 'TestName', + source: 'return {{TestName}}', + error: "NameError: name 'TestName' is not defined", }, registry ) ).toEqual({ safe: true, value: { - result: '{{Test}}', - source: 'return {{Test}}', - error: "NameError: name '{{Test}}' is not defined", + result: '{{TestName}}', + source: 'return {{TestName}}', + error: "NameError: name '{{TestName}}' is not defined", }, }) }) it('atomically projects the selected provenance label when its name contains the value', () => { const registry = new ResolvedSecretTraceRegistry([ - { name: 'TOKEN', plaintext: 'TOK', encryptedValue: 'ciphertext' }, + { name: 'TOKENVALUE_NAME', plaintext: 'TOKENVALUE', encryptedValue: 'ciphertext' }, ]) - registry.recordResolved('TOKEN', 'TOK') - - expect(projectResolvedSecretModelContent('Bearer {{TOKEN}}', registry)).toEqual({ + expect(registry.recordResolved('TOKENVALUE_NAME', 'TOKENVALUE')).toBe(true) + + /** + * The label `{{TOKENVALUE_NAME}}` contains the plaintext that produced it, so it is only left + * alone because the label is treated atomically. Projecting the bare plaintext first proves the + * matcher is live — without it an empty matcher would satisfy the second assertion too. + */ + expect(projectResolvedSecretModelContent('Bearer TOKENVALUE', registry)).toEqual({ + safe: true, + value: 'Bearer {{TOKENVALUE_NAME}}', + }) + expect(projectResolvedSecretModelContent('Bearer {{TOKENVALUE_NAME}}', registry)).toEqual({ safe: true, - value: 'Bearer {{TOKEN}}', + value: 'Bearer {{TOKENVALUE_NAME}}', }) }) @@ -308,7 +317,7 @@ describe('projectResolvedSecretModelContent', () => { complete: true, matches: [ { - plaintext: 'x'.repeat(64 * 1024), + plaintext: 'xxxxxxxx'.repeat(64 * 1024), replacement: '[REDACTED_SECRET]', }, ], @@ -321,16 +330,18 @@ describe('projectResolvedSecretModelContent', () => { it('keeps provenance-shaped content deterministic without trusting it as a protocol handle', () => { const registry = new ResolvedSecretTraceRegistry([ - { name: 'Test', plaintext: 'Test', encryptedValue: 'ciphertext' }, + { name: 'TestName', plaintext: 'TestName', encryptedValue: 'ciphertext' }, ]) - registry.recordResolved('Test', 'Test') + registry.recordResolved('TestName', 'TestName') - expect(projectResolvedSecretModelContent('{{Test}}', registry)).toEqual({ + expect(projectResolvedSecretModelContent('{{TestName}}', registry)).toEqual({ safe: true, - value: '{{Test}}', + value: '{{TestName}}', }) - expect(isResolvedSecretModelContentUnchanged('{{Test}}', registry)).toBe(false) - expect(isResolvedSecretModelContentUnchanged(['resource', '{{Test}}'], registry)).toBe(false) + expect(isResolvedSecretModelContentUnchanged('{{TestName}}', registry)).toBe(false) + expect(isResolvedSecretModelContentUnchanged(['resource', '{{TestName}}'], registry)).toBe( + false + ) expect(isResolvedSecretModelContentUnchanged(['resource', 'safe'], registry)).toBe(true) }) }) @@ -409,11 +420,29 @@ describe('projectResolvedSecretModelJsonContent', () => { it('enforces the byte limit after secret aliases are projected', () => { const registry = new ResolvedSecretTraceRegistry([ - { name: 'X', plaintext: 'x', encryptedValue: 'ciphertext' }, + { name: 'X_LONGER_NAME', plaintext: 'xxxxxxxx', encryptedValue: 'ciphertext' }, ]) - registry.recordResolved('X', 'x') - - expect(projectResolvedSecretModelJsonContent({ a: 'x' }, registry, 9)).toEqual({ safe: false }) + expect(registry.recordResolved('X_LONGER_NAME', 'xxxxxxxx')).toBe(true) + + /** + * Three separate limits can reject this value, and only the last one is what this test is for: + * the raw encoding (16 bytes), the content walk's running budget, and the JSON re-encoding of + * the projected object (25 bytes). A limit of 20 is the only band that isolates the third — + * the walk charges the key `a` and then admits the 17-byte alias against the remaining 19, so + * anything that rejects at 20 can only be the wire check. Pinning both halves keeps it that + * way: drop the re-encoding check and the second assertion starts passing. + */ + expect(projectResolvedSecretModelContent({ a: 'xxxxxxxx' }, registry, 20)).toEqual({ + safe: true, + value: { a: '{{X_LONGER_NAME}}' }, + }) + expect(projectResolvedSecretModelJsonContent({ a: 'xxxxxxxx' }, registry, 20)).toEqual({ + safe: false, + }) + expect(projectResolvedSecretModelJsonContent({ a: 'xxxxxxxx' }, registry, 25)).toEqual({ + safe: true, + value: { a: '{{X_LONGER_NAME}}' }, + }) }) }) @@ -445,7 +474,7 @@ describe('projectResolvedSecretDiagnosticError', () => { it('sanitizes an inactive compiler alias without activating or scanning its secret', () => { const registry = new ResolvedSecretTraceRegistry([ - { name: 'X', plaintext: 'x', encryptedValue: 'ciphertext' }, + { name: 'X', plaintext: 'xxxxxxxx', encryptedValue: 'ciphertext' }, ]) const error = new Error('Box __var_X') diff --git a/apps/sim/executor/utils/resolved-secret-content-projection.ts b/apps/sim/executor/utils/resolved-secret-content-projection.ts index 92b948c8de4..9f707c566a1 100644 --- a/apps/sim/executor/utils/resolved-secret-content-projection.ts +++ b/apps/sim/executor/utils/resolved-secret-content-projection.ts @@ -38,7 +38,6 @@ function createResolvedSecretModelMatcher( ): ResolvedSecretMatcher | undefined { const matcher = createResolvedSecretMatcher(matches, { preserveNamedProvenanceLabels: true, - mode: 'render', }) if (!matcher) return undefined @@ -75,7 +74,7 @@ function createResolvedSecretModelMatcher( })), ...opaquePlaceholderMatches, ], - { preserveNamedProvenanceLabels: true, mode: 'render' } + { preserveNamedProvenanceLabels: true } ) } diff --git a/apps/sim/executor/utils/resolved-secret-match-policy.test.ts b/apps/sim/executor/utils/resolved-secret-match-policy.test.ts index bf79df5754a..48261e96d54 100644 --- a/apps/sim/executor/utils/resolved-secret-match-policy.test.ts +++ b/apps/sim/executor/utils/resolved-secret-match-policy.test.ts @@ -3,106 +3,31 @@ */ import { describe, expect, it } from 'vitest' import { - getResolvedSecretMatchPolicy, isNonIdentifyingSecretLiteral, - isWordBoundaryMatch, - MIN_UNANCHORED_MATCH_LENGTH, + MIN_SUBSTITUTABLE_LITERAL_LENGTH, } from '@/executor/utils/resolved-secret-match-policy' -describe('getResolvedSecretMatchPolicy', () => { - it.each(['test', 'Test', '483920', 'hunter2', 'F', ''])( - 'restricts short value %s to boundary matches', - (value) => { - expect(value.length).toBeLessThan(MIN_UNANCHORED_MATCH_LENGTH) - expect(getResolvedSecretMatchPolicy(value)).toBe('boundary') - } - ) - - it.each([ - ['32-char hex', '5f4dcc3b5aa765d61d8327deb882cf99'], - ['base64 key', 'sk-proj-Ab3xK9mQ2pLw7nRt5vYc8Zd4'], - ['github pat', 'ghp_16C7e42F292c6912E7710c838347Ae178B4a'], - ['slack bot token', 'xoxb-2334-4567-abcdefGHIJKL'], - ['9-digit value', '123456789'], - ['8-char password', 'Passw0rd'], - ])('allows unanchored matching for a %s', (_label, value) => { - expect(getResolvedSecretMatchPolicy(value)).toBe('anywhere') - }) - - /** - * Every one of these scores below 3.0 bits/char; an entropy floor would have demoted them. - * Prefixed shapes are assembled at runtime so the source carries no literal that reads as a - * live credential to a secret scanner. - */ - it.each([ - ['all-f HMAC key', 'f'.repeat(32)], - ['test PAN', '4111111111111111'], - ['padded AWS key id', `AKIA${'0'.repeat(16)}`], - ['repeated-block hex', 'deadbeefdeadbeefdeadbeefdeadbeef'], - ['padded stripe-style key', `sk_live_${'0'.repeat(24)}`], - ['padded PAT', `ghp_${'a'.repeat(36)}`], - ])('keeps unanchored matching for a low-variety full-length %s', (_label, value) => { - expect(getResolvedSecretMatchPolicy(value)).toBe('anywhere') - }) -}) - -describe('isWordBoundaryMatch', () => { - it.each([ - ['test', 0, 4, true], - ['key=test', 4, 8, true], - ['"test"', 1, 5, true], - ['{"k":"test"}', 6, 10, true], - ['test ok', 0, 4, true], - ['latest', 2, 6, false], - ['tested', 0, 4, false], - ['prefixtest', 6, 10, false], - ])('anchors %s at [%i,%i) => %s', (value, start, end, expected) => { - expect(isWordBoundaryMatch(value, start, end)).toBe(expected) - }) - - it.each([ - ['user_test_id', 5, 9], - ['sk_live_test', 8, 12], - ['test_suffix', 0, 4], - ])('anchors %s across an underscore, the dominant identifier joiner', (value, start, end) => { - expect(isWordBoundaryMatch(value, start, end)).toBe(true) - }) - - it('treats non-ASCII letters as word characters', () => { - expect(isWordBoundaryMatch('прtestка', 2, 6)).toBe(false) - }) - - it('treats astral-plane letters as word characters', () => { - expect(isWordBoundaryMatch('\u{1D400}test\u{1D401}', 2, 6)).toBe(false) - expect(isWordBoundaryMatch('x\u{20000}test\u{20000}y', 3, 7)).toBe(false) - }) - - it('keeps a combining mark attached to the word it decorates', () => { - expect(isWordBoundaryMatch('test́ing', 0, 4)).toBe(false) - }) - - it('anchors a match whose own edge characters are not word characters', () => { - expect(isWordBoundaryMatch('a!!!!b', 1, 5)).toBe(true) - }) - - it('reads an out-of-range probe as a non-word character rather than a match', () => { - expect(isWordBoundaryMatch('abc', 0, 3)).toBe(true) - expect(isWordBoundaryMatch('abc', 3, 3)).toBe(true) - }) -}) - describe('isNonIdentifyingSecretLiteral', () => { - it.each(['true', 'false', 'null'])( - 'excludes %s, whose value space is too small to identify', + it.each(['', '7', '14', 'true', 'false', 'null', 'test', 'hunter2'])( + 'excludes %s, whose value space is too small for a hit to be evidence', (literal) => { + expect(literal.length).toBeLessThan(MIN_SUBSTITUTABLE_LITERAL_LENGTH) expect(isNonIdentifyingSecretLiteral(literal)).toBe(true) } ) - it.each(['0', '1', 'False', 'TRUE', 'Null', 'nullish', '', 'hunter2', 'sk_live_abc'])( - 'keeps %s protectable', + it.each(['hunter22', 'sk_live_abc', '4815162342', 'ffffffffffffffff'])( + 'keeps %s substitutable', (literal) => { + expect(literal.length).toBeGreaterThanOrEqual(MIN_SUBSTITUTABLE_LITERAL_LENGTH) expect(isNonIdentifyingSecretLiteral(literal)).toBe(false) } ) + + it('turns on length alone, so a low-entropy full-length credential stays protected', () => { + expect(isNonIdentifyingSecretLiteral('0'.repeat(MIN_SUBSTITUTABLE_LITERAL_LENGTH))).toBe(false) + expect(isNonIdentifyingSecretLiteral('0'.repeat(MIN_SUBSTITUTABLE_LITERAL_LENGTH - 1))).toBe( + true + ) + }) }) diff --git a/apps/sim/executor/utils/resolved-secret-match-policy.ts b/apps/sim/executor/utils/resolved-secret-match-policy.ts index 4b572662ed5..bdb1bf611f5 100644 --- a/apps/sim/executor/utils/resolved-secret-match-policy.ts +++ b/apps/sim/executor/utils/resolved-secret-match-policy.ts @@ -1,23 +1,13 @@ /** - * Decides how a known secret literal is allowed to match inside a larger string. + * Decides whether a known secret literal may be substituted at all. * * The matcher knows every secret's exact bytes, so this is not detection — it is the narrower - * question of whether a substring hit is distinctive enough to be attributed to the secret rather - * than to coincidence. A four-character value such as `test` occurs inside ordinary words; an - * eight-character one effectively does not. + * question of whether a hit is distinctive enough to be attributed to the secret rather than to + * coincidence. */ /** - * `'anywhere'` substitutes a hit at any offset. - * - * `'boundary'` substitutes a hit only when it sits on a word boundary, so a short literal can still - * be replaced when it stands alone (`test`), is delimited (`key=test`, `"test"`, `user_test`), or is - * the whole value, but cannot rewrite the interior of an unrelated token (`latest`). - */ -export type ResolvedSecretMatchPolicy = 'anywhere' | 'boundary' - -/** - * Shortest literal that may be substituted at an arbitrary offset inside surrounding text. + * Shortest literal a match on which counts as evidence that the secret is present. * * Length, not randomness, is what makes a coincidental hit implausible. Shannon entropy measured * over a literal's own character distribution answers "is this string internally varied", which is @@ -29,99 +19,30 @@ export type ResolvedSecretMatchPolicy = 'anywhere' | 'boundary' * random 12-character hex, 74% of random 16-digit numerics, and 99% of 9-digit values fall below it. * * Eight is chosen because every false positive observed in practice came from a value of seven - * characters or fewer, and because a literal that short is the only kind that plausibly appears - * inside unrelated log text by accident. Values below the floor are still substituted — they just - * have to land on a word boundary, which covers standing alone, delimited, and whole-value cases. - */ -export const MIN_UNANCHORED_MATCH_LENGTH = 8 - -/** - * Literals that may not match at all, at any offset, because they identify nothing. + * characters or fewer. * - * This is cardinality, not entropy — the distinction the floor above turns on. An all-`f` HMAC key - * is low-entropy but drawn from an enormous space, so a hit on it is evidence. `false` is drawn - * from a space of two: a hit on it is evidence of nothing, and substituting it protects nothing an - * attacker could not guess by flipping a coin. Meanwhile it rewrites every boolean any workflow - * ever wrote — one deployment turned 2,000 `had_error` cells into `[REDACTED_SECRET]` because a - * `*_BANNER_ENABLED` variable happened to hold `false`. + * This is the whole rule, and the tier below it is deliberately gone. That tier substituted a short + * literal whenever it landed on a word boundary — standing alone, delimited, or as the whole value — + * on the theory that those positions made the hit unambiguous. Position is not the variable that + * matters: a hit on `7` is uninformative wherever it sits, because the value space is ten. It + * rewrote `_raw_idx = 7` into `_raw_idx = {{WEEKLY_OWNWORK_TTL}}` for the one shard whose index + * collided with a TTL variable, and turned 2,000 boolean `had_error` cells into `[REDACTED_SECRET]` + * because a `*_ENABLED` variable held `false`. Both were patched with per-value exception lists; + * this floor subsumes them, so there are no exceptions left to maintain. * - * Exactly the three JSON renderings of a non-string primitive, and nothing else. `0` and `1` are - * deliberately absent: a short numeric secret is entirely plausible where a boolean one is not. - * Matching is case-sensitive because the set is defined by what `String(value)` produces for a - * typed primitive, not by what looks boolean — an environment variable literally holding `False` - * keeps its protection. - * - * The residual is one bit: a variable whose whole value is the string `false` is no longer hidden. + * The cost is explicit and was accepted: a secret shorter than this is no longer redacted from logs + * or model-visible content. Substitution cannot hide such a value anyway — an observer who can read + * the surrounding text can enumerate a space that small. */ -const NON_IDENTIFYING_SECRET_LITERALS: ReadonlySet = new Set(['true', 'false', 'null']) +export const MIN_SUBSTITUTABLE_LITERAL_LENGTH = 8 /** - * True when a literal carries too little information to be worth protecting anywhere. + * True when a literal is too short for a match on it to be evidence that the secret is present. * * Applied where literals are turned into matchers, so it governs detection and substitution alike: * such a value is never rewritten out of content, and never recorded into durable provenance as * something a later read must redact. */ export function isNonIdentifyingSecretLiteral(plaintext: string): boolean { - return NON_IDENTIFYING_SECRET_LITERALS.has(plaintext) -} - -/** - * Combining marks count so a substitution cannot split a grapheme cluster. `_` deliberately does - * NOT: `sk_live_...` and `user_483920_profile` are the dominant way a secret gets joined into an - * identifier, and treating `_` as a word character would suppress those hits entirely. - */ -const WORD_CHARACTER = /[\p{L}\p{N}\p{M}]/u - -/** Classifies one secret literal by whether a hit on it could plausibly be a coincidence. */ -export function getResolvedSecretMatchPolicy(plaintext: string): ResolvedSecretMatchPolicy { - return plaintext.length >= MIN_UNANCHORED_MATCH_LENGTH ? 'anywhere' : 'boundary' -} - -/** - * Reads the whole code point occupying `index`, including when `index` addresses the trailing half - * of a surrogate pair. Returns undefined out of range, which callers treat as "not a word - * character" so an out-of-bounds probe widens the match rather than suppressing it. - */ -function codePointAt(value: string, index: number): number | undefined { - if (index < 0 || index >= value.length) return undefined - const code = value.codePointAt(index) - if (code !== undefined && code >= 0xdc00 && code <= 0xdfff && index > 0) { - const paired = value.codePointAt(index - 1) - if (paired !== undefined && paired > 0xffff) return paired - } - return code -} - -function isWordCharacter(value: string, index: number): boolean { - const code = codePointAt(value, index) - if (code === undefined) return false - if (code < 0x80) { - return (code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122) - } - return WORD_CHARACTER.test(String.fromCodePoint(code)) -} - -/** - * True when the span `[start, end)` is not spliced into the middle of a surrounding word. - * - * A boundary exists wherever two adjacent characters are not both word characters, which is the - * generalization of a regex `\b` to a span. `key=test` and `"test"` are anchored because `=` and - * `"` are not word characters; `latest` is not, because `a` and `t` both are. A whole-value match - * is anchored by the string edges, so an exact value is always replaceable regardless of policy. - */ -export function isWordBoundaryMatch(value: string, start: number, end: number): boolean { - const startsWord = isWordCharacter(value, start - 1) && isWordCharacter(value, start) - const endsWord = isWordCharacter(value, end) && isWordCharacter(value, end - 1) - return !startsWord && !endsWord -} - -/** True when a hit at `[start, end)` may be substituted under `policy`. Omitted policy is wide. */ -export function satisfiesResolvedSecretMatchPolicy( - value: string, - start: number, - end: number, - policy: ResolvedSecretMatchPolicy | undefined -): boolean { - return policy !== 'boundary' || isWordBoundaryMatch(value, start, end) + return plaintext.length < MIN_SUBSTITUTABLE_LITERAL_LENGTH } diff --git a/apps/sim/executor/utils/resolved-secret-matcher.test.ts b/apps/sim/executor/utils/resolved-secret-matcher.test.ts index 2d43bed94a7..92eba5cc370 100644 --- a/apps/sim/executor/utils/resolved-secret-matcher.test.ts +++ b/apps/sim/executor/utils/resolved-secret-matcher.test.ts @@ -3,12 +3,9 @@ */ import { describe, expect, it } from 'vitest' import { - type CreateResolvedSecretMatcherOptions, containsResolvedSecret, createResolvedSecretMatcher, OPAQUE_RESOLVED_SECRET_REPLACEMENT, - type ResolvedSecretMatch, - type ResolvedSecretMatcher, sanitizeResolvedSecretPrimitive, sanitizeResolvedSecretString, scanResolvedSecretString, @@ -19,10 +16,10 @@ const PRESERVE_NAMED_PROVENANCE = { preserveNamedProvenanceLabels: true } as con describe('resolved secret matcher', () => { it('reports each matched literal once across large repeated content', () => { const matcher = createResolvedSecretMatcher([ - { plaintext: 'x', replacement: '{{SHORT}}' }, - { plaintext: 'xx', replacement: '{{OVERLAP}}' }, - { plaintext: 'abc', replacement: '{{PREFIX}}' }, - { plaintext: 'bc', replacement: '{{SUFFIX}}' }, + { plaintext: 'xxxxxxxx', replacement: '{{SHORT}}' }, + { plaintext: 'xxxxxxxxx', replacement: '{{OVERLAP}}' }, + { plaintext: 'abcdefgh', replacement: '{{PREFIX}}' }, + { plaintext: 'bcdefghi', replacement: '{{SUFFIX}}' }, ]) const matches: string[] = [] @@ -30,23 +27,25 @@ describe('resolved secret matcher', () => { if (!matcher) return expect( scanResolvedSecretString( - `${'x'.repeat(1_000_001)}abcabc`, + `${'x'.repeat(1_000_001)}abcdefghiabcdefghi`, matcher, (match) => matches.push(match), 4 ) ).toBe(4) - expect(matches).toEqual(['x', 'xx', 'abc', 'bc']) + expect(matches).toEqual(['xxxxxxxx', 'xxxxxxxxx', 'abcdefgh', 'bcdefghi']) }) it('uses exact matching for typed primitive renderings', () => { - const matcher = createResolvedSecretMatcher([{ plaintext: '23', replacement: '{{TOKEN}}' }]) + const matcher = createResolvedSecretMatcher([ + { plaintext: '23456789', replacement: '{{TOKEN}}' }, + ]) expect(matcher).toBeDefined() if (!matcher) return - expect(sanitizeResolvedSecretPrimitive('23', matcher)).toBe('{{TOKEN}}') - expect(sanitizeResolvedSecretPrimitive('123', matcher)).toBeUndefined() - expect(sanitizeResolvedSecretString('123', matcher)).toBe('1{{TOKEN}}') + expect(sanitizeResolvedSecretPrimitive('23456789', matcher)).toBe('{{TOKEN}}') + expect(sanitizeResolvedSecretPrimitive('123456789', matcher)).toBeUndefined() + expect(sanitizeResolvedSecretString('123456789', matcher)).toBe('1{{TOKEN}}') }) it('sanitizes short inputs with a maximum-length catalog literal', () => { @@ -60,31 +59,35 @@ describe('resolved secret matcher', () => { }) it('uses opaque model-safe replacements by default when a label contains plaintext', () => { - const matcher = createResolvedSecretMatcher([{ plaintext: 'Test', replacement: '{{Test}}' }]) + const matcher = createResolvedSecretMatcher([ + { plaintext: 'TestValue', replacement: '{{TestValue}}' }, + ]) expect(matcher).toBeDefined() if (!matcher) return - expect(sanitizeResolvedSecretString('Test', matcher)).toBe(OPAQUE_RESOLVED_SECRET_REPLACEMENT) + expect(sanitizeResolvedSecretString('TestValue', matcher)).toBe( + OPAQUE_RESOLVED_SECRET_REPLACEMENT + ) }) it('preserves matcher-issued placeholders for user-visible provenance', () => { const matcher = createResolvedSecretMatcher( - [{ plaintext: 'Test', replacement: '{{Test}}' }], + [{ plaintext: 'TestValue', replacement: '{{TestValue}}' }], PRESERVE_NAMED_PROVENANCE ) expect(matcher).toBeDefined() if (!matcher) return - expect(sanitizeResolvedSecretString('Test', matcher)).toBe('{{Test}}') - expect(sanitizeResolvedSecretString('{{Test}}', matcher)).toBe('{{Test}}') - expect(sanitizeResolvedSecretString('Test {{Test}} Test', matcher)).toBe( - '{{Test}} {{Test}} {{Test}}' + expect(sanitizeResolvedSecretString('TestValue', matcher)).toBe('{{TestValue}}') + expect(sanitizeResolvedSecretString('{{TestValue}}', matcher)).toBe('{{TestValue}}') + expect(sanitizeResolvedSecretString('TestValue {{TestValue}} TestValue', matcher)).toBe( + '{{TestValue}} {{TestValue}} {{TestValue}}' ) - expect(containsResolvedSecret('{{Test}}', matcher)).toBe(false) - expect(containsResolvedSecret('{{Test}} Test', matcher)).toBe(true) + expect(containsResolvedSecret('{{TestValue}}', matcher)).toBe(false) + expect(containsResolvedSecret('{{TestValue}} TestValue', matcher)).toBe(true) }) - it.each(['123TOKEN', 'API-KEY', 'LEGACY KEY'])( + it.each(['123TOKEN', 'API-KEY-1', 'LEGACY KEY'])( 'preserves matcher-issued placeholders for supported legacy name %s', (name) => { const matcher = createResolvedSecretMatcher( @@ -99,23 +102,25 @@ describe('resolved secret matcher', () => { } ) - it.each(['{{Test{B}}}', '{{Test}}B}}'])( + it.each(['{{TestValue{B}}}', '{{TestValue}}B}}'])( 'fails closed for malformed provenance label %s', (replacement) => { const matcher = createResolvedSecretMatcher( - [{ plaintext: 'Test', replacement }], + [{ plaintext: 'TestValue', replacement }], PRESERVE_NAMED_PROVENANCE ) expect(matcher).toBeDefined() if (!matcher) return - expect(sanitizeResolvedSecretString('Test', matcher)).toBe(OPAQUE_RESOLVED_SECRET_REPLACEMENT) + expect(sanitizeResolvedSecretString('TestValue', matcher)).toBe( + OPAQUE_RESOLVED_SECRET_REPLACEMENT + ) } ) it('reports protected-token matches to provenance callbacks', () => { const matcher = createResolvedSecretMatcher( - [{ plaintext: 'Test', replacement: '{{Test}}' }], + [{ plaintext: 'TestValue', replacement: '{{TestValue}}' }], PRESERVE_NAMED_PROVENANCE ) const matches: string[] = [] @@ -123,248 +128,79 @@ describe('resolved secret matcher', () => { expect(matcher).toBeDefined() if (!matcher) return expect( - sanitizeResolvedSecretString('{{Test}}', matcher, undefined, (plaintext) => + sanitizeResolvedSecretString('{{TestValue}}', matcher, undefined, (plaintext) => matches.push(plaintext) ) - ).toBe('{{Test}}') - expect(matches).toEqual(['Test']) + ).toBe('{{TestValue}}') + expect(matches).toEqual(['TestValue']) }) it('keeps malformed placeholder-like input linear and still projects trailing plaintext', () => { const matcher = createResolvedSecretMatcher( - [{ plaintext: 'Test', replacement: '{{Test}}' }], + [{ plaintext: 'TestValue', replacement: '{{TestValue}}' }], PRESERVE_NAMED_PROVENANCE ) const malformedPrefix = '{'.repeat(100_000) expect(matcher).toBeDefined() if (!matcher) return - const sanitized = sanitizeResolvedSecretString(`${malformedPrefix}Test`, matcher) - expect(sanitized.length).toBe(malformedPrefix.length + '{{Test}}'.length) - expect(sanitized.endsWith('{{Test}}')).toBe(true) + const sanitized = sanitizeResolvedSecretString(`${malformedPrefix}TestValue`, matcher) + expect(sanitized.length).toBe(malformedPrefix.length + '{{TestValue}}'.length) + expect(sanitized.endsWith('{{TestValue}}')).toBe(true) }) it('still replaces secrets that extend beyond a protected placeholder', () => { const matcher = createResolvedSecretMatcher( [ - { plaintext: 'x{{Test}}y', replacement: '{{COMPOSITE}}' }, - { plaintext: 'Test', replacement: '{{Test}}' }, + { plaintext: 'x{{TestValue}}y', replacement: '{{COMPOSITE}}' }, + { plaintext: 'TestValue', replacement: '{{TestValue}}' }, ], PRESERVE_NAMED_PROVENANCE ) expect(matcher).toBeDefined() if (!matcher) return - expect(sanitizeResolvedSecretString('x{{Test}}y', matcher)).toBe('{{COMPOSITE}}') - expect(containsResolvedSecret('x{{Test}}y', matcher)).toBe(true) + expect(sanitizeResolvedSecretString('x{{TestValue}}y', matcher)).toBe('{{COMPOSITE}}') + expect(containsResolvedSecret('x{{TestValue}}y', matcher)).toBe(true) }) it('uses the opaque fallback for unsafe non-placeholder replacements', () => { const matcher = createResolvedSecretMatcher([ - { plaintext: 'Test', replacement: 'visible-Test' }, + { plaintext: 'TestValue', replacement: 'visible-TestValue' }, ]) expect(matcher).toBeDefined() if (!matcher) return - expect(sanitizeResolvedSecretString('Test', matcher)).toBe(OPAQUE_RESOLVED_SECRET_REPLACEMENT) + expect(sanitizeResolvedSecretString('TestValue', matcher)).toBe( + OPAQUE_RESOLVED_SECRET_REPLACEMENT + ) }) it('does not protect another secret merely because it occurs inside a named placeholder', () => { const matcher = createResolvedSecretMatcher( [ - { plaintext: 'Test', replacement: '{{Test}}' }, - { plaintext: '{', replacement: '{{BRACE}}' }, + { plaintext: 'TestValue', replacement: '{{TestValue}}' }, + { plaintext: '{{TestVa', replacement: '{{BRACE}}' }, ], PRESERVE_NAMED_PROVENANCE ) expect(matcher).toBeDefined() if (!matcher) return - expect(sanitizeResolvedSecretString('Test', matcher)).toBe(OPAQUE_RESOLVED_SECRET_REPLACEMENT) - expect(sanitizeResolvedSecretString('{', matcher)).toBe('{{BRACE}}') + expect(sanitizeResolvedSecretString('TestValue', matcher)).toBe( + OPAQUE_RESOLVED_SECRET_REPLACEMENT + ) + expect(sanitizeResolvedSecretString('{{TestVa', matcher)).toBe('{{BRACE}}') }) it('fails safely when the opaque fallback contains another active secret', () => { const matcher = createResolvedSecretMatcher([ - { plaintext: 'Test', replacement: 'visible-Test' }, + { plaintext: 'TestValue', replacement: 'visible-TestValue' }, { plaintext: 'REDACTED', replacement: '{{OTHER}}' }, ]) expect(matcher).toBeDefined() if (!matcher) return - expect(sanitizeResolvedSecretString('Test', matcher)).toBe('') - }) -}) - -describe('resolved secret matcher match policy', () => { - const SHORT = [{ plaintext: 'test', replacement: '{{TOKEN}}' }] - const API_KEY = 'sk-proj-Ab3xK9mQ2pLw7nRt5vYc8Zd4' - const LONG = [{ plaintext: API_KEY, replacement: '{{API_KEY}}' }] - - function build( - matches: ResolvedSecretMatch[], - options?: CreateResolvedSecretMatcherOptions - ): ResolvedSecretMatcher { - const matcher = createResolvedSecretMatcher(matches, options) - if (!matcher) throw new Error('expected a matcher') - return matcher - } - - it('matches a short literal anywhere when classifying content', () => { - const matcher = build(SHORT) - - expect(containsResolvedSecret('the latest news', matcher)).toBe(true) - expect(sanitizeResolvedSecretString('the latest news', matcher)).toBe('the la{{TOKEN}} news') - }) - - it.each([ - ['test', '{{TOKEN}}'], - ['key=test', 'key={{TOKEN}}'], - ['"test"', '"{{TOKEN}}"'], - ['{"k":"test"}', '{"k":"{{TOKEN}}"}'], - ['test test', '{{TOKEN}} {{TOKEN}}'], - ['user_test_id', 'user_{{TOKEN}}_id'], - ])('still renders a boundary-anchored short literal in %s', (value, expected) => { - const matcher = build(SHORT, { mode: 'render' }) - - expect(sanitizeResolvedSecretString(value, matcher)).toBe(expected) - expect(containsResolvedSecret(value, matcher)).toBe(true) - }) - - it.each(['the latest news', 'tested', 'prefixtest'])( - 'leaves an unanchored short literal in %s untouched when rendering', - (value) => { - const matcher = build(SHORT, { mode: 'render' }) - - expect(sanitizeResolvedSecretString(value, matcher)).toBe(value) - expect(containsResolvedSecret(value, matcher)).toBe(false) - } - ) - - it('renders a full-length literal at any offset, including mid-token', () => { - const matcher = build(LONG, { mode: 'render' }) - - expect(sanitizeResolvedSecretString(`prefix${API_KEY}suffix`, matcher)).toBe( - 'prefix{{API_KEY}}suffix' - ) - expect(containsResolvedSecret(`prefix${API_KEY}suffix`, matcher)).toBe(true) - }) - - /** Prefixed shapes are assembled at runtime so no source literal reads as a live credential. */ - it.each([ - ['f'.repeat(32), 'all-f HMAC key'], - ['4111111111111111', 'test PAN'], - [`AKIA${'0'.repeat(16)}`, 'padded AWS key id'], - [`sk_live_${'0'.repeat(24)}`, 'padded stripe-style key'], - ])('renders low-variety full-length credential (%s) mid-token', (secret) => { - const matcher = build([{ plaintext: secret, replacement: '{{KEY}}' }], { mode: 'render' }) - - expect(sanitizeResolvedSecretString(`etag_${secret}x`, matcher)).toBe('etag_{{KEY}}x') - expect(containsResolvedSecret(`etag_${secret}x`, matcher)).toBe(true) - }) - - it('settles a boundary that an earlier substitution exposed', () => { - const matcher = build( - [ - { plaintext: API_KEY, replacement: '{{API_KEY}}' }, - { plaintext: 'test', replacement: '{{TOKEN}}' }, - ], - { mode: 'render' } - ) - - expect(sanitizeResolvedSecretString(`${API_KEY}test`, matcher)).toBe('{{API_KEY}}{{TOKEN}}') - }) - - it('settles a literal that an empty replacement spliced into existence', () => { - const matcher = build([ - { plaintext: API_KEY, replacement: '' }, - { plaintext: 'password', replacement: '{{PW}}' }, - ]) - - expect(sanitizeResolvedSecretString(`pass${API_KEY}word`, matcher)).toBe('{{PW}}') - }) - - it('keeps the substitution pass and its invariant in agreement', () => { - const matcher = build(SHORT, { mode: 'render' }) - - for (const value of ['the latest news', 'key=test', 'contest testable test']) { - const sanitized = sanitizeResolvedSecretString(value, matcher) - expect(containsResolvedSecret(sanitized, matcher)).toBe(false) - } - }) - - it('reports a suppressed match to provenance callbacks so detection stays conservative', () => { - const matcher = build(SHORT, { mode: 'render' }) - const matches: string[] = [] - - expect( - sanitizeResolvedSecretString('the latest news', matcher, undefined, (plaintext) => - matches.push(plaintext) - ) - ).toBe('the latest news') - expect(matches).toEqual(['test']) - }) - - it.each([ - ['Test', '{{Test}}'], - ['{{Test}}', '{{Test}}'], - ['Test {{Test}} Test', '{{Test}} {{Test}} {{Test}}'], - ['laTest news', 'laTest news'], - ])('preserves named provenance labels under the render policy for %s', (value, expected) => { - const matcher = build([{ plaintext: 'Test', replacement: '{{Test}}' }], { - ...PRESERVE_NAMED_PROVENANCE, - mode: 'render', - }) - - expect(sanitizeResolvedSecretString(value, matcher)).toBe(expected) - }) - - it('keeps the protected-placeholder behaviours under the options production uses', () => { - const composite = build( - [ - { plaintext: 'x{{Test}}y', replacement: '{{COMPOSITE}}' }, - { plaintext: 'Test', replacement: '{{Test}}' }, - ], - { ...PRESERVE_NAMED_PROVENANCE, mode: 'render' } - ) - expect(sanitizeResolvedSecretString('x{{Test}}y', composite)).toBe('{{COMPOSITE}}') - - const malformed = build([{ plaintext: 'Test', replacement: '{{Test{B}}}' }], { - ...PRESERVE_NAMED_PROVENANCE, - mode: 'render', - }) - expect(sanitizeResolvedSecretString('Test', malformed)).toBe(OPAQUE_RESOLVED_SECRET_REPLACEMENT) - - const chained = build( - [ - { plaintext: 'Test', replacement: 'visible-Test' }, - { plaintext: 'REDACTED', replacement: '{{OTHER}}' }, - ], - { mode: 'render' } - ) - expect(sanitizeResolvedSecretString('Test', chained)).toBe('') - }) - - it('keeps exact replacement available below the length floor', () => { - const matcher = build([{ plaintext: '23', replacement: '{{TOKEN}}' }], { mode: 'render' }) - - expect(sanitizeResolvedSecretPrimitive('23', matcher)).toBe('{{TOKEN}}') - expect(sanitizeResolvedSecretString('23', matcher)).toBe('{{TOKEN}}') - expect(sanitizeResolvedSecretString('123', matcher)).toBe('123') - }) - - it('builds a matcher for an astral-plane literal instead of failing construction', () => { - const secret = 'k\u{1F600}ey12345' - const matcher = build([{ plaintext: secret, replacement: '{{EMOJI}}' }], { mode: 'render' }) - - expect(sanitizeResolvedSecretString(`token ${secret} end`, matcher)).toBe('token {{EMOJI}} end') - }) - - it('does not rewrite a token interior next to an astral-plane letter', () => { - const matcher = build(SHORT, { mode: 'render' }) - - expect(sanitizeResolvedSecretString('\u{1D400}test\u{1D401}', matcher)).toBe( - '\u{1D400}test\u{1D401}' - ) + expect(sanitizeResolvedSecretString('TestValue', matcher)).toBe('') }) }) diff --git a/apps/sim/executor/utils/resolved-secret-matcher.ts b/apps/sim/executor/utils/resolved-secret-matcher.ts index 60080f3249e..cd08ceecaf5 100644 --- a/apps/sim/executor/utils/resolved-secret-matcher.ts +++ b/apps/sim/executor/utils/resolved-secret-matcher.ts @@ -1,10 +1,5 @@ import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/limits' -import { - getResolvedSecretMatchPolicy, - isNonIdentifyingSecretLiteral, - type ResolvedSecretMatchPolicy, - satisfiesResolvedSecretMatchPolicy, -} from '@/executor/utils/resolved-secret-match-policy' +import { isNonIdentifyingSecretLiteral } from '@/executor/utils/resolved-secret-match-policy' import { getResolvedSecretMatcherCapacityFailure } from '@/executor/utils/resolved-secret-matcher-capacity' const MAX_MATCH_EVENTS = 1_000_000 @@ -17,8 +12,6 @@ export const OPAQUE_RESOLVED_SECRET_REPLACEMENT = '[REDACTED_SECRET]' interface SecretReplacement { plaintext: string replacement: string - /** Absent on a detect matcher, where every literal matches at any offset. */ - policy?: ResolvedSecretMatchPolicy } interface SecretTrieNode { @@ -47,19 +40,6 @@ export interface CreateResolvedSecretMatcherOptions { * the exact plaintext that produced it; overlapping secret literals remain detectable. */ preserveNamedProvenanceLabels?: boolean - /** - * `'detect'` (the default) matches every literal at any offset. Use it wherever a hit only - * classifies content — provenance export, file-safety scans — because there a coincidental hit - * costs an over-broad label while a missed hit can wrongly certify content as secret-free. - * - * `'render'` restricts literals below {@link MIN_UNANCHORED_MATCH_LENGTH} to word-boundary hits. - * Use it wherever a hit rewrites text. A projection's own post-check must be built with the same - * options as the projection it verifies: it asks "did I substitute what I promised", so reading a - * wider match set would make it demand replacements the projector deliberately declined and drop - * the content instead. That does mean such a check cannot see a short literal sitting inside an - * unrelated token — that occurrence is defined as coincidental here, not overlooked. - */ - mode?: 'detect' | 'render' } class ResolvedSecretMatcherError extends Error { @@ -243,8 +223,7 @@ export function containsResolvedSecret(value: string, matcher: ResolvedSecretMat protectedSpan?.start ?? -1, protectedSpan?.end ?? -1, protectedSpan?.plaintexts - ) && - satisfiesResolvedSecretMatchPolicy(value, start, end, outputNode.replacement.policy) + ) ) { return true } @@ -322,9 +301,8 @@ export function sanitizeResolvedSecretString( onMatch?: (plaintext: string) => void ): string { /** - * A substitution can leave a literal the previous pass could not act on: it may expose a word - * boundary that suppressed a narrow-policy match (`test` becoming `{{KEY}}test`), and an - * empty replacement can splice its neighbours into a literal that was not present in the input. + * A substitution can leave a literal the previous pass could not act on: an empty replacement can + * splice its neighbours into a literal that was not present in the input. * Each pass strictly consumes matches, so this converges in practice; the bound is what keeps a * pathological chain from looping, and the throw past it stays the fail-closed backstop callers * already handle by dropping the value. @@ -419,7 +397,6 @@ function substituteResolvedSecrets( protectedSpan?.end ?? -1, protectedSpan?.plaintexts ) && - satisfiesResolvedSecretMatchPolicy(value, start, end, outputNode.replacement.policy) && start >= emitCursor ) { const slot = start % windowSize @@ -460,8 +437,7 @@ export function createResolvedSecretMatcher( for (const match of matches) { /** * Dropped before any construction-time check runs, so no later stage can be talked into - * treating one of these as protectable — including the wide-match-set checks below, which - * deliberately ignore the narrow policy. + * treating one of these as protectable. */ if (!match.plaintext || isNonIdentifyingSecretLiteral(match.plaintext)) continue const current = replacementByPlaintext.get(match.plaintext) @@ -516,7 +492,6 @@ export function createResolvedSecretMatcher( const exactReplacements = new Map() const protectedReplacementPlaintexts = new Map>() - const assigned: SecretReplacement[] = [] for (const { plaintext, replacement } of provisional) { const node = findTerminalNode(detector.root, plaintext) const namedReplacement = isNamedResolvedSecretReplacement(replacement) @@ -534,20 +509,9 @@ export function createResolvedSecretMatcher( plaintext, replacement: safeReplacement, } - assigned.push(node.replacement) exactReplacements.set(plaintext, safeReplacement) } - /** - * Applied only after every construction-time safety check above has run, so those checks always - * see the widest match set and a narrow policy can never talk one of them out of failing closed. - */ - if (options.mode === 'render') { - for (const replacement of assigned) { - replacement.policy = getResolvedSecretMatchPolicy(replacement.plaintext) - } - } - detector.exactReplacements = exactReplacements detector.protectedReplacementPlaintexts = protectedReplacementPlaintexts detector.protectedReplacementMatcher = createProtectedReplacementMatcher( diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts index 448ef802188..3d41d848a93 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.test.ts @@ -1030,19 +1030,19 @@ describe('ResolvedSecretTraceRegistry', () => { it('exports active numeric literals crossing a value boundary, but not boolean or null', () => { const registry = new ResolvedSecretTraceRegistry([ - { name: 'NUMBER', plaintext: '1234', encryptedValue: 'number-ciphertext' }, + { name: 'NUMBER', plaintext: '12345678', encryptedValue: 'number-ciphertext' }, { name: 'BOOLEAN', plaintext: 'false', encryptedValue: 'boolean-ciphertext' }, { name: 'NULL', plaintext: 'null', encryptedValue: 'null-ciphertext' }, - { name: 'ABSENT', plaintext: '5678', encryptedValue: 'absent-ciphertext' }, + { name: 'ABSENT', plaintext: '56781234', encryptedValue: 'absent-ciphertext' }, ]) - registry.recordResolved('NUMBER', '1234') + registry.recordResolved('NUMBER', '12345678') registry.recordResolved('BOOLEAN', 'false') registry.recordResolved('NULL', 'null') - registry.recordResolved('ABSENT', '5678') + registry.recordResolved('ABSENT', '56781234') expect( registry.exportProvenanceForValue( - { number: 1234, boolean: false, nullable: null }, + { number: 12345678, boolean: false, nullable: null }, { anonymous: true } ) ).toEqual({ @@ -1182,23 +1182,23 @@ describe('ResolvedSecretTraceRegistry', () => { it('handles duplicate and empty values deterministically', () => { const registry = new ResolvedSecretTraceRegistry([ - { name: 'Z_TOKEN', plaintext: 'same', encryptedValue: 'z-ciphertext' }, - { name: 'A_TOKEN', plaintext: 'same', encryptedValue: 'a-ciphertext' }, + { name: 'Z_TOKEN', plaintext: 'samevalue', encryptedValue: 'z-ciphertext' }, + { name: 'A_TOKEN', plaintext: 'samevalue', encryptedValue: 'a-ciphertext' }, { name: 'EMPTY', plaintext: '', encryptedValue: 'empty-ciphertext' }, - { name: 'A', plaintext: 'A', encryptedValue: 'short-ciphertext' }, + { name: 'A', plaintext: 'AAAAAAAA', encryptedValue: 'short-ciphertext' }, ]) - registry.recordResolved('Z_TOKEN', 'same') - registry.recordResolved('A_TOKEN', 'same') + registry.recordResolved('Z_TOKEN', 'samevalue') + registry.recordResolved('A_TOKEN', 'samevalue') registry.recordResolved('EMPTY', '') expect(registry.getActiveMatches()).toEqual([ - { plaintext: 'same', replacement: ANONYMOUS_SECRET_TRACE_REPLACEMENT }, + { plaintext: 'samevalue', replacement: ANONYMOUS_SECRET_TRACE_REPLACEMENT }, ]) - registry.recordResolved('A', 'A') + registry.recordResolved('A', 'AAAAAAAA') expect(registry.getActiveMatches()).toEqual([ - { plaintext: 'same', replacement: ANONYMOUS_SECRET_TRACE_REPLACEMENT }, - { plaintext: 'A', replacement: '{{A}}' }, + { plaintext: 'samevalue', replacement: ANONYMOUS_SECRET_TRACE_REPLACEMENT }, + { plaintext: 'AAAAAAAA', replacement: '{{A}}' }, ]) }) diff --git a/apps/sim/executor/variables/resolver.test.ts b/apps/sim/executor/variables/resolver.test.ts index 6f246b5bdff..1bc54b1c0e7 100644 --- a/apps/sim/executor/variables/resolver.test.ts +++ b/apps/sim/executor/variables/resolver.test.ts @@ -194,7 +194,7 @@ describe('VariableResolver function block inputs', () => { }) it('binds propagated references to exact model-selected inputs without changing runtime values', async () => { - const secret = 'x' + const secret = 'xxxxxxxx' const provenance = { version: 1 as const, complete: true, diff --git a/apps/sim/lib/copilot/request/tools/executor.test.ts b/apps/sim/lib/copilot/request/tools/executor.test.ts index 4848e891104..52bb6dcef6c 100644 --- a/apps/sim/lib/copilot/request/tools/executor.test.ts +++ b/apps/sim/lib/copilot/request/tools/executor.test.ts @@ -161,7 +161,7 @@ describe('buildToolExecutionContext', () => { it('isolates one tool from a sibling secret activation and merges settled provenance', () => { const parentRegistry = new ResolvedSecretTraceRegistry([ - { name: 'TOKEN', plaintext: 'secret', encryptedValue: 'encrypted-secret' }, + { name: 'TOKEN', plaintext: 'secretvalue', encryptedValue: 'encrypted-secret' }, ]) const completeSiblingActivation = parentRegistry.beginPendingActivation() const executionContext: ExecutionContext = { @@ -175,11 +175,11 @@ describe('buildToolExecutionContext', () => { expect(toolRegistry).not.toBe(parentRegistry) expect(toolRegistry?.isComplete()).toBe(true) - expect(toolRegistry?.recordResolved('TOKEN', 'secret')).toBe(true) + expect(toolRegistry?.recordResolved('TOKEN', 'secretvalue')).toBe(true) parentRegistry.mergeToolCallRegistry(toolRegistry!) completeSiblingActivation() expect(parentRegistry.getActiveMatches()).toEqual([ - { plaintext: 'secret', replacement: '{{TOKEN}}' }, + { plaintext: 'secretvalue', replacement: '{{TOKEN}}' }, ]) }) }) diff --git a/apps/sim/lib/copilot/request/tools/files.test.ts b/apps/sim/lib/copilot/request/tools/files.test.ts index dd5b0237ffe..19c04e383e7 100644 --- a/apps/sim/lib/copilot/request/tools/files.test.ts +++ b/apps/sim/lib/copilot/request/tools/files.test.ts @@ -369,7 +369,7 @@ describe('maybeWriteOutputToFile', () => { }) it('reuses serialized provenance for output files with the same format', async () => { - const secret = 'a"b' + const secret = 'aaaa"bbbb' const registry = new ResolvedSecretTraceRegistry( [{ name: 'CSV_SECRET', plaintext: secret, encryptedValue: 'encrypted-csv-secret' }], { userId: 'user-1', workspaceId: 'workspace-1' } diff --git a/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts b/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts index 3edb21c4718..26bff98ec4c 100644 --- a/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts +++ b/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts @@ -49,15 +49,15 @@ describe('projectToolResultForCopilot', () => { it('projects an exact-name/exact-value Function result to its named placeholder', () => { const registry = new ResolvedSecretTraceRegistry([ - { name: 'Test', plaintext: 'Test', encryptedValue: 'ciphertext' }, + { name: 'TestName', plaintext: 'TestName', encryptedValue: 'ciphertext' }, ]) - registry.recordResolved('Test', 'Test', { propagated: true }) + registry.recordResolved('TestName', 'TestName', { propagated: true }) const runtimeResult = { success: true, output: { - result: 'Test', - embedded: 'Bearer Test', - legacy: '__var_Test', + result: 'TestName', + embedded: 'Bearer TestName', + legacy: '__var_TestName', compiler: '__sim_code_0_binding_0', }, } @@ -67,16 +67,16 @@ describe('projectToolResultForCopilot', () => { expect(projected).toEqual({ success: true, output: { - result: '{{Test}}', - embedded: 'Bearer {{Test}}', - legacy: '{{Test}}', + result: '{{TestName}}', + embedded: 'Bearer {{TestName}}', + legacy: '{{TestName}}', compiler: '__sim_code_0_binding_0', }, }) expect(JSON.stringify(projected)).not.toContain('"Test"') expect(JSON.stringify(projected)).not.toContain('__var_') expect(JSON.stringify(projected)).toContain('__sim_code_0_binding_0') - expect(runtimeResult.output.result).toBe('Test') + expect(runtimeResult.output.result).toBe('TestName') }) it('projects both output and error from a failed Function execution', () => { @@ -138,22 +138,24 @@ describe('projectToolResultForCopilot', () => { const middle = 'Kq7Xz2Lm9P' const registry = new ResolvedSecretTraceRegistry([ { name: 'MIDDLE', plaintext: middle, encryptedValue: 'encrypted-middle' }, - { name: 'BRACE', plaintext: '{', encryptedValue: 'encrypted-brace' }, - { name: 'JOINED', plaintext: 'ac', encryptedValue: 'encrypted-ac' }, + { name: 'LABEL_PREFIX', plaintext: '{{MIDDLE', encryptedValue: 'encrypted-prefix' }, + { name: 'JOINED', plaintext: 'aaaacccc', encryptedValue: 'encrypted-ac' }, ]) registry.recordResolved('MIDDLE', middle, { propagated: true }) - registry.recordResolved('BRACE', '{', { propagated: true }) - registry.recordResolved('JOINED', 'ac', { propagated: true }) + registry.recordResolved('LABEL_PREFIX', '{{MIDDLE', { propagated: true }) + registry.recordResolved('JOINED', 'aaaacccc', { propagated: true }) - expect(projectToolResultForCopilot({ success: true, output: `a${middle}c` }, registry)).toEqual( - { - success: true, - output: 'a[REDACTED_SECRET]c', - } - ) + expect( + projectToolResultForCopilot({ success: true, output: `aaaa${middle}cccc` }, registry) + ).toEqual({ success: true, output: 'aaaa[REDACTED_SECRET]cccc' }) }) - it('projects a short secret standing alone or delimited, but not inside another word', () => { + /** + * The floor replaced a tier that substituted a short literal wherever it sat on a word boundary. + * A six-character value is below the floor, so no position substitutes it any more — the accepted + * cost of never rewriting an unrelated token that happens to share those bytes. + */ + it('leaves a secret below the length floor alone in every position', () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'PIN', plaintext: '483920', encryptedValue: 'encrypted-pin' }, ]) @@ -175,15 +177,16 @@ describe('projectToolResultForCopilot', () => { ).toEqual({ success: true, output: { - whole: '{{PIN}}', - delimited: 'code={{PIN}}', - underscored: 'user_{{PIN}}_profile', + whole: '483920', + delimited: 'code=483920', + underscored: 'user_483920_profile', embedded: 'ref483920x', }, }) }) - it('keeps content and the control error safe from active one-character values', () => { + /** A one-character value cannot be hidden by substitution; an observer can enumerate ten. */ + it('leaves an active one-character value in keys and the control error alone', () => { const registry = new ResolvedSecretTraceRegistry([ { name: 'F_SECRET', plaintext: 'F', encryptedValue: 'encrypted-f' }, ]) @@ -200,8 +203,8 @@ describe('projectToolResultForCopilot', () => { expect(projected).toEqual({ success: false, - output: { '{{F_SECRET}}': 'first', '': 'second' }, - error: '{{F_SECRET}}', + output: { F: 'first', '': 'second' }, + error: 'F', }) }) @@ -229,11 +232,11 @@ describe('projectToolResultForCopilot', () => { it('projects exact typed numeric secrets, leaving booleans and null identifying nothing', () => { const registry = new ResolvedSecretTraceRegistry([ - { name: 'NUMBER', plaintext: '123', encryptedValue: 'number-ciphertext' }, + { name: 'NUMBER', plaintext: '12345678', encryptedValue: 'number-ciphertext' }, { name: 'BOOLEAN', plaintext: 'true', encryptedValue: 'boolean-ciphertext' }, { name: 'NULL', plaintext: 'null', encryptedValue: 'null-ciphertext' }, ]) - registry.recordResolved('NUMBER', '123', { propagated: true }) + registry.recordResolved('NUMBER', '12345678', { propagated: true }) registry.recordResolved('BOOLEAN', 'true', { propagated: true }) registry.recordResolved('NULL', 'null', { propagated: true }) @@ -242,10 +245,10 @@ describe('projectToolResultForCopilot', () => { { success: true, output: { - number: 123, + number: 12345678, boolean: true, nothing: null, - numberText: '123', + numberText: '12345678', booleanText: 'true', nilText: 'null', }, diff --git a/apps/sim/lib/logs/execution/logging-session.test.ts b/apps/sim/lib/logs/execution/logging-session.test.ts index f166d94bb3b..172c08c419c 100644 --- a/apps/sim/lib/logs/execution/logging-session.test.ts +++ b/apps/sim/lib/logs/execution/logging-session.test.ts @@ -305,8 +305,8 @@ describe('LoggingSession terminal provenance', () => { } await session.complete({ - finalOutput: { result: 'Test' }, - workflowInput: { token: 'Test' }, + finalOutput: { result: 'TestValue' }, + workflowInput: { token: 'TestValue' }, executionState, }) @@ -786,7 +786,7 @@ describe('LoggingSession completion retries', () => { status: 'success', output: { apiKey: 'ordinary-value' }, displayResolvedSecretTraceProvenance: createDisplayProvenance([ - { plaintext: 'E', replacement: '{{X}}' }, + { plaintext: 'EEEEEEEE', replacement: '{{X}}' }, ]), }, ] @@ -799,13 +799,13 @@ describe('LoggingSession completion retries', () => { preview: '{{X}}', } as const session.setResolvedSecretTraceRegistry( - createSecretRegistry([{ plaintext: 'E', replacement: '{{X}}' }]) + createSecretRegistry([{ plaintext: 'EEEEEEEE', replacement: '{{X}}' }]) ) prepareTraceSpansForProjectionMock.mockImplementationOnce( async ({ traceSpans }: { traceSpans: Array> }) => traceSpans.map((span) => ({ ...span, output: { payload: ref } })) ) - materializeLargeValueRefMock.mockResolvedValue({ value: 'hidden-E' }) + materializeLargeValueRefMock.mockResolvedValue({ value: 'hidden-EEEEEEEE' }) await session.safeComplete({ traceSpans: sourceTraceSpans as any }) @@ -977,7 +977,7 @@ describe('LoggingSession completion retries', () => { it('projects live block errors and terminal block logs without mutating raw callback data', async () => { const session = new LoggingSession('workflow-1', 'execution-display-safe', 'manual', 'req-1') - const secret = '1234' + const secret = '12345678' const rawError = `Reference Error: Line 1: return blah +${secret} - blah is not defined` const rawLog = { blockId: 'function-1', @@ -1057,7 +1057,7 @@ describe('LoggingSession completion retries', () => { it('projects a numeric Function result produced by a resolved numeric secret', async () => { const session = new LoggingSession('workflow-1', 'execution-numeric-secret', 'manual', 'req-1') session.setResolvedSecretTraceRegistry( - createSecretRegistry([{ plaintext: '1234', replacement: '{{OPENAI_API_KEY}}' }]) + createSecretRegistry([{ plaintext: '12345678', replacement: '{{OPENAI_API_KEY}}' }]) ) const rawLog = { blockId: 'function-1', @@ -1068,10 +1068,10 @@ describe('LoggingSession completion retries', () => { durationMs: 1, success: true, executionOrder: 1, - input: { code: 'return 1234' }, - output: { result: 1234, stdout: '' }, + input: { code: 'return 12345678' }, + output: { result: 12345678, stdout: '' }, displayResolvedSecretTraceProvenance: createDisplayProvenance([ - { plaintext: '1234', replacement: '{{OPENAI_API_KEY}}' }, + { plaintext: '12345678', replacement: '{{OPENAI_API_KEY}}' }, ]), } @@ -1079,13 +1079,13 @@ describe('LoggingSession completion retries', () => { expect(displayLog.input).toEqual({ code: 'return {{OPENAI_API_KEY}}' }) expect(displayLog.output).toEqual({ result: '{{OPENAI_API_KEY}}', stdout: '' }) - expect(rawLog.output.result).toBe(1234) + expect(rawLog.output.result).toBe(12345678) }) it('projects each block log with only its causal provenance', async () => { const session = new LoggingSession('workflow-1', 'execution-sibling-values', 'manual', 'req-1') session.setResolvedSecretTraceRegistry( - createSecretRegistry([{ plaintext: 'Test', replacement: '{{SHORT_SECRET}}' }]) + createSecretRegistry([{ plaintext: 'TestValue', replacement: '{{SHORT_SECRET}}' }]) ) const baseLog = { blockName: 'Function', @@ -1100,22 +1100,22 @@ describe('LoggingSession completion retries', () => { ...baseLog, blockId: 'secret-block', executionOrder: 1, - output: { result: 'Test' }, + output: { result: 'TestValue' }, displayResolvedSecretTraceProvenance: createDisplayProvenance([ - { plaintext: 'Test', replacement: '{{SHORT_SECRET}}' }, + { plaintext: 'TestValue', replacement: '{{SHORT_SECRET}}' }, ]), }, { ...baseLog, blockId: 'public-block', executionOrder: 2, - output: { result: 'Test' }, + output: { result: 'TestValue' }, displayResolvedSecretTraceProvenance: createDisplayProvenance([]), }, ]) expect(displayLogs[0].output).toEqual({ result: '{{SHORT_SECRET}}' }) - expect(displayLogs[1].output).toEqual({ result: 'Test' }) + expect(displayLogs[1].output).toEqual({ result: 'TestValue' }) expect(displayLogs[0]).not.toHaveProperty('displayResolvedSecretTraceProvenance') expect(displayLogs[1]).not.toHaveProperty('displayResolvedSecretTraceProvenance') }) diff --git a/apps/sim/lib/logs/execution/trace-secret-projection.test.ts b/apps/sim/lib/logs/execution/trace-secret-projection.test.ts index 2a1a3b4510a..924eea76c56 100644 --- a/apps/sim/lib/logs/execution/trace-secret-projection.test.ts +++ b/apps/sim/lib/logs/execution/trace-secret-projection.test.ts @@ -103,13 +103,13 @@ describe('projectTraceSpansForSecrets', () => { it('preserves named provenance when an exact secret name and value overlap', async () => { const registry = new ResolvedSecretTraceRegistry([ - { name: 'Test', plaintext: 'Test', encryptedValue: 'ciphertext' }, + { name: 'TestName', plaintext: 'TestName', encryptedValue: 'ciphertext' }, ]) - expect(registry.recordResolved('Test', 'Test')).toBe(true) + expect(registry.recordResolved('TestName', 'TestName')).toBe(true) const source = createSpan({ - input: { code: 'return {{Test}}' }, + input: { code: 'return {{TestName}}' }, output: { - result: 'Test', + result: 'TestName', legacy: '__var_Test', compiler: '__sim_code_0_binding_0', }, @@ -120,17 +120,17 @@ describe('projectTraceSpansForSecrets', () => { store: STORE, }) - expect(projected.input).toEqual({ code: 'return {{Test}}' }) + expect(projected.input).toEqual({ code: 'return {{TestName}}' }) expect(projected.output).toEqual({ - result: '{{Test}}', + result: '{{TestName}}', legacy: '[REDACTED_SECRET]', compiler: '[RUNTIME_BINDING]', }) expect(JSON.stringify(projected)).not.toContain('__var_') expect(JSON.stringify(projected)).not.toContain('__sim_code_') - expect(source.input).toEqual({ code: 'return {{Test}}' }) + expect(source.input).toEqual({ code: 'return {{TestName}}' }) expect(source.output).toEqual({ - result: 'Test', + result: 'TestName', legacy: '__var_Test', compiler: '__sim_code_0_binding_0', }) @@ -268,10 +268,10 @@ describe('projectTraceSpansForSecrets', () => { it('uses deterministic longest matches and does not rescan replacements', async () => { const result = await projectTraceSpansForSecrets( - [createSpan({ output: { value: 'secret-suffix secret A_SECRET' } })], + [createSpan({ output: { value: 'secret-suffix secretval A_SECRET' } })], { registry: createRegistry([ - { plaintext: 'secret', replacement: '{{SHORT}}' }, + { plaintext: 'secretval', replacement: '{{SHORT}}' }, { plaintext: 'secret-suffix', replacement: '{{LONG}}' }, { plaintext: 'A_SECRET', replacement: '{{A_SECRET}}' }, ]), @@ -294,7 +294,7 @@ describe('projectTraceSpansForSecrets', () => { { registry: createRegistry([ ...dormant, - { plaintext: 'secret', replacement: '{{SHORT}}' }, + { plaintext: 'secretval', replacement: '{{SHORT}}' }, { plaintext: 'secret-suffix', replacement: '{{LONG}}' }, ]), store: STORE, @@ -322,9 +322,9 @@ describe('projectTraceSpansForSecrets', () => { it('supports one-character case-sensitive secrets without altering structural fields', async () => { const result = await projectTraceSpansForSecrets( - [createSpan({ id: 'A-structural', output: { value: 'A a' } })], + [createSpan({ id: 'A-structural', output: { value: 'AAAAAAAA a' } })], { - registry: createRegistry([{ plaintext: 'A', replacement: '{{LETTER}}' }]), + registry: createRegistry([{ plaintext: 'AAAAAAAA', replacement: '{{LETTER}}' }]), store: STORE, } ) @@ -334,27 +334,27 @@ describe('projectTraceSpansForSecrets', () => { }) it('projects a successfully resolved numeric Function result without mutating runtime output', async () => { - const runtimeOutput = { result: 1234, unchanged: 5678 } + const runtimeOutput = { result: 12345678, unchanged: 5678 } const source = createSpan({ output: runtimeOutput }) const [result] = await projectTraceSpansForSecrets([source], { - registry: createRegistry([{ plaintext: '1234', replacement: '{{NUMERIC_SECRET}}' }]), + registry: createRegistry([{ plaintext: '12345678', replacement: '{{NUMERIC_SECRET}}' }]), store: STORE, }) expect(result.output).toEqual({ result: '{{NUMERIC_SECRET}}', unchanged: 5678 }) expect(source.output).toBe(runtimeOutput) - expect(runtimeOutput).toEqual({ result: 1234, unchanged: 5678 }) + expect(runtimeOutput).toEqual({ result: 12345678, unchanged: 5678 }) }) it('does not infer or redact values derived from a resolved secret', async () => { const source = createSpan({ - input: { code: 'return 1234 + 5' }, + input: { code: 'return 12345678 + 5' }, output: { result: 1239 }, }) const [result] = await projectTraceSpansForSecrets([source], { - registry: createRegistry([{ plaintext: '1234', replacement: '{{NUMERIC_SECRET}}' }]), + registry: createRegistry([{ plaintext: '12345678', replacement: '{{NUMERIC_SECRET}}' }]), store: STORE, }) @@ -364,9 +364,9 @@ describe('projectTraceSpansForSecrets', () => { it('omits a content field when secret replacement collides object keys', async () => { const result = await projectTraceSpansForSecrets( - [createSpan({ input: { safe: 'keep' }, output: { secret: 1, '{{TOKEN}}': 2 } })], + [createSpan({ input: { safe: 'keep' }, output: { secretval: 1, '{{TOKEN}}': 2 } })], { - registry: createRegistry([{ plaintext: 'secret', replacement: '{{TOKEN}}' }]), + registry: createRegistry([{ plaintext: 'secretval', replacement: '{{TOKEN}}' }]), store: STORE, } ) @@ -454,7 +454,7 @@ describe('projectTraceSpansForSecrets', () => { const source = [createSpan({ output: { summary: 'the latest news' } })] const result = await enforceTraceSpanSecretInvariant(source, { - registry: createRegistry([{ plaintext: 'test', replacement: '{{TOKEN}}' }]), + registry: createRegistry([{ plaintext: 'testvalue', replacement: '{{TOKEN}}' }]), store: STORE, }) @@ -505,7 +505,7 @@ describe('projectTraceSpansForSecrets', () => { materializeLargeValueRefMock.mockResolvedValue({ value: '{{X}}' }) const result = await enforceTraceSpanSecretInvariant(source, { - registry: createRegistry([{ plaintext: 'E', replacement: '{{X}}' }]), + registry: createRegistry([{ plaintext: 'EEEEEEEE', replacement: '{{X}}' }]), store: STORE, }) @@ -552,13 +552,13 @@ describe('projectTraceSpansForSecrets', () => { } as const const refWithExtraMetadata = { ...safeRef, - leaked: 'hidden-E', + leaked: 'hidden-EEEEEEEE', } const source = [createSpan({ output: { first: safeRef, duplicate: refWithExtraMetadata } })] materializeLargeValueRefMock.mockResolvedValue({ value: '{{X}}' }) const result = await enforceTraceSpanSecretInvariant(source, { - registry: createRegistry([{ plaintext: 'E', replacement: '{{X}}' }]), + registry: createRegistry([{ plaintext: 'EEEEEEEE', replacement: '{{X}}' }]), store: STORE, }) @@ -586,11 +586,11 @@ describe('projectTraceSpansForSecrets', () => { } as const const source = [createSpan({ output: { payload: outerRef } })] materializeLargeValueRefMock.mockImplementation(async (ref: { id: string }) => - ref.id === nestedRef.id ? { value: 'hidden-E' } : { value: '{{X}}' } + ref.id === nestedRef.id ? { value: 'hidden-EEEEEEEE' } : { value: '{{X}}' } ) const result = await enforceTraceSpanSecretInvariant(source, { - registry: createRegistry([{ plaintext: 'E', replacement: '{{X}}' }]), + registry: createRegistry([{ plaintext: 'EEEEEEEE', replacement: '{{X}}' }]), store: STORE, }) @@ -632,11 +632,11 @@ describe('projectTraceSpansForSecrets', () => { }), ] materializeLargeValueRefMock.mockImplementation(async (ref: { id: string }) => - ref.id === nestedRef.id ? { value: 'hidden-E' } : { value: '{{X}}' } + ref.id === nestedRef.id ? { value: 'hidden-EEEEEEEE' } : { value: '{{X}}' } ) const result = await enforceTraceSpanSecretInvariant(source, { - registry: createRegistry([{ plaintext: 'E', replacement: '{{X}}' }]), + registry: createRegistry([{ plaintext: 'EEEEEEEE', replacement: '{{X}}' }]), store: STORE, }) @@ -658,10 +658,10 @@ describe('projectTraceSpansForSecrets', () => { preview: '{{X}}', } as const const source = [createSpan({ output: { payload: ref } })] - materializeLargeValueRefMock.mockResolvedValue({ visibleAfterPreview: 'value-E' }) + materializeLargeValueRefMock.mockResolvedValue({ visibleAfterPreview: 'value-EEEEEEEE' }) const result = await enforceTraceSpanSecretInvariant(source, { - registry: createRegistry([{ plaintext: 'E', replacement: '{{X}}' }]), + registry: createRegistry([{ plaintext: 'EEEEEEEE', replacement: '{{X}}' }]), store: STORE, }) @@ -694,7 +694,7 @@ describe('projectTraceSpansForSecrets', () => { materializeLargeValueRefMock.mockResolvedValue([{ token: '{{X}}' }]) const result = await enforceTraceSpanSecretInvariant(source, { - registry: createRegistry([{ plaintext: 'E', replacement: '{{X}}' }]), + registry: createRegistry([{ plaintext: 'EEEEEEEE', replacement: '{{X}}' }]), store: STORE, }) @@ -738,7 +738,7 @@ describe('projectTraceSpansForSecrets', () => { materializeLargeValueRefMock.mockResolvedValue([{ token: '{{X}}' }]) const result = await enforceTraceSpanSecretInvariant(source, { - registry: createRegistry([{ plaintext: 'E', replacement: '{{X}}' }]), + registry: createRegistry([{ plaintext: 'EEEEEEEE', replacement: '{{X}}' }]), store: STORE, }) @@ -768,10 +768,10 @@ describe('projectTraceSpansForSecrets', () => { preview: [{ token: '{{X}}' }], } as const const source = [createSpan({ output: { items: manifest } })] - materializeLargeValueRefMock.mockResolvedValue([{ token: 'hidden-E' }]) + materializeLargeValueRefMock.mockResolvedValue([{ token: 'hidden-EEEEEEEE' }]) const result = await enforceTraceSpanSecretInvariant(source, { - registry: createRegistry([{ plaintext: 'E', replacement: '{{X}}' }]), + registry: createRegistry([{ plaintext: 'EEEEEEEE', replacement: '{{X}}' }]), store: STORE, }) diff --git a/apps/sim/lib/logs/execution/trace-secret-projection.ts b/apps/sim/lib/logs/execution/trace-secret-projection.ts index e96025cf5ac..7ff7501739a 100644 --- a/apps/sim/lib/logs/execution/trace-secret-projection.ts +++ b/apps/sim/lib/logs/execution/trace-secret-projection.ts @@ -1537,7 +1537,6 @@ export async function enforceTraceSpanSecretInvariant( const matcher = createResolvedSecretMatcher(options.registry.getActiveMatches(), { preserveNamedProvenanceLabels: true, - mode: 'render', }) if (!matcher) return traceSpans @@ -1566,7 +1565,6 @@ export async function projectTraceSpansForSecrets( try { const matcher = createResolvedSecretMatcher(options.registry.getActiveMatches(), { preserveNamedProvenanceLabels: true, - mode: 'render', }) if (!matcher) return cloneTraceSpansForProjection(traceSpans) diff --git a/apps/sim/lib/logs/execution/trace-store.test.ts b/apps/sim/lib/logs/execution/trace-store.test.ts index 4b9762cb4fc..926f519aa0b 100644 --- a/apps/sim/lib/logs/execution/trace-store.test.ts +++ b/apps/sim/lib/logs/execution/trace-store.test.ts @@ -36,7 +36,7 @@ const CONTEXT = { beforeEach(() => { vi.clearAllMocks() - decryptSecretMock.mockResolvedValue({ decrypted: '1234' }) + decryptSecretMock.mockResolvedValue({ decrypted: '12345678' }) }) describe('execution data storage', () => { @@ -94,10 +94,10 @@ describe('execution data storage', () => { describe('projectExecutionDataForDisplay', () => { it('retains run-global projection for legacy rows without exact value sidecars', async () => { const executionData = { - finalOutput: { result: 1234, derived: 1239 }, - workflowInput: { nested: { token: 'prefix-1234-suffix' } }, - completionFailure: 'Function failed with 1234', - errorDetails: { blockId: 'function-1', error: 'Invalid token 1234' }, + finalOutput: { result: 12345678, derived: 12345683 }, + workflowInput: { nested: { token: 'prefix-12345678-suffix' } }, + completionFailure: 'Function failed with 12345678', + errorDetails: { blockId: 'function-1', error: 'Invalid token 12345678' }, traceSpans: [ { id: 'span-1', @@ -106,11 +106,11 @@ describe('projectExecutionDataForDisplay', () => { duration: 1, startTime: '2026-07-31T00:00:00.000Z', endTime: '2026-07-31T00:00:00.001Z', - output: { result: 1234 }, + output: { result: 12345678 }, }, ], executionState: { - blockStates: { 'function-1': { output: { result: 1234 } } }, + blockStates: { 'function-1': { output: { result: 12345678 } } }, resolvedSecretTraceProvenance: { version: 1 as const, complete: true, @@ -124,7 +124,7 @@ describe('projectExecutionDataForDisplay', () => { expect(displayData.finalOutput).toEqual({ result: '{{OPENAI_API_KEY}}', - derived: 1239, + derived: 12345683, }) expect(displayData.workflowInput).toEqual({ nested: { token: 'prefix-{{OPENAI_API_KEY}}-suffix' }, @@ -138,15 +138,15 @@ describe('projectExecutionDataForDisplay', () => { expect.objectContaining({ output: { result: '{{OPENAI_API_KEY}}' } }), ]) expect(displayData).not.toHaveProperty('executionState') - expect(executionData.finalOutput).toEqual({ result: 1234, derived: 1239 }) + expect(executionData.finalOutput).toEqual({ result: 12345678, derived: 12345683 }) expect(executionData.executionState.resolvedSecretTraceProvenance.entries).toEqual([ { name: 'OPENAI_API_KEY', encryptedValue: 'ciphertext' }, ]) - expect(JSON.stringify(displayData)).not.toContain('1234') + expect(JSON.stringify(displayData)).not.toContain('12345678') }) it('projects only values carrying exact provenance when sibling fields share low-entropy bytes', async () => { - decryptSecretMock.mockResolvedValue({ decrypted: 'Test' }) + decryptSecretMock.mockResolvedValue({ decrypted: 'TestValue' }) const secretProvenance = { version: 1 as const, complete: true, @@ -160,8 +160,8 @@ describe('projectExecutionDataForDisplay', () => { scope: { userId: 'user-1', workspaceId: 'workspace-1' }, } const executionData = { - finalOutput: { result: 'Test' }, - workflowInput: { token: 'Test' }, + finalOutput: { result: 'TestValue' }, + workflowInput: { token: 'TestValue' }, executionState: { resolvedSecretTraceProvenance: secretProvenance, finalOutputResolvedSecretTraceProvenance: emptyProvenance, @@ -171,7 +171,7 @@ describe('projectExecutionDataForDisplay', () => { const displayData = await projectExecutionDataForDisplay(executionData, CONTEXT) - expect(displayData.finalOutput).toEqual({ result: 'Test' }) + expect(displayData.finalOutput).toEqual({ result: 'TestValue' }) expect(displayData.workflowInput).toEqual({ token: '{{TOKEN}}' }) expect(displayData).not.toHaveProperty('executionState') }) diff --git a/apps/sim/providers/runtime-context.test.ts b/apps/sim/providers/runtime-context.test.ts index 74ab0d7ee1e..00494b67b2a 100644 --- a/apps/sim/providers/runtime-context.test.ts +++ b/apps/sim/providers/runtime-context.test.ts @@ -295,16 +295,16 @@ describe('provider runtime context', () => { it('projects only the active preset secret for the exact configured tool instance', async () => { const sourceRegistry = new ResolvedSecretTraceRegistry([ - { name: 'ACTIVE', plaintext: 'x', encryptedValue: 'encrypted-active' }, - { name: 'UNUSED', plaintext: 'true', encryptedValue: 'encrypted-unused' }, + { name: 'ACTIVE', plaintext: 'xxxxxxxx', encryptedValue: 'encrypted-active' }, + { name: 'UNUSED', plaintext: 'truetrue', encryptedValue: 'encrypted-unused' }, ]) const sourcePath = ['tools', '0', 'params', 'apiKey'] as const - sourceRegistry.recordResolvedAtInputPath('ACTIVE', 'x', sourcePath) - sourceRegistry.recordResolvedInputProjection(sourcePath, 'x', '{{ACTIVE}}') + sourceRegistry.recordResolvedAtInputPath('ACTIVE', 'xxxxxxxx', sourcePath) + sourceRegistry.recordResolvedInputProjection(sourcePath, 'xxxxxxxx', '{{ACTIVE}}') const runtimeRegistry = sourceRegistry.forkForInputPaths([]) const tool = { id: 'duplicate-tool', - params: { apiKey: 'x' }, + params: { apiKey: 'xxxxxxxx' }, parameters: { type: 'object', properties: {}, required: [] }, paramsTransform: (params: Record) => ({ token: params.apiKey }), } @@ -313,7 +313,7 @@ describe('provider runtime context', () => { sourcePath: ['tools', '0', 'params'], projectedParams: { apiKey: '{{ACTIVE}}' }, }) - const rawResult = { success: true, output: { reflected: 'x', ordinary: 'true' } } + const rawResult = { success: true, output: { reflected: 'xxxxxxxx', ordinary: 'true' } } mockExecuteTool.mockResolvedValueOnce(rawResult) const execution = await runWithProviderRuntimeContext( @@ -329,25 +329,27 @@ describe('provider runtime context', () => { reflected: '{{ACTIVE}}', ordinary: 'true', }) - expect(mockExecuteTool.mock.calls.at(-1)?.[1]).toEqual(expect.objectContaining({ token: 'x' })) + expect(mockExecuteTool.mock.calls.at(-1)?.[1]).toEqual( + expect.objectContaining({ token: 'xxxxxxxx' }) + ) expect(mockExecuteTool.mock.calls.at(-1)?.[1]).not.toHaveProperty( '__resolvedSecretTraceProvenance' ) expect(runtimeRegistry.getActiveMatches()).toEqual([ - { plaintext: 'x', replacement: '{{ACTIVE}}' }, + { plaintext: 'xxxxxxxx', replacement: '{{ACTIVE}}' }, ]) }) it('does not carry a prior low-entropy preset into a later duplicate tool instance', async () => { const registry = new ResolvedSecretTraceRegistry([ - { name: 'FIRST', plaintext: 'x', encryptedValue: 'encrypted-first' }, + { name: 'FIRST', plaintext: 'xxxxxxxx', encryptedValue: 'encrypted-first' }, ]) const firstPath = ['tools', '0', 'params', 'apiKey'] as const - registry.recordResolvedAtInputPath('FIRST', 'x', firstPath) - registry.recordResolvedInputProjection(firstPath, 'x', '{{FIRST}}') + registry.recordResolvedAtInputPath('FIRST', 'xxxxxxxx', firstPath) + registry.recordResolvedInputProjection(firstPath, 'xxxxxxxx', '{{FIRST}}') const firstTool = { id: 'duplicate-tool', - params: { apiKey: 'x' }, + params: { apiKey: 'xxxxxxxx' }, parameters: { type: 'object', properties: {}, required: [] }, } const secondTool = { @@ -366,7 +368,7 @@ describe('provider runtime context', () => { projectedParams: { query: 'safe' }, }) mockExecuteTool - .mockResolvedValueOnce({ success: true, output: { value: 'x' } }) + .mockResolvedValueOnce({ success: true, output: { value: 'xxxxxxxx' } }) .mockResolvedValueOnce({ success: true, output: { value: 'Box' } }) const executions = await runWithProviderRuntimeContext( @@ -387,14 +389,14 @@ describe('provider runtime context', () => { it('does not activate a configured preset that the deterministic transform drops', async () => { const registry = new ResolvedSecretTraceRegistry([ - { name: 'DROPPED', plaintext: 'x', encryptedValue: 'encrypted-dropped' }, + { name: 'DROPPED', plaintext: 'xxxxxxxx', encryptedValue: 'encrypted-dropped' }, ]) const sourcePath = ['tools', '0', 'params', 'inactive'] as const - registry.recordResolvedAtInputPath('DROPPED', 'x', sourcePath) - registry.recordResolvedInputProjection(sourcePath, 'x', '{{DROPPED}}') + registry.recordResolvedAtInputPath('DROPPED', 'xxxxxxxx', sourcePath) + registry.recordResolvedInputProjection(sourcePath, 'xxxxxxxx', '{{DROPPED}}') const tool = { id: 'conditional-tool', - params: { inactive: 'x', query: 'safe' }, + params: { inactive: 'xxxxxxxx', query: 'safe' }, parameters: { type: 'object', properties: {}, required: [] }, paramsTransform: (params: Record) => ({ query: params.query }), } @@ -483,7 +485,7 @@ describe('provider runtime context', () => { expect(result.output).toBe('{{TOKEN}}') }) - it.each(['123'])( + it.each(['12345678'])( 'leaves non-model resource metadata untouched while projecting content (%s)', async (secret) => { const registry = new ResolvedSecretTraceRegistry([ @@ -495,7 +497,7 @@ describe('provider runtime context', () => { success: true, output: { value: `Result ${secret}`, - converted: secret === '123' ? 123 : true, + converted: secret === '12345678' ? 12345678 : true, }, resources: [ { diff --git a/apps/sim/tools/index.test.ts b/apps/sim/tools/index.test.ts index 40429d0803e..fbeda47c273 100644 --- a/apps/sim/tools/index.test.ts +++ b/apps/sim/tools/index.test.ts @@ -1855,7 +1855,7 @@ describe('executeTool Function', () => { }) it('preserves empty thrown errors instead of replacing their runtime semantics', async () => { - const secret = '!' + const secret = '!!!!!!!!' const registry = new ResolvedSecretTraceRegistry([ { name: 'API_KEY', plaintext: secret, encryptedValue: 'encrypted-value' }, ]) @@ -1894,7 +1894,7 @@ describe('executeTool Function', () => { }) it('does not rewrite coincidental low-entropy matches in thrown runtime errors', async () => { - const secret = 'x' + const secret = 'xxxxxxxx' const registry = new ResolvedSecretTraceRegistry([ { name: 'API_KEY', plaintext: secret, encryptedValue: 'encrypted-value' }, ]) @@ -3625,7 +3625,7 @@ describe('Copilot Env Variable Reference Resolution', () => { }) it('keeps direct integration execution raw while projecting only its active workspace secret', async () => { - const activeSecret = 'x' + const activeSecret = 'xxxxxxxx' const unusedSecret = 'true' mockGetEffectiveDecryptedEnv.mockResolvedValueOnce({ SERPER_API_KEY: activeSecret,