Skip to main content

Defending yourself against compromised npm packages

The recent software supply-chain attacks proof once again that the npm ecosystem is a double-edged sword. With over 2 million packages available, developers can build applications faster than ever before. But this convenience comes with a significant security risk. When a single compromised package can affect thousands of downstream projects, we need better defenses.

In this post, I'll show you how combining npm lock files with the --ignore-scripts flag creates a powerful security layer that can protect your projects from many common attack vectors.

The growing threat of supply chain attacks

Supply chain attacks in the npm ecosystem aren't theoretical—they're happening regularly. In recent years, we've seen high-profile incidents like the event-stream compromise, where a popular package was hijacked to steal Bitcoin wallets, and the ua-parser-js attack, where malicious code was injected to install cryptominers and password stealers.

These attacks often follow a pattern: malicious code executes during npm install through lifecycle scripts. The moment you run npm install, packages can execute arbitrary code on your machine via preinstall, install, or postinstall hooks. This happens before you've even run your application.

The problem is amplified by transitive dependencies. Install one package, and you might be pulling in dozens or hundreds of dependencies you've never heard of. Each one is a potential attack vector.

Your first line of defense: lock files

Most developers understand that package-lock.json ensures reproducible builds, but fewer recognize its security value. A lock file is essentially a snapshot of your entire dependency tree at a specific moment in time, including:

  • Exact version numbers for every package
  • Cryptographic hashes (integrity checksums) for each package
  • The complete dependency graph

When you commit your lock file to version control, you're creating a "known good" baseline. npm will verify that downloaded packages match these hashes, preventing tampering. If someone compromises a package on the registry after you've locked it, the hash won't match, and the installation will fail.

This protection only works if you use your lock file correctly:

The npm ci command (short for "clean install") is designed for automated environments like CI/CD pipelines, testing platforms, and production deployments where you need a deterministic, repeatable install of dependencies. It installs exactly what's in your lock file. It's faster than npm install and provides better security guarantees. So next to using it in your CI/CD pipelines, encourage your team to use it locally.

Your second line of defense: --ignore-scripts

While lock files protect against unauthorized changes to packages, they don't protect against malicious code that was already present when you locked the version. This is where --ignore-scripts comes in.

The --ignore-scripts flag prevents npm from executing any lifecycle scripts during installation:

npm ci --ignore-scripts

This simple flag dramatically reduces your attack surface. Malicious code that depends on running during installation simply won't execute. You've essentially neutered a primary attack vector.

You can also set this as a default in your .npmrc file:

ignore-scripts=true

With this configuration, scripts are disabled by default for all npm operations in your project.

Whitelisting

Before you rush to add --ignore-scripts everywhere, understand the trade-offs. Some legitimate packages need to run scripts during installation:

  • Native modules that need compilation (packages using node-gyp)
  • Build tools that need to download platform-specific binaries
  • Packages with platform-specific setup requirements

For packages that legitimately need scripts, you can selectively enable them:

 npm install puppeteer --ignore-scripts=false

While manually managing --ignore-scripts works, it can become cumbersome as your project grows. LavaMoat's @lavamoat/allow-scripts tool provides an automated way to manage lifecycle script allowlists, making it easier to maintain security at scale.

LavaMoat is a suite of security tools developed by MetaMask's security team to protect against supply chain attacks. The allow-scripts package specifically handles lifecycle script management by:

  • Automatically disabling all install scripts by default
  • Providing a declarative allowlist in your package.json
  • Failing fast if unexpected scripts try to run
  • Maintaining an audit trail of which packages need scripts

To use it, first setup the package

npm install -D @lavamoat/allow-scripts

Now you can intialize it for your project:

npm exec allow-scripts setup

This setup command adds ignore-scripts=true to your .npmrc (or enableScripts: false for Yarn), and installs @lavamoat/preinstall-always-fail as a dev dependency. This failsafe package throws an error if any lifecycle script attempts to run, catching configuration mistakes early.

Next step is to auto-generate your allowlist:

npm exec allow-scripts auto

This scans your dependencies and creates a configuration in your package.json:

By default, everything is set to false. You'll need to review and enable scripts for packages that legitimately need them.

Now you can run your allowed scripts with:

npm exec allow-scripts

This executes only the scripts you've explicitly allowed. If a new dependency tries to run a script that isn't in your allowlist, the installation fails with a helpful error message.

To summarize

No single security measure is perfect, but combining lock files with --ignore-scripts creates meaningful defense in depth:

  • Lock files ensure you're installing known, verified versions
  • --ignore-scripts prevents malicious code from executing automatically

Safe coding!

More information

npm Documentation: package-lock.json

npm Documentation: scripts

OWASP: Software Component Verification Standard

Popular posts from this blog

Azure DevOps/ GitHub emoji

I’m really bad at remembering emoji’s. So here is cheat sheet with all emoji’s that can be used in tools that support the github emoji markdown markup: All credits go to rcaviers who created this list.

Kubernetes–Limit your environmental impact

Reducing the carbon footprint and CO2 emission of our (cloud) workloads, is a responsibility of all of us. If you are running a Kubernetes cluster, have a look at Kube-Green . kube-green is a simple Kubernetes operator that automatically shuts down (some of) your pods when you don't need them. A single pod produces about 11 Kg CO2eq per year( here the calculation). Reason enough to give it a try! Installing kube-green in your cluster The easiest way to install the operator in your cluster is through kubectl. We first need to install a cert-manager: kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.14.5/cert-manager.yaml Remark: Wait a minute before you continue as it can take some time before the cert-manager is up & running inside your cluster. Now we can install the kube-green operator: kubectl apply -f https://github.com/kube-green/kube-green/releases/latest/download/kube-green.yaml Now in the namespace where we want t...

Podman– Command execution failed with exit code 125

After updating WSL on one of the developer machines, Podman failed to work. When we took a look through Podman Desktop, we noticed that Podman had stopped running and returned the following error message: Error: Command execution failed with exit code 125 Here are the steps we tried to fix the issue: We started by running podman info to get some extra details on what could be wrong: >podman info OS: windows/amd64 provider: wsl version: 5.3.1 Cannot connect to Podman. Please verify your connection to the Linux system using `podman system connection list`, or try `podman machine init` and `podman machine start` to manage a new Linux VM Error: unable to connect to Podman socket: failed to connect: dial tcp 127.0.0.1:2655: connectex: No connection could be made because the target machine actively refused it. That makes sense as the podman VM was not running. Let’s check the VM: >podman machine list NAME         ...