mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 13:51:36 +00:00
Bundle E: Mechanical sweeps & defensive polish — 6 findings closed; L-004 deferred
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
This commit is contained in:
@@ -4,6 +4,28 @@ All notable changes to certctl are documented in this file. Dates use ISO 8601.
|
|||||||
|
|
||||||
## [unreleased] — 2026-04-26
|
## [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
|
### 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).
|
> 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).
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321
|
github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321
|
||||||
github.com/pkg/sftp v1.13.10
|
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
|
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 v1.24.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.24.0 // indirect
|
go.opentelemetry.io/otel/metric v1.24.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace 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/oauth2 v0.34.0 // indirect
|
||||||
golang.org/x/sys v0.40.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
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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.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 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
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.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||||
golang.org/x/mod v0.3.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=
|
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.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 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
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 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
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=
|
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.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 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||||
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
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.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.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.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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
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.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 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44=
|
||||||
golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
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=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
|||||||
@@ -389,6 +389,14 @@ func (tb *tokenBucket) allow() bool {
|
|||||||
tb.mu.Lock()
|
tb.mu.Lock()
|
||||||
defer tb.mu.Unlock()
|
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()
|
now := time.Now()
|
||||||
elapsed := now.Sub(tb.lastRefill).Seconds()
|
elapsed := now.Sub(tb.lastRefill).Seconds()
|
||||||
tb.tokens += elapsed * tb.rate
|
tb.tokens += elapsed * tb.rate
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -302,9 +303,23 @@ func (c *Connector) ensureClient(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// zeroSSLEABEndpoint is the ZeroSSL API endpoint for auto-generating EAB credentials.
|
// zeroSSLEABEndpoint is the ZeroSSL API endpoint for auto-generating EAB
|
||||||
// Variable (not const) to allow test overrides.
|
// credentials. Variable (not const) to allow test overrides AND operator
|
||||||
var zeroSSLEABEndpoint = "https://api.zerossl.com/acme/eab-credentials-email"
|
// 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.
|
// isZeroSSL returns true if the ACME directory URL points to ZeroSSL.
|
||||||
func isZeroSSL(directoryURL string) bool {
|
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)
|
||||||
|
}
|
||||||
@@ -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)
|
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"
|
sortField := "created_at"
|
||||||
sortDir := "DESC"
|
var sortDir string
|
||||||
sortFieldMap := map[string]string{
|
sortFieldMap := map[string]string{
|
||||||
"notAfter": "expires_at",
|
"notAfter": "expires_at",
|
||||||
"expiresAt": "expires_at",
|
"expiresAt": "expires_at",
|
||||||
@@ -163,16 +165,16 @@ func (r *CertificateRepository) List(ctx context.Context, filter *repository.Cer
|
|||||||
var limitClause string
|
var limitClause string
|
||||||
var offset int
|
var offset int
|
||||||
if filter.Cursor != "" {
|
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)
|
limitClause = fmt.Sprintf("LIMIT $%d", argCount)
|
||||||
args = append(args, pageSize)
|
args = append(args, pageSize)
|
||||||
argCount++
|
|
||||||
} else {
|
} 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
|
offset = (filter.Page - 1) * pageSize
|
||||||
limitClause = fmt.Sprintf("LIMIT $%d OFFSET $%d", argCount, argCount+1)
|
limitClause = fmt.Sprintf("LIMIT $%d OFFSET $%d", argCount, argCount+1)
|
||||||
args = append(args, pageSize, offset)
|
args = append(args, pageSize, offset)
|
||||||
argCount += 2
|
|
||||||
}
|
}
|
||||||
|
|
||||||
query := fmt.Sprintf(`
|
query := fmt.Sprintf(`
|
||||||
|
|||||||
@@ -29,12 +29,12 @@ func NewAgentGroupService(
|
|||||||
|
|
||||||
// ListAgentGroups returns paginated agent groups (handler interface method).
|
// ListAgentGroups returns paginated agent groups (handler interface method).
|
||||||
func (s *AgentGroupService) ListAgentGroups(ctx context.Context, page, perPage int) ([]domain.AgentGroup, int64, error) {
|
func (s *AgentGroupService) ListAgentGroups(ctx context.Context, page, perPage int) ([]domain.AgentGroup, int64, error) {
|
||||||
if page < 1 {
|
// Bundle E / Audit L-020: page/perPage are unused; the underlying repo
|
||||||
page = 1
|
// List() does not yet take pagination params. Marked explicitly so
|
||||||
}
|
// ineffassign sees no dead store and future maintainers see the
|
||||||
if perPage < 1 {
|
// vestigial params rather than a misleading default-applied clamp.
|
||||||
perPage = 50
|
_ = page
|
||||||
}
|
_ = perPage
|
||||||
|
|
||||||
groups, err := s.groupRepo.List(ctx)
|
groups, err := s.groupRepo.List(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -629,12 +629,12 @@ func (s *IssuerService) buildEnvVarSeeds(cfg *config.Config) []*domain.Issuer {
|
|||||||
|
|
||||||
// ListIssuers returns paginated issuers (handler interface method).
|
// ListIssuers returns paginated issuers (handler interface method).
|
||||||
func (s *IssuerService) ListIssuers(ctx context.Context, page, perPage int) ([]domain.Issuer, int64, error) {
|
func (s *IssuerService) ListIssuers(ctx context.Context, page, perPage int) ([]domain.Issuer, int64, error) {
|
||||||
if page < 1 {
|
// Bundle E / Audit L-020: page/perPage are unused; the underlying repo
|
||||||
page = 1
|
// List() does not yet take pagination params. Marked explicitly so
|
||||||
}
|
// ineffassign sees no dead store and future maintainers see the
|
||||||
if perPage < 1 {
|
// vestigial params rather than a misleading default-applied clamp.
|
||||||
perPage = 50
|
_ = page
|
||||||
}
|
_ = perPage
|
||||||
|
|
||||||
issuers, err := s.issuerRepo.List(ctx)
|
issuers, err := s.issuerRepo.List(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -127,12 +127,12 @@ func (s *OwnerService) Delete(ctx context.Context, id string, actor string) erro
|
|||||||
|
|
||||||
// ListOwners returns paginated owners (handler interface method).
|
// ListOwners returns paginated owners (handler interface method).
|
||||||
func (s *OwnerService) ListOwners(ctx context.Context, page, perPage int) ([]domain.Owner, int64, error) {
|
func (s *OwnerService) ListOwners(ctx context.Context, page, perPage int) ([]domain.Owner, int64, error) {
|
||||||
if page < 1 {
|
// Bundle E / Audit L-020: page/perPage are unused; the underlying repo
|
||||||
page = 1
|
// List() does not yet take pagination params. Marked explicitly so
|
||||||
}
|
// ineffassign sees no dead store and future maintainers see the
|
||||||
if perPage < 1 {
|
// vestigial params rather than a misleading default-applied clamp.
|
||||||
perPage = 50
|
_ = page
|
||||||
}
|
_ = perPage
|
||||||
|
|
||||||
owners, err := s.ownerRepo.List(ctx)
|
owners, err := s.ownerRepo.List(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -29,12 +29,12 @@ func NewProfileService(
|
|||||||
|
|
||||||
// ListProfiles returns all profiles (handler interface method).
|
// ListProfiles returns all profiles (handler interface method).
|
||||||
func (s *ProfileService) ListProfiles(ctx context.Context, page, perPage int) ([]domain.CertificateProfile, int64, error) {
|
func (s *ProfileService) ListProfiles(ctx context.Context, page, perPage int) ([]domain.CertificateProfile, int64, error) {
|
||||||
if page < 1 {
|
// Bundle E / Audit L-020: page/perPage are unused; the underlying repo
|
||||||
page = 1
|
// List() does not yet take pagination params. Marked explicitly so
|
||||||
}
|
// ineffassign sees no dead store and future maintainers see the
|
||||||
if perPage < 1 {
|
// vestigial params rather than a misleading default-applied clamp.
|
||||||
perPage = 50
|
_ = page
|
||||||
}
|
_ = perPage
|
||||||
|
|
||||||
profiles, err := s.profileRepo.List(ctx)
|
profiles, err := s.profileRepo.List(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -263,12 +263,12 @@ func (s *TargetService) TestConnection(ctx context.Context, id string) error {
|
|||||||
|
|
||||||
// ListTargets returns paginated targets (handler interface method).
|
// ListTargets returns paginated targets (handler interface method).
|
||||||
func (s *TargetService) ListTargets(ctx context.Context, page, perPage int) ([]domain.DeploymentTarget, int64, error) {
|
func (s *TargetService) ListTargets(ctx context.Context, page, perPage int) ([]domain.DeploymentTarget, int64, error) {
|
||||||
if page < 1 {
|
// Bundle E / Audit L-020: page/perPage are unused; the underlying repo
|
||||||
page = 1
|
// List() does not yet take pagination params. Marked explicitly so
|
||||||
}
|
// ineffassign sees no dead store and future maintainers see the
|
||||||
if perPage < 1 {
|
// vestigial params rather than a misleading default-applied clamp.
|
||||||
perPage = 50
|
_ = page
|
||||||
}
|
_ = perPage
|
||||||
|
|
||||||
targets, err := s.targetRepo.List(ctx)
|
targets, err := s.targetRepo.List(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -127,12 +127,12 @@ func (s *TeamService) Delete(ctx context.Context, id string, actor string) error
|
|||||||
|
|
||||||
// ListTeams returns paginated teams (handler interface method).
|
// ListTeams returns paginated teams (handler interface method).
|
||||||
func (s *TeamService) ListTeams(ctx context.Context, page, perPage int) ([]domain.Team, int64, error) {
|
func (s *TeamService) ListTeams(ctx context.Context, page, perPage int) ([]domain.Team, int64, error) {
|
||||||
if page < 1 {
|
// Bundle E / Audit L-020: page/perPage are unused; the underlying repo
|
||||||
page = 1
|
// List() does not yet take pagination params. Marked explicitly so
|
||||||
}
|
// ineffassign sees no dead store and future maintainers see the
|
||||||
if perPage < 1 {
|
// vestigial params rather than a misleading default-applied clamp.
|
||||||
perPage = 50
|
_ = page
|
||||||
}
|
_ = perPage
|
||||||
|
|
||||||
teams, err := s.teamRepo.List(ctx)
|
teams, err := s.teamRepo.List(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user