Anomaly Detection

has_anomalies / inspect_anomalies flag text that carries out-of-place characters disguising a real word — a cross-script homoglyph, a bidi-direction conflict, leet, a single-letter segmentation, a zero-width / bidi control, or zalgo. Like is_suspicious_hostname, the detector reports a technical fact and leaves the malicious-or-not judgement to the caller — it never claims intent.

Defensive publication

This detector is described publicly as prior art so the method stays freely usable and cannot be patented by others. See issue #389 for the dated record.

Detected classes

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)
invisible a zero-width / formatting codepoint inside a Latin word emoji ZWJ sequences; ZWJ/ZWNJ joiners in Indic & Arabic; soft hyphen
bidi an LRO/RLO override anywhere, or an isolate inside a majority-Latin token (Trojan Source) bare directional marks; LRE..PDF embeddings (RTL text, hashtags)
zalgo excessive stacked combining marks ordinary accents
mixed_script Latin combined with Cyrillic or Greek in one token CJK / Thai / kaomoji; legitimate unit symbols (, µF)
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 (fr33free) 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+001CU+001F, NEL), which are real separators collapse_whitespace folds to a space
compat_fold a token mixing a Unicode compatibility form with ASCII, where the non-ASCII part folds to ASCIIadmin, example.com, <script>. canonicalize performs that fold as its first step, so the class was neutralized and reported clean ordinary fullwidth typography with no ASCII letter (NHK, Q&A, 1995年, CD-ROM); unit symbols whose fold is Greek, not ASCII (, µF), and the squared CJK units that do fold to ASCII but carry no letter (10㎏10kg, 5㎞5km); and a token spelled wholly in a compatibility form (paypal), which cannot be told from NHK by character class

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 leet attack substitutes a letter, whereas win32 carries a literal number that maps to no letter, so requiring every out-of-place character to be a real letter-substitution that yields a common word rejects the literals.

Usage

from disarm import has_anomalies, inspect_anomalies

words = {"free", "paypal"}

# leet: "fr33" decodes to "free"
assert has_anomalies("get fr33 now", words)
# a literal number is not a substitution, so "win32" is spared
assert not has_anomalies("the win32 api", words)

report = inspect_anomalies("log in to paypаl", {"paypal"})  # Cyrillic а
assert report.anomalous
assert report.kinds == ["mixed_script"]
assert report.findings[0].kind == "mixed_script"
use disarm::api::{self, AnomalyKind};
use std::collections::HashSet;

let words: HashSet<String> = ["free", "paypal"].iter().map(|s| s.to_string()).collect();

assert!(api::has_anomalies("get fr33 now", &words));
assert!(!api::has_anomalies("the win32 api", &words));

let report = api::inspect_anomalies("log in to paypаl", &words);
assert!(report.anomalous);
assert_eq!(report.kinds, vec![AnomalyKind::MixedScript]);
require "disarm"

# the lexicon is a common-word collection (Array or Set)
Disarm.has_anomalies?("get fr33 now", ["free"])  # => true
Disarm.has_anomalies?("the win32 api", ["free"]) # => false

Disarm.inspect_anomalies("log in to paypаl", ["paypal"])[:kinds] # => ["mixed_script"]
import { hasAnomalies, inspectAnomalies } from 'disarm'

// the lexicon is a Set or array of common words
hasAnomalies('get fr33 now', ['free'])  // => true
hasAnomalies('the win32 api', ['free']) // => false

inspectAnomalies('log in to paypаl', ['paypal']).kinds // => ['mixed_script']

The report

inspect_anomalies returns a report with anomalous, kinds (the anomaly kinds that fired, in first-appearance order), findings, and reason (the first finding's plain-language sentence). Each finding carries the offending kind, token, byte start/end span, detail (the codepoint, the scripts, or the decoded word), and its own reason.

A False result is not a safety guarantee — it means only that none of the six branches fired on the lexicon you supplied. Compose this with your own policy, as you would the hostname analysis.

Checking a transform at the seam

Run has_anomalies on the output of a transform and it tells you whether that transform left something behind. The check needs no new API and is the cheapest way to find out you picked the wrong function.

One input carrying three different hazards — a right-to-left override, a zero-width space and a Cyrillic ԁ standing in for Latin d:

from disarm import (
    canonicalize,
    has_anomalies,
    inspect_anomalies,
    ml_normalize,
    normalize_confusables,
)

hostile = "\u202eexample\u200b.com\u0501"

# canonicalize clears all three: nothing is left to report.
assert canonicalize(hostile) == "example.comd"
assert has_anomalies(canonicalize(hostile)) is False

# ml_normalize is not a security preset. The override survives, and the seam
# check is what tells you so.
assert has_anomalies(ml_normalize(hostile)) is True
assert inspect_anomalies(ml_normalize(hostile)).kinds == ["bidi"]

# normalize_confusables folds the homoglyph and nothing else.
assert inspect_anomalies(normalize_confusables(hostile)).kinds == ["invisible"]

The guidance only runs one way

A clean result is not an all-clear

If has_anomalies is still true after you clean, you used the wrong function for your input. A false result does not mean you chose right — the anomaly panel does not cover every class a transform can leave behind.

Reported recall across 645 adversarial vectors is 42.6%: 130 of 305 wrong-choice failures are visible at the seam, at zero false positives, splitting as confusables 43%, bidi 58%, PUA 0%. Those figures are not ours and we have not reproduced them; the mechanism above is measured here.

At that recall this is a useful alarm and a useless all-clear. Wire it into CI as an acceptance test and it will read "clean" on well over half the inputs that are not. The PUA column is the sharpest case: private-use characters are not an anomaly kind, so every transform that forwards one is reported clean.

See #643 for classes the panel does not cover.