Security review checklist for AI-generated Go
Go's standard library gets most things right by default, which means the vulnerabilities that survive are the ones where you chose the wrong stdlib package.
Go is a good language for writing secure code and generated Go is usually reasonable — parameterised queries by default, no buffer overflows, sane crypto defaults. What survives review is narrow and specific: choosing the wrong package from a pair that look interchangeable, and the handful of things gosec cannot infer.
Turn on the machine checks first#
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./... # CVEs, but only for code paths you actually call
golangci-lint run # with gosec enabledgovulncheck is the standout tool here and has no equivalent in most ecosystems: it does reachability analysis, so it reports the vulnerable functions your code can actually reach rather than every CVE in your dependency tree. That means near-zero noise, which means people act on it.
linters:
enable: [gosec, bodyclose, noctx, contextcheck, errcheck, errorlint, sqlclosecheck]The wrong-package mistakes#
1. text/template where html/template was needed#
The most consequential single mistake in generated Go web code.
import "text/template" // no escaping whatsoever
t := template.Must(template.New("p").Parse(`<div>{{.Comment}}</div>`))
t.Execute(w, data) // stored XSSThe two packages have identical APIs, so swapping the import compiles and passes every test. html/template is contextually aware — it escapes differently inside an attribute, a URL, a script block — and text/template does nothing at all.
Correct: import "html/template" for anything that reaches a browser.
Catch it with: grep -rn '"text/template"' --include='*.go' . in review, and a line in your AGENTS.md. No linter reliably distinguishes intent here.
2. math/rand where crypto/rand was needed#
token := fmt.Sprintf("%x", rand.Int63()) // predictableSession tokens, password reset links, nonces, API keys, filenames in a shared directory. math/rand/v2 is better distributed than the old package and is still not cryptographically secure.
b := make([]byte, 32)
if _, err := crypto_rand.Read(b); err != nil {
return fmt.Errorf("generate token: %w", err)
}
token := base64.RawURLEncoding.EncodeToString(b)Catch it with: gosec G404.
3. == on secrets#
if mac == expectedMAC { // early return leaks timingsubtle.ConstantTimeCompare for MACs, tokens and password hashes. Catch it with: gosec G201-family rules, and review.
Injection#
4. SQL built with fmt.Sprintf#
q := fmt.Sprintf("SELECT * FROM users WHERE email = '%s'", email)
db.Query(q)Still generated when the surrounding code does not use an ORM or sqlc. Use placeholders — db.Query("... WHERE email = $1", email). Catch it with: gosec G201/G202.
Note that a query built for a dynamic ORDER BY cannot be parameterised. That case needs an allowlist:
var allowed = map[string]string{"name": "name", "created": "created_at"}
col, ok := allowed[req.SortBy]
if !ok { return ErrBadSort }5. Command execution#
exec.Command("sh", "-c", "git log --author="+author) // injection
exec.Command("git", "log", "--author="+author) // fine: no shellGo's exec.Command takes an argument slice and does not invoke a shell, which makes it safe by default — until generated code reaches for sh -c to get pipes or globbing. Catch it with: grep -rn '"sh", "-c"' --include='*.go' and gosec G204.
Also check exec.LookPath behaviour: a relative or attacker-influenced PATH means you may run a different binary than you intended.
Web-layer#
6. SSRF#
func fetchAvatar(w http.ResponseWriter, r *http.Request) {
url := r.URL.Query().Get("url")
resp, _ := http.Get(url) // now fetches your metadata endpointGenerated "import from URL", webhook and avatar features almost never validate. Allowlist the scheme and host, resolve the name and reject private ranges, and disable redirects:
client := &http.Client{
CheckRedirect: func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse // do not follow into the private range
},
Timeout: 10 * time.Second,
}A redirect check matters: an attacker controls a public host that 302s to 169.254.169.254.
7. Path traversal#
http.ServeFile(w, r, filepath.Join(dir, r.URL.Path)) // ../../etc/passwdUse http.FileServer with http.Dir, which handles this, or os.Root (Go 1.24+) for a directory you cannot escape. If you must join manually, filepath.Clean then verify the result is still inside the base with a separator-aware prefix check.
8. Missing timeouts#
http.ListenAndServe(":8080", mux) // no timeouts at allThe zero-value http.Server has no read, write or idle timeout, so a slow client can hold a connection indefinitely. Generated servers use the one-liner constantly.
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second, // the Slowloris one
ReadTimeout: 15 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 1 << 20,
}Catch it with: gosec G112 for ReadHeaderTimeout specifically.
9. Unbounded reads#
body, _ := io.ReadAll(r.Body) // attacker decides your memory usagehttp.MaxBytesReader(w, r.Body, 1<<20) first. Same for json.Decoder — and use DisallowUnknownFields to catch mass assignment through an API you did not intend to expose.
10. Decompression bombs#
gzip.NewReader followed by io.ReadAll will happily expand a small upload into all your memory. Wrap with io.LimitReader on the decompressed side.
Types and numbers#
11. Integer conversion truncating#
var n int64 = userSuppliedValue
buf := make([]byte, int32(n)) // silently truncates or goes negativeGo 1.24's vet includes checks for some of these, and gosec G115 flags integer overflow conversions. Worth enabling — it is a real source of allocation bugs at boundaries.
12. Errors ignored on security-relevant calls#
_ = json.Unmarshal(data, &claims) // parse failed; claims is zero-valued
if claims.Role == "admin" { } // zero value is "", so this is fine — this timeThe pattern is dangerous whenever the zero value happens to be permissive. errcheck catches the discard; review catches whether the zero value is safe.
Authorisation#
The most common real vulnerability in any web codebase, and no linter finds it:
func (h *Handler) GetInvoice(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
inv, err := h.store.Invoice(r.Context(), id) // any authenticated user, any invoiceThe ownership check belongs beside the query:
inv, err := h.store.InvoiceForUser(r.Context(), id, user.ID)
if errors.Is(err, sql.ErrNoRows) {
http.NotFound(w, r) // 404, not 403 — do not confirm it exists
return
}Write the test once per resource type:
func TestCannotReadAnotherUsersInvoice(t *testing.T) {
rr := doRequest(t, "GET", "/invoices/"+bobInvoiceID, aliceToken)
if rr.Code != http.StatusNotFound {
t.Errorf("status = %d, want 404", rr.Code)
}
}TLS and crypto#
Go's defaults are good. What generated code gets wrong is disabling them:
tls.Config{InsecureSkipVerify: true} // never in production
tls.Config{MinVersion: tls.VersionTLS10} // set 1.2 or 1.3InsecureSkipVerify appears in generated code as a way past a certificate error during development and then survives into main. Catch it with: gosec G402, and a grep in review.
The review, as commands#
govulncheck ./...
golangci-lint run
grep -rn '"text/template"' --include='*.go' . # XSS
grep -rn 'InsecureSkipVerify' --include='*.go' .
grep -rn '"sh", "-c"' --include='*.go' .
grep -rn 'math/rand' --include='*.go' . | grep -iE 'token|secret|key|nonce|session'
grep -rn 'ListenAndServe' --include='*.go' . # missing timeoutsUnder a minute, and it covers the automatable half. The half worth your attention is authorisation and SSRF, because both depend on knowing what your system is supposed to allow.
The Go-specific thing to remember
Two pairs of standard library packages look identical and are not: text/template vs html/template, and math/rand vs crypto/rand. Picking the wrong one compiles, passes tests, and is a vulnerability. Grep for both on every review.
Common questions#
Is govulncheck better than a generic scanner?#
For Go, substantially — it uses call-graph reachability, so it reports vulnerabilities your code can actually reach rather than every CVE in the module graph. That means far less noise, which is the difference between a tool people act on and one they mute.
Does the standard library's net/http need a framework to be secure?#
No. It needs timeouts set explicitly, body size limits, and html/template for rendering. A framework may give you those by default, which is a real convenience, but nothing about net/http is insecure once configured.
Why does generated Go use InsecureSkipVerify?#
Because it is the answer to "how do I get past this certificate error", and a great deal of example code contains it. Treat any occurrence as a defect and require a comment explaining the specific test scenario if it must exist at all.
Should I use os.Root for file serving?#
If you are on Go 1.24 or later and serving files from a directory, yes — it gives you a handle that cannot be escaped by traversal or symlinks, which is a stronger guarantee than any amount of path cleaning.
Get the Go agent pack
A battle-tested AGENTS.md, the review checklist, and the failure-mode cheat sheet for Go. One email, then occasional updates when the tooling shifts. No course pitch.
AGENTS.md now — no email needed.