# Changelog All notable changes to certctl are documented in this file. Dates use ISO 8601. Versions follow [Semantic Versioning](https://semver.org/). ## [unreleased] — 2026-04-25 ### Bundle 4 (EST/SCEP Hardening): 3 audit findings closed > First closure bundle from the 2026-04-25 comprehensive audit > (`cowork/comprehensive-audit-2026-04-25/`). Hardens the only attack surface > reachable by an anonymous network attacker in certctl: the unauthenticated > EST + SCEP enrollment endpoints. #### Added - **PKCS#7 fuzz targets (Audit H-004)** — 4 new `Fuzz*` test targets covering both the network-reachable hand-rolled ASN.1 parser (`internal/api/handler/scep.go::extractCSRFromPKCS7` + `parseSignedDataForCSR`) and defense-in-depth on the PKCS#7 encoder helpers (`internal/pkcs7/PEMToDERChain`, `ASN1EncodeLength`). Local smoke runs (~2M execs across all 4) found zero panics. Run via `go test -run='^$' -fuzz=Fuzz -fuzztime=10m`. CWE-1287 + CWE-674 + CWE-770. - **EST TLS transport pre-conditions (Audit M-021)** — `internal/api/handler/est.go::verifyESTTransport` enforces `r.TLS != nil`, `HandshakeComplete`, and TLS version ≥ 1.2 before any state mutation in `SimpleEnroll` and `SimpleReEnroll`. Defense-in-depth at the EST trust boundary; the full RFC 7030 §3.2.3 channel binding only applies when EST mTLS is in use, which certctl does not currently support. RFC 9266 (TLS 1.3 `tls-exporter`) and EST mTLS support documented as deferred follow-ups. - **EST/SCEP issuer-binding startup validation (Audit L-005)** — `cmd/server/main.go::preflightEnrollmentIssuer` calls `GetCACertPEM(ctx)` at startup with a 10-second timeout. Pre-Bundle-4, an operator binding `CERTCTL_EST_ISSUER_ID` to an ACME / DigiCert / Sectigo / etc. issuer would boot successfully and only fail at first `/est/cacerts` request (those issuer types return explicit error from `GetCACertPEM`). Post-Bundle-4: the server fails-loud at startup with the connector's own error message + `os.Exit(1)`. #### Tests - `internal/api/handler/est_transport_test.go` — 5 table cases for `verifyESTTransport` - `cmd/server/preflight_test.go` — `TestPreflightEnrollmentIssuer` covering nil-connector / error-from-issuer / empty-PEM / valid cases - `internal/api/handler/scep_fuzz_test.go` — `FuzzExtractCSRFromPKCS7`, `FuzzParseSignedDataForCSR` - `internal/pkcs7/pkcs7_fuzz_test.go` — `FuzzPEMToDERChain`, `FuzzASN1EncodeLength` - `internal/api/handler/est_handler_test.go` (modified) — 7 POST sites stamp `r.TLS` to satisfy the new transport pre-condition - `internal/integration/negative_test.go` (modified) — `setupTestServer` wraps the test handler with a fake-TLS-state injector #### Why this matters Pre-Bundle-4, certctl exposed an unauthenticated network attack surface (EST simpleenroll / SCEP PKCSReq) that called into a hand-rolled ASN.1 parser with no fuzz coverage and no TLS pre-conditions. An attacker could submit crafted PKCS#7 envelopes targeting parser bugs; replay CSRs across TLS sessions without channel-binding catching it; or cause silent runtime failure if operator misconfigured EST/SCEP issuer wiring (no startup validation). Bundle 4 closes all three. ### T-1 + Q-1: Final-tail closure of the 2026-04-24 audit — 47/47 (100%) > The last two findings from the v5 unified audit closed in two independent > sub-bundles. After this lands, the `coverage-gap-audit-2026-04-24-v5/` > folder is officially closed; future audits start a new dated folder. ### Added (T-1) - **8 new Vitest test files for high-leverage pages** — `web/src/pages/CertificatesPage.test.tsx` (F-1 filter+pagination contract: team_id, expires_before, sort param wiring, page-reset on filter change), `PoliciesPage.test.tsx` (D-006/D-008 TitleCase severity contract, toggle-enabled inversion, delete confirm), `IssuersPage.test.tsx` (D-2 phantom-trim + B-1 EditIssuer rename-only), `TargetsPage.test.tsx` (D-2 phantom-trim status derivation), `AgentsPage.test.tsx` + `AgentDetailPage.test.tsx` (D-2 phantom-trim + heartbeatStatus undefined-fallback + lazy retired tab + registered_at row), `OwnersPage.test.tsx` + `TeamsPage.test.tsx` + `AgentGroupsPage.test.tsx` (B-1 Edit modals call updateOwner/updateTeam/updateAgentGroup with right payload), `RenewalPoliciesPage.test.tsx` (B-1 brand-new page; PolicyFormModal create + edit modes; alert_thresholds_days display), `DiscoveryPage.test.tsx` (I-2 dismiss flow; status filter wiring). Total ~35 new Vitest cases lifting page-level coverage from 3/28 (11%) → 14/28 (50%). - **`.github/workflows/ci.yml::Frontend page-coverage regression guard (T-1)`** — blocks new pages from landing without a sibling `.test.tsx` unless added to a 14-name deferred allowlist with one-line "why deferred" justifications (drill-down views covered transitively, read-only timelines, etc.). Each allowlist entry is a TODO with a name attached; future commits remove entries as they ship the corresponding test. ### Changed (Q-1) - **37 skipped-test sites across 9 files now have closure comments** pinning the rationale: `cmd/agent/verify_test.go` (defensive httptest guard), `deploy/test/qa_test.go` (file-level header explaining the `//go:build qa` tag + 11 manual-test markers), `deploy/test/healthcheck_test.go` (file-level header explaining 5 docker / testing.Short / not-yet-wired skips), `deploy/test/integration_test.go` (5 in-flight-state guards: poll-with-skip after 90s, inter-test ordering, scheduler-tick race, defensive PEM-empty fallback — each comment explains why skip is preferable to fail), `internal/repository/postgres/{testutil,seed,repo}_test.go` (5 testing.Short gates for testcontainers), `internal/connector/notifier/email/email_test.go` (2 anti-fixture assertions), `internal/connector/target/iis/iis_test.go` (2 platform-gated for non-Windows). No tests were re-enabled, deleted, or restructured — the closure is purely documentation. All skips were correctly gated; the audit recommendation was "audit each skip and decide", and the decision is uniformly **document-skip**. ### H-1: Security hardening trio — closed end-to-end > Three 2026-04-24 audit findings (all P2) that together complete the HTTPS-Everywhere security baseline. The audit flagged: (1) the unauth surface (EST RFC 7030, SCEP, PKI CRL/OCSP, /health, /ready) accepted arbitrary-size request bodies because the `noAuthHandler` middleware chain was missing the `bodyLimitMiddleware` that the authed `apiHandler` chain has; (2) zero security headers (CSP, HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy) were emitted on any response — enabling clickjacking, MIME-sniffing, and untrusted-origin resource loads against the dashboard and API; (3) `CERTCTL_CONFIG_ENCRYPTION_KEY` was accepted with any non-empty value, including a single character — PBKDF2-SHA256 with 100k rounds does not compensate for low-entropy passphrases at scale (CWE-916 / CWE-329). ### Breaking Changes **Operators with low-entropy `CERTCTL_CONFIG_ENCRYPTION_KEY` will fail to start after upgrade.** Pre-H-1 the field accepted any non-empty string. Post-H-1 it requires ≥32 bytes (e.g. `openssl rand -base64 32`). The startup error names the offending env var, the actual length, the required minimum, and the canonical generation command. Empty (`""`) remains accepted — the existing fail-closed sentinel `crypto.ErrEncryptionKeyRequired` triggers downstream when an empty key tries to encrypt or decrypt. Operators using a short passphrase must rotate before the upgrade. ### Added - **`internal/api/middleware/securityheaders.go`** (new) — `SecurityHeaders` middleware applies HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, and a conservative Content-Security-Policy on every response. Defaults via `SecurityHeadersDefaults()` are: `Strict-Transport-Security: max-age=31536000; includeSubDomains`, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer-when-downgrade`, and `Content-Security-Policy: default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'`. Operators behind a customising reverse proxy can override per-header by setting any field of the config struct to the empty string (omits that header). - **`bodyLimitMiddleware` wired into `noAuthHandler`** in `cmd/server/main.go`. Same default cap (1 MB, configurable via `CERTCTL_MAX_BODY_SIZE`), same 413 response on overflow. Pre-H-1 only the authed surface had this protection. - **`securityHeadersMiddleware` wired into BOTH chains** (`middlewareStack` for authed routes; `noAuthHandler` for unauth routes). Applied before the audit middleware so headers reach 4xx/5xx responses too — critical for security posture (an attacker probing for misconfiguration sees the same headers on a 401 as on a 200). - **`CERTCTL_CONFIG_ENCRYPTION_KEY` length validation** in `internal/config/config.go::Validate()` — rejects keys shorter than 32 bytes with a structured error naming the actual length, the required minimum, and the canonical generation command. Empty keys remain accepted (downstream fail-closed sentinel handles it). - **Tests:** `internal/api/middleware/securityheaders_test.go` (4 cases — defaults present, empty disables single header, override applied, headers on 4xx/5xx). `internal/config/config_test.go` adds 5 cases for the encryption-key length check (empty accepted, 1-byte rejected, 31-byte rejected at boundary, 32-byte accepted, 44-byte realistic operator key accepted). ### Audit findings closed - `cat-s5-4936a1cf0118` (P2, EST/SCEP/PKI unauth endpoints bypass `http.MaxBytesReader`) - `cat-s11-missing_security_headers` (P2, no CSP / HSTS / X-Frame-Options on responses) - `cat-r-encryption_key_no_length_validation` (P2, encryption key accepted with zero entropy validation) ### Known follow-ups (deferred from H-1 scope) A weak-key dictionary check (reject `password123`, common ASCII patterns) is deferred — adds operational friction with low marginal entropy gain at the 32-byte minimum. CSP `'unsafe-inline'` for styles is required because Tailwind via Vite injects per-component `