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 one 2026-04-24 audit finding (P2):
- cat-s6-efc7f6f6bd50: 30 strings.Contains(err.Error(), ...) sites
in internal/api/handler/ — brittle to repository-layer message
changes, untyped against the actual failure mode.
Approach (Option B from prompt design notes):
- New typed sentinels in internal/repository/errors.go:
ErrNotFound, ErrForeignKeyConstraint
IsForeignKeyError(err) helper (the only place substring
matching at the lib/pq boundary is allowed; isolates the
DB-driver string knowledge to one function).
- New typed sentinel in internal/domain/errors.go:
ErrValidation (reserved for future per-entity validation
wrappers; not yet used by all handlers).
- 49 sites in internal/repository/postgres/*.go updated to wrap
sql.ErrNoRows-derived errors via fmt.Errorf("...: %w",
repository.ErrNotFound).
- 18 not-found handler sites + 2 FK-constraint handler sites
refactored to errors.Is(err, repository.ErrNotFound) /
repository.IsForeignKeyError(err).
- 23 inline `fmt.Errorf("X not found")` test fixtures across
handler tests rewrapped to wrap repository.ErrNotFound.
- test_utils.go::ErrMockNotFound rewrapped to wrap
repository.ErrNotFound; renewal_policy.go closure docblock
updated to reflect the new convention.
- integration test mockJobRepository.Get wraps repository.ErrNotFound.
CI regression guardrail:
- .github/workflows/ci.yml::"Forbidden strings.Contains(err.Error())
regression guard (S-2)" greps for the three patterns ("not found",
"violates foreign key", "RESTRICT") under internal/api/handler/
and fails the build on regression.
Verification:
- go build ./... — clean
- go vet ./... — clean
- go test ./... -short -count=1 — all packages pass (handler +
repository + service + integration)
- golangci-lint v2.11.4 run ./... — 0 issues
- S-2 guardrail dry-run on post-fix tree → empty (good)
- All sibling guardrails (S-1, G-3, D-1+D-2, B-1, L-1, H-1, C-1, F-1, P-1) pass
Audit findings closed:
- cat-s6-efc7f6f6bd50 (P2)
Deferred follow-ups:
- 6 domain-specific substring patterns still inline in handlers
("cannot approve", "cannot reject", "cannot be parsed",
"no certificates found", "challenge password", "invalid"/
"required" validation chains in profiles + agent_groups). Each
needs its own typed sentinel, scoped per service. Documented
by the S-2 CI guardrail's allowlist for closure-comments only.
- Per-entity not-found sentinels (Option A — ErrCertificateNotFound,
ErrAgentNotFound, etc.) deferred. Generic ErrNotFound covers the
current dispatch needs; per-entity precision would let handlers
return entity-aware error bodies without a domain.Type field,
but not blocking.
M-004 — OCSP issuer binding (composite key):
The OCSP lookup path now binds (issuer_id, serial) as a composite key
rather than resolving by serial alone. CertificateRepository and
RevocationRepository gain GetByIssuerAndSerial methods; ca_operations.go
scopes both lookups by the issuer_id path param. When no managed cert
binds to that (issuer, serial) tuple, GetOCSPResponse constructs an
RFC 6960 §2.2 'unknown' response (CertStatus=2) instead of the prior
default 'good'. Short-lived cert exemption (profile TTL < 1h) is
preserved. Real repo errors (non-sql.ErrNoRows) fail closed with a log.
Regression coverage: internal/service/ca_operations_test.go
- TestCAOperationsSvc_GetOCSPResponse_Unknown_CrossIssuer
- TestCAOperationsSvc_GetOCSPResponse_Unknown_UnknownSerial
M-005 — Discovery Claim/Dismiss actor propagation:
DiscoveryService.ClaimDiscovered and DismissDiscovered now accept an
explicit 'actor string' parameter (propagation pattern mirrors
bulk_revocation.go / revocation_svc.go). The handler layer passes
resolveActor(r.Context()) — the named-key identity established by the
M-002 auth unification — and the service falls back to 'api' (the same
safe sentinel resolveActor uses when no auth context is present) only
when the caller passes an empty string. Never falls back to 'operator'.
Regression coverage: internal/service/discovery_test.go
- TestDiscoveryService_ClaimDiscovered_AuditActor
- TestDiscoveryService_DismissDiscovered_AuditActor
- TestDiscoveryService_ClaimDiscovered_EmptyActorFallsBackToAPI
- TestDiscoveryService_DismissDiscovered_EmptyActorFallsBackToAPI
Each new test asserts event.Actor matches the caller-supplied string (or
'api' on empty input) and explicitly asserts event.Actor != 'operator'
to lock in the historical fix intent.
Files:
internal/api/handler/discovery.go — pass resolveActor(ctx)
internal/api/handler/discovery_handler_test.go — updated call sites
internal/integration/lifecycle_test.go — updated mock wiring
internal/repository/interfaces.go — GetByIssuerAndSerial on
CertificateRepository +
RevocationRepository
internal/repository/postgres/certificate.go — composite key lookup
internal/service/ca_operations.go — (issuer_id, serial) scoping
internal/service/ca_operations_test.go — 2 new M-004 tests
internal/service/discovery.go — actor parameter + 'api' fallback
internal/service/discovery_test.go — 4 new M-005 tests
internal/service/shortlived_test.go — mock signature update
internal/service/testutil_test.go — mock GetByIssuerAndSerial
scanCertificate never queried the certificate_target_mappings junction
table, so Certificate.TargetIDs was always nil on reads. This silently
broke deployment lookups, bulk revocation filters, cert detail pages,
and any code path that iterated TargetIDs to dispatch target work.
Fix:
- Convert scanCertificate to a receiver method (r *CertificateRepository)
so it has access to the DB for the secondary junction query.
- Get(): scan the row, then call r.getTargetIDs(ctx, certID) to populate
TargetIDs with a single targeted query.
- List() and GetExpiringCertificates(): inline the scan loop so we can
collect all certIDs first, then call getTargetIDsForCertificates once
with pq.Array(certIDs) to avoid N+1 round-trips. Build a map and
attach TargetIDs to each certificate in the result set.
- Default TargetIDs to []string{} (not nil) when a cert has no mappings
so JSON marshals as [] rather than null.
Tests:
- New integration test file certificate_targetids_test.go with 5
subtests exercising Get / List / GetExpiringCertificates single
and multi-target cases plus the empty-slice vs nil contract.
- Uses the shared testcontainers-go setupTestDB infrastructure and
skips under 'go test -short' so CI (which excludes ./internal/repository/...
from coverage paths anyway) stays green.
Addresses M-7 from certctl-audit-report.md.
Enforce certificate profile crypto constraints across all 5 issuance paths
(renewal, agent CSR, EST, SCEP). ValidateCSRAgainstProfile() rejects CSRs
with key algorithm/size that don't match profile rules. MaxTTL enforcement
caps certificate validity per issuer connector (Local CA, Vault, step-ca
enforce directly; ACME/DigiCert/Sectigo pass through). Key algorithm and
size are now persisted in certificate_versions for audit compliance.
16 new tests (12 service-layer + 4 Local CA connector). Removes hardcoded
version number from GUI sidebar. Documentation updated across architecture,
features, connectors, and README.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Root cause: certificate_versions.csr_pem is nullable in the schema but
Go code scanned it into a plain string. Used sql.NullString in
ListVersions and GetLatestVersion to handle NULL values correctly.
Also includes: partial update fetch-merge-update pattern to prevent FK
violations, nil directory guard in discovery service, diagnostic slog
logging in handlers, export handler 422 for unparseable PEM, OpenAPI
spec corrections, MCP tool description improvements, and test fixes.
Rewrites the Release Sign-Off section in testing-guide.md to individual
test-level granularity (320 rows) with smoke test results audited and
checked off (121 pass, 5 skip, 194 manual remaining).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implements core revocation infrastructure: POST /api/v1/certificates/{id}/revoke
with all 8 RFC 5280 reason codes, JSON-formatted CRL at GET /api/v1/crl, webhook
and email revocation notifications, best-effort issuer notification, and immutable
revocation audit trail. Includes 48 new tests across service, handler, integration,
and domain layers (600+ total). Fixes 3 pre-existing test bugs (team_test error
matching, agent_group delete status code, team handler per_page validation).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Runtime fixes:
- Fix env var mismatch (CERTCTL_DB_URL → CERTCTL_DATABASE_URL)
- Fix table name mismatches (certificates → managed_certificates, notifications → notification_events)
- Add renewal_policy_id to certificate queries
- Remove non-existent created_at from notification queries
- Add env var fallback for agent CLI flags
- Graceful degradation for missing notifiers/issuers in demo mode
- Copy web/ directory in Dockerfile for dashboard serving
Service layer:
- Implement handler-service interface pattern across all services
- Wire up certificate, agent, job, policy, team, owner, audit, notification services
Documentation:
- Add concepts.md: beginner-friendly guide to TLS, CAs, private keys
- Rewrite quickstart.md with accurate API examples matching actual handlers
- Add demo-advanced.md: interactive demo with cert issuance and automated script
- Update architecture.md with correct table names and connector interfaces
- Update connectors.md to match actual Go interface signatures
- Update demo-guide.md with cross-references to new docs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>