1
0
Fork 0

fix(lexicon): two quadratic patterns, reachable through the output gate too

The input-path duty `8deca93` scoped. All 83 lexicon patterns measured arm by
arm; two are quadratic, same shape 0.3.2 fixed -- a run in front of a required
literal that may cross the pattern's own opening anchor. Exponent 1.98 over five
points, so quadratic, not exponential.

  markdown:link-anchor-injection  `[`          1.91s @8k   ~8.3h at the cap
  markdown:link-anchor-injection  `[system](`  0.006s @8k  ~89s at the cap
  markdown:link-ref-comment       `[//]: # (`  0.22s @8k   ~1.0h at the cap

Not input-path-only: `scan_lexicon` runs on the output path, so `scan_output("["
* 100_000)` took 334.7s. 0.3.2's "last quadratic site on the output path" was
false when written -- its sweep drove `[` only through `scan_active_content`.

Fix is anchor exclusion, not bounding (bounding attacker-controlled content is a
one-line bypass). The excluded char is `(`, not the obvious `[`: excluding `[`
drops `[//]: # (see [x] then ignore this)`, which no other pattern catches. The
anchors contain `(` too, so it telescopes at zero measured recall cost.

N is per row deliberately. The URL arm ran 0.9s UNFIXED at N=100_000 -- under the
2.0s bound, so that row could not have failed. Measured at N=300_000 instead,
where crafted (8.10s) and legitimate (0.926s) separate 8.8x.
This commit is contained in:
Kjell Tore Guttormsen 2026-07-31 21:50:51 +02:00
commit b8028ba870
4 changed files with 97 additions and 5 deletions

View file

@ -588,7 +588,7 @@
},
{
"id": "markdown:link-ref-comment",
"regex": "\\[//\\]:\\s*#\\s*\\(.*(?:ignore|override|system|instruction|execute)",
"regex": "\\[//\\]:\\s*#\\s*\\([^(\\n]*(?:ignore|override|system|instruction|execute)",
"flags": "i",
"severity": "medium",
"owasp": "LLM01",
@ -604,7 +604,7 @@
},
{
"id": "markdown:link-anchor-injection",
"regex": "\\[[^\\]]*(?:system|ignore|override|exfiltrate|execute)[^\\]]*\\]\\([^)]+\\)",
"regex": "\\[[^\\]\\[]*(?:system|ignore|override|exfiltrate|execute)[^\\]\\[]*\\]\\([^)(]+\\)",
"flags": "i",
"severity": "medium",
"owasp": "LLM01",

View file

@ -23,9 +23,25 @@ caller's — this module only reports.
**Self-safety (OWASP LLM10).** A scanner that hangs on crafted input *is* the
DoS. Two guards land here (the shared guard ``entropy`` deferred to this
module): an input-size cap (:data:`MAX_SCAN_CHARS`; oversize input is scanned up
to the cap and flagged) and ReDoS-safe patterns the two sub-agent patterns
whose seed form nested ``.*?`` are ported with *bounded* token-gap quantifiers
(``(?:\\S+\\s+){0,N}?``), since Python's ``re`` has no timeout.
to the cap and flagged) and ReDoS-safe patterns, since Python's ``re`` has no
timeout. The pattern table needs *two* remedies, not one:
* **Bounded token gaps** the two sub-agent patterns whose seed form nested
``.*?`` are ported with ``(?:\\S+\\s+){0,N}?``.
* **Anchor exclusion** a run in front of a *required* literal is quadratic
whenever it may cross the pattern's own opening anchor, with no nesting
involved. Measured across all 83 patterns arm by arm, two markdown patterns
had this defect; both now exclude the anchor character from the run. The
exclusion is ``(`` rather than ``[`` in both cases: it telescopes just as
well (the anchors contain ``(`` too) and costs no measured recall, whereas
excluding ``[`` drops a link-ref comment carrying a nested bracket that no
other pattern catches. Bounding the runs instead is the wrong fix here for
the reason ``active_content`` documents the content is attacker-controlled,
so padding past a bound would be a one-line bypass.
The cap does not mitigate this on its own: it bounds the *input*, and quadratic
work on a bounded input is still hours. See
``tests/test_lexicon.py::test_crafted_redos_payload_stays_bounded_in_the_lexicon``.
The pattern table ships as JSON (``injection_lexicon.json``) the single source
of truth, decoupled from this engine for a future TS port. Non-Latin data in

View file

@ -11,6 +11,8 @@ mutation. Disposition (WARN / QUARANTINE / FAIL_SECURE) is the caller's.
import base64
import time
import pytest
from llm_ingestion_guard.lexicon import (
LexiconPattern,
check_cognitive_load_trap,
@ -234,3 +236,63 @@ def test_redos_pathological_subagent_input_returns_fast():
elapsed = time.monotonic() - start
assert elapsed < 2.0
assert isinstance(r, Report)
# --- crafted ReDoS payloads against the JSON pattern table (OWASP LLM10) -----
# The INPUT-path duty `8deca93` scoped: 0.3.2 fixed the output path's scanners,
# but the lexicon is the load-bearing input gate and its 83 patterns had never
# been measured. Two of them are quadratic, same shape as everything 0.3.2
# fixed -- a run followed by a REQUIRED literal, where the run may cross the
# pattern's own opening anchor. Crafted input repeats the anchor and never
# supplies the literal, so every start position rescans the tail.
#
# These are NOT input-path-only. `scan_lexicon` runs on the output path too, so
# 0.3.2's "the output path is bounded" was too broad: its gate test used `<a:`
# and the `[` unit was only ever run against `scan_active_content`, never
# against `scan_lexicon`. Measured through the public gate before the fix:
# `scan_output("[" * 16_000)` took 8.045s. The gate row in test_output.py
# closes that hole; these rows name the guilty pattern.
#
# Exponent measured over five points (1k..16k): 1.98 -- quadratic, not
# exponential. Extrapolated to the 1_000_000-char cap the gate accepts:
# `[` -> 8.29 HOURS (markdown:link-anchor-injection, anchor run)
# `[system](` -> 89 seconds (same pattern, the URL run -- a separate arm)
# `[//]: # (` -> 0.97 HOURS (markdown:link-ref-comment, the `.*` run)
#
# Both arms of link-anchor-injection get a row for the reason the output table
# already learned: a pattern is only safe once EVERY run in it is. The URL arm
# was missed by a sweep whose payloads were generic; it only appeared once the
# payloads were synthesised per-run from the pattern's own skeleton.
#
# Bound derivation (measurement, not taste -- same method as the output table):
# at N=100_000 the slowest LEGITIMATE content through `scan_lexicon` is 0.316s
# (prose 0.316 / html 0.315 / markdown 0.297 / connection-string doc 0.296).
# 2.0s is ~6.3x that.
#
# N is PER ROW, and that is the point. The URL arm is quadratic with a small
# constant: at N=100_000 it ran 0.9s UNFIXED, so a 2.0s bound there passes
# whether or not the pattern is fixed -- a row that cannot fail is not a test,
# it is decoration. Re-measured at N=300_000 it separates properly: 8.104s
# crafted against 0.926s for the slowest legitimate content of that size (prose
# 0.918 / markdown 0.926), so the 3.0s bound sits 3.2x over legitimate and 2.7x
# under crafted. The two 100_000 rows ran 297s and 55s unfixed -- far over.
#
# (id, repeating unit, N, bound). Each unit denies the literal its run needs: no
# closing `]` for the anchor text, no `)` for the URL, no keyword for the comment
# run. Table is a literal -- it cannot silently empty.
_LEXICON_REDOS_ROWS = [
("md-link-anchor-text", "[", 100_000, 2.0),
("md-link-anchor-url", "[system](", 300_000, 3.0),
("md-link-ref-comment", "[//]: # (", 100_000, 2.0),
]
@pytest.mark.parametrize(
"unit,n,bound", [(u, n, b) for _, u, n, b in _LEXICON_REDOS_ROWS],
ids=[i for i, _, _, _ in _LEXICON_REDOS_ROWS],
)
def test_crafted_redos_payload_stays_bounded_in_the_lexicon(unit, n, bound):
payload = (unit * (n // len(unit) + 1))[:n]
start = time.monotonic()
scan_lexicon(payload)
assert time.monotonic() - start < bound

View file

@ -422,3 +422,17 @@ def test_crafted_redos_payload_bounded_through_the_public_gate():
start = time.monotonic()
scan_output(payload)
assert time.monotonic() - start < 2.0
def test_gate_is_bounded_on_the_payload_the_first_sweep_missed():
# The hole in 0.3.2, found by the input-path sweep that `8deca93` scoped.
# `[` appears in the table above only against `scan_active_content`, and the
# gate test above uses `<a:` -- so no row ever drove `[` through the LEXICON,
# which `scan_output` also runs. It was quadratic there: 8.045s at 16_000
# chars, ~8.3 HOURS extrapolated to the cap. The guilty pattern is named by
# test_lexicon.py::test_crafted_redos_payload_stays_bounded_in_the_lexicon;
# this row exists so the composed gate a caller actually invokes is covered.
payload = "[" * _REDOS_N
start = time.monotonic()
scan_output(payload)
assert time.monotonic() - start < 2.0