From 60e0707a83b42aea852d19b69cac3084239ac37e Mon Sep 17 00:00:00 2001 From: dj-oyu <68707227+dj-oyu@users.noreply.github.com> Date: Tue, 24 Feb 2026 05:09:21 +0900 Subject: [PATCH] fix: sanitize sensitive fields in log entries exposed via miniapp Log entries streamed over WebSocket and included in snapshots could contain sensitive values (tokens, API keys, passwords) in their Fields map. Add SanitizeFields() to mask values for keys matching patterns like token, key, secret, password, authorization, and credential. Applied in RecentLogs() (covers snapshots + wsLogs init) and the wsLogs real-time stream loop. Co-Authored-By: Claude Opus 4.6 --- pkg/logger/logger.go | 24 +++++++++- pkg/logger/logger_test.go | 93 +++++++++++++++++++++++++++++++++++++++ pkg/miniapp/miniapp.go | 3 +- 3 files changed, 118 insertions(+), 2 deletions(-) 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 }