131 lines
3.9 KiB
Go
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)
|
|
}
|