Keegan Ryan of the security firm Trail of Bits set up a fake compromised package server and pointed 25 popular package managers at it. Two of them, Go and vcpkg, verified every package they downloaded, even without a lockfile. Without one, the other 23 installed whatever they were given.
Lockfiles didn't rescue most of them either.
The test
Ryan routed each tool's traffic through a proxy that could rewrite downloads, then installed quickstart projects with default settings, with and without a lockfile. An attack counted as a success if the injected code ran. The threat model assumes any web-facing server, such as a registry or a mirror, can be compromised, but backend secrets like private signing keys can't.
The lockfile is the safety net, and it's mostly unplugged
A lockfile pins every dependency's exact version and, ideally, a hash: a fingerprint the download has to match. Fewer than half of the 25 tools checked those hashes by default.
All 25 package managers, by default behavior. Source: Trail of Bits, October 14, 2026.
Nine verify when a lockfile is present, including npm, pnpm, Cargo, uv and Yarn. Five can verify, but only with extra steps. The last nine don't verify lockfile hashes at all, or the check doesn't apply to them.
Checks that don't stop anything
Tools with no check are the obvious failures. The sneakier ones run a check and install anyway:
-
Pub (Dart and Flutter) detects a hash mismatch, installs the package anyway and updates the lockfile to match, unless you pass
--enforce-lockfile. Some Flutter commands runpub gettoo, and can overwrite the lockfile unless called with--no-pub. - pnpm overwrote the lockfile on a mismatch until version 11.4, in May 2026, so the tampered package simply became the new truth.
- Composer (PHP) locks a Git commit, then downloads a ZIP archive it never checks against that commit.
- CocoaPods hashes the package spec but fetches the source by Git tag, which a compromised host could serve with different contents.
- cpanminus (Perl) still connects to its repository over plain HTTP by default.
Four outcomes when the download and the lockfile disagree. Only the green one protects you.
Why the defaults are like this
None of this is hidden; it's documented behavior. Lockfiles were built for reproducible installs, and as Ryan notes, reading them through a supply-chain lens "is a recent interpretation that can be at odds with their original design." Strict pinning also has a cost: every update, security fixes included, needs a lockfile change.
Go shows the alternative. It checks every hash against a signed checksum database that works like a transparency log, which makes targeted tampering much harder, at the cost of concentrating trust in a single private key. It was the only tool that verified every download and checked signatures by default. As Ryan puts it, "Package signing is in a similar state now to where HTTPS was a decade ago."
What to do this week
- Commit your lockfile, at least for applications. Norms for libraries vary by ecosystem.
-
Install in strict mode in CI:
npm ci,pnpm install --frozen-lockfile,uv sync --locked,pip install --require-hashes -r requirements.txt(with hashes generated bypip-compile --generate-hashes) ordart pub get --enforce-lockfile. -
If your tool needs extra steps, take them: the conda-lock library for Conda, a
verification-metadata.xmlfile for Gradle, theRestorePackagesWithLockFileproperty for NuGet. -
If your tool is in the last group, don't treat its lockfile as a security control. Maven can check signatures against trusted keys with the third-party
pgpverify-maven-plugin. Beyond that, the fallback Conan's developers suggest applies widely: if integrity is critical, run your own repository server.
Then test it: change one hash in your lockfile on purpose and confirm CI fails.
Caveats
Each tool was tested with one package, one version and one platform, Ubuntu, and some setups, like PowerShell and CocoaPods on Ubuntu, don't reflect typical use. The study shows that reasonable defaults can leave you exposed, not that every setup is.
Verification comes before detection
Most supply chain tooling hunts for packages already known to be bad. This study is about the step before that: making sure the package you got is the package you asked for. For most of the 25 tools, that check is off by default or missing. Where it exists, turning it on is about the cheapest security win you'll find.
If you check what your package manager does on a hash mismatch, I'd like to hear what you find.
Sources
- Package managers have an integrity problem, Keegan Ryan, Trail of Bits, October 14, 2026


Top comments (9)
One hop up from the package manager there is a dependency with no lockfile at all: the workflow's own steps. uses: org/action@v4 is a tag, and GitHub's API shows where a tag points now, not where it pointed last week. In March 2026 an attacker force pushed 76 of 77 version tags of trivy-action to credential stealing code, per Aqua's advisory.
The fix is pinning each action to a full commit SHA, with a catch from that same advisory: a trivy-action pinned to a commit from before April 2025 could still pull a malicious setup-trivy by tag. Pinning the top does not pin what it calls. Your "change one hash" test has a twin: grep your workflows for third party uses lines that don't end in a 40 character SHA.
Thanks, Gautam. This covers a gap the post left open.
GitHub can also enforce this for you: a repo or org setting makes a workflow fail if any action in it isn't locked to an exact commit. That includes actions inside other actions, so the case you described would be blocked as well.
The setting can't catch what an action downloads while it runs, though. During that attack, a malicious Trivy release was up for about three hours, and workflows set to install the latest Trivy got it, even with the action locked to a safe commit. So set a fixed tool version too.
Good call on the setting, and that three hour window is the scary part: the pin was right and the runtime still pulled a bad binary. So the rule becomes two locks, not one: pin the action to a commit, and pin the tool it installs to an exact version with a checksum check, so "latest" never decides what runs in CI.
Agreed. One addition: keep that checksum in your repo, recorded when you pick the version. If the workflow downloads it from the same release as the binary, whoever can swap the binary can swap the checksum file too.
Agreed, the hash has to come from somewhere the attacker can't also write. Keeping it in the repo has a second benefit: every version bump shows up as a reviewable diff of the hash, so a changed checksum gets the same review as changed code.
Where a project publishes them, GitHub artifact attestations add one more check on who built the file (
gh attestation verify).The Composer one is the pattern that worries me most: the lock pins a commit, then the install downloads something else.
For Solidity people reading this: forge install uses git submodules, so after install the parent repo pins a commit hash and git checks the content against it. Soldeer, the package manager now built into Foundry, downloads zip files from a registry instead. Has anyone checked if it verifies the hash in soldeer.lock on install? It wasn't in the 25 tools tested.
It does, as long as the dependency has an entry in soldeer.lock. Soldeer hashes the downloaded zip (SHA-256), compares it with the checksum in the lockfile, and stops the install with an error if they differ. The same check runs under forge soldeer, since Foundry uses Soldeer's own code.
The gap is recursive_deps (installing a package's own dependencies too), which is off by default. With it on, Soldeer replaces a package's submodules with fresh clones of their repos, checked out at a fixed commit only if the package ships a foundry.lock. Same shape as the Composer issue: the lockfile covers the zip, not the code cloned afterwards, and a change there only triggers a warning.
Here's the check in Soldeer's source.
Thanks for digging into the source, that's really useful. So the zip is covered, and the hole only opens with recursive_deps on. For Solidity teams I'd keep it off and add the transitive deps as direct dependencies, so each one gets its own entry and hash in soldeer.lock. And if someone really needs it on, failing CI on that warning looks like the cheap fix. Did you open an issue on the Soldeer repo about it?
It didn't. The study's attacker controlled the servers packages come from, not the project, so the lockfile itself was never the target. When Pub (or pnpm before 11.4) overwrote the lockfile, that happened at install time, after a real mismatch.
The case you're describing is harder to catch. If the bad hash is already committed when CI runs, the tampered download matches it, so even strict mode lets the install through. That can come from a malicious pull request, or from a tool that overwrote the lockfile on a developer's machine during an attack, with the change committed afterwards.
The defense there is review: a hash or download URL that changes while the version stays the same deserves a closer look.