From dec6c61769dc8b958b9ecf557ec937a1be86eb4f Mon Sep 17 00:00:00 2001 From: Richard Quinn Date: Wed, 26 Aug 2026 20:38:40 +0200 Subject: [PATCH 1/2] feat: add a `control` anomaly kind, closing seven CVE rows nothing reported (#612) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A non-whitespace control — NUL, ESC, BEL, DEL, the C1 block — is never legitimate in text, and no detector reported one. strip_control_chars has removed them since #433, so the transform existed and the detector did not. Why they were invisible is the interesting part: the introducers are plain ASCII, so the ASCII fast path in classify() — which exists because the invisible, bidi, zalgo and mixed-script branches can only fire above U+007F — skipped them entirely. The new branch runs before that gate. PRESENCE, NOT POSITION. #612 framed this as an "edge" question because it started from whitespace trimming, but a control hides things wherever it sits: the last character of "malicious\x1b\\" is a backslash, so an edge-only rule would call that token clean while the escape introducer sits one place in. There is a test for exactly that case. The whitespace-class controls are excluded by reusing is_fold_whitespace rather than restating the set, so the two cannot drift. TAB, LF, VT, FF, CR, U+001C-U+001F and NEL are real separators that collapse_whitespace folds to a space; flagging them would fire on every multi-line string. has_anomalies goes from 11 CVE rows to 18. Seven rows that docs/security/cve-validation.md listed as reported by nothing are now reported: CVE-2023-24329 (leading NUL) and the whole terminal-control class (CVE-2008-2383, CVE-2019-9535, CVE-2025-55754, CVE-2024-52005, CVE-2023-43620, CVE-2023-37275). Four pinned tests failed by design and are updated, including one whose assertion inverts — it is kept rather than deleted so a regression fails loudly. The registry's per-CVE detector lists and dispositions are derived from behaviour, so those and the rendered docs matrix moved with it. The three rows still undetected are a different shape, and the page now says so: each needs a comparison (a fold collision, a length budget, a table lookup) rather than the presence of a character, so no further character class will close them. Deliberately NOT added: leading/trailing whitespace detection, which #612 also asked for. inspect_anomalies documents itself as flagging characters "disguising a real word". Padding disguises nothing, and a kind for it would fire on ordinary text. Also fixes the Node AnomalyKind union, which shipped without bidi_mixed from #412. A TypeScript caller matching on it got a type error for a kind the library really returns, and nothing caught it because the value crosses napi as a bare String and index.ts casts. Node is the only binding that restates the set, so a drift gate now reads the as_str arms out of src/anomalies.rs and compares them, plus a second test asserting every kind is reachable from some input. Signed-off-by: Richard Quinn Assisted-by: Claude Code:claude-opus-5 --- CHANGELOG.md | 39 ++++++++++ bindings/node/index.ts | 2 +- docs/security/cve-validation.md | 43 ++++++----- docs/user-guide/anomaly-detection.md | 3 +- src/anomalies.rs | 34 +++++++++ tests/test_anomalies.py | 110 +++++++++++++++++++++++++++ tests/test_cve_vectors.py | 64 ++++++++++------ 7 files changed, 252 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 693b7b06..d23715b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -460,6 +460,45 @@ compatibility (see [RELEASING.md](RELEASING.md)). ### Fixed +- **A new `control` anomaly kind — `has_anomalies` goes from 11 CVE rows to 18 (#612).** + A non-whitespace control (`NUL`, `ESC`, `BEL`, `DEL`, the C1 block) is never + legitimate in text, and nothing reported one. `strip_control_chars` has removed them + since #433, so the transform existed and the detector did not. + + The reason they were invisible is worth recording: the introducers are plain ASCII, so + the ASCII fast path in the token classifier — which exists because the invisible, bidi, + zalgo and mixed-script branches can only fire above `U+007F` — skipped them entirely. + The new branch runs before that gate. + + **Presence, not position.** #612 framed this as an "edge" question because it started + from whitespace trimming, but a control hides things wherever it sits: the last + character of `"malicious\u001b\\"` is a backslash, so an edge-only rule would call + that token clean while the escape introducer sits one place in. + + The whitespace-class controls are excluded, reusing `is_fold_whitespace` rather than + restating the set. TAB, LF, VT, FF, CR, `U+001C`–`U+001F` and NEL are real separators + that `collapse_whitespace` folds to a space, and flagging them would fire on every + multi-line string. + + This closes seven rows that `docs/security/cve-validation.md` listed as reported by + nothing: CVE-2023-24329 (leading NUL) and the whole terminal-control class + (CVE-2008-2383, CVE-2019-9535, CVE-2025-55754, CVE-2024-52005, CVE-2023-43620, + CVE-2023-37275). The three that remain undetected are a different shape — a fold + collision, a length budget, a table lookup — so no further character class will close + them, and the page now says so. + + Deliberately *not* added: leading/trailing whitespace detection, which #612 also asked + for. `inspect_anomalies` documents itself as flagging characters "disguising a real + word", and padding disguises nothing; a kind for it would fire on ordinary text. + +- **The Node `AnomalyKind` union shipped without `bidi_mixed`.** It was added to the Rust + enum in #412 and never mirrored, so a TypeScript caller matching on it got a type error + for a kind the library really returns. Nothing caught it, because the value crosses + napi as a bare `String` and `index.ts` casts. Node is the only binding that restates + the set — every other surface passes it through as a string — so a drift gate now reads + the `as_str` arms out of `src/anomalies.rs` and compares them to the union, plus a + second test asserting every kind is reachable from some 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/bindings/node/index.ts b/bindings/node/index.ts index 9bd9e5b9..48c42e56 100644 --- a/bindings/node/index.ts +++ b/bindings/node/index.ts @@ -49,7 +49,7 @@ export { Lexicon } export { Pipeline } /** The anomaly branch that fired for a finding. */ -export type AnomalyKind = 'invisible' | 'bidi' | 'zalgo' | 'mixed_script' | 'leet' | 'segmentation' +export type AnomalyKind = 'invisible' | 'bidi' | 'bidi_mixed' | 'zalgo' | 'mixed_script' | 'leet' | 'segmentation' | 'control' /** * One reason a token is anomalous. Re-typed over the generated {@link NativeFinding} diff --git a/docs/security/cve-validation.md b/docs/security/cve-validation.md index bfe6556e..9f331f74 100644 --- a/docs/security/cve-validation.md +++ b/docs/security/cve-validation.md @@ -58,10 +58,10 @@ ranking. | [CVE-2017-7832](https://nvd.nist.gov/vuln/detail/CVE-2017-7832) | Firefox — dotless-i address-bar spoof evading punycode display | 5.3 (v3.0) | Neutralized + detected | `canonicalize`, `normalize_confusables` | `is_confusable`, `is_suspicious_hostname` | | [CVE-2017-5383](https://nvd.nist.gov/vuln/detail/CVE-2017-5383) | Firefox — alternative hyphens and quotes evading punycode display | 5.3 (v3.0) | Neutralized + detected | `canonicalize`, `normalize_confusables`, `catalog_key` | `is_confusable` | | [CVE-2017-7833](https://nvd.nist.gov/vuln/detail/CVE-2017-7833) | Firefox — combining vowel mark eclipsing a Latin letter in a domain | 5.3 (v3.0) | Neutralized + detected | `strip_obfuscation`, `catalog_key`, `strip_zalgo` | `has_anomalies`, `has_bidi_conflict`, `is_mixed_script`, `is_suspicious_hostname` | -| [CVE-2023-24329](https://nvd.nist.gov/vuln/detail/CVE-2023-24329) | Python urllib.parse — blocklist bypass via leading blank characters | 7.5 (v3.1) | Neutralized | `canonicalize`, `strip_obfuscation` | — | +| [CVE-2023-24329](https://nvd.nist.gov/vuln/detail/CVE-2023-24329) | Python urllib.parse — blocklist bypass via leading blank characters | 7.5 (v3.1) | Neutralized + detected | `canonicalize`, `strip_obfuscation` | `has_anomalies` | | [CVE-2019-9636](https://nvd.nist.gov/vuln/detail/CVE-2019-9636) | Python urlsplit — netloc misparse under NFKC normalization | 9.8 (v3.1) | Out of scope | — | — | -| [CVE-2008-2383](https://nvd.nist.gov/vuln/detail/CVE-2008-2383) | xterm — command execution via DECRQSS escape sequence | 9.3 (v2.0) | Neutralized | `strip_log_injection`, `canonicalize`, `strip_obfuscation` | — | -| [CVE-2019-9535](https://nvd.nist.gov/vuln/detail/CVE-2019-9535) | iTerm2 — command execution via tmux control-mode output | 9.8 (v3.1) | Neutralized | `strip_log_injection`, `canonicalize` | — | +| [CVE-2008-2383](https://nvd.nist.gov/vuln/detail/CVE-2008-2383) | xterm — command execution via DECRQSS escape sequence | 9.3 (v2.0) | Neutralized + detected | `strip_log_injection`, `canonicalize`, `strip_obfuscation` | `has_anomalies` | +| [CVE-2019-9535](https://nvd.nist.gov/vuln/detail/CVE-2019-9535) | iTerm2 — command execution via tmux control-mode output | 9.8 (v3.1) | Neutralized + detected | `strip_log_injection`, `canonicalize` | `has_anomalies` | | [CVE-2025-32711](https://nvd.nist.gov/vuln/detail/CVE-2025-32711) | Microsoft 365 Copilot (EchoLeak) — AI command injection | 9.3 (v3.1) | Neutralized | `strip_tags`, `llm_guardrail`, `canonicalize`, `strip_obfuscation` | — | | [CVE-2024-5184](https://nvd.nist.gov/vuln/detail/CVE-2024-5184) | EmailGPT — prompt injection via untrusted message text | 9.1 (v3.1) | Out of scope | — | — | | [CVE-2024-5565](https://nvd.nist.gov/vuln/detail/CVE-2024-5565) | Vanna.AI — prompt injection to arbitrary Python execution | 8.1 (v3.1) | Out of scope | — | — | @@ -73,10 +73,10 @@ ranking. | [CVE-2024-43093](https://nvd.nist.gov/vuln/detail/CVE-2024-43093) | Android — path filter bypass via improper Unicode normalization (CISA KEV) | 7.3 (v3.1) | Out of scope | — | — | | [CVE-2023-41889](https://nvd.nist.gov/vuln/detail/CVE-2023-41889) | SHIRASAGI — validation performed before Unicode normalization | 5.3 (v3.1) | Out of scope | — | — | | [CVE-2023-52081](https://nvd.nist.gov/vuln/detail/CVE-2023-52081) | ffcss — regex filter re-populated by NFKC-equivalent characters | 5.3 (v3.1) | Out of scope | — | — | -| [CVE-2025-55754](https://nvd.nist.gov/vuln/detail/CVE-2025-55754) | Apache Tomcat — ANSI escape injection into Windows console logs | 9.6 (v3.1) | Neutralized | `strip_log_injection`, `canonicalize`, `strip_obfuscation` | — | -| [CVE-2024-52005](https://nvd.nist.gov/vuln/detail/CVE-2024-52005) | Git — ANSI escape sequences in sideband channel messages | 8.8 (v3.1) | Neutralized | `strip_log_injection`, `canonicalize`, `strip_obfuscation` | — | -| [CVE-2023-43620](https://nvd.nist.gov/vuln/detail/CVE-2023-43620) | Croc — ANSI escape sequences placed in a filename | 7.8 (v3.1) | Neutralized | `sanitize_filename`, `strip_log_injection`, `canonicalize` | — | -| [CVE-2023-37275](https://nvd.nist.gov/vuln/detail/CVE-2023-37275) | Auto-GPT — console spoofing via ANSI relayed through an LLM | 4.3 (v3.1) | Neutralized | `strip_log_injection`, `canonicalize` | — | +| [CVE-2025-55754](https://nvd.nist.gov/vuln/detail/CVE-2025-55754) | Apache Tomcat — ANSI escape injection into Windows console logs | 9.6 (v3.1) | Neutralized + detected | `strip_log_injection`, `canonicalize`, `strip_obfuscation` | `has_anomalies` | +| [CVE-2024-52005](https://nvd.nist.gov/vuln/detail/CVE-2024-52005) | Git — ANSI escape sequences in sideband channel messages | 8.8 (v3.1) | Neutralized + detected | `strip_log_injection`, `canonicalize`, `strip_obfuscation` | `has_anomalies` | +| [CVE-2023-43620](https://nvd.nist.gov/vuln/detail/CVE-2023-43620) | Croc — ANSI escape sequences placed in a filename | 7.8 (v3.1) | Neutralized + detected | `sanitize_filename`, `strip_log_injection`, `canonicalize` | `has_anomalies` | +| [CVE-2023-37275](https://nvd.nist.gov/vuln/detail/CVE-2023-37275) | Auto-GPT — console spoofing via ANSI relayed through an LLM | 4.3 (v3.1) | Neutralized + detected | `strip_log_injection`, `canonicalize` | `has_anomalies` | | [CVE-2019-11721](https://nvd.nist.gov/vuln/detail/CVE-2019-11721) | Firefox — Latin kra spoofing 'k' in the address bar | 6.5 (v3.1) | Neutralized + detected | `normalize_confusables`, `canonicalize`, `strip_obfuscation` | `is_confusable`, `is_suspicious_hostname` | | [CVE-2023-4399](https://nvd.nist.gov/vuln/detail/CVE-2023-4399) | Grafana — request deny list bypassed by punycode encoding | 7.2 (v3.1) | Detected only | — | `is_suspicious_hostname` | | [CVE-2026-23950](https://nvd.nist.gov/vuln/detail/CVE-2026-23950) | node-tar — symlink poisoning via a Unicode path collision | 5.9 (v3.1) | Neutralized | `fold_case`, `search_key`, `catalog_key` | — | @@ -150,22 +150,31 @@ Treat this as "no vector here needs a third call", not as coverage. ### Detection has no equivalent The symmetry breaks here, and it decides how a pipeline should be built. No -single detector covers the matrix, and neither does all of them together: five -vectors are silent to every detector disarm exposes. +single detector covers the matrix, and neither does all of them together. + +The gap used to be wider. Seven rows — a leading NUL and the whole +terminal-control class — went unreported because their introducers are plain +ASCII controls, and the ASCII fast path in the token classifier skipped them +entirely. The `control` anomaly kind (#612) closed all seven in one branch, and +`has_anomalies` went from 11 rows to 18. + +What remains is a different shape, which is the useful part: | Vector | Why nothing flags it | |---|---| -| CVE-2023-24329 | A leading NUL is not an anomaly kind | -| CVE-2008-2383, CVE-2019-9535 | Nor is a terminal escape sequence | -| CVE-2025-32711 | Nor is the Unicode Tags block | -| CVE-2019-9636 | Nor is compatibility-fold unmasking | +| CVE-2025-32711 | The Unicode Tags block is not an anomaly kind | +| CVE-2026-23950 | Nor is a case-folding path collision | +| CVE-2023-46695 | Nor is a long run of already-normalized characters | -All five are still *neutralized* by `canonicalize`. So the rule follows from the -measurement rather than from taste: +None of the three is a character you can look for. Each needs a *comparison* — a +fold collision against another string, a length budget, a table lookup — so no +number of additional character classes will close them. That is why the rule +below follows from the measurement rather than from taste: **Clean unconditionally. Use the detectors to decide whether to alert, never -whether to clean.** A pipeline that screens first and cleans only what it -flagged forwards those five untouched. +whether to clean.** All three are still *neutralized* by `canonicalize`, so a +pipeline that screens first and cleans only what it flagged forwards them +untouched. ## How other tools handle the same vectors diff --git a/docs/user-guide/anomaly-detection.md b/docs/user-guide/anomaly-detection.md index bcee90e6..c9c0dcf5 100644 --- a/docs/user-guide/anomaly-detection.md +++ b/docs/user-guide/anomaly-detection.md @@ -15,7 +15,7 @@ the caller — it never claims intent. ## Detected classes -Six branches fire, in order; the first four need no lexicon and are +Eight branches fire, in order; the first six need no lexicon and are script-agnostic, so they port across writing systems. | Kind | Fires on | Spared (false-positive guards) | @@ -27,6 +27,7 @@ script-agnostic, so they port across writing systems. | `bidi_mixed` | one token mixes strong left-to-right and strong right-to-left **letters** (`varonisו`), which can visually reorder ("BiDi Swap") — no `U+202x` override (that is `bidi`) | single-direction text (all-LTR or all-RTL); digits are neutral | | `leet` | every out-of-place char substitutes a letter and the result is a common word (`fr33` → `free`) | a literal number that maps to no letter (`win32`, `Power5`, `21st`, `3pm`) | | `segmentation` | dense separators splitting single letters into a real word (`v.i.a.g.r.a`) | multi-letter parts (`6-foot-6`); a lone separator (`e-mail`) | +| `control` | a non-whitespace control anywhere in the token — `NUL`, `ESC`, `BEL`, `DEL`, the C1 block. Never legitimate in text, and the introducer for terminal-escape injection and leading-blank blocklist bypass | the whitespace-class controls (TAB, LF, VT, FF, CR, `U+001C`–`U+001F`, NEL), which are real separators `collapse_whitespace` folds to a space | The **leet** and **segmentation** branches take a caller-supplied **lexicon** — a set of common words for the language being protected. The defining rule: a real diff --git a/src/anomalies.rs b/src/anomalies.rs index f08b325b..1654cc5e 100644 --- a/src/anomalies.rs +++ b/src/anomalies.rs @@ -70,6 +70,15 @@ pub enum AnomalyKind { Leet, /// Dense separators splitting single letters into a real word (`v.i.a.g.r.a`). Segmentation, + /// A non-whitespace control character (`NUL`, `ESC`, `BEL`, `DEL`, the C1 block). + /// + /// Never legitimate in text, and the introducer for terminal-escape injection + /// (CVE-2008-2383, CVE-2019-9535) and leading-blank blocklist bypass + /// (CVE-2023-24329). The whitespace-class controls — TAB, LF, VT, FF, CR, the + /// information separators `U+001C`–`U+001F`, NEL — are excluded: they are real + /// separators that [`crate::whitespace::collapse_whitespace`] folds to a space, + /// so flagging them would fire on ordinary multi-line text (#612). + Control, } impl AnomalyKind { @@ -84,6 +93,7 @@ impl AnomalyKind { AnomalyKind::BidiMixed => "bidi_mixed", AnomalyKind::Leet => "leet", AnomalyKind::Segmentation => "segmentation", + AnomalyKind::Control => "control", } } } @@ -135,6 +145,9 @@ impl Finding { AnomalyKind::Segmentation => { format!("{:?} splits the word {:?}", self.token, self.detail) } + AnomalyKind::Control => { + format!("{:?} contains the control character {}", self.token, self.detail) + } } } } @@ -338,6 +351,27 @@ fn classify(tok: &str, start: usize, lexicon: &HashSet) -> Option set[str]: + """The ``as_str`` arms — the wire format, read from its definition.""" + body = self.RUST.read_text(encoding="utf-8") + block = re.search(r"pub fn as_str\(self\) -> &'static str \{.*?\n \}", body, re.S) + assert block, "as_str() not found — update this gate" + return set(re.findall(r'=> "([a-z_]+)"', block.group(0))) + + def _node_kinds(self) -> set[str]: + text = self.NODE.read_text(encoding="utf-8") + union = re.search(r"export type AnomalyKind = ([^\n]+)", text) + assert union, "AnomalyKind union not found — update this gate" + return set(re.findall(r"'([a-z_]+)'", union.group(1))) + + def test_node_union_matches_the_rust_wire_format(self): + rust, node = self._rust_kinds(), self._node_kinds() + assert rust == node, { + "in Rust, missing from the TS union": sorted(rust - node), + "in the TS union, not a real kind": sorted(node - rust), + } + + def test_every_kind_is_reachable(self): + """A kind nothing can produce is worse than a missing one — it is a lie.""" + samples = { + "invisible": "pay\u200bpal", + "bidi": "user\u202etxt.exe", + "bidi_mixed": "varonis\u05d5", + "zalgo": "a\u0301\u0301\u0301\u0301", + "mixed_script": "p\u0430ypal", + "leet": "fr33", + "segmentation": "v.i.a.g.r.a", + "control": "\x00evil", + } + assert set(samples) == self._rust_kinds(), "sample set is stale" + # `leet` and `segmentation` are lexicon-gated by design, so they need one. + lex = {"free", "viagra"} + for kind, text in samples.items(): + kinds = inspect_anomalies(text, lex).kinds + assert kind in kinds, f"{kind} unreachable via {text!r} (got {kinds})" diff --git a/tests/test_cve_vectors.py b/tests/test_cve_vectors.py index 5669dc2e..d753eb45 100644 --- a/tests/test_cve_vectors.py +++ b/tests/test_cve_vectors.py @@ -1628,9 +1628,9 @@ def test_disarm_does_not_bound_input_length(self) -> None: cwe="CWE-20", cvss=7.5, cvss_version="v3.1", - dispositions=frozenset({NEUTRALIZED}), + dispositions=frozenset({NEUTRALIZED, DETECTED}), neutralizers=("canonicalize", "strip_obfuscation"), - detectors=(), + detectors=("has_anomalies",), probe="\x00" + BLOCKED_URL, reference="https://github.com/python/cpython/issues/102153", ), @@ -1653,9 +1653,9 @@ def test_disarm_does_not_bound_input_length(self) -> None: cwe="CWE-94", cvss=9.3, cvss_version="v2.0", - dispositions=frozenset({NEUTRALIZED}), + dispositions=frozenset({NEUTRALIZED, DETECTED}), neutralizers=("strip_log_injection", "canonicalize", "strip_obfuscation"), - detectors=(), + detectors=("has_anomalies",), probe=DECRQSS_ATTACK, reference="https://www.debian.org/security/2009/dsa-1694", ), @@ -1665,9 +1665,9 @@ def test_disarm_does_not_bound_input_length(self) -> None: cwe="CWE-74", cvss=9.8, cvss_version="v3.1", - dispositions=frozenset({NEUTRALIZED}), + dispositions=frozenset({NEUTRALIZED, DETECTED}), neutralizers=("strip_log_injection", "canonicalize"), - detectors=(), + detectors=("has_anomalies",), probe=TMUX_ATTACK, reference="https://blog.mozilla.org/security/2019/10/09/iterm2-critical-issue-moss-audit/", ), @@ -1810,9 +1810,9 @@ def test_disarm_does_not_bound_input_length(self) -> None: cwe="CWE-150", cvss=9.6, cvss_version="v3.1", - dispositions=frozenset({NEUTRALIZED}), + dispositions=frozenset({NEUTRALIZED, DETECTED}), neutralizers=("strip_log_injection", "canonicalize", "strip_obfuscation"), - detectors=(), + detectors=("has_anomalies",), probe=TOMCAT_LOG_LINE, reference="https://nvd.nist.gov/vuln/detail/CVE-2025-55754", ), @@ -1822,9 +1822,9 @@ def test_disarm_does_not_bound_input_length(self) -> None: cwe="CWE-116", cvss=8.8, cvss_version="v3.1", - dispositions=frozenset({NEUTRALIZED}), + dispositions=frozenset({NEUTRALIZED, DETECTED}), neutralizers=("strip_log_injection", "canonicalize", "strip_obfuscation"), - detectors=(), + detectors=("has_anomalies",), probe=GIT_SIDEBAND, reference="https://nvd.nist.gov/vuln/detail/CVE-2024-52005", ), @@ -1834,9 +1834,9 @@ def test_disarm_does_not_bound_input_length(self) -> None: cwe="CWE-116", cvss=7.8, cvss_version="v3.1", - dispositions=frozenset({NEUTRALIZED}), + dispositions=frozenset({NEUTRALIZED, DETECTED}), neutralizers=("sanitize_filename", "strip_log_injection", "canonicalize"), - detectors=(), + detectors=("has_anomalies",), probe=CROC_FILENAME, reference="https://nvd.nist.gov/vuln/detail/CVE-2023-43620", ), @@ -1846,9 +1846,9 @@ def test_disarm_does_not_bound_input_length(self) -> None: cwe="CWE-117", cvss=4.3, cvss_version="v3.1", - dispositions=frozenset({NEUTRALIZED}), + dispositions=frozenset({NEUTRALIZED, DETECTED}), neutralizers=("strip_log_injection", "canonicalize"), - detectors=(), + detectors=("has_anomalies",), probe=AUTOGPT_OUTPUT, reference="https://nvd.nist.gov/vuln/detail/CVE-2023-37275", ), @@ -2192,16 +2192,23 @@ class TestDetectionHasNoSuperset: #: Pinned, so closing one shows up here as a failure to celebrate rather #: than as a silent improvement nobody notices. UNDETECTED_IN_SCOPE = { - "CVE-2023-24329", # a leading NUL is not an anomaly kind - "CVE-2008-2383", # nor is an escape sequence … + "CVE-2025-32711", # the Tags block is not an anomaly kind + "CVE-2026-23950", # nor is a case-folding path collision + "CVE-2023-46695", # nor is a long run of already-normalized characters + } + #: Closed by the ``control`` anomaly kind (#612). Kept as a record of what the + #: set used to be, because the shape of the remaining three is the interesting + #: part: each needs a *comparison* (a fold collision, a length budget, a table + #: lookup), not the presence of a character, which is why one more character + #: class will not close them. + CLOSED_BY_THE_CONTROL_KIND = { + "CVE-2023-24329", # a leading NUL + "CVE-2008-2383", # an escape sequence … "CVE-2019-9535", "CVE-2025-55754", # … and the whole terminal-control class with it "CVE-2024-52005", "CVE-2023-43620", "CVE-2023-37275", - "CVE-2025-32711", # nor is the Tags block - "CVE-2026-23950", # nor is a case-folding path collision - "CVE-2023-46695", # nor is a long run of already-normalized characters } @staticmethod @@ -2232,7 +2239,9 @@ def test_each_detector_covers_only_part_of_the_matrix(self) -> None: for name, predicate in DETECTOR_PANEL.items() } assert coverage == { - "has_anomalies": 11, + # 11 before #612 added the `control` kind, which closed the seven + # terminal-control and leading-NUL rows in one branch. + "has_anomalies": 18, "is_confusable": 9, "is_mixed_script": 4, # CVE-2017-7833 is the only row that fires this: the Arabic mark is @@ -2256,8 +2265,14 @@ def test_the_union_misses_these_in_scope_rows(self) -> None: undetected = self._undetected() assert undetected == self.UNDETECTED_IN_SCOPE, sorted(undetected) - def test_no_detector_reports_any_terminal_control_row(self) -> None: - """The class-level statement, kept separate so it fails on its own.""" + def test_every_terminal_control_row_is_now_reported(self) -> None: + """Inverted by #612. Kept, rather than deleted, so it fails if this regresses. + + Until the ``control`` anomaly kind existed, this asserted the opposite: that + no detector reported any of these rows. That was the sharpest instance of the + asymmetry this class describes — the introducers are plain ASCII controls, so + the ASCII fast path in ``classify`` skipped them entirely. + """ terminal = { "CVE-2008-2383", "CVE-2019-9535", @@ -2266,9 +2281,10 @@ def test_no_detector_reports_any_terminal_control_row(self) -> None: "CVE-2023-43620", "CVE-2023-37275", } - assert terminal <= self.UNDETECTED_IN_SCOPE + assert terminal <= self.CLOSED_BY_THE_CONTROL_KIND + assert not (terminal & self.UNDETECTED_IN_SCOPE) for cve_id in sorted(terminal): - assert not self._fires(BY_ID[cve_id]), cve_id + assert self._fires(BY_ID[cve_id]), cve_id def test_nfkc_unmasking_is_silent_too(self) -> None: """CVE-2019-9636 is out of scope *and* undetected, which is the worst pair. From a8278934d9622556260ab493a987caf68930ef87 Mon Sep 17 00:00:00 2001 From: Richard Quinn Date: Wed, 26 Aug 2026 21:48:55 +0200 Subject: [PATCH 2/2] review: describe the branch order the code actually has The intro sentence had drifted from the code in two ways, and adding two branches made both visible. "in order" no longer described evaluation order. `control` is checked first, ahead of the ASCII fast-path, but the table lists it eighth. The mismatch also predates this change: `bidi_mixed` evaluates before `mixed_script` and the table has them the other way round. "the first six need no lexicon" was a mechanical update of the previous "first four", and the count moved past `leet`, which does need one. Six branches need no lexicon, but they are not the first six in table order. "script-agnostic" was never true of `mixed_script`, which is anchored on Latin -- the row two lines below says so. The sentence now states what is true: six branches need no lexicon; the table is grouped by kind rather than evaluation order; `control` runs first and why; the rest split on `!tok.is_ascii()`; and `mixed_script` is the Latin-anchored exception to script-agnosticism. Signed-off-by: Richard Quinn Assisted-by: Claude Code:claude-opus-5 --- docs/user-guide/anomaly-detection.md | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/docs/user-guide/anomaly-detection.md b/docs/user-guide/anomaly-detection.md index c9c0dcf5..1c0c31a6 100644 --- a/docs/user-guide/anomaly-detection.md +++ b/docs/user-guide/anomaly-detection.md @@ -15,8 +15,17 @@ the caller — it never claims intent. ## Detected classes -Eight branches fire, in order; the first six need no lexicon and are -script-agnostic, so they port across writing systems. +Eight branches fire. Six need no lexicon — only `leet` and `segmentation` do. + +The table below is grouped by kind, not by evaluation order. `control` is checked +**first**, ahead of the ASCII fast-path, because `NUL`, `ESC`, `BEL` and `DEL` are +themselves ASCII: a check placed after that fast-path would never see the vectors it +exists for. The remaining branches split on `!tok.is_ascii()`, so `invisible`, `bidi`, +`zalgo`, `bidi_mixed` and `mixed_script` only run on non-ASCII tokens, and `leet` and +`segmentation` run last on everything. + +Most branches are script-agnostic and port across writing systems. `mixed_script` is the +exception — it is anchored on Latin, and fires on Latin combined with Cyrillic or Greek. | Kind | Fires on | Spared (false-positive guards) | |---|---|---|