Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions humane_proxy/classifiers/heuristics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"(?<!\w){re.escape(phrase)}(?!\w)", re.I)
Expand Down Expand Up @@ -203,6 +263,10 @@ def classify(text: str) -> 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()
Expand Down
80 changes: 76 additions & 4 deletions tests/test_heuristics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Loading