feat(active-content,okf): bound the last two detection surfaces
`scan_active_content` called directly and `okf.link_graph` were the two surfaces still reading attacker-supplied text with no cap — the first reached by an adapter that wants the active-content classes alone, the second running a `findall` over every body in a bundle. Both are detection-shaped, so they truncate and flag rather than raise the way the transform surfaces do: what a detector shortens is its own coverage, not the caller's content. Truncation is only honest if it is visible, so neither goes quiet: the scanner emits `active:oversize-input` (LLM10), and `link_graph` records `(from_id, body_length)` in `LinkGraphResult.truncated` — the field that lets a caller tell "no links past here" from "no links read past here". Reached through `scan_output`, the text is already under that surface's cap and `max_scan_chars` is now passed down, so the flag is raised once, there.
This commit is contained in:
parent
0bf07295c2
commit
b90233481a
6 changed files with 111 additions and 22 deletions
|
|
@ -302,21 +302,25 @@ items; this is the full list, each with the mechanism.
|
||||||
*closes* around a long body, and a run of plain characters carrying no anchor
|
*closes* around a long body, and a run of plain characters carrying no anchor
|
||||||
at all.
|
at all.
|
||||||
|
|
||||||
- **Two detection surfaces still accept unbounded input; the transform surfaces
|
- **Every surface now bounds its input, but not all of them the same way.**
|
||||||
no longer do.** Unreleased, on `main` at `2d98d68`: `sanitize`, `fence` and
|
`sanitize`, `fence` and `neutralize` raise `OversizeInputError` above
|
||||||
`neutralize` raise
|
`MAX_INPUT_CHARS` (1 000 000) rather than returning a partially transformed
|
||||||
`OversizeInputError` above `MAX_INPUT_CHARS` (1 000 000) rather than returning
|
document, which bounds the whole input path — `sanitize` is step 1 of
|
||||||
a partially transformed document, which bounds the whole input path — `sanitize`
|
`prepare_input`, and it only ever removes, so everything after it is already
|
||||||
is step 1 of `prepare_input`, and it only ever removes, so everything after it
|
under the cap. They reject rather than truncate because they return *content*:
|
||||||
is already under the cap. They reject rather than truncate because they return
|
a shortened document is silent data loss, and a transformed prefix followed by
|
||||||
*content*: a shortened document is silent data loss, and a transformed prefix
|
an untransformed tail is a bypass an attacker positions the payload into. The
|
||||||
followed by an untransformed tail is a bypass an attacker positions the payload
|
detection surfaces truncate instead, which costs only detection in the tail —
|
||||||
into. The scanners keep truncating, which costs only detection in the tail.
|
`scan_lexicon` / `scan_output` always have, and as of 0.4.0
|
||||||
What remains uncapped is `scan_active_content` **called directly** (reached
|
`scan_active_content` **called directly** does too (reached through
|
||||||
through `scan_output` it inherits that cap) and the okf link graph, whose cost
|
`scan_output` it inherits that surface's cap and is not flagged twice), as does
|
||||||
is a bundle-wide `findall` over every document body. Both are detection-shaped,
|
`okf.link_graph`, whose cost was a bundle-wide `findall` over every document
|
||||||
so the scanners' truncate-and-flag mechanism transfers to them unchanged — that
|
body. **What truncation costs is worth naming: past the cap, "no finding" means
|
||||||
is a mechanical follow-up, not a policy question, and it is not yet done.
|
"not looked at".** Each says so rather than staying silent — the scanners emit
|
||||||
|
an `oversize-input` finding (`active:oversize-input`, OWASP LLM10), and
|
||||||
|
`link_graph` records `(from_id, body_length)` in `LinkGraphResult.truncated`,
|
||||||
|
which is what lets a caller tell "no links past here" apart from "no links
|
||||||
|
*read* past here".
|
||||||
|
|
||||||
## The six documented gaps (tracked by the coverage matrix)
|
## The six documented gaps (tracked by the coverage matrix)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,12 +60,13 @@ from urllib.parse import urlsplit
|
||||||
from .calibration import (
|
from .calibration import (
|
||||||
ACTIVE_CONTENT_ORDINARY_SEVERITY as _ORDINARY_SEVERITY,
|
ACTIVE_CONTENT_ORDINARY_SEVERITY as _ORDINARY_SEVERITY,
|
||||||
ACTIVE_CONTENT_SEVERITY as _SEVERITY,
|
ACTIVE_CONTENT_SEVERITY as _SEVERITY,
|
||||||
|
MAX_SCAN_CHARS,
|
||||||
URL_OPAQUE_ENTROPY_H as _OPAQUE_H,
|
URL_OPAQUE_ENTROPY_H as _OPAQUE_H,
|
||||||
URL_OPAQUE_HEX_MIN_LEN as _OPAQUE_HEX_LEN,
|
URL_OPAQUE_HEX_MIN_LEN as _OPAQUE_HEX_LEN,
|
||||||
URL_OPAQUE_MIN_LEN as _OPAQUE_MIN_LEN,
|
URL_OPAQUE_MIN_LEN as _OPAQUE_MIN_LEN,
|
||||||
)
|
)
|
||||||
from .entropy import is_hex_blob, shannon_entropy, try_decode_base64
|
from .entropy import is_hex_blob, shannon_entropy, try_decode_base64
|
||||||
from .report import Finding, Report, Source
|
from .report import Finding, Report, Severity, Source
|
||||||
|
|
||||||
# --- URL defang (shared primitive) -------------------------------------------
|
# --- URL defang (shared primitive) -------------------------------------------
|
||||||
# Rewrite a URL to a form no renderer will resolve, while keeping it readable.
|
# Rewrite a URL to a form no renderer will resolve, while keeping it readable.
|
||||||
|
|
@ -267,15 +268,35 @@ def is_ordinary_url(url: str) -> bool:
|
||||||
# shares them.
|
# shares them.
|
||||||
|
|
||||||
|
|
||||||
def scan_active_content(text: str, source: Source = Source.OUTPUT) -> Report:
|
def scan_active_content(
|
||||||
|
text: str,
|
||||||
|
source: Source = Source.OUTPUT,
|
||||||
|
max_scan_chars: int = MAX_SCAN_CHARS,
|
||||||
|
) -> Report:
|
||||||
"""Report active-content constructs with an external target in ``text``.
|
"""Report active-content constructs with an external target in ``text``.
|
||||||
|
|
||||||
Report-only (design principles 3 & 4): the input is never mutated and no
|
Report-only (design principles 3 & 4): the input is never mutated and no
|
||||||
disposition is rendered here. Labels are ``active:<class>``; severities
|
disposition is rendered here. Labels are ``active:<class>``; severities
|
||||||
mirror ``neutralize``'s (image / raw-html / data-uri HIGH, links MEDIUM).
|
mirror ``neutralize``'s (image / raw-html / data-uri HIGH, links MEDIUM).
|
||||||
|
|
||||||
|
Self-safety (OWASP LLM10): the scanned length is capped once, and an
|
||||||
|
``active:oversize-input`` finding announces that the tail went unread. It
|
||||||
|
truncates rather than raising the way the transform surfaces do — what a
|
||||||
|
detector shortens is its own coverage, not the caller's content. Reached
|
||||||
|
through :func:`~llm_ingestion_guard.output.scan_output` the text is already
|
||||||
|
under that surface's cap, so the flag is raised once, there.
|
||||||
"""
|
"""
|
||||||
report = Report()
|
report = Report()
|
||||||
|
|
||||||
|
if len(text) > max_scan_chars:
|
||||||
|
report.add(Finding(
|
||||||
|
label="active:oversize-input", severity=Severity.MEDIUM,
|
||||||
|
source=source, detector="active_content", count=len(text),
|
||||||
|
owasp="LLM10",
|
||||||
|
evidence=f"input {len(text)} chars exceeds cap {max_scan_chars}; scanned prefix only",
|
||||||
|
))
|
||||||
|
text = text[:max_scan_chars]
|
||||||
|
|
||||||
def _flag(cls: str, hits: list[tuple[str, bool]]) -> None:
|
def _flag(cls: str, hits: list[tuple[str, bool]]) -> None:
|
||||||
"""Report one finding for ``cls``, graded by its *worst* member.
|
"""Report one finding for ``cls``, graded by its *worst* member.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
from .calibration import MAX_SCAN_CHARS
|
||||||
from .output import scan_output
|
from .output import scan_output
|
||||||
from .report import Report, Source
|
from .report import Report, Source
|
||||||
from .disposition import Trust, Disposition, Policy, decide
|
from .disposition import Trust, Disposition, Policy, decide
|
||||||
|
|
@ -428,12 +429,15 @@ class LinkGraphResult:
|
||||||
signal of §7.2 (a link planted to a not-yet-written concept). ``rejected`` —
|
signal of §7.2 (a link planted to a not-yet-written concept). ``rejected`` —
|
||||||
``(from_id, target, reason)`` for links refused outright (dangerous scheme or
|
``(from_id, target, reason)`` for links refused outright (dangerous scheme or
|
||||||
bundle escape). ``resolved`` — ``(from_id, target_concept_id)`` for links to
|
bundle escape). ``resolved`` — ``(from_id, target_concept_id)`` for links to
|
||||||
concepts present in the bundle.
|
concepts present in the bundle. ``truncated`` — ``(from_id, body_length)`` for
|
||||||
|
bodies read only as far as the scan cap, so a caller can tell "no links past
|
||||||
|
here" apart from "no links *read* past here" (OWASP LLM10).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
dangling: tuple
|
dangling: tuple
|
||||||
rejected: tuple
|
rejected: tuple
|
||||||
resolved: tuple
|
resolved: tuple
|
||||||
|
truncated: tuple = ()
|
||||||
|
|
||||||
|
|
||||||
def extract_link_targets(body):
|
def extract_link_targets(body):
|
||||||
|
|
@ -475,14 +479,20 @@ def resolve_link(target, from_concept_id):
|
||||||
return normalized[: -len(".md")]
|
return normalized[: -len(".md")]
|
||||||
|
|
||||||
|
|
||||||
def link_graph(bundle):
|
def link_graph(bundle, max_scan_chars=MAX_SCAN_CHARS):
|
||||||
"""Resolve every cross-link in ``bundle`` against the concepts it contains.
|
"""Resolve every cross-link in ``bundle`` against the concepts it contains.
|
||||||
|
|
||||||
``bundle`` maps concept path to document text (as :func:`import_bundle`). Only
|
``bundle`` maps concept path to document text (as :func:`import_bundle`). Only
|
||||||
the body is scanned for links. See :class:`LinkGraphResult` for the outcome.
|
the body is scanned for links. See :class:`LinkGraphResult` for the outcome.
|
||||||
|
|
||||||
|
Self-safety (OWASP LLM10): every body is attacker-supplied and each is walked
|
||||||
|
by a `findall`, so each body is capped at ``max_scan_chars`` and recorded in
|
||||||
|
``truncated``. It truncates rather than raising, the way the scanners do: the
|
||||||
|
graph reports on documents, it does not hand them back, so a shortened scan
|
||||||
|
costs edges — not the caller's content.
|
||||||
"""
|
"""
|
||||||
present = {p[: -len(".md")] for p in bundle if p.endswith(".md")}
|
present = {p[: -len(".md")] for p in bundle if p.endswith(".md")}
|
||||||
dangling, rejected, resolved = [], [], []
|
dangling, rejected, resolved, truncated = [], [], [], []
|
||||||
|
|
||||||
for path in sorted(bundle):
|
for path in sorted(bundle):
|
||||||
if not path.endswith(".md"):
|
if not path.endswith(".md"):
|
||||||
|
|
@ -493,6 +503,10 @@ def link_graph(bundle):
|
||||||
except OKFFrontmatterError:
|
except OKFFrontmatterError:
|
||||||
body = bundle[path] # unparseable frontmatter is T2's reject, not ours
|
body = bundle[path] # unparseable frontmatter is T2's reject, not ours
|
||||||
|
|
||||||
|
if len(body) > max_scan_chars:
|
||||||
|
truncated.append((from_id, len(body)))
|
||||||
|
body = body[:max_scan_chars]
|
||||||
|
|
||||||
for target in extract_link_targets(body):
|
for target in extract_link_targets(body):
|
||||||
try:
|
try:
|
||||||
concept_id = resolve_link(target, from_id)
|
concept_id = resolve_link(target, from_id)
|
||||||
|
|
@ -506,7 +520,9 @@ def link_graph(bundle):
|
||||||
else:
|
else:
|
||||||
dangling.append((from_id, concept_id))
|
dangling.append((from_id, concept_id))
|
||||||
|
|
||||||
return LinkGraphResult(tuple(dangling), tuple(rejected), tuple(resolved))
|
return LinkGraphResult(
|
||||||
|
tuple(dangling), tuple(rejected), tuple(resolved), tuple(truncated)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_bundle_path(path):
|
def _normalize_bundle_path(path):
|
||||||
|
|
|
||||||
|
|
@ -336,6 +336,7 @@ def scan_output(
|
||||||
# 6. Active-content constructs with an external target (the EchoLeak class,
|
# 6. Active-content constructs with an external target (the EchoLeak class,
|
||||||
# OWASP LLM05) — reported here so disposition sees them; defanging stays
|
# OWASP LLM05) — reported here so disposition sees them; defanging stays
|
||||||
# neutralize's separate, opt-in job.
|
# neutralize's separate, opt-in job.
|
||||||
report.extend(scan_active_content(scan_text, source).findings)
|
# scan_text is already <= cap, so no second oversize finding is emitted.
|
||||||
|
report.extend(scan_active_content(scan_text, source, max_scan_chars).findings)
|
||||||
|
|
||||||
return report
|
return report
|
||||||
|
|
|
||||||
|
|
@ -331,3 +331,30 @@ def test_url_defanging_survives_the_redos_fix():
|
||||||
evidence = " ".join(f.evidence or "" for f in report.findings)
|
evidence = " ".join(f.evidence or "" for f in report.findings)
|
||||||
assert expected in evidence, raw
|
assert expected in evidence, raw
|
||||||
assert "http://" not in evidence and "https://" not in evidence, raw
|
assert "http://" not in evidence and "https://" not in evidence, raw
|
||||||
|
|
||||||
|
|
||||||
|
# --- self-safety (OWASP LLM10) ----------------------------------------------
|
||||||
|
#
|
||||||
|
# Reached through `scan_output` this detector inherits that surface's cap. Called
|
||||||
|
# directly — the shape an adapter reaches for when it wants the active-content
|
||||||
|
# classes alone — it had none. It is detection-shaped, so it truncates and flags
|
||||||
|
# rather than raising the way the transform surfaces do: what a shortened scan
|
||||||
|
# costs is coverage of the tail, not the caller's content.
|
||||||
|
|
||||||
|
def test_oversize_input_is_capped_and_flagged():
|
||||||
|
big = "x" * 200 + "\n\n"
|
||||||
|
report = scan_active_content(big, max_scan_chars=50)
|
||||||
|
|
||||||
|
oversize = [f for f in report.findings if "oversize" in f.label]
|
||||||
|
assert len(oversize) == 1
|
||||||
|
assert oversize[0].owasp == "LLM10"
|
||||||
|
assert oversize[0].count == len(big)
|
||||||
|
# Prefix only: the construct past the cap is not reported. This is the cost
|
||||||
|
# the flag exists to announce, so assert it rather than assume it.
|
||||||
|
assert not [f for f in report.findings if f.label == "active:markdown-image"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_input_exactly_at_the_cap_is_not_flagged():
|
||||||
|
# The cap is the largest scanned size, not the smallest truncated one.
|
||||||
|
report = scan_active_content("x" * 50, max_scan_chars=50)
|
||||||
|
assert not [f for f in report.findings if "oversize" in f.label]
|
||||||
|
|
|
||||||
|
|
@ -496,6 +496,26 @@ def test_link_graph_resolves_present_target():
|
||||||
assert graph.dangling == ()
|
assert graph.dangling == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_graph_caps_an_oversize_body_and_records_it():
|
||||||
|
# Self-safety (OWASP LLM10): the graph runs a `findall` over every body in
|
||||||
|
# the bundle, all of it attacker-supplied. It is detection-shaped, so it
|
||||||
|
# truncates and records rather than raising — the caller's documents are not
|
||||||
|
# what it returns.
|
||||||
|
body = "y" * 200 + "\nSee [later](/b/target.md).\n"
|
||||||
|
graph = link_graph({"a/main.md": "---\ntype: t\n---\n" + body}, max_scan_chars=50)
|
||||||
|
|
||||||
|
assert graph.truncated == (("a/main", len(body)),)
|
||||||
|
# The link past the cap was never read — that cost is what the record announces.
|
||||||
|
assert graph.dangling == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_graph_body_at_the_cap_is_not_recorded():
|
||||||
|
body = "See [later](/b/target.md).\n"
|
||||||
|
graph = link_graph({"a/main.md": "---\ntype: t\n---\n" + body}, max_scan_chars=len(body))
|
||||||
|
assert graph.truncated == ()
|
||||||
|
assert ("a/main", "b/target") in graph.dangling
|
||||||
|
|
||||||
|
|
||||||
def test_link_graph_records_rejected_dangerous_link():
|
def test_link_graph_records_rejected_dangerous_link():
|
||||||
bundle = {"a/main.md": "---\ntype: t\n---\n[x](javascript:alert(1))\n"}
|
bundle = {"a/main.md": "---\ntype: t\n---\n[x](javascript:alert(1))\n"}
|
||||||
graph = link_graph(bundle)
|
graph = link_graph(bundle)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue