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:
parent
f06b21d427
commit
60e0707a83
3 changed files with 118 additions and 2 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue