Skip to main content
BHPS

Writing

NPM Apocalypse 2025: How to sleep when the ecosystem is collapsing

Jan Szotkowski

If you follow the JavaScript world, you could not have missed it. Year 2025 was a complete disaster for the security of the NPM ecosystem.

We are not talking about a handful of cases. We are talking about hundreds of packages that were compromised. Attackers got into maintainer accounts (often through social engineering or leaked tokens) and smuggled malicious code into legitimate, widely used libraries.

Some of those packages had millions of downloads a week. Picture it: you come into work on Monday morning, run npm install, and without suspecting a thing you pull malware into the project. It steals your ENV variables or injects a cryptominer into your users' browsers.

It is frightening. And honestly, if you are relying on luck, it is only a matter of time before it hits you too.

Three layers of manual defence

The first instinctive reaction is: "I'll stop using NPM!" That is a nice thought, but in modern frontend development it is about as realistic as building a skyscraper with a hammer and nails. We cannot do without the React, TanStack, or Tailwind ecosystem today.

There are steps I take to keep the risk down:

1. Minimalism and forking

Before I add a new dependency, I ask: "Do I really need a whole library to format a date?" If it is a small utility, I would rather write it myself. If it is a more complex library that still carries a lot of ballast, I often make a fork. I take only the part of the code I need and put it straight into my repository. That cuts the cord to future updates (which may be infected).

2. Disabling scripts (ignore-scripts)

Most of these attacks rely on postinstall scripts. Those are commands that run automatically right after the package is downloaded. Attackers love them. It is the perfect place to hide a curl that sends your .env files to their server.

The defence is simple. In the project root (next to package.json) add an .npmrc file with this content:

ignore-scripts=true

That disables these scripts globally. Yes, it occasionally breaks the install of a legitimate tool (esbuild or Cypress, for example), but you can fix that by allowlisting specific packages. The security is worth it.

3. pnpm, and versions that have been out for a while

npm downloads a package the minute someone publishes it. An attacker ships a version, you run the install in that same moment, and it is on your machine.

pnpm, from version 12, does not do that by default. It has a rule called minimumReleaseAge: it only downloads a version that has been on the registry for more than 24 hours. The value is in minutes, and the default is 1440 (one day). Most of those compromised releases are found and removed from the registry within the first few hours. A day's delay cuts you off from them before you download them.

A day is not enough for me. I want versions that are 7 to 14 days old, that have been out for a while, and that nobody has had to yank as malware. You set it in pnpm-workspace.yaml. Seven days is 10080 minutes, fourteen is 20160:

# 7 days. If the project can wait two weeks, put 20160 here.
minimumReleaseAge: 10080

This applies to transitive dependencies too, not only what you listed in package.json. You can still install a newer version when its turn comes. It just will not be the one that came out ten minutes ago.

Automation: when a person is not enough

Manual steps are fine, but they have one weakness: me. I forget. I do not have time every morning to read security reports and check whether lodash 4.17.21 happens to be vulnerable.

I spent a long time looking for a tool that would watch my back. I needed something that:

  1. Runs automatically (CI/CD).
  2. Knows the vulnerability databases (GitHub Security Advisories, OSV).
  3. Will not bother me with updates I do not need, but will shout when it is a security issue.

The winner was Renovate Bot.

My "security-first" config

Most people know Renovate as the tool that bumps package versions for you. "A new React version is out? Here is a PR."

I use it differently. On stable projects I do not want the bot "breaking" the app with minor updates I do not need. I want it to stay quiet and speak up only when my current version contains a security hole.

This is the configuration I am testing right now, and it lets me sleep:

{
    "$schema": "[https://docs.renovatebot.com/renovate-schema.json](https://docs.renovatebot.com/renovate-schema.json)",
    "extends": ["group:turboMonorepo", ":semanticCommits"],
    "timezone": "Europe/Prague",
    "prConcurrentLimit": 10,
    "prHourlyLimit": 0,
    "dependencyDashboard": true,
    "dependencyDashboardApproval": false,
    // This is where it starts: turn on security alerts
    "vulnerabilityAlerts": {
        "enabled": true,
        "labels": ["security", "vulnerability"]
    },
    "osvVulnerabilityAlerts": true,
    "automerge": false,
    "rangeStrategy": "pin",
    "schedule": ["at any time"],
    // Rule that disables ALL updates...
    "packageRules": [
        {
            "description": "Disable all non-security updates",
            "matchUpdateTypes": ["major", "minor", "patch", "pin", "digest", "lockFileMaintenance", "rollback", "bump", "replacement"],
            "enabled": false
        }
    ]
}

How does it work?

Look at the packageRules section. It explicitly says: "Disable (enabled: false) every update type (major, minor, patch...)."

If I stopped there, Renovate would do nothing. But because I have vulnerabilityAlerts and osvVulnerabilityAlerts turned on above, Renovate still checks the vulnerability databases.

The result?

  • axios v1.8.0 comes out (a new feature)? Silence. Renovate leaves me alone.
  • It turns out axios v1.7.0 has a security hole? ALARM. Renovate immediately opens a pull request with the fix to a safe version and tags it as security.

Wire this config into a GitHub or GitLab CI pipeline and run it once a day (or every hour).

That way I know that even while I am asleep, my repository is being compared with the latest threat databases. It is not 100% protection (that does not exist), but it is much better than hoping it will pass me by.