mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-13 14:08:56 +00:00
feat(config): SEC-013 — advisory WARN on external sslmode=disable
Acquisition-audit SEC-013 closure (Sprint 2 ACQ, 2026-05-16). Add a post-Validate advisory WARN (NOT fail-closed) that fires when `CERTCTL_DATABASE_URL` parses as a Postgres URL with `sslmode=disable` AND the host is outside the local safelist. The advisory exists because the legitimate compose / Helm topology genuinely uses sslmode=disable over the Docker bridge — failing closed would break the production-shaped quickstart — but pointing CERTCTL_DATABASE_URL at a managed-Postgres host (RDS / Cloud SQL / Azure Database) without flipping sslmode to verify-full puts the entire control plane's Postgres traffic on the wire in cleartext. Safelist (silenced): - localhost, 127.0.0.1, ::1 - postgres (compose default service name) - certctl-postgres (compose / Helm service name) - *.svc.cluster.local (K8s in-cluster service-name convention) Anything else → `slog.Warn` with structured `host=` + `sslmode=` fields plus a pointer to docs/operator/database-tls.md for the verify-full upgrade procedure. Tests: - TestWarnExternalSslmodeDisable_FiresOnExternalHost - TestWarnExternalSslmodeDisable_QuietForLocalSafelist (6 subtests) - TestWarnExternalSslmodeDisable_QuietWithoutDisable (3 subtests) - TestWarnExternalSslmodeDisable_QuietOnUnparseableOrEmpty (3 subtests) Docs: docs/operator/security.md gains a Postgres transport encryption subsection covering both SEC-013 (this commit) and SEC-014 (loopback host-port bind, prior commit); the deep procedure remains at docs/operator/database-tls.md.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
@@ -2120,3 +2121,108 @@ func TestValidate_AgentKeygenIgnoresDemoAck(t *testing.T) {
|
||||
t.Errorf("Validate(KeygenMode=agent, DemoAck=false) = %v; want nil (production default must boot)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// newBufferLogger returns a slog.Logger that writes JSON records into the
|
||||
// returned buffer, suitable for asserting WARN emission from
|
||||
// warnExternalSslmodeDisable. SEC-013 closure (Sprint 2 ACQ).
|
||||
func newBufferLogger() (*slog.Logger, *bytes.Buffer) {
|
||||
var buf bytes.Buffer
|
||||
h := slog.NewJSONHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})
|
||||
return slog.New(h), &buf
|
||||
}
|
||||
|
||||
// TestWarnExternalSslmodeDisable_FiresOnExternalHost asserts an external
|
||||
// host (e.g. RDS) + sslmode=disable produces a WARN. SEC-013 closure
|
||||
// (Sprint 2 ACQ, 2026-05-16). The advisory exists to surface the
|
||||
// real-world landmine: an operator who points CERTCTL_DATABASE_URL at a
|
||||
// managed-Postgres host outside the bridge network without flipping
|
||||
// sslmode to verify-full.
|
||||
func TestWarnExternalSslmodeDisable_FiresOnExternalHost(t *testing.T) {
|
||||
t.Parallel()
|
||||
logger, buf := newBufferLogger()
|
||||
warnExternalSslmodeDisable("postgres://certctl:secret@db.internal.example.com:5432/certctl?sslmode=disable", logger)
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, `"level":"WARN"`) {
|
||||
t.Fatalf("expected a WARN record, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "db.internal.example.com") {
|
||||
t.Errorf("WARN should include the external host in structured fields; got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "sslmode") {
|
||||
t.Errorf("WARN should include the sslmode structured field; got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnExternalSslmodeDisable_QuietForLocalSafelist asserts the
|
||||
// loopback + in-cluster service-name conventions stay silent. These are
|
||||
// the legitimate sslmode=disable callers — compose bridge network
|
||||
// (`postgres` / `certctl-postgres`), localhost dev loops, and K8s
|
||||
// in-cluster service names (`*.svc.cluster.local`). SEC-013 closure.
|
||||
func TestWarnExternalSslmodeDisable_QuietForLocalSafelist(t *testing.T) {
|
||||
t.Parallel()
|
||||
silentHosts := []string{
|
||||
"postgres://certctl@localhost:5432/certctl?sslmode=disable",
|
||||
"postgres://certctl@127.0.0.1:5432/certctl?sslmode=disable",
|
||||
"postgres://certctl@[::1]:5432/certctl?sslmode=disable",
|
||||
"postgres://certctl@postgres:5432/certctl?sslmode=disable",
|
||||
"postgres://certctl@certctl-postgres:5432/certctl?sslmode=disable",
|
||||
"postgres://certctl@certctl-postgres.certctl.svc.cluster.local:5432/certctl?sslmode=disable",
|
||||
}
|
||||
for _, url := range silentHosts {
|
||||
url := url
|
||||
t.Run(url, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
logger, buf := newBufferLogger()
|
||||
warnExternalSslmodeDisable(url, logger)
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("expected silence for safelisted host (%s); got: %s", url, buf.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnExternalSslmodeDisable_QuietWithoutDisable asserts that any
|
||||
// sslmode other than `disable` (the production-grade modes) stays
|
||||
// silent even with an external host. SEC-013 closure.
|
||||
func TestWarnExternalSslmodeDisable_QuietWithoutDisable(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, url := range []string{
|
||||
"postgres://certctl@db.internal.example.com:5432/certctl?sslmode=verify-full&sslrootcert=/etc/ssl/ca.pem",
|
||||
"postgres://certctl@db.internal.example.com:5432/certctl?sslmode=require",
|
||||
"postgres://certctl@db.internal.example.com:5432/certctl", // no sslmode at all
|
||||
} {
|
||||
url := url
|
||||
t.Run(url, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
logger, buf := newBufferLogger()
|
||||
warnExternalSslmodeDisable(url, logger)
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("expected silence for non-disable sslmode (%s); got: %s", url, buf.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWarnExternalSslmodeDisable_QuietOnUnparseableOrEmpty asserts the
|
||||
// helper is permissive on garbage input — downstream sql.Open surfaces
|
||||
// the real parse error; the SEC-013 advisory must not become a noisy
|
||||
// hot path. SEC-013 closure.
|
||||
func TestWarnExternalSslmodeDisable_QuietOnUnparseableOrEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, url := range []string{
|
||||
"",
|
||||
"not-a-url",
|
||||
"mysql://certctl@db:3306/x?sslmode=disable", // non-postgres scheme
|
||||
} {
|
||||
url := url
|
||||
t.Run(url, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
logger, buf := newBufferLogger()
|
||||
warnExternalSslmodeDisable(url, logger)
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("expected silence for unparseable/non-postgres input (%q); got: %s", url, buf.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user