This commit is contained in:
Atterpac 2026-03-02 12:10:07 -07:00 committed by GitHub
commit fc8feeffc5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1656 additions and 3 deletions

View file

@ -219,6 +219,87 @@ 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
Configure sanitization with all available options:
```go
app := application.New(application.Options{
Name: "MyApp",
SanitizeOptions: &application.SanitizeOptions{
// RedactFields: additional field names to redact (merged with defaults)
RedactFields: []string{"cardNumber", "cvv", "ssn"},
// 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
regexp.MustCompile(`\b\d{3}-\d{2}-\d{4}\b`), // SSN format
},
// 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,
},
})
```
### 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 +319,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 +336,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

View file

@ -351,17 +351,84 @@ 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{
// RedactFields: additional field names to redact (merged with defaults)
RedactFields: []string{"cardNumber", "cvv", "ssn"},
// 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]",
// DisableDefaults: if true, only use explicitly specified fields/patterns
// DisableDefaults: false,
// Disabled: completely disable sanitization
// Disabled: false,
},
})
```
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.

View file

@ -17,6 +17,9 @@ After processing, the content will be moved to the main changelog and this file
## Added
<!-- New features, capabilities, or enhancements -->
## Added
<!-- New features, capabilities, or enhancements -->
- Support for automatic and configurable log sanitizing to prevent private data from being logged by @atterpac in [#4807]
## Changed
<!-- Changes in existing functionality -->

View file

@ -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 |

View file

@ -0,0 +1,113 @@
package main
import (
"fmt"
"regexp"
"strings"
"github.com/wailsapp/wails/v3/pkg/application"
)
func main() {
app := application.New(application.Options{
Name: "Log Sanitization Demo",
SanitizeOptions: &application.SanitizeOptions{
// RedactFields: additional field names to redact (merged with defaults)
RedactFields: []string{"cardNumber", "cvv", "ssn"},
// 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]",
// DisableDefaults: if true, only use explicitly specified fields/patterns
// DisableDefaults: false,
// Disabled: completely disable sanitization
// Disabled: false,
},
})
sanitizer := app.Sanitizer()
fmt.Println("\n=== Log Sanitization Demo ===")
// 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))
// 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))
// 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",
"password": "secret123",
"settings": map[string]any{
"theme": "dark",
"auth_token": "bearer_xyz789",
},
},
}
fmt.Println("Original:", nestedData)
fmt.Println("Sanitized:", sanitizer.SanitizeMap(nestedData))
// 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)))
}

View file

@ -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 {

View file

@ -59,6 +59,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

View file

@ -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
}

View file

@ -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)
}

View file

@ -0,0 +1,869 @@
package application
import (
"context"
"encoding/json"
"log/slog"
"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)
}
}
// 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,
)
}
}