fix(calibration): grade active content on URL shape, not construct type
v0.3.0 made the untrusted upload path unusable: measured on both doors, an ordinary remote image fail_secure'd and an ordinary link/autolink/refdef quarantined, so only documents without external references persisted. Two independent defects compounded; neither fix works alone: 1. `markdown-image: HIGH` fired on any external image. The exfil primitive is a URL that moves bytes outward, not an image. `is_ordinary_url` now grades on shape - http(s)/protocol-relative, no query, no userinfo, no percent-escape, no opaque host label or path segment -> LOW; anything data-carrying keeps the carrier's severity. raw-html and data: URIs stay HIGH unconditionally. Opacity reuses entropy's primitives; floors calibrated against real doc URLs (worst legit token H=4.08, exfil segments 4.36-4.54) and frozen in calibration. 2. The quarantine_default floor fired on ANY finding, a premise that broke when every ordinary link became a finding. It now fires at MEDIUM+ - a no-op for every detector that shipped before 0.3.0 (no LOW/INFO exists), which is what makes this a patch rather than a minor. The corpus blind spot that let this pass 522 green tests is closed: the FP corpus carries realistic markdown and is asserted on the OUTPUT gate under PRESET_USER_UPLOAD, with a counter-corpus of exfil-shaped URLs that must still block. Beaconing and short opaque segments are conceded in LIMITATIONS and asserted by the coverage matrix rather than papered over. No new public API; no new preset (0.4.0 work); allow_reserved default unchanged.
This commit is contained in:
parent
da7421e6c8
commit
6e9b8168e3
13 changed files with 533 additions and 46 deletions
|
|
@ -24,6 +24,25 @@ those formats' core mechanism. ``neutralize`` keeps its broader defang-anything
|
|||
behavior: it is opt-in, and bracketed dots in a relative path are auditable,
|
||||
not blocking.
|
||||
|
||||
**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. ```` carries nothing
|
||||
outward, so grading it like ```` made
|
||||
ordinary documents unpersistable on the upload preset (measured on v0.3.0: every
|
||||
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.
|
||||
|
||||
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
|
||||
credential in a URL is caught by the secret-egress patterns in the same
|
||||
``scan_output`` pass regardless of the severity assigned here. The residual
|
||||
holes it leaves — pure beaconing, short opaque segments — are documented in
|
||||
``docs/LIMITATIONS.md`` rather than papered over.
|
||||
|
||||
Scan order mirrors ``neutralize``'s pass order, with each matched construct
|
||||
masked out of the working text before the next pass — so a construct is counted
|
||||
once by its most specific class (an image is not also a link; an autolink is
|
||||
|
|
@ -36,8 +55,16 @@ without recreating the affordance it flagged.
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from .calibration import ACTIVE_CONTENT_SEVERITY as _SEVERITY
|
||||
from .calibration import (
|
||||
ACTIVE_CONTENT_ORDINARY_SEVERITY as _ORDINARY_SEVERITY,
|
||||
ACTIVE_CONTENT_SEVERITY as _SEVERITY,
|
||||
URL_OPAQUE_ENTROPY_H as _OPAQUE_H,
|
||||
URL_OPAQUE_HEX_MIN_LEN as _OPAQUE_HEX_LEN,
|
||||
URL_OPAQUE_MIN_LEN as _OPAQUE_MIN_LEN,
|
||||
)
|
||||
from .entropy import is_hex_blob, shannon_entropy, try_decode_base64
|
||||
from .report import Finding, Report, Source
|
||||
|
||||
# --- URL defang (shared primitive) -------------------------------------------
|
||||
|
|
@ -139,6 +166,62 @@ def _always(url: str) -> bool:
|
|||
return True
|
||||
|
||||
|
||||
# --- URL shape: can this URL carry data outward? -----------------------------
|
||||
# Only http(s) and protocol-relative URLs have an "ordinary" form. Every other
|
||||
# scheme (javascript:, data:, file:, ftp:, ...) is active or fetches out-of-band
|
||||
# on its own terms and never grades down.
|
||||
_ORDINARY_SCHEME_RE = re.compile(r"^(?:https?://|//)", re.IGNORECASE)
|
||||
# Host labels and path segments: the separators that delimit a *name*. A token
|
||||
# that survives this split and still looks like a blob is carried data.
|
||||
_URL_TOKEN_RE = re.compile(r"[/._\-~+,;:=&$!*'()]+")
|
||||
|
||||
|
||||
def _is_opaque(token: str) -> bool:
|
||||
"""True if a URL token looks like carried data rather than a name.
|
||||
|
||||
Three reused ``entropy`` signals, cheapest first: base64 that decodes to
|
||||
printable text (the encoding an exfil path actually uses), a hex id at the
|
||||
URL-token floor, and — as a backstop for random-looking tokens that are
|
||||
neither — length-paired Shannon entropy.
|
||||
"""
|
||||
if try_decode_base64(token) is not None:
|
||||
return True
|
||||
if len(token) >= _OPAQUE_HEX_LEN and is_hex_blob(token):
|
||||
return True
|
||||
return len(token) >= _OPAQUE_MIN_LEN and shannon_entropy(token) >= _OPAQUE_H
|
||||
|
||||
|
||||
def is_ordinary_url(url: str) -> bool:
|
||||
"""True if ``url`` merely *names* a remote document, carrying nothing outward.
|
||||
|
||||
Ordinary means all of: an http(s) or protocol-relative scheme, no query, no
|
||||
userinfo, no percent-escapes, and no opaque host label or path segment.
|
||||
|
||||
The fragment is deliberately excluded from the test: it is never sent to the
|
||||
server, so it cannot carry data to the host that a renderer auto-fetches —
|
||||
``…/overview#prerequisites`` is the single most common shape in real
|
||||
documentation. Percent-escapes count as carrying, which grades a legitimate
|
||||
``%20`` in a path as data-carrying; that false positive is accepted and
|
||||
documented (``docs/LIMITATIONS.md``) because obfuscated encoding is a core
|
||||
exfil primitive and the ambiguous case belongs on the review side.
|
||||
"""
|
||||
if not _ORDINARY_SCHEME_RE.match(url):
|
||||
return False
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
except ValueError: # malformed authority (bad IPv6, bad port) -> never ordinary
|
||||
return False
|
||||
if parts.query or parts.username or parts.password:
|
||||
return False
|
||||
# `netloc`, not `hostname`: the latter lowercases, which would destroy the
|
||||
# mixed case a base64 payload smuggled into a subdomain depends on. Userinfo
|
||||
# is already rejected above, so what is left is host[:port].
|
||||
named = parts.netloc + parts.path
|
||||
if "%" in named:
|
||||
return False
|
||||
return not any(_is_opaque(token) for token in _URL_TOKEN_RE.split(named) if token)
|
||||
|
||||
|
||||
# Per-construct severities (_SEVERITY, imported above) live in `calibration` —
|
||||
# zero-click auto-fetch/execute -> HIGH, click-required -> MEDIUM — the Node port
|
||||
# shares them.
|
||||
|
|
@ -153,25 +236,34 @@ def scan_active_content(text: str, source: Source = Source.OUTPUT) -> Report:
|
|||
"""
|
||||
report = Report()
|
||||
|
||||
def _flag(cls: str, count: int, evidence: str) -> None:
|
||||
def _flag(cls: str, hits: list[tuple[str, bool]]) -> None:
|
||||
"""Report one finding for ``cls``, graded by its *worst* member.
|
||||
|
||||
A class collapses to a single finding, so an exfil-shaped URL hiding
|
||||
behind an ordinary one must set both the severity and the evidence —
|
||||
otherwise the report would show an innocent URL next to a HIGH verdict.
|
||||
"""
|
||||
carrying = [evidence for evidence, ordinary in hits if not ordinary]
|
||||
report.add(Finding(
|
||||
label=f"active:{cls}", severity=_SEVERITY[cls], source=source,
|
||||
detector="active_content", count=count,
|
||||
evidence=redact(evidence), owasp="LLM05",
|
||||
label=f"active:{cls}",
|
||||
severity=_SEVERITY[cls] if carrying else _ORDINARY_SEVERITY,
|
||||
source=source, detector="active_content", count=len(hits),
|
||||
evidence=redact(carrying[0] if carrying else hits[0][0]), owasp="LLM05",
|
||||
))
|
||||
|
||||
masked = text
|
||||
|
||||
def _scan(pattern: re.Pattern[str], url_group, keep) -> list[str]:
|
||||
"""Collect defanged URLs of kept matches; mask every match with spaces
|
||||
(same length, so line structure and later offsets survive)."""
|
||||
def _scan(pattern: re.Pattern[str], url_group, keep) -> list[tuple[str, bool]]:
|
||||
"""Collect ``(defanged url, is_ordinary)`` for kept matches; mask every
|
||||
match with spaces (same length, so line structure and later offsets
|
||||
survive)."""
|
||||
nonlocal masked
|
||||
hits: list[str] = []
|
||||
hits: list[tuple[str, bool]] = []
|
||||
|
||||
def _sub(m: re.Match[str]) -> str:
|
||||
url = m.group(url_group)
|
||||
if keep(url):
|
||||
hits.append(defang_url(url))
|
||||
hits.append((defang_url(url), is_ordinary_url(url)))
|
||||
return " " * len(m.group(0))
|
||||
|
||||
masked = pattern.sub(_sub, masked)
|
||||
|
|
@ -181,34 +273,38 @@ def scan_active_content(text: str, source: Source = Source.OUTPUT) -> Report:
|
|||
# then links, refdefs, autolinks, raw HTML, and standalone data: URIs.
|
||||
imgs = _scan(MD_IMAGE_RE, "url", _has_external_target)
|
||||
if imgs:
|
||||
_flag("markdown-image", len(imgs), imgs[0])
|
||||
_flag("markdown-image", imgs)
|
||||
|
||||
links = _scan(MD_LINK_RE, "url", _has_external_target)
|
||||
if links:
|
||||
_flag("markdown-link", len(links), links[0])
|
||||
_flag("markdown-link", links)
|
||||
|
||||
refs = _scan(MD_REFDEF_RE, "url", _always)
|
||||
if refs:
|
||||
_flag("reference-link", len(refs), refs[0])
|
||||
_flag("reference-link", refs)
|
||||
|
||||
autos = _scan(AUTOLINK_RE, "url", _always)
|
||||
if autos:
|
||||
_flag("autolink", len(autos), autos[0])
|
||||
_flag("autolink", autos)
|
||||
|
||||
html: list[str] = []
|
||||
# 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]] = []
|
||||
|
||||
def _tag(m: re.Match[str]) -> str:
|
||||
if not is_active_tag(m.group("name"), m.group("attrs") or ""):
|
||||
return m.group(0)
|
||||
html.append(URL_IN_TEXT_RE.sub(lambda u: defang_url(u.group(0)), m.group(0)))
|
||||
html.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", len(html), html[0])
|
||||
_flag("raw-html", html)
|
||||
|
||||
# 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.
|
||||
datas = _scan(DATA_URI_RE, 0, _always)
|
||||
if datas:
|
||||
_flag("data-uri", len(datas), datas[0])
|
||||
_flag("data-uri", datas)
|
||||
|
||||
return report
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue