Mixed script & whole-script confusables
Check a lookalike domain
Enter a hostname to see it label by label, with the script of each and whether
it folds onto a Latin name. One flag cannot answer this: the same
suspicious fires for a Cyrillic imitation of a brand and for an
ordinary Russian domain.
The tool
The example spells a brand entirely in Cyrillic, so every character of the label agrees with every other and a mixed-script check reports nothing — yet it folds exactly onto the Latin name. Compare it with the others below.
Enter a hostname to check it.
Nothing is uploaded. The engine runs inside this page.
Label by label
Each label with the scripts it uses. The top-level domain is shown too, because its script is half the question.
Folds to
The canonical form. When this is a familiar name, that is the point.
Loading the engine…
Why one flag is not enough
Measured with disarm. Note the suspicious column: it cannot tell the
second row from the fourth.
| Hostname | suspicious | mixed_script | whole_script_confusable | folds to |
|---|---|---|---|---|
| paypal.com | false | false | false | paypal.com |
| раураӏ.com | true | false | true | paypal.com |
| раураӏ.рф | true | false | true | paypal.pф |
| яндекс.рф | true | false | false | яндekc.pф |
| рaypal.com | true | true | false | paypal.com |
Rows two and four both report suspicious. One is a Cyrillic
imitation of a payment provider; the other is Russia's largest search engine.
Blocking on that flag alone rejects the second, and ignoring it lets the first
through.
whole_script_confusable separates them, because the imitation folds
onto a Latin name and the real domain does not. The third row shows why the
top-level domain matters as well: the same Cyrillic label under
.рф is what a site written in Russian looks like, so
the usable policy is a label that folds onto Latin, under a Latin top-level
domain — which is what per-label results let you express.
Row five is the easier case and the one browsers already handle: two scripts inside one label, which no ordinary name does.
The same thing in your own code
Each block is a file CI compiles and runs, so none can quietly stop working, and all seven print the same line. disarm on GitHub →
# Tell a spoofed domain from a real one written in another script.
# pip install disarm
from disarm import is_suspicious_hostname
# paypal spelled entirely in Cyrillic: er, a, u, er, a, palochka. Every character
# agrees with every other, so nothing is mixed-script.
SPOOF = "раураӏ.com"
LEGIT = "яндекс.рф" # Russia's largest search engine
spoof_flag, spoof = is_suspicious_hostname(SPOOF)
legit_flag, legit = is_suspicious_hostname(LEGIT)
# The blunt signal cannot tell them apart — it fires for both.
assert spoof_flag and legit_flag, "suspicious flags both"
assert not spoof.mixed_script, "the spoof is not mixed-script; every letter is Cyrillic"
# Whole-script confusability is what separates them.
assert spoof.whole_script_confusable, "the spoof folds onto a Latin name"
assert not legit.whole_script_confusable, "the real domain does not"
assert spoof.canonical == "paypal.com", spoof.canonical
print("ok: both flagged suspicious; whole-script confusability separates them")
// Tell a spoofed domain from a real one written in another script.
// cargo add disarm
use disarm::api::analyze_hostname_with;
fn main() {
// paypal spelled entirely in Cyrillic: er, a, u, er, a, palochka.
let spoof = "\u{0440}\u{0430}\u{0443}\u{0440}\u{0430}\u{04CF}.com";
let legit = "\u{044F}\u{043D}\u{0434}\u{0435}\u{043A}\u{0441}.\u{0440}\u{0444}";
let s = analyze_hostname_with(spoof, false);
let l = analyze_hostname_with(legit, false);
// The blunt signal cannot tell them apart — it fires for both.
assert!(s.suspicious && l.suspicious, "suspicious flags both");
assert!(!s.mixed_script, "the spoof is not mixed-script; every letter is Cyrillic");
// Whole-script confusability is what separates them.
assert!(s.whole_script_confusable, "the spoof folds onto a Latin name");
assert!(!l.whole_script_confusable, "the real domain does not");
assert_eq!(s.canonical, "paypal.com");
println!("ok: both flagged suspicious; whole-script confusability separates them");
}
// Tell a spoofed domain from a real one written in another script.
// npm i disarm
const assert = require("node:assert/strict");
const { analyzeHostname } = require("disarm");
// paypal spelled entirely in Cyrillic: er, a, u, er, a, palochka.
const SPOOF = "раураӏ.com";
const LEGIT = "яндекс.рф";
const s = analyzeHostname(SPOOF);
const l = analyzeHostname(LEGIT);
// The blunt signal cannot tell them apart — it fires for both.
assert.ok(s.suspicious && l.suspicious, "suspicious flags both");
assert.ok(!s.mixedScript, "the spoof is not mixed-script; every letter is Cyrillic");
// Whole-script confusability is what separates them.
assert.ok(s.wholeScriptConfusable, "the spoof folds onto a Latin name");
assert.ok(!l.wholeScriptConfusable, "the real domain does not");
assert.equal(s.canonical, "paypal.com");
console.log("ok: both flagged suspicious; whole-script confusability separates them");
# Tell a spoofed domain from a real one written in another script.
# gem install disarm
require "disarm"
# paypal spelled entirely in Cyrillic: er, a, u, er, a, palochka.
SPOOF = "\u{0440}\u{0430}\u{0443}\u{0440}\u{0430}\u{04CF}.com"
LEGIT = "\u{044F}\u{043D}\u{0434}\u{0435}\u{043A}\u{0441}.\u{0440}\u{0444}"
s = Disarm.analyze_hostname(SPOOF)
l = Disarm.analyze_hostname(LEGIT)
# The blunt signal cannot tell them apart — it fires for both.
raise "suspicious flags both" unless s[:suspicious] && l[:suspicious]
raise "the spoof is not mixed-script" if s[:mixed_script]
# Whole-script confusability is what separates them.
raise "the spoof folds onto a Latin name" unless s[:whole_script_confusable]
raise "the real domain does not" if l[:whole_script_confusable]
raise "unexpected canonical #{s[:canonical]}" unless s[:canonical] == "paypal.com"
puts "ok: both flagged suspicious; whole-script confusability separates them"
// Tell a spoofed domain from a real one written in another script.
// implementation("dev.disarm:disarm:0.14.1")
import dev.disarm.Disarm;
import dev.disarm.HostnameAnalysis;
public class CheckHostname {
public static void main(String[] args) {
// paypal spelled entirely in Cyrillic: er, a, u, er, a, palochka.
String spoof = "раураӏ.com";
String legit = "яндекс.рф";
HostnameAnalysis s = Disarm.analyzeHostname(spoof);
HostnameAnalysis l = Disarm.analyzeHostname(legit);
// The blunt signal cannot tell them apart — it fires for both.
if (!(s.suspicious() && l.suspicious())) throw new AssertionError("suspicious flags both");
if (s.mixedScript()) throw new AssertionError("the spoof is not mixed-script");
// Whole-script confusability is what separates them.
if (!s.wholeScriptConfusable()) throw new AssertionError("the spoof folds onto a Latin name");
if (l.wholeScriptConfusable()) throw new AssertionError("the real domain does not");
if (!s.canonical().equals("paypal.com")) throw new AssertionError(s.canonical());
System.out.println("ok: both flagged suspicious; whole-script confusability separates them");
}
}
// Tell a spoofed domain from a real one written in another script.
// implementation("dev.disarm:disarm-kotlin:0.14.1")
import dev.disarm.kotlin.*
fun main() {
// paypal spelled entirely in Cyrillic: er, a, u, er, a, palochka.
val spoof = "раураӏ.com"
val legit = "яндекс.рф"
val s = spoof.analyzeHostname()
val l = legit.analyzeHostname()
// The blunt signal cannot tell them apart — it fires for both.
check(s.suspicious() && l.suspicious()) { "suspicious flags both" }
check(!s.mixedScript()) { "the spoof is not mixed-script" }
// Whole-script confusability is what separates them.
check(s.wholeScriptConfusable()) { "the spoof folds onto a Latin name" }
check(!l.wholeScriptConfusable()) { "the real domain does not" }
check(s.canonical() == "paypal.com") { s.canonical() }
println("ok: both flagged suspicious; whole-script confusability separates them")
}
/* Tell a spoofed domain from a real one written in another script.
*
* The C ABI returns the analysis as a JSON object rather than a struct, so this
* looks for the fields directly instead of pulling in a parser. Keys are
* snake_case here, matching Rust and Python rather than the camelCase the Node
* and Java bindings use.
*/
#include <stdio.h>
#include <string.h>
#include "disarm.h"
static int has(const char *json, const char *key, const char *value) {
char needle[128];
snprintf(needle, sizeof needle, "\"%s\":%s", key, value);
return strstr(json, needle) != NULL;
}
int main(void) {
/* paypal spelled entirely in Cyrillic: er, a, u, er, a, palochka. */
const char *spoof = "\U00000440\U00000430\U00000443\U00000440\U00000430\U000004CF.com";
const char *legit = "\U0000044F\U0000043D\U00000434\U00000435\U0000043A\U00000441.\U00000440\U00000444";
char *s = disarm_analyze_hostname(spoof);
char *l = disarm_analyze_hostname(legit);
if (!s || !l) { fprintf(stderr, "analysis failed\n"); return 1; }
/* The blunt signal cannot tell them apart — it fires for both. */
if (!has(s, "suspicious", "true") || !has(l, "suspicious", "true")) {
fprintf(stderr, "suspicious should flag both\n"); return 1;
}
if (!has(s, "mixed_script", "false")) { fprintf(stderr, "spoof is not mixed-script\n"); return 1; }
/* Whole-script confusability is what separates them. */
if (!has(s, "whole_script_confusable", "true")) { fprintf(stderr, "spoof folds onto Latin\n"); return 1; }
if (!has(l, "whole_script_confusable", "false")) { fprintf(stderr, "real domain does not\n"); return 1; }
printf("ok: both flagged suspicious; whole-script confusability separates them\n");
disarm_string_free(s);
disarm_string_free(l);
return 0;
}