From 78c9f2f7f198726b6a0645b14b097cd7f4b22cd8 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sat, 4 Jul 2026 18:55:12 +0200 Subject: [PATCH] feat(neutralize): opt-in pure defang of active-content output (TDD) [skip-docs] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 / 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) Claude-Session: https://claude.ai/code/session_01HyRCQMocjZ6SmSQ6JidJ2k --- src/llm_ingestion_guard/neutralize.py | 222 ++++++++++++++++++++++++++ tests/test_neutralize.py | 141 ++++++++++++++++ 2 files changed, 363 insertions(+) create mode 100644 src/llm_ingestion_guard/neutralize.py create mode 100644 tests/test_neutralize.py diff --git a/src/llm_ingestion_guard/neutralize.py b/src/llm_ingestion_guard/neutralize.py new file mode 100644 index 0000000..bf0283a --- /dev/null +++ b/src/llm_ingestion_guard/neutralize.py @@ -0,0 +1,222 @@ +"""neutralize — opt-in, pure defang of active content in model OUTPUT. + +Query-time guardrails guard the answer; this guards the *persisted artifact*. +When model output is written to a wiki, doc, or knowledge base and later rendered, +active-content constructs become an exfiltration channel: a markdown image URL is +auto-fetched the moment the page renders, leaking whatever the attacker packed +into it — with no click. This is the EchoLeak class (CVE-2025-32711). Such +carriers are neither injection strings nor high-entropy blobs, so ``lexicon`` and +``entropy`` do not see them; neutralizing them is a distinct control (OWASP +LLM05 — Improper Output Handling). + +Defang, don't delete. Each active construct is rewritten to an inert but still +human-auditable form: URLs get a non-resolvable scheme and bracketed dots +(``https://evil.com`` -> ``hxxps://evil[.]com``), and raw active HTML is escaped +so a renderer shows it as literal text instead of executing it. The visible +information survives review; only the machine-actionable affordance dies. + +Two properties are load-bearing and mirror the sanitizer: + +1. **Opt-in and separate.** Calling this function *is* the opt-in to mutate. + Detection elsewhere in the library stays report-only (design principles 3 & 4); + the report-only output gate (``output``) never rewrites. A caller that wants + findings without mutation reads ``result.report`` and discards ``result.text``. +2. **Byte-identical on clean input.** Output with no active construct is returned + unchanged with an empty report. Benign inline formatting (````, ````) + and prose containing stray ``<``, ``>``, ``[`` are left untouched. + +Scope note (conceded, not hidden): this is a targeted defanger, not a full HTML +sanitizer. Text *between* escaped tags (e.g. a ``") + assert "