Dependency integrity
Pin, lock, verify; confusion and typosquats.
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.
# 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 requestsrequests==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 install --require-hashes -r requirements.lockCollecting 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 haveupdated the package versions, please update the hashes. Otherwise, examine the packagecontents 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 70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6Got 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:
{"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 cinpm error code EINTEGRITYnpm 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 signaturesaudited 412 packages in 4s412 packages have verified registry signatures58 packages have verified attestations# --- if a tarball is swapped after publish ---$ npm audit signaturesaudited 412 packages in 4s411 packages have verified registry signatures1 package has an invalid registry signature:[email protected] (https://registry.npmjs.org)Someone might have tampered with this package since it was published on theregistry (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.
$ grep gin go.sumgithub.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 verifyall modules verified# --- a proxy serves different bytes for the same pinned version ---$ go build ./...go: downloading github.com/gin-gonic/gin v1.10.0verifying github.com/gin-gonic/[email protected]: checksum mismatchdownloaded: h1:9Wh5v0k7B1eQ3rX8mZ2cP6dF4gH7jK0lN1oQ2sT3uU=go.sum: h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=SECURITY ERRORThis download does NOT match an earlier download recorded in go.sum.The bits may have been replaced on the origin server, or an attacker mayhave intercepted the download attempt.For more information, see 'go help module-auth'.
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.
# --- .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.
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.
$ 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 cinpm 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 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-onlypackage-lock.json# --- is one of your internal names free for anyone to publish? ---$ npm view acme-internal-utilsnpm error code E404npm error 404 Not Found - GET https://registry.npmjs.org/acme-internal-utils - Not foundnpm error 404npm 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.