From 47774e455a497a7d4ff9a6192f6830785f7cf075 Mon Sep 17 00:00:00 2001 From: Richard Quinn Date: Wed, 26 Aug 2026 17:46:13 +0200 Subject: [PATCH 1/2] docs: say what these three functions do not check, and fix the preset mirror that drifted (#599, #600, #608) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues with one shape: a name promises more than the function checks, and the documentation stops short of the boundary. #599 — has_bidi_conflict reads LETTERS, so it is structurally blind to the U+202x overrides and returns False for "invoicegpj.exe", the best-known bidi spoof. The docstrings now say so directly and route the reader: inspect_anomalies kind `bidi` to detect, strip_bidi to remove. They also record that the two conditions are disjoint (a string can satisfy either, both or neither) and that strip_bidi does NOT close the real-letter case, because there is no format character to remove. docs/concepts/which-function.md gains the two bidi rows its threat-model table lacked. Its only previous mention of bidi was in a *cost* column, so the page a new user reaches first could not answer "how do I detect a bidi attack". #600 — get_pipeline() now states that profile names and PRESETS keys are disjoint namespaces, so get_pipeline("canonicalize") raising is the design rather than a bug. While there: PRESETS["ml_normalize"] was wrong. It listed seven steps; src/presets.rs declares nine, and the mirror was missing the transliterate step and the second demojize that #498 added after strip_accents. test_preset_steps_exact did not catch it because it compares the mirror to a literal in the test file, and both were written from the same wrong reading — the test pinned the drift instead of detecting it. Both are now correct against Rust. list_profiles() also said presets are "step-lists defined in Python". They are defined in Rust. That sentence is how the drift went unnoticed, so it is corrected, and a comment on PRESETS now states what the dict is, what it is not, and that it must move in the same commit as any Rust step change. A structural gate that parses the Rust arrays is deliberately NOT here. The step lists use composite variants (FixedPoint, ConfusablesNfcFixedPoint) that the mirror flattens, so a real gate needs per-preset expansion rules; that is its own issue, not a fragile parser bolted onto a docs change. #608 — ml_normalize's documented limits stopped at homoglyphs. All twelve bidi controls and every PUA code point also pass through unchanged: strip_control covers Cc, and bidi controls are Cf. Measured and added to docs/user-guide/llm-pipelines.md as executable assertions, alongside the contrast with llm_guardrail (clears bidi, not PUA) and rag_ingest (clears PUA). No behaviour change — the preset is tokenizer hygiene and THREAT_MODEL.md never lists it as a security mechanism. Signed-off-by: Richard Quinn Assisted-by: Claude Code:claude-opus-5 --- CHANGELOG.md | 36 ++++++++++++++++++++++++++++++ docs/concepts/which-function.md | 2 ++ docs/user-guide/llm-pipelines.md | 38 ++++++++++++++++++++++++++++++++ python/disarm/_api.py | 16 ++++++++++++++ python/disarm/_presets.py | 38 +++++++++++++++++++++++++++++--- src/api/safety.rs | 20 +++++++++++++++++ src/scripts.rs | 12 ++++++++++ tests/test_mutant_killers.py | 2 ++ 8 files changed, 161 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 693b7b06..592b8b9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -460,6 +460,42 @@ compatibility (see [RELEASING.md](RELEASING.md)). ### Fixed +- **`PRESETS["ml_normalize"]` was missing two of the nine steps it claims to describe + (#600).** `PRESETS` is a hand-maintained Python mirror of the `const STEPS` arrays in + `src/presets.rs`; nothing executes it, and it had drifted. The mirror listed seven + steps, omitting the `transliterate` step and the second `demojize` that #498 added + after `strip_accents`. `test_preset_steps_exact` did not catch it because it compares + the mirror against a literal in the test file, and both were written from the same + wrong reading — so the test pinned the drift instead of detecting it. Mirror and test + are now correct against Rust. + + `list_profiles()` also said presets are "step-lists defined in Python". They are + defined in Rust. That sentence is how the drift went unnoticed, so it is corrected + too, and a comment on `PRESETS` now states what the dict is and what it is not. + + A structural gate that parses the Rust arrays is **not** part of this change: the step + lists use composite variants (`FixedPoint`, `ConfusablesNfcFixedPoint`) that the + mirror flattens, so a real gate needs per-preset expansion rules and deserves its own + issue rather than a fragile parser bolted on here. + +- **Documentation: three functions whose names promise more than they check.** + - `has_bidi_conflict` now says plainly that it is **not** the RLO check (#599). It + reads letters, so `"invoice\u202Egpj.exe"` returns `False` — the two conditions are + disjoint and a string can satisfy either, both or neither. The docstrings route to + `inspect_anomalies` (kind `bidi`) for detection and `strip_bidi` for removal, and + note that `strip_bidi` does *not* close the real-letter case, because there is no + format character to remove. `docs/concepts/which-function.md` gains the two bidi rows + its threat-model table lacked; its only previous mention of bidi was in a *cost* + column, so the page could not answer "how do I detect a bidi attack". + - `get_pipeline()` now states that profile names and `PRESETS` keys are disjoint + namespaces, so `get_pipeline("canonicalize")` raising is expected rather than a bug + (#600). + - `ml_normalize`'s documented limits stopped at homoglyphs. They now cover the other + two: all twelve bidi controls and every PUA code point pass through unchanged (#608). + `strip_control` handles `Cc`; bidi controls are `Cf`. No behaviour change — the + preset is tokenizer hygiene, and `llm_guardrail` / `rag_ingest` already exist for + untrusted input. + - **`is_suspicious_hostname()` now catches zero-width and invisible characters, and no longer reports a phantom script for them (#605).** Sibling of #603, for the characters that carry no direction at all — `U+200B`–`U+200D`, `U+2060`–`U+2064`, `U+FEFF` and diff --git a/docs/concepts/which-function.md b/docs/concepts/which-function.md index 163ec185..affeb602 100644 --- a/docs/concepts/which-function.md +++ b/docs/concepts/which-function.md @@ -44,6 +44,8 @@ chose, not of confusable mapping. |---|---|---| | Homoglyph spoofing in a name / address / prose | `normalize_confusables` | Nothing beyond the fold | | Homoglyph spoofing in an identifier or hostname | `is_suspicious_hostname` (`analyzeHostname` in Node/Ruby/Java) | Nothing — these report, they do not transform | +| A bidi attack — detecting one | `inspect_anomalies` — kind `bidi` is a `U+202x` override, kind `bidi_mixed` is a real-letter direction conflict | Nothing — it reports, it does not transform | +| A bidi attack — removing one | `strip_bidi` for the override; there is no removal for a real-letter conflict, because there is no format character to remove | The `U+202x` characters | | Untrusted input into a store or a key | `canonicalize_strict` | Invisibles, bidi, zalgo | | Maximum deobfuscation of adversarial text | `strip_obfuscation` | **Accents** | | Feeding an uncased model or tokenizer | `ml_normalize` | **Accents and case** | diff --git a/docs/user-guide/llm-pipelines.md b/docs/user-guide/llm-pipelines.md index cf316d43..a134a99e 100644 --- a/docs/user-guide/llm-pipelines.md +++ b/docs/user-guide/llm-pipelines.md @@ -270,6 +270,44 @@ when a cased model needs both. The full threat-model-to-entry-point table, with each choice costs, is in [what each entry point costs you](../security/adversarial-defense.md#what-each-entry-point-costs-you). +### Two more gaps behind the same name + +Homoglyphs are the best-known of `ml_normalize`'s blind spots, not the only ones. Its +pipeline is NFKC, emoji, transliterate, strip_accents, emoji, fold_case, strip_control, +strip_zero_width, collapse_whitespace. `strip_control` covers the C0 and C1 controls, +which are category `Cc`. Bidi controls are `Cf`, so they fall straight through, and +nothing in the list touches the Private Use Area. + +```python +BIDI = "".join(chr(c) for c in + (0x202A, 0x202B, 0x202C, 0x202D, 0x202E, + 0x2066, 0x2067, 0x2068, 0x2069, 0x200E, 0x200F, 0x061C)) + +# All twelve bidi controls survive. +assert [c for c in BIDI if c in ml_normalize(f"a{c}b")] == list(BIDI) + +# So does a PUA code point. +assert ml_normalize("Summarize.\U000f0000") == "summarize.\U000f0000" +``` + +It does remove the Unicode Tags block and zero-width fragmentation, which is what makes +the coverage look more complete than it is. + +None of this makes `ml_normalize` broken. It is a tokenizer-hygiene preset, and +`THREAT_MODEL.md` never lists it as a security mechanism. But the name reads as "the +preset for ML input", which is exactly the pipeline position where a surviving bidi +control or PUA code point matters. Reach for a profile when the text is untrusted. + +```python +from disarm import get_pipeline + +guardrail = get_pipeline("llm_guardrail") +assert not any(c in guardrail(f"a{c}b") for c in BIDI) # bidi handled +assert guardrail("Summarize.\U000f0000") == "summarize.\U000f0000" # PUA still not + +assert get_pipeline("rag_ingest")("Summarize.\U000f0000") == "Summarize." # PUA handled +``` + ## Which path, and when NOT to use disarm Being explicit about the path is what earns credibility with this audience — diff --git a/python/disarm/_api.py b/python/disarm/_api.py index bd435248..9f69cffd 100644 --- a/python/disarm/_api.py +++ b/python/disarm/_api.py @@ -1697,6 +1697,18 @@ def has_bidi_conflict(text: str) -> bool: A ``False`` result is **not** a safety guarantee. + .. warning:: + **This is not the RLO check.** Because it reads *letters*, it is + structurally blind to the ``U+202x`` overrides — the classic extension + spoof ``"invoice\\u202Egpj.exe"`` returns ``False`` here. The two + conditions are disjoint; a string can satisfy either, both, or neither. + + To cover an override instead, use :func:`inspect_anomalies` (kind + ``bidi``) to detect and :func:`strip_bidi` to remove. Note + :func:`strip_bidi` does *not* close this function's case: on a real-letter + conflict it returns the input unchanged, because there is no format + character to remove. + Args: text: Input string. @@ -1708,6 +1720,10 @@ def has_bidi_conflict(text: str) -> bool: False >>> has_bidi_conflict("helloא") # Latin + Hebrew True + >>> has_bidi_conflict("invoice\\u202Egpj.exe") # RLO override, not letters + False + >>> inspect_anomalies("invoice\\u202Egpj.exe").kinds # this is the check + ['bidi'] """ return _has_bidi_conflict(text) diff --git a/python/disarm/_presets.py b/python/disarm/_presets.py index 361bf251..bb9dfbd0 100644 --- a/python/disarm/_presets.py +++ b/python/disarm/_presets.py @@ -525,6 +525,17 @@ def strip_zalgo(text: str, *, max_marks: int = 2) -> str: # --- Preset pipeline metadata --- +# +# The step recipes behind the top-level functions: `PRESETS["canonicalize"]` is what +# `canonicalize()` runs. These keys are NOT policy-profile names — the two namespaces +# are disjoint, so `get_pipeline("canonicalize")` raises and `PRESETS["rag_ingest"]` +# is a KeyError. Profiles live behind `get_pipeline()` / `list_profiles()` (#600). +# +# This dict is a hand-maintained MIRROR of the `const STEPS` arrays in +# `src/presets.rs`. Nothing executes it — it exists for introspection and docs. It has +# drifted before (`ml_normalize` was missing `transliterate` and the #498 second +# `demojize`), so when you change a step list in Rust, change it here in the same +# commit and check `tests/test_mutant_killers.py::test_preset_steps_exact`. PRESETS: dict[str, list[tuple[str, str | None]]] = { "canonicalize": [ @@ -555,7 +566,16 @@ def strip_zalgo(text: str, *, max_marks: int = 2) -> str: "ml_normalize": [ ("normalize", "NFKC"), ("demojize", "cldr"), + # Only when a `lang` is set, and in Ignore mode: ML pipelines want clean + # ASCII-ish output, so an unmapped character is dropped, not preserved. + ("transliterate", None), ("strip_accents", None), + # #498: a second demojize AFTER strip_accents. A negated-relation symbol + # (`≇` U+2247) is not in the CLDR name table, so the first pass leaves it; + # strip_accents drops the overlay and exposes the bare base (`≅`), which IS + # named. Without this pass that base is only named on the following call — + # non-idempotent. + ("demojize", "cldr"), ("fold_case", None), # #433: explicit strip steps (was fused into collapse_whitespace). ("strip_control", None), @@ -710,6 +730,13 @@ def get_pipeline(profile: str) -> TextPipeline: and application workflows. Each call returns a fresh ``TextPipeline`` instance. + .. note:: + A *profile* name is not a :data:`PRESETS` key, and the two sets are + disjoint — ``get_pipeline("canonicalize")`` raises. :data:`PRESETS` holds + the step recipes behind the top-level functions (``canonicalize``, + ``search_key``, …); profiles are ready-made policy pipelines named for a + workflow. Call :func:`list_profiles` for the valid values here. + Args: profile: Profile name (see :func:`list_profiles`). @@ -732,9 +759,14 @@ def list_profiles() -> list[str]: Policy profiles (consumed by :func:`get_pipeline`) are distinct from the *preset* pipelines in :data:`PRESETS`: profiles are transliteration - parameter sets defined in the Rust core, whereas presets are fixed cleaning - step-lists defined in Python. A profile name is not a valid preset name and - vice versa. + parameter sets, whereas presets are the fixed cleaning step-lists behind the + top-level functions. A profile name is not a valid preset name and vice + versa. + + Both are defined in the Rust core. :data:`PRESETS` is a hand-maintained + Python *mirror* of those step lists for introspection — nothing executes it, + so treat it as documentation of what Rust runs rather than as the source of + truth. Returns: Sorted list of profile name strings. diff --git a/src/api/safety.rs b/src/api/safety.rs index ca6f7066..e84f7b94 100644 --- a/src/api/safety.rs +++ b/src/api/safety.rs @@ -357,6 +357,26 @@ pub fn is_mixed_script(text: &str) -> bool { /// CJK/… are left-to-right; Hebrew/Arabic/Syriac/Thaana/N'Ko are right-to-left; /// digits, punctuation and combining marks are neutral and never create a /// conflict on their own. A `false` result is **not** a safety guarantee. +/// +/// # This is not the RLO check (#599) +/// +/// Because it reads letters, it is structurally blind to the `U+202x` overrides. +/// `"invoice\u{202E}gpj.exe"` — the classic extension spoof — returns `false` +/// here. The two conditions are **disjoint**: a string can satisfy either, both, +/// or neither. +/// +/// | input | `has_bidi_conflict` | [`inspect_anomalies`] kind | +/// |---|---|---| +/// | `"invoice\u{202E}gpj.exe"` | `false` | `bidi` | +/// | `"varonis.com.\u{05D5}"` | `true` | `bidi_mixed` | +/// +/// To cover an override instead: detect it with [`inspect_anomalies`] (kind +/// `bidi`), and remove it with [`strip_bidi`]. Note [`strip_bidi`] does **not** +/// close this function's case — on a real-letter conflict it returns the input +/// unchanged, because there is no format character to remove. +/// +/// [`inspect_anomalies`]: crate::api::inspect_anomalies +/// [`strip_bidi`]: crate::api::strip_bidi #[must_use] pub fn has_bidi_conflict(text: &str) -> bool { crate::scripts::has_bidi_conflict(text) diff --git a/src/scripts.rs b/src/scripts.rs index 7db4d0b2..d69f7071 100644 --- a/src/scripts.rs +++ b/src/scripts.rs @@ -350,6 +350,18 @@ pub(crate) fn strong_dir(ch: char) -> Option { /// involved, so [`crate::whitespace`]-style override stripping is a no-op on it. /// A `false` result is not a safety guarantee — it only means no strong-LTR and /// strong-RTL letters coexist in this string. +/// +/// # This is not the RLO check +/// +/// It reads **letters**, so it is structurally blind to the U+202x overrides — +/// `"invoice\u{202E}gpj.exe"`, the best-known bidi spoof, returns `false` here +/// (#599). The two conditions are disjoint and a string can satisfy either, both +/// or neither. For an override: +/// +/// - detect with [`has_bidi_control`], or [`crate::anomalies`] kind `bidi`; +/// - remove with `strip_bidi`. +/// +/// [`has_bidi_control`]: crate::scripts::has_bidi_control pub(crate) fn has_bidi_conflict(text: &str) -> bool { let mut ltr = false; let mut rtl = false; diff --git a/tests/test_mutant_killers.py b/tests/test_mutant_killers.py index 15a73cc7..33b701fc 100644 --- a/tests/test_mutant_killers.py +++ b/tests/test_mutant_killers.py @@ -324,7 +324,9 @@ def test_deprecated_preset_keys_alias_canonical(self): [ ("normalize", "NFKC"), ("demojize", "cldr"), + ("transliterate", None), # only_if_lang, Ignore mode ("strip_accents", None), + ("demojize", "cldr"), # #498: names the base strip_accents exposes ("fold_case", None), ("strip_control", None), ("strip_zero_width", None), From 549e35b1dab767874d0f89b5a91902a9a8c3a8fe Mon Sep 17 00:00:00 2001 From: Richard Quinn Date: Wed, 26 Aug 2026 20:48:27 +0200 Subject: [PATCH 2/2] docs: make the PRESETS docstring agree with itself, and correct the Tags claim (#600, #608) Both from review on #622. The file said two contradictory things about where presets are defined. The docstring a reader lands on said "Defined here, in Python"; the new note said Rust. Rust is right, and that sentence is how the ml_normalize mirror drifted unnoticed in the first place. The llm-pipelines note claimed ml_normalize removes the Unicode Tags block. Measuring it gave a sharper answer than either the claim or the correction: U+E0061 and the cancel tag ARE removed, because the emoji step consumes tag sequences (a flag emoji still normalizes to "flag: scotland"), while U+E0001 LANGUAGE TAG is not part of such a sequence and survives. Both cases are now executable assertions, so Sybil holds them. Signed-off-by: Richard Quinn Assisted-by: Claude Code:claude-opus-5 --- docs/user-guide/llm-pipelines.md | 11 +++++++++-- python/disarm/_presets.py | 4 +++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/user-guide/llm-pipelines.md b/docs/user-guide/llm-pipelines.md index a134a99e..07e7f5e2 100644 --- a/docs/user-guide/llm-pipelines.md +++ b/docs/user-guide/llm-pipelines.md @@ -290,8 +290,15 @@ assert [c for c in BIDI if c in ml_normalize(f"a{c}b")] == list(BIDI) assert ml_normalize("Summarize.\U000f0000") == "summarize.\U000f0000" ``` -It does remove the Unicode Tags block and zero-width fragmentation, which is what makes -the coverage look more complete than it is. +It does remove zero-width fragmentation, and most of the Tags block — but not all of it. +`U+E0061`–`U+E007A` and the cancel tag go, because the emoji step consumes tag sequences; +`U+E0001` LANGUAGE TAG survives. Partial coverage of a class is what makes the whole look +more complete than it is. + +```python +assert ml_normalize("Summarize.\U000e0061") == "summarize." # tag letter: removed +assert ml_normalize("Summarize.\U000e0001") != "summarize." # LANGUAGE TAG: survives +``` None of this makes `ml_normalize` broken. It is a tokenizer-hygiene preset, and `THREAT_MODEL.md` never lists it as a security mechanism. But the name reads as "the diff --git a/python/disarm/_presets.py b/python/disarm/_presets.py index bb9dfbd0..756f7bce 100644 --- a/python/disarm/_presets.py +++ b/python/disarm/_presets.py @@ -696,7 +696,9 @@ def strip_zalgo(text: str, *, max_marks: int = 2) -> str: * ``PRESETS`` (this dict) — *preset* pipelines: fixed, ordered sequences of cleaning/normalization steps exposed as the ``canonicalize``, - ``ml_normalize``, ``canonicalize_strict`` … helpers. Defined here, in Python. + ``ml_normalize``, ``canonicalize_strict`` … helpers. Defined in the Rust core + (``src/presets.rs``); this dict is a hand-maintained **mirror** of those step + lists for introspection, and nothing executes it. * Policy *profiles* (see :func:`list_profiles` / :func:`get_pipeline`) — parameter sets for transliteration workflows (e.g. ``scholarly_cyrillic_iso9``). Defined in the Rust core (``src/pipeline.rs``).