Skip to content

Commit 92084df

Browse files
committed
fix(logs): match log-grep patterns with RE2 instead of dropping regex support
Restores full regex support on copilot log-grep, which the previous commits in this PR had removed to close the ReDoS. Deleting the capability was the wrong trade: RE2JS is a port of RE2 that matches in time linear in the input, so no pattern can blow up regardless of the text, and callers keep their regexes. Measured through the real compile path, against a 100k-character adversarial input — 10x the run that took 213s on JSC / 132s on V8: (a+)+$ 13.0ms (\w+\s?)*$ 10.7ms (a|a)*b 6.7ms (x+x+)+y 4.0ms a*a*b 8.1ms ^(\d+)*$ 0.0ms Two escape hatches keep this honest: - A pattern with no regex metacharacter takes the built-in engine. Semantics are identical when there is nothing to interpret, and RE2JS costs ~100x more per byte (~25ms/MB), so the common plain-text search stays native. - Lookaround and backreferences are not in RE2. RE2JS rejects them at compile time, so those fall back to a literal and return a `patternNotice` naming the constructs — a narrow, reported gap rather than a silent behavior change. The match-time and scanned-character budgets stop being formalities: RE2JS's throughput is what they now bound, not backtracking. Also collapses the old test-then-exec double scan — `compilePattern` returns a single `find` returning a match index, which `recordIfMatch` passes straight to `snippetAround`, so each field is scanned once instead of twice. re2js@2.8.6 is MIT, pure JavaScript (no native addon, so nothing changes for the oven/bun image), server-only, and published 2026-07-05 — clearing the 7-day bunfig minimumReleaseAge gate without an exclusion.
1 parent 2a721c8 commit 92084df

4 files changed

Lines changed: 123 additions & 89 deletions

File tree

apps/sim/lib/logs/log-views.test.ts

Lines changed: 38 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -170,51 +170,59 @@ describe('grepSpans', () => {
170170
expect(result.matches.some((m) => m.field === 'output')).toBe(true)
171171
})
172172

173-
it('matches regex syntax literally rather than interpreting it', async () => {
174-
const spans = [span({ output: { v: 'value a(b found' } })]
175-
const result = await grepSpans(spans, '(', ctx)
176-
expect(result.matches.some((m) => m.field === 'output')).toBe(true)
177-
expect(result.patternNotice).toContain('literal')
173+
it.each([
174+
['character class', 'status=\\d+'],
175+
['anchor', '^Agent'],
176+
['alternation', '(openai|anthropic)'],
177+
['word boundary', '\\bstatus\\b'],
178+
['bounded quantifier', '\\d{4}-\\d{2}-\\d{2}'],
179+
['wildcard', 'called.*status'],
180+
])('interprets %s regex syntax', async (_label, pattern) => {
181+
// None of these patterns occur literally in the span, so a match proves the
182+
// regex was interpreted. `^Agent` anchors against the name field, the rest
183+
// against output — hence the field-agnostic assertion.
184+
const spans = [span({ output: { v: 'called api.openai.com -> status=503 on 2026-01-01' } })]
185+
const result = await grepSpans(spans, pattern, ctx)
186+
expect(result.matches.length).toBeGreaterThan(0)
187+
expect(result.patternNotice).toBeUndefined()
188+
})
189+
190+
it('falls back to a literal, with a notice, for syntax RE2 does not implement', async () => {
191+
const spans = [span({ output: { v: 'id: abc and (?=x) literally here' } })]
192+
193+
const lookahead = await grepSpans(spans, '(?=x)', ctx)
194+
expect(lookahead.matches.some((m) => m.field === 'output')).toBe(true)
195+
expect(lookahead.patternNotice).toContain('RE2')
196+
197+
// Unbalanced paren is invalid in both engines; still degrades to a literal.
198+
const invalid = await grepSpans([span({ output: { v: 'value a(b' } })], '(', ctx)
199+
expect(invalid.matches.some((m) => m.field === 'output')).toBe(true)
200+
expect(invalid.patternNotice).toContain('RE2')
178201
})
179202

180-
it.each(['example.com', 'file.pdf', 'v1.2.3', 'block_1.output', '$0.42', 'why?'])(
181-
'does not warn about regex on the ordinary literal %s',
182-
async (pattern) => {
183-
const spans = [span({ output: { v: `saw ${pattern} here` } })]
184-
const result = await grepSpans(spans, pattern, ctx)
185-
expect(result.matches.some((m) => m.field === 'output')).toBe(true)
186-
expect(result.patternNotice).toBeUndefined()
187-
}
188-
)
189-
190-
it('does not interpret a regex pattern, and says so', async () => {
191-
const spans = [span({ output: { v: 'status=503' } })]
192-
193-
// Literal 'status=\d+' is not present; the regex it denotes would have matched.
194-
const asRegex = await grepSpans(spans, 'status=\\d+', ctx)
195-
expect(asRegex.matches).toEqual([])
196-
expect(asRegex.patternNotice).toContain('literal')
197-
198-
const asLiteral = await grepSpans(spans, 'status=503', ctx)
199-
expect(asLiteral.matches.some((m) => m.field === 'output')).toBe(true)
200-
expect(asLiteral.patternNotice).toBeUndefined()
203+
it('takes the built-in engine for a metacharacter-free pattern', async () => {
204+
const spans = [span({ output: { v: 'saw ECONNREFUSED here' } })]
205+
const result = await grepSpans(spans, 'ECONNREFUSED', ctx)
206+
expect(result.matches.some((m) => m.field === 'output')).toBe(true)
207+
expect(result.patternNotice).toBeUndefined()
201208
})
202209

203210
it.each([
204211
['nested quantifier', '(a+)+$'],
205212
['duplicate alternation, passes safe-regex2', '(a|a)*b'],
206213
['adjacent quantifiers, passes every structural screen', 'a*a*b'],
207-
])('never executes a catastrophic pattern (%s)', async (_label, pattern) => {
208-
// Each of these blocks the event loop for minutes if compiled as a regex:
214+
])('runs a catastrophic pattern in linear time (%s)', async (_label, pattern) => {
215+
// Each blocks the event loop for minutes on a backtracking engine:
209216
// `a*a*b` measured 213s on JSC / 132s on V8 over a 10k-character run.
210-
const spans = [span({ output: { v: `${'a'.repeat(5000)}!` } })]
217+
// RE2 has no backtracking, so these are matched normally and stay fast.
218+
const spans = [span({ output: { v: `${'a'.repeat(10000)}!` } })]
211219

212220
const start = Date.now()
213221
const result = await grepSpans(spans, pattern, ctx)
214222
const elapsedMs = Date.now() - start
215223

216224
expect(elapsedMs).toBeLessThan(1000)
217-
expect(result.matches).toEqual([])
225+
expect(result.truncated).toBe(false)
218226
})
219227

220228
it('matches a long pattern literally without a length cap', async () => {

apps/sim/lib/logs/log-views.ts

Lines changed: 81 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { RE2JS } from 're2js'
12
import {
23
materializeLargeArrayManifest,
34
readLargeArrayManifestSlice,
@@ -26,17 +27,21 @@ const DEFAULT_MAX_SLICES_SCANNED = 200
2627
/**
2728
* Cumulative time the pattern itself may spend matching, across all spans/fields.
2829
*
29-
* Deliberately counts only time inside `test`/`exec`, not the grep's wall clock:
30-
* the scan awaits blob-store reads (array slices, large-value refs) between
31-
* matches, and charging that I/O to the budget would truncate slow-but-legitimate
32-
* greps under load. Matching is the only part that occupies the event loop, so
33-
* it is the only part worth bounding.
30+
* Deliberately counts only time spent matching, not the grep's wall clock: the
31+
* scan awaits blob-store reads (array slices, large-value refs) between matches,
32+
* and charging that I/O to the budget would truncate slow-but-legitimate greps
33+
* under load. Matching is the only part that occupies the event loop, so it is
34+
* the only part worth bounding.
35+
*
36+
* RE2JS trades throughput for its linear-time guarantee — roughly 100x slower
37+
* than the built-in engine, ~25ms per megabyte — so on a very large trace this
38+
* budget is what actually caps the scan rather than a formality.
3439
*/
3540
const DEFAULT_MATCH_TIME_BUDGET_MS = 5_000
3641
/**
37-
* Total characters a single grep may run the pattern over. A backstop against a
38-
* pattern that slips past the backtracking screen being amplified across every
39-
* slice — set well above any realistic trace so normal greps never trip it.
42+
* Total characters a single grep may run the pattern over. Bounds the work one
43+
* request can demand across every span and slice; set well above any realistic
44+
* trace so normal greps never trip it.
4045
*/
4146
const DEFAULT_MAX_SCANNED_CHARS = 64 * 1024 * 1024
4247

@@ -196,10 +201,10 @@ export interface GrepSpansResult {
196201
*/
197202
truncated: boolean
198203
/**
199-
* Present when the pattern contained regex syntax, which is matched literally.
200-
* The tool catalog cannot say so up front — it is generated from a contract in
201-
* another repository — so the caller is told here instead of silently reading
202-
* zero matches as "not present in the trace".
204+
* Present only when the pattern used syntax RE2 does not implement and was
205+
* therefore matched literally. The tool catalog cannot warn up front — it is
206+
* generated from a contract in another repository — so the caller is told
207+
* here rather than reading zero matches as "not present in the trace".
203208
*/
204209
patternNotice?: string
205210
}
@@ -223,67 +228,84 @@ interface GrepState {
223228
maxSlicesScanned: number
224229
maxScannedChars: number
225230
matchTimeBudgetMs: number
226-
regex: RegExp
231+
find: FindMatch
227232
}
228233

229234
function escapeRegExp(input: string): string {
230235
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
231236
}
232237

233-
/**
234-
* Signals that the caller wrote a pattern *intending* regex semantics: escape
235-
* classes, a character class, a group, alternation, a leading/trailing anchor,
236-
* or a repetition quantifier.
237-
*
238-
* Deliberately narrower than the set `escapeRegExp` escapes. A bare `.` or `?`
239-
* is ordinary text in the things people actually grep for — `example.com`,
240-
* `file.pdf`, `v1.2.3`, `block_1.output` — and flagging those would tell the
241-
* caller its correct search was misinterpreted, prompting a pointless retry.
242-
*/
243-
const REGEX_INTENT = /\\[dwsbDWSB]|[[\]()|*+]|^\^|\$$|\{\d+,?\d*\}/
238+
/** Any regex metacharacter — a pattern without one behaves the same either way. */
239+
const REGEX_METACHARACTERS = /[.*+?^${}()|[\]\\]/
240+
241+
/** Index of the first case-insensitive match in `text`, or -1. */
242+
type FindMatch = (text: string) => number
243+
244+
/** Escaped-literal search on the built-in engine: linear, and allocation-free. */
245+
function literalFinder(pattern: string): FindMatch {
246+
const regex = new RegExp(escapeRegExp(pattern), 'i')
247+
return (text) => {
248+
const match = regex.exec(text)
249+
return match ? match.index : -1
250+
}
251+
}
244252

245253
/**
246-
* Compile a caller-supplied grep pattern as a case-insensitive literal.
254+
* Compile a caller-supplied grep pattern into a matcher that cannot backtrack.
247255
*
248-
* Caller patterns are deliberately never executed as regexes. Trace text is
249-
* attacker-influenced — a workflow can emit arbitrarily long uniform runs into
250-
* its own block outputs — and matching runs synchronously on the shared event
251-
* loop, so a backtracking pattern stalls every request on the instance.
256+
* Trace text is attacker-influenced — a workflow can emit arbitrarily long
257+
* uniform runs into its own block outputs — and matching runs synchronously on
258+
* the shared event loop, so a backtracking engine lets one request stall every
259+
* other request on the instance. RE2JS is a port of RE2: it matches in time
260+
* linear in the input, so no pattern can blow up regardless of the text.
252261
*
253-
* Screening was tried and abandoned: `safe-regex2` (which the guardrails
254-
* validator uses) documents itself as having false negatives and passes
255-
* `(a|a)*b`; rejecting quantified groups on top of it still passes `a*a*b`,
256-
* measured at 213s on JSC and 132s on V8 over a 10k-character run. Each rule
257-
* only excludes the shapes someone thought to enumerate. An escaped literal is
258-
* linear on every engine, which is the one property worth relying on here.
262+
* Screening the pattern instead was tried and abandoned. `safe-regex2` (used by
263+
* the guardrails validator) documents itself as having false negatives and
264+
* passes `(a|a)*b`; rejecting quantified groups on top of it still passes
265+
* `a*a*b`, measured at 213s on JSC and 132s on V8 over a 10k-character run.
266+
* Every syntactic rule only excludes the shapes someone thought to enumerate,
267+
* which is why the engine changed instead of the filter.
268+
*
269+
* Two escape hatches keep the common path fast and the rare one honest:
270+
* a pattern with no metacharacter takes the built-in engine (identical
271+
* semantics, ~100x quicker), and syntax RE2 does not implement — lookaround,
272+
* backreferences — falls back to a literal with a notice, since RE2JS rejects
273+
* those at compile time rather than matching them.
259274
*/
260-
function compilePattern(pattern: string): { regex: RegExp; notice?: string } {
261-
const regex = new RegExp(escapeRegExp(pattern), 'i')
262-
if (!REGEX_INTENT.test(pattern)) return { regex }
263-
return {
264-
regex,
265-
notice:
266-
'Matched as a literal, case-insensitive substring — regex syntax is not interpreted. Search for the literal text you expect to see in the trace.',
275+
function compilePattern(pattern: string): { find: FindMatch; notice?: string } {
276+
if (!REGEX_METACHARACTERS.test(pattern)) return { find: literalFinder(pattern) }
277+
278+
try {
279+
const compiled = RE2JS.compile(pattern, RE2JS.CASE_INSENSITIVE)
280+
return {
281+
find: (text) => {
282+
const matcher = compiled.matcher(text)
283+
return matcher.find() ? matcher.start() : -1
284+
},
285+
}
286+
} catch {
287+
return {
288+
find: literalFinder(pattern),
289+
notice:
290+
'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.',
291+
}
267292
}
268293
}
269294

270295
/**
271-
* Charge the elapsed matching time to the grep's budget. Every `test`/`exec` on
272-
* caller-supplied patterns goes through here.
296+
* Run the pattern over `text`, charging the elapsed matching time to the grep's
297+
* budget. Every match on a caller-supplied pattern goes through here.
273298
*/
274-
function runTimed<T>(state: GrepState, match: (regex: RegExp) => T): T {
299+
function findTimed(text: string, state: GrepState): number {
275300
const started = performance.now()
276301
try {
277-
state.regex.lastIndex = 0
278-
return match(state.regex)
302+
return state.find(text)
279303
} finally {
280304
state.matchTimeMs += performance.now() - started
281305
}
282306
}
283307

284-
function snippetAround(text: string, state: GrepState): string {
285-
const m = runTimed(state, (regex) => regex.exec(text))
286-
const index = m ? m.index : 0
308+
function snippetAround(text: string, index: number, state: GrepState): string {
287309
const maxChars = state.maxSnippetChars
288310
const half = Math.floor(maxChars / 2)
289311
const start = Math.max(0, index - half)
@@ -314,13 +336,14 @@ function recordIfMatch(
314336
return
315337
}
316338
state.scannedChars += text.length
317-
if (!runTimed(state, (regex) => regex.test(text))) return
339+
const index = findTimed(text, state)
340+
if (index < 0) return
318341
state.matches.push({
319342
spanId: span.id,
320343
blockId: span.blockId,
321344
name: span.name,
322345
field,
323-
snippet: snippetAround(text, state),
346+
snippet: snippetAround(text, index, state),
324347
})
325348
if (state.matches.length >= state.maxMatches) state.truncated = true
326349
}
@@ -394,12 +417,11 @@ function safeStringify(value: unknown): string {
394417
* next); single large refs are materialized under a byte cap (falling back to
395418
* the ref preview). Only bounded match snippets are accumulated.
396419
*
397-
* `pattern` is matched as a literal substring, never as a regex — see
398-
* `compilePattern`. Two budgets bound the scan on top of that: a total
399-
* character budget and a cumulative match-time budget. Neither counts the
400-
* blob-store I/O this scan awaits, so a slow-but-legitimate grep is not
401-
* truncated for being slow. With literal matching both are backstops rather
402-
* than load-bearing, and they stay to bound total work per request.
420+
* `pattern` is matched by a non-backtracking engine — see `compilePattern` — so
421+
* no pattern can blow up on any input. Two budgets bound total work on top of
422+
* that: a character budget and a cumulative match-time budget. Neither counts
423+
* the blob-store I/O this scan awaits, so a slow-but-legitimate grep is not
424+
* truncated merely for being slow.
403425
*/
404426
export async function grepSpans(
405427
spans: TraceSpan[],
@@ -419,7 +441,7 @@ export async function grepSpans(
419441
maxSlicesScanned: opts?.maxSlicesScanned ?? DEFAULT_MAX_SLICES_SCANNED,
420442
maxScannedChars: opts?.maxScannedChars ?? DEFAULT_MAX_SCANNED_CHARS,
421443
matchTimeBudgetMs: opts?.matchTimeBudgetMs ?? DEFAULT_MATCH_TIME_BUDGET_MS,
422-
regex: compiled.regex,
444+
find: compiled.find,
423445
}
424446

425447
const walk = async (list: TraceSpan[]): Promise<void> => {

apps/sim/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@
194194
"posthog-node": "5.28.9",
195195
"pptxgenjs": "4.0.1",
196196
"prismjs": "^1.30.0",
197+
"re2js": "2.8.6",
197198
"react": "19.2.4",
198199
"react-dom": "19.2.4",
199200
"react-hook-form": "^7.54.2",

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)