fix: sanitize sensitive fields in log entries exposed via miniapp

Log entries streamed over WebSocket and included in snapshots could
contain sensitive values (tokens, API keys, passwords) in their Fields
map. Add SanitizeFields() to mask values for keys matching patterns
like token, key, secret, password, authorization, and credential.

Applied in RecentLogs() (covers snapshots + wsLogs init) and the
wsLogs real-time stream loop.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-24 05:09:21 +09:00
parent 346b3dcd35
commit 4356bf4fa2
3 changed files with 118 additions and 2 deletions

View file

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"regexp"
"runtime" "runtime"
"strings" "strings"
"sync" "sync"
@ -111,6 +112,26 @@ type LogEntry struct {
Caller string `json:"caller,omitempty"` Caller string `json:"caller,omitempty"`
} }
var sensitiveKeyPattern = regexp.MustCompile(`(?i)(token|key|secret|password|authorization|credential)`)
// SanitizeFields returns a copy of fields with sensitive values masked.
// Keys matching patterns like token, key, secret, password, authorization,
// or credential (case-insensitive) will have their values replaced with "***".
func SanitizeFields(fields map[string]any) map[string]any {
if len(fields) == 0 {
return fields
}
sanitized := make(map[string]any, len(fields))
for k, v := range fields {
if sensitiveKeyPattern.MatchString(k) {
sanitized[k] = "***"
} else {
sanitized[k] = v
}
}
return sanitized
}
func init() { func init() {
once.Do(func() { once.Do(func() {
logger = &Logger{} logger = &Logger{}
@ -338,6 +359,7 @@ func RecentLogs(minLevel LogLevel, component string, limit int) []LogEntry {
continue continue
} }
e.Caller = "" // strip for security e.Caller = "" // strip for security
e.Fields = SanitizeFields(e.Fields) // mask sensitive values
result = append(result, e) result = append(result, e)
} }
// Reverse so oldest first // Reverse so oldest first

View file

@ -292,6 +292,99 @@ func TestUnsubscribe_ClosesChannel(t *testing.T) {
} }
} }
func TestSanitizeFields(t *testing.T) {
tests := []struct {
name string
input map[string]any
maskedK []string // keys that should be "***"
safeK []string // keys that should keep original value
}{
{
name: "nil fields",
input: nil,
maskedK: nil,
},
{
name: "empty fields",
input: map[string]any{},
maskedK: nil,
},
{
name: "sensitive keys masked",
input: map[string]any{"token": "abc123", "api_key": "sk-xxx", "secret": "s3cr3t", "password": "pass", "authorization": "Bearer tok"},
maskedK: []string{"token", "api_key", "secret", "password", "authorization"},
},
{
name: "case insensitive",
input: map[string]any{"Token": "abc", "API_KEY": "xyz", "Secret": "s", "PASSWORD": "p", "Authorization": "a", "Credential": "c"},
maskedK: []string{"Token", "API_KEY", "Secret", "PASSWORD", "Authorization", "Credential"},
},
{
name: "safe keys preserved",
input: map[string]any{"error": "something failed", "count": 42, "user_id": "12345", "component": "test"},
safeK: []string{"error", "count", "user_id", "component"},
},
{
name: "mixed keys",
input: map[string]any{"token": "sensitive", "msg_signature": "safe", "corp_secret": "sensitive2", "nonce": "safe2"},
maskedK: []string{"token", "corp_secret"},
safeK: []string{"msg_signature", "nonce"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := SanitizeFields(tt.input)
for _, k := range tt.maskedK {
if v, ok := result[k]; !ok || v != "***" {
t.Errorf("expected key %q to be masked, got %v", k, v)
}
}
for _, k := range tt.safeK {
if result[k] != tt.input[k] {
t.Errorf("expected key %q to be preserved as %v, got %v", k, tt.input[k], result[k])
}
}
})
}
}
func TestSanitizeFieldsDoesNotMutateOriginal(t *testing.T) {
original := map[string]any{"token": "secret_value", "name": "test"}
_ = SanitizeFields(original)
if original["token"] != "secret_value" {
t.Error("SanitizeFields should not mutate the original map")
}
}
func TestRecentLogsSanitizesFields(t *testing.T) {
initialLevel := GetLevel()
defer SetLevel(initialLevel)
SetLevel(DEBUG)
InfoCF("sanitize-test", "log with sensitive fields", map[string]any{
"token": "my-secret-token",
"api_key": "sk-12345",
"user_id": "safe-value",
})
got := RecentLogs(DEBUG, "sanitize-test", 100)
if len(got) == 0 {
t.Fatal("expected at least one log entry")
}
last := got[len(got)-1]
if last.Fields["token"] != "***" {
t.Errorf("expected token to be masked, got %v", last.Fields["token"])
}
if last.Fields["api_key"] != "***" {
t.Errorf("expected api_key to be masked, got %v", last.Fields["api_key"])
}
if last.Fields["user_id"] != "safe-value" {
t.Errorf("expected user_id to be preserved, got %v", last.Fields["user_id"])
}
}
func TestParseLevel(t *testing.T) { func TestParseLevel(t *testing.T) {
tests := []struct { tests := []struct {
input string input string

View file

@ -952,6 +952,7 @@ func (h *Handler) wsLogs(w http.ResponseWriter, r *http.Request) {
return return
} }
entry.Caller = "" // strip for security entry.Caller = "" // strip for security
entry.Fields = logger.SanitizeFields(entry.Fields) // mask sensitive values
if err := conn.WriteJSON(map[string]any{"type": "entry", "entry": entry}); err != nil { if err := conn.WriteJSON(map[string]any{"type": "entry", "entry": entry}); err != nil {
return return
} }