Last reviewed: 2026-09-15

Direct answer

Treat every new or changed regular expression that processes externally controlled data as an algorithm with a resource budget. A coding-agent patch should pass its merge gate only after the reviewer can demonstrate four things: the input is bounded before matching begins, the pattern avoids ambiguous repetition, static analysis reports no unresolved complexity finding, and adversarial near-miss fixtures complete inside a hard deadline in an isolated test process.

Ordinary functional tests are not enough. The worst case is often a long string that almost matches and then fails near its end. The engine may revisit many possible paths before returning false. The OWASP explanation of regular-expression denial of service illustrates the mechanism with ^(a+)+$: a short run of a characters followed by a failing suffix creates many paths, and each extra repeated character can multiply the work.

The practical gate is layered:

  1. Inventory every changed expression, constructor, validator, parser, and input boundary.
  2. Remove nested quantifiers or overlapping alternatives when the same text can be matched in multiple ways.
  3. Reject overlong input before calling the expression.
  4. Run the language-appropriate static security query.
  5. Exercise valid, invalid, boundary, and near-miss fixtures in a process the test runner can terminate.
  6. Record only sanitized measurements and classifications.

This is an availability control, not a style preference. CWE-1333 describes inefficient regular-expression complexity as potentially exponential and identifies excessive CPU consumption as the primary consequence. It recommends removing backtracking structures, limiting input length, using engine limits where available, and applying static analysis. For JavaScript and TypeScript, the CodeQL inefficient-regular-expression query detects ambiguous repetition and is identified as a high-precision security query. In a Node.js service, expensive synchronous matching can block the event loop, delaying unrelated requests; the Node.js event-loop guidance explains why work performed for one client must remain small.

Who this is for

This guide is for developers reviewing coding-agent changes to parsers, routers, validators, filters, log processors, and request handlers. It is especially relevant when an agent introduces a new expression, rewrites an existing one, removes an input limit, changes a regex engine, or moves matching closer to a public request boundary.

Security reviewers can use the contract below as a narrow pull-request gate. Test engineers can use it to design fixtures that exercise rejection cost rather than checking correctness alone. Platform teams can apply the same evidence format across repositories without prescribing one language or regex engine.

Agent-written code is not a special regex dialect. The operational challenge is that a compact pattern can hide algorithmic behavior while still looking plausible in a large diff. The reviewer therefore needs evidence tied to the changed call site, not a general claim that the full test suite passed.

Key takeaways

  • Find the data path as well as the pattern. A risky expression becomes exploitable when an attacker can control enough input to reach it.
  • Prefer an unambiguous rewrite. A timeout or length limit is a secondary control, not permission to keep an avoidable exponential pattern.
  • Put the input check before the match. A post-match length check cannot protect the expensive operation that already ran.
  • Combine static and dynamic evidence. A scanner can identify known structures; bounded near-miss tests exercise the actual engine and configuration.
  • Run hazardous probes outside the application process. The outer runner must be able to stop a stalled evaluation.
  • Log the expression identifier, fixture class, length, duration, threshold, engine family, and result. Do not log raw request data.

Sources checked

Contract details to verify

Start with a small contract for each changed call site. Keep it in the pull-request description or a test manifest so that another reviewer can reproduce the decision.

SurfaceQuestionRequired evidence
Call siteWhich literal, constructor, dependency, or helper changed?File and line plus a stable expression identifier
InputCan a user, file, message, or upstream service influence the string?Documented origin and a pre-match maximum length
EngineWhich runtime and regex engine perform the match?Engine family in the test result
StructureAre repetitions nested, or do repeated alternatives overlap?Static result and a manual review note
BehaviorDo valid and invalid inputs still produce the intended result?Functional fixture results
CostDoes a growing near-miss input remain within the project budget?Isolated timing results with a hard deadline

Review the changed surface

Do not search only for regex literals. Include dynamic constructors, validation libraries, route definitions, parsing helpers, and dependencies whose configuration accepts patterns. Trace the input backward to its first enforceable boundary. If a pattern itself can be assembled from external input, treat that as a separate injection risk and require an explicit design decision rather than assuming escaping solves the complexity problem.

Flag repeated groups containing another repetition, such as ^(a+)+$, and repeated alternatives that can consume the same characters, such as (a|aa)+$. These are review signals, not a complete parser for regex safety. The decisive question is whether the engine can partition the same input in many ways and then has to explore those choices after a late failure.

A rewrite must preserve the intended language. Removing ambiguity can change which strings match, so retain positive and negative behavior tests around every rewrite.

Define bounded fixtures

For each expression, create at least these fixtures:

  • the shortest representative valid value;
  • the longest permitted valid value;
  • an invalid value that fails near the beginning;
  • a near-miss value that repeats the ambiguous body and fails at the end;
  • a value one unit beyond the declared input limit.

Build the near-miss ladder from small lengths upward. Sizes such as 16, 32, 64, and the maximum permitted length expose growth without requiring giant payloads. Run that ladder only inside the isolated probe. Stop increasing the size as soon as a deadline is reached.

The following validator illustrates two important controls: the repeated segment has an unambiguous hyphen separator, and the length check runs before the expression.

const MAX_SEGMENT_LENGTH = 128;
const ROUTE_SEGMENT = /^[a-z0-9]+(?:-[a-z0-9]+)*$/i;

export function isRouteSegment(value) {
  if (typeof value !== 'string') return false;
  if (value.length === 0 || value.length > MAX_SEGMENT_LENGTH) return false;
  return ROUTE_SEGMENT.test(value);
}

Behavior tests should include both sides of the boundary:

import assert from 'node:assert/strict';
import { isRouteSegment } from './route-segment.js';

const fixtures = [
  { name: 'valid-short', input: 'docs-v2', expected: true },
  { name: 'valid-max', input: 'a'.repeat(128), expected: true },
  { name: 'invalid-prefix', input: '!docs', expected: false },
  { name: 'near-miss', input: `${'a-'.repeat(63)}!`, expected: false },
  { name: 'over-limit', input: 'a'.repeat(129), expected: false }
];

for (const fixture of fixtures) {
  assert.equal(isRouteSegment(fixture.input), fixture.expected, fixture.name);
}

Keep the resource budget in data rather than burying it in a test helper. This sample value is illustrative; calibrate the deadline against the repository’s runner and service objective.

{
  "regex_id": "route_segment",
  "input_limit": 128,
  "probe_lengths": [16, 32, 64, 128],
  "timeout_ms": 250,
  "required_fixtures": [
    "valid-short",
    "valid-max",
    "invalid-prefix",
    "near-miss",
    "over-limit"
  ]
}

Combine static and runtime gates

For JavaScript or TypeScript, enable the js/redos query or a suite that contains it and fail the pull request on a new unresolved finding. Its documented signal is an ambiguous subexpression beneath r* or r+. For another language, use an equivalent rule that understands that language’s engine and data flow.

Static analysis is a strong first gate, but it does not replace runtime evidence. Dynamic construction, library boundaries, engine-specific behavior, or an omitted input bound may escape a pattern-only check. Conversely, a noisy timing result does not prove ambiguity. Preserve both results so the reviewer can distinguish a structural finding from runner variance.

Launch each timing probe in a separate worker or disposable subprocess and enforce the deadline from outside it. Use the same engine family as production. Warm the runner consistently, keep fixture counts fixed, and compare results on similar hardware. A timeout always fails the gate; slower-than-baseline results should trigger review under a repository-owned threshold.

Happy-path operator workflow

  1. The agent changes a route validator and identifies the expression and its call site in the pull-request scope.
  2. The reviewer confirms that the string is limited to 128 characters before matching and that both the valid 128-character input and the 129-character rejection have tests.
  3. Static analysis completes without a new complexity finding.
  4. The isolated probe runs valid, invalid, boundary, and near-miss fixtures at the declared sizes.
  5. Every result matches the behavioral expectation, no probe reaches the 250-millisecond example deadline, and the log contains only sanitized fields.
  6. The reviewer attaches the manifest and result record to the change evidence, then allows the broader test suite to continue.

Error-path operator workflow

  1. Static analysis reports ambiguous repetition, the isolated probe reaches its deadline, a fixture returns an unexpected result, or the probe exits with an execution error.
  2. The gate fails immediately. The outer runner terminates the probe only when the deadline expires; completed mismatches and execution errors retain their actual classification.
  3. The operator records the expression identifier, call site, fixture class, input length, deadline, engine family, gate status, and failure class. The raw input is omitted.
  4. The patch returns to the agent with a focused request: remove the ambiguity, introduce a pre-match bound, restore the expected matching behavior, or replace the regex with a simpler parser.
  5. The reviewer checks the semantic diff because a faster expression that accepts the wrong strings is still incorrect.
  6. Static analysis, boundary tests, near-miss probes, and the full functional suite run again. Prior failure evidence remains attached for comparison.

Keep logs safe and useful

A gate record should support reproduction without copying request contents into CI artifacts. A passing record can look like this:

{
  "event": "regex_gate_result",
  "change_id": "pr-184",
  "regex_id": "route_segment",
  "call_site": "src/router/segment.js:18",
  "fixture_class": "near_miss",
  "input_length": 127,
  "elapsed_ms": 6.8,
  "timeout_ms": 250,
  "expected_outcome": "rejected",
  "outcome": "rejected",
  "gate_status": "pass",
  "failure_class": "none",
  "engine_family": "node-v8"
}

Use outcome for what the runtime probe actually did: matched, rejected, timeout, or execution_error. Set it to timeout only when the external deadline expires. If a completed match result differs from the expectation, preserve the actual matched or rejected outcome, set gate_status to fail, and set failure_class to behavioral_mismatch. For a probe exception or abnormal process exit, use execution_error. A static-analysis finding belongs in the scanner result with a failing gate status; it should not be relabeled as a runtime timeout. Do not add the raw input, request body, full dynamically supplied pattern, or unrelated user identifiers. Store synthetic fixture definitions in the test source and refer to them by stable class and name.

Before approval, verify that every changed call site is represented, the length check precedes the match, the scanner has no unresolved finding, every fixture returns the expected result, no isolated probe times out, and the evidence record contains the declared fields.

Failure modes

  • Testing only successful matches. Catastrophic work frequently appears on a near-match that fails late. Include adversarial rejection cases, not just realistic accepted values.
  • Checking length after matching. The expensive evaluation has already happened. Enforce the maximum at the earliest trusted boundary before any regex call.
  • Treating one fast run as proof. Small inputs can conceal nonlinear growth, and CI timing varies. Use a fixed ladder of input lengths, an external deadline, and a repository-owned baseline.
  • Relying on a scanner alone. Static analysis can find known ambiguous structures, but dynamic construction and dependency internals may require manual tracing and runtime tests.
  • Running a hazardous probe on the service event loop. If the expression stalls, the test can reproduce the same availability problem it is meant to detect. Put the probe behind a terminable execution boundary.
  • Keeping ambiguity because an input cap exists. A bound reduces exposure but can drift or be bypassed at another call site. Remove avoidable ambiguity first, then retain the bound as defense in depth.
  • Changing semantics during the rewrite. A simpler expression may reject previously valid values or accept invalid ones. Preserve explicit positive and negative fixtures around the intended language.
  • Logging the crafted input. Raw payloads add no timing insight and can leak data or inflate artifacts. Log classification, length, timing, engine, and outcome instead.
  • Ignoring patterns supplied through configuration or libraries. A diff may change an option rather than a literal. Review constructors, parser settings, validation rules, and dependency upgrades in the changed surface.
  • Using a universal millisecond threshold. Hardware, runtime versions, and service budgets differ. Own the threshold in the repository, calibrate it on representative runners, and still treat any hard timeout as a failure.

FAQ

What causes catastrophic regex backtracking?

It occurs when a backtracking engine can match part of the input through many equivalent paths and must revisit those choices after a later mismatch. Nested repetition and overlapping alternatives are common signals. The practical risk requires a pattern with costly choices, an input that can fail after substantial work, and enough attacker-controlled length to make that work significant.

Is every nested quantifier exploitable?

No. Structure is a screening signal, while exploitability depends on the engine, ambiguity, input shape, input origin, and maximum length. Review and test the actual call site instead of approving or rejecting a pattern solely by appearance.

Is a maximum input length enough?

A verified pre-match limit is an important mitigation, especially when the allowed strings are inherently short. It should accompany an unambiguous pattern where possible. Also test the boundary itself so a later refactor cannot silently move or remove the check.

Can an in-process timer interrupt a slow synchronous match?

A timer scheduled on the same event loop cannot protect other work while synchronous matching occupies that loop. Use an engine-supported execution limit where available or place the probe behind a worker or subprocess that an outer controller can terminate.

How should I construct a near-miss fixture?

Identify the portion that can be consumed in more than one way, repeat a synthetic character sequence accepted by that portion, and add a suffix that forces rejection near the end. Start with short values and grow only inside the bounded runner. The fixture should contain no production request data.

How do I know a repair is complete?

Confirm that the ambiguity was removed or tightly bounded, static analysis is clear, all functional fixtures preserve intended behavior, the maximum valid input is accepted, the over-limit case fails before matching, the near-miss ladder stays inside budget, and the sanitized evidence can be reproduced by another reviewer.

Reader next step

Open the next coding-agent pull request that changes validation or parsing code. List every regex call site in the diff, trace its input, add a pre-match limit, and create valid, invalid, maximum-valid, over-limit, and near-miss fixtures. Run the relevant static query and the bounded probe before the full suite.

Then add the result to a broader pull-request security gate . If the input space is difficult to enumerate, generate stronger fixtures with property-based testing . Do not merge while an ambiguity finding, missing pre-match bound, semantic regression, execution error, or probe timeout remains unresolved.