test(output): the ReDoS bounds move to CPU time, and the long-attribute row was dead
Two rows failed at 2.24s against a 2.0s wall-clock bound while two census
processes had the CPU, and passed 3/3 on an idle machine. Reproduced under
artificial oversubscription before touching anything (16 logical / 8 physical
cores), `lexicon-script-tag` shipped form, idle vs 2x vs 4x:
wall 0.74s -> 3.55s -> 8.13s (11x, still climbing with load)
cpu 0.74s -> 1.42s -> 1.50s (2.0x, flat from 2x to 4x)
Wall-clock inflation tracks how many other processes want the CPU and has no
ceiling; process CPU inflation is bounded by SMT and memory contention. The
bound itself is UNCHANGED at 2.0s: on an idle machine the two clocks are the
same number (measured ratio 1.00), so every figure in the derivation comment
stays true as a CPU-time figure. Raising the bound instead was rejected by
measurement -- the vulnerable `[^>]` script-tag form runs 4.0s idle, so any
wall bound loose enough to survive load would let the defect pass.
Signal verified by patching the pre-fix forms back in: both rows go red
(4.49s and 18.85s against 2.0s) and green again with the shipped forms.
That verification found the second, worse defect. 0.7.0's own no-URL narrowing
killed `test_gate_is_bounded_on_the_long_attribute_arm`: `<a>` is in
`_URL_AFFORDANCE_TAGS`, so the bare `<a ` + 100k + `>` payload is now inert and
returns BEFORE the body reaches `URL_IN_TEXT_RE` -- the arm the row exists to
guard. Measured against the vulnerable form through `scan_active_content`:
<a ...> 0.028s and NO findings <- dead: separation 1.0x
<script ...> 12.475s
<a href=x …> 12.719s
<form ...> 17.092s
The carrier is now `<script `: active by NAME with no attributes, so no future
URL-shaped narrowing can make it inert the same way. 0.53s shipped vs 18.85s
vulnerable through `scan_output` -- 35x apart, bound 3.8x above the shipped side.
`test_pathological_input_returns_within_a_bound` deliberately keeps its wall
clock: it claims to catch "a hang or a blowup", and only a wall clock catches
the first. New instrument test pins the clock via the same helper the bounds
use, so the choice cannot drift silently.
792 tests, 129/129 coverage, 6/6 gaps. The ReDoS block passes 3/3 under the
4x oversubscription that produced 8.13s wall.
This commit is contained in:
parent
be9759b4b3
commit
566706360a
1 changed files with 77 additions and 13 deletions
|
|
@ -351,6 +351,25 @@ def test_pathological_input_returns_within_a_bound():
|
||||||
|
|
||||||
|
|
||||||
# --- crafted ReDoS payloads against OUR OWN patterns (OWASP LLM10) -----------
|
# --- 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
|
||||||
|
|
||||||
|
|
||||||
# The gap the test above explicitly does NOT cover. Every pattern here has the
|
# 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
|
# same shape: a `+`/`*` run followed by a REQUIRED literal, reachable from a
|
||||||
# short anchor. The payload repeats that anchor and never supplies the literal,
|
# short anchor. The payload repeats that anchor and never supplies the literal,
|
||||||
|
|
@ -422,9 +441,41 @@ _REDOS_PAYLOADS = [
|
||||||
)
|
)
|
||||||
def test_crafted_redos_payload_stays_bounded(scanner, unit, n):
|
def test_crafted_redos_payload_stays_bounded(scanner, unit, n):
|
||||||
payload = (unit * (n // len(unit) + 1))[:n]
|
payload = (unit * (n // len(unit) + 1))[:n]
|
||||||
start = time.monotonic()
|
assert _scan_seconds(scanner, payload) < 2.0
|
||||||
scanner(payload)
|
|
||||||
assert time.monotonic() - start < 2.0
|
|
||||||
|
def test_the_redos_clock_ignores_time_this_process_did_not_spend():
|
||||||
|
# The instrument the bounds above are measured on, pinned -- because getting
|
||||||
|
# it wrong makes a GREEN suite look red. In 0.7.0 these bounds ran on
|
||||||
|
# `time.monotonic()`, and two rows failed at 2.24s / 3.66s against the 2.0s
|
||||||
|
# bound while two census processes had the CPU; the same rows passed 3/3 on
|
||||||
|
# an idle machine. The scans had not slowed down -- they were descheduled.
|
||||||
|
#
|
||||||
|
# Measured on this machine (16 logical / 8 physical cores), `lexicon-script-tag`,
|
||||||
|
# shipped form, idle vs 2x vs 4x oversubscription:
|
||||||
|
#
|
||||||
|
# wall 0.74s -> 3.55s -> 8.13s (11x, still climbing with load)
|
||||||
|
# cpu 0.74s -> 1.42s -> 1.50s (2.0x, flat from 2x to 4x)
|
||||||
|
#
|
||||||
|
# Wall-clock inflation is proportional to how many other processes want the
|
||||||
|
# CPU and has no ceiling. Process CPU inflation is bounded by SMT and memory
|
||||||
|
# contention -- a sibling hyperthread can cost you roughly 2x and nothing
|
||||||
|
# beyond it, which is why the two right-hand columns barely differ. On an
|
||||||
|
# idle machine the two clocks are the same number (measured ratio 1.00), so
|
||||||
|
# switching instrument re-derives NOTHING above: every figure in the bound
|
||||||
|
# derivation stays true as a CPU-time figure.
|
||||||
|
#
|
||||||
|
# What this clock gives up: a scan that BLOCKS forever burns no CPU, so it
|
||||||
|
# would hang the suite instead of failing it. Acceptable here -- these
|
||||||
|
# scanners are pure regex over an in-memory string, with no I/O and no locks,
|
||||||
|
# so the only way they can be slow is by spending cycles. It is also why
|
||||||
|
# `test_pathological_input_returns_within_a_bound` above keeps a wall clock:
|
||||||
|
# that test claims to catch "a hang or a blowup", and only a wall clock
|
||||||
|
# catches the first.
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
def test_crafted_redos_payload_bounded_through_the_public_gate():
|
def test_crafted_redos_payload_bounded_through_the_public_gate():
|
||||||
|
|
@ -433,9 +484,7 @@ def test_crafted_redos_payload_bounded_through_the_public_gate():
|
||||||
# invokes is bounded too -- with the worst measured payload (`<a:`, 660x the
|
# invokes is bounded too -- with the worst measured payload (`<a:`, 660x the
|
||||||
# slowest legitimate content of the same size).
|
# slowest legitimate content of the same size).
|
||||||
payload = ("<a:" * (_REDOS_N // 3 + 1))[:_REDOS_N]
|
payload = ("<a:" * (_REDOS_N // 3 + 1))[:_REDOS_N]
|
||||||
start = time.monotonic()
|
assert _scan_seconds(scan_output, payload) < 2.0
|
||||||
scan_output(payload)
|
|
||||||
assert time.monotonic() - start < 2.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_gate_is_bounded_on_the_payload_the_first_sweep_missed():
|
def test_gate_is_bounded_on_the_payload_the_first_sweep_missed():
|
||||||
|
|
@ -447,9 +496,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;
|
# 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.
|
# this row exists so the composed gate a caller actually invokes is covered.
|
||||||
payload = "[" * _REDOS_N
|
payload = "[" * _REDOS_N
|
||||||
start = time.monotonic()
|
assert _scan_seconds(scan_output, payload) < 2.0
|
||||||
scan_output(payload)
|
|
||||||
assert time.monotonic() - start < 2.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_gate_is_bounded_on_the_long_attribute_arm():
|
def test_gate_is_bounded_on_the_long_attribute_arm():
|
||||||
|
|
@ -458,10 +505,27 @@ def test_gate_is_bounded_on_the_long_attribute_arm():
|
||||||
# caller actually invokes inherits it. Not expressible as a repeating unit —
|
# caller actually invokes inherits it. Not expressible as a repeating unit —
|
||||||
# the tag has to CLOSE for the body to be handed on — which is exactly why
|
# the tag has to CLOSE for the body to be handed on — which is exactly why
|
||||||
# the unit-table above never covered it.
|
# the unit-table above never covered it.
|
||||||
payload = "<a " + "A" * _REDOS_N + ">"
|
#
|
||||||
start = time.monotonic()
|
# The carrier is `<script `, not the `<a ` this row shipped with through
|
||||||
scan_output(payload)
|
# 0.7.0, because 0.7.0's own no-URL narrowing killed the row: `<a>` is in
|
||||||
assert time.monotonic() - start < 2.0
|
# `_URL_AFFORDANCE_TAGS`, so a bare `<a ...>` carrying no URL attribute is
|
||||||
|
# now inert and returns BEFORE the body reaches `URL_IN_TEXT_RE` — the arm
|
||||||
|
# this row exists to guard. Measured with the pre-fix uncapped scheme run
|
||||||
|
# patched back in, at _REDOS_N through `scan_active_content`:
|
||||||
|
#
|
||||||
|
# <a ...> 0.028s and NO findings <- dead: never reaches the arm
|
||||||
|
# <script ...> 12.475s <- the arm, still quadratic
|
||||||
|
# <a href=x …> 12.719s
|
||||||
|
# <form ...> 17.092s
|
||||||
|
#
|
||||||
|
# So the row was green against the vulnerable form: separation 1.0x, zero
|
||||||
|
# signal. With `<script ` it is 0.53s shipped vs 18.85s vulnerable through
|
||||||
|
# `scan_output` — 35x apart, with the bound 3.8x above the shipped side.
|
||||||
|
# `<script>` is the durable choice of the three: it is 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 >`.
|
||||||
|
payload = "<script " + "A" * _REDOS_N + ">"
|
||||||
|
assert _scan_seconds(scan_output, payload) < 2.0
|
||||||
|
|
||||||
|
|
||||||
# --- ZWJ inside emoji sequences on the output gate ---------------------------
|
# --- ZWJ inside emoji sequences on the output gate ---------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue