If you’ve got an app that even tangentially touches NodeJS/NPM (hell if you have eyes and ears full-stop) you’ve probably seen these.

I am not going to spend a lot of time writing about these particular pieces of malware. The posts above such as that from my mate Paul McCarty will do the job much better than I ever could.

I am also not going to talk much about how to respond to this directly. To those affected I wish you a manageable IR process.

Rather, I want to spend my words talking about how we as engineers, can move forward from this, providing some steps for short term solutions, plans for medium term solutions and then thought pieces about longer term plays.

Our model of dependency trust is broken and has been broken since the foundation of package managers

Industry standards for managing this risk are fundamentally faulty.

Manifest-based composition analysis is at best a great distraction from improving your security posture

Vulnerability management is less about managing vulnerabilities and more about managing expectations, CVSS/EPSS you name it, they are all largely awful. The number of CVE’s never gets smaller and your system never gets more secure.

Reachability analysis might give us some relief from this but I’ve yet to see if actually provide benefits to triaging vulnerabilities.

Sidebar: There are vendors attempting to solve these problems and I applaud them. I don’t get paid to say this but you should check them out.

Anyways on to the actual practical bits, this post is going to examine short term solutions in a very compressed manner.

Short Term

Pinning dependencies

Dependencies are typically specified within two key file types, manifest files and lock files. Manifest files package.json, go.mod) typically contain the list of direct dependencies e.g. ones you have explicitly installed via npm install or pip install . Lock files on the other hand contain not only the direct dependencies but they typically also contain indirect or transitive dependencies as well as some form of cryptographic verification information (largely used to reduce downloading things that haven’t changed or preventing corruption).

The dependencies within manifest files are declared typically using a constraint format such as package_name@>1.2.3 this would indicate that we are seeking to install the dependency package_name with a version greater than 1.2.3 from a package repository.

Version tags as represented within most dependency repositories are immutable, meaning once they are published the dependency version is typically available forever. There are circumstances where a package may need to be removed (such as cases of supply chain compromise) and the behaviour of the registries vary. You can see a breakdown of behaviours below.

Repository

Response Code

Removal Policy

Alternative Actions

NPM

404

Limited unpublishing (24-72 hours after publish) plus other conditions

Tombstone/placeholder for established packages

PyPi

404

Authors can delete releases but not entire projects easily

"Yanked" releases - hidden but metadata visible

Maven Central

Never returns 404

Never deletes - core immutability principle

Deprecation warnings only

RubyGems

404

Complete removal possible but discouraged

"Yank" versions - hidden from search

Cargo

Normal response with warning

Cannot delete once published

"Yank" versions - unavailable for new projects

NuGet

404

Complete deletion allowed for package owners

"Unlist" packages - hidden from search

We can see that some registries actually don’t have facilities for removal whatsoever rather they rely upon the behaviour of the package manager itself.

These behaviours combined with the typical constraint definition within manifest files create the first component of supply chain risk.

Going back to the package_name@>1.2.3 example. When all is working, a maintainer publishes 1.2.4 and you run npm install which ultimately results in you getting v1.2.4 of package_name. What happens if (like recent compromises) a bad actor publishes v.1.2.5 ? Well next time you run npm install you’re getting the bad version and its game over.

What can we do about this?

There are a few key things you can do to manage this a little better

Provide strict constraints

Define your dependencies using explicit constraints rather than wide constraints. For example you should specify [email protected] rather than package_name@>1.2.3

This only protects your direct dependencies, unfortunately most dependencies have dependencies and they may not specify their dependencies in a manner that is as strict as yours.

Use deterministic operating modes

A deterministic dependency manager produces identical results every time it runs with the same inputs, installing exactly the same versions of all packages regardless of when or where the installation occurs. This contrasts with non-deterministic behaviour where running the same install command might fetch newer versions of dependencies or resolve conflicts differently, leading to inconsistent environments across different machines or deployments. The largest cause of frustration and introducer of risk within dependency managers is when they operate in a non-deterministic manner, NPM is a great example of this behaviour:

Running npm install results in an interesting set of behaviours

  • Install performs a full dependency resolution which makes it slow. This process is necessary if you don’t already have a package-lock.json but is entirely superfluous if you do.

  • If dependencies (either direct or indirect) have loose constraints install will update them transparently without prompting. This is fine if you intend it, but can be a disaster when security is the aim.

Arguably this violates the entire point of a lock file but hey.

NPM offers an alternative install mode called ci which is designed to install exactly what is within the package-lock.json and will fail if it doesn’t match or can’t be installed.

So for NPM people:

  • Developers: Use npm install when adding or upgrading dependencies, then commit package-lock.json. Afterwards use npm ci

  • CI/CD & Production: Always use npm ci to guarantee a clean, reproducible install.

I’ve highlighted similar operating modes for other dependency tools below.

Package Manager

npm ci Equivalent

Deterministic

yarn

yarn install --frozen-lockfile or yarn install --immutable

Yes

pnpm

pnpm install --frozen-lockfile

Yes

bun

bun install --frozen-lockfile

Yes

pip

pip-sync (pip-tools)

⚠️ Limited (External tool)

Poetry

poetry install

Yes

Pipenv

pipenv sync

Yes

uv

uv sync --frozen

Yes

Bundler

bundle install

Yes

Cargo

cargo build --locked

Yes

Go Modules

go mod download + go build

Yes

Maven

N/A

No

Gradle

./gradlew build (with locks)

Yes

NuGet

dotnet restore --locked-mode

Yes

Composer

composer install

Yes

For those that are within the Python ecosystem I recommend the use of Astral’s uv dependency manager. It’s just all around better than any other dependency manager from a speed and safety perspective.

For those that are within the Javascript/Typescript ecosystem I recommend the use of bun or pnpm. Again these are just all around better than the other options.

Use deterministic operating modes of dependency managers combined with explicit version constraints to ensure you aren’t getting the latest versions of dependencies without explicitly seeking them.

Takeaway 1

Disabling or limiting lifecycle scripts

A common feature of dependency managers is their ability to run scripts through the lifecycle of the package install process e.g. pre/post install. These are also an extremely common vector for malware authors as they tend to be overlooked by security tooling. To highlight the use of these scripts I’ve done a bit of research and pulled together this table (please note this is using available data, I will provide a detailed research dataset in a public document)

Ecosystem

Total Packages

Script Usage Rate

Malicious use estimate (all packages)

npm (Node.js)

2.5M+

2.2% use install scripts (~55,000)

0.9-1.3%

ArXiv 2024, Socket 2023-2025

Python (PyPI)

530K+

~33% use setup.py (~175,000)

0.06-0.2%

Chainguard 2023, Multiple vendors

Ruby

158K+

Native extensions common (no specific stats)

0.5-0.8%

ReversingLabs 2020-2025, Socket 2025

Rust (Cargo)

140K+

build.rs scripts common (no specific stats)

<0.01%

RustSec Advisory DB

Golang

~1M+ (hard to estimate go packages are decentralised)

None (by design)

0%

Java (Maven)

Millions

Extensive lifecycle hooks (no specific stats)

<0.1%

Industry reports (limited data)

.NET (NuGet)

200K+

Limited in modern format (no specific stats)

<0.1%

Microsoft (limited data)

PHP (Packagist)

400K+

Root-only scripts, limited (no specific stats)

<0.05%

Community reports (limited data)

As we can see for some ecosystems there are relatively strong arguments for auditing and limiting or outright disabling pre-install scripts. I’d argue the strongest cases for these are:

Javascript

The Javascript ecosystem is the clearest case for disabling lifecycle hook scripts, several modern dependency managers such as bun and pnpm actually do this by default. They require you to explicitly enable each lifecycle hook for each dependency that has them. If switching dependency managers isn’t possible you are best served by disabling them via the npm ci --ignore-scripts command. If you’re in the rare situation where you need a script it is best to look at moving to bun or pnpm.

Python

The Python ecosystem is a little less clear, unfortunately there isn’t a direct guaranteed means of disabling pre-install scripts nor does the data really support it. There is the ability if you’re operating with a relatively well supported set of dependencies to force the installation of binary wheel formats rather than building anything from source.

Python as an interpreted language relies heavily upon system libraries and other common interfaces in C to get a lot of its work done. Some of these extensions need to be compiled but in most cases the interfaces they interact with are common enough across a set of systems (or the dependencies license permissive enough) that they can be pre-compiled and distributed as binary that will run across a majority of systems. In the Python ecosystem these are called wheels a wheel is basically zip file that contains the python source as well as the binary extensions.

Using wheels means you don’t execute the setup.py script which closes down a vector of risk. You can force pip and uv to attempt to only use wheels by passing the --only-binary=all command. This will fail if a dependency you’re using doesn’t have a wheel though. In pip you can also pass a list of dependencies that you want installed only from wheel so this allows you to find the ones that have setup.py scripts and specify them. Yes this is painful. Luckily uv is just plain better and allows you to pass --no-binary=some-other-package package_name as well, so you can specify exactly the dependencies to install from source and just audit those for risk. If you combine this with the previous suggestion of dependency pinning you are narrowing the window of risk down to when you legitimately update the dependencies that you allow pre-install scripts to run in during a supply chain attack.

Disable lifecycle scripts within the Javascript and Python ecosystems to limit your exposure to malware.

Takeaway 2
Java, NuGet

I am going to leave Java and NuGet for the next episode as there is a lot of medium and long term work we can do in these spaces but a little less short-term work.

Reply

or to participate

Keep Reading

No posts found