Closes CodeQL alert #23 (go/request-forgery, Critical) at the
structural level — by telling CodeQL what the runtime code already
does — rather than via per-line `// codeql[...]` suppressions.
Background. internal/service/scep_probe.go:232 calls client.Do(req)
where the request URL is built from operator-supplied input. The
runtime defense is two-layer:
1. validation.ValidateSafeURL(rawURL) at scep_probe.go:86 rejects
non-http(s) schemes, empty hosts, literal-IP hosts in reserved
ranges (loopback, link-local incl. cloud metadata
169.254.169.254, multicast, broadcast, unspecified, IPv6
link-local), and DNS names whose A/AAAA resolution returns any
reserved IP. RFC 1918 is intentionally NOT blocked — see
internal/validation/ssrf.go:17-21 for the design rationale.
2. validation.SafeHTTPDialContext on the http.Transport (line 254)
re-resolves at dial time, applies the same reserved-IP set, and
pins the dial to a literal non-reserved IP — defeating DNS
rebinding between validate and dial.
CodeQL's go/request-forgery query is a syntactic taint-tracking rule
with no built-in knowledge of either validator, so it reports the
finding even though the runtime is correctly defended.
The fix. Add a Models-as-Data (MaD) extension at .github/codeql/
declaring ValidateSafeURL as a request-forgery barrier. The barrier
applies to Argument[0] (the URL parameter), which means the analyzer
treats every URL flowing through ValidateSafeURL as sanitized for the
request-forgery taint set. After this lands:
- Alert #23 dismisses at scep_probe.go:232.
- The same model applies to the second site of this exact shape —
webhook notifier's outbound client.Do (internal/connector/
notifier/webhook/webhook.go) — without per-line annotations.
- Future code that flows operator URLs through ValidateSafeURL
inherits the barrier automatically.
This is the structural fix, not a band-aid:
- Band-aid (rejected): `// codeql[go/request-forgery]` suppression
on line 232. Suppresses one alert; doesn't teach the analyzer.
Webhook notifier would need the same comment when its sibling
rule landing fires.
- Structural (this change): teach CodeQL via models-as-data, in
config checked into the repo, that lives next to the workflow
that uses it. The validators ARE sanitizers in the runtime —
this PR makes the analyzer's model match reality.
Files:
- .github/codeql/qlpack.yml — local model pack manifest, declares
extensionTargets: codeql/go-all: '*'
- .github/codeql/models/request-forgery-sanitizers.model.yml —
barrierModel row for validation.ValidateSafeURL Argument[0] /
request-forgery taint kind / manual provenance
- .github/codeql/codeql-config.yml — references the local pack +
keeps security-and-quality query suite scope
- .github/workflows/codeql.yml — Initialize CodeQL step picks up
config-file: ./.github/codeql/codeql-config.yml. The existing
`queries: security-and-quality` line stays so even if the config
file fails to load, the suite scope is preserved.
- docs/architecture.md::Input Validation and SSRF Protection —
extended to name the egress validators (ValidateSafeURL +
SafeHTTPDialContext) and the call sites (SCEP probe + webhook
notifier). Closes the docs gap surfaced during the audit; the
egress threat-model previously lived only in source comments.
Requires CodeQL CLI ≥ 2.25.2 for the barrierModel extensible
predicate (Go MaD support added 2026-04-21). github/codeql-action@v3
ships a recent enough CLI by default; if a future analysis fails
with "unknown extensible predicate barrierModel", the action's CLI
has regressed below 2.25.2 — pin a newer action version rather than
reverting this pack. Documented inline in qlpack.yml.
References:
- https://codeql.github.com/docs/codeql-language-guides/customizing-library-models-for-go/
- https://github.blog/changelog/2026-04-21-codeql-now-supports-sanitizers-and-validators-in-models-as-data/
Triggered by Reddit feedback (sysadmin user ran Aikido against the
public repo, reported critical command/file-inclusion findings, won't
deploy without seeing scanner-public credibility). Aikido's free tier
gates on OSI-approved licenses, which excludes BSL 1.1; CodeQL is
GitHub-native and free for public repos regardless of license.
Why CodeQL on top of the existing security-deep-scan.yml gosec /
osv-scanner / trivy / ZAP / semgrep / schemathesis / nuclei / testssl:
gosec is single-file pattern matching; CodeQL does interprocedural
taint tracking that catches the same vulnerability classes when input
is laundered through several function calls or struct fields. SARIF
results land in the public Security tab where any operator/security
team auditing certctl can see scan history and triage state without
asking.
Workflow shape
=================
- Triggers: push to master, PR to master, weekly Sun 06:00 UTC
- Matrix: go + javascript-typescript
- Query suite: security-and-quality (security + maintainability,
comparable to Aikido / SonarCloud scope)
- Go version: 1.25.9 (matches ci.yml + release.yml + security-
deep-scan.yml)
- SARIF auto-uploads via codeql-action/analyze@v3 (implicit;
populates Security → Code scanning tab)
- permissions: contents:read + security-events:write + actions:read
- Fail-fast: false (Go and JS analysis run independently)
- Timeout: 30min
Suppressions for known-intentional findings (e.g., SSH connector's
InsecureIgnoreHostKey, ACME script-callout shell-out) get inline
codeql[<rule-id>] comments OR config-pack tweaks in a follow-up
commit, with the threat-model justification cited so external
readers see why the finding is intentional.
Verification
=================
- python3 yaml.safe_load(.github/workflows/codeql.yml): OK
- First run will surface in the Security tab on next push to master
Bundle: security/codeql-baseline