Skip to content

ADR-0077: Go security scanning — govulncheck + gosec

  • Status: Accepted
  • Date: 2026-05-23
  • Driver: 2026-05-23 best-practices review §2.8.3 — CodeQL only scans javascript-typescript, leaving Go modules without static analysis for known-CVEs in stdlib/dependencies or insecure idioms.

Context

.github/workflows/codeql.yml matrix is language: [javascript-typescript] only. The Go side has:

  • go vet ./... per-module (idiomatic-bug catcher; no security focus)
  • gitleaks (secret scanning; not source-code analysis)
  • trivy (container image scanning; runs against built images, not source)
  • scorecard (supply-chain meta-analysis; not per-PR source analysis)

Gap: when crypto/... or net/http ships a CVE patch (e.g. CVE-2024-24790 net/netip IPv4-in-IPv6 mapping bug, CVE-2024-34155 go/parser panic), nothing in our CI pipeline tells us we're still on the vulnerable line.

The 2026-05-23 best-practices review §2.8.3 flagged this as the most addressable tier-2 security gap.

Decision

Introduce a new workflow .github/workflows/go-security.yml that:

  1. Discovers Go modules dynamicallyfind . -name go.mod so new modules pick up the gate automatically.
  2. govulncheck per module — runs golang.org/x/vuln/cmd/govulncheck against each ./.... Govulncheck is reachability-aware: it only fires if the vulnerable code path is actually reachable from the entry point, dramatically reducing false positives.
  3. gosec per module — runs securego/gosec@master with SARIF output uploaded to the GitHub Security tab. SARIF integration means findings show up in the PR's "Files changed" view + the Security tab dashboard, not just a log line.

Phased rollout

Phase When Action
W0 — Now This PR Workflow runs warn-only on every PR (continue-on-error on govulncheck; -no-fail on gosec). Findings visible in logs + Security tab but do NOT block merge.
W1 — +4 weeks Once baseline is stable Promote to required_status_checks for main. Convert govulncheck from || true to fail-on-finding.
W2 — +8 weeks After noise tuning Re-enable G104 in gosec (we exclude it initially because the codebase has many idiomatic _ = f.Close() patterns that gosec misclassifies).
W3 — +12 weeks After supply-chain hygiene wave Add weekly scheduled run (every Sunday 04:23 UTC) that catches CVEs published since last PR activity.

Weekly scheduled run is already wired in this PR (cron: "23 4 * * 0") — it just doesn't gate anything until W1.

Why not put this inside _setup-go.yml (ADR-0074)?

_setup-go.yml is a reusable workflow for the test/build path. Adding security scanning there would: * Couple test failures with security gate failures (harder to triage) * Slow down every PR by ~30s × number-of-modules * Mix concerns — _setup-go.yml is "is the code correct?", this gate is "is the code safe?"

Separate workflows = independent CI concurrency groups, separate fail signals, clearer post-mortem.

Consequences

Positive

  • First Go-side CVE awareness in the CI pipeline. Closes the §2.8.3 audit gap.
  • SARIF integration makes findings visible in the PR file diff and the GitHub Security tab, not buried in logs.
  • Reachability-aware — govulncheck won't false-positive on imports that exist but are never called from main().
  • Self-extending — find-based module discovery means new modules (e.g. when a future feature adds pkg/foo) pick up the gate without workflow edits.

Negative

  • Adds ~5 minutes to the PR CI elapsed time (37 modules × ~8s govulncheck + ~6s gosec, parallel). Mitigated by fail-fast: false matrix + concurrency-group cancel-in-progress.
  • gosec G104 over-reports in our codebase (_ = f.Close() is idiomatic). Excluded initially; re-enabled in wave 2.
  • Warn-only first phase means findings are visible but not blocking — operator discipline required to actually look at the Security tab.

Alternatives considered

  • golangci-lint instead of separate gosec — bundles many linters including gosec, staticcheck, errcheck. Rejected initially because the bundled gosec doesn't ship SARIF output. Re-evaluate in wave 3.
  • CodeQL with Go matrix entry — CodeQL supports Go. Rejected because (a) CodeQL Go analysis is heavier than govulncheck+gosec for the same coverage, (b) CodeQL fires once per push not per-module, so signal is mixed across modules.
  • Manual annual audit — not a control; doesn't scale; doesn't survive personnel changes.

See also

  • .github/workflows/go-security.yml — the workflow this ADR introduces
  • .github/workflows/codeql.yml — JS/TS side, complemented (not replaced) by this
  • .github/workflows/scorecard.yml — supply-chain meta, complementary
  • ADR-0074 — reusable Go CI workflow + coverage gate (sibling pattern)