3acc966658
SecretStore.Add now registers both plain text and URL-encoded forms of the password. Fixes cases where passwords with special characters (e.g. @, #, :) appear percent-encoded in URLs but were not matched by the masking handler.
200 lines
5.5 KiB
Go
200 lines
5.5 KiB
Go
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
|
|
}
|