mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:01:32 +00:00
6b5af27546
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
123 lines
3.6 KiB
Go
123 lines
3.6 KiB
Go
package config
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Audit L-004 (CWE-924): graceful API key rotation overlap window.
|
|
// Pre-bundle ParseNamedAPIKeys rejected duplicate names. Post-bundle
|
|
// duplicates are allowed iff the admin flag matches across entries —
|
|
// this gives operators a zero-downtime rotation primitive without
|
|
// requiring schema, GUI, or DB-resident key storage.
|
|
//
|
|
// These tests pin the contract end-to-end through ParseNamedAPIKeys.
|
|
// The auth-middleware side is exercised separately in
|
|
// internal/api/middleware via auth_l004_rotation_test.go.
|
|
|
|
func TestL004_DualKeyRotation_SameAdmin_Accepted(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
input string
|
|
}{
|
|
{"both_admin", "alice:OLDKEY:admin,alice:NEWKEY:admin"},
|
|
{"both_non_admin", "ci-runner:OLD,ci-runner:NEW"},
|
|
{"three_keys_admin", "ops:K1:admin,ops:K2:admin,ops:K3:admin"},
|
|
{"mixed_with_other_users", "alice:OLDKEY:admin,bob:UNRELATED,alice:NEWKEY:admin"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
keys, err := ParseNamedAPIKeys(tc.input)
|
|
if err != nil {
|
|
t.Fatalf("expected dual-key rotation to parse, got error: %v", err)
|
|
}
|
|
if len(keys) < 2 {
|
|
t.Errorf("expected ≥2 entries, got %d", len(keys))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestL004_DualKeyRotation_AdminMismatch_Rejected(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
input string
|
|
}{
|
|
{"first_admin_then_user", "alice:OLD:admin,alice:NEW"},
|
|
{"first_user_then_admin", "alice:OLD,alice:NEW:admin"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := ParseNamedAPIKeys(tc.input)
|
|
if err == nil {
|
|
t.Fatal("expected admin-flag mismatch to be rejected")
|
|
}
|
|
if !strings.Contains(err.Error(), "mismatched admin flag") {
|
|
t.Errorf("error must cite admin flag mismatch, got: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestL004_DualKeyRotation_IdenticalNameAndKey_Rejected(t *testing.T) {
|
|
// Same name + same key is a typo, not a rotation. The rotation
|
|
// case is DIFFERENT keys under the same name.
|
|
_, err := ParseNamedAPIKeys("alice:SAMEKEY:admin,alice:SAMEKEY:admin")
|
|
if err == nil {
|
|
t.Fatal("expected (name,key) duplicate to be rejected")
|
|
}
|
|
if !strings.Contains(err.Error(), "duplicate (name,key)") {
|
|
t.Errorf("error must cite (name,key) duplicate, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestL004_DualKeyRotation_SteadyStateUnchanged(t *testing.T) {
|
|
// Single-key (no rotation) and multi-distinct-name configs must
|
|
// continue to parse the same way they did pre-bundle.
|
|
cases := []struct {
|
|
name string
|
|
input string
|
|
want int
|
|
}{
|
|
{"single", "alice:KEY:admin", 1},
|
|
{"two_distinct_names", "alice:KEY1:admin,bob:KEY2", 2},
|
|
{"three_distinct_names", "alice:K1:admin,bob:K2,carol:K3:admin", 3},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
keys, err := ParseNamedAPIKeys(tc.input)
|
|
if err != nil {
|
|
t.Fatalf("steady-state parse failed: %v", err)
|
|
}
|
|
if len(keys) != tc.want {
|
|
t.Errorf("got %d entries, want %d", len(keys), tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestL004_DualKeyRotation_PreservesAllEntries(t *testing.T) {
|
|
// Round-trip: every input entry must appear in the parsed output.
|
|
keys, err := ParseNamedAPIKeys("alice:OLDKEY:admin,alice:NEWKEY:admin")
|
|
if err != nil {
|
|
t.Fatalf("parse: %v", err)
|
|
}
|
|
if len(keys) != 2 {
|
|
t.Fatalf("got %d, want 2", len(keys))
|
|
}
|
|
gotKeys := map[string]bool{keys[0].Key: true, keys[1].Key: true}
|
|
for _, want := range []string{"OLDKEY", "NEWKEY"} {
|
|
if !gotKeys[want] {
|
|
t.Errorf("missing key %q in parsed entries: %+v", want, keys)
|
|
}
|
|
}
|
|
for _, k := range keys {
|
|
if k.Name != "alice" {
|
|
t.Errorf("entry %+v has wrong name; want alice", k)
|
|
}
|
|
if !k.Admin {
|
|
t.Errorf("entry %+v lost admin flag", k)
|
|
}
|
|
}
|
|
}
|