The Mirror Is the Attacker: Two of 25 Package Managers Survive a Malicious Repository

On 14 October 2026, Trail of Bits researcher Keegan Ryan published the results of an experiment the ecosystem has needed for years: he pointed 25 popular package managers at a repository that lies, and recorded which ones notice. The malicious server does the simplest hostile thing imaginable — it embeds malware in otherwise legitimate packages — and the question is whether the client’s cryptography catches it. The headline numbers are stark. Fewer than half of the managers used lockfiles that check for tampering by default. Only two — Go and vcpkg — verified the integrity of every package they downloaded. And only one, Go, verified signatures by default. The rest ranged from secure-with-conditions to cryptographically unaware that a problem exists. If your threat model ends at the developer’s account, this study moves the line one step further out: to the mirror, the proxy, and the registry itself.

The framing is deliberate. Supply-chain defense has spent years hardening the developer — phishing-resistant publishing, trusted publishers, provenance attestations, the whole response to Shai-Hulud and TeamPCP — and Trail of Bits itself has contributed to that work. Ryan’s premise is that if those mitigations succeed, attackers will walk around them to the next weakest link: the infrastructure that serves the code. A compromised registry, a poisoned mirror, a hostile proxy. The study asks what stops a developer who follows reasonable conventions from installing a trojan in that world. The honest answer, for most ecosystems, is nothing the tooling does unprompted.

How you test a mirror that lies

The methodology is worth describing because it is what makes the claims checkable rather than impressionistic. Ryan built a harness from Ubuntu Docker images and a custom mitmproxy addon, joined by a WireGuard network with the proxy’s TLS certificate authority installed inside the test image — so the addon can rewrite repository plaintext at will, simulating a fully compromised server while keeping private signing keys out of reach. Each image holds a package manager, a sample project, and optionally a lockfile; the harness resolves dependencies through the hostile server, the server injects malicious code into the returned packages, and the attack counts as successful if the sample project executes the malware. Every manager ran with default flags after a quickstart-style setup, and both lockfile-present and lockfile-absent configurations were tested, since the industry genuinely disagrees about committing lockfiles.

The author states the limits plainly, and they matter for how far to generalize: one package, one manager version, one platform per test, with quickstart-grade projects rather than production builds. PowerShell tooling ran on Ubuntu rather than Windows, and cpanminus — which still fetches over unencrypted HTTP by default, a years-known weakness its maintainer has declined to change as default behavior — was tested with that behavior patched to HTTPS in the container. The setup is not meant to prove every developer is exploitable. It is meant to show that a developer doing nothing unusual can be, which is the configuration that matters at fleet scale — and at the scale of autonomous coding agents that run install commands without a human reading the output.

The lockfile lottery

The first results table sorts all 25 managers by how much work a developer must do to get integrity out of a lockfile, and the spread is the story. At the top, Go and vcpkg verify integrity outright. Then a broad middle — Bun, Bundler, Cargo, npm, pnpm, Poetry, SwiftPM, uv, Yarn — verifies if a lockfile is present, which quietly loads two conditions onto the developer: commit the lockfile, and install with the flags that enforce it. A third tier — Conda, Gradle, Pip, NuGet, Pub — needs the lockfile plus extra steps: opt-in lockfile formats, properties flipped on in project files, non-default flags. And the bottom tier — CocoaPods, Composer, Conan, cpanminus, Gem, Maven, PowerShellGet, PSResourceGet, R — gets no integrity from its lockfile path at all, either for lack of a lockfile, lack of hashes in it, or lack of anything that checks.

Two footnotes in this section deserve emphasis because they cut against the obvious reading of the table. SwiftPM’s registry protocol does support signatures, but only through private server implementations; with no public registry speaking that protocol, the test used the signatureless Git path, which is what the public actually consumes. And Gradle’s gradle.lockfile carries no hashes — but Gradle can hold content hashes in a separate verification-metadata file, which is exactly the “lockfile plus extra steps” pattern: the protection exists, in another file, behind configuration most projects never write.

A hash that warns and installs anyway

The study’s most instructive section is the one where hashes are present and still do not protect, because it shows how many good-faith designs answer a different question than “was this tampered with.” Dart’s Pub manager detects a content-hash mismatch against pubspec.lock, prints a message explaining the lockfile may be corrupted — and installs the package anyway, updating the lockfile to match the server. Blocking requires the non-default --enforce-lockfile flag, and since common Flutter subcommands invoke the fetch implicitly, everyday workflows can silently re-baseline trust onto whatever the mirror served. Until version 11.4 in May 2026, pnpm behaved the same way by default, overwriting the lockfile on mismatch; the fix that changed the default was explicitly framed as closing supply-chain holes.

The remaining cases are subtler, and each names a distinct failure mode worth auditing for. Conan’s lockfile carries a 128-bit revision hash, but it is an MD5 used as a unique identifier for a recipe, never recomputed at install — and the platform-specific binary revision is not in the lockfile at all. CocoaPods pins a SHA-1 of the podspec, which guards the dependency’s metadata while the actual code arrives from a Git tag on a server that, if compromised, simply serves different code under the same tag. Composer records the commit hash that uniquely identifies the code, yet downloads a ZIP archive containing no Git tree to verify against — so the archive can diverge from the pinned commit undetected. The pattern across all five: an integrity-looking value that binds the wrong object, checked by nothing, or checked too late to stop the install. A scanner that merely confirms “lockfile present, hashes present” would bless every one of these configurations.

Signatures exist; verification is optional

The second table repeats the exercise for signatures, and the result is harsher: only Go verifies by default. Six managers verify with configuration, eight have missing or incomplete verification code, and eight have no signatures available to verify at all. The gap between “the ecosystem signed something” and “your install checked it” is where most of the industry currently lives, and the study walks it manager by manager.

The details sting precisely because the signing infrastructure has genuinely improved. Sigstore’s identity-based “keyless” signing — short-lived certificates from the Fulcio CA bound to OIDC identity, no long-term keys to manage — underpins provenance attestations now supported by PyPI, npm, RubyGems, and Maven Central, with better adoption than the older optional developer-key schemes ever achieved. But adoption of signing is not adoption of verification. Running npm install does not check registry signatures; that requires the separate npm audit signatures command almost nobody runs in a pipeline. pnpm recently added an equivalent — but it fetches the registry’s public keys from the registry under test, so a malicious mirror simply presents its own key and self-consistent signature. Bun and Yarn have no equivalent command. NuGet’s default signature mode is literally named accept, installing invalidly signed packages until an operator switches it to require and curates trusted signers. Gem, Bundler, Maven, and Gradle can verify developer signatures only after manual key enrollment. Pip, uv, and Poetry currently have no path at all for verifying the identity-based attestations their registries can carry. And cpanminus once verified repository checksums, then removed the code in response to a verification-bypass vulnerability — the verification was worse than absent, it was a false assurance, and now it is gone.

There is a direct line from this section to our SlugFest coverage: trusted publishing moved npm toward short-lived OIDC identity at publish time, yet the client still does not verify what the registry signed at install time. Identity at the publisher and verification at the consumer are two halves of one mechanism, and the industry spent 2026 building the first half. Ryan’s tables inventory the missing second half.

The one design that binds a name to bits

Go stands alone in both tables, and the reason is architectural rather than cultural. When go get or go mod download fetches a module, it hashes the contents and checks the name, version, and hash against a checksum database whose contents are registry-signed — with the default database’s public key hardcoded in Go’s own source. As long as that key is used honestly, a bare name-and-version resolves to exactly one set of bits, which is why go.mod needs no hashes and go.sum is a local cache of database entries rather than a lockfile. The checksum database is a transparency log in the certificate-transparency and Sigstore-Rekor sense: a global, tamper-evident record that makes selective targeting — serving a clean package to everyone except one victim — detectable rather than silent.

Ryan does not let Go off with a trophy. The registry-signature shape of the design concentrates trust in a single private key, and transparency logs fail open against log-key compromise, underlying vulnerabilities, or a world where nobody actually monitors the log. Go performed best under test; it also has the most centralized trust to lose. That symmetry is the study’s most mature judgment: every integrity design moves trust somewhere, and the honest question is always where it pooled and who watches the pool.

Lockfiles were never only about attackers

One section steps back to explain why the ecosystem underperforms on a test it never agreed to take. Lockfile hashes arrived as an integrity side effect of mechanisms built for other jobs: pinning functionality, sharing consistent environments, reproducible builds. Reading them purely as security controls is, as the study puts it, a recent interpretation that can collide with the original design — the pnpm default-flip being one collision, the libraries-versus-applications commit question another. Most guidance says commit the lockfile for applications; for libraries the advice splinters across commit, exclude, and dealer’s-choice, because managers ignore libraries’ lockfiles to dodge transitive conflicts. The study cites Gamage et al.’s interview research with fifteen developers, which found lockfile practice varies by manager and surfaces five perceived benefits — build determinism, integrity, and visibility into indirect dependencies among them. Security is one tenant in a shared building, and it does not set the house rules.

The proposed way forward follows from that diagnosis: stop asking the lockfile to commit to a hash of particular bits, and have it commit to a developer identity or public key instead. That reuses the hash-distribution machinery already deployed, while separating “which version do I build” from “who am I willing to believe.” A lockfile could pin one version for reproducibility or a lower bound for fast updates, with the embedded identity stopping a malicious mirror from substituting either. Pair it with a transparency log against targeted substitution, build on Sigstore’s keyless defaults with self-managed keys for those who want them, and the shape is already visible in the wild: attestation identities in Python’s pylock.toml specification, trusted keys in Gradle’s verification metadata, the key map in Maven’s PGP verification plugin. The closing analogy is HTTPS a decade ago — nearly universal now, driven by recognition of the risk plus free, easy tooling as a public good — with package signing cast as the next public good waiting for its Let’s Encrypt moment and its funding.

Why this lands harder in the age of the agent

This site keeps finding the same shape from different directions, and Ryan’s study is the mirror-image complement to all of them. Our Eye Security piece argued for a three-day release-age rule committed beside the lockfile precisely because coding agents inherit whatever the package manager permits and cannot be talked out of it at the prompt layer. The S1ngularity retrospective showed agents acting as the installer class — the software that fetches, builds, and runs third-party code on instruction. TensorLake proved valid provenance coexists with a poisoned source. TeamPCP’s long campaign, from the Trivy and LiteLLM feed compromises onward, keeps demonstrating that the serving layer is already a battlefield. Ryan adds the missing measurement: in most ecosystems, the client side of that battlefield has no sentries posted by default, and the sentries that exist mostly need to be hired, configured, and supervised per project.

There is also an uncomfortable interaction with the agent threat du jour. Prompt-injection guidance tells operators to constrain what the agent may fetch and run — but every one of those constraints assumes the fetch returns the bytes the name denotes. Against a malicious mirror, the agent’s allowlisted dependency is the payload’s delivery address, and no prompt-level policy distinguishes them. Integrity verification is the layer below the agent’s decisions, and you cannot prompt your way around its absence.

What to do

  • Commit the lockfile for applications, and enforce it in CI with the flag your manager actually requires. Presence is not enforcement: use frozen or enforce modes (--frozen-lockfile, --enforce-lockfile, restore-with-lockfile properties, Gradle verification metadata) so a mismatch fails the build instead of re-baselining trust onto the mirror.
  • Audit which tier of Ryan’s tables each of your ecosystems sits in — then close the specific gap. Switch NuGet signature mode from accept to require with curated signers; run signature-audit commands where they exist and verify they check against Sigstore rather than the registry under test; enroll developer keys where verification is manual; assume nothing where no verification path exists.
  • Treat internal mirrors and proxies as untrusted until the client proves otherwise. The study’s adversary is any server that answers a fetch — compromised registry, poisoned mirror, hostile proxy. If your client cannot detect substitution, your mirror is a single point of compromise for every build behind it.
  • Put signer identity into your pinning strategy, not just version numbers. Where lockfile formats support attestation identities or trusted keys, use them: a version pin says what to build, an identity pin says whom to believe, and only the second survives a lying server.
  • Constrain agent-driven installs at the manager layer, not the prompt layer. Agents inherit client defaults silently and at machine speed. Cooldown rules, frozen lockfiles, and signature requirements are constraints the agent cannot negotiate away — instructions in a system prompt are.
  • Fund the boring part. Ryan’s closing point is aimed at the industry, but operators can act on it too: the managers whose communities have pushed integrity work for years need resources and adoption of what they already built, not another advisory telling developers to be careful.

Our verification was read-only and external. The study’s design, harness architecture, both results tables, the Pub/pnpm/Conan/CocoaPods/Composer cases, the signature-verification gaps, the Go checksum-database mechanics, the proposed identity-in-lockfile design, the cited precedents, and the stated limitations above were read first-hand from the Trail of Bits post itself, fetched directly. Brave Search was used for discovery only. We installed no package manager, ran no harness, stood up no malicious mirror, and contacted no registry. Figures, footnote numbering, and quoted phrases are the study’s own; ecosystem states are reported as tested in the study’s configuration, not as universal claims about every deployment.

Sources: