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
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
18
docs/PLAN.md
18
docs/PLAN.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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*=",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) ----------
|
||||
|
|
|
|||
|
|
@ -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