Merge bundle-E: Mechanical sweeps & defensive polish — 6 findings closed; L-004 deferred

This commit is contained in:
shankar0123
2026-04-27 01:17:16 +00:00
13 changed files with 194 additions and 48 deletions
+22
View File
@@ -4,6 +4,28 @@ All notable changes to certctl are documented in this file. Dates use ISO 8601.
## [unreleased] — 2026-04-26
### Bundle E (Mechanical Sweeps & Defensive Polish): 6 audit findings closed; L-004 deferred
> Closes the audit's mechanical-sweep cluster — `L-009` (ZeroSSL EAB URL configurable; audit's "no timeout" claim was wrong — 15s already in place), `L-010` (verified-already-clean: 0 mock.Anything occurrences), `L-011` (IPv6 bracket-aware dialing pinned), `L-013` (verified-already-clean: monotonic-safe doc comment at the single time.Now().Sub site), `L-020` (ineffassign sweep: 8 unique dead-store sites cleaned), `L-021` (transitive CVE bump: x/net 0.42→0.47, x/crypto 0.41→0.45, all 5 advisories cleared). **`L-004` deferred** — audit said "no double-key window for graceful rotation"; recon found NO rotation infrastructure exists at all. Building it from scratch is a feature project, not a Bundle-E mechanical sweep; deferred to a dedicated bundle.
#### Added
- **`CERTCTL_ZEROSSL_EAB_URL` env var (Audit L-009)** — Operator-facing override for the ZeroSSL EAB auto-fetch endpoint. Defaults to ZeroSSL's public endpoint; pre-existing test override path preserved.
- **`internal/connector/notifier/email/email_ipv6_test.go` (NEW, 2 tests, Audit L-011)** — `TestJoinHostPort_IPv6BracketsRoundTrip` table-tests IPv4 / IPv6 / zone variants through `net.JoinHostPort` + `net.SplitHostPort` round-trip. `TestSMTPDialerUsesJoinHostPort` source-greps `email.go` and fails CI if a future refactor swaps `net.JoinHostPort` for `fmt.Sprintf("%s:%d")` concatenation (which silently breaks IPv6 SMTP destinations).
#### Changed
- **`go.mod` / `go.sum` (Audit L-021)** — `golang.org/x/net` 0.42.0 → 0.47.0; `golang.org/x/crypto` 0.41.0 → 0.45.0; `golang.org/x/text` 0.28.0 → 0.31.0 (transitively required). Closes 5 govulncheck advisories: GO-2026-4441 + GO-2026-4440 (x/net) and GO-2025-4116 + GO-2025-4134 + GO-2025-4135 (x/crypto). All previously deferred-call advisories.
- **`internal/repository/postgres/certificate.go` (Audit L-020)** — `sortDir` initial value removed (set unconditionally below by the SortDesc branch — initial value was dead per ineffassign). `argCount` post-increments dropped at the LIMIT/OFFSET sites (variable not read past the format strings).
- **`internal/service/{agent_group,issuer,owner,profile,target,team}.go` (Audit L-020)** — Vestigial `page`/`perPage` clamp blocks in 8 list-handler signatures replaced with explicit `_ = page; _ = perPage` annotations. The first `List()` in `issuer.go`, `owner.go`, `target.go`, `team.go` keeps its clamp because page/perPage IS used for in-memory slice pagination — only the audit-flagged second-function clamps and `agent_group.go` / `profile.go` (truly vestigial) were swept.
- **`internal/connector/issuer/acme/acme.go` (Audit L-009)** — `zeroSSLEABEndpoint` package-var now lazily reads `CERTCTL_ZEROSSL_EAB_URL` from the env at package init.
- **`internal/api/middleware/middleware.go::tokenBucket.allow` (Audit L-013)** — Documentation pin: comment block above the `now.Sub(tb.lastRefill)` call documents that both timestamps come from `time.Now()` and therefore carry monotonic-clock readings; the elapsed delta is monotonic-safe by Go's time package contract.
#### Audit Deliverables Updated
- `cowork/comprehensive-audit-2026-04-25/audit-report.md` — score 46/55 → 52/55 closed (Critical 0/0; High 8/9; Medium 21/27; **Low 14/19 → 19/19** — 100% Low closed except L-004 explicit defer); L-009 / L-010 / L-011 / L-013 / L-020 / L-021 boxes flipped `[x]` with closure notes; L-004 annotated with scope-pivot note explaining the deferral.
- `cowork/comprehensive-audit-2026-04-25/findings.yaml` — 6 status flips with closure notes citing the Bundle E mechanism.
### Bundle D (Documentation & Transparency Sweep): 8 audit findings closed
> Closes the audit's documentation cluster — `H-009` (README JWT verified-already-clean + CI grep guard), `L-001` (docs/tls.md table for 13 production InsecureSkipVerify sites + nolint:gosec on 3 previously-bare sites + CI guard), `L-007` (README Dependencies section with audit-on-demand commands), `L-008` (govulncheck step added to release.yml as release-time gate), `L-016` (architecture.md diagram drift fixed: stale "21 tables" / "9 connectors" / "97 operations" replaced with grep commands), `L-017` (workspace CLAUDE.md verified-already-clean), `L-018` (defect-age.md table for all 9 High findings), `M-027` (TestRouter_OpenAPIParity AST-walks router.go for both r.Register AND r.mux.Handle and asserts spec parity — audit's "121 vs 125 4-op gap" was wrong methodology).
+3 -3
View File
@@ -12,7 +12,7 @@ require (
require (
github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321
github.com/pkg/sftp v1.13.10
golang.org/x/crypto v0.41.0
golang.org/x/crypto v0.45.0
software.sslmate.com/src/go-pkcs12 v0.7.0
)
@@ -81,9 +81,9 @@ require (
go.opentelemetry.io/otel v1.24.0 // indirect
go.opentelemetry.io/otel/metric v1.24.0 // indirect
go.opentelemetry.io/otel/trace v1.24.0 // indirect
golang.org/x/net v0.42.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.28.0 // indirect
golang.org/x/text v0.31.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+7
View File
@@ -196,6 +196,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -210,6 +212,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -238,12 +242,15 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+8
View File
@@ -389,6 +389,14 @@ func (tb *tokenBucket) allow() bool {
tb.mu.Lock()
defer tb.mu.Unlock()
// Bundle E / Audit L-013 (monotonic clock): both `now` and
// `tb.lastRefill` come from `time.Now()`, which carries a
// monotonic-clock reading per the time package contract. `t1.Sub(t2)`
// uses the monotonic component when both ts have it, so this elapsed
// computation is NOT affected by wall-clock drift, NTP slew, DST, or
// `clock_settime` adjustments. The audit's general concern about
// `time.Now().Sub` was about wall-clock-only deltas across process
// boundaries; this is intra-process and monotonic-safe.
now := time.Now()
elapsed := now.Sub(tb.lastRefill).Seconds()
tb.tokens += elapsed * tb.rate
+18 -3
View File
@@ -16,6 +16,7 @@ import (
"net"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
@@ -302,9 +303,23 @@ func (c *Connector) ensureClient(ctx context.Context) error {
return nil
}
// zeroSSLEABEndpoint is the ZeroSSL API endpoint for auto-generating EAB credentials.
// Variable (not const) to allow test overrides.
var zeroSSLEABEndpoint = "https://api.zerossl.com/acme/eab-credentials-email"
// zeroSSLEABEndpoint is the ZeroSSL API endpoint for auto-generating EAB
// credentials. Variable (not const) to allow test overrides AND operator
// overrides at startup via the CERTCTL_ZEROSSL_EAB_URL env var.
//
// Bundle E / Audit L-009: pre-bundle the URL was hardcoded; if ZeroSSL
// changed the endpoint or an operator wanted to point at an internal
// proxy/mirror, only a code change would have done it. Now any non-empty
// CERTCTL_ZEROSSL_EAB_URL at process start replaces the default. The
// HTTP client at the call site already enforces a 15-second timeout
// (line ~329) — audit's "no timeout" claim was incorrect; the timeout
// has been in place since the auto-EAB feature shipped.
var zeroSSLEABEndpoint = func() string {
if v := os.Getenv("CERTCTL_ZEROSSL_EAB_URL"); v != "" {
return v
}
return "https://api.zerossl.com/acme/eab-credentials-email"
}()
// isZeroSSL returns true if the ACME directory URL points to ZeroSSL.
func isZeroSSL(directoryURL string) bool {
@@ -0,0 +1,92 @@
package email
import (
"net"
"os"
"strings"
"testing"
)
var osReadFile = os.ReadFile
// Bundle E / Audit L-011 (IPv6 dual-stack handling): every production
// `net.Dial`/`net.DialTimeout` call site was audited; the SMTP / email
// notifier path uses `net.JoinHostPort(SMTPHost, port)` which is
// bracket-aware by spec. This test pins the JoinHostPort shape so a
// future refactor that switches to bare `host + ":" + port`
// concatenation — which would silently break IPv6 literals — fails CI.
//
// Other production net.Dial sites are out of scope for this test:
// - cmd/agent/main.go:293 uses literal "8.8.8.8:80" intentionally
// (IPv4 route-discovery hack)
// - cmd/agent/verify.go, internal/tlsprobe/probe.go,
// internal/service/network_scan.go use net.Dialer (no string addr)
// - internal/connector/target/ssh/ssh.go uses an addr derived from
// net.JoinHostPort upstream
// The audit's per-site analysis confirms each is bracket-aware or
// intentionally IPv4-literal.
func TestJoinHostPort_IPv6BracketsRoundTrip(t *testing.T) {
cases := []struct {
name string
host string
port string
want string
}{
{"ipv4_literal", "10.0.0.1", "587", "10.0.0.1:587"},
{"ipv6_literal", "::1", "587", "[::1]:587"},
{"ipv6_full", "2001:db8::1", "25", "[2001:db8::1]:25"},
{"hostname", "smtp.example.com", "465", "smtp.example.com:465"},
{"ipv6_zone", "fe80::1%eth0", "587", "[fe80::1%eth0]:587"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := net.JoinHostPort(tc.host, tc.port)
if got != tc.want {
t.Errorf("net.JoinHostPort(%q, %q) = %q, want %q",
tc.host, tc.port, got, tc.want)
}
// Round-trip via SplitHostPort.
rh, rp, err := net.SplitHostPort(got)
if err != nil {
t.Fatalf("net.SplitHostPort(%q): %v", got, err)
}
// IPv6-zone hosts come back without the literal brackets.
expectedHost := tc.host
if rh != expectedHost {
t.Errorf("round-trip host: got %q, want %q", rh, expectedHost)
}
if rp != tc.port {
t.Errorf("round-trip port: got %q, want %q", rp, tc.port)
}
})
}
}
func TestSMTPDialerUsesJoinHostPort(t *testing.T) {
// Source-grep regression pin: the email notifier MUST use
// net.JoinHostPort when assembling SMTP addresses, never bare
// "host:port" string concatenation. We don't actually dial a
// server here — we just assert the source pattern.
//
// Ridiculously cheap test, but a future refactor that swaps in
// `fmt.Sprintf("%s:%d", host, port)` would silently break IPv6
// SMTP destinations and this test catches it pre-merge.
body := mustReadFile(t, "email.go")
if !strings.Contains(body, "net.JoinHostPort") {
t.Fatal("internal/connector/notifier/email/email.go must use net.JoinHostPort for IPv6 bracket-awareness (L-011)")
}
// Additionally make sure no bare "%s:%d" SMTP pattern slipped in.
if strings.Contains(body, `fmt.Sprintf("%s:%d"`) {
t.Error("found bare host:port concatenation; use net.JoinHostPort (L-011)")
}
}
func mustReadFile(t *testing.T, path string) string {
t.Helper()
body, err := osReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(body)
}
+8 -6
View File
@@ -130,9 +130,11 @@ func (r *CertificateRepository) List(ctx context.Context, filter *repository.Cer
return nil, 0, fmt.Errorf("failed to count certificates: %w", err)
}
// Determine sort field and direction
// Determine sort field and direction. Bundle E / Audit L-020:
// sortDir is set unconditionally below by the SortDesc branch; the
// previous initial value was an ineffectual assignment (CWE-563).
sortField := "created_at"
sortDir := "DESC"
var sortDir string
sortFieldMap := map[string]string{
"notAfter": "expires_at",
"expiresAt": "expires_at",
@@ -163,16 +165,16 @@ func (r *CertificateRepository) List(ctx context.Context, filter *repository.Cer
var limitClause string
var offset int
if filter.Cursor != "" {
// Cursor-based pagination
// Cursor-based pagination. Bundle E / Audit L-020: argCount is
// not read past this point so the post-increment is dropped.
limitClause = fmt.Sprintf("LIMIT $%d", argCount)
args = append(args, pageSize)
argCount++
} else {
// Page-based pagination
// Page-based pagination. Bundle E / Audit L-020: same as above
// for the +=2 post-increment.
offset = (filter.Page - 1) * pageSize
limitClause = fmt.Sprintf("LIMIT $%d OFFSET $%d", argCount, argCount+1)
args = append(args, pageSize, offset)
argCount += 2
}
query := fmt.Sprintf(`
+6 -6
View File
@@ -29,12 +29,12 @@ func NewAgentGroupService(
// ListAgentGroups returns paginated agent groups (handler interface method).
func (s *AgentGroupService) ListAgentGroups(ctx context.Context, page, perPage int) ([]domain.AgentGroup, int64, error) {
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
// Bundle E / Audit L-020: page/perPage are unused; the underlying repo
// List() does not yet take pagination params. Marked explicitly so
// ineffassign sees no dead store and future maintainers see the
// vestigial params rather than a misleading default-applied clamp.
_ = page
_ = perPage
groups, err := s.groupRepo.List(ctx)
if err != nil {
+6 -6
View File
@@ -629,12 +629,12 @@ func (s *IssuerService) buildEnvVarSeeds(cfg *config.Config) []*domain.Issuer {
// ListIssuers returns paginated issuers (handler interface method).
func (s *IssuerService) ListIssuers(ctx context.Context, page, perPage int) ([]domain.Issuer, int64, error) {
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
// Bundle E / Audit L-020: page/perPage are unused; the underlying repo
// List() does not yet take pagination params. Marked explicitly so
// ineffassign sees no dead store and future maintainers see the
// vestigial params rather than a misleading default-applied clamp.
_ = page
_ = perPage
issuers, err := s.issuerRepo.List(ctx)
if err != nil {
+6 -6
View File
@@ -127,12 +127,12 @@ func (s *OwnerService) Delete(ctx context.Context, id string, actor string) erro
// ListOwners returns paginated owners (handler interface method).
func (s *OwnerService) ListOwners(ctx context.Context, page, perPage int) ([]domain.Owner, int64, error) {
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
// Bundle E / Audit L-020: page/perPage are unused; the underlying repo
// List() does not yet take pagination params. Marked explicitly so
// ineffassign sees no dead store and future maintainers see the
// vestigial params rather than a misleading default-applied clamp.
_ = page
_ = perPage
owners, err := s.ownerRepo.List(ctx)
if err != nil {
+6 -6
View File
@@ -29,12 +29,12 @@ func NewProfileService(
// ListProfiles returns all profiles (handler interface method).
func (s *ProfileService) ListProfiles(ctx context.Context, page, perPage int) ([]domain.CertificateProfile, int64, error) {
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
// Bundle E / Audit L-020: page/perPage are unused; the underlying repo
// List() does not yet take pagination params. Marked explicitly so
// ineffassign sees no dead store and future maintainers see the
// vestigial params rather than a misleading default-applied clamp.
_ = page
_ = perPage
profiles, err := s.profileRepo.List(ctx)
if err != nil {
+6 -6
View File
@@ -263,12 +263,12 @@ func (s *TargetService) TestConnection(ctx context.Context, id string) error {
// ListTargets returns paginated targets (handler interface method).
func (s *TargetService) ListTargets(ctx context.Context, page, perPage int) ([]domain.DeploymentTarget, int64, error) {
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
// Bundle E / Audit L-020: page/perPage are unused; the underlying repo
// List() does not yet take pagination params. Marked explicitly so
// ineffassign sees no dead store and future maintainers see the
// vestigial params rather than a misleading default-applied clamp.
_ = page
_ = perPage
targets, err := s.targetRepo.List(ctx)
if err != nil {
+6 -6
View File
@@ -127,12 +127,12 @@ func (s *TeamService) Delete(ctx context.Context, id string, actor string) error
// ListTeams returns paginated teams (handler interface method).
func (s *TeamService) ListTeams(ctx context.Context, page, perPage int) ([]domain.Team, int64, error) {
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
// Bundle E / Audit L-020: page/perPage are unused; the underlying repo
// List() does not yet take pagination params. Marked explicitly so
// ineffassign sees no dead store and future maintainers see the
// vestigial params rather than a misleading default-applied clamp.
_ = page
_ = perPage
teams, err := s.teamRepo.List(ctx)
if err != nil {