Dependency integrity
Pinning, lockfiles, and dependency confusion.
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.
"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.
$ npm ci
npm error code EUSAGEnpm errornpm 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 errornpm error Missing: [email protected] from lock filenpm errornpm 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.
$ go mod verify
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.
requests==2.32.3 \--hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6 \--hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760
$ pip install --require-hashes -r requirements.txt
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 70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6Expected or 55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760Got 8b3d7f42c1029e6a4f8b0d353f9c1a7eb2d8406f5e1c93a07d4b28ff0a6e5c19
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.
$ docker inspect --format='{{index .RepoDigests 0}}' \gcr.io/distroless/static-debian12:nonroot
gcr.io/distroless/static-debian12@sha256:6706c73e82d5a3b9f6e6a8b3f4b6b9c8e2d1a0f9c8b7a6d5e4f3c2b1a0987654
# base pinned by digest, not by the mutable :nonroot tagFROM gcr.io/distroless/static-debian12@sha256:6706c73e82d5a3b9f6e6a8b3f4b6b9c8e2d1a0f9c8b7a6d5e4f3c2b1a0987654COPY --chown=nonroot:nonroot app /appENTRYPOINT ["/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.
jobs:build:runs-on: ubuntu-22.04steps:# 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.
$ npm view billing-utils version --registry=https://registry.npmjs.org/
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.
# 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.
$ npm config get @acme:registry
https://npm.acme.internal/
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.