Skip to content

Commit a6cf3fd

Browse files
committed
fix: harden allowlist patterns against ReDoS, document match semantics
Review findings: reject quantified groups containing nested quantifiers at compile time (catastrophic backtracking on attacker-influenced entity text), cap pattern length at 512 chars, and skip pattern matching for entities longer than 512 chars (fail-safe: the finding is kept). Match semantics documented as case-sensitive with no Unicode normalization; allowlist entries are operator configuration, never end-user input. Adds regression tests for the rejection heuristic, the smart-engine path, and the redact(entities=..., allowlist=...) guard. Replaces a walrus assignment with a plain one in the litellm adapter.
1 parent 8ad51fe commit a6cf3fd

3 files changed

Lines changed: 81 additions & 13 deletions

File tree

datafog/engine.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -281,11 +281,35 @@ def _filter_entity_types(
281281
return [entity for entity in entities if entity.type in allowed]
282282

283283

284+
# Python's re module backtracks; a quantified group containing another
285+
# quantifier (e.g. ``(a+)+``) can take exponential time on adversarial
286+
# input, and entity text can be attacker-influenced (LLM messages, tool
287+
# output). Reject that construct outright rather than matching under it.
288+
_NESTED_QUANTIFIER = re.compile(
289+
r"\((?:[^()\\]|\\.)*(?<!\\)[+*}](?:[^()\\]|\\.)*\)\s*[+*{]"
290+
)
291+
MAX_ALLOWLIST_PATTERN_LENGTH = 512
292+
# Entities longer than this skip pattern matching (fail-safe: the finding
293+
# is kept, never suppressed) so match time stays bounded.
294+
MAX_PATTERN_SUBJECT_LENGTH = 512
295+
296+
284297
def _compile_allowlist_patterns(
285298
allowlist_patterns: Optional[list[str]],
286299
) -> list["re.Pattern[str]"]:
287300
compiled = []
288301
for raw in allowlist_patterns or []:
302+
if len(raw) > MAX_ALLOWLIST_PATTERN_LENGTH:
303+
raise ValueError(
304+
"allowlist_patterns entries must be at most "
305+
f"{MAX_ALLOWLIST_PATTERN_LENGTH} characters"
306+
)
307+
if _NESTED_QUANTIFIER.search(raw):
308+
raise ValueError(
309+
"allowlist_patterns contains a quantified group with a nested "
310+
f"quantifier ({raw!r}), which risks catastrophic backtracking; "
311+
"rewrite the pattern without nesting quantifiers"
312+
)
289313
try:
290314
compiled.append(re.compile(raw))
291315
except re.error as exc:
@@ -302,8 +326,12 @@ def _apply_allowlist(
302326
) -> list[Entity]:
303327
"""Drop entities whose exact text is allowlisted.
304328
305-
Exact values match the full entity text; patterns must fullmatch it,
306-
so a partial match never suppresses a finding.
329+
Matching semantics, deliberately strict for a security boundary:
330+
exact values are case-sensitive with no Unicode normalization, and
331+
patterns must fullmatch the entity text, so a partial match never
332+
suppresses a finding. Allowlist entries and patterns are operator
333+
configuration; treat them like code and never accept them from end
334+
users.
307335
"""
308336
if not allowlist and not allowlist_patterns:
309337
return entities
@@ -313,7 +341,11 @@ def _apply_allowlist(
313341
entity
314342
for entity in entities
315343
if entity.text not in exact
316-
and not any(pattern.fullmatch(entity.text) for pattern in patterns)
344+
and not any(
345+
pattern.fullmatch(entity.text)
346+
for pattern in patterns
347+
if len(entity.text) <= MAX_PATTERN_SUBJECT_LENGTH
348+
)
317349
]
318350

319351

datafog/integrations/litellm_guardrail.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,10 +197,9 @@ async def async_pre_call_hook(
197197
},
198198
)
199199

200-
self._record_guardrail_logging(
201-
new_data_final := {**data, "messages": new_messages}, total_counts
202-
)
203-
return new_data_final
200+
new_data = {**data, "messages": new_messages}
201+
self._record_guardrail_logging(new_data, total_counts)
202+
return new_data
204203

205204
def _record_guardrail_logging(
206205
self, data: dict, total_counts: dict[str, int]

tests/test_allowlist.py

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,7 @@ def test_allowlisted_value_is_not_reported(self):
2323
assert [e.text for e in result.entities] == [EMAIL]
2424

2525
def test_allowlist_is_exact_not_substring(self):
26-
result = datafog.scan(
27-
f"mail {EMAIL}", engine="regex", allowlist=["jane.doe"]
28-
)
26+
result = datafog.scan(f"mail {EMAIL}", engine="regex", allowlist=["jane.doe"])
2927
assert [e.text for e in result.entities] == [EMAIL]
3028

3129
def test_empty_allowlist_is_noop(self):
@@ -76,6 +74,47 @@ def test_patterns_and_values_combine(self):
7674
assert result.entities == []
7775

7876

77+
class TestReDoSGuards:
78+
def test_catastrophic_pattern_rejected(self):
79+
with pytest.raises(ValueError, match="catastrophic backtracking"):
80+
datafog.scan("text", engine="regex", allowlist_patterns=[r"(a+)+$"])
81+
82+
def test_nested_star_rejected(self):
83+
with pytest.raises(ValueError, match="catastrophic backtracking"):
84+
datafog.scan("text", engine="regex", allowlist_patterns=[r"(.*)*"])
85+
86+
def test_overlong_pattern_rejected(self):
87+
with pytest.raises(ValueError, match="at most"):
88+
datafog.scan("text", engine="regex", allowlist_patterns=["a" * 513])
89+
90+
def test_benign_quantified_group_still_allowed(self):
91+
result = datafog.scan(
92+
f"mail {EMAIL}",
93+
engine="regex",
94+
allowlist_patterns=[r"(abc)+", r".*@example\.com"],
95+
)
96+
assert result.entities == [] # broad pattern suppresses, no rejection
97+
98+
99+
class TestEnginePaths:
100+
def test_smart_engine_applies_allowlist(self):
101+
import warnings as _warnings
102+
103+
with _warnings.catch_warnings():
104+
_warnings.simplefilter("ignore")
105+
result = datafog.scan(f"mail {EMAIL}", engine="smart", allowlist=[EMAIL])
106+
assert result.entities == []
107+
108+
def test_redact_rejects_allowlist_with_explicit_entities(self):
109+
scanned = datafog.scan(f"mail {EMAIL}", engine="regex")
110+
with pytest.raises(ValueError, match="cannot be combined"):
111+
datafog.redact(
112+
f"mail {EMAIL}",
113+
entities=scanned.entities,
114+
allowlist=[EMAIL],
115+
)
116+
117+
79118
class TestPresidioAliases:
80119
def test_email_address_alias(self):
81120
result = datafog.scan(
@@ -85,9 +124,7 @@ def test_email_address_alias(self):
85124

86125
def test_us_ssn_alias(self):
87126
ssn = "856-45-" "6789"
88-
result = datafog.scan(
89-
f"ssn {ssn}", engine="regex", entity_types=["US_SSN"]
90-
)
127+
result = datafog.scan(f"ssn {ssn}", engine="regex", entity_types=["US_SSN"])
91128
assert [e.type for e in result.entities] == ["SSN"]
92129

93130

0 commit comments

Comments
 (0)