feat(sanitize,fence,neutralize): reject oversize input instead of half-transforming it
The scanners cap by truncating: they return findings, so reading a prefix costs detection in the tail and nothing else. The three transform surfaces return *content*, where the same move is not available — a shortened document is silent data loss, and a transformed prefix followed by an untransformed tail is a bypass, since the attacker chooses where in the document the payload sits. So they fail secure instead. Above MAX_INPUT_CHARS (1 000 000) sanitize, fence and neutralize raise OversizeInputError. sanitize is step 1 of prepare_input and only ever removes, so that one refusal bounds the whole input path. OversizeInputError subclasses ContractViolation: a pipeline already bracketing its quarantined stage keeps failing closed rather than meeting a type it has never heard of. It inherits the alert-routable property too — sizes in the message, refusing surface in details, no input in either. Invariant now pinned across all three: returned text is always fully transformed, or not returned at all. Still uncapped and recorded in LIMITATIONS: scan_active_content called directly (through scan_output it inherits that cap) and the okf link graph. Both are detection-shaped, so truncate-and-flag transfers unchanged — mechanical, not policy. 699 tests (+23), coverage 128/128 + 6/6, ReDoS sweep 0 candidates / 150.
This commit is contained in:
parent
adf93e47fb
commit
2d98d6809d
10 changed files with 272 additions and 21 deletions
41
CHANGELOG.md
41
CHANGELOG.md
|
|
@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
> **Behaviour change, not a pure fix.** The three transform surfaces gain a
|
||||
> refusal path they did not have. A caller that today passes a document larger
|
||||
> than 1 000 000 characters gets an exception where it previously got a result.
|
||||
> Both pinned consumers were measured against this before it was written; git
|
||||
> pins are exact, so nobody picks it up without re-pinning.
|
||||
|
||||
### Added — input-size cap on the transform surfaces (OWASP LLM10)
|
||||
|
||||
`sanitize`, `fence` and `neutralize` now raise `OversizeInputError` above
|
||||
`MAX_INPUT_CHARS` (1 000 000) instead of accepting text of any length. Since
|
||||
`sanitize` is step 1 of `prepare_input` and only ever *removes*, that single
|
||||
refusal bounds the whole input path.
|
||||
|
||||
They **reject** where the scanners **truncate**, and the asymmetry is the point:
|
||||
|
||||
- `scan_lexicon` / `scan_output` return findings. Reading a prefix costs
|
||||
detection in the tail and nothing else — a lossy answer, but an answer.
|
||||
- `sanitize` / `fence` / `neutralize` return *content*. Truncating would return
|
||||
a shortened document (silent data loss for anything that persists the result)
|
||||
or a transformed prefix followed by an untransformed tail — a bypass, since an
|
||||
attacker chooses where in the document the payload sits.
|
||||
|
||||
The invariant the three now keep: **returned text is always fully transformed,
|
||||
or not returned at all.**
|
||||
|
||||
`OversizeInputError` subclasses `ContractViolation`, so a pipeline already
|
||||
bracketing its quarantined stage in `except ContractViolation` keeps failing
|
||||
closed. Like its parent it is alert-routable: the message carries the size and
|
||||
the cap, `details` names the refusing surface, and neither carries input.
|
||||
`max_input_chars` is a per-call parameter, defaulting to the single calibrated
|
||||
constant.
|
||||
|
||||
### Still uncapped, and deliberately
|
||||
|
||||
`scan_active_content` **called directly** and the okf link graph. Reached through
|
||||
`scan_output`, `scan_active_content` inherits that function's cap. Both are
|
||||
detection-shaped, so the truncate-and-flag mechanism transfers to them unchanged
|
||||
— mechanical follow-up work, not a policy question. Recorded in
|
||||
`docs/LIMITATIONS.md`.
|
||||
|
||||
|
||||
## [0.3.4] — 2026-08-01
|
||||
|
||||
> **Denial-of-service fix on the INPUT path. Upgrade from 0.3.3.** 0.3.3 swept
|
||||
|
|
|
|||
11
README.md
11
README.md
|
|
@ -3,7 +3,7 @@
|
|||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
**Write-time ingestion is the trust boundary that query-time guardrails
|
||||
|
|
@ -90,6 +90,15 @@ the disposition is `FAIL_SECURE`, never a silent persist. Pass
|
|||
together with a transform failure is treated as a probable forced-fallback attack
|
||||
and halts regardless of trust tier.
|
||||
|
||||
`prepare_input` fails **closed** on size too: above `MAX_INPUT_CHARS`
|
||||
(1 000 000) it raises `OversizeInputError`, a `ContractViolation` subclass, rather
|
||||
than returning a half-sanitized document. The scanners bound their work by
|
||||
reading a prefix and flagging, which costs only detection in the tail; a
|
||||
transform returns *content*, where the same move would either drop your data
|
||||
silently or hand back an untransformed tail — the exact place an attacker would
|
||||
put the payload. Catch it where you catch your other ingest refusals; the
|
||||
exception carries sizes and the refusing surface, never any of the input.
|
||||
|
||||
Every primitive is also exported for pipelines that compose the checklist
|
||||
themselves — `sanitize`, `scan_lexicon`, `scan_entropy`, `scan_output`,
|
||||
`scan_active_content`, `neutralize`, the `decide` / `guard` disposition
|
||||
|
|
|
|||
|
|
@ -302,19 +302,20 @@ items; this is the full list, each with the mechanism.
|
|||
*closes* around a long body, and a run of plain characters carrying no anchor
|
||||
at all.
|
||||
|
||||
- **Only `scan_lexicon` and `scan_output` cap their input; the input-side entry
|
||||
points do not.** `MAX_SCAN_CHARS` (1 000 000) is applied in those two functions
|
||||
only. `sanitize`, `fence`, `neutralize`, `scan_active_content` and the okf link
|
||||
graph accept text of any length, so their cost is bounded by the caller's
|
||||
input, not by this library. Every *known* quadratic run on those paths is
|
||||
fixed, and the residual above states what the sweep can and cannot claim — but
|
||||
where an output-path residual is capped at ~23 s, the same residual on the
|
||||
input path has no ceiling. A caller that ingests untrusted documents of
|
||||
unbounded size should impose its own limit before `prepare_input`. Extending
|
||||
the cap into the input path is deliberately **not** done as part of a ReDoS
|
||||
patch: it changes the contract for existing callers (what happens to the
|
||||
truncated remainder is a policy question), and that deserves its own decision
|
||||
rather than being smuggled in.
|
||||
- **Two detection surfaces still accept unbounded input; the transform surfaces
|
||||
no longer do.** Since 0.3.5 `sanitize`, `fence` and `neutralize` raise
|
||||
`OversizeInputError` above `MAX_INPUT_CHARS` (1 000 000) rather than returning
|
||||
a partially transformed document, which bounds the whole input path — `sanitize`
|
||||
is step 1 of `prepare_input`, and it only ever removes, so everything after it
|
||||
is already under the cap. They reject rather than truncate because they return
|
||||
*content*: a shortened document is silent data loss, and a transformed prefix
|
||||
followed by an untransformed tail is a bypass an attacker positions the payload
|
||||
into. The scanners keep truncating, which costs only detection in the tail.
|
||||
What remains uncapped is `scan_active_content` **called directly** (reached
|
||||
through `scan_output` it inherits that cap) and the okf link graph, whose cost
|
||||
is a bundle-wide `findall` over every document body. Both are detection-shaped,
|
||||
so the scanners' truncate-and-flag mechanism transfers to them unchanged — that
|
||||
is a mechanical follow-up, not a policy question, and it is not yet done.
|
||||
|
||||
## The six documented gaps (tracked by the coverage matrix)
|
||||
|
||||
|
|
|
|||
|
|
@ -48,9 +48,11 @@ from .disposition import (
|
|||
from .contract import (
|
||||
assert_tool_less,
|
||||
assert_credential_allowlist,
|
||||
assert_within_input_cap,
|
||||
credential_env_names,
|
||||
scoped_env,
|
||||
ContractViolation,
|
||||
OversizeInputError,
|
||||
)
|
||||
from .grounding import (
|
||||
SourceGroundingCheck,
|
||||
|
|
@ -140,6 +142,7 @@ __all__ = [
|
|||
# contract asserters
|
||||
"assert_tool_less", "assert_credential_allowlist",
|
||||
"credential_env_names", "scoped_env", "ContractViolation",
|
||||
"assert_within_input_cap", "OversizeInputError",
|
||||
# grounding seam
|
||||
"SourceGroundingCheck", "no_grounding_check", "DEFAULT_GROUNDING_CHECK",
|
||||
# §6 bookends
|
||||
|
|
|
|||
|
|
@ -47,6 +47,17 @@ ENTROPY_HEX_FLOOR_LEN = 64
|
|||
# measured to do before the ReDoS fix (see active_content's pattern-table note).
|
||||
MAX_SCAN_CHARS = 1_000_000
|
||||
|
||||
# --- transform surfaces: input cap ------------------------------------------
|
||||
# The same size, and deliberately NOT the same constant, because the two caps
|
||||
# buy different things and may need to move apart. MAX_SCAN_CHARS truncates: a
|
||||
# scanner returns findings, so reading the prefix costs *detection* on the tail
|
||||
# and nothing else. `sanitize` / `fence` / `neutralize` return content, so the
|
||||
# equivalent move would hand back either a shortened document (silent data loss)
|
||||
# or an untransformed tail (a bypass an attacker positions the payload into).
|
||||
# They reject instead — see contract.OversizeInputError. Held at 1M so a
|
||||
# document accepted by the input path is one the scanners can also read whole.
|
||||
MAX_INPUT_CHARS = 1_000_000
|
||||
|
||||
# --- output: secret-egress self-safety --------------------------------------
|
||||
# Longest password a connection-string pattern will match. A bound is required
|
||||
# (not merely nice) because the run sits in front of a mandatory `@`: unbounded,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ Three asserts, matching the reusable-contract checklist (BRIEF §6, steps 3-4):
|
|||
env so a hijacked stage cannot even read a credential it was never granted;
|
||||
the assert then passes by construction.
|
||||
|
||||
A fourth asserter lives here for the same reason — it enforces rather than
|
||||
reports — though it belongs to the transform path rather than the quarantine
|
||||
checklist: **(d) bounded transform input**, :func:`assert_within_input_cap`,
|
||||
which the three content-returning surfaces call so an oversize document is
|
||||
refused instead of half-transformed.
|
||||
|
||||
Reference: ``claude-code-llm-wiki`` ``tools/wiki_ingest/enrich.py``
|
||||
``assert_quarantine`` — a pipeline-specific quarantine gate, generalized here
|
||||
into framework-agnostic, reusable pieces.
|
||||
|
|
@ -51,6 +57,40 @@ class ContractViolation(Exception):
|
|||
self.details = details
|
||||
|
||||
|
||||
class OversizeInputError(ContractViolation):
|
||||
"""A transform surface was handed more text than it will transform.
|
||||
|
||||
A *subclass*, not a sibling: a pipeline that already brackets its quarantined
|
||||
stage in ``except ContractViolation`` keeps failing closed rather than
|
||||
meeting an exception type it has never heard of. :attr:`details` names the
|
||||
surface that refused; the sizes go in the message, and neither carries any
|
||||
of the input, so the exception stays alert-routable like its parent.
|
||||
"""
|
||||
|
||||
|
||||
def assert_within_input_cap(text: str, *, surface: str, max_input_chars: int) -> None:
|
||||
"""Raise :class:`OversizeInputError` unless ``text`` fits the transform cap.
|
||||
|
||||
The write-time asserter for the *transform* surfaces (d). Where the scanners
|
||||
bound their work by truncating — findings are lossy in the tail and nothing
|
||||
else — a transform returns content, so a prefix-only result is either silent
|
||||
data loss or an untransformed tail the payload can be positioned into. The
|
||||
invariant these three keep instead is: returned text is always fully
|
||||
transformed, or not returned at all.
|
||||
|
||||
``max_input_chars`` is the largest accepted size, not the smallest rejected
|
||||
one.
|
||||
"""
|
||||
size = len(text)
|
||||
if size > max_input_chars:
|
||||
raise OversizeInputError(
|
||||
f"{surface}: input {size} chars exceeds cap {max_input_chars}; "
|
||||
"refused rather than partially transformed",
|
||||
code="oversize-input",
|
||||
details=(surface,),
|
||||
)
|
||||
|
||||
|
||||
# --- (a) tool-less transform -----------------------------------------------
|
||||
|
||||
# Populated tool surface across Anthropic + OpenAI request shapes. An empty
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ import re
|
|||
import secrets
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .calibration import MAX_INPUT_CHARS
|
||||
from .contract import assert_within_input_cap
|
||||
from .report import Finding, Report, Severity, Source
|
||||
|
||||
# Static delimiter skeleton. The per-call nonce is the security boundary; this
|
||||
|
|
@ -59,9 +61,21 @@ def _redact(s: str, show_start: int = 16, show_end: int = 5) -> str:
|
|||
return f"{s[:show_start]}...{s[-show_end:]}"
|
||||
|
||||
|
||||
def fence(text: str, source: Source = Source.INPUT) -> FenceResult:
|
||||
def fence(
|
||||
text: str,
|
||||
source: Source = Source.INPUT,
|
||||
max_input_chars: int = MAX_INPUT_CHARS,
|
||||
) -> FenceResult:
|
||||
"""Strip fabricated fence markers from ``text``, then wrap it in a
|
||||
randomized, unspoofable delimiter."""
|
||||
randomized, unspoofable delimiter.
|
||||
|
||||
Raises :class:`~llm_ingestion_guard.contract.OversizeInputError` above
|
||||
``max_input_chars``. Reached through :func:`prepare_input` the text is
|
||||
already bounded (``sanitize`` only ever removes), so the guard matters for
|
||||
direct callers — and there it is load-bearing: a fence whose *body* was
|
||||
truncated would present unstripped attacker markers as fenced data.
|
||||
"""
|
||||
assert_within_input_cap(text, surface="fence", max_input_chars=max_input_chars)
|
||||
report = Report()
|
||||
|
||||
# 1. Strip attacker fence markers FIRST — otherwise a lucky guess of the
|
||||
|
|
|
|||
|
|
@ -53,6 +53,8 @@ from .active_content import (
|
|||
is_active_tag,
|
||||
redact,
|
||||
)
|
||||
from .calibration import MAX_INPUT_CHARS
|
||||
from .contract import assert_within_input_cap
|
||||
from .report import Finding, Report, Severity, Source
|
||||
|
||||
|
||||
|
|
@ -64,13 +66,23 @@ class NeutralizeResult:
|
|||
report: Report
|
||||
|
||||
|
||||
def neutralize(text: str, source: Source = Source.OUTPUT) -> NeutralizeResult:
|
||||
def neutralize(
|
||||
text: str,
|
||||
source: Source = Source.OUTPUT,
|
||||
max_input_chars: int = MAX_INPUT_CHARS,
|
||||
) -> NeutralizeResult:
|
||||
"""Defang active-content constructs in ``text`` and report each class.
|
||||
|
||||
Rewrites markdown images/links, reference-link definitions, angle-bracket
|
||||
autolinks, raw active HTML, and ``data:`` URIs into inert forms. Text with no
|
||||
such construct is returned byte-identical with an empty report.
|
||||
|
||||
Raises :class:`~llm_ingestion_guard.contract.OversizeInputError` above
|
||||
``max_input_chars``. A partially defanged artifact is the worst outcome
|
||||
available here: it *looks* neutralized, and the live constructs are all in
|
||||
the tail nobody re-reads.
|
||||
"""
|
||||
assert_within_input_cap(text, surface="neutralize", max_input_chars=max_input_chars)
|
||||
report = Report()
|
||||
out = text
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ from __future__ import annotations
|
|||
import re
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .calibration import MAX_INPUT_CHARS
|
||||
from .contract import assert_within_input_cap
|
||||
from .report import Finding, Report, Severity, Source
|
||||
|
||||
# Invisible / steganographic character classes (codepoints).
|
||||
|
|
@ -29,8 +31,10 @@ _TAG_LO, _TAG_HI = 0xE0000, 0xE007F # Unicode Tags block (U+E0000–U+E007F)
|
|||
# was wrong in the same way `output`'s was: a lazy run in front of a REQUIRED
|
||||
# literal costs a full tail rescan at *every* start position when the literal
|
||||
# never arrives, so `<!--` repeated to 100_000 chars measured 20.1s (exponent
|
||||
# 1.96–2.14 over four doublings) — and this module, unlike `scan_lexicon` /
|
||||
# `scan_output`, applies no input cap, so nothing bounds that above.
|
||||
# 1.96–2.14 over four doublings) — and at the time this module, unlike
|
||||
# `scan_lexicon` / `scan_output`, applied no input cap, so nothing bounded that
|
||||
# above. MAX_INPUT_CHARS now does, but as a second line only: the cap bounds a
|
||||
# *future* quadratic pattern's damage, it does not make a quadratic one safe.
|
||||
#
|
||||
# Neither of the two fixes used elsewhere fits here. Excluding the opener (`<`)
|
||||
# from the run would drop every comment containing markup — `<!-- <b>x</b> -->`
|
||||
|
|
@ -94,8 +98,19 @@ def _decode_tags(codepoints: list[int]) -> str:
|
|||
return "".join(out)
|
||||
|
||||
|
||||
def sanitize(text: str, source: Source = Source.INPUT) -> SanitizeResult:
|
||||
"""Strip carrier classes from ``text`` and report per-class counts."""
|
||||
def sanitize(
|
||||
text: str,
|
||||
source: Source = Source.INPUT,
|
||||
max_input_chars: int = MAX_INPUT_CHARS,
|
||||
) -> SanitizeResult:
|
||||
"""Strip carrier classes from ``text`` and report per-class counts.
|
||||
|
||||
Raises :class:`~llm_ingestion_guard.contract.OversizeInputError` above
|
||||
``max_input_chars``: this is step 1 of the input path, so the refusal bounds
|
||||
the whole path, and a *partially* sanitized document is worse than none —
|
||||
the unstripped tail is where a carrier would be placed.
|
||||
"""
|
||||
assert_within_input_cap(text, surface="sanitize", max_input_chars=max_input_chars)
|
||||
report = Report()
|
||||
|
||||
# Character-class carriers: single pass, keep everything else verbatim.
|
||||
|
|
|
|||
105
tests/test_input_cap.py
Normal file
105
tests/test_input_cap.py
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
"""Tests for the input-size cap on the TRANSFORM surfaces (OWASP LLM10).
|
||||
|
||||
The scan surfaces (``scan_lexicon`` / ``scan_output``) cap by *truncating*: they
|
||||
read the prefix, emit an ``oversize-input`` finding, and return findings only.
|
||||
That trade does not transfer here. ``sanitize`` / ``fence`` / ``neutralize``
|
||||
return **content**, so truncating would either drop user data silently or hand
|
||||
back a tail that never went through the transform — a one-line bypass, since an
|
||||
attacker controls where in the document the payload sits.
|
||||
|
||||
So these three fail *secure* instead. The invariant under test:
|
||||
|
||||
returned text is ALWAYS fully transformed, or not returned at all.
|
||||
"""
|
||||
import inspect
|
||||
|
||||
import pytest
|
||||
|
||||
from llm_ingestion_guard import prepare_input
|
||||
from llm_ingestion_guard.calibration import MAX_INPUT_CHARS
|
||||
from llm_ingestion_guard.contract import ContractViolation, OversizeInputError
|
||||
from llm_ingestion_guard.fence import fence
|
||||
from llm_ingestion_guard.neutralize import neutralize
|
||||
from llm_ingestion_guard.sanitize import sanitize
|
||||
|
||||
# Every transform surface, called through a uniform (text, max) shim so the
|
||||
# invariant is asserted once per surface rather than restated three times.
|
||||
TRANSFORMS = (
|
||||
("sanitize", lambda text, cap: sanitize(text, max_input_chars=cap)),
|
||||
("fence", lambda text, cap: fence(text, max_input_chars=cap)),
|
||||
("neutralize", lambda text, cap: neutralize(text, max_input_chars=cap)),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,call", TRANSFORMS, ids=[n for n, _ in TRANSFORMS])
|
||||
def test_oversize_input_is_rejected(name, call):
|
||||
with pytest.raises(OversizeInputError):
|
||||
call("a" * 101, 100)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,call", TRANSFORMS, ids=[n for n, _ in TRANSFORMS])
|
||||
def test_input_exactly_at_the_cap_is_accepted(name, call):
|
||||
# The cap is the largest accepted size, not the smallest rejected one.
|
||||
result = call("a" * 100, 100)
|
||||
assert result.text is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,call", TRANSFORMS, ids=[n for n, _ in TRANSFORMS])
|
||||
def test_rejection_is_a_contract_violation(name, call):
|
||||
# Subclass, so a pipeline with a broad `except ContractViolation` around its
|
||||
# quarantined stage keeps failing closed instead of meeting a new exception
|
||||
# type it has never heard of.
|
||||
with pytest.raises(ContractViolation) as exc:
|
||||
call("a" * 101, 100)
|
||||
assert exc.value.code == "oversize-input"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,call", TRANSFORMS, ids=[n for n, _ in TRANSFORMS])
|
||||
def test_error_carries_sizes_never_content(name, call):
|
||||
# Same alert-safety property ContractViolation already promises: the raised
|
||||
# object must be routable to an alert channel without leaking the payload.
|
||||
secret = "CANARYVALUE"
|
||||
with pytest.raises(OversizeInputError) as exc:
|
||||
call(secret * 100, 100)
|
||||
assert secret not in str(exc.value)
|
||||
assert not any(secret in d for d in exc.value.details)
|
||||
assert "101" not in exc.value.details # details name the surface, not sizes
|
||||
assert name in exc.value.details
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,call", TRANSFORMS, ids=[n for n, _ in TRANSFORMS])
|
||||
def test_error_reports_both_the_size_and_the_cap(name, call):
|
||||
with pytest.raises(OversizeInputError) as exc:
|
||||
call("a" * 101, 100)
|
||||
message = str(exc.value)
|
||||
assert "101" in message and "100" in message
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,call", TRANSFORMS, ids=[n for n, _ in TRANSFORMS])
|
||||
def test_under_cap_is_unchanged_by_the_guard(name, call):
|
||||
# The cap must not perturb the ordinary path: clean prose still round-trips
|
||||
# the way each surface's own tests already pin.
|
||||
result = call("ordinary prose", 100)
|
||||
assert "ordinary prose" in result.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"func", [sanitize, fence, neutralize], ids=["sanitize", "fence", "neutralize"]
|
||||
)
|
||||
def test_default_cap_comes_from_calibration(func):
|
||||
# Calibration numbers live in ONE module; a literal re-typed at a call site
|
||||
# is the drift this pins against.
|
||||
default = inspect.signature(func).parameters["max_input_chars"].default
|
||||
assert default == MAX_INPUT_CHARS
|
||||
|
||||
|
||||
def test_prepare_input_inherits_the_cap():
|
||||
# The input path is sanitize -> fence, and step 1 is where the document
|
||||
# arrives, so the whole path is bounded by sanitize's guard alone.
|
||||
with pytest.raises(OversizeInputError):
|
||||
prepare_input("a" * (MAX_INPUT_CHARS + 1))
|
||||
|
||||
|
||||
def test_prepare_input_accepts_a_document_at_the_cap():
|
||||
result = prepare_input("a" * MAX_INPUT_CHARS)
|
||||
assert result.fenced
|
||||
Loading…
Add table
Add a link
Reference in a new issue