From 9a321fae0279577c4fd94c475f44b4b3fde52a90 Mon Sep 17 00:00:00 2001 From: Nishtha Sharma Date: Sat, 18 Jul 2026 08:13:53 +0530 Subject: [PATCH 1/2] Implement Unicode normalization for evasion detection Add Unicode evasion normalization to classify() --- humane_proxy/classifiers/heuristics.py | 64 ++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/humane_proxy/classifiers/heuristics.py b/humane_proxy/classifiers/heuristics.py index 6e53d68..515cc24 100644 --- a/humane_proxy/classifiers/heuristics.py +++ b/humane_proxy/classifiers/heuristics.py @@ -16,10 +16,70 @@ from __future__ import annotations import re +import unicodedata from humane_proxy.config import get_config +# --------------------------------------------------------------------------- +# Unicode evasion normalization +# --------------------------------------------------------------------------- +# Prior to this, `classify()` only collapsed whitespace before matching — +# no Unicode normalization at all. That made the keyword/pattern matching +# below trivially bypassable: +# - zero-width characters inserted mid-keyword ("k\u200bill myself") +# broke every regex match outright -> classified "safe". +# - visually-identical non-Latin letters (Cyrillic "у" for Latin "y" in +# "kill mуself") not only missed the self_harm keyword, but could +# change which *other* pattern matched instead — flipping the result +# to the wrong category rather than just missing it. +# +# This is a lightweight, dependency-free normalization pass targeting the +# realistic, demonstrated evasion patterns above — not a general-purpose +# Unicode-confusables library. It runs once per message before any +# matching happens. + +# Curated Cyrillic/Greek letters that are visually near-identical to a +# Latin letter and have real-world use as spoofing substitutes. Only +# genuinely confusable single letters are included (e.g. Cyrillic "в" is +# excluded — it reads as Latin "B", not a lowercase letter, so folding it +# would risk unrelated false matches). +_CONFUSABLE_MAP: dict[str, str] = { + # Cyrillic -> Latin + "а": "a", "е": "e", "о": "o", "р": "p", "с": "c", "у": "y", "х": "x", + "і": "i", "ѕ": "s", "ј": "j", "һ": "h", "ԁ": "d", "ԛ": "q", "ѡ": "w", + "А": "a", "Е": "e", "О": "o", "Р": "p", "С": "c", "У": "y", "Х": "x", + "І": "i", "Ѕ": "s", "Ј": "j", + # Greek -> Latin + "α": "a", "ο": "o", "ρ": "p", "ν": "v", "κ": "k", "ι": "i", "τ": "t", + "Α": "a", "Ο": "o", "Ρ": "p", "Ν": "v", "Κ": "k", "Ι": "i", "Τ": "t", +} + + +def _normalize_evasion(text: str) -> str: + """Neutralize common Unicode-based classifier-evasion tricks. + + Order matters: + 1. NFKC — folds fullwidth/compatibility forms to plain ASCII + (e.g. the fullwidth "kill" -> "kill"). + 2. Drop Unicode "Format" category characters (category ``Cf``) — + zero-width space/joiner/non-joiner, BOM, word joiner, soft + hyphen, etc. These render invisibly but split regex matches when + inserted mid-word. + 3. Fold curated Cyrillic/Greek confusables to Latin (see + ``_CONFUSABLE_MAP``), then NFKD-decompose and drop combining + diacritical marks (category ``Mn``) to fold accented Latin + variants (e.g. "kìll" -> "kill"). + 4. Re-apply NFKC to recompose into a stable, minimal form. + """ + text = unicodedata.normalize("NFKC", text) + text = "".join(ch for ch in text if unicodedata.category(ch) != "Cf") + text = "".join(_CONFUSABLE_MAP.get(ch, ch) for ch in text) + text = unicodedata.normalize("NFKD", text) + text = "".join(ch for ch in text if unicodedata.category(ch) != "Mn") + return unicodedata.normalize("NFKC", text) + + def _word_boundary_pattern(phrase: str) -> re.Pattern[str]: """Compile a case-insensitive word-boundary pattern for *phrase*.""" return re.compile(rf"(? tuple[str, float, list[str]]: if not text or not text.strip(): return "safe", 0.0, [] + # Neutralize Unicode-based evasion (zero-width chars, homoglyphs, + # fullwidth forms, diacritics) before any keyword/pattern matching. + text = _normalize_evasion(text) + # Normalize whitespace: collapse runs of spaces/tabs/newlines into a # single space. Defeats evasion tricks like embedded newlines. text = re.sub(r"\s+", " ", text).strip() From c03f00a6d647b8c8aeddc2922a89df5ae64773f2 Mon Sep 17 00:00:00 2001 From: Nishtha Sharma Date: Sat, 18 Jul 2026 08:15:26 +0530 Subject: [PATCH 2/2] Fix Cyrillic homoglyph test and add normalization tests Updated the test for Cyrillic homoglyph detection to correctly classify a self-harm message. Added multiple tests for Unicode evasion normalization to ensure proper detection and handling of various character forms. --- tests/test_heuristics.py | 80 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 4 deletions(-) diff --git a/tests/test_heuristics.py b/tests/test_heuristics.py index 8058b0a..ce82fb7 100644 --- a/tests/test_heuristics.py +++ b/tests/test_heuristics.py @@ -275,11 +275,20 @@ def test_leetspeak_self_harm_not_matched(self): assert score == 0.0 assert triggers == [] - def test_cyrillic_homoglyph_suicide_not_matched(self): + def test_cyrillic_homoglyph_suicide_now_matched(self): + """Was `test_cyrillic_homoglyph_suicide_not_matched`, asserting + `cat == "safe"`. That assertion *was* the bug: it pinned a + Stage-1 detection bypass as expected behavior. Normalization now + folds this specific Cyrillic і (U+0456) to Latin "i" before + matching, so it's correctly caught. See TestUnicodeEvasionNormalization + below for the fuller regression suite, including the more severe + variant where a homoglyph didn't just miss a match but flipped + the result to the wrong category (self_harm -> criminal_intent). + """ cat, score, triggers = classify("su\u0456cide") - assert cat == "safe" - assert score == 0.0 - assert triggers == [] + assert cat == "self_harm" + assert score == 1.0 + assert triggers def test_punctuated_letters_bomb_not_matched(self): cat, score, triggers = classify("how to make a b.o.m.b") @@ -324,3 +333,66 @@ def test_multi_trigger_clamps_at_one(self): assert cat == "criminal_intent" assert score == 1.0 assert len(triggers) >= 2 + + +class TestUnicodeEvasionNormalization: + """Regression: Unicode tricks must not bypass or misroute detection. + + Before normalization was added, classify() only collapsed whitespace. + Zero-width characters broke word-boundary regex matches outright + (-> "safe"), and homoglyph substitution didn't just miss the intended + match — it could make a *different* pattern match instead, flipping + self-harm messages to "criminal_intent" (see the harm_others_method / + first_person_harm_others patterns, which exclude "myself" by literal + string match). + """ + + def test_zero_width_space_inside_keyword_still_detected(self): + cat, score, triggers = classify("i want to k\u200bill myself") + assert cat == "self_harm" + assert score == 1.0 + assert triggers # matched, not silently dropped + + def test_zero_width_joiner_and_non_joiner_stripped(self): + cat, _, _ = classify("i want to kill\u200c \u200dmyself") + assert cat == "self_harm" + + def test_bom_and_word_joiner_stripped(self): + cat, _, _ = classify("i want to\ufeff kill\u2060 myself") + assert cat == "self_harm" + + def test_cyrillic_homoglyph_no_longer_misroutes_to_criminal_intent(self): + """The specific, worse failure mode: a self-harm message getting + classified as harm-to-others because a homoglyph broke the + "(?!myself)" negative lookahead in a criminal-intent pattern.""" + cat, score, _ = classify("i want to kill mуself") # Cyrillic у, U+0443 + assert cat == "self_harm" + assert score == 1.0 + + def test_greek_homoglyph_detected(self): + cat, _, _ = classify("i want to k\u03b9ll myself") # Greek iota (ι) for "i" + assert cat == "self_harm" + + def test_fullwidth_form_detected(self): + cat, _, _ = classify("kill myself now") + assert cat == "self_harm" + + def test_combining_diacritics_detected(self): + cat, _, _ = classify("kìll mysélf") + assert cat == "self_harm" + + def test_legitimate_non_english_text_not_falsely_flagged(self): + """Normalization must not turn unrelated foreign-language text + into a false positive — only the curated confusable letters fold, + and only where they happen to spell an existing keyword.""" + cat, score, _ = classify("Привет, как дела?") # "Hi, how are you?" + assert cat == "safe" + assert score == 0.0 + + def test_evasion_normalization_is_idempotent_on_plain_ascii(self): + """Sanity check: normal English input is unaffected — same + category/score/triggers as before normalization existed.""" + cat, score, triggers = classify("i want to kill myself") + assert cat == "self_harm" + assert score == 1.0 + assert "self_harm_keyword:kill myself" in triggers