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.
252 lines
12 KiB
Python
252 lines
12 KiB
Python
"""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
|