diff --git a/httpclient/logging_improvements_test.go b/httpclient/logging_improvements_test.go index 06a78532..956ee69e 100644 --- a/httpclient/logging_improvements_test.go +++ b/httpclient/logging_improvements_test.go @@ -178,7 +178,10 @@ func TestLoggingImprovements(t *testing.T) { details := fmt.Sprintf("%v", requestEntry.KeyVals["details"]) assert.Contains(t, details, "POST", "Should show method") - assert.Contains(t, details, "Authorization", "Should show authorization header") + // The Authorization header NAME should still be visible for observability, + // but its secret VALUE must be redacted (go/clear-text-logging). + assert.Contains(t, details, "Authorization", "Should show authorization header name") + assert.NotContains(t, details, "token123", "Authorization secret value must be redacted") case "truncated_with_content": // Should show truncated content with [truncated] marker @@ -302,3 +305,146 @@ func TestNoUselessDotDotDotLogs(t *testing.T) { // Ensure we actually have some log entries to test assert.GreaterOrEqual(t, len(entries), 2, "Should have generated some log entries to test") } + +// TestSensitiveHeadersRedacted verifies that Authorization and other sensitive headers are +// redacted in both request and response important_headers logging (go/clear-text-logging fix). +func TestSensitiveHeadersRedacted(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Set-Cookie", "session=abc123; HttpOnly") + w.Header().Set("Authorization", "Bearer server-token") // unusual but tests redaction + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + testLogger := &TestLogger{} + + // Use non-detailed logging path (LogHeaders=false, LogBody=false) so the + // important_headers map is populated and can be inspected. + transport := &loggingTransport{ + Transport: http.DefaultTransport, + Logger: testLogger, + LogHeaders: false, + LogBody: false, + LogToFile: false, + } + client := &http.Client{Transport: transport} + + req, err := http.NewRequestWithContext(context.Background(), "GET", server.URL+"/", nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer secret-token-value") + req.Header.Set("Cookie", "session=hunter2") + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + entries := testLogger.GetEntries() + require.GreaterOrEqual(t, len(entries), 2) + + // Find request entry + var reqEntry, respEntry *LogEntry + for i := range entries { + if strings.Contains(entries[i].Message, "Outgoing request") { + reqEntry = &entries[i] + } + if strings.Contains(entries[i].Message, "Received response") { + respEntry = &entries[i] + } + } + + // Check request headers: Authorization and Cookie must be redacted + require.NotNil(t, reqEntry, "must have request log entry") + reqHeaders, ok := reqEntry.KeyVals["important_headers"] + require.True(t, ok, "request entry must have important_headers key") + reqHeadersStr := fmt.Sprintf("%v", reqHeaders) + assert.NotContains(t, reqHeadersStr, "secret-token-value", "Authorization value must not appear in logs") + assert.NotContains(t, reqHeadersStr, "hunter2", "Cookie value must not appear in logs") + // The key name may appear (that's fine), but the value must be masked + if strings.Contains(reqHeadersStr, "Authorization") || strings.Contains(reqHeadersStr, "authorization") { + assert.Contains(t, reqHeadersStr, "***", "masked sentinel must be present") + } + + // Check response headers: Set-Cookie must be redacted + require.NotNil(t, respEntry, "must have response log entry") + respHeaders, ok := respEntry.KeyVals["important_headers"] + require.True(t, ok, "response entry must have important_headers key") + respHeadersStr := fmt.Sprintf("%v", respHeaders) + assert.NotContains(t, respHeadersStr, "abc123", "Set-Cookie value must not appear in logs") +} + +// TestSensitiveHeadersRedactedInDetailedDump verifies that when detailed logging is +// enabled (LogHeaders=true), the raw HTTP dump emitted as "details" has sensitive +// header VALUES redacted while header names and the body remain intact +// (go/clear-text-logging fix for the detailed-logging path). +func TestSensitiveHeadersRedactedInDetailedDump(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Set-Cookie", "session=resp-secret-cookie; HttpOnly") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"message": "Hello, World!"}`)) + })) + defer server.Close() + + testLogger := &TestLogger{} + + // Detailed logging enabled: dumps the full request/response including headers. + transport := &loggingTransport{ + Transport: http.DefaultTransport, + Logger: testLogger, + LogHeaders: true, + LogBody: true, + MaxBodyLogSize: 4096, // large enough to avoid truncation + LogToFile: false, + } + client := &http.Client{Transport: transport} + + reqBody := bytes.NewBufferString(`{"test": "data"}`) + req, err := http.NewRequestWithContext(context.Background(), "POST", server.URL+"/api/test", reqBody) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer secret-token-value") + req.Header.Set("Cookie", "session=req-secret-cookie") + req.Header.Set("X-Api-Key", "my-api-key-secret") + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + entries := testLogger.GetEntries() + var reqEntry, respEntry *LogEntry + for i := range entries { + if strings.Contains(entries[i].Message, "Outgoing request") { + reqEntry = &entries[i] + } + if strings.Contains(entries[i].Message, "Received response") { + respEntry = &entries[i] + } + } + + require.NotNil(t, reqEntry, "must have request log entry") + reqDetails := fmt.Sprintf("%v", reqEntry.KeyVals["details"]) + + // Secret VALUES must never appear in the dump. + assert.NotContains(t, reqDetails, "secret-token-value", "Authorization bearer token must be redacted in dump") + assert.NotContains(t, reqDetails, "req-secret-cookie", "Cookie value must be redacted in dump") + assert.NotContains(t, reqDetails, "my-api-key-secret", "X-Api-Key value must be redacted in dump") + + // Header NAMES and the body should still be present (observability preserved). + assert.Contains(t, reqDetails, "Authorization", "Authorization header name should remain") + assert.Contains(t, reqDetails, "***", "redaction sentinel must be present") + assert.Contains(t, reqDetails, `{"test": "data"}`, "request body must be preserved") + assert.Contains(t, reqDetails, "POST /api/test HTTP/1.1", "request line must be preserved") + + require.NotNil(t, respEntry, "must have response log entry") + respDetails := fmt.Sprintf("%v", respEntry.KeyVals["details"]) + assert.NotContains(t, respDetails, "resp-secret-cookie", "Set-Cookie value must be redacted in dump") + assert.Contains(t, respDetails, "Set-Cookie", "Set-Cookie header name should remain") + assert.Contains(t, respDetails, `{"message": "Hello, World!"}`, "response body must be preserved") +} diff --git a/httpclient/module.go b/httpclient/module.go index 3a7b4f8d..aea12826 100644 --- a/httpclient/module.go +++ b/httpclient/module.go @@ -664,9 +664,11 @@ func (t *loggingTransport) logRequest(id string, req *http.Request) { ) } } else { - // Log to application logger with smart truncation - dumpStr := string(reqDump) - if t.MaxBodyLogSize > 0 && len(reqDump) > t.MaxBodyLogSize { + // Log to application logger with smart truncation. + // Redact sensitive header values in the raw dump before logging so + // Authorization/Cookie/etc. are not emitted in clear text. + dumpStr := redactDump(string(reqDump)) + if t.MaxBodyLogSize > 0 && len(dumpStr) > t.MaxBodyLogSize { // Smart truncation: try to include the request line and headers truncated := t.smartTruncateRequest(dumpStr, t.MaxBodyLogSize) t.Logger.Info("Outgoing request", @@ -696,7 +698,7 @@ func (t *loggingTransport) logRequest(id string, req *http.Request) { "id", id, "request", basicInfo, "content_length", req.ContentLength, - "important_headers", headers, + "important_headers", redactHeaders(headers), ) } } @@ -791,9 +793,11 @@ func (t *loggingTransport) logResponse(id, url string, resp *http.Response, dura ) } } else { - // Log to application logger with smart truncation - dumpStr := string(respDump) - if t.MaxBodyLogSize > 0 && len(respDump) > t.MaxBodyLogSize { + // Log to application logger with smart truncation. + // Redact sensitive header values in the raw dump before logging so + // Set-Cookie/Authorization/etc. are not emitted in clear text. + dumpStr := redactDump(string(respDump)) + if t.MaxBodyLogSize > 0 && len(dumpStr) > t.MaxBodyLogSize { // Smart truncation: try to include the status line and headers truncated := t.smartTruncateResponse(dumpStr, t.MaxBodyLogSize) t.Logger.Info("Received response", @@ -829,7 +833,7 @@ func (t *loggingTransport) logResponse(id, url string, resp *http.Response, dura "url", url, "duration_ms", duration.Milliseconds(), "content_length", resp.ContentLength, - "important_headers", headers, + "important_headers", redactHeaders(headers), ) } } @@ -944,6 +948,81 @@ func (t *loggingTransport) smartTruncateResponse(dump string, maxSize int) strin return dump[:maxSize] } +// sensitiveHeaderPatterns lists lowercase substrings that identify headers whose values +// must be redacted before logging (go/clear-text-logging). +var sensitiveHeaderPatterns = []string{ + "authorization", "proxy-authorization", "cookie", "set-cookie", + "x-api-key", "x-auth-token", "token", "secret", "password", "apikey", +} + +// isSensitiveHeader reports whether a header's value must be redacted. +func isSensitiveHeader(name string) bool { + lower := strings.ToLower(name) + for _, pat := range sensitiveHeaderPatterns { + if strings.Contains(lower, pat) { + return true + } + } + return false +} + +// redactHeaders returns a new map with sensitive header values replaced by "***". +func redactHeaders(headers map[string]string) map[string]string { + redacted := make(map[string]string, len(headers)) + for k, v := range headers { + if isSensitiveHeader(k) { + redacted[k] = "***" + } else { + redacted[k] = v + } + } + return redacted +} + +// redactDump scrubs sensitive header values from a raw HTTP request/response dump +// (as produced by httputil.DumpRequestOut / DumpResponse) before it is logged. +// It scans the header section (everything up to the first blank line that separates +// headers from the body) line by line; for any line of the form "Name: value" whose +// header name matches isSensitiveHeader, the value is replaced with "***" while the +// header name and the rest of the dump (including the body) are left intact. +func redactDump(dump string) string { + if dump == "" { + return dump + } + + // Preserve the original line endings (dumps use CRLF). Split on "\n" and + // strip a trailing "\r" per line so we can match, then re-attach it. + lines := strings.Split(dump, "\n") + for i, line := range lines { + // Stop at the blank line separating headers from the body. A blank line + // is "" or just "\r". + trimmed := strings.TrimRight(line, "\r") + if trimmed == "" { + break + } + + colon := strings.Index(trimmed, ":") + if colon <= 0 { + // Request/status line (e.g. "GET / HTTP/1.1") has no leading "Name:". + continue + } + + name := trimmed[:colon] + if !isSensitiveHeader(name) { + continue + } + + // Rebuild as "Name: ***", preserving the original CRLF if present. + suffix := "" + if strings.HasSuffix(line, "\r") { + suffix = "\r" + } + lines[i] = name + ": ***" + suffix + } + + return strings.Join(lines, "\n") +} + // isImportantHeader determines if a header is important enough to show // even when detailed logging is disabled. func (t *loggingTransport) isImportantHeader(headerName string) bool { diff --git a/logger_decorator.go b/logger_decorator.go index a79046c5..cfcae82d 100644 --- a/logger_decorator.go +++ b/logger_decorator.go @@ -31,7 +31,11 @@ func (d *BaseLoggerDecorator) GetInnerLogger() Logger { return d.inner } -// Forward all Logger interface methods to the inner logger +// Forward all Logger interface methods to the inner logger. +// NOTE: BaseLoggerDecorator is a pure passthrough; callers that need +// sanitization (e.g. DualWriterLoggerDecorator) apply it themselves so that +// subclasses (e.g. MaskingLogger in the logmasker package) can control +// the full redaction pipeline without double-masking. func (d *BaseLoggerDecorator) Info(msg string, args ...any) { d.inner.Info(msg, args...) @@ -65,23 +69,27 @@ func NewDualWriterLoggerDecorator(primary, secondary Logger) *DualWriterLoggerDe } func (d *DualWriterLoggerDecorator) Info(msg string, args ...any) { - d.inner.Info(msg, args...) - d.secondary.Info(msg, args...) + safe := sanitizeLogArgs(args) + d.inner.Info(msg, safe...) + d.secondary.Info(msg, safe...) } func (d *DualWriterLoggerDecorator) Error(msg string, args ...any) { - d.inner.Error(msg, args...) - d.secondary.Error(msg, args...) + safe := sanitizeLogArgs(args) + d.inner.Error(msg, safe...) + d.secondary.Error(msg, safe...) } func (d *DualWriterLoggerDecorator) Warn(msg string, args ...any) { - d.inner.Warn(msg, args...) - d.secondary.Warn(msg, args...) + safe := sanitizeLogArgs(args) + d.inner.Warn(msg, safe...) + d.secondary.Warn(msg, safe...) } func (d *DualWriterLoggerDecorator) Debug(msg string, args ...any) { - d.inner.Debug(msg, args...) - d.secondary.Debug(msg, args...) + safe := sanitizeLogArgs(args) + d.inner.Debug(msg, safe...) + d.secondary.Debug(msg, safe...) } // ValueInjectionLoggerDecorator automatically injects key-value pairs into all log events. @@ -113,19 +121,19 @@ func (d *ValueInjectionLoggerDecorator) combineArgs(originalArgs []any) []any { } func (d *ValueInjectionLoggerDecorator) Info(msg string, args ...any) { - d.inner.Info(msg, d.combineArgs(args)...) + d.inner.Info(msg, sanitizeLogArgs(d.combineArgs(args))...) } func (d *ValueInjectionLoggerDecorator) Error(msg string, args ...any) { - d.inner.Error(msg, d.combineArgs(args)...) + d.inner.Error(msg, sanitizeLogArgs(d.combineArgs(args))...) } func (d *ValueInjectionLoggerDecorator) Warn(msg string, args ...any) { - d.inner.Warn(msg, d.combineArgs(args)...) + d.inner.Warn(msg, sanitizeLogArgs(d.combineArgs(args))...) } func (d *ValueInjectionLoggerDecorator) Debug(msg string, args ...any) { - d.inner.Debug(msg, d.combineArgs(args)...) + d.inner.Debug(msg, sanitizeLogArgs(d.combineArgs(args))...) } // FilterLoggerDecorator filters log events based on configurable criteria. @@ -189,25 +197,25 @@ func (d *FilterLoggerDecorator) shouldLog(level, msg string, args ...any) bool { func (d *FilterLoggerDecorator) Info(msg string, args ...any) { if d.shouldLog("info", msg, args...) { - d.inner.Info(msg, args...) + d.inner.Info(msg, sanitizeLogArgs(args)...) } } func (d *FilterLoggerDecorator) Error(msg string, args ...any) { if d.shouldLog("error", msg, args...) { - d.inner.Error(msg, args...) + d.inner.Error(msg, sanitizeLogArgs(args)...) } } func (d *FilterLoggerDecorator) Warn(msg string, args ...any) { if d.shouldLog("warn", msg, args...) { - d.inner.Warn(msg, args...) + d.inner.Warn(msg, sanitizeLogArgs(args)...) } } func (d *FilterLoggerDecorator) Debug(msg string, args ...any) { if d.shouldLog("debug", msg, args...) { - d.inner.Debug(msg, args...) + d.inner.Debug(msg, sanitizeLogArgs(args)...) } } @@ -232,26 +240,28 @@ func (d *LevelModifierLoggerDecorator) logWithLevel(originalLevel, msg string, a targetLevel = mapped } + safe := sanitizeLogArgs(args) + switch targetLevel { case "debug": - d.inner.Debug(msg, args...) + d.inner.Debug(msg, safe...) case "info": - d.inner.Info(msg, args...) + d.inner.Info(msg, safe...) case "warn": - d.inner.Warn(msg, args...) + d.inner.Warn(msg, safe...) case "error": - d.inner.Error(msg, args...) + d.inner.Error(msg, safe...) default: // If unknown level, use original switch originalLevel { case "debug": - d.inner.Debug(msg, args...) + d.inner.Debug(msg, safe...) case "info": - d.inner.Info(msg, args...) + d.inner.Info(msg, safe...) case "warn": - d.inner.Warn(msg, args...) + d.inner.Warn(msg, safe...) case "error": - d.inner.Error(msg, args...) + d.inner.Error(msg, safe...) } } } @@ -272,8 +282,45 @@ func (d *LevelModifierLoggerDecorator) Debug(msg string, args ...any) { d.logWithLevel("debug", msg, args...) } +// sensitiveKeySubstrings lists lowercase substrings that mark a key as sensitive when +// contained in strings.ToLower(key). The list is deliberately PRECISE: it only contains +// substrings that do not collide with innocent observability keys. For example, bare +// "auth"/"token"/"key" are intentionally excluded because they would over-mask +// author/authority/authenticated/token_count/primary_key. Compound forms +// (authorization, access_token, ...) are listed explicitly instead. +var sensitiveKeySubstrings = []string{ + "password", "passwd", "secret", "credential", + "apikey", "api_key", "api-key", "accesskey", "access_key", "access-key", + "privatekey", "private_key", "private-key", "authorization", "cookie", "bearer", + "access_token", "refresh_token", "id_token", "session_token", "auth_token", + "access-token", "refresh-token", "id-token", "session-token", "auth-token", +} + +// sensitiveKeyExact lists lowercase key names that are masked only on an exact match. +// These are kept exact (not Contains) so that observability keys like "tenantID", +// "tenantName", or "tenantCount" are NOT masked — only the bare "tenant"/"requestId". +var sensitiveKeyExact = map[string]struct{}{ + "tenant": {}, + "requestid": {}, +} + +// isSensitiveKey reports whether a structured-log key name should have its value masked. +func isSensitiveKey(key string) bool { + lower := strings.ToLower(key) + if _, ok := sensitiveKeyExact[lower]; ok { + return true + } + for _, pattern := range sensitiveKeySubstrings { + if strings.Contains(lower, pattern) { + return true + } + } + return false +} + // sanitizeLogArgs masks potentially sensitive values in structured log arguments. // It assumes key/value pairs (key at even index, value at odd index). +// The check is broad and case-insensitive to catch all variants of sensitive keys. func sanitizeLogArgs(args []any) []any { if len(args) == 0 { return args @@ -289,8 +336,7 @@ func sanitizeLogArgs(args []any) []any { continue } - // Mask values for known potentially sensitive keys. - if key == "tenant" || key == "requestId" { + if isSensitiveKey(key) { valueIndex := i + 1 if valueIndex < len(sanitized) { sanitized[valueIndex] = "***" diff --git a/logger_decorator_test.go b/logger_decorator_test.go index 2acc674d..9fa26292 100644 --- a/logger_decorator_test.go +++ b/logger_decorator_test.go @@ -409,6 +409,199 @@ func TestDecoratorComposition(t *testing.T) { }) } +// TestSanitizeLogArgs_SensitiveKeys verifies that sanitizeLogArgs masks values for the +// precise set of genuinely-sensitive keys, including case-insensitive and compound forms +// (go/clear-text-logging). +func TestSanitizeLogArgs_SensitiveKeys(t *testing.T) { + t.Parallel() + + sensitiveKeys := []string{ + // substring matches (precise — no collisions with innocent words) + "password", "Password", "PASSWORD", + "passwd", "Passwd", + "secret", "Secret", "SECRET", + "db_secret", + "credential", "Credential", "credentials", + "apikey", "ApiKey", "APIKEY", + "api_key", "Api_Key", "API_KEY", + "x-api-key", + "accesskey", "AccessKey", + "access_key", "Access_Key", + "privatekey", "PrivateKey", + "private_key", "Private_Key", + "authorization", "Authorization", "AUTHORIZATION", + "cookie", "Cookie", "COOKIE", + "set-cookie", "Set-Cookie", // contains "cookie" + "bearer", "Bearer", "BEARER", + "access_token", "Access_Token", + "refresh_token", "Refresh_Token", + "id_token", + "session_token", + "auth_token", "Auth_Token", + // exact matches + "tenant", "Tenant", "TENANT", + "requestId", "requestid", "REQUESTID", + } + + for _, key := range sensitiveKeys { + key := key + t.Run("masks_"+key, func(t *testing.T) { + t.Parallel() + args := []any{key, "super-secret-value", "safe_key", "safe-value"} + result := sanitizeLogArgs(args) + assert.Equal(t, "***", result[1], "value for key %q should be masked", key) + assert.Equal(t, "safe-value", result[3], "non-sensitive key should pass through") + }) + } +} + +// TestSanitizeLogArgs_NotMasked guards against over-masking: precise patterns must NOT +// collide with innocent observability keys. This is a regression guard for the +// adversarial-review findings (tenantID, token_count, author, etc.). +func TestSanitizeLogArgs_NotMasked(t *testing.T) { + t.Parallel() + + innocentKeys := []string{ + // "tenant" is exact-only, so these compound forms must pass through. + "tenantID", "tenantId", "tenantName", "tenantCount", + // bare "token" / "auth" / "key" are intentionally NOT substrings. + "token_count", "tokenCount", "numTokens", + "author", "authority", "authenticated", "authn", "authz", + "primary_key", "primaryKey", "key", "keyspace", + // general observability fields. + "service", "version", "content_length", "request", "status", + "method", "url", "duration_ms", "id", + } + + for _, key := range innocentKeys { + key := key + t.Run("passes_"+key, func(t *testing.T) { + t.Parallel() + args := []any{key, "observable-value"} + result := sanitizeLogArgs(args) + assert.Equal(t, "observable-value", result[1], "innocent key %q must NOT be masked", key) + }) + } +} + +// TestSanitizeLogArgs_NonSensitivePassThrough verifies benign keys are not masked while +// the exact-match keys (requestId) still are. +func TestSanitizeLogArgs_NonSensitivePassThrough(t *testing.T) { + t.Parallel() + args := []any{"service", "my-service", "version", "1.2.3", "requestId", "abc123"} + result := sanitizeLogArgs(args) + assert.Equal(t, "my-service", result[1]) + assert.Equal(t, "1.2.3", result[3]) + // requestId is masked (exact-match key). + assert.Equal(t, "***", result[5]) +} + +// TestDecorators_SensitiveArgsAreMasked verifies each decorator sanitizes sensitive args +// before forwarding to the inner logger (go/clear-text-logging fix). +func TestDecorators_SensitiveArgsAreMasked(t *testing.T) { + t.Parallel() + + sensitiveArgs := []any{"password", "hunter2", "safe", "value"} + + t.Run("BaseLoggerDecorator is a pure passthrough (subclasses own masking)", func(t *testing.T) { + // BaseLoggerDecorator intentionally does NOT sanitize — it is a foundation + // type used by MaskingLogger and others that own their own redaction pipeline. + // Sanitization is applied by the higher-level decorators (DualWriter, Filter, etc.). + t.Parallel() + inner := NewTestLogger() + dec := NewBaseLoggerDecorator(inner) + dec.Info("msg", sensitiveArgs...) + require.Len(t, inner.entries, 1) + // Passthrough: value is NOT masked by base (masking comes from the caller layer) + m := argsToMap(inner.entries[0].Args) + assert.Equal(t, "hunter2", m["password"], "BaseLoggerDecorator passes args through unchanged") + }) + + t.Run("DualWriterLoggerDecorator masks sensitive args in both inner and secondary", func(t *testing.T) { + t.Parallel() + primary := NewTestLogger() + secondary := NewTestLogger() + dec := NewDualWriterLoggerDecorator(primary, secondary) + dec.Error("msg", sensitiveArgs...) + require.Len(t, primary.entries, 1) + require.Len(t, secondary.entries, 1) + pm := argsToMap(primary.entries[0].Args) + sm := argsToMap(secondary.entries[0].Args) + assert.Equal(t, "***", pm["password"]) + assert.Equal(t, "***", sm["password"]) + assert.Equal(t, "value", pm["safe"]) + assert.Equal(t, "value", sm["safe"]) + }) + + t.Run("ValueInjectionLoggerDecorator masks sensitive args in combined args", func(t *testing.T) { + t.Parallel() + inner := NewTestLogger() + dec := NewValueInjectionLoggerDecorator(inner, "service", "svc") + dec.Warn("msg", sensitiveArgs...) + require.Len(t, inner.entries, 1) + m := argsToMap(inner.entries[0].Args) + assert.Equal(t, "***", m["password"]) + assert.Equal(t, "svc", m["service"]) + assert.Equal(t, "value", m["safe"]) + }) + + t.Run("FilterLoggerDecorator masks sensitive args that pass filter", func(t *testing.T) { + t.Parallel() + inner := NewTestLogger() + dec := NewFilterLoggerDecorator(inner, nil, nil, nil) + dec.Info("msg", sensitiveArgs...) + require.Len(t, inner.entries, 1) + m := argsToMap(inner.entries[0].Args) + assert.Equal(t, "***", m["password"]) + assert.Equal(t, "value", m["safe"]) + }) + + t.Run("LevelModifierLoggerDecorator masks sensitive args", func(t *testing.T) { + t.Parallel() + inner := NewTestLogger() + dec := NewLevelModifierLoggerDecorator(inner, map[string]string{"info": "debug"}) + dec.Info("msg", sensitiveArgs...) + require.Len(t, inner.entries, 1) + m := argsToMap(inner.entries[0].Args) + assert.Equal(t, "***", m["password"]) + assert.Equal(t, "value", m["safe"]) + }) + + t.Run("Authorization key is masked across decorators", func(t *testing.T) { + t.Parallel() + inner := NewTestLogger() + dec := NewDualWriterLoggerDecorator(inner, NewTestLogger()) + dec.Info("request", "authorization", "Bearer abc123", "method", "GET") + require.Len(t, inner.entries, 1) + m := argsToMap(inner.entries[0].Args) + assert.Equal(t, "***", m["authorization"]) + assert.Equal(t, "GET", m["method"]) + }) + + t.Run("access_token key is masked in ValueInjection decorator", func(t *testing.T) { + t.Parallel() + inner := NewTestLogger() + dec := NewValueInjectionLoggerDecorator(inner, "env", "prod") + dec.Debug("auth", "access_token", "secret-token-value", "user", "alice") + require.Len(t, inner.entries, 1) + m := argsToMap(inner.entries[0].Args) + assert.Equal(t, "***", m["access_token"]) + assert.Equal(t, "alice", m["user"]) + }) + + t.Run("innocent keys (token_count, tenantID, author) are NOT masked across decorators", func(t *testing.T) { + t.Parallel() + inner := NewTestLogger() + dec := NewDualWriterLoggerDecorator(inner, NewTestLogger()) + dec.Info("metrics", "token_count", 42, "tenantID", "tenant-7", "author", "alice") + require.Len(t, inner.entries, 1) + m := argsToMap(inner.entries[0].Args) + assert.Equal(t, 42, m["token_count"], "token_count must not be masked") + assert.Equal(t, "tenant-7", m["tenantID"], "tenantID must not be masked") + assert.Equal(t, "alice", m["author"], "author must not be masked") + }) +} + // Test the SetLogger/Service integration fix func TestSetLoggerServiceIntegration(t *testing.T) { t.Parallel()