
English | Português
Standalone scanner for the keyv/cacheable supply-chain incident ("Shai-Hulud: Here We Go Again", Aug 4 2026) — 440+ npm packages compromised by a self-propagating worm that steals cloud/CI credentials and plants persistence with a dead-man's switch.
Detects, in minutes and without installing anything:
package-lock.json, npm-shrinkwrap.json, yarn.lock (v1 and Berry), pnpm-lock.yaml and bun.lock — including transitive dependencies, with the full chain (e.g. eslint → file-entry-cache → flat-cache → [email protected]);node_modules (name + SHA-256 hash of the known artifacts);SUSPECT, never confirmed without a hash;.claude/settings.json and .vscode/tasks.json, temporary artifacts (bun-dl-*);keyv/cacheable families was compromised; the attacker published new versions with a "preinstall": "node setup.mjs" hook — code that runs before the package is installed, with the privileges of whoever ran npm install.setup.mjs downloads the Bun runtime from GitHub and runs the payload in it — evasion against tools that only monitor node processes.Math_Symbol.js (~728 KB, obfuscated) steals credentials: AWS instance metadata, AWS/GCP/Azure keys, Vault tokens, Kubernetes service accounts, GitHub Actions secrets, npm tokens, plus a generic regex sweep for private keys and bearer tokens on disk.npm install/npm ci with lifecycle scripts enabled since 2026-08-04 09:35 UTC. With --ignore-scripts, the hook did not run..claude/settings.json (SessionStart) and .vscode/tasks.json (folderOpen) that run the loader when the cloned folder is opened — no npm install, nothing installed. This includes people who cloned the repo to investigate the incident and AI coding agents that opened the directory — one of the first public cases of AI-agent hooks (.claude/) used as a supply-chain vector.The implant installs a "watcher" (gh-token-monitor) kept alive by a LaunchAgent (macOS) or a systemd user service + loginctl enable-linger (Linux). Every 60 seconds it validates the stolen GitHub token against the API. While the token works, nothing happens. When the response turns 4xx — i.e. the moment you revoke the token — it runs, via eval, the contents of ~/.config/gh-token-monitor/handler: an arbitrary command defined remotely by the attacker. Public analysis does not know what it contains — it could be data destruction, re-implant, ransomware, or nothing. The risk is not assessable; that is why the response order is absolute.
Three properties that change the response:
host mode.[email protected] shipped with a passing SLSA attestation. Provenance attests to build integrity, not source: the legitimate workflow compiled already-trojanized code.package.json (the preinstall hook) and two new files added to the package (setup.mjs, Math_Symbol.js).eslint → file-entry-cache → flat-cache → keyv. That is why the scanner shows the chain in every finding.scan.mjs has the following properties — important for anyone responding to a supply-chain incident:
npm install. Audit the whole of scan.mjs in 15 minutes before running it.--update (download a fresh IOC manifest), explicit and optional.docker run --network=none or an isolated machine: just copy scan.mjs + iocs.json.Requirement: Node.js ≥ 18 (any machine with npm already has it). Download the two files — scan.mjs + iocs.json — and that is it: no installation.
Heads-up: if you cloned this whole repository, the
fixtures/folder contains inert IOCs used in the tests (real names and versions, dummy content — no malware). The scanner skips it automatically and warns in the output; findings from it only appear if you scan it on purpose.
There are two run modes that answer different questions, and that is what decides where to run:
repo mode reads lockfiles and node_modules — and lockfiles live in git, so it can be centralized: one person scans every repository in the company.host mode looks for the implant (watcher, LaunchAgent/systemd, IDE hooks), which lives on the machine where the code executed — that is not in git and cannot be centralized.node scan.mjs repo /folder/with/all/the/repos --json=result.json --html=report.html
Answers "which projects are exposed" in minutes, without involving anyone. Accepts multiple paths; walks subdirectories (monorepos and workspaces included).
For each project with a finding, identify who touched it since 2026-08-04 09:35 UTC (git log, CI logs). Those people run, on their machine:
node scan.mjs # current directory + host, in ~30 seconds
In scope: anyone who (a) ran npm install/npm ci in the window; or (b) merely cloned and opened the folder in VS Code or an AI agent — Vector B needs no install.
Since the cost is ~30 seconds and the funnel can leak (a stray clone, a personal project), the safest internal message is: every developer runs node scan.mjs once and sends the --json/--html to AppSec. Sending is manual by design — the scanner has no telemetry (zero egress).
Top priority: this is where the most valuable credentials live. Here the scanner has two different roles — one for the past, one for the future:
Triaging the past — do NOT scan the runner to decide. The question "was this runner hit?" is not answered by a scan: if any job installed an affected version without --ignore-scripts since 08-04, the credentials were already stolen at that moment, and the runner host rarely keeps evidence (ephemeral runners destroy the container at the end of the job; the watcher self-clears in ~24h). What answers it are the Step 1 lockfiles and the CI logs. If the answer is "yes, it installed": rebuild the runner and rotate its secrets — runners are ephemeral, there is no reason to clean them.
Prevention going forward — YES, run it in the pipeline. Add the scanner as a build step, in repo mode, after checkout and before npm install. It does not examine the runner host — it examines the code about to be installed, and the exit code fails the build before the malicious preinstall gets a chance to run:
# example (GitHub Actions / GitLab CI — adapt):
- run: node scan.mjs repo . --json # exit 0 clean · 1 findings · 2 COMPROMISED
- run: npm ci --ignore-scripts # only runs if the previous step passed
node scan.mjs # scan the current directory + the host
node scan.mjs repo /path/a /path/b
node scan.mjs host # persistence/implants on the machine only
node scan.mjs repo . --json=result.json --html=report.html
node scan.mjs --update # update iocs.json (the only network operation)
--html produces a self-contained report with a timestamp, hostname, IOC manifest version and the scanner's own SHA-256 — usable as an attachment to an incident notification and an audit trail.
Do not revoke or rotate any credential yet — that is the trap's trigger. The sequence:
1. ISOLATE — cut the machine off the network. It is safe: with no HTTP response there is no 4xx, the trap does not fire, and exfiltration stops. Do not power off (volatile memory is evidence).
2. PRESERVE — before deleting anything (the watcher self-destructs in ~24h):
mkdir -p /tmp/evidence && cp -r ~/.config/gh-token-monitor /tmp/evidence/ 2>/dev/null
cp /tmp/gh-token-monitor.*.log /tmp/evidence/ 2>/dev/null
shasum -a 256 /tmp/evidence/* 2>/dev/null
The handler file is the attacker's command that would be executed — do not run it, do not paste it into a shell; treat it as inert text. The started_at file bounds the exposure window (the auditor and the regulator will ask for it).
3. ERADICATE — kill the watcher process first, then:
# macOS
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/com.user.gh-token-monitor.plist
rm -f ~/Library/LaunchAgents/com.user.gh-token-monitor.plist
# Linux
systemctl --user disable --now gh-token-monitor.service
loginctl disable-linger "$USER"
rm -f ~/.config/systemd/user/gh-token-monitor.service
# both
rm -rf ~/.config/gh-token-monitor ~/.local/bin/gh-token-monitor.sh /tmp/bun-dl-*
Also remove the malicious hooks from .claude/settings.json and .vscode/tasks.json, the setup.mjs/Math_Symbol.js/math_init.js files, and clear the caches (~/.npm/_cacache, pnpm, yarn). Run node scan.mjs host again until it comes back clean.
4. ROTATE — only with every affected host cleaned and verified (a single live watcher is enough to fire the trigger). Revoke the npm token first (stops the worm from propagating); then GitHub (PATs, deploy keys), AWS/GCP/Azure, Vault, Kubernetes, CI secrets — and any secret that was on disk, because there was a regex sweep.
5. AUDIT — the worm acts in your name: look for repositories described Shai-Hulud: Here We Go Again in your organizations, npm versions published unexpectedly since 08-04 (deprecate them and notify consumers), and use of the credentials in CloudTrail/Audit Logs within the started_at window.
Afterwards: delete node_modules, reinstall from a clean lockfile with --ignore-scripts. CI runners and hosts with confirmed execution: rebuild from scratch, always — arbitrary code executed, and the list of known artifacts is no guarantee of completeness.
Regulated institutions (BR): a confirmed compromise with credential access can trigger reporting obligations (Res. CMN 4.893/2021, Res. BCB 85/2021; LGPD art. 48 if personal data is involved). Document the timeline in UTC —
started_at, detection, containment, eradication, rotation — and confirm deadlines with legal/compliance.
The incident is active and the list grows. To pull the latest manifest:
node scan.mjs --update # the only operation that touches the network
--update fetches iocs.json from this repository (securest8/npm-incident-response), never from a third party — Securest8 is the curation gate. You get whatever was last published here.
The package list comes from the public Wiz feed; the hashes, C2 domains, persistence IOCs and safe versions are static and curated in tools/gen-iocs.mjs. A snapshot of the Wiz CSV lives at tools/keyv-packages.csv for reproducibility and offline runs.
node tools/gen-iocs.mjs # fetch the latest Wiz CSV, regenerate iocs.json + refresh the snapshot
node tools/gen-iocs.mjs --offline # regenerate from the committed snapshot, no network
node tools/gen-iocs.mjs --allow-shrink # allow a package count lower than the snapshot (guarded by default)
The generator is idempotent: it keeps the existing manifest_version and does not rewrite the file when nothing material changed, and it refuses to write an empty or shrunken manifest (guarding against a truncated/changed upstream feed).
Automation: .github/workflows/update-iocs.yml runs the generator daily (and on demand) and commits to main only when the IOCs actually change — so users' --update tracks the Wiz feed within about a day, with the full history auditable in commits. Flip it to a pull-request step (noted in the workflow) once the incident cools and you want a manual merge instead.
node test/run-tests.mjs # 15 assertions against fixtures/demo-repo
fixtures/demo-repo is a test repository with inert IOCs (real names and versions, dummy content — no malware). When scanning the scanner's own repository, the fixtures/ folder is skipped automatically (with a warning in the output); the tests scan it by passing the path explicitly.
A single-incident tool, built for fast triage during the active window of the attack — not a replacement for Socket, Snyk or similar. Research and IOCs: Socket.dev, Wiz Research (public CSV), Kodem Security.
Maintained by Securest8. MIT License.
| Level | Meaning | Action |
|---|
COMPROMISED | Malicious version installed in node_modules, payload confirmed by hash, or a persistence implant found | Treat the host as compromised; follow the response order — clean the implant before rotating credentials |
EXPOSED | Malicious version pinned in a lockfile, no evidence of execution | Pin a safe version, delete node_modules, reinstall with --ignore-scripts |
AT_RISK | Range (^/~) in package.json that admits a malicious version | Pin an exact version or block it at the registry proxy |
SUSPECT | Heuristic (worm filename with a mismatched hash, suspicious lifecycle script) | Inspect manually — could be a new variant or a false positive |
INFO | Vector present but no IOC (e.g. a generic folderOpen task) | Review |