mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 15:01:32 +00:00
7cb453a336
Mechanical reformat. The new 'gofmt drift' CI step (added in
ci-pipeline-cleanup Phase 4, commit 0f205a8) surfaced 111 files
with accumulated gofmt drift across cmd/, internal/, and deploy/test/.
Each file's diff is gofmt-standard: whitespace adjustments, intra-
group import sorting (alphabetical by import path within blank-line-
separated groups), and struct-tag column alignment. No semantic
changes — verified via 'git diff --ignore-all-space' which shows only
the line-position deltas from import reordering.
The gate stays in place after this commit. Going forward it catches
gofmt drift at PR time.
177 lines
5.9 KiB
Go
177 lines
5.9 KiB
Go
package service
|
|
|
|
import (
|
|
"strings"
|
|
)
|
|
|
|
// Bundle-6 / Audit H-008 + M-022 / CWE-532 (Insertion of Sensitive Information into Log File):
|
|
//
|
|
// Audit events flow into the audit_events.details JSONB column. Pre-Bundle-6,
|
|
// the middleware stored only `body_hash` (sha256 truncated) — no raw body —
|
|
// but service-layer call sites pass arbitrary map[string]interface{} details
|
|
// at every RecordEvent invocation. A future call site that accidentally
|
|
// includes a credential key (api_key, password, ACME EAB secret, etc.) or
|
|
// a PII key (email, phone, SSN, etc.) would persist plaintext into the
|
|
// append-only audit table.
|
|
//
|
|
// This file is the chokepoint that scrubs every details map BEFORE
|
|
// AuditService.RecordEvent marshals it. Two deny-lists:
|
|
//
|
|
// credentialKeys — value replaced with "[REDACTED:CREDENTIAL]"
|
|
// piiKeys — value replaced with "[REDACTED:PII]"
|
|
//
|
|
// The redacted entry surfaces in `details.redacted_keys` so operators can
|
|
// audit the redactor itself during a compliance review (GDPR Art. 30
|
|
// records-of-processing requires this transparency).
|
|
//
|
|
// Match semantics:
|
|
// - case-insensitive
|
|
// - structural: walks nested maps and arrays
|
|
// - exact key match (substring would over-redact — e.g. "tokenized_data")
|
|
//
|
|
// Compliance mapping:
|
|
// - GDPR Art. 32 (data minimization) — M-022
|
|
// - HIPAA §164.312(b) (audit controls) — paired with WORM trigger
|
|
// - PCI-DSS 4.0 Req 3 (protect stored PII) — paired with M-018 (deferred)
|
|
|
|
// credentialKeys are field names whose values must never appear in the
|
|
// audit log. Match is case-insensitive. Add new entries when a new
|
|
// credential-bearing field is introduced anywhere in the codebase.
|
|
var credentialKeys = map[string]bool{
|
|
"api_key": true,
|
|
"apikey": true,
|
|
"password": true,
|
|
"passphrase": true,
|
|
"secret": true,
|
|
"client_secret": true,
|
|
"token": true,
|
|
"access_token": true,
|
|
"refresh_token": true,
|
|
"bootstrap_token": true,
|
|
"credential": true,
|
|
"credentials": true,
|
|
"private_key": true,
|
|
"privatekey": true,
|
|
"private_key_pem": true,
|
|
"key_pem": true,
|
|
"cert_pem": true,
|
|
"chain_pem": true,
|
|
"full_pem": true,
|
|
"eab_secret": true,
|
|
"eab_kid": true,
|
|
"acme_account_key": true,
|
|
"hmac": true,
|
|
"hmac_key": true,
|
|
"signature": true,
|
|
"auth": true,
|
|
"authorization": true,
|
|
"bearer": true,
|
|
}
|
|
|
|
// piiKeys are field names that may carry personal data. Redacted by
|
|
// default; per-route opt-in retention is a future enhancement (post-Bundle-6).
|
|
// Note `ip_address` is debatable — useful for forensics but flagged by
|
|
// GDPR Art. 32 — defaulting to redact, operators can audit + adjust.
|
|
var piiKeys = map[string]bool{
|
|
"email": true,
|
|
"email_address": true,
|
|
"phone": true,
|
|
"phone_number": true,
|
|
"telephone": true,
|
|
"ssn": true,
|
|
"social_security": true,
|
|
"dob": true,
|
|
"date_of_birth": true,
|
|
"name": true,
|
|
"full_name": true,
|
|
"first_name": true,
|
|
"last_name": true,
|
|
"surname": true,
|
|
"address": true,
|
|
"street": true,
|
|
"street_address": true,
|
|
"city": true,
|
|
"postal_code": true,
|
|
"zip": true,
|
|
"zipcode": true,
|
|
"ip": true,
|
|
"ip_address": true,
|
|
}
|
|
|
|
// RedactDetailsForAudit walks a details map and returns a NEW map with
|
|
// credential + PII values scrubbed. The original map is NOT mutated (so
|
|
// service-layer code that reuses the map for other purposes is safe).
|
|
//
|
|
// The returned map is the original shape PLUS a `redacted_keys` array
|
|
// listing every key path that was scrubbed. The array surfaces redaction
|
|
// footprint to operators without exposing values.
|
|
//
|
|
// nil-in / empty-in returns nil so callers can pass through to
|
|
// json.Marshal which renders "null" — matches pre-Bundle-6 behaviour
|
|
// for nil-details RecordEvent calls.
|
|
func RedactDetailsForAudit(details map[string]interface{}) map[string]interface{} {
|
|
if len(details) == 0 {
|
|
return nil
|
|
}
|
|
|
|
out := make(map[string]interface{}, len(details)+1)
|
|
var redactedKeys []string
|
|
|
|
for k, v := range details {
|
|
lower := strings.ToLower(k)
|
|
switch {
|
|
case credentialKeys[lower]:
|
|
out[k] = "[REDACTED:CREDENTIAL]"
|
|
redactedKeys = append(redactedKeys, k)
|
|
case piiKeys[lower]:
|
|
out[k] = "[REDACTED:PII]"
|
|
redactedKeys = append(redactedKeys, k)
|
|
default:
|
|
// Recurse into nested maps + arrays so deeply-nested credentials
|
|
// don't bypass the redactor. Primitives pass through unchanged.
|
|
out[k] = redactValue(v, &redactedKeys, k)
|
|
}
|
|
}
|
|
|
|
if len(redactedKeys) > 0 {
|
|
// Surface the redaction footprint. If the caller accidentally
|
|
// passed `redacted_keys` themselves, prefer ours — the redactor's
|
|
// view of what was scrubbed is the load-bearing audit signal.
|
|
out["redacted_keys"] = redactedKeys
|
|
}
|
|
return out
|
|
}
|
|
|
|
// redactValue is the recursive arm of RedactDetailsForAudit. It walks
|
|
// arbitrary JSON-shaped values (map / slice / scalar) and returns a value
|
|
// with credential + PII keys scrubbed. Mutation-free.
|
|
func redactValue(v interface{}, redactedKeys *[]string, parentKey string) interface{} {
|
|
switch typed := v.(type) {
|
|
case map[string]interface{}:
|
|
nested := make(map[string]interface{}, len(typed))
|
|
for k, vv := range typed {
|
|
lower := strings.ToLower(k)
|
|
switch {
|
|
case credentialKeys[lower]:
|
|
nested[k] = "[REDACTED:CREDENTIAL]"
|
|
*redactedKeys = append(*redactedKeys, parentKey+"."+k)
|
|
case piiKeys[lower]:
|
|
nested[k] = "[REDACTED:PII]"
|
|
*redactedKeys = append(*redactedKeys, parentKey+"."+k)
|
|
default:
|
|
nested[k] = redactValue(vv, redactedKeys, parentKey+"."+k)
|
|
}
|
|
}
|
|
return nested
|
|
case []interface{}:
|
|
nested := make([]interface{}, len(typed))
|
|
for i, item := range typed {
|
|
nested[i] = redactValue(item, redactedKeys, parentKey)
|
|
}
|
|
return nested
|
|
default:
|
|
// scalar (string, number, bool, nil) — pass through unchanged.
|
|
return typed
|
|
}
|
|
}
|