Supply chain security for npm-heavy projects
A developer gets a security alert from GitHub: a dependency three levels deep in her project's transitive dependency tree has a known RCE vulnerability. The vulnerable package is request, which she has never heard of. It was installed transitively because a depends on b which depends on c which depends on request. The vulnerability is a year old. Her project has been shipping it to users for a year.
npm projects routinely have 500-1,500 unique packages in node_modules. Each is authored by someone outside the team. Most are maintained — but "maintained" is not the same as "secure." The surface area for supply chain attacks is not zero. The question is not whether to care about supply chain security, but what level of protection is proportional to the project's risk profile.
Why supply chain risk is higher than it appears
The mental model most developers have for dependencies is: "I added lodash for utility functions, and it is well-maintained, so it is safe." This model misses two things.
First, every dependency brings its own dependency tree. A package with 10 direct dependencies, each of which has 10 of their own, results in 100 transitive packages you did not explicitly choose. Each of those package maintainers is a potential vector: their account can be compromised, their package can be typosquatted, or their package can be abandoned and transferred to a malicious new maintainer.
Second, npm packages execute code at install time. A malicious package does not need to wait for your code to use it — postinstall scripts run during npm install, with whatever permissions the process has. A supply chain attack that targets the install phase can exfiltrate environment variables, SSH keys, and credentials before your code runs a single line.
The incidents that illustrate this are not theoretical. The event-stream compromise in 2018, the ua-parser-js hijack in 2021, and the node-ipc protest-ware in 2022 all demonstrated that popular, trusted packages can be compromised or weaponized. Each reached millions of projects through transitive dependencies.
The threat model
Supply chain attacks on npm packages take several forms:
Typosquatting — publishing a malicious package with a name similar to a popular package. A developer installs lodahs instead of lodash by typo. The malicious package runs code at install time.
Account takeover — an attacker gains access to a maintainer's npm account and publishes a new version containing malicious code. The package name is legitimate; the new version is not.
Dependency confusion — in organizations with private npm packages, an attacker publishes a public package with the same name as an internal private package. If the registry configuration prefers public packages for this name, the public (malicious) package is installed instead.
Compromised package — a legitimate package is abandoned and the name is transferred to a new (malicious) maintainer, or a legitimate maintainer's machine is compromised.
Protestware — a maintainer deliberately introduces breaking or malicious behavior into their own package as a form of protest. The package is "legitimate" in the sense that the maintainer controls it, but the behavior is adversarial.
Lockfiles and reproducible installs
package-lock.json (npm) or yarn.lock records the exact version and content hash of every installed package. Running npm ci (not npm install) in CI ensures the installed packages match the lockfile exactly.
# CI should use 'npm ci', not 'npm install'
# npm ci:
# - Installs exactly what is in package-lock.json
# - Fails if package.json and package-lock.json are out of sync
# - Verifies content hashes
# npm install:
# - May update lockfile
# - May install newer patch versions
# - Does not verify hashes on re-install
# GitHub Actions — use npm ci
- run: npm ci
# Not: npm install
Lockfiles should be committed to the repository. Teams that gitignore lockfiles lose reproducible builds and supply chain protection. A common reason for gitignoring the lockfile is that it "creates merge conflicts" — but those conflicts are the lockfile correctly reflecting that two branches added different dependencies and the conflict must be resolved explicitly.
The lockfile is a security artifact. Treat it as one.
Audit and update tooling
# npm audit: check for known vulnerabilities in direct and transitive dependencies
npm audit
# npm audit fix: automatically update packages with low-risk fixes
npm audit fix
# For high-severity vulnerabilities that cannot be auto-fixed:
npm audit --audit-level=high
# Returns exit code 1 if any high-severity vulnerabilities exist
# Use in CI to fail builds on high-severity issues
# GitHub Actions — fail CI on high-severity vulnerabilities
- name: Security audit
run: npm audit --audit-level=high
# Pipeline fails if any high or critical vulnerabilities are present
Automated security alerts via GitHub Dependabot or similar tools send PRs when vulnerabilities are patched in new versions:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
groups:
# Group minor and patch updates together for faster review
minor-and-patch:
update-types:
- "minor"
- "patch"
# Review major updates individually — they may contain breaking changes
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
Dependabot PRs should be reviewed and merged promptly. A dependency update PR that sits for three months while the vulnerability it fixes is actively exploited provides no protection. Set a service-level expectation for dependency update review: high and critical CVEs within 24 hours, others within a sprint.
Package verification
npm packages can be verified using the package's published registry manifest, which includes integrity hashes. npm ci performs this verification automatically. For additional assurance:
# Verify a specific package's integrity
npm pack lodash --dry-run
# Shows what would be installed — useful for reviewing new packages before installing
# Use npm-audit-resolver to document decisions about vulnerabilities you accept
npm install -g npm-audit-resolver
npm-audit-resolver
# Creates audit-resolve.json documenting accepted risks
For verifying that the published package matches the source repository (important for high-value dependencies), tools like npm-package-json-lint and manual inspection of the package contents can reveal unexpected files:
# Inspect what is inside a package before installing
npm pack <package-name> --dry-run 2>/dev/null | head -50
# Shows files that would be included in the package
# Alternatively, download and inspect manually
npm pack <package-name>
tar -tf <package-name>-<version>.tgz
Minimizing the attack surface
The most effective supply chain protection is having fewer dependencies:
# Identify unused dependencies
npx depcheck
# Reports dependencies declared in package.json but not actually imported
# Identify dependencies with minimal community maintenance
# (npm provides download counts and last-publish dates)
npm view <package-name>
# Consider bundling utilities instead of adding a dependency
# Is 'npm install left-pad' worth the supply chain risk?
# Better: write 5 lines of code
function padStart(str: string, len: number, char = ' '): string {
return str.length >= len ? str : char.repeat(len - str.length) + str;
}
Before adding any new direct dependency, answer:
- What does this package do that could not be implemented in 50 lines of code?
- When was it last published? (A package not updated in 3 years may be abandoned)
- How many maintainers does it have? (Single-maintainer packages are higher risk)
- How many weekly downloads does it have? (High download counts mean more scrutiny)
- Does the source code match what is published? (Check using
npm pack --dry-run)
For packages that provide significant utility, the dependency is often worth it. For packages that provide a single function or a thin wrapper, the supply chain risk often outweighs the convenience.
Dependency confusion mitigation
For projects with private npm packages, configure the registry to prevent dependency confusion:
# .npmrc — scope private packages to a private registry
@mycompany:registry=https://npm.pkg.github.com/
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}
# All packages in the @mycompany scope use the private registry
# Other packages use the public npm registry
# An attacker cannot publish a @mycompany package to the public registry
For packages without a scope (common in older internal tools):
# .npmrc — configure private packages to only install from private registry
always-auth=true
registry=https://npm.pkg.github.com/mycompany/
# Or use a block list of internal package names in npm config
# to prevent installation from the public registry
Scoping is the more robust solution. Internal packages under a @mycompany scope cannot be published to the public npm registry by an attacker because the scope is owned by the organization. Packages without a scope can be confused with public packages with the same name.
Restricting install-time scripts
npm's postinstall scripts are the mechanism that makes supply chain attacks dangerous. They can be disabled:
# Prevent all lifecycle scripts from running during install
npm ci --ignore-scripts
# Or set in .npmrc to apply to all installs in the project:
# ignore-scripts=true
Note: --ignore-scripts prevents the malicious scripts, but also prevents legitimate build scripts. Some packages (like native add-ons) require lifecycle scripts to compile. Review whether any direct dependencies need lifecycle scripts before enabling this flag globally.
For CI environments, --ignore-scripts is often appropriate — the build step handles compilation explicitly, and there is less reason for package install hooks to run.
The practical baseline
The practices that cover 90% of supply chain risk without significant overhead:
- Commit
package-lock.json— enables reproducible installs and hash verification - Use
npm ciin CI — enforces the lockfile - Enable GitHub Dependabot — automated PRs for vulnerability fixes
- Run
npm audit --audit-level=highin CI — fail builds on critical vulnerabilities - Review new direct dependencies before adding them — check maintenance status, download count, and the identity of the maintainer
The developer whose project shipped a year-old vulnerability would have caught it with items 3 and 4. Dependabot would have filed a PR within days of the CVE being published; the CI audit check would have flagged it in the next build.
Item 5 is the hardest to operationalize but the most valuable for preventing future incidents. A brief dependency review at PR time — three minutes checking npm view <package> before merging a PR that adds a new dependency — is a proportionate investment for the risk it reduces. Most teams do not do this review because it is not in the PR template. Add it to the template and it will happen consistently.
Software Bill of Materials (SBOM)
An SBOM is a machine-readable inventory of every dependency in a project, including transitive dependencies, their versions, and their licenses. It is the supply chain equivalent of a product's ingredient list.
Generating an SBOM for an npm project:
# Generate SBOM in SPDX format using CycloneDX
npm install -g @cyclonedx/cyclonedx-npm
# Generate SBOM
cyclonedx-npm --output-format JSON --output-file sbom.json
# Or use syft for a broader SBOM that includes OS-level dependencies
syft dir:. -o spdx-json=sbom.spdx.json
The SBOM serves two purposes:
- Vulnerability scanning. Tools like Grype can scan an SBOM for known CVEs without reinstalling dependencies:
# Scan SBOM for vulnerabilities
grype sbom:./sbom.json
# Reports vulnerabilities in all direct and transitive dependencies
# Faster than running npm audit because it works offline against cached CVE data
- License compliance. The SBOM lists the license for each package. Legal requirements may prohibit including GPL-licensed code in a commercial product, or require attribution for MIT/Apache packages:
# List all licenses in the project
npx license-checker --json | jq 'to_entries | map({package: .key, license: .value.licenses})'
# Fail if any GPL licenses are present (for commercial products)
npx license-checker --failOn 'GPL-2.0;GPL-3.0;LGPL-2.0;LGPL-2.1;LGPL-3.0'
Add license checking to CI for projects with license compliance requirements:
- name: Check dependency licenses
run: npx license-checker --production --failOn 'GPL-2.0;GPL-3.0'
Monitoring for new vulnerabilities after deployment
Vulnerability databases are updated continuously. A package that was secure when deployed may have a CVE filed against it the following week. Continuous monitoring — as opposed to point-in-time scanning — catches vulnerabilities that appear after the code ships.
GitHub Dependabot provides this automatically for repositories hosted on GitHub: it monitors the npm Advisory Database and files PRs when vulnerabilities are published for any package in the lockfile. The PR includes the severity, the CVE reference, and the minimum version that addresses the vulnerability.
For non-GitHub repositories or for additional coverage, integrate with a vulnerability database API:
# Weekly vulnerability scan in CI, independent of dependency updates
# .github/workflows/vuln-scan.yml
on:
schedule:
- cron: '0 8 * * MON' # Monday mornings
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm audit --audit-level=moderate --json > audit-report.json
continue-on-error: true
- name: Create issue on new vulnerabilities
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const audit = JSON.parse(fs.readFileSync('audit-report.json'));
const vulnCount = Object.keys(audit.vulnerabilities ?? {}).length;
if (vulnCount > 0) {
await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: `Weekly security scan: ${vulnCount} vulnerabilities found`,
body: `Run \`npm audit\` locally for details. Severity breakdown in audit-report artifact.`,
labels: ['security', 'dependencies'],
});
}
The combination of Dependabot for automated PRs and weekly scheduled scans provides layered coverage: Dependabot catches most CVEs within days of publication; the weekly scan catches anything Dependabot misses and produces a regular report that keeps vulnerability awareness on the team's radar.
The supply chain risk posture over time
Supply chain risk is not static. The risk level of a project changes as:
- New dependencies are added
- Existing dependencies go unmaintained
- New CVEs are published
- The npm ecosystem's threat landscape evolves
A project that was low-risk at launch can become high-risk a year later through dependency accumulation and abandonment. The practices in this article — lockfile enforcement, audit checks, Dependabot, dependency review — are not one-time setup tasks. They are ongoing maintenance that keeps the supply chain risk posture from degrading over time.
The developer whose project shipped a year-old RCE vulnerability did not make a bad decision when she added the dependency. The vulnerability was not known when she added it. The failure was in the monitoring: no Dependabot, no audit checks, no process for catching vulnerabilities that emerged after deployment. The fix is not better judgment at dependency selection time — it is continuous monitoring that catches post-deployment vulnerabilities before they become incidents.