From 7792c9d8ea84281279142d7a49dfe4079685ad6f Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Sat, 8 Aug 2026 10:30:26 -0700 Subject: [PATCH] fix(provenance): stop short secret values from rewriting unrelated log text --- .../resolved-secret-content-projection.ts | 3 +- .../resolved-secret-match-policy.test.ts | 91 +++++++++ .../utils/resolved-secret-match-policy.ts | 96 ++++++++++ .../utils/resolved-secret-matcher.test.ts | 176 ++++++++++++++++++ .../executor/utils/resolved-secret-matcher.ts | 88 +++++++-- .../tools/resolved-secret-result.test.ts | 41 +++- .../logs/execution/logging-session.test.ts | 4 +- .../execution/trace-secret-projection.test.ts | 23 ++- .../logs/execution/trace-secret-projection.ts | 2 + 9 files changed, 500 insertions(+), 24 deletions(-) create mode 100644 apps/sim/executor/utils/resolved-secret-match-policy.test.ts create mode 100644 apps/sim/executor/utils/resolved-secret-match-policy.ts diff --git a/apps/sim/executor/utils/resolved-secret-content-projection.ts b/apps/sim/executor/utils/resolved-secret-content-projection.ts index 9f707c566a1..92b948c8de4 100644 --- a/apps/sim/executor/utils/resolved-secret-content-projection.ts +++ b/apps/sim/executor/utils/resolved-secret-content-projection.ts @@ -38,6 +38,7 @@ function createResolvedSecretModelMatcher( ): ResolvedSecretMatcher | undefined { const matcher = createResolvedSecretMatcher(matches, { preserveNamedProvenanceLabels: true, + mode: 'render', }) if (!matcher) return undefined @@ -74,7 +75,7 @@ function createResolvedSecretModelMatcher( })), ...opaquePlaceholderMatches, ], - { preserveNamedProvenanceLabels: true } + { preserveNamedProvenanceLabels: true, mode: 'render' } ) } diff --git a/apps/sim/executor/utils/resolved-secret-match-policy.test.ts b/apps/sim/executor/utils/resolved-secret-match-policy.test.ts new file mode 100644 index 00000000000..864d920e451 --- /dev/null +++ b/apps/sim/executor/utils/resolved-secret-match-policy.test.ts @@ -0,0 +1,91 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + getResolvedSecretMatchPolicy, + isWordBoundaryMatch, + MIN_UNANCHORED_MATCH_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) + }) +}) diff --git a/apps/sim/executor/utils/resolved-secret-match-policy.ts b/apps/sim/executor/utils/resolved-secret-match-policy.ts new file mode 100644 index 00000000000..2bd9ffd0b11 --- /dev/null +++ b/apps/sim/executor/utils/resolved-secret-match-policy.ts @@ -0,0 +1,96 @@ +/** + * Decides how a known secret literal is allowed to match inside a larger string. + * + * 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. + */ + +/** + * `'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. + * + * 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 + * not the same question and misfires badly on real credentials: an all-`f` 32-character HMAC key + * scores 0.00 bits/char, a zero-padded card number scores 0.34, a zero-padded AWS key id scores + * 1.02, and a zero-padded `sk_live_` key scores 1.50 — every one of them a full-length secret that + * an entropy floor would demote. Sampling confirms the same for genuinely random values, where the + * finite-sample bias of a short string drags the estimate down: at a 3.0 bits/char floor, 46% of + * 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 + +/** + * 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) +} diff --git a/apps/sim/executor/utils/resolved-secret-matcher.test.ts b/apps/sim/executor/utils/resolved-secret-matcher.test.ts index c90e7329986..2d43bed94a7 100644 --- a/apps/sim/executor/utils/resolved-secret-matcher.test.ts +++ b/apps/sim/executor/utils/resolved-secret-matcher.test.ts @@ -3,9 +3,12 @@ */ import { describe, expect, it } from 'vitest' import { + type CreateResolvedSecretMatcherOptions, containsResolvedSecret, createResolvedSecretMatcher, OPAQUE_RESOLVED_SECRET_REPLACEMENT, + type ResolvedSecretMatch, + type ResolvedSecretMatcher, sanitizeResolvedSecretPrimitive, sanitizeResolvedSecretString, scanResolvedSecretString, @@ -192,3 +195,176 @@ describe('resolved secret matcher', () => { 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}' + ) + }) +}) diff --git a/apps/sim/executor/utils/resolved-secret-matcher.ts b/apps/sim/executor/utils/resolved-secret-matcher.ts index 049f0fe4b14..b20aad11653 100644 --- a/apps/sim/executor/utils/resolved-secret-matcher.ts +++ b/apps/sim/executor/utils/resolved-secret-matcher.ts @@ -1,13 +1,23 @@ import { MAX_INLINE_MATERIALIZATION_BYTES } from '@/lib/execution/payloads/limits' +import { + getResolvedSecretMatchPolicy, + type ResolvedSecretMatchPolicy, + satisfiesResolvedSecretMatchPolicy, +} from '@/executor/utils/resolved-secret-match-policy' import { getResolvedSecretMatcherCapacityFailure } from '@/executor/utils/resolved-secret-matcher-capacity' const MAX_MATCH_EVENTS = 1_000_000 +/** Bounds the substitute/verify loop in {@link sanitizeResolvedSecretString}. */ +const MAX_SETTLE_PASSES = 4 + 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 { @@ -36,6 +46,19 @@ 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 { @@ -102,6 +125,17 @@ function createMatcherFromReplacements( } } +/** Walks by code unit, matching how {@link createMatcherFromReplacements} keys the trie. */ +function findTerminalNode(root: SecretTrieNode, plaintext: string): SecretTrieNode { + let node = root + for (let index = 0; index < plaintext.length; index += 1) { + const child = node.children.get(plaintext[index]) + if (!child) throw new ResolvedSecretMatcherError('Secret matcher construction failed') + node = child + } + return node +} + function advanceMatcher( matcher: ResolvedSecretMatcher, node: SecretTrieNode, @@ -208,7 +242,8 @@ export function containsResolvedSecret(value: string, matcher: ResolvedSecretMat protectedSpan?.start ?? -1, protectedSpan?.end ?? -1, protectedSpan?.plaintexts - ) + ) && + satisfiesResolvedSecretMatchPolicy(value, start, end, outputNode.replacement.policy) ) { return true } @@ -284,6 +319,30 @@ export function sanitizeResolvedSecretString( matcher: ResolvedSecretMatcher, maxBytes = MAX_INLINE_MATERIALIZATION_BYTES, 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. + * 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. + */ + let sanitized = substituteResolvedSecrets(value, matcher, maxBytes, onMatch) + for (let pass = 1; containsResolvedSecret(sanitized, matcher); pass += 1) { + if (pass >= MAX_SETTLE_PASSES) { + throw new ResolvedSecretMatcherError('Sanitized content still contains an active secret') + } + sanitized = substituteResolvedSecrets(sanitized, matcher, maxBytes, onMatch) + } + return sanitized +} + +function substituteResolvedSecrets( + value: string, + matcher: ResolvedSecretMatcher, + maxBytes: number, + onMatch?: (plaintext: string) => void ): string { if (maxBytes < 0) { throw new ResolvedSecretMatcherError('Sanitized secret-bearing string exceeds the size limit') @@ -359,6 +418,7 @@ export function sanitizeResolvedSecretString( protectedSpan?.end ?? -1, protectedSpan?.plaintexts ) && + satisfiesResolvedSecretMatchPolicy(value, start, end, outputNode.replacement.policy) && start >= emitCursor ) { const slot = start % windowSize @@ -375,11 +435,7 @@ export function sanitizeResolvedSecretString( finalizeThrough(value.length - 1) append(value.slice(literalStart)) - const sanitized = chunks.join('') - if (containsResolvedSecret(sanitized, matcher)) { - throw new ResolvedSecretMatcherError('Sanitized content still contains an active secret') - } - return sanitized + return chunks.join('') } /** Replaces only an exact primitive rendering, never a substring of another primitive. */ @@ -454,13 +510,9 @@ export function createResolvedSecretMatcher( const exactReplacements = new Map() const protectedReplacementPlaintexts = new Map>() + const assigned: SecretReplacement[] = [] for (const { plaintext, replacement } of provisional) { - let node = detector.root - for (const character of plaintext) { - const child = node.children.get(character) - if (!child) throw new ResolvedSecretMatcherError('Secret matcher construction failed') - node = child - } + const node = findTerminalNode(detector.root, plaintext) const namedReplacement = isNamedResolvedSecretReplacement(replacement) const replacementContainsSecret = containsResolvedSecret(replacement, detector) const safeReplacement = !replacementContainsSecret @@ -476,8 +528,20 @@ 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/lib/copilot/request/tools/resolved-secret-result.test.ts b/apps/sim/lib/copilot/request/tools/resolved-secret-result.test.ts index 8b49d87fe65..e138881aa12 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 @@ -135,18 +135,51 @@ describe('projectToolResultForCopilot', () => { }) it('uses an opaque marker when a replacement contains another active literal', () => { + const middle = 'Kq7Xz2Lm9P' const registry = new ResolvedSecretTraceRegistry([ - { name: 'MIDDLE', plaintext: 'B', encryptedValue: 'encrypted-b' }, + { name: 'MIDDLE', plaintext: middle, encryptedValue: 'encrypted-middle' }, { name: 'BRACE', plaintext: '{', encryptedValue: 'encrypted-brace' }, { name: 'JOINED', plaintext: 'ac', encryptedValue: 'encrypted-ac' }, ]) - registry.recordResolved('MIDDLE', 'B', { propagated: true }) + registry.recordResolved('MIDDLE', middle, { propagated: true }) registry.recordResolved('BRACE', '{', { propagated: true }) registry.recordResolved('JOINED', 'ac', { propagated: true }) - expect(projectToolResultForCopilot({ success: true, output: 'aBc' }, registry)).toEqual({ + expect(projectToolResultForCopilot({ success: true, output: `a${middle}c` }, registry)).toEqual( + { + success: true, + output: 'a[REDACTED_SECRET]c', + } + ) + }) + + it('projects a short secret standing alone or delimited, but not inside another word', () => { + const registry = new ResolvedSecretTraceRegistry([ + { name: 'PIN', plaintext: '483920', encryptedValue: 'encrypted-pin' }, + ]) + registry.recordResolved('PIN', '483920', { propagated: true }) + + expect( + projectToolResultForCopilot( + { + success: true, + output: { + whole: '483920', + delimited: 'code=483920', + underscored: 'user_483920_profile', + embedded: 'ref483920x', + }, + }, + registry + ) + ).toEqual({ success: true, - output: 'a[REDACTED_SECRET]c', + output: { + whole: '{{PIN}}', + delimited: 'code={{PIN}}', + underscored: 'user_{{PIN}}_profile', + embedded: 'ref483920x', + }, }) }) diff --git a/apps/sim/lib/logs/execution/logging-session.test.ts b/apps/sim/lib/logs/execution/logging-session.test.ts index b0ccc67eaba..f166d94bb3b 100644 --- a/apps/sim/lib/logs/execution/logging-session.test.ts +++ b/apps/sim/lib/logs/execution/logging-session.test.ts @@ -744,12 +744,12 @@ describe('LoggingSession completion retries', () => { status: 'success', output: { apiKey: 'ordinary-value' }, displayResolvedSecretTraceProvenance: createDisplayProvenance([ - { plaintext: 'E', replacement: '{{X}}' }, + { plaintext: 'REDACTED', replacement: '{{X}}' }, ]), }, ] session.setResolvedSecretTraceRegistry( - createSecretRegistry([{ plaintext: 'E', replacement: '{{X}}' }]) + createSecretRegistry([{ plaintext: 'REDACTED', replacement: '{{X}}' }]) ) prepareTraceSpansForProjectionMock.mockImplementationOnce( async ({ traceSpans }: { traceSpans: Array> }) => 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 ccc8969e2ba..2a1a3b4510a 100644 --- a/apps/sim/lib/logs/execution/trace-secret-projection.test.ts +++ b/apps/sim/lib/logs/execution/trace-secret-projection.test.ts @@ -442,7 +442,7 @@ describe('projectTraceSpansForSecrets', () => { const source = [createSpan({ output: { apiKey: '[REDACTED]' } })] const result = await enforceTraceSpanSecretInvariant(source, { - registry: createRegistry([{ plaintext: 'E', replacement: '{{X}}' }]), + registry: createRegistry([{ plaintext: 'REDACTED', replacement: '{{X}}' }]), store: STORE, }) @@ -450,11 +450,23 @@ describe('projectTraceSpansForSecrets', () => { expect(source[0].output).toEqual({ apiKey: '[REDACTED]' }) }) + it('keeps content whose only literal occurrence sits inside an unrelated word', async () => { + const source = [createSpan({ output: { summary: 'the latest news' } })] + + const result = await enforceTraceSpanSecretInvariant(source, { + registry: createRegistry([{ plaintext: 'test', replacement: '{{TOKEN}}' }]), + store: STORE, + }) + + expect(result).toBe(source) + expect(result[0].output).toEqual({ summary: 'the latest news' }) + }) + it('names the invariant that forced the structural fallback', async () => { const source = [createSpan({ output: { apiKey: '[REDACTED]' } })] await enforceTraceSpanSecretInvariant(source, { - registry: createRegistry([{ plaintext: 'E', replacement: '{{X}}' }]), + registry: createRegistry([{ plaintext: 'REDACTED', replacement: '{{X}}' }]), store: STORE, }) @@ -520,7 +532,7 @@ describe('projectTraceSpansForSecrets', () => { materializeLargeValueRefMock.mockResolvedValue({ value: '{{X}}' }) const result = await enforceTraceSpanSecretInvariant(source, { - registry: createRegistry([{ plaintext: 'E', replacement: '{{X}}' }]), + registry: createRegistry([{ plaintext: 'REDACTED', replacement: '{{X}}' }]), store: STORE, }) @@ -971,7 +983,8 @@ describe('projectTraceSpansForSecrets', () => { kind: 'array' as const, size: 1, })) - const expandedValue = 'a'.repeat(1024) + const expandingSecret = 'a7Kq2Xz9Lm4P' + const expandedValue = expandingSecret.repeat(1024) materializeLargeValueRefMock.mockImplementation(async () => [expandedValue]) storeLargeValueMock.mockImplementation( async (_value: unknown, _json: string, size: number) => ({ @@ -996,7 +1009,7 @@ describe('projectTraceSpansForSecrets', () => { const result = await projectTraceSpansForSecrets( [createSpan({ input: { ok: true }, output: { items: manifest } })], { - registry: createRegistry([{ plaintext: 'a', replacement: 'X'.repeat(1024) }]), + registry: createRegistry([{ plaintext: expandingSecret, replacement: 'X'.repeat(1024) }]), 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 7ff7501739a..e96025cf5ac 100644 --- a/apps/sim/lib/logs/execution/trace-secret-projection.ts +++ b/apps/sim/lib/logs/execution/trace-secret-projection.ts @@ -1537,6 +1537,7 @@ export async function enforceTraceSpanSecretInvariant( const matcher = createResolvedSecretMatcher(options.registry.getActiveMatches(), { preserveNamedProvenanceLabels: true, + mode: 'render', }) if (!matcher) return traceSpans @@ -1565,6 +1566,7 @@ export async function projectTraceSpansForSecrets( try { const matcher = createResolvedSecretMatcher(options.registry.getActiveMatches(), { preserveNamedProvenanceLabels: true, + mode: 'render', }) if (!matcher) return cloneTraceSpansForProjection(traceSpans)