dependency pinning npm

Dependency Pinning for npm: Defending Against Supply Chain Attacks

Over the last several months, supply chain attacks against open-source libraries have become one of the most consistent threats facing development teams and businesses that vendor software. The most recent being the Axios npm package, which was only live for a matter of hours, but left widespread exposure.

Rather than revisiting the details of that specific incident, this article focuses on the practical steps you can take to defend against this type of attack, through dependency pinning and a few additional hardening practices.

How npm versioning works

When you install packages through npm you’ll store these packages and their installed versions in a package.json file. The entries here are your direct dependencies, and may look like this:

"dependencies": {
	"Package1": "1.2.3",
	"Package2": "^1.2.3",
	"Package3": "~1.2.3"
}

There are three types of dependency versions shown in the code above:

  • Versions prefixed with ~ indicate that patch updates are allowed to be installed (e.g. 1.2.3, 1.2.4, or anything below 1.3.0)
  • Versions prefixed with ^ allow both patch and minor versions to be installed (e.g. 1.2.2 > 1.3.0)
  • Version numbers without a prefix mean that minor or patch versions won’t be installed, only the exact version listed

The package lock file

With npm, after installing a package, you’ll generate a package-lock.json file. This file includes the full resolved dependency tree, which also includes the transitive dependencies (your dependencies of your direct dependencies). 

Each of the entries in this file will have a hash of the package, the path to where it was resolved from, and then its listed dependencies. 

This file should always be committed with the rest of the repository. It’s what allows the builds to be reproducible and is the basis of securing the project dependencies.

Pinning direct dependencies

Start by removing the ^ and ~ prefixes from the package.json file manually, or reinstall packages directly with the following to store the exact versions of the dependencies that you’re using:

npm install --save-exact [packageName@version]

You can make this a default configuration for all future installs, either individually for yourself or globally within a repository config file:

npm config set save-exact true

Or commit a .npmrc file to your repository so it applies to the whole team:

save-exact=true

Regenerate the package lock file

After pinning, regenerate your lock file without touching node_modules:

npm i --package-lock-only

Or do a clean reinstall after deleting the package-lock.json file and the node_modules directory:

npm install

Enforcing configuration with .npmrc

As mentioned above, the .npmrc file within your repository ensures that the npm configuration is consistent across each developer that interacts with the project and the CI environment, without having to manually include the npm input arguments each time.

Without this file, a developer’s individual npm config or omission of the various arguments could accidentally break the dependency pinning or introduce potential project risk. For example, if a developer installs a new package without the --save-exact argument this would include the semver range.

As a general recommendation, consider the following baseline .npmrc file:

save-exact=true
ignore-scripts=true

This enforces exact versioning and blocks install scripts for everyone working on the project, by default, without relying on individuals remembering to pass the right flags. Any exceptions to this, such as packages that require scripts to build, can then be handled explicitly via npm rebuild as covered below.

Overriding transitive dependencies

Sometimes a package that you depend on can pull in a vulnerable version of something that you don’t have direct control over. The overrides field in package.json lets you force a specific version anywhere in the tree:

"overrides": {
    "axios": "1.14.0"
}

This is one method of how you can remediate a compromised transitive dependency without waiting for an upstream maintainer to issue a new release. Yarn uses resolutions, whilst pnpm uses overrides with slightly different behaviour.

Deploying exact dependencies with npm

Always use npm ci in CI/CD pipelines and deployments. This ensures that the exact versions that are defined in the lock file are installed, and removed anything that shouldn’t be there.

npm ci

Unlike npm install, the npm ci command installs strictly from the lock file and will hard fail if package.json and package-lock.json are out of sync.

Blocking install scripts

The majority of recent supply chain attacks have been executed through the pre, post, or install scripts, where the code defined in one of these scripts is actioned within seconds of the npm install completing. The –ignore-scripts flag prevents this by preventing any of the install scripts:

npm install --ignore-scripts
npm ci --ignore-scripts

Care should be taken when using this argument as some native packages (e.g. node-gyp compiled modules) require the install scripts to build. Our recommendation here is to install all packages without scripts, audit any packages that break functionality, and then rebuild the individual packages that are trusted and require scripts.

npm install --ignore-scripts [email protected]
npm rebuild examplePackage

Rebuilding a package re-runs the build for the names package without causing the scripts to run for every other dependency. This is a common method for allowing trusted packages to use their defined scripts.

Alternatively, through pnmp you can define the individual packages that should be whitelisted. By default pnmp doesn’t allow lifecycle scripts to run, which is a direction npm should consider taking.

"pnpm": {
  "onlyBuiltDependencies": ["examplePackage1", "examplePackage2"]
}

Updating npm packages

When it comes time to update packages from the pinned versions, the latest version of npm has implemented the --min-release-age parameter. This new input now aligns with other package managers like pnpm by enforcing a minimum lifetime of a package before it can be installed.

npm install --min-release-age 7

In this example the updates without pinned versions would only apply if a new version had been published for at least 7 days.

The malicious Axios versions were only live for a few hours before being removed from the npm registry. A delay of just one day would have prevented exposure to this.

The limits of npm audit

Everyone should be running npm audit as part of their regular maintenance procedure, but in the case of this Axios compromise npm audit returned no vulnerabilities as the package could be considered a zero-day.

Implementing dependency pinning and performing a clean install via npm ci are the primary protection methods for malicious dependencies. The minimum release age within npm and pnpm are an extra fail-safe for any projects that may have missing transitive overrides or exact pinned versions.