fix(llm-security): the ReDoS gate timed every pattern and reached 8 of 45

The v8.x-A whole-table gate times every exported pattern against a corpus
of 8 hand-written units and asserts its own coverage -- but the assertion
`covers every exported pattern` guards the pattern LIST, not the input
corpus. A pattern is only measured if some unit happens to carry its
leading literal; otherwise it fails on the first character and reports
green having measured nothing.

Measured on the pre-swap tables: 37 of the 45 prefix-bearing patterns
were never reached, including BOTH quadratic hybrid-xss rows this gate
was believed to cover. `<script ` and `<iframe ` appear in no unit, so
the two rows commons independently measured as quadratic ran their
literal-prefix check and stopped. Same defect class as all of v7.8.2:
reported success without running.

Hand-writing 37 more units does not fix it -- it re-arms the same trap at
the next pattern. Class 3 derives each attack unit from the pattern's OWN
literal prefix, so coverage is a function of the table rather than a list
someone must remember to extend. 64KB rather than the 512KB read cap for
the class-1 reason: a quadratic pattern met at 512KB stalls the run for
minutes instead of failing it.

Proven to fire, both directions, against the vendored file:
  - gate written first, pre-swap: RED, naming script-tag 1429ms and
    iframe-src 1161ms against a 150ms budget (exit 1)
  - post-swap: GREEN, 17.7ms for all 45 probes (exit 0)
  - vendored JSON mutated back to [^>]*: golden AND ReDoS gates both exit 1
  - vendored JSON corrupted: golden exit 1, conformance 3 fail
  - restored: all green

Clean-table margin at 64KB is ~700x: worst legitimate pattern 1.66ms.

Carried with the commons v0.4.3 subtree pull, which is what makes the
gate passable. v0.4.0 was the tag commons announced; v0.4.1-v0.4.3 came
after and touch no data table -- lexicon 0.8.0 and secret-egress
0.3.0/19 are identical across all four -- so v0.4.3 was taken for the
conformance manifest correction (302625e) they sent separately.

Golden re-blessed after a post-by-post diff: exactly 2 changed records,
both [^>]* -> [^><]*, 0 added, 0 removed, reference run 61/61 unchanged.
The file-sha256 layer did NOT move, contrary to the note in STATE: it
pins scanners/lib/injection-patterns.mjs, which has held no literals
since be14867. The vendored lexicon is covered by the regex layer only.

The script-tag tripwire pinned the old form and fired correctly. Updated
to the v0.4.x form and widened to the iframe row, which had no tripwire
while it was quadratic -- which is why nobody had named it.

Full suite 2193 pass / 6 skipped. The one red is the documented
pre-compact size-cap timing flake; passes alone (exit 0), as do
attack-simulator and the gate itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KJxU3xuwfMq8W1mxtiGhLk
This commit is contained in:
Kjell Tore Guttormsen 2026-08-13 20:30:31 +02:00
commit 18bc1dc92e
3 changed files with 101 additions and 8 deletions

View file

@ -352,7 +352,7 @@
{
"kind": "regex",
"key": "injection-patterns:HYBRID_PATTERNS[4].pattern",
"source": "<script\\b[^>]*>",
"source": "<script\\b[^><]*>",
"flags": "i"
},
{
@ -370,7 +370,7 @@
{
"kind": "regex",
"key": "injection-patterns:HYBRID_PATTERNS[7].pattern",
"source": "<iframe\\b[^>]*src\\s*=\\s*[\"'][^\"']*(?:javascript:|data:text\\/html)",
"source": "<iframe\\b[^><]*src\\s*=\\s*[\"'][^\"']*(?:javascript:|data:text\\/html)",
"flags": "i"
},
{