Static Analysis and Security
Static Analysis and Security
The boring security default is not a clever scanner you run once. It is go vet on every change, govulncheck on the module graph, and secrets that never live in source. The toolchain already knows how to nag. Let it.
Mental model
Three layers, in order:
go vet— ships with Go. Reads your packages and reports mistakes the compiler allows (wrongPrintfverbs, copying async.Mutex, unreachable code).govulncheck— reads the public Go vulnerability database against the modules you actually import, then against the functions you actually call.staticcheck— a third-party checker with extra pattern rules. Optional. Useful on a team. Not required to finish this book.
None of these replace a human reading a diff. They catch the class of bug that is embarrassing in production and invisible in a happy-path run.
Worked examples
Case 1: A desk program vet is happy with
Save as go.mod and open.go in an empty directory.
module example.com/desk
go 1.27
// open.go
package main
import "fmt"
func main() {
id := 7
fmt.Printf("opened ticket %d\n", id)
}Run the program:
go run .Output:
opened ticket 7
Run vet from the same directory:
go vet ./...No output. Exit status 0. That silence is the success signal.
Case 2: A format bug the compiler will not catch
Change the verb so the argument type is wrong. Save as open.go again:
// open.go
package main
import "fmt"
func main() {
id := 7
fmt.Printf("opened ticket %s\n", id)
}The program still compiles. go run . prints something like opened ticket %!s(int=7) — a confused line, not a compile error. Vet is the tool that treats this as a defect:
go vet ./...Output (paths may be example.com/desk instead of the directory name):
# example.com/desk
./open.go:8:2: fmt.Printf format %s has arg id of wrong type int
Fix the verb (%d) or the argument (strconv.Itoa). Re-run go vet ./... until it is quiet.
Case 3: Locks copied by value (copylocks)
In Go, sync.Mutex must never be copied. If a struct containing a mutex is passed by value, each copy gets a separate lock state. The compiler will not stop you, but go vet catches it immediately.
Save as copylock.go:
// copylock.go
package main
import (
"fmt"
"sync"
)
type Till struct {
mu sync.Mutex
cents int
}
func addBad(t Till, amount int) {
t.mu.Lock()
defer t.mu.Unlock()
t.cents += amount
}
func main() {
till := Till{}
addBad(till, 500)
fmt.Println("cents:", till.cents)
}Run go vet:
go vet copylock.goOutput:
./copylock.go:13:14: addBad passes lock by value: command-line-arguments.Till contains sync.Mutex
./copylock.go:21:9: call of addBad copies lock value: command-line-arguments.Till contains sync.Mutex
Change func addBad(t Till, amount int) to func addGood(t *Till, amount int) and pass &till. Run go vet again: silence.
Case 4: govulncheck on a module with no known holes
govulncheck is not inside the go binary. The boring way to run it without a permanent install is go run on the published command. From the same module as Case 1 (the fixed %d version):
go run golang.org/x/vuln/cmd/govulncheck@latest ./...Typical output when nothing in the graph matches a known report:
No vulnerabilities found.
Newer builds may print a short header (=== Symbol Results ===) above that line. The sentence that matters is the same.
When a report does match, the tool names the vulnerable module, the fixed version, and — in symbol mode — whether your code calls the bad function. A dependency that sits unused in go.mod is a different problem (go mod tidy) than a function you call on every request.
Run this in CI next to go test and go vet. Update the module when the tool names a fix version. Do not argue with the database from memory.
Case 5: A secret that is not in the repo
Save as client.go (keep the same go.mod):
// client.go
package main
import (
"fmt"
"os"
)
func main() {
key := os.Getenv("DESK_API_KEY")
if key == "" {
fmt.Fprintln(os.Stderr, "DESK_API_KEY is not set")
os.Exit(1)
}
fmt.Println("desk client ready")
}Use only this file when you run it (or drop open.go from the directory so you do not have two main functions):
go run client.goOutput (stderr, exit 1):
DESK_API_KEY is not set
Set the variable in the environment, not in the file:
DESK_API_KEY=not-a-real-secret go run client.goOutput:
desk client ready
The process can read the key. Git never does. Do not print the value in logs.
Case 6: Optional extra — staticcheck
staticcheck is a separate binary (honnef.co/go/tools/cmd/staticcheck). Teams add it because it nags about extra patterns: unused values, always-true conditions, deprecated APIs. Install it if your team agrees. Do not block a desk tool on a linter you have not chosen yet.
A reasonable local pipeline, once you opt in:
go vet ./...
staticcheck ./...
go run golang.org/x/vuln/cmd/govulncheck@latest ./...Vet stays first because it is already on every machine that has Go.
The trap
A “temporary” key in source is a real key the moment you push. This program works on your laptop and is a credential leak:
// leak.go
package main
import "fmt"
const deskAPIKey = "sk-desk-please-rotate-me"
func main() {
fmt.Println("desk client ready")
_ = deskAPIKey
}Run:
go run leak.goOutput:
desk client ready
go vet is silent. govulncheck is silent. The leak is the constant. History in git keeps it after you delete the line.
The fix is Case 4: read os.Getenv, fail closed if missing, inject the value from a secret store or a local env file that is gitignored. Sample .env.example files may list the name DESK_API_KEY=. They must not list a live value.
The boring rule
- Run
go vet ./...with tests. Treat findings as build failures. - Run
govulncheckon the module in CI. Bump the named versions; do not “accept the risk” of a function you call. - Keep
staticcheckoptional until the team owns the noise. - Never commit tokens, private keys, or connection strings. Names of env vars are fine. Values are not.
- Do not log secrets. Do not put them in
ldflagseither — those strings sit in the binary.
Try this
- In Case 2, change
%sto%dand confirmgo vet ./...is quiet. Then pass astringto%dand read the new vet line. - In
client.go, refuse keys shorter than 8 characters. Print a generic error, not the key. - Run
govulncheckon this module. Read the help (go run golang.org/x/vuln/cmd/govulncheck@latest -h) and note the difference between default symbol mode and-show verbose. - Search your own desk repo for
sk-,password, andBEGIN. If a hit is a real secret, rotate it before you do anything else.