package logger import ( "context" "fmt" "log/slog" "net/url" "strings" "sync" ) // SecretStore holds a set of secret strings that should be masked in log output. // It is safe for concurrent use by multiple goroutines. Multiple concurrent scans // can register different passwords; all are masked simultaneously. type SecretStore struct { mu sync.RWMutex secrets map[string]struct{} } // NewSecretStore creates a new empty secret store. func NewSecretStore() *SecretStore { return &SecretStore{ secrets: make(map[string]struct{}), } } // Add registers a secret string to be masked in all future log output. // Empty strings are ignored. Both the plain text and URL-encoded forms // are registered, because credentials may appear percent-encoded in URLs // (e.g. "p@ss" becomes "p%40ss" via url.QueryEscape or url.UserPassword). func (s *SecretStore) Add(secret string) { if secret == "" { return } s.mu.Lock() s.secrets[secret] = struct{}{} encoded := url.QueryEscape(secret) if encoded != secret { s.secrets[encoded] = struct{}{} } s.mu.Unlock() } // Remove unregisters a secret string so it is no longer masked. func (s *SecretStore) Remove(secret string) { if secret == "" { return } s.mu.Lock() delete(s.secrets, secret) encoded := url.QueryEscape(secret) if encoded != secret { delete(s.secrets, encoded) } s.mu.Unlock() } // Mask replaces all registered secret strings in text with "***". // Returns the original string unchanged if no secrets are registered. func (s *SecretStore) Mask(text string) string { s.mu.RLock() defer s.mu.RUnlock() if len(s.secrets) == 0 { return text } for secret := range s.secrets { if strings.Contains(text, secret) { text = strings.ReplaceAll(text, secret, "***") } } return text } // SecretMaskingHandler wraps a slog.Handler and replaces registered secrets // with "***" in all log record messages and attribute values before passing // them to the inner handler. This ensures credentials never appear in log // output regardless of where they originate in the code. type SecretMaskingHandler struct { inner slog.Handler secrets *SecretStore } // NewSecretMaskingHandler creates a handler that masks secrets in log output. func NewSecretMaskingHandler(inner slog.Handler, secrets *SecretStore) *SecretMaskingHandler { return &SecretMaskingHandler{ inner: inner, secrets: secrets, } } // Enabled reports whether the inner handler handles records at the given level. func (h *SecretMaskingHandler) Enabled(ctx context.Context, level slog.Level) bool { return h.inner.Enabled(ctx, level) } // Handle masks secrets in the record message and all attributes, then // delegates to the inner handler. func (h *SecretMaskingHandler) Handle(ctx context.Context, record slog.Record) error { // Fast path: no secrets registered h.secrets.mu.RLock() hasSecrets := len(h.secrets.secrets) > 0 h.secrets.mu.RUnlock() if !hasSecrets { return h.inner.Handle(ctx, record) } // Mask the message record.Message = h.secrets.Mask(record.Message) // Mask all attributes by collecting, masking, and replacing them maskedAttrs := make([]slog.Attr, 0, record.NumAttrs()) record.Attrs(func(a slog.Attr) bool { maskedAttrs = append(maskedAttrs, h.maskAttr(a)) return true }) // Create a new record without the old attrs and add the masked ones. // slog.Record doesn't have a method to clear attrs, so we build a new one. newRecord := slog.NewRecord(record.Time, record.Level, record.Message, record.PC) newRecord.AddAttrs(maskedAttrs...) return h.inner.Handle(ctx, newRecord) } // WithAttrs returns a new handler with the given pre-masked attributes. func (h *SecretMaskingHandler) WithAttrs(attrs []slog.Attr) slog.Handler { masked := make([]slog.Attr, len(attrs)) for i, a := range attrs { masked[i] = h.maskAttr(a) } return &SecretMaskingHandler{ inner: h.inner.WithAttrs(masked), secrets: h.secrets, } } // WithGroup returns a new handler with the given group name. func (h *SecretMaskingHandler) WithGroup(name string) slog.Handler { return &SecretMaskingHandler{ inner: h.inner.WithGroup(name), secrets: h.secrets, } } // maskAttr masks secrets in an attribute value. Handles string values, // error values, and recursively handles group attributes. func (h *SecretMaskingHandler) maskAttr(a slog.Attr) slog.Attr { switch a.Value.Kind() { case slog.KindString: a.Value = slog.StringValue(h.secrets.Mask(a.Value.String())) case slog.KindGroup: attrs := a.Value.Group() masked := make([]slog.Attr, len(attrs)) for i, ga := range attrs { masked[i] = h.maskAttr(ga) } a.Value = slog.GroupValue(masked...) case slog.KindAny: v := a.Value.Any() // Handle error values (Go's http.Client embeds full URLs in errors) if err, ok := v.(error); ok { masked := h.secrets.Mask(err.Error()) a.Value = slog.StringValue(masked) return a } // Handle fmt.Stringer (e.g. time.Duration, url.URL, etc.) if stringer, ok := v.(fmt.Stringer); ok { masked := h.secrets.Mask(stringer.String()) a.Value = slog.StringValue(masked) return a } // Handle string slices (used in BuildURLsFromEntry logging) if ss, ok := v.([]string); ok { maskedSlice := make([]string, len(ss)) for i, s := range ss { maskedSlice[i] = h.secrets.Mask(s) } a.Value = slog.AnyValue(maskedSlice) return a } // For other Any values, convert to string and mask str := fmt.Sprintf("%v", v) masked := h.secrets.Mask(str) if masked != str { a.Value = slog.StringValue(masked) } } return a }