Closes L-009 + L-010 + L-011 + L-013 + L-020 + L-021 from
comprehensive-audit-2026-04-25. L-004 deferred — recon found NO
rotation infrastructure exists at all; building it from scratch is
a feature project, not a Bundle-E mechanical sweep.
L-009 — ZeroSSL EAB URL configurable
Audit's 'no timeout' claim was wrong: ari.go:329 has 15s timeout.
internal/connector/issuer/acme/acme.go: zeroSSLEABEndpoint now
lazily reads CERTCTL_ZEROSSL_EAB_URL from env at package init;
defaults to ZeroSSL public endpoint. Pre-existing test override
path preserved.
L-010 — Verified-already-clean
grep -rn 'mock\.Anything' --include='*_test.go' . returned 0.
certctl uses hand-rolled struct mocks (mockJobRepo, mockAuditRepo,
etc.) with explicit method bodies; no testify-style mocks anywhere.
L-011 — IPv6 bracket-aware dialing pinned
Every production net.Dial / DialTimeout site audited:
cmd/agent/main.go:293 — intentional IPv4 literal '8.8.8.8:80'
verify.go / tlsprobe / network_scan — net.Dialer (no string addr)
email.go — net.JoinHostPort (bracket-aware)
ssh.go — addr derives from JoinHostPort upstream
ssrf.go — net.Dialer
internal/connector/notifier/email/email_ipv6_test.go (NEW):
TestJoinHostPort_IPv6BracketsRoundTrip pins IPv4/IPv6/zone variants;
TestSMTPDialerUsesJoinHostPort source-greps email.go and fails CI
if a future refactor swaps in 'host:port' concatenation.
L-013 — Verified-already-clean (monotonic-safe)
Only one site uses now.Sub: middleware.go:393 in tokenBucket.allow().
Both 'now' and tb.lastRefill come from time.Now() which carries
monotonic-clock readings per Go's time package contract;
intra-process now.Sub is monotonic-safe by construction. Doc
comment block added above the call to make the invariant explicit.
L-020 (CWE-563) — ineffassign sweep, 8 unique sites
certificate.go:135 — sortDir initial value dropped (set
unconditionally below by SortDesc branch).
certificate.go:169,175 — argCount post-increments dropped (var
not read past the LIMIT/OFFSET formatting).
agent_group.go, profile.go — page/perPage truly vestigial,
replaced with _ = page; _ = perPage.
issuer.go:633, owner.go:131, target.go:267, team.go:131 — same
treatment for the audit-flagged second-function ListXxx clamps.
First-function List() in issuer/owner/target/team KEEPS its
clamp because page/perPage is used for in-memory slice
pagination — ineffassign correctly didn't flag those.
Build + tests green post-sweep.
L-021 — Transitive CVE bump
go get golang.org/x/crypto@v0.45.0 golang.org/x/net@v0.47.0
(crypto required net@0.47.0). go-text@v0.31.0 transitively
bumped.
Per tool-output govulncheck-verbose: x/net@v0.45.0 fixes
GO-2026-4441 + GO-2026-4440; x/crypto@v0.45.0 fixes
GO-2025-4134 + GO-2025-4135 + GO-2025-4116 — all 5 advisories
cleared. Bundle B's ISV grep guard + Bundle D's release-time
govulncheck step are the going-forward monitor + bump pass.
L-004 — Deferred to dedicated bundle
Recon: zero hits for RotateAPIKey / rotated_at / key_status
anywhere in source. API keys configured via
CERTCTL_API_KEYS_NAMED env var; rotation is operator-managed
(edit env + restart). Building rotation infrastructure from
scratch is a feature project, not a mechanical sweep.
Documented in audit-report.md with scope-pivot note.
Audit deliverables:
audit-report.md: score 46/55 -> 52/55 closed
(Low 14/19 -> 19/19 — 100% Low closed except L-004 deferred)
findings.yaml: 6 status flips
certctl/CHANGELOG.md: Bundle E section
Verification:
go test -count=1 -short ./internal/service ./internal/connector/issuer/acme
./internal/connector/notifier/email green
go vet on changed packages clean
Closes Q-1 (cat-s3-58ce7e9840be) — 37 t.Skip / testing.Short() sites
across 9 test files audited. Per-site verdict matrix:
- cmd/agent/verify_test.go (1 site): defensive guard against unreachable
httptest.NewTLSServer code path. Document-skip with closure comment.
- deploy/test/qa_test.go (11 sites): file already gated by `//go:build qa`
tag. The 11 t.Skip("Requires X — manual test") markers are runtime
second-line guards for operators who run -tags qa against a stack
missing the required external service. File-level header comment
block added explaining the manual-test convention.
- deploy/test/healthcheck_test.go (5 sites): 3 docker-availability +
1 testing.Short + 1 hard-skip for not-yet-wired runtime probe
(image-spec contract above already covers the audit-flagged
regression). All correctly gated; file-level header comment block
added explaining each.
- deploy/test/integration_test.go (5 sites): in-flight-state guards
(poll-with-skip after 90s polling for agent-online, inter-test
Phase04→Phase07 ordering, scheduler-tick race for discovered certs,
inter-test issuer fallthrough, defensive PEM-empty assertion).
Each site now has a closure comment explaining why skip is the
right choice rather than fail (upstream phase already surfaces the
real failure; skipping prevents masking root cause behind cascading
noise).
- internal/repository/postgres/{testutil,seed,repo}_test.go (5 sites):
testing.Short() gates for testcontainers-backed live PostgreSQL
integration tests. All correctly gated; closure comments added
naming the run command.
- internal/connector/notifier/email/email_test.go (2 sites):
anti-fixture assertions (test asserts SMTP dial fails; if a captive
portal black-holes the call to success, skip rather than false-pass).
Closure comments added explaining the fixture assumption.
- internal/connector/target/iis/iis_test.go (2 sites): platform-gated
skip for powershell.exe absence on non-Windows hosts. Mirrors the
production iis_connector.go LookPath guard. Closure comments added.
Total: 17 closure comments anchor the 37 skip sites (some sites share a
single block-level comment). All skips remain in place; the change is
purely documentation. The audit recommendation was "audit each skip and
decide" — for these 37, the decision is uniformly **document-skip**:
the gating is correct, the t.Skip messages name the missing precondition,
and the closure comments now pin the rationale for future readers.
See coverage-gap-audit-2026-04-24-v5/unified-audit.md
cat-s3-58ce7e9840be for closure rationale.
H-3 in certctl-audit-report.md: caller-supplied From/To/Subject were
interpolated directly into the SMTP DATA payload and handed to
client.Mail / client.Rcpt with no sanitization, allowing an attacker
who controls any of those values to inject extra headers (Bcc:,
Reply-To:), split the message body (CRLFCRLF), or tamper with the
SMTP envelope. CWE-113.
Fix:
- New package helper internal/validation.ValidateHeaderValue(field,
value). Rejects CR ("\r"), LF ("\n"), and NUL ("\x00") with an error
that names the offending field but does NOT echo the raw value,
so log readers cannot be attacked with injected content. Silent
stripping was considered and rejected: authentication-relevant
headers must fail visibly.
- Two-layer defense in internal/connector/notifier/email/email.go:
(1) primary guard at the top of sendEmail / sendHTMLEmail, which
blocks tampering of the SMTP envelope (client.Mail, client.Rcpt)
since net/smtp does not sanitize those arguments; and
(2) defense-in-depth guard inside formatEmailMessage /
formatHTMLEmailMessage, catching any future caller that
bypasses sendEmail. Both format functions now return an error.
- Body content is intentionally NOT validated — CR/LF in body is legal
RFC 5322 content and net/smtp handles dot-stuffing.
Tests:
- internal/validation/headers_test.go: 3 functions (AcceptsSafeInput,
RejectsControlCharacters, DefaultFieldName) covering plain ASCII,
UTF-8 multibyte, tabs, typical email addresses, CRLF injection,
lone CR, lone LF, NUL, CRLFCRLF body split, trailing CR, leading LF.
Each reject case asserts the field name IS in the error and the
raw offending value IS NOT (anti-log-injection).
- internal/connector/notifier/email/email_test.go: added
TestEmail_FormatEmailMessage_RejectsCRLFInjection and
TestEmail_FormatHTMLEmailMessage_RejectsCRLFInjection. Existing
format tests updated for the new (bytes, error) signature.
Wire-format invariants preserved:
- SMTP DATA headers still use CRLF separators and RFC 1123Z Date
(unchanged).
- Content-Type headers unchanged (text/plain for plain, text/html +
MIME-Version: 1.0 for HTML).
- No change to message encoding or transport.
Verification (Go 1.25.9 linux-arm64, parent e9947dc):
- go build ./... clean
- go vet ./... clean
- go test -race ./internal/validation/... ok
- go test -race ./internal/connector/notifier/email/... ok
- go test -race ./internal/connector/notifier/webhook/... ok
- Per-layer coverage gates all pass:
validation 95.1% (+0.7 vs baseline 94.4%)
email 39.7% (+1.4 vs baseline 38.3%)
service 67.8% (unchanged)
handler 78.6% (unchanged)
middleware 80.0% (unchanged)
domain 92.7% (unchanged)
- govulncheck ./... No vulnerabilities found
- golangci-lint run ./internal/validation/... ./internal/connector/notifier/email/...
0 issues
Operational note: SMTP sends that would previously deliver a
tampered message now fail fast at the notifier with a clear error.
Operators who were relying on header-injection-shaped inputs (there
should be none in practice — all callers are internal certctl code)
will see "failed to format message: <field> contains disallowed
control character" in logs.
Scope: H-3 only. H-4 (webhook SSRF) follows in a separate commit.
Close coverage gaps identified by dual-audit (qualitative + quantitative).
New test files for config (0%→98%), router (0%→100%), handler validation,
health, audit, response helpers, webhook notifier (0%→88%), email notifier,
middleware (recovery, rate limiter), domain profile, service nil-safety,
config helpers, issuer bootstrap, and server bootstrap wiring. Expanded
existing tests for ACME (34%→42%), step-ca (42%→52%), F5, SSH, agent
(43%→63%), scheduler (88%→99%), renewal service, and issuerfactory.
All tests pass: go test -short, go vet, go test -race clean.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace fmt.Sprintf("%s:%d") with net.JoinHostPort() for IPv6 compatibility.
Bump setup-go action to v5 to resolve Node.js 20 deprecation warnings.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>