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:
parent
566706360a
commit
c48a2923ac
7 changed files with 112 additions and 54 deletions
|
|
@ -18,8 +18,6 @@ from __future__ import annotations
|
|||
|
||||
import pytest
|
||||
|
||||
import time
|
||||
|
||||
from llm_ingestion_guard import (
|
||||
scan_active_content,
|
||||
scan_output,
|
||||
|
|
@ -29,6 +27,7 @@ from llm_ingestion_guard import (
|
|||
)
|
||||
from llm_ingestion_guard.okf import import_bundle, Origin, Channel
|
||||
from llm_ingestion_guard.report import Severity, Source
|
||||
from redos_clock import scan_seconds
|
||||
|
||||
# The zero-click EchoLeak primitive: an auto-fetched markdown image URL.
|
||||
_ECHOLEAK = ""
|
||||
|
|
@ -447,10 +446,25 @@ _ATTR_REDOS_N = 100_000
|
|||
|
||||
|
||||
def test_crafted_long_attribute_tag_stays_bounded():
|
||||
payload = "<a " + "A" * _ATTR_REDOS_N + ">"
|
||||
start = time.monotonic()
|
||||
scan_active_content(payload)
|
||||
assert time.monotonic() - start < 2.0
|
||||
# The carrier is `<script `, not the `<a ` this row shipped with through
|
||||
# 0.7.0, because 0.7.0's own no-URL narrowing killed the row: `<a>` is in
|
||||
# `_URL_AFFORDANCE_TAGS`, so a bare `<a ...>` carrying no URL attribute is
|
||||
# inert and returns BEFORE its body reaches `URL_IN_TEXT_RE` — the arm this
|
||||
# row exists to guard. Re-measured here with the pre-fix uncapped scheme run
|
||||
# patched back in, at _ATTR_REDOS_N through `scan_active_content`:
|
||||
#
|
||||
# <a ...> 0.041s and NO findings <- dead: never reaches the arm
|
||||
# <script ...> 19.349s and one finding <- the arm, still quadratic
|
||||
#
|
||||
# So the `<a ` row was green against the vulnerable form — separation 1.2x,
|
||||
# zero signal. With `<script ` it is 0.052s shipped vs 19.349s vulnerable,
|
||||
# 373x apart, with the bound 38x above the shipped side. `<script>` is the
|
||||
# durable carrier: active by NAME with no attributes at all, so no future
|
||||
# URL-shaped narrowing can make it inert the way it just did to `<a >`.
|
||||
# Same fix, same reason, as test_output.py::test_gate_is_bounded_on_the_
|
||||
# long_attribute_arm — the composed-gate twin of this row.
|
||||
payload = "<script " + "A" * _ATTR_REDOS_N + ">"
|
||||
assert scan_seconds(scan_active_content, payload) < 2.0
|
||||
|
||||
|
||||
def test_url_defanging_survives_the_redos_fix():
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue