Dependency integrity

Pin, lock, verify; confusion and typosquats.

Advanced30 min · lesson 12 of 15

In October 2021 an attacker took over the npm (node package manager, the registry nearly every JavaScript project installs from) account behind ua-parser-js, a small library that reads browser user-agent strings and gets pulled in, directly or through other packages, by millions of projects. They published three fresh releases: 0.7.29, 0.8.0 and 1.0.0. Each one dropped a crypto-miner and a credential stealer. There was no CVE (Common Vulnerabilities and Exposures, the public catalogue number a known software flaw gets) and no exploit. The malware walked in the front door dressed as a routine update. Every project that asked for ua-parser-js with a floating range like ^0.7.0 and had no lockfile committed pulled the poison on its next npm install. Projects that had committed a package-lock.json and installed with npm ci never saw it. Their lockfile named 0.7.28 by exact version and by a SHA-512 (Secure Hash Algorithm, 512-bit) fingerprint of the file, so the resolver never moved forward, and swapped bytes would have been thrown out on the spot. The gap between a floating version and a pinned, hash-checked one is what this lesson is about.

Pin the bytes, not the version string

A version number works like the label on a warehouse shelf. It tells the picker which slot to walk to. It says nothing about what sits in that slot today, because whoever runs the warehouse can restock it whenever they like. A cryptographic hash is a different kind of thing: a short fingerprint computed from every byte of a file, where flipping a single byte produces a completely different fingerprint. A lockfile writes down the fully resolved dependency graph, every package you asked for plus every package those packages dragged in, each with the version that got chosen and the fingerprint of the file that arrived. Installs become repeatable, and any change to what you depend on turns into a line you can read in a diff instead of something that happens to you quietly. Hash pinning shuts two separate doors. The first is the auto-upgrade: a floating range slides forward into a newly published malicious version, which is exactly how ua-parser-js reached everyone. The second is same version, different bytes: a poisoned mirror, a hijacked proxy or CDN (content delivery network, the cache layer that serves files from a machine near you) cache, or a forced republish hands you altered content under a version you already pinned. There is a trap sitting between the two. A bare == pin fixes the label and nothing else, so it closes the first door and leaves the second one open. Only a recorded hash, checked at install time, closes both. Here is an excerpt from a pip lockfile with hashes. A complete one lists every package in the tree, including the ones requests drags in behind it, each with its own hashes.

requirements.lock (excerpt)
# Generated: pip-compile --generate-hashes --output-file=requirements.lock requirements.in
# Install with: pip install --require-hashes -r requirements.lock
#
certifi==2024.7.4 \
--hash=sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b \
--hash=sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90
# via requests
requests==2.32.3 \
--hash=sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760 \
--hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6
# via -r requirements.in

Each package carries two hashes because pip might end up downloading either of two files: the prebuilt wheel, which unzips straight into place, or the sdist (source distribution, the raw source tarball that gets built on your machine). Whichever one shows up has to match one of the recorded fingerprints. The --require-hashes flag is all-or-nothing by design. If one dependency anywhere in the tree lacks a hash, including a package you never named yourself, pip installs nothing at all. That is why you generate this file with a resolver such as pip-compile or uv instead of typing hashes in by hand. When a mirror serves a wheel whose SHA-256 digest does not match the recorded value, the install stops dead:

pip rejects a tampered wheel
$ pip install --require-hashes -r requirements.lock
Collecting requests==2.32.3 (from -r requirements.lock (line 6))
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/.../requests-2.32.3-py3-none-any.whl
(from -r requirements.lock (line 6)):
Expected sha256 70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6
Got b1e2c9d4f0a7e5c3d18f6a2b4c9e0d1f3a5b7c9e1d2f4a6b8c0d2e4f6a8b0c2d4

npm: the integrity line inside your lockfile

npm pins content using a scheme borrowed from the browser world called Subresource Integrity, or SRI. Browsers use it when a web page loads a script from somebody else's server and wants proof it received the file it expected. In a lockfile the integrity value is the literal text sha512- followed by the base64 encoding (a way of writing raw bytes as ordinary text characters) of the SHA-512 digest of the package tarball, stored per package inside package-lock.json. The command you type matters as much as the file you committed. npm ci, the clean-install command, works strictly from the lockfile: it deletes node_modules first, never writes the lockfile back, errors out if package.json and the lock disagree, and re-hashes every tarball it downloads to compare against the recorded integrity value. npm install treats the lockfile as a suggestion and will happily rewrite it. Here is the pinned entry, followed by what npm ci does when the bytes it fetched hash to something else:

package-lock.json (excerpt)
{
"name": "web-app",
"lockfileVersion": 3,
"requires": true,
"packages": {
"node_modules/ua-parser-js": {
"version": "0.7.28",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.28.tgz",
"integrity": "sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g==",
"engines": { "node": "*" }
}
}
}
npm ci aborts on an integrity mismatch
$ npm ci
npm error code EINTEGRITY
npm error sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g== integrity checksum failed when using sha512: wanted sha512-6Gurc1n//gjp9eQNXjD9O3M/sMwVtN5S8Lv9bvOYBfKfDNiIIhqiyi01vMBO45u4zkDE420w/e0se7Vs+sIg+g== but got sha512-Kf9Z1p0aQ2r7Nt3xY8bV6cD4eF5gH0iJ1kL2mN3oP4qR5sT6uV7wX8yZ9aB0cD1eF2gH3iJ4kL5mN6oP7qR5sQ==. (85374 bytes)
npm error A complete log of this run can be found in: /root/.npm/_logs/2026-07-16T09_04_11_882Z-debug-0.log

Who built it: npm audit signatures

An integrity hash tells you the parcel has not been opened since you weighed it. It says nothing about who packed it, or what they packed it from. That second question is provenance, and npm audit signatures answers it with two independent checks. First, registry signatures. npm signs every tarball it publishes with an ECDSA (Elliptic Curve Digital Signature Algorithm, a compact public-key signing scheme) key and serves the public half at the registry's /-/npm/v1/keys endpoint, so a mirror that swaps a tarball and rewrites the integrity field to match its own bytes still cannot produce npm's signature for them. Read where those keys come from, though: npm asks whichever registry you have configured for that endpoint. Point npm at a mirror and the mirror serves the keys along with the packages, so it can sign its own bytes and pass. This check is worth exactly as much as the source of the key. Second, provenance attestations. A package published from CI (continuous integration, the automated build service that runs on every push) with npm publish --provenance carries a SLSA v1.0 (Supply-chain Levels for Software Artifacts, the industry spec for build integrity) provenance predicate naming the builder, the source repository and commit, and the workflow that ran. That statement is signed, and nobody has to hold a signing key to do it. The build job asks its CI provider for an OpenID Connect identity token, a short-lived note that names the workflow currently running, and sends it to Fulcio, a certificate authority run by the Sigstore project. Fulcio checks the token and issues a certificate good for about ten minutes with that workflow name written into it, for example https://github.com/org/repo/.github/workflows/publish.yml@refs/tags/v1.2.0. The job signs with a key it throws away afterwards, and the signature is copied into Rekor, a public log that can only be appended to. The signed statement travels inside a DSSE envelope (Dead Simple Signing Envelope), the format covered in the in-toto and Fulcio lessons. npm audit signatures walks that chain again and checks that the identity matches the repository the package claims to come from.

npm audit signatures
$ npm audit signatures
audited 412 packages in 4s
412 packages have verified registry signatures
58 packages have verified attestations
# --- if a tarball is swapped after publish ---
$ npm audit signatures
audited 412 packages in 4s
411 packages have verified registry signatures
1 package has an invalid registry signature:
[email protected] (https://registry.npmjs.org)
Someone might have tampered with this package since it was published on the
registry (monitoring for such events is not yet supported).

Go modules: go.sum and a ledger nobody can edit

go.sum records two h1: hashes for every dependency, one covering the module's whole file tree and one covering its go.mod file on its own. The h1: value is not a hash of the zip archive you downloaded. Go builds it with dirhash.Hash1: sort the file paths, then walk them in that order, writing one line per file that is the SHA-256 of that file's contents in hex, two spaces, then the path. Glue those lines together, SHA-256 the lot and base64-encode the result. Sorting the paths first is the whole trick. The fingerprint stops depending on the order files happened to sit in an archive, while still covering every byte of every file. The first time a build needs a particular version, Go asks the checksum database at sum.golang.org for its hash, records that hash in go.sum, and from then on every download has to match. sum.golang.org behaves like a public ledger written in permanent ink: it is a signed, append-only Merkle log (a tree of hashes where each new entry is cryptographically tied to every entry before it), the same tamper-evident structure Rekor and Certificate Transparency are built on. An attacker who wants to feed you different bytes for gin v1.10.0 has to forge an entry in a ledger the whole world can read, rather than swapping one file on one mirror. go mod verify re-checks the unpacked module cache against the recorded hashes, and GOPRIVATE and GONOSUMDB keep internal modules out of the public log.

go.sum verification and a mismatch
$ grep gin go.sum
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
$ go mod verify
all modules verified
# --- a proxy serves different bytes for the same pinned version ---
$ go build ./...
go: downloading github.com/gin-gonic/gin v1.10.0
verifying github.com/gin-gonic/[email protected]: checksum mismatch
downloaded: h1:9Wh5v0k7B1eQ3rX8mZ2cP6dF4gH7jK0lN1oQ2sT3uU=
go.sum: h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
SECURITY ERROR
This download does NOT match an earlier download recorded in go.sum.
The bits may have been replaced on the origin server, or an attacker may
have intercepted the download attempt.
For more information, see 'go help module-auth'.
What a compromised republish hits on the next install
Next install fetches package bytes
one version string can map to different bytes: bad mirror, poisoned cache, forced republish
floating range, no lockfile
resolver takes the newest match, so the trojaned version runs
ua-parser-js: ^0.7.x auto-pulled 0.7.29 malware
committed lockfile + npm ci
SHA-512 SRI mismatch → EINTEGRITY, install aborts
bytes must equal the pinned integrity or nothing installs
pip --require-hashes
wheel SHA-256 ≠ recorded hash → hard error
every dependency must carry a hash or pip refuses to run
Go modules
h1: mismatch vs go.sum → SECURITY ERROR
checksum-DB Merkle log makes the recorded hash tamper-evident
Same attack, four outcomes. Floating and unpinned runs the malware; every hash-pinned ecosystem turns it into a loud, blocking failure.

When the hash pins the wrong package

Everything so far assumes you asked for the right package. A hash answers exactly one question: are these the bytes I wrote down? It says nothing about whether the name at the top of that entry belongs to the project you meant. Picture a parcel that arrives sealed, at the right weight, from a supplier you have never dealt with, because a stranger answered the phone the day you placed the order. Dependency confusion is that phone call. Your team has an internal package, say acme-internal-utils, that exists only on your private index. Someone reads the name out of a leaked package.json, a public Dockerfile or a stack trace, and publishes a package with that exact name on the public registry at version 99.0.0. If your build knows about both sources and has no rule about which one wins, most resolvers do the obvious thing and take the highest version they can find anywhere. That is what Alex Birsan showed in 2021, when packages named after internal libraries ran his code inside Apple, Microsoft, PayPal and dozens of other companies, with no exploit and no stolen password. Your lockfile then records the attacker's package and the attacker's hash, and every install after that verifies perfectly. Pinning proves you got the file you wrote down. It cannot prove you wrote down the right file.

So this one gets decided a step earlier, in how a name turns into a source. On npm, publish internal packages under a scope you own, like @acme/utils, and map that scope to your own registry in .npmrc. npm then asks only your registry for anything beginning @acme and never gives the public one a chance to answer, provided your internal registry does not itself fall through to the public registry for scoped names it has never heard of. With pip the trap has a name: --extra-index-url. It does not mean "try this second, only if the first index has nothing". pip queries every index you hand it and installs the highest version it finds in any of them, so point index-url at a single private index that proxies the Python Package Index (PyPI) upstream and leave it at that. In every ecosystem, claim your names as well: register the scope, or the internal package names themselves, on the public registry so that nobody else can.

name resolution: .npmrc and pip.conf
# --- .npmrc: everything under the @acme scope resolves to one registry ---
@acme:registry=https://npm.acme.internal/
//npm.acme.internal/:_authToken=${NPM_TOKEN}
registry=https://registry.npmjs.org/
# --- pip.conf: one index, which proxies PyPI upstream ---
[global]
index-url = https://pypi.acme.internal/simple
# --- pip.conf: the version that loses. pip queries both indexes with no
# preference between them and installs the highest version it finds,
# so a 99.0.0 published on PyPI beats your internal 1.4.2 ---
# [global]
# index-url = https://pypi.org/simple
# extra-index-url = https://pypi.acme.internal/simple

Typosquatting plays the same trick on human fingers rather than on a resolver. In 2017 a package called crossenv sat on npm one hyphen away from the widely used cross-env, and it posted the environment variables of every machine that installed it to a stranger's server, which in a build job means your tokens. In 2019 the Python Package Index carried jeIlyfish, spelled with a capital I where jellyfish has an l, and it went after the keys developers use to log into servers and sign their commits. A hash pins a typo exactly as faithfully as it pins the real thing. What catches this is a person, once: a new dependency name appears in a lockfile diff exactly one time, on the pull request that first adds it. Read the names in that diff, not only the version numbers, and treat a package nobody on the team has seen before as something to look up rather than approve. It is also where the provenance check earns its keep, because a typosquat cannot produce an attestation naming the repository you believed you were installing from.

Living with hash pins in production

Hash pinning sends you a bill in three places, and each one has a right answer. One: --require-hashes is all-or-nothing, so let a resolver produce the lockfile for the entire transitive graph (pip-compile --generate-hashes, or uv pip compile) instead of maintaining hashes by hand. Two: every upgrade now produces a lockfile diff, and that is the product you paid for, not the friction. A dependency change becomes a reviewable line in a pull request rather than a silent swap on someone's laptop. Three: a private proxy or mirror has to serve byte-identical copies of what the public registry served, or you will spend your week chasing integrity failures that mean nothing. When one turns out to be real, treat it as an incident, never as noise to skip past with --no-verify. Provenance verification bills you separately: it needs the Fulcio and Rekor trust roots, which you can pin to disk ahead of time for air-gapped builds, and it costs a couple of Sigstore lookups per package. One last habit worth building: accepting a package because some attestation exists is a weak check, so require that the attestation names your source repository. Everything in this lesson checks what goes into a build: the exact bytes you pulled down before anything was compiled. The next lesson, Verify at admission, moves the same signatures and provenance out to the cluster boundary, so an image that cannot produce them never gets scheduled.

npm install will quietly undo your pin
The hard part is not knowing which command to use. It is finding the places that already run the wrong one: a Dockerfile line that says RUN npm install, a cache-warming step in a workflow file, a Makefile target written before the lockfile existed, a helper script somebody added in a hurry. Every one of those regenerates the lock on its own and re-pins to whatever is newest that day, with nobody reading the result. Grep the repository for npm install outside its documentation, switch those callers to npm ci, and make a lockfile change arrive in a pull request of its own where somebody has to look at it.
Quick check
01Your CI job runs npm ci and stops with npm error code EUSAGE, Invalid: lock file's [email protected] does not satisfy ua-parser-js@^1.0.0. Somebody widened the range in package.json and committed without touching the lockfile. How do you clear it?
Incorrect — The message does suggest running npm install, but it means run it locally, not in the build. In CI it turns the failure green by rewriting the lock and walking in-range dependencies forward, with nobody reading the diff.
Incorrect — npm ci already deletes node_modules before it installs anything. EUSAGE is about package.json and the lockfile disagreeing with each other, so a clean directory changes nothing.
Correct — The upgrade happens once, in a pull request where the lockfile diff is a line somebody reads, and the build keeps the install that refuses to drift.
Incorrect — That throws away every exact version and every integrity value you committed, and hands resolution back to the floating range. It is the first door hash pinning was there to close.
02A Go build that worked yesterday now stops with verifying github.com/gin-gonic/[email protected]: checksum mismatch, downloaded h1:9Wh5v0k7..., go.sum: h1:nTuyha1T..., followed by SECURITY ERROR. A teammate says the maintainers must have republished, and proposes deleting that go.sum line so Go can record the new hash. What happens if you do?
Incorrect — Go does not take the proxy's word on a first fetch for a public module. It asks the checksum database for that version's hash and writes down what the database says.
Correct — The log only ever appends, so the hash published for a released version stays what it was. Deleting the line just fetches the old value back, which points the investigation at the proxy or whatever sits in front of it. Treat it as an incident until you know otherwise.
Incorrect — go.sum is committed, and every build on every machine verifies downloads against it. Deleting the line does not confine the problem to one laptop.
Incorrect — go mod verify re-checks the unpacked module cache against the hashes already in go.sum. It would agree with the hash you just accepted rather than question where it came from.
03Your team's internal package acme-internal-utils lives only on your private index. You run npm view acme-internal-utils against the public registry and get npm error code E404, 'acme-internal-utils@*' is not in this registry. What should you conclude?
Correct — A 404 means the name is sitting free, which is exactly the opening dependency confusion needs. Publish under a scope you own, map @acme to your registry so the public one never gets asked, and register the names so nobody else can.
Incorrect — The public registry has no idea your private index exists. On the public side, the first person to publish a name owns it.
Incorrect — A hash pins whatever entry got resolved. If a public 99.0.0 wins the resolve, its hash is what lands in your lockfile, and every install after that verifies perfectly.
Incorrect — A registry signature proves npm published that tarball and nobody swapped it since. It says nothing about whether the publisher is your team. Provenance can name a source repository, but only if you require the attestation to match yours.

Try this

Do this in a throwaway directory on any machine with npm and git installed. The first part pins a package, then walks the pin forward the way a careless afternoon does, so you meet both the command that refuses and the command that shrugs. The last one asks the public registry whether an internal package name of yours is still unclaimed. A 404 there is not reassuring: it means the name is sitting free for anyone to publish, which is the opening dependency confusion needs.

terminal
$ mkdir dep-lab && cd dep-lab && npm init -y > /dev/null
$ npm install --no-audit --no-fund [email protected]
added 1 package in 2s
$ git init -q && git add package.json package-lock.json && git commit -qm "pinned"
# --- widen the range the way a hand-edited package.json does ---
$ npm pkg set 'dependencies.ua-parser-js=^1.0.0'
$ git commit -qam "widen the range"
$ npm ci
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 Invalid: lock file's [email protected] does not satisfy ua-parser-js@^1.0.0
# --- the same drift through npm install: no complaint, and the pin is gone ---
$ npm install --no-audit --no-fund > /dev/null
$ git diff --name-only
package-lock.json
# --- is one of your internal names free for anyone to publish? ---
$ npm view acme-internal-utils
npm error code E404
npm error 404 Not Found - GET https://registry.npmjs.org/acme-internal-utils - Not found
npm error 404
npm error 404 'acme-internal-utils@*' is not in this registry.

Takeaway

The trap worth remembering here: npm install will quietly undo your pin. 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