Skip to content

Commit 6b13124

Browse files
committed
fix(security): correct three defects in the lookaround split decomposition
A fourth reviewer, given no context beyond "these scanners are new and unreviewed", differential-tested them against the built-in engine over ~62k comparisons and found three real defects in code added an hour earlier. All were reachable through RegexChunker with plausible patterns, and all silently produced different chunks rather than failing. 1. **Top-level alternation was reshaped into a different pattern.** `(?<=\.)\s+|\n\n` means `((?<=\.)\s+)|(\n\n)`, but decomposing it produced `(?:\.)(\s+|\n\n)` — demanding the period before *both* branches. It could lose boundaries (`"Heading\n\nAlpha beta.\nGamma delta."` split in 2, not 3) and invent them. No grouping recovers the original meaning, so patterns whose middle alternates at the top level are now declined outright, which also removes an asymmetry: `\n\n|(?<=\.)\s` already threw. 2. **A capturing group inside the lookbehind stole group 1.** `(?<=(a))b` cut at the assertion instead of the delimiter, and where the group did not participate `start(1)` was -1, so `find` and `test` contradicted each other. The middle is now captured by name, immune to numbering. 3. **Consuming the assertion text swallowed every other boundary.** The compiled form matched `behind + middle + ahead`, so any boundary whose lookahead doubles as the next one's lookbehind was skipped: `(?<=\w)\s+(?=[A-Z])` over `"A B C D"` split 2 of 3 gaps. Each iteration now resumes at the end of the delimiter rather than the end of the match. This was documented as affecting only self-overlapping delimiters; that was wrong and far too narrow. The reviewer confirmed `translateToRe2` and `closingParen` clean across exhaustive escape-state, character-class, and paren-matching probes, and that nothing added is superlinear. One divergence remains and is now documented rather than overstated: when delimiters themselves overlap (a single-character middle whose matches abut, as in `(?<=\w).(?=\w)`), a match starting behind the cursor is dropped instead of emitting the empty segment the built-in engine produces. Splitters that consume whitespace or punctuation between tokens do not overlap and match exactly.
1 parent 6651434 commit 6b13124

2 files changed

Lines changed: 95 additions & 19 deletions

File tree

apps/sim/lib/core/security/linear-regex.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,4 +168,33 @@ describe('compileLookaroundSplit', () => {
168168
])('returns null for %s', (_label, pattern) => {
169169
expect(compileLookaroundSplit(pattern)).toBeNull()
170170
})
171+
172+
it.each([
173+
['leading assertion', '(?<=\\.)\\s+|\\n\\n'],
174+
['empty middle', '(?<=</p>)|<hr>'],
175+
['trailing assertion', 'a|b(?=c)'],
176+
])('rejects %s with top-level alternation rather than reshaping it', (_label, pattern) => {
177+
// `(?<=X)A|B` means `((?<=X)A)|B`; rebuilt as `(?:X)(A|B)` it would demand
178+
// the assertion before both branches. No grouping recovers that, so the
179+
// only correct answer is to decline the pattern.
180+
expect(compileLookaroundSplit(pattern)).toBeNull()
181+
})
182+
183+
it('is unaffected by a capturing group inside the lookbehind', () => {
184+
// The middle is captured by name, so group numbering cannot shift.
185+
expect(compileLookaroundSplit('(?<=(a))b')?.split('xaby')).toEqual(['xa', 'y'])
186+
187+
const optional = compileLookaroundSplit('(?<=(a)|b)c')
188+
expect(optional?.find('bc')).toBe(1)
189+
expect(optional?.test('bc')).toBe(true)
190+
})
191+
192+
it.each([
193+
['(?<=\\w)\\s+(?=[A-Z])', 'A B C D'],
194+
['(?<=\\w)\\s+(?=\\w)', 'a b c d e'],
195+
])('does not consume assertion text between boundaries (%s)', (pattern, doc) => {
196+
// The lookahead of one boundary is the lookbehind of the next. Consuming
197+
// it would swallow every other split.
198+
expect(compileLookaroundSplit(pattern)?.split(doc)).toEqual(doc.split(new RegExp(pattern, 'g')))
199+
})
171200
})

apps/sim/lib/core/security/linear-regex.ts

Lines changed: 66 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,36 @@ interface SplitShape {
150150
ahead: string
151151
}
152152

153+
/**
154+
* True when `pattern` has a `|` outside any group or character class.
155+
*
156+
* A split shape cannot be decomposed when its middle alternates at the top
157+
* level: `(?<=\.)\s+|\n\n` means `((?<=\.)\s+)|(\n\n)`, so the assertion binds
158+
* to the first branch only. Rebuilding it as `(?:\.)(\s+|\n\n)` would require
159+
* the period before *either* branch — a silently different pattern. No grouping
160+
* fixes that, so such patterns are rejected rather than reshaped.
161+
*/
162+
function hasTopLevelAlternation(pattern: string): boolean {
163+
let depth = 0
164+
let inClass = false
165+
for (let i = 0; i < pattern.length; i++) {
166+
const char = pattern[i]
167+
if (char === '\\') {
168+
i += 1
169+
continue
170+
}
171+
if (inClass) {
172+
if (char === ']') inClass = false
173+
continue
174+
}
175+
if (char === '[') inClass = true
176+
else if (char === '(') depth += 1
177+
else if (char === ')') depth -= 1
178+
else if (char === '|' && depth === 0) return true
179+
}
180+
return false
181+
}
182+
153183
/**
154184
* Decompose a split pattern into `(?<=behind) middle (?=ahead)`, with either
155185
* assertion optional. Returns `null` when neither is present (the caller should
@@ -195,6 +225,7 @@ function parseSplitShape(pattern: string): SplitShape | null {
195225
}
196226

197227
if (!behind && !ahead) return null
228+
if (hasTopLevelAlternation(rest)) return null
198229
return { behind, middle: rest, ahead }
199230
}
200231

@@ -208,13 +239,22 @@ function parseSplitShape(pattern: string): SplitShape | null {
208239
* it, `(?<=X)` alone splits after one, and `(?<=[.!?])\s+(?=[A-Z])` — the
209240
* sentence splitter — consumes the whitespace between them.
210241
*
211-
* Returns `null` for negative lookaround, backreferences, or any body RE2
212-
* cannot represent.
242+
* Returns `null` for negative lookaround, backreferences, a middle that
243+
* alternates at the top level, or any body RE2 cannot represent.
244+
*
245+
* Two details make the reconstruction faithful rather than approximate. The
246+
* middle is captured by *name*, so a capturing group inside `behind` cannot
247+
* shift the index out from under it. And each iteration resumes the search at
248+
* the end of the previous delimiter rather than at the end of the whole match,
249+
* so the assertion text is not consumed — without that, every boundary whose
250+
* lookahead text doubles as the next boundary's lookbehind would be swallowed
251+
* (`(?<=\w)\s+(?=[A-Z])` over `A B C D` would split only half the gaps).
213252
*
214-
* Two divergences from the built-in engine, both narrow: RE2 iterates
215-
* non-overlapping matches, so a self-overlapping delimiter (`(?=aa)` over
216-
* `aaaa`) yields fewer boundaries; and `test`/`find` report on the compiled
217-
* form rather than the zero-width assertion position.
253+
* Known divergence: when delimiters themselves overlap — a single-character
254+
* middle whose matches abut, as in `(?<=\w).(?=\w)` — a match starting behind
255+
* the cursor is dropped instead of emitting the empty segment the built-in
256+
* engine would. Splitters that consume whitespace or punctuation between
257+
* tokens do not overlap and are unaffected.
218258
*/
219259
export function compileLookaroundSplit(
220260
pattern: string,
@@ -225,7 +265,7 @@ export function compileLookaroundSplit(
225265

226266
const behind = shape.behind ? `(?:${shape.behind})` : ''
227267
const ahead = shape.ahead ? `(?:${shape.ahead})` : ''
228-
const source = translateToRe2(`${behind}(${shape.middle})${ahead}`)
268+
const source = translateToRe2(`${behind}(?P<mid>${shape.middle})${ahead}`)
229269

230270
let compiled: ReturnType<typeof RE2JS.compile>
231271
try {
@@ -234,24 +274,31 @@ export function compileLookaroundSplit(
234274
return null
235275
}
236276

277+
/** Span of the delimiter itself — what `String.prototype.split` removes. */
278+
const delimiterAt = (text: string, from: number): { start: number; end: number } | null => {
279+
const matcher = compiled.matcher(text)
280+
if (!matcher.find(from)) return null
281+
return { start: matcher.start('mid'), end: matcher.end('mid') }
282+
}
283+
237284
return {
238285
test: (text) => compiled.matcher(text).find(),
239-
find: (text) => {
240-
const matcher = compiled.matcher(text)
241-
return matcher.find() ? matcher.start(1) : -1
242-
},
286+
find: (text) => delimiterAt(text, 0)?.start ?? -1,
243287
split: (text) => {
244288
const segments: string[] = []
245-
const matcher = compiled.matcher(text)
246289
let cursor = 0
247-
while (matcher.find()) {
248-
const from = matcher.start(1)
249-
const to = matcher.end(1)
290+
let searchFrom = 0
291+
while (searchFrom <= text.length) {
292+
const span = delimiterAt(text, searchFrom)
293+
if (!span) break
294+
// Always advance, so a zero-width delimiter cannot spin.
295+
searchFrom = span.end > span.start ? span.end : span.start + 1
250296
// Skip a boundary behind the cursor, or one that would only emit an
251-
// empty leading/trailing segment.
252-
if (from < cursor || (from === cursor && to === cursor) || from >= text.length) continue
253-
segments.push(text.slice(cursor, from))
254-
cursor = to
297+
// empty leading or trailing segment.
298+
if (span.start < cursor || span.start >= text.length) continue
299+
if (span.start === cursor && span.end === cursor) continue
300+
segments.push(text.slice(cursor, span.start))
301+
cursor = span.end
255302
}
256303
segments.push(text.slice(cursor))
257304
return segments

0 commit comments

Comments
 (0)