1
0
Fork 0

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:
Kjell Tore Guttormsen 2026-07-31 18:31:58 +02:00
commit cff043787d
10 changed files with 220 additions and 30 deletions

View file

@ -215,7 +215,7 @@ a green scan means safe content. The highest-impact items:
egress, semantic poisoning, trusted-prose lone-HIGH, lexicon dedup (`count=1`),
pure beaconing, and short opaque URL segments.
**Full list — 27 items, each with the mechanism, plus the out-of-scope boundary:**
**Full list — 29 items, each with the mechanism, plus the out-of-scope boundary:**
[`docs/LIMITATIONS.md`](docs/LIMITATIONS.md). Several carry field measurements from
consumer corpora, including the false positives the URL-shape rule actually produces.

View file

@ -244,6 +244,22 @@ items; this is the full list, each with the mechanism.
only, so hex (and other encodings, or nested wraps) is a deliberate boundary —
decode the transport layer first if you need it scanned.
- **Prose that merely mentions `<script>` fires `hybrid-xss:script-tag`.** The
pattern matches the opening tag and no longer requires `</script>`, so a
document *about* XSS is flagged alongside a document that *carries* it. This
is a deliberate trade made twice over: requiring the closing tag was a
fail-open (an unclosed `<script>alert(1)` was silently missed) and it was the
last quadratic-backtracking site on the output path. Report-only, so the cost
is a review, not a block.
- **A connection-string password longer than 256 chars is not matched.** The
password run in the `*-connstr` egress patterns is bounded by
`MAX_CONNSTR_VALUE`; unbounded, it sits in front of a mandatory `@` and makes
crafted input quadratic. Excluding the anchor character instead — the fix the
active-content table uses — is unavailable here because that character is `/`,
and a password containing `/` is the common case. The realistic long value (a
token used as a DB password) is still caught by `egress:jwt-token`.
## The six documented gaps (tracked by the coverage matrix)
These are asserted to *still hold* by `tests/test_coverage_matrix.py` — a closed gap

View file

@ -132,11 +132,19 @@ Maximal reuse: most detection logic is a JS→Python **port**, not new code.
- **Contract asserters** — a tool-carrying request and a credential-leaking stage env both
raise; the happy path passes.
- **Self-safety** — pathological/ReDoS-prone and oversize input return within a bound,
never hang. Scope, measured 2026-07-31: the ReDoS half is carried by the *lexicon*
path alone (`test_redos_pathological_subagent_input_returns_fast`, crafted against a
known-bad nested `.*?`). The `output` path's bound is a no-hang guard only — its blob
is *slower* than size-matched ordinary prose (0.93x/0.96x), so it does not exercise
catastrophic backtracking. A crafted payload for the output regexes is not written.
never hang. Scope, measured 2026-07-31: the *lexicon* path is covered by
`test_redos_pathological_subagent_input_returns_fast` (crafted against a known-bad
nested `.*?`). The `output` path is covered by
`test_crafted_redos_payload_stays_bounded` — 15 crafted payloads plus one through the
composed gate. Writing them found the defect they were meant to rule out: 19 quadratic
runs across 17 patterns in `output` (4), `active_content` (5 patterns / 7 runs) and the
lexicon JSON (8), worst case ~5.7 hours at the 1_000_000-char cap the gate accepts. Note the shape, since it is
*not* the textbook one: no nested quantifier is involved. A run in front of a required
literal, reachable from a short anchor, is enough — crafted input repeats the anchor,
never supplies the literal, and every start position rescans the tail. The earlier
"output blob is slower than ordinary prose (0.93x/0.96x)" measurement stands and was
never wrong; it simply measured throughput on a blob, which is a different question
from what a crafted payload asks.
- **Neutralize** — active-content output is defanged; clean output is byte-identical.
- **End-to-end showcase (the FINAL deliverable, built last).** One realistic
piece of ingested content that carries *many* vulnerabilities at once — visible

View file

@ -104,31 +104,52 @@ def redact(s: str, show_start: int = 16, show_end: int = 6) -> str:
# --- active-content constructs (the shared pattern table) ---------------------
#
# ReDoS note (OWASP LLM10) — every run below excludes the character that OPENS
# this pattern's own anchor: `[` for the markdown forms, `<` for the autolink and
# the raw tag. That exclusion is what keeps the table linear, and it is not
# cosmetic. Each of these is a run followed by a REQUIRED literal (`]`, `)`,
# `>`); if the run may cross the next anchor, then crafted input that repeats the
# anchor and never supplies the literal makes every start position rescan the
# whole tail — quadratic time, no nested quantifier needed. Measured before the
# exclusions: `<a:` x 100_000 took 23.4s in AUTOLINK_RE alone and ~5.7 HOURS
# extrapolated to the 1_000_000-char cap the gate accepts. With the exclusion a
# run cannot reach past the next anchor, so the per-start costs telescope.
# Bounding the runs instead ({0,256}) would also be linear but is the WRONG fix
# here: the content is attacker-controlled, so padding past the bound would be a
# one-line detection bypass of the very EchoLeak class this table exists to
# catch. See tests/test_output.py::test_crafted_redos_payload_stays_bounded.
#
# Markdown image / inline link: `[text](url "title")`. `url` stops at the first
# `)` or whitespace (balanced-paren URLs matched conservatively — see the
# neutralize scope note).
# neutralize scope note). `[` is excluded per the ReDoS note above; a URL that
# needs a literal `[` (an IPv6 host literal) must percent-encode it anyway.
MD_IMAGE_RE = re.compile(
r"!\[(?P<alt>[^\]]*)\]\(\s*(?P<url>[^)\s]+)(?P<title>(?:\s+\"[^\"]*\")?)\s*\)"
r"!\[(?P<alt>[^\]\[]*)\]\(\s*(?P<url>[^)\s\[]+)(?P<title>(?:\s+\"[^\"]*\")?)\s*\)"
)
MD_LINK_RE = re.compile(
r"(?<!!)\[(?P<text>[^\]]*)\]\(\s*(?P<url>[^)\s]+)(?P<title>(?:\s+\"[^\"]*\")?)\s*\)"
r"(?<!!)\[(?P<text>[^\]\[]*)\]\(\s*(?P<url>[^)\s\[]+)(?P<title>(?:\s+\"[^\"]*\")?)\s*\)"
)
# Reference-style link definition: `[label]: destination`. Only fires when the
# destination is absolute (has a scheme or is protocol-relative) — a footnote
# `[1]: some plain text` is not a link target and is left alone.
MD_REFDEF_RE = re.compile(
r"(?m)^(?P<pre>[ ]{0,3}\[[^\]]+\]:\s*)(?P<url>[A-Za-z][\w+.\-]*:\S+|//\S+)"
r"(?m)^(?P<pre>[ ]{0,3}\[[^\]\[]+\]:\s*)(?P<url>[A-Za-z][\w+.\-]*:\S+|//\S+)"
)
# Angle-bracket autolink: `<scheme:...>`.
AUTOLINK_RE = re.compile(r"<(?P<url>[A-Za-z][A-Za-z0-9+.\-]*:[^>\s]+)>")
# Angle-bracket autolink: `<scheme:...>`. A URL inside `<...>` cannot contain a
# raw `<`, so excluding it costs no recall (verified) and bounds the run.
AUTOLINK_RE = re.compile(r"<(?P<url>[A-Za-z][A-Za-z0-9+.\-]*:[^>\s<]+)>")
# Standalone `data:` URI in prose (not preceded by a letter/digit -> "metadata:"
# is not a match), consuming to the next whitespace / quote / bracket.
DATA_URI_RE = re.compile(r"(?<![A-Za-z0-9])data:[^\s'\"<>)]+", re.IGNORECASE)
# Raw HTML tag. Attribute values may hold `>` inside quotes, so quoted runs are
# consumed atomically. A tag is *active* if it is an inherently-executing element,
# carries an event handler, or carries a URL-bearing attribute.
HTML_TAG_RE = re.compile(r"<(?P<slash>/?)(?P<name>[A-Za-z][A-Za-z0-9:-]*)(?P<attrs>(?:[^>\"']|\"[^\"]*\"|'[^']*')*)>")
# carries an event handler, or carries a URL-bearing attribute. The unquoted-char
# branch excludes `<` per the ReDoS note above — a raw `<` cannot appear in an
# unquoted attribute region anyway, and a `<` inside a QUOTED value is still
# consumed by the quoted branches, so this costs no recall (verified).
HTML_TAG_RE = re.compile(r"<(?P<slash>/?)(?P<name>[A-Za-z][A-Za-z0-9:-]*)(?P<attrs>(?:[^>\"'<]|\"[^\"]*\"|'[^']*')*)>")
_EVENT_ATTR_RE = re.compile(r"\bon[a-z]+\s*=", re.IGNORECASE)
_URL_ATTR_RE = re.compile(
r"\b(?:src|href|xlink:href|srcset|data|poster|formaction|action|background|cite|codebase|longdesc)\s*=",

View file

@ -40,10 +40,25 @@ ENTROPY_HEX_FLOOR_LEN = 64
# --- lexicon: self-safety + variant thresholds ------------------------------
# Input-size cap (OWASP LLM10): large enough for a real ingested document;
# beyond it the scanner reads the prefix and flags, so runtime stays bounded
# even on a decompression-bomb-sized input.
# beyond it the scanner reads the prefix and flags, so every sub-scanner sees a
# bounded input. Note what this cap does NOT buy: bounded input is only bounded
# runtime if the patterns are linear in it. A quadratic pattern turns this cap
# into hours of work, which is what crafted input against the output path was
# measured to do before the ReDoS fix (see active_content's pattern-table note).
MAX_SCAN_CHARS = 1_000_000
# --- output: secret-egress self-safety --------------------------------------
# Longest password a connection-string pattern will match. A bound is required
# (not merely nice) because the run sits in front of a mandatory `@`: unbounded,
# crafted input repeating `redis://:` and never supplying the `@` makes every
# start position rescan the tail — quadratic. Excluding the anchor character the
# way the active-content table does is not available here, since that character
# is `/` and passwords containing `/` are the common case.
# 256 is generous for a password and cheap to scan; the residual miss is a
# credential longer than this, which for the realistic case (a token used as a
# DB password) is still caught by the jwt-token / high-specificity patterns.
MAX_CONNSTR_VALUE = 256
# Minimum length before the rot13 variant is scanned — shorter strings hit
# rot13-look-alike false positives.
ROT13_MIN_LEN = 40

View file

@ -236,7 +236,7 @@
},
{
"id": "html-obfuscation:display-none",
"regex": "<[^>]+style\\s*=\\s*\"[^\"]*display\\s*:\\s*none[^\"]*\"[^>]*>",
"regex": "<[^><]+style\\s*=\\s*\"[^\"]*display\\s*:\\s*none[^\"]*\"[^><]*>",
"flags": "i",
"severity": "high",
"owasp": "LLM01",
@ -244,7 +244,7 @@
},
{
"id": "html-obfuscation:visibility-hidden",
"regex": "<[^>]+style\\s*=\\s*\"[^\"]*visibility\\s*:\\s*hidden[^\"]*\"[^>]*>",
"regex": "<[^><]+style\\s*=\\s*\"[^\"]*visibility\\s*:\\s*hidden[^\"]*\"[^><]*>",
"flags": "i",
"severity": "high",
"owasp": "LLM01",
@ -252,7 +252,7 @@
},
{
"id": "html-obfuscation:offscreen",
"regex": "<[^>]+style\\s*=\\s*\"[^\"]*position\\s*:\\s*absolute[^\"]*-\\d{3,}px[^\"]*\"[^>]*>",
"regex": "<[^><]+style\\s*=\\s*\"[^\"]*position\\s*:\\s*absolute[^\"]*-\\d{3,}px[^\"]*\"[^><]*>",
"flags": "i",
"severity": "high",
"owasp": "LLM01",
@ -260,7 +260,7 @@
},
{
"id": "html-obfuscation:zero-font",
"regex": "<[^>]+style\\s*=\\s*\"[^\"]*font-size\\s*:\\s*0[^\"]*\"[^>]*>",
"regex": "<[^><]+style\\s*=\\s*\"[^\"]*font-size\\s*:\\s*0[^\"]*\"[^><]*>",
"flags": "i",
"severity": "high",
"owasp": "LLM01",
@ -268,7 +268,7 @@
},
{
"id": "html-obfuscation:zero-opacity",
"regex": "<[^>]+style\\s*=\\s*\"[^\"]*opacity\\s*:\\s*0[^\"]*\"[^>]*>",
"regex": "<[^><]+style\\s*=\\s*\"[^\"]*opacity\\s*:\\s*0[^\"]*\"[^><]*>",
"flags": "i",
"severity": "high",
"owasp": "LLM01",
@ -276,7 +276,7 @@
},
{
"id": "html-obfuscation:zero-size-overflow",
"regex": "<[^>]+style\\s*=\\s*\"[^\"]*(?:height|width)\\s*:\\s*0[^\"]*overflow\\s*:\\s*hidden[^\"]*\"[^>]*>",
"regex": "<[^><]+style\\s*=\\s*\"[^\"]*(?:height|width)\\s*:\\s*0[^\"]*overflow\\s*:\\s*hidden[^\"]*\"[^><]*>",
"flags": "i",
"severity": "high",
"owasp": "LLM01",
@ -460,7 +460,7 @@
},
{
"id": "hybrid-xss:script-tag",
"regex": "<script\\b[^>]*>[\\s\\S]*?</script>",
"regex": "<script\\b[^><]*>",
"flags": "i",
"severity": "high",
"owasp": "LLM01",
@ -484,7 +484,7 @@
},
{
"id": "hybrid-xss:iframe-src",
"regex": "<iframe\\b[^>]*src\\s*=\\s*[\"\\'][^\"\\']*(?:javascript:|data:text/html)",
"regex": "<iframe\\b[^><]*src\\s*=\\s*[\"\\'][^\"\\']*(?:javascript:|data:text/html)",
"flags": "i",
"severity": "high",
"owasp": "LLM01",

View file

@ -46,8 +46,19 @@ length. The report is meant to be logged; it must not become the leak.
**Self-safety (OWASP LLM10).** The output is capped once to ``max_scan_chars``
and a single ``output:oversize-input`` finding is emitted if it was truncated;
every sub-scanner then sees bounded input. The egress patterns are linear
(anchored prefixes / negated character classes no nested quantifiers).
every sub-scanner then sees bounded input.
Bounded input is not by itself bounded runtime, and this module used to claim it
was. The egress patterns have no nested quantifiers that part was true but
absence of nesting does not imply linearity. A run in front of a *required*
literal (here: the password run before ``@``) makes every start position rescan
the tail when the literal never arrives, which is quadratic in the scanned
length. Crafted input repeating ``redis://:`` measured 8.2s at 100_000 chars and
extrapolated to hours at the 1_000_000-char cap this gate itself accepts. The
connection-string runs are therefore bounded to
:data:`~llm_ingestion_guard.calibration.MAX_CONNSTR_VALUE`; the same defect in
the active-content table is fixed there by excluding the anchor character. Both
are pinned by ``tests/test_output.py::test_crafted_redos_payload_stays_bounded``.
"""
from __future__ import annotations
@ -56,6 +67,7 @@ from dataclasses import dataclass, replace
from typing import Optional, Union
from .active_content import scan_active_content
from .calibration import MAX_CONNSTR_VALUE
from .entropy import scan_entropy
from .lexicon import MAX_SCAN_CHARS, scan_lexicon
from .report import Finding, Report, Severity, Source
@ -122,19 +134,25 @@ _SECRET_PATTERNS: list[_SecretPattern] = [
_SecretPattern("pkcs8-private-key", _p(r"-{5}BEGIN PRIVATE KEY-{5}"),
Severity.CRITICAL, "PEM PKCS#8 private key header"),
# --- DB connection strings (suppress placeholder passwords) -------------
# The password run is bounded at MAX_CONNSTR_VALUE per the ReDoS note on
# _SECRET_PATTERNS above. Unlike the active-content table, excluding the
# anchor character is NOT available here: the anchor opens with `/`, and a
# password containing `/` is the common case (a base64-ish secret), so
# excluding it would drop real credentials. The bound is the lesser loss.
_SecretPattern("postgres-connstr",
_p(r"postgres(?:ql)?://[^:@\s]+:(?P<val>[^@\s]+)@[^\s'\"]+"),
_p(r"postgres(?:ql)?://[^:@\s]+:(?P<val>[^@\s]{1,%d})@[^\s'\"]+" % MAX_CONNSTR_VALUE),
Severity.CRITICAL, "PostgreSQL connection string with credentials",
value_group="val"),
_SecretPattern("mongodb-connstr",
_p(r"mongodb(?:\+srv)?://[^:@\s]+:(?P<val>[^@\s]+)@[^\s'\"]+"),
_p(r"mongodb(?:\+srv)?://[^:@\s]+:(?P<val>[^@\s]{1,%d})@[^\s'\"]+" % MAX_CONNSTR_VALUE),
Severity.CRITICAL, "MongoDB connection string with credentials",
value_group="val"),
_SecretPattern("mysql-connstr",
_p(r"mysql(?:2)?://[^:@\s]+:(?P<val>[^@\s]+)@[^\s'\"]+"),
_p(r"mysql(?:2)?://[^:@\s]+:(?P<val>[^@\s]{1,%d})@[^\s'\"]+" % MAX_CONNSTR_VALUE),
Severity.CRITICAL, "MySQL/MariaDB connection string with credentials",
value_group="val"),
_SecretPattern("redis-connstr", _p(r"redis://:(?P<val>[^@\s]+)@[^\s'\"]+"),
_SecretPattern("redis-connstr",
_p(r"redis://:(?P<val>[^@\s]{1,%d})@[^\s'\"]+" % MAX_CONNSTR_VALUE),
Severity.HIGH, "Redis connection string with password",
value_group="val"),
# --- JWT (high false-positive rate -> MEDIUM, flag for review) ----------

View file

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

View file

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

View file

@ -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", scan_active_content, "["),
("active-md-refdef", scan_active_content, "[a\n"),
("active-autolink", scan_active_content, "<a:"),
("active-html-tag", scan_active_content, "<a "),
# The rows above attack the FIRST run in each pattern (alt text, label,
# tag name). These three attack the url run and the attribute run behind
# it -- separately quadratic, and missed by the first sweep. A pattern is
# only safe once every run in it is, so each arm gets its own row.
("active-md-image-url", scan_active_content, "![a]("),
("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