Securing Transitive Dependencies in End-of-Life Software: A Guide
How to Secure Vulnerable Transitive Dependencies in EOL npm Packages
.png)
One of npm’s greatest strengths is its robust ecosystem of packages. Installing a direct dependency is as easy as running npm install <package-name>. However, installing a package also pulls in all of that package’s dependencies (transitive dependencies). Transitive dependencies can introduce security vulnerabilities just as easily as direct dependencies, but as an application developer you have less control over managing transitive dependencies. This situation gets even more complicated when some of your installed packages are End-of-Life (EOL).
This guide will provide practical strategies for mitigating security risks associated with transitive dependencies in EOL software. We'll explore how npm resolves package versions in general, illustrate real-world examples of vulnerabilities related to using pinned versions, discuss why version ranges like caret (^) and tilde (~) ranges are useful shorthands for getting recent security patches, and demonstrate how to use npm update and npm overrides to patch these vulnerabilities. Finally, we'll review how HeroDevs Never-Ending Support (NES) products offer security patches when transitive dependencies themselves reach EOL. By the end of this guide, you'll have a solid understanding of how to secure your applications against vulnerabilities in EOL software.
1. Pinned Versions and Security Vulnerabilities
A fundamental part of securing transitive dependencies is understanding how npm resolves package versions according to the SemVer specification.
As a quick refresher, here is a summary of the SemVer specification:
Given a version number MAJOR.MINOR.PATCH, increment the:
- MAJOR version when you make incompatible API changes
- MINOR version when you add functionality in a backward compatible manner
- PATCH version when you make backward compatible bug fixes
When installing a package, you can pin an exact version: npm install <package-name>@MAJOR.MINOR.PATCH --save-exact. This ensures that only version MAJOR.MINOR.PATCH is installed, preventing any updates. This is beneficial to ‘shrinkwrap’ your product with a specific set of dependencies, but it makes it harder to pull recent security patches.
Let’s see how using pinned versions can result in avoidable security risks. Assume your package.json contains "resolve-url-loader": "3.1.2". When you run npm install, it will install resolve-url-loader@3.1.2, which strictly pins its transitive dependency loader-utils@1.2.3—a version with a known security vulnerability. As we’ll discuss later on in this post, there is another version of loader-utils in major version 1.x with a security patch. But if you use a pinned version your application will fail to install the secure version.
2. How Version Ranges Help Fix Vulnerabilities
Pinning dependencies is not the only way to install packages. Npm also allows users to specify a range of acceptable package versions.
For example, you can use comparators like >= (greater than or equal to) and < (less than) to allow any packages greater than or equal to v1.2.3 but less than v2.0.0: >=1.2.3 <2.0.0.
SemVer includes shorthands like caret (^) and tilde (~) ranges that make it easier to pull recent security patches within a given range. The caret range and tilde range allows npm to install newer versions within a compatible range:
- ^1.2.3 → Allows updates to minor versions 1.x.x, but not to major version 2.0.0
- ~1.2.3 → Allows updates to patch versions 1.2.x, but not major/minor 1.3.0
Many npm packages only support the latest minor version of a major. This means that any changes (including security fixes) do not land in older minor versions. Caret ranges include minor version updates, whereas tilde ranges are limited to patches.
Going back to our previous example, let’s add a caret to the version in your package.json: "resolve-url-loader": "^3.1.2". Running npm install will now allow resolve-url-loader to update to 3.1.5, which in turn uses loader-utils@1.4.2—a patched version that fixes the vulnerability.
A Note About npm install and npm ci
The default behavior of npm install is to save package versions with the ^ prefix (caret range). As discussed in a 2022 npm Request For Comments, this has the presumed intention of automatically opting users into security patches. As the RRFC points out though, the caret range can inadvertently introduce security risks if npm install is used in production: if a library is compromised somehow, the author can publish a PATCH/MINOR version that performs malicious actions and is brought into a production build. As noted in the RRFC, npm ci should be used in production to avoid this behavior. For more details, see the npm ci documentation.
Key Takeaway: Caret ranges (^) aren’t the only solution for getting security patches, but they generally strike a good balance between maximizing your chances of receiving a fix while minimizing the risk of getting a breaking change.
3. Fixing Transitive Dependency Vulnerabilities with npm update
The npm update command fixes vulnerabilities in patched transitive dependencies without manual modifications to your package.json.
This command updates all packages to the latest version, respecting the SemVer constraints of both your package and its dependencies. You can run npm help update to get a full description of this command and several examples showing how the various flags are used.
Example Scenario:
- Your app depends on "resolve-url-loader": "^3.1.2", but your package-lock.json was last updated before loader-utils@1.4.2 was released.
- Running npm ci (and in some cases npm install) will continue using the vulnerable version of loader-utils.
To resolve this, simply run npm update. This updates the package-lock.json to the latest compatible versions (within the ranges specified in each package’s package.json) automatically upgrading loader-utils to its patched version.
Key Takeaway: Running npm update periodically can help resolve CVEs, especially in legacy projects.
4. Using Overrides When npm update Falls Short
What if your direct dependency is EOL and pins its transitive dependencies?
For example, @angular-devkit/build-angular v10 pins "resolve-url-loader": "3.1.2", meaning an application that uses this version of build-angular is stuck with the vulnerable loader-utils@1.2.3.
Solution: Use npm overrides
Overrides allow you to force a more up-to-date version of a transitive dependency no matter what version your dependencies rely on:
"overrides": {
"loader-utils": "^1.4.2"
}
This ensures that a patched version of loader-utils is installed, even if an EOL dependency tries to pin an older version.
It is also possible to only override transitive dependencies when its a child (or grandchild, or great grandchild, etc.) of a specific dependency:
"overrides": {
"resolve-url-loader": {
"loader-utils": "^1.4.2"
}
}
For more details, see the npm overrides documentation.
Tip: when using overrides, ensure that a compatible version of npm is installed. Overrides were introduced in npm v8.3.0.
5. When Transitive Dependencies Themselves Are EOL
The above solutions work when security patches exist–but what if a transitive dependency itself is EOL and no patches are available?
And consider a related scenario: what if old versions of a transitive dependency are EOL even if recent versions are secure? For example, @angular-cli/build-angular@0.901.15 has a transitive dependency on http-proxy-middleware@0.19.1, which in turn is vulnerable to CVE-2024-21536. Both products have some EOL versions and some maintained versions. There is no security patch available for 0.x of http-proxy-middleware. Theoretically, if you depend on build-angular@0.901.15 you could use overrides to force your application to install a patched version of http-proxy-middleware. However, you’d have to install a different major version (v2.0.7) which could cause breaking changes to build-angular and therefore your application.
In some scenarios, HeroDevs NES products may help.
HeroDevs can provide secure, maintained versions of critical EOL dependencies and, at times, certain transitive dependencies, which may help keep your application protected. For example:
- Our Web Essentials secures some widely used transitive dependencies, including http-proxy-middleware@0.19.x.
- For all Angular CLI NES users, we’ve bumped loader-utils versions to a secure and compatible version, eliminating the need for overrides.
- For Nuxt 2 NES users, we’ve included the NES version of the EOL ip transitive dependency.
Even when we don't update transitive dependencies, we always provide remediation recommendations to help our customers stay secure.