Every release, and what was broken before it
Versions follow semantic versioning. Anything that made snare report clean on an infected repository is listed first in its release, because that is the failure that matters most in a scanner.
Checking what you are running
snare version shows your version and commit.
snare update --check compares commits, not just version
numbers — the version once stood still for 33 commits, so a user 14 commits behind was
being told they were up to date.
snare version snare update --check snare update && snare selftest
1.6.0
2026-10-25 CurrentAdded — known-malicious package versions, in every ecosystem
- A known-malware list, the same for everyone.
.github/workflows/intel.ymlcondenses [OpenSSF's malicious-packages](https://github.com/ossf/malicious-packages) database, about 117,000 packages across npm, PyPI, crates.io, Go, RubyGems, Packagist, NuGet and Maven, into one small file every six hours. It publishes the file as theintelrelease's assets. Every snare downloads that same file and matches it on its own machine, so nothing about anyone's packages is sent anywhere. A download that fails its checksum is refused, and the old list is kept. A build with implausibly few entries is never published, because an empty list would read as "nothing is malicious". snare deps [dir] [--system]reads lockfiles and installed packages and names each known-malicious one, with where it is and its OSV advisory. - npm, pnpm, yarn and bun lockfiles, plusnode_modules-requirements*.txt,poetry.lock,uv.lock,pdm.lock,pylock.tomlandPipfile.lock, plus the site-packages of every virtualenv in the project -Cargo.lock,go.sum,Gemfile.lock,composer.lockand NuGet'spackages.lock.json- with--system, also the user's Python, pipx, global npm andcargo installsnare deps fixasks about each one: remove it, or replace it with the newest version that is not listed. Replacements install with scripts off (npm --ignore-scripts,pip --only-binary :all:), so they cannot run code while installing. It points tosnare rotatewhen something malicious was actually installed. A package whose every version is malicious gets a warning about lookalike names. It is person-only, likesnare gate allow.- The gate stops installs of a listed version. This covers
npm/pnpm/yarn/buninstall/add,npx,dlx,pip/pip3/python -m pip,uv add,uv pip,uvx,pipx,poetry/pdm/rye add,cargo addandinstall,go get/install/run,gem installandcomposer require. It also checks a requirements file named with-r, and a repository's lockfiles onnpm install. For an unpinned install of a package with some bad versions, it asks the registry which version would install, the same request the install was about to make, and stops only if that version is listed. A wrong list entry in a lockfile is approvable withsnare gate allow, tied to content as always. - Files other languages run on their own are now repository routes, like
package.jsonscripts: -setup.py(on install),conftest.py(on test),build.rs(on build),sitecustomize.pyandusercustomize.py(at every Python start), and.pthfiles (Python runs their import lines at every start): flagged when a line downloads, or decodes and runs, code. -//go:generatelines that fetch or pipe into a shell. - An ordinarysetup.pythat calls cmake through subprocess, abuild.rsthat compiles C,stringerand coverage's.pthare left alone. All 77 local repositories in the false-alarm sweep stay clean. snare doctorshows the list's age and size,snare updaterefreshes it, and the agent hook refreshes it in the background when it is a day old, never making a command wait.
Fixed — a block on incoming code could not be approved
snare gate allownow also looks at the code a block stopped before it arrived: the branch a checkout would bring, the upstream a pull would merge, and anything the gate recorded for this repository.snare gate allow <dir> <ref>names a branch.snare gate allow <url>approves a blocked clone. Each finding says it is "not on disk yet". Approvals are still tied to the file's content, so if the file changes it is flagged again.- The block now prints the exact allow command for each thing it stopped, for example
snare gate allow /path/to/repo main. Before, it printedsnare gate allow 'main in /path/to/repo', which is not a path. snare respondre-checks incoming blocks, shows each file and line, and offers allow or leave, with a pointer tosnare fixif it really is infected at the source. It no longer prints the raw command.- Considered and rejected: exempting snare's own repository from branch and pull checks, the way its working copy is exempt. Those checks are where incoming code is examined, so the exemption would let a poisoned upstream of snare through unseen. Making it approvable fixes the dead end without that cost.
1.5.0
2026-10-14Added — the gate: scan before code comes in or runs
git cloneclones to a quarantine directory first and scans that. The clone you asked for only happens if it is clean.git pull,merge,rebase,checkout,switchrungit fetch— which executes nothing — then scan the incoming tree in a throwaway worktree, plus the forged-force-push check, before a single file lands in your working copy.npm/pnpm/yarn/bun install,npm ci,npx,npm run …,node,next,vite,tsx,turboscan the directory they would run in. Anything in the npm family that is not on a short read-only list (view,ls,--version…) is gated: an unknown subcommand is treated as one that runs code.
Added — snare agents: the gate in front of every coding agent
- A Claude Code
PreToolUsehook in~/.claude/settings.json. This is the part that actually protects you: it runs whether or not the agent reads or agrees with its instructions, and exit 2 stops the command and hands the findings to the agent. A block reads "FIRE ON THE MOUNTAIN", tells the agent to stop and tell you, and raises a desktop notification in case it does not. - An instruction block in
~/.claude/CLAUDE.md,~/.claude/AGENTS.md,~/AGENTS.md, Codex's~/.codex/AGENTS.mdand Gemini'sGEMINI.md— only for tools that are installed. Advisory, for agents with no hook mechanism: runsnare gate -- <command>first, and on a block, stop and tell the user rather than route around it. --project [dir]writes the same block into a repository's ownCLAUDE.md,AGENTS.md,GEMINI.mdand Copilot instructions, plus a project hook that callssnareby name, so teammates who have snare are covered too.- The plugin ships the hook as well. Installed both ways, the plugin defers to the global hook rather than scanning twice.
Measured
- An agent's everyday commands —
ls,git status,git diff,rg— pay ~30ms, because a pure-bash pre-filter answers before snare loads. The first version cost ~370ms on every one. - A clean repository costs ~1.2s the first time and ~0.4s after, because clean verdicts are cached against HEAD, upstream, working-tree status and the files the known routes live in — including gitignored ones, since a payload dropped into an ignored
.vscode/changes nothinggit statussees. Findings are never cached. snare scan repogainedSNARE_FAST, which the gate uses: the repository's own files and the forged-push check, withoutnode_modulesor the full history walk.snare scan repoitself is unchanged and remains the full check.
Changed — a block shows what is suspected and where; the person decides
- It is fine, it is mine:
snare gate allow [dir], in their own terminal. It walks through each finding and asks. An approval is stored in~/.snare/gate-allow— never in the repository, where a payload could ship its own — and is tied to the file's content: change the file and it is flagged again.snare gate allowedlists approvals;snare gate forgetwithdraws them. - Let snare take charge:
snare respond, the guided clean-up.
Changed — the agent setup asks first
- The installer, the first run after upgrading and
snare updateeach show what would change and ask once ([Y/n]). They read the answer from the terminal, socurl … | bashcan still ask. - No terminal, CI, or a coding agent running snare: nothing is installed and nothing is recorded, so a person is asked next time.
- A "no" is remembered.
snare agents installis the yes, typed by hand. snare agents synconly refreshes a setup someone already agreed to; it never installs one.
Added — snare never deletes a repository, an organization or a remote branch
Fixed — snare respond replayed an old run and never said what it found
- The gate now records each place it stops. respond opens with them, re-checked now: every location with its line, the suspicion and the code. For each one the person chooses clean (snare strips or removes what it flagged, backs up uncommitted files first, and stages the change for review; nothing is committed or pushed), allow (it is theirs), or leave (it stays blocked and stays on the list).
- If nothing was confirmed as real, respond offers to stop there rather than pushing someone through a full credential rotation over a false alarm.
- A finished run starts a fresh check, and its record is kept as
respond-state.prev. - Flagged repositories are named in the scan step and the summary, with "cleaned" or "NOT cleaned yet". A repo you said no to stays listed.
- Cleaning a local checkout pins the git settings that can run a program (
core.fsmonitorand the rest), the same set the gate uses. snare respondapproves and cleans, so an agent is refused it, the same assnare gate allow.snare respond --statusstays open.- The selftest no longer writes test entries into the real gate state.
- Gate: a script that sourced a library had only refusals kept from it. Sourcing runs every top-level line, so
bash run.shthat sources a lib which clones and installs another repository checked neither. Sourced and nested scripts are now classified in full (found by automated review).
Fixed — the gate could run the code it was checking
Fixed — more ways past the gate, from further review passes
curl … | bashruns whatever arrives; it is reported as unverifiable, for the person to decide.curl -o x.sh … && bash x.sh: the script does not exist until the same command creates it, so it cannot be read in advance — unverifiable.git fetch --upload-pack=…and--receive-packrun a program for a local remote;git configwrites (core.hooksPath,alias.x '!…') arm one for later;export NODE_OPTIONS=…and similar arm the next command. All are checked now — and the fast path no longer waves through any command with a quote or an exec-style option.- A planted script in
.git/hooks(or a configuredcore.hooksPath) fires on the next commit, and is now read like any other route. bash /path/to/snare gate allowis still snare, and is still refused.- An agent's own file tools never pass through the shell hook, so it could have written an approval straight into
~/.snare/gate-allow.snare agents installnow adds Claude Code permission rules denying the agentEditandWriteon~/.snare, which Claude Code enforces on every tool; they are removed on uninstall, and your other permissions are left exactly as they were. Writing that directory from the shell is refused by the gate — reading it is fine, and so is any otherSNARE_HOME. - The narrower check had dropped coverage. It only looked at routes, so a payload hidden in an ordinary source file the app imports — which the full scan always found — went unseen. A bounded pass over the source (6000 files, 64MB) now looks for campaign markers and code hidden past whitespace, requiring the hidden part to look obfuscated so aligned comments and minified bundles are not reported. Re-measured on all 67 local repositories: still no false alarms.
Fixed — a sixth review pass
- A repository could exempt itself. The route check skipped any directory holding a
.snare-toolfile, as snare's own source does — so a repository that shipped that one empty file was never checked. Only snare's actual install is exempt now. - A cached clean outlived a source change. The source pass reads every script, but the cache key only covered the routes; it now covers every file the check reads.
n\<newline>pm installisnpm installto bash, which deletes a backslash-newline; the tokenizer had turned it into a space.- A redirect glued to a word —
echo x>~/.snare/gate-allow— and an interpreter writing snare's state from inside a quoted string were not seen as writes. Any write into the state directory, not only the named files, is refused. (An HMAC over approvals was considered and not done: on a single-user machine there is nowhere to keep the key that the agent cannot also read, so the boundary that holds is Claude Code's deny rules.)
Fixed — the gate's check was the full scan, and timed out
Fixed — husky was reported as malware
Fixed — "read-only" programs that can run a command
Changed — the gate decides what is safe, not what is dangerous
Fixed — upgrading did not install the gate
Fixed — the gate's own bypasses, before it shipped
- A newline before the command. The hook receives the command JSON-encoded, so a newline is the two characters
\n;cd app\nnpm installput the letterndirectly beforenpm, the word boundary failed, and every multi-line agent command skipped the gate entirely. The same for a tab. - The agent could switch it off.
SNARE_SKIP_GATE=1 npm installwas honoured when it appeared in the command — and the agent writes the command, so a README or issue that talked it into that prefix turned the gate off. An in-command bypass no longer exists. The environment variable still works, because it is set where Claude Code was launched, which the agent cannot change, and it is logged and announced every time. - Wrapping it.
sh -c "npm install",bash -lc '…',eval "…", and a heredoc fed tobash. The gate now reads inside them. - Launchers.
corepack pnpm install,volta run npm install,cross-env … npm run dev,timeout 60 npm install, and similar. The gate looks through them to the program they start. - Checking out a branch that exists only on the remote.
git checkout featcreatesfeatfromorigin/feat; the gate read "no such local ref" as "nothing to check". It now resolves the remote copy and scans it. gh pr checkoutandgh repo clone, which bring code in — a pull request's head can come from anyone's fork — and were not seen at all.- A slow scan let the command through. When Claude Code's hook timeout fires it treats the hook as failed and runs the command anyway. The gate now keeps its own shorter deadline and blocks when it is hit, and kills the whole scan tree — the first attempt killed only the direct child and left a timed-out
git cloneof the Linux kernel downloading in the background. - The installed hook command embedded snare's path in hand-written quotes; a path containing a quote would have become code that runs before every agent command. It is
%q-quoted now, and tested with a hostile path. - A clone URL could be read as an option.
--now separates them.
Fixed — the shell shield broke npm for every coding agent
Fixed while building it
- The first version called an empty clone clean. A remote whose
HEADnames a branch that does not exist clones to an empty checkout, and the gate scanned that empty directory and let it through — a verdict about code it never looked at. It now fetches and scans every branch tip when nothing is checked out.
Known limits
- The hook covers Claude Code. Codex, Cursor and Gemini get the instruction block, which they can ignore; Cursor's own shell hooks are a natural next step.
- It reads commands, not intent: a script file that runs
npm installinside it is checked only as far as the command that launches it shows. A value assembled at run time is reported as unverifiable rather than guessed. npm install <package>from a registry or a git URL runs that package's own lifecycle scripts; the gate checks the repository you are in, not the package you are adding.- The gate checks the repository's own files.
node_modulesis left tosnare scan repo, and npm itself tosnare doctor.
1.4.3
2026-10-13Fixed — --purge-history reported zero while a payload stayed in history
- The file OPENED with the whitespace wall. 424 tabs, then 28KB of code on the same line, and that was the whole file. Rule A needs real code in front of the run; rule B needs the payload to look obfuscated by the markers below. This shape satisfied neither. A line that begins with 50+ spaces or tabs, runs long and dense, and carries a hard campaign marker is now dropped on that basis alone — the whitespace prefix is the structural tell, and no honest file opens that way.
- The obfuscation was a style nothing recognised. No
_0xidentifiers, no\u00escapes, noeval(, noatob(— just long random identifiers (RvJq_qacMrKUHEOocOXlFJqDe) and an array of hex strings decoded at runtime. Two adjacent hex strings of eight or more characters inside an array literal is now an obfuscation signal in its own right.
1.4.2
2026-10-13Fixed — false positives
node -ematched two letters inside a filename. The pattern wasnode\s+.*(-e|--eval), with the flag unanchored, soatlas-enginematched and an honestPostToolUsehook runningpackages/atlas-engine/hooks/atlas-tag.tswas reported as a dropper. The same pattern sat in thepackage.jsoninstall-hook check and the API scan, where it would equally have fired onnode scripts/build-extract.js,node --experimental-vm-modules test.jsor anything else with a hyphenated word beginning ine. The flag must now stand on its own.
Fixed — and four ways to run code that nothing caught
1.4.1
2026-10-13Fixed — update took over the launcher it did not own
1.4.0
2026-10-12Three people found this campaign on their own machines and wrote up what they saw: [@kleenpulse](https://github.com/kleenpulse) #47, [@ibraheembello](https://github.com/ibraheembello) #46 and [@SamuelOshin](https://github.com/SamuelOshin) #44. Every item below is a sample or a bug they reported, with the reproduction and the measurement taken here before and after.
Fixed — false clean results
- The run scripts, not the install hooks.
package.jsonkeeps every script it had and gains"dev": "node api.js && next dev"(and the same forbuildandstart), so nothing has to be installed for the loader to run and--ignore-scriptsis irrelevant. Section 2 only ever readpreinstall/postinstall/prepare. The shape on its own is an ordinary build step —node scripts/build.js && tscis honest work — so the check fires only when the file being run is itself obfuscated: one line over 5,000 characters, a_0xidentifier array, or an assignment toglobal[..]. - The AI agent's own config is the persistence. A
SessionStarthook in.claude/settings.jsonruns a dropper every time a session opens. It survives removing the package that delivered it, npm never sees it, and the.claude/setup.mjsand.vscode/setup.mjscopies re-create each other, so removing one directory fixes nothing. Hooks are a real feature — snare ships a plugin itself — so the command decides: aprettierhook is a note,node .vscode/setup.mjsis a finding. - The obfuscated loader set
global['!'], notglobal.i. The marker list and the history cleaner both keyed onglobal\.i, so the obfuscated half of this campaign matched nothing and--purge-historyrewrote nothing while reporting success. The campaign tags4738-2and2503-2, the push-forging artefacts (temp_auto_push.bat,temp_interactive_push.bat,branch_structure.json— added to.gitignoreso they stay out ofgit status) and the Windows clipboard stealer (GetClipboardSequenceNumberunder a hidden-EncodedCommand) are IOCs now. doctornever looked at npm on Windows. It printedno npm installation found to checkon every Windows host: it knew only the Unixlib/node_moduleslayout, never%APPDATA%\npm\node_modules\npm. That is also the copy that runs — thenpmandnpxshims prefer a global-prefix install over the one bundled in Program Files, so a singlenpm i -g npmis enough. A live infection sat there whiledoctorreported clean #46. Size is checked first, because it needs no knowledge of the malware at all: measured 407 bytes here and 419 bytes on the reference host, against 503,857 and 1,332,948 bytes in the two reported infections.
Fixed — parser differentials
.vscode/tasks.jsonwas gated ongrep -q folderOpen. The verdict now comes from the parsed document and compares resolved strings, so the escape has nothing to hide behind.- An agent config that could not be parsed was silently skipped. One
//comment in.claude/settings.jsonmadejson.loadsthrow, the check exited 0, and aSessionStartdropper went unreported while the agent still ran it. Both files are read as JSONC, and a config nothing here can parse is a finding of its own — VS Code is more tolerant than any parser snare ships, so silence is the wrong default. - The history cleaner had its own byte-level pre-filter in front of the same parse, with the same result for
--purge-history. Four review passes found four variants of that one gate — an escaped"\u0074asks"key, a UTF-8 BOM, 2,000 leading spaces, and a UTF-16 file that VS Code reads and runs while the same bytes decode to mojibake as UTF-8. The lesson is the gate, not the variants: it now tests nothing but shape, on text decoded the way an editor decodes it, and every verdict is taken after parsing. All four have regression tests, as do the controls —package.json,tsconfig.json, an honesttasks.jsonand a PNG are asserted untouched, because a shape-only gate parses more blobs than a byte-matched one.
Fixed — destructive behaviour
- A 0-byte fake font was treated as a genuine font.
magic4returns nothing for a file with no bytes to read, andis_fontread that as "font", so a dropper committed as an emptypublic/fonts/fa-solid-500.woff2was skipped byfixand left in place along with the task that overwrites and runs it #44. tasks.jsonis JSONC, and that deleted people's build tasks. Real VS Code files carry//comments,/ /blocks and a trailing comma;json.loadsrejects all three, and the caller read "cannot parse" as "nothing legitimate in it" and removed the file. A project with honest build tasks plus one injectedfolderOpentask lost every task it had #44. Both cleaners now parse JSONC, and the history cleaner identifies atasks.jsonblob by content —git filter-repohands its blob callback no filename, which is why a purge rewrote payload lines but left every injected task sitting in history.fixinvented a branch calledoriginand pushed it.refs/remotes/origin/HEADshort-names to plainorigin, which passesgrep -v HEAD;${ref#origin/}then leavesoriginunchanged, so the tip-cleaning loop created a local branch of that name and force-pushed it asrefs/heads/originon the remote being remediated #44.- A failed push was reported as a success.
git push ... | tail -2discards the exit status, so a 443 timeout printed its error into the pipe while the purge reported success and the rewritten history had never left the machine #44.
Fixed — false positives
- The whitespace heuristic fired on block comments. Measured on a real 53,740-file project: 5 findings, every one of them aligned prose inside a
/ /comment, where nothing after the whitespace run can execute. That project now reports clean. A line whose first non-space character is*,//or#is skipped.
Added — forged force-pushes
Changed — being behind is now a visible risk
- A clean verdict says when it came from an outdated scanner. This is the one output that stops someone looking further, so
scan reponow prints the commit distance and what that version could not see. Cache only — no network on that path. - The notice reaches installs that are not git checkouts. The refresh returned early without a
.gitdirectory, so anyone who downloaded a zip or tarball was never told anything at all. The version in the remote'sbin/snareis recorded as well, which works without a repository. - The notice says what is missing, not just that something is. It named 1.1.0 forever; it now names the release that fixed the false-clean bug the running version still has.
Changed — the repository moved to github.com/nexlabz/snare
- The update channel, the reusable CI workflow, the plugin manifests, the skill and the field-guide links all name the canonical path.
- A stale
originis repointed wherever snare talks to its own remote — not only insnare update, but in the background refresh that feeds the "you are behind" notice, which reaches the network first. A notice sourced from a hijacked remote would be worse than no notice at all.snare updatesays so out loud; the refresh does it silently, because it must never write to the terminal. It runs before the local-modifications guard, so a checkout with edits still gets repointed, and matches case-insensitively because GitHub treats owner names that way andclone github.com/avioflagos/snareproduces a working remote that a case-sensitive match would miss. Anssh://remote on the old path is moved to the canonical HTTPS URL. - The site moved with it.
avioflagos.github.io/snarenow returns 404 and the field guide is at nexlabz.github.io/snare — which also means every canonical URL,og:urland sitemap entry on the published site had been pointing at a dead host.
Fixed — the website could not drift, but it did
Not fixed
--purge-historystill cannot remove a fake font from history by name:git filter-repo's blob callback is given no filename, and a font-named payload has no reliable content signature. InjectedfolderOpentasks are handled by content; for a font, remove the path explicitly:git filter-repo --invert-paths --path public/fonts/<file>.- How this family reaches a machine in the first place is still unidentified. #47 documents npm's own
cli.jsrewritten on disk, but not what put it there.
1.3.4
2026-10-02Fixed — scan repo did not finish on a real project
- The IOC set is split by whether it needs a regex engine at all. 42 of the 53 patterns are plain strings once backslash-escapes are removed —
global\['_t_s'\]andsetup_bun\.jslook like regexes but are literals — and those go through-F, which is Aho-Corasick and indifferent to how many patterns there are. Only 11 genuinely need-E. - ripgrep is used when it is installed, falling back to grep when it is not. Measured on the same tree, a literal pass over
node_modules: BSD grep >120s, ripgrep 1.6s.
Known, not fixed
1.3.3
2026-10-02Fixed — the guard cost about a third of a core to do nothing
- The network scan walked every open socket in bash, testing each C2 IP with
case. That is 1.75s a call. A singlegrep -F -fpass does the same filtering in C and normally matches nothing, so the loop body — and its per-connectionsnare_net_pid/snare_ps_cmdforks — is never entered. - The process scan read every process on the machine. Only interpreters can execute an inline payload, which the
casealready enforced exactly, so agrep -Epre-filter now drops the other few hundred before bash sees one. - The C2 IP list was re-resolved inside the per-connection loop, costing a fork for every open socket on each scan.
Changed — the two checks now tick at different rates
Also
91.218.183.174added to the kill pattern, not just the scan IOCs. The guard's cmdline pattern is separate fromiocs.txtand had been missed.- Verified end to end rather than by inspection: the live daemon detected and SIGKILLed
node -e "global['_t_u']=..."within 3 seconds, with evidence written, after each change.
1.3.2
2026-10-02Fixed — false positives, measured on real repos
_H2,_t_s,_t_uwere matched bare.bwip-jsnames generated variables_H0.._H4and@edge-runtime/primitivescontainsPAUSED_H2_UPGRADE, so any project with those dependencies lit up. The loader always sets these as globals, so they are now anchored (global['_H2'],_global._H2, …). Checked against the plaintext sample: the anchored forms hit it exactly as the bare token did, so no detection was traded away.changeCountwas matched bare.NSPasteboard.changeCountis the real clipboard-stealer signal, but the bare word matched vitest's diff printer (changeCounts.a) and fired on anything with a test runner. Now anchored to the pasteboard API.generalPasteboardandNSPasteboardwere added on their own — Apple-specific, near-zero false positive — so a stealer dropped as a plain.jswith noosascripton the line is caught, which it was not before. Net: fewer false positives and better coverage.- The whitespace heuristic read generated output.
node_moduleswas excluded but.next/was not, and a bundler legitimately emits long whitespace runs — three findings per build in a clean repo. Now also skips.nuxt,.turbo,.svelte-kit,.output,.vercel,.cache,coverage.dist/andbuild/are deliberately still scanned: those get committed, and are a real place to hide a payload. - The new stray-asset check flagged
.gitkeep. Empty files and the usual placeholders are now skipped — a task has to execute something, and an empty file cannot be it.
1.3.1
2026-10-02Fixed — scan github reported clean on an infected repo
producer | grep -q Xcould report NO MATCH when X was present.grep -qexits on the first match, which SIGPIPEs whatever is still writing.bin/snareruns underset -o pipefail, so that 141 became the pipeline's status and every caller read it as "not found". The failure is size-dependent, which is why it survived: it only bites once the producer has more to write than the pipe buffer holds, so small fixtures always passed.
1.3.0
2026-10-02Fixed — scanner reported clean on an infected repo
- A payload wearing an extension snare did not glob was invisible. The 2026-10 sample shipped as
public/fonts/fa-solid-300.llf— about 2000 tab characters followed by an obfuscated loader. Section 3 globs only.woff2/.woff/.ttf/.otf, and section 4 only.js/.mjs/.cjs/.ts, so the file was read by neither and the scan printed "no code hidden past whitespace". The same campaign's.woff2drop was caught immediately, so the extension was the whole evasion. Section 3 now also reports any file in afonts/,assets/orstatic/directory whose extension is not an asset or script type, and previews it with whitespace squeezed. Known residual gap, recorded in the code:.eotand.svgstill pass, because they are real asset types here and their magic is not verified.
Added — the guard follows a rotating C2
snare c2resolves the live C2 from the blockchain dead drop the loader itself reads, so a shipped IP list no longer goes stale by design. The loader takes the latest transaction from a fixed Ethereum sender and reads the first 8 bytes of that transaction'stoaddress as two IPv4 addresses; the operator rotates by signing a new transaction. snare reads the same drop and feeds it into the guard's kill list.91.218.183.174— live at the time of writing, and absent from the previous hardcoded list — is now covered both dynamically and as a builtin. The lookup is read-only against a public explorer, refreshes at most once a day in a detached process, and the 1-second guard loop only ever reads a cached file, so the hot path stays fork-free.snare_pypicks python3 / python /py -3, for Git Bash hosts that only ship the launcher.- New IOCs for the 2026-10 campaign: the C2, both staging paths, the sender address, the
helloipbotdead-drop marker, the second XOR key andNONCE_FANOUT. Patterns generic enough to fire on ordinary web3 code were deliberately left out. - Five selftest checks covering the decoder, the kill list without a cache, and that the stray-asset check flags the
.llfshape while leaving a real font and an ordinary script in an asset directory alone. 32 checks total.
1.2.0
2026-09-10Remediation could delete the file it was meant to clean. If you ran snare fix --push on a release before this one, check the repository: a build config may have been removed rather than repaired.
Fixed — destructive behaviour
fixdeleted the build config instead of cleaning it. The delete pass removed any file matching an IOC string, and a payload appended topostcss.config.mjsis an IOC string — so the file was gone before the strip pass further down the same function could reach it. It pushed a commit that removed the malware and the project's build with it. Stripping runs first now, and a file the project needs is cleaned and kept, never deleted. Reported and fixed by @phoenixdahdev. #39.vscode/tasks.jsonwas deleted whole, losing honest build tasks along with the injectedfolderOpenone. Only the malicious task goes now. #39- A payload on a line of its own was never removed. Both cleaners keyed on the one-line signature. The new rule is gated narrowly — long, minified in shape, a hard campaign marker and obfuscated code — because dropping a whole line on a weaker signal is itself destructive. #39
Fixed — detection and coverage
- Worm artifacts and fake assets are now found by filename and magic bytes during remediation, not only during scanning:
scanreported them andfixwalked straight past. #39 - npm's own
lib/cli.jsis checked. A live host was found with ~1.4MB appended to it after 200 spaces, so everynpminvocation ran the loader —--ignore-scriptsdoes not help, because it is the package manager itself and not a package script. New campaignA8-4893-2, with two C2 addresses and a socket.io RAT held by a crontab@rebootline. #39 - The doctor fixture path is normalised, so the npm check no longer failed on macOS while passing on Linux — the suite reported "detection is broken" for a detector that was working correctly. #39
Fixed — contribution and platform
- No pull request from a fork could ever pass CI.
ci.ymlpassedgithub.head_refto the reusable workflow, which then tried to check that ref out of this repository — a fork's branch does not exist here, so checkout failed three times before a single check ran. The first outside contribution hit it, and the failure looked like the contributor's fault rather than ours. It usesrefs/pull/N/headnow. #40 - Windows
guard installfailed with no explanation.schtasks.exeneeds a Windows path and was handed an MSYS one, and every error was discarded. It now converts the path, prints the real error, and falls back to a Startup entry that needs no administrator rights. #32 - snare flagged its own plugin manifest and skill, which quote the malware's keywords because they describe it. #38
Added
snare respond— one guided clean-up instead of nine commands, in the order that actually works: rotate credentials, clean the machine you push from, then the repositories, then tell your collaborators. Resumable, asks before every action, refuses to run unattended. #33, #34- A passive update notice. Every command now says, at most once a day and in one line, when the installation is behind. It never blocks — the check is cached and refreshed off the command path — sends nothing, and is off with
SNARE_NO_UPDATE_CHECK=1. The people who most need the fixes are the ones who cloned once and never thought about it again. #41 - snare ships as a Claude Code plugin, so a coding agent can find and run it. #38
- An incident-response walkthrough on the site, and a copy-paste prompt for people who would rather have an AI assistant do the work — written to forbid the assistant from running anything destructive without asking. #36
Changed
- Post-scan guidance leads with rotating credentials and cleaning your machine, not with
snare fix. Cleaning repositories first is wasted work while the machine that pushes to them is still infected. #31 - The notify templates lead with rotation too, and carry the full list including the clipboard and the Actions workflow that keeps exfiltrating after the dropper is gone. #32
snare versionno longer runs a live fetch to decide whether to nudge; a one-line command took about five seconds on a slow link. #41- The site is a multi-page field guide with corrected document semantics, a crawler policy naming 29 search and AI crawlers,
llms.txt, and structured data on every page. #33, #35, #37
Fixed — snare doctor was macOS-only, and said so in neither place
- The guard check asked
launchctlon every platform. On Linux the guard is a systemd user unit and on Windows a scheduled task, so doctor reported "guard not installed" no matter what it was actually doing. It now asks the right supervisor per platform, distinguishes installed but stopped from not installed, and counts a hand-startedsnare guard runas running. - "Persistence spots" checked three macOS paths that do not exist on Linux, found nothing in them, and printed
emptythree times — a clean bill of health from having looked nowhere. This is the same false-clean shape as the scanner bugs in 1.1.0, in the one command whose entire job is answering "is this machine compromised". It now checks the locations that platform actually uses — systemd user units, autostart,/etc/cron.d— and lists what it finds rather than only counting. The user crontab is checked everywhere, which is where the RAT on the reference host lived. - Added a Node and npm integrity check, because the package manager is a file like any other and nothing was looking at it. It finds every npm on the system without running npm to ask — running npm was the thing that executed the loader — and flags
lib/*.jscarrying a hidden payload or a known IOC. It also reports~/.node_modules(a legacy global resolution path, rarely deliberate, and where the RAT's dependencies were planted) and aNODE_OPTIONS--require/--importinjection in the environment or a shell profile. cmd_doctormoved out ofbin/snareintolib/doctor.sh, like every other command.snare selftestcovers all of it: 3 checks asserting a patched npm is flagged, an intact one is not, andguard_stateanswers for the host platform. 27 in total.
Added — where a detection came from
snare guard origins— an append-only provenance log ($SNARE_HOME/logs/origins.log) recording, per detection: the file responsible, the working directory, the full parent chain, and a link to the evidence dump. It ends with the most frequent origins, because a source that keeps re-launching shows up as a count.- The guard now walks the parent chain at detection time and records it.
node -e <payload>names no file on disk; its parent almost always does, and that parent is the thing that needs cleaning. On the host above this resolves in one line to.../node_modules/npm/lib/cli.js. - Ancestry is captured before anything else in the handler. These processes exit in well under a second, and a parent that has already gone is a dead end.
- A dropper that deletes itself is still reported, as
<path> (no longer on disk). Reporting a path that is gone beats reporting nothing, which is what the first version did. - Evidence dumps gain an
originsection and the parent chain. They do not record the process environment: on a machine being investigated for credential theft, writing every environment variable into a log file is its own leak.
Added — IOCs from a live host, 2026-09-06..08
193.247.144.38— the EtherHiding loader's C2. Not the address in the existing IOC list; the operator rotates it from the blockchain, which is the whole point of that design and the reason blocking one IP is not a fix.194.11.226.41— a second, separate implant: an obfuscated socket.io RAT dropped to~/.local/share/<random>.js, held by acrontab @rebootline, writing to a decoyVSCodeUpdater.log, with its dependencies (axios,socket.io-client) installed into~/.node_modulesso Node's legacy global resolution would find them. Its command line contained no loader string at all — the guard would not have caught it. It is matched now by the shape of its argument:--token "http://IP:PORT|SECRET"./RS260605/,/M260630A/— markers the payload writes into what it patches, and the ones that identify a patched file at rest.- The npm-patch route matters on its own:
ignore-scripts=truewas set on that host and did not help, because the package manager itself was modified rather than any package's install script.
Added — one command from picking to fixing
snare fix --pickchooses organisations from the same listscan orgsprints, scans them, and remediates what it finds, in one pass. It is a dry run unless you add--push;--purge-history --pushalso erases the payload from all history.--owner a,bis the same thing without the prompt, for a script or a timer. The destructive flags stay on the command line rather than hiding inside a friendlier-sounding verb — force-pushing rewritten history across an organisation should be legible in your shell history.- With
--purge-historyit checks forgit-filter-repobefore the picker and the scan. The check previously lived inside the per-repository purge, so you chose an organisation, waited out a full scan and a clone, and only then found out it could not proceed. - It repeats the rotate-first warning at the last point before anything is written to a repository other people depend on.
Added — choosing what to scan
snare scan orgslists every account and organisation your token can reach, largest first, with how many repositories each holds and how many of those are private. Until now the only way to narrow a scan was to already know an organisation's exact login; the only alternative was scanning everything, which on a real account is 22 organisations and around 950 repositories — several thousand API calls. Individual accounts you hold a single collaborator bit on are collapsed into a count;--alllists them.snare scan github --pickshows that same list and lets you choose from it —2,2,5,1-4,2,7-9orall. It refuses to run without a terminal and tells you the non-interactive equivalent instead.snare scan github --ownertakes a list:--owner a,b,c. This is what--pickresolves to, so a selection made once can be re-run from a script or a timer.- The repository counts are the number
--ownerwill actually scan, taken from the same call the scan uses. Deriving them from the affiliation endpoint was cheaper but wrong — it counts only repositories you are directly attached to, so one organisation listed as holding 1 repository actually held 5, and another listed as 4 held 123. Counting runs in bounded parallel batches (SNARE_COUNT_JOBS, default 8); sequentially it took 41 seconds.
Fixed — scanning
- An unrecognised flag to
scan githubwas ignored in silence.--all-branchinstead of--all-branches, or any other typo, quietly scanned every repository you can reach rather than the narrower thing you asked for. Unknown flags are now an error, as they already were inreportandrespond. - The local
scan repolost its primary detector on Debian and Ubuntu. The structural test — code, a long whitespace run, then more code, the signal that catches a variant carrying none of the IOC strings — was anawkinterval expression,{50,}.mawk, the defaultawkon Debian and Ubuntu, accepts that syntax and matches nothing, so the test never fired there: a repository whose only infection was a payload padded past a run of whitespace came backNo IOC matches, exit 0. It reported clean on an infected repository, which is the failure this changelog lists first. The test now runs throughgrep -E, the same enginescan github,fixandhookalready use for that pattern, and theselftestsample it is checked against no longer carries an IOC string of its own — it did, so the test passed through the working-tree IOC grep and never exercised the structural test at all. - The by-hand long-line command on the inspection page never looked at the file types the payload lands in. It globbed
.js, sopostcss.config.mjsandtailwind.config.mjs— the file every live infection of this campaign sits in — were not passed toawkat all. It now also matches.mjsand*.cjs, the same setscan repowalks.
Added — test coverage
snare selftestnow covers remediation, not just detection: 14 checks asserting that a build config andpackage.jsonsurvive with the payload gone and still parse, that a dropper, a fake font and a worm artifact are removed, and that a genuine font, a clean source file and an honest build task are left alone. 24 checks in total.
1.1.0
2026-08-29Everything since the initial release. If you installed snare before this, update before you trust a clean result — several defects below made the scanner report clean on genuinely infected repositories.
Fixed — false clean results
- The working-tree scan never ran.
"${EXCL[@]}"on an empty array is fatal underset -uon bash 3.2, which is what macOS ships. The substitution died, the output came back empty, and theelsebranch printedclean. Only snare's own repository populated that array, so it was the only repository section 1 ever actually scanned. #2, #3 - The API scan never looked at build configs.
postcss.config.andnext.config.were never fetched and the hidden-payload heuristic was never applied, so one of the two execution routes documented in the README could not be detected over the API. A real infection survived a clean 127-repository sweep this way. #2, #3 - The heuristic keyed on raw line length.
length > 1500missed a 664-char payload. The structural signature — code, a run of 50+ whitespace, then more code — is now the primary test and catches a payload of any length. #2, #3 fix --purge-historycould force-push while removing nothing. The blob callback only rewrote blobs containing a hardcoded marker, and the completeness check grepped for one of those same markers. On an obfuscated variant it stripped nothing, verified nothing, reported success, and pushed. #2, #3- Missing
xxdmade every genuine font look like a payload.xxdships with vim and is absent from minimal Linux images and some Git Bash installs. When it was missing the magic-byte read returned empty and fell through to the failure case — and in the pre-push hook that blocked every push from any repository containing fonts. Now uses POSIXod, and an unreadable magic counts as cannot tell rather than an accusation. #29
Fixed — evasion and detection
- The guard could be evaded by putting
snarein a process argv. It skipped any process whose command line contained the substringsnareanywhere, so a payload hid from it by being namednode /tmp/snare-helper.js. Matching is now against the real install path. #17 - Every legitimate
.ttfwas flagged. The TrueType magic case compared against$'\x00\x01\x00\x00', but bash cannot hold NUL bytes, so that branch could never match. #2, #3 - Long minified files were reported as findings. Raw line length is now an informational note, not a finding — a scanner nobody reads protects nobody. #10
- Actions workflow persistence is now detected by content (whole-secret-context dumps, known exfiltration endpoints), not just by filename. #22, #23
Fixed — platform and upgrade
- Install was broken on Windows Git Bash. The installer fell back to copying when
ln -sfailed, and a copy cannot resolve its own library directory. It now writes a launcher stub instead, and verifies the symlink actually exists rather than trustingln's exit status. #26 - An old shell shield kept a silent bypass. An early version gated on
[ -t 1 ], sonpm install > log 2>&1skipped the check entirely — andshield installrefused to touch an existing block. It now detects an outdated block and refreshes it in place. #27 - Hooks written before the version marker became invisible to
status,uninstallandinstall. Legacy hooks are now recognised and upgraded in place. #27 snare updatenow reports what it could not update for you — a running guard, a stale shield, an old hook — instead of leaving stale copies running. #27mktemptemplates are GNU-safe; withoutXXXXXXthe scanner recorded no findings at all on Linux. #16shasumis no longer assumed present; without it every repository collided onto a single baseline key. #29- Missing library files now produce one actionable message instead of thirteen cryptic errors.
SNARE_ROOToverrides the location. #26
Added
snare selftest— asserts known-bad samples are flagged and known-good ones are not, including withxxdandshasumunavailable. Ten checks. #5, #29snare update— self-update, fast-forward only, refuses to discard local edits without--force. #4snare shield— scans beforenpm/pnpm/yarn/bun/npxandgit clonecan execute anything. #18snare hook— pre-push (and optional pre-commit) block, the only feature that prevents spread rather than detecting it afterwards. #9snare ci— writes a GitHub Actions workflow; a required status check is the closest thing to rejecting a push, since GitHub.com has no pre-receive hooks. #15snare rotate— what to revoke and in what order, plus a GitHub audit for the repositories and secret-exporting workflows this family leaves behind. npm write tokens first, because a stolen one is how a single machine becomes a supply-chain event. #22snare report/snare schedule— timestamped reports,--json, meaningful exit codes, and an opt-in timer. No email, deliberately. #7snare baseline— accept known findings so scheduled scans surface only what is new. Accepting a finding silences it; it does not make it safe. #8snare fix --all— dry run by default, and the confirmation requires typing the repository count. #6
Changed
snare update --checkcompares commits, not version strings. The version stood still for 33 commits, so a user 14 commits behind — carrying the font bug, the shield bypass and broken hook detection — was told "up to date".snare versionreports the commit alongside the version.snare ciwith no subcommand shows status instead of attempting an install.snare helpdocuments every dispatched command; CI fails if one is missing.require_ghoffers to rungh auth loginwhen a human is present, and prints a platform-specific install command whenghis absent. Scripts, hooks, timers and CI keep the old message and exit code.- shellcheck runs on every push; it found the guard evasion hole that eight rounds of manual review had missed.
1.0.0
2026-08-25Initial release. Guard, repository and GitHub scanning, fix, notify.
Full commit history: github.com/nexlabz/snare/commits/main. These notes are generated from CHANGELOG.md, so this page cannot drift from the release it describes.