- Resolve the path
github.com/…maps straight to a repository. Other paths cost one request,GET https://golang.org/x/crypto?go-get=1, read from ago-importmeta tag. - Fetch from the proxy
proxy.golang.org/<path>/@v/v1.6.0.info,.mod,.zip. The proxy serves its own cached copy, not the live repository. - Hash the zip
h1:= base64(SHA-256 of the sorted list of per-file SHA-256 hashes). - Check
go.summatch: continuemismatch: SECURITY ERRORmissing: step 5 - Check
sum.golang.orgtrust decisionThe first request for a version makes the log record its hash permanently. The log checks consistency, not behavior. Malware recorded here is the canonical version. - Write
go.sumand the module cacheThe cache under$GOMODCACHEis read-only by default. - BuildNo module code runs during fetch or build. No install-script hook exists.
- Run or testfirst executionEvery
init()in every imported package runs beforemain()or the first test.
Go Modules
Go modules are Go's dependency system: versioned packages named by repository paths, declared in go.mod and verified against go.sum and a public checksum log.
Also known as golang modules, Go module system, vgo
Go modules (often shortened to go mod) are the Go toolchain's dependency system. A module is a versioned tree of packages named by a repository-style path, declared in go.mod, with content hashes recorded in go.sum. The go command fetches modules through a caching proxy and checks each download against a public checksum log, so every user builds identical code.
Go modules make an integrity promise few ecosystems match. Once anyone has fetched a module version, a public append-only log fixes its hash, and every later download must match that hash or the build stops. The promise covers integrity, not safety. The log records whatever the first fetch returned, malware included. A backdoored typosquat of BoltDB stayed downloadable through the default proxy for more than three years after its author scrubbed the GitHub repository. Our view is that Go has largely solved reproducible resolution and install-time execution. What remains is the moment someone adds a new module path, and the init() code that runs when a test or binary starts. Review that moment, and analyze the module zip the proxy serves rather than the repository it came from.
What are Go modules?
#A Go module is a collection of Go packages with a go.mod file at its root, released by tagging a semantic version in a version control repository. The module path, such as github.com/shopspring/decimal, is both the import prefix and the download location. The go command is the only package manager Go needs. There is no registry account, no publish step, and no upload: pushing a v1.2.3 tag releases a version. Modules shipped in Go 1.11 in August 2018, and Go 1.13 made the public module mirror and checksum database the default in September 2019, according to the Go release history.
Here is a module built from scratch. main.go imports github.com/shopspring/decimal, and nothing else is configured. Go 1.27 writes go 1.26.0 into a new go.mod, one release behind the toolchain, a default introduced in Go 1.26.
$ go mod init example.com/hello
go: creating new go.mod: module example.com/hello
$ go mod tidy
go: finding module for package github.com/shopspring/decimal
go: downloading github.com/shopspring/decimal v1.6.0
go: found github.com/shopspring/decimal in github.com/shopspring/decimal v1.6.0
$ cat go.mod
module example.com/hello
go 1.26.0
require github.com/shopspring/decimal v1.6.0
$ cat go.sum
github.com/shopspring/decimal v1.6.0 h1:DcTwjCj4tJPLuGZYsR9W0hyX9pQwMIkdo26Ig1T9xX4=
github.com/shopspring/decimal v1.6.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=The go.mod file states requirements: the module's own path, the minimum Go version, and a minimum version of each dependency. The go.sum file holds two hashes per version. One covers the whole module zip. The other covers that version's go.mod alone, so the resolver can read a dependency's requirements without downloading its code.
Paths on github.com and a few other known hosts map straight to a repository. Any other path costs one HTTPS request. For golang.org/x/crypto, the go command sends GET https://golang.org/x/crypto?go-get=1 and reads a <meta name="go-import"> tag that names the real repository. Naming modules by URL cuts both ways for security. Dependency confusion mostly disappears, because a private path like corp.example.com/auth contains a domain no attacker controls. Typosquatting gets easier, because a lookalike module is just a lookalike GitHub account. The bpoorman/uuid typosquat imitated pborman/uuid for more than four years. The xinfeisoft/crypto module posed as a copy of golang.org/x/crypto and stole whatever users typed into ReadPassword.
What does go mod tidy do?
#go mod tidy rewrites go.mod and go.sum to match the imports in your source code. It adds modules that provide imported packages, removes modules that provide none, and adds or deletes the matching go.sum hashes. To see it work, we removed the only import of example.com/b from a small test module and asked for a diff instead of an edit:
$ go mod tidy -diff
--- current/go.mod
+++ tidy/go.mod
@@ -2,9 +2,6 @@
go 1.22
-require (
- example.com/a v1.0.0
- example.com/b v1.0.0
-)
+require example.com/a v1.0.0
require example.com/c v1.2.0 // indirect
--- current/go.sum
+++ tidy/go.sum
@@ -1,8 +1,4 @@
example.com/a v1.0.0 h1:0YstTFP57oHaB1LHPkou3uI4hQ88VqJSKHbyUCWyBWs=
example.com/a v1.0.0/go.mod h1:Cg5o19qt63F5qZnxvlazSN42WL9TH81VnebnIKrtmII=
-example.com/b v1.0.0 h1:wCS/R+/gI9yztqYqTqxyI9u0oaSck7FG6xNbln2o0Cs=
-example.com/b v1.0.0/go.mod h1:9xOO/byH5Q+KemurEsp2tY8/aAoXrSc2tl+NB/mnZtM=
example.com/c v1.2.0 h1:PhyzfjQoRfDFaFi02X74upfHixlYohvTkufaLxJBqWQ=
example.com/c v1.2.0/go.mod h1:slkmCTK66XZBf3CTta1WAoTNx6XLaBNrYJ6tHb7WT0g=
-example.com/c v1.3.0 h1:EfymIOn59whz4SfyhpR4EGYrP+RbA1hQF6npBO1jW7s=
-example.com/c v1.3.0/go.mod h1:slkmCTK66XZBf3CTta1WAoTNx6XLaBNrYJ6tHb7WT0g=
$ echo $?
1Three details in that output matter for security. First, go mod tidy does not downgrade: example.com/c stays at v1.2.0 even though the remaining dependency asks only for v1.1.0. Second, go.sum accumulates: hashes for c v1.3.0, left over from an earlier go get, stayed in the file until tidy pruned them. Third, only explicit commands such as go get and go mod tidy change your dependency set. Since Go 1.16, ordinary builds fail instead of editing an incomplete go.mod, as the Go blog's supply chain overview notes.
That makes go mod tidy -diff a cheap CI gate: a non-zero exit means go.mod and go.sum no longer match the code. As of Go 1.27, tidy also merges scattered require blocks into at most two, one direct and one indirect, in modules that declare go 1.27, per the Go 1.27 release notes. Each new path in a go.mod diff is a new trust decision. Review it like a code change, because it is one.
Minimal version selection keeps new releases out of builds
#Go chooses dependency versions with minimal version selection (MVS): for each module, it uses the highest version that any go.mod in the graph requires, never anything newer. A new release on the proxy does not change a build. Only a new requirement does.
We built a three-module graph behind a local file proxy to show it. example.com/a requires c v1.1.0, example.com/b requires c v1.2.0, and the proxy also offers a later c v1.3.0:
$ go list -m all
example.com/app
example.com/a v1.0.0
example.com/b v1.0.0
example.com/c v1.2.0
$ go get -u ./...
go: downloading example.com/c v1.3.0
go: upgraded example.com/c v1.2.0 => v1.3.0c v1.3.0 stayed out of the build until someone explicitly ran go get -u. Compare the axios compromise in March 2026, where ranges like ^1.13.5 resolved fresh npm installs to the malicious 1.14.1 while it was live. Under MVS, a poisoned release reaches a transitive dependency graph only when its owner chooses to upgrade during the window.
The Go wiki answers a common confusion in one line: "No, go.sum is not a lock file." The go.mod files in the graph already determine the build list, and go.sum only records which bytes those versions must hash to. Go therefore needs no separate lockfile, and a fresh clone resolves the same versions years later.
Two gaps remain. The first is deliberate freshness. go get -u and automated update bots pull the newest versions on purpose, and the go command has no built-in dependency cooldown. As of October 2026, the proposal to add one, golang/go#76485, is on hold while the Go team explores a cooldown at the module proxy layer. Until then, configure the delay in the update bot.
The second gap is tools run outside your module. go run example.com/tool@v1.0.0 resolves the tool's dependencies from the tool's own go.mod, which is safer than npx because those transitive versions are fixed. It also never touches your module's files:
$ sha256sum go.mod go.sum > /tmp/before
$ go run example.com/tool@v1.0.0
go: downloading example.com/tool v1.0.0
tool init() ran
$ sha256sum -c /tmp/before
go.mod: OK
go.sum: OKYour go.sum is therefore not a record of what CI executed. Our analysis of the axios blast radius made the same point about npm lockfiles and npx. And go run tool@latest floats the top-level version exactly the way npx tool does.
How does Go verify module downloads?
#Go verifies each module download by hashing its contents and comparing the result with go.sum. For a version go.sum has never seen, it also checks the Go checksum database at sum.golang.org. Figure 1 shows where each check sits between typing an import and running code.
Figure 1, in text: adding a module version takes eight steps. (1) Resolve the module path: github.com/... maps straight to a repository, and other paths cost one ?go-get=1 request whose go-import meta tag names the repository. (2) Fetch .info, .mod, and .zip from proxy.golang.org, which serves its own cached copy rather than the live repository. (3) Compute the h1: hash, base64 of a SHA-256 over the sorted list of per-file SHA-256 hashes. (4) Compare with go.sum: a match continues, a mismatch stops with SECURITY ERROR, and a missing entry goes to step 5. (5) Check sum.golang.org. This is the trust decision: the first request for a version makes the log record its hash permanently, and the log checks consistency, not behavior. (6) Write go.sum and the read-only module cache. (7) Build, which runs no module code because Go has no install-script hook. (8) Run or test, the first execution: every init() in every imported package runs before main() or the first test.
The proxy protocol is a handful of plain HTTPS GETs with no query parameters: $GOPROXY/<module>/@v/list, @v/<version>.info, @v/<version>.mod, @v/<version>.zip, and @latest. Any static file server, including a file:// URL, can act as a proxy. The module cache on disk uses the same layout:
$ ls $GOMODCACHE/cache/download/github.com/shopspring/decimal/@v/
list v1.6.0.info v1.6.0.lock v1.6.0.mod v1.6.0.zip v1.6.0.ziphash
$ cat $GOMODCACHE/cache/download/github.com/shopspring/decimal/@v/v1.6.0.info
{"Version":"v1.6.0","Time":"2026-10-18T15:33:05Z","Origin":{"VCS":"git","URL":"https://github.com/shopspring/decimal","Hash":"447b833d1eea2d7720d37ae95fcf090d01fa866b","Ref":"refs/tags/v1.6.0"}}The h1: value is not a hash of the zip file. The dirhash package defines it as base64 of a SHA-256 over a sorted manifest of per-file SHA-256 hashes, so you can reproduce it with coreutils:
$ unzip -q v1.6.0.zip
$ find . -type f | sed 's|^\./||' | LC_ALL=C sort | xargs sha256sum \
| sha256sum | cut -d' ' -f1 \
| python3 -c 'import sys,base64;print("h1:"+base64.b64encode(bytes.fromhex(sys.stdin.read().strip())).decode())'
h1:DcTwjCj4tJPLuGZYsR9W0hyX9pQwMIkdo26Ig1T9xX4=That matches the first go.sum line above. Because the hash covers file contents rather than zip bytes, recompressing a module does not change it, but editing any file does. We served a copy of v1.6.0 from a local file proxy with one import added to decimal.go, then rebuilt:
$ GOPROXY=file:///tmp/fakeproxy go build .
go: downloading github.com/shopspring/decimal v1.6.0
verifying github.com/shopspring/decimal@v1.6.0: checksum mismatch
downloaded: h1:ndU8FaNu7sldceLKMToCktMmHFCuRTDqB2YLDRkh+uQ=
go.sum: h1:DcTwjCj4tJPLuGZYsR9W0hyX9pQwMIkdo26Ig1T9xX4=
SECURITY ERROR
This download does NOT match an earlier download recorded in go.sum.
The bits may have been replaced on the origin server, or an attacker may
have intercepted the download attempt.
For more information, see 'go help module-auth'.A go.sum entry protects only versions you have already seen. Russ Cox and Filippo Valsorda's 2019 checksum database design names the gap directly: before it, the downloads that add or update dependencies were not authenticated, a model they compared to SSH's "trust on first use." The checksum database closes that gap for everyone at once. Valsorda later described it on the Go blog as "a global append-only cryptographically-verifiable list of go.sum entries." It is a Merkle-tree transparency log: clients verify that the server never removes a record, and auditors can detect a server that shows different users different histories. When a hash disagrees with the log, the go command stops with a second message: "This download does NOT match the one reported by the checksum server."
What the log proves is consistency: you got the same bytes as everyone else. If the first fetch of a version was malware, the log certifies the malware.
Dependency code runs at init(), not at install
#Dependency code in Go first runs when a program or test binary that imports it starts, because Go executes every imported package's init() functions before main(). Fetching and building run nothing. We put an init() that writes a marker file into a local dependency, then ran the everyday commands in a module that imports it:
| Command | Did the dependency's init() run? |
|---|---|
| go mod tidy | No |
| go mod download | No |
| go build ./... | No |
| go vet ./... | No |
| go test ./... | Yes, inside the app.test binary |
Valsorda stated the limit plainly on the Go blog in March 2022: "There is no security boundary within a build: any package that contributes to a build can define an init function." In practice, go test is Go's equivalent of npm install. A CI job that runs tests on a pull request executes every dependency's init() with that job's tokens in its environment. The shopsprint/decimal typosquat did exactly this: its v1.3.3 added an init() that polls DNS TXT records every five minutes and executes each value as a command.
Run-time execution is still a real improvement on npm and PyPI. Go never had install scripts, the default-on hook that npm only switched off with npm v12 in July 2026. Moving first execution from install time to run time narrows the window, but the developer laptop and the CI runner are still where it opens.
Three narrower paths run code earlier:
- cgo flags. CVE-2018-6574 let a package load a compiler plugin through a
#cgodirective, sogo getexecuted attacker code during the build. Go 1.9.4 fixed it on February 7, 2018 by accepting only an allowlist of compiler and linker flags. - Toolchain switching. Under the default
GOTOOLCHAIN=auto, agoortoolchainline in the main module that is newer than the installed Go makes thegocommand download and run that toolchain. Only official builds from thegolang.org/toolchainmodule qualify, and withGOSUMDB=offthe download fails rather than run unverified. - Repository files read by other tools. The Miasma campaign reached the Go ecosystem in June 2026 through
github.com/verana-labs/verana-blockchain, but no Go build ran the payload. A VS Code folder-open task in.vscode/tasks.jsondid. Opening a module's repository in an IDE or coding agent is not the same as fetching the module.
Can a malicious Go module be removed?
#A malicious Go module cannot be removed by its author, because proxy.golang.org serves its own cached copy and the checksum database keeps the hash regardless. Deleting the tag, the repository, or the GitHub account changes nothing for go get. The mirror's FAQ tells authors who regret a release to publish a new version instead. It also says checksums stay in the database even when the mirror stops serving a module.
The BoltDB typosquat we reported in February 2025 shows what that permanence does to code review. Figure 2 traces the two views of the same version.
github.com/boltdb-go/bolt: two views of v1.3.1go get receivesdb.go.sum.golang.org records its h1: hash.Figure 2, in text: github.com/boltdb-go/bolt imitated github.com/boltdb/bolt, which 8,367 packages depended on. In November 2021 the attacker tagged v1.3.1 on a commit carrying a backdoor, and the first fetch cached that zip on the proxy while sum.golang.org recorded its hash. Later, the attacker repointed the v1.3.1 tag at clean code, so GitHub showed a benign fork while go get still received the backdoor. In February 2025, more than three years after the upload, we disclosed the module, and the backdoored v1.3.1 was still served.
The backdoor activated when a caller ran Open(), opened a TCP connection to a C2 server at 49.12.198[.]231:20022, and executed whatever shell commands came back. The address was assembled at runtime from constants hidden in cursor.go. The proxy's .info file for the malicious version also lacked the Git commit hash that normally ties a version to its source.
The checksum database made the tag rewrite pointless in a perverse way. Once sum.golang.org had recorded v1.3.1, a client that fetched the rewritten tag directly from GitHub, bypassing the proxy, would have computed a different hash and stopped with a checksum mismatch. Every honest path led to the backdoor or to an error. Go's integrity machinery worked exactly as designed, and it made the malware the canonical v1.3.1.
You can check any dependency for this kind of tag drift. Compare the commit the module was cached from with the tag's current target:
$ go mod download -json github.com/shopspring/decimal@v1.6.0 | jq -r .Origin.Hash
447b833d1eea2d7720d37ae95fcf090d01fa866b
$ git ls-remote https://github.com/shopspring/decimal 'refs/tags/v1.6.0*'
447b833d1eea2d7720d37ae95fcf090d01fa866b refs/tags/v1.6.0If the hashes differ, the tag moved after the version was cached, and the repository no longer shows the code you build. For annotated tags, compare against the ^{} line. A missing Origin block deserves the same suspicion, as the BoltDB case showed.
Removal now depends on the Go security team. After a report, the proxy can block a module and answer every fetch with 403 SECURITY ERROR. That is how github.com/shopsprint/decimal was cut off in May 2026, about 33 months after its malicious v1.3.3 appeared on August 19, 2023. Blocking stops new fetches only. Module caches, vendor/ directories, and compiled binaries keep the code. Go also publishes no per-module download counts, so nobody outside the proxy operator can size the exposure, and the "Imported by" number on pkg.go.dev counts only public importers.
Where Go modules stop supply chain attacks, and where they don't
#| Attack | Default npm or PyPI exposure | Go modules | Verdict |
|---|---|---|---|
| Code execution at install | npm before v12, PyPI source distributions | No install hooks; fetch and build run nothing | Closed |
| Fresh malicious release pulled in by a version range | Any fresh install inside the window | MVS ignores new releases until someone upgrades | Mostly closed |
| Published version silently replaced | Registry policy and server honesty | go.sum plus a transparency log | Closed |
| Dependency confusion | Public name shadows a private one | Paths include a domain; GOPRIVATE keeps private paths off the proxy | Closed |
| Typosquat or lookalike path | Exposed | Exposed: any GitHub account can mint a path | Open |
| Malicious first release, or benign-then-poisoned history | Exposed | Exposed, and the checksum log certifies it | Open |
| Compromised maintainer tags a new version | Every range that matches | Only users who upgrade | Narrowed |
| Malicious code runs on import | require or import side effects | init() runs at test and run time | Open |
| Removal after detection | Registry deletes the version | Author cannot; Go team can block; caches keep it | Partial |
Verdict: Go is the strongest mainstream design for resolution and integrity, and no better than any other for identity and behavior. Use Go's defaults for the first group, and spend your review effort on the second: new module paths, version bumps, and what the code inside a module actually does.
Defending a Go dependency graph
#Gate go.mod changes. Run go mod tidy -diff in CI, and treat every new require line as a dependency review. Check the owner segment of each new path character by character against the canonical project. This catches lookalike paths at the moment they enter. It does not catch a trusted module that ships a malicious new version.
Analyze what the module contains, not what it is called. A decimal library that imports net and os/exec, or a UUID helper that posts to a paste site, is detectable from code alone. For Go projects, Socket's scanner analyzes the module code that actually gets installed and flags obfuscation, network backdoors, and misuse of exec.Command. Behavioral analysis is probabilistic, and new evasion techniques will beat any detector for some period.
Put policy in the proxy path. Because GOPROXY is plain HTTPS, a package firewall can sit in front of proxy.golang.org. Socket Firewall's registry mode supports Go modules with proxy.golang.org as the upstream and answers blocked versions with HTTP 403. Paths matched by GOPRIVATE or GONOPROXY, and anything fetched with GOPROXY=direct, bypass it.
Use govulncheck for known vulnerabilities. It reports only vulnerabilities in functions your code transitively calls, which is the reachability analysis approach and cuts most CVE noise. It answers a different question from malware detection, because a typosquat's backdoor is not a known vulnerability and often runs from init().
Keep GOSUMDB on and scope GOPRIVATE tightly. Any path matching GOPRIVATE or GONOSUMDB skips the checksum database entirely, so a broad pattern such as github.com/* silently turns off global verification for most of the ecosystem.
Go solved the problems package managers create for themselves: floating versions, mutable releases, and install hooks. It left the problem no package manager can solve alone, which is deciding whether the code at a module path deserves to run. The checksum database will prove, with cryptographic certainty, that you received the same backdoor as everyone else. Deciding whether that code should run at all is the job left to whoever reviews the go.mod diff and whatever sits in front of the proxy.
