ByteGuard
Reads bytes, not filenames.
A VS Code extension that scans a workspace the moment you open it for PolinRider / GlassWorm
supply-chain malware, lets you review and delete what it finds, and checks that VS Code and your
Node toolchain have not been patched.
It exists because of a real infection. The entry point was public/fonts/fa-solid-400.woff2 — a
"font" that was actually whitespace-padded JavaScript, launched by a .vscode/tasks.json task with
runOn: folderOpen. Every scanner that trusted the file extension walked straight past it.
ByteGuard checks what a file contains, never what it is called.
Requires VS Code 1.85 or newer. Runs in untrusted workspaces — you want the scan to happen before
you trust a folder.
What it catches
|
|
| Disguised binaries |
.woff2, .png, .wasm and 12 other types whose magic bytes don't match the extension — the font-file vector |
| Loader indicators |
22 regex rules: victim tags, app-vscode-eval, inz artifacts, C2 paths and hosts, the stage-2 XOR key, build markers, eval(Buffer.from(…)) |
| Auto-running tasks |
.vscode/tasks.json entries with runOn: folderOpen, hidden output, or an interpreter pointed at a binary-named file |
| npm lifecycle hooks |
postinstall and friends that pipe curl to a shell, use node -e, or execute binary-named files |
| Trojan-Source Unicode |
Bidi controls left unbalanced on a line, so what a reviewer reads is not what compiles, plus zero-width and private-use characters, and joiners wedged inside an identifier |
| Editor & toolchain |
VS Code's out/main.js, plus npm, yarn, pnpm and Claude Code — the loader reinfects from these, so a clean editor alone proves nothing |
Critical findings raise a modal. Everything lands in the Problems panel, the ByteGuard output
channel, and a status-bar item.
Install
git clone https://github.com/n0m4dz/ByteGuard && cd ByteGuard
npm install --ignore-scripts
npm run compile
npx @vscode/vsce package --no-dependencies
code --install-extension byteguard-1.0.0.vsix
Press F5 to run it in an Extension Development Host instead.
Commands
| Command |
What it does |
ByteGuard: Scan Workspace |
Rescan now |
ByteGuard: Deep Scan |
Same scan including node_modules, vendor/ and minified bundles. Run it after any npm install you did not fully trust |
ByteGuard: Check Editor and Toolchain Integrity |
VS Code out/ plus npm / yarn / pnpm / Claude Code |
ByteGuard: Open Rules File |
Opens the most specific rules file that exists |
ByteGuard: Create Workspace Rules |
Scaffolds .byteguard/rules.json |
Review and delete
The modal's Review & delete button opens a checklist: one row per file, showing which rules
fired and why. You tick what goes, then confirm the count. Nothing you did not tick is touched.
Only files that are payload in their entirety are pre-ticked — a "font" that is really a script.
A tasks.json with real build tasks or a package.json you still need is listed but left
unticked, because the fix there is to remove one entry by hand. Unicode findings are never offered:
an invisible character is a property of a line, not a reason to delete a file.
Deletion is permanent — files do not go to the Trash. A record of path, rules, size and SHA-256
is written to ~/.byteguard/deleted/, but never the contents. The workspace is rescanned after.
A patched editor or toolchain is never deleted. Reinstall VS Code from code.visualstudio.com and
reinstall Node; deleting main.js mid-session breaks the install without removing the persistence.
Rules
Rules are JSON, merged in four layers — later wins:
rules/default.rules.json (shipped)
~/.config/byteguard/rules.json (personal)
<workspace>/.byteguard/rules.json (per project — commit it)
- the
byteguard.rulesFile setting
JSONC comments and trailing commas are fine; the bundled schema gives autocomplete.
{
"version": 1,
"rules": [
// add a rule
{ "id": "project.cdn_key_leak", "severity": "critical",
"pattern": "X-API-KEY\\s*[:=]\\s*['\"][A-Za-z0-9]{20,}", "message": "Hardcoded CDN key" },
// change only the severity of a shipped rule — pattern and message carry over
{ "id": "ioc.curl_pipe_sh", "severity": "medium" },
// turn one off
{ "id": "ioc.xor_decode_loop", "enabled": false }
],
// silence a known-good file: file glob + rule glob
"allow": [
{ "file": "**/Pods/Mantle/**", "rule": "unicode.*", "reason": "upstream LRM in headers" }
]
}
Rule fields: id, severity (critical | high | medium), pattern (JS regex source),
flags, message, files (globs limiting the rule), enabled.
Three things that surprise people:
- Reusing an
id replaces fields, it does not append. A new pattern overwrites the old one.
- The
masquerade, tasks, unicode and npmHooks blocks replace each key wholesale. Listing
three extensions leaves only those three checked — paste the full default list and edit it.
Extensions with no known magic bytes (.svg, for one) have no effect at all.
allow entries from every layer are concatenated and cannot be removed, only narrowed.
Prefer an allow entry over "enabled": false — disabling a rule turns it off everywhere.
Security docs flag themselves
Any file quoting an indicator matches the rule that hunts for it, so an incident report or a
runbook lights up with criticals that are all its own prose. That is the rules working. This repo
handles its own case in .byteguard/rules.json: every entry names a
single file, never a directory, so a payload planted beside one is still caught. npm test proves
that by planting one in each allowed location.
Settings
| Setting |
Default |
|
byteguard.scanOnOpen |
true |
Scan when a folder opens |
byteguard.checkSelfOnStartup |
true |
Check editor and toolchain at startup |
byteguard.maxFileSizeKB |
2048 |
Skip larger files |
byteguard.excludeGlobs |
node_modules, .git, Pods, build, dist, .dart_tool, vendor, *.min.js |
Paths to skip |
byteguard.deepScanExcludeGlobs |
**/.git/** |
Paths to skip during a deep scan |
byteguard.rulesFile |
"" |
Extra rules file, applied last |
Limits
- Each rule reports its first match per file — a finding tells you the file is dirty, not how
many times. The Unicode check caps at 20 findings per file.
- The Unicode check is aimed at code review, so it skips generated lines over 2000 characters and
ignores characters that ordinary writing needs: ZWNJ and ZWJ (Persian, Arabic, Devanagari, emoji),
variation selectors, and balanced bidi isolates around right-to-left strings. A regex character
range whose endpoint happens to be a bidi code point can still produce a false positive — two did,
across 64,000 files in 80 extensions.
- ZWNJ and ZWJ are the exception to that exception when they sit between two ASCII identifier
characters (
unicode.identifier_joiner, high). Orthographic joiners are flanked by their own
script's letters and emoji joiners by emoji, so this placement only occurs inside an identifier —
where fetch\u200cData renders exactly like fetchData and binds to something else. Unlike a
zero-width space, which is not a valid identifier part and throws at parse time, this one runs.
Zero hits across the same 64,000 files.
- The default scan skips
node_modules and vendor for speed. That is exactly where a compromised
dependency lives — that is what Deep Scan is for.
- Symlinks are not followed.
- The toolchain check covers
~/.nvm, ~/.npm, ~/.local/share/claude, /usr/local and
/opt/homebrew. Installs elsewhere, and other editors, are not checked.
- Detection is signature- and heuristic-based. A clean scan means these checks found nothing. It
is not proof of safety.
Keep "task.allowAutomaticTasks": "off" and npm config set ignore-scripts true regardless.
Zero runtime dependencies
Node built-ins and the vscode API only. A supply-chain scanner with a supply chain of its own
would be a poor joke.
MIT licensed. Contributions welcome — especially new indicators.