sota-golang
State-of-the-art Go engineering rules (2026 baseline, Go 1.25+) that Claude applies when writing new Go code or auditing existing Go code. Covers error handling, interface/package design, goroutine and channel correctness, net/http hardening, security (SQL, exec, path traversal,
Install
npx skills add https://github.com/martinholovsky/SOTA-skills/tree/main/skills/sota-golang
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install martinholovsky-sota-skills@llmmart
git clone https://github.com/martinholovsky/SOTA-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole martinholovsky/sota-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
SOTA Go (2026)
Expert-level rules for producing and auditing production Go. Baseline language
version: Go 1.25+, the oldest release still in security support (Go fixes the
last two majors; 1.24 left support with 1.26's release, 2026-02). Feature
notes: loop-var scoping from 1.22, b.Loop/os.Root/tool directives from
1.24, testing/synctest and container-aware GOMAXPROCS from 1.25,
errors.AsType and the default-on Green Tea GC from 1.26 — noted where
relevant. Every rule states the why; every rules file
ends with an audit checklist of grep/vet/lint patterns.
Purpose
Two consumers, one source of truth:
- BUILD mode — generating new Go code: follow the rules as defaults, not suggestions. Deviate only with an explicit comment justifying it.
- AUDIT mode — reviewing existing Go code: hunt violations using the audit checklists, classify by severity, report in the finding format below.
BUILD mode
- Before writing code, read the rules files relevant to the task (see index).
A service touching HTTP + DB + goroutines needs
03,04,05. - Apply the top-10 non-negotiables (below) unconditionally.
- New modules:
go mod initwith a real module path; since 1.26 it writes the previous minor as thegodirective (e.g.go 1.25.0) for ecosystem compatibility — keep that unless you need newer language features; pin thetoolchaindirective to the current patch release. Addgolangci-lintconfig and a CI step runninggo vet,golangci-lint run,go test -race ./...,govulncheck ./...from day one (seerules/07). - Prefer stdlib. Each dependency must earn its place (see
rules/05supply chain section). - Write table tests alongside the code, not after. Exported behavior gets a
test; concurrency gets a
-racetest; parsers get a fuzz target. - When generating code that violates a rule for a legitimate reason (e.g.
sync.Poolcomplexity,unsafe), leave a// NOTE(sota):comment explaining the trade-off so auditors don't flag it blind.
AUDIT mode
Work through each relevant rules file's audit checklist against the target repo. Run the listed grep/vet/lint commands; confirm each hit manually before reporting (greps are recall-oriented, expect false positives).
Severity conventions
| Severity | Meaning | Examples |
|---|---|---|
| CRITICAL | Exploitable or guaranteed-incorrect in production | SQL built with fmt.Sprintf, command injection via sh -c, unbounded goroutine leak on hot path, InsecureSkipVerify: true, data race confirmed by -race |
| HIGH | Likely production incident or security weakness | Missing http.Server timeouts, no ctx cancellation on blocking goroutine, unchecked integer truncation on attacker input (G115), resp.Body never closed, panic for control flow in a server |
| MEDIUM | Correctness/maintainability hazard, latent bug | Error strings compared with strings.Contains, context stored in struct, time.After in a loop, map writes without lock under suspected concurrency, missing errors.Is/As |
| LOW | Idiom/perf debt, works but wrong shape | Returning interfaces, util package dumps, missing preallocation on hot path, non-table tests, no t.Parallel |
| INFO | Style, doc, or hygiene note | Naming, missing doc comments, gofumpt drift |
Finding format
[SEVERITY] file.go:LINE — short title
Rule: rules/NN-name.md § section
Evidence: the offending line(s), verbatim
Impact: one sentence — what goes wrong, under what conditions
Fix: concrete replacement code or action
Effort: trivial | small | medium | large
Group findings by severity, CRITICAL first. End the audit with: counts per severity, the three highest-leverage fixes, and which checklists were run.
Rules index
| File | Read this when... |
|---|---|
rules/01-errors.md |
Writing/reviewing any error path: wrapping with %w, errors.Is/As, sentinel vs typed errors, in-band sentinels (absence encoded as -1/0/"") and comma-ok, panic/recover policy, error API design for libraries vs apps |
rules/02-design.md |
Designing packages or APIs: interface placement and size, package layout and internal/, naming, zero values, generics restraint, embedding, functional options, context.Context discipline |
rules/03-concurrency.md |
Anything with go, chan, sync, or select: goroutine lifecycle ownership, leak catalog, errgroup fan-out, channels-vs-mutex decision, race patterns, worker pools, semaphores, time.After traps |
rules/04-http-services.md |
Building or auditing HTTP servers/clients: all five server timeouts, client timeouts and body hygiene, connection reuse, graceful shutdown, middleware, slog structured logging, request-scoped values |
rules/05-security.md |
Any input crossing a trust boundary: SQL parameterization, os/exec safety, path traversal and os.Root, integer overflow (G115), output encoding (html/template), CSPRNG (crypto/rand vs math/rand), TLS config, unsafe/cgo policy, govulncheck, supply chain and go.sum |
rules/06-performance.md |
Latency/memory work: pprof workflow, testing.B + b.Loop, allocation reduction, strings.Builder, sync.Pool criteria, escape analysis, GOGC/GOMEMLIMIT, PGO |
rules/07-tooling-ci.md |
Setting up or auditing CI and tests: golangci-lint curated config, staticcheck/gofumpt/vet, table tests, t.Parallel correctness, testcontainers, golden files, fuzzing, go.mod hygiene and tool directives. Test strategy — suite shape, TDD, doubles, test data, flake policy — lives in sota-testing; load it for any build that writes logic. This file owns Go runner mechanics only. |
Top-10 non-negotiables
- Every error is handled or wrapped with
%wand context — never discarded with_, never logged-and-ignored on a path that must abort. Compare witherrors.Is/errors.As, never string matching. (rules/01) - No panics for control flow.
panicis for unreachable programmer errors only; servers recover at goroutine boundaries and log. (rules/01) - Every goroutine has an owner and a guaranteed exit path — tied to a
context.Context, a closed channel, or aWaitGroup/errgroupjoin. If you can't say how it stops, don't start it. (rules/03) go test -race ./...in CI, always. A race detector failure is a CRITICAL finding, not flaky-test noise. (rules/03,rules/07)http.ServersetsReadHeaderTimeout,ReadTimeout,WriteTimeout,IdleTimeout; clients set timeouts anddefer resp.Body.Close()with drain. Default zero timeouts are a DoS. (rules/04)- SQL only via parameterized queries (
database/sqlplaceholders, pgx, or sqlc-generated code). String-built SQL is CRITICAL, no exceptions for "internal" values. (rules/05) os/execwith argv lists, neversh -cwith interpolated input; file paths validated against a root (os.Rooton 1.24+, elsefilepath.Clean+ prefix check after resolving symlinks). (rules/05)context.Contextis the first parameter, flows down, is never stored in a struct, and carries only request-scoped metadata — never dependencies. (rules/02)- Accept interfaces, return structs; define interfaces at the consumer,
keep them small. No premature interfaces "for mocking". (
rules/02) govulncheck ./...andgolangci-lintgate CI;go.sumcommitted; dependencies minimal and justified. (rules/05,rules/07)
Files (sota-skills)
-
rules
-
01-errors.md 12.8 KB
# 01 — Errors: handling, wrapping, design Errors are values. They are part of your API surface and your observability story. Most production incidents in Go services trace back to an error that was dropped, double-logged, string-matched, or panicked on. ## 1. Handle every error, exactly once **Handle = one of:** return it (usually wrapped), act on it (retry, fallback, default), or — only at the top of a call stack — log it and continue/abort. Doing two of these for the same error is a bug: log-and-return produces duplicate log lines with no extra information. ```go // BAD — double handling: caller will log it again if err != nil { log.Printf("query failed: %v", err) return err } // GOOD — add context, return once; the top-level handler logs if err != nil { return fmt.Errorf("query user %d: %w", id, err) } ``` Never discard with `_` unless the API genuinely cannot fail in context and you say why: ```go // BAD b, _ := json.Marshal(resp) // GOOD — justified discard, documented // Marshal of a struct with no chan/func/cycles cannot fail. b, _ := json.Marshal(resp) //nolint:errcheck // statically infallible ``` In practice prefer handling anyway; `errcheck` (via golangci-lint) enforces this and exceptions should be rare and annotated. `defer f.Close()` on **writes** loses the error that tells you the write failed (buffered data flushes on close). Capture it: ```go // GOOD — close error matters for writers defer func() { if cerr := w.Close(); cerr != nil && err == nil { err = fmt.Errorf("close %s: %w", path, cerr) } }() ``` For read-only files, `defer f.Close()` discarding the error is acceptable. ## 2. Wrap with %w; add context, not noise `fmt.Errorf("...: %w", err)` preserves the chain for `errors.Is/As`. Use `%v` only when you deliberately want to *break* the chain (hiding an internal error from callers — a real, intentional choice at package boundaries). Context rules: - Say what *you* were doing, with identifiers: `"load config %q: %w"`. Do not restate what the callee already says (`"failed to open file: open file..."`). - No `"failed to"` / `"error:"` prefixes — chains read as `"a: b: c: underlying"`. Lowercase, no trailing punctuation (staticcheck ST1005). - Wrap at each layer that adds information; pass through (`return err`) when you have nothing to add. Mechanical wrapping at every return is noise. ```go // BAD — no chain (%v), redundant phrasing, capitalized return fmt.Errorf("Failed to read the file: %v", err) // GOOD return fmt.Errorf("read manifest %s: %w", path, err) ``` Multiple causes: `errors.Join(errA, errB)` (Go 1.20+) or `fmt.Errorf("...: %w; also: %w", e1, e2)`. `errors.Is/As` traverse joined trees. Typical use: accumulating cleanup errors, validating many fields. ## 3. Inspect with errors.Is / errors.As — never strings ```go // BAD — breaks on wrapping, wording changes, localization if strings.Contains(err.Error(), "not found") { ... } if err == sql.ErrNoRows { ... } // breaks once anything wraps it // GOOD if errors.Is(err, sql.ErrNoRows) { ... } var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation { ... } // GOOD — Go 1.26+: errors.AsType[E error](err) (E, bool) is the type-safe, // faster replacement for errors.As; prefer it on 1.26+ codebases if pgErr, ok := errors.AsType[*pgconn.PgError](err); ok && pgErr.Code == pgerrcode.UniqueViolation { ... } ``` `==` on errors is only valid where wrapping is impossible (comparing to `nil`, or inside the package that just created the sentinel). Audit any `err ==` against exported sentinels as MEDIUM. Context errors: check `errors.Is(err, context.Canceled)` / `context.DeadlineExceeded` before counting a failure as a real one — a canceled request is not a dependency outage; don't page on it. ### 4a. In-band sentinels — Go's are documented, yours are not Go's stdlib deliberately returns in-band sentinels and **documents them**: `strings.Index`/`LastIndex` return `-1` when not found (verified, go1.26.5). That is idiomatic and fine *because the contract is published and every caller is expected to test it*. The defect is the undocumented one you write yourself — the class is `sota-architecture` rules/02 §8a. - `strconv.Atoi("x")` returns `(0, err)` (verified, go1.26.5). The `0` is a perfectly ordinary value; the error is the only thing distinguishing it from `Atoi("0")`. Dropping `err` converts an out-of-band signal into an in-band one, which is precisely what `errcheck` exists to stop. - Go has no sum type, so the out-of-band alternatives are **comma-ok** (`v, ok := m[k]`) or `(T, error)`. Return one of them; a `func f() int` that answers `-1` for "no value" has no way to say why. - `*int` for "optional int" is legal and usually worse than comma-ok — it moves the check to a nil dereference and heap-allocates. Prefer `(int, bool)`. - Audit: `grep -rnE 'return -1|return 0, nil' --include='*.go' .` for producers, then the tell that actually finds the bug — a comparison where one side is filtered against the sentinel and the other is not. ## 4. Sentinel vs typed vs opaque — choosing the error kind | Kind | When | Example | |---|---|---| | **Opaque** (just `error`) | Caller can only propagate/log. Default. | most internal funcs | | **Sentinel** (`var ErrX = errors.New`) | Caller branches on *which* condition, no payload needed | `io.EOF`, `sql.ErrNoRows`, `fs.ErrNotExist` | | **Typed** (struct implementing `error`) | Caller needs structured data from the failure | `*fs.PathError`, validation errors with field names | Rules: - Every exported sentinel/type is **API forever**. Export the minimum; start opaque, promote to sentinel/typed when a real caller needs to branch. - Sentinels: `Err` prefix, `var ErrQuotaExceeded = errors.New("quota exceeded")`. Wrap them when returning: `fmt.Errorf("user %d: %w", id, ErrQuotaExceeded)`. - Typed errors: name ends in `Error`, pointer receiver, and **return the concrete pointer only into an `error` variable immediately** — a typed nil pointer stored in an `error` interface is non-nil: ```go // BAD — classic typed-nil bug: returns non-nil error interface func do() *QueryError { ... return nil } var err error = do() // err != nil even though pointer is nil! // GOOD — functions return `error`, not concrete error types func do() error { if bad { return &QueryError{Query: q, Err: cause} } return nil } ``` - Implement `Unwrap() error` (or `Unwrap() []error`) on wrapper types so `errors.Is/As` see through them. - Libraries: prefer behavior interfaces over exported types when feasible (`interface{ Timeout() bool }` à la `net.Error`), so callers depend on capability, not identity. ## 5. No panic for control flow `panic` means "programmer bug, state is corrupt, crashing is correct": impossible switch cases, broken invariants, failed init of must-exist state. It is never a substitute for returning an error on input, I/O, network, parse, or not-found conditions. ```go // BAD — user input panics the server func ParseLevel(s string) Level { l, ok := levels[s] if !ok { panic("bad level: " + s) } return l } // GOOD func ParseLevel(s string) (Level, error) { l, ok := levels[s] if !ok { return 0, fmt.Errorf("unknown level %q", s) } return l, nil } ``` Legitimate panic patterns: - `MustCompile`-style helpers for package-level init with constant input: `var pathRe = regexp.MustCompile(...)`. Write your own `MustX` only for init-time constants, never runtime data. - `panic` across goroutines is fatal: **a panic in a goroutine you spawned kills the whole process** regardless of recovers elsewhere. Any goroutine running third-party or panic-capable code needs its own deferred recover (errgroup does NOT recover for you; `golang.org/x/sync/errgroup` ≥ v0.11 still propagates panics by re-panicking in `Wait`). - HTTP: `net/http` recovers per-request panics by default but the response is broken; middleware should recover, log with stack (`debug.Stack()`), and return 500. Recover converts to error at the boundary; it never resumes business logic. `recover()` only works in a deferred function in the same goroutine. Audit any `recover` outside `defer` as dead code (vet catches some of these). ## 6. Error flow patterns - **Guard clauses, happy path left-aligned.** `if err != nil { return ... }` immediately; no `else` after a return. - **Don't pre-declare `var err error`** and reuse across unrelated calls; shadowing bugs (`:=` in an inner scope silently ignoring the outer err) are common — `go vet` + golangci-lint `govet` shadow check help. - **Errors in loops**: decide explicitly — fail fast (`return` first error), or collect (`errors.Join`) and continue. Comment which and why. - **errors as part of concurrency**: never send on an error channel without a receiver guarantee; prefer `errgroup` which does this correctly (see `rules/03`). - **Don't log below the top.** Libraries return errors; binaries log them once, at the handler/main level, with `slog` and the full chain (`slog.Any("err", err)` or `"err", err` — `%+v` style stacks need a wrapper lib; stdlib chains carry context strings instead of stacks). - **main() pattern**: ```go func main() { if err := run(context.Background(), os.Args, os.Stdout); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } ``` `run` returns errors; `main` is the only place that exits. `os.Exit` skips defers — never call it (or `log.Fatal`) outside `main`. ## 7. Library vs application error policy - **Libraries**: no logging, no `os.Exit`/`log.Fatal`, no leaking implementation errors as API (wrap third-party errors with `%v` or your own type at the boundary if callers shouldn't depend on them). Document which sentinels/types you return. - **Applications**: map errors to user/transport meaning in ONE place — e.g. an HTTP error mapper translating `ErrNotFound`→404, validation type→400, default→500 + log. Scattered status-code decisions drift. ```go // GOOD — single translation point func httpError(w http.ResponseWriter, err error) { switch { case errors.Is(err, ErrNotFound): http.Error(w, "not found", http.StatusNotFound) case errors.As(err, new(*ValidationError)): http.Error(w, err.Error(), http.StatusBadRequest) default: slog.Error("internal error", "err", err) http.Error(w, "internal error", http.StatusInternalServerError) } } ``` Never echo internal error chains to external clients (information disclosure — see `rules/05`). ## Audit checklist Run from repo root; verify each hit manually. **In-band sentinels — a value standing in for absence** (§4a). Go's own `-1` from `strings.Index` is documented and fine when tested immediately; yours is not: ```bash grep -rnE 'return -1$|return -1,' --include='*.go' . # producer — prefer (T, bool) comma-ok grep -rnE ':?= .*strconv\.Atoi\(' --include='*.go' . | grep -v 'err' # the 0 is in-band if err is dropped ``` ```bash # Discarded errors (also rely on errcheck via golangci-lint) grep -rnE '^\s*[a-zA-Z_].*,\s*_\s*(:?=).*\(' --include='*.go' . | grep -v _test.go grep -rn '_ = ' --include='*.go' . | grep -vE '(test|//)' # String matching on errors — MEDIUM+ grep -rnE 'strings\.(Contains|HasPrefix|HasSuffix)\(\s*err\.Error\(\)' --include='*.go' . grep -rn '\.Error() ==' --include='*.go' . # == comparison against sentinels (should be errors.Is) — MEDIUM grep -rnE 'err\s*[!=]=\s*(sql\.ErrNoRows|io\.EOF|os\.ErrNotExist|context\.(Canceled|DeadlineExceeded))' --include='*.go' . # %v wrapping where %w likely intended (manual review) grep -rnE 'fmt\.Errorf\([^)]*%v[^)]*err\s*\)' --include='*.go' . # Panics outside main/init/Must/tests — HIGH if reachable from input grep -rn 'panic(' --include='*.go' . | grep -vE '(_test\.go|Must|init\()' # log.Fatal / os.Exit outside main package — HIGH in libraries grep -rnE '(log\.Fatal|os\.Exit)' --include='*.go' . | grep -v 'main\.go' # Error strings: capitalized or "failed to" noise — INFO/LOW (ST1005) grep -rnE 'errors\.New\("[A-Z]|fmt\.Errorf\("[A-Z]' --include='*.go' . grep -rn 'failed to' --include='*.go' . | grep -E '(errors\.New|fmt\.Errorf)' # Typed-nil hazard: functions returning concrete error pointer types grep -rnE 'func .*\) \*\w+Error( |$)' --include='*.go' . # recover outside defer; missing Unwrap on wrapper types (manual) grep -rn 'recover()' --include='*.go' . grep -rln 'type .*Error struct' --include='*.go' . | xargs grep -L 'func (.*) Unwrap()' # Tooling go vet ./... golangci-lint run --enable-only errcheck,errorlint,err113,wrapcheck,nilerr ./... staticcheck ./... # ST1005 error strings, SA4006 unused err, SA1019 ``` Severity guide: string-matched errors MEDIUM (HIGH if driving retry/billing logic); dropped error on write/commit path HIGH; panic reachable from external input HIGH; double logging LOW; phrasing INFO. -
02-design.md 12.4 KB
# 02 — API & package design: interfaces, layout, context, options Go rewards small, concrete, boring designs. Most design debt in Go codebases is premature abstraction: interfaces nobody needed, packages named after grab-bags, context misused as a dependency bag. ## 1. Interfaces: accept interfaces, return structs - **Define interfaces where they are consumed**, not where implemented. The consumer declares the minimal capability it needs; producers just happen to satisfy it. This kills import cycles and keeps interfaces honest. - **Return concrete types.** Callers get the full method set, godoc, and zero indirection; you can add methods without breaking anyone. Returning an interface is justified only when multiple implementations are returned from the same constructor (e.g. `io.Reader` chosen at runtime) or to hide an internal type on purpose. - **Keep interfaces small.** 1–2 methods is the sweet spot (`io.Reader`, `http.Handler`, `fmt.Stringer`). A 5+ method interface is a sign you're modeling an implementation, not a need. ```go // BAD — producer-side, fat, forces every consumer to depend on everything package storage type Store interface { GetUser(ctx context.Context, id int) (*User, error) PutUser(ctx context.Context, u *User) error ListUsers(ctx context.Context) ([]User, error) GetOrder(ctx context.Context, id int) (*Order, error) // ... 9 more } // GOOD — consumer-side, minimal package billing type userGetter interface { GetUser(ctx context.Context, id int) (*storage.User, error) } func NewInvoicer(users userGetter) *Invoicer { ... } ``` - **No interfaces "for mocking" by default.** A concrete dependency with a consumer-side 1-method interface at the test boundary is enough; you don't need a package-wide `XInterface` + `XImpl` pair (a Java-ism; audit as LOW). - Compile-time conformance checks where they add safety: `var _ http.Handler = (*Server)(nil)`. - Don't add methods to satisfy hypothetical future interfaces. Interface upgrades via type assertion (`if f, ok := w.(http.Flusher); ok`) are the escape hatch when you must sniff optional behavior — document it. ## 2. Package layout - **Packages are named after what they provide, not what they contain.** `util`, `common`, `helpers`, `misc`, `shared`, `base`, `types` are dumping grounds — audit as LOW, refactor target. If a function has no home, it belongs next to its single caller, unexported. - Package name is part of the call site: `bytes.Buffer`, not `bytespkg.BytesBuffer`. No stutter (`http.HTTPServer` → `http.Server`). Short, lowercase, no underscores. - **`internal/` for anything not meant for external import.** In applications, most code goes under `internal/`; `pkg/` is cargo cult — only keep it if the repo genuinely exports libraries and the team likes the convention. - Typical service shape (don't over-nest; flat beats deep): ``` cmd/api/main.go // wiring only: flags/env, construct deps, call run() internal/server/ // HTTP handlers, middleware, routes internal/billing/ // domain logic, owns its consumer-side interfaces internal/postgres/ // storage impl named after the technology go.mod ``` - **No package-level mutable state.** Package vars make code untestable, order-dependent (`init` graphs), and racy. Dependencies are constructed in `main` and passed down (plain constructor injection — no DI framework). ```go // BAD var db *sql.DB func init() { db = mustOpen() } // GOOD type Server struct{ db *sql.DB } func New(db *sql.DB) *Server { return &Server{db: db} } ``` Acceptable package-level state: true constants, `regexp.MustCompile` of constant patterns, registered `expvar`/metrics where the ecosystem demands it. `init()` should be rare and side-effect-light; audit nontrivial `init` as MEDIUM. - One package per directory; `_test` package suffix (`foo_test`) for black-box tests is encouraged for exported-API tests. ## 3. Naming - MixedCaps, never snake_case. Acronyms keep case: `ServeHTTP`, `userID`, `parseURL` (not `Id`, `Url`). - Short names for short scopes (`i`, `r`, `buf`); descriptive names for wide scopes and exported identifiers. The wider the scope, the longer the name. - Getters drop `Get`: `c.Name()`, not `c.GetName()`. Setters keep `Set`. - Constructor: `New` if the package has one main type (`bytes.NewBuffer`... actually `buf := bytes.Buffer{}` — prefer usable zero values, §4); `NewX` when several. - Single-method interface names: method + `er` (`Reader`, `Closer`, `Validator`) even when slightly awkward. - Receiver names: 1–2 letters, consistent across methods (`s *Server` everywhere — never `this`/`self`). - Errors: `ErrX` sentinels, `XError` types (see `rules/01`). ## 4. Zero values as a design tool Make the zero value useful; it removes constructors, nil checks, and initialization ordering bugs. ```go // stdlib exemplars: ready with no constructor var buf bytes.Buffer // usable var mu sync.Mutex // usable var wg sync.WaitGroup // usable // GOOD — design your types the same way type Limiter struct { mu sync.Mutex burst int // 0 = unlimited, documented } ``` - Document zero-value semantics on the type's doc comment. - If the zero value is *invalid* (must-have dependencies), force the constructor: unexported fields + `NewX`, and make misuse fail fast (nil check with a clear panic in the constructor's absence is worse than a compile-time-unavoidable constructor). - `nil` slices and maps: nil slice reads/appends/ranges fine — don't `make([]T, 0)` just to avoid nil (exception: JSON `[]` vs `null` matters). Nil **map writes panic** — maps that get written need `make`. - Don't return pointers just to enable `nil` as "absent"; prefer `(T, bool)` or a zero value with documented meaning. ## 5. Generics: judicious use only Generics (1.18+) are for **code that is identical except for the type**: containers, slice/map algorithms (`slices`, `maps` packages), constraints on ordered/numeric types, type-safe pools. Do NOT use generics when: - An interface already expresses it: `func Print(s fmt.Stringer)` beats `func Print[T fmt.Stringer](s T)` unless you measured the devirtualization win. - Only one type instantiation exists in the codebase — YAGNI; write the concrete version. - The constraint is `any` and you're just avoiding writing two functions — readability cost exceeds duplication cost. - You're building a generic "repository/service" framework. Domain code stays concrete. Prefer stdlib generics over hand-rolling: `slices.Contains/SortFunc/Index`, `maps.Keys`, `cmp.Or` (1.22), `min`/`max` builtins (1.21), `sync.OnceValue` (1.21). Audit hand-written loops duplicating these as LOW. ## 6. Embedding vs composition - Embedding is **not inheritance**: no overrides seen by the embedded type's methods, no LSP. It's automatic delegation only. - Embed to satisfy interfaces wholesale or compose behaviors (`struct { sync.Mutex; m map[string]int }` is fine for small unexported types; for exported types a named `mu sync.Mutex` field is better — embedding exports `Lock`/`Unlock` into your API). - **Never embed types whose method set becomes accidental public API** (embedding `*sql.DB` in your exported `Store` exposes all of it forever). - Embedding interfaces in structs to partially implement (test fakes: `struct{ storage.Store }` + override one method) is idiomatic in tests; in production code a nil embedded interface panics at runtime — audit. - Marshal trap: embedding flattens JSON fields and can silently change wire formats; promoted `MarshalJSON` from an embedded type hijacks the whole struct's encoding. MEDIUM if found on API types. ## 7. Functional options pattern Use when a constructor has ≥3 optional knobs or needs future extensibility without breaking callers. For ≤2 stable options, a config struct parameter or plain arguments are simpler — don't cargo-cult. ```go type Option func(*Server) func WithTimeout(d time.Duration) Option { return func(s *Server) { s.timeout = d } } func WithLogger(l *slog.Logger) Option { return func(s *Server) { s.log = l } } func New(addr string, opts ...Option) *Server { s := &Server{addr: addr, timeout: 30 * time.Second, log: slog.Default()} for _, o := range opts { o(s) } return s } ``` Rules: required params are positional args, never options; every option has a sane default; options validate or the constructor returns `error` if combinations can be invalid; for cross-package extensibility use `Option interface{ apply(*config) }` instead of a bare func type. ## 8. context.Context discipline - **First parameter, named `ctx`, of every function on a request/IO path.** Not last, not in a struct, not optional. - **Never store ctx in a struct.** A struct outlives requests; storing ctx ties the object to one call's lifetime and hides cancellation flow. The known exceptions (`http.Request`) exist for compatibility — don't copy them. Audit `ctx context.Context` struct fields as MEDIUM. - **Values: request-scoped metadata only** — trace ID, auth principal, deadline-irrelevant telemetry. Never dependencies (DB handles, loggers as the *only* way to get them), never function parameters in disguise. If removing the value breaks business logic, it was a parameter. - Use **unexported key types** to avoid collisions: ```go type ctxKey struct{} func WithUser(ctx context.Context, u *User) context.Context { return context.WithValue(ctx, ctxKey{}, u) } func UserFrom(ctx context.Context) (*User, bool) { u, ok := ctx.Value(ctxKey{}).(*User) return u, ok } ``` - Pass `ctx` down every blocking call: DB (`QueryContext`), HTTP (`http.NewRequestWithContext`), exec (`exec.CommandContext`). A blocking call without ctx on a request path is a HIGH leak/latency hazard. - `context.Background()` only in `main`, init paths, and tests (`t.Context()` in 1.24+); `context.TODO()` is a tracked refactor marker — audit lingering TODOs as LOW. - Always `defer cancel()` from `WithTimeout`/`WithCancel` (vet's `lostcancel` catches misses). - Detach correctly: to outlive a request (async audit log), use `context.WithoutCancel(ctx)` (1.21+) — keeps values, drops cancellation — with your own timeout. Don't pass the request ctx into background work (dies with request) and don't pass `Background()` (loses trace metadata). - `ctx.Err()` after select; return it unwrapped or wrapped with `%w` so callers can `errors.Is(err, context.Canceled)`. ## Audit checklist ```bash # Grab-bag packages — LOW find . -type d | grep -iE '/(util|utils|common|helpers|shared|misc)($|/)' # Returned interfaces from constructors (manual review) — LOW grep -rnE 'func New\w*\([^)]*\) [A-Z]\w*(Interface| interface)' --include='*.go' . # Fat interfaces: >4 methods (then inspect) grep -rn -A 12 'interface {' --include='*.go' . | less # manual # Package-level mutable state — MEDIUM grep -rnE '^var \w+ (=|\*|map\[|\[\])' --include='*.go' . | grep -vE '(Err|_test|MustCompile|regexp)' grep -rn 'func init()' --include='*.go' . # Context violations grep -rnE 'ctx\s+context\.Context' --include='*.go' . | grep -E 'struct|^\s+[A-Za-z]+ +context\.Context' # ctx in struct — MEDIUM grep -rnE 'func [^(]*\([^)]*\bctx context\.Context' --include='*.go' . | grep -vE '\(ctx context\.Context' # ctx not first param grep -rn 'context.WithValue' --include='*.go' . # check key types & payloads grep -rn 'context.TODO()' --include='*.go' . # LOW, should be tracked grep -rnE 'context\.Background\(\)' --include='*.go' . | grep -v 'main\|_test' # suspicious mid-stack # Blocking calls missing ctx variants — HIGH on request paths grep -rnE '\.(Query|QueryRow|Exec)\(' --include='*.go' . | grep -v Context grep -rn 'http.Get(\|http.Post(' --include='*.go' . grep -rn 'exec.Command(' --include='*.go' . | grep -v CommandContext # Naming drift — INFO grep -rnE 'func.*Get[A-Z]\w*\(\) ' --include='*.go' . # Get-prefixed getters grep -rnE '\b(Id|Url|Http|Api)\b' --include='*.go' . # acronym casing # Tooling go vet ./... # lostcancel, composites golangci-lint run --enable-only revive,ireturn,containedctx,contextcheck,fatcontext ./... staticcheck ./... # ST1003 naming, S1021, SA1029 ctx keys ``` Severity guide: ctx in struct / dependency-in-ctx MEDIUM; missing ctx on blocking request-path call HIGH; util-package and returned interfaces LOW; mutable package state MEDIUM (HIGH if written concurrently). -
03-concurrency.md 11.6 KB
# 03 — Concurrency: goroutines, channels, sync, races Concurrency bugs are the most expensive class of Go defect: they pass review, pass tests, and corrupt data or leak memory in production. The discipline is ownership: every goroutine, channel, and shared variable has exactly one defined owner and lifecycle. ## 1. Goroutine lifecycle ownership **Before writing `go`, answer three questions in code, not in your head:** 1. **When does it exit?** (ctx canceled, input channel closed, work done) 2. **How do we wait for it?** (`errgroup.Wait`, `sync.WaitGroup`, join channel) 3. **Where does its error/panic go?** (errgroup, error channel, recover+log) If any answer is "it doesn't / we don't", that's a leak by design — HIGH. ```go // BAD — fire-and-forget: no exit signal, no join, error lost go processQueue(q) // GOOD — owned: bounded by ctx, joined, error surfaced g, ctx := errgroup.WithContext(ctx) g.Go(func() error { return processQueue(ctx, q) }) // ... if err := g.Wait(); err != nil { ... } ``` - **A panic in any goroutine kills the process** — goroutines running panic-capable code (callbacks, plugins, parsers on untrusted input) need their own `defer func(){ if r := recover(); ... }()`. - Libraries must not spawn goroutines that outlive the call unless the API has an explicit `Close`/`Shutdown` that joins them. - Never use `time.Sleep` to "wait for the goroutine to start/finish" — that's a race with a timer on it. Synchronize with channels/WaitGroup. In tests on 1.25+, `testing/synctest` gives you a fake-time bubble instead of sleeps. ## 2. Goroutine leak catalog Each pattern below leaks the goroutine (and everything it references) forever. **Blocked send, receiver gone** — classic in "first result wins" and timeouts: ```go // BAD — if caller times out, the worker blocks on send forever func fetch() chan result { ch := make(chan result) go func() { ch <- slowCall() }() // leak when nobody receives return ch } // FIX — buffer of 1 (send always completes) or select on ctx.Done() ch := make(chan result, 1) // or: select { case ch <- slowCall(): case <-ctx.Done(): } ``` **Forgotten receiver / abandoned range** — producer ranges forever on a channel nobody closes, or consumer ranges a channel whose producer errored out before closing: ```go // BAD — if produce() returns early on error without close(ch), this blocks forever for v := range ch { ... } // FIX — producer owns the channel: defer close(ch) unconditionally go func() { defer close(ch) for _, v := range items { select { case ch <- v: case <-ctx.Done(): return } } }() ``` **Missing ctx cancellation** — goroutine blocks on I/O or a channel with no `<-ctx.Done()` branch. Every blocking select in a spawned goroutine must include the done channel. **`time.After` in a loop** — pre-1.23, each call allocates a timer not collected until it fires; in a hot select loop this is unbounded memory. Since Go 1.23 unreferenced timers are collectable, so it's no longer a leak — but it still allocates per iteration; `time.NewTimer` + `Reset` or `time.Tick`/`time.NewTicker` remains correct for loops: ```go // BAD (pre-1.23 leak; ≥1.23 still allocates per iteration) for { select { case m := <-in: handle(m) case <-time.After(timeout): return } } // GOOD t := time.NewTimer(timeout) defer t.Stop() for { select { case m := <-in: handle(m) t.Reset(timeout) case <-t.C: return } } ``` **Detection**: goroutine count metric (`runtime.NumGoroutine`) trending up; `pprof/goroutine?debug=2` dumps; `goleak` (`go.uber.org/goleak`) in tests: `defer goleak.VerifyNone(t)` — make it standard in packages that spawn. Go 1.26 adds an experimental `goroutineleak` pprof profile (build with `GOEXPERIMENT=goroutineleakprofile`, fetch `/debug/pprof/goroutineleak`) that reports goroutines blocked on unreachable concurrency primitives — planned on-by-default in 1.27; use it where available. ## 3. Channels vs mutexes Decision rule: **mutex for state, channels for handoff/signaling.** - Protecting a map/counter/struct field → `sync.Mutex`/`RWMutex`. A channel "manager goroutine" for simple state is slower, harder to read, and deadlock-prone. - Transferring ownership of data, pipelines, fan-out/fan-in, completion signals, semaphores → channels. - If you're tempted by both, the simpler mutex wins. Channel rules: - **The producer (writer) closes; never the consumer.** Closing is a broadcast: "no more values". Send on closed channel panics; close of closed channel panics; receive from closed channel returns zero immediately — `v, ok := <-ch` to distinguish. - Multiple producers: nobody closes directly; coordinate via a `sync.WaitGroup` + a single closer goroutine, or signal with a separate `done` channel. - Buffer sizes are 0 (synchronization), 1 (async handoff/notification), or a measured capacity (bounded queue). Any other magic number needs a comment; "buffered so it probably won't block" is a latent leak — MEDIUM. - `nil` channel in select disables that case — idiomatic for toggling cases off, surprising otherwise: ```go // Idiomatic: disable send case once drained var out chan<- Item if len(pending) > 0 { out = outCh } else { out = nil } select { case out <- pending[0]: ... case v := <-in: ... } ``` - Don't use channels as mutexes (`make(chan struct{}, 1)` as a lock) unless you need ctx-aware locking (`select` on acquire) — then comment it. ## 4. sync primitives - **`errgroup` is the default for fan-out** (`golang.org/x/sync/errgroup`): first error cancels the derived ctx, `Wait` joins and returns it, `SetLimit(n)` bounds concurrency. Prefer it over hand-rolled WaitGroup+error-channel every time: ```go g, ctx := errgroup.WithContext(ctx) g.SetLimit(8) for _, u := range urls { g.Go(func() error { return fetch(ctx, u) }) // 1.22+: u is per-iteration } if err := g.Wait(); err != nil { return err } ``` - **WaitGroup pitfalls**: `Add` before `go` (never inside the goroutine — race with `Wait`); never copy a WaitGroup (pass pointer; vet `copylocks` catches it); don't reuse before `Wait` returns. Go 1.25 adds `wg.Go(fn)` which does Add/Done correctly — prefer it where available. - **`sync.Once`** for lazy init; `sync.OnceValue/OnceValues/OnceFunc` (1.21+) are cleaner. Beware: if the once-fn panics, Once is consumed — subsequent calls return without retrying. For retryable init, use mutex + done flag. - **`sync.RWMutex`**: only when profiling shows read contention; RLock is not free and write starvation is real. Default to plain Mutex. - **`sync.Map`** is a niche tool (append-mostly caches, disjoint key sets); a mutex-guarded map is the default and is type-safe. - **`atomic`**: use the typed API (`atomic.Int64`, `atomic.Bool`, `atomic.Pointer[T]`) — it can't be misused with mixed atomic/non-atomic access the way `atomic.AddInt64(&x, 1)` on a plain int can. Atomics are for counters/flags; multi-field invariants need a mutex. - **Never copy a struct containing a mutex/WaitGroup/Cond** after first use — methods needing the lock take pointer receivers; `go vet` `copylocks`. ## 5. Data races A data race is undefined behavior in Go — not "stale reads", actual memory corruption is possible (interface values, slice headers, maps). - **Race detector in CI, always**: `go test -race ./...`. Run race-enabled binaries in staging/canary if feasible (`go build -race`, ~2-10x CPU, 5-10x memory). A `-race` report is CRITICAL — there are no benign races. - **Loop variable capture**: since Go 1.22 (`go 1.22`+ in go.mod), `for` variables are per-iteration — the classic `go func(){ use(v) }()` bug is fixed *only if* the module's `go` directive is ≥1.22. Auditing a module with `go 1.21` or lower: every closure over a loop variable is suspect (HIGH). Check `go.mod` first. - **Concurrent map access**: unsynchronized read+write throws `fatal error: concurrent map writes` (unrecoverable, no recover) — or worse, races undetected. Any map reachable from multiple goroutines needs a mutex or redesign. - **Lazy "checked" init** (`if m == nil { m = make(...) }` from multiple goroutines), bool flags as ad-hoc signaling, and stat counters (`count++`) are all races even when "writes are rare". - HTTP handlers run concurrently: any handler touching shared server fields without sync is racy — top audit target. ## 6. Bounding concurrency: pools and semaphores **Unbounded `go` per work item is a self-DoS** (memory, FD, downstream overload). Bound everything fed by external input. - First choice: `errgroup` with `SetLimit` (above). - Channel semaphore when you need ctx-aware acquire or weighted slots (`golang.org/x/sync/semaphore` for weights): ```go sem := make(chan struct{}, maxInFlight) for _, job := range jobs { select { case sem <- struct{}{}: case <-ctx.Done(): return ctx.Err() } go func() { defer func() { <-sem }() process(ctx, job) }() } ``` - Worker pool (long-lived workers + job channel) only when worker setup is expensive (per-worker connections, caches) or you need strict FIFO; for plain throughput bounding, errgroup is less code and self-draining: ```go jobs := make(chan Job) g, ctx := errgroup.WithContext(ctx) for range numWorkers { g.Go(func() error { for j := range jobs { if err := handle(ctx, j); err != nil { return err } } return nil }) } // feed then close — producer owns the channel go func() { defer close(jobs); for _, j := range all { jobs <- j } }() err := g.Wait() ``` - GOMAXPROCS: 1.25+ respects container CPU quotas automatically; on older runtimes in containers, `go.uber.org/automaxprocs` or explicit setting prevents throttling-induced latency. ## Audit checklist ```bash # go.mod language version — decides loop-var semantics (HIGH if <1.22 with closures in loops) grep -E '^go ' go.mod # Fire-and-forget goroutines — review each: exit path? join? error? grep -rn 'go func' --include='*.go' . | grep -v _test.go grep -rnE '^\s*go [a-zA-Z]' --include='*.go' . # Goroutines without ctx plumbed (manual: does the func take/select on ctx?) grep -rn -A3 'go func()' --include='*.go' . | grep -L 'ctx' # time.After in loops — MEDIUM (HIGH pre-1.23) grep -rn -B5 'time.After' --include='*.go' . | grep -E 'for |select' # Unbounded fan-out: go inside range — HIGH if input is external grep -rn -B2 'go func' --include='*.go' . | grep 'for .*range' # WaitGroup misuse: Add inside goroutine, copied WG grep -rn -A2 'go func' --include='*.go' . | grep 'wg.Add' go vet ./... # copylocks, loopclosure (pre-1.22) # Raw atomic on plain ints (prefer atomic.Int64 types) grep -rnE 'atomic\.(Add|Load|Store|Swap)(Int|Uint|Pointer)' --include='*.go' . # sync.Map usage — verify it matches its niche grep -rn 'sync.Map' --include='*.go' . # Consumer-side close, double close candidates grep -rn 'close(' --include='*.go' . # verify producer owns each # Sleep-based synchronization — MEDIUM (flaky + racy) grep -rn 'time.Sleep' --include='*.go' . | grep _test.go # Race detector + leak detection — non-negotiable go test -race ./... go test -race -count=5 ./... # shake out flaky interleavings grep -rn 'goleak' --include='*_test.go' . # present in goroutine-spawning pkgs? # Runtime evidence (live systems) curl -s localhost:6060/debug/pprof/goroutine?debug=2 | head -100 ``` Severity guide: confirmed race CRITICAL; goroutine leak / unbounded fan-out on request path HIGH; missing `-race` in CI HIGH; `time.After` loop MEDIUM; hand-rolled errgroup-equivalent LOW. -
04-http-services.md 12.3 KB
# 04 — HTTP & services: timeouts, shutdown, middleware, slog `net/http` defaults are tuned for compatibility, not production: zero server timeouts, infinite client waits, unlimited header sizes. Every production service overrides them. This file covers server, client, lifecycle, and observability. ## 1. http.Server — set ALL the timeouts `http.ListenAndServe(addr, h)` is unfit for production: no timeouts means any slow/malicious client (slowloris) holds a connection and goroutine forever — HIGH finding on any internet-facing service. ```go // GOOD — every production server srv := &http.Server{ Addr: ":8080", Handler: mux, ReadHeaderTimeout: 5 * time.Second, // slowloris defense; cheap, always set ReadTimeout: 10 * time.Second, // full request incl. body WriteTimeout: 30 * time.Second, // from end of headers (HTTP/1.1) to last byte written IdleTimeout: 120 * time.Second, // keep-alive connections between requests MaxHeaderBytes: 1 << 20, // default 1MB; set explicitly } ``` What each guards: | Timeout | Covers | If unset | |---|---|---| | `ReadHeaderTimeout` | Client sending request headers | Slowloris: drip headers forever | | `ReadTimeout` | Headers + entire body read | Slow body upload pins the conn | | `WriteTimeout` | Writing the response | Slow reader pins handler + buffers | | `IdleTimeout` | Keep-alive idle gap | Falls back to ReadTimeout; if both 0, idle conns live forever | - Large uploads/downloads/streaming/SSE: coarse `ReadTimeout`/`WriteTimeout` kill legitimate transfers. Use per-route control: `http.TimeoutHandler(h, d, msg)` for handler deadlines, `rc := http.NewResponseController(w); rc.SetWriteDeadline(...)` (1.20+) to extend deadlines per request. Keep `ReadHeaderTimeout` regardless. - Per-request deadlines for *work* belong in ctx: `context.WithTimeout(r.Context(), d)` around downstream calls. Server timeouts protect the transport; ctx protects the business logic. - Body limits: `http.MaxBytesReader(w, r.Body, maxSize)` on every endpoint that reads a body — unbounded `io.ReadAll(r.Body)` is a memory DoS (HIGH). - Routing: stdlib `http.ServeMux` (1.22+) supports methods and wildcards — `mux.HandleFunc("GET /users/{id}", h)`, `r.PathValue("id")`. Default to it; reach for chi/echo only for needed extras (route-scoped middleware trees). - Go 1.25+: `http.CrossOriginProtection` gives stdlib CSRF protection via Sec-Fetch-Site; use it or an equivalent for cookie-authenticated mutations. ## 2. HTTP clients — timeouts, body hygiene, reuse **`http.DefaultClient` has NO timeout** — a hung server hangs your goroutine forever. Never use `http.Get/Post/...` package functions in services (HIGH). ```go // GOOD — explicit client, reused (it's goroutine-safe; pools connections) client := &http.Client{ Timeout: 10 * time.Second, // absolute cap: dial+TLS+request+read body } // Per-attempt control with ctx (preferred for request-scoped deadlines) ctx, cancel := context.WithTimeout(ctx, 3*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) ``` `Client.Timeout` includes reading the body; for streaming responses, leave it 0 and use ctx + `Transport` knobs instead: ```go t := &http.Transport{ Proxy: http.ProxyFromEnvironment, DialContext: (&net.Dialer{Timeout: 5 * time.Second, KeepAlive: 30 * time.Second}).DialContext, TLSHandshakeTimeout: 5 * time.Second, ResponseHeaderTimeout: 5 * time.Second, ExpectContinueTimeout: 1 * time.Second, MaxIdleConns: 100, MaxIdleConnsPerHost: 100, // DEFAULT IS 2 — throttles any single-host workload IdleConnTimeout: 90 * time.Second, } client := &http.Client{Transport: t, Timeout: 10 * time.Second} ``` `DefaultMaxIdleConnsPerHost = 2` is the classic hidden bottleneck for service-to-service traffic: connections churn, ports exhaust (TIME_WAIT), latency spikes. Set `MaxIdleConnsPerHost` ≈ peak concurrency to that host. **Body discipline — every response, every path:** ```go resp, err := client.Do(req) if err != nil { return err // resp is nil on error; do NOT touch resp.Body here } // Connection reuse requires the body fully read before Close. Defers run // LIFO: register Close first so the bounded drain executes before it. defer resp.Body.Close() defer io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10)) ``` Unclosed bodies leak FDs and goroutines; undrained bodies kill connection reuse (LOW perf, MEDIUM at scale). Always check `resp.StatusCode` — `err == nil` for 4xx/5xx. Create **one client per upstream at startup**, inject it; never build a client (or Transport) per request — each Transport owns a fresh pool. ## 3. Graceful shutdown Pattern: catch signals via ctx, stop accepting, drain in-flight with a deadline, then close dependencies. ```go func run(ctx context.Context) error { ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) defer stop() srv := &http.Server{ /* ...timeouts as §1... */ } errCh := make(chan error, 1) go func() { errCh <- srv.ListenAndServe() }() select { case err := <-errCh: return fmt.Errorf("server: %w", err) // ListenAndServe always returns non-nil case <-ctx.Done(): } shCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() if err := srv.Shutdown(shCtx); err != nil { // stops Accept, waits for handlers return fmt.Errorf("shutdown: %w", err) // DeadlineExceeded => srv.Close() already forced } return nil } ``` - `Shutdown` does NOT cancel handler contexts or wait for hijacked conns (WebSockets); use `srv.RegisterOnShutdown` to signal those, and propagate a "draining" ctx to long-running handlers. - `http.ErrServerClosed` from `ListenAndServe` after Shutdown is expected — filter it: `if !errors.Is(err, http.ErrServerClosed)`. - Shutdown deadline must be **shorter than** the orchestrator's kill grace period (K8s `terminationGracePeriodSeconds`, default 30s) and account for readiness-probe propagation: flip readiness to failing first, sleep a beat (or rely on `preStop`), then Shutdown — otherwise traffic still arrives at a closed listener. - Close order after drain: server → background workers (cancel + join) → DB pools/queues → flush telemetry. Reverse of startup. ## 4. Middleware Standard shape — `func(http.Handler) http.Handler`, composed outermost-first: ```go func RequestLogger(log *slog.Logger) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() ww := &statusWriter{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(ww, r) log.LogAttrs(r.Context(), slog.LevelInfo, "request", slog.String("method", r.Method), slog.String("path", r.URL.Path), slog.Int("status", ww.status), slog.Duration("dur", time.Since(start)), ) }) } } type statusWriter struct { http.ResponseWriter status int } func (w *statusWriter) WriteHeader(c int) { w.status = c; w.ResponseWriter.WriteHeader(c) } ``` - Order matters: recover (outermost) → request ID/trace → logging → auth → rate limit → handler. Recovery middleware logs `debug.Stack()` and returns 500; check `errors.Is(err, http.ErrAbortHandler)` style sentinel — re-panic `http.ErrAbortHandler` rather than swallowing it. - Wrapper `ResponseWriter`s hide optional interfaces (`http.Flusher`, `http.Hijacker`); implement passthroughs or use `http.NewResponseController(w)` (1.20+), which unwraps automatically — SSE and WebSockets break otherwise (MEDIUM). - Don't read `r.Body` in middleware unless you replace it (`r.Body = io.NopCloser(bytes.NewReader(buf))`) — handlers get an empty body. - Prefer returning errors from handlers via a small adapter (`func(w, r) error` → `http.Handler`) so the error mapper from `rules/01 §7` is the single response-shaping point. ## 5. Structured logging with slog `log/slog` (1.21+) is the standard. `fmt.Println`/`log.Printf` in services is LOW debt; unstructured logs can't be queried. ```go log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelInfo, // make it a flag/env; use slog.LevelVar for runtime changes })) slog.SetDefault(log) // also reroutes legacy log.Printf log.InfoContext(ctx, "payment processed", "order_id", orderID, // alternating key/value slog.Int("attempts", n), // or typed attrs — faster, type-safe ) ``` - **Always the `*Context` variants on request paths** — handlers can extract trace/span IDs from ctx (OTel bridges do). - Inject `*slog.Logger` as a dependency (constructor arg) in libraries; `slog.Default()` is acceptable at app level. Pre-bind request attrs once: `log = log.With("request_id", id)` in middleware, pass via ctx value or handler closure. - Hot paths: `log.LogAttrs(ctx, level, msg, attrs...)` avoids `[]any` allocs; guard expensive computation with `if log.Enabled(ctx, slog.LevelDebug)`. - Fan-out to several sinks (e.g. JSON to stdout + a file handler): `slog.NewMultiHandler(h1, h2)` (1.26+) replaces hand-rolled multi-handler wrappers and third-party equivalents. - Never log secrets/PII: implement `slog.LogValuer` on sensitive types to redact by construction: ```go func (t Token) LogValue() slog.Value { return slog.StringValue("REDACTED") } ``` - Levels: Debug (dev diagnosis), Info (state changes worth auditing), Warn (degraded, self-healed), Error (failed operation, human may act). Don't log Error for client mistakes (4xx) — that's Info/Warn; alert noise kills oncall. ## 6. Request-scoped values - Request ID/trace context: set in middleware, store in ctx (unexported key — `rules/02 §8`), read everywhere via accessor funcs. - Auth principal: middleware authenticates, puts `*User`/claims in ctx; handlers call `auth.UserFrom(ctx)`. Handlers never re-parse tokens. - Everything else (parsed body, query params) is plain function arguments — ctx is not a parameter bag. - Propagate outbound: when calling downstream services pass `ctx` into `http.NewRequestWithContext` and inject trace headers (otelhttp transport does both). ## Audit checklist ```bash # Naked servers — HIGH grep -rn 'http.ListenAndServe\|http.ListenAndServeTLS' --include='*.go' . grep -rn -A8 'http.Server{' --include='*.go' . # verify all four timeouts present # Default client / package-level helpers — HIGH grep -rnE 'http\.(Get|Post|PostForm|Head)\(' --include='*.go' . grep -rn 'http.DefaultClient' --include='*.go' . grep -rn -A6 'http.Client{' --include='*.go' . # Timeout set? Transport tuned? grep -rn 'MaxIdleConnsPerHost' --include='*.go' . # absent + high fan-out = bottleneck # Body hygiene grep -rn 'client.Do\|\.Get(\|\.Post(' --include='*.go' . # then verify defer Close + drain near each grep -rn 'resp.Body.Close' --include='*.go' . grep -rn 'io.ReadAll(r.Body\|io.ReadAll(req.Body' --include='*.go' . # MaxBytesReader present? — HIGH grep -rn 'MaxBytesReader' --include='*.go' . # Shutdown grep -rn 'signal.NotifyContext\|signal.Notify' --include='*.go' . grep -rn 'srv.Shutdown\|.Shutdown(' --include='*.go' . # absent => no graceful drain — MEDIUM grep -rn 'ErrServerClosed' --include='*.go' . # Per-request client/transport construction — MEDIUM perf grep -rn -B3 'http.Client{' --include='*.go' . | grep -E 'func.*\(w http|Handler' # Logging grep -rnE '\b(fmt\.Print|log\.Print)' --include='*.go' . | grep -v _test.go # LOW grep -rn 'slog.' --include='*.go' . | grep -v Context # request paths should use *Context grep -rnE '(password|token|secret|authorization|api_?key)' --include='*.go' . | grep -i 'slog\|log\.' # PII in logs — HIGH # Middleware ResponseWriter wrappers missing Flush/Hijack passthrough grep -rn -A4 'http.ResponseWriter$' --include='*.go' . | grep 'struct' # Tooling golangci-lint run --enable-only bodyclose,noctx,gosec ./... # noctx: requests without ctx go vet ./... ``` Severity guide: no server timeouts internet-facing HIGH; default client in service HIGH; unbounded body read HIGH; missing graceful shutdown MEDIUM; unclosed/undrained bodies MEDIUM; unstructured logging LOW; secrets in logs HIGH. -
05-security.md 16.9 KB
# 05 — Security: injection, paths, TLS, integers, supply chain Threat model every input by origin: network, files, env, CLI args, DB contents, and inter-service messages are all untrusted until validated. Go removes memory-corruption classes but injection, traversal, SSRF, and misconfiguration are entirely yours. ## 1. Input validation at trust boundaries - Validate at the **boundary** (handler/consumer), once, into a typed value; interior code trusts its types. Re-validating everywhere means nobody knows where the real check is. - Allowlist over denylist: enums via `ParseX(string) (X, error)`, bounded lengths/sizes on every string/slice, `utf8.ValidString` on text that reaches storage or other parsers. - JSON: `dec := json.NewDecoder(r.Body); dec.DisallowUnknownFields()` for strict APIs; remember `encoding/json` ignores case in field matching and silently drops unknown fields by default — security-relevant for privilege fields. Pair with `http.MaxBytesReader` (`rules/04`). - Numbers from JSON into `any` become `float64` — large int64 IDs silently lose precision; decode into concrete struct types or `json.Number`. - Never echo raw input into errors/logs without bounding (`%.100q`) — log injection and PII leak. - Don't reflect internal errors to clients (`rules/01 §7`): stack traces, SQL text, and file paths in responses are recon gifts — MEDIUM. - Output encoding is contextual and separate from input validation: render HTML through `html/template` (it auto-escapes per context), never `text/template` or `fmt.Fprintf` into a page — that's stored/reflected XSS. The `template.HTML`/`JS`/`URL`/`CSS`/`HTMLAttr` cast types switch escaping off; each is a sink that must be independently sanitized. Full XSS/CSP/output-encoding rules live in sota-code-security `05`. ## 2. SQL — parameterized always String-built SQL is CRITICAL regardless of the value's provenance ("it's an int", "it's internal") — provenance changes, code is copied. ```go // BAD — CRITICAL q := fmt.Sprintf("SELECT * FROM users WHERE email = '%s'", email) rows, err := db.Query(q) // GOOD — database/sql placeholders rows, err := db.QueryContext(ctx, "SELECT id, name FROM users WHERE email = $1", email) // GOOD — pgx native row := pool.QueryRow(ctx, "SELECT id FROM users WHERE email = $1", email) ``` - **sqlc** is SOTA for query-heavy services: SQL in `.sql` files, generated type-safe Go, parameterization by construction, schema-checked at codegen. **pgx/v5** as the runtime driver/pool (`pgxpool`) for Postgres. - Identifiers (table/column names, ORDER BY direction) can't be placeholders: map them through a hardcoded allowlist, never interpolate input: ```go orderCol, ok := map[string]string{"name": "name", "created": "created_at"}[req.Sort] if !ok { return ErrBadSort } q := "SELECT ... ORDER BY " + orderCol // safe: values are program constants ``` - `IN (...)` lists: build placeholders programmatically or use pgx's array binding (`= ANY($1)`). - LIKE patterns: escape `%`/`_` in user input before binding. - Always `defer rows.Close()`; check `rows.Err()` after the loop; use `QueryContext`/`ExecContext` (ctx discipline). Pool settings (`SetMaxOpenConns`, `SetMaxIdleConns`, `SetConnMaxLifetime`) explicit — default unlimited open conns can flatten the DB. - ORMs: if GORM/ent are in use, audit every `Raw`, `Exec`, `Where(fmt.Sprintf` call site — string interpolation there is the same CRITICAL. ## 3. os/exec — argv, never shell `exec.Command` does NOT invoke a shell — that's the security feature. Each argument is a separate argv entry; metacharacters are inert. ```go // BAD — CRITICAL command injection out, err := exec.Command("sh", "-c", "convert "+userFile+" out.png").Output() // GOOD — argv vector, ctx-bound cmd := exec.CommandContext(ctx, "convert", userFile, "out.png") cmd.Stdout, cmd.Stderr = &outBuf, &errBuf err := cmd.Run() ``` - `sh -c` / `bash -c` with ANY interpolated data is CRITICAL. With constant strings only, it's MEDIUM (fragile pattern, invites edits that interpolate). - Argument injection still applies: a userFile like `-trim` becomes a flag. Use `--` end-of-options where the tool supports it, validate the value shape, or prefix paths (`./`+name). - Set `cmd.Dir`, pass minimal `cmd.Env` (don't inherit secrets-laden environ into child processes: `cmd.Env = []string{"PATH=/usr/bin"}`). - Binary resolution: Go 1.19+ `exec.LookPath`/`Command` refuse relative-path matches from the current directory on Unix; still prefer absolute paths for security-sensitive helpers. - `CommandContext` kills on ctx cancel but **waits for copied pipes**; set `cmd.WaitDelay` (1.20+) so a child that inherits the pipe can't block `Wait` forever. ## 4. Path traversal Joining user input into paths without containment is HIGH/CRITICAL (read: arbitrary file read/write). ```go // BAD — ../../../etc/passwd f, err := os.Open(filepath.Join(baseDir, userPath)) ``` **Go 1.24+: `os.Root` is the answer** — kernel-enforced containment (openat2/RESOLVE_BENEATH semantics), immune to `..`, absolute paths, and symlink escapes: ```go root, err := os.OpenRoot(baseDir) if err != nil { return err } defer root.Close() f, err := root.Open(userPath) // cannot escape baseDir, even via symlinks ``` Caveat: containment is only as good as the toolchain — CVE-2026-39822 (fixed in 1.26.5/1.25.12, 2026-07) let `root.Open("symlink/")` follow a trailing-slash symlink out of the Root on Unix (go.dev issue #79005). Reinforces the §8 keep-the-toolchain-current rule. Pre-1.24 fallback (and for path *strings* not yet opened): ```go func securePath(baseDir, userPath string) (string, error) { if !filepath.IsLocal(userPath) { // 1.20+: rejects ../, absolute, reserved names return "", fmt.Errorf("invalid path %q", userPath) } return filepath.Join(baseDir, userPath), nil } ``` - `filepath.Clean` alone is NOT containment (it normalizes; `Clean("../x")` is still `../x`). Prefix-checking `strings.HasPrefix(abs, base)` misses symlinks and `base`-sibling prefixes (`/srv/app` vs `/srv/app-secrets`) — if you must, compare against `filepath.Separator`-terminated resolved (`filepath.EvalSymlinks`) paths. - Zip/tar extraction: validate every entry name with `filepath.IsLocal` + reject absolute/`..` (zip-slip); bound total size and file count (decompression bomb). - Serving files: `http.ServeFile` rejects `..` but build the path with `http.FileServer`/`http.FS` over a rooted FS rather than manual joins; `os.DirFS` + `fs.Sub`, or `os.Root.FS()` on 1.24+. ## 5. Integer conversion overflow (gosec G115) Conversions between int sizes/signs silently truncate/wrap — exploitable when the value gates an allocation, length, offset, or privilege check. ```go // BAD — attacker sends length = 4294967296; on 32-bit int wraps to 0 n := int32(req.Length) // truncates buf := make([]byte, n) // BAD — negative int → huge uint u := uint64(off) // off = -1 → 18446744073709551615 // GOOD — bounds-check before every narrowing/sign-changing conversion if req.Length < 0 || req.Length > math.MaxInt32 { return fmt.Errorf("length %d out of range", req.Length) } n := int32(req.Length) ``` - High-risk sites: `len()` math into smaller ints, binary protocol parsing, `strconv.Atoi` (returns platform `int`) then narrowed — use `strconv.ParseInt(s, 10, 32)` with the explicit bit size instead. - gosec rule G115 flags these; triage rather than blanket-`nolint`. For hot paths a tiny generic helper (`func conv[T, U constraints.Integer]`) centralizes the check. - Durations: `time.Duration(n) * time.Second` where `n` is attacker-supplied can overflow int64 — bound first. ## 6. Cryptographic practices: CSPRNG & TLS ### Randomness — `crypto/rand`, never `math/rand` Security-bearing randomness — tokens, session IDs, password-reset codes, API keys, salts, nonces, IVs — MUST come from `crypto/rand`. `math/rand` *and* `math/rand/v2` are PRNGs whose "outputs might be easily predictable regardless of how it's seeded" (package docs); using either for anything an attacker must not guess is HIGH (CRITICAL when it gates auth — a guessable reset token is account takeover). ```go // BAD — predictable; applies to math/rand and math/rand/v2 alike token := strconv.FormatInt(mrand.Int63(), 36) // GOOD — Go 1.24+: ready-made secret string (RFC 4648 base32, ≥128 bits) token := rand.Text() // crypto/rand.Text // GOOD — raw bytes; Read never errors and always fills b entirely b := make([]byte, 32) rand.Read(b) // crypto/rand.Read ``` - `crypto/rand.Read`/`Text` cannot hand back a short or weak read — on a failing source they crash the program rather than degrade. Don't wrap them in `if err != nil` logic that silently falls back to `math/rand`. - Watch the import line, not just the call: `math/rand.Read` is **deprecated** precisely because an unqualified `rand.Read` under `import "math/rand"` reads like the crypto one but isn't. Grep imports, then call sites. - `math/rand/v2` is the right tool — and the better PRNG API — for *non-secret* work: jitter, load distribution, sampling, test fixtures. The dividing line is "does predictability help an attacker", not "which package is newer". - Keys, signing, AEAD: use `crypto/*` (`ed25519`, `crypto/ecdsa`, `crypto/aes` + `cipher.NewGCM`) drawing from `crypto/rand.Reader`; never hand-roll. Password hashing is `golang.org/x/crypto/bcrypt`/`argon2` — algorithm choice and parameters are owned by sota-code-security `04`. ### TLS configuration Go's `crypto/tls` defaults are good (1.22+ defaults to strong suites; 1.24+ enables post-quantum X25519MLKEM768 key exchange, and 1.26 also enables SecP256r1MLKEM768/SecP384r1MLKEM1024 by default; the legacy GODEBUG opt-outs `tlsrsakex`/`tls10server`/`tls3des` are slated for removal in 1.27). The main sins are *downgrades*: ```go // CRITICAL — disables all certificate verification cfg := &tls.Config{InsecureSkipVerify: true} // GOOD — modern floor, otherwise stdlib defaults cfg := &tls.Config{MinVersion: tls.VersionTLS12} // TLS13 for internal-only ``` - `InsecureSkipVerify: true` is CRITICAL anywhere near production code paths, including "temporary" test toggles compiled into the binary. Pinning or custom CA? Use `RootCAs` with the CA pool, or `VerifyPeerCertificate` with real verification — never blanket skip. - Don't set `CipherSuites`/`CurvePreferences` manually unless compliance forces it — stale hand-picked lists rot; stdlib defaults track best practice per release. - mTLS: server `ClientAuth: tls.RequireAndVerifyClientCert` + `ClientCAs`. - Plain `http://` to internal services carrying credentials is HIGH unless the transport is otherwise authenticated/encrypted (mTLS mesh). ## 7. unsafe and cgo policy - `unsafe`: forbidden outside a designated, documented, owner-reviewed package. Each use carries a comment proving the invariant (per `unsafe.Pointer` rules) and a fuzz/race-tested wrapper. Audit any new `unsafe.Pointer` arithmetic as HIGH until proven. - `//go:linkname`, `reflect.SliceHeader`/`StringHeader` (deprecated): treat as `unsafe`; 1.20+ `unsafe.String/StringData/Slice/SliceData` are the only sanctioned forms. - cgo: each C dependency reintroduces memory-unsafety, complicates cross-compilation and static linking, and bypasses govulncheck's call analysis. Require justification (no pure-Go alternative), pin and scan the C library separately, and isolate behind one package. `CGO_ENABLED=0` for builds unless cgo is required. ## 8. Supply chain & vuln management - **`govulncheck ./...` in CI** (it's call-graph aware — low false positives; also run `govulncheck -mode=binary` on shipped artifacts — it now checks the binary's main module too, and `-format sarif` feeds code-scanning UIs). Fails the build on findings; triage with documented suppressions, not removal of the step. - **The stdlib itself is the top vuln surface** — H1 2026 alone patched DoS CVEs in `crypto/tls` (CVE-2026-32283, TLS 1.3 key-update flood), `crypto/x509` (CVE-2026-32280, CVE-2026-27145) and `net/mail` (CVE-2026-42499); 1.26.5/1.25.12 (2026-07) fixed CVE-2026-39822 (os.Root symlink escape, §4) and CVE-2026-42505 (`crypto/tls` ECH leaked pre-shared key identities, de-anonymizing server hostnames). govulncheck only helps if the *toolchain* is current: track the monthly patch releases (verify the current level at go.dev/doc/devel/release) and rebuild/redeploy on security point releases. - **`go.sum` committed always**; builds verify hashes against it and the checksum DB (sum.golang.org), on by default. `GONOSUMDB` and `GONOPROXY` exempt matching module patterns from sumdb/proxy; `GOPRIVATE` sets both — use `GOPRIVATE=*.corp.example.com` for internal modules and nothing else. `GOSUMDB=off`, `GOFLAGS=-mod=mod` in CI, or wildcard `GONOSUMDB=*` disable verification repo-wide — HIGH finding. Everything public must flow through proxy.golang.org + sumdb verification. - **Minimal deps philosophy**: stdlib first; `golang.org/x/*` second; each third-party module needs maintenance signal (recent releases, issue hygiene), a license check, and a reason a 50-line vendored function can't replace it. Transitives count: `go mod graph | wc -l` before/after. - **Audit side — already-landed deps**: `go mod why -m <module>` prints `(main module does not need module …)` for an unreached one — verbatim, exit 0 — but its graph includes tests of reachable packages (`-vendor` excludes tests *of dependencies*; your own test-only deps still read as reached). A tool's silence is not proof — the sweep, the deletion proof, and the leverage-ratio and upstream-health checks are `sota-devsecops` rules/10. - `go mod tidy` enforced in CI (`git diff --exit-code go.mod go.sum` after). - Pin tool versions via 1.24 `tool` directives in go.mod (`rules/07`) so the linter/codegen supply chain is hash-verified too. - Don't `replace` to forks silently — audit `replace` directives (MEDIUM: hidden fork drift; CRITICAL if pointing at an unreviewed repo). - Secrets: never in code/env-committed files; load via env/secret manager at start; `slog.LogValuer` redaction (`rules/04 §5`). ## Audit checklist ```bash # SQL injection — CRITICAL grep -rnE '(Sprintf|fmt\.Sprint|\+ ?\w+ ?\+).*((?i)select|insert|update|delete|where)' --include='*.go' . grep -rnE '(Query|Exec|QueryRow)[^(]*\(("[^"]*"\s*\+|fmt\.Sprintf)' --include='*.go' . grep -rnE '\.(Raw|Where)\(fmt\.Sprintf' --include='*.go' . # GORM-style # Command injection — CRITICAL grep -rnE 'exec\.Command(Context)?\(\s*"(sh|bash|cmd|powershell)"' --include='*.go' . grep -rn 'exec.Command' --include='*.go' . # verify argv construction per site # Path traversal — HIGH grep -rnE 'filepath\.Join\([^)]*(r\.|req\.|input|name|param|id)' --include='*.go' . grep -rn 'os.Root\|filepath.IsLocal' --include='*.go' . # mitigations present? go version # os.Root containment needs >=1.26.5/1.25.12 — CVE-2026-39822 symlink escape grep -rnE 'os\.(Open|Create|ReadFile|WriteFile|Remove)' --include='*.go' . # trace path provenance # TLS — CRITICAL/HIGH grep -rn 'InsecureSkipVerify' --include='*.go' . grep -rnE 'MinVersion:\s*tls\.VersionTLS1[01]' --include='*.go' . grep -rn '"http://' --include='*.go' . | grep -v 'localhost\|127.0.0.1\|test' # Integer conversion — gosec G115 grep -rnE '\b(int8|int16|int32|uint8|uint16|uint32|uint64|uintptr)\(' --include='*.go' . | grep -vE '(_test|const)' gosec -include=G115,G118,G201,G202,G204,G304,G401,G402 ./... # gosec 2.24+ adds G113 (request smuggling via conflicting headers), # G118 (ctx-propagation goroutine leaks), G408 (SSH PublicKeyCallback bypass) # CSPRNG misuse — HIGH (security-bearing randomness from a PRNG) grep -rn 'math/rand' --include='*.go' . # any import: verify each call site is non-secret grep -rnE '\brand\.(Int|Intn|Int31|Int63|Uint|Float|Perm|Shuffle|N)\b' --include='*.go' . # PRNG calls — crypto context? # Output encoding / XSS — HIGH grep -rn 'text/template' --include='*.go' . | grep -iv _test # HTML rendered via text/template? grep -rnE 'template\.(HTML|JS|URL|CSS|HTMLAttr)\(' --include='*.go' . # escaping bypass — verify sanitized # unsafe / cgo grep -rn 'unsafe.Pointer\|go:linkname' --include='*.go' . grep -rln 'import "C"' --include='*.go' . # Supply chain test -f go.sum && echo OK || echo 'MISSING go.sum — HIGH' grep -E '^replace' go.mod go env GOFLAGS GONOSUMDB GOSUMDB GOPRIVATE GONOSUMCHECK 2>/dev/null go mod verify govulncheck ./... go mod tidy && git diff --exit-code go.mod go.sum # Secrets in repo grep -rnE '(api[_-]?key|secret|password|token)\s*[:=]\s*"[A-Za-z0-9+/_-]{16,}"' --include='*.go' . ``` Severity guide: string-built SQL / `sh -c` with input / InsecureSkipVerify / `math/rand` for an auth-gating token CRITICAL; traversal-reachable file ops, disabled sumdb, unchecked narrowing on attacker-controlled sizes, `math/rand` for other secrets, HTML via `text/template` MEDIUM-to-HIGH; raw error echo to clients, silent `replace` MEDIUM. -
06-performance.md 11.5 KB
# 06 — Performance: profiling, allocations, GC, PGO Rule zero: **measure before and after; optimize only what profiles indict.** Clarity-destroying micro-optimizations without a benchmark are LOW findings in audits, same as obvious waste on a measured hot path. ## 1. pprof workflow Wire profiling into every service (auth-gated or bound to localhost/admin port — exposing pprof publicly is a MEDIUM info-leak/DoS finding): ```go import _ "net/http/pprof" // registers on http.DefaultServeMux go func() { // separate internal-only listener, never the public mux slog.Error("pprof", "err", http.ListenAndServe("localhost:6060", nil)) }() ``` Capture and analyze: ```bash go tool pprof -http=:8081 'http://localhost:6060/debug/pprof/profile?seconds=30' # CPU go tool pprof -http=:8081 'http://localhost:6060/debug/pprof/heap' # live heap (inuse_space) go tool pprof -sample_index=alloc_space -http=:8081 '.../debug/pprof/heap' # cumulative allocs curl -s 'localhost:6060/debug/pprof/goroutine?debug=2' # goroutine dump (leak hunt) go tool pprof '.../debug/pprof/mutex' # contention; needs runtime.SetMutexProfileFraction(>0) go tool pprof '.../debug/pprof/block' # blocking; needs runtime.SetBlockProfileRate(>0) curl -s 'localhost:6060/debug/pprof/goroutineleak' # 1.26 experimental leak profile (GOEXPERIMENT=goroutineleakprofile) ``` Reading order for a perf complaint: CPU profile (flame graph) → if CPU is in `runtime.mallocgc`/GC, switch to heap `alloc_space` to find allocation sites → if CPU is idle but latency high, goroutine/block/mutex profiles. `runtime/trace` (`go tool trace`) for scheduler-level mysteries: long GC pauses, goroutine starvation, syscall stalls. Continuous profiling (Parca/Pyroscope/Cloud Profiler) is SOTA for production — point-in-time pprof lies about spiky loads. Go 1.25 adds `trace.FlightRecorder` for capturing the moments *before* an anomaly. ## 2. Benchmarks with testing.B ```go func BenchmarkParse(b *testing.B) { data := loadFixture(b) // setup outside the loop b.ReportAllocs() for b.Loop() { // Go 1.24+: replaces `for i := 0; i < b.N; i++` parse(data) // b.Loop prevents the compiler optimizing the call away } } ``` - `b.Loop()` (1.24+) auto-excludes setup/teardown from timing and keeps the loop body from being dead-code-eliminated — the old `b.N` + global-sink idiom is obsolete; on older Go, assign results to a package-level sink. Go 1.26 fixed `b.Loop` blocking inlining in the loop body (which skewed allocs/op on 1.24/1.25) — on 1.26+ migrate all `b.N` benchmarks with no caveats. - Always `b.ReportAllocs()`; allocs/op regressions are the early warning. - Run with `-count=10` and compare using **benchstat** (significance, not vibes): `go test -bench=. -count=10 | benchstat old.txt new.txt`. Single-run deltas under ~5% are noise. - `-benchmem`, `-cpuprofile`/`-memprofile` on benchmarks feed pprof directly: `go test -bench=Parse -cpuprofile=cpu.out && go tool pprof cpu.out`. - Benchmark with realistic data sizes/shapes; `b.Run` sub-benchmarks over a size table exposes O(n²) cliffs. ## 3. Allocation reduction Allocations cost three times: malloc, GC mark, cache misses. The biggest wins: **Preallocate when length is known:** ```go // BAD — grows: repeated alloc+copy var out []Item for _, r := range rows { out = append(out, convert(r)) } // GOOD out := make([]Item, 0, len(rows)) for _, r := range rows { out = append(out, convert(r)) } m := make(map[string]int, len(keys)) // maps too ``` **String building** — `+` in a loop is O(n²) allocs: ```go var sb strings.Builder sb.Grow(estimatedLen) // one alloc if estimate holds for _, p := range parts { sb.WriteString(p) } s := sb.String() // no copy: Builder transfers ownership ``` Also: `strconv.AppendInt(buf, n, 10)` family over `fmt.Sprintf` on hot paths (fmt reflects and allocates); `fmt.Appendf` over Sprintf-then-copy. **string ↔ []byte**: each conversion copies. Avoid round-trips; work in one domain. Map lookups `m[string(bytes)]` and `switch string(b)` are compiler-optimized (no alloc) — don't contort code to avoid those. For zero-copy in extreme hot paths, 1.20+ `unsafe.String(ptr, len)` — only under the `rules/05 §7` unsafe policy, with the immutability invariant proven. **sync.Pool** — justified only when ALL hold: profile shows the allocation hot (GC pressure), objects are large or expensive, lifetime is strictly scoped (get → use → put, no retention), contents fully reset on put. Pools of small objects or pooling "just in case" adds complexity and can be slower. Classic correct use: per-request buffers in high-QPS encoders. ```go var bufPool = sync.Pool{New: func() any { return new(bytes.Buffer) }} buf := bufPool.Get().(*bytes.Buffer) buf.Reset() // ALWAYS reset on get or put defer bufPool.Put(buf) ``` Cap what you return to the pool (don't pool 64MB outliers — check `buf.Cap()` before Put). Never put slices whose backing array is still referenced elsewhere (aliasing corruption — HIGH). **Misc**: reuse buffers across loop iterations (`buf = buf[:0]`); avoid `[]byte(fmt.Sprintf(...))`; prefer streaming (`io.Copy`, `json.NewEncoder(w)`) over materializing (`io.ReadAll`, `json.Marshal` then `w.Write`); value receivers on small structs avoid pointer-chasing, but see escape analysis. ## 4. Escape analysis basics `go build -gcflags='-m'` (or `-m -m` for detail) shows what escapes to heap. You don't fight every escape — just know the triggers on hot paths: - Returning a pointer to a local → escapes (fine; that's how constructors work). - Storing into an interface (`fmt.Sprintf` args, `slog` `[]any`, `any` params) → escapes. This is why `LogAttrs`/`strconv.Append*` beat fmt on hot paths. - Closures capturing by reference, sending pointers to channels, slices that outgrow their scope → escape. - `make([]byte, n)` with variable `n` escapes; constant small `n` can stay on stack. - Don't return `*T` "for performance" on small structs — value returns often stay stack-allocated and copy cheaper than the GC cost of the escape. ## 5. GC tuning: GOGC and GOMEMLIMIT Defaults first; tune only with metrics (`runtime/metrics`, GC CPU fraction, RSS). Go 1.26 ships the Green Tea GC by default (10–40% lower GC overhead, more on AVX-512-class CPUs) — re-baseline GC metrics after upgrading before re-tuning; `GOEXPERIMENT=nogreenteagc` is a temporary escape hatch slated for removal in 1.27. - **`GOMEMLIMIT`** (1.19+) is the main knob in containers: set to ~90% of the container memory limit (`GOMEMLIMIT=900MiB` for a 1Gi pod). It's a soft limit — GC runs harder as you approach it instead of OOM-killing. Leave headroom for non-heap memory (stacks, cgo, mmap). - **`GOGC`** trades memory for CPU: `GOGC=200` halves GC frequency (more heap), `GOGC=50` doubles it (less heap). With GOMEMLIMIT set, `GOGC=off` + memlimit is a valid "use all the RAM I'm given" strategy for batch jobs; risky for spiky services (death-spiral near the limit — watch `/gc/limiter/last-enabled:gc-cycle`). - Symptoms → actions: high `runtime.mallocgc`/`gcBgMarkWorker` CPU → reduce allocations (§3) before touching knobs; OOMKilled pods → GOMEMLIMIT; RSS far below limit with GC CPU high → raise GOGC. - Ballast hacks are obsolete post-GOMEMLIMIT — audit and remove (LOW). ## 6. PGO builds Profile-guided optimization (default-on since 1.21 when a profile is present): put a representative CPU profile at `default.pgo` in the main package directory; `go build` picks it up automatically. ```bash curl -so cpu.pprof 'http://prod-host:6060/debug/pprof/profile?seconds=60' # peak traffic mv cpu.pprof cmd/api/default.pgo go build ./cmd/api # check logs: PGO enables extra inlining/devirtualization ``` - Typical win 2–8% CPU; free once automated. SOTA setup: CI job refreshes `default.pgo` weekly from continuous-profiling data; stale profiles degrade gracefully (no correctness risk). - Verify it's active: `go build -pgo=auto -x` or check `go version -m binary` for the `-pgo` setting. ## 7. Production performance signals Profiles answer "where"; metrics answer "when/whether". Export from `runtime/metrics` (or via the OTel/Prometheus runtime collectors): - `/gc/heap/allocs:bytes` rate — allocation pressure trend; pair with `/cpu/classes/gc/total:cpu-seconds` (GC CPU share; >10–15% sustained means go to §3). - `/sched/latencies:seconds` — scheduler delay histogram; rising tail with idle CPU points at goroutine floods or syscall stalls (`go tool trace`). - `/memory/classes/heap/live:bytes` vs container limit — headroom for GOMEMLIMIT decisions; `/gc/limiter/last-enabled:gc-cycle` flags memlimit death-spiral mode. - `runtime.NumGoroutine()` as a gauge — monotonic growth is a leak (`rules/03 §2`), not a perf tuning problem. Alert on trends, not absolutes; baseline per service. Capture a CPU+heap profile automatically when alerts fire (flight recorder / continuous profiler) so the incident carries its own evidence. ## 8. Hot-path checklist (apply only where profiles point) - Bounds-check elimination: iterate with `for i := range s` / `for _, v`; hoist `_ = s[n-1]` hints rarely needed post-1.21. - Avoid defer in ultra-hot tiny functions pre-1.14 lore is obsolete — defer is ~1ns now; keep defers, drop the superstition (flag stale "defer is slow" comments as INFO). - Map with `int` keys beats `string` keys; consider sharded maps under write contention before `sync.Map`. - Sort with `slices.SortFunc` (no interface boxing) over `sort.Slice`. - JSON dominating? `json.NewDecoder`/`Encoder` streaming, smaller structs, `encoding/json/v2` (GOEXPERIMENT=jsonv2 — still experimental as of 1.26) or a faster codec — measure. ## Audit checklist ```bash # Profiling wired up? (absence in a latency-sensitive service = LOW gap) grep -rn 'net/http/pprof' --include='*.go' . grep -rn 'pprof' --include='*.go' . | grep -i 'listen\|mux\|handle' # exposed publicly? — MEDIUM # Benchmarks exist for hot packages; b.Loop adoption grep -rln 'func Benchmark' --include='*_test.go' . grep -rn 'b.N' --include='*_test.go' . # candidates to migrate to b.Loop (1.24+) grep -rn 'ReportAllocs' --include='*_test.go' . # Growth-by-append without prealloc near loops (then check if size was knowable) grep -rnE 'var \w+ \[\]' --include='*.go' . | head -50 golangci-lint run --enable-only prealloc,perfsprint,makezero ./... # String concat in loops — O(n²) grep -rn -B3 '+= ' --include='*.go' . | grep -E 'for |range' | grep -i 'str\|msg\|out' # fmt on hot paths (handler/loop proximity — manual confirm) grep -rnE 'fmt\.Sprintf' --include='*.go' . | wc -l # sync.Pool correctness: Reset on reuse? cap check? aliasing? grep -rn -A5 'sync.Pool' --include='*.go' . # io.ReadAll on large/unbounded inputs — MEDIUM grep -rn 'io.ReadAll\|ioutil.ReadAll' --include='*.go' . # GC knobs & ballast grep -rn 'GOGC\|GOMEMLIMIT\|SetMemoryLimit\|SetGCPercent' -r . --include='*.go' --include='*.yaml' --include='Dockerfile*' grep -rn 'ballast' --include='*.go' . # obsolete pattern — LOW # PGO ls cmd/*/default.pgo 2>/dev/null; grep -rn 'pgo' Makefile* .github/ 2>/dev/null # Escape analysis spot-check on hot package go build -gcflags='-m' ./internal/hotpkg 2>&1 | grep 'escapes to heap' | head -30 ``` Severity guide: pool aliasing/missing reset HIGH (corruption); unbounded ReadAll MEDIUM; missing prealloc/Builder on measured hot path LOW–MEDIUM; publicly exposed pprof MEDIUM; cargo-cult optimizations without benchmarks LOW. -
07-tooling-ci.md 12.8 KB
# 07 — Tooling, testing, CI, go.mod hygiene A Go repo without lint+race+vuln gates accumulates every defect class in this skill silently. Tooling is cheap; retrofitting it is not. This file defines the SOTA gate set and test discipline. ## 1. CI gate set (minimum viable, in order) ```bash gofumpt -l -d . # formatting (superset of gofmt) go vet ./... golangci-lint run # curated config below go test -race -shuffle=on ./... # shuffle kills inter-test ordering deps go test -race -coverprofile=cover.out ./... govulncheck ./... go mod tidy && git diff --exit-code go.mod go.sum go build ./... # catches main-package breakage tests miss ``` Pin tool versions (see §4); cache `~/go/pkg/mod` and golangci-lint cache. A repo missing `-race` or govulncheck in CI: HIGH audit finding. On toolchain upgrades, run Go 1.26's revamped `go fix` — it now applies "modernizer" fixers (built on the vet analysis framework) that rewrite code to current idioms; review the diff like any refactor. ## 2. golangci-lint curated config Don't enable-all (noise kills adoption); don't run bare defaults (misses too much). Curated `.golangci.yml` (v2 schema): ```yaml version: "2" linters: default: none enable: # correctness - govet # includes shadow-ish checks, lostcancel, copylocks - staticcheck # the big one: SA bugs, ST style, S simplifications - errcheck # unchecked errors - errorlint # %w misuse, err == comparisons - nilerr # return nil after err != nil - bodyclose # unclosed http response bodies - rowserrcheck # rows.Err() after iteration - sqlclosecheck # rows/stmt Close - noctx # http requests without context - contextcheck # ctx not propagated - containedctx # ctx stored in struct - copyloopvar # obsolete loop-var copies (go >= 1.22) - gosec # security incl. G115 - musttag # struct tags on (un)marshaled types settings: errcheck: check-type-assertions: true gosec: excludes: [] # triage per-finding with #nosec + justification staticcheck: checks: ["all"] formatters: enable: [gofumpt, goimports] ``` Add per-repo: `prealloc`/`perfsprint` (perf-sensitive), `revive` (style depth), `exhaustive` (enum switches), `gomodguard` (dependency policy). Inline suppressions require a reason: `//nolint:gosec // G304: path validated by os.Root above` — bare `//nolint` is itself a LOW finding. `staticcheck` ships inside golangci-lint; running the standalone binary too is fine but redundant. `gofumpt` over `gofmt`: stricter, zero-config, no debates. Version notes (2026-06): golangci-lint v2.9.0+ is required for Go 1.26 support (use the latest stable). `noctx` now also flags missing-ctx `log/slog`, `os/exec` and `crypto/tls` call sites, not just HTTP requests; `errcheck` v1.10+ excludes `crypto/rand.Read` by default (it never fails). ## 3. Test discipline **Table tests** are the default shape; name cases, use subtests: ```go func TestParseLevel(t *testing.T) { t.Parallel() tests := map[string]struct { in string want Level wantErr error }{ "info": {in: "info", want: LevelInfo}, "unknown": {in: "nope", wantErr: ErrBadLevel}, } for name, tt := range tests { t.Run(name, func(t *testing.T) { t.Parallel() got, err := ParseLevel(tt.in) if tt.wantErr != nil { if !errors.Is(err, tt.wantErr) { t.Fatalf("err = %v, want %v", err, tt.wantErr) } return } if err != nil { t.Fatal(err) } if got != tt.want { t.Errorf("got %v, want %v", got, tt.want) } }) } } ``` - **`t.Parallel()` correctness**: with `go.mod` ≥1.22 the loop-var capture trap is gone; on older modules every parallel subtest needs `tt := tt`. Parallel subtests + shared fixtures = races — fixtures must be per-subtest or immutable. `t.Setenv`/`t.Chdir` are incompatible with `t.Parallel` (panics — by design). - Use `t.Helper()` in assertion helpers, `t.Cleanup` over manual defers (runs even on Fatal, ordered LIFO, works with subtests), `t.TempDir()` for files, `t.Context()` (1.24+) for ctx. - Test behavior through exported APIs (`package foo_test`); reaching into internals couples tests to refactors. `export_test.go` for the rare internal hook. - Assertions: stdlib comparisons + `github.com/google/go-cmp` for deep diffs (`cmp.Diff(want, got)` in the error message). testify is acceptable if already in-house; don't mix styles. - **No time.Sleep synchronization** in tests — flaky by construction (MEDIUM). Use channels, fakes for clocks, or `testing/synctest` (1.25): `synctest.Test(t, func(t *testing.T){ ... })` runs goroutines in a bubble with virtual time — `time.Sleep` completes instantly and deterministically. **Integration tests — testcontainers** over mocks for DB/queue behavior: ```go func TestUserStore(t *testing.T) { if testing.Short() { t.Skip("integration") } ctx := t.Context() pg, err := postgres.Run(ctx, "postgres:17-alpine", postgres.WithDatabase("app"), postgres.BasicWaitStrategies()) testcontainers.CleanupContainer(t, pg) if err != nil { t.Fatal(err) } // connect, migrate, exercise the real store } ``` Gate with `testing.Short()` or build tags so `go test ./...` stays fast; CI runs both tiers. Mock at *your* consumer-side interfaces (hand-written fakes or `moq`/`mockgen` if codegen helps) — never mock `*sql.DB`. **Golden files** for large/structured outputs (rendered templates, JSON, codegen): store under `testdata/` (toolchain-ignored), compare bytes, update via flag: ```go var update = flag.Bool("update", false, "rewrite golden files") golden := filepath.Join("testdata", t.Name()+".golden") if *update { os.WriteFile(golden, got, 0o644) } want, _ := os.ReadFile(golden) if diff := cmp.Diff(string(want), string(got)); diff != "" { t.Errorf("mismatch (-want +got):\n%s", diff) } ``` Review golden diffs like code — `-update` runs that get rubber-stamped make the tests decorative. **Fuzzing** (native, 1.18+) for every parser/decoder/validator that touches untrusted bytes: ```go func FuzzParseManifest(f *testing.F) { f.Add([]byte(`{"v":1}`)) // seed corpus f.Fuzz(func(t *testing.T, data []byte) { m, err := ParseManifest(data) if err != nil { return } // invalid input may error — must not panic round, err := m.MarshalBinary() // invariants: roundtrip, no panic, bounded output if err != nil { t.Fatal(err) } _ = round }) } ``` `go test -fuzz=FuzzParseManifest -fuzztime=60s` in a periodic CI job; commit found crashers from `testdata/fuzz/` as permanent regression seeds. Seeds run on every normal `go test`. Coverage: track trend, don't worship a number; `-coverprofile` + `go tool cover -func` in CI. Untested error paths matter more than the percentage. ## 4. go.mod hygiene ``` module github.com/org/app go 1.25.0 // language version (1.26's `go mod init` writes the previous minor by design) toolchain go1.26.5 // exact toolchain: reproducible builds across dev/CI (pin the current patch — verify at go.dev/doc/devel/release) require ( ... ) tool ( // 1.24+: tool dependencies, versioned & sum-verified golang.org/x/tools/cmd/stringer github.com/sqlc-dev/sqlc/cmd/sqlc ) ``` - **`go` directive** is semantic, not decorative: it selects language behavior per-module (loop-var scoping needs ≥1.22 — `rules/03 §5`). Keep it within two releases of current (only the last two minors get security fixes — an EOL `go` directive with no newer toolchain is a MEDIUM finding). - **`toolchain` directive** pins the exact compiler; CI and developers build identically. Update deliberately (Dependabot/Renovate handle it). - **`tool` directives (1.24+)** replace the `tools.go` blank-import hack and ad-hoc `go install tool@version` drift: `go get -tool <pkg>`, run via `go tool stringer`. Tools become sum-verified supply chain (`rules/05 §8`). Audit repos still using floating `go install foo@latest` in CI: MEDIUM. - `go mod tidy` clean in CI (diff check); `go mod verify` on release builds. - Versioning: tag semver; v2+ requires the `/v2` module path suffix — retagging without it breaks consumers. Avoid `v0` forever for published libraries; commit to v1 once the API settles. - Workspaces (`go.work`) for local multi-module dev only — **never commit go.work to a library repo**; it's developer-machine state (`.gitignore` it). - `replace` directives in committed go.mod: temporary at best, document an expiry; they don't apply to downstream consumers of a library (so a library relying on `replace` is broken for users — HIGH). - **`require` is a floor, not a ceiling.** Under Minimal Version Selection the build uses the **highest** version required anywhere in the module graph, and a `require` line states a *minimum* — "required versions in go.mod files are minimum versions and may be increased automatically" (go.dev/ref/mod). So `require foo v1.2.3` — or a build-tool flag that becomes one, such as an `xcaddy --with foo@v1.2.3` — **cannot cap** `foo`: if anything else in the graph requires v1.5.0, you build v1.5.0 while your file reads v1.2.3. It is an exact pin only for a **leaf** dependency nothing else requires. Only `replace` caps: `exclude` drops a specific version and redirects that requirement to the *next higher* one, so it cannot hold a module back either. - Used deliberately, the floor is the right tool for a CVE fix — requiring the fixed version raises what gets selected without capping anything, and goes inert once upstream requires it anyway. But **say which you mean**: "we pinned it" reads as a ceiling to almost every reviewer, and for a transitive dependency it is not one. Verify what was actually built with `go list -m <module>` (or `go version -m ./bin/app`, §5) — never the `require` line you wrote. Same trap from the supply-chain side: `sota-devsecops` rules/03 §3.7.1. ## 5. Reproducible builds & release - Build with `-trimpath`; inject version via `-ldflags="-X main.version=$(git describe --tags)"` or read `debug.ReadBuildInfo()` (embeds VCS revision automatically). - `CGO_ENABLED=0` for static binaries unless cgo is required (`rules/05 §7`); distroless/scratch base images. - `go version -m ./bin/app` audits any binary's module versions and build settings — use it on artifacts you didn't build. ## Audit checklist ```bash # CI gates present? Inspect workflow files grep -rnE '(go test|race|govulncheck|golangci-lint|staticcheck|gofumpt)' .github/workflows/ Makefile* 2>/dev/null grep -rn 'test -race' . --include='*.yml' --include='*.yaml' --include='Makefile*' || echo 'NO RACE IN CI — HIGH' # Lint config exists and is curated (not empty, not enable-all) ls .golangci.yml .golangci.yaml 2>/dev/null grep -n 'enable-all\|disable-all' .golangci.y*ml 2>/dev/null # Suppression hygiene grep -rn 'nolint' --include='*.go' . | grep -v '//' | head # malformed grep -rnE '//nolint(:\w+)?$' --include='*.go' . # no justification — LOW grep -rn '#nosec' --include='*.go' . # justify each # go.mod hygiene grep -E '^(go|toolchain) ' go.mod # version current? toolchain pinned? grep -A5 '^tool' go.mod # 1.24 tool directives in use? grep -rn 'tools.go' . 2>/dev/null # legacy pattern — migrate (LOW) grep -rn 'go install .*@latest' .github/ Makefile* 2>/dev/null # floating tools — MEDIUM git ls-files | grep 'go.work$' && echo 'go.work committed — check intent' grep -E '^replace' go.mod go list -m <module> # the SELECTED version — `require` is a floor, not a cap (§4) # Test quality grep -rln 'func Test' --include='*_test.go' . | wc -l grep -rn 't.Parallel' --include='*_test.go' . | wc -l grep -rn 'time.Sleep' --include='*_test.go' . # flaky sync — MEDIUM grep -rln 'func Fuzz' --include='*_test.go' . # parsers fuzzed? ls testdata/fuzz 2>/dev/null # crash corpus committed? grep -rn 'testcontainers' go.mod go test -shuffle=on ./... # ordering deps? go test -race -count=3 ./... # Formatting drift gofumpt -l . | head goimports -l . | head # Toolchain & vuln state go version; go env GOTOOLCHAIN govulncheck ./... ``` Severity guide: no `-race`/govulncheck in CI HIGH; library shipping `replace` HIGH; EOL toolchain MEDIUM; floating tool versions MEDIUM; a `require` cited as a version *cap* for a non-leaf dependency MEDIUM (it is a floor — §4); sleep-synced or order-dependent tests MEDIUM; missing table tests / `t.Parallel` / golden review discipline LOW.
-
-
SKILL.md 8 KB
--- name: sota-golang description: State-of-the-art Go engineering rules (2026 baseline, Go 1.25+) that Claude applies when writing new Go code or auditing existing Go code. Covers error handling, interface/package design, goroutine and channel correctness, net/http hardening, security (SQL, exec, path traversal, CSPRNG, TLS, supply chain), performance (pprof, allocations, GC, PGO), and tooling/CI. Trigger keywords - Go, golang, goroutine, channel, go.mod, errgroup, context.Context, pprof, govulncheck, net/http, slog. Use for BOTH building Go services/libraries/CLIs and reviewing or auditing Go codebases. --- # SOTA Go (2026) Expert-level rules for producing and auditing production Go. Baseline language version: Go 1.25+, the oldest release still in security support (Go fixes the last two majors; 1.24 left support with 1.26's release, 2026-02). Feature notes: loop-var scoping from 1.22, `b.Loop`/`os.Root`/tool directives from 1.24, `testing/synctest` and container-aware GOMAXPROCS from 1.25, `errors.AsType` and the default-on Green Tea GC from 1.26 — noted where relevant. Every rule states the *why*; every rules file ends with an audit checklist of grep/vet/lint patterns. ## Purpose Two consumers, one source of truth: - **BUILD mode** — generating new Go code: follow the rules as defaults, not suggestions. Deviate only with an explicit comment justifying it. - **AUDIT mode** — reviewing existing Go code: hunt violations using the audit checklists, classify by severity, report in the finding format below. ## BUILD mode 1. Before writing code, read the rules files relevant to the task (see index). A service touching HTTP + DB + goroutines needs `03`, `04`, `05`. 2. Apply the **top-10 non-negotiables** (below) unconditionally. 3. New modules: `go mod init` with a real module path; since 1.26 it writes the previous minor as the `go` directive (e.g. `go 1.25.0`) for ecosystem compatibility — keep that unless you need newer language features; pin the `toolchain` directive to the current patch release. Add `golangci-lint` config and a CI step running `go vet`, `golangci-lint run`, `go test -race ./...`, `govulncheck ./...` from day one (see `rules/07`). 4. Prefer stdlib. Each dependency must earn its place (see `rules/05` supply chain section). 5. Write table tests alongside the code, not after. Exported behavior gets a test; concurrency gets a `-race` test; parsers get a fuzz target. 6. When generating code that violates a rule for a legitimate reason (e.g. `sync.Pool` complexity, `unsafe`), leave a `// NOTE(sota):` comment explaining the trade-off so auditors don't flag it blind. ## AUDIT mode Work through each relevant rules file's audit checklist against the target repo. Run the listed grep/vet/lint commands; confirm each hit manually before reporting (greps are recall-oriented, expect false positives). ### Severity conventions | Severity | Meaning | Examples | |---|---|---| | **CRITICAL** | Exploitable or guaranteed-incorrect in production | SQL built with `fmt.Sprintf`, command injection via `sh -c`, unbounded goroutine leak on hot path, `InsecureSkipVerify: true`, data race confirmed by `-race` | | **HIGH** | Likely production incident or security weakness | Missing `http.Server` timeouts, no ctx cancellation on blocking goroutine, unchecked integer truncation on attacker input (G115), `resp.Body` never closed, panic for control flow in a server | | **MEDIUM** | Correctness/maintainability hazard, latent bug | Error strings compared with `strings.Contains`, context stored in struct, `time.After` in a loop, map writes without lock under suspected concurrency, missing `errors.Is/As` | | **LOW** | Idiom/perf debt, works but wrong shape | Returning interfaces, `util` package dumps, missing preallocation on hot path, non-table tests, no `t.Parallel` | | **INFO** | Style, doc, or hygiene note | Naming, missing doc comments, gofumpt drift | ### Finding format ``` [SEVERITY] file.go:LINE — short title Rule: rules/NN-name.md § section Evidence: the offending line(s), verbatim Impact: one sentence — what goes wrong, under what conditions Fix: concrete replacement code or action Effort: trivial | small | medium | large ``` Group findings by severity, CRITICAL first. End the audit with: counts per severity, the three highest-leverage fixes, and which checklists were run. ## Rules index | File | Read this when... | |---|---| | `rules/01-errors.md` | Writing/reviewing any error path: wrapping with `%w`, `errors.Is/As`, sentinel vs typed errors, **in-band sentinels (absence encoded as `-1`/`0`/`""`)** and comma-ok, panic/recover policy, error API design for libraries vs apps | | `rules/02-design.md` | Designing packages or APIs: interface placement and size, package layout and `internal/`, naming, zero values, generics restraint, embedding, functional options, `context.Context` discipline | | `rules/03-concurrency.md` | Anything with `go`, `chan`, `sync`, or `select`: goroutine lifecycle ownership, leak catalog, errgroup fan-out, channels-vs-mutex decision, race patterns, worker pools, semaphores, `time.After` traps | | `rules/04-http-services.md` | Building or auditing HTTP servers/clients: all five server timeouts, client timeouts and body hygiene, connection reuse, graceful shutdown, middleware, `slog` structured logging, request-scoped values | | `rules/05-security.md` | Any input crossing a trust boundary: SQL parameterization, `os/exec` safety, path traversal and `os.Root`, integer overflow (G115), output encoding (`html/template`), CSPRNG (`crypto/rand` vs `math/rand`), TLS config, `unsafe`/cgo policy, govulncheck, supply chain and go.sum | | `rules/06-performance.md` | Latency/memory work: pprof workflow, `testing.B` + `b.Loop`, allocation reduction, `strings.Builder`, `sync.Pool` criteria, escape analysis, GOGC/GOMEMLIMIT, PGO | | `rules/07-tooling-ci.md` | Setting up or auditing CI and tests: golangci-lint curated config, staticcheck/gofumpt/vet, table tests, `t.Parallel` correctness, testcontainers, golden files, fuzzing, go.mod hygiene and `tool` directives. **Test *strategy* — suite shape, TDD, doubles, test data, flake policy — lives in `sota-testing`; load it for any build that writes logic. This file owns Go runner mechanics only.** | ## Top-10 non-negotiables 1. **Every error is handled or wrapped with `%w` and context** — never discarded with `_`, never logged-and-ignored on a path that must abort. Compare with `errors.Is`/`errors.As`, never string matching. (`rules/01`) 2. **No panics for control flow.** `panic` is for unreachable programmer errors only; servers recover at goroutine boundaries and log. (`rules/01`) 3. **Every goroutine has an owner and a guaranteed exit path** — tied to a `context.Context`, a closed channel, or a `WaitGroup`/`errgroup` join. If you can't say how it stops, don't start it. (`rules/03`) 4. **`go test -race ./...` in CI, always.** A race detector failure is a CRITICAL finding, not flaky-test noise. (`rules/03`, `rules/07`) 5. **`http.Server` sets `ReadHeaderTimeout`, `ReadTimeout`, `WriteTimeout`, `IdleTimeout`; clients set timeouts and `defer resp.Body.Close()` with drain.** Default zero timeouts are a DoS. (`rules/04`) 6. **SQL only via parameterized queries** (`database/sql` placeholders, pgx, or sqlc-generated code). String-built SQL is CRITICAL, no exceptions for "internal" values. (`rules/05`) 7. **`os/exec` with argv lists, never `sh -c` with interpolated input; file paths validated against a root** (`os.Root` on 1.24+, else `filepath.Clean` + prefix check after resolving symlinks). (`rules/05`) 8. **`context.Context` is the first parameter, flows down, is never stored in a struct**, and carries only request-scoped metadata — never dependencies. (`rules/02`) 9. **Accept interfaces, return structs; define interfaces at the consumer, keep them small.** No premature interfaces "for mocking". (`rules/02`) 10. **`govulncheck ./...` and `golangci-lint` gate CI**; `go.sum` committed; dependencies minimal and justified. (`rules/05`, `rules/07`)
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.