From eeb100172331d2d74e696bdd3cfbfe53bb3a6817 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 11 Aug 2026 11:37:54 -0700 Subject: [PATCH 1/4] fix(provenance): make one length floor the whole substitution rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A literal shorter than eight characters is no longer substituted anywhere. It was already the floor for matches inside a larger token; below it a second tier still substituted whenever the hit sat 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. That tier 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`. Each was patched with a per-value exception list; the floor subsumes both, so the lists are deleted. With no literal below the floor reaching a matcher, the tier's machinery is unreachable and goes with it: the match-policy type, its classifier, the word boundary test, and the detect/render mode that existed only to select between them. One constant now governs the question. The cost is explicit and accepted: a secret shorter than eight characters is no longer redacted from logs or model-visible content. Substitution cannot hide a value that short — an observer who can read the surrounding text can enumerate it. Two tests that pinned the old tier are rewritten to pin this, rather than deleted, so the trade stays visible. Co-Authored-By: Claude Opus 5 (1M context) --- .../executor/execution/block-executor.test.ts | 6 +- .../executor/handlers/agent/memory.test.ts | 4 +- .../executor/handlers/pi/search/tool.test.ts | 18 +- ...resolved-secret-content-projection.test.ts | 64 ++-- .../resolved-secret-content-projection.ts | 3 +- .../resolved-secret-match-policy.test.ts | 103 +------ .../utils/resolved-secret-match-policy.ts | 117 ++------ .../utils/resolved-secret-matcher.test.ts | 274 ++++-------------- .../executor/utils/resolved-secret-matcher.ts | 46 +-- .../resolved-secret-trace-registry.test.ts | 28 +- apps/sim/executor/variables/resolver.test.ts | 2 +- .../copilot/request/tools/executor.test.ts | 6 +- .../lib/copilot/request/tools/files.test.ts | 2 +- .../tools/resolved-secret-result.test.ts | 63 ++-- .../logs/execution/logging-session.test.ts | 32 +- .../execution/trace-secret-projection.test.ts | 68 ++--- .../logs/execution/trace-secret-projection.ts | 2 - .../lib/logs/execution/trace-store.test.ts | 28 +- apps/sim/providers/runtime-context.test.ts | 40 +-- apps/sim/tools/index.test.ts | 6 +- 20 files changed, 281 insertions(+), 631 deletions(-) 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..33d1e102e92 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}`, 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..df71132672b 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,25 +267,25 @@ 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", }, }) }) @@ -308,7 +308,7 @@ describe('projectResolvedSecretModelContent', () => { complete: true, matches: [ { - plaintext: 'x'.repeat(64 * 1024), + plaintext: 'xxxxxxxx'.repeat(64 * 1024), replacement: '[REDACTED_SECRET]', }, ], @@ -321,16 +321,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,7 +411,7 @@ describe('projectResolvedSecretModelJsonContent', () => { it('enforces the byte limit after secret aliases are projected', () => { const registry = new ResolvedSecretTraceRegistry([ - { name: 'X', plaintext: 'x', encryptedValue: 'ciphertext' }, + { name: 'X', plaintext: 'xxxxxxxx', encryptedValue: 'ciphertext' }, ]) registry.recordResolved('X', 'x') @@ -445,7 +447,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, From db10ffac03b6d4154fa24b8e3532d4b62d905b02 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 11 Aug 2026 12:00:18 -0700 Subject: [PATCH 2/4] test(provenance): restore two tests that the length floor had hollowed out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Greptile caught the byte-limit test: lengthening the catalog fixture without the `recordResolved` value beside it left them mismatched, so the registry latched and `projectResolvedSecretModelJsonContent` returned `{ safe: false }` at its completeness guard — before any alias projection. The test passed while covering nothing it is named for. It now records the same literal it catalogs, sizes the limit between the raw bytes and the projected bytes, and asserts both directions so a limit applied to the wrong side fails it. Auditing every changed test for the same shape — a `recordResolved` value that does not match its own catalog entry — found one more, pre-dating this branch: the legacy-memory test drew its teeth from substituting the one-character secret `x` inside `Box`, which the floor no longer substitutes, so it too had become vacuous. Its fixture is now a full-length secret that appears in the message, which is what makes "not projected" meaningful. The three remaining mismatches are deliberate: those tests are about a resolution that fails to verify. Co-Authored-By: Claude Opus 5 (1M context) --- .../executor/handlers/agent/memory.test.ts | 8 ++++---- ...resolved-secret-content-projection.test.ts | 19 +++++++++++++++---- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/apps/sim/executor/handlers/agent/memory.test.ts b/apps/sim/executor/handlers/agent/memory.test.ts index 33d1e102e92..9a1186b7a4b 100644 --- a/apps/sim/executor/handlers/agent/memory.test.ts +++ b/apps/sim/executor/handlers/agent/memory.test.ts @@ -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/utils/resolved-secret-content-projection.test.ts b/apps/sim/executor/utils/resolved-secret-content-projection.test.ts index df71132672b..8601920a185 100644 --- a/apps/sim/executor/utils/resolved-secret-content-projection.test.ts +++ b/apps/sim/executor/utils/resolved-secret-content-projection.test.ts @@ -411,11 +411,22 @@ describe('projectResolvedSecretModelJsonContent', () => { it('enforces the byte limit after secret aliases are projected', () => { const registry = new ResolvedSecretTraceRegistry([ - { name: 'X', plaintext: 'xxxxxxxx', 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) + + /** + * `{"a":"xxxxxxxx"}` is 16 bytes and fits; projecting it to + * `{"a":"{{X_LONGER_NAME}}"}` takes it to 25. The limit has to be applied to the projected + * bytes, so the first call must fail and the second must not. + */ + expect(projectResolvedSecretModelJsonContent({ a: 'xxxxxxxx' }, registry, 16)).toEqual({ + safe: false, + }) + expect(projectResolvedSecretModelJsonContent({ a: 'xxxxxxxx' }, registry, 25)).toEqual({ + safe: true, + value: { a: '{{X_LONGER_NAME}}' }, + }) }) }) From e08d069d0e98a29ad3798f531c50492f1eed8286 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 11 Aug 2026 12:04:07 -0700 Subject: [PATCH 3/4] test(provenance): give the label-protection test a live matcher again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cursor caught the third instance of the same shape: the fixture `TOK` is three characters, so the floor drops it and the matcher is empty. The assertion was `'Bearer {{TOKEN}}'` in and out, which an empty matcher satisfies exactly as well as working label protection — the test could no longer tell them apart. Use a fixture over the floor whose label still contains its own plaintext, and project the bare plaintext first. That control fails if the matcher is inert, so the atomic-label assertion beside it can only pass for the right reason. Auditing every test on this branch for the shape — all fixtures below the floor — returned twenty-four, but the rest are sound: most assert that nothing is substituted, which the floor makes more certain rather than less, and the remainder run through the resolver's causal path, which never consults a matcher. Co-Authored-By: Claude Opus 5 (1M context) --- .../resolved-secret-content-projection.test.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) 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 8601920a185..bf53770fed4 100644 --- a/apps/sim/executor/utils/resolved-secret-content-projection.test.ts +++ b/apps/sim/executor/utils/resolved-secret-content-projection.test.ts @@ -292,13 +292,22 @@ describe('projectResolvedSecretModelContent', () => { 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(registry.recordResolved('TOKENVALUE_NAME', 'TOKENVALUE')).toBe(true) - expect(projectResolvedSecretModelContent('Bearer {{TOKEN}}', registry)).toEqual({ + /** + * 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}}', }) }) From fdfb43905a81e2e55abcd800c3aa3cfc6eec8e4e Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Tue, 11 Aug 2026 12:14:36 -0700 Subject: [PATCH 4/4] test(provenance): make the byte-limit test reject at the gate it names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three limits can reject this value and my previous fix still tripped the wrong one. `projectResolvedSecretModelJsonContent` checks the raw encoding, then walks the content against a running budget, then re-encodes the projected object — and only the last is what the test is for. At 16 the walk charged the key `a` and then measured the 17-byte alias against the remaining 15, so it failed before the re-encoding ran; at 25 everything passed. Neither assertion touched the check. Twenty is the band that isolates it: the walk admits the alias against its remaining 19, so a rejection there can only come from re-encoding the 25-byte result. Asserting the content projection succeeds at the same limit pins that, and deleting the re-encoding check now fails the test rather than leaving it green. Co-Authored-By: Claude Opus 5 (1M context) --- .../resolved-secret-content-projection.test.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) 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 bf53770fed4..ce930f53005 100644 --- a/apps/sim/executor/utils/resolved-secret-content-projection.test.ts +++ b/apps/sim/executor/utils/resolved-secret-content-projection.test.ts @@ -425,11 +425,18 @@ describe('projectResolvedSecretModelJsonContent', () => { expect(registry.recordResolved('X_LONGER_NAME', 'xxxxxxxx')).toBe(true) /** - * `{"a":"xxxxxxxx"}` is 16 bytes and fits; projecting it to - * `{"a":"{{X_LONGER_NAME}}"}` takes it to 25. The limit has to be applied to the projected - * bytes, so the first call must fail and the second must not. + * 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(projectResolvedSecretModelJsonContent({ a: 'xxxxxxxx' }, registry, 16)).toEqual({ + 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({