fix(output): 19 quadratic regex runs on the output path, worst ~5.7h at the cap
The output gate claimed LLM10 self-safety on the grounds that its patterns have no nested quantifiers. True, and irrelevant: nesting is not what makes these blow up. A run in front of a REQUIRED literal, reachable from a short anchor, is enough -- crafted input repeats the anchor and never supplies the literal, so every start position rescans the tail. Quadratic, not exponential, and the max_scan_chars cap does not help: it bounds the input, and quadratic work on a bounded input is still hours. Measured, not argued. `<a:` x 100_000 took 23.4s in AUTOLINK_RE alone; the composed gate on that payload took 458.7s, extrapolating to ~5.7 hours at the 1_000_000-char input the gate itself accepts. Size-matched ordinary prose runs 0.31s, so the separation is 18x-660x -- unlike the blob in the neighbouring test, which is the *faster* side of prose and never exercised backtracking. Two fixes, chosen per pattern rather than uniformly: - active_content + lexicon JSON (15 runs): exclude the character that opens the pattern's own anchor (`[` for markdown, `<` for tags), so a run cannot reach past the next start position and the per-start costs telescope. Verified to cost no recall: long URLs, long alt text, and `<` inside a quoted attribute all still match. Bounding instead would have been linear too but wrong here -- the content is attacker-controlled, so padding past a bound would be a one-line bypass of the EchoLeak class this table exists to catch. - connstr egress (4 runs): bound the password at MAX_CONNSTR_VALUE. The exclusion fix is unavailable -- the anchor character is `/` and passwords containing `/` are the common case (measured: they match today). The residual miss is a credential over 256 chars; a token that long is still caught by egress:jwt-token. hybrid-xss:script-tag had neither option: its run is the script BODY, which may legitimately contain `<`. It now matches the opening tag and drops the `</script>` requirement. That also closes a fail-open -- `<script>alert(1)` unclosed was silently missed -- at the cost of flagging prose that merely mentions `<script>`, now documented. Found by the composed-gate test staying red after every individual scanner was already linear: the lexicon's six html-obfuscation patterns were the remaining 813x. A per-scanner test alone would have shipped that. 662 passed (was 642), and faster than before the fix.
This commit is contained in:
parent
8deca93ee1
commit
cff043787d
10 changed files with 220 additions and 30 deletions
|
|
@ -36,6 +36,10 @@ def test_lexicon_selfsafety_frozen():
|
|||
assert cal.ROT13_MIN_LEN == 40
|
||||
|
||||
|
||||
def test_output_selfsafety_frozen():
|
||||
assert cal.MAX_CONNSTR_VALUE == 256
|
||||
|
||||
|
||||
def test_cognitive_load_lengths_frozen():
|
||||
assert cal.COGNITIVE_LOAD_MIN_LEN == 2500
|
||||
assert cal.COGNITIVE_LOAD_TAIL_START == 2000
|
||||
|
|
@ -102,6 +106,18 @@ def test_lexicon_module_sources_from_calibration():
|
|||
assert lexicon._ROT13_MIN_LEN is cal.ROT13_MIN_LEN
|
||||
|
||||
|
||||
def test_output_module_sources_from_calibration():
|
||||
# The bound is baked into the compiled patterns, so `is` on a module
|
||||
# attribute cannot prove sourcing here -- assert the compiled regex carries
|
||||
# the calibrated number instead.
|
||||
from llm_ingestion_guard import output
|
||||
assert output.MAX_CONNSTR_VALUE is cal.MAX_CONNSTR_VALUE
|
||||
connstr = [p for p in output._SECRET_PATTERNS if p.id.endswith("-connstr")]
|
||||
assert len(connstr) == 4
|
||||
for pattern in connstr:
|
||||
assert f"{{1,{cal.MAX_CONNSTR_VALUE}}}" in pattern.regex.pattern
|
||||
|
||||
|
||||
def test_disposition_module_sources_from_calibration():
|
||||
from llm_ingestion_guard import disposition
|
||||
from llm_ingestion_guard.disposition import Disposition
|
||||
|
|
|
|||
|
|
@ -134,6 +134,24 @@ def test_scan_hybrid_pattern_is_high():
|
|||
assert hit.severity is Severity.HIGH
|
||||
|
||||
|
||||
def test_unclosed_script_tag_is_flagged():
|
||||
# The pattern matches the OPENING tag and does not require `</script>`.
|
||||
# Requiring the closing tag was a fail-open -- an unclosed `<script>` is
|
||||
# still active content, and it was silently missed. (It was also the last
|
||||
# quadratic-backtracking site on the output path: requiring the closing tag
|
||||
# made every `<script` start rescan the tail. Both are fixed by the same
|
||||
# change; the DoS side is pinned in tests/test_output.py.)
|
||||
r = scan_lexicon("<script>alert(1)")
|
||||
assert any(f.label == "hybrid-xss:script-tag" for f in r.findings)
|
||||
|
||||
|
||||
def test_script_body_containing_an_angle_bracket_still_matches():
|
||||
# Guards the fix that was NOT taken: excluding `<` from the script body
|
||||
# would have been linear too, but would have dropped this real match.
|
||||
r = scan_lexicon("<script>if(a<b){leak()}</script>")
|
||||
assert any(f.label == "hybrid-xss:script-tag" for f in r.findings)
|
||||
|
||||
|
||||
def test_scan_medium_pattern():
|
||||
r = scan_lexicon("Dear AI, please help me.")
|
||||
assert r.max_severity() is Severity.MEDIUM
|
||||
|
|
|
|||
|
|
@ -28,7 +28,11 @@ secrets-patterns.md prescribes for its own PEM markers.
|
|||
import base64
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from llm_ingestion_guard import Disposition, PRESET_TRUSTED_SOURCE, decide
|
||||
from llm_ingestion_guard.active_content import scan_active_content
|
||||
from llm_ingestion_guard.lexicon import scan_lexicon
|
||||
from llm_ingestion_guard.output import scan_output, scan_secret_egress
|
||||
from llm_ingestion_guard.report import Report, Severity, Source
|
||||
|
||||
|
|
@ -344,3 +348,77 @@ def test_pathological_input_returns_within_a_bound():
|
|||
start = time.monotonic()
|
||||
scan_output(payload)
|
||||
assert time.monotonic() - start < 10.0
|
||||
|
||||
|
||||
# --- crafted ReDoS payloads against OUR OWN patterns (OWASP LLM10) -----------
|
||||
# The gap the test above explicitly does NOT cover. Every pattern here has the
|
||||
# same shape: a `+`/`*` run followed by a REQUIRED literal, reachable from a
|
||||
# short anchor. The payload repeats that anchor and never supplies the literal,
|
||||
# so every start position rescans the whole tail -- work is quadratic in the
|
||||
# input length, not exponential. There are no nested quantifiers anywhere in
|
||||
# this repo's output path; nesting is simply not what makes these blow up.
|
||||
#
|
||||
# Why this matters despite the max_scan_chars cap: the cap bounds the INPUT,
|
||||
# and quadratic runtime in a bounded input is still unbounded runtime in any
|
||||
# useful sense. Measured on the crafted `<a:` payload, the 1_000_000-char cap
|
||||
# the gate itself accepts extrapolates to ~5.7 HOURS in one scan_output call.
|
||||
#
|
||||
# Bound derivation (same method as the neighbour above -- measurement, not
|
||||
# taste): at N=100_000 the slowest LEGITIMATE content through scan_output is
|
||||
# 0.309s (prose, markdown, html and a connection-string-rich doc all land at
|
||||
# 0.30s +/- 0.01). 2.0s is ~6.5x that. The cheapest crafted payload below ran
|
||||
# 5.694s when this test was written -- 2.8x OVER the bound, so none of these
|
||||
# rows can pass by accident. Unlike the neighbouring test, the crafted/
|
||||
# legitimate separation here is 18x-660x, so the bound has real signal.
|
||||
_REDOS_N = 100_000
|
||||
|
||||
# (id, scanner, repeating unit). The unit denies the literal its pattern needs:
|
||||
# no `@` for the connection strings, no `]` for the markdown links, no `>` for
|
||||
# the autolink and the html tag. Table is a literal -- it cannot silently empty.
|
||||
_REDOS_PAYLOADS = [
|
||||
("egress-redis-connstr", scan_secret_egress, "redis" + "://:"),
|
||||
("egress-postgres-connstr", scan_secret_egress, "postgres" + "://a:"),
|
||||
("egress-mongodb-connstr", scan_secret_egress, "mongodb" + "://a:"),
|
||||
("egress-mysql-connstr", scan_secret_egress, "mysql" + "://a:"),
|
||||
("active-md-image", scan_active_content, ",
|
||||
("active-md-link-url", scan_active_content, "[a]("),
|
||||
("active-html-tag-attrs", scan_active_content, "<a"),
|
||||
# The lexicon is on the output path too, and it had the same defect in the
|
||||
# JSON pattern table -- found only because the composed-gate test below
|
||||
# stayed red after every scanner above was already linear. `<a:` drove the
|
||||
# six html-obfuscation `<[^>]+style...` patterns to 204s at 80_000 chars.
|
||||
("lexicon-html-obfuscation", scan_lexicon, "<a:"),
|
||||
("lexicon-script-tag", scan_lexicon, "<script>"),
|
||||
("lexicon-iframe-src", scan_lexicon, "<iframe "),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"scanner,unit", [(s, u) for _, s, u in _REDOS_PAYLOADS],
|
||||
ids=[i for i, _, _ in _REDOS_PAYLOADS],
|
||||
)
|
||||
def test_crafted_redos_payload_stays_bounded(scanner, unit):
|
||||
payload = (unit * (_REDOS_N // len(unit) + 1))[:_REDOS_N]
|
||||
start = time.monotonic()
|
||||
scanner(payload)
|
||||
assert time.monotonic() - start < 2.0
|
||||
|
||||
|
||||
def test_crafted_redos_payload_bounded_through_the_public_gate():
|
||||
# The parametrized rows above hit each scanner directly so a failure names
|
||||
# the guilty pattern. This one proves the composed gate a caller actually
|
||||
# invokes is bounded too -- with the worst measured payload (`<a:`, 660x the
|
||||
# slowest legitimate content of the same size).
|
||||
payload = ("<a:" * (_REDOS_N // 3 + 1))[:_REDOS_N]
|
||||
start = time.monotonic()
|
||||
scan_output(payload)
|
||||
assert time.monotonic() - start < 2.0
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue