# 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-27 ### Bundle 0.7 (Coverage Audit Closure): cmd/agent key-handling regression coverage — C-008 closed > Phase 0 of the 2026-04-27 coverage audit's closure plan triggered a halt-condition: `cmd/agent/keymem.go`'s two security-critical functions were at 0.0% / 11.1% line coverage despite being defense-in-depth for agent private-key memory hygiene (Bundle 9 / Audit L-002 + L-003 — agent edition). Bundle 0.7 was inserted before Bundle J as mandatory; this entry closes finding **C-008** (`CRTCTL-COVAUDIT-2026-04-27-0034`). `cmd/agent/keymem_test.go` (~510 LoC, 17 top-level test functions) ships: - **`marshalAgentKeyAndZeroize` regression coverage** — happy path, nil-key guard (asserts `onDER` is NOT invoked), upstream error propagation via `errors.Is`, and the **DER-buffer-zeroized-after-return invariant** verified observably: capture the slice header inside `onDER` (sharing the backing array, NOT a deep copy), then assert every byte reads `0x00` after the function returns. Pinned for both the happy path AND the `onDER`-error path. A future refactor that drops the `defer clear(der)` line would break the test even if the simpler assertions still pass. Also adds a "contract violator" defense test: a buggy caller that retains the slice past `onDER` reads zeros, not the private scalar. - **`ensureAgentKeyDirSecure` regression coverage** — 13-row table-driven matrix covering empty/dot/root refuse with documented error wrap, create-with-0700, create-nested-0700, accept-existing-0700 (no-op short-circuit), tighten 0750/0755/0777 to 0700, accept-existing-0500/0400 (owner-only-no-write `mode&0o077 == 0` branch, no chmod), `filepath.Clean` normalization (trailing slash + dot prefix). Plus PathIsAFile (documents current behavior — function chmod's a file path silently, not a correctness bug per current call sites but a hardening candidate filed against any future refactor), Idempotent, Concurrent (`-race` clean across 8 goroutines), Stat/Mkdir/Chmod error-propagation paths (root-required ones `t.Skip` cleanly on non-root CI rather than being absent), and Format-includes-cleaned-path debuggability assertion. - **End-to-end smoke** (`TestKeymem_AgentMainFlowSmoke`) replaying `cmd/agent/main.go`'s composition: `ensureAgentKeyDirSecure` → `marshalAgentKeyAndZeroize`. Coverage delta: | | Pre-Bundle-0.7 | Post-Bundle-0.7 | Gate | Met? | |---|---|---|---|---| | `cmd/agent/keymem.go::marshalAgentKeyAndZeroize` | 0.0% | **85.7%** | ≥85% | ✓ | | `cmd/agent/keymem.go::ensureAgentKeyDirSecure` | 11.1% | **94.4%** | ≥85% | ✓ | | `cmd/agent` overall | 54.3% | **57.7%** (+3.4pp) | (≥75% stretch) | △ partial | Verification: `go test -race -count=3 ./cmd/agent/...` clean (0 races); `gofmt -l` clean; `go vet ./cmd/agent/...` clean; `staticcheck ./cmd/agent/...` clean. The cmd/agent overall ≥75% stretch target is unachievable from a keymem-only test file (the package's bulk — `Run`, `main`, `executeCSRJob`, `executeDeploymentJob`, `verifyAndReportDeployment` — is unrelated to key-handling and dominates the denominator); the remaining lift is tracked as a follow-on cmd/agent flow-test bundle. Audit deliverable updates: `coverage-audit-2026-04-27/findings.yaml` flips C-008 `open` → `closed` with closure note + post-Bundle coverage numbers; `gap-backlog.md` adds a closure log entry and partial-closure note on H-006; `coverage-matrix.md` updates the cmd/agent row from "NOT MEASURED" to 57.7%; `coverage-report.md::Phase 0 Results` appends a Bundle 0.7 closure block with the coverage delta table and pinned-invariant list; `coverage-audit-closure-plan.md` checklist ticks Bundle 0.7. **Bundle J (ACME failure-mode coverage) unblocked.** ### Bundle H (M-029 Drain — AUDIT FULLY CLOSED): 1 audit finding closed across 3 passes > Closes the last remaining open finding from the 2026-04-25 audit. **Score: 54/55 → 55/55 (100%); deferred 7/7 (100%); AUDIT CLOSED.** The M-029 frontend per-page migration backlog was framed by Bundle 8 as incremental ("closes per-PR as each page ships"); Bundle H shipped all three passes end-to-end across 9 merged commits to master rather than spread per-PR. #### Pass 1: useMutation → useTrackedMutation (56 sites, 6 batches) All 56 bare `useMutation` call sites in `web/src/` migrated to the Bundle 8 wrapper, which enforces the M-009 invalidation contract per-site via a discriminated-union type (`invalidates: QueryKey[] | 'noop'`). The wrapper invalidates BEFORE invoking the caller's onSuccess, so user code drops the redundant `qc.invalidateQueries` calls and lets the wrapper's contract become the source of truth. | Batch | Pages migrated | Sites | Commit | |---|---|---|---| | 1 | AgentsPage, CertificatesPage, DigestPage, IssuerDetailPage | 4 | `08ffbad` | | 2 | DashboardPage, DiscoveryPage, NotificationsPage, TargetDetailPage, TargetsPage | 10 | `73c6883` | | 3 | HealthMonitorPage, AgentGroupsPage, JobsPage | 9 | `64c6cd0` | | 4 | OwnersPage, PoliciesPage, ProfilesPage, RenewalPoliciesPage, TeamsPage | 15 | `d5541fe` | | 5 | IssuersPage, NetworkScanPage | 8 | `1c960ff` | | 6 | CertificateDetailPage, OnboardingWizard | 10 | `1baefd4` | Total Pass 1: **56 → 0 bare `useMutation` sites**; 0 → 61 `useTrackedMutation` sites. (Pass 1's count grew net positive because some 5-mutation pages collapsed two `qc.invalidateQueries` calls into one `invalidates` array literal.) After Pass 1 completed, `0266f2b` tightened the `.github/workflows/ci.yml` M-009 guard from a soft-budget gate (`useMutation ≤ invalidations + 5`) to a hard-zero invariant: any bare `useMutation` call in `web/src/` outside `web/src/hooks/useTrackedMutation.ts` (the wrapper itself) fails CI immediately. Strictly stronger than the prior +5 budget; failure mode also improves — operators get the exact `file:line` of the offending bare call instead of a count delta. #### Pass 2: useState pagination → useListParams (1 site, 1 commit) Bundle 8's recon estimate of ~14 list pages turned out to be wrong: **only `CertificatesPage` had real UI-driven pagination state** (`setPage`/`setPerPage` with 7 filter `useState` hooks). Most other pages either fetch filter-dropdown sidecars with hardcoded `per_page` (not pagination) or were already using `useSearchParams` directly. `99f52a6` collapses CertificatesPage's 9 useState hooks (statusFilter, envFilter, issuerFilter, ownerFilter, profileFilter, teamFilter, expiresBefore, sortBy, page, perPage) into a single `useListParams({ pageSize: 50 })` call. Effect: - All 8 filter onChange handlers now call `setFilter('', value)`. - `setFilter` automatically resets page to 1 on every filter / sort change, so the manual `setPage(1)` calls at three sites (team / expires_before / sort) are no longer needed — the F-1 contract is now hook-enforced. - Pagination handler simplified: `onPerPageChange: setPageSize` (the hook drops the page param from the URL when pageSize changes). - All filter / sort / pagination state is now URL-resident (`?filter[status]=Active&page=2&page_size=50`) — deep-link + browser-back correct. The existing CertificatesPage.test.tsx F-1 contract tests (5 cases: getCertificates params for team_id, expires_before, sort, plus page-reset on filter and per_page change) all continue to pass against the new shape. #### Pass 3: Per-page render + XSS-hardening test files for the 14 T-1-deferred pages (3 batches) Each new test: - Renders the page with mock data containing `` payloads in every text-rendering field. - Asserts `document.querySelectorAll('script[data-xss=""]')` is empty post-render. - Asserts `window.__xss_pwned__` stays undefined (no global side-effect from the script body). - Asserts `document.body.textContent` contains the literal `