The colors package printed garbage to every Node.js console in January 2022. The maintainer pushed it deliberately. The node-ipc package wiped files on machines with Russian IP addresses two months later — also intentional. event-stream shipped a Bitcoin wallet stealer hidden inside a legitimate utility. These weren’t zero-days or nation-state exploits. They were ordinary npm install commands run by developers who thought their lockfile had them covered.
It didn’t.
A lockfile tells your package manager which version to install. It says nothing about whether the bytes that show up are actually what the registry published yesterday. This article is about closing that gap — with checksums for integrity, digests for immutability, and a layered strategy that makes supply chain attacks genuinely hard to pull off against your pipeline.
The Threat Model You’re Actually Defending Against
Before diving into tooling, be clear about what can go wrong:
Registry compromise — The registry itself gets hacked, and packages are replaced with trojaned versions. Happened to RubyGems and PyPI multiple times. The version number stays the same; the bytes change.
Maintainer account takeover — Credentials get phished. Attacker publishes a new version of a package with 50 million weekly downloads. Your lockfile says "install 4.17.21", the registry now serves 4.17.21-but-different.
Dependency confusion — You have an internal package named @company/auth. An attacker publishes a public package with the same name but a higher version number. Misconfigured npm clients pull the public one. No lockfile protects against this unless it already resolved the correct source.
Malicious maintainer — As with colors, the account is legitimate. The person who owns it just decided to do something destructive.
Lockfile tampering — Someone with write access to your repo modifies package-lock.json directly to point at a different resolved URL or hash. You review diff of source files, not lockfiles.
A lockfile defends against exactly one of these: accidental version drift. That’s still valuable! But it’s nowhere near enough.
Layer 1 — Lockfiles: Necessary But Insufficient
Every mature ecosystem has one. package-lock.json and yarn.lock for Node, poetry.lock and Pipfile.lock for Python, go.sum for Go, Cargo.lock for Rust, Gemfile.lock for Ruby.
The job of a lockfile is to make dependency resolution deterministic. Without it, pip install requests today might grab 2.31.0, and next month it grabs 2.32.0. The lockfile records the exact resolved version graph so every install produces the same result.
What a lockfile actually stores:
// package-lock.json excerpt
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZa2zcAQMhKSqxDEoqH3A=="
}
Notice that integrity field. npm lockfiles already embed a checksum — SHA-512 in SRI format. This is where things get interesting, because npm actually verifies this on install. If the bytes from the registry don’t match, the install fails.
But here’s the catch: when was that hash written into the lockfile? When you first ran npm install. If the registry was already compromised at that point, the hash in your lockfile matches the malicious package. Your CI will happily install and verify it forever.
A lockfile is a snapshot of a moment in time. If the moment was poisoned, the snapshot is too.
Gotcha: Don’t regenerate lockfiles in CI. I still see pipelines running npm install without --frozen-lockfile or --ci. This defeats the entire point. Use npm ci, pip install --require-hashes, or poetry install --frozen. If the lockfile is out of sync, fail loudly and fix it in a PR.
# Wrong — silently updates the lockfile in CI
npm install
# Right — fails if lockfile doesn't match package.json
npm ci
# Right for pip with hashes
pip install --require-hashes -r requirements.txt
Layer 2 — Checksums: Verify What You Actually Downloaded
Checksums answer a different question from versions: "are these bytes what we expected?" They’re a cryptographic hash of the package content, and they should be checked at install time — not just recorded at first install.
pip and hashes — Python’s pip has explicit hash-pinning support that works independently of virtual environments or lockfiles. Generate a requirements file with hashes:
pip-compile --generate-hashes requirements.in -o requirements.txt
The output looks like:
requests==2.31.0 \
--hash=sha256:58cd2187423d6... \
--hash=sha256:942c5a758f98d...
Two hashes because sdist and wheel have different content. Pass --require-hashes to pip and it refuses to install anything without a matching hash in the requirements file. This is the right default for production Python.
Go modules — Go’s module system bakes this in. go.sum stores SHA-256 hashes of every module zip and its go.mod file separately:
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt38=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
The h1: prefix means the hash algorithm (currently SHA-256 of the directory tree hash). go mod verify re-downloads and re-checks everything in go.sum. Run it in CI. It’s fast and it catches tampering.
# In your CI pipeline
go mod verify
go build ./...
GitHub Actions — the forgotten checksum surface — Your workflows use third-party actions like actions/checkout or actions/setup-node. If you pin by tag (uses: actions/checkout@v4), that tag can be moved to point at a different commit. Pin by commit SHA instead:
# Dangerous — tag can be moved
- uses: actions/checkout@v4
# Safe — commit SHA is immutable
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
This is tedious to manage manually. Tools like Dependabot with digest pinning or pin-github-action CLI handle it automatically. There’s no excuse for not doing it.
Gotcha: Hash collisions are not your threat model. SHA-256 collisions aren’t something you need to worry about for package verification. What you’re guarding against is an attacker serving different bytes for the same version identifier — and a SHA-256 hash makes that computationally infeasible with current cryptography.
Layer 3 — Digests: Immutable Content Addressing
Digests are checksums promoted to first-class identifiers. Instead of saying "version 1.2.3 of this thing, whose bytes happen to hash to X", you say "the thing whose bytes hash to X, which we happen to call version 1.2.3". The name becomes an alias; the hash becomes the truth.
This is the dominant model in container infrastructure.
Docker image digests — Every image pushed to a registry gets a content-addressed digest. The tag (nginx:1.25-alpine) is mutable. The digest (nginx@sha256:a17b0...) is not. A registry cannot change what a digest refers to — if the bytes change, the digest changes.
# Mutable — this could change tomorrow
FROM nginx:1.25-alpine
# Immutable — this will always be these exact bytes
FROM nginx@sha256:a17b08f7b7572ac574d4e5f74af25e3d3bb00b9a3c9d7b8e5f4...
Pull by digest in your Dockerfiles for anything running in production. Yes, this makes updates a conscious action rather than an accident. That’s the point.
To find the digest for a tag:
docker pull nginx:1.25-alpine
docker inspect nginx:1.25-alpine --format='{{index .RepoDigests 0}}'
# or
skopeo inspect docker://nginx:1.25-alpine | jq .Digest
OCI artifacts beyond images — The same digest model applies to Helm charts stored in OCI registries, WASM modules, and any artifact pushed with oras. If your registry supports content addressing, use digests.
Sigstore and Cosign — Digests tell you what you got. Signatures tell you who signed it. Cosign from the Sigstore project attaches cryptographic signatures to OCI digests:
# Verify before pulling
cosign verify \
--certificate-identity="https://github.com/sigstore/cosign/.github/workflows/release.yml@refs/heads/main" \
--certificate-oidc-issuer="https://accounts.google.com" \
gcr.io/projectsigstore/cosign@sha256:...
Major projects — Kubernetes, Distroless, Chainguard images — now publish Cosign signatures. Verify them in your image pull policy or admission controller. This closes the gap between "I know exactly what bytes this is" (digest) and "I know who built these bytes and under what conditions" (signature + provenance).
Gotcha: Your base image digest goes stale. Pin to a digest in your Dockerfile, but also set up automated digest rotation. Pinned images don’t get security patches. Renovate Bot supports digest bumps for Docker images out of the box — configure it and let it send PRs when the upstream image changes.
// renovate.json — enable digest pinning
{
"extends": ["config:base"],
"pinDigests": true,
"packageRules": [
{
"matchDatasources": ["docker"],
"pinDigests": true,
"automerge": false
}
]
}
Putting It Together: A Defense-in-Depth Pipeline
Here’s how layered pinning looks in practice for a typical service — Node.js application in a Docker container, deployed via CI:
package.json + package-lock.json — lockfile with integrity hashes, committed to the repo, never regenerated in CI.
npm ci in the build step — fails on any mismatch, never updates the lockfile.
Dockerfile with pinned digest:
# Use digest, not tag — updated by Renovate Bot PRs
FROM node:20-alpine@sha256:b6c9b... AS base
WORKDIR /app
# Copy lockfile first for cache efficiency
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
RUN npm run build
FROM node:20-alpine@sha256:b6c9b... AS runtime
WORKDIR /app
COPY --from=base /app/dist ./dist
COPY --from=base /app/node_modules ./node_modules
USER node
CMD ["node", "dist/index.js"]
GitHub Actions workflow:
name: Build and Push
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
# Pinned by commit SHA
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
- uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@4a13e500e55cf31b7a5d59a38ab2040ab0f42f56 # v5.1.0
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
# Always output the digest so you can pin downstream
outputs: type=image,push=true
SLSA provenance (stretch goal) — The slsa-github-generator adds build provenance attestations to your artifacts. Anyone downstream can verify not just what the bytes are, but which repo, which commit, and which CI run produced them. This is SLSA level 3 and it’s free for GitHub Actions users. It’s overkill for an internal tool, but mandatory thinking if you ship public artifacts.
Ecosystem Quick Reference
Here’s the minimum for each runtime, no excuses:
| Ecosystem | Lockfile | Integrity check | Immutable reference |
|---|---|---|---|
| Node/npm | package-lock.json |
npm ci (sha512 in lockfile) |
— |
| Python/pip | requirements.txt with hashes |
pip install --require-hashes |
— |
| Python/poetry | poetry.lock |
poetry install --frozen |
— |
| Go | go.sum |
go mod verify |
— |
| Rust | Cargo.lock |
cargo fetch --locked |
— |
| Docker | — | — | image@sha256:... |
| GitHub Actions | — | — | uses: action@commit-sha |
| Helm (OCI) | — | — | oci://registry/chart@sha256:... |
Operational Gotchas Worth Calling Out
Rotating your pins is not optional. Security patches happen. CVEs get published. If you pin everything immutably and never update, you accumulate vulnerabilities faster than if you hadn’t pinned at all. Automate updates with Renovate Bot or Dependabot, require human review for major bumps, and automerge patch-level changes with green CI. Pinning without rotation is just delayed roulette.
The hash in your lockfile is only as trustworthy as the moment it was generated. If you ran npm install on a compromised network that MITM’d the registry, the hash in your lockfile reflects the malicious package. The fix: regenerate lockfiles on trusted infrastructure, ideally using your own registry proxy (Verdaccio, Artifactory, Nexus) that caches content-addressed artifacts.
Private registry mirrors do not automatically make you safe. If your mirror pulls from upstream without verifying, it’s just adding a hop. Configure your mirror to verify upstream signatures and reject packages that don’t match their published checksums. Nexus and Artifactory both support this; it’s usually off by default.
Go’s module proxy adds a transparency log. sum.golang.org is a Merkle tree of every module hash Go has ever seen. If the module proxy serves you something inconsistent with the transparency log, go will refuse it. This is stronger than most ecosystems. It doesn’t help if the module was malicious from day one, but it prevents a registry from silently serving different bytes to different people.
SRI hashes in lockfiles only cover the package archive, not the package contents after extraction. A cleverly crafted tar archive could in theory extract differently on different operating systems. This is theoretical, not practical — but it’s why some people also run static analysis or sandboxed install steps for high-risk dependencies.
Where To Start If Your Repo Has None of This
Don’t try to fix everything at once. Pick the highest-impact change per hour of work:
- Commit all existing lockfiles if they’re in
.gitignore. Unblock this immediately. - Replace
npm install/pip installwith their frozen equivalents in CI. This is a one-line change. - Pin GitHub Actions to commit SHAs. Run
pinactor enable Dependabot for actions. Takes 20 minutes. - Pin your base Docker images to digests. Update your Dockerfiles, configure Renovate for automated updates.
- Add
go mod verifyor pip hashes for Go and Python services respectively.
Everything after step 4 is diminishing returns for most teams. Step 5 and beyond matters most if you’re building infrastructure tooling, shipping public packages, or operating in a regulated environment.
The supply chain attacks that make headlines are not sophisticated. They exploit the gap between "we use version X" and "we verified the bytes are actually version X from the legitimate source". Lockfiles partially close that gap. Checksums close it further. Digests and signatures make tampering cryptographically detectable. Use all three. Automate the rotation. Don’t think of it as security theater — the colors and node-ipc incidents were real money and real downtime for real companies.
Your pipeline, your responsibility.