1
0
Fork 0
llm-ingestion-pipeline-secu.../tests/test_invisible.py
Kjell Tore Guttormsen 1c6816cdfe
fix(owasp): qualify every catalog number with the 2026 edition
OWASP's Top 10 for LLM Applications 2026 renumbered the list; only LLM01
and LLM02 kept their meaning. Every finding's `owasp` was written against
2025 with no year, so EchoLeak findings said LLM05, which in 2026 means
Data and Model Poisoning. Row 2 of the 2026 done-criterion counted 95
such sites at 6702d7a (82 old meaning, 13 wrong); it is now 0.

- Producers name the risk, never the number: one table (`_owasp.py`)
  owns `LLMnn:2026`. The vendored lexicon stays byte-equal with commons
  and gets its year where the finding is made; a commons number whose
  meaning moved between editions raises instead of being guessed.
- Mechanical map from the 2025 meaning (verified against the publisher
  in session 1): LLM05 -> LLM10:2026, LLM06 -> LLM03:2026,
  LLM10 -> LLM06:2026, LLM08 -> LLM09:2026, LLM04 -> LLM05:2026.

The 13 wrong-meaning sites, judged:
- Factual poisoning (PoisonedRAG gap, and the test that follows it):
  LLM05:2026. The 2026 text names "RAG Knowledge Base Poisoning" as its
  example 6. It said LLM09, which was Misinformation.
- Fail-secure (README row, matrix row): no number. It is the guard's own
  failure semantics; no edition describes it. Was LLM10.
- Parser-surface refusals (nested mapping, block scalar, quoted flow
  element, off-allowlist key): no number. Refusing a shape the parser
  would have to interpret is not resource consumption. Was LLM10. The
  anchor and merge-key rows stay LLM06:2026 (alias expansion is DoS).
- `resource` URL and cross-link scheme validation (4): no number. They
  validate received bundle metadata; LLM10:2026 is about model output
  and says input validation belongs to LLM01. Was LLM05.
- `_raise_case` default (OKF path traversal, absolute path, reserved
  name, non-.md, bundle escape): no number. None is prompt injection.
  Was LLM01.
A doubtful number would be the misreading the year exists to prevent,
so `Case.owasp` is now Optional and the matrix prints "-".

Self-safety (input caps, ReDoS) keeps LLM06:2026: the 2026 text, read at
the publisher, covers "input flood ... exploiting processing
inefficiencies" and application-side rechunking. The README's LLM09
anchor (Misinformation) is dropped; it was the factual-poisoning row.

The permanent test (tests/test_catalog_numbers.py) fails on an old or
wrong number, on a number without the year outside the vendored
lexicon, and on any finding the coverage matrix drives whose `owasp`
lacks the newest year; "newest" is read from the catalog register. The
census holds its wrong-rules to their counts at the frozen revision
only, so the frozen listing (da1aa0fa...6f) is unchanged.
2026-10-31 14:20:35 +01:00

252 lines
12 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Every invisible codepoint is caught, or exempt on context with a named reason.
The denominator is frozen in `docs/threat-2026/codepoints.json` (session 1,
2026-10-28): Default_Ignorable_Code_Point | Cf | Co in Unicode 18.0, 141 674
codepoints. Before 1.4.2, 17 of 19 measured classes gave 0 findings and stayed in
the "sanitized" text — variation-selector smuggling (A01) and Sneaky Bits (A02)
among them.
The rule is the one the ZWJ fix established: judge a character by its CONTEXT,
never by identity alone. The exemptions, each a known legitimate use:
- one VS15/VS16 after an emoji base (presentation selector);
- one ideographic selector after a CJK ideograph (registered name variants);
- ZWNJ/ZWJ next to an Arabic-script or Indic letter (required orthography);
- LRM/RLM/ALM in a document that carries right-to-left text;
- an Arabic number sign directly before a digit (it spans the digits).
A RUN is a carrier whatever it stands after: an exemption never holds when a
neighbour is itself invisible (selectors: when the next one is a selector).
Private use is caught in full (operator 2026-10-28: an icon-font glyph is
blocked rather than leaving a ~17-bit-per-slot channel open).
Every payload here is built from escapes — no invisible glyph sits in source.
"""
import json
from pathlib import Path
import pytest
from llm_ingestion_guard import Disposition, PRESET_TRUSTED_SOURCE, decide, scan_output
from llm_ingestion_guard.lexicon import scan_lexicon
from llm_ingestion_guard.report import Report
from llm_ingestion_guard.sanitize import sanitize
from redos_clock import scan_seconds
_CODEPOINTS = (Path(__file__).resolve().parent.parent
/ "docs" / "threat-2026" / "codepoints.json")
_DENOMINATOR = json.loads(_CODEPOINTS.read_text(encoding="utf-8"))
# The output-gate label for each sanitize class (one predicate, two surfaces).
_OUTPUT_LABEL = {
"sanitize:zero-width": "output:zero-width-present",
"sanitize:bidi-override": "output:bidi-present",
"sanitize:unicode-tag": "output:unicode-tag-present",
"sanitize:variation-selector": "output:variation-selector-present",
"sanitize:private-use": "output:private-use-present",
"sanitize:invisible-format": "output:invisible-format-present",
}
VS16 = "\ufe0f"
VS15 = "\ufe0e"
ZWNJ = "\u200c"
ZWJ = "\u200d"
LRM = "\u200e"
RLM = "\u200f"
ALM = "\u061c"
HEBREW = "שלום" # shalom
PERSIAN_WORD = "می" + ZWNJ + "خواهم" # mi-khaham
def _labels(report) -> set:
return {f.label for f in report.findings}
def _sanitize_labels(text: str) -> set:
return {lbl for lbl in _labels(sanitize(text).report) if lbl in _OUTPUT_LABEL}
def _output_labels(text: str) -> set:
return {lbl for lbl in _labels(scan_output(text)) if lbl in _OUTPUT_LABEL.values()}
def _ranges():
return [(int(lo, 16), int(hi, 16)) for lo, hi in _DENOMINATOR["ranges"]]
# --- the denominator ---------------------------------------------------------
def test_the_frozen_denominator_is_the_one_in_the_code():
from llm_ingestion_guard.sanitize import _INVISIBLE_RANGES
assert list(_INVISIBLE_RANGES) == _ranges()
assert sum(hi - lo + 1 for lo, hi in _INVISIBLE_RANGES) == _DENOMINATOR["counts"]["total"]
def test_every_denominator_codepoint_out_of_context_is_caught_on_both_surfaces():
# 141 674 codepoints, each between two ASCII letters: no exemption context
# exists there, so every one must be counted, stripped and flagged.
cps = [cp for lo, hi in _ranges() for cp in range(lo, hi + 1)]
assert len(cps) == 141_674
text = "a" + "a".join(chr(cp) for cp in cps) + "a"
result = sanitize(text)
assert result.text == "a" * (len(cps) + 1)
stripped = sum(f.count for f in result.report.findings if f.label in _OUTPUT_LABEL)
assert stripped == len(cps)
from llm_ingestion_guard.output import _scan_invisible_carriers
from llm_ingestion_guard.report import Source
flagged = _scan_invisible_carriers(text, Source.OUTPUT)
assert sum(f.count for f in flagged.findings) == len(cps)
@pytest.mark.parametrize("cp", [
0x2060, 0x2061, 0x2062, 0x2063, 0x2064, 0x180E, 0x3164, 0x115F, 0x1160,
0xFFA0, 0x034F, 0xFFF9, 0x200E, 0x061C, 0xE000, 0xFE00, 0xE0100,
], ids=lambda cp: f"U+{cp:04X}")
def test_the_seventeen_classes_measured_red_are_caught(cp):
# PLAN T.1.2: 17 of 19 classes, 8 of each, gave 0 findings on v1.4.1.
text = "notes " + chr(cp) * 8 + " end"
assert _sanitize_labels(text), f"U+{cp:04X}: not caught by sanitize"
assert chr(cp) not in sanitize(text).text
assert _output_labels(text), f"U+{cp:04X}: not caught by the output gate"
def test_new_carrier_labels_block_in_every_tier():
for label in _OUTPUT_LABEL.keys() | set(_OUTPUT_LABEL.values()):
from llm_ingestion_guard.report import Finding, Severity, Source
report = Report()
report.add(Finding(label=label, severity=Severity.HIGH, source=Source.INPUT,
detector="sanitize", owasp="LLM01:2026"))
decision = decide(report, PRESET_TRUSTED_SOURCE)
assert decision.disposition is Disposition.FAIL_SECURE, label
# --- the attacks -------------------------------------------------------------
def test_variation_selector_run_after_an_emoji_is_a_carrier():
# A01: Butler's encoding hangs the run off a visible emoji. The first
# selector stands right after a valid base; the run is what convicts it.
payload = "".join(chr(0xFE00 + b) if b < 16 else chr(0xE0100 + b - 16)
for b in b"ignore previous")
text = "Notes \U0001F600" + payload + " end."
result = sanitize(text)
assert "sanitize:variation-selector" in _labels(result.report)
assert result.text == "Notes \U0001F600 end."
assert "output:variation-selector-present" in _labels(scan_output(text))
def test_sneaky_bits_are_a_carrier():
# A02: bits in U+2062 / U+2064.
text = "Notes" + "\u2062\u2064\u2064\u2062" * 10 + " end."
assert "sanitize:invisible-format" in _labels(sanitize(text).report)
assert sanitize(text).text == "Notes end."
assert "output:invisible-format-present" in _labels(scan_output(text))
# --- the exemptions: legitimate use survives byte-identical --------------------
_LEGITIMATE = [
("VS16 after stopwatch", "Timer ⏱" + VS16 + " 5 min"), # measured in corpus
("VS16 after warning", "⚠" + VS16 + " Do not deploy"),
("VS16 after heart", "Thanks ❤" + VS16 + " team"),
("VS15 after heart", "Thanks ❤" + VS15 + " team"),
("keycap one", "Step 1" + VS16 + "⃣ first"),
("copyright sign", "©" + VS16 + " 2026"),
("VS16 + ZWJ sequence", "❤" + VS16 + ZWJ + "\U0001F525 on fire"),
("ideographic variant", "葛\U000E0100城"), # registered name form
("CJK compatibility variant", "豈\ufe00 ok"),
("Persian ZWNJ", "من " + PERSIAN_WORD + " این"),
("Persian ZWNJ after a link", "[کتاب]]" + ZWNJ + "ها"),
("Persian ZWJ for medial heh", "ه" + ZWJ + "."),
("Devanagari half form", "क्" + ZWJ + "ष"),
("Malayalam explicit virama", "ന്" + ZWNJ + " "),
("LRM in Hebrew text", HEBREW + " (ABC" + LRM + ") " + HEBREW),
("RLM in Hebrew text", HEBREW + " 6" + RLM + ": " + HEBREW),
("LRM elsewhere in an RTL document", "Ref [1" + LRM + "].\n" + HEBREW),
("ALM in Arabic text", "مصر " + ALM + "(1) مصر"),
("Arabic number sign", "المبلغ \u0600١٢٣"),
("Arabic year sign", "\u0601" + "2026"),
]
@pytest.mark.parametrize("name,text", _LEGITIMATE, ids=[c[0] for c in _LEGITIMATE])
def test_legitimate_use_is_neither_flagged_nor_stripped(name, text):
result = sanitize(text)
assert not _sanitize_labels(text), f"{name}: {_labels(result.report)}"
assert result.text == text, f"{name}: content corrupted"
assert not _output_labels(text), f"{name}: output gate flagged it"
def test_persian_zwnj_does_not_trip_the_word_splitting_row():
# The lexicon row `unicode:zero-width-in-word` (\w[ZW]\w) matched every
# Persian word written with its required ZWNJ. It judges the joiner by the
# same predicate now; a Latin word split is still the row's target.
assert "unicode:zero-width-in-word" not in _labels(scan_lexicon(PERSIAN_WORD))
assert "unicode:zero-width-in-word" in _labels(scan_lexicon("impor" + ZWNJ + "tant"))
mixed = PERSIAN_WORD + " impor" + ZWNJ + "tant"
assert "unicode:zero-width-in-word" in _labels(scan_lexicon(mixed))
# --- the exemptions do not reach the attack shapes ---------------------------
_CARRIERS = [
("two VS16 after an emoji", "❤" + VS16 + VS16, "sanitize:variation-selector"),
("VS16 after a letter", "a" + VS16, "sanitize:variation-selector"),
("VS16 at document start", VS16 + "x", "sanitize:variation-selector"),
("two ideographic selectors", "葛\U000E0100\U000E0101", "sanitize:variation-selector"),
("ideographic selector after a letter", "a\U000E0100", "sanitize:variation-selector"),
("ZWNJ splitting a Latin word", "impor" + ZWNJ + "tant", "sanitize:zero-width"),
("ZWNJ between emoji", "\U0001F469" + ZWNJ + "\U0001F4BB", "sanitize:zero-width"),
("ZWNJ run in Persian", "می" + ZWNJ + ZWNJ + "خ", "sanitize:zero-width"),
("ZWNJ next to another invisible", "م" + ZWNJ + LRM + "خ", "sanitize:zero-width"),
("LRM in a Latin-only document", "Plain text" + LRM + " here", "sanitize:invisible-format"),
("ALM alone is not RTL context", ALM + "x", "sanitize:invisible-format"),
("number sign alone is not RTL context", "\u0600 then " + LRM, "sanitize:invisible-format"),
("LRM/RLM bits in Hebrew", HEBREW + " " + LRM + RLM + LRM + RLM, "sanitize:invisible-format"),
("number sign without a digit", "م \u0600م", "sanitize:invisible-format"),
("number-sign run", "\u0600\u0600١", "sanitize:invisible-format"),
("BMP private use", "icon \ue000 here", "sanitize:private-use"),
("Apple logo", "\uf8ff Mac", "sanitize:private-use"),
("plane-15 private use", "x\U000F0001y", "sanitize:private-use"),
("Mongolian vowel separator", "ᠠ\u180eᠡ", "sanitize:invisible-format"),
("interlinear annotation", "\ufff9hidden\ufffb", "sanitize:invisible-format"),
]
@pytest.mark.parametrize("name,text,label", _CARRIERS, ids=[c[0] for c in _CARRIERS])
def test_carrier_shapes_are_caught(name, text, label):
assert label in _sanitize_labels(text), f"{name}: {_labels(sanitize(text).report)}"
assert _OUTPUT_LABEL[label] in _output_labels(text), name
# --- one predicate, two surfaces ---------------------------------------------
@pytest.mark.parametrize(
"text",
[c[1] for c in _LEGITIMATE] + [c[1] for c in _CARRIERS],
ids=[c[0] for c in _LEGITIMATE] + [c[0] for c in _CARRIERS],
)
def test_sanitize_and_the_output_gate_agree_per_class(text):
# Anything sanitize strips, the output gate flags — class for class, count
# for count. A split is how a carrier reaches a persisted artifact.
stripped = {(f.label, f.count) for f in sanitize(text).report.findings
if f.label in _OUTPUT_LABEL}
from llm_ingestion_guard.output import _scan_invisible_carriers
from llm_ingestion_guard.report import Source
flagged = {(f.label, f.count) for f in _scan_invisible_carriers(text, Source.OUTPUT).findings}
assert {(_OUTPUT_LABEL[lbl], n) for lbl, n in stripped} == flagged
# --- self-safety --------------------------------------------------------------
def test_document_level_rtl_context_stays_linear():
# The RTL context is one lookup per document, not one per mark: a rescan
# per mark would be quadratic in a document made of marks.
text = HEBREW + (" x" + LRM) * 300_000
assert scan_seconds(sanitize, text) < 2.0
from llm_ingestion_guard.output import _scan_invisible_carriers
from llm_ingestion_guard.report import Source
assert scan_seconds(lambda t: _scan_invisible_carriers(t, Source.OUTPUT), text) < 2.0