DEV Community

Jules Robineau
Jules Robineau

Posted on Originally published at jrobineau.com

Why the npm Worm Does Not Work in Go, and Where Go Is Still Exposed

On August 4, a worm crawled through npm. It started from a widely used cache library, keyv, and spread from package to package on its own. Hundreds of poisoned packages in a few hours.

Since then, I keep hearing the same question: "would that work in Go?" Short answer: that worm, no. Honest answer: Go has doors of its own, and some are wide open.

I replayed every mechanism with Go 1.27 to check what I am saying. The outputs below are real.

TL;DR: a worm needs three things. Run at install time, find a token to publish with, and spread without a human touching anything. npm offered all three. Go refuses all three by design. Nothing runs on download. There is no registry to publish to. And nothing moves without a change to go.mod. But Go runs dependency code the moment you type go test. It keeps a poisoned version in cache forever. And your update bot can open the door for it.

This article is for Go teams who read the npm headlines and want to know what applies to them. No knowledge of Go module internals needed.

What the worm did, in three links

On the morning of August 4, poisoned versions of keyv and the cacheable family appear on npm. A single line in package.json does all the work: a preinstall script. npm runs it automatically during npm install, before the package code is even used.

That script downloads a runtime and launches an obfuscated payload. It searches the machine: cloud keys, GitHub tokens, npm tokens, CI secrets. Then it uses the stolen npm tokens to republish poisoned versions of its victims' packages. And so on.

The result: hundreds of packages hit, across organizations with no link to each other, in less than a morning. Socket flagged the first poisoned version six minutes after publication. Too late for the first npm install runs.

The detail that hurt: the poisoned versions carried valid provenance, signed by GitHub Actions. The attacker did not break the signature. They pushed their code into the repository, and the legitimate release pipeline signed the poison. Chainguard put it well: provenance proves who published, not that the publisher's environment was trustworthy.

Keep the mechanics in mind. Execution at install. A publishing token within reach. Automatic propagation to every project depending on the package. Three links. Let's look at Go link by link.

Link 1: in Go, downloading runs nothing

It is a design goal the Go team wrote down in plain words. Neither fetching nor building a module may execute its code, even malicious code. There is no equivalent of preinstall. A Go module is an archive of sources and a hash. Nothing else.

I checked with a homemade poisoned dependency: a local module whose init() function writes to stderr the moment it runs. Here is what the commands give:

$ go get github.com/google/uuid@v1.6.0
go: downloading github.com/google/uuid v1.6.0
go: added github.com/google/uuid v1.6.0

$ go build ./... && go vet ./...
(nothing: the poisoned dependency did not run)

$ go test ./...
[evil] init ran: HOME=/home/jules GITHUB_TOKEN present=true
Enter fullscreen mode Exit fullscreen mode

Download, compile, analyze: nothing runs. The worm's first link does not exist. But read the last line. As soon as you run go test or go run, the dependency's code executes. With your environment, your variables, your tokens. I will come back to that.

Link 2: there is no registry to publish to

The npm worm spreads by publishing. It has a token, it pushes a version, thousands of projects install it. In Go, there is nothing to push. A Go module is a tag in a git repository. The public proxy, proxy.golang.org, fetches the tag itself:

$ go mod download -json github.com/google/uuid@v1.6.0
"Sum": "h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=",
"Origin": {
    "VCS":  "git",
    "URL":  "https://github.com/google/uuid",
    "Hash": "0f11ee6918f41a04c201eceeadf612a377bc7fbc",
    "Ref":  "refs/tags/v1.6.0"
}
Enter fullscreen mode Exit fullscreen mode

To publish a poisoned version of a Go library, you need write access to its git repository. A stolen GitHub token can do it, true. But that is no longer a registry token sitting in a config file. It is write access to a repository, more visible, more often protected by a review or a signature.

And every version is carved in stone. The proxy computes a hash and records it in sum.golang.org, a public log nobody can edit after the fact. Everyone receives the same bytes, forever. An attacker cannot serve a clean version to auditors and a poisoned one to everyone else.

Link 3: nothing moves without touching go.mod

This is the real worm killer. In npm, a dependency declared as ^6.0.0 automatically accepts 6.0.1. The worm publishes a poisoned 6.0.1, and the next npm install takes it. No human decided anything.

Go does the opposite. It picks the lowest version that satisfies everything go.mod declares, never the latest published. That is minimal version selection, MVS. A newly published version is never taken on its own:

$ go list -m -u github.com/google/uuid
github.com/google/uuid v1.5.0 [v1.6.0]   # an update exists

$ go build ./... && grep uuid go.mod
require github.com/google/uuid v1.5.0    # the build leaves it alone
Enter fullscreen mode Exit fullscreen mode

Changing a version takes an explicit command, go get, and a commit that modifies go.mod and go.sum. A poisoned upstream version stays out of your build as long as nobody asked for it. The worm can publish all it wants. Without a commit on your side, it does not crawl.

And if someone tries to swap the bytes of an already known version, the build stops dead:

verifying github.com/google/uuid@v1.6.0: checksum mismatch
    downloaded: h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
    go.sum:     h1:AAAAJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=

SECURITY ERROR
This download does NOT match an earlier download recorded in go.sum.
Enter fullscreen mode Exit fullscreen mode

Three links, three refusals. That worm does not get through Go. Now, the doors.

Door 1: go test runs everything, with your secrets

Back to the [evil] init ran line. The npm worm stole tokens at install time. In Go, it just waits a little longer: the first go test, the first go run. On your machine, or in your CI, with the same environment variables.

A poisoned dependency does not need preinstall. An init() function is enough. It runs before your main, before your tests, without you calling it. In spring 2025, three ordinary-looking Go modules did exactly that: an obfuscated script that, on Linux, fetched a payload and wiped the disk.

So the difference with npm is not "the code does not run". It is "the code does not run until you have added it to your build". That delay gives you time to read the diff of a new dependency. Use it, or the protection is worth nothing.

Door 2: your update bot can change go.mod for you

MVS protects you as long as a human decides versions. Many teams have delegated that decision to a bot. Me included: Renovate opens dependency PRs every day, and minor updates merge on their own once CI is green.

Look at what that does to link 3. The worm publishes a poisoned version in the morning. The bot opens the PR at noon. CI runs go test, hence the poisoned code, with the job's secrets. Then it goes green, and the PR merges. You just rebuilt npm's automatic propagation by hand.

The answer fits in one setting: a delay before taking a new version. GitHub made it the default in Dependabot in late July, a week before the worm: a three-day wait. Renovate has the same option, minimumReleaseAge. The poisoned keyv versions lived for a few hours. With three days of latency, your bot would never have seen them.

// renovate.json
{
  "minimumReleaseAge": "3 days",
  "packageRules": [
    { "matchUpdateTypes": ["major"], "automerge": false }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Door 3: the proxy never forgets, poison included

Proxy immutability protects in one direction and traps in the other. In 2021, someone publishes github.com/boltdb-go/bolt, a copy of the real boltdb/bolt library with a backdoor. That is typosquatting. The Go proxy caches the version.

Then the attacker rewrites the tag on GitHub to point at clean code. An auditor reading the repository sees nothing. The proxy, meanwhile, serves the cached, poisoned version to everyone. For more than three years, until it was discovered in early 2025.

The rule that guarantees "the same bytes for everyone" also guarantees "the same poisoned bytes for everyone". The hash log proves consistency, not innocence. Exactly like npm provenance.

Door 4: what runs anyway

Four cases run code you have not read, and you need to know them.

go generate runs the commands written in //go:generate comments. Never on its own, only when you type the command. Verified: my go build did not trigger it, my go generate did.

The tool directive in go.mod, since Go 1.24, declares tools as dependencies. Same rule: go build does not start them, go tool name runs them. A poisoned tool therefore runs when you call it, with your rights.

cgo compiles C during go build. A C compiler should not execute what it compiles. Unless there is a bug. CVE-2025-61732 was one: a difference in how Go and the C compiler read comments allowed code to be smuggled in and executed at compile time. Fixed since. Keep your toolchain current, and be wary of a dependency that brings C for no reason.

And the most dangerous command is the most ordinary one: go run github.com/someone/tool@latest. It is the exact equivalent of npx. Download and immediate execution, floating version, no go.mod to hold you back.

What I change in my pipelines after August 4

My supply chain pipeline from July still stands. Pin, scan, inventory, sign. The August worm adds three reflexes.

A delay on automatic updates. Three days, not zero. A minor update is not worth the risk of being the first to install it.

Tests that run with as few secrets as possible. The test job does not need the deploy key. If it has it, the poisoned dependency has it too. A short-lived OIDC token, requested only by the job that deploys, leaks nothing useful.

A look at behavior, not only at CVEs. govulncheck only knows published vulnerabilities. capslock lists what a Go package is capable of: network access, running commands, reading files. A cache library that opens an outbound connection has a question to answer.

On my test bench, it points straight at the poisoned dependency's init(), which reads the environment, with the exact call path:

$ capslock -packages ./... -output v
Analyzed packages:
  example.com/evil v0.0.0
  github.com/google/uuid v1.6.0

READ_SYSTEM_STATE: 1 references (0 direct, 1 transitive)
Example callpath:
  example.com/app.init
  example.com/evil.init
  evil.go:11:12:os.Getenv
Enter fullscreen mode Exit fullscreen mode

A dependency that shows up under NETWORK or EXEC with no business reason, you read before you keep.

The checklist

The worm does not crawl through Go on its own. Do not lend it a hand.

  • [ ] No floating version: go.mod and go.sum committed, builds in -mod=readonly
  • [ ] GOTOOLCHAIN=local in CI, with a pinned toolchain
  • [ ] A three-day delay before any automatic version bump (minimumReleaseAge)
  • [ ] Major updates wait for a human, and the diff gets read
  • [ ] go test in CI without deploy secrets: short OIDC token, only in the job that deploys
  • [ ] Never go run pkg@latest on a machine that holds secrets
  • [ ] go generate and go tool only on pinned tools, reviewed once
  • [ ] A dependency that brings C or a networked init() has to justify itself (capslock)
  • [ ] go mod verify and govulncheck in the pipeline, as before
  • [ ] No blind trust in a signature: provenance and sumdb prove origin, not intent

What to remember

Go did not get lucky on August 4. It had an architecture. No execution on download, no registry, no version that moves without a commit. The npm worm stops at each of those doors.

But Go runs your dependencies at the first go test, keeps the poison in cache, and your update bot can sign in your place. The worm does not crawl on its own. Do not lend it a hand.

Want a second pair of eyes on your Go project's supply chain? Let's talk.


Sources: Socket, keyv/cacheable compromise analysis (4 August 2026) · Chainguard, "Inside the Mini Shai-Hulud campaign" · GitHub, "Disrupting supply chain attacks on npm and GitHub Actions" (28 July 2026) · Go blog, "How Go Mitigates Supply Chain Attacks" · Go modules reference, authentication · Socket, the boltdb-go/bolt backdoor (February 2025) · The Hacker News, disk-wiping Go modules (May 2025) · golang/go#76697, CVE-2025-61732 · outputs captured with Go 1.27.0 on my own test bench

Top comments (0)