1
0
Fork 0

test(redos): one CPU clock for every bound, and a second row measured dead

`5667063` moved test_output.py's ReDoS bounds off the wall clock, because a
loaded machine steals wall seconds without adding any cycles and two rows
failed at 2.24s / 3.66s against a 2.0s bound while census had the CPU. The
remaining ten bounds in five other files still ran on `time.monotonic()` and
carried the same defect. They now share ONE clock.

The clock is IMPORTED, not copied: `tests/redos_clock.py`. Five private copies
would leave four of them unpinned -- the instrument test
(test_the_redos_clock_ignores_time_this_process_did_not_spend) can only pin the
implementation it calls, and the suite already holds that rule for the code it
measures.

Every ported row was verified the only way a time bound can be: the vulnerable
form patched back in, red demanded, `git checkout --` after. Measured against
the 2.0s bound (3.0s for the url arm):

  active_content long-attr   `{0,63}` -> `*`        RED
  neutralize     long-attr   same patch             RED
  output gate    long-attr   same patch             12.41s
  okf link graph  `[^\]\[]` -> `[^\]]`               6.91s
  sanitize comment  str.find -> `<!--.*?-->`        17.56s
  lexicon md-link-anchor-text                      319.14s
  lexicon md-link-anchor-url                         8.55s
  lexicon md-link-ref-comment                       37.82s

Two rows did not go red, for two different reasons.

test_sanitize.py::test_legitimate_comment_heavy_document is the legitimate SIDE
of a separation, not a second pin on the defect: closed comments never withhold
the required literal, so the lazy form runs it in 0.016s. Recorded in place.

test_lexicon.py::test_redos_pathological_subagent_input_returns_fast is DEAD --
the same zero-signal shape the `<a ` carrier had, found by the same method. The
seed form is `(?:.*?\s+)?` (llm-security 7.8.0, injection-patterns.mjs:84) and
this repo has never carried it: the bounded `{0,12}?` port is in the pattern
table's first commit. Patched in by hand at the row's own size: shipped 0.135s
vs seed 0.113s, separation 1.2x. Not the keyword gate either -- a variant that
reaches the inner branch stays linear over four doublings (exponent ~1.0),
because the nesting is one lazy run inside an OPTIONAL group, never a repeated
one. Left standing with the measurement written into it; picking a new carrier
is an operator call, like the wall-clock row above it.

The dead sibling row named in STATE is fixed: test_active_content.py's
long-attribute row swaps carrier `<a ` -> `<script `, for the reason `5667063`
established on its composed-gate twin -- 0.7.0's own no-URL narrowing put `<a>`
in `_URL_AFFORDANCE_TAGS`, so the tag returns inert BEFORE its body reaches the
arm the row guards. Re-measured here, not inherited: `<a ` 0.041s and NO
findings against the vulnerable form; `<script ` 19.349s against 0.052s
shipped, 373x apart.

`test_pathological_input_returns_within_a_bound` deliberately keeps its wall
clock (operator decision): it claims to catch a hang, and only a wall clock
catches one.

792 tests, 129/129, 6/6.
This commit is contained in:
Kjell Tore Guttormsen 2026-08-13 21:25:36 +02:00
commit c48a2923ac
7 changed files with 112 additions and 54 deletions

View file

@ -35,6 +35,7 @@ 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
from redos_clock import scan_seconds
# --- fixtures assembled at runtime (never contiguous in source) --------------
@ -352,22 +353,11 @@ def test_pathological_input_returns_within_a_bound():
# --- crafted ReDoS payloads against OUR OWN patterns (OWASP LLM10) -----------
#
# Every bound below goes through `_scan_seconds`, so the rows share ONE clock
# and one derivation. The neighbouring test above keeps its own wall clock on
# purpose -- see the instrument test for why the two must not be merged.
def _scan_seconds(scanner, payload) -> float:
"""CPU seconds a scan cost -- the clock the ReDoS bounds are derived against.
Process CPU time, not wall clock: a ReDoS blowup is spent cycles, and a
loaded machine steals wall clock without adding any. Pinned by
``test_the_redos_clock_ignores_time_this_process_did_not_spend``, which
carries the measurements and what this clock gives up.
"""
start = time.process_time()
scanner(payload)
return time.process_time() - start
# Every bound below goes through `scan_seconds`, so the rows share ONE clock and
# one derivation -- and since `redos_clock` is imported, not copied, that "one"
# now spans every ReDoS bound in the suite, not just this file's. The
# neighbouring test above keeps its own wall clock on purpose -- see the
# instrument test for why the two must not be merged.
# The gap the test above explicitly does NOT cover. Every pattern here has the
@ -441,7 +431,7 @@ _REDOS_PAYLOADS = [
)
def test_crafted_redos_payload_stays_bounded(scanner, unit, n):
payload = (unit * (n // len(unit) + 1))[:n]
assert _scan_seconds(scanner, payload) < 2.0
assert scan_seconds(scanner, payload) < 2.0
def test_the_redos_clock_ignores_time_this_process_did_not_spend():
@ -475,7 +465,7 @@ def test_the_redos_clock_ignores_time_this_process_did_not_spend():
#
# A sleep is the defect class at its purest: wall-clock seconds this process
# did not spend. 0.4s is 4x the assertion, so this cannot pass by timing luck.
assert _scan_seconds(lambda _: time.sleep(0.4), "") < 0.1
assert scan_seconds(lambda _: time.sleep(0.4), "") < 0.1
def test_crafted_redos_payload_bounded_through_the_public_gate():
@ -484,7 +474,7 @@ def test_crafted_redos_payload_bounded_through_the_public_gate():
# 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]
assert _scan_seconds(scan_output, payload) < 2.0
assert scan_seconds(scan_output, payload) < 2.0
def test_gate_is_bounded_on_the_payload_the_first_sweep_missed():
@ -496,7 +486,7 @@ def test_gate_is_bounded_on_the_payload_the_first_sweep_missed():
# 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
assert _scan_seconds(scan_output, payload) < 2.0
assert scan_seconds(scan_output, payload) < 2.0
def test_gate_is_bounded_on_the_long_attribute_arm():
@ -525,7 +515,7 @@ def test_gate_is_bounded_on_the_long_attribute_arm():
# attributes at all, so no future URL-shaped narrowing can make it inert the
# way it just did to `<a >`.
payload = "<script " + "A" * _REDOS_N + ">"
assert _scan_seconds(scan_output, payload) < 2.0
assert scan_seconds(scan_output, payload) < 2.0
# --- ZWJ inside emoji sequences on the output gate ---------------------------