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
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