Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

GosPreinstallHookScanner

License: MIT Shell: bash Tests Dependencies

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/project

Reference: Snyk — Inside the keyv npm compromise

======================================================
 VERDICT: COMPROMISED  ->  YES
======================================================

Usage

./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 stderrmanifests 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).

What it detects

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)

Why a matching filename is not, by itself, a finding

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

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

If the verdict is YES

In this order — the persistence unit watches for token revocation, so disabling it comes before rotating anything:

  1. Isolate the host or CI runner from the network.
  2. Disable gh-token-monitor persistence.
  3. Rotate every GitHub, npm, cloud, database, Vault and Kubernetes credential from a clean machine.
  4. Pin the affected packages to the rollback versions and reinstall with --ignore-scripts.
  5. Purge the malicious tarballs from any private registry mirror.
  6. Review audit logs for unauthorised activity.

Adding IOCs

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.

License

MIT © Charlie

About

Single-file bash scanner for the keyv/cacheable npm compromise — detects preinstall malware, IDE auto-run hooks and gh-token-monitor persistence. No dependencies, one YES/NO verdict.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages