A malicious payload sat at the end of an ordinary line of code, behind about 2,000 spaces, for over a year. Editors didn't wrap it, diffs didn't show it, and it reached production. Here's the thirty-line CI guard that would have caught it, the family of invisible-code tricks it belongs to, and the open-source scanner it turned into.
The Problem
The malware was on the last line of a small utility file. The line itself looked fine: a closing brace and an export. What you couldn't see was everything after it: about 2,000 spaces, and then several thousand characters of obfuscated JavaScript.
That is the whole trick, and it works far better than it should:
- ▸Editors don't wrap code by default. The line looks like it ends at the brace. You'd have to scroll right past column 2,000 to find anything.
- ▸Diff views don't wrap either. GitHub's PR diff showed a normal-looking line with a little extra trailing whitespace, if you noticed the horizontal scrollbar at all.
- ▸Linters didn't care. Long lines are a style warning nobody had switched on, and trailing whitespace before more code isn't "trailing" as far as most rules are concerned.
Its first appearance in git history was a commit from 2024. It sat there, invisible to every person who opened the file, until it was found running on a production server. By then the question was no longer "is the code clean" but "which developer machines has this touched", and that is a much worse week.
The cleanup is a story for another day. This post is about the 30 lines of CI that would have caught it on day one, and the free tool they turned into.
The First Fix: A Thirty-Line Guard
The immediate response was a script in the repo's deploy workflow that fails the build if any tracked source file has:
- ▸A non-blank character, 100+ spaces or tabs, then another non-blank character on the same line. Indentation and genuine trailing whitespace don't match; "something, a huge gap, something else" does.
- ▸A line over 2,000 characters in a hand-written source file.
- ▸The specific marker strings the payload used.
It runs in milliseconds, it has no dependencies, and it flags the payload immediately. The uncomfortable lesson was how cheap the check is compared with how expensive the miss was.
But the marker strings only catch this payload, and the whitespace rule only catches this hiding trick. Once you start looking, it's one of a family.
The Family of Tricks
Every one of these exploits the same gap: what a reviewer sees is not what the computer runs.
- ▸Off-screen padding. The one above. Push the payload past the right edge.
- ▸Bidirectional overrides ("Trojan Source", 2021). Unicode control characters that reverse the display order of text. A line can read as a comment to a human while the compiler sees an early
return. GitHub now shows a warning banner for these. Most CI pipelines still don't check. - ▸Invisible identifiers. A zero-width space inside a variable name makes
isAdminandisAdmin-plus-nothing two different variables that look identical. Hangul filler characters render as blank but count as letters, so they can be entire variable names. - ▸Variation-selector payloads ("GlassWorm", 2025). Variation selectors are meant to choose between emoji styles. There are 256 of them, which happens to be exactly one byte each. A string can carry a full encoded payload that renders as an empty pair of quotes.
- ▸Tag characters in agent instruction files. Unicode tag characters are invisible copies of ASCII. Text in
AGENTS.md,CLAUDE.mdor an editor rules file can carry instructions that a human reviewer sees as nothing at all, but that an AI coding agent reads perfectly. This is the newest variant and, with agents now reading those files on every run, the one I'd worry about most.
None of these needs a vulnerability in anything. They just need a reviewer who trusts what they see.
Turning It Into a Tool
So I rebuilt the guard as a general-purpose tool and open-sourced it: offscreen-scan, a zero-dependency CLI and GitHub Action.
npx github:hassams/offscreen-scan # the current repo
npx github:hassams/offscreen-scan --deps # ...plus node_modules
It reports file, line and the exact column where the hidden content starts, with an escaped preview of what's there:
lib/helpers.js:41:2028 error content after 2000 blank characters, pushed off-screen [whitespace-gap]
hidden: ;(function(a,b){var c=a.split('').reverse().join('');eval(...
Output comes as text, JSON, GitHub annotations (so findings land on the PR line) or SARIF (so they land in the Security tab). The rules:
- ▸whitespace-gap: content after 100+ blank characters, counting tabs, no-break spaces and the other Unicode blanks, not just ASCII spaces.
- ▸invisible-char: zero-width, bidirectional, filler, variation-selector and tag characters.
- ▸long-line, encoded-blob, decode-and-run: weaker signals, reported as warnings.
The acid test was the real payload: pulled out of git history and fed to the scanner, it trips three separate rules (the gap, the line length, and a decode-then-execute pattern), and the preview points straight at the hidden code.
The Hard Part: Not Crying Wolf
Catching the attack was easy. Making a tool people will leave switched on was the real work.
The first version flagged every invisible character as an error. I pointed it at the 27,000 files that next, react, typescript, webpack, eslint, jest, zod and date-fns install, and got about 5,000 errors. All of them were false alarms, and every one taught me something about how real text uses these characters:
- ▸Emoji are built from invisible parts. 👩🏻💻 is a woman, a skin tone, a zero-width joiner and a laptop. England's flag is a black flag followed by six tag characters: the same characters the agent-instruction attack uses.
- ▸Real languages need them. Persian uses the zero-width non-joiner between letters as a normal part of spelling. A date library's Persian locale is full of them.
- ▸Parsers mention them. HTML entity tables, Unicode identifier ranges and character-set encoders all contain these characters as data, usually as a raw U+200C sitting alone between two quotes, compared against input.
- ▸Markdown tables pad. A README table with a wide column has 200 spaces in a row and is perfectly innocent.
So the rules became contextual. Bidirectional overrides, tag characters outside a flag, fillers and variation-selector runs are always errors, because there's no ordinary reason for them. Zero-width characters are fine when they're part of an emoji, sit between two non-Latin characters, or are the entire content of a string literal. Three or more in a row is always an error, because that's an encoding, not a typo. Inside node_modules, a lone stray character drops to a warning, because published packages are full of harmless copy-paste debris.
That took the dependency scan from 5,000 errors to 48. All 48 are one thing: React's development build makes unique function names by appending runs of zero-width spaces to a word, then builds the function with new Function. Invisible characters fed to a code constructor is exactly what an attack looks like. I left it as an error and documented the one-line ignore, because a scanner that quietly allows the most attack-shaped thing in your dependency tree isn't doing its job.
Design Decisions Worth Stealing
No inline suppressions. Most linters let you write // eslint-disable-line. This tool deliberately doesn't. A suppression comment is one more thing an attacker can hide off-screen, right next to their payload. Exceptions go in --ignore globs in the workflow file, where a reviewer sees them change.
Escape everything you print. The tool prints previews of hidden content, and hidden content is attacker-controlled. Every control character and invisible character is escaped before it reaches your terminal or CI log, so a payload can't use the report to send terminal escape sequences.
Never skip silently. A file over the 5 MB scan limit is reported as unscanned, not ignored. Padding a file past a scanner's size limit is just the same trick one level up.
Hiding rules run everywhere. Long-line and blob checks are skipped for lockfiles, minified files and dist/, because those are legitimately huge. The gap and invisible-character checks are not, because there is no file type where hidden content is expected.
Deterministic. No network calls, no AI, no heuristics that change between runs. The same diff gets the same answer every time, which is what you want from something that can block a merge.
The Part I Didn't Expect
I built this with an AI coding assistant. The very first time the scanner ran against its own repository, it failed.
My test file was full of escape sequences like \u200b, written specifically so the tests would contain no invisible characters. Somewhere between the assistant and the disk, those escapes had been turned into the real characters. The test file was now full of genuine zero-width spaces and bidirectional overrides, and nobody could see them. It happened twice more before I was done: once in a code comment, once in the README.
Nothing malicious happened. But it's a tidy demonstration of the whole point. Invisible characters get into codebases through ordinary tools doing ordinary things, and you will not see them by reading. You need something that reads the bytes.
Limits
This finds hiding, not malware. Obfuscated code on a normal-length, normally-indented line passes. So does a malicious dependency that doesn't bother to hide, or a payload fetched at runtime. It belongs alongside lockfile review, pinned dependencies and least-privilege deploys, not instead of them.
But the attack that started all this beat every one of those controls by being somewhere nobody looked. A thirty-line check would have caught it the day it was committed.
offscreen-scan is MIT-licensed. Add it to CI in four lines, and if it flags something odd in your dependencies, I'd genuinely like to hear about it.