Request access
Articles

The worm in your dependencies

A self-spreading worm rode npm install hooks into hundreds of packages. Your build ran their code before your app did. Here is what we check.

3 min read

Most of the code in your web app is not yours. A modern build pulls in hundreds of packages, and every one of them runs on your machine and your build server the moment you install it. That is the part attackers go after, and it is the part a scanner pointed at your running site never sees.

A worm called ChainDrop moved through the npm registry. It did not wait to be imported. It ran from a package's install hook, the small script npm runs automatically during install, before a single line of your own code executes.

{
  "name": "some-popular-helper",
  "version": "4.2.1",
  "scripts": {
    "preinstall": "node setup.mjs"
  }
}

That one line is the whole foothold. Running npm install was enough. The install script pulled a second stage, then went looking for anything worth taking: npm tokens, SSH keys, environment variables, cloud credentials, the secrets in your CI. With a stolen npm token it republished the maintainer's other packages with the same hook and a bumped version number, which is how one compromise became hundreds.

Why the running site does not show it

A scanner that crawls your production URLs is looking at the output. This attack lives upstream of that, in the tree of things your build trusted. The malicious version can steal a deploy key and a database URL without ever changing a page a scanner would load. By the time anything shows on the site, the credentials are already gone.

So the test moves to where the risk is. We read the dependency tree, not the short list of packages you chose but the full resolved set, and we look at what each one is allowed to do at install time. A package that declares a preinstall or postinstall script gets read, because that script runs with your permissions and no sandbox.

What we actually check

We check that the lockfile pins exact versions and integrity hashes, so a package cannot be swapped for a repackaged one between installs. We check which dependencies run lifecycle scripts and whether they need to. We look at what the build can reach on the network, because a package that phones out during install is how a stolen token leaves the building. And we check the provenance of the critical packages: who can publish them, and whether publishing is protected by more than a reused password.

# the tell: an install that opens the network
preinstall -> node setup.mjs
  -> fetch(C2)          # pull stage two
  -> read ~/.npmrc      # npm token
  -> read env, ~/.ssh   # cloud creds, keys
  -> POST exfil         # gzip + AES, out

None of that is visible from the front door. It is visible from the build, which is why we test the build.

The attacker did not break into your app. Your app installed them.

You cannot audit every transitive dependency by hand, and you should not have to. What you can do is know which of them run code at install, keep them pinned, and cut the network paths that turn a bad package into a stolen credential. We map that for your project and tell you where a single npm install trusts more than it should.