feat: detect the full hidden-character corpus - #10
Merged
Conversation
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>
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ports the test corpus from out-of-character (MIT) — the most complete public inventory of characters that render as nothing — and closes the gaps it exposed.
Its API is per-character (
detect/replace), ours is per-word (analyze), so each case is restated in terms of theinvisiblesignal andnormalizedoutput rather than copied literally.What now gets caught
analyzeTokenearly-returns on, so anything not touching a letter was unreachable. A separate scan over runs not covered by a word catches them. All four bidi controls in a Trojan Source line are now stripped, not two.U+2800, musical null notehead. The fillers previously misreported asmixed_script, since they are Hangul letters to the property tables.\p{Cf}rule missed them. Each stays legitimate inside its own script (Kaithi is now named inscripts.tsfor exactly this).Not implemented, on purpose
The Unicode whitespace family (
U+00A0,U+2000–U+200A,U+3000) — 8 of the 16 blank cases in that corpus. These areWhite_Space, so every\smatcher already breaks on them (the attacker gains nothing), and they are ordinary typography: NBSP in HTML,U+202Fin French,U+3000in CJK. Flagging them buys false positives and no detection. Recorded as a documented won't-fix in the known-gaps suite.False positives
A sweep over legitimate multilingual/emoji text caught a real bug during development:
ℹ️(U+2139) is a letter that is also an emoji, so it legitimately takes the presentation selector. The first cut flagged it. That sweep is now a permanent regression suite.Sequence-building invisibles are exempted only next to
Extended_Pictographic, enclosing marks and regional indicators — deliberately not any symbol, so$<ZWSP>100is still caught (asserted).Docs
README now leads with the attacks rather than the feature list — Trojan Source, ASCII smuggling (with a snippet that decodes the payload), lookalike domains, filter evasion — plus recipes, a Moderation API section and credits to out-of-character.
Every documented output is asserted by
test/readme.test.ts, so the README cannot drift from the library. It earned that immediately: a hand-computedmsg.lengthin the docs was wrong (76 vs 78).Verification
Uint8Arraymask, not a per-character range search — the existing linear-scaling guard passesChangeset included (
minor→ 0.2.0).