mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 20:41:30 +00:00
5c8d37e0f0
Closes Audit-2026-04-25 H-006 (High), H-007 (High), M-011 (Medium),
L-006 (Low — verified-already-closed via C-1 master closure in v2.0.54).
Hardens the orchestrator-facing surface — k8s probes, agent enrollment,
shutdown audit drain, scheduler config plumbing.
What changed
- internal/api/handler/health.go — split contract:
* /health stays shallow 200 (k8s liveness — process alive)
* /ready accepts *sql.DB; runs db.PingContext(2s); 503 on failure
* Nil DB path returns 200 + db=not_configured (test fixtures)
- internal/api/handler/agent_bootstrap.go (NEW) — verifyBootstrapToken:
* empty expected = warn-mode pass-through
* non-empty = `Authorization: Bearer <token>` required
* crypto/subtle.ConstantTimeCompare; length-mismatch path runs dummy
compare to keep timing uniform
* ErrBootstrapTokenInvalid sentinel
- internal/api/handler/agents.go — RegisterAgent calls verifyBootstrapToken
BEFORE body parse so unauth probes don't even allocate a JSON decoder
- internal/config/config.go — two new env vars:
* CERTCTL_AGENT_BOOTSTRAP_TOKEN (Auth.AgentBootstrapToken)
* CERTCTL_AUDIT_FLUSH_TIMEOUT_SECONDS (Server.AuditFlushTimeoutSeconds)
- cmd/server/main.go — 3 changes:
* pass *sql.DB into NewHealthHandler (H-006)
* pass cfg.Auth.AgentBootstrapToken into NewAgentHandler (H-007)
* configurable shutdown audit-flush timeout (M-011)
* one-shot startup WARN when bootstrap token unset (deprecation)
- new tests: agent_bootstrap_test.go (full deny/accept/warn-mode coverage,
constant-time compare path, length-mismatch); health_test.go extended
with /ready DB-probe failure (503), nil-DB pass-through, /health-shallow
L-006 verified
- cmd/server/main.go:557 already calls
sched.SetShortLivedExpiryCheckInterval(cfg.Scheduler.ShortLivedExpiryCheckInterval)
per the C-1 master closure in v2.0.54. Bundle 5 confirms; no code change.
Threat model: TB-1 (operator/orchestrator), TB-2 (Agent↔Server).
- CWE-754 (Improper Check for Unusual or Exceptional Conditions) for H-006
- CWE-306 + CWE-288 (Missing Authentication for Critical Function) for H-007
Verification
- go vet ./... → clean
- go build ./... → clean
- go test -short -count=1 ./... → all packages pass
- targeted Bundle-5 regressions → all pass
- npx tsc --noEmit (web) → clean
- npx vitest run (web) → in-flight (sandbox 45s
ceiling exceeded; no failure markers in dot stream; no frontend
changes in this bundle so no regression risk)
- python3 yaml.safe_load(api/openapi.yaml) → 89 paths
Backward compatibility
- Bootstrap token defaults to empty (warn-mode) — existing demo
deployments unaffected. Server logs deprecation WARN; v2.2.0 will
require it.
- Audit flush timeout default 30s preserves prior behaviour.
- Helm chart already routes readiness probe to /ready (no chart change
needed); now /ready actually probes the DB.
Bundle 5 of the 2026-04-25 comprehensive audit.
140 lines
5.1 KiB
Go
140 lines
5.1 KiB
Go
package handler
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
// Bundle-5 / Audit H-007 / CWE-306 + CWE-288:
|
|
// regression coverage for verifyBootstrapToken — the bootstrap-token gate
|
|
// applied to POST /api/v1/agents.
|
|
|
|
func TestVerifyBootstrapToken_EmptyExpected_PassThrough(t *testing.T) {
|
|
// Warn-mode contract: when the configured token is empty, the helper
|
|
// MUST return nil regardless of what the caller presents — preserves
|
|
// backwards compat with v2.0.x demo deployments.
|
|
cases := []struct {
|
|
name string
|
|
header string
|
|
}{
|
|
{"no_authorization", ""},
|
|
{"bearer_anything", "Bearer not-the-real-token"},
|
|
{"basic_auth", "Basic dXNlcjpwYXNz"},
|
|
{"malformed", "garbage"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", nil)
|
|
if tc.header != "" {
|
|
req.Header.Set("Authorization", tc.header)
|
|
}
|
|
if err := verifyBootstrapToken(req, ""); err != nil {
|
|
t.Errorf("warn-mode pass-through: expected nil, got %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestVerifyBootstrapToken_MatchingBearer_Accepts(t *testing.T) {
|
|
expected := "secret-token-with-some-entropy-12345"
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", nil)
|
|
req.Header.Set("Authorization", "Bearer "+expected)
|
|
|
|
if err := verifyBootstrapToken(req, expected); err != nil {
|
|
t.Errorf("matching Bearer: expected nil, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyBootstrapToken_MissingHeader_Rejects(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", nil)
|
|
err := verifyBootstrapToken(req, "configured-token")
|
|
if !errors.Is(err, ErrBootstrapTokenInvalid) {
|
|
t.Errorf("missing Authorization: expected ErrBootstrapTokenInvalid, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyBootstrapToken_WrongScheme_Rejects(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", nil)
|
|
req.Header.Set("Authorization", "Basic dXNlcjpwYXNz")
|
|
err := verifyBootstrapToken(req, "configured-token")
|
|
if !errors.Is(err, ErrBootstrapTokenInvalid) {
|
|
t.Errorf("wrong scheme: expected ErrBootstrapTokenInvalid, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyBootstrapToken_EmptyBearerToken_Rejects(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", nil)
|
|
req.Header.Set("Authorization", "Bearer ")
|
|
err := verifyBootstrapToken(req, "configured-token")
|
|
if !errors.Is(err, ErrBootstrapTokenInvalid) {
|
|
t.Errorf("empty bearer: expected ErrBootstrapTokenInvalid, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyBootstrapToken_WrongToken_Rejects(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", nil)
|
|
req.Header.Set("Authorization", "Bearer wrong-token")
|
|
err := verifyBootstrapToken(req, "configured-token")
|
|
if !errors.Is(err, ErrBootstrapTokenInvalid) {
|
|
t.Errorf("wrong token: expected ErrBootstrapTokenInvalid, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestVerifyBootstrapToken_LengthMismatch_Rejects(t *testing.T) {
|
|
// Different length than expected — must fail. Ensures we don't accidentally
|
|
// short-circuit before the constant-time compare.
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", nil)
|
|
req.Header.Set("Authorization", "Bearer x")
|
|
err := verifyBootstrapToken(req, "much-longer-configured-token-value")
|
|
if !errors.Is(err, ErrBootstrapTokenInvalid) {
|
|
t.Errorf("length mismatch: expected ErrBootstrapTokenInvalid, got %v", err)
|
|
}
|
|
}
|
|
|
|
// TestRegisterAgent_BootstrapTokenGate_E2E confirms the handler-level
|
|
// integration: when AgentHandler.BootstrapToken is set, requests without
|
|
// the matching Bearer header get 401 BEFORE the body is parsed.
|
|
func TestRegisterAgent_BootstrapTokenGate_E2E(t *testing.T) {
|
|
// Mock service returns success — proves the 401 path runs BEFORE service.
|
|
mock := &MockAgentService{}
|
|
h := NewAgentHandler(mock, "the-real-token")
|
|
|
|
t.Run("missing_token_returns_401", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", nil)
|
|
w := httptest.NewRecorder()
|
|
h.RegisterAgent(w, req)
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("missing token: expected 401, got %d", w.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("wrong_token_returns_401", func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", nil)
|
|
req.Header.Set("Authorization", "Bearer wrong-token")
|
|
w := httptest.NewRecorder()
|
|
h.RegisterAgent(w, req)
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Errorf("wrong token: expected 401, got %d", w.Code)
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestRegisterAgent_WarnModeAcceptsWithoutToken confirms the v2.0.x
|
|
// backwards-compat path: empty bootstrap-token + no Authorization header
|
|
// must NOT 401 — the handler proceeds to body parse / validation.
|
|
func TestRegisterAgent_WarnModeAcceptsWithoutToken(t *testing.T) {
|
|
mock := &MockAgentService{}
|
|
h := NewAgentHandler(mock, "") // warn-mode
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/agents", nil)
|
|
w := httptest.NewRecorder()
|
|
h.RegisterAgent(w, req)
|
|
// Body is empty, so the JSON decode will fail with 400. The point of this
|
|
// test is that we DON'T see 401 — the gate let the request through.
|
|
if w.Code == http.StatusUnauthorized {
|
|
t.Errorf("warn-mode: gate should not reject; got 401")
|
|
}
|
|
}
|