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
|
|
@ -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