Skip to content

Fix regex injection in StringDetector word-match (re.escape)#1882

Closed
AUTHENSOR wants to merge 1 commit into
NVIDIA:mainfrom
AUTHENSOR:fix/stringdetector-regex-escape
Closed

Fix regex injection in StringDetector word-match (re.escape)#1882
AUTHENSOR wants to merge 1 commit into
NVIDIA:mainfrom
AUTHENSOR:fix/stringdetector-regex-escape

Conversation

@AUTHENSOR

Copy link
Copy Markdown

Fix regex injection in StringDetector word-match (re.escape)

Summary

StringDetector's matchtype="word" path builds its match regex by raw
interpolation
: re.search(r"\b" + s + r"\b", text). Substrings containing
regex metacharacters are treated as regex, not literals. This affects the 18
Surge/Ofcom safety detectors
in unsafe_content.py that use
matchtype="word" with externally-sourced profanity/hate-speech word lists —
two of those lists contain metacharacters, causing crashes and silent
mis-matches in a production safety scanner.

The fix is re.escape(s) so triggers are matched literally.

The bug

# garak/detectors/base.py, StringDetector.detect (before)
elif self.matchtype == "word":
    if re.search(r"\b" + s + r"\b", output_text):   # <- raw interpolation
        match = True

re.escape is never applied, so the trigger substring s is compiled as a
regex. Two failure modes, both confirmed against the bundled Surge profanity
list
(2,105 terms):

1. Crash (re.error)

Any trigger that is an invalid regex aborts detect() for the whole attempt:

>>> re.search(r"\b(result\b", "text")   # trigger "(result"
re.error: missing ), unterminated subpattern
>>> re.search(r"\b[\b", "text")          # trigger "["
re.error: unterminated character set

2. Silent mis-match (false positives / negatives)

The Surge list contains 25 terms (19 unique) with regex metacharacters.
Each compiles to a regex that matches the wrong text:

Surge-list term Compiles as Matches (wrongly) Example false-positive
c*nt c* nt (0+ c's) the word "nt" "nt" → flagged profane
bi+ch bi+ ch (1+ i's) "bich", "biich"
s.o.b. s.o.b. (. = any char) "sxobx"
pu$sy pu$ sy (end-anchor) nothing real term missed
twa+ twa+ (1+ a's) "twa", "twaaa"

The worst case: c*nt matches the benign English word "nt", so any model
output containing the standalone word "nt" trips the SurgeProfanitySexual
detector. The safety scan produces wrong verdicts.

Reproduction (verified against the real detector)

from garak.detectors.unsafe_content import SurgeProfanitySexual
from garak.attempt import Attempt, Message

d = SurgeProfanitySexual()
a = Attempt(prompt=Message("test", lang="en"))
a.outputs = [Message(t, lang="en") for t in ["Just the word: nt", "The model said: c*nt is bad"]]

# On main:     d.detect(a) == [1.0, 1.0]   <- "nt" FALSE-POSITIVES (c*nt matches "nt")
# After fix:   d.detect(a) == [0.0, 1.0]   <- "nt" correctly misses, "c*nt" literal still hits

The fix

# after
elif self.matchtype == "word":
    if re.search(r"\b" + re.escape(s) + r"\b", search_text):
        match = True

Also refactored case normalization: previously output_text was lowered
inside the substring loop (re-lowered N times). str.lower is idempotent so
it happened to be correct, but it was a latent bug. Now lowered once per output
into a search_text variable.

Tests

Appended to tests/detectors/test_detectors_base.py (11 new tests; the 10
existing tests in that file pass unchanged — confirming no behavior regression):

  • Crash regression (@pytest.mark.parametrize, 5 cases): (result, (,
    [, a{2,1}, (* — each used to raise re.error; now treated as literals.
  • Mis-match regression: c*nt no longer matches "nt"; bi+ch no longer
    matches "bich"; s.o.b. no longer matches "sxobx".
  • Literal still matches: c*nt matches the literal "c*nt" after escape.
  • Backward compat: plain terms ("secret") still match; matchtype="str"
    unaffected (no regex path).
$ python -m pytest tests/detectors/test_detectors_base.py -q
.............................                                           [100%]
21 passed in 0.06s

Impact

  • Affected detectors: all 18 unsafe_content.py classes that set
    matchtype="word" (Surge and Ofcom profanity categories, LDNOOBW,
    SlursReclaimedSlurs), plus any third-party detector subclassing
    StringDetector with the word matchtype.
  • Affected data: 25 of 2,105 Surge-list terms (19 unique) contain regex
    metacharacters. The OFCOM list has 0.
  • Severity: high for integrity — a safety scanner whose detectors fire on
    benign text ("nt") or miss real terms (pu$sy) is unreliable. No security
    impact beyond the scan itself.

Scope

Single-purpose: one fix in StringDetector.detect + tests appended to the
existing test_detectors_base.py. Diff: +112/−4 across 2 files. No changes to
any detector class, probe, or evaluator.

Notes

  • matchtype="str" and startswith paths are unaffected (they use in /
    .startswith, no regex).
  • TriggerListDetector is unaffected (also uses in, no regex).
  • black formatting passes; the matchtype="word" word-boundary semantics
    mean terms starting/ending in non-word chars (like (result) or s.o.b.)
    don't match their own literal under \b — this is a pre-existing property
    of the word matchtype, not changed by this fix (documented in the test
    comments).
  • Verified no behavior change: the 10 pre-existing tests in
    test_detectors_base.py pass identically before/after, and the broader
    detector suite (440 tests) shows no regression.

@AUTHENSOR AUTHENSOR force-pushed the fix/stringdetector-regex-escape branch from 47f5370 to 835e078 Compare June 25, 2026 05:38
StringDetector's matchtype='word' path built a regex via raw interpolation:
re.search(r'\b' + s + r'\b', text). Substrings containing regex metacharacters
were treated as regex, not literals. This affects the 18 Surge/Ofcom safety
detectors (unsafe_content.py) that use matchtype='word' with externally-
sourced profanity/hate-speech word lists.

Two failure modes, both confirmed against the bundled Surge list:
- CRASH: triggers like '(' or '[' (unbalanced) raise re.error, aborting the
  detector.
- MIS-MATCH: 25 Surge-list terms contain metachars. e.g. 'c*nt' compiles as
  'zero-or-more c, then nt' and matches the benign word 'nt' (false
  positive); 'bi+ch' matches 'bich'; 's.o.b.' matches 'sxobx'.

Fix: re.escape(s) before interpolation, so triggers are matched literally.
Also: normalize case once per output (lower() outside the substring loop) —
the previous in-loop lowering was a latent bug masked by lower()'s
idempotency. 11 tests added (regression + fix + backward-compat).

Signed-off-by: John Kearney <johndanielkearney@gmail.com>
@jmartin-tech

Copy link
Copy Markdown
Collaborator

This duplicates #1880 why would this version be better?

@erickgalinkin

Copy link
Copy Markdown
Collaborator

PR seems a little overwrought; Prefer the fix in #1880. Closing.

@AUTHENSOR

Copy link
Copy Markdown
Author

Fair point, the core fix (re.escape(s) in the word-match path) is the same as #1880, and I missed that an open PR already addressed it. Apologies for the noise.

The one additional thing I'd note (happy to raise it on #1880 rather than here): the surrounding case-normalization also has a latent issue, output_text is re-lowered inside the substring loop on every iteration (works only because .lower() is idempotent). The fix in #1880 doesn't touch that, so it's worth a follow-up. The extra test coverage and the confusables table from this PR are also available if useful, but no objection to the minimal fix landing first.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants