From 1c74c4d21529aa919249946d591b1ad75ebd22be Mon Sep 17 00:00:00 2001 From: atterpac Date: Tue, 16 Dec 2025 12:10:44 -0700 Subject: [PATCH 1/7] add core log sanitization with sensitive data redaction - Add Sanitizer type with configurable field/pattern-based redaction - Include default redaction for passwords, tokens, API keys, JWTs - Support case-insensitive substring matching for field names - Support regex patterns for value-based detection - Support recursive sanitization of nested JSON/maps - Add CustomSanitizeFunc for developer override control - Add comprehensive unit tests and benchmarks --- v3/pkg/application/sanitize.go | 274 +++++++++++++ v3/pkg/application/sanitize_test.go | 585 ++++++++++++++++++++++++++++ 2 files changed, 859 insertions(+) create mode 100644 v3/pkg/application/sanitize.go create mode 100644 v3/pkg/application/sanitize_test.go diff --git a/v3/pkg/application/sanitize.go b/v3/pkg/application/sanitize.go new file mode 100644 index 000000000..bd0860392 --- /dev/null +++ b/v3/pkg/application/sanitize.go @@ -0,0 +1,274 @@ +package application + +import ( + "encoding/json" + "regexp" + "strings" +) + +// DefaultRedactFields contains field names that are redacted by default. +// Matching is case-insensitive and uses substring/contains matching. +// For example, "password" will match "userPassword", "password_hash", etc. +var DefaultRedactFields = []string{ + // Authentication + "password", "passwd", "pwd", "pass", + "token", "bearer", "jwt", "access_token", "refresh_token", + "secret", "client_secret", + "apikey", "api_key", "api-key", + "auth", "authorization", "credential", + + // Cryptographic + "private", "privatekey", "private_key", + "signing", "encryption_key", + + // Session/Identity + "session", "sessionid", "session_id", + "cookie", "csrf", "xsrf", +} + +// DefaultRedactPatterns contains regex patterns that match sensitive values. +// These are applied to string values regardless of field name. +var DefaultRedactPatterns = []*regexp.Regexp{ + // JWT tokens (header.payload.signature format) + regexp.MustCompile(`eyJ[A-Za-z0-9_-]*\.eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*`), + // Bearer tokens in values + regexp.MustCompile(`(?i)bearer\s+[A-Za-z0-9_-]+`), + // Common API key formats (sk_xxx, pk_xxx with optional live/test prefix) + regexp.MustCompile(`(?i)(sk|pk)_(live|test)_[A-Za-z0-9]{10,}`), + // Generic API key formats (api_xxx, key_xxx with 20+ chars) + regexp.MustCompile(`(?i)(api|key)[-_][A-Za-z0-9]{20,}`), +} + +// DefaultReplacement is the default string used to replace redacted values. +const DefaultReplacement = "***" + +// SanitizeOptions configures automatic redaction of sensitive data +// in logs and other output. +type SanitizeOptions struct { + // RedactFields specifies additional field names to redact. + // Matching is case-insensitive and uses substring matching. + // These are merged with DefaultRedactFields unless DisableDefaults is true. + // Example: []string{"ssn", "credit_card", "dob"} + RedactFields []string + + // RedactPatterns specifies additional regex patterns to match against string values. + // Matching values are replaced with the Replacement string. + // These are merged with DefaultRedactPatterns unless DisableDefaults is true. + // Example: []*regexp.Regexp{regexp.MustCompile(`\b\d{3}-\d{2}-\d{4}\b`)} // SSN + RedactPatterns []*regexp.Regexp + + // CustomSanitizeFunc provides full control over sanitization. + // When set, this function is called for every key-value pair. + // Return (newValue, true) to use newValue, or (_, false) to use default logic. + // + // Parameters: + // - key: The field name (e.g., "password") + // - value: The original value + // - path: Full dot-separated path (e.g., "args.user.password") + // + // Example: + // + // CustomSanitizeFunc: func(key string, value any, path string) (any, bool) { + // if strings.HasPrefix(path, "args.user.") { + // return "***", true + // } + // return nil, false // Use default sanitization + // } + CustomSanitizeFunc func(key string, value any, path string) (any, bool) + + // DisableDefaults disables the default redact fields and patterns. + // When true, only explicitly specified RedactFields and RedactPatterns apply. + DisableDefaults bool + + // Replacement is the string used to replace redacted values. + // Default: "***" + Replacement string + + // Disabled completely disables sanitization. + // Use with caution - only for debugging sanitization issues. + Disabled bool +} + +// Sanitizer handles redaction of sensitive data in logs and other output. +type Sanitizer struct { + fields []string + patterns []*regexp.Regexp + customFunc func(key string, value any, path string) (any, bool) + replacement string + disabled bool +} + +// NewSanitizer creates a new Sanitizer with the given options. +// If opts is nil, default sanitization is applied. +func NewSanitizer(opts *SanitizeOptions) *Sanitizer { + s := &Sanitizer{ + replacement: DefaultReplacement, + } + + if opts == nil { + // Default configuration + s.fields = DefaultRedactFields + s.patterns = DefaultRedactPatterns + return s + } + + if opts.Disabled { + s.disabled = true + return s + } + + if opts.Replacement != "" { + s.replacement = opts.Replacement + } + + s.customFunc = opts.CustomSanitizeFunc + + // Build field list + if opts.DisableDefaults { + s.fields = opts.RedactFields + s.patterns = opts.RedactPatterns + } else { + // Merge with defaults + s.fields = make([]string, 0, len(DefaultRedactFields)+len(opts.RedactFields)) + s.fields = append(s.fields, DefaultRedactFields...) + s.fields = append(s.fields, opts.RedactFields...) + + s.patterns = make([]*regexp.Regexp, 0, len(DefaultRedactPatterns)+len(opts.RedactPatterns)) + s.patterns = append(s.patterns, DefaultRedactPatterns...) + s.patterns = append(s.patterns, opts.RedactPatterns...) + } + + return s +} + +// SanitizeValue sanitizes a single value based on its key and path. +// The path is the full dot-separated path to the value (e.g., "args.user.password"). +func (s *Sanitizer) SanitizeValue(key string, value any, path string) any { + if s.disabled { + return value + } + + // Check custom function first + if s.customFunc != nil { + if newValue, handled := s.customFunc(key, value, path); handled { + return newValue + } + } + + // Check if key matches any redact field (case-insensitive contains) + keyLower := strings.ToLower(key) + for _, field := range s.fields { + if strings.Contains(keyLower, strings.ToLower(field)) { + return s.replacement + } + } + + // For string values, check patterns + if str, ok := value.(string); ok { + for _, pattern := range s.patterns { + if pattern.MatchString(str) { + return s.replacement + } + } + } + + // Recursively handle nested structures + switch v := value.(type) { + case map[string]any: + return s.sanitizeMapInternal(v, path) + case []any: + return s.sanitizeSlice(v, path) + case json.RawMessage: + return s.SanitizeJSON(v) + } + + return value +} + +// SanitizeMap sanitizes all values in a map, recursively processing nested structures. +func (s *Sanitizer) SanitizeMap(m map[string]any) map[string]any { + if s.disabled || m == nil { + return m + } + return s.sanitizeMapInternal(m, "") +} + +func (s *Sanitizer) sanitizeMapInternal(m map[string]any, parentPath string) map[string]any { + result := make(map[string]any, len(m)) + for k, v := range m { + path := k + if parentPath != "" { + path = parentPath + "." + k + } + result[k] = s.SanitizeValue(k, v, path) + } + return result +} + +func (s *Sanitizer) sanitizeSlice(slice []any, parentPath string) []any { + result := make([]any, len(slice)) + for i, v := range slice { + // For array elements, use index in path + path := parentPath + if path != "" { + path = parentPath + "[]" + } + // Array elements don't have a "key" for field matching, + // but we still process nested structures and check patterns + result[i] = s.SanitizeValue("", v, path) + } + return result +} + +// SanitizeJSON sanitizes JSON data, returning sanitized JSON. +// If the input is invalid JSON, it returns the original data unchanged. +func (s *Sanitizer) SanitizeJSON(data []byte) []byte { + if s.disabled || len(data) == 0 { + return data + } + + var parsed any + if err := json.Unmarshal(data, &parsed); err != nil { + // Not valid JSON, return as-is + // But still check if the raw string matches any patterns + str := string(data) + for _, pattern := range s.patterns { + if pattern.MatchString(str) { + return []byte(`"` + s.replacement + `"`) + } + } + return data + } + + sanitized := s.SanitizeValue("", parsed, "") + result, err := json.Marshal(sanitized) + if err != nil { + return data + } + return result +} + +// SanitizeString checks if a string matches any redact patterns and returns +// the replacement string if so, otherwise returns the original string. +func (s *Sanitizer) SanitizeString(str string) string { + if s.disabled { + return str + } + + for _, pattern := range s.patterns { + if pattern.MatchString(str) { + return s.replacement + } + } + return str +} + +// IsDisabled returns true if sanitization is disabled. +func (s *Sanitizer) IsDisabled() bool { + return s.disabled +} + +// Replacement returns the replacement string used for redacted values. +func (s *Sanitizer) Replacement() string { + return s.replacement +} diff --git a/v3/pkg/application/sanitize_test.go b/v3/pkg/application/sanitize_test.go new file mode 100644 index 000000000..24d0641e2 --- /dev/null +++ b/v3/pkg/application/sanitize_test.go @@ -0,0 +1,585 @@ +package application + +import ( + "encoding/json" + "regexp" + "strings" + "testing" +) + +func TestNewSanitizer_Defaults(t *testing.T) { + s := NewSanitizer(nil) + + if s.IsDisabled() { + t.Error("expected sanitizer to be enabled by default") + } + if s.Replacement() != DefaultReplacement { + t.Errorf("expected replacement %q, got %q", DefaultReplacement, s.Replacement()) + } + if len(s.fields) != len(DefaultRedactFields) { + t.Errorf("expected %d default fields, got %d", len(DefaultRedactFields), len(s.fields)) + } + if len(s.patterns) != len(DefaultRedactPatterns) { + t.Errorf("expected %d default patterns, got %d", len(DefaultRedactPatterns), len(s.patterns)) + } +} + +func TestNewSanitizer_Disabled(t *testing.T) { + s := NewSanitizer(&SanitizeOptions{Disabled: true}) + + if !s.IsDisabled() { + t.Error("expected sanitizer to be disabled") + } + + // Disabled sanitizer should pass through values unchanged + result := s.SanitizeValue("password", "secret123", "password") + if result != "secret123" { + t.Errorf("expected unchanged value, got %v", result) + } +} + +func TestNewSanitizer_CustomReplacement(t *testing.T) { + s := NewSanitizer(&SanitizeOptions{Replacement: "[REDACTED]"}) + + if s.Replacement() != "[REDACTED]" { + t.Errorf("expected replacement [REDACTED], got %q", s.Replacement()) + } + + result := s.SanitizeValue("password", "secret123", "password") + if result != "[REDACTED]" { + t.Errorf("expected [REDACTED], got %v", result) + } +} + +func TestNewSanitizer_DisableDefaults(t *testing.T) { + s := NewSanitizer(&SanitizeOptions{ + DisableDefaults: true, + RedactFields: []string{"custom_field"}, + }) + + // Default field should not be redacted + result := s.SanitizeValue("password", "secret123", "password") + if result == s.Replacement() { + t.Error("expected password to NOT be redacted when defaults disabled") + } + + // Custom field should be redacted + result = s.SanitizeValue("custom_field", "value", "custom_field") + if result != s.Replacement() { + t.Error("expected custom_field to be redacted") + } +} + +func TestNewSanitizer_MergeFields(t *testing.T) { + s := NewSanitizer(&SanitizeOptions{ + RedactFields: []string{"custom_field"}, + }) + + // Default field should still be redacted + result := s.SanitizeValue("password", "secret123", "password") + if result != s.Replacement() { + t.Error("expected password to be redacted") + } + + // Custom field should also be redacted + result = s.SanitizeValue("custom_field", "value", "custom_field") + if result != s.Replacement() { + t.Error("expected custom_field to be redacted") + } +} + +func TestSanitizeValue_FieldMatching(t *testing.T) { + s := NewSanitizer(nil) + + tests := []struct { + name string + key string + value any + expected any + }{ + // Exact matches + {"exact password", "password", "secret", "***"}, + {"exact token", "token", "abc123", "***"}, + {"exact secret", "secret", "mysecret", "***"}, + {"exact apikey", "apikey", "key123", "***"}, + + // Case insensitive + {"uppercase PASSWORD", "PASSWORD", "secret", "***"}, + {"mixed case Token", "Token", "abc123", "***"}, + {"mixed case API_KEY", "API_KEY", "key123", "***"}, + + // Substring/contains matching + {"userPassword contains password", "userPassword", "secret", "***"}, + {"password_hash contains password", "password_hash", "hash", "***"}, + {"auth_token contains token", "auth_token", "abc", "***"}, + {"jwt_token contains token", "jwt_token", "xyz", "***"}, + {"MySecretKey contains secret", "MySecretKey", "value", "***"}, + + // Non-sensitive fields should pass through + {"username not sensitive", "username", "john", "john"}, + {"email not sensitive", "email", "john@example.com", "john@example.com"}, + {"count not sensitive", "count", 42, 42}, + {"enabled not sensitive", "enabled", true, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := s.SanitizeValue(tt.key, tt.value, tt.key) + if result != tt.expected { + t.Errorf("expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestSanitizeValue_PatternMatching(t *testing.T) { + s := NewSanitizer(nil) + + tests := []struct { + name string + key string + value string + shouldRedact bool + }{ + // JWT tokens + {"JWT token", "data", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U", true}, + + // Bearer tokens + {"Bearer token", "header", "Bearer abc123xyz789", true}, + {"bearer lowercase", "header", "bearer mytoken123", true}, + + // API keys (Stripe-style sk_live_xxx / pk_test_xxx) + {"sk_live API key", "key", "sk_live_abcdefghij12", true}, + {"pk_test API key", "key", "pk_test_abcdefghij12", true}, + // Generic API keys (api_xxx with 20+ chars) + {"api_ key", "key", "api_abcdefghij12345678901234", true}, + + // Non-matching + {"normal string", "message", "Hello, World!", false}, + {"short sk key", "key", "sk_live_short", false}, // Too short (needs 10+ after prefix) + {"email", "data", "user@example.com", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := s.SanitizeValue(tt.key, tt.value, tt.key) + if tt.shouldRedact { + if result != s.Replacement() { + t.Errorf("expected value to be redacted, got %v", result) + } + } else { + if result == s.Replacement() { + t.Errorf("expected value to NOT be redacted, got %v", result) + } + } + }) + } +} + +func TestSanitizeValue_NestedMap(t *testing.T) { + s := NewSanitizer(nil) + + input := map[string]any{ + "user": map[string]any{ + "name": "John", + "password": "secret123", + "settings": map[string]any{ + "theme": "dark", + "api_token": "mytoken", + }, + }, + "count": 42, + } + + result := s.SanitizeValue("data", input, "data") + resultMap, ok := result.(map[string]any) + if !ok { + t.Fatalf("expected map result, got %T", result) + } + + userMap := resultMap["user"].(map[string]any) + if userMap["name"] != "John" { + t.Errorf("expected name to be unchanged, got %v", userMap["name"]) + } + if userMap["password"] != "***" { + t.Errorf("expected password to be redacted, got %v", userMap["password"]) + } + + settingsMap := userMap["settings"].(map[string]any) + if settingsMap["theme"] != "dark" { + t.Errorf("expected theme to be unchanged, got %v", settingsMap["theme"]) + } + if settingsMap["api_token"] != "***" { + t.Errorf("expected api_token to be redacted, got %v", settingsMap["api_token"]) + } + + if resultMap["count"] != 42 { + t.Errorf("expected count to be unchanged, got %v", resultMap["count"]) + } +} + +func TestSanitizeValue_Slice(t *testing.T) { + s := NewSanitizer(nil) + + input := []any{ + "normal string", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature123", + map[string]any{ + "password": "secret", + "name": "test", + }, + } + + result := s.SanitizeValue("items", input, "items") + resultSlice, ok := result.([]any) + if !ok { + t.Fatalf("expected slice result, got %T", result) + } + + if resultSlice[0] != "normal string" { + t.Errorf("expected first element unchanged, got %v", resultSlice[0]) + } + if resultSlice[1] != "***" { + t.Errorf("expected JWT to be redacted, got %v", resultSlice[1]) + } + + mapElement := resultSlice[2].(map[string]any) + if mapElement["password"] != "***" { + t.Errorf("expected password in slice element to be redacted, got %v", mapElement["password"]) + } + if mapElement["name"] != "test" { + t.Errorf("expected name in slice element unchanged, got %v", mapElement["name"]) + } +} + +func TestSanitizeMap(t *testing.T) { + s := NewSanitizer(nil) + + input := map[string]any{ + "username": "john", + "password": "secret123", + "accessToken": "token123", + "data": map[string]any{ + "secret_key": "mysecret", + "public_id": "pub123", + }, + } + + result := s.SanitizeMap(input) + + if result["username"] != "john" { + t.Errorf("expected username unchanged, got %v", result["username"]) + } + if result["password"] != "***" { + t.Errorf("expected password redacted, got %v", result["password"]) + } + if result["accessToken"] != "***" { + t.Errorf("expected accessToken redacted, got %v", result["accessToken"]) + } + + dataMap := result["data"].(map[string]any) + if dataMap["secret_key"] != "***" { + t.Errorf("expected secret_key redacted, got %v", dataMap["secret_key"]) + } + if dataMap["public_id"] != "pub123" { + t.Errorf("expected public_id unchanged, got %v", dataMap["public_id"]) + } +} + +func TestSanitizeMap_Nil(t *testing.T) { + s := NewSanitizer(nil) + result := s.SanitizeMap(nil) + if result != nil { + t.Errorf("expected nil result for nil input, got %v", result) + } +} + +func TestSanitizeJSON(t *testing.T) { + s := NewSanitizer(nil) + + input := `{"username":"john","password":"secret123","nested":{"token":"abc"}}` + result := s.SanitizeJSON([]byte(input)) + + var parsed map[string]any + if err := json.Unmarshal(result, &parsed); err != nil { + t.Fatalf("failed to parse result JSON: %v", err) + } + + if parsed["username"] != "john" { + t.Errorf("expected username unchanged, got %v", parsed["username"]) + } + if parsed["password"] != "***" { + t.Errorf("expected password redacted, got %v", parsed["password"]) + } + + nested := parsed["nested"].(map[string]any) + if nested["token"] != "***" { + t.Errorf("expected nested token redacted, got %v", nested["token"]) + } +} + +func TestSanitizeJSON_InvalidJSON(t *testing.T) { + s := NewSanitizer(nil) + + // Invalid JSON should be returned as-is (unless it matches a pattern) + input := []byte("not valid json") + result := s.SanitizeJSON(input) + if string(result) != string(input) { + t.Errorf("expected invalid JSON to be returned unchanged, got %s", result) + } + + // But if it contains a pattern match, it should be redacted + inputWithJWT := []byte("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig") + result = s.SanitizeJSON(inputWithJWT) + if string(result) != `"***"` { + t.Errorf("expected JWT in invalid JSON to be redacted, got %s", result) + } +} + +func TestSanitizeJSON_Empty(t *testing.T) { + s := NewSanitizer(nil) + result := s.SanitizeJSON([]byte{}) + if len(result) != 0 { + t.Errorf("expected empty result for empty input, got %s", result) + } +} + +func TestSanitizeString(t *testing.T) { + s := NewSanitizer(nil) + + // Normal string - unchanged + result := s.SanitizeString("Hello, World!") + if result != "Hello, World!" { + t.Errorf("expected unchanged string, got %s", result) + } + + // JWT - redacted + result = s.SanitizeString("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig") + if result != "***" { + t.Errorf("expected JWT redacted, got %s", result) + } +} + +func TestCustomSanitizeFunc(t *testing.T) { + s := NewSanitizer(&SanitizeOptions{ + CustomSanitizeFunc: func(key string, value any, path string) (any, bool) { + // Custom handling for user paths + if strings.HasPrefix(path, "user.") { + return "[USER_DATA]", true + } + // Let defaults handle password + if key == "password" { + return nil, false + } + return nil, false + }, + }) + + // Custom function should handle user paths + result := s.SanitizeValue("email", "john@example.com", "user.email") + if result != "[USER_DATA]" { + t.Errorf("expected custom handling for user path, got %v", result) + } + + // Default should still handle password + result = s.SanitizeValue("password", "secret", "password") + if result != "***" { + t.Errorf("expected default handling for password, got %v", result) + } + + // Non-sensitive field should pass through + result = s.SanitizeValue("count", 42, "count") + if result != 42 { + t.Errorf("expected count unchanged, got %v", result) + } +} + +func TestCustomSanitizeFunc_FullOverride(t *testing.T) { + s := NewSanitizer(&SanitizeOptions{ + CustomSanitizeFunc: func(key string, value any, path string) (any, bool) { + // Handle everything - even "password" should use custom logic + if key == "password" { + return "[CUSTOM_REDACTED]", true + } + return value, true // Return original for everything else + }, + }) + + // Custom function overrides default password handling + result := s.SanitizeValue("password", "secret", "password") + if result != "[CUSTOM_REDACTED]" { + t.Errorf("expected custom redaction, got %v", result) + } + + // Custom function returns original for other fields + result = s.SanitizeValue("token", "abc123", "token") + if result != "abc123" { + t.Errorf("expected token unchanged by custom func, got %v", result) + } +} + +func TestCustomPatterns(t *testing.T) { + // Custom pattern for SSN + ssnPattern := regexp.MustCompile(`\b\d{3}-\d{2}-\d{4}\b`) + + s := NewSanitizer(&SanitizeOptions{ + RedactPatterns: []*regexp.Regexp{ssnPattern}, + }) + + // SSN should be redacted + result := s.SanitizeValue("data", "SSN: 123-45-6789", "data") + if result != "***" { + t.Errorf("expected SSN redacted, got %v", result) + } + + // Default patterns should still work + result = s.SanitizeValue("data", "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig", "data") + if result != "***" { + t.Errorf("expected JWT still redacted, got %v", result) + } +} + +func TestSanitizeValue_JSONRawMessage(t *testing.T) { + s := NewSanitizer(nil) + + input := json.RawMessage(`{"password":"secret","name":"test"}`) + result := s.SanitizeValue("data", input, "data") + + resultBytes, ok := result.([]byte) + if !ok { + t.Fatalf("expected []byte result, got %T", result) + } + + var parsed map[string]any + if err := json.Unmarshal(resultBytes, &parsed); err != nil { + t.Fatalf("failed to parse result: %v", err) + } + + if parsed["password"] != "***" { + t.Errorf("expected password redacted in RawMessage, got %v", parsed["password"]) + } + if parsed["name"] != "test" { + t.Errorf("expected name unchanged, got %v", parsed["name"]) + } +} + +func TestPathTracking(t *testing.T) { + var capturedPaths []string + + s := NewSanitizer(&SanitizeOptions{ + CustomSanitizeFunc: func(key string, value any, path string) (any, bool) { + capturedPaths = append(capturedPaths, path) + return nil, false // Let defaults handle it + }, + }) + + input := map[string]any{ + "user": map[string]any{ + "password": "secret", + "profile": map[string]any{ + "name": "John", + }, + }, + } + + s.SanitizeMap(input) + + expectedPaths := []string{ + "user", + "user.password", + "user.profile", + "user.profile.name", + } + + for _, expected := range expectedPaths { + found := false + for _, captured := range capturedPaths { + if captured == expected { + found = true + break + } + } + if !found { + t.Errorf("expected path %q to be captured, got paths: %v", expected, capturedPaths) + } + } +} + +// Benchmark tests +func BenchmarkSanitizeMap_Small(b *testing.B) { + s := NewSanitizer(nil) + input := map[string]any{ + "username": "john", + "password": "secret123", + "email": "john@example.com", + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + s.SanitizeMap(input) + } +} + +func BenchmarkSanitizeMap_Nested(b *testing.B) { + s := NewSanitizer(nil) + input := map[string]any{ + "user": map[string]any{ + "name": "John", + "password": "secret123", + "settings": map[string]any{ + "theme": "dark", + "api_token": "mytoken", + }, + }, + "metadata": map[string]any{ + "created": "2024-01-01", + "secret": "hidden", + }, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + s.SanitizeMap(input) + } +} + +func BenchmarkSanitizeJSON(b *testing.B) { + s := NewSanitizer(nil) + input := []byte(`{"username":"john","password":"secret123","nested":{"token":"abc","data":"value"}}`) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + s.SanitizeJSON(input) + } +} + +func BenchmarkSanitizeValue_NoRedaction(b *testing.B) { + s := NewSanitizer(nil) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + s.SanitizeValue("username", "john", "username") + } +} + +func BenchmarkSanitizeValue_WithRedaction(b *testing.B) { + s := NewSanitizer(nil) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + s.SanitizeValue("password", "secret123", "password") + } +} + +func BenchmarkSanitizer_Disabled(b *testing.B) { + s := NewSanitizer(&SanitizeOptions{Disabled: true}) + input := map[string]any{ + "username": "john", + "password": "secret123", + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + s.SanitizeMap(input) + } +} From 51d9888513d2dd758e0606630817693fa82b74ec Mon Sep 17 00:00:00 2001 From: atterpac Date: Tue, 16 Dec 2025 12:34:36 -0700 Subject: [PATCH 2/7] integrate log sanitization into application - Add SanitizingHandler to wrap slog.Handler for automatic sanitization - Add SanitizeOptions to application Options struct - Wrap application logger with sanitizing handler on initialization - Expose Sanitizer() method on App for public API access - Add comprehensive handler tests --- v3/pkg/application/application.go | 13 + v3/pkg/application/application_options.go | 5 + v3/pkg/application/sanitize_handler.go | 182 ++++++++++++++ v3/pkg/application/sanitize_test.go | 284 ++++++++++++++++++++++ 4 files changed, 484 insertions(+) create mode 100644 v3/pkg/application/sanitize_handler.go diff --git a/v3/pkg/application/application.go b/v3/pkg/application/application.go index 5a8c6f068..cb45a1c1f 100644 --- a/v3/pkg/application/application.go +++ b/v3/pkg/application/application.go @@ -58,6 +58,12 @@ func New(appOptions Options) *App { } } + // Initialize sanitizer and wrap logger + result.sanitizer = NewSanitizer(appOptions.SanitizeOptions) + if !result.sanitizer.IsDisabled() { + result.Logger = WrapLoggerWithSanitizer(result.Logger, result.sanitizer) + } + // Set up signal handling (platform-specific) result.setupSignalHandler(appOptions) @@ -378,6 +384,7 @@ type App struct { clipboard *Clipboard customEventProcessor *EventProcessor Logger *slog.Logger + sanitizer *Sanitizer contextMenus map[string]*ContextMenu contextMenusLock sync.RWMutex @@ -422,6 +429,12 @@ func (a *App) Config() Options { return a.options } +// Sanitizer returns the application's sanitizer for redacting sensitive data. +// Use this to sanitize your own data using the same rules as the application's log sanitization. +func (a *App) Sanitizer() *Sanitizer { + return a.sanitizer +} + // Context returns the application context that is canceled when the application shuts down. // This context should be used for graceful shutdown of goroutines and long-running operations. func (a *App) Context() context.Context { diff --git a/v3/pkg/application/application_options.go b/v3/pkg/application/application_options.go index 5e9eb57e2..f24dbaab2 100644 --- a/v3/pkg/application/application_options.go +++ b/v3/pkg/application/application_options.go @@ -58,6 +58,11 @@ type Options struct { // LogLevel defines the log level of the Wails system logger. LogLevel slog.Level + // SanitizeOptions configures automatic redaction of sensitive data in logs. + // If nil, default sanitization is applied (common secret field patterns are redacted). + // Set SanitizeOptions.Disabled to true to disable all sanitization. + SanitizeOptions *SanitizeOptions + // Assets are the application assets to be used. Assets AssetOptions diff --git a/v3/pkg/application/sanitize_handler.go b/v3/pkg/application/sanitize_handler.go new file mode 100644 index 000000000..29ce78a2d --- /dev/null +++ b/v3/pkg/application/sanitize_handler.go @@ -0,0 +1,182 @@ +package application + +import ( + "context" + "log/slog" +) + +// SanitizingHandler wraps a slog.Handler to sanitize sensitive data in log attributes. +// All log attributes are passed through the Sanitizer before being forwarded to the +// underlying handler. +type SanitizingHandler struct { + handler slog.Handler + sanitizer *Sanitizer + groups []string // Track nested groups for path building +} + +// NewSanitizingHandler creates a new SanitizingHandler that wraps the given handler. +// If sanitizer is nil, a default sanitizer is created. +func NewSanitizingHandler(handler slog.Handler, sanitizer *Sanitizer) *SanitizingHandler { + if sanitizer == nil { + sanitizer = NewSanitizer(nil) + } + return &SanitizingHandler{ + handler: handler, + sanitizer: sanitizer, + } +} + +// Enabled reports whether the handler handles records at the given level. +func (h *SanitizingHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.handler.Enabled(ctx, level) +} + +// Handle sanitizes the record's attributes and forwards to the underlying handler. +func (h *SanitizingHandler) Handle(ctx context.Context, record slog.Record) error { + if h.sanitizer.IsDisabled() { + return h.handler.Handle(ctx, record) + } + + // Create a new record with sanitized attributes + newRecord := slog.NewRecord(record.Time, record.Level, record.Message, record.PC) + + // Sanitize each attribute + record.Attrs(func(attr slog.Attr) bool { + sanitized := h.sanitizeAttr(attr, h.buildPath("")) + newRecord.AddAttrs(sanitized) + return true + }) + + return h.handler.Handle(ctx, newRecord) +} + +// WithAttrs returns a new handler with the given attributes added. +// The attributes are sanitized before being stored. +func (h *SanitizingHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + if h.sanitizer.IsDisabled() { + return &SanitizingHandler{ + handler: h.handler.WithAttrs(attrs), + sanitizer: h.sanitizer, + groups: h.groups, + } + } + + // Sanitize attributes before passing to underlying handler + sanitizedAttrs := make([]slog.Attr, len(attrs)) + for i, attr := range attrs { + sanitizedAttrs[i] = h.sanitizeAttr(attr, h.buildPath("")) + } + + return &SanitizingHandler{ + handler: h.handler.WithAttrs(sanitizedAttrs), + sanitizer: h.sanitizer, + groups: h.groups, + } +} + +// WithGroup returns a new handler with the given group name. +func (h *SanitizingHandler) WithGroup(name string) slog.Handler { + newGroups := make([]string, len(h.groups), len(h.groups)+1) + copy(newGroups, h.groups) + newGroups = append(newGroups, name) + + return &SanitizingHandler{ + handler: h.handler.WithGroup(name), + sanitizer: h.sanitizer, + groups: newGroups, + } +} + +// buildPath constructs the full path for an attribute key. +func (h *SanitizingHandler) buildPath(key string) string { + if len(h.groups) == 0 { + return key + } + + path := "" + for _, g := range h.groups { + if path != "" { + path += "." + } + path += g + } + if key != "" { + if path != "" { + path += "." + } + path += key + } + return path +} + +// sanitizeAttr recursively sanitizes an slog.Attr. +func (h *SanitizingHandler) sanitizeAttr(attr slog.Attr, parentPath string) slog.Attr { + key := attr.Key + path := key + if parentPath != "" { + path = parentPath + "." + key + } + + // Handle group attributes (nested) + if attr.Value.Kind() == slog.KindGroup { + groupAttrs := attr.Value.Group() + sanitizedGroup := make([]slog.Attr, len(groupAttrs)) + for i, ga := range groupAttrs { + sanitizedGroup[i] = h.sanitizeAttr(ga, path) + } + return slog.Attr{Key: key, Value: slog.GroupValue(sanitizedGroup...)} + } + + // Sanitize the value + sanitizedValue := h.sanitizeValue(key, attr.Value, path) + return slog.Attr{Key: key, Value: sanitizedValue} +} + +// sanitizeValue sanitizes an slog.Value using the Sanitizer. +func (h *SanitizingHandler) sanitizeValue(key string, value slog.Value, path string) slog.Value { + // Resolve LogValuer interfaces + value = value.Resolve() + + switch value.Kind() { + case slog.KindString: + sanitized := h.sanitizer.SanitizeValue(key, value.String(), path) + if str, ok := sanitized.(string); ok { + return slog.StringValue(str) + } + return slog.AnyValue(sanitized) + + case slog.KindAny: + anyVal := value.Any() + sanitized := h.sanitizer.SanitizeValue(key, anyVal, path) + return slog.AnyValue(sanitized) + + case slog.KindGroup: + // Already handled in sanitizeAttr + return value + + default: + // For other kinds (Int64, Uint64, Float64, Bool, Time, Duration), + // we still need to check if the key matches a redact field + // Convert to any and sanitize + anyVal := value.Any() + sanitized := h.sanitizer.SanitizeValue(key, anyVal, path) + + // If it was redacted (became a string), return as string + if str, ok := sanitized.(string); ok && str == h.sanitizer.Replacement() { + return slog.StringValue(str) + } + + // Otherwise return original value + return value + } +} + +// WrapLoggerWithSanitizer wraps an existing slog.Logger with sanitization. +// This is a convenience function for wrapping a logger. +func WrapLoggerWithSanitizer(logger *slog.Logger, sanitizer *Sanitizer) *slog.Logger { + if logger == nil { + return nil + } + handler := NewSanitizingHandler(logger.Handler(), sanitizer) + return slog.New(handler) +} diff --git a/v3/pkg/application/sanitize_test.go b/v3/pkg/application/sanitize_test.go index 24d0641e2..0e1c3d072 100644 --- a/v3/pkg/application/sanitize_test.go +++ b/v3/pkg/application/sanitize_test.go @@ -1,7 +1,9 @@ package application import ( + "context" "encoding/json" + "log/slog" "regexp" "strings" "testing" @@ -583,3 +585,285 @@ func BenchmarkSanitizer_Disabled(b *testing.B) { s.SanitizeMap(input) } } + +// SanitizingHandler tests + +type testLogEntry struct { + Level slog.Level + Message string + Attrs map[string]any +} + +type testHandler struct { + entries []testLogEntry +} + +func (h *testHandler) Enabled(_ context.Context, _ slog.Level) bool { + return true +} + +func (h *testHandler) Handle(_ context.Context, record slog.Record) error { + entry := testLogEntry{ + Level: record.Level, + Message: record.Message, + Attrs: make(map[string]any), + } + record.Attrs(func(attr slog.Attr) bool { + h.collectAttr(entry.Attrs, "", attr) + return true + }) + h.entries = append(h.entries, entry) + return nil +} + +func (h *testHandler) collectAttr(m map[string]any, prefix string, attr slog.Attr) { + key := attr.Key + if prefix != "" { + key = prefix + "." + key + } + + if attr.Value.Kind() == slog.KindGroup { + for _, ga := range attr.Value.Group() { + h.collectAttr(m, key, ga) + } + return + } + + m[key] = attr.Value.Any() +} + +func (h *testHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return h +} + +func (h *testHandler) WithGroup(name string) slog.Handler { + return h +} + +func TestSanitizingHandler_Basic(t *testing.T) { + underlying := &testHandler{} + sanitizer := NewSanitizer(nil) + handler := NewSanitizingHandler(underlying, sanitizer) + logger := slog.New(handler) + + logger.Info("test message", + "username", "john", + "password", "secret123", + "count", 42, + ) + + if len(underlying.entries) != 1 { + t.Fatalf("expected 1 log entry, got %d", len(underlying.entries)) + } + + entry := underlying.entries[0] + if entry.Message != "test message" { + t.Errorf("expected message 'test message', got %q", entry.Message) + } + if entry.Attrs["username"] != "john" { + t.Errorf("expected username 'john', got %v", entry.Attrs["username"]) + } + if entry.Attrs["password"] != "***" { + t.Errorf("expected password '***', got %v", entry.Attrs["password"]) + } + if entry.Attrs["count"] != int64(42) { + t.Errorf("expected count 42, got %v", entry.Attrs["count"]) + } +} + +func TestSanitizingHandler_NestedGroup(t *testing.T) { + underlying := &testHandler{} + sanitizer := NewSanitizer(nil) + handler := NewSanitizingHandler(underlying, sanitizer) + logger := slog.New(handler) + + logger.Info("test message", + slog.Group("user", + slog.String("name", "john"), + slog.String("password", "secret123"), + ), + ) + + if len(underlying.entries) != 1 { + t.Fatalf("expected 1 log entry, got %d", len(underlying.entries)) + } + + entry := underlying.entries[0] + if entry.Attrs["user.name"] != "john" { + t.Errorf("expected user.name 'john', got %v", entry.Attrs["user.name"]) + } + if entry.Attrs["user.password"] != "***" { + t.Errorf("expected user.password '***', got %v", entry.Attrs["user.password"]) + } +} + +func TestSanitizingHandler_PatternMatching(t *testing.T) { + underlying := &testHandler{} + sanitizer := NewSanitizer(nil) + handler := NewSanitizingHandler(underlying, sanitizer) + logger := slog.New(handler) + + jwt := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.signature" + logger.Info("auth", + "data", jwt, + "normal", "hello", + ) + + if len(underlying.entries) != 1 { + t.Fatalf("expected 1 log entry, got %d", len(underlying.entries)) + } + + entry := underlying.entries[0] + if entry.Attrs["data"] != "***" { + t.Errorf("expected JWT redacted, got %v", entry.Attrs["data"]) + } + if entry.Attrs["normal"] != "hello" { + t.Errorf("expected normal 'hello', got %v", entry.Attrs["normal"]) + } +} + +func TestSanitizingHandler_Disabled(t *testing.T) { + underlying := &testHandler{} + sanitizer := NewSanitizer(&SanitizeOptions{Disabled: true}) + handler := NewSanitizingHandler(underlying, sanitizer) + logger := slog.New(handler) + + logger.Info("test", "password", "secret123") + + if len(underlying.entries) != 1 { + t.Fatalf("expected 1 log entry, got %d", len(underlying.entries)) + } + + entry := underlying.entries[0] + if entry.Attrs["password"] != "secret123" { + t.Errorf("expected password unchanged when disabled, got %v", entry.Attrs["password"]) + } +} + +func TestSanitizingHandler_WithAttrs(t *testing.T) { + underlying := &testHandler{} + sanitizer := NewSanitizer(nil) + handler := NewSanitizingHandler(underlying, sanitizer) + + // Create handler with pre-set attrs + handlerWithAttrs := handler.WithAttrs([]slog.Attr{ + slog.String("api_key", "secret_key_123"), + }) + + logger := slog.New(handlerWithAttrs) + logger.Info("test", "username", "john") + + // The WithAttrs should have sanitized the api_key + // Note: Our test handler doesn't properly handle WithAttrs, + // but we're testing that WithAttrs returns a SanitizingHandler + if _, ok := handlerWithAttrs.(*SanitizingHandler); !ok { + t.Error("expected WithAttrs to return a SanitizingHandler") + } +} + +func TestSanitizingHandler_WithGroup(t *testing.T) { + underlying := &testHandler{} + sanitizer := NewSanitizer(nil) + handler := NewSanitizingHandler(underlying, sanitizer) + + handlerWithGroup := handler.WithGroup("request") + + if h, ok := handlerWithGroup.(*SanitizingHandler); !ok { + t.Error("expected WithGroup to return a SanitizingHandler") + } else { + if len(h.groups) != 1 || h.groups[0] != "request" { + t.Errorf("expected groups ['request'], got %v", h.groups) + } + } +} + +func TestSanitizingHandler_AnyValue(t *testing.T) { + underlying := &testHandler{} + sanitizer := NewSanitizer(nil) + handler := NewSanitizingHandler(underlying, sanitizer) + logger := slog.New(handler) + + // Log a map as Any value + data := map[string]any{ + "username": "john", + "password": "secret", + } + logger.Info("test", "data", data) + + if len(underlying.entries) != 1 { + t.Fatalf("expected 1 log entry, got %d", len(underlying.entries)) + } + + entry := underlying.entries[0] + dataResult, ok := entry.Attrs["data"].(map[string]any) + if !ok { + t.Fatalf("expected data to be map, got %T", entry.Attrs["data"]) + } + + if dataResult["username"] != "john" { + t.Errorf("expected username 'john', got %v", dataResult["username"]) + } + if dataResult["password"] != "***" { + t.Errorf("expected password '***', got %v", dataResult["password"]) + } +} + +func TestWrapLoggerWithSanitizer(t *testing.T) { + underlying := &testHandler{} + originalLogger := slog.New(underlying) + sanitizer := NewSanitizer(nil) + + wrappedLogger := WrapLoggerWithSanitizer(originalLogger, sanitizer) + + wrappedLogger.Info("test", "password", "secret123") + + if len(underlying.entries) != 1 { + t.Fatalf("expected 1 log entry, got %d", len(underlying.entries)) + } + + entry := underlying.entries[0] + if entry.Attrs["password"] != "***" { + t.Errorf("expected password '***', got %v", entry.Attrs["password"]) + } +} + +func TestWrapLoggerWithSanitizer_Nil(t *testing.T) { + result := WrapLoggerWithSanitizer(nil, nil) + if result != nil { + t.Error("expected nil result for nil logger") + } +} + +func BenchmarkSanitizingHandler(b *testing.B) { + underlying := &testHandler{} + sanitizer := NewSanitizer(nil) + handler := NewSanitizingHandler(underlying, sanitizer) + logger := slog.New(handler) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + underlying.entries = nil // Reset + logger.Info("test", + "username", "john", + "password", "secret123", + "count", 42, + ) + } +} + +func BenchmarkSanitizingHandler_Disabled(b *testing.B) { + underlying := &testHandler{} + sanitizer := NewSanitizer(&SanitizeOptions{Disabled: true}) + handler := NewSanitizingHandler(underlying, sanitizer) + logger := slog.New(handler) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + underlying.entries = nil + logger.Info("test", + "username", "john", + "password", "secret123", + "count", 42, + ) + } +} From 35c341dee2a5723b042d82ad9512c0620cd407b8 Mon Sep 17 00:00:00 2001 From: atterpac Date: Tue, 16 Dec 2025 12:35:22 -0700 Subject: [PATCH 3/7] docs: add log sanitization documentation - Add Log Sanitization section to security guide - Document default redacted fields and patterns - Add configuration examples and options table - Add Sanitizer API reference to application doc --- docs/src/content/docs/guides/security.mdx | 93 ++++++++++++++++++- .../content/docs/reference/application.mdx | 59 +++++++++++- 2 files changed, 149 insertions(+), 3 deletions(-) diff --git a/docs/src/content/docs/guides/security.mdx b/docs/src/content/docs/guides/security.mdx index 51bc21b3e..1341a6f61 100644 --- a/docs/src/content/docs/guides/security.mdx +++ b/docs/src/content/docs/guides/security.mdx @@ -219,6 +219,94 @@ func (r *RateLimiter) Allow(key string) bool { } ``` +## Log Sanitization + +Wails automatically sanitizes sensitive data in IPC logs to prevent accidental exposure of secrets. This feature is **enabled by default** with sensible defaults. + +### Default Protection + +The following field names are automatically redacted (case-insensitive, substring matching): + +- **Authentication**: `password`, `passwd`, `pwd`, `token`, `bearer`, `jwt`, `access_token`, `refresh_token`, `secret`, `apikey`, `api_key`, `auth`, `authorization`, `credential` +- **Cryptographic**: `private`, `privatekey`, `private_key`, `signing`, `encryption_key` +- **Session**: `session`, `sessionid`, `session_id`, `cookie`, `csrf`, `xsrf` + +Additionally, these patterns are detected in values: +- JWT tokens (`eyJhbG...`) +- Bearer tokens (`Bearer xxx`) +- Common API key formats (`sk_live_xxx`, `pk_test_xxx`) + +### Custom Configuration + +Add custom fields or patterns to redact: + +```go +app := application.New(application.Options{ + Name: "MyApp", + SanitizeOptions: &application.SanitizeOptions{ + // Add app-specific sensitive fields + RedactFields: []string{"ssn", "credit_card", "date_of_birth"}, + + // Add custom patterns (e.g., SSN format) + RedactPatterns: []*regexp.Regexp{ + regexp.MustCompile(`\b\d{3}-\d{2}-\d{4}\b`), + }, + }, +}) +``` + +### Full Custom Control + +For complex requirements, use `CustomSanitizeFunc`: + +```go +app := application.New(application.Options{ + Name: "MyApp", + SanitizeOptions: &application.SanitizeOptions{ + CustomSanitizeFunc: func(key string, value any, path string) (any, bool) { + // Redact all user data + if strings.HasPrefix(path, "args.user.") { + return "***", true + } + + // Custom handling for payment data + if strings.Contains(path, "payment") && key != "transactionId" { + return "***", true + } + + // Fall back to default sanitization + return nil, false + }, + }, +}) +``` + +### Configuration Options + +| Option | Description | +|--------|-------------| +| `RedactFields` | Additional field names to redact (merged with defaults) | +| `RedactPatterns` | Additional regex patterns to match values | +| `CustomSanitizeFunc` | Full control function; return `(value, true)` to override | +| `DisableDefaults` | Only use explicitly specified fields/patterns | +| `Replacement` | Custom replacement string (default: `***`) | +| `Disabled` | Completely disable sanitization (use with caution) | + +### Public Sanitizer API + +Use the sanitizer for your own data: + +```go +// Get the application's sanitizer +sanitizer := app.Sanitizer() + +// Sanitize a map +cleanData := sanitizer.SanitizeMap(sensitiveData) + +// Sanitize JSON +cleanJSON := sanitizer.SanitizeJSON(jsonBytes) +``` + ## Best Practices ### ✅ Do @@ -238,9 +326,10 @@ func (r *RateLimiter) Allow(key string) bool { - Don't store passwords in plain text - Don't hardcode secrets - Don't skip certificate verification -- Don't expose sensitive data in logs +- Don't expose sensitive data in logs (Wails sanitizes IPC logs by default) - Don't use weak encryption - Don't ignore security updates +- Don't disable log sanitization in production ## Security Checklist @@ -254,6 +343,8 @@ func (r *RateLimiter) Allow(key string) bool { - [ ] Security logging enabled - [ ] Error messages don't leak info - [ ] Code reviewed for vulnerabilities +- [ ] Log sanitization configured for app-specific fields +- [ ] Sensitive fields not exposed in custom logging ## Next Steps diff --git a/docs/src/content/docs/reference/application.mdx b/docs/src/content/docs/reference/application.mdx index 050578a62..5d709eff1 100644 --- a/docs/src/content/docs/reference/application.mdx +++ b/docs/src/content/docs/reference/application.mdx @@ -351,17 +351,72 @@ app.Logger.Warn("Warning message") ```go func (s *MyService) ProcessData(data string) error { s.app.Logger.Info("Processing data", "length", len(data)) - + if err := process(data); err != nil { s.app.Logger.Error("Processing failed", "error", err) return err } - + s.app.Logger.Info("Processing complete") return nil } ``` +## Sanitizer + +The application provides a sanitizer for redacting sensitive data. IPC logs are automatically sanitized by default. + +### Sanitizer() + +Returns the application's sanitizer instance. + +```go +func (a *App) Sanitizer() *Sanitizer +``` + +**Example:** + +```go +// Get the sanitizer +sanitizer := app.Sanitizer() + +// Sanitize a map +cleanData := sanitizer.SanitizeMap(map[string]any{ + "username": "john", + "password": "secret123", // Will be redacted +}) + +// Sanitize JSON bytes +cleanJSON := sanitizer.SanitizeJSON(jsonBytes) +``` + +### Configuration + +Configure sanitization via `SanitizeOptions` in application options: + +```go +app := application.New(application.Options{ + Name: "My App", + SanitizeOptions: &application.SanitizeOptions{ + // Add custom fields to redact + RedactFields: []string{"ssn", "credit_card"}, + + // Custom replacement string (default: "***") + Replacement: "[REDACTED]", + + // Custom sanitization logic + CustomSanitizeFunc: func(key string, value any, path string) (any, bool) { + if strings.HasPrefix(path, "user.") { + return "***", true + } + return nil, false // Use default logic + }, + }, +}) +``` + +See [Security Guide](/guides/security#log-sanitization) for full documentation. + ## Raw Message Handling For applications that need direct, low-level control over frontend-to-backend communication, Wails provides the `RawMessageHandler` option. This bypasses the standard binding system. From ebdffac152bff70b51a0115f29f922c0bef9befe Mon Sep 17 00:00:00 2001 From: atterpac Date: Tue, 16 Dec 2025 12:46:06 -0700 Subject: [PATCH 4/7] log sanitization example --- v3/examples/log-sanitization/README.md | 43 +++++++++++++++ v3/examples/log-sanitization/main.go | 72 ++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 v3/examples/log-sanitization/README.md create mode 100644 v3/examples/log-sanitization/main.go diff --git a/v3/examples/log-sanitization/README.md b/v3/examples/log-sanitization/README.md new file mode 100644 index 000000000..57d7f6632 --- /dev/null +++ b/v3/examples/log-sanitization/README.md @@ -0,0 +1,43 @@ +# Log Sanitization Example + +Demonstrates Wails' automatic log sanitization feature which redacts sensitive data to prevent accidental exposure of secrets. + +## Running + +```bash +go run . +``` + +Watch the terminal output - it shows original vs sanitized data, demonstrating automatic redaction. + +## What Gets Redacted + +**Default fields** (case-insensitive, substring match): +- `password`, `passwd`, `pwd`, `token`, `secret`, `apikey`, `api_key` +- `auth`, `authorization`, `credential`, `bearer`, `jwt` +- `private`, `privatekey`, `session`, `cookie`, `csrf` + +**Default patterns** (detected in values): +- JWT tokens: `eyJhbG...` +- Bearer tokens: `Bearer xxx` +- API keys: `sk_live_xxx`, `pk_test_xxx` + +## Configuration + +```go +app := application.New(application.Options{ + SanitizeOptions: &application.SanitizeOptions{ + RedactFields: []string{"cardNumber", "ssn"}, + RedactPatterns: []*regexp.Regexp{...}, + Replacement: "[REDACTED]", // default: "***" + }, +}) +``` + +## Status + +| Platform | Status | +|----------|---------| +| Mac | Working | +| Windows | Working | +| Linux | Working | diff --git a/v3/examples/log-sanitization/main.go b/v3/examples/log-sanitization/main.go new file mode 100644 index 000000000..7f30a9f27 --- /dev/null +++ b/v3/examples/log-sanitization/main.go @@ -0,0 +1,72 @@ +package main + +import ( + "fmt" + "regexp" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +func main() { + app := application.New(application.Options{ + Name: "Log Sanitization Demo", + + // Configure log sanitization + SanitizeOptions: &application.SanitizeOptions{ + // Add custom fields to redact (merged with defaults) + RedactFields: []string{"cardNumber", "cvv", "ssn"}, + + // Add custom patterns + RedactPatterns: []*regexp.Regexp{ + regexp.MustCompile(`\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b`), + }, + }, + }) + + // Demonstrate the sanitizer API + fmt.Println("\n=== Log Sanitization Demo ===\n") + + sanitizer := app.Sanitizer() + + // Test data with sensitive information + testData := map[string]any{ + "username": "john_doe", + "password": "super_secret_123", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig", + "apiKey": "sk_live_abcdefghij1234567890", + "email": "john@example.com", + "cardNumber": "4111-1111-1111-1111", // custom field + "cvv": "123", // custom field + "ssn": "123-45-6789", // custom field + } + + fmt.Println("Original data:") + for k, v := range testData { + fmt.Printf(" %s: %v\n", k, v) + } + + fmt.Println("\nSanitized data:") + cleanData := sanitizer.SanitizeMap(testData) + for k, v := range cleanData { + fmt.Printf(" %s: %v\n", k, v) + } + + // Demonstrate nested sanitization + nestedData := map[string]any{ + "user": map[string]any{ + "name": "Jane", + "password": "secret123", + "settings": map[string]any{ + "theme": "dark", + "auth_token": "bearer_xyz789", + }, + }, + } + + fmt.Println("\nNested original:") + fmt.Printf(" %+v\n", nestedData) + + fmt.Println("\nNested sanitized:") + cleanNested := sanitizer.SanitizeMap(nestedData) + fmt.Printf(" %+v\n", cleanNested) +} From 53eb75276346ae5110522ffeceedd4813516a753 Mon Sep 17 00:00:00 2001 From: atterpac Date: Tue, 16 Dec 2025 12:53:03 -0700 Subject: [PATCH 5/7] demo all options --- docs/src/content/docs/guides/security.mdx | 47 ++++---- .../content/docs/reference/application.mdx | 32 ++++-- v3/examples/log-sanitization/main.go | 105 ++++++++++++------ 3 files changed, 115 insertions(+), 69 deletions(-) diff --git a/docs/src/content/docs/guides/security.mdx b/docs/src/content/docs/guides/security.mdx index 1341a6f61..d6807710c 100644 --- a/docs/src/content/docs/guides/security.mdx +++ b/docs/src/content/docs/guides/security.mdx @@ -238,45 +238,38 @@ Additionally, these patterns are detected in values: ### Custom Configuration -Add custom fields or patterns to redact: +Configure sanitization with all available options: ```go app := application.New(application.Options{ Name: "MyApp", SanitizeOptions: &application.SanitizeOptions{ - // Add app-specific sensitive fields - RedactFields: []string{"ssn", "credit_card", "date_of_birth"}, + // RedactFields: additional field names to redact (merged with defaults) + RedactFields: []string{"cardNumber", "cvv", "ssn"}, - // Add custom patterns (e.g., SSN format) + // RedactPatterns: additional regex patterns to match values RedactPatterns: []*regexp.Regexp{ - regexp.MustCompile(`\b\d{3}-\d{2}-\d{4}\b`), + regexp.MustCompile(`\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b`), // card numbers + regexp.MustCompile(`\b\d{3}-\d{2}-\d{4}\b`), // SSN format }, - }, -}) -``` -### Full Custom Control - -For complex requirements, use `CustomSanitizeFunc`: - -```go -app := application.New(application.Options{ - Name: "MyApp", - SanitizeOptions: &application.SanitizeOptions{ + // CustomSanitizeFunc: full control - return (value, true) to override CustomSanitizeFunc: func(key string, value any, path string) (any, bool) { - // Redact all user data - if strings.HasPrefix(path, "args.user.") { - return "***", true + // Custom handling for specific paths + if strings.HasPrefix(path, "payment.") && key != "amount" { + return "[PAYMENT_REDACTED]", true } - - // Custom handling for payment data - if strings.Contains(path, "payment") && key != "transactionId" { - return "***", true - } - - // Fall back to default sanitization - return nil, false + return nil, false // fall through to default logic }, + + // Replacement: custom replacement string (default: "***") + Replacement: "[REDACTED]", + + // DisableDefaults: if true, only use explicitly specified fields/patterns + // DisableDefaults: false, + + // Disabled: completely disable sanitization + // Disabled: false, }, }) ``` diff --git a/docs/src/content/docs/reference/application.mdx b/docs/src/content/docs/reference/application.mdx index 5d709eff1..54dadf691 100644 --- a/docs/src/content/docs/reference/application.mdx +++ b/docs/src/content/docs/reference/application.mdx @@ -398,19 +398,31 @@ Configure sanitization via `SanitizeOptions` in application options: app := application.New(application.Options{ Name: "My App", SanitizeOptions: &application.SanitizeOptions{ - // Add custom fields to redact - RedactFields: []string{"ssn", "credit_card"}, + // RedactFields: additional field names to redact (merged with defaults) + RedactFields: []string{"cardNumber", "cvv", "ssn"}, - // Custom replacement string (default: "***") + // RedactPatterns: additional regex patterns to match values + RedactPatterns: []*regexp.Regexp{ + regexp.MustCompile(`\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b`), // card numbers + }, + + // CustomSanitizeFunc: full control - return (value, true) to override + CustomSanitizeFunc: func(key string, value any, path string) (any, bool) { + // Custom handling for specific paths + if strings.HasPrefix(path, "payment.") && key != "amount" { + return "[PAYMENT_REDACTED]", true + } + return nil, false // fall through to default logic + }, + + // Replacement: custom replacement string (default: "***") Replacement: "[REDACTED]", - // Custom sanitization logic - CustomSanitizeFunc: func(key string, value any, path string) (any, bool) { - if strings.HasPrefix(path, "user.") { - return "***", true - } - return nil, false // Use default logic - }, + // DisableDefaults: if true, only use explicitly specified fields/patterns + // DisableDefaults: false, + + // Disabled: completely disable sanitization + // Disabled: false, }, }) ``` diff --git a/v3/examples/log-sanitization/main.go b/v3/examples/log-sanitization/main.go index 7f30a9f27..57287dd94 100644 --- a/v3/examples/log-sanitization/main.go +++ b/v3/examples/log-sanitization/main.go @@ -3,6 +3,7 @@ package main import ( "fmt" "regexp" + "strings" "github.com/wailsapp/wails/v3/pkg/application" ) @@ -11,47 +12,86 @@ func main() { app := application.New(application.Options{ Name: "Log Sanitization Demo", - // Configure log sanitization SanitizeOptions: &application.SanitizeOptions{ - // Add custom fields to redact (merged with defaults) + // RedactFields: additional field names to redact (merged with defaults) RedactFields: []string{"cardNumber", "cvv", "ssn"}, - // Add custom patterns + // RedactPatterns: additional regex patterns to match values RedactPatterns: []*regexp.Regexp{ - regexp.MustCompile(`\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b`), + regexp.MustCompile(`\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b`), // card numbers }, + + // CustomSanitizeFunc: full control - return (value, true) to override + CustomSanitizeFunc: func(key string, value any, path string) (any, bool) { + // Custom handling for specific paths + if strings.HasPrefix(path, "payment.") && key != "amount" { + return "[PAYMENT_REDACTED]", true + } + return nil, false // fall through to default logic + }, + + // Replacement: custom replacement string (default: "***") + Replacement: "[REDACTED]", + + // DisableDefaults: if true, only use explicitly specified fields/patterns + // DisableDefaults: false, + + // Disabled: completely disable sanitization + // Disabled: false, }, }) - // Demonstrate the sanitizer API - fmt.Println("\n=== Log Sanitization Demo ===\n") - sanitizer := app.Sanitizer() - // Test data with sensitive information - testData := map[string]any{ - "username": "john_doe", - "password": "super_secret_123", - "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig", - "apiKey": "sk_live_abcdefghij1234567890", - "email": "john@example.com", - "cardNumber": "4111-1111-1111-1111", // custom field - "cvv": "123", // custom field - "ssn": "123-45-6789", // custom field - } + fmt.Println("\n=== Log Sanitization Demo ===") - fmt.Println("Original data:") - for k, v := range testData { - fmt.Printf(" %s: %v\n", k, v) + // Test default field redaction + fmt.Println("\n--- Default Fields ---") + defaultData := map[string]any{ + "username": "john_doe", + "password": "super_secret_123", + "token": "abc123", + "apiKey": "sk_live_xyz", + "email": "john@example.com", } + fmt.Println("Original:", defaultData) + fmt.Println("Sanitized:", sanitizer.SanitizeMap(defaultData)) - fmt.Println("\nSanitized data:") - cleanData := sanitizer.SanitizeMap(testData) - for k, v := range cleanData { - fmt.Printf(" %s: %v\n", k, v) + // Test custom field redaction + fmt.Println("\n--- Custom Fields ---") + customData := map[string]any{ + "cardNumber": "4111-1111-1111-1111", + "cvv": "123", + "ssn": "123-45-6789", + "name": "John Doe", } + fmt.Println("Original:", customData) + fmt.Println("Sanitized:", sanitizer.SanitizeMap(customData)) - // Demonstrate nested sanitization + // Test pattern matching + fmt.Println("\n--- Pattern Matching ---") + patternData := map[string]any{ + "data": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sig", // JWT + "header": "Bearer abc123xyz", + "message": "Hello world", + } + fmt.Println("Original:", patternData) + fmt.Println("Sanitized:", sanitizer.SanitizeMap(patternData)) + + // Test CustomSanitizeFunc + fmt.Println("\n--- CustomSanitizeFunc ---") + paymentData := map[string]any{ + "payment": map[string]any{ + "cardNumber": "4111111111111111", + "amount": 99.99, + "currency": "USD", + }, + } + fmt.Println("Original:", paymentData) + fmt.Println("Sanitized:", sanitizer.SanitizeMap(paymentData)) + + // Test nested structures + fmt.Println("\n--- Nested Structures ---") nestedData := map[string]any{ "user": map[string]any{ "name": "Jane", @@ -62,11 +102,12 @@ func main() { }, }, } + fmt.Println("Original:", nestedData) + fmt.Println("Sanitized:", sanitizer.SanitizeMap(nestedData)) - fmt.Println("\nNested original:") - fmt.Printf(" %+v\n", nestedData) - - fmt.Println("\nNested sanitized:") - cleanNested := sanitizer.SanitizeMap(nestedData) - fmt.Printf(" %+v\n", cleanNested) + // Test JSON sanitization + fmt.Println("\n--- JSON Sanitization ---") + jsonData := []byte(`{"user":"john","password":"secret","token":"abc123"}`) + fmt.Println("Original:", string(jsonData)) + fmt.Println("Sanitized:", string(sanitizer.SanitizeJSON(jsonData))) } From e4031be0b582cf214e9d4cda7e62edcafef7f0de Mon Sep 17 00:00:00 2001 From: atterpac Date: Tue, 16 Dec 2025 13:01:04 -0700 Subject: [PATCH 6/7] changelog --- v3/UNRELEASED_CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/v3/UNRELEASED_CHANGELOG.md b/v3/UNRELEASED_CHANGELOG.md index 8e4648038..4bfac133b 100644 --- a/v3/UNRELEASED_CHANGELOG.md +++ b/v3/UNRELEASED_CHANGELOG.md @@ -17,6 +17,8 @@ After processing, the content will be moved to the main changelog and this file ## Added +**Added:** + - Support for automatic and configurable log sanitizing to prevent private data from being logged by @atterpac in [#4807] ## Changed From 2d546cfb12e9fa73bfa81f3a4e15f1e02f11899a Mon Sep 17 00:00:00 2001 From: Atterpac <89053530+atterpac@users.noreply.github.com> Date: Fri, 19 Dec 2025 08:11:15 -0700 Subject: [PATCH 7/7] Update v3/UNRELEASED_CHANGELOG.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- v3/UNRELEASED_CHANGELOG.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/v3/UNRELEASED_CHANGELOG.md b/v3/UNRELEASED_CHANGELOG.md index 4bfac133b..c32e31ec2 100644 --- a/v3/UNRELEASED_CHANGELOG.md +++ b/v3/UNRELEASED_CHANGELOG.md @@ -17,8 +17,9 @@ After processing, the content will be moved to the main changelog and this file ## Added -**Added:** - - Support for automatic and configurable log sanitizing to prevent private data from being logged by @atterpac in [#4807] +## Added + +- Support for automatic and configurable log sanitizing to prevent private data from being logged by @atterpac in [#4807] ## Changed