mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:51:30 +00:00
1d6c7a0552
Closes Audit-2026-04-25 H-008 (High), M-017 (Medium), M-022 (Medium).
Hardens audit-trail tamper-resistance + minimizes PII leakage in one
cohesive change, with both controls applying automatically and no
operator action required at install time.
What changed
- internal/service/audit_redact.go (NEW) — RedactDetailsForAudit:
* credentialKeys deny-list (api_key, password, *_pem, eab_secret, ...)
* piiKeys deny-list (email, phone, ssn, name, address, ip_address, ...)
* case-insensitive key match; recurses into nested maps + arrays
* mutation-free; surfaces redacted_keys array for operator visibility
* nil/empty input → nil out (preserves pre-Bundle-6 behaviour)
- internal/service/audit.go — RecordEvent now routes details through
RedactDetailsForAudit BEFORE marshaling. No call-site changes required.
- internal/service/audit_redact_test.go (NEW) — full coverage:
* credential keys (~30 entries)
* PII keys (~20 entries)
* nested maps + arrays
* case-insensitivity
* mutation-free invariant
* JSON round-trip (catches type-assertion regressions)
* scalar pass-through (no panic on int/bool/nil)
- migrations/000018_audit_events_worm.up.sql (NEW) — DB-level WORM:
* BEFORE UPDATE OR DELETE trigger raises check_violation with
diagnostic citing the rationale + compliance-superuser hint
* REVOKE UPDATE,DELETE ON audit_events FROM certctl (defence-in-depth)
* REVOKE wrapped in pg_roles existence check so test fixtures
without the certctl role stay idempotent
- migrations/000018_audit_events_worm.down.sql (NEW) — clean teardown
for dev resets; not for production use.
- internal/repository/postgres/audit_worm_test.go (NEW, testcontainers,
-short gated) — INSERT succeeds; UPDATE + DELETE fail with
check_violation; second INSERT after blocked modification still
succeeds (no trigger-state corruption).
- docs/compliance.md — new section "Audit-Trail Integrity & Privacy
(Bundle 6)" with verification psql snippet, compliance-superuser
pattern (NOT auto-created), redactor before/after example, and a
maintenance note for adding new credential keys.
Compliance mapping
- H-008 (CWE-532 Insertion of Sensitive Information into Log File)
- M-017 (HIPAA Technical Safeguards §164.312(b) — audit controls)
- M-022 (GDPR Art. 32 — data minimization)
Threat model: TB-3 (audit log tampering), TB-1 (operator/orchestrator).
Verification
- go vet ./... → clean
- go build ./... → clean
- go test -short -count=1 ./... → all packages pass
- go test -count=1 -run TestRedactDetailsForAudit ./internal/service/...
→ all pass
- (testcontainers, gated by -short) audit_worm_test.go pins WORM contract
- npx tsc --noEmit (web) → clean (no frontend changes)
- python3 yaml.safe_load(api/openapi.yaml) → 89 paths
Backward compatibility
- Trigger applies forward only — existing rows unchanged.
- nil/empty details from RecordEvent callers → nil out (preserves prior
behaviour for the many existing call sites that pass nil).
- Compliance superusers (provisioned out-of-band) bypass the trigger.
Bundle 6 of the 2026-04-25 comprehensive audit.
246 lines
7.6 KiB
Go
246 lines
7.6 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// Bundle-6 / Audit H-008 + M-022 / CWE-532 regression suite.
|
|
|
|
func TestRedactDetailsForAudit_NilAndEmpty(t *testing.T) {
|
|
if got := RedactDetailsForAudit(nil); got != nil {
|
|
t.Errorf("nil input → expected nil out, got %v", got)
|
|
}
|
|
if got := RedactDetailsForAudit(map[string]interface{}{}); got != nil {
|
|
t.Errorf("empty input → expected nil out, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestRedactDetailsForAudit_CredentialKeys(t *testing.T) {
|
|
cases := []string{
|
|
"api_key", "ApiKey", "API_KEY", "password", "Passphrase",
|
|
"secret", "client_secret", "token", "access_token",
|
|
"refresh_token", "bootstrap_token", "private_key", "PrivateKey",
|
|
"private_key_pem", "key_pem", "cert_pem", "chain_pem", "full_pem",
|
|
"eab_secret", "eab_kid", "acme_account_key", "hmac",
|
|
"signature", "auth", "authorization", "bearer",
|
|
}
|
|
for _, key := range cases {
|
|
t.Run(key, func(t *testing.T) {
|
|
in := map[string]interface{}{
|
|
key: "sensitive-value-do-not-leak",
|
|
"non_sensitive_id": "ok-public-id",
|
|
}
|
|
out := RedactDetailsForAudit(in)
|
|
if out[key] != "[REDACTED:CREDENTIAL]" {
|
|
t.Errorf("expected credential redaction, got %v", out[key])
|
|
}
|
|
if out["non_sensitive_id"] != "ok-public-id" {
|
|
t.Errorf("non-sensitive field mutated: %v", out["non_sensitive_id"])
|
|
}
|
|
redactedKeys, ok := out["redacted_keys"].([]string)
|
|
if !ok || len(redactedKeys) != 1 || redactedKeys[0] != key {
|
|
t.Errorf("redacted_keys = %v, expected [%q]", out["redacted_keys"], key)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRedactDetailsForAudit_PIIKeys(t *testing.T) {
|
|
cases := []string{
|
|
"email", "Email_Address", "phone", "telephone", "ssn",
|
|
"social_security", "dob", "date_of_birth", "name", "full_name",
|
|
"first_name", "last_name", "surname", "address", "street",
|
|
"street_address", "city", "postal_code", "zip", "ip_address",
|
|
}
|
|
for _, key := range cases {
|
|
t.Run(key, func(t *testing.T) {
|
|
in := map[string]interface{}{key: "personal-data"}
|
|
out := RedactDetailsForAudit(in)
|
|
if out[key] != "[REDACTED:PII]" {
|
|
t.Errorf("expected PII redaction, got %v", out[key])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRedactDetailsForAudit_NestedMap(t *testing.T) {
|
|
in := map[string]interface{}{
|
|
"resource_id": "iss-prod",
|
|
"config": map[string]interface{}{
|
|
"endpoint": "https://acme.example.com",
|
|
"eab_secret": "do-not-leak-this-secret",
|
|
"contact": map[string]interface{}{
|
|
"email": "ops@example.com",
|
|
"role": "admin",
|
|
},
|
|
},
|
|
}
|
|
out := RedactDetailsForAudit(in)
|
|
|
|
cfg, ok := out["config"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("config field shape changed: %T", out["config"])
|
|
}
|
|
if cfg["eab_secret"] != "[REDACTED:CREDENTIAL]" {
|
|
t.Errorf("nested credential not redacted: %v", cfg["eab_secret"])
|
|
}
|
|
if cfg["endpoint"] != "https://acme.example.com" {
|
|
t.Errorf("non-sensitive nested field mutated: %v", cfg["endpoint"])
|
|
}
|
|
contact, ok := cfg["contact"].(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("contact field shape changed: %T", cfg["contact"])
|
|
}
|
|
if contact["email"] != "[REDACTED:PII]" {
|
|
t.Errorf("nested PII not redacted: %v", contact["email"])
|
|
}
|
|
if contact["role"] != "admin" {
|
|
t.Errorf("non-sensitive nested field mutated: %v", contact["role"])
|
|
}
|
|
|
|
// redacted_keys array surfaces the dotted paths
|
|
redactedKeys, ok := out["redacted_keys"].([]string)
|
|
if !ok {
|
|
t.Fatalf("redacted_keys missing or wrong type: %T", out["redacted_keys"])
|
|
}
|
|
sort.Strings(redactedKeys)
|
|
wantKeys := []string{"config.contact.email", "config.eab_secret"}
|
|
if len(redactedKeys) != len(wantKeys) {
|
|
t.Errorf("redacted_keys len mismatch: got %v want %v", redactedKeys, wantKeys)
|
|
}
|
|
for i, want := range wantKeys {
|
|
if i >= len(redactedKeys) || redactedKeys[i] != want {
|
|
t.Errorf("redacted_keys[%d] = %q want %q", i, redactedKeys[i], want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRedactDetailsForAudit_NestedArray(t *testing.T) {
|
|
// Arrays of maps (e.g. SANs with metadata) — credentials inside array
|
|
// elements must also be redacted.
|
|
in := map[string]interface{}{
|
|
"contacts": []interface{}{
|
|
map[string]interface{}{
|
|
"name": "Alice",
|
|
"email": "alice@example.com",
|
|
},
|
|
map[string]interface{}{
|
|
"name": "Bob",
|
|
"email": "bob@example.com",
|
|
},
|
|
},
|
|
}
|
|
out := RedactDetailsForAudit(in)
|
|
contacts, ok := out["contacts"].([]interface{})
|
|
if !ok {
|
|
t.Fatalf("contacts shape changed: %T", out["contacts"])
|
|
}
|
|
if len(contacts) != 2 {
|
|
t.Fatalf("expected 2 contacts, got %d", len(contacts))
|
|
}
|
|
for i, c := range contacts {
|
|
m, ok := c.(map[string]interface{})
|
|
if !ok {
|
|
t.Fatalf("contact %d shape changed: %T", i, c)
|
|
}
|
|
if m["email"] != "[REDACTED:PII]" {
|
|
t.Errorf("contact[%d].email not redacted: %v", i, m["email"])
|
|
}
|
|
if m["name"] != "[REDACTED:PII]" {
|
|
t.Errorf("contact[%d].name not redacted: %v", i, m["name"])
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRedactDetailsForAudit_NoRedactionPath(t *testing.T) {
|
|
// Maps with no sensitive keys should NOT have a redacted_keys array
|
|
// — clutter-free for the common case.
|
|
in := map[string]interface{}{
|
|
"action": "create_certificate",
|
|
"cert_id": "mc-prod-001",
|
|
"latency_ms": float64(42),
|
|
}
|
|
out := RedactDetailsForAudit(in)
|
|
if _, present := out["redacted_keys"]; present {
|
|
t.Errorf("expected no redacted_keys when no redaction occurred, got %v", out["redacted_keys"])
|
|
}
|
|
}
|
|
|
|
func TestRedactDetailsForAudit_DoesNotMutateInput(t *testing.T) {
|
|
in := map[string]interface{}{
|
|
"api_key": "secret-do-not-leak",
|
|
"resource": "iss-prod",
|
|
}
|
|
_ = RedactDetailsForAudit(in)
|
|
if in["api_key"] != "secret-do-not-leak" {
|
|
t.Errorf("input map was mutated: api_key = %v", in["api_key"])
|
|
}
|
|
}
|
|
|
|
func TestRedactDetailsForAudit_CaseInsensitive(t *testing.T) {
|
|
cases := []string{"API_KEY", "Api_Key", "api_KEY", "EMAIL", "Email"}
|
|
for _, key := range cases {
|
|
t.Run(key, func(t *testing.T) {
|
|
out := RedactDetailsForAudit(map[string]interface{}{key: "leak-me"})
|
|
val, _ := out[key].(string)
|
|
if !strings.HasPrefix(val, "[REDACTED:") {
|
|
t.Errorf("case-insensitive match failed for %q: %v", key, out[key])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRedactDetailsForAudit_JSONRoundTrip(t *testing.T) {
|
|
// The redacted map MUST round-trip through json.Marshal (the
|
|
// AuditService persistence path). Catches type-assertion regressions.
|
|
in := map[string]interface{}{
|
|
"reason": "compromised-key",
|
|
"api_key": "leak-me",
|
|
"contacts": []interface{}{
|
|
map[string]interface{}{"email": "ops@example.com"},
|
|
},
|
|
}
|
|
out := RedactDetailsForAudit(in)
|
|
b, err := json.Marshal(out)
|
|
if err != nil {
|
|
t.Fatalf("redacted map failed json.Marshal: %v", err)
|
|
}
|
|
body := string(b)
|
|
if strings.Contains(body, "leak-me") {
|
|
t.Errorf("credential value leaked through marshal: %s", body)
|
|
}
|
|
if strings.Contains(body, "ops@example.com") {
|
|
t.Errorf("PII value leaked through marshal: %s", body)
|
|
}
|
|
if !strings.Contains(body, "[REDACTED:CREDENTIAL]") {
|
|
t.Errorf("redaction sentinel missing from marshaled output: %s", body)
|
|
}
|
|
if !strings.Contains(body, "[REDACTED:PII]") {
|
|
t.Errorf("PII redaction sentinel missing from marshaled output: %s", body)
|
|
}
|
|
if !strings.Contains(body, "redacted_keys") {
|
|
t.Errorf("redacted_keys array missing from marshaled output: %s", body)
|
|
}
|
|
}
|
|
|
|
// TestRedactDetailsForAudit_ScalarTypes confirms the recursive arm doesn't
|
|
// mishandle non-map non-slice values.
|
|
func TestRedactDetailsForAudit_ScalarTypes(t *testing.T) {
|
|
in := map[string]interface{}{
|
|
"string_field": "hello",
|
|
"int_field": 42,
|
|
"float_field": 3.14,
|
|
"bool_field": true,
|
|
"nil_field": nil,
|
|
}
|
|
out := RedactDetailsForAudit(in)
|
|
if out["string_field"] != "hello" || out["int_field"] != 42 ||
|
|
out["float_field"] != 3.14 || out["bool_field"] != true ||
|
|
out["nil_field"] != nil {
|
|
t.Errorf("scalar pass-through failed: %v", out)
|
|
}
|
|
}
|