proxy-pool/internal/platform/logging/logging.go
2026-08-02 14:29:10 +08:00

131 lines
3.9 KiB
Go

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)
}