On 4 August 2026, the CHAINDROP wave of the Shai-Hulud worm compromised over 400 npm packages
with a combined 1.3 billion monthly downloads. keyv alone accounts for around 600 million of
those. flat-cache, cacheable-request, cache-manager — none of them packages you chose.
All of them packages you have.
The worm stole credentials for AI providers, AWS, GCP, Azure, and GitHub, obfuscated itself with
control-flow flattening and Base91 string encoding, and pulled its command-and-control address
out of an Ethereum smart contract so takedowns wouldn't stick.
By the time you read this, that specific campaign is contained. The pattern is not. This is the
third round of the same worm family, and each round has worked the same way: compromise a
maintainer account, publish a patch version, wait for everyone's ^ range to pick it up.
So here's the uncomfortable thing about the tool most of us reach for first.
npm audit answers the wrong question
npm audit tells you which of your dependencies have published advisories. That is a useful
question. It is not the question these attacks punish you for.
A malicious version published forty minutes ago has no advisory. It has no CVE. It will pass
npm audit cleanly until a researcher files it — which, in the CHAINDROP case, took hours
during which the package was being installed continuously.
What would have flagged those packages ahead of time is duller and more boring:
- Maintainer count of one. A single compromised account is a single point of failure, and most of the worm's entry points were solo-maintained packages.
- Long silence, then a sudden patch release. A package that hadn't shipped in fourteen months suddenly cutting a patch version at 3am is the signal.
- Transitive depth. The packages that did the damage were four and five levels down, where nobody looks.
- Deprecation. Deprecated packages still installed in production are unowned attack surface.
None of that is a vulnerability. All of it is risk. And there is no single command that gives it
to you across a whole package-lock.json.
That is the gap I kept hitting, so I built something to fill it.
The check I actually run
The data already exists, publicly, in four places:
- npm registry / PyPI — versions, licenses, maintainers, deprecation flags
- OSV.dev — vulnerability advisories, properly version-scoped
- deps.dev — the resolved dependency graph, including transitives
- pypistats / npm downloads — adoption signal
The annoying part isn't getting any one of them. It's getting all four, for 300 packages, joined
on the same key, without writing four rate-limit handlers and a cache.
I wrapped that into an Apify actor —
npm, PyPI & crates.io Package Intelligence —
that takes a list of packages and returns one row each: version, license, maintainer count,
download volume, OSV advisories, deps.dev dependency counts, and a 0–100 health score.
The bulk endpoint takes up to 50 packages per call, which is what makes this practical against a
real lock file rather than a curiosity you run on one package.
Step 1 — get your actual dependency list
Not your package.json. Your lock file, because that's where the transitives live:
# npm — every package in the resolved tree, deduplicated
npm ls --all --json \
| jq -r '[.. | objects | select(has("version")) | .name?] | unique | .[]' \
| grep -v '^null$' > packages.txt
wc -l packages.txt
For Python:
pip list --format=json | jq -r '.[].name' > packages.txt
On a mid-sized Node service this is usually somewhere between 400 and 1,200 lines. That number
alone is worth sitting with for a second.
Step 2 — score them in bulk
Input to the actor is deliberately boring:
{
"packages": ["keyv", "flat-cache", "cacheable-request", "express", "lodash"],
"ecosystem": "npm",
"includeDependencies": true
}
Package names can carry an ecosystem prefix, so a polyglot monorepo goes in as one run rather
than three:
{
"packages": ["npm:keyv", "pypi:requests", "crates:serde"],
"includeDependencies": true
}
Run it from the API and pipe the dataset straight into jq:
jq -Rn '{packages: [inputs], includeDependencies: true}' < packages.txt > input.json
curl -s -X POST \
"https://api.apify.com/v2/acts/optirefine~package-intelligence/run-sync-get-dataset-items?token=$APIFY_TOKEN" \
-H 'Content-Type: application/json' \
--data-binary @input.json \
> health.json
The scoring endpoints are pay-per-event and priced per package; the raw lookup endpoints
(metadata, vulns, deps, downloads) are free at 60/min and 2,000/day, so you can prototype the
whole pipeline before spending anything. Packages that don't exist return 404 without charging,
which matters more than it sounds when you feed it a lock file containing internal scoped
packages.
Step 3 — sort by the thing that actually predicts trouble
jq -r '
map(select(.maintainerCount == 1 and .downloads > 1000000))
| sort_by(.healthScore)
| .[]
| [.name, .healthScore, .maintainerCount, .downloads, .lastPublished]
| @tsv
' health.json | column -t
That query — high download volume, single maintainer, low health score — is the CHAINDROP
shape. It is the list I'd want on a screen before approving any dependency bump.
The four numbers worth writing down
When you run this against your own tree, four counts tell you almost everything. I'd write them
in a comment on the PR that introduces the check, so you have a baseline to compare against in
six months:
- Total packages in the resolved tree. Not direct dependencies — everything. This is the number that makes people quiet in a room.
- How many are single-maintainer with over a million weekly downloads. Your CHAINDROP-shaped surface. This is the list a human should actually read.
- How many are deprecated but still installed. Unowned code running in production. Every one of these is either a migration ticket or a decision to accept the risk on purpose — and it should be one of those two, not neither.
- How many haven't published in over a year. Not inherently bad — some packages are simply finished. But combined with (2), it's the set where a sudden patch release should make you look rather than merge.
Track those four over time rather than chasing an aggregate score. The absolute numbers vary
enormously by ecosystem and project age, so your own trend is the only meaningful comparison.
What you want to see is (2) and (3) going down while (1) goes up, which is what a team actively
managing its surface looks like.
How the health score is built (and why I kept it dumb)
The score is a documented v1 heuristic, not a model. It reads on these signals:
- Release recency — how long since the last publish
- Maintainer count — one is a risk, not a virtue
- Deprecation status — flagged hard
- Vulnerability load — OSV advisories resolved against the current version, not every version ever published
- Dependency health — the shape of what it pulls in
That last point about OSV is the one I'd defend hardest. A lot of tooling reports every advisory
ever filed against a package name, which makes well-maintained packages that patched promptly
look worse than abandoned ones that never had a researcher look at them. Scoring against the
resolved current version fixes that inversion.
The score is deliberately auditable. Every input is a public field you can go and check yourself.
If you disagree with the weighting, the raw fields are all in the same row and you can build your
own — which I'd encourage, because the right weighting depends on your threat model, not mine.
What it does not do: it doesn't detect malicious code. Nothing in this pipeline reads a
package's source. It measures the conditions under which a compromise is more likely to go
unnoticed. Those are different claims, and I'd rather be clear about which one I'm making.
Wire it into CI, or it won't happen
The version that actually changes behaviour runs on pull requests that touch the lock file:
# .github/workflows/dependency-health.yml
name: Dependency health
on:
pull_request:
paths: ['package-lock.json', 'requirements.txt', 'poetry.lock']
jobs:
health:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 2 }
- name: Diff newly added packages
id: added
run: |
git diff HEAD^ HEAD -- package-lock.json \
| grep -oP '^\+\s+"node_modules/\K[^"]+' \
| sort -u > added.txt
echo "count=$(wc -l < added.txt)" >> "$GITHUB_OUTPUT"
- name: Score them
if: steps.added.outputs.count != '0'
env:
APIFY_TOKEN: ${{ secrets.APIFY_TOKEN }}
run: ./scripts/score-packages.sh added.txt
One deliberate design choice worth copying: only score newly added dependencies, not version
bumps of existing ones. Scoring the whole tree on every PR produces a wall of noise that people
learn to click through within a week, and a check everyone ignores is worse than no check.
Pair it with the boring controls that would have actually stopped CHAINDROP:
- A soak period before adopting updates — pin exact versions and let new releases age 48–72h
-
npm 12+, which blockspreinstallhooks by default - 2FA enforced on every npm account you own
- Post-incident: rotate GitHub tokens, npm credentials, and cloud keys from any machine that
installed during the window, and grep your repos for unauthorized commits (CHAINDROP's were
labelled
chore: update config)
The part I'd argue about
Health scoring is a lagging measure dressed as a leading one. A single-maintainer package with a
low score has been that way for years without being compromised, and most of them never will be.
Treat the score as a prioritisation aid for where to spend review attention, not as a gate
that blocks merges. I've watched teams turn a score threshold into a hard CI failure and then
spend the next month adding exceptions until the check meant nothing.
The useful version of this is smaller than it sounds: a weekly list of the fifteen riskiest
things in your tree, looked at by a human for ten minutes.
The actor is here if it's useful:
npm, PyPI & crates.io Package Intelligence.
Free endpoints for the raw registry, vulnerability and dependency data; pay-per-event for the
health scoring, so a one-off audit of a few hundred packages costs about the price of a coffee.
If you run it against your own tree, I'd like to hear what your single-maintainer count came out
at — and whether the number surprised you. That's the one I've never seen anyone guess correctly
in advance.
Sources
- Shai-Hulud strikes again: CHAINDROP worm hits 400+ npm packages — Elastic Security Labs
- "Shai-Hulud" Worm Compromises npm Ecosystem — Unit 42, Palo Alto Networks
- Major Shai-Hulud campaign strikes npm again, affecting keyv and 400+ packages — JFrog Security Research
- Widespread Supply Chain Compromise Impacting npm Ecosystem — CISA
Top comments (0)