diff --git a/apps/sim/blocks/blocks/guardrails.ts b/apps/sim/blocks/blocks/guardrails.ts index 1b893243951..6b857f690f8 100644 --- a/apps/sim/blocks/blocks/guardrails.ts +++ b/apps/sim/blocks/blocks/guardrails.ts @@ -76,7 +76,11 @@ export const GuardrailsBlock: BlockConfig = { enabled: true, prompt: `Generate a regular expression pattern based on the user's description. The regex should be: -- Valid JavaScript regex syntax +- Valid RE2 syntax: no lookahead ((?=...), (?!...)), no lookbehind ((?<=...), (? { expect(container.textContent).toMatch(/Invalid regex/) }) - it('shows an inline error for a catastrophic-backtracking pattern', () => { - renderEditor([row('(a+)+$')], vi.fn()) - expect(container.textContent).toMatch(/potentially unsafe/) - }) + it.each(['(a+)+$', '(?<=id: )\\w+', '(?:https?://)?example\\.com'])( + 'accepts %s, which the removed safe-regex2 screen rejected', + (pattern) => { + // The editor no longer runs a catastrophic-backtracking screen. It caught + // `(a+)+$` but passed `a*a*b`, so it deterred only the obvious spelling + // while blocking valid Presidio patterns like lookbehind. These patterns + // run in Presidio, not in this process. + renderEditor([row(pattern)], vi.fn()) + expect(container.textContent).not.toMatch(/potentially unsafe/) + expect(container.textContent).not.toMatch(/Invalid regex/) + } + ) it('appends an empty row when "Add pattern" is clicked', () => { const onChange = vi.fn() diff --git a/apps/sim/components/pii/custom-patterns-editor.tsx b/apps/sim/components/pii/custom-patterns-editor.tsx index 23ba59f658e..4be1204f5b0 100644 --- a/apps/sim/components/pii/custom-patterns-editor.tsx +++ b/apps/sim/components/pii/custom-patterns-editor.tsx @@ -15,9 +15,13 @@ interface CustomPatternsEditorProps { /** * Editor for user-supplied custom regex patterns. Each row is a name label, the - * regex (validated inline for syntax + catastrophic-backtracking safety), and the - * verbatim replacement token that matches are redacted to. Shared by the Data - * Retention settings and any other PII-policy surface. + * regex (validated inline for syntax only), and the verbatim replacement token + * that matches are redacted to. Shared by the Data Retention settings and any + * other PII-policy surface. + * + * Syntax is the only check because these patterns execute in Presidio, not in + * this process — see `validateRegexPattern` for why a backtracking screen here + * was removed rather than kept. */ export function CustomPatternsEditor({ patterns, onChange }: CustomPatternsEditorProps) { function updateRow(index: number, patch: Partial) { diff --git a/apps/sim/lib/api/contracts/primitives.test.ts b/apps/sim/lib/api/contracts/primitives.test.ts index 82e5e425140..727a42a65f1 100644 --- a/apps/sim/lib/api/contracts/primitives.test.ts +++ b/apps/sim/lib/api/contracts/primitives.test.ts @@ -35,10 +35,22 @@ describe('customPatternSchema', () => { } }) - it('rejects a catastrophic-backtracking regex at the boundary', () => { - expect( - customPatternSchema.safeParse({ name: 'evil', regex: '(a+)+$', replacement: '' }).success - ).toBe(false) + it('no longer screens for catastrophic backtracking, which never worked here', () => { + // This used to reject `(a+)+$` via `safe-regex2`. The screen was removed: + // it caught that shape but passed `a*a*b`, which is just as catastrophic, + // so it only ever deterred the obvious spelling of a misconfiguration a + // user can inflict on their own workspace. It also rejected valid patterns + // (lookbehind, optional groups) that Presidio accepts. + // + // These patterns run in Presidio, not in this process, so they cannot + // stall this event loop; Presidio's own timeout is the bound. In-process + // matching uses `compileLinearRegex`, which cannot backtrack at all. + for (const regex of ['(a+)+$', 'a*a*b', '(?<=id: )\\w+']) { + expect( + customPatternSchema.safeParse({ name: 'p', regex, replacement: '' }).success, + `pattern ${regex} should be accepted` + ).toBe(true) + } }) }) diff --git a/apps/sim/lib/chunkers/regex-chunker.test.ts b/apps/sim/lib/chunkers/regex-chunker.test.ts index f4f55112e55..96d84cc5ed4 100644 --- a/apps/sim/lib/chunkers/regex-chunker.test.ts +++ b/apps/sim/lib/chunkers/regex-chunker.test.ts @@ -387,5 +387,39 @@ describe('RegexChunker', () => { ) } }) + it.concurrent( + 'chunks with a catastrophic pattern without hanging', + async () => { + // `a*a*b` defeats every syntactic backtracking screen. The previous + // guard probed it against 'a'.repeat(10000) and measured the elapsed + // time only after the match returned, so the guard itself hung for + // ~213s. RE2 has no backtracking, so this simply completes. + const chunker = new RegexChunker({ pattern: 'a*a*b', chunkSize: 100, chunkOverlap: 0 }) + + const start = Date.now() + await chunker.chunk(`${'a'.repeat(10000)}!`) + + expect(Date.now() - start).toBeLessThan(2000) + }, + 10000 + ) + + it.concurrent('still supports lookahead split patterns', async () => { + // RE2 has no lookaround; compileLookaroundSplit expresses this as slicing + // at each match start, so the idiom keeps working on the linear engine. + // (No `m` flag is applied, so anchor the lookahead on the delimiter + // itself rather than on `^`.) + const chunker = new RegexChunker({ + pattern: '(?=#\\s)', + chunkSize: 1024, + chunkOverlap: 0, + strictBoundaries: true, + }) + const chunks = await chunker.chunk('# One\nalpha\n# Two\nbeta') + + expect(chunks.length).toBe(2) + expect(chunks[0].text).toContain('# One') + expect(chunks[1].text).toContain('# Two') + }) }) }) diff --git a/apps/sim/lib/chunkers/regex-chunker.ts b/apps/sim/lib/chunkers/regex-chunker.ts index 0cafa47b0d3..b294a504eee 100644 --- a/apps/sim/lib/chunkers/regex-chunker.ts +++ b/apps/sim/lib/chunkers/regex-chunker.ts @@ -10,6 +10,11 @@ import { splitAtWordBoundaries, tokensToChars, } from '@/lib/chunkers/utils' +import { + compileLinearRegex, + compileLookaroundSplit, + type LinearRegex, +} from '@/lib/core/security/linear-regex' const logger = createLogger('RegexChunker') @@ -56,7 +61,7 @@ function toNonCapturing(pattern: string): string { export class RegexChunker { private readonly chunkSize: number private readonly chunkOverlap: number - private readonly regex: RegExp + private readonly regex: LinearRegex private readonly strictBoundaries: boolean constructor(options: RegexChunkerOptions) { @@ -67,7 +72,24 @@ export class RegexChunker { this.strictBoundaries = options.strictBoundaries ?? false } - private compilePattern(pattern: string): RegExp { + /** + * Compile the caller's split pattern on an engine that cannot backtrack. + * + * This previously screened for catastrophic backtracking by running the + * pattern against six probe strings — including `'a'.repeat(10000)` — and + * rejecting anything slower than 50ms. It measured the elapsed time *after* + * the match returned, so the screen was the denial of service it existed to + * prevent: `a*a*b` against that probe measured 213s on JSC. RE2 removes the + * failure mode outright, so the probe is gone rather than repaired. + * + * Keeping the delimiter — `(?=X)` before a chunk, `(?<=X)` after one — is the + * reason a split pattern reaches for lookaround, and `compileLookaroundSplit` + * runs both on RE2 without it. Anything else RE2 cannot represent is rejected + * rather than run on the built-in engine: no probe can tell a safe pattern + * from an unsafe one without running it, which is what made the old guard + * hang, so there is nothing to fall back *to*. + */ + private compilePattern(pattern: string): LinearRegex { if (!pattern) { throw new Error('Regex pattern is required') } @@ -77,34 +99,18 @@ export class RegexChunker { } try { - const regex = new RegExp(toNonCapturing(pattern), 'g') - - const testStrings = [ - 'a'.repeat(10000), - ' '.repeat(10000), - 'a '.repeat(5000), - 'aB1 xY2\n'.repeat(1250), - `${'a'.repeat(30)}!`, - `${'a b '.repeat(25)}!`, - ] - for (const testStr of testStrings) { - regex.lastIndex = 0 - const start = Date.now() - regex.test(testStr) - const elapsed = Date.now() - start - if (elapsed > 50) { - throw new Error('Regex pattern appears to have catastrophic backtracking') - } - } - - regex.lastIndex = 0 - return regex + new RegExp(pattern) } catch (error) { - if (error instanceof Error && error.message.includes('catastrophic')) { - throw error - } throw new Error(`Invalid regex pattern "${pattern}": ${toError(error).message}`) } + + const source = toNonCapturing(pattern) + const compiled = compileLinearRegex(source) ?? compileLookaroundSplit(source) + if (compiled) return compiled + + throw new Error( + `Regex pattern "${pattern}" uses syntax that cannot be evaluated safely. Unsupported: negative lookaround ("(?!...)", "(? { @@ -119,8 +125,7 @@ export class RegexChunker { return buildChunks([cleaned], 0) } - this.regex.lastIndex = 0 - const segments = cleaned.split(this.regex).filter((s) => s.trim().length > 0) + const segments = this.regex.split(cleaned).filter((s) => s.trim().length > 0) if (segments.length <= 1) { if (this.strictBoundaries) { diff --git a/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts b/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts index 4e4af600e85..9ab073f7dd8 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/query-logs.ts @@ -145,12 +145,17 @@ export const queryLogsServerTool: BaseServerTool = { if (args.pattern) { logger.info('query_logs grep', { workspaceId, executionId: args.executionId }) - const { matches, truncated } = await grepSpans(traceSpans, args.pattern, viewCtx) + const { matches, truncated, patternNotice } = await grepSpans( + traceSpans, + args.pattern, + viewCtx + ) return { executionId: detail.executionId, workflowId: detail.workflowId, status: detail.status, pattern: args.pattern, + ...(patternNotice ? { patternNotice } : {}), matches, truncated, } diff --git a/apps/sim/lib/copilot/vfs/operations.test.ts b/apps/sim/lib/copilot/vfs/operations.test.ts index 32d1ad39007..e626b702842 100644 --- a/apps/sim/lib/copilot/vfs/operations.test.ts +++ b/apps/sim/lib/copilot/vfs/operations.test.ts @@ -138,3 +138,35 @@ describe('grep', () => { expect(hits).toHaveLength(1) }) }) + +describe('grep regex safety', () => { + it('runs a catastrophic pattern in linear time', () => { + // `a*a*b` takes minutes on a backtracking engine against this content; + // both the pattern and the file content are caller-supplied. + const files = vfsFromEntries([['notes.md', `${'a'.repeat(10000)}!`]]) + + const start = Date.now() + grep(files, 'a*a*b') + + expect(Date.now() - start).toBeLessThan(2000) + }) + + it('still interprets regex syntax', () => { + const files = vfsFromEntries([['log.txt', 'req finished status=503']]) + expect(grep(files, 'status=\\d+')).toHaveLength(1) + expect(grep(files, 'status=\\d+', undefined, { outputMode: 'count' })).toEqual([ + { path: 'log.txt', count: 1 }, + ]) + }) + + it('matches syntax RE2 cannot represent literally instead of not at all', () => { + const files = vfsFromEntries([['log.txt', 'contains (?=x) verbatim']]) + expect(grep(files, '(?=x)')).toHaveLength(1) + }) + + it('honours ignoreCase across repeated line tests', () => { + const files = vfsFromEntries([['log.txt', 'Alpha\nALPHA\nalpha']]) + expect(grep(files, 'alpha', undefined, { ignoreCase: true })).toHaveLength(3) + expect(grep(files, 'alpha')).toHaveLength(1) + }) +}) diff --git a/apps/sim/lib/copilot/vfs/operations.ts b/apps/sim/lib/copilot/vfs/operations.ts index bd7208719b8..624707b43e9 100644 --- a/apps/sim/lib/copilot/vfs/operations.ts +++ b/apps/sim/lib/copilot/vfs/operations.ts @@ -1,4 +1,13 @@ +import { createLogger } from '@sim/logger' import micromatch from 'micromatch' +import { + compileLinearRegex, + isPlainText, + type LinearRegex, + literalRegex, +} from '@/lib/core/security/linear-regex' + +const logger = createLogger('VfsOperations') export interface GrepMatch { path: string @@ -125,7 +134,16 @@ export function pathWithinGrepScope(filePath: string, scope: string): boolean { } /** - * Regex search over VFS file contents using ECMAScript `RegExp` syntax. + * Regex search over VFS file contents using RE2 syntax — a subset of + * ECMAScript `RegExp` (see `@/lib/core/security/linear-regex`). + * + * A pattern RE2 cannot represent — negative lookaround, backreferences — is + * matched as a literal instead of on the backtracking engine, as is a pattern + * that does not compile at all (which previously returned no results). Both + * cases log a warning: the return shape carries results only, so there is + * nowhere to tell the caller inline, and a literal fallback can match the + * pattern's own text when grepping source that contains regexes. + * * `content` and `count` are line-oriented (split on newline, CR stripped per line). * `files_with_matches` tests the entire file string once, so multiline patterns can match there * but not in line modes. @@ -142,19 +160,27 @@ export function grep( const showLineNumbers = opts?.lineNumbers ?? true const contextLines = opts?.context ?? 0 - const flags = ignoreCase ? 'gi' : 'g' - let regex: RegExp - try { - regex = new RegExp(pattern, flags) - } catch { - return [] + // Caller-supplied pattern over caller-supplied file content on the shared + // event loop — matched by RE2 so it cannot backtrack. Syntax RE2 cannot + // represent degrades to a literal rather than to the backtracking engine. + let regex: LinearRegex + if (isPlainText(pattern)) { + regex = literalRegex(pattern, { ignoreCase }) + } else { + const linear = compileLinearRegex(pattern, { ignoreCase }) + if (!linear) { + // The return shape carries results only, so the caller cannot be told + // inline that its regex was taken literally — log it, since silently + // returning "no matches" reads as "not in the file". + logger.warn('Grep pattern is not RE2-representable; matching it literally', { pattern }) + } + regex = linear ?? literalRegex(pattern, { ignoreCase }) } if (outputMode === 'files_with_matches') { const matchingFiles: string[] = [] for (const [filePath, content] of files) { if (path && !pathWithinGrepScope(filePath, path)) continue - regex.lastIndex = 0 if (regex.test(content)) { matchingFiles.push(filePath) if (matchingFiles.length >= maxResults) break @@ -170,7 +196,6 @@ export function grep( const lines = splitLinesForGrep(content) let count = 0 for (const line of lines) { - regex.lastIndex = 0 if (regex.test(line)) count++ } if (count > 0) { @@ -188,7 +213,6 @@ export function grep( const lines = splitLinesForGrep(content) for (let i = 0; i < lines.length; i++) { - regex.lastIndex = 0 if (regex.test(lines[i])) { if (contextLines > 0) { const start = Math.max(0, i - contextLines) diff --git a/apps/sim/lib/core/security/linear-regex.differential.test.ts b/apps/sim/lib/core/security/linear-regex.differential.test.ts new file mode 100644 index 00000000000..02c5abbf516 --- /dev/null +++ b/apps/sim/lib/core/security/linear-regex.differential.test.ts @@ -0,0 +1,241 @@ +/** + * @vitest-environment node + * + * Differential test: every pattern the linear engine accepts must behave like + * the built-in engine. + * + * Every defect found in this module has been a silent semantic divergence, not + * a crash — `\s` losing Unicode whitespace, a decomposed lookaround binding to + * the wrong alternation branch, a consumed lookahead swallowing boundaries. + * Each was caught by comparing engines over a corpus, and each would have + * shipped otherwise, because the code was self-consistent and every + * hand-written example passed. + * + * So the comparison lives here rather than in a scratch script. Any future + * change to `translateToRe2` or the split decomposition is checked against + * `RegExp` across the corpus below, with the known divergences enumerated + * explicitly — a divergence that is not on that list is a bug. + */ + +import { describe, expect, it } from 'vitest' +import { compileLinearRegex, compileLookaroundSplit } from '@/lib/core/security/linear-regex' + +/** How a caller compiles: linear engine first, split decomposition second. */ +function compile(pattern: string) { + return compileLinearRegex(pattern) ?? compileLookaroundSplit(pattern) +} + +/** + * Delimiters people actually write, plus the shapes that have broken before. + * Anything here that the linear engine accepts must split like `RegExp`. + */ +const SPLIT_PATTERNS = [ + // Plain delimiters + '\\n\\n', + '\\n\\n+', + '\\s+', + '\\s{2,}', + '\\.\\s+', + '[.!?]\\s+', + '---+', + '\\|', + ',\\s*', + '\\t', + // Structural + '\\n#{1,6}\\s', + '', + '', + '\\r?\\n', + // Lookaround: keep the delimiter + '(?=#\\s)', + '(?=#{1,6}\\s)', + '(?<=\\.)', + '(?<=)', + '(?<=\\.)\\s+', + '(?<=[.!?])\\s+', + '(?<=[.!?])\\s+(?=[A-Z])', + '(?<=\\w)\\s+(?=[A-Z])', + '(?<=\\w)\\s+(?=\\w)', + '\\n(?=Chapter )', + '\\n(?=\\d+\\.)', + '(?<=;)\\s*', + // Top-level alternation beside an assertion: must be declined, never + // reshaped — `(?<=X)A|B` is not `(?:X)(A|B)`. + '(?<=\\.)\\s+|\\n\\n', + '(?<=

)|
', + 'a|b(?=c)', + '\\n\\n|(?<=\\.)\\s', + // Quantifier and class shapes + '[-=]{3,}', + '\\s*\\n\\s*\\n\\s*', + '(?:\\r\\n|\\n){2}', + '[\\s\\u00b7]+', +] + +/** + * Documents chosen to exercise the divergences that have bitten: non-ASCII + * whitespace from PDF/HTML extraction, CJK, and adjacent delimiters. + */ +const DOCUMENTS = [ + 'Section one\n\nSection two\n\nSection three', + 'One. Two. Three. Four.', + 'A B C D', + 'Heading\n\nAlpha beta.\nGamma delta.', + '# One\nalpha\n## Two\nbeta\n### Three', + 'onetwothree', + 'Chapter 1\nintro\nChapter 2\nmore', + // Non-breaking and exotic whitespace — the stored-data divergence + 'Section 1. Overview The agreement', + 'Bullet one  Bullet two  Bullet three', + 'Prix : 100 EUR. Livraison offerte.', + '第一章 概要 第二章 詳細', + 'line
separator
paragraph', + 'tab\tseparated\tvalues', + // Degenerate + '', + ' ', + '\n\n\n', + 'nodelimiterhere', + 'a,b,', + 'trailing delimiter.\n\n', + 'Heading\n\nAlpha beta.\nGamma delta.\n\nMore.', + 'one

two
three', + 'zabzabz', +] + +/** + * Divergences that are known, documented on the API, and accepted. + * + * Keep this list short and specific — it is the honest boundary of the + * guarantee, so every entry needs a reason, not just a pattern. + */ +const KNOWN_DIVERGENCES: Array<{ pattern: string; doc: string; reason: string }> = [ + { + pattern: '(?<=aa)', + doc: 'aaaaa', + reason: + 'Lookbehind whose body self-overlaps. Matching every position would mean restarting the scan one character past each match start, which is quadratic on a multi-megabyte document and forfeits the linear guarantee this module exists for. Delimiters that do not self-overlap — punctuation, tags, whitespace — are exact.', + }, +] + +function isKnownDivergence(pattern: string): boolean { + return KNOWN_DIVERGENCES.some((entry) => entry.pattern === pattern) +} + +/** + * `LinearRegex.split` omits the trailing empty segment `RegExp` produces, and + * every caller filters empties anyway — so compare on the filtered forms. + */ +function normalize(segments: string[]): string[] { + return segments.filter((segment) => segment !== '') +} + +describe('differential: split parity with the built-in engine', () => { + const cases = SPLIT_PATTERNS.flatMap((pattern) => + DOCUMENTS.map((doc) => ({ pattern, doc })) + ).filter(({ pattern }) => !isKnownDivergence(pattern)) + + it(`covers ${cases.length} pattern/document pairs`, () => { + expect(cases.length).toBeGreaterThan(400) + }) + + it.each(SPLIT_PATTERNS.filter((pattern) => !isKnownDivergence(pattern)))( + 'splits %s identically to RegExp across every document', + (pattern) => { + const compiled = compile(pattern) + // A pattern the linear engine declines is handled by the caller (notice, + // literal fallback, or a thrown config error) — not a parity concern. + if (!compiled) return + + for (const doc of DOCUMENTS) { + expect( + normalize(compiled.split(doc)), + `pattern ${pattern} on ${JSON.stringify(doc)}` + ).toEqual(normalize(doc.split(new RegExp(pattern, 'g')))) + } + } + ) +}) + +describe('differential: test/find parity with the built-in engine', () => { + /** Grep-style patterns, where `test` and the match index are what matter. */ + const MATCH_PATTERNS = [ + 'timeout', + 'ECONNREFUSED', + 'status=\\d+', + 'status=5\\d\\d', + '^Agent', + 'agent$', + '(openai|anthropic)', + '\\bstatus\\b', + '\\d{4}-\\d{2}-\\d{2}', + '[Ee]xception', + 'https?://[^\\s"]+', + '\\$\\{[^}]*\\}', + 'block_\\d+.*output', + '\\s+$', + '^\\s*\\{.*\\}\\s*$', + 'a.*b.*c', + '.*', + '.+', + 'sk-[A-Za-z0-9]{20,}', + 'rate.?limit', + '\\w+@\\w+\\.\\w+', + '[0-9a-f]{8}-[0-9a-f]{4}', + '\\s', + '\\S+', + ] + + const HAYSTACKS = [ + 'Agent 1 called api.openai.com -> status=503 at 2026-01-01', + 'request timeout occurred after 30s', + 'ECONNREFUSED connecting to db', + 'user@example.com signed in', + 'sk-abcdefghijklmnopqrstuvwxyz012345', + 'value with non-breaking space', + '第一章 概要', + '{ "ok": true }', + '', + ' ', + ] + + it.each(MATCH_PATTERNS)('matches %s identically to RegExp', (pattern) => { + const compiled = compile(pattern) + if (!compiled) return + + const oracle = new RegExp(pattern) + for (const text of HAYSTACKS) { + const label = `pattern ${pattern} on ${JSON.stringify(text)}` + expect(compiled.test(text), label).toBe(oracle.test(text)) + + const match = oracle.exec(text) + expect(compiled.find(text), label).toBe(match ? match.index : -1) + } + }) + + it.each(MATCH_PATTERNS)('matches %s identically to RegExp when ignoring case', (pattern) => { + const compiled = compileLinearRegex(pattern, { ignoreCase: true }) + if (!compiled) return + + const oracle = new RegExp(pattern, 'i') + for (const text of HAYSTACKS) { + expect(compiled.test(text), `pattern ${pattern} on ${JSON.stringify(text)}`).toBe( + oracle.test(text) + ) + } + }) +}) + +describe('differential: every known divergence is still exactly as documented', () => { + // Pinned so the list cannot rot. If a divergence is silently fixed this + // fails and the entry must be deleted; if one spreads, the parity suites + // above fail. Either way the list stays honest about the real boundary. + it.each(KNOWN_DIVERGENCES)('$pattern still diverges on $doc — $reason', ({ pattern, doc }) => { + const compiled = compile(pattern) + expect(compiled).not.toBeNull() + + expect(normalize(compiled?.split(doc) ?? [])).not.toEqual( + normalize(doc.split(new RegExp(pattern, 'g'))) + ) + }) +}) diff --git a/apps/sim/lib/core/security/linear-regex.test.ts b/apps/sim/lib/core/security/linear-regex.test.ts new file mode 100644 index 00000000000..c6edab05414 --- /dev/null +++ b/apps/sim/lib/core/security/linear-regex.test.ts @@ -0,0 +1,200 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { + compileLinearRegex, + compileLookaroundSplit, + isPlainText, + literalRegex, +} from '@/lib/core/security/linear-regex' + +/** + * Patterns that take exponential time on a backtracking engine. `a*a*b` is the + * important one: it defeats `safe-regex2`'s star-height screen and a + * quantified-group screen alike, and measured 213s on JSC / 132s on V8 against + * the input below. + */ +const CATASTROPHIC = ['(a+)+$', '(a|a)*b', 'a*a*b', '(x+x+)+y', '(\\w+\\s?)*$', '^(\\d+)*$'] + +describe('compileLinearRegex', () => { + it.each(CATASTROPHIC)('matches %s in linear time on adversarial input', (pattern) => { + const regex = compileLinearRegex(pattern) + expect(regex).not.toBeNull() + + const adversarial = `${'a'.repeat(50000)}!` + const start = Date.now() + regex?.test(adversarial) + expect(Date.now() - start).toBeLessThan(2000) + }) + + it('interprets regex syntax rather than matching it literally', () => { + const regex = compileLinearRegex('status=\\d+') + expect(regex?.test('http status=503 here')).toBe(true) + expect(regex?.test('status=abc')).toBe(false) + expect(regex?.find('http status=503')).toBe(5) + }) + + it('honours ignoreCase only when asked', () => { + expect(compileLinearRegex('ERROR', { ignoreCase: true })?.test('an error here')).toBe(true) + expect(compileLinearRegex('ERROR')?.test('an error here')).toBe(false) + }) + + it('splits equivalently to String.prototype.split, minus the trailing empty', () => { + const doc = '# One\ntext a\n\n# Two\ntext b' + expect(compileLinearRegex('\\n\\n+')?.split(doc)).toEqual(doc.split(/\n\n+/g)) + + // The documented divergence: a trailing delimiter yields no empty tail. + expect(compileLinearRegex(',')?.split('a,b,')).toEqual(['a', 'b']) + expect('a,b,'.split(/,/g)).toEqual(['a', 'b', '']) + }) + + it.each([ + ['non-breaking space', '\u00a0'], + ['narrow no-break space', '\u202f'], + ['ideographic space', '\u3000'], + ['line separator', '\u2028'], + ['vertical tab', '\v'], + ])('treats %s as whitespace, matching the built-in engine', (_label, ws) => { + // RE2's own \\s is ASCII-only. Untranslated, a \\s document splitter stops + // splitting on the whitespace that PDF/HTML extraction emits, silently + // changing stored chunk boundaries. + const doc = `alpha${ws}beta` + expect(compileLinearRegex('\\s+')?.split(doc)).toEqual(doc.split(/\s+/g)) + expect(compileLinearRegex('\\s')?.test(doc)).toBe(true) + }) + + it.each([ + ['lookahead', '(?=foo)bar'], + ['lookbehind', '(?<=id: )\\w+'], + ['backreference', '(ab)\\1'], + ['invalid syntax', '('], + ])('returns null for %s so the caller must choose how to degrade', (_label, pattern) => { + expect(compileLinearRegex(pattern)).toBeNull() + }) + + it('returns -1 from find when there is no match', () => { + expect(compileLinearRegex('zzz')?.find('abc')).toBe(-1) + }) +}) + +describe('literalRegex', () => { + it('treats regex syntax as ordinary characters', () => { + const regex = literalRegex('a+b') + expect(regex.test('xxa+bxx')).toBe(true) + expect(regex.test('aaab')).toBe(false) + }) + + it('is unaffected by repeated calls (no lastIndex carry-over)', () => { + const regex = literalRegex('needle') + const text = 'needle here and needle again' + expect([regex.test(text), regex.test(text), regex.test(text)]).toEqual([true, true, true]) + expect([regex.find(text), regex.find(text)]).toEqual([0, 0]) + }) + + it('matches case-insensitively when asked', () => { + expect(literalRegex('Needle', { ignoreCase: true }).test('a NEEDLE')).toBe(true) + expect(literalRegex('Needle').test('a NEEDLE')).toBe(false) + }) +}) + +describe('isPlainText / escapeRegExp', () => { + it.each(['timeout', 'ECONNREFUSED', 'status=503', 'GET /api/logs'])( + 'treats %s as plain text', + (pattern) => expect(isPlainText(pattern)).toBe(true) + ) + + it.each(['example.com', 'a+b', '^x', '(a|b)', '[abc]'])( + 'treats %s as containing metacharacters', + (pattern) => expect(isPlainText(pattern)).toBe(false) + ) + + it.each(['.', '*', '+', '?', '^', '$', '{', '}', '(', ')', '|', '[', ']', '\\'])( + 'escapes %s so a literal pattern matches only itself', + (meta) => { + const regex = literalRegex(`a${meta}b`) + expect(regex.test(`a${meta}b`)).toBe(true) + // Under-escaping shows up here: an unescaped metacharacter would let the + // pattern match text that does not contain it verbatim. + expect(regex.test('aXb')).toBe(false) + expect(regex.test('ab')).toBe(false) + } + ) +}) + +describe('compileLookaroundSplit', () => { + it('splits before each delimiter for (?=X), matching String.split', () => { + const doc = '# One\nalpha\n# Two\nbeta' + expect(compileLookaroundSplit('(?=#\\s)')?.split(doc)).toEqual( + doc.split(/(?=#\s)/g).filter(Boolean) + ) + }) + + it('splits after each delimiter for (?<=X)', () => { + const doc = 'onetwothree' + expect(compileLookaroundSplit('(?<=)')?.split(doc)).toEqual([ + 'one', + 'two', + 'three', + ]) + }) + + it('stays linear on a catastrophic body', () => { + const regex = compileLookaroundSplit('(?=a*a*b)') + expect(regex).not.toBeNull() + + const start = Date.now() + regex?.split(`${'a'.repeat(20000)}!`) + expect(Date.now() - start).toBeLessThan(2000) + }) + + it.each([ + ['split after a period', '(?<=\\.)\\s+', 'One. Two. Three.'], + ['split before a heading', '\\n(?=Chapter )', 'intro\nChapter 1\nChapter 2'], + ['sentence splitter', '(?<=[.!?])\\s+(?=[A-Z])', 'One. Two! Three? four.'], + ])('handles %s, where the assertion has an affix', (_label, pattern, doc) => { + // These are the common shapes: a lookaround combined with other syntax. + // Handling only a whole-pattern assertion would reject them outright. + const split = compileLookaroundSplit(pattern)?.split(doc) + expect(split).toEqual(doc.split(new RegExp(pattern, 'g'))) + }) + + it.each([ + ['negative lookahead', '(?!x)y'], + ['negative lookbehind', '(? { + expect(compileLookaroundSplit(pattern)).toBeNull() + }) + + it.each([ + ['leading assertion', '(?<=\\.)\\s+|\\n\\n'], + ['empty middle', '(?<=

)|
'], + ['trailing assertion', 'a|b(?=c)'], + ])('rejects %s with top-level alternation rather than reshaping it', (_label, pattern) => { + // `(?<=X)A|B` means `((?<=X)A)|B`; rebuilt as `(?:X)(A|B)` it would demand + // the assertion before both branches. No grouping recovers that, so the + // only correct answer is to decline the pattern. + expect(compileLookaroundSplit(pattern)).toBeNull() + }) + + it('is unaffected by a capturing group inside the lookbehind', () => { + // The middle is captured by name, so group numbering cannot shift. + expect(compileLookaroundSplit('(?<=(a))b')?.split('xaby')).toEqual(['xa', 'y']) + + const optional = compileLookaroundSplit('(?<=(a)|b)c') + expect(optional?.find('bc')).toBe(1) + expect(optional?.test('bc')).toBe(true) + }) + + it.each([ + ['(?<=\\w)\\s+(?=[A-Z])', 'A B C D'], + ['(?<=\\w)\\s+(?=\\w)', 'a b c d e'], + ])('does not consume assertion text between boundaries (%s)', (pattern, doc) => { + // The lookahead of one boundary is the lookbehind of the next. Consuming + // it would swallow every other split. + expect(compileLookaroundSplit(pattern)?.split(doc)).toEqual(doc.split(new RegExp(pattern, 'g'))) + }) +}) diff --git a/apps/sim/lib/core/security/linear-regex.ts b/apps/sim/lib/core/security/linear-regex.ts new file mode 100644 index 00000000000..a22134473cb --- /dev/null +++ b/apps/sim/lib/core/security/linear-regex.ts @@ -0,0 +1,367 @@ +import { RE2JS } from 're2js' + +/** + * Linear-time matching for caller-supplied regex patterns. + * + * The built-in engine backtracks, so a pattern chosen by a caller can take + * exponential time on input the same caller controls — `a*a*b` against a 10k + * run of `a` measured 213s on JSC and 132s on V8. Anywhere that runs on a + * shared event loop, that is a denial of service against every other tenant. + * + * Screening the pattern instead does not work. `safe-regex2` documents itself + * as having false negatives and passes `(a|a)*b`; rejecting quantified groups + * on top of it still passes `a*a*b`. Every syntactic rule only excludes the + * shapes someone thought to enumerate, so the engine has to change instead. + * + * RE2 has no backtracking and matches in time linear in the input. Two costs + * follow, and this module works to keep both off the caller: + * + * - **Syntax.** RE2 implements neither lookaround nor backreferences, and + * spells some escapes differently. `translateToRe2` bridges the mechanical + * differences and `compileLookaroundSplit` recovers the lookaround *split* + * idioms; genuine gaps return `null` so each caller decides how to degrade. + * - **Throughput.** Measured 0.5–270ms per megabyte depending on the pattern, + * against roughly 0.04ms/MB for the built-in engine. Callers matching a + * pattern with no metacharacter should take `literalRegex` instead. + */ +export interface LinearRegexOptions { + ignoreCase?: boolean +} + +export interface LinearRegex { + /** Whether the pattern matches anywhere in `text`. */ + test(text: string): boolean + /** Index of the first match in `text`, or -1. */ + find(text: string): number + /** + * Split `text` around every match. + * + * Follows `String.prototype.split` except that a trailing empty segment is + * omitted — RE2 drops it, and every caller here discards empties anyway. + */ + split(text: string): string[] +} + +const METACHARACTERS = /[.*+?^${}()|[\]\\]/ + +/** Escape every regex metacharacter so `input` matches only itself. */ +function escapeRegExp(input: string): string { + return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** True when `pattern` has no metacharacter, so both engines behave identically. */ +export function isPlainText(pattern: string): boolean { + return !METACHARACTERS.test(pattern) +} + +/** + * ECMAScript's `\s` as an RE2 character-class body. + * + * RE2's `\s` is ASCII-only (`[\t\n\f\r ]`), while ECMAScript's also covers + * `\v`, NBSP, U+1680, U+2000–U+200A, U+2028, U+2029, U+202F, U+205F, U+3000 + * and U+FEFF. Left untranslated, a `\s`-based document splitter silently stops + * splitting on the non-breaking spaces that PDF, DOCX and HTML extraction put + * everywhere — producing different chunks, and so different embeddings, for a + * document that has not changed. Verified equivalent to ECMAScript `\s` across + * a full sweep of the BMP. + */ +const JS_WHITESPACE_BODY = '\\t\\n\\v\\f\\r\\x{2028}\\x{2029}\\x{feff}\\p{Zs}' + +/** + * Rewrite the mechanical ECMAScript-vs-RE2 spelling differences. + * + * Two substitutions, both verified equivalent: + * - `\s`/`\S` → the Unicode set above, so whitespace splitting is unchanged. + * - `\uXXXX` → `\x{XXXX}`, RE2's spelling. Untranslated, RE2 rejects the + * pattern outright, which turns the ordinary way of writing a non-ASCII + * delimiter (`•`) into a hard failure. + * + * Scans with escape and character-class state so `\\s` (a literal backslash + * followed by `s`) is left alone and `[\s\d]` splices the set body rather than + * nesting a class. `\S` *inside* a class is left as RE2's ASCII form: negation + * within a set cannot be spliced, and `[^\S\n]` is rare enough not to justify + * a full class parser. + */ +function translateToRe2(pattern: string): string { + let out = '' + let inClass = false + for (let i = 0; i < pattern.length; i++) { + const char = pattern[i] + + if (char === '\\') { + const next = pattern[i + 1] + if (next === 'u' && /^[0-9a-fA-F]{4}$/.test(pattern.slice(i + 2, i + 6))) { + out += `\\x{${pattern.slice(i + 2, i + 6)}}` + i += 5 + continue + } + if (next === 's') { + out += inClass ? JS_WHITESPACE_BODY : `[${JS_WHITESPACE_BODY}]` + i += 1 + continue + } + if (next === 'S' && !inClass) { + out += `[^${JS_WHITESPACE_BODY}]` + i += 1 + continue + } + out += next === undefined ? char : char + next + i += 1 + continue + } + + if (inClass) { + if (char === ']') inClass = false + } else if (char === '[') { + inClass = true + } + out += char + } + return out +} + +/** Index of the `)` closing the group opened at `open`, or -1. */ +function closingParen(pattern: string, open: number): number { + let depth = 0 + let inClass = false + for (let i = open; i < pattern.length; i++) { + const char = pattern[i] + if (char === '\\') { + i += 1 + continue + } + if (inClass) { + if (char === ']') inClass = false + continue + } + if (char === '[') inClass = true + else if (char === '(') depth += 1 + else if (char === ')') { + depth -= 1 + if (depth === 0) return i + } + } + return -1 +} + +interface SplitShape { + behind: string + middle: string + ahead: string +} + +/** + * True when `pattern` has a `|` outside any group or character class. + * + * A split shape cannot be decomposed when its middle alternates at the top + * level: `(?<=\.)\s+|\n\n` means `((?<=\.)\s+)|(\n\n)`, so the assertion binds + * to the first branch only. Rebuilding it as `(?:\.)(\s+|\n\n)` would require + * the period before *either* branch — a silently different pattern. No grouping + * fixes that, so such patterns are rejected rather than reshaped. + */ +function hasTopLevelAlternation(pattern: string): boolean { + let depth = 0 + let inClass = false + for (let i = 0; i < pattern.length; i++) { + const char = pattern[i] + if (char === '\\') { + i += 1 + continue + } + if (inClass) { + if (char === ']') inClass = false + continue + } + if (char === '[') inClass = true + else if (char === '(') depth += 1 + else if (char === ')') depth -= 1 + else if (char === '|' && depth === 0) return true + } + return false +} + +/** + * Decompose a split pattern into `(?<=behind) middle (?=ahead)`, with either + * assertion optional. Returns `null` when neither is present (the caller should + * compile normally) or when the shape is anything else. + */ +function parseSplitShape(pattern: string): SplitShape | null { + let rest = pattern + let behind = '' + + if (rest.startsWith('(?<=')) { + const close = closingParen(rest, 0) + if (close === -1) return null + behind = rest.slice(4, close) + rest = rest.slice(close + 1) + } + + let ahead = '' + let depth = 0 + let inClass = false + for (let i = 0; i < rest.length; i++) { + const char = rest[i] + if (char === '\\') { + i += 1 + continue + } + if (inClass) { + if (char === ']') inClass = false + continue + } + if (char === '[') { + inClass = true + continue + } + if (char === ')') depth -= 1 + if (char !== '(') continue + depth += 1 + if (depth !== 1 || !rest.startsWith('(?=', i)) continue + const close = closingParen(rest, i) + if (close !== rest.length - 1) continue + ahead = rest.slice(i + 3, close) + rest = rest.slice(0, i) + break + } + + if (!behind && !ahead) return null + if (hasTopLevelAlternation(rest)) return null + return { behind, middle: rest, ahead } +} + +/** + * Compile a lookaround *split* pattern onto RE2, which has no lookaround. + * + * Splitting never needs the assertion itself — only the span the delimiter + * consumes. `(?<=X)Y(?=Z)` becomes `(?:X)(Y)(?:Z)`, and the span of group 1 is + * exactly what `String.prototype.split` would remove. That covers every + * combination in one rule: `(?=Z)` alone splits before a delimiter and keeps + * it, `(?<=X)` alone splits after one, and `(?<=[.!?])\s+(?=[A-Z])` — the + * sentence splitter — consumes the whitespace between them. + * + * Returns `null` for negative lookaround, backreferences, a middle that + * alternates at the top level, or any body RE2 cannot represent. + * + * Two details make the reconstruction faithful rather than approximate. The + * middle is captured by *name*, so a capturing group inside `behind` cannot + * shift the index out from under it. And each iteration resumes the search at + * the end of the previous delimiter rather than at the end of the whole match, + * so the assertion text is not consumed — without that, every boundary whose + * lookahead text doubles as the next boundary's lookbehind would be swallowed + * (`(?<=\w)\s+(?=[A-Z])` over `A B C D` would split only half the gaps). + * + * Known divergence: a delimiter that self-overlaps — `(?<=aa)` over `aaaaa`, + * or a single-character middle whose matches abut as in `(?<=\w).(?=\w)` — + * yields fewer boundaries than the built-in engine. Matching every position + * would mean restarting the scan one character past each match start, which is + * quadratic on a multi-megabyte document and forfeits the linear guarantee + * this module exists for. Delimiters that do not self-overlap — punctuation, + * tags, whitespace between tokens — are exact, and + * `linear-regex.differential.test.ts` pins that. + */ +export function compileLookaroundSplit( + pattern: string, + options: LinearRegexOptions = {} +): LinearRegex | null { + const shape = parseSplitShape(pattern) + if (!shape) return null + + const behind = shape.behind ? `(?:${shape.behind})` : '' + const ahead = shape.ahead ? `(?:${shape.ahead})` : '' + const source = translateToRe2(`${behind}(?P${shape.middle})${ahead}`) + + let compiled: ReturnType + try { + compiled = RE2JS.compile(source, options.ignoreCase ? RE2JS.CASE_INSENSITIVE : 0) + } catch { + return null + } + + /** Span of the delimiter itself — what `String.prototype.split` removes. */ + const delimiterAt = (text: string, from: number): { start: number; end: number } | null => { + const matcher = compiled.matcher(text) + if (!matcher.find(from)) return null + return { start: matcher.start('mid'), end: matcher.end('mid') } + } + + return { + test: (text) => compiled.matcher(text).find(), + find: (text) => delimiterAt(text, 0)?.start ?? -1, + split: (text) => { + const segments: string[] = [] + let cursor = 0 + let searchFrom = 0 + while (searchFrom <= text.length) { + const span = delimiterAt(text, searchFrom) + if (!span) break + // Always advance, so a zero-width delimiter cannot spin. + searchFrom = span.end > span.start ? span.end : span.start + 1 + // Skip a boundary behind the cursor, or one that would only emit an + // empty leading or trailing segment. + if (span.start < cursor || span.start >= text.length) continue + if (span.start === cursor && span.end === cursor) continue + segments.push(text.slice(cursor, span.start)) + cursor = span.end + } + segments.push(text.slice(cursor)) + return segments + }, + } +} + +/** + * Match `pattern` as an escaped literal on the built-in engine. + * + * Safe because an escaped literal cannot backtrack, and far quicker than RE2 — + * worth taking whenever the pattern has no metacharacter to interpret, or as a + * degradation path when RE2 rejects the syntax. + */ +export function literalRegex(pattern: string, options: LinearRegexOptions = {}): LinearRegex { + const source = escapeRegExp(pattern) + const caseFlag = options.ignoreCase ? 'i' : '' + // Non-global, so `exec`/`test` keep no `lastIndex` between calls and one + // instance is reusable — callers scan line-by-line, and recompiling per line + // would dominate the cost. + const scanner = new RegExp(source, caseFlag) + return { + test: (text) => scanner.test(text), + find: (text) => { + const match = scanner.exec(text) + return match ? match.index : -1 + }, + // Built lazily: no caller splits on a literal, so the second compile is + // only paid if one ever does. + split: (text) => text.split(new RegExp(source, `g${caseFlag}`)), + } +} + +/** + * Compile `pattern` into a matcher that cannot backtrack. + * + * Returns `null` when RE2 cannot represent the pattern — invalid syntax, or + * constructs RE2 does not implement (lookaround, backreferences, repeat counts + * above 1000). Callers must handle `null` explicitly rather than silently + * falling back to the built-in engine, which would reintroduce the exposure + * this exists to remove. + */ +export function compileLinearRegex( + pattern: string, + options: LinearRegexOptions = {} +): LinearRegex | null { + try { + const compiled = RE2JS.compile( + translateToRe2(pattern), + options.ignoreCase ? RE2JS.CASE_INSENSITIVE : 0 + ) + return { + test: (text) => compiled.matcher(text).find(), + find: (text) => { + const matcher = compiled.matcher(text) + return matcher.find() ? matcher.start() : -1 + }, + split: (text) => compiled.split(text), + } + } catch { + return null + } +} diff --git a/apps/sim/lib/guardrails/validate_regex.test.ts b/apps/sim/lib/guardrails/validate_regex.test.ts new file mode 100644 index 00000000000..905b869edb2 --- /dev/null +++ b/apps/sim/lib/guardrails/validate_regex.test.ts @@ -0,0 +1,67 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { validateRegex, validateRegexPattern } from '@/lib/guardrails/validate_regex' + +describe('validateRegex', () => { + it('passes when the input matches', () => { + expect(validateRegex('order 12345 shipped', '\\d{5}')).toEqual({ passed: true }) + }) + + it('fails with a reason when the input does not match', () => { + expect(validateRegex('no digits', '\\d{5}')).toEqual({ + passed: false, + error: 'Input does not match regex pattern', + }) + }) + + it('runs a catastrophic pattern in linear time', () => { + // Both the guardrail pattern and the text it checks are caller-influenced, + // and this executes on the shared event loop. `a*a*b` against this input + // measured 213s on JSC before the engine change. + const start = Date.now() + const result = validateRegex(`${'a'.repeat(10000)}!`, 'a*a*b') + + expect(Date.now() - start).toBeLessThan(2000) + expect(result.passed).toBe(false) + }) + + it('reports syntax RE2 cannot evaluate rather than running it', () => { + const result = validateRegex('anything', '(?=foo)bar') + expect(result.passed).toBe(false) + expect(result.error).toContain('lookahead') + }) + + it('reports invalid syntax distinctly from unsupported syntax', () => { + const result = validateRegex('anything', '(') + expect(result.passed).toBe(false) + expect(result.error).toContain('Invalid regex pattern') + }) +}) + +describe('validateRegexPattern', () => { + it('accepts a valid pattern', () => { + expect(validateRegexPattern('\\d{3}-\\d{4}')).toEqual({ valid: true }) + }) + + it('rejects an empty pattern', () => { + expect(validateRegexPattern('')).toMatchObject({ valid: false }) + }) + + it('rejects invalid syntax', () => { + expect(validateRegexPattern('(')).toMatchObject({ valid: false }) + }) + + it.each([ + ['lookbehind', '(?<=id: )\\w+'], + ['optional group', '(?:https?://)?example\\.com'], + ['nested quantifier', '(a+)+$'], + ])('accepts %s, which the removed safe-regex2 screen rejected', (_label, pattern) => { + // These are valid Presidio patterns that could not be saved before. The + // screen that blocked them caught no real ReDoS (it passes `a*a*b`), and + // these run in Presidio rather than in this process. + expect(validateRegexPattern(pattern)).toEqual({ valid: true }) + }) +}) diff --git a/apps/sim/lib/guardrails/validate_regex.ts b/apps/sim/lib/guardrails/validate_regex.ts index 442daa7b3ee..f8a90dc6e5e 100644 --- a/apps/sim/lib/guardrails/validate_regex.ts +++ b/apps/sim/lib/guardrails/validate_regex.ts @@ -1,4 +1,8 @@ -import safe from 'safe-regex2' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { compileLinearRegex } from '@/lib/core/security/linear-regex' + +const logger = createLogger('ValidateRegex') /** * Validate if input matches regex pattern @@ -15,9 +19,24 @@ export interface RegexPatternValidation { } /** - * Validate a regex pattern's syntax and safety without matching it against input: - * it must compile (`new RegExp`) and pass `safe-regex2`'s catastrophic-backtracking - * screen. Shared by the custom-pattern editor UI and any pre-flight boundary check. + * Validate a PII custom pattern's syntax before it is persisted and handed to + * Presidio. Shared by the custom-pattern editor UI and the write boundary. + * + * Syntax only, deliberately. This previously also ran a `safe-regex2` + * catastrophic-backtracking screen, which was removed because it was pure cost: + * it screens star height alone and is documented as having false negatives — it + * passes `(a|a)*b`, and `a*a*b` defeats it and every syntactic rule of its kind + * — while rejecting patterns that work perfectly well, including lookbehind + * (`(?<=id: )\w+`) and optional groups (`(?:https?://)?example\.com`). It + * blocked valid rules and stopped nothing. + * + * Nor could a screen here be made sound: these patterns execute in Presidio's + * Python engine, which backtracks on shapes RE2 accepts, so RE2-representability + * says nothing about their runtime there. Presidio's own request timeout is the + * real bound — note it fails open on timeout, leaving PII unredacted. + * + * Anything that matches a caller-supplied pattern *in this process* must use + * `compileLinearRegex` from `@/lib/core/security/linear-regex` instead. */ export function validateRegexPattern(pattern: string): RegexPatternValidation { if (pattern.length === 0) { @@ -26,34 +45,42 @@ export function validateRegexPattern(pattern: string): RegexPatternValidation { try { new RegExp(pattern) } catch (error) { - return { valid: false, error: `Invalid regex: ${(error as Error).message}` } - } - if (!safe(pattern)) { - return { - valid: false, - error: 'Pattern rejected: potentially unsafe (catastrophic backtracking)', - } + return { valid: false, error: `Invalid regex: ${getErrorMessage(error)}` } } return { valid: true } } +/** + * Match `inputStr` against a caller-defined guardrail `pattern`. + * + * Both the pattern and the input are caller-influenced and this runs on the + * shared event loop, so matching goes through RE2 — a backtracking engine here + * lets one guardrail rule stall every other request on the instance. Patterns + * RE2 cannot represent (lookaround, backreferences) are reported rather than + * run on the built-in engine, which would reintroduce that exposure. + */ export function validateRegex(inputStr: string, pattern: string): ValidationResult { - let regex: RegExp try { - regex = new RegExp(pattern) - } catch (error: any) { - return { passed: false, error: `Invalid regex pattern: ${error.message}` } + new RegExp(pattern) + } catch (error) { + return { passed: false, error: `Invalid regex pattern: ${getErrorMessage(error)}` } } - if (!safe(pattern)) { + const regex = compileLinearRegex(pattern) + if (!regex) { + // A rule that used lookaround worked before this became RE2-only and now + // fails closed, which reads to the workspace as the guardrail tripping on + // every input. Log it so an operator can find and rewrite the rule from + // logs rather than from user reports. + logger.warn('Guardrail regex uses syntax RE2 cannot evaluate; failing closed', { pattern }) return { passed: false, - error: 'Regex pattern rejected: potentially unsafe (catastrophic backtracking)', + error: + 'Regex pattern uses syntax that cannot be evaluated safely (lookahead, lookbehind and backreferences are unsupported). Rewrite it without those constructs.', } } - const match = regex.test(inputStr) - if (match) { + if (regex.test(inputStr)) { return { passed: true } } return { passed: false, error: 'Input does not match regex pattern' } diff --git a/apps/sim/lib/logs/log-views.test.ts b/apps/sim/lib/logs/log-views.test.ts index 9b31ed4feb5..de444485adc 100644 --- a/apps/sim/lib/logs/log-views.test.ts +++ b/apps/sim/lib/logs/log-views.test.ts @@ -32,6 +32,7 @@ vi.mock('@/lib/execution/payloads/store', () => ({ materializeLargeValueRef: materializeLargeValueRefMock, })) +import { sleep } from '@sim/utils/helpers' import type { TraceSpan } from '@/lib/logs/types' import { grepSpans, type LogViewContext, toFull, toOverview } from './log-views' @@ -169,10 +170,114 @@ describe('grepSpans', () => { expect(result.matches.some((m) => m.field === 'output')).toBe(true) }) - it('falls back to literal substring on invalid regex', async () => { - const spans = [span({ output: { v: 'value a(b found' } })] - const result = await grepSpans(spans, '(', ctx) + it.each([ + ['character class', 'status=\\d+'], + ['anchor', '^Agent'], + ['alternation', '(openai|anthropic)'], + ['word boundary', '\\bstatus\\b'], + ['bounded quantifier', '\\d{4}-\\d{2}-\\d{2}'], + ['wildcard', 'called.*status'], + ])('interprets %s regex syntax', async (_label, pattern) => { + // None of these patterns occur literally in the span, so a match proves the + // regex was interpreted. `^Agent` anchors against the name field, the rest + // against output — hence the field-agnostic assertion. + const spans = [span({ output: { v: 'called api.openai.com -> status=503 on 2026-01-01' } })] + const result = await grepSpans(spans, pattern, ctx) + expect(result.matches.length).toBeGreaterThan(0) + expect(result.patternNotice).toBeUndefined() + }) + + it('falls back to a literal, with a notice, for syntax RE2 does not implement', async () => { + const spans = [span({ output: { v: 'id: abc and (?=x) literally here' } })] + + const lookahead = await grepSpans(spans, '(?=x)', ctx) + expect(lookahead.matches.some((m) => m.field === 'output')).toBe(true) + expect(lookahead.patternNotice).toContain('RE2') + + // Unbalanced paren is invalid in both engines; still degrades to a literal. + const invalid = await grepSpans([span({ output: { v: 'value a(b' } })], '(', ctx) + expect(invalid.matches.some((m) => m.field === 'output')).toBe(true) + expect(invalid.patternNotice).toContain('RE2') + }) + + it('takes the built-in engine for a metacharacter-free pattern', async () => { + const spans = [span({ output: { v: 'saw ECONNREFUSED here' } })] + const result = await grepSpans(spans, 'ECONNREFUSED', ctx) expect(result.matches.some((m) => m.field === 'output')).toBe(true) + expect(result.patternNotice).toBeUndefined() + }) + + it.each([ + ['nested quantifier', '(a+)+$'], + ['duplicate alternation, passes safe-regex2', '(a|a)*b'], + ['adjacent quantifiers, passes every structural screen', 'a*a*b'], + ])('runs a catastrophic pattern in linear time (%s)', async (_label, pattern) => { + // Each blocks the event loop for minutes on a backtracking engine: + // `a*a*b` measured 213s on JSC / 132s on V8 over a 10k-character run. + // RE2 has no backtracking, so these are matched normally and stay fast. + const spans = [span({ output: { v: `${'a'.repeat(10000)}!` } })] + + const start = Date.now() + const result = await grepSpans(spans, pattern, ctx) + const elapsedMs = Date.now() - start + + expect(elapsedMs).toBeLessThan(1000) + expect(result.truncated).toBe(false) + }) + + it('matches a long pattern literally without a length cap', async () => { + const pattern = `${'x'.repeat(600)}needle` + const spans = [span({ output: { v: pattern } })] + const result = await grepSpans(spans, pattern, ctx) + expect(result.matches.some((m) => m.field === 'output')).toBe(true) + }) + + it('stops scanning and marks truncated once the character budget is exhausted', async () => { + const spans = [ + span({ id: 'a', output: { v: 'x'.repeat(400) } }), + span({ id: 'b', output: { v: 'needle' } }), + ] + const result = await grepSpans(spans, 'needle', ctx, { maxScannedChars: 100 }) + expect(result.matches).toEqual([]) + expect(result.truncated).toBe(true) + }) + + it('stops scanning and marks truncated once the match-time budget is exhausted', async () => { + const spans = [span({ output: { v: 'needle' } })] + const result = await grepSpans(spans, 'needle', ctx, { matchTimeBudgetMs: 0 }) + expect(result.matches).toEqual([]) + expect(result.truncated).toBe(true) + }) + + it('accumulates match time and truncates once the budget is spent', async () => { + // Guards the accumulation in `findTimed`: with that line removed, + // matchTimeMs stays 0, the budget never trips, and every span is scanned. + const big = 'x'.repeat(400_000) + const spans = [ + span({ id: 'a', output: { v: big } }), + span({ id: 'b', output: { v: big } }), + span({ id: 'c', output: { v: `${big} needle` } }), + ] + + const result = await grepSpans(spans, 'needle|nomatch', ctx, { matchTimeBudgetMs: 1 }) + + expect(result.truncated).toBe(true) + expect(result.matches).toEqual([]) + }) + + it('does not charge blob-store I/O to the match-time budget', async () => { + // Each slice read sleeps well past the budget: only time spent matching + // counts, so a slow-but-legitimate grep must still return complete results. + readLargeArrayManifestSliceMock.mockImplementation(async (_m: unknown, start: number) => { + await sleep(30) + return start === 400 ? [{ v: 'found the needle here' }] : [{ v: 'nothing' }] + }) + const spans = [span({ output: manifest(500) as any })] + + const result = await grepSpans(spans, 'needle', ctx, { matchTimeBudgetMs: 50 }) + + expect(result.matches.some((m) => m.field === 'output')).toBe(true) + expect(result.truncated).toBe(false) }) it('returns empty for empty traceSpans', async () => { diff --git a/apps/sim/lib/logs/log-views.ts b/apps/sim/lib/logs/log-views.ts index e7e4feaceee..1f679ce53b0 100644 --- a/apps/sim/lib/logs/log-views.ts +++ b/apps/sim/lib/logs/log-views.ts @@ -1,3 +1,4 @@ +import { compileLinearRegex, isPlainText, literalRegex } from '@/lib/core/security/linear-regex' import { materializeLargeArrayManifest, readLargeArrayManifestSlice, @@ -23,6 +24,27 @@ const DEFAULT_MAX_MATCHES = 50 const DEFAULT_MAX_SNIPPET_CHARS = 500 const DEFAULT_MAX_SLICES_SCANNED = 200 +/** + * Cumulative time the pattern itself may spend matching, across all spans/fields. + * + * Deliberately counts only time spent matching, not the grep's wall clock: the + * scan awaits blob-store reads (array slices, large-value refs) between matches, + * and charging that I/O to the budget would truncate slow-but-legitimate greps + * under load. Matching is the only part that occupies the event loop, so it is + * the only part worth bounding. + * + * RE2JS trades throughput for its linear-time guarantee — roughly 100x slower + * than the built-in engine, ~25ms per megabyte — so on a very large trace this + * budget is what actually caps the scan rather than a formality. + */ +const DEFAULT_MATCH_TIME_BUDGET_MS = 5_000 +/** + * Total characters a single grep may run the pattern over. Bounds the work one + * request can demand across every span and slice; set well above any realistic + * trace so normal greps never trip it. + */ +const DEFAULT_MAX_SCANNED_CHARS = 64 * 1024 * 1024 + // --------------------------------------------------------------------------- // Overview (Level 2): block tree with timing + cost, NO input/output. // --------------------------------------------------------------------------- @@ -171,40 +193,86 @@ export interface GrepSpanMatch { export interface GrepSpansResult { matches: GrepSpanMatch[] + /** + * Whether the scan stopped early — because a budget was exhausted, the slice + * cap was hit, or `maxMatches` was reached. It is a "there may be more" flag, + * not proof that trace was left unread: reaching `maxMatches` on the final + * match sets it even when nothing remained. Treat it as a prompt to narrow + * the pattern, never as a count. + */ truncated: boolean + /** + * Present only when the pattern used syntax RE2 does not implement and was + * therefore matched literally. The tool catalog cannot warn up front — it is + * generated from a contract in another repository — so the caller is told + * here rather than reading zero matches as "not present in the trace". + */ + patternNotice?: string } export interface GrepSpansOptions { maxMatches?: number maxSnippetChars?: number maxSlicesScanned?: number + maxScannedChars?: number + matchTimeBudgetMs?: number } interface GrepState { matches: GrepSpanMatch[] slicesScanned: number + scannedChars: number + matchTimeMs: number truncated: boolean maxMatches: number maxSnippetChars: number maxSlicesScanned: number - regex: RegExp + maxScannedChars: number + matchTimeBudgetMs: number + find: FindMatch } -function escapeRegExp(input: string): string { - return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +/** Index of the first case-insensitive match in `text`, or -1. */ +type FindMatch = (text: string) => number + +/** + * Compile a caller-supplied grep pattern into a matcher that cannot backtrack. + * + * Trace text is attacker-influenced — a workflow can emit arbitrarily long + * uniform runs into its own block outputs — and matching runs synchronously on + * the shared event loop, so a backtracking engine lets one request stall every + * other request on the instance. See `@/lib/core/security/linear-regex` for why + * the engine changed rather than the pattern being screened. + * + * A pattern with no metacharacter takes the built-in engine, which is ~100x + * quicker and identical in meaning when there is nothing to interpret. Syntax + * RE2 cannot represent degrades to a literal with a notice, so the caller knows + * its regex was not applied instead of reading zero matches as "not present". + */ +function compilePattern(pattern: string): { find: FindMatch; notice?: string } { + if (isPlainText(pattern)) return { find: literalRegex(pattern, { ignoreCase: true }).find } + + const compiled = compileLinearRegex(pattern, { ignoreCase: true }) + if (compiled) return { find: compiled.find } + + return { + find: literalRegex(pattern, { ignoreCase: true }).find, + notice: + 'Pattern is not valid RE2 syntax (lookahead, lookbehind and backreferences are unsupported), so it was matched as a literal string. Rewrite it without those constructs to search by regex.', + } } -function buildRegex(pattern: string): RegExp { +function findTimed(text: string, state: GrepState): number { + const started = performance.now() try { - return new RegExp(pattern, 'i') - } catch { - return new RegExp(escapeRegExp(pattern), 'i') + return state.find(text) + } finally { + state.matchTimeMs += performance.now() - started } } -function snippetAround(text: string, regex: RegExp, maxChars: number): string { - const m = regex.exec(text) - const index = m ? m.index : 0 +function snippetAround(text: string, index: number, state: GrepState): string { + const maxChars = state.maxSnippetChars const half = Math.floor(maxChars / 2) const start = Math.max(0, index - half) const end = Math.min(text.length, start + maxChars) @@ -214,7 +282,12 @@ function snippetAround(text: string, regex: RegExp, maxChars: number): string { } function done(state: GrepState): boolean { - return state.truncated || state.matches.length >= state.maxMatches + if (state.truncated || state.matches.length >= state.maxMatches) return true + if (state.matchTimeMs >= state.matchTimeBudgetMs) { + state.truncated = true + return true + } + return false } function recordIfMatch( @@ -224,15 +297,19 @@ function recordIfMatch( state: GrepState ): void { if (done(state)) return - state.regex.lastIndex = 0 - if (!state.regex.test(text)) return - state.regex.lastIndex = 0 + if (state.scannedChars + text.length > state.maxScannedChars) { + state.truncated = true + return + } + state.scannedChars += text.length + const index = findTimed(text, state) + if (index < 0) return state.matches.push({ spanId: span.id, blockId: span.blockId, name: span.name, field, - snippet: snippetAround(text, state.regex, state.maxSnippetChars), + snippet: snippetAround(text, index, state), }) if (state.matches.length >= state.maxMatches) state.truncated = true } @@ -305,6 +382,12 @@ function safeStringify(value: unknown): string { * directly; large-array I/O is streamed slice-by-slice (each released before the * next); single large refs are materialized under a byte cap (falling back to * the ref preview). Only bounded match snippets are accumulated. + * + * `pattern` is matched by a non-backtracking engine — see `compilePattern` — so + * no pattern can blow up on any input. Two budgets bound total work on top of + * that: a character budget and a cumulative match-time budget. Neither counts + * the blob-store I/O this scan awaits, so a slow-but-legitimate grep is not + * truncated merely for being slow. */ export async function grepSpans( spans: TraceSpan[], @@ -312,14 +395,19 @@ export async function grepSpans( ctx: LogViewContext, opts?: GrepSpansOptions ): Promise { + const compiled = compilePattern(pattern) const state: GrepState = { matches: [], slicesScanned: 0, + scannedChars: 0, + matchTimeMs: 0, truncated: false, maxMatches: opts?.maxMatches ?? DEFAULT_MAX_MATCHES, maxSnippetChars: opts?.maxSnippetChars ?? DEFAULT_MAX_SNIPPET_CHARS, maxSlicesScanned: opts?.maxSlicesScanned ?? DEFAULT_MAX_SLICES_SCANNED, - regex: buildRegex(pattern), + maxScannedChars: opts?.maxScannedChars ?? DEFAULT_MAX_SCANNED_CHARS, + matchTimeBudgetMs: opts?.matchTimeBudgetMs ?? DEFAULT_MATCH_TIME_BUDGET_MS, + find: compiled.find, } const walk = async (list: TraceSpan[]): Promise => { @@ -335,5 +423,9 @@ export async function grepSpans( } await walk(spans) - return { matches: state.matches, truncated: state.truncated } + return { + matches: state.matches, + truncated: state.truncated, + ...(compiled.notice ? { patternNotice: compiled.notice } : {}), + } } diff --git a/apps/sim/package.json b/apps/sim/package.json index b0205c85f7f..8ef25a4b95b 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -194,6 +194,7 @@ "posthog-node": "5.28.9", "pptxgenjs": "4.0.1", "prismjs": "^1.30.0", + "re2js": "2.8.6", "react": "19.2.4", "react-dom": "19.2.4", "react-hook-form": "^7.54.2", @@ -206,7 +207,6 @@ "remark-gfm": "4.0.1", "resend": "^4.1.2", "rss-parser": "3.13.0", - "safe-regex2": "5.1.0", "sharp": "0.35.3", "socket.io-client": "4.8.1", "ssh2": "^1.17.0", diff --git a/bun.lock b/bun.lock index 20cc0d013c2..597bc798df4 100644 --- a/bun.lock +++ b/bun.lock @@ -269,6 +269,7 @@ "posthog-node": "5.28.9", "pptxgenjs": "4.0.1", "prismjs": "^1.30.0", + "re2js": "2.8.6", "react": "19.2.4", "react-dom": "19.2.4", "react-hook-form": "^7.54.2", @@ -281,7 +282,6 @@ "remark-gfm": "4.0.1", "resend": "^4.1.2", "rss-parser": "3.13.0", - "safe-regex2": "5.1.0", "sharp": "0.35.3", "socket.io-client": "4.8.1", "ssh2": "^1.17.0", @@ -3598,6 +3598,8 @@ "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], + "re2js": ["re2js@2.8.6", "", {}, "sha512-xLgQil4kIUCrAzVk9fRSkxkFNwmygLFjVxXrLc65aE1F0+Zsb8rxumFBy4XKyvgMCTL6kilDq3EZ0piE2dP/Dg=="], + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], @@ -3696,8 +3698,6 @@ "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], - "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="], - "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], "retry-request": ["retry-request@7.0.2", "", { "dependencies": { "@types/request": "^2.48.8", "extend": "^3.0.2", "teeny-request": "^9.0.0" } }, "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w=="], @@ -3734,8 +3734,6 @@ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "safe-regex2": ["safe-regex2@5.1.0", "", { "dependencies": { "ret": "~0.5.0" }, "bin": { "safe-regex2": "bin/safe-regex2.js" } }, "sha512-pNHAuBW7TrcleFHsxBr5QMi/Iyp0ENjUKz7GCcX1UO7cMh+NmVK6HxQckNL1tJp1XAJVjG6B8OKIPqodqj9rtw=="], - "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],