fix(sanitize): catch every invisible codepoint, exempt only on context
Variation-selector smuggling (A01) and Sneaky Bits (A02) gave 0 findings on 1.4.1 and stayed in the sanitized text. The denominator is now the frozen Unicode 18.0 set (DICP | Cf | Co, 141 674 codepoints); each one is stripped and flagged unless a known legitimate use exempts it on context: one VS15/16 after an emoji base, one ideographic selector after a CJK ideograph, ZWNJ/ZWJ beside Arabic-script or Indic letters, LRM/RLM/ALM in a document with RTL text, an Arabic number sign before a digit. A run is always a carrier; private use is caught in full (operator decisions 2026-10-28). One predicate serves sanitize, the output gate and the lexicon's word-split row, whose regex stays byte-identical with commons. A01 and A02 are green; the FP populations and the pinned OKF corpus are identical before and after.
This commit is contained in:
parent
08ca8f61bc
commit
c72e648a94
11 changed files with 734 additions and 108 deletions
|
|
@ -602,7 +602,7 @@ items; this is the full list, each with the mechanism.
|
|||
missed; on one preset it is held for review instead of halted.
|
||||
|
||||
- **The ReDoS sweep has a measured sensitivity floor, not a clean bill of
|
||||
health.** All 152 compiled patterns across all eleven regex-bearing modules are
|
||||
health.** All 154 compiled patterns across all eleven regex-bearing modules are
|
||||
swept arm by arm — payloads synthesised per run from each pattern's own
|
||||
skeleton, so `[`, `[system]` and `[system](` are each probed separately rather
|
||||
than relying on generic units, and each pattern is timed in the call mode the
|
||||
|
|
@ -771,38 +771,72 @@ fails the test, forcing this doc to be updated:
|
|||
own map makes "disposed non-WARN" a different claim from the one measured here, with
|
||||
nothing in either repo failing to say so.
|
||||
|
||||
- **A ZWJ hidden between two emoji is exempt, and ZWNJ's own false-positive
|
||||
class is untouched.** U+200D composes emoji (👩💻 is WOMAN + ZWJ + PERSONAL
|
||||
COMPUTER), so testing it on codepoint membership alone flagged *and stripped*
|
||||
the joiner in any document containing a ZWJ-composed emoji — an any-tier
|
||||
`FAIL_SECURE` carrier, plus silent decomposition of the emoji, on ordinary
|
||||
first-party content. The joiner is now judged by context instead: exempt only
|
||||
when **both** neighbours are emoji-context codepoints, measured to cover
|
||||
122/122 of the codepoints adjacent to a ZWJ across Unicode 17.0's 1614 RGI
|
||||
sequences. Two residuals follow. First, a joiner placed *between two emoji*
|
||||
is now invisible to the carrier check and could carry a covert channel — it
|
||||
costs an emoji per bit and cannot split a word, which is the shape the
|
||||
word-splitting attack needs, so the narrowing is deliberate rather than
|
||||
complete. Second, **U+200C (ZWNJ) still has no context test**, and it is
|
||||
orthographically *required* in Persian, Arabic and Devanagari — those
|
||||
documents remain hard-blocked. That is a separate criterion (script-based,
|
||||
not pictographic) and no corpus is available here to verify it against, so it
|
||||
is parked as a known false-positive class rather than guessed at.
|
||||
- **A ZWJ hidden between two emoji is exempt.** U+200D composes emoji (👩💻 is
|
||||
WOMAN + ZWJ + PERSONAL COMPUTER), so testing it on codepoint membership alone
|
||||
flagged *and stripped* the joiner in any document containing a ZWJ-composed
|
||||
emoji — an any-tier `FAIL_SECURE` carrier, plus silent decomposition of the
|
||||
emoji, on ordinary first-party content. The joiner is now judged by context
|
||||
instead: exempt only when **both** neighbours are emoji-context codepoints,
|
||||
measured to cover 122/122 of the codepoints adjacent to a ZWJ across Unicode
|
||||
17.0's 1614 RGI sequences. A joiner placed *between two emoji* is therefore
|
||||
invisible to the carrier check and could carry a covert channel — it costs an
|
||||
emoji per bit and cannot split a word, which is the shape the word-splitting
|
||||
attack needs, so the narrowing is deliberate rather than complete. *(Until
|
||||
1.4.2 this bullet also said ZWNJ had no context test and hard-blocked Persian,
|
||||
Arabic-script and Devanagari documents. It has one now: see the next bullet.)*
|
||||
|
||||
- **That context test is one predicate on two surfaces, and the symbol carrying it is
|
||||
private.** `sanitize` owns `_is_joiner_in_emoji_sequence`; `output` imports it
|
||||
(`output.py:74`) instead of restating it, because the same defect had to be fixed on
|
||||
both surfaces and a split would let the input side stop flagging while the output
|
||||
side kept hard-blocking — or the reverse, which is how a carrier reaches a persisted
|
||||
artifact after passing the input gate. The agreement is pinned by
|
||||
`tests/test_output.py::test_output_zwj_narrowing_matches_the_sanitize_side`, which
|
||||
asserts `stripped == flagged` across six shapes — half-context on either side, a
|
||||
leading and a trailing joiner, one genuine in-sequence joiner, and a word split.
|
||||
Two things that pin does not give. The six shapes are hand-written rather than
|
||||
drawn from a corpus, so everywhere outside them
|
||||
the surfaces agree by *shared implementation*, not by test — which is the stronger
|
||||
guarantee only for as long as the import survives. And the leading underscore means
|
||||
the predicate is **not** part of the surface frozen under semver: a consumer that
|
||||
- **Every invisible codepoint is caught or exempt on context — and each
|
||||
exemption leaves a narrow channel open.** The denominator is frozen in
|
||||
`docs/threat-2026/codepoints.json`: Default_Ignorable_Code_Point, format
|
||||
characters (Cf) and private use (Co) in Unicode 18.0, **141 674 codepoints**.
|
||||
Up to 1.4.1 the guard knew 142 of them; variation-selector smuggling and Sneaky
|
||||
Bits passed with 0 findings and stayed in the "sanitized" text. From 1.4.2 each
|
||||
one is stripped and flagged unless a known legitimate use exempts it **on its
|
||||
context**, and a run is a carrier whatever it stands after. What stays open,
|
||||
per exemption:
|
||||
- **One VS15/VS16 after an emoji base** (371/371 of Unicode 18.0's emoji
|
||||
variation sequences): one selector per emoji, i.e. a bit per emoji.
|
||||
- **One ideographic selector after a CJK ideograph** (operator decision
|
||||
2026-10-28): CJK text can hide **one byte per ideograph**. The published
|
||||
attack shape — a run of selectors — is caught; an attacker who interleaves
|
||||
one selector per visible ideograph is not.
|
||||
- **ZWNJ/ZWJ next to an Arabic-script or Indic letter**, neither neighbour
|
||||
invisible: a bit per letter boundary in those scripts. A Latin word split
|
||||
is still caught, and the lexicon's word-split row judges its joiner by the
|
||||
same predicate. Measured on 9 Wikipedia articles (fa, hi, ml, ur, he, ar;
|
||||
2.5 MB, fetched 2026-10-28): articles with a carrier finding went from 6/9
|
||||
to 2/9. The two left are real: one Persian article writes ZWSP where ZWNJ
|
||||
belongs (ZWSP has no orthographic role, so it is not exempt), and one
|
||||
Malayalam article has two ZWNJ in a row.
|
||||
- **LRM/RLM/ALM in a document that carries right-to-left text**: a bit per
|
||||
gap between visible characters. The context is the document, not the line,
|
||||
because a measured Persian article put an LRM on a citation line with no RTL
|
||||
letter.
|
||||
- **An Arabic number sign directly before a digit** (operator decision
|
||||
2026-10-28): visible marks, no channel beyond their own presence.
|
||||
|
||||
And what is now caught that has a legitimate use, so a document carrying it
|
||||
is blocked: **private use** in full (operator decision 2026-10-28 — an icon
|
||||
from an icon font, such as a pasted terminal prompt or the Apple logo, rather
|
||||
than one ~17-bit codepoint per visible gap; none of the 3 505 documents in the three
|
||||
false-positive populations or the 78 in the pinned OKF corpus carries one); **variation sequences outside emoji and CJK** (math symbols, Mongolian,
|
||||
Myanmar, Egyptian hieroglyphs, Phags-pa, Manichaean); the Mongolian vowel
|
||||
separator; Egyptian hieroglyph format controls; and, unchanged from before,
|
||||
the soft hyphen, a leading byte-order mark, and the tag characters inside
|
||||
subdivision-flag emoji. None of these occurs in the measured benign corpora.
|
||||
|
||||
- **That context test is one predicate on two surfaces, and the symbols carrying
|
||||
it are private.** `sanitize` owns `_invisible_carriers`; `output` imports it
|
||||
instead of restating it, because a split would let the input side stop
|
||||
flagging while the output side kept hard-blocking — or the reverse, which is
|
||||
how a carrier reaches a persisted artifact after passing the input gate. The
|
||||
agreement is pinned by `tests/test_invisible.py`, which asserts class-for-class
|
||||
and count-for-count agreement across 40 legitimate and attack shapes, and that
|
||||
all 141 674 codepoints out of context are stripped on one side and flagged on
|
||||
the other. Two things that pin does not give. Outside those shapes the
|
||||
surfaces agree by *shared implementation*, not by test — the stronger guarantee
|
||||
only for as long as the import survives. And the leading underscore means the
|
||||
predicate is **not** part of the surface frozen under semver: a consumer that
|
||||
imports it is pinning a private name 1.x makes no promise about.
|
||||
|
||||
## Out-of-scope (documented boundary)
|
||||
|
|
|
|||
18
docs/PLAN.md
18
docs/PLAN.md
|
|
@ -176,6 +176,22 @@ Nummereringen er rekkefølgen, og hver økt starter fra STATE.md.
|
|||
- Vaktregelen er måling på falsk-positiv-korpuset og OKF-korpuset før og etter.
|
||||
- Etterpå sendes målingen og kontekstregelen til `llm-security-commons` som innspill
|
||||
til tegntabellen. Commons eier sin tabell, og vi skriver aldri dit.
|
||||
*Gjort 28.10 (1.4.2):* alle 141 674 tegn i nevneren fanges utenfor kontekst, på
|
||||
begge sider med samme predikat (testet tegn for tegn). A01 og A02 er grønne, så
|
||||
**rad 3 står på 10 fanget, 1 hull og 8 røde rader (8 av 31 ankomster, var 12)**.
|
||||
Operatøren avgjorde de tre åpne unntakene etter anbefaling: privat bruk **fanges
|
||||
helt**, arabiske talltegn **unntas foran et siffer**, og planen om **én ideografisk
|
||||
velger per CJK-tegn står**. Hva hvert unntak lar stå åpent, står i LIMITATIONS.
|
||||
Målt før bygging: av 3 505 dokumenter i falsk-positiv-populasjonene og 78 i
|
||||
OKF-korpuset bærer bare 348 VS16 etter emoji og 1 ZWJ mellom emoji noe fra
|
||||
nevneren. ZWNJ-regelen er målt mot 9 Wikipedia-artikler (fa, hi, ml, ur, he, ar).
|
||||
Der hadde 4 666 av 4 784 skrifttegn på begge sider, og resten sto ved markup eller
|
||||
virama. Derfor er regelen «én side», og artikler med funn gikk fra 6/9 til 2/9.
|
||||
Ett tillegg til planen: leksikonraden for orddeling dømmer nå ZWNJ med det samme
|
||||
predikatet. Ellers ville persisk fortsatt fått MEDIUM på hvert ord, selv med tegnet
|
||||
i behold. Selve regex-en er uendret og byte-lik med commons. Vaktregelen er målt
|
||||
før og etter: OKF er identisk (6/53, import 47/31), og falsk-positiv-tallene står i
|
||||
CHANGELOG for 1.4.2.
|
||||
|
||||
**Økt 3: katalognumre med årstall (rad 2 → 0). Gis ut som 1.5.0.**
|
||||
- Alle funn bærer `LLMxx:2026`. Mapping fra 2025-betydning, verifisert i økt 1:
|
||||
|
|
@ -251,6 +267,8 @@ bekrefter flyttene. OWASP skriver selv `LLM01:2026`. Detaljer og bevis står i
|
|||
1. ~~Publiseres testsakene åpent?~~ **Ja**, operatøren 28.10 (§ T.3).
|
||||
2. ~~Grensetilfellene i § T.3.~~ **Avgjort** av operatøren 28.10 (§ T.3).
|
||||
3. ~~Tegnnevneren.~~ **Utvidet med Cf**, operatøren 28.10 (§ T.4 økt 1).
|
||||
4. ~~Unntakene med uavklart kontekst.~~ **Avgjort** av operatøren 28.10 (§ T.4
|
||||
økt 2): privat bruk fanges, talltegn unntas foran siffer, CJK-velgeren står.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -166,6 +166,9 @@ _HEX_BLOB = "a1b2c3d4" * 10 # 80 hex chars > 64 shape floor
|
|||
_ZW = chr(0x200B) # zero-width space
|
||||
_BIDI = chr(0x202E) # right-to-left override
|
||||
_TAG = "".join(chr(0xE0000 + ord(c)) for c in "hi") # Unicode-tag stego
|
||||
_VS_RUN = "".join(chr(0xFE00 + n) for n in (6, 8, 9)) # selector run (A01 shape)
|
||||
_PUA = chr(0xE000) # private use
|
||||
_BITS = (chr(0x2062) + chr(0x2064)) * 4 # Sneaky Bits (A02 shape)
|
||||
_CYR_R = chr(0x0440) # Cyrillic 'er' — a Latin 'p' look-alike
|
||||
|
||||
|
||||
|
|
@ -451,6 +454,19 @@ def _build_cases() -> list[Case]:
|
|||
_BIDI + "kcatta", via="output"),
|
||||
_scan_case("carrier", "Unicode-tag/PUA present (persist-gate)", "lexicon:unicode-tags-present",
|
||||
"legit" + _TAG, via="output"),
|
||||
# 1.4.2: the rest of the frozen invisible-codepoint denominator.
|
||||
_scan_case("carrier", "variation-selector run (input-strip)", "sanitize:variation-selector",
|
||||
"notes " + chr(0x1F600) + _VS_RUN, via="input"),
|
||||
_scan_case("carrier", "private-use character (input-strip)", "sanitize:private-use",
|
||||
"icon " + _PUA, via="input"),
|
||||
_scan_case("carrier", "invisible format characters (input-strip)", "sanitize:invisible-format",
|
||||
"notes" + _BITS, via="input"),
|
||||
_scan_case("carrier", "variation-selector run in artifact (persist-gate)",
|
||||
"output:variation-selector-present", "notes " + chr(0x1F600) + _VS_RUN, via="output"),
|
||||
_scan_case("carrier", "private-use character in artifact (persist-gate)",
|
||||
"output:private-use-present", "icon " + _PUA, via="output"),
|
||||
_scan_case("carrier", "invisible format characters in artifact (persist-gate)",
|
||||
"output:invisible-format-present", "notes" + _BITS, via="output"),
|
||||
]
|
||||
|
||||
# 2. The full injection lexicon (one case per pattern id).
|
||||
|
|
|
|||
|
|
@ -123,8 +123,15 @@ _CARRIER_LABELS = frozenset({
|
|||
"sanitize:zero-width",
|
||||
"sanitize:bidi-override",
|
||||
"sanitize:unicode-tag",
|
||||
"sanitize:variation-selector",
|
||||
"sanitize:private-use",
|
||||
"sanitize:invisible-format",
|
||||
"output:zero-width-present",
|
||||
"output:bidi-present",
|
||||
"output:unicode-tag-present",
|
||||
"output:variation-selector-present",
|
||||
"output:private-use-present",
|
||||
"output:invisible-format-present",
|
||||
"lexicon:unicode-tags-present",
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ from .calibration import (
|
|||
)
|
||||
from .entropy import try_decode_base64
|
||||
from .report import Finding, Report, Severity, Source
|
||||
from .sanitize import _is_exempt_joiner
|
||||
|
||||
# Self-safety input-size cap (OWASP LLM10), rot13 variant floor, and the
|
||||
# cognitive-load-trap lengths all live in `calibration` (the Node port shares
|
||||
|
|
@ -320,6 +321,27 @@ def _build_variants(text: str) -> list[tuple[str, str]]:
|
|||
return variants
|
||||
|
||||
|
||||
# The word-split row matches `\w<joiner>\w`, which is also how every Persian
|
||||
# word carrying its required ZWNJ is spelled. The joiner it matched is judged by
|
||||
# the predicate `sanitize` and `output` use, so the row keeps its target — a
|
||||
# Latin word split — and stops matching orthography. The regex itself stays
|
||||
# byte-identical with the commons lexicon; only the verdict on a match changes.
|
||||
_JOINER_ROW = "unicode:zero-width-in-word"
|
||||
|
||||
|
||||
def _search(pattern: LexiconPattern, variant: str):
|
||||
if pattern.id != _JOINER_ROW:
|
||||
return pattern.regex.search(variant)
|
||||
# Step one past each exempt match's start rather than past its end: the
|
||||
# letter after an exempt joiner may open the next, non-exempt split.
|
||||
pos = 0
|
||||
while (match := pattern.regex.search(variant, pos)) is not None:
|
||||
if not _is_exempt_joiner(variant, match.start() + 1):
|
||||
return match
|
||||
pos = match.start() + 1
|
||||
return None
|
||||
|
||||
|
||||
def scan_lexicon(
|
||||
text: str,
|
||||
source: Source = Source.INPUT,
|
||||
|
|
@ -349,7 +371,7 @@ def scan_lexicon(
|
|||
for pattern in patterns:
|
||||
if pattern.id in seen:
|
||||
continue
|
||||
match = pattern.regex.search(variant)
|
||||
match = _search(pattern, variant)
|
||||
if match is None:
|
||||
continue
|
||||
seen.add(pattern.id)
|
||||
|
|
|
|||
|
|
@ -29,11 +29,11 @@ input-side scanners do not cover:
|
|||
references. Ported from the ``llm-security`` ``knowledge/secrets-patterns.md``
|
||||
seed. Classic PII (email / national-ID / card numbers) is intentionally out
|
||||
of scope for v1 — high false-positive risk, not in the seed.
|
||||
5. **Invisible carrier presence** (:func:`_scan_invisible_carriers`) — zero-width
|
||||
and BIDI override/isolate characters in the emitted artifact. Output is
|
||||
report-only and never sanitized, so this is the persist-gate analogue of
|
||||
``sanitize``'s input-side stripping; disposition treats the labels as
|
||||
any-tier carriers. Unicode-tag / PUA stego is already surfaced by step 1.
|
||||
5. **Invisible carrier presence** (:func:`_scan_invisible_carriers`) — every
|
||||
invisible codepoint in the emitted artifact that ``sanitize`` would strip,
|
||||
judged by the same predicate. Output is report-only and never sanitized, so
|
||||
this is the persist-gate analogue of ``sanitize``'s input-side stripping;
|
||||
disposition treats the labels as any-tier carriers.
|
||||
6. **Active content** (:func:`~llm_ingestion_guard.active_content.scan_active_content`,
|
||||
OWASP LLM05 — Improper Output Handling) — markdown images/links, reference
|
||||
definitions, autolinks, raw active HTML and ``data:`` URIs with an external
|
||||
|
|
@ -71,7 +71,7 @@ from .calibration import MAX_CONNSTR_VALUE
|
|||
from .entropy import scan_entropy
|
||||
from .lexicon import MAX_SCAN_CHARS, scan_lexicon
|
||||
from .report import Finding, Report, Severity, Source
|
||||
from .sanitize import _is_joiner_in_emoji_sequence
|
||||
from .sanitize import _CARRIER_CLASSES, _invisible_carriers
|
||||
|
||||
# --- secret / credential egress patterns (OWASP LLM02) ----------------------
|
||||
# Ported from knowledge/secrets-patterns.md. ``value_group`` names the capturing
|
||||
|
|
@ -243,37 +243,43 @@ def scan_secret_egress(text: str, source: Source = Source.OUTPUT) -> Report:
|
|||
# --- invisible carrier presence (BRIEF §4.7) --------------------------------
|
||||
# The output-gate analogue of sanitize's input-side stripping: model output is
|
||||
# report-only and never sanitized, so an invisible carrier reaching the persist
|
||||
# gate must be flagged here. Zero-width / soft-hyphen and BIDI override/isolate
|
||||
# code points; Unicode-tag / PUA stego is already surfaced by scan_lexicon
|
||||
# (``lexicon:unicode-tags-present``), so it is not repeated. Disposition treats
|
||||
# these labels as any-tier carriers (FAIL_SECURE regardless of trust).
|
||||
_ZERO_WIDTH_CPS = frozenset({0x200B, 0x200C, 0x200D, 0xFEFF, 0x00AD})
|
||||
_BIDI_CPS = frozenset({0x202A, 0x202B, 0x202C, 0x202D, 0x202E, 0x2066, 0x2067, 0x2068, 0x2069})
|
||||
# gate must be flagged here. Disposition treats these labels as any-tier
|
||||
# carriers (FAIL_SECURE regardless of trust).
|
||||
#
|
||||
# The predicate is imported from `sanitize`, never re-stated here: the two
|
||||
# surfaces are one decision, and a second copy of the rule is how the input side
|
||||
# stops flagging while the output side keeps hard-blocking. Unicode tags are
|
||||
# reported here as well as by the lexicon (``lexicon:unicode-tags-present``),
|
||||
# whose check is narrower — it misses U+E0000 — so this is the complete one.
|
||||
# The first two labels predate 1.4.2 and keep their names.
|
||||
_OUTPUT_LABELS = {
|
||||
"zero-width": ("output:zero-width-present",
|
||||
"invisible zero-width/soft-hyphen characters present"),
|
||||
"bidi-override": ("output:bidi-present", "BIDI override/isolate characters present"),
|
||||
"unicode-tag": ("output:unicode-tag-present", "invisible Unicode-tag characters present"),
|
||||
"variation-selector": ("output:variation-selector-present",
|
||||
"variation selectors outside a single emoji/CJK presentation choice"),
|
||||
"private-use": ("output:private-use-present", "private-use characters present"),
|
||||
"invisible-format": ("output:invisible-format-present",
|
||||
"invisible format characters present"),
|
||||
}
|
||||
|
||||
|
||||
def _scan_invisible_carriers(text: str, source: Source) -> Report:
|
||||
"""Flag invisible zero-width / BIDI carriers present in ``text`` (report-only)."""
|
||||
"""Flag invisible carriers present in ``text`` (report-only)."""
|
||||
report = Report()
|
||||
# The ZWJ exemption is imported from `sanitize`, never re-stated here: the
|
||||
# two surfaces are one decision, and a second copy of the rule is how the
|
||||
# input side stops flagging while the output side keeps hard-blocking.
|
||||
zero_width = sum(
|
||||
1 for i, ch in enumerate(text)
|
||||
if ord(ch) in _ZERO_WIDTH_CPS
|
||||
and not (ord(ch) == 0x200D and _is_joiner_in_emoji_sequence(text, i))
|
||||
)
|
||||
bidi = sum(1 for ch in text if ord(ch) in _BIDI_CPS)
|
||||
if zero_width:
|
||||
counts = dict.fromkeys(_CARRIER_CLASSES, 0)
|
||||
for _, klass in _invisible_carriers(text):
|
||||
counts[klass] += 1
|
||||
for klass, count in counts.items():
|
||||
if not count:
|
||||
continue
|
||||
label, evidence = _OUTPUT_LABELS[klass]
|
||||
report.add(Finding(
|
||||
label="output:zero-width-present", severity=Severity.HIGH,
|
||||
source=source, detector="output", count=zero_width, owasp="LLM01",
|
||||
evidence="invisible zero-width/soft-hyphen characters present",
|
||||
))
|
||||
if bidi:
|
||||
report.add(Finding(
|
||||
label="output:bidi-present", severity=Severity.HIGH,
|
||||
source=source, detector="output", count=bidi, owasp="LLM01",
|
||||
evidence="BIDI override/isolate characters present",
|
||||
label=label,
|
||||
severity=Severity.CRITICAL if klass == "unicode-tag" else Severity.HIGH,
|
||||
source=source, detector="output", count=count, owasp="LLM01",
|
||||
evidence=evidence,
|
||||
))
|
||||
return report
|
||||
|
||||
|
|
@ -336,9 +342,9 @@ def scan_output(
|
|||
# 4. Secret / credential egress (OWASP LLM02).
|
||||
report.extend(scan_secret_egress(scan_text, source).findings)
|
||||
|
||||
# 5. Invisible carriers present in the artifact (zero-width / BIDI). Output
|
||||
# is never sanitized, so this is the persist-gate carrier signal; the
|
||||
# unicode-tag case is already covered by the lexicon scan in step 1.
|
||||
# 5. Invisible carriers present in the artifact, judged by sanitize's own
|
||||
# predicate. Output is never sanitized, so this is the persist-gate
|
||||
# carrier signal.
|
||||
report.extend(_scan_invisible_carriers(scan_text, source).findings)
|
||||
|
||||
# 6. Active-content constructs with an external target (the EchoLeak class,
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
"""sanitize — carrier stripping for untrusted content.
|
||||
|
||||
Removes the invisible/steganographic carrier classes that smuggle instructions
|
||||
past a human reader but reach the model: zero-width characters, BIDI overrides,
|
||||
Unicode-tag steganography, HTML comments, and ``data:`` URIs.
|
||||
past a human reader but reach the model: every invisible codepoint (zero-width,
|
||||
BIDI override, Unicode-tag, variation-selector, private-use and invisible-format
|
||||
classes — the frozen Unicode 18.0 denominator below) that is not exempt on its
|
||||
context, plus HTML comments and ``data:`` URIs.
|
||||
|
||||
Contract (BRIEF §5, §9): this function only ever *removes* — never rewrites.
|
||||
Clean input returns byte-identical with an empty report, and the output is
|
||||
|
|
@ -13,16 +15,107 @@ and string-utils primitives.
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from bisect import bisect_right
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
|
||||
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).
|
||||
# --- the invisible-codepoint denominator --------------------------------------
|
||||
#
|
||||
# Every codepoint that renders as nothing, or as nothing a reader would notice:
|
||||
# Default_Ignorable_Code_Point | General_Category=Cf | General_Category=Co in
|
||||
# Unicode 18.0 — 141 674 codepoints, frozen with the source files' sha256 in
|
||||
# `docs/threat-2026/codepoints.json` and pinned equal to this table by
|
||||
# `tests/test_invisible.py`. Up to 1.4.1 this module knew 142 of them (5
|
||||
# zero-width, 9 BIDI override/isolate, the 128-codepoint tag block);
|
||||
# variation-selector smuggling and Sneaky Bits passed with 0 findings and stayed
|
||||
# in the "sanitized" text.
|
||||
#
|
||||
# The table is the codepoints.json ranges verbatim, not `unicodedata`: the stdlib
|
||||
# database is pinned to the interpreter (16.0 on 3.14), so membership would move
|
||||
# with the Python version instead of with the frozen denominator.
|
||||
_INVISIBLE_RANGES = (
|
||||
(0x00AD, 0x00AD), (0x034F, 0x034F), (0x0600, 0x0605), (0x061C, 0x061C),
|
||||
(0x06DD, 0x06DD), (0x070F, 0x070F), (0x0890, 0x0891), (0x08E2, 0x08E2),
|
||||
(0x115F, 0x1160), (0x17B4, 0x17B5), (0x180B, 0x180F), (0x200B, 0x200F),
|
||||
(0x202A, 0x202E), (0x2060, 0x206F), (0x3164, 0x3164), (0xE000, 0xF8FF),
|
||||
(0xFE00, 0xFE0F), (0xFEFF, 0xFEFF), (0xFFA0, 0xFFA0), (0xFFF0, 0xFFFB),
|
||||
(0x110BD, 0x110BD), (0x110CD, 0x110CD), (0x13430, 0x1343F),
|
||||
(0x1BCA0, 0x1BCA3), (0x1D173, 0x1D17A), (0xE0000, 0xE0FFF),
|
||||
(0xF0000, 0xFFFFD), (0x100000, 0x10FFFD),
|
||||
)
|
||||
|
||||
|
||||
def _char_class(ranges) -> re.Pattern[str]:
|
||||
return re.compile("[" + "".join(f"\\U{lo:08x}-\\U{hi:08x}" for lo, hi in ranges) + "]")
|
||||
|
||||
|
||||
# Located with one C-level scan; the per-codepoint judgement below then runs only
|
||||
# on the hits, so a clean document costs one regex pass, not a Python loop.
|
||||
_INVISIBLE_RE = _char_class(_INVISIBLE_RANGES)
|
||||
|
||||
|
||||
_INVISIBLE_STARTS = tuple(lo for lo, _ in _INVISIBLE_RANGES)
|
||||
|
||||
|
||||
def _is_invisible(cp: int) -> bool:
|
||||
# Called on the neighbours of every hit, so a binary search rather than a
|
||||
# scan of 28 ranges: a document made of invisible codepoints is the input
|
||||
# this has to stay fast on.
|
||||
k = bisect_right(_INVISIBLE_STARTS, cp) - 1
|
||||
return k >= 0 and cp <= _INVISIBLE_RANGES[k][1]
|
||||
|
||||
|
||||
# Carrier classes. The first three keep their pre-1.4.2 membership and labels;
|
||||
# everything else in the denominator lands in the last three.
|
||||
_ZERO_WIDTH = frozenset({0x200B, 0x200C, 0x200D, 0xFEFF, 0x00AD})
|
||||
_BIDI = frozenset({0x202A, 0x202B, 0x202C, 0x202D, 0x202E, 0x2066, 0x2067, 0x2068, 0x2069})
|
||||
_TAG_LO, _TAG_HI = 0xE0000, 0xE007F # Unicode Tags block (U+E0000–U+E007F)
|
||||
_SELECTOR_RANGES = ((0x180B, 0x180D), (0x180F, 0x180F), (0xFE00, 0xFE0F), (0xE0100, 0xE01EF))
|
||||
_PRIVATE_USE_RANGES = ((0xE000, 0xF8FF), (0xF0000, 0xFFFFD), (0x100000, 0x10FFFD))
|
||||
# Report order, which is also the order the labels were introduced in.
|
||||
_CARRIER_CLASSES = ("zero-width", "bidi-override", "unicode-tag",
|
||||
"variation-selector", "private-use", "invisible-format")
|
||||
|
||||
|
||||
def _in(cp: int, ranges) -> bool:
|
||||
for lo, hi in ranges:
|
||||
if lo <= cp <= hi:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _carrier_class(cp: int) -> str:
|
||||
"""The class a non-exempt denominator codepoint is reported under."""
|
||||
if cp in _ZERO_WIDTH:
|
||||
return "zero-width"
|
||||
if cp in _BIDI:
|
||||
return "bidi-override"
|
||||
if _TAG_LO <= cp <= _TAG_HI:
|
||||
return "unicode-tag"
|
||||
if _in(cp, _SELECTOR_RANGES):
|
||||
return "variation-selector"
|
||||
if _in(cp, _PRIVATE_USE_RANGES):
|
||||
return "private-use"
|
||||
return "invisible-format"
|
||||
|
||||
|
||||
# --- context exemptions ------------------------------------------------------
|
||||
#
|
||||
# A character is judged by CONTEXT, never by identity alone — the rule the ZWJ
|
||||
# fix below established. Each exemption is a known legitimate use, and a RUN is
|
||||
# a carrier whatever it stands after: no exemption holds when a neighbour is
|
||||
# itself invisible (for selectors: when the next codepoint is a selector). That
|
||||
# is what keeps every exemption from reaching the published attack shapes, which
|
||||
# are all runs — Butler's byte-per-selector string (A01), Sneaky Bits (A02).
|
||||
#
|
||||
# What the exemptions still leave open is a channel of at most one invisible
|
||||
# codepoint per visible one, and each is named in LIMITATIONS. Private use has
|
||||
# no exemption at all (operator 2026-10-28): an icon-font glyph is blocked rather
|
||||
# than leaving one ~17-bit codepoint per visible gap.
|
||||
|
||||
# ZWJ (U+200D) is the one zero-width codepoint with a legitimate, extremely
|
||||
# common use: it composes emoji. 👩💻 is WOMAN + ZWJ + PERSONAL COMPUTER, and
|
||||
|
|
@ -62,7 +155,7 @@ _EMOJI_CTX_RANGES = (
|
|||
|
||||
def _is_emoji_context(cp: int) -> bool:
|
||||
"""True when ``cp`` may legitimately sit adjacent to an emoji-composing ZWJ."""
|
||||
return any(lo <= cp <= hi for lo, hi in _EMOJI_CTX_RANGES)
|
||||
return _in(cp, _EMOJI_CTX_RANGES)
|
||||
|
||||
|
||||
def _is_joiner_in_emoji_sequence(text: str, i: int) -> bool:
|
||||
|
|
@ -75,6 +168,189 @@ def _is_joiner_in_emoji_sequence(text: str, i: int) -> bool:
|
|||
return False
|
||||
return _is_emoji_context(ord(text[i - 1])) and _is_emoji_context(ord(text[i + 1]))
|
||||
|
||||
|
||||
# ZWNJ and ZWJ are required orthography in Arabic-script languages (Persian
|
||||
# می<ZWNJ>خواهم, Urdu) and in the Indic scripts (Devanagari half forms, Malayalam
|
||||
# explicit virama). Up to 1.4.1 every such document was an any-tier carrier, and
|
||||
# the lexicon row above matched every Persian word that carries its ZWNJ.
|
||||
#
|
||||
# Exempt when one neighbour is a letter or mark of these scripts and neither is
|
||||
# invisible. One side, not both — the rule the emoji ZWJ keeps — because the
|
||||
# measured shapes need it: on 9 Wikipedia articles (fa ×3, hi ×2, ml, ur, he, ar;
|
||||
# 2.5 MB, fetched 2026-10-28) 4 666 of 4 784 joiners had script on both sides, and
|
||||
# the rest sat at a word end after a virama (`क्<ZWNJ> `) or against markup (`]]<ZWNJ>ها`).
|
||||
# A Latin word split still has Latin on both sides, which is the shape the
|
||||
# word-splitting attack needs, so it stays a carrier.
|
||||
_JOINING_SCRIPT_RANGES = (
|
||||
(0x0600, 0x08FF), # Arabic, Syriac, Thaana, NKo, Samaritan, Mandaic, Arabic Ext.
|
||||
(0x0900, 0x0DFF), # Devanagari … Sinhala
|
||||
(0xFB50, 0xFDFF), # Arabic Presentation Forms-A
|
||||
(0xFE70, 0xFEFE), # Arabic Presentation Forms-B (U+FEFF, the BOM, excluded)
|
||||
)
|
||||
|
||||
|
||||
def _is_joiner_in_script(text: str, i: int) -> bool:
|
||||
if i == 0 or i + 1 >= len(text):
|
||||
return False
|
||||
prev, nxt = ord(text[i - 1]), ord(text[i + 1])
|
||||
if _is_invisible(prev) or _is_invisible(nxt):
|
||||
return False
|
||||
return _in(prev, _JOINING_SCRIPT_RANGES) or _in(nxt, _JOINING_SCRIPT_RANGES)
|
||||
|
||||
|
||||
# One variation selector after the base it is defined for, and never a second.
|
||||
# VS15/VS16 choose text or emoji presentation; the bases below cover 371/371 of
|
||||
# Unicode 18.0's `emoji-variation-sequences.txt` (sha256 ff1707564aa1f1b2…),
|
||||
# blocks where the block is a symbol block, single codepoints where it is a text
|
||||
# block — a whole Basic Latin exemption would hang a selector off every letter.
|
||||
# The 348 selectors in the benign corpora (VS16 after ⚠ ⏱ ❤ 🛡) are all of this
|
||||
# shape. The ideographic selectors (U+E0100–E01EF, and FE00–FE02 for the 1 002
|
||||
# CJK compatibility sequences in StandardizedVariants.txt) choose a registered
|
||||
# glyph variant of a CJK ideograph; operator 2026-10-28 kept the plan's one
|
||||
# selector per ideograph, so CJK text can still hide one byte per ideograph.
|
||||
# Selectors after any other base (math, Mongolian, Myanmar, Egyptian) are caught.
|
||||
_EMOJI_VS_BASE_CPS = frozenset({
|
||||
0x0023, 0x002A, *range(0x0030, 0x003A), # # * 0-9: keycap bases
|
||||
0x00A9, 0x00AE, 0x203C, 0x2049, 0x2122, 0x2139, 0x3030, 0x303D,
|
||||
})
|
||||
_EMOJI_VS_BASE_RANGES = (
|
||||
(0x2190, 0x21FF), (0x2300, 0x23FF), (0x2460, 0x24FF), (0x25A0, 0x25FF),
|
||||
(0x2600, 0x27BF), (0x2900, 0x297F), (0x2B00, 0x2BFF), (0x3200, 0x32FF),
|
||||
(0x1F000, 0x1FAFF),
|
||||
)
|
||||
_CJK_IDEOGRAPH_RANGES = (
|
||||
(0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF),
|
||||
(0x20000, 0x3FFFF), # planes 2-3: extensions B-J and their headroom
|
||||
)
|
||||
|
||||
|
||||
def _is_selector_on_its_base(text: str, i: int, cp: int) -> bool:
|
||||
if i == 0:
|
||||
return False
|
||||
if i + 1 < len(text) and _in(ord(text[i + 1]), _SELECTOR_RANGES):
|
||||
return False # a run
|
||||
base = ord(text[i - 1])
|
||||
if cp in (0xFE0E, 0xFE0F):
|
||||
return base in _EMOJI_VS_BASE_CPS or _in(base, _EMOJI_VS_BASE_RANGES)
|
||||
if 0xFE00 <= cp <= 0xFE02 or 0xE0100 <= cp <= 0xE01EF:
|
||||
return _in(base, _CJK_IDEOGRAPH_RANGES)
|
||||
return False
|
||||
|
||||
|
||||
# LRM / RLM / ALM order neutral characters in bidirectional text, so their
|
||||
# context is the document's: one is exempt when the document carries
|
||||
# right-to-left text and neither neighbour is invisible. Not the line — a
|
||||
# measured Persian article put an LRM in a citation line with no RTL letter on
|
||||
# it. The evidence never counts an invisible codepoint, or an ALM would be its
|
||||
# own context. Looked up once per document, so a document of marks stays linear.
|
||||
_DIRECTIONAL_MARKS = frozenset({0x200E, 0x200F, 0x061C})
|
||||
_RTL_RANGES = (
|
||||
(0x0590, 0x08FF), (0xFB1D, 0xFDFF), (0xFE70, 0xFEFE),
|
||||
(0x10800, 0x10FFF), (0x1E800, 0x1EFFF),
|
||||
)
|
||||
|
||||
|
||||
def _minus(ranges, holes):
|
||||
out = []
|
||||
for lo, hi in ranges:
|
||||
for h_lo, h_hi in holes:
|
||||
if h_hi < lo or h_lo > hi:
|
||||
continue
|
||||
if h_lo > lo:
|
||||
out.append((lo, h_lo - 1))
|
||||
lo = h_hi + 1
|
||||
if lo > hi:
|
||||
break
|
||||
if lo <= hi:
|
||||
out.append((lo, hi))
|
||||
return tuple(out)
|
||||
|
||||
|
||||
_RTL_RE = _char_class(_minus(_RTL_RANGES, _INVISIBLE_RANGES))
|
||||
|
||||
# The Arabic number signs and their kin (Prepended_Concatenation_Mark) are
|
||||
# visible: they span the digits that follow, which is the context (operator
|
||||
# 2026-10-28). Without a digit after them they are caught, and two in a row
|
||||
# never have one.
|
||||
_PREPENDED_MARKS = frozenset({
|
||||
0x0600, 0x0601, 0x0602, 0x0603, 0x0604, 0x0605, 0x06DD, 0x070F,
|
||||
0x0890, 0x0891, 0x08E2, 0x110BD, 0x110CD,
|
||||
})
|
||||
|
||||
|
||||
@lru_cache(maxsize=1024)
|
||||
def _route(cp: int) -> tuple[str, str | None]:
|
||||
"""``(carrier class, context rule or None)`` for a denominator codepoint.
|
||||
|
||||
Cached because it depends on the codepoint alone, and a document made of a
|
||||
few distinct invisible codepoints repeated is the input that must stay fast.
|
||||
"""
|
||||
if cp in (0x200C, 0x200D):
|
||||
context = "joiner"
|
||||
elif _in(cp, _SELECTOR_RANGES):
|
||||
context = "selector"
|
||||
elif cp in _DIRECTIONAL_MARKS:
|
||||
context = "mark"
|
||||
elif cp in _PREPENDED_MARKS:
|
||||
context = "prepended"
|
||||
else:
|
||||
context = None
|
||||
return _carrier_class(cp), context
|
||||
|
||||
|
||||
def _is_exempt(text: str, i: int, context: str | None, has_rtl) -> bool:
|
||||
"""True when the denominator codepoint at ``text[i]`` is a legitimate use.
|
||||
|
||||
``has_rtl`` is a zero-argument callable, so the document-level RTL lookup is
|
||||
paid only when a directional mark actually needs it.
|
||||
"""
|
||||
if context is None:
|
||||
return False
|
||||
cp = ord(text[i])
|
||||
if context == "joiner":
|
||||
if cp == 0x200D and _is_joiner_in_emoji_sequence(text, i):
|
||||
return True
|
||||
return _is_joiner_in_script(text, i)
|
||||
if context == "selector":
|
||||
return _is_selector_on_its_base(text, i, cp)
|
||||
if context == "mark":
|
||||
if i > 0 and _is_invisible(ord(text[i - 1])):
|
||||
return False
|
||||
if i + 1 < len(text) and _is_invisible(ord(text[i + 1])):
|
||||
return False
|
||||
return has_rtl()
|
||||
return i + 1 < len(text) and text[i + 1].isdecimal() # a prepended mark
|
||||
|
||||
|
||||
def _rtl_lookup(text: str):
|
||||
cache: list[bool] = []
|
||||
|
||||
def has_rtl() -> bool:
|
||||
if not cache:
|
||||
cache.append(_RTL_RE.search(text) is not None)
|
||||
return cache[0]
|
||||
|
||||
return has_rtl
|
||||
|
||||
|
||||
def _invisible_carriers(text: str):
|
||||
"""Yield ``(index, class)`` for every invisible codepoint that is a carrier.
|
||||
|
||||
The one predicate behind both surfaces: ``sanitize`` strips what this yields
|
||||
and ``output`` flags it, so the two cannot disagree on a character.
|
||||
"""
|
||||
has_rtl = _rtl_lookup(text)
|
||||
for match in _INVISIBLE_RE.finditer(text):
|
||||
i = match.start()
|
||||
klass, context = _route(ord(text[i]))
|
||||
if not _is_exempt(text, i, context, has_rtl):
|
||||
yield i, klass
|
||||
|
||||
|
||||
def _is_exempt_joiner(text: str, i: int) -> bool:
|
||||
"""The joiner half of the predicate, for the lexicon's word-split row."""
|
||||
return _is_exempt(text, i, _route(ord(text[i]))[1], _rtl_lookup(text))
|
||||
|
||||
# Span carriers.
|
||||
#
|
||||
# ReDoS note (OWASP LLM10). The comment stripper used to be `<!--.*?-->` with a
|
||||
|
|
@ -164,40 +440,38 @@ def sanitize(
|
|||
assert_within_input_cap(text, surface="sanitize", max_input_chars=max_input_chars)
|
||||
report = Report()
|
||||
|
||||
# Character-class carriers: single pass, keep everything else verbatim.
|
||||
zero_width = 0
|
||||
bidi = 0
|
||||
# Character-class carriers: drop each one the shared predicate convicts and
|
||||
# keep everything else verbatim.
|
||||
counts = dict.fromkeys(_CARRIER_CLASSES, 0)
|
||||
tag_cps: list[int] = []
|
||||
kept: list[str] = []
|
||||
for i, ch in enumerate(text):
|
||||
cp = ord(ch)
|
||||
if cp == 0x200D and _is_joiner_in_emoji_sequence(text, i):
|
||||
kept.append(ch) # composing an emoji, not carrying a payload
|
||||
elif cp in _ZERO_WIDTH:
|
||||
zero_width += 1
|
||||
elif cp in _BIDI:
|
||||
bidi += 1
|
||||
elif _TAG_LO <= cp <= _TAG_HI:
|
||||
tag_cps.append(cp)
|
||||
else:
|
||||
kept.append(ch)
|
||||
pos = 0
|
||||
for i, klass in _invisible_carriers(text):
|
||||
kept.append(text[pos:i])
|
||||
pos = i + 1
|
||||
counts[klass] += 1
|
||||
if klass == "unicode-tag":
|
||||
tag_cps.append(ord(text[i]))
|
||||
# Preserve object identity (and byte-identity) when nothing was stripped.
|
||||
cleaned = "".join(kept) if (zero_width or bidi or tag_cps) else text
|
||||
if pos:
|
||||
kept.append(text[pos:])
|
||||
cleaned = "".join(kept)
|
||||
else:
|
||||
cleaned = text
|
||||
|
||||
# Span carriers.
|
||||
cleaned, n_comments = _strip_html_comments(cleaned)
|
||||
cleaned, n_data = _DATA_URI_RE.subn("", cleaned)
|
||||
|
||||
if zero_width:
|
||||
report.add(Finding(label="sanitize:zero-width", severity=Severity.HIGH,
|
||||
source=source, detector="sanitize", count=zero_width, owasp="LLM01"))
|
||||
if bidi:
|
||||
report.add(Finding(label="sanitize:bidi-override", severity=Severity.HIGH,
|
||||
source=source, detector="sanitize", count=bidi, owasp="LLM01"))
|
||||
if tag_cps:
|
||||
report.add(Finding(label="sanitize:unicode-tag", severity=Severity.CRITICAL,
|
||||
source=source, detector="sanitize", count=len(tag_cps),
|
||||
evidence=_redact(_decode_tags(tag_cps)), owasp="LLM01"))
|
||||
for klass, count in counts.items():
|
||||
if not count:
|
||||
continue
|
||||
tag = klass == "unicode-tag"
|
||||
report.add(Finding(label=f"sanitize:{klass}",
|
||||
severity=Severity.CRITICAL if tag else Severity.HIGH,
|
||||
source=source, detector="sanitize", count=count,
|
||||
evidence=_redact(_decode_tags(tag_cps)) if tag else None,
|
||||
owasp="LLM01"))
|
||||
if n_comments:
|
||||
report.add(Finding(label="sanitize:html-comment", severity=Severity.MEDIUM,
|
||||
source=source, detector="sanitize", count=n_comments, owasp="LLM01"))
|
||||
|
|
|
|||
|
|
@ -221,11 +221,8 @@ GAP_CASES = [
|
|||
]
|
||||
|
||||
# Rows red on 2026-10-28 (measured, session 1). Strict: a fix fails until removed.
|
||||
# A01 and A02 caught from 1.4.2 (session 2: the invisible-codepoint denominator).
|
||||
RED = {
|
||||
"A01-input": "variation selectors are not a carrier class: 0 findings, all left in the text",
|
||||
"A01-output": "variation selectors are not a carrier class: 0 findings",
|
||||
"A02-input": "U+2062/U+2064 are not a carrier class: 0 findings",
|
||||
"A02-output": "U+2062/U+2064 are not a carrier class: 0 findings",
|
||||
"A04-input": "1px white text: zero-font matches a size starting with 0 only; 0 findings",
|
||||
"A05-output": "each image carries no data, the sequence does: LOW -> WARN (beaconing gap)",
|
||||
"A08-output": "natural-language persistence instruction in the output: 0 findings",
|
||||
|
|
|
|||
|
|
@ -272,13 +272,13 @@ def test_redos_sweep_floor_and_flag_match_the_published_numbers():
|
|||
assert "2.6 flag threshold" in text
|
||||
|
||||
|
||||
def test_redos_sweep_collector_covers_152_patterns_across_11_tables():
|
||||
# The count docs/LIMITATIONS.md carries as "all 152 compiled patterns across
|
||||
def test_redos_sweep_collector_covers_154_patterns_across_11_tables():
|
||||
# The count docs/LIMITATIONS.md carries as "all 154 compiled patterns across
|
||||
# all eleven regex-bearing modules". A pattern added or removed in `src/`
|
||||
# without re-measuring would drift the doc's claim silently otherwise.
|
||||
assert len(redos_sweep.TABLES) == 11
|
||||
total = sum(len(collect()) for collect in redos_sweep.TABLES.values())
|
||||
assert total == 152
|
||||
assert total == 154
|
||||
|
||||
|
||||
# --- both scripts: the argument-less contract --------------------------------
|
||||
|
|
|
|||
252
tests/test_invisible.py
Normal file
252
tests/test_invisible.py
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
"""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"))
|
||||
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
|
||||
|
|
@ -184,9 +184,9 @@ def test_zwj_at_document_edge_is_still_a_carrier():
|
|||
|
||||
|
||||
def test_other_zero_width_classes_are_untouched_by_the_zwj_narrowing():
|
||||
# Only U+200D got a context test. ZWSP/ZWNJ/BOM/soft-hyphen between emoji
|
||||
# stay carriers — ZWNJ's own false-positive class (Persian/Devanagari
|
||||
# orthography) is a separate, deliberately unaddressed decision.
|
||||
# Only U+200D got the emoji context test. ZWSP/ZWNJ/BOM/soft-hyphen between
|
||||
# emoji stay carriers — ZWNJ's own context is script, not pictographic
|
||||
# (1.4.2, `tests/test_invisible.py`), and an emoji is not that script.
|
||||
for cp in (0x200B, 0x200C, 0xFEFF, 0x00AD):
|
||||
text = f"\U0001F469{chr(cp)}\U0001F4BB"
|
||||
result = sanitize(text)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue