Skip to content

Commit 0aae736

Browse files
fix(provenance): stop short secret values from rewriting unrelated log text (#6416)
1 parent 4f5ad20 commit 0aae736

9 files changed

Lines changed: 500 additions & 24 deletions

apps/sim/executor/utils/resolved-secret-content-projection.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ function createResolvedSecretModelMatcher(
3838
): ResolvedSecretMatcher | undefined {
3939
const matcher = createResolvedSecretMatcher(matches, {
4040
preserveNamedProvenanceLabels: true,
41+
mode: 'render',
4142
})
4243
if (!matcher) return undefined
4344

@@ -74,7 +75,7 @@ function createResolvedSecretModelMatcher(
7475
})),
7576
...opaquePlaceholderMatches,
7677
],
77-
{ preserveNamedProvenanceLabels: true }
78+
{ preserveNamedProvenanceLabels: true, mode: 'render' }
7879
)
7980
}
8081

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import {
6+
getResolvedSecretMatchPolicy,
7+
isWordBoundaryMatch,
8+
MIN_UNANCHORED_MATCH_LENGTH,
9+
} from '@/executor/utils/resolved-secret-match-policy'
10+
11+
describe('getResolvedSecretMatchPolicy', () => {
12+
it.each(['test', 'Test', '483920', 'hunter2', 'F', ''])(
13+
'restricts short value %s to boundary matches',
14+
(value) => {
15+
expect(value.length).toBeLessThan(MIN_UNANCHORED_MATCH_LENGTH)
16+
expect(getResolvedSecretMatchPolicy(value)).toBe('boundary')
17+
}
18+
)
19+
20+
it.each([
21+
['32-char hex', '5f4dcc3b5aa765d61d8327deb882cf99'],
22+
['base64 key', 'sk-proj-Ab3xK9mQ2pLw7nRt5vYc8Zd4'],
23+
['github pat', 'ghp_16C7e42F292c6912E7710c838347Ae178B4a'],
24+
['slack bot token', 'xoxb-2334-4567-abcdefGHIJKL'],
25+
['9-digit value', '123456789'],
26+
['8-char password', 'Passw0rd'],
27+
])('allows unanchored matching for a %s', (_label, value) => {
28+
expect(getResolvedSecretMatchPolicy(value)).toBe('anywhere')
29+
})
30+
31+
/**
32+
* Every one of these scores below 3.0 bits/char; an entropy floor would have demoted them.
33+
* Prefixed shapes are assembled at runtime so the source carries no literal that reads as a
34+
* live credential to a secret scanner.
35+
*/
36+
it.each([
37+
['all-f HMAC key', 'f'.repeat(32)],
38+
['test PAN', '4111111111111111'],
39+
['padded AWS key id', `AKIA${'0'.repeat(16)}`],
40+
['repeated-block hex', 'deadbeefdeadbeefdeadbeefdeadbeef'],
41+
['padded stripe-style key', `sk_live_${'0'.repeat(24)}`],
42+
['padded PAT', `ghp_${'a'.repeat(36)}`],
43+
])('keeps unanchored matching for a low-variety full-length %s', (_label, value) => {
44+
expect(getResolvedSecretMatchPolicy(value)).toBe('anywhere')
45+
})
46+
})
47+
48+
describe('isWordBoundaryMatch', () => {
49+
it.each([
50+
['test', 0, 4, true],
51+
['key=test', 4, 8, true],
52+
['"test"', 1, 5, true],
53+
['{"k":"test"}', 6, 10, true],
54+
['test ok', 0, 4, true],
55+
['latest', 2, 6, false],
56+
['tested', 0, 4, false],
57+
['prefixtest', 6, 10, false],
58+
])('anchors %s at [%i,%i) => %s', (value, start, end, expected) => {
59+
expect(isWordBoundaryMatch(value, start, end)).toBe(expected)
60+
})
61+
62+
it.each([
63+
['user_test_id', 5, 9],
64+
['sk_live_test', 8, 12],
65+
['test_suffix', 0, 4],
66+
])('anchors %s across an underscore, the dominant identifier joiner', (value, start, end) => {
67+
expect(isWordBoundaryMatch(value, start, end)).toBe(true)
68+
})
69+
70+
it('treats non-ASCII letters as word characters', () => {
71+
expect(isWordBoundaryMatch('прtestка', 2, 6)).toBe(false)
72+
})
73+
74+
it('treats astral-plane letters as word characters', () => {
75+
expect(isWordBoundaryMatch('\u{1D400}test\u{1D401}', 2, 6)).toBe(false)
76+
expect(isWordBoundaryMatch('x\u{20000}test\u{20000}y', 3, 7)).toBe(false)
77+
})
78+
79+
it('keeps a combining mark attached to the word it decorates', () => {
80+
expect(isWordBoundaryMatch('test́ing', 0, 4)).toBe(false)
81+
})
82+
83+
it('anchors a match whose own edge characters are not word characters', () => {
84+
expect(isWordBoundaryMatch('a!!!!b', 1, 5)).toBe(true)
85+
})
86+
87+
it('reads an out-of-range probe as a non-word character rather than a match', () => {
88+
expect(isWordBoundaryMatch('abc', 0, 3)).toBe(true)
89+
expect(isWordBoundaryMatch('abc', 3, 3)).toBe(true)
90+
})
91+
})
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/**
2+
* Decides how a known secret literal is allowed to match inside a larger string.
3+
*
4+
* The matcher knows every secret's exact bytes, so this is not detection — it is the narrower
5+
* question of whether a substring hit is distinctive enough to be attributed to the secret rather
6+
* than to coincidence. A four-character value such as `test` occurs inside ordinary words; an
7+
* eight-character one effectively does not.
8+
*/
9+
10+
/**
11+
* `'anywhere'` substitutes a hit at any offset.
12+
*
13+
* `'boundary'` substitutes a hit only when it sits on a word boundary, so a short literal can still
14+
* be replaced when it stands alone (`test`), is delimited (`key=test`, `"test"`, `user_test`), or is
15+
* the whole value, but cannot rewrite the interior of an unrelated token (`latest`).
16+
*/
17+
export type ResolvedSecretMatchPolicy = 'anywhere' | 'boundary'
18+
19+
/**
20+
* Shortest literal that may be substituted at an arbitrary offset inside surrounding text.
21+
*
22+
* Length, not randomness, is what makes a coincidental hit implausible. Shannon entropy measured
23+
* over a literal's own character distribution answers "is this string internally varied", which is
24+
* not the same question and misfires badly on real credentials: an all-`f` 32-character HMAC key
25+
* scores 0.00 bits/char, a zero-padded card number scores 0.34, a zero-padded AWS key id scores
26+
* 1.02, and a zero-padded `sk_live_` key scores 1.50 — every one of them a full-length secret that
27+
* an entropy floor would demote. Sampling confirms the same for genuinely random values, where the
28+
* finite-sample bias of a short string drags the estimate down: at a 3.0 bits/char floor, 46% of
29+
* random 12-character hex, 74% of random 16-digit numerics, and 99% of 9-digit values fall below it.
30+
*
31+
* Eight is chosen because every false positive observed in practice came from a value of seven
32+
* characters or fewer, and because a literal that short is the only kind that plausibly appears
33+
* inside unrelated log text by accident. Values below the floor are still substituted — they just
34+
* have to land on a word boundary, which covers standing alone, delimited, and whole-value cases.
35+
*/
36+
export const MIN_UNANCHORED_MATCH_LENGTH = 8
37+
38+
/**
39+
* Combining marks count so a substitution cannot split a grapheme cluster. `_` deliberately does
40+
* NOT: `sk_live_...` and `user_483920_profile` are the dominant way a secret gets joined into an
41+
* identifier, and treating `_` as a word character would suppress those hits entirely.
42+
*/
43+
const WORD_CHARACTER = /[\p{L}\p{N}\p{M}]/u
44+
45+
/** Classifies one secret literal by whether a hit on it could plausibly be a coincidence. */
46+
export function getResolvedSecretMatchPolicy(plaintext: string): ResolvedSecretMatchPolicy {
47+
return plaintext.length >= MIN_UNANCHORED_MATCH_LENGTH ? 'anywhere' : 'boundary'
48+
}
49+
50+
/**
51+
* Reads the whole code point occupying `index`, including when `index` addresses the trailing half
52+
* of a surrogate pair. Returns undefined out of range, which callers treat as "not a word
53+
* character" so an out-of-bounds probe widens the match rather than suppressing it.
54+
*/
55+
function codePointAt(value: string, index: number): number | undefined {
56+
if (index < 0 || index >= value.length) return undefined
57+
const code = value.codePointAt(index)
58+
if (code !== undefined && code >= 0xdc00 && code <= 0xdfff && index > 0) {
59+
const paired = value.codePointAt(index - 1)
60+
if (paired !== undefined && paired > 0xffff) return paired
61+
}
62+
return code
63+
}
64+
65+
function isWordCharacter(value: string, index: number): boolean {
66+
const code = codePointAt(value, index)
67+
if (code === undefined) return false
68+
if (code < 0x80) {
69+
return (code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122)
70+
}
71+
return WORD_CHARACTER.test(String.fromCodePoint(code))
72+
}
73+
74+
/**
75+
* True when the span `[start, end)` is not spliced into the middle of a surrounding word.
76+
*
77+
* A boundary exists wherever two adjacent characters are not both word characters, which is the
78+
* generalization of a regex `\b` to a span. `key=test` and `"test"` are anchored because `=` and
79+
* `"` are not word characters; `latest` is not, because `a` and `t` both are. A whole-value match
80+
* is anchored by the string edges, so an exact value is always replaceable regardless of policy.
81+
*/
82+
export function isWordBoundaryMatch(value: string, start: number, end: number): boolean {
83+
const startsWord = isWordCharacter(value, start - 1) && isWordCharacter(value, start)
84+
const endsWord = isWordCharacter(value, end) && isWordCharacter(value, end - 1)
85+
return !startsWord && !endsWord
86+
}
87+
88+
/** True when a hit at `[start, end)` may be substituted under `policy`. Omitted policy is wide. */
89+
export function satisfiesResolvedSecretMatchPolicy(
90+
value: string,
91+
start: number,
92+
end: number,
93+
policy: ResolvedSecretMatchPolicy | undefined
94+
): boolean {
95+
return policy !== 'boundary' || isWordBoundaryMatch(value, start, end)
96+
}

apps/sim/executor/utils/resolved-secret-matcher.test.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
*/
44
import { describe, expect, it } from 'vitest'
55
import {
6+
type CreateResolvedSecretMatcherOptions,
67
containsResolvedSecret,
78
createResolvedSecretMatcher,
89
OPAQUE_RESOLVED_SECRET_REPLACEMENT,
10+
type ResolvedSecretMatch,
11+
type ResolvedSecretMatcher,
912
sanitizeResolvedSecretPrimitive,
1013
sanitizeResolvedSecretString,
1114
scanResolvedSecretString,
@@ -192,3 +195,176 @@ describe('resolved secret matcher', () => {
192195
expect(sanitizeResolvedSecretString('Test', matcher)).toBe('')
193196
})
194197
})
198+
199+
describe('resolved secret matcher match policy', () => {
200+
const SHORT = [{ plaintext: 'test', replacement: '{{TOKEN}}' }]
201+
const API_KEY = 'sk-proj-Ab3xK9mQ2pLw7nRt5vYc8Zd4'
202+
const LONG = [{ plaintext: API_KEY, replacement: '{{API_KEY}}' }]
203+
204+
function build(
205+
matches: ResolvedSecretMatch[],
206+
options?: CreateResolvedSecretMatcherOptions
207+
): ResolvedSecretMatcher {
208+
const matcher = createResolvedSecretMatcher(matches, options)
209+
if (!matcher) throw new Error('expected a matcher')
210+
return matcher
211+
}
212+
213+
it('matches a short literal anywhere when classifying content', () => {
214+
const matcher = build(SHORT)
215+
216+
expect(containsResolvedSecret('the latest news', matcher)).toBe(true)
217+
expect(sanitizeResolvedSecretString('the latest news', matcher)).toBe('the la{{TOKEN}} news')
218+
})
219+
220+
it.each([
221+
['test', '{{TOKEN}}'],
222+
['key=test', 'key={{TOKEN}}'],
223+
['"test"', '"{{TOKEN}}"'],
224+
['{"k":"test"}', '{"k":"{{TOKEN}}"}'],
225+
['test test', '{{TOKEN}} {{TOKEN}}'],
226+
['user_test_id', 'user_{{TOKEN}}_id'],
227+
])('still renders a boundary-anchored short literal in %s', (value, expected) => {
228+
const matcher = build(SHORT, { mode: 'render' })
229+
230+
expect(sanitizeResolvedSecretString(value, matcher)).toBe(expected)
231+
expect(containsResolvedSecret(value, matcher)).toBe(true)
232+
})
233+
234+
it.each(['the latest news', 'tested', 'prefixtest'])(
235+
'leaves an unanchored short literal in %s untouched when rendering',
236+
(value) => {
237+
const matcher = build(SHORT, { mode: 'render' })
238+
239+
expect(sanitizeResolvedSecretString(value, matcher)).toBe(value)
240+
expect(containsResolvedSecret(value, matcher)).toBe(false)
241+
}
242+
)
243+
244+
it('renders a full-length literal at any offset, including mid-token', () => {
245+
const matcher = build(LONG, { mode: 'render' })
246+
247+
expect(sanitizeResolvedSecretString(`prefix${API_KEY}suffix`, matcher)).toBe(
248+
'prefix{{API_KEY}}suffix'
249+
)
250+
expect(containsResolvedSecret(`prefix${API_KEY}suffix`, matcher)).toBe(true)
251+
})
252+
253+
/** Prefixed shapes are assembled at runtime so no source literal reads as a live credential. */
254+
it.each([
255+
['f'.repeat(32), 'all-f HMAC key'],
256+
['4111111111111111', 'test PAN'],
257+
[`AKIA${'0'.repeat(16)}`, 'padded AWS key id'],
258+
[`sk_live_${'0'.repeat(24)}`, 'padded stripe-style key'],
259+
])('renders low-variety full-length credential (%s) mid-token', (secret) => {
260+
const matcher = build([{ plaintext: secret, replacement: '{{KEY}}' }], { mode: 'render' })
261+
262+
expect(sanitizeResolvedSecretString(`etag_${secret}x`, matcher)).toBe('etag_{{KEY}}x')
263+
expect(containsResolvedSecret(`etag_${secret}x`, matcher)).toBe(true)
264+
})
265+
266+
it('settles a boundary that an earlier substitution exposed', () => {
267+
const matcher = build(
268+
[
269+
{ plaintext: API_KEY, replacement: '{{API_KEY}}' },
270+
{ plaintext: 'test', replacement: '{{TOKEN}}' },
271+
],
272+
{ mode: 'render' }
273+
)
274+
275+
expect(sanitizeResolvedSecretString(`${API_KEY}test`, matcher)).toBe('{{API_KEY}}{{TOKEN}}')
276+
})
277+
278+
it('settles a literal that an empty replacement spliced into existence', () => {
279+
const matcher = build([
280+
{ plaintext: API_KEY, replacement: '' },
281+
{ plaintext: 'password', replacement: '{{PW}}' },
282+
])
283+
284+
expect(sanitizeResolvedSecretString(`pass${API_KEY}word`, matcher)).toBe('{{PW}}')
285+
})
286+
287+
it('keeps the substitution pass and its invariant in agreement', () => {
288+
const matcher = build(SHORT, { mode: 'render' })
289+
290+
for (const value of ['the latest news', 'key=test', 'contest testable test']) {
291+
const sanitized = sanitizeResolvedSecretString(value, matcher)
292+
expect(containsResolvedSecret(sanitized, matcher)).toBe(false)
293+
}
294+
})
295+
296+
it('reports a suppressed match to provenance callbacks so detection stays conservative', () => {
297+
const matcher = build(SHORT, { mode: 'render' })
298+
const matches: string[] = []
299+
300+
expect(
301+
sanitizeResolvedSecretString('the latest news', matcher, undefined, (plaintext) =>
302+
matches.push(plaintext)
303+
)
304+
).toBe('the latest news')
305+
expect(matches).toEqual(['test'])
306+
})
307+
308+
it.each([
309+
['Test', '{{Test}}'],
310+
['{{Test}}', '{{Test}}'],
311+
['Test {{Test}} Test', '{{Test}} {{Test}} {{Test}}'],
312+
['laTest news', 'laTest news'],
313+
])('preserves named provenance labels under the render policy for %s', (value, expected) => {
314+
const matcher = build([{ plaintext: 'Test', replacement: '{{Test}}' }], {
315+
...PRESERVE_NAMED_PROVENANCE,
316+
mode: 'render',
317+
})
318+
319+
expect(sanitizeResolvedSecretString(value, matcher)).toBe(expected)
320+
})
321+
322+
it('keeps the protected-placeholder behaviours under the options production uses', () => {
323+
const composite = build(
324+
[
325+
{ plaintext: 'x{{Test}}y', replacement: '{{COMPOSITE}}' },
326+
{ plaintext: 'Test', replacement: '{{Test}}' },
327+
],
328+
{ ...PRESERVE_NAMED_PROVENANCE, mode: 'render' }
329+
)
330+
expect(sanitizeResolvedSecretString('x{{Test}}y', composite)).toBe('{{COMPOSITE}}')
331+
332+
const malformed = build([{ plaintext: 'Test', replacement: '{{Test{B}}}' }], {
333+
...PRESERVE_NAMED_PROVENANCE,
334+
mode: 'render',
335+
})
336+
expect(sanitizeResolvedSecretString('Test', malformed)).toBe(OPAQUE_RESOLVED_SECRET_REPLACEMENT)
337+
338+
const chained = build(
339+
[
340+
{ plaintext: 'Test', replacement: 'visible-Test' },
341+
{ plaintext: 'REDACTED', replacement: '{{OTHER}}' },
342+
],
343+
{ mode: 'render' }
344+
)
345+
expect(sanitizeResolvedSecretString('Test', chained)).toBe('')
346+
})
347+
348+
it('keeps exact replacement available below the length floor', () => {
349+
const matcher = build([{ plaintext: '23', replacement: '{{TOKEN}}' }], { mode: 'render' })
350+
351+
expect(sanitizeResolvedSecretPrimitive('23', matcher)).toBe('{{TOKEN}}')
352+
expect(sanitizeResolvedSecretString('23', matcher)).toBe('{{TOKEN}}')
353+
expect(sanitizeResolvedSecretString('123', matcher)).toBe('123')
354+
})
355+
356+
it('builds a matcher for an astral-plane literal instead of failing construction', () => {
357+
const secret = 'k\u{1F600}ey12345'
358+
const matcher = build([{ plaintext: secret, replacement: '{{EMOJI}}' }], { mode: 'render' })
359+
360+
expect(sanitizeResolvedSecretString(`token ${secret} end`, matcher)).toBe('token {{EMOJI}} end')
361+
})
362+
363+
it('does not rewrite a token interior next to an astral-plane letter', () => {
364+
const matcher = build(SHORT, { mode: 'render' })
365+
366+
expect(sanitizeResolvedSecretString('\u{1D400}test\u{1D401}', matcher)).toBe(
367+
'\u{1D400}test\u{1D401}'
368+
)
369+
})
370+
})

0 commit comments

Comments
 (0)