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¶
Fifteen kinds fire. Thirteen 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 per-token branches after it split on !tok.is_ascii():
mixed_numbers, invisible, bidi, enclosing_mark, zalgo, duplicate_mark,
bidi_mixed, mixed_script and compat_fold only run on non-ASCII tokens, leet and
segmentation then run on everything, and confusable runs last, on non-ASCII tokens.
deletion and smuggled are not per-token at all: they read the whole text.
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; a run of tag, variation-selector, zero-width or Private Use Area characters standing on their own (#700, #812); one Private Use Area character, or a touching run of them, between two Latin letters of the same case, and eight between any other letters outside the CJK scripts (#1212); the twelve Default_Ignorable Cf code points that are invisible by property rather than by name — Duployan U+1BCA0–U+1BCA3 and musical U+1D173–U+1D17A (#813); the 3,738 that Unicode reserves as Default_Ignorable and has not assigned — U+2065, U+FFF0–U+FFF8, U+E0080–U+E00FF and U+E01F0–U+E0FFF (#1103); the deprecated format controls U+206A-U+206F and the interlinear annotation characters U+FFF9-U+FFFB, which strip_bidi, strip_format and canonicalize delete from a word; and any noncharacter (U+FDD0-U+FDEF and the last two code points of every plane), even one standing alone: nothing legitimate emits one, and canonicalize deletes it. Those two were found by the Lean model in formal/lean/Detection (Finding 1) |
emoji ZWJ sequences; ZWJ/ZWNJ joiners in Indic & Arabic; soft hyphen; a single Private Use Area code point, which is an icon-font glyph — it takes four in a row, the same reason strip_format keeps the block at all (#413); the 29 Cf code points that render and carry meaning, such as the Arabic number signs and the Egyptian hieroglyph layout controls |
bidi |
an LRO/RLO override anywhere; an isolate or an LRE..PDF embedding in a token that is majority-Latin or has no letters at all (12<isolate>34 — a bare account number is exactly the carrier, so numeric tokens are in scope, Trojan Source, #643); an RLM/ALM immediately before a run of European numbers in the same context — Transfer <RLM>100 200 300 to Bob renders Transfer 300 200 100 to Bob (#741) |
LRM, which produced no reordering over any carrier measured; RLM/ALM anywhere other than in front of a number run, so RTL prose and hashtags do not fire |
zalgo |
excessive stacked combining marks; a strikethrough or slash on two letters and half a word or more; an underline, U+0334 or U+0335 on every letter of three or more, or on all letters but one with five at the least; three marks on two letters and half a Latin word or more (#1156, #1194, #1213). The overlay rule also reads the canonical form of a text holding an overlay mark, so a filler between a letter and its strike does not hide the strike (#1209) |
ordinary accents; one overlaid letter; under half a word struck; two letters of three under U+0334 or U+0335, the IPA mark for a velarized or pharyngealized sound and a short bar; an underlined digraph in romanized Arabic (s\u0332h\u0332); a negation on a symbol (a=\u0338b); the overline U+0305 and the macron below U+0331, which have real uses on every letter; Vietnamese, Indic and Burmese clusters |
enclosing_mark |
two or more enclosing marks (Me) in one token — I⃝g⃝n⃝o⃝r⃝e⃝. Its own kind rather than a zalgo finding, because it is a different fact: not "too many marks" but a mark whose category is never an accent. One per base is below every threshold — is_zalgo fires above three, strip_zalgo keeps two — so the class was clean at every surface while strip_obfuscation removed it (#724) |
keycap sequences on a digit, # or *, the bases a keycap can have (1️⃣ is 1 + U+FE0F + U+20E3, and the variation selector is what makes it an RGI keycap). The sequence on a letter reports, as canonicalize keeps its mark (#1195); the five marks of historic Cyrillic notation on a Cyrillic base (U+0488, U+0489, U+A670–U+A672), while U+20DD or U+20E3 on Cyrillic letters reports; a single enclosing mark, which is a character someone may have typed |
mixed_numbers |
one token drawing digits from more than one decimal numbering system — UTS #39 §5.3. 1٢۳4५ reads as 12345 and is five systems; 12٣ is two. Digits carry the script of nothing, so mixed_script cannot see the common shape: a token that is mostly ASCII with one substituted digit is one script to every other check here, and the whole class was clean at every surface (#777) |
a single system, however unusual — ٢٠٢٤ is a year and २०२४ is the same year in Devanagari. The check is about mixing, not about which digits |
duplicate_mark |
the same nonspacing mark twice in a row on one base — UTS #39 §5.4. á́ renders indistinguishably from á, so it is a spelling of a word that no keyboard produces and no reader can tell apart. Below every zalgo threshold by design: two marks is ordinary, and it is the repetition rather than the count that carries no information (#835) |
a repeated mark of combining class 0, which is positioned rather than stacked — a doubled Devanagari matra is an orthography question, not this one; and two different marks on one base, which is how stacked diacritics normally work (ế) |
mixed_script |
Latin combined with Cyrillic or Greek in one token | CJK / Thai / kaomoji; legitimate unit symbols (kΩ, µ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 either the decode is a lexicon word (fr33 → free) or, at five characters and up, it is one edit from one (1ogin → iogin → login). The second path exists because 1 decodes to i, not l — correct, but it means a 1-for-l spoof never decodes exactly and the exact path can never see it (#825) |
a literal number that maps to no letter (win32, Power5, 21st, 3pm); a one-edit decode shorter than five characters, where the neighbourhood is dense enough that k8s and co2 start reading as words |
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 |
deletion |
a lone carriage return that overwrites text — ZZZZZZ<CR>paypal renders as paypal on any terminal, and nothing in the code points says so. The fourth class in the Boucher et al. taxonomy (§IV-G), and the one the other three generators in tests/test_attack_corpus.py did not cover. Its own kind rather than a control finding because the kind names the treatment path, and since #937 the two paths diverge further: BS and DEL are resolved — the preset applies the erase, so pXaXyXpXaXlX comes back as paypal — while a lone CR is resolved by nothing unless the caller sets resolve_cr, because it is byte-identical to a classic Mac OS line ending. Detection is unchanged either way: a reader who saw a control picture is still told. Text-level, not per-token: a lone CR is whitespace, so it splits the tokens either side of it and both halves are clean on their own (#739) |
a CRLF line ending; a CR followed only by more CRs; a CR at end of text, which overwrites nothing; a CR at the start of a line, which has no prefix to hide — after any UAX #14 mandatory break (LF, VT, FF, NEL, LS, PS), not just LF; a CR followed by one or two spaces and no more, unless they blank the whole line: two spaces and a line feed is the stray CR before a Markdown hard line break, and it hides two cells. Three or more spaces report, whatever is left of the line, because 23 after delete the audit log now leaves a lone w (#1193). Two cells can change a meaning: unsafe with a CR and two spaces shows safe, and no access shows access. This is not reported, because the same bytes are a stray CR before a Markdown hard line break, and the detector cannot tell them apart. canonicalize and llm_guardrail return the whole text. TextPipeline(resolve_deletions=True, resolve_cr=True) returns what a terminal shows. Known false positive: a classic Mac OS file, which used a lone CR as its line ending until 2001, and a stray CR pasted in front of ordinary text — indistinguishable from an overwrite in the bytes, so the report is a technical fact and the judgement is yours |
smuggled |
a run of smuggling carriers that decodes to readable text — tag_ascii (U+E0020–U+E007E), variation_bytes (U+FE00–U+FE0F, U+E0100–U+E01EF), zero_width_binary (U+200B = 0, U+200C = 1, MSB first) or sneaky_bits (U+2062 = 0, U+2064 = 1, in eight-bit bytes or unpadded). invisible reports that a carrier is present; this reports what it says. Different strengths of evidence: an invisible character can arrive by accident — a copy-paste artefact, a BOM, an editor quirk — but a run that decodes to printable text cannot, because random damage does not spell words. It therefore needs no threshold and no policy to interpret, and leads the kinds list when it fires. Additive: the run is still reported as invisible too, so a caller already matching that kind keeps working (#701) |
percent_escape, the fourth scheme decode_smuggled reports — a %XX run spelling readable text is ordinary in any URL, so it is decoded for inspection and deliberately never reported here (#727); a run whose bytes are not valid printable UTF-8 — left to invisible, which is the right verdict for it; a well-formed emoji subdivision flag, using the stripper's own allowlist rather than a second copy of it; a lone variation selector, which is emoji presentation |
compat_fold |
a token mixing a Unicode compatibility form with ASCII, where the non-ASCII part folds to ASCII — admin, 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 (kΩ, µF), and the squared CJK units that do fold to ASCII but carry no letter (10㎏ → 10kg, 5㎞ → 5km); a token spelled wholly in a compatibility form (paypal), which cannot be told from NHK by character class; the ellipsis …, which is ordinary typography; and fullwidth sentence punctuation (,:;!?()) in a word that holds CJK, because a Chinese sentence has no spaces and one token spans it |
confusable |
a token where the confusable fold — not NFKC — produces ASCII the input did not carry: pɑypal (U+0251), gıthub (U+0131), ord∶end (U+2236 → :). The fold also reaches 187 Latin letters that differ from an ASCII letter in shape rather than by an accent: a stroke, bar or hook (\u00f8, \u0142, \u0111, \u0127), a dotless or turned form (\u0131, \u01dd), a small capital, an IPA letter. Those folds are deliberate and report, so K\u00f8benhavn and \u0141\u00f3d\u017a fire. 186 of them have no decomposition; the other, \u01fe, is \u00d8 plus an acute and reports exactly when \u00d8 does. canonicalize has two ASCII-producing steps and compat_fold reported only the first; the second is the largest table disarm ships and the detector never consulted it. The slice with no compatibility decomposition is also single-script, so mixed_script cannot see it either. 232 code points reach ASCII by the fold alone, 76 producing one of : = % & ? # / \ |
ordinary typography: curly quotes, the dashes and hyphens U+2010 to U+2015, and the minus sign U+2212, unless the word is shaped like a host name (ex\u2010ample.com, CVE-2017-5383) or the marks split single letters. canonicalize still folds them, so has_anomalies false does not mean the text is canonical: to guard a sink where a new ASCII ' matters, ask is_canonical, which is false on admin\u2019-- and on \u2019 OR 1=1; text where every letter folds to Latin and none is ASCII — Привет, Ελλάδα, which is the whole-legitimate-non-Latin-web over-flagging #545 removed from is_suspicious_hostname; accented Latin, where the fold leaves the letter alone (café, naïve, straße) or only drops the accent: a letter whose canonical decomposition begins with the ASCII letter it folds to, so Fran\u00e7ais and ch\u1ec9 are spared (found by the Lean model in formal/lean/Detection, Finding 4); unit symbols (µF, kΩ); and a word boundary — IT-специалист is two words, judged separately |
The bidi kind is a judgement, not a census (#778)¶
The row above spares LRM everywhere and RLM/ALM outside a number run, on purpose: a
lone directional mark is ordinary in right-to-left text, and reporting one would fire on
any page that uses it. So the kind answers 9 of the 12 UAX #9 controls.
When the question is the census rather than the judgement — a filename, an identifier, a
source file, anywhere the caller has already decided their input should carry no bidi
control at all — use has_bidi_control, which answers all twelve and applies no context:
from disarm import has_bidi_conflict, has_bidi_control, inspect_anomalies
assert has_bidi_control("\u200e") # a mark counts
assert inspect_anomalies("\u200e").kinds == [] # and is deliberately not an anomaly
assert has_bidi_control("invoice\u202egpj.exe") # so does an override
assert not has_bidi_conflict("invoice\u202egpj.exe") # which reads letters, not controls
The three predicates are disjoint answers to different questions: has_bidi_control is the
raw set, inspect_anomalies is the judged subset, and has_bidi_conflict reads
strong-direction letters and is structurally blind to controls altogether.
Shapes other detectors answer differently (#1165)¶
A leading U+FEFF reports invisible. It is what a file read as utf-8 and not utf-8-sig
begins with, and two other detectors read it as a byte order mark. disarm keeps reporting it
because <BOM>paypal is not paypal to an exact match. Open a file with utf-8-sig if the mark
is expected.
One private-use character beside a letter or on its own reports nothing (#812), because that is an
icon-font glyph. One between two Latin letters of the same case reports, because pay + U+E000 +
pal is a split word, and canonicalize returns paypal (#1212). A glued label changes case at the
join, so Save + U+E001 + File stays clean, and so does Pay + U+E000 + Pal, where
is_canonical still returns False. Between any other letters it takes eight, and a CJK letter on
either side does not count, since legacy encodings for Han used this block. of + U+F001 + ce
reports too, and that is correct, because canonicalize damages it to ofce.
A ZWJ at the end of a text, after an emoji, reports nothing. One joiner cannot spell a byte, and a truncated message leaves one by accident, so this is a broken emoji sequence and not a carrier. The same goes for a lone zero-width character beside an emoji, with no letter to split or hide. Beside a letter, both report.
U+200B between two digits reports nothing (#1204). The rule for one invisible character reads
letters, so 12 U+200B 34 stays clean while ab U+200B cd and a1 U+200B 2b report.
A digit group has no word to split, and UAX #14 makes U+200B a line-break opportunity, which a
long number has no other way to carry. The cost falls on a filter that matches digits in raw text,
since 4111 U+200B 1111 gets past it. Run such a filter on canonicalize output, which
removes the character, or ask is_canonical, which returns False.
tests/test_rival_vectors.py holds every string other detectors' test suites state a verdict on,
and says for each one whether disarm agrees, differs by decision, or differs with an issue open.
Levels (#946)¶
anomaly_severity(kind) gives the level a finding of that kind carries, by the name a report holds:
"error" or "warning", which are SARIF's names. smuggled is the one "error", because it fires
only on a run that decodes to readable text. Every other kind reports a technical fact and leaves the
judgement to the caller, and that includes deletion, whose presence is not a matter of degree: a
classic Mac OS file with CR line endings is a known false positive. The Node, Ruby, Java and C ABI
bindings take the same name. The scanner's SARIF output reads these levels.
One verdict for every canonically equivalent spelling¶
\u00e9 is one code point in NFC and two in NFD, and a reader cannot tell them apart. The
detector gives both spellings the same report: it classifies each token in its composed
form (NFC), and the tests that ask for an ASCII letter read a letter through its
canonical decomposition, so \u00e9 counts as the e it is. Before this, \u00e9t\u00e9
followed by an isolate was clean in NFC and bidi in NFD, and the NFC spelling, the one
nearly all text arrives in, was the unreported one (found by the Lean model in
formal/lean/Detection, Finding 3). tests/exhaustive_anomalies.rs checks the property
over every Unicode scalar.
The exception is a character NFC replaces with a different one rather than
composing: the canonical singletons, such as U+212A KELVIN SIGN (to K) and
U+037E GREEK QUESTION MARK (to ;), and duplicate encodings such as U+1FEE. Those are
not a spelling of the letters they stand for, they are different characters that look
like them, so the detector judges them as spelled: \u212Aey reports compat_fold
where Key reports nothing, and canonicalize rewrites it. Neither NFC nor NFD
contains such a character, so any text's two normal forms still get one verdict. A
caller who must refuse bytes that are not already normalized wants is_normalized or
is_canonical, not this detector.
canonicalize preserves enclosing marks; strip_obfuscation removes them
That asymmetry is deliberate and is the same one the accent-preserving decision
(#429) produces: canonicalize caps stacked marks rather than deleting them, because
café and Việt must survive, and it does not read the mark's category.
724 §3 asks whether it should strip Me specifically — no enclosing mark is an¶
accent, so doing so would not weaken #429. The argument is sound and the change is
not made here: it moves canonicalize output for 13 code points, which is a
### Changed (breaking) entry and a decision of its own rather than a side effect of
adding a detector rule. Until then, inspect_anomalies reports the class and
strip_obfuscation removes it — screen with the first, clean with the second, and do
not read a clean canonicalize as a claim that the text carries no enclosing mark.
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 fifteen
kinds 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.
Non-Latin text is one such case. canonicalize, llm_guardrail and
strip_obfuscation fold letters that have a Latin look-alike and keep the others, so
a clean Russian or Hebrew word usually comes back as a mix that reports mixed_script
or bidi_mixed. Use rag_ingest to romanize it, or leave it alone if it goes straight
to a multilingual model. See
the LLM guide for the same case from the other side.
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: a private-use character beside a letter or on its own is not an anomaly (it takes a run of four, #812), and only one between two Latin letters of the same case reports (#1212), so a transform that forwards one is usually reported clean.
See #643 for classes the panel does not cover.