0x55aa
Back to Blog

๐Ÿ”’ Lockfile Poisoning: The File Nobody Reviews But Everyone Trusts

|
5 min read

๐Ÿ”’ Lockfile Poisoning: The File Nobody Reviews But Everyone Trusts

Quick honesty check: when was the last time you actually read a diff to package-lock.json before approving a PR?

Not scrolled past it. Not clicked "collapse" because it's 4,000 lines of resolved and integrity hashes that look like alien hieroglyphics. Actually read it, line by line, the way you read the three lines someone changed in package.json.

Never, right? Nobody does. And that's the whole attack.

The file that's supposed to be boring

Lockfiles exist to solve a real problem: package.json says "axios": "^1.6.0", which is a range, not a version. Without a lockfile, "works on my machine" becomes "worked on my machine, at that specific moment, with whatever version happened to be latest that Tuesday." Lockfiles pin everything โ€” every transitive dependency, every resolved URL, every integrity hash โ€” so a build is reproducible.

That's the pitch. In practice, the lockfile is also the single largest, least-read file in most repositories, and it's the file that npm install / yarn install / pnpm install trusts completely. Your CI pipeline doesn't re-derive what should be installed from package.json and sanity-check it against the lockfile. It just installs whatever the lockfile says, hashes and all. The lockfile isn't a cache of trust โ€” it is the trust.

So the attack isn't clever cryptography. It's much dumber and much more effective: get a bad entry into the lockfile, and get a human to approve the PR without reading 4,000 lines of JSON.

Three ways it actually happens

1. The dependency confusion sneak-in. A transitive dependency of a transitive dependency gets bumped in a routine npm update. Nobody reviews forty new sub-dependency lines โ€” they review whether the top-level package version in package.json looks sane. If an attacker has planted a similarly-named package upstream (public registry beats your internal one when resolution isn't scoped correctly), the lockfile quietly records the malicious resolved URL and hash, and every future npm ci installs it byte-for-byte, forever, until someone notices.

2. The malicious-but-legitimate-looking hash. This is subtler. Say a maintainer's npm account gets compromised and a backdoored 2.4.1 gets published for twenty minutes before it's pulled. If your lockfile was regenerated during that window โ€” someone ran npm install locally instead of npm ci, which happens constantly โ€” you now have a lockfile entry with a valid integrity hash for a compromised package. The hash isn't lying. It's honestly describing something bad.

3. The direct lockfile tamper. Someone with write access to a fork, or a compromised CI credential, edits package-lock.json directly โ€” not package.json โ€” to repoint a resolved URL at a malicious tarball host, while leaving the version string looking normal. Reviewers glance at package.json, see nothing changed, approve it. The lockfile is where the actual swap happened, and it's the file nobody was looking at.

What npm ci actually protects you from โ€” and doesn't

This is the part people get wrong. npm ci is stricter than npm install: it refuses to run if package.json and package-lock.json are out of sync, and it installs exactly what's pinned rather than re-resolving ranges. That's good โ€” it stops silent drift. It does not validate that what's pinned is trustworthy. It will happily and faithfully install a poisoned lockfile with zero complaints, because from npm ci's point of view, a poisoned entry looks identical to a legitimate one: a name, a resolved URL, a hash that matches the tarball at that URL.

# npm ci guarantees reproducibility, not integrity of intent.
# It answers "does this match the lockfile?" not "should this be in the lockfile?"
npm ci --ignore-scripts   # at minimum, don't let install scripts run unreviewed

The --ignore-scripts flag matters more than people think โ€” a huge share of real npm supply-chain incidents fire through postinstall, not through the code you import. If you haven't audited why a dependency needs a postinstall script, that's a separate rabbit hole worth its own read, but it compounds directly with lockfile poisoning: get a bad package into the lockfile, and the payload often doesn't wait for someone to call a function โ€” it runs the moment install finishes.

Making the lockfile diff actually reviewable

At Cubet, when we tightened this up for one of our Node services, the fix wasn't a fancy tool โ€” it was making the review surface smaller and the diff legible:

# .github/workflows/lockfile-check.yml
- name: Verify lockfile matches manifest
  run: npm ci --ignore-scripts

- name: Diff resolved registries against allowlist
  run: |
    jq -r '.packages[] | select(.resolved != null) | .resolved' package-lock.json \
      | grep -vE '^https://registry\.npmjs\.org/' && exit 1 || true

That second step is deliberately unglamorous: it just fails the build if anything in the lockfile resolves from somewhere that isn't your expected registry. It won't catch a compromised package published legitimately to npmjs.org, but it kills the "resolved URL quietly repointed to attacker infra" class of attack outright, and it costs about four lines of CI config.

The bigger cultural fix is treating a package-lock.json diff with new top-level-looking entries โ€” new package names, not just version bumps of existing ones โ€” as something that gets an actual second pair of eyes, the same way you'd review a new IAM permission. git diff --stat on the lockfile before merge takes ten seconds and tells you immediately whether "bump one dependency" quietly became "add fourteen new packages."

The uncomfortable takeaway

Lockfiles were built to solve nondeterminism, and they're excellent at that. But "deterministic" and "trustworthy" got conflated somewhere along the way, and attackers noticed before most of us did. The fix isn't abandoning lockfiles โ€” it's remembering that a file computers trust completely is exactly the file worth a human occasionally, deliberately, actually reading.


Got a lockfile horror story, or a better CI gate than the grep-and-pray one above? I'd genuinely like to hear it.

๐Ÿฆ Twitter/X ยท ๐Ÿ’ผ LinkedIn ยท ๐Ÿ™ GitHub

Share:LinkedInXHacker News

Thanks for reading!

Back to all posts