






















The event-stream incident in 2018 was not subtle. A malicious actor took over a popular npm package used by a cryptocurrency project, injected a script that harvested private keys, and shipped it to thousands of downstream consumers. Nobody noticed for two months. Then in 2021, ua-parser-js, a package with 7 million weekly downloads, was hijacked and stuffed with cryptominers and credential stealers. In 2024, the eslint-scope compromise showed that even the linting tools in your pipeline are attack surface.
If you think “we pin our dependencies so we are safe,” you have not looked at your lockfile lately. Pinning versions does not stop a published update from being malicious. Your lockfile records a specific tarball hash, but if the attacker publishes a new version with the same semver range, npm fetches the new payload silently.
This is not theoretical. If you have ever run npm install without verifying the integrity of every package in your dependency tree, you have accepted trust as a security control. Trust is not a security control.
Here is the practical pipeline for securing your npm supply chain: automated auditing, lockfile integrity checks, provenance verification, dependency diffing in CI, and a review workflow that does not force every developer to become a full-time security auditor.
npm audit is the first line of defense and the most misunderstood. By default it compares your dependency tree against the npm Advisory database and surfaces known vulnerabilities. It is useful. It also produces so much noise that most teams disable it.
The problem is not the tool. The problem is running it without a policy. Here is what that looks like:
$ npm audit
# 45 vulnerabilities (3 low, 31 moderate, 8 high, 3 critical)
Three critical vulnerabilities sounds urgent. But one of those might be a prototype pollution in a dev dependency used only in tests, and another might be a denial-of-service vector in a URL parser that never touches user input. Do you block the deploy? Do you ignore the report? Neither. You annotate.
The fix is npm audit --audit-level=high combined with a script that lets you suppress known false positives while enforcing action on real threats. Create an .npm-audit-allowlist.json:
{
"ignore": [
{
"id": 1096098,
"reason": "Prototype pollution in dev-only mock library, no production exposure",
"expires": "2026-09-01"
}
]
}
Then run a script that cross-references the audit output against the allowlist:
// scripts/audit.js
import { readFileSync } from 'node:fs';
import { execSync } from 'node:child_process';
const allowlist = JSON.parse(readFileSync('.npm-audit-allowlist.json', 'utf-8'));
const ignoredIds = new Set(allowlist.ignore.map(i => i.id));
const result = JSON.parse(execSync('npm audit --json', { encoding: 'utf-8' }));
const failures = [];
for (const [pkg, advisories] of Object.entries(result.vulnerabilities || {})) {
for (const via of advisories.via || []) {
if (typeof via === 'object' && via.source && !ignoredIds.has(via.source)) {
failures.push(`${pkg}: ${via.title || 'unknown'} (${via.severity})`);
}
}
}
if (failures.length > 0) {
console.error('Blocking vulnerabilities found:\n' + failures.join('\n'));
process.exit(1);
}
console.log(`Audit passed. ${ignoredIds.size} findings suppressed.`);
Add it to your CI script:
{
"scripts": {
"audit": "node scripts/audit.js"
}
}
Now you can run npm run audit in CI without the noise. When a real vulnerability appears in a production dependency at high or critical severity, the build fails. When a low-severity issue in a dev-only mock surfaces, you annotate it with a reason and an expiry date, and somebody has to revisit it before the quarter ends.
Your package-lock.json contains integrity fields with SHA-512 hashes of every tarball. npm verifies these during install. The catch: npm only verifies what is already in the lockfile. If you run npm install <new-package> without a lockfile or if you merge a lockfile that was generated by a compromised npm client, you lose that protection.
Enforce lockfile presence and integrity with a pre-install check:
#!/bin/bash
# scripts/verify-lockfile.sh
LOCKFILE="package-lock.json"
if [ ! -f "$LOCKFILE" ]; then
echo "ERROR: $LOCKFILE is missing. Run 'npm install --package-lock-only' to generate it."
exit 1
fi
# Verify all integrity fields parse correctly
INVALID=$(node -e "
const lock = require('./$LOCKFILE');
const pkgs = lock.packages || {};
const issues = [];
for (const [path, meta] of Object.entries(pkgs)) {
if (meta.resolved && !meta.integrity) {
issues.push(path);
}
}
if (issues.length) console.log(issues.join('\n'));
")
if [ -n "$INVALID" ]; then
echo "ERROR: Packages missing integrity hashes:"
echo "$INVALID"
exit 1
fi
echo "Lockfile integrity check passed."
Add this as a preinstall hook or run it in CI before npm ci:
# .github/workflows/ci.yml step
- run: bash scripts/verify-lockfile.sh
- run: npm ci
This catches the scenario where a dependency update arrives without integrity metadata. It is rare, but it is the exact signal that something is wrong with the registry response.
npm provenance lets publishers attest that a package was built and published from a specific source repository and CI pipeline. When you install a package with provenance, you can verify that the published tarball matches the claimed source commit.
To check provenance on your existing dependencies:
npm query ":provenance"
This returns every package in your tree that has a signed provenance attestation. If a critical production dependency is missing provenance, that is worth a conversation. It does not mean the package is malicious, but it means nobody has bothered to set up the signing infrastructure.
For your own packages, enable provenance in your publish workflow so your consumers can verify you:
# .github/workflows/publish.yml
name: Publish
on:
release:
types: [published]
permissions:
id-token: write # Required for provenance
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- run: npm ci
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
The --provenance flag generates a signed attestation linking the published package to the exact commit and workflow run that produced it. Consumers who verify provenance can reject any version that lacks this attestation.
In your npmrc, you can enforce provenance requirements:
# .npmrc
provenance=true
This makes npm install warn or fail when a package is missing provenance, depending on your npm version.
npm diffPackage hijacks do not always bump the version number. An attacker can unpublish a legitimate version and republish a malicious one with the same version string. npm has a diff command that compares two package versions down to the file level:
npm diff --diff=lodash@4.17.21 --diff=lodash@4.17.21
If the hashes match, both tarballs are identical. If they do not match, you know the registry returned different content for the same version string. That is a red flag.
In practice, you do not diff every package manually. You automate it by caching the expected hashes after the first install and comparing them on subsequent installs:
# scripts/check-dependency-drift.sh
HASH_CACHE=".dependency-hashes.json"
if [ ! -f "$HASH_CACHE" ]; then
echo "Generating initial hash baseline..."
node -e "
const lock = require('./package-lock.json');
const map = {};
for (const [path, meta] of Object.entries(lock.packages || {})) {
if (meta.resolved) map[meta.resolved] = meta.integrity;
}
require('fs').writeFileSync('$HASH_CACHE', JSON.stringify(map, null, 2));
"
echo "Baseline saved. Commit this file to track dependency changes."
exit 0
fi
echo "Checking for drift..."
node -e "
const lock = require('./package-lock.json');
const cached = require('./$HASH_CACHE');
const drift = [];
for (const [path, meta] of Object.entries(lock.packages || {})) {
if (meta.resolved && cached[meta.resolved] && cached[meta.resolved] !== meta.integrity) {
drift.push({ name: path, resolved: meta.resolved, old: cached[meta.resolved], new: meta.integrity });
}
}
if (drift.length) {
console.log('Dependency integrity drift detected:');
drift.forEach(d => console.log(\` \${d.name}: integrity changed for \${d.resolved}\`));
process.exit(1);
}
console.log('No drift detected. All resolved tarballs match cached hashes.');
"
This does not catch a first-time malicious install. But it catches the scenario where a package you already vetted changes its tarball without a version bump. If you commit the hash cache to your repo and review changes to it in pull requests, an attacker cannot swap the tarball without somebody noticing.
The combination of static analysis and runtime checks catches most supply chain attacks, but you also need to enforce policy around what kinds of dependencies enter your project in the first place.
Use dependency-cruiser or a lightweight rules engine to block patterns that increase risk:
npm install -D dependency-cruiser
Create a .dependency-cruiser.js config that enforces rules:
module.exports = {
forbidden: [
{
name: 'no-deprecated-packages',
severity: 'error',
comment: 'Deprecated packages may stop receiving security patches at any time',
from: {},
to: {
dependencyTypes: ['deprecated']
}
},
{
name: 'no-unlicensed-packages',
severity: 'error',
comment: 'All dependencies must have a known license',
from: {},
to: {
licenseNot: [/MIT/, /Apache-2.0/, /ISC/, /BSD/, /Unlicense/]
}
},
{
name: 'no-git-dependencies',
severity: 'warn',
comment: 'Dependencies sourced from git URLs bypass registry integrity checks',
from: {},
to: {
dependencyTypes: ['git']
}
}
]
};
Then run it in CI:
npx depcruise src --output-type dot | npx depcruise-wrap-stream-in-html > report.html
Or use a simpler approach with a custom script:
# scripts/check-deps.js
import { readFileSync } from 'node:fs';
const pkg = JSON.parse(readFileSync('package.json', 'utf-8'));
const issues = [];
for (const [name, version] of Object.entries(pkg.dependencies || {})) {
if (version.startsWith('git+') || version.startsWith('github:')) {
issues.push(`Production dependency '${name}' uses git protocol (${version}). No integrity verification possible.`);
}
if (version.startsWith('file:')) {
issues.push(`Production dependency '${name}' uses local file path (${version}). Not reproducible in CI.`);
}
if (version.includes('*')) {
issues.push(`Production dependency '${name}' uses wildcard range (${version}). Unpredictable updates.`);
}
}
if (issues.length > 0) {
console.error('Dependency policy violations:');
issues.forEach(i => console.error(` - ${i}`));
process.exit(1);
}
console.log('All dependencies pass policy checks.');
The most effective tool for supply chain security is a human eyeball on every dependency change. Dependabot and Renovate both generate PRs with changelogs and diff summaries. That is table stakes. What most teams miss is that the diff needs to be reviewed with the same rigor as a code change.
When a dependency PR comes in, ask three questions:
maintainers field in the package registry. If the maintainer list changed since the last version you installed, the package may have changed hands.Automate the third question with a transitive dependency diff:
# Compare the lockfile in the PR branch vs main
git checkout main
npm ci
npm ls --all --json > /tmp/deps-main.json
git checkout PR_BRANCH
npm ci
npm ls --all --json > /tmp/deps-pr.json
# Parse and diff the transitive dependency lists
node -e "
const main = require('/tmp/deps-main.json');
const pr = require('/tmp/deps-pr.json');
const flatten = (tree, prefix) => {
const deps = {};
for (const [name, meta] of Object.entries(tree.dependencies || {})) {
const key = prefix ? prefix + '/' + name : name;
deps[key] = meta.version;
Object.assign(deps, flatten(meta, key));
}
return deps;
};
const mainDeps = flatten(main, '');
const prDeps = flatten(pr, '');
const added = Object.keys(prDeps).filter(k => !mainDeps[k]);
const removed = Object.keys(mainDeps).filter(k => !prDeps[k]);
if (added.length) console.log('New transitive deps:\\n' + added.join('\\n'));
if (removed.length) console.log('Removed transitive deps:\\n' + removed.join('\\n'));
if (!added.length && !removed.length) console.log('No change in transitive dependency tree.');
"
Add this as a CI job on pull requests targeting main. The output appears in the PR comments, and your reviewers can spot-check unexpected additions before approving.
Here is the complete GitHub Actions workflow that ties every check together:
# .github/workflows/dependency-security.yml
name: Dependency Security
on:
pull_request:
paths:
- 'package.json'
- 'package-lock.json'
push:
branches: [main]
paths:
- 'package.json'
- 'package-lock.json'
permissions:
contents: read
pull-requests: write
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- name: Lockfile integrity check
run: bash scripts/verify-lockfile.sh
- name: Dependency policy check
run: node scripts/check-deps.js
- name: npm audit with allowlist
run: node scripts/audit.js
- name: Dependency drift check
run: bash scripts/check-dependency-drift.sh
- name: Transitive dependency diff
if: github.event_name == 'pull_request'
run: |
git fetch origin ${{ github.base_ref }}
git checkout origin/${{ github.base_ref }}
npm ci --silent
npm ls --all --json > /tmp/deps-main.json
git checkout ${{ github.head_ref }}
npm ci --silent
npm ls --all --json > /tmp/deps-pr.json
node -e "
const main = require('/tmp/deps-main.json');
const pr = require('/tmp/deps-pr.json');
const flatten = (tree, prefix) => {
const deps = {};
for (const [name, meta] of Object.entries(tree.dependencies || {})) {
const key = prefix ? prefix + '/' + name : name;
deps[key] = meta.version;
Object.assign(deps, flatten(meta, key));
}
return deps;
};
const m = flatten(main, ''), p = flatten(pr, '');
const added = Object.keys(p).filter(k => !m[k]);
if (added.length) {
const body = '### New transitive dependencies\n\n' + added.map(d => '- ' + d).join('\n');
const { execSync } = require('child_process');
execSync(\`gh pr comment \${process.env.GH_PR_NUM} --body "\${body}"\`, { env: { ...process.env, GH_PR_NUM: '${{ github.event.pull_request.number }}' } });
}
"
This pipeline does not protect against:
lodash as lodashs. The policy check on transitive dependencies helps, but the strongest defense is npm-scope-based publishing and a package manager that warns on new unsigned packages.What the pipeline does solve: the most common supply chain attacks that target real projects today. Hijacked maintainer accounts, republished tarballs, malicious updates that slip through a human reviewer, and dependencies that silently change after review. The cost of setting this up is a few scripts and a CI workflow file. The cost of recovering from a compromised dependency is an incident postmortem, a forensic audit of every commit during the window of compromise, and a crisis-communication exercise you did not plan for.
Set up the pipeline. Suppress the noise. Review every dependency change like it is a code change. Then go back to shipping features without wondering whether your next npm install will be the one that makes the news.
The kind of work this post describes (enforcing dependency integrity, reviewing transitive dependency trees, maintaining an audit allowlist with expirations) is unglamorous and easy to deprioritize until an incident forces the issue. It is also the difference between a development pipeline you can trust and one where every npm install is a roll of the dice. That attention to production-grade infrastructure engineering is exactly what Yojji ships for its clients. Yojji is an international custom software development company, founded in 2016, with offices in Europe, the US, and the UK. Their teams specialize in the JavaScript stack (React, Node.js, TypeScript), cloud platforms (AWS, Azure, Google Cloud), and microservices architectures, delivering full-cycle product engagements that include security-conscious development practices out of the box.
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。