package logging import ( "context" "io" "log/slog" "reflect" "regexp" "strings" "unicode" ) const redacted = "[REDACTED]" var ( urlCredentialPattern = regexp.MustCompile(`(?i)([a-z][a-z0-9+.-]*://)([^/@\s:]+)(?::[^@/\s]*)?@`) secretAssignment = regexp.MustCompile(`(?i)\b(password|token|secret|authorization|api[_-]?key)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;&]+)`) bearerValue = regexp.MustCompile(`(?i)\bbearer\s+[^\s,;]+`) ) // NewJSONLogger returns a structured logger that redacts sensitive fields and // recognizable secret material before delegating to the JSON handler. func NewJSONLogger(writer io.Writer) *slog.Logger { if writer == nil { writer = io.Discard } return slog.New(&redactingHandler{next: slog.NewJSONHandler(writer, nil)}) } // WriteProcessError emits one structured process-level failure. It is for // lifecycle boundaries only and must not be called for per-request failures. func WriteProcessError(writer io.Writer, component string, err error) { NewJSONLogger(writer).Error("process failed", "component", component, "error_type", errorType(err)) } type redactingHandler struct{ next slog.Handler } func (handler *redactingHandler) Enabled(ctx context.Context, level slog.Level) bool { return handler != nil && handler.next != nil && handler.next.Enabled(ctx, level) } func (handler *redactingHandler) Handle(ctx context.Context, record slog.Record) error { if handler == nil || handler.next == nil { return nil } clean := slog.NewRecord(record.Time, record.Level, record.Message, record.PC) record.Attrs(func(attribute slog.Attr) bool { clean.AddAttrs(sanitizeAttribute(attribute)) return true }) return handler.next.Handle(ctx, clean) } func (handler *redactingHandler) WithAttrs(attributes []slog.Attr) slog.Handler { if handler == nil || handler.next == nil { return handler } return &redactingHandler{next: handler.next.WithAttrs(sanitizeAttributes(attributes))} } func (handler *redactingHandler) WithGroup(name string) slog.Handler { if handler == nil || handler.next == nil { return handler } return &redactingHandler{next: handler.next.WithGroup(name)} } func sanitizeAttributes(attributes []slog.Attr) []slog.Attr { result := make([]slog.Attr, 0, len(attributes)) for _, attribute := range attributes { result = append(result, sanitizeAttribute(attribute)) } return result } func sanitizeAttribute(attribute slog.Attr) slog.Attr { if sensitiveKey(attribute.Key) { return slog.String(attribute.Key, redacted) } switch attribute.Value.Kind() { case slog.KindString: return slog.String(attribute.Key, sanitizeText(attribute.Value.String())) case slog.KindAny: if _, ok := attribute.Value.Any().(error); ok { return slog.String(attribute.Key, redacted) } case slog.KindGroup: return slog.Attr{Key: attribute.Key, Value: slog.GroupValue(sanitizeAttributes(attribute.Value.Group())...)} } return attribute } func sensitiveKey(key string) bool { switch normalizeKey(key) { case "password", "passphrase", "token", "apitoken", "apikey", "authorization", "secret", "credential", "credentials", "privatekey", "error", "err": return true default: return false } } func errorType(err error) string { if err == nil { return "" } return reflect.TypeOf(err).String() } func normalizeKey(value string) string { var normalized strings.Builder normalized.Grow(len(value)) for _, character := range value { if unicode.IsLetter(character) || unicode.IsDigit(character) { normalized.WriteRune(unicode.ToLower(character)) } } return normalized.String() } func sanitizeText(value string) string { value = urlCredentialPattern.ReplaceAllString(value, "${1}"+redacted+"@") value = secretAssignment.ReplaceAllStringFunc(value, func(match string) string { separator := strings.IndexAny(match, ":=") if separator < 0 { return redacted } return match[:separator+1] + redacted }) return bearerValue.ReplaceAllString(value, "Bearer "+redacted) }