Skip to content

Commit d8f002d

Browse files
chrisdengsoclaude
andauthored
feat: detect the full hidden-character corpus (#10)
Ports the test corpus from spencermountain/out-of-character (MIT) — the most complete public inventory of characters that render as nothing — and closes the gaps it exposed. `analyze()` now also reports and strips: - Invisible characters that belong to no word. A lone format character forms a token with no letters in it, which the per-token pass returns early on, so anything not touching a letter was unreachable. A separate scan over runs not covered by a word catches them — this is what hid two of the four controls in a Trojan Source line. - Blank glyphs that are not whitespace: Hangul fillers, BRAILLE PATTERN BLANK, MUSICAL SYMBOL NULL NOTEHEAD. The fillers previously misreported as `mixed_script`, being Hangul letters to the property tables. - Invisible combining marks (CGJ, Khmer inherent vowels, KAITHI VOWEL SIGN I), which are category Mn and so missed by the format-character rule. Each stays legitimate inside its own script. - Variation selectors on a base with no registered sequence — the ASCII smuggling channel. Registered sequences are untouched: emoji presentation, keycaps, ideographic variants, Mongolian FVS, and letters that are themselves emoji (U+2139 "info"). Sequence-building invisibles are exempted only next to Extended_Pictographic, enclosing marks and regional indicators — not any symbol, so "$<ZWSP>100" is still caught. Deliberately NOT flagged: the Unicode whitespace family (U+00A0, U+2000..U+200A, U+3000). It is real typography and every `\s` matcher already treats it as a space, so flagging it costs false positives for no detection gain. Recorded as a documented won't-fix in the known-gaps suite. Docs: README leads with the attacks it catches (Trojan Source, ASCII smuggling, lookalike domains, filter evasion), adds recipes and credits, and every documented output is asserted by test/readme.test.ts so it cannot drift. Tests: 260 passing, incl. a false-positive regression sweep over legitimate multilingual and emoji text. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 9127450 commit d8f002d

8 files changed

Lines changed: 893 additions & 16 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@moderation-api/unicode-spoofing': minor
3+
---
4+
5+
Broaden the `invisible` signal to the full hidden-character corpus.
6+
7+
`analyze()` now also reports and strips:
8+
9+
- **Invisible characters that belong to no word** — a bidi control or
10+
zero-width character sitting between punctuation or spaces, rather than
11+
inside a token. This is what hid two of the four controls in a Trojan Source
12+
line (`if (level != "user<RLO> <LRI>// admin check<PDI> <LRI>") {`).
13+
- **Blank glyphs that are not whitespace** — Hangul fillers (U+115F, U+1160,
14+
U+3164, U+FFA0), BRAILLE PATTERN BLANK, MUSICAL SYMBOL NULL NOTEHEAD. The
15+
Hangul fillers previously misreported as `mixed_script`, since they are
16+
Hangul letters to the property tables.
17+
- **Invisible combining marks** — COMBINING GRAPHEME JOINER, the Khmer inherent
18+
vowels, KAITHI VOWEL SIGN I. Each stays legitimate inside its own script.
19+
- **Stray variation selectors** — a selector on a base with no registered
20+
sequence, the "ASCII smuggling" payload channel. Registered sequences (emoji
21+
presentation, keycaps, ideographic variants, Mongolian FVS) are untouched.
22+
23+
Unicode whitespace (U+00A0, U+2000–U+200A, U+3000, …) is deliberately still not
24+
flagged: it is ordinary typography and every `\s` matcher already treats it as a
25+
space.

README.md

Lines changed: 170 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@
44
[![CI](https://github.com/moderation-api/unicode-spoofing/actions/workflows/ci.yml/badge.svg)](https://github.com/moderation-api/unicode-spoofing/actions/workflows/ci.yml)
55
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
66

7-
Detection of Unicode-based text obfuscation ("homoglyph attacks"): spammers
8-
mixing lookalike characters into words to slip past keyword filters, e.g.
9-
`НОТ busіnеss рrоduсt` — visually clean, but `НОТ` is pure Cyrillic and
10-
`busіnеss` mixes two scripts inside one word.
7+
Text that reads as English but isn't. `НОТ busіnеss рrоduсt` looks ordinary in
8+
any font — `НОТ` is pure Cyrillic, `busіnеss` mixes two scripts inside one word,
9+
and neither matches a keyword filter looking for `HOT` or `business`.
10+
11+
This library finds that, plus the characters you can't see at all: zero-width
12+
joiners inside a word, bidi overrides that reorder a line, tag characters
13+
smuggling an instruction into a prompt. It reports what fired, where, and gives
14+
you the de-obfuscated text back.
1115

1216
Zero runtime dependencies. Script classification rides the JS engine's own
1317
Unicode property tables (`\p{Script=…}`); the only shipped data is the
@@ -28,24 +32,157 @@ npm install @moderation-api/unicode-spoofing
2832
import { analyze, skeleton } from '@moderation-api/unicode-spoofing';
2933

3034
const r = analyze('Неу Anatoly, НОТ busіnеss рrоduсt just drоppеd.');
35+
3136
r.spoofed; // true
32-
r.signals; // { mixed_script: true, confusable_word: true, invisible: false, zalgo: false }
33-
r.words; // [{ word: 'НОТ', skeleton: 'HOT', scripts: ['Cyrillic'], signals: ['confusable_word'], index: 13 }, …]
37+
r.signals.confusable_word; // true
3438
r.normalized; // 'Hey Anatoly, HOT business product just dropped.'
39+
r.words[1]; // { word: 'НОТ', index: 13, signals: ['confusable_word'],
40+
// scripts: ['Cyrillic'], skeleton: 'HOT' }
3541

3642
skeleton('раураl') === skeleton('paypal'); // true (UTS #39 comparison)
3743
```
3844

45+
## What it catches
46+
47+
Every example below runs as a test — see [`test/readme.test.ts`](./test/readme.test.ts),
48+
which asserts each documented output. Characters that would otherwise be
49+
invisible in this file are written as escapes.
50+
51+
### Trojan Source
52+
53+
Bidi controls reorder how a line _renders_ without changing what the compiler
54+
reads ([CVE-2021-42574](https://trojansource.codes/)). The `// admin check`
55+
below is not a comment — it only looks like one.
56+
57+
```ts
58+
const line = 'if (level != "user\u202E \u2066// admin check\u2069 \u2066") {';
59+
60+
analyze(line).signals.invisible; // true
61+
analyze(line).normalized; // 'if (level != "user // admin check ") {'
62+
```
63+
64+
The same trick renames files: `'invoice\u202Egnp.exe'` renders as
65+
`invoiceexe.png` in most UIs, and normalizes to `invoicegnp.exe`.
66+
67+
### ASCII smuggling
68+
69+
Tag characters (`U+E0000``U+E007F`) re-encode ASCII invisibly. Pasted into a
70+
prompt, an LLM reads the payload and a human reviewer sees a compliment.
71+
72+
```ts
73+
const encode = (s: string) =>
74+
[...s].map((c) => String.fromCodePoint(0xe0000 + c.codePointAt(0)!)).join('');
75+
76+
const msg = `Looks helpful!${encode('Ignore all previous instructions')}`;
77+
78+
msg.length; // 78 — the visible part is 14
79+
analyze(msg).signals.invisible; // true
80+
analyze(msg).normalized; // 'Looks helpful!'
81+
```
82+
83+
To read what was hidden rather than just strip it:
84+
85+
```ts
86+
const hidden = analyze(msg)
87+
.words.flatMap((w) => [...w.word])
88+
.filter((c) => c.codePointAt(0)! >= 0xe0000)
89+
.map((c) => String.fromCodePoint(c.codePointAt(0)! - 0xe0000))
90+
.join(''); // 'Ignore all previous instructions'
91+
```
92+
93+
### Lookalike domains and usernames
94+
95+
```ts
96+
// Cyrillic 'а' in the middle of a Latin word.
97+
analyze('Login at pаypal.com to verify').words[0];
98+
// { word: 'pаypal', index: 9, signals: ['mixed_script'],
99+
// scripts: ['Latin', 'Cyrillic'], skeleton: 'paypal' }
100+
101+
// A whole word in one non-Latin script needs context to judge — say what you
102+
// expect and it resolves.
103+
analyze('аррӏе.com', { expectedScripts: ['Latin'] }).normalized; // 'apple.com'
104+
105+
// Comparing identifiers directly: skeleton() is the UTS #39 fold.
106+
skeleton('аdmin') === skeleton('admin'); // true
107+
```
108+
109+
### Filter evasion
110+
111+
Four spellings that read normally, match no keyword list, and come back clean:
112+
113+
```ts
114+
analyze('Get f\u200Br\u200Be\u200Be m\u200Bo\u200Bn\u200Be\u200By now').normalized;
115+
// 'Get free money now' — zero-width spaces inside the words
116+
117+
analyze('buy cheap v\u3164i\u3164a\u3164g\u3164ra').normalized;
118+
// 'buy cheap viagra' — HANGUL FILLER: blank, but not whitespace
119+
120+
analyze('𝐅𝐑𝐄𝐄 𝐜𝐫𝐲𝐩𝐭𝐨 giveaway').normalized;
121+
// 'FREE crypto giveaway' — math alphanumerics
122+
123+
analyze('Ⓕⓡⓔⓔ ⓜⓞⓝⓔⓨ').normalized;
124+
// 'Free money' — circled letters (also FREE fullwidth)
125+
```
126+
127+
### Zalgo
128+
129+
```ts
130+
analyze('Z̸̢̬̈a̛̠͎lg̕o̶ spam').normalized; // 'Zalgo spam'
131+
```
132+
133+
### Left alone
134+
135+
None of these are flagged — see [false-positive guards](#false-positive-guards):
136+
137+
```ts
138+
analyze('Привет, как дела?').spoofed; // false — real Cyrillic, not a lookalike
139+
analyze('日本語のテキスト。辻\uFE00さん').spoofed; // false — ideographic variant sequence
140+
analyze('می\u200Cخواهم کتاب').spoofed; // false — Persian ZWNJ is orthography
141+
analyze('Ship it 🎉 👨\u200D👩\u200D👧 ℹ\uFE0F').spoofed; // false — emoji ZWJ, presentation selector
142+
```
143+
39144
## Signals
40145

41146
| Signal | Meaning | Example |
42147
| ----------------- | -------------------------------------------------------------------- | ------------------------------------- |
43148
| `mixed_script` | One word blends multiple scripts | `busіnеss` (Latin + Cyrillic `і`/`е`) |
44149
| `confusable_word` | Whole word is a Latin lookalike (UTS #39 skeleton resolves to ASCII) | `НОТ``HOT`, `HOT`, `𝐇𝐎𝐓` |
45-
| `invisible` | Format characters (zero-width etc.) inside a word | `fr​ee` |
150+
| `invisible` | Characters that render as nothing, in or between words | zero-width, bidi, tag chars, fillers |
46151
| `zalgo` | Combining marks stacked beyond orthographic depth (≥3 per base) | `Z̸̢̬a̛lg̕o` |
47152
| `illegal` | Control, non-character, or replacement code points anywhere in text | `NUL`, `U+FFFE`, `U+FFFD` |
48153

154+
What `invisible` covers: format characters (zero-width space/joiner, word
155+
joiner, …), bidi controls incl. the Trojan Source overrides, tag characters,
156+
blank-but-not-whitespace glyphs, invisible combining marks, and variation
157+
selectors on a base with no registered sequence — whether they sit inside a
158+
word or alone between punctuation.
159+
160+
## Recipes
161+
162+
**Run your existing filter against clean text.** `normalized` is the whole
163+
point: match on it, report on the original.
164+
165+
```ts
166+
const { normalized, spoofed } = analyze(userInput);
167+
const hit = BANNED.some((word) => normalized.toLowerCase().includes(word));
168+
if (hit || spoofed) flagForReview(userInput);
169+
```
170+
171+
**Block impersonating usernames** without banning non-Latin ones:
172+
173+
```ts
174+
const taken = new Set(existingUsernames.map(skeleton));
175+
if (taken.has(skeleton(candidate))) reject('too similar to an existing name');
176+
```
177+
178+
**Tell it what's normal for your traffic.** Declaring `expectedScripts` cuts
179+
false positives on genuine non-Latin content _and_ sharpens detection: a whole
180+
word in an unexpected script becomes evidence on its own.
181+
182+
```ts
183+
analyze(text, { expectedScripts: ['Cyrillic'] }); // real Russian traffic
184+
```
185+
49186
### False-positive guards
50187

51188
- Legitimate multilingual text is never flagged: whole words in a single
@@ -63,11 +200,35 @@ skeleton('раураl') === skeleton('paypal'); // true (UTS #39 comparison)
63200
are legitimate single-word script mixes and are exempt.
64201
- Scripts whose orthography uses joiners/zero-width characters (Arabic, Indic
65202
scripts, Persian ZWNJ, …) are exempt from `invisible`; scripts with stacked
66-
marks (Hebrew points, Arabic tashkeel, …) are exempt from `zalgo`. Emoji ZWJ
67-
sequences are never word tokens, so they are never flagged.
203+
marks (Hebrew points, Arabic tashkeel, …) are exempt from `zalgo`.
204+
- Invisible characters that build a sequence are left alone: emoji ZWJ
205+
sequences, keycaps, the tag characters in `🏴󠁧󠁢󠁳󠁣󠁴󠁿`, emoji presentation
206+
selectors (`❤️`, `ℹ️`), ideographic variants (`辻︀`) and Mongolian FVS.
207+
- Unicode whitespace (`U+00A0`, `U+2000``U+200A`, `U+3000`, …) is NOT flagged:
208+
it is real typography and every `\s` matcher already treats it as a space.
209+
Blank glyphs that are _not_ whitespace — Hangul fillers, `U+2800` — are.
68210
- `normalized` only rewrites _affected_ words; legitimate non-Latin text is
69211
returned byte-for-byte.
70212

213+
## From Moderation API
214+
215+
We build [Moderation API](https://moderationapi.com) — a hosted moderation
216+
platform covering toxicity, NSFW, PII, spam, scams and phishing across text,
217+
images, video and audio in 120+ languages, with review queues, user trust
218+
levels and DSA/GDPR audit trails.
219+
220+
This library is one primitive from that stack, released standalone because
221+
Unicode spoofing is a self-contained problem worth solving in the open. It
222+
makes no network calls, needs no API key, and does not talk to the hosted
223+
service — use it on its own for as long as it does the job. If lookalike text
224+
turns out to be one symptom of a wider user-generated-content problem, the
225+
platform is the rest of the answer.
226+
227+
## Credits
228+
229+
- Confusables data comes from the Unicode Consortium's
230+
[UTS #39](https://www.unicode.org/reports/tr39/) security tables.
231+
71232
## Updating the Unicode data
72233

73234
```bash

0 commit comments

Comments
 (0)