diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go index 70809e654..97b6b62b1 100644 --- a/pkg/logger/logger.go +++ b/pkg/logger/logger.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "os" + "regexp" "runtime" "strings" "sync" @@ -111,6 +112,26 @@ type LogEntry struct { Caller string `json:"caller,omitempty"` } +var sensitiveKeyPattern = regexp.MustCompile(`(?i)(token|key|secret|password|authorization|credential)`) + +// SanitizeFields returns a copy of fields with sensitive values masked. +// Keys matching patterns like token, key, secret, password, authorization, +// or credential (case-insensitive) will have their values replaced with "***". +func SanitizeFields(fields map[string]any) map[string]any { + if len(fields) == 0 { + return fields + } + sanitized := make(map[string]any, len(fields)) + for k, v := range fields { + if sensitiveKeyPattern.MatchString(k) { + sanitized[k] = "***" + } else { + sanitized[k] = v + } + } + return sanitized +} + func init() { once.Do(func() { logger = &Logger{} @@ -337,7 +358,8 @@ func RecentLogs(minLevel LogLevel, component string, limit int) []LogEntry { if component != "" && e.Component != component { continue } - e.Caller = "" // strip for security + e.Caller = "" // strip for security + e.Fields = SanitizeFields(e.Fields) // mask sensitive values result = append(result, e) } // Reverse so oldest first diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go index 73fad89ac..ef7270380 100644 --- a/pkg/logger/logger_test.go +++ b/pkg/logger/logger_test.go @@ -292,6 +292,99 @@ func TestUnsubscribe_ClosesChannel(t *testing.T) { } } +func TestSanitizeFields(t *testing.T) { + tests := []struct { + name string + input map[string]any + maskedK []string // keys that should be "***" + safeK []string // keys that should keep original value + }{ + { + name: "nil fields", + input: nil, + maskedK: nil, + }, + { + name: "empty fields", + input: map[string]any{}, + maskedK: nil, + }, + { + name: "sensitive keys masked", + input: map[string]any{"token": "abc123", "api_key": "sk-xxx", "secret": "s3cr3t", "password": "pass", "authorization": "Bearer tok"}, + maskedK: []string{"token", "api_key", "secret", "password", "authorization"}, + }, + { + name: "case insensitive", + input: map[string]any{"Token": "abc", "API_KEY": "xyz", "Secret": "s", "PASSWORD": "p", "Authorization": "a", "Credential": "c"}, + maskedK: []string{"Token", "API_KEY", "Secret", "PASSWORD", "Authorization", "Credential"}, + }, + { + name: "safe keys preserved", + input: map[string]any{"error": "something failed", "count": 42, "user_id": "12345", "component": "test"}, + safeK: []string{"error", "count", "user_id", "component"}, + }, + { + name: "mixed keys", + input: map[string]any{"token": "sensitive", "msg_signature": "safe", "corp_secret": "sensitive2", "nonce": "safe2"}, + maskedK: []string{"token", "corp_secret"}, + safeK: []string{"msg_signature", "nonce"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SanitizeFields(tt.input) + for _, k := range tt.maskedK { + if v, ok := result[k]; !ok || v != "***" { + t.Errorf("expected key %q to be masked, got %v", k, v) + } + } + for _, k := range tt.safeK { + if result[k] != tt.input[k] { + t.Errorf("expected key %q to be preserved as %v, got %v", k, tt.input[k], result[k]) + } + } + }) + } +} + +func TestSanitizeFieldsDoesNotMutateOriginal(t *testing.T) { + original := map[string]any{"token": "secret_value", "name": "test"} + _ = SanitizeFields(original) + if original["token"] != "secret_value" { + t.Error("SanitizeFields should not mutate the original map") + } +} + +func TestRecentLogsSanitizesFields(t *testing.T) { + initialLevel := GetLevel() + defer SetLevel(initialLevel) + SetLevel(DEBUG) + + InfoCF("sanitize-test", "log with sensitive fields", map[string]any{ + "token": "my-secret-token", + "api_key": "sk-12345", + "user_id": "safe-value", + }) + + got := RecentLogs(DEBUG, "sanitize-test", 100) + if len(got) == 0 { + t.Fatal("expected at least one log entry") + } + + last := got[len(got)-1] + if last.Fields["token"] != "***" { + t.Errorf("expected token to be masked, got %v", last.Fields["token"]) + } + if last.Fields["api_key"] != "***" { + t.Errorf("expected api_key to be masked, got %v", last.Fields["api_key"]) + } + if last.Fields["user_id"] != "safe-value" { + t.Errorf("expected user_id to be preserved, got %v", last.Fields["user_id"]) + } +} + func TestParseLevel(t *testing.T) { tests := []struct { input string diff --git a/pkg/miniapp/miniapp.go b/pkg/miniapp/miniapp.go index 211f7629f..f9e332cb8 100644 --- a/pkg/miniapp/miniapp.go +++ b/pkg/miniapp/miniapp.go @@ -951,7 +951,8 @@ func (h *Handler) wsLogs(w http.ResponseWriter, r *http.Request) { if !ok { return } - entry.Caller = "" // strip for security + entry.Caller = "" // strip for security + entry.Fields = logger.SanitizeFields(entry.Fields) // mask sensitive values if err := conn.WriteJSON(map[string]any{"type": "entry", "entry": entry}); err != nil { return }