One script. No dependencies. One answer: YES or NO.
A single-file, read-only shell scanner for the keyv / cacheable npm supply-chain
compromise of 2026-08-04 — 11 malicious releases that shipped a
"preinstall": "node setup.mjs" lifecycle hook, a second-stage credential
stealer, IDE auto-run hooks, and a gh-token-monitor persistence unit.
It answers one question: is this machine or checkout compromised — yes or no?
chmod +x GosPreinstallHookScanner.sh && ./GosPreinstallHookScanner.sh /path/to/projectReference: Snyk — Inside the keyv npm compromise
======================================================
VERDICT: COMPROMISED -> YES
======================================================
./GosPreinstallHookScanner.sh # scan the current directory + $HOME persistence
./GosPreinstallHookScanner.sh /path/to/project # scan a specific checkout
./GosPreinstallHookScanner.sh --quiet . # verdict line only
./GosPreinstallHookScanner.sh --json . # machine-readable output for CI
./GosPreinstallHookScanner.sh --strict . # treat review items as compromising
./GosPreinstallHookScanner.sh --no-home . # skip the $HOME persistence hunt
./GosPreinstallHookScanner.sh --verbose . # also list INFO items (every hook, every downgraded name hit)
./GosPreinstallHookScanner.sh --exclude fixtures . # skip paths containing "fixtures" (repeatable)While scanning it prints a one-line progress indicator to stderr —
manifests 64% node_modules/foo/package.json — which is redrawn in place and
erased before the report. It is suppressed automatically for --json,
--quiet, and whenever stderr is not a terminal, so it never contaminates
piped or redirected output.
Output is coloured when stdout is a terminal — red block for COMPROMISED,
green for CLEAN, yellow for review-only. Colour is dropped automatically when
piped or redirected, and honours NO_COLOR; force it either way with
--color / --no-color.
| Exit | Verdict | Meaning |
|---|---|---|
0 |
NO | clean |
1 |
YES | at least one CRITICAL or HIGH finding |
2 |
NO | only MEDIUM review items |
3 |
— | bad usage / unreadable target |
Requirements: bash, awk, sed, grep, find, and one of
sha256sum / shasum / openssl. No Node.js, no network, no dependencies.
The scanner never executes anything it scans. It does not invoke a package
manager, does not run lifecycle hooks, and does not resolve anything over the
network. This is enforced by a test (preinstall hook was read, not executed).
Detection is layered on purpose — each vector is caught independently, so a missing lockfile, a repacked tarball, or a renamed payload does not produce a false "clean".
| Rule | Severity | What it catches |
|---|---|---|
malicious-package |
CRITICAL | a known-malicious name@version in package-lock.json (v1/v2/v3), npm-shrinkwrap.json, yarn.lock (v1 + berry), pnpm-lock.yaml or bun.lock |
malicious-package-installed |
CRITICAL | the release present in node_modules/**/package.json, even with no lockfile |
ioc-hash |
CRITICAL | a file whose SHA-256 matches a published payload hash |
ioc-lifecycle-hook |
CRITICAL | an install/preinstall/postinstall script invoking setup.mjs, Math_Symbol, math_init or gh-token-monitor |
ide-autorun-ioc |
CRITICAL | Claude SessionStart or VS Code folderOpen task invoking a payload file |
persistence |
CRITICAL | gh-token-monitor script, systemd user unit, LaunchAgent plist, or config dir under $HOME |
ioc-payload-hooked |
CRITICAL | a payload-named file at a package root and invoked by that package's install hook |
ioc-payload-in-affected-package |
CRITICAL | a payload-named file at the root of one of the eleven compromised projects |
ioc-payload-packaged |
HIGH | a payload-named file at a package root, listed in the manifest's "files" array — the packaging signature of this compromise, under any package name |
ioc-payload-suspicious |
HIGH | a payload-named file at a package root whose contents reference process execution or credential material |
ioc-payload-ide-dir |
HIGH | a payload-named file inside .claude/ or .vscode/ — the IDE auto-run vector |
suspicious-lifecycle-hook |
HIGH | an install hook doing curl … | sh, base64 -d, eval(, /dev/tcp/, or inline code that reaches the network/shell/environment |
inline-require-hostile |
HIGH | a node -e hook whose required file references process execution or credential material |
ide-autorun-hook |
MEDIUM | an auto-run IDE hook with a non-IOC command — legitimate in many repos, so it is a review item, not a verdict |
preinstall-runs-local-script |
MEDIUM | a dependency running a bundled script before install |
inline-require-missing |
MEDIUM | a node -e hook requiring a file that is not in the package — it would have to appear at install time |
inline-node-eval |
MEDIUM | node -e with no resolvable local require |
ioc-filename |
MEDIUM | a payload-named file at a package root with nothing else to corroborate it |
lifecycle-hook |
INFO | every other install hook (--verbose only) |
inline-require-local |
INFO | a node -e hook whose required file is bundled, present and clean (--verbose only) |
ioc-filename-benign |
INFO | a payload-named file inside a library's dist/, src/ or tests/ — recorded, but not where the payload lands (--verbose only) |
The attacker chose names that blend in. Math_Symbol.js is a real Unicode
general-category table shipped by regenerate-unicode-properties (a transitive
Babel dependency), and setup.mjs is an ordinary test-harness entry point —
@assistant-ui/react and motion-dom both ship one. Grading every name match
as HIGH makes the tool shout COMPROMISED at any project that installed Babel,
and a tool that cries wolf gets ignored.
So the name only opens the question; placement and wiring answer it. Per the
Snyk write-up the payload was dropped at the package root, added to the
manifest's "files" array, and executed from "preinstall": "node setup.mjs" —
so that is what the classification keys on. A name hit that none of those
explain is still reported, just at a severity that does not drive the verdict.
Nothing is ever discarded silently; --verbose shows every downgraded row.
The same principle applies to inline hooks. vue-demi's real postinstall is
node -e "try{require('./scripts/postinstall.js')}catch(e){}", and an attacker
can use that exact string while putting the payload in the required file —
so the wrapper settles nothing. The scanner resolves the required path against
the package directory and grades what it finds there: hostile contents are
HIGH, a missing file is MEDIUM, a bundled and clean file is INFO.
Each malicious release is reported with its known-good rollback version:
| Package | Malicious | Roll back to |
|---|---|---|
keyv |
6.0.0 | 5.6.0 |
@cacheable/net |
2.1.1 | 2.1.0 |
@cacheable/node-cache |
3.1.2 | 3.1.1 |
@cacheable/memory |
2.2.1 | 2.2.0 |
@cacheable/utils |
2.5.1 | 2.5.0 |
cacheable |
2.5.1 | 2.5.0 |
cacheable-request |
13.0.20 | 13.0.19 |
cache-manager |
7.2.10 | 7.2.9 |
flat-cache |
6.1.24 | 6.1.23 |
file-entry-cache |
11.1.6 | 11.1.5 |
ecto |
5.0.1 | 5.0.0 |
keyv@6.0.0 carried valid npm provenance signed by GitHub Actions — the
repository was compromised before CI built the artifact. Attestation is not a
substitute for this check.
./tests/run_tests.sh # 41 assertions
./tests/run_tests.sh yarn # only tests matching "yarn"The suite covers every lockfile format, the no-lockfile case, IDE hooks, persistence, hash matching and the CLI contract. A coverage test regenerates a probe lockfile for all 11 IOC releases and asserts each is detected.
It also pins both failure directions, using cases taken from real scan output:
Must not alarm — a version that merely starts with a malicious one
(@cacheable/utils@2.5.10 vs 2.5.1); a legitimate postinstall; vue-demi's
node -e hook; and regenerate-unicode-properties' Math_Symbol.js plus the
setup.mjs files inside @assistant-ui/react and motion-dom.
Must still alarm — a payload placed at the package root of an unlisted
package name (repacked); a payload whose hash does not match the published IOC;
and a minified package.json, which hid every top-level key from the
line-oriented manifest pass until the parser learned to reflow JSON first.
fixtures/ holds simulated infected projects. The payload files
(setup.mjs, Math_Symbol.js, gh-token-monitor.sh) are inert
placeholders with a header saying so — this repository never carries working
malware, and their hashes deliberately do not match the real IOCs. The hash
path is exercised instead via GOS_EXTRA_SHA256.
In this order — the persistence unit watches for token revocation, so disabling it comes before rotating anything:
- Isolate the host or CI runner from the network.
- Disable
gh-token-monitorpersistence. - Rotate every GitHub, npm, cloud, database, Vault and Kubernetes credential from a clean machine.
- Pin the affected packages to the rollback versions and reinstall with
--ignore-scripts. - Purge the malicious tarballs from any private registry mirror.
- Review audit logs for unauthorised activity.
New hashes need no code change:
GOS_EXTRA_SHA256="<sha256> <sha256>" ./GosPreinstallHookScanner.sh .New releases go in the MALICIOUS_RELEASES block at the top of GosPreinstallHookScanner.sh,
one name@version|rollback-version per line. The coverage test reads that
block directly, so it starts asserting the new entries with no test edit.
MIT © Charlie