Guard Regex

Regex-pattern content-safety guard — refuses user-supplied pattern strings that exhibit catastrophic-backtracking (ReDoS) shapes BEFORE the framework compiles them with new RegExp(...). Operator-untrusted patterns flow into search filters, allow-lists, route matchers, and form validators; this primitive screens them so a hostile input can't pin a CPU at 100% inside the regex engine. KIND=identifier; the gate consumes ctx.identifier (or ctx.pattern) and refuses on hostile shapes. Composes with framework parsers (b.safeJson / b.safeBuffer / route helpers) so any operator-fed pattern hits the guard first.

Threat catalog: nested quantifiers ((a+)+, (a*)+, (.+)+ — the canonical ReDoS class, e.g. CVE-2024-21538 cross-spawn and CVE-2022-25929 chartjs-adapter-luxon); alternation-with- quantifier ((a|a)*, (\d|\d{2})*) where two branches can match at the same position and the overlap amplifies search paths — an alternation whose branches cannot start on the same character is the character class it is written out long-hand as, and passes; quantifier-inside-lookaround ((?=.*+), (?!a*)) — catastrophic in some engines; bounded repetition with a large upper bound (gated by maxBoundedRepeat); per-pattern byte cap to defend against parser-stage DoS; BIDI override / zero-width / C0 control / null-byte universal refuse.

Profiles: strict / balanced / permissive. Compliance postures: hipaa / pci-dss / gdpr / soc2. Operators select via { profile: "strict" } or { compliancePosture: "hipaa" }; postures overlay on top of the profile baseline. Nested-quantifier rejection holds at every profile — the catastrophic class is never an operator opt-in.

Pattern strings can't be repaired safely — sanitize either passes through clean input or throws GuardRegexError; the gate returns serve / audit-only / refuse (no sanitize action). Detector regexes themselves are length-bounded by maxPatternBytes so the screener can't be DoS'd by its own inputs, and the unambiguity analysis spends a fixed work budget — a pattern too expensive to reason about exhausts it and stays refused, so cost cannot buy leniency.

b.guardRegex.validate(input, opts) #

stable0.7.13hipaapci-dssgdprsoc2
{
  profile:                "strict"|"balanced"|"permissive",
  compliancePosture: "hipaa"|"pci-dss"|"gdpr"|"soc2",
  bidiPolicy:             "reject"|"audit"|"allow",
  controlPolicy:          "reject"|"audit"|"allow",
  nullBytePolicy:         "reject"|"audit"|"allow",
  zeroWidthPolicy:        "reject"|"audit"|"allow",
  nestedQuantPolicy:      "reject"|"audit"|"allow",
  alternationQuantPolicy: "reject"|"audit"|"allow",
  boundedRepeatPolicy:    "reject"|"audit"|"allow",
  lookaroundQuantPolicy:  "reject"|"audit"|"allow",
  consecutiveStarPolicy:  "reject"|"audit"|"allow",
  nestedExtglobPolicy:    "reject"|"audit"|"allow",
  inputKind:              "regex"|"glob",
  maxBoundedRepeat:       number,
  maxConsecutiveStars:    number,
  maxPatternBytes:        number,
  maxBytes:               number,
  maxRuntimeMs:           number,
}

Inspect a user-supplied regex pattern string and return an aggregated issue list. Pure inspection — never throws on hostile patterns; caller decides what to do with the issues. The ok flag is true only when zero critical / high issues fire. Throws GuardRegexError("regex/bad-opt") when a numeric opt is non-finite / negative (config-time mistake by the operator).

var clean = b.guardRegex.validate("^[a-z]+$", { profile: "strict" });
clean.ok;                                          // → true

var hostile = b.guardRegex.validate("(a+)+b", { profile: "strict" });
hostile.ok;                                        // → false
hostile.issues.some(function (i) { return i.kind === "nested-quantifier"; });  // → true

b.guardRegex.sanitize(input, opts) #

stable0.7.13hipaapci-dssgdprsoc2
{
  profile:                "strict"|"balanced"|"permissive",
  compliancePosture: "hipaa"|"pci-dss"|"gdpr"|"soc2",
  nestedQuantPolicy:      "reject"|"audit"|"allow",
  alternationQuantPolicy: "reject"|"audit"|"allow",
  boundedRepeatPolicy:    "reject"|"audit"|"allow",
  lookaroundQuantPolicy:  "reject"|"audit"|"allow",
  consecutiveStarPolicy:  "reject"|"audit"|"allow",
  nestedExtglobPolicy:    "reject"|"audit"|"allow",
  inputKind:              "regex"|"glob",
  maxBoundedRepeat:       number,
  maxConsecutiveStars:    number,
  maxPatternBytes:        number,
}

Pass-through-or-throw. Regex patterns cannot be safely repaired (stripping a + from a quantifier silently changes match semantics); this primitive returns the input unchanged when no critical or high issue fires, otherwise throws GuardRegexError with the offending rule id (e.g. regex.nested-quantifier, regex.lookaround-quantifier, regex.bounded-repeat-cap). Operators that need a "best-effort cleanup" semantic should reject the input at the boundary instead.

var safe = b.guardRegex.sanitize("^[a-z]+$", { profile: "strict" });
safe;                                              // → "^[a-z]+$"

try {
  b.guardRegex.sanitize("(a+)+b", { profile: "strict" });
} catch (e) {
  e.code;                                          // → "regex.nested-quantifier"
}

b.guardRegex.gate(opts) #

stable0.7.13hipaapci-dssgdprsoc2
{
  profile:                "strict"|"balanced"|"permissive",
  compliancePosture: "hipaa"|"pci-dss"|"gdpr"|"soc2",
  name:                   string,    // override gate name in audit emissions
  nestedQuantPolicy:      "reject"|"audit"|"allow",
  alternationQuantPolicy: "reject"|"audit"|"allow",
  boundedRepeatPolicy:    "reject"|"audit"|"allow",
  lookaroundQuantPolicy:  "reject"|"audit"|"allow",
  consecutiveStarPolicy:  "reject"|"audit"|"allow",
  nestedExtglobPolicy:    "reject"|"audit"|"allow",
  inputKind:              "regex"|"glob",
  maxBoundedRepeat:       number,
  maxConsecutiveStars:    number,
  maxPatternBytes:        number,
}

Build a b.gateContract gate that screens ctx.identifier (or ctx.pattern) before any compilation step. Action chain: serve (no issues) → audit-only (warn-only) → refuse (any critical or high). No sanitize action — pattern strings cannot be repaired. Compose into framework parsers / form validators / route matchers so operator-fed patterns hit the guard before reaching new RegExp().

var gate = b.guardRegex.gate({ profile: "strict" });

gate.check({ identifier: "(a+)+b" }).then(function (rv) {
  rv.ok;                                           // → false
  rv.action;                                       // → "refuse"
});

gate.check({ identifier: "^[a-z]+$" }).then(function (rv) {
  rv.action;                                       // → "serve"
});

b.guardRegex.assertSafe(input, label?, ErrorClass?, code?, opts?) #

stable0.15.39
{
  profile:              string,   // guardRegex profile (default: "strict")
  boundedRepeatPolicy:  string,   // default: "allow" (large bounded repeats are linear)
  unanchoredScanPolicy: string,   // "reject" at strict, "audit" at balanced, "allow" at permissive
}

Screen an already-compiled RegExp (or a raw pattern string) for catastrophic-backtracking (ReDoS) shapes, throwing if the pattern is unsafe.

Screening asks whether a pattern LOOKS dangerous, which is a different question from running it safely. If what you need is to match an operator's pattern against request data, b.regexLinear.compile runs it in time proportional to the subject whatever the pattern is, and needs no screening at all — no shape it accepts can be made to backtrack. Screening is for the cases where the platform engine must do the matching: a pattern handed to a library, to String.prototype.replace, or to anything else that takes a RegExp. The two are complements, and the runner names the constructs it cannot take (backreferences, lookaround) so the choice between them is visible rather than implied. This is the config-time guard for request-lifecycle code that matches an operator-supplied regex against attacker-controlled input (User-Agent, Origin, request path, form field, HELO) — an accidentally-catastrophic operator pattern would otherwise be a per-request DoS once a hostile input triggers the backtracking.

Pass a RegExp instance (its .source is screened) or a pattern string. On a hostile shape it throws ErrorClass(code, ...) when an error class is supplied, otherwise the underlying GuardRegexError. Returns the input unchanged on success.

By default it rejects the catastrophic-backtracking classes — nested, alternation-with, and lookaround quantifiers — but ALLOWS large/open bounded repeats ({8,}, {n,m}): a single counted repeat is linear, not exponential, and legitimate patterns (e.g. a hex hash of 8+ digits) use them. Pass an explicit opts to override.

What it can and cannot tell you. Two costs decide what a match against hostile input is worth, and the analysis reaches both, but by different means and with different confidence.

The first is what one match attempt costs — whether a repetition's parts compete for the same characters, so the engine explores many ways to divide the input between them. That is the backtracking analysis, and it is conservative by construction: a pattern it cannot characterize is refused rather than waved through. It is not a decision procedure, though. It proves unambiguity for the shapes it knows and refuses the rest, so a pattern that is in fact linear can still be turned away — the refusal names the shape, and rewriting to a form it can prove (a distinct leading character per branch, a separator no other part matches) is usually a small edit.

The second is how many attempts there are. An unanchored pattern is retried at every position in the subject, and when it can consume an unbounded amount before reaching something that must match, each attempt walks the rest of the input — quadratic overall, with no ambiguity anywhere for the first analysis to find. That is reported separately as regex.unanchored-scan, under unanchoredScanPolicy, so an operator who bounds the subject length instead can turn it off without giving up the backtracking classes. Anchoring the pattern, or compiling it sticky, removes the cost outright.

Neither answers the question a running system actually asks, which is how long THIS match will take on THIS input. Screening the pattern removes the shapes whose cost explodes; it does not make an unbounded subject safe. Where the input is attacker-controlled, cap its length as well.

b.guardRegex.assertSafe(/^[a-z]+$/);            // ok — returns the RegExp
b.guardRegex.assertSafe(/\.[a-f0-9]{8,}\./);    // ok — a single bounded repeat is linear
try { b.guardRegex.assertSafe(/((a)+)+$/); }    // throws — nested quantifier
catch (e) { e.code; }                           // → "regex/unsafe-pattern"

b.guardRegex.assertStateless(input, label?, ErrorClass?, code?) #

stable0.20.30

Refuse a RegExp that carries the g or y flag. With either flag, test() and exec() start at the lastIndex the previous call left. A pattern kept in configuration and run once per request, row or file then gives an answer that depends on the value before it: a /^alice$/g pattern fails on every second "alice", and a sticky pattern matches only at the offset where the previous match ended. Every framework option that keeps an operator's RegExp and runs it on later calls passes it through this check when the option is accepted.

On a flagged RegExp it throws ErrorClass(code, ...) when an error class is supplied, and otherwise a GuardRegexError with code regex/stateful-pattern. It returns a pattern string, or a RegExp without either flag, unchanged.

b.guardRegex.assertStateless(/^[a-z]+$/i);           // returns the RegExp
try { b.guardRegex.assertStateless(/^[a-z]+$/g); }   // throws: g flag
catch (e) { e.code; }                                // → "regex/stateful-pattern"

b.guardRegex.compliancePosture(name) #

stable0.7.13hipaapci-dssgdprsoc2

Look up a compliance-posture overlay by name (one of "hipaa" / "pci-dss" / "gdpr" / "soc2"). Returns a fresh clone of the posture overlay so the caller may mutate it freely without disturbing the shared table. Throws GuardRegexError with code "regex.bad-posture" when the name is not one this guard maps. Wired by gateContract.defineGuard through gateContract.lookupCompliancePosture, so the clone semantics and error code are identical across every guard in the family.

var posture = b.guardRegex.compliancePosture("hipaa");
posture;                                             // → overlay clone (mutable)

try {
  b.guardRegex.compliancePosture("not-a-regime");
} catch (e) {
  e.code;                                            // → "regex.bad-posture"
}

b.guardRegex.buildProfile(opts) #

stable0.7.13
{
  extends:   string|string[],   // base profile name(s) to compose
  ...:       any guard key,      // inline override of resolved keys
}

Compose a derived profile from one or more named bases plus inline overrides, resolving names through this guard's own PROFILES table. opts.extends is a base profile name ("strict" / "balanced" / "permissive") or an array of names — later entries shadow earlier ones, and inline opts keys win last. Wired by gateContract.defineGuard through gateContract.makeProfileBuilder, so operator-defined profiles stay traceable to a baseline instead of a hand-typed dictionary.

var custom = b.guardRegex.buildProfile({ extends: "strict" });
custom;                                              // → composed profile object

b.guardRegex.loadRulePack(pack) #

stable0.7.13

Register an operator-supplied rule pack with this guard's rule-pack registry. The pack is identified by pack.id (a non-empty string) and stored for later dispatch by gates that opt in via opts.rulePackId. Returns the pack unchanged on success; throws GuardRegexError with code "regex.bad-opt" when pack is missing or pack.id is not a non-empty string. Wired by gateContract.defineGuard through gateContract.makeRulePackLoader, so storage shape and validation are identical across the family.

var pack = b.guardRegex.loadRulePack({ id: "tenant-policy", rules: [] });
pack.id;                                             // → "tenant-policy"

b.guardRegex.resolveOpts(opts?) #

stable0.7.13
{
  profile:           string,    // one of PROFILES; default this guard's default
  compliancePosture: string,    // overlay one of hipaa/pci-dss/gdpr/soc2
}

Resolve caller opts against this guard's PROFILES + compliance-posture overlays into the fully-defaulted option set the guard runs on — the same resolution validate / sanitize / gate apply internally. Wired by gateContract.defineGuard from the guard's binding config (profiles / postures / defaults / error class), so a guard's bespoke gate calls resolveOpts instead of re-declaring the per-guard resolver wrapper. Throws GuardRegexError with code "regex.bad-opt" / "regex.bad-posture" on an unknown profile or posture name.

var resolved = b.guardRegex.resolveOpts({ profile: "strict" });
resolved.profile;                                    // → "strict"

Last updated 2026-10-12T14:21:56.879Z by seeder.