mirror of
https://github.com/wailsapp/wails.git
synced 2026-03-14 14:45:49 +01:00
Merge 2d546cfb12 into bb4fbf9574
This commit is contained in:
commit
fc8feeffc5
10 changed files with 1656 additions and 3 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
43
v3/examples/log-sanitization/README.md
Normal file
43
v3/examples/log-sanitization/README.md
Normal 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 |
|
||||
113
v3/examples/log-sanitization/main.go
Normal file
113
v3/examples/log-sanitization/main.go
Normal 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)))
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
274
v3/pkg/application/sanitize.go
Normal file
274
v3/pkg/application/sanitize.go
Normal 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
|
||||
}
|
||||
182
v3/pkg/application/sanitize_handler.go
Normal file
182
v3/pkg/application/sanitize_handler.go
Normal 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)
|
||||
}
|
||||
869
v3/pkg/application/sanitize_test.go
Normal file
869
v3/pkg/application/sanitize_test.go
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue