DevOps 6 min read 1179 words

Only 2 of 25 package managers always check that code hasn't been tampered with

ES
Only 2 of 25 package managers always check that code hasn't been tampered with

When we think about software supply-chain attacks, we almost always picture the same thing: someone steals the account of a popular package’s maintainer and publishes a version with malicious code. Trail of Bits has published Package managers have an integrity problem, where Keegan Ryan raises the next step: what happens if what gets compromised is the package repository itself?

The reasoning makes sense: if maintainers’ accounts get properly protected, attackers will go after the next weakest link, which is the servers distributing the code. To check how well each ecosystem holds up, they tested 25 of the most popular package managers against a malicious repository that injects code into legitimate packages. The result isn’t good.

How they tested it

I find the setup very elegant. Each package manager runs in a Docker container with a sample project, with and without a lockfile. Traffic goes over WireGuard to a mitmproxy with a custom addon, and since mitmproxy’s certificate is installed in the container, it can modify everything the repository returns. The proxy injects malicious code into the downloaded packages, and if the sample project runs it, the attack worked.

Any internet-facing server is assumed to be compromisable, but not the private signing keys. And each manager is run with its default commands, following its quickstart guides, to see whether extra steps are needed to be protected.

First defence: hashes in the lockfile

The most natural way to check that a package hasn’t changed is to store its hash in the lockfile and compare it on download. Here’s how well each manager does it:

BehaviourPackage managers
Always verifies integrityGo, vcpkg
Verifies if there’s a lockfileBun, Bundler, Cargo, npm, pnpm, Poetry, SwiftPM, uv, Yarn
Verifies with lockfile and extra stepsConda, Gradle, Pip, NuGet, Pub
Doesn’t verifyCocoaPods, Composer, Conan, cpanminus, Gem, Maven, PowerShellGet, PSResourceGet, R

Some cases are surprising:

  • cpanminus, the recommended package manager for Perl, still uses unencrypted HTTP by default. This has been known for years, but changing it would break existing behaviour.
  • Pub, for Dart and Flutter, stores a SHA-256 in the lockfile and detects a mismatch… but installs the package anyway and updates the lockfile. Blocking it requires --enforce-lockfile. pnpm did the same until version 11.4, in May 2026.
  • Conan stores an MD5 that serves as an identifier, not as protection, and doesn’t even recompute it on install.
  • CocoaPods stores the SHA-1 of the package spec, but downloads the code from a git repository by tag. A compromised server could serve different code under the same tag.

The Composer case

Since PHP has always been very present on this blog, Composer’s case is the one that caught my eye the most. composer.lock stores the commit hash of each dependency:

"dist": {
    "type": "zip",
    "url": "https://api.github.com/repos/guzzle/psr7/zipball/b094ded77ee97a6027ad6cf0e8c7b9f88381814c",
    "reference": "b094ded77ee97a6027ad6cf0e8c7b9f88381814c",
    "shasum": ""
}

It looks protected, but it isn’t: the commit hash identifies the code, but the downloaded ZIP doesn’t include the git tree, so there’s no way to check it matches that commit. And the shasum field is empty. If GitHub served a tampered ZIP, Composer wouldn’t notice. The post does acknowledge that the Composer team is adding protections against this kind of attack and has plans for package integrity verification.

Second defence: signatures

Lockfile hashes have another problem: if you pin the exact hash, you don’t get security updates until you update the lockfile. Signatures solve that, because what you check is who published the package, not its exact contents. But the picture here is worse:

BehaviourPackage managers
Verifies signatures by defaultGo
Verifies if configuredBundler, Gem, Gradle, Maven, npm, NuGet
Incomplete or missing verificationBun, cpanminus, Pip, pnpm, Poetry, PowerShellGet, PSResourceGet, uv, Yarn
No signatures to verifyCargo, CocoaPods, Composer, Conan, Conda, Pub, R, SwiftPM, vcpkg

The curious part is that signatures exist in many ecosystems, but almost nobody checks them:

  • npm signs packages in the registry, but npm install doesn’t check them. You have to run npm audit signatures separately. pnpm’s equivalent fetches the public keys from the registry itself, which is exactly what could be compromised.
  • NuGet accepts packages with invalid signatures by default.
  • Maven Central requires PGP signatures, but neither Maven nor Gradle checks them by default.
  • PyPI, npm, RubyGems and Maven Central already support Sigstore signatures (no private keys to manage, using the GitHub Actions or GitLab CI identity), but Pip, uv and Poetry don’t verify them yet.

Why Go comes out so well

Go is the only one that verifies everything by default, and it does so with a different idea: the checksum database (sum.golang.org). Every time go get downloads a module, it computes its hash and compares it with the one in that database, which is signed with a public key embedded in Go’s own source code.

It’s a transparency log: a global, immutable log where everyone sees the same thing. If an attacker wanted to give one specific victim a tampered version of a package, it wouldn’t match the log. It’s the same idea as the Certificate Transparency logs for TLS certificates, which I wrote about recently with the fake certificates after three domains were hijacked. The downside is that a lot of trust rests on a single private key.

A few weeks ago I wrote about using your own domain for Go imports, and this complements it nicely: even if you change where the code is downloaded from, the checksum database guarantees it’s the same code.

The proposal

Trail of Bits proposes combining all of the above: the lockfile wouldn’t store each package’s hash, but the identity of whoever publishes it (their public key or Sigstore identity), plus a transparency log to prevent targeted attacks. That way you could pin an exact version or just a minimum version, and in both cases it would be checked that the package comes from who it should.

It’s not a new idea: it already appears in Python’s pylock.toml format, in Gradle’s dependency verification and in a Maven plugin. And the post closes with a comparison I think is spot on: package signing today is where HTTPS was ten years ago. Everyone knows it’s a problem, but real protection barely exists. HTTPS became the norm when Let’s Encrypt made it free and easy, and package signing needs something similar.

My takeaway

For day-to-day work, a few practical things:

  • Commit the lockfile for applications and use the strict modes: npm ci, pnpm install --frozen-lockfile, flutter pub get --enforce-lockfile, etc.
  • Run npm audit signatures in CI if you use npm. It’s one command and it checks the signatures npm install ignores.
  • If you use Composer, be aware that the lockfile pins versions, but doesn’t protect against a compromised server.
  • If you publish packages, sign with Sigstore from your CI. The more signed packages there are, the sooner it will be worth package managers verifying them by default.

And I keep the underlying idea: we trust that what we download is what the maintainer published, but in most ecosystems that trust rests only on the server not being compromised.

Source: Package managers have an integrity problem, Keegan Ryan, Trail of Bits.