How to Find a Compromised Dependency in Your Node Project

Euael Eshete ยท August 10, 2026

Part 1 of a three-part series on investigating and recovering from a software supply-chain compromise.

TL;DR

  • Almost every dependency attack takes one of two shapes. A fake package that impersonates a real name, or a takeover of a real maintainer account.
  • Both usually deliver a downloader, not a finished payload. The malicious logic never sits on the registry long enough to get reviewed.
  • The overrides and resolutions fields redirect a package name across the entire dependency tree. Read every line of them.
  • One hook is not proof of one hook. Grep the whole repository for the package name, not only the file where you found it.
  • File timestamps on node_modules and the lockfile set the exposure window. Every check in Parts 2 and 3 depends on that window.

The trust boundary is not the code you wrote

A normal project pulls in hundreds of transitive dependencies. Almost nobody reads them. That is the correct trade for shipping anything, but it moves the trust boundary. The real boundary is the code you wrote, plus every package your dependencies depend on, at any depth.

Attackers know this. This post covers how to find the one package in that tree that does not belong.

Two attacks, one goal

Impersonation. Someone publishes a package under a name close to a popular one. A misspelling, a swapped hyphen, a scoped variant of an unscoped name. The attacker then hopes a developer installs it by mistake. The smarter version forces the substitution through a resolution mechanism, so nobody has to make the typo at all.

Maintainer takeover. Someone steals the credentials of a maintainer of a real, trusted package. Phishing, credential stuffing, or a leaked npm token. They then publish a new version with a payload appended. This version is more dangerous. The package was trustworthy until the moment it was not. It has real download counts, a real commit history, and no reason to look odd during a patch bump.

Both attacks deliver the same thing. Not a complete malware payload, which a code review would catch. They deliver a small downloader whose only job is to fetch and run something else.

The mechanism that hides both: resolution overrides

Every package manager can force a dependency name to resolve to a different package. In npm and yarn, the field is overrides or resolutions. The legitimate use case: patch a vulnerable transitive dependency without waiting for a maintainer three levels up the tree.

The syntax looks routine:

"overrides": {
  "some-real-package": "npm:[email protected]"
}

The effect is not routine. Every require and every import of some-real-package, anywhere in the tree, silently gets the substitute. This includes packages that trust that name completely and have no idea the swap happened.

flowchart TB PJ["package.json<br/>overrides: real-pkg to npm:fake-pkg"] --> RES{"Resolver"} A["Your code<br/>import 'real-pkg'"] --> RES B["Dependency A<br/>requires 'real-pkg'"] --> RES C["Dependency B<br/>requires 'real-pkg'"] --> RES RES --> FAKE["node_modules/real-pkg<br/>= [email protected]"] FAKE --> RUN["Payload runs on every import"]

The line also survives review. It reads like dependency pinning noise. The substitute package can copy the real package metadata closely enough that the registry page looks normal at a glance.

The check. Read this field line by line in any package.json you did not write yourself:

grep -n "overrides\|resolutions" package.json

If a target uses the npm:<name>@<version> alias syntax, and <name> does not match the key it overrides, that mismatch is the attack.

Do not stop at the top-level manifest. The same substitution can sit at more than one layer. A direct dependency entry in a sub-package manifest. A build tool config such as vite.config.* or webpack.config.* that redirects an import specifier through resolve.alias. Attackers sometimes add a source comment that justifies the redirect, so it survives a diff review.

Confirm what is on disk, not what the manifest claims

A manifest states an intent. Check what the resolver actually did:

npm ls <package-name>

Clean output shows a normal version number. A poisoned resolution shows the alias directly, in the form <real-name>@npm:<other-name>@<version>.

Cross-check the lockfile. It records the exact tarball URL and integrity hash the installer pulled. It is a second place the same evidence appears, even if someone edits the manifest later:

grep -n "<suspect-package-name>" package-lock.json

Check the publish timeline as well. A long-idle package that suddenly ships a release is the clearest sign of a maintainer takeover. Confirm it against the public repository. No matching commits and no issue activity means the release did not come from the project:

npm view <package-name> time --json

Read the package metadata for impersonation signals

cat node_modules/<suspect-name>/package.json

An impersonation package often copies the real package metadata almost word for word. Same author, same homepage, same repository and funding links. The copy exists to survive a quick check.

Two signals give it away. The name field inside the package does not match the name on the registry. The version number is also implausible. Take a version of 0.0.1 for a clone of a library stable at a high major version for years. That mismatch is a strong signal on its own.

A takeover package is harder to catch this way. The metadata is genuinely the real project metadata. The timeline is the only tell.

Search for the payload by capability, not by reading

You will not find an obfuscated payload by reading a file from top to bottom. You find it by searching for capability clusters that the package has no reason to need:

grep -rlE "eval\(|new Function\(|child_process|spawn\(|execSync|require\(.https?.|Buffer\.from\([^)]*base64" \
  node_modules/<suspect-name> --include=*.js --include=*.mjs
Pattern Why it is suspicious here
eval(, new Function( Runs code built at runtime. Almost never legitimate outside a few templating and sandbox libraries.
child_process, spawn(, execSync Starts an OS process. A tool that only transforms local files has no reason to do this.
require('http'), require('https') Outbound network access from a build tool that should only touch the local filesystem.
Buffer.from(..., 'base64') Decodes an embedded blob. A common way to keep payload content out of a plain-text grep.
Dense unicode escapes (\u00...) Writes an identifier one escaped character at a time, so it never appears as a readable string.

Read the surrounding context of every hit before you conclude anything. A lone process.env.NODE_ENV check is normal library code and will trip a broad grep on its own. What matters is a cluster: a child_process import next to a hand-rolled HTTP client and a spawn(..., { detached: true }).unref() call.

Classify the payload without reversing it

You do not need to decode every byte to make a risk decision. You need the shape of the thing. Three questions cover it.

Where does it get instructions? Simple payloads hardcode a command-and-control domain. A registrar can sinkhole that domain once someone reports it. Capable payloads read the target from somewhere no registrar controls. A blockchain transaction, a paste site, or a DNS TXT record on a rotating domain.

A public example shows how far this goes. Researchers named the technique NullReceiver after finding it in a set of npm packages in 2026. The malware reads a hard-coded attacker wallet on Ethereum. It takes the destination address of the most recent outbound transfer. It then decodes a command-and-control IP address from the first four bytes of that address. The transfer carries no value and no data. There is no smart contract and no payload field to inspect.

Note what this is and what it is not. It is a dead drop for an address. It is not a wallet drainer, and the Ethereum layer steals nothing. Two properties make it hard to fight. No registrar can take a wallet down, and the destination address is different every time, so defenders have no fixed indicator to block.

What does it do with a target? It usually fetches a second stage over plain HTTP, not HTTPS. That choice is deliberate. It avoids looking like a normal encrypted API call during casual traffic inspection. The transfer often uses XOR or similar light obfuscation rather than real cryptography, because the goal is evasion, not confidentiality.

How does it run the result? eval() in the current process is the simplest option. The persistent variant spawns a detached, unreferenced child process. That combination lets the process outlive the thing that launched it. No scheduled task, no registry key, just a process that survives until the next reboot.

That classification, a downloader rather than a complete payload, is enough to drive the decisions in Parts 2 and 3. Attackers build second-stage infrastructure to be short-lived, so you often cannot retrieve it afterward.

Set the exposure window

Version control does not track installed dependencies. Filesystem timestamps are often the only remaining record. They tell you when a poisoned install landed:

stat node_modules/<suspect-name>/<main-file>
stat package-lock.json

Write both timestamps down. Every host check and credential check in the next two parts covers only what happened on or after that moment.

Confirm scope before you move on

An override can only redirect the names it lists. Check that nothing else in the tree carries the same fingerprint, in case someone planted more than one hook:

grep -rlE "<a distinctive string from the payload>" node_modules --include=*.js --include=*.mjs

A single match is good supporting evidence, not proof, that the compromise stops at one dependency.

Four defaults that prevent the next one

The playbook above finds the package. It does not stop the next one. Four settings do most of that work, and all four are configuration rather than tooling spend.

  1. Run untrusted code in a disposable container, never on a workstation. Client repositories, take-home tests, and interview projects all count as untrusted.
  2. Use npm install --ignore-scripts by default. Lifecycle scripts are the most common execution path for this class of package.
  3. Turn on branch protection and block force push on every repository, not only the ones that feel important. A compromised developer account cannot rewrite history it cannot force push to.
  4. Issue short-lived, scoped tokens instead of long-lived personal access tokens. A stolen token that expires is a smaller problem than one that does not.

All four are free. All four are off unless you turn them on.

The short version

Find the hook, confirm it on disk, classify the payload by shape, and write down the timestamp. Do not treat the first hook you find as the only one. Whether the code actually ran on your machine is a separate question, and it is the subject of Part 2.


The series

  1. Finding a Compromised Dependency (this post)
  2. Part 2: Did the Payload Actually Run?
  3. Part 3: How Far Did It Reach?