DEV Community

Cover image for A Routine npm install Almost Pulled In Credential-Stealing Malware
Rohit Bhadani
Rohit Bhadani Subscriber

Posted on

A Routine npm install Almost Pulled In Credential-Stealing Malware

Monday morning, a teammate opened a PR to bump a handful of utility dependencies — the kind of housekeeping PR nobody reviews carefully because it's "just version bumps." I almost approved it on sight. The only reason I didn't was that CI was slower than usual, and while I waited I actually read the diff instead of skimming it.

One of the packages had jumped two major versions with a changelog that didn't match the jump. Not wrong, exactly — just thin. A real library doesn't usually go from 2.1.4 to 4.0.0 with a one-line changelog that says "bug fixes."

The wrong leads

First guess: the maintainer just does bad changelogs. Plenty of small libraries have sloppy release notes. I almost wrote this off as normal open-source messiness and moved on.

Second guess: a legitimate but careless publish. Maybe they'd accidentally bumped the major version instead of patch. It happens. I checked the package's GitHub repo to compare against what was actually published to npm.

That's where it stopped being explainable by carelessness. The GitHub repo's latest tag was still 2.1.4. The npm registry had a 4.0.0 that didn't correspond to any tag, any commit, any release in the actual source repository. Someone had published a version directly to npm that didn't come from the project's own GitHub at all.

What it actually was

This landed the same week security researchers at CloudSEK and Checkmarx disclosed a campaign — they're calling it MALFEX — involving eight malicious npm packages that had racked up over 40,000 downloads delivering a remote access trojan and credential stealer. Ours wasn't on their published list, but the pattern was identical enough that I stopped assuming coincidence: a trusted-looking package, a version bump with no corresponding source, shipped specifically to ride along on routine dependency update PRs that get rubber-stamped.

We pulled the tarball and looked at the actual installed code versus the GitHub source. Buried in a postinstall script:

{
  "scripts": {
    "postinstall": "node ./scripts/setup.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

setup.js wasn't in the GitHub repo at all. It fetched a second-stage payload from a URL encoded as a base64 string split across three variables, decoded at runtime, and executed it with child_process.exec. Classic obfuscation to dodge a quick grep for suspicious strings — nothing in the file itself looked overtly like a URL.

The fix

1. We never let npm install run with real credentials or network access to anything that matters, full stop. Every dependency install — for a feature branch, a routine bump, anything — now happens inside a disposable sandbox first, before it ever touches a developer's real machine or CI's real secrets.

#!/usr/bin/env bash
# sandboxed-install.sh — run on a fresh, disposable VM only
set -euo pipefail

npm ci --ignore-scripts
echo "Scripts skipped. Checking for postinstall/preinstall hooks across the tree:"
grep -rE '"(pre|post)install"' node_modules/*/package.json 2>/dev/null || echo "none found"

echo "Now running with scripts enabled, isolated, outbound-restricted:"
npm ci
Enter fullscreen mode Exit fullscreen mode

I'm the founder of Krova Cloud, and this is exactly the shape of problem disposable VMs solve well — the sandbox has no access to our actual secrets store, no path back into anything that matters, and outbound-only networking that we can restrict further for this specific check. If a postinstall script tries to phone home or exfiltrate anything, it's reaching out from a box that gets destroyed in minutes and never had anything worth stealing in the first place. Running a quick sandboxed install before trusting a dependency bump costs us a couple of minutes and effectively nothing in compute, against a malware campaign that cost other teams actual credential theft.

2. We added a CI check that flags any published npm version with no matching tag or commit in the linked source repository — not perfect, since not every legitimate package keeps tags in lockstep, but it turns "someone published something that doesn't match source" from invisible to a yellow flag a human has to dismiss.

3. We stopped auto-approving dependency bump PRs under a certain size threshold. The whole reason this almost got through was that "small, boring, routine" is exactly the camouflage this kind of attack is built for.

Lessons

  • A major version bump with a suspiciously thin changelog is worth thirty seconds of checking, even when everything about the PR screams "routine."
  • If a package's published npm version doesn't trace back to a tag or commit in its own source repository, that's not proof of malice, but it's a real signal worth checking before you trust it.
  • Postinstall scripts run with the same privileges as whatever invoked npm install — your dev laptop, your CI runner, whatever secrets are sitting in that environment. Treat any dependency install as code execution, because that's exactly what it is.
  • "It's just a version bump" is precisely the category of change most likely to get rubber-stamped, which is exactly why it's the category attackers increasingly target.

If your CI or local dev environment has standing access to real secrets and runs npm install without a second thought, this is a good week to change that.


I'm Rohit, founder of Krova Cloud — disposable cloud VMs for running untrusted installs, builds, and scripts with no access to your real secrets or infrastructure. If you want more deep debugging stories like this one, I write regularly over at debugly.dev too.

Top comments (0)