Scope: Every
re.compile,re.match,re.search,re.sub,re.findall, andre.splitcall inforgelm/andtests/. Why this exists: Phase 11/11.5/12 review cycles produced ~10 distinct SonarCloud / reviewer findings on regex correctness, ReDoS exposure, and code-smell patterns. This standard codifies the lessons so the next regex we ship doesn't trip the same flags. Enforced by: Code review checklist (in code-review.md) + the ReDoS guard in logging-observability.md when audit / ingest extends.
These come straight from real findings on the ForgeLM codebase. If a new regex breaks one, fix the regex (or fail review).
In Python, \w is not the same as [A-Za-z0-9_]. By default \w matches the full Unicode \p{Word} class — every letter in every script (ş, é, 中, Ω, …) plus all Unicode digits. [A-Za-z0-9_] is strictly ASCII.
import re
re.findall(r"\w+", "ünicode türkçe") # ['ünicode', 'türkçe']
re.findall(r"\w+", "ünicode türkçe", re.ASCII) # ['nicode', 't', 'rk', 'e']
re.findall(r"[A-Za-z0-9_]+", "ünicode türkçe") # ['nicode', 't', 'rk', 'e']Rule of thumb:
| Input shape | Use |
|---|---|
| Tokens / credentials / IDs whose grammar is ASCII-only (AWS keys, GitHub PATs, JWTs, base64) | \w with flags=re.ASCII, or the explicit [A-Za-z0-9_] |
| Natural-language text where any script counts (PII regex on Turkish names, multilingual prose) | bare \w (Unicode-aware) |
| Mixed identifier + free text | be explicit — pick one and document why |
Sonar python:S6353 only fires on bare [A-Za-z0-9_] — but if you adopt \w, say what you mean about Unicode. The two failure modes are (a) [A-Za-z0-9_] rejecting a legitimate Turkish customer name in PII detection, and (b) bare \w matching é inside a token regex that should reject malformed inputs.
# RIGHT for an ASCII-only credential — explicit re.ASCII flag.
# The standard's recommendation is the shorthand, but only with the flag
# so Unicode word chars don't leak into the match universe.
"github_token": re.compile(r"\b(?:ghp|gho)_\w{20,}", flags=re.ASCII),
# RIGHT for prose where Turkish / French / etc. should match.
_WORD_PATTERN = re.compile(r"\b[\w']+\b", re.UNICODE) # explicit, intentional
# ALSO RIGHT — explicit ASCII class without flag, no ambiguity.
"github_token_v2": re.compile(r"\b(?:ghp|gho)_[A-Za-z0-9_]{20,}"),If you genuinely need to exclude underscore (rare), use [A-Za-z0-9] and document why.
[ ] is a one-character class wrapping a single space. It's identical to a literal space, just slower for the engine and noisier to read. SonarCloud python:S6328.
# WRONG — flagged in forgelm/ingestion.py (Phase 12 review round 2)
re.compile(r"^[ ]{0,3}(#{1,6})\s+(.+?)$", re.MULTILINE)
# RIGHT
re.compile(r"^ {0,3}(#{1,6})\s+(.+?)$", re.MULTILINE)Same applies to [a], [\.], [\\] — strip the character class.
If the practical max is small, write it: {1,6} instead of +. Bounded quantifiers eliminate a whole class of ReDoS shapes and document the actual contract.
# WEAK — accepts arbitrary-depth nesting; ATX heading depth is ≤ 6 in CommonMark
re.compile(r"^(#+)[ \t]+(.+)$")
# STRONG — bounded to the spec
re.compile(r"^(#{1,6})[ \t]+(.+)$")This is the #1 ReDoS shape we keep hitting. When two * / + / *? / +? quantifiers can both consume the same characters (overlapping character classes, or . matching everything), the engine has to try every split — O(n²) on lines that don't ultimately match.
# WRONG — ReDoS-prone. Phase 12 audit confirmed: 100ms at n=2000, 600ms at n=5000.
# `[ \t]+`, `(.+?)`, and `[ \t]*$` can all match a space — engine tries every split.
re.compile(r"^ {0,3}(#{1,6})[ \t]+(.+?)[ \t]*$", re.MULTILINE)
# RIGHT — anchor the body's first/last char on non-whitespace.
# Now `[ \t]+` and `[ \t]*` can't compete with the body capture.
re.compile(r"^ {0,3}(#{1,6})[ \t]+(\S(?:[^\n]*\S)?)[ \t]*$", re.MULTILINE)The fix pattern: replace (.+?) between two whitespace runs with (\S(?:[^\n]*\S)?) — non-whitespace anchor at both ends, negated character class [^\n] in the middle.
\s matches [ \t\n\r\f\v] — including newlines. Under re.MULTILINE this almost always introduces ambiguity that the engine has to backtrack through, especially when the regex is anchored with ^…$.
# WRONG — `\s` overlaps with newlines under MULTILINE
re.compile(r"^(\w+)\s+(.+)$", re.MULTILINE)
# RIGHT — explicit "horizontal whitespace only"
re.compile(r"^(\w+)[ \t]+(.+)$", re.MULTILINE)\s is acceptable in single-line contexts where newlines genuinely should count (rare in our codebase — the audit / ingest pipelines all process pre-split text).
This combination is the SonarCloud python:S5852 "polynomial runtime" trigger. Even when CPython's regex engine handles it well in practice, the static analyser flags every instance.
# WRONG — Sonar flagged data_audit.py L896 (Phase 12 review round 2.5).
# The `(?P=fence)` back-reference + lazy `.*?` + DOTALL is a textbook ReDoS shape.
_CODE_FENCE_BLOCK = re.compile(
r"^ {0,3}(?P<fence>`{3,}|~{3,})[^\n]*\n.*?^ {0,3}(?P=fence)[ \t]*$",
re.MULTILINE | re.DOTALL,
)
# RIGHT — replace the regex with a per-line state machine.
# Same logic; provably O(n); identical behaviour pinned by tests.
def _strip_code_fences(text: str) -> str:
out, open_fence = [], None
for line in text.splitlines(keepends=True):
...The principle: when a regex with a back-reference needs to span multiple lines, write it as a state machine. The line-walker pattern in forgelm/ingestion.py::_markdown_sections and forgelm/data_audit/_quality.py::_strip_code_fences is the model.
^ and $ mean different things with and without re.MULTILINE. Pick one and document the intent.
re.MULTILINEwhen you're working line-by-line on a multi-line string. Use[^\n]instead of.for the body (avoids overlap with^/$).- No
re.MULTILINEwhen the input is one logical line at a time (passed throughfor line in text.splitlines()already). Then^/$are start / end of the whole string.
^.* invites the engine to backtrack .* for every failure. Pick the regex that matches your actual intent — and if you only need a fixed-prefix check, skip regex entirely:
# WRONG — `^.*foo` against a line that doesn't contain "foo" walks the whole line.
re.compile(r"^.*ERROR:")
# RIGHT — match only lines that *start* with "ERROR:".
re.compile(r"^ERROR:") # or, even better: text.startswith("ERROR:")
# RIGHT — match "ERROR:" anywhere in the string (no anchor, no `^.*`).
re.compile(r"ERROR:") # equivalent to the unanchored search intentAvoid clever-looking variants like r"^[^E]*ERROR:" — [^E]* matches zero characters when the line begins with E, so "EEE ERROR: boom" fails to match even though ERROR: is present. Spell out the intent instead.
For "starts with X", just use text.startswith("X"). Regex isn't the answer to every string question.
Some regexes consume operator-controlled input (e.g. audit / ingest walking user-provided JSONL or PDFs). Adversarial 100K-row corpora are a realistic threat for a CI/CD pipeline that runs ForgeLM on a webhook trigger.
For any regex that runs on operator-controlled input:
- Verify linearity empirically — measure scaling, not absolute milliseconds. Run the regex at multiple input sizes (e.g. 1K / 5K / 10K characters of pathological input) and take the median of N runs (we use 5 in
tests/test_phase12_review_fixes.py) to absorb shared-CI jitter. Acceptable: roughly linear growth — doubling the input doubles the time. Broken: super-linear (≥ ~3× on a 2× input) or any explosive jump. Don't pin a hard ms cutoff in the standard; what matters is the shape of the curve, not the wall-clock on whoever's laptop. Existing ReDoS-regression tests assert generous absolute bounds (≤ 100 ms / ≤ 1 s on 10K input) only as a safety floor — a real ReDoS blows past those by orders of magnitude. - Or replace with a non-regex parser. State machines (line walkers) are O(n) by construction. We use them in
forgelm/data_audit/_quality.py::_strip_code_fencesandforgelm/ingestion.py::_markdown_sectionsprecisely because the regex equivalents tripped SonarCloud.
The pathological-input benchmark for our regexes:
import re, time
pat = re.compile(r"YOUR_PATTERN")
for n in [1_000, 5_000, 10_000]:
payload = "ADVERSARIAL_BUILDER" * n
t0 = time.perf_counter()
pat.search(payload)
elapsed_ms = (time.perf_counter() - t0) * 1000
print(f"n={n}: {elapsed_ms:.2f} ms")Linear scaling on doubling input → safe. Quadratic / exponential scaling → broken; fix the regex or replace with a parser.
Test files that exercise security-sensitive regexes (PII, credentials, JWTs) inevitably contain credential-shaped strings. Repo-wide secret scanners (gitleaks, trufflehog, GitGuardian) treat full literals as leaks even when they're test fixtures.
Build secret-shaped fixtures from inert fragments at runtime. The regex still has to match the canonical shape; no full literal credential lives in the source tree.
# WRONG — gitleaks flags this as a real AWS key leak.
text = "config: aws_access_key_id=AKIAIOSFODNN7EXAMPLE end"
# RIGHT — module-level fragment-built constant.
FAKE_AWS_KEY: str = "AKIA" + "IOSFODNN7" + "EXAMPLE"
text = f"config: aws_access_key_id={FAKE_AWS_KEY} end"The same applies to PEM block markers in regex source (we split r"-----" + r"BEGIN " + r"..." in forgelm/data_audit/_secrets.py::_SECRET_PATTERNS for this reason).
Before opening a PR that touches regex:
- No unintentional
[A-Za-z0-9_](use\w); explicit ASCII-only classes are permitted for deliberate ASCII-only grammars when paired withre.ASCIIor a documented justification (see Rule 1 —\w+re.ASCIIand an explicit ASCII class are both listed as RIGHT). - No single-char character classes (use the character).
- Quantifiers bounded where the spec allows (
{1,6}not+). - No two competing unbounded quantifiers over the same character class.
- No
.*?+ back-reference +DOTALL(replace with a state machine). -
re.MULTILINEmatches the actual input shape — line-by-line vs. whole-string. - If the regex runs on operator-controlled input: linearity verified empirically — pathological benchmark at 1K / 5K / 10K characters, median of N runs (we use 5), and approximately linear growth across sizes (per the methodology in ReDoS exposure budget).
- Test fixtures with credential-shaped strings built from inert fragments.
- coding.md — overall Python style.
- code-review.md — the
regexline item in the review checklist. - testing.md — how to add ReDoS regression tests.
Each rule above traces back to a concrete review finding:
| Rule | Finding (file:line) | Phase |
|---|---|---|
[A-Za-z0-9_] → \w |
forgelm/data_audit/_secrets.py:_SECRET_PATTERNS["github_token"] |
12 round 2 |
| Single-char class | ingestion.py:_MARKDOWN_HEADING_PATTERN |
12 round 2 |
| Two competing quantifiers | ingestion.py:_MARKDOWN_HEADING_PATTERN |
12 round 2.5 (ReDoS confirmed) |
\s overlap with \n |
ingestion.py:_MARKDOWN_HEADING_PATTERN (early Phase 12) |
12 round 1 |
.*? + back-ref + DOTALL |
forgelm/data_audit/_quality.py:_strip_code_fences (the _CODE_FENCE_BLOCK regex named in Rule 6 above was eliminated outright by the state-machine rewrite; _strip_code_fences + its _is_code_fence_open / _is_code_fence_close helpers are the current implementation) |
12 round 2.5 |
| Test fixture fragmentation | tests/test_data_audit_phase12.py, tests/test_ingestion_phase12.py |
12 round 2 |
| PEM marker fragmentation | forgelm/data_audit/_secrets.py:_SECRET_PATTERNS["openssh_private_key"] |
12 round 2 |
Note: these findings predate the Phase 12.6 data_audit.py → forgelm/data_audit/
sub-package split (see architecture.md "Module topology");
citations above use the current post-split paths. tests/test_data_audit_phase12.py
retains its historical name but exercises the split modules.