Changelog

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.

check and update
snare version
snare update --check
snare update && snare selftest

1.6.0

2026-10-25 Current

Added — known-malicious package versions, in every ecosystem

  • A known-malware list, the same for everyone. .github/workflows/intel.yml condenses [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 the intel release'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, plus node_modules - requirements*.txt, poetry.lock, uv.lock, pdm.lock, pylock.toml and Pipfile.lock, plus the site-packages of every virtualenv in the project - Cargo.lock, go.sum, Gemfile.lock, composer.lock and NuGet's packages.lock.json - with --system, also the user's Python, pipx, global npm and cargo install
  • snare deps fix asks 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 to snare rotate when something malicious was actually installed. A package whose every version is malicious gets a warning about lookalike names. It is person-only, like snare gate allow.
  • The gate stops installs of a listed version. This covers npm/pnpm/ yarn/bun install/add, npx, dlx, pip/pip3/python -m pip, uv add, uv pip, uvx, pipx, poetry/pdm/rye add, cargo add and install, go get/install/run, gem install and composer require. It also checks a requirements file named with -r, and a repository's lockfiles on npm 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 with snare gate allow, tied to content as always.
  • Files other languages run on their own are now repository routes, like package.json scripts: - setup.py (on install), conftest.py (on test), build.rs (on build), sitecustomize.py and usercustomize.py (at every Python start), and .pth files (Python runs their import lines at every start): flagged when a line downloads, or decodes and runs, code. - //go:generate lines that fetch or pipe into a shell. - An ordinary setup.py that calls cmake through subprocess, a build.rs that compiles C, stringer and coverage's .pth are left alone. All 77 local repositories in the false-alarm sweep stay clean.
  • snare doctor shows the list's age and size, snare update refreshes 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 allow now 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 printed snare gate allow 'main in /path/to/repo', which is not a path.
  • snare respond re-checks incoming blocks, shows each file and line, and offers allow or leave, with a pointer to snare fix if 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-14

Added — the gate: scan before code comes in or runs

  • git clone clones to a quarantine directory first and scans that. The clone you asked for only happens if it is clean.
  • git pull, merge, rebase, checkout, switch run git 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, turbo scan 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 PreToolUse hook 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.md and Gemini's GEMINI.md — only for tools that are installed. Advisory, for agents with no hook mechanism: run snare 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 own CLAUDE.md, AGENTS.md, GEMINI.md and Copilot instructions, plus a project hook that calls snare by 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 nothing git status sees. Findings are never cached.
  • snare scan repo gained SNARE_FAST, which the gate uses: the repository's own files and the forged-push check, without node_modules or the full history walk. snare scan repo itself 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 allowed lists approvals; snare gate forget withdraws 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 update each show what would change and ask once ([Y/n]). They read the answer from the terminal, so curl … | bash can 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 install is the yes, typed by hand.
  • snare agents sync only 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.fsmonitor and the rest), the same set the gate uses.
  • snare respond approves and cleans, so an agent is refused it, the same as snare gate allow. snare respond --status stays 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.sh that 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 … | bash runs 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-pack run a program for a local remote; git config writes (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 configured core.hooksPath) fires on the next commit, and is now read like any other route.
  • bash /path/to/snare gate allow is 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 install now adds Claude Code permission rules denying the agent Edit and Write on ~/.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 other SNARE_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-tool file, 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 install is npm install to 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 install put the letter n directly before npm, 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 install was 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 to bash. 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 feat creates feat from origin/feat; the gate read "no such local ref" as "nothing to check". It now resolves the remote copy and scans it.
  • gh pr checkout and gh 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 clone of 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 HEAD names 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 install inside 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_modules is left to snare scan repo, and npm itself to snare doctor.

1.4.3

2026-10-13

Fixed — --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 _0x identifiers, no \u00 escapes, no eval(, no atob( — 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-13

Fixed — false positives

  • node -e matched two letters inside a filename. The pattern was node\s+.*(-e|--eval), with the flag unanchored, so atlas-engine matched and an honest PostToolUse hook running packages/atlas-engine/hooks/atlas-tag.ts was reported as a dropper. The same pattern sat in the package.json install-hook check and the API scan, where it would equally have fired on node scripts/build-extract.js, node --experimental-vm-modules test.js or anything else with a hyphenated word beginning in e. The flag must now stand on its own.

Fixed — and four ways to run code that nothing caught

1.4.1

2026-10-13

Fixed — update took over the launcher it did not own

1.4.0

2026-10-12

Three 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.json keeps every script it had and gains "dev": "node api.js && next dev" (and the same for build and start), so nothing has to be installed for the loader to run and --ignore-scripts is irrelevant. Section 2 only ever read preinstall/postinstall/prepare. The shape on its own is an ordinary build step — node scripts/build.js && tsc is honest work — so the check fires only when the file being run is itself obfuscated: one line over 5,000 characters, a _0x identifier array, or an assignment to global[..].
  • The AI agent's own config is the persistence. A SessionStart hook in .claude/settings.json runs a dropper every time a session opens. It survives removing the package that delivered it, npm never sees it, and the .claude/setup.mjs and .vscode/setup.mjs copies re-create each other, so removing one directory fixes nothing. Hooks are a real feature — snare ships a plugin itself — so the command decides: a prettier hook is a note, node .vscode/setup.mjs is a finding.
  • The obfuscated loader set global['!'], not global.i. The marker list and the history cleaner both keyed on global\.i, so the obfuscated half of this campaign matched nothing and --purge-history rewrote nothing while reporting success. The campaign tags 4738-2 and 2503-2, the push-forging artefacts (temp_auto_push.bat, temp_interactive_push.bat, branch_structure.json — added to .gitignore so they stay out of git status) and the Windows clipboard stealer (GetClipboardSequenceNumber under a hidden -EncodedCommand) are IOCs now.
  • doctor never looked at npm on Windows. It printed no npm installation found to check on every Windows host: it knew only the Unix lib/node_modules layout, never %APPDATA%\npm\node_modules\npm. That is also the copy that runs — the npm and npx shims prefer a global-prefix install over the one bundled in Program Files, so a single npm i -g npm is enough. A live infection sat there while doctor reported 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.json was gated on grep -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.json made json.loads throw, the check exited 0, and a SessionStart dropper 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 honest tasks.json and 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. magic4 returns nothing for a file with no bytes to read, and is_font read that as "font", so a dropper committed as an empty public/fonts/fa-solid-500.woff2 was skipped by fix and left in place along with the task that overwrites and runs it #44.
  • tasks.json is JSONC, and that deleted people's build tasks. Real VS Code files carry // comments, / / blocks and a trailing comma; json.loads rejects 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 injected folderOpen task lost every task it had #44. Both cleaners now parse JSONC, and the history cleaner identifies a tasks.json blob by content — git filter-repo hands its blob callback no filename, which is why a purge rewrote payload lines but left every injected task sitting in history.
  • fix invented a branch called origin and pushed it. refs/remotes/origin/HEAD short-names to plain origin, which passes grep -v HEAD; ${ref#origin/} then leaves origin unchanged, so the tip-cleaning loop created a local branch of that name and force-pushed it as refs/heads/origin on the remote being remediated #44.
  • A failed push was reported as a success. git push ... | tail -2 discards 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 repo now 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 .git directory, so anyone who downloaded a zip or tarball was never told anything at all. The version in the remote's bin/snare is 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 origin is repointed wherever snare talks to its own remote — not only in snare 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 update says 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 and clone github.com/avioflagos/snare produces a working remote that a case-sensitive match would miss. An ssh:// remote on the old path is moved to the canonical HTTPS URL.
  • The site moved with it. avioflagos.github.io/snare now returns 404 and the field guide is at nexlabz.github.io/snare — which also means every canonical URL, og:url and 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-history still 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. Injected folderOpen tasks 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.js rewritten on disk, but not what put it there.

1.3.4

2026-10-02

Fixed — 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'\] and setup_bun\.js look 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-02

Fixed — 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 single grep -F -f pass does the same filtering in C and normally matches nothing, so the loop body — and its per-connection snare_net_pid / snare_ps_cmd forks — is never entered.
  • The process scan read every process on the machine. Only interpreters can execute an inline payload, which the case already enforced exactly, so a grep -E pre-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.174 added to the kill pattern, not just the scan IOCs. The guard's cmdline pattern is separate from iocs.txt and 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-02

Fixed — false positives, measured on real repos

  • _H2, _t_s, _t_u were matched bare. bwip-js names generated variables _H0.._H4 and @edge-runtime/primitives contains PAUSED_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.
  • changeCount was matched bare. NSPasteboard.changeCount is 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. generalPasteboard and NSPasteboard were added on their own — Apple-specific, near-zero false positive — so a stealer dropped as a plain .js with no osascript on the line is caught, which it was not before. Net: fewer false positives and better coverage.
  • The whitespace heuristic read generated output. node_modules was 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/ and build/ 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-02

Fixed — scan github reported clean on an infected repo

  • producer | grep -q X could report NO MATCH when X was present. grep -q exits on the first match, which SIGPIPEs whatever is still writing. bin/snare runs under set -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-02

Fixed — 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 .woff2 drop was caught immediately, so the extension was the whole evasion. Section 3 now also reports any file in a fonts/, assets/ or static/ directory whose extension is not an asset or script type, and previews it with whitespace squeezed. Known residual gap, recorded in the code: .eot and .svg still pass, because they are real asset types here and their magic is not verified.

Added — the guard follows a rotating C2

  • snare c2 resolves 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's to address 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_py picks 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 helloipbot dead-drop marker, the second XOR key and NONCE_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 .llf shape while leaving a real font and an ordinary script in an asset directory alone. 32 checks total.

1.2.0

2026-09-10

Remediation 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

  • fix deleted the build config instead of cleaning it. The delete pass removed any file matching an IOC string, and a payload appended to postcss.config.mjs is 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.json was deleted whole, losing honest build tasks along with the injected folderOpen one. 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: scan reported them and fix walked straight past. #39
  • npm's own lib/cli.js is checked. A live host was found with ~1.4MB appended to it after 200 spaces, so every npm invocation ran the loader — --ignore-scripts does not help, because it is the package manager itself and not a package script. New campaign A8-4893-2, with two C2 addresses and a socket.io RAT held by a crontab @reboot line. #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.yml passed github.head_ref to 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 uses refs/pull/N/head now. #40
  • Windows guard install failed with no explanation. schtasks.exe needs 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 version no 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 launchctl on 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-started snare guard run as running.
  • "Persistence spots" checked three macOS paths that do not exist on Linux, found nothing in them, and printed empty three 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/*.js carrying 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 a NODE_OPTIONS --require/--import injection in the environment or a shell profile.
  • cmd_doctor moved out of bin/snare into lib/doctor.sh, like every other command.
  • snare selftest covers all of it: 3 checks asserting a patched npm is flagged, an intact one is not, and guard_state answers 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 origin section 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 a crontab @reboot line, writing to a decoy VSCodeUpdater.log, with its dependencies (axios, socket.io-client) installed into ~/.node_modules so 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=true was 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 --pick chooses organisations from the same list scan orgs prints, scans them, and remediates what it finds, in one pass. It is a dry run unless you add --push; --purge-history --push also erases the payload from all history. --owner a,b is 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-history it checks for git-filter-repo before 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 orgs lists 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; --all lists them.
  • snare scan github --pick shows that same list and lets you choose from it — 2, 2,5, 1-4, 2,7-9 or all. It refuses to run without a terminal and tells you the non-interactive equivalent instead.
  • snare scan github --owner takes a list: --owner a,b,c. This is what --pick resolves to, so a selection made once can be re-run from a script or a timer.
  • The repository counts are the number --owner will 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 github was ignored in silence. --all-branch instead 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 in report and respond.
  • The local scan repo lost 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 an awk interval expression, {50,}. mawk, the default awk on 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 back No IOC matches, exit 0. It reported clean on an infected repository, which is the failure this changelog lists first. The test now runs through grep -E, the same engine scan github, fix and hook already use for that pattern, and the selftest sample 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, so postcss.config.mjs and tailwind.config.mjs — the file every live infection of this campaign sits in — were not passed to awk at all. It now also matches .mjs and *.cjs, the same set scan repo walks.

Added — test coverage

  • snare selftest now covers remediation, not just detection: 14 checks asserting that a build config and package.json survive 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-29

Everything 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 under set -u on bash 3.2, which is what macOS ships. The substitution died, the output came back empty, and the else branch printed clean. 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. and next.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 > 1500 missed 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-history could 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 xxd made every genuine font look like a payload. xxd ships 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 POSIX od, and an unreadable magic counts as cannot tell rather than an accusation. #29

Fixed — evasion and detection

  • The guard could be evaded by putting snare in a process argv. It skipped any process whose command line contained the substring snare anywhere, so a payload hid from it by being named node /tmp/snare-helper.js. Matching is now against the real install path. #17
  • Every legitimate .ttf was 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 -s failed, and a copy cannot resolve its own library directory. It now writes a launcher stub instead, and verifies the symlink actually exists rather than trusting ln's exit status. #26
  • An old shell shield kept a silent bypass. An early version gated on [ -t 1 ], so npm install > log 2>&1 skipped the check entirely — and shield install refused 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, uninstall and install. Legacy hooks are now recognised and upgraded in place. #27
  • snare update now reports what it could not update for you — a running guard, a stale shield, an old hook — instead of leaving stale copies running. #27
  • mktemp templates are GNU-safe; without XXXXXX the scanner recorded no findings at all on Linux. #16
  • shasum is 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_ROOT overrides the location. #26

Added

  • snare selftest — asserts known-bad samples are flagged and known-good ones are not, including with xxd and shasum unavailable. Ten checks. #5, #29
  • snare update — self-update, fast-forward only, refuses to discard local edits without --force. #4
  • snare shield — scans before npm/pnpm/yarn/bun/npx and git clone can execute anything. #18
  • snare hook — pre-push (and optional pre-commit) block, the only feature that prevents spread rather than detecting it afterwards. #9
  • snare 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. #15
  • snare 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. #22
  • snare report / snare schedule — timestamped reports, --json, meaningful exit codes, and an opt-in timer. No email, deliberately. #7
  • snare baseline — accept known findings so scheduled scans surface only what is new. Accepting a finding silences it; it does not make it safe. #8
  • snare fix --all — dry run by default, and the confirmation requires typing the repository count. #6

Changed

  • snare update --check compares 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 version reports the commit alongside the version.
  • snare ci with no subcommand shows status instead of attempting an install.
  • snare help documents every dispatched command; CI fails if one is missing.
  • require_gh offers to run gh auth login when a human is present, and prints a platform-specific install command when gh is 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-25

Initial 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.