mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 19:11:30 +00:00
f7fcd1e187ce043bb91e5b1bb1d5a00355e53fc3
9 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
5ea45a19b9 |
feat(security): Sprint 5 ACQ — RED-003 deny-empty flip + SEC-009/RED-005 RFC1918 opt-in
Acquisition-audit Sprint 5 ACQ closure (2026-05-16). Two
independent findings ship together because they share Load() /
main.go wiring; the closure comments tie each line to its finding.
PART A — RED-003 (agent-bootstrap deny-empty cutover)
=====================================================
Phase 2 SEC-H1 closure (2026-05-13) introduced the
CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY staged feature flag with
default `false` so v2.1.x operators wouldn't get a surprise
fail-closed on upgrade. This commit flips the default to `true`
(per the staged plan in the existing CHANGELOG "Breaking changes
(scheduled for v2.2.0)" block). Operators who haven't generated a
real bootstrap token yet keep the v2.1.x warn-mode pass-through
for one upgrade window by setting
CERTCTL_AGENT_BOOTSTRAP_TOKEN_DENY_EMPTY=false explicitly.
Demo-mode escape hatch: CERTCTL_DEMO_MODE_ACK=true skips the
fail-closed gate so the screenshot/demo path stays one-command-up.
The accompanying boot-banner WARN at cmd/server/main.go:124-126
keeps demo mode visible in every log scraper, so this override
cannot silently re-enable warn-mode in production.
internal/config/config.go
- Load() default for AgentBootstrapTokenDenyEmpty flipped to true
- Validate() gate now also checks !c.Auth.DemoModeAck so the demo
override line up with the boot-banner WARN
- Closure comment block updated to cross-reference Sprint 5 ACQ
and the CHANGELOG v2.2.0 entry
cmd/server/main.go
- Updated boot-time WARN message to reflect the new default
(deny-empty=true) — the warn now fires only in the two
explicit override scenarios (warn-mode opt-back or demo mode),
and explains the operator action either way
- Info-line on configured-token path unchanged
PART B — SEC-009 + RED-005 (opt-in RFC1918 outbound block)
==========================================================
internal/validation/ssrf.go::IsReservedIP has always intentionally
left RFC 1918 ranges (10/8, 172.16/12, 192.168/16) NOT-reserved
because certctl is designed to manage certificates inside private
networks. For operators on hosted IaaS where RFC1918 IS internal
trust (kubeadm-default 10.96.0.0/12 service CIDR exposes the
Kubernetes API on 10.96.0.1; cloud-provider internal monitoring;
hosted-bastion subnets), this default is a real exposure path.
Add a package-level atomic.Bool toggle in internal/validation/ssrf.go
that, when on, extends IsReservedIP to ALSO return true for the
three RFC1918 ranges. Every IsReservedIP-derived path
(SafeHTTPDialContext, ValidateSafeURL, the network scanner, the
webhook + OIDC + ACME callers) picks up the new policy
transitively without per-call-site changes.
internal/validation/ssrf.go
- blockRFC1918Outbound atomic.Bool + SetBlockRFC1918Outbound /
BlockRFC1918OutboundEnabled accessor pair
- rfc1918Nets pre-parsed at package init (panic on parse failure
surfaces a misconfigured ssrf package immediately, not via a
silently disabled toggle)
- IsReservedIP checks the toggle after the existing reserved-IP
checks
- Header comment rewritten to document the toggle + the
transitive coverage
internal/config/config.go
- New NetworkConfig sub-config; Config gains a Network field
- Load() reads CERTCTL_BLOCK_RFC1918_OUTBOUND env var (default
false; preserves the existing self-hosted threat model)
- NetworkConfig docstring lists the operator-trap (enabling this
also blocks RFC1918 from the network scanner) so an operator
cert-discovering their own RFC1918 space doesn't get a
silently-empty scan result
cmd/server/main.go
- Wires validation.SetBlockRFC1918Outbound after config.Load and
near the demo-mode banner / agent-bootstrap-token block; emits
a one-shot INFO line when the toggle is enabled so the policy
is visible in journals
Tests
=====
internal/config/config_test.go
- TestLoad_AgentBootstrapTokenDenyEmpty_DefaultIsTrue — pins the
default flip at the boot path (Load returns the flipped value)
- TestValidate_DenyEmptyDefault_RefusesWithoutToken — pins the
fail-closed behavior under the new default
- TestValidate_DenyEmptyExplicitFalse_AllowsEmpty — pins the
v2.1.x back-compat escape hatch
- TestValidate_DenyEmpty_DemoModeAckOverride_AllowsEmpty — pins
the demo-mode override
internal/validation/ssrf_test.go
- TestIsReservedIP_RFC1918_OptIn — pins toggle-off / toggle-on
behavior across all three RFC1918 ranges, edge cases
immediately outside the ranges, and the toggle-back-off path
- TestSafeHTTPDialContext_RFC1918_OptIn — pins that the toggle
reaches the dial-time SSRF check transitively (not just
IsReservedIP in isolation)
Test-helper updates (Sprint-5-induced churn):
- internal/config/config_test.go::setMinimalValidEnv now sets
CERTCTL_AGENT_BOOTSTRAP_TOKEN to a placeholder so Load()-based
tests that don't specifically exercise the empty-token gate
keep passing under the new fail-closed default. Tests that DO
exercise the empty-token path explicitly override back to "".
- internal/config/config_est_profiles_test.go +
internal/config/config_scep_profiles_test.go: same placeholder
fix for the four Load()-based EST/SCEP profile tests.
- cmd/server/main_test.go::TestMain_ServerConfigFromEnvironment +
TestMain_AuthTypeConfiguration: same fix at the main.go test
layer with prior-value restore.
Verified locally: gofmt -l clean; go vet clean; staticcheck clean
across internal/config, internal/validation, cmd/server; short
tests green on all three packages; targeted -v run of all six new
test names confirms PASS.
|
||
|
|
7d2e7043b9 |
fix(server): SEC-003 — keep securityHeadersMiddleware in rate-limit stack
Sprint 1 unified-master-audit closure. cmd/server/main.go built two
middleware stacks: a default (line ~2054) and a rate-limit-enabled
rebuild (line ~2079). The rebuild dropped securityHeadersMiddleware,
silently turning off five browser-side defenses (Strict-Transport-
Security, X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
Content-Security-Policy) the moment an operator flipped
CERTCTL_RATE_LIMIT_ENABLED=true.
Fix: re-insert securityHeadersMiddleware at the same position as the
default stack and place rateLimiter immediately after, so even a 429
response carries the same headers as a 200.
Regression coverage:
- cmd/server/main_test.go TestMain_RateLimitedStack_EmitsSecurityHeaders
mirrors the production stack composition and asserts each of the
five headers lands on the response. A future regression that
removes securityHeadersMiddleware (or reorders it after the rate
limiter such that a 429 misses the headers) surfaces here.
Closes SEC-003.
|
||
|
|
99a012e3be |
auth-bundle-1 Phase 0: extract internal/auth/ from middleware package
Bundle 1 / Phase 0: pure refactor splitting auth surface out of internal/api/middleware so Bundle 2 (OIDC + sessions) and the broader RBAC primitive (roles, permissions, scoped grants) have a clean home. Moved to internal/auth/: NamedAPIKey, HashAPIKey, AuthConfig, NewAuthWithNamedKeys, NewAuth, UserKey, AdminKey, GetUser, IsAdmin. Added testfixtures.go (WithActor / WithAdmin / WithActorAdmin) so handler tests don't construct context manually. Stayed in internal/api/middleware/: RequestID, Logging, NewLogging, Recovery, RateLimitConfig, NewRateLimiter (now imports auth.GetUser for per-user keying per audit Category C), CORSConfig, NewCORS, ContentType, CORS, GetRequestID, responseWriter, Chain, audit middleware (now imports auth.GetUser). Updated 22 caller files across cmd/, internal/api/handler/, internal/api/middleware/, internal/mcp/. Existing m008_admin_gate_test.go now scans for auth.IsAdmin( substring; Phase 3 will further evolve to track auth.RequirePermission. Behavior unchanged: all handler / middleware / service / connector / cmd / mcp tests pass with no test-logic edits, only import-path renames. Phase 0 exit criteria: internal/auth/ exists with 6 files; middleware.go went 575 -> 422 lines (auth-related ~150 lines moved out); grep -rE 'middleware\.(GetUser|IsAdmin|UserKey|AdminKey|NamedAPIKey|HashAPIKey|NewAuth)' returns 0 hits; context.WithValue(.*middleware.UserKey/AdminKey) returns 0 hits; go vet ./... clean; go test -short ./... green across all packages tested. Branch: dev/auth-bundle-1. Per cowork/auth-bundle-1-prompt.md, do not merge to master without (1) make verify green, (2) >= 2 external testers confirm, (3) >= 90% coverage on internal/auth/ in .github/coverage-thresholds.yml. |
||
|
|
8b75e0311b |
chore: rename Go module path to github.com/certctl-io/certctl
Mechanical sed across the main go.mod's module declaration, the f5-mock-icontrol
sub-module's go.mod, every Go file's import path (361 files), and a rebuild of
the checked-in f5-mock-icontrol binary so its embedded build-info reflects the
new module path. No behavior change.
Choice B from cowork/transfer-certctl-to-org.md, executed 2026-05-04. Choice A
(keep module path declared as github.com/shankar0123/certctl regardless of
repo URL) shipped on the day of the org transfer (2026-05-03) since we had no
external Go consumers; this commit closes that deferral.
Backward-compat: GitHub HTTP redirects continue to forward
github.com/shankar0123/certctl → github.com/certctl-io/certctl at the URL
level, but Go's module proxy uses the path declared in go.mod as the
canonical name. Pre-fix, anyone trying `go get github.com/certctl-io/certctl/...`
hit a "module path mismatch" error because go.mod said
github.com/shankar0123/certctl and the URL they fetched it from said
certctl-io/certctl. Post-fix, the canonical name and the URL agree, so
go get / go install / external Go consumers / Go-tooling integrations
work cleanly via either the new path (preferred) or the old path (which
redirects and Go follows the redirect for source fetch).
Anyone still importing the old path inside their own code keeps working
provided they update their go.mod's `require` line to match — the module
path declared in their consumer's go.sum / go.mod is the authoritative
import name, so a mass sed across their import statements is the migration
on the consumer side. No external consumers exist today.
Diff shape:
361 *.go files — import path replacement only
2 go.mod — module declaration replacement only
1 binary — deploy/test/f5-mock-icontrol/f5-mock-icontrol rebuilt
so embedded build-info reflects the new path (8618965 vs
8618933 bytes; 32-byte diff is the build-info change)
Total: 364 files, 730 insertions / 730 deletions, net-zero size, pure
mechanical substitution.
Verification:
gofmt: 17 files needed re-alignment after sed (the new path is one char
shorter than the old, so column-aligned import groups drifted). Applied
`gofmt -w` to fix.
go mod tidy: clean exit on both modules.
go vet ./...: clean exit.
go build ./...: clean exit.
go test -short -count=1 on representative packages: all green
(internal/domain, internal/validation, internal/crypto, internal/crypto/signer,
cmd/agent). Test output now reads `ok github.com/certctl-io/certctl/...`
confirming the module path resolves correctly.
binary: f5-mock-icontrol rebuilt; `strings | grep shankar0123` returns
nothing; `strings | grep certctl-io/certctl` shows the new module path
embedded in build-info.
Files intentionally NOT touched in this commit:
README.md / CHANGELOG.md / docs/ / etc. — already swept to certctl-io
URLs in commit
|
||
|
|
a172b6ed3b |
Bundle B CI follow-up: G-3 env-var docs + M-028 closure (final 5 SA1019 sites)
Two CI failures on master after Bundle B merge:
1. Frontend Build / G-3 env-var docs guardrail
Bundle B introduced CERTCTL_RATE_LIMIT_PER_USER_RPS and
CERTCTL_RATE_LIMIT_PER_USER_BURST without adding them to
docs/features.md. The guardrail step that scans Go source for
getEnv* calls and asserts each appears in a doc page failed.
Fix: docs/features.md rate-limit section extended with both new
env vars + a paragraph explaining the per-key keying contract
from M-025.
2. Go Build & Test / staticcheck SA1019 hits (6 errors)
The CI workflow runs staticcheck without continue-on-error. Bundle
7 opened M-028 to track 6 deprecated-API sites; Bundle 9 closed 1
of them (the elliptic.Marshal in local.go) but kept a deliberate
regression-oracle reference in bundle9_coverage_test.go protected
only by golangci-lint's //nolint comment — staticcheck-as-CLI does
not honor that, only its native //lint:ignore directive.
Closure of remaining 5 sites:
cmd/server/main_test.go:47, 163, 192, 465 — 4 × middleware.NewAuth
migrated to middleware.NewAuthWithNamedKeys with explicit
NamedAPIKey entries. The auth=none case at line 465 maps to a
nil NamedAPIKey slice (no-op pass-through, matches the
NewAuthWithNamedKeys contract for empty input). Audit count was
3; recon found a 4th at line 465 that was missed.
internal/api/handler/scep.go:266 — csr.Attributes is a real RFC
2985 §5.4.1 challengePassword carve-out. Go's stdlib deprecation
note explicitly applies only to OID 1.2.840.113549.1.9.14
(requestedExtensions), NOT to OID 1.2.840.113549.1.9.7
(challengePassword), for which there is no non-deprecated
stdlib API. Suppressed with native //lint:ignore SA1019 +
comment block citing the RFC.
internal/connector/issuer/local/bundle9_coverage_test.go:342 —
deliberate regression-oracle that calls elliptic.Marshal to
prove the new crypto/ecdh path is byte-identical. Comment
converted from //nolint:staticcheck to native //lint:ignore
SA1019 so staticcheck-as-CLI honors the suppression.
Audit deliverables:
cowork/comprehensive-audit-2026-04-25/audit-report.md: M-028 box
flipped [x]; score 30/55 -> 31/55 (Medium 12/27 -> 13/27).
cowork/comprehensive-audit-2026-04-25/findings.yaml: M-028 status
partial_closed -> closed with closure note.
Verification:
go test -count=1 -short ./cmd/server ./internal/api/handler
./internal/connector/issuer/local ./internal/api/middleware
./internal/config — all green.
staticcheck on each changed package — 0 SA1019 hits.
Bundle C had M-028 in scope; this CI-fix lift moves it forward so
master CI goes green immediately. Bundle C scope adjusts to remove
M-028 and focuses on M-006 / M-015 / M-016 / M-019 / M-020 plus the
M-007 / M-008 coverage gaps.
|
||
|
|
52248be717 |
v2.0.47: HTTPS Everywhere — TLS-only control plane, agents/CLI/MCP
Breaking change release. Plaintext HTTP listener removed. The certctl control plane now terminates TLS 1.3 on :8443 via http.Server.ListenAndServeTLS. No CERTCTL_TLS_ENABLED=false escape hatch. No dual-listener mode. One-step cutover per docs/upgrade-to-tls.md. Server - cmd/server/tls.go: certHolder with SIGHUP hot-reload + atomic cert swap, buildServerTLSConfig (TLS 1.3 min, GetCertificate callback), preflightServerTLS validation - cmd/server/main.go: ListenAndServeTLS in place of ListenAndServe, watchSIGHUP wiring, cert/key path config threading - tls_test.go: 418-line regression coverage of reload, preflight, callback behavior, SAN validation Config - CERTCTL_TLS_CERT_PATH / CERTCTL_TLS_KEY_PATH (required) - Plaintext rejection: agents/CLI/MCP pre-flight-fail on http:// URLs with a pointer to docs/upgrade-to-tls.md Agents, CLI, MCP - All three pre-flight-reject http:// URLs with fail-loud diagnostic - CERTCTL_SERVER_CA_BUNDLE_PATH for private-CA trust - CERTCTL_SERVER_TLS_INSECURE_SKIP_VERIFY for dev-only bypass (loud warning on startup) - install-agent.sh emits both vars as commented template lines docker-compose - certctl-tls-init sidecar generates SAN-valid self-signed cert into deploy/test/certs/ on first boot - All demo-stack curls pin against ca.crt with --cacert Helm chart - Three TLS provisioning modes, exactly one required: - server.tls.existingSecret (operator-supplied) - server.tls.certManager.enabled (cert-manager integration) - server.tls.selfSigned.enabled (eval only — not for production) - server-certificate.yaml template for cert-manager mode - helm install without a TLS source fails at template render with a pointer to docs/tls.md CI - .github/workflows/ci.yml Helm Chart Validation step renders the chart in both existingSecret and cert-manager modes, plus an inverse guard-regression test that asserts helm template MUST refuse to render when no TLS source is configured. Previously the single `helm template` invocation hit the certctl.tls.required fail-loud guard and exit-1'd CI. Four invocations now: lint (existingSecret), template (existingSecret), template (cert-manager), template (no args — must fail). Integration tests - deploy/test/integration_test.go stands up the Compose stack over HTTPS, extracts the CA bundle, and exercises every certctl API over https://localhost:8443 - All 34 integration subtests green (per Phase 8 local CI-parity) Documentation - New: docs/tls.md (provisioning patterns, rotation, SIGHUP reload) - New: docs/upgrade-to-tls.md (one-step cutover, no-downgrade warnings, fleet-roll sequencing) - CHANGELOG.md: v2.2.0 "HTTPS Everywhere — The Irony" entry (file heading unchanged; release tag is v2.0.47) - All curls in docs/, examples/, deploy/helm/ guides use https://localhost:8443 --cacert Verification - grep -rn "ListenAndServe[^T]" cmd/ internal/ → 0 hits - grep -rn "\"http://" cmd/ internal/ → 2 benign hits (Caddy admin API default, SSRF doc comment) — zero certctl endpoints - Tasks #197–#206 (Phases 0–8) all closed in the tracker Files: 65 changed, 3489 insertions, 372 deletions (pre-CI-fix). |
||
|
|
b813660c74 |
security: require SCEP challenge password when SCEP enabled (fixes H-2)
Problem (CWE-306 Missing Authentication for Critical Function):
internal/service/scep.go PKCSReq skipped the shared-secret check when
s.challengePassword was empty. An unconfigured-but-enabled SCEP server
accepted any unauthenticated client reaching /scep and issued a
certificate against the configured issuer for any CSR with a valid
signature. No audit trail distinguished authenticated from
unauthenticated enrollments. This matches the two-layer fail-closed
pattern already used for C-2 (
|
||
|
|
370f856725 |
fix: resolve 8 staticcheck lint errors in test files
SA1029: use typed context key instead of string in main_test.go S1039: remove unnecessary fmt.Sprintf in validation_test.go SA4023: fix unreachable nil check on concrete error type SA4006: fix unused variable assignments in stepca_test.go (4 occurrences) SA4000: fix duplicate expression in ssh_test.go (BEGIN vs END CERTIFICATE) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
7382e5f03b |
test: comprehensive test gap closure across 24 packages
Close coverage gaps identified by dual-audit (qualitative + quantitative). New test files for config (0%→98%), router (0%→100%), handler validation, health, audit, response helpers, webhook notifier (0%→88%), email notifier, middleware (recovery, rate limiter), domain profile, service nil-safety, config helpers, issuer bootstrap, and server bootstrap wiring. Expanded existing tests for ACME (34%→42%), step-ca (42%→52%), F5, SSH, agent (43%→63%), scheduler (88%→99%), renewal service, and issuerfactory. All tests pass: go test -short, go vet, go test -race clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |