mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:01:32 +00:00
Bundle G: Final audit closure — L-004 + D-003/4/5/7 closed; 54/55 + 7/7
Closes the 2026-04-25 audit's final-closure cluster. Score 51/55 -> 54/55
(98% closed); deferred 4/7 -> 7/7 (100%). All severity-graded findings now
closed except M-029 (frontend per-PR migration backlog, by design incremental).
L-004 (CWE-924) — dual-key API rotation overlap window:
internal/config/config.go::ParseNamedAPIKeys rewritten to allow same-name
duplicate entries iff admin flag matches. Mismatched-admin entries rejected
at startup (privilege escalation guard); exact (name,key) duplicates rejected
(typo guard — rotation requires DIFFERENT keys under the same name). Startup
INFO log per name with multiple entries surfaces the active rotation window.
NewAuthWithNamedKeys was already shaped correctly (constant-time hash compare
across all entries, same UserKey + AdminKey for either bearer); Bundle B's
M-025 per-user rate-limit bucket and audit-trail actor inherit consistency
across the rollover automatically. 8 new tests pin the contract end-to-end.
docs/security.md::API key rotation walks the 6-step zero-downtime rollover.
D-003 — Mutation testing wired:
security-deep-scan.yml gets a go-mutesting step covering ./internal/crypto/...,
./internal/pkcs7/..., ./internal/connector/issuer/local/... with per-package
summary lines extracted into go-mutesting.txt artefact.
D-007 — Frontend semgrep wired (recon found Bundle 7's wiring claim was false):
security-deep-scan.yml gets a 'semgrep p/react-security' step running
returntocorp/semgrep:latest --config=p/react-security against /src/web/src;
results uploaded as semgrep-react.json.
D-004 + D-005 — Operator runbook published:
docs/testing-strategy.md (NEW) consolidates per-tool local-run procedures,
acceptance thresholds, and triage paths for go-mutesting, ZAP baseline DAST,
testssl.sh, and semgrep p/react-security. Closes the 'wired CI-only, no
local-run validation' framing for D-004/D-005 by giving operators the same
commands the CI workflow runs.
Verification:
gofmt -l no diff
go vet ./internal/config/... ./internal/api/middleware/... clean
go test -short -count=1 ./internal/config/... ./internal/api/middleware/... PASS
python3 -c 'yaml.safe_load(...)' YAML OK
G-3 env-var docs guard no phantom env-vars
Audit deliverables:
audit-report.md: L-004 + D-003/4/5/7 boxes flipped [x]; score 51/55 -> 54/55
findings.yaml: 5 status flips; new bundle-G-final-closure closure_log entry
CHANGELOG.md: Bundle G entry under [unreleased]; supersedes Bundle E + F
L-004-deferred framing
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Audit L-004 (CWE-924) — auth-middleware side of the dual-key rotation
|
||||
// contract. ParseNamedAPIKeys allows two entries to share a name during
|
||||
// the overlap window; NewAuthWithNamedKeys must accept either bearer
|
||||
// token and produce the same UserKey + Admin context value either way.
|
||||
|
||||
func TestL004_AuthMiddleware_BothKeysValidate(t *testing.T) {
|
||||
mw := NewAuthWithNamedKeys([]NamedAPIKey{
|
||||
{Name: "alice", Key: "OLDKEY", Admin: true},
|
||||
{Name: "alice", Key: "NEWKEY", Admin: true},
|
||||
})
|
||||
|
||||
makeReq := func(token string) *http.Request {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/anything", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
return req
|
||||
}
|
||||
|
||||
for _, tok := range []string{"OLDKEY", "NEWKEY"} {
|
||||
t.Run("token="+tok, func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := GetUser(r.Context()); got != "alice" {
|
||||
t.Errorf("UserKey = %q, want alice (rotation must preserve identity across both keys)", got)
|
||||
}
|
||||
if !IsAdmin(r.Context()) {
|
||||
t.Errorf("Admin flag lost — both rotation entries carry admin=true, context must reflect that")
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
handler.ServeHTTP(rec, makeReq(tok))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("token %s should validate during rotation overlap; got %d", tok, rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestL004_AuthMiddleware_PostRotationOldKeyRejected(t *testing.T) {
|
||||
// Operator has completed the rotation: old key removed from
|
||||
// CERTCTL_API_KEYS_NAMED, only new key remains. Old bearer must
|
||||
// now fail.
|
||||
mw := NewAuthWithNamedKeys([]NamedAPIKey{
|
||||
{Name: "alice", Key: "NEWKEY", Admin: true},
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/anything", nil)
|
||||
req.Header.Set("Authorization", "Bearer OLDKEY")
|
||||
rec := httptest.NewRecorder()
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("OLDKEY post-rotation should be rejected; got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestL004_AuthMiddleware_DualUserKeyedRateLimit(t *testing.T) {
|
||||
// Bundle B's rate limiter keys on the UserKey. Both rotation
|
||||
// entries must produce the SAME UserKey value so the per-user
|
||||
// bucket stays consistent across the overlap window — otherwise
|
||||
// a client rotating its key would get a fresh bucket and bypass
|
||||
// the rate limit. Pin the invariant.
|
||||
mw := NewAuthWithNamedKeys([]NamedAPIKey{
|
||||
{Name: "alice", Key: "OLDKEY", Admin: false},
|
||||
{Name: "alice", Key: "NEWKEY", Admin: false},
|
||||
})
|
||||
|
||||
captured := []string{}
|
||||
handler := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
captured = append(captured, GetUser(r.Context()))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
for _, tok := range []string{"OLDKEY", "NEWKEY"} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
}
|
||||
|
||||
if len(captured) != 2 {
|
||||
t.Fatalf("expected 2 captured UserKey values, got %d", len(captured))
|
||||
}
|
||||
if captured[0] != captured[1] {
|
||||
t.Errorf("UserKey diverged across rotation: OLDKEY=%q NEWKEY=%q — rate-limit bucket would split",
|
||||
captured[0], captured[1])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user