diff --git a/src/client/client.go b/src/client/client.go index c666e90a..85cf6717 100644 --- a/src/client/client.go +++ b/src/client/client.go @@ -3,7 +3,6 @@ package client import ( "bytes" "fmt" - "io" "log/slog" "net/http" "os" @@ -70,7 +69,7 @@ func uploadPlaylistArtwork(hc *util.HttpClient, endpoint, localPath string, head } req, err := http.NewRequest("POST", endpoint, bytes.NewReader(data)) if err != nil { - return fmt.Errorf("build request: %w", err) + return fmt.Errorf("build artwork upload request") } req.Header.Set("Content-Type", "image/jpeg") req.ContentLength = int64(len(data)) @@ -79,7 +78,7 @@ func uploadPlaylistArtwork(hc *util.HttpClient, endpoint, localPath string, head } resp, err := hc.Client.Do(req) if err != nil { - return fmt.Errorf("post: %w", err) + return fmt.Errorf("artwork upload request failed") } defer func() { if cerr := resp.Body.Close(); cerr != nil { @@ -87,8 +86,7 @@ func uploadPlaylistArtwork(hc *util.HttpClient, endpoint, localPath string, head } }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - body, _ := io.ReadAll(resp.Body) - return fmt.Errorf("upload artwork: status %d, body: %s", resp.StatusCode, string(body)) + return fmt.Errorf("upload artwork: status %d", resp.StatusCode) } return nil } @@ -325,4 +323,4 @@ func rankResult(track NormalisedTrack, r SearchResult) int { } } return score -} \ No newline at end of file +} diff --git a/src/logging/logging.go b/src/logging/logging.go index 2e3e5014..23be46cf 100644 --- a/src/logging/logging.go +++ b/src/logging/logging.go @@ -9,6 +9,7 @@ import ( func Init(cfg *config.Config) { + configureRedaction(cfg) baseHandler := slog.NewTextHandler( os.Stdout, &slog.HandlerOptions{ Level: getLogLevel(cfg.LogLevel), }) @@ -19,10 +20,11 @@ func Init(cfg *config.Config) { } - handler := ¬ifyHandler{ + notifyHandler := ¬ifyHandler{ handler: baseHandler, notify: notifyClient, } + handler := newRedactingHandler(notifyHandler) logger := slog.New(handler) slog.SetDefault(logger) } @@ -54,4 +56,4 @@ func getLogLevel(level string) slog.Level { default: return slog.LevelWarn } -} \ No newline at end of file +} diff --git a/src/logging/redact_handler.go b/src/logging/redact_handler.go new file mode 100644 index 00000000..a96f77a3 --- /dev/null +++ b/src/logging/redact_handler.go @@ -0,0 +1,148 @@ +package logging + +import ( + "context" + "fmt" + "io" + "log/slog" + "sort" + "strings" + "sync/atomic" + + "explo/src/config" +) + +const redactedValue = "[REDACTED]" + +var secretValues atomic.Value + +func init() { + secretValues.Store([]string(nil)) +} + +type redactingHandler struct { + handler slog.Handler +} + +func newRedactingHandler(handler slog.Handler) slog.Handler { + return &redactingHandler{handler: handler} +} + +// NewTextHandler creates a text handler that omits credential fields and +// redacts configured credential values from messages and attributes. +func NewTextHandler(w io.Writer, opts *slog.HandlerOptions) slog.Handler { + return newRedactingHandler(slog.NewTextHandler(w, opts)) +} + +func (h *redactingHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.handler.Enabled(ctx, level) +} + +func (h *redactingHandler) Handle(ctx context.Context, record slog.Record) error { + clean := slog.NewRecord(record.Time, record.Level, redactText(record.Message), record.PC) + record.Attrs(func(attr slog.Attr) bool { + if attr = redactAttr(attr); attr.Key != "" { + clean.AddAttrs(attr) + } + return true + }) + return h.handler.Handle(ctx, clean) +} + +func (h *redactingHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + clean := make([]slog.Attr, 0, len(attrs)) + for _, attr := range attrs { + if attr = redactAttr(attr); attr.Key != "" { + clean = append(clean, attr) + } + } + return &redactingHandler{handler: h.handler.WithAttrs(clean)} +} + +func (h *redactingHandler) WithGroup(name string) slog.Handler { + return &redactingHandler{handler: h.handler.WithGroup(name)} +} + +func redactAttr(attr slog.Attr) slog.Attr { + if isCredentialKey(attr.Key) { + return slog.Attr{} + } + + value := attr.Value.Resolve() + switch value.Kind() { + case slog.KindString: + attr.Value = slog.StringValue(redactText(value.String())) + case slog.KindGroup: + group := value.Group() + clean := make([]slog.Attr, 0, len(group)) + for _, child := range group { + if child = redactAttr(child); child.Key != "" { + clean = append(clean, child) + } + } + attr.Value = slog.GroupValue(clean...) + case slog.KindAny: + plain := fmt.Sprint(value.Any()) + if redacted := redactText(plain); redacted != plain { + attr.Value = slog.StringValue(redacted) + } + } + return attr +} + +func isCredentialKey(key string) bool { + key = strings.ToLower(key) + key = strings.NewReplacer("_", "", "-", "", " ", "").Replace(key) + for _, marker := range []string{"password", "passwd", "apikey", "authorization", "credential", "secret", "token"} { + if strings.Contains(key, marker) { + return true + } + } + return false +} + +func redactText(value string) string { + for _, secret := range secretValues.Load().([]string) { + value = strings.ReplaceAll(value, secret, redactedValue) + } + return value +} + +func configureRedaction(cfg *config.Config) { + values := []string{ + cfg.ServerCfg.Password, + cfg.ClientCfg.Creds.APIKey, + cfg.ClientCfg.Creds.Password, + cfg.ClientCfg.Creds.Token, + cfg.ClientCfg.Creds.Salt, + cfg.ClientCfg.AdminCreds.APIKey, + cfg.ClientCfg.AdminCreds.Password, + cfg.DownloadCfg.Youtube.APIKey, + cfg.DownloadCfg.Slskd.APIKey, + cfg.DownloadCfg.Lidarr.APIKey, + cfg.DiscoveryCfg.Listenbrainz.UserToken, + cfg.NotifyCfg.Matrix.AccessToken, + cfg.NotifyCfg.Discord.BotToken, + } + values = append(values, cfg.NotifyCfg.Http.ReceiverURLs...) + for key, value := range cfg.ClientCfg.Creds.Headers { + if isCredentialKey(key) { + values = append(values, value) + } + } + + seen := make(map[string]struct{}, len(values)) + secrets := make([]string, 0, len(values)) + for _, value := range values { + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + secrets = append(secrets, value) + } + sort.Slice(secrets, func(i, j int) bool { return len(secrets[i]) > len(secrets[j]) }) + secretValues.Store(secrets) +} diff --git a/src/util/http.go b/src/util/http.go index 588e3b04..0672b6a6 100644 --- a/src/util/http.go +++ b/src/util/http.go @@ -6,13 +6,13 @@ import ( "io" "log/slog" "net/http" + "net/url" "os" "path/filepath" "strings" "time" "explo/src/config" - "explo/src/logging" ) type HttpClientConfig struct { @@ -36,7 +36,7 @@ func NewHttp(cfg HttpClientConfig) *HttpClient { func (c *HttpClient) MakeRequest(method, url string, payload io.Reader, headers map[string]string) ([]byte, error) { req, err := http.NewRequest(method, url, payload) if err != nil { - return nil, fmt.Errorf("failed to initialize request: %s", err.Error()) + return nil, fmt.Errorf("failed to initialize %s request to %s", method, requestEndpoint(url)) } req.Header.Add("Content-Type", "application/json") req.Header.Add("Accept", "application/json") @@ -48,7 +48,7 @@ func (c *HttpClient) MakeRequest(method, url string, payload io.Reader, headers resp, err := c.Client.Do(req) if err != nil { - return nil, fmt.Errorf("failed to make request: %s", err.Error()) + return nil, fmt.Errorf("failed to make %s request to %s", method, requestEndpoint(url)) } defer func() { @@ -63,17 +63,27 @@ func (c *HttpClient) MakeRequest(method, url string, payload io.Reader, headers } if resp.StatusCode < 200 || resp.StatusCode >= 300 { - slog.Debug("response info", logging.RuntimeAttr(string(body))) - return nil, fmt.Errorf("got %d from %s", resp.StatusCode, url) + return nil, fmt.Errorf("got %d from %s", resp.StatusCode, requestEndpoint(url)) } return body, nil } +func requestEndpoint(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return "remote endpoint" + } + u.User = nil + u.RawQuery = "" + u.ForceQuery = false + u.Fragment = "" + return u.String() +} + func ParseResp[T any](body []byte, target *T) error { if err := json.Unmarshal(body, target); err != nil { - slog.Debug("response info", logging.RuntimeAttr(string(body))) return fmt.Errorf("error unmarshaling response body: %s", err.Error()) } return nil @@ -93,7 +103,7 @@ func DownloadFile(url, destPath string) (string, error) { } resp, err := http.Get(url) //nolint:noctx if err != nil { - return "", fmt.Errorf("get: %w", err) + return "", fmt.Errorf("failed to download from %s", requestEndpoint(url)) } defer func() { if cerr := resp.Body.Close(); cerr != nil { @@ -101,7 +111,7 @@ func DownloadFile(url, destPath string) (string, error) { } }() if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("status %d from %s", resp.StatusCode, url) + return "", fmt.Errorf("status %d from %s", resp.StatusCode, requestEndpoint(url)) } data, err := io.ReadAll(resp.Body) if err != nil { diff --git a/src/web/backend/playlist/spotify.go b/src/web/backend/playlist/spotify.go index 9209f51f..8ccb27b1 100644 --- a/src/web/backend/playlist/spotify.go +++ b/src/web/backend/playlist/spotify.go @@ -331,13 +331,12 @@ func (s *spotifySession) fetchAccessToken() error { resp, err := s.client.Do(req) if err != nil { - return err + return fmt.Errorf("token endpoint request failed") } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return fmt.Errorf("token endpoint returned %d: %s", resp.StatusCode, string(b)) + return fmt.Errorf("token endpoint returned %d", resp.StatusCode) } var tok struct { @@ -391,8 +390,7 @@ func (s *spotifySession) fetchClientToken() error { defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) - return fmt.Errorf("client token endpoint returned %d: %s", resp.StatusCode, string(b)) + return fmt.Errorf("client token endpoint returned %d", resp.StatusCode) } var result struct { @@ -661,8 +659,7 @@ func queryPartnerAPI(playlistID string, offset, limit int) (*partnerPlaylistResp defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - return nil, fmt.Errorf("partner API returned %d: %s", resp.StatusCode, string(b)) + return nil, fmt.Errorf("partner API returned %d", resp.StatusCode) } var result partnerPlaylistResp @@ -808,4 +805,4 @@ func findPartHash(jsContent, name string) string { return "" } return rest[:end] -} \ No newline at end of file +} diff --git a/src/web/backend/run/events.go b/src/web/backend/run/events.go index 3f958bbd..f5daa1fe 100644 --- a/src/web/backend/run/events.go +++ b/src/web/backend/run/events.go @@ -1,13 +1,15 @@ package run import ( + "bufio" "fmt" + "io" "log/slog" "os" - "io" - "path/filepath" - "bufio" "os/exec" + "path/filepath" + + "explo/src/logging" ) @@ -90,7 +92,7 @@ func (mr *ManualRun) InitServerLog() { return } w := io.MultiWriter(os.Stderr, lf) - slog.SetDefault(slog.New(slog.NewTextHandler(w, nil))) + slog.SetDefault(slog.New(logging.NewTextHandler(w, nil))) } // openRunLog opens the single rolling log file in append mode. @@ -100,4 +102,4 @@ func (mr *ManualRun) openRunLog() (*os.File, error) { return nil, err } return os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) -} \ No newline at end of file +}