mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:01: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.
89 lines
3.1 KiB
Go
89 lines
3.1 KiB
Go
package postgres_test
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// Bundle-6 / Audit M-017 / HIPAA §164.312(b):
|
|
//
|
|
// migrations/000018_audit_events_worm.up.sql installs a BEFORE UPDATE OR
|
|
// DELETE trigger on audit_events that raises check_violation. This test
|
|
// boots a real Postgres via testcontainers, runs all migrations (including
|
|
// 000018), then exercises the trigger:
|
|
//
|
|
// INSERT a row → succeeds (append is allowed)
|
|
// UPDATE the row → fails with check_violation
|
|
// DELETE the row → fails with check_violation
|
|
// INSERT a second row → succeeds (write path remains open)
|
|
//
|
|
// The test is gated by testing.Short() so the default `go test ./... -short`
|
|
// loop in CI doesn't require docker-in-docker. Run via:
|
|
//
|
|
// go test -count=1 ./internal/repository/postgres/...
|
|
|
|
func TestAuditEventsWORM_AppendOnlyEnforced(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("skipping integration test in short mode")
|
|
}
|
|
|
|
tdb := setupTestDB(t)
|
|
defer tdb.teardown(t)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
// INSERT — must succeed (append is the supported write path).
|
|
_, err := tdb.db.ExecContext(ctx, `
|
|
INSERT INTO audit_events (id, actor, actor_type, action, resource_type, resource_id, details, timestamp)
|
|
VALUES ('audit-bundle6-001', 'tester', 'User', 'create_certificate', 'certificate', 'mc-test-001', '{}'::jsonb, NOW())
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("INSERT (append) should succeed: %v", err)
|
|
}
|
|
|
|
// UPDATE — trigger MUST fire and raise check_violation.
|
|
_, err = tdb.db.ExecContext(ctx, `
|
|
UPDATE audit_events SET actor = 'tampered' WHERE id = 'audit-bundle6-001'
|
|
`)
|
|
if err == nil {
|
|
t.Fatal("UPDATE should fail with check_violation; got nil error (WORM trigger missing?)")
|
|
}
|
|
if !strings.Contains(err.Error(), "audit_events is append-only") {
|
|
t.Errorf("UPDATE error should cite the WORM rationale; got: %v", err)
|
|
}
|
|
|
|
// DELETE — trigger MUST fire and raise check_violation.
|
|
_, err = tdb.db.ExecContext(ctx, `
|
|
DELETE FROM audit_events WHERE id = 'audit-bundle6-001'
|
|
`)
|
|
if err == nil {
|
|
t.Fatal("DELETE should fail with check_violation; got nil error (WORM trigger missing?)")
|
|
}
|
|
if !strings.Contains(err.Error(), "audit_events is append-only") {
|
|
t.Errorf("DELETE error should cite the WORM rationale; got: %v", err)
|
|
}
|
|
|
|
// INSERT again — confirm the write path remains open after a blocked
|
|
// modification attempt (no trigger-state corruption).
|
|
_, err = tdb.db.ExecContext(ctx, `
|
|
INSERT INTO audit_events (id, actor, actor_type, action, resource_type, resource_id, details, timestamp)
|
|
VALUES ('audit-bundle6-002', 'tester', 'User', 'list_certificates', 'certificate', '*', '{}'::jsonb, NOW())
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("INSERT after blocked UPDATE/DELETE should still succeed: %v", err)
|
|
}
|
|
|
|
// Sanity check: both INSERTs landed.
|
|
var count int
|
|
row := tdb.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM audit_events WHERE id IN ('audit-bundle6-001', 'audit-bundle6-002')`)
|
|
if err := row.Scan(&count); err != nil {
|
|
t.Fatalf("count query failed: %v", err)
|
|
}
|
|
if count != 2 {
|
|
t.Errorf("expected 2 rows, got %d (WORM trigger may be blocking INSERT)", count)
|
|
}
|
|
}
|