package logx import ( "log/slog" "reflect" "strings" "time" "unicode" ) const redacted = "[REDACTED]" // Redaction applies to structured keys, including nested maps and structs. // Free text (messages, errors, URLs embedded in strings) is not parsed. func redactor(extra []string) func([]string, slog.Attr) slog.Attr { keys := make(map[string]bool) for _, key := range append([]string{"password", "passwd", "secret", "clientSecret", "token", "accessToken", "refreshToken", "authorization", "cookie", "setCookie", "apiKey", "privateKey", "signedUrl"}, extra...) { keys[normalizedKey(key)] = true } return func(groups []string, a slog.Attr) slog.Attr { for _, group := range groups { if keys[normalizedKey(group)] { return slog.String(a.Key, redacted) } } if keys[normalizedKey(a.Key)] { return slog.String(a.Key, redacted) } a.Value = a.Value.Resolve() if a.Value.Kind() == slog.KindAny { a.Value = slog.AnyValue(sanitize(reflect.ValueOf(a.Value.Any()), keys, 0)) } return a } } func normalizedKey(key string) string { return strings.Map(func(r rune) rune { if unicode.IsLetter(r) || unicode.IsDigit(r) { return unicode.ToLower(r) } return -1 }, key) } func sanitize(v reflect.Value, keys map[string]bool, depth int) any { if !v.IsValid() { return nil } if depth >= 16 { return "[TRUNCATED]" } if (v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface) && v.IsNil() { return nil } if v.CanInterface() { switch x := v.Interface().(type) { case *slog.Source: return sanitize(reflect.ValueOf(map[string]any{"function": x.Function, "file": x.File, "line": x.Line}), keys, depth+1) case time.Time: return x case error: return x.Error() case []slog.Attr: out := make(map[string]any, len(x)) for _, a := range x { if keys[normalizedKey(a.Key)] { out[a.Key] = redacted } else { out[a.Key] = sanitize(reflect.ValueOf(a.Value.Resolve().Any()), keys, depth+1) } } return out case slog.LogValuer: value := slog.AnyValue(x).Resolve() return sanitize(reflect.ValueOf(value.Any()), keys, depth+1) } } switch v.Kind() { case reflect.Interface, reflect.Pointer: return sanitize(v.Elem(), keys, depth+1) case reflect.Map: if v.Type().Key().Kind() != reflect.String { return "[UNSUPPORTED MAP]" } out := make(map[string]any, v.Len()) it := v.MapRange() for it.Next() { key := it.Key().String() if keys[normalizedKey(key)] { out[key] = redacted } else { out[key] = sanitize(it.Value(), keys, depth+1) } } return out case reflect.Struct: out := make(map[string]any) for i := 0; i < v.NumField(); i++ { field := v.Type().Field(i) if !field.IsExported() { continue } name := strings.Split(field.Tag.Get("json"), ",")[0] if name == "-" { continue } if name == "" { name = field.Name } if keys[normalizedKey(name)] || keys[normalizedKey(field.Name)] { out[name] = redacted } else { out[name] = sanitize(v.Field(i), keys, depth+1) } } return out case reflect.Slice, reflect.Array: if v.Type().Elem().Kind() == reflect.Uint8 { return "[BINARY]" } out := make([]any, v.Len()) for i := range out { out[i] = sanitize(v.Index(i), keys, depth+1) } return out default: if v.CanInterface() { return v.Interface() } return nil } }