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 commitpackage-lock.json
. Afterwards usenpm 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 |
| ✅ Yes |
pnpm |
| ✅ Yes |
bun |
| ✅ Yes |
pip |
| ⚠️ Limited (External tool) |
Poetry |
| ✅ Yes |
Pipenv |
| ✅ Yes |
uv |
| ✅ Yes |
Bundler |
| ✅ Yes |
Cargo |
| ✅ Yes |
Go Modules |
| ✅ Yes |
Maven | N/A | ❌ No |
Gradle |
| ✅ Yes |
NuGet |
| ✅ Yes |
Composer |
| ✅ 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.
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.
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.