Static Analysis and Security

Updated

September 13, 2026

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 (wrong Printf verbs, copying a sync.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.go

Output:

./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.go

Output (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.go

Output:

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.go

Output:

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 govulncheck on the module in CI. Bump the named versions; do not “accept the risk” of a function you call.
  • Keep staticcheck optional 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 ldflags either — those strings sit in the binary.

Try this

  1. In Case 2, change %s to %d and confirm go vet ./... is quiet. Then pass a string to %d and read the new vet line.
  2. In client.go, refuse keys shorter than 8 characters. Print a generic error, not the key.
  3. Run govulncheck on 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.
  4. Search your own desk repo for sk-, password, and BEGIN. If a hit is a real secret, rotate it before you do anything else.