Dependency integrity

Pinning, lockfiles, and dependency confusion.

Advanced14 min · lesson 5 of 18

You did not write most of the code you ship. A normal service pulls in hundreds of packages, and each of those pulls in more, until the code you typed by hand is a thin skin stretched over a mountain of other people's work. You inherit the security of every layer of it. Dependency integrity is the discipline of controlling exactly which bytes enter your build, and proving those bytes are the same ones you reviewed, not something swapped in along the way.

The lockfile is a tamper-evident receipt

When your manifest says express@^4, you have named a shelf, not a bottle. The caret means 'any 4.x release that happens to exist the moment someone runs install.' Two developers, or the same pipeline run a week apart, can walk away with different code from the identical manifest. A lockfile closes that gap. Think of it as the itemized receipt taped to a sealed shipment: for every package in the whole tree, the ones you asked for and the ones they quietly dragged in (its transitive dependencies, meaning the dependencies of your dependencies), it records the exact version, where it was fetched from, and a cryptographic hash.

package-lock.json
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvKg=="
}

That integrity line is the seal. It is a SHA-512 hash (Secure Hash Algorithm, a function that turns a file of any size into a short fixed fingerprint, where changing one byte changes the whole fingerprint) written in the Subresource Integrity format: sha512- followed by the hash in base64. The resolved field says where the bytes came from. On the next install, the tool downloads the tarball, hashes it, and compares. Match, and it unpacks. Mismatch, and it refuses. go.sum does the same job for Go modules with h1: hashes, and Cargo.lock does it for Rust.

Install from the lock, or the lock is theater

Recording the seal is worthless if you break it on every build, and this is the single most common mistake in dependency integrity. npm install is allowed to change the lockfile: if a newer matching version exists, it can quietly update the pin and rewrite the file. npm ci does the opposite. It deletes node_modules, installs strictly from package-lock.json, refuses to touch either file, and exits with an error if the lockfile and the manifest disagree. In continuous integration (CI, the automated system that builds and tests every change), you always run the locked, verifying install: npm ci, go mod verify, cargo build --locked, pip install --require-hashes. Never the loose one.

terminal
$ npm ci
output
npm error code EUSAGE
npm error
npm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.
npm error
npm error Missing: [email protected] from lock file
npm error
npm error A complete log of this run can be found in: /home/dev/.npm/_logs/2026-07-17T09_14_22_881Z-debug-0.log

That failure is the feature. Someone edited package.json without regenerating the lockfile, and CI stopped rather than resolving a fresh, unreviewed version on its own. Go carries this idea further with a shared public ledger. The first time your organization downloads a module, its hash is checked against the Go checksum database (sum.golang.org, an append-only transparency log run by Google that every Go user shares) and then written into your go.sum. go mod verify re-checks every module already sitting in your local cache against those recorded hashes.

terminal
$ go mod verify
output
github.com/acme/logger v1.4.0: dir has been modified (/home/dev/go/pkg/mod/github.com/acme/[email protected])

A clean run prints all modules verified and nothing else. The line above is what tampering looks like. The files sitting in your local module cache no longer hash to the value Go wrote down when it first fetched them, so something rewrote them after they were locked. That is the alarm you want. Python does not lock by default, so you turn it on. Pin exact versions and their hashes in requirements.txt, then install in hash-checking mode, which refuses any file whose hash is not listed.

requirements.txt
requests==2.32.3 \
--hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 \
--hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760
terminal
$ pip install --require-hashes -r requirements.txt
output
Collecting requests==2.32.3 (from -r requirements.txt (line 1))
Downloading requests-2.32.3-py3-none-any.whl (64 kB)
ERROR: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE. If you have updated the package versions, please update the hashes. Otherwise, examine the package contents carefully; someone may have tampered with them.
requests==2.32.3 from https://files.pythonhosted.org/packages/.../requests-2.32.3-py3-none-any.whl (from -r requirements.txt (line 1)):
Expected sha256 70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6
Expected or 55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760
Got 8b3d7f42c1029e6a4f8b0d353f9c1a7eb2d8406f5e1c93a07d4b28ff0a6e5c19
A lockfile you do not enforce is decoration
Committing package-lock.json helps only if CI runs the locked, verifying install (npm ci, not npm install; the strict variant for your ecosystem, not the loose one). The loose install can rewrite the lockfile mid-build and defeat the pin. And treat any unexplained lockfile change in a diff as a security event to review, not as formatting noise. It is often the first visible trace of a compromised dependency.

Pin the base image and the pipeline, not a floating tag

An application dependency is not the only thing you fetch by name. Your container image starts from a base like gcr.io/distroless/static-debian12:nonroot, and that tag is a label on the bottle, not the bottle. Whoever controls the registry (the server that stores and hands out images) can repaint the label and point nonroot at different bytes tomorrow. A digest cannot be repainted. It is the image's own SHA-256 content hash, so referencing the base by @sha256:... means a rebuild gets the exact bytes you reviewed or it fails outright. Resolve the digest once, then pin it.

terminal
$ docker inspect --format='{{index .RepoDigests 0}}' \
gcr.io/distroless/static-debian12:nonroot
output
gcr.io/distroless/static-debian12@sha256:6706c73e82d5a3b9f6e6a8b3f4b6b9c8e2d1a0f9c8b7a6d5e4f3c2b1a0987654
Dockerfile
# base pinned by digest, not by the mutable :nonroot tag
FROM gcr.io/distroless/static-debian12@sha256:6706c73e82d5a3b9f6e6a8b3f4b6b9c8e2d1a0f9c8b7a6d5e4f3c2b1a0987654
COPY --chown=nonroot:nonroot app /app
ENTRYPOINT ["/app"]

The same hole runs through your pipeline. A GitHub Actions step written as actions/checkout@v4 trusts a tag, and tags move. The person who owns that action can point v4 at new commits whenever they like, and that code runs with access to your repository and your secrets. Pin the step to a full 40-character commit hash instead, which names one immutable snapshot of the code, and leave the version in a comment so a human can still read what it is.

.github/workflows/build.yml
jobs:
build:
runs-on: ubuntu-22.04
steps:
# pinned to a commit, not the mutable v4 tag
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1

Dependency confusion, the impostor with a bigger version number

Now the sharp one. Say your build can reach both your private registry (the server that stores and hands out your internal packages) and public npm, and you depend on an internal package by a bare name like billing-utils, published only inside your company at version 2.1.0. Most resolvers, asked for billing-utils, look everywhere they can reach and pick the highest version they find, regardless of which registry it came from. An attacker who guesses that internal name (they leak constantly, in stack traces, old commits, and public build logs) publishes billing-utils to public npm at version 9.9.9. Your next loose install prefers the impostor. This is dependency confusion, shown at scale by Alex Birsan in 2021 against dozens of large companies.

terminal
$ npm view billing-utils version --registry=https://registry.npmjs.org/
output
9.9.9

Two changes shut the door, and you want both. First, put internal packages under a namespace you own, an npm scope such as @acme/, so a stranger on public npm cannot publish under @acme/ at all. Second, tell the resolver that anything in that scope comes only from your private registry, with no public fallback. A request for @acme/billing-utils then has exactly one place it can resolve, and an impostor has nowhere to stand.

.npmrc
# the @acme scope resolves ONLY here, never to public npm
@acme:registry=https://npm.acme.internal/
//npm.acme.internal/:_authToken=${NPM_TOKEN}

Verify the change the way an attacker would probe it: ask the tool where a scoped package actually resolves. If the scope points at your internal host, the public registry is unreachable for that name, and the higher-version trick has nothing to hijack.

terminal
$ npm config get @acme:registry
output
https://npm.acme.internal/
A scoped registry only protects that scope
@acme:registry directs the @acme scope to your private host, but it does nothing for bare internal names like billing-utils, which stay wide open to confusion. It also does nothing if your private registry is configured to transparently proxy or 'merge' public npm on a miss, because it can then hand you the public impostor anyway. Audit what your registry does when a package is not found locally, and move every internal package under a scope you own.
Pin every name in your build to something that cannot move
App dependencies
Pin
exact version + hash in the lockfile
Verify
npm ci / go mod verify / --require-hashes
Base image
Pin
FROM image@sha256: digest
Verify
rebuild matches the digest or fails
CI actions & plugins
Pin
uses: action@<full commit SHA>
Verify
tag lives in a comment, SHA in the ref
Mutable references (version ranges, tags, latest) are trust holes. Immutable identifiers (hashes, digests, commit SHAs) are the fix. The verb is the same everywhere: pin, then verify on install.
Quick check
01Your build can reach both your private registry and public npm. 'npm view billing-utils version --registry=https://registry.npmjs.org/' prints 9.9.9, while the copy published inside your company is 2.1.0. A loose install asks for the bare name billing-utils. Which one lands, and why?
Incorrect — Resolvers carry no built-in preference for your host. You get that ordering only after you configure a scope such as @acme to resolve there and nowhere else.
Incorrect — Two registries offering the same name is not an error condition. The candidates merge into one list and get ranked, which is the whole opening the attacker is counting on.
Correct — Both copies are in reach, so 9.9.9 beats 2.1.0 on the number and the impostor ships. Alex Birsan showed this working against dozens of large companies in 2021.
Incorrect — A token buys you access to the internal host, not preference over the public one. Authentication and resolution order are separate mechanisms.
02Your pipeline runs 'npm ci' and it exits with 'npm error code EUSAGE' and 'Missing: [email protected] from lock file'. What happened, and what is the right response?
Correct — The two files disagree and npm ci stops rather than picking a version nobody reviewed. Run the loose install locally, read the lock diff, and commit it like any other change to what you ship.
Incorrect — That hands the decision back to the loose install, which may write a newer matching version into the lock before npm ci ever looks at it. The pin is gone by then.
Incorrect — Read the message again: the package is missing from the lock file, not from the registry. The sync check runs before anything is downloaded.
Incorrect — Private packages install fine under npm ci once your registry configuration points at them. This error is about the manifest and the lock disagreeing, not about where bytes are served from.
03'go mod verify' reports: 'github.com/acme/logger v1.4.0: dir has been modified (/home/dev/go/pkg/mod/github.com/acme/[email protected])'. What does that line tell you?
Incorrect — This command never looks upstream. It rehashes what already sits in your module cache and compares that against the hash Go stored when it first fetched the code.
Incorrect — With no recorded hash there would be nothing to compare and you would get a different failure. This line means a comparison did run and came out wrong.
Incorrect — A healthy run says all modules verified and nothing more. Silence is the pass, so this line is the opposite of routine.
Correct — Stop the pipeline and work out what touched that directory. Wiping the cache and rebuilding hides the signal without answering how the bytes changed.

Wire all of this into code review. A pull request that changes a lockfile hash, bumps a base-image digest, or edits a pinned commit SHA is changing which code you will run in production, and it deserves the same scrutiny as a change to the source. An unexplained lockfile diff is not noise. It is usually the one place a supply-chain attack becomes visible before it ever runs.

Try this

Run go mod verify on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: a lockfile you do not enforce is decoration. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related