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 --immutablerather thanyarn installpnpm install --frozen-lockfilepip install -r requirements.txtwith--require-hashes, orpip-syncbundle install --deploymentcomposer installrather thancomposer updatego mod downloadwith-mod=readonlyand a verifiedgo.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:
- Use the frozen install command. No exceptions, including in local scripts people copy.
- Fail the build if the install modifies the lockfile.
- 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.