npm Supply-Chain Sweep
Advisory-driven verification: a maintainer account got compromised, poisoned
versions of package(s) were published, and the user wants to know "did I get
hit?". Answer with evidence, not vibes. Read-only, no installs required except
the packages you are checking against.
When to Use
- "New npm supply-chain advisory just dropped — sweep my machine"
- "Did I install the compromised @?"
- Any poisoned-version list from an advisory (Mini Shai-Hulud, esbuild,
ua-parser-js, event-stream, colors/faker, etc.). The workflow is the same.
Core Principle
A package DIRECTORY with a matching name is NOT a verdict. keyv,
file-entry-cache, cacheable-request and friends are ubiquitous transitive
deps of eslint/stylelint — their dirs appear in almost every node_modules.
The exact installed VERSION is the verdict. Always compare installed
version against the advisory's poisoned version list.
Workflow
Parse the advisory — build the exact pkg@poisoned-version list.
Versions matter: file-entry-cache@11.1.6 is poisoned while 11.1.2 is
fine. Extract payload/dropper filenames (e.g. setup.mjs, Math_*.js)
and any C2/exfil domains for later.
Inventory tooling — node -v, npm -v, bun -v, npm ls -g --depth=0,
npm config get registry (a non-registry.npmjs.org registry is itself a
finding).
Find lockfiles (exclude */Library/*, */.hermes/*, */node_modules/*):
package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lock, bun.lockb,
npm-shrinkwrap.json. For each poisoned package, grep with package-name
context, never a bare version string.
Installed copies — find */node_modules/<pkg> (and nested copies
under node_modules/<other>/node_modules/<pkg> — nested deps are a common
hiding spot). Read each package.json: version + scripts. A
preinstall/prepare script in a version that shouldn't have one is a red
flag.
Dropper artifacts — search node_modules for the advisory's filenames
(setup.mjs, Math_*.js, preinstall*). Absence = strong clean signal.
Caches — npm cache ls <pkg> (poisoned tarball URL = exposure, even if
node_modules was reinstalled), plus the bun cache
~/.bun/install/cache/<pkg>/. npm cache ls per-package can be truncated
with head — run full when the poisoned version is the question.
DECISIVE: publish-time proof — query
curl -s https://registry.npmjs.org/<pkg> → d['time'][version] for the
poisoned version. Compare against install timestamps
(ls -laT <pkg>/package.json). If every install predates the poisoned
publish, infection is physically impossible — state that.
Live-process check — ps aux | grep -E '/(bun|node|deno)': a payload
executes via bun/node; no such process = nothing running.
Token surface — list .npmrc files (existence only; NEVER print token
contents). No .npmrc in projects = nothing for the stealer to harvest.
Network — lsof -i -P -n | grep ESTABLISHED and grep for the
advisory's exfil domains. Check /etc/hosts: users often add their own
block entries for C2/exfil domains — that is intentional defense, not
hijack; note it, don't flag it.
Report
Deliver findings IN CHAT, bottom line first ("no poisoned version installed,
and here is the timing proof"), then severity-ordered. HIGH = poisoned version
found OR patch debt; MEDIUM/LOW = posture items; INFO = intentional
defensive edits. State evidence chains (installed version, lockfile resolved
URL + integrity, publish timestamp) for every verdict.
Support files
scripts/npm_poison_sweep.sh — re-runnable sweep: takes pkg@version
pairs, checks lockfiles, all installed copies (incl. nested), npm cache,
dropper artifacts, and fetches the poisoned publish timestamp.
references/mini-shai-hulud-iocs.md — worked example: the Mini Shai-Hulud
advisory (poisoned versions + publish dates, payload names, exfil domains,
Defender name) and the session's clean-verdict evidence chain.
Pitfalls
- Grepping lockfiles for
"version": "6.0.0" matches ANY package at 6.0.0
(e.g. locate-path@6.0.0) — massive false-positive noise. Anchor on the
package entry ("node_modules/keyv" block) or the resolved tarball URL.
- Nested copies:
node_modules/stylelint/node_modules/file-entry-cache can be
a different (benign) major version than the top-level one — check every copy.
npm cache ls may only show a few lines; the poisoned tarball could be
further down the list.
- Scoped packages (
@servicetitan/*, @cacheable/*) need the @scope/ in
the find path and grep patterns.
- Don't string-grep whole node_modules trees; target
find by package dir.
freshclam/ClamAV is a separate, slower pass — the npm sweep is
deterministic and does not need an AV scan to reach a verdict.
1---2name: npm-supply-chain-sweep3description: Check npm installs after a supply-chain poisoning advisory.4---5
6# npm Supply-Chain Sweep
7
8Advisory-driven verification: a maintainer account got compromised, poisoned
9versions of package(s) were published, and the user wants to know "did I get
10hit?". Answer with evidence, not vibes. Read-only, no installs required except
11the packages you are checking against.
12
13## When to Use
14- "New npm supply-chain advisory just dropped — sweep my machine"
15- "Did I install the compromised <pkg>@<version>?"
16- Any poisoned-version list from an advisory (Mini Shai-Hulud, esbuild,
17 ua-parser-js, event-stream, colors/faker, etc.). The workflow is the same.
18
19## Core Principle
20A package DIRECTORY with a matching name is NOT a verdict. `keyv`,
21`file-entry-cache`, `cacheable-request` and friends are ubiquitous transitive
22deps of eslint/stylelint — their dirs appear in almost every node_modules.
23**The exact installed VERSION is the verdict.** Always compare installed
24version against the advisory's poisoned version list.
25
26## Workflow
27
281. **Parse the advisory** — build the exact `pkg@poisoned-version` list.
29 Versions matter: `file-entry-cache@11.1.6` is poisoned while `11.1.2` is
30 fine. Extract payload/dropper filenames (e.g. `setup.mjs`, `Math_*.js`)
31 and any C2/exfil domains for later.
32
332. **Inventory tooling** — `node -v`, `npm -v`, `bun -v`, `npm ls -g --depth=0`,
34 `npm config get registry` (a non-registry.npmjs.org registry is itself a
35 finding).
36
373. **Find lockfiles** (exclude `*/Library/*`, `*/.hermes/*`, `*/node_modules/*`):
38 `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`, `bun.lock`, `bun.lockb`,
39 `npm-shrinkwrap.json`. For each poisoned package, grep with **package-name
40 context**, never a bare version string.
41
424. **Installed copies** — `find` `*/node_modules/<pkg>` (and nested copies
43 under `node_modules/<other>/node_modules/<pkg>` — nested deps are a common
44 hiding spot). Read each `package.json`: `version` + `scripts`. A
45 `preinstall`/`prepare` script in a version that shouldn't have one is a red
46 flag.
47
485. **Dropper artifacts** — search node_modules for the advisory's filenames
49 (`setup.mjs`, `Math_*.js`, `preinstall*`). Absence = strong clean signal.
50
516. **Caches** — `npm cache ls <pkg>` (poisoned tarball URL = exposure, even if
52 node_modules was reinstalled), plus the bun cache
53 `~/.bun/install/cache/<pkg>/`. `npm cache ls` per-package can be truncated
54 with `head` — run full when the poisoned version is the question.
55
567. **DECISIVE: publish-time proof** — query
57 `curl -s https://registry.npmjs.org/<pkg>` → `d['time'][version]` for the
58 poisoned version. Compare against install timestamps
59 (`ls -laT <pkg>/package.json`). If every install predates the poisoned
60 publish, infection is *physically impossible* — state that.
61
628. **Live-process check** — `ps aux | grep -E '/(bun|node|deno)'`: a payload
63 executes via bun/node; no such process = nothing running.
64
659. **Token surface** — list `.npmrc` files (existence only; NEVER print token
66 contents). No `.npmrc` in projects = nothing for the stealer to harvest.
67
6810. **Network** — `lsof -i -P -n | grep ESTABLISHED` and grep for the
69 advisory's exfil domains. Check `/etc/hosts`: users often add their own
70 block entries for C2/exfil domains — that is intentional defense, not
71 hijack; note it, don't flag it.
72
73## Report
74Deliver findings IN CHAT, bottom line first ("no poisoned version installed,
75and here is the timing proof"), then severity-ordered. HIGH = poisoned version
76found OR patch debt; MEDIUM/LOW = posture items; INFO = intentional
77defensive edits. State evidence chains (installed version, lockfile resolved
78URL + integrity, publish timestamp) for every verdict.
79
80## Support files
81- `scripts/npm_poison_sweep.sh` — re-runnable sweep: takes `pkg@version`
82 pairs, checks lockfiles, all installed copies (incl. nested), npm cache,
83 dropper artifacts, and fetches the poisoned publish timestamp.
84- `references/mini-shai-hulud-iocs.md` — worked example: the Mini Shai-Hulud
85 advisory (poisoned versions + publish dates, payload names, exfil domains,
86 Defender name) and the session's clean-verdict evidence chain.
87
88## Pitfalls
89- Grepping lockfiles for `"version": "6.0.0"` matches ANY package at 6.0.0
90 (e.g. `locate-path@6.0.0`) — massive false-positive noise. Anchor on the
91 package entry (`"node_modules/keyv"` block) or the `resolved` tarball URL.
92- Nested copies: `node_modules/stylelint/node_modules/file-entry-cache` can be
93 a different (benign) major version than the top-level one — check every copy.
94- `npm cache ls` may only show a few lines; the poisoned tarball could be
95 further down the list.
96- Scoped packages (`@servicetitan/*`, `@cacheable/*`) need the `@scope/` in
97 the find path and grep patterns.
98- Don't string-grep whole node_modules trees; target `find` by package dir.
99- `freshclam`/ClamAV is a separate, slower pass — the npm sweep is
100 deterministic and does not need an AV scan to reach a verdict.