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:
- Discovers Go modules dynamically —
find . -name go.modso new modules pick up the gate automatically. govulncheckper module — runsgolang.org/x/vuln/cmd/govulncheckagainst each./.... Govulncheck is reachability-aware: it only fires if the vulnerable code path is actually reachable from the entry point, dramatically reducing false positives.gosecper module — runssecurego/gosec@masterwith 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: falsematrix + 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-lintinstead of separate gosec — bundles many linters includinggosec,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)