Safeguard
Software Supply Chain Security

A Lockfile Is Not a Control, the Install Command Is

You pinned every dependency with an integrity hash and committed the file. None of that means the artifact you shipped contains those versions, because several install commands are allowed to resolve differently and rewrite the lockfile.

Marcus Chen
DevSecOps Engineer
6 min read

You have a lockfile. It pins every dependency to an exact version with an integrity hash. You committed it, your scanner reads it, and your SBOM is generated from it.

None of that means the artifact you shipped contains those versions. A lockfile is a declaration of intent, and it only becomes a fact if the install command respects it, which several of them do not by default.

This post is the specific ways a lockfile and a running artifact drift apart, and how to detect it. For whoever relies on a lockfile for anything, which by now is everyone.

The install command is the whole problem

The most common cause, and it is one word.

npm install   # may resolve differently and REWRITE the lockfile
npm ci        # installs exactly the lockfile, fails if it disagrees with the manifest

npm install is allowed to update the lockfile when the manifest permits a newer version. In CI that means your build may resolve something the committed lockfile never named, and the lockfile change is discarded when the container exits. Nobody sees it.

The same distinction exists everywhere:

  • yarn install --immutable rather than yarn install
  • pnpm install --frozen-lockfile
  • pip install -r requirements.txt with --require-hashes, or pip-sync
  • bundle install --deployment
  • composer install rather than composer update
  • go mod download with -mod=readonly and a verified go.sum

Go and check which one your pipeline uses. In a meaningful share of builds it is the permissive form, chosen once because it worked locally.

Five other ways they diverge

The lockfile is not committed. Common in libraries, where the advice not to commit one is about consumers resolving their own tree. It still means your CI build is not reproducible, and your scanner is reading a manifest with ranges rather than facts.

Multiple lockfiles, one build. A monorepo with a root lockfile and per-package lockfiles, and a build that uses a different one than your scanner reads. Both files are real, they disagree, and each tool picked one.

The tool version changed. Lockfile format and resolution behaviour vary between major versions of the package manager. A lockfile written by one version and installed by another can resolve differently. Pin the package manager version in CI, the same as any other dependency.

Docker layer caching. A cached RUN npm ci layer means the install from three weeks ago is what is in the image, regardless of what the lockfile says today, because the layer key did not change in a way the builder noticed. This one is genuinely hard to spot because the build succeeds and the output looks right.

Post-install mutation. Anything that installs at runtime rather than at build time: a startup script fetching a plugin, a framework downloading a binary on first run, an in-cell install in a notebook. None of it is in the lockfile and all of it is in the running process.

Detect it by comparing the artifact to the file

The check that matters is not whether the lockfile is well-formed. It is whether the installed tree matches it.

# npm: fails if node_modules does not match the lockfile
npm ci --dry-run

# Did the install modify the lockfile? In CI this must be a failure.
npm ci && git diff --exit-code package-lock.json \
  || { echo "install modified the lockfile"; exit 1; }

# Python: compare what is installed against what was declared
pip freeze | sort > /tmp/installed.txt
diff <(sort requirements.txt) /tmp/installed.txt | head -30

The git diff --exit-code check is the highest-value line here. It is one command, it fails loudly, and it catches the permissive-install case permanently.

Then go one level further and compare the image to the lockfile, since that is what ships:

docker run --rm your-image:tag sh -c 'cd /app && npm ls --all --json' > installed.json
# compare the resolved versions in installed.json against the committed lockfile

A mismatch here means every downstream artifact is wrong: your SBOM, your scan results, and your answer to "were we running the affected version".

Why it matters more than it sounds

The consequences are all the same shape: something you believe is a fact turns out to be a claim.

Your SBOM is wrong, because it was generated from the lockfile rather than the artifact. During an incident you will answer the "did we ship the bad version" question from a document that describes a different build.

Your scan results are wrong in the same direction, and in the direction that under-reports.

Reproducibility is gone. You cannot rebuild the artifact you shipped, which means you cannot bisect across it or verify it.

An advisory response is guesswork. The one question that matters during a supply chain incident is which versions you actually ran, and lockfile drift is exactly what makes that unanswerable.

Make it a build invariant

Three lines of CI, and the problem is closed rather than monitored:

  1. Use the frozen install command. No exceptions, including in local scripts people copy.
  2. Fail the build if the install modifies the lockfile.
  3. Pin the package manager version.

Then generate your SBOM from the built artifact rather than from the lockfile, so that even if something slips, the document describes reality.

The concession

The permissive install exists for good reasons and is correct in some contexts. During local development you want the resolution to update when you change a manifest. For a library, pinning transitives in a committed lockfile can push conflicts onto your consumers.

So the rule is about position rather than principle: permissive when developing, frozen in CI and in any build that produces a shipped artifact. The failure is not that anyone chose the wrong command on purpose. It is that the same command ended up in both places, because it was copied from a README written for the first case.

The implication

A lockfile is not a control. The install command is the control, and the lockfile is its input.

Check which command your pipeline runs. That is a two-minute task, and it determines whether everything downstream, your SBOM, your scan, your incident response, is describing the thing you actually shipped.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.