diff --git a/cmd/strix/main.go b/cmd/strix/main.go index f89edc4..24bb6e6 100644 --- a/cmd/strix/main.go +++ b/cmd/strix/main.go @@ -45,11 +45,11 @@ func main() { cfg.Version = Version // Setup logger - slogger := cfg.SetupLogger() + slogger, secrets := cfg.SetupLogger() slog.SetDefault(slogger) // Create adapter for our interface - log := logger.NewAdapter(slogger) + log := logger.NewAdapter(slogger, secrets) log.Info("starting Strix", slog.String("version", Version), @@ -63,7 +63,7 @@ func main() { } // Create API server - apiServer, err := api.NewServer(cfg, log) + apiServer, err := api.NewServer(cfg, secrets, log) if err != nil { log.Error("failed to create API server", err) os.Exit(1) diff --git a/internal/api/handlers/discover.go b/internal/api/handlers/discover.go index 073791c..7a8f58a 100644 --- a/internal/api/handlers/discover.go +++ b/internal/api/handlers/discover.go @@ -7,6 +7,7 @@ import ( "github.com/go-playground/validator/v10" "github.com/eduard256/Strix/internal/camera/discovery" "github.com/eduard256/Strix/internal/models" + "github.com/eduard256/Strix/internal/utils/logger" "github.com/eduard256/Strix/pkg/sse" ) @@ -15,6 +16,7 @@ type DiscoverHandler struct { scanner *discovery.Scanner sseServer *sse.Server validator *validator.Validate + secrets *logger.SecretStore logger interface{ Debug(string, ...any); Error(string, error, ...any); Info(string, ...any) } } @@ -22,11 +24,13 @@ type DiscoverHandler struct { func NewDiscoverHandler( scanner *discovery.Scanner, sseServer *sse.Server, + secrets *logger.SecretStore, logger interface{ Debug(string, ...any); Error(string, error, ...any); Info(string, ...any) }, ) *DiscoverHandler { return &DiscoverHandler{ scanner: scanner, sseServer: sseServer, + secrets: secrets, validator: validator.New(), logger: logger, } @@ -65,6 +69,13 @@ func (h *DiscoverHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // Register password as a secret so it gets masked in all log output. + // The secret is automatically unregistered when the request completes. + if req.Password != "" { + h.secrets.Add(req.Password) + defer h.secrets.Remove(req.Password) + } + h.logger.Info("stream discovery requested", "target", req.Target, "model", req.Model, diff --git a/internal/api/routes.go b/internal/api/routes.go index bd643d3..a014190 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -10,6 +10,7 @@ import ( "github.com/eduard256/Strix/internal/camera/discovery" "github.com/eduard256/Strix/internal/camera/stream" "github.com/eduard256/Strix/internal/config" + logutil "github.com/eduard256/Strix/internal/utils/logger" "github.com/eduard256/Strix/pkg/sse" ) @@ -22,12 +23,14 @@ type Server struct { scanner *discovery.Scanner probeService *discovery.ProbeService sseServer *sse.Server + secrets *logutil.SecretStore logger interface{ Debug(string, ...any); Error(string, error, ...any); Info(string, ...any) } } // NewServer creates a new API server func NewServer( cfg *config.Config, + secrets *logutil.SecretStore, logger interface{ Debug(string, ...any); Error(string, error, ...any); Info(string, ...any) }, ) (*Server, error) { // Initialize database loader @@ -102,6 +105,7 @@ func NewServer( scanner: scanner, probeService: probeService, sseServer: sseServer, + secrets: secrets, logger: logger, } @@ -147,7 +151,7 @@ func (s *Server) setupRoutes() { s.router.Post("/cameras/search", handlers.NewSearchHandler(s.searchEngine, s.logger).ServeHTTP) // Stream discovery (SSE) - s.router.Post("/streams/discover", handlers.NewDiscoverHandler(s.scanner, s.sseServer, s.logger).ServeHTTP) + s.router.Post("/streams/discover", handlers.NewDiscoverHandler(s.scanner, s.sseServer, s.secrets, s.logger).ServeHTTP) // Device probe (ping + DNS + ARP/OUI + mDNS) s.router.Get("/probe", handlers.NewProbeHandler(s.probeService, s.logger).ServeHTTP) diff --git a/internal/config/config.go b/internal/config/config.go index 0bdef72..7c9fbe8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/eduard256/Strix/internal/utils/logger" "gopkg.in/yaml.v3" ) @@ -225,8 +226,10 @@ func validateListen(listen string) error { return nil } -// SetupLogger configures the global logger -func (c *Config) SetupLogger() *slog.Logger { +// SetupLogger configures the global logger. It returns the logger and a +// SecretStore that can be used to register credentials for automatic masking +// in all log output. +func (c *Config) SetupLogger() (*slog.Logger, *logger.SecretStore) { var level slog.Level switch c.Logger.Level { case "debug": @@ -250,7 +253,10 @@ func (c *Config) SetupLogger() *slog.Logger { handler = slog.NewTextHandler(os.Stdout, opts) } - return slog.New(handler) + secrets := logger.NewSecretStore() + maskedHandler := logger.NewSecretMaskingHandler(handler, secrets) + + return slog.New(maskedHandler), secrets } func getEnv(key, defaultValue string) string { diff --git a/internal/utils/logger/adapter.go b/internal/utils/logger/adapter.go index b0319f8..37a339f 100644 --- a/internal/utils/logger/adapter.go +++ b/internal/utils/logger/adapter.go @@ -5,11 +5,12 @@ import "log/slog" // Adapter wraps slog.Logger to match our interface type Adapter struct { *slog.Logger + Secrets *SecretStore } // NewAdapter creates a new logger adapter -func NewAdapter(logger *slog.Logger) *Adapter { - return &Adapter{Logger: logger} +func NewAdapter(logger *slog.Logger, secrets *SecretStore) *Adapter { + return &Adapter{Logger: logger, Secrets: secrets} } // Debug logs a debug message diff --git a/internal/utils/logger/masking_handler.go b/internal/utils/logger/masking_handler.go new file mode 100644 index 0000000..cefe4b0 --- /dev/null +++ b/internal/utils/logger/masking_handler.go @@ -0,0 +1,188 @@ +package logger + +import ( + "context" + "fmt" + "log/slog" + "strings" + "sync" +) + +// SecretStore holds a set of secret strings that should be masked in log output. +// It is safe for concurrent use by multiple goroutines. Multiple concurrent scans +// can register different passwords; all are masked simultaneously. +type SecretStore struct { + mu sync.RWMutex + secrets map[string]struct{} +} + +// NewSecretStore creates a new empty secret store. +func NewSecretStore() *SecretStore { + return &SecretStore{ + secrets: make(map[string]struct{}), + } +} + +// Add registers a secret string to be masked in all future log output. +// Empty strings are ignored. +func (s *SecretStore) Add(secret string) { + if secret == "" { + return + } + s.mu.Lock() + s.secrets[secret] = struct{}{} + s.mu.Unlock() +} + +// Remove unregisters a secret string so it is no longer masked. +func (s *SecretStore) Remove(secret string) { + if secret == "" { + return + } + s.mu.Lock() + delete(s.secrets, secret) + s.mu.Unlock() +} + +// Mask replaces all registered secret strings in text with "***". +// Returns the original string unchanged if no secrets are registered. +func (s *SecretStore) Mask(text string) string { + s.mu.RLock() + defer s.mu.RUnlock() + + if len(s.secrets) == 0 { + return text + } + + for secret := range s.secrets { + if strings.Contains(text, secret) { + text = strings.ReplaceAll(text, secret, "***") + } + } + return text +} + +// SecretMaskingHandler wraps a slog.Handler and replaces registered secrets +// with "***" in all log record messages and attribute values before passing +// them to the inner handler. This ensures credentials never appear in log +// output regardless of where they originate in the code. +type SecretMaskingHandler struct { + inner slog.Handler + secrets *SecretStore +} + +// NewSecretMaskingHandler creates a handler that masks secrets in log output. +func NewSecretMaskingHandler(inner slog.Handler, secrets *SecretStore) *SecretMaskingHandler { + return &SecretMaskingHandler{ + inner: inner, + secrets: secrets, + } +} + +// Enabled reports whether the inner handler handles records at the given level. +func (h *SecretMaskingHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.inner.Enabled(ctx, level) +} + +// Handle masks secrets in the record message and all attributes, then +// delegates to the inner handler. +func (h *SecretMaskingHandler) Handle(ctx context.Context, record slog.Record) error { + // Fast path: no secrets registered + h.secrets.mu.RLock() + hasSecrets := len(h.secrets.secrets) > 0 + h.secrets.mu.RUnlock() + + if !hasSecrets { + return h.inner.Handle(ctx, record) + } + + // Mask the message + record.Message = h.secrets.Mask(record.Message) + + // Mask all attributes by collecting, masking, and replacing them + maskedAttrs := make([]slog.Attr, 0, record.NumAttrs()) + record.Attrs(func(a slog.Attr) bool { + maskedAttrs = append(maskedAttrs, h.maskAttr(a)) + return true + }) + + // Create a new record without the old attrs and add the masked ones. + // slog.Record doesn't have a method to clear attrs, so we build a new one. + newRecord := slog.NewRecord(record.Time, record.Level, record.Message, record.PC) + newRecord.AddAttrs(maskedAttrs...) + + return h.inner.Handle(ctx, newRecord) +} + +// WithAttrs returns a new handler with the given pre-masked attributes. +func (h *SecretMaskingHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + masked := make([]slog.Attr, len(attrs)) + for i, a := range attrs { + masked[i] = h.maskAttr(a) + } + return &SecretMaskingHandler{ + inner: h.inner.WithAttrs(masked), + secrets: h.secrets, + } +} + +// WithGroup returns a new handler with the given group name. +func (h *SecretMaskingHandler) WithGroup(name string) slog.Handler { + return &SecretMaskingHandler{ + inner: h.inner.WithGroup(name), + secrets: h.secrets, + } +} + +// maskAttr masks secrets in an attribute value. Handles string values, +// error values, and recursively handles group attributes. +func (h *SecretMaskingHandler) maskAttr(a slog.Attr) slog.Attr { + switch a.Value.Kind() { + case slog.KindString: + a.Value = slog.StringValue(h.secrets.Mask(a.Value.String())) + + case slog.KindGroup: + attrs := a.Value.Group() + masked := make([]slog.Attr, len(attrs)) + for i, ga := range attrs { + masked[i] = h.maskAttr(ga) + } + a.Value = slog.GroupValue(masked...) + + case slog.KindAny: + v := a.Value.Any() + + // Handle error values (Go's http.Client embeds full URLs in errors) + if err, ok := v.(error); ok { + masked := h.secrets.Mask(err.Error()) + a.Value = slog.StringValue(masked) + return a + } + + // Handle fmt.Stringer (e.g. time.Duration, url.URL, etc.) + if stringer, ok := v.(fmt.Stringer); ok { + masked := h.secrets.Mask(stringer.String()) + a.Value = slog.StringValue(masked) + return a + } + + // Handle string slices (used in BuildURLsFromEntry logging) + if ss, ok := v.([]string); ok { + maskedSlice := make([]string, len(ss)) + for i, s := range ss { + maskedSlice[i] = h.secrets.Mask(s) + } + a.Value = slog.AnyValue(maskedSlice) + return a + } + + // For other Any values, convert to string and mask + str := fmt.Sprintf("%v", v) + masked := h.secrets.Mask(str) + if masked != str { + a.Value = slog.StringValue(masked) + } + } + + return a +} diff --git a/internal/utils/logger/masking_handler_test.go b/internal/utils/logger/masking_handler_test.go new file mode 100644 index 0000000..49623a1 --- /dev/null +++ b/internal/utils/logger/masking_handler_test.go @@ -0,0 +1,203 @@ +package logger + +import ( + "bytes" + "context" + "errors" + "log/slog" + "strings" + "sync" + "testing" +) + +func TestSecretStore_AddRemoveMask(t *testing.T) { + store := NewSecretStore() + + // No secrets: text unchanged + if got := store.Mask("password=secret123"); got != "password=secret123" { + t.Errorf("expected unchanged text, got %q", got) + } + + // Add a secret + store.Add("secret123") + if got := store.Mask("password=secret123"); got != "password=***" { + t.Errorf("expected masked, got %q", got) + } + + // Remove the secret + store.Remove("secret123") + if got := store.Mask("password=secret123"); got != "password=secret123" { + t.Errorf("expected unmasked after remove, got %q", got) + } +} + +func TestSecretStore_EmptyString(t *testing.T) { + store := NewSecretStore() + store.Add("") + if got := store.Mask("test"); got != "test" { + t.Errorf("empty secret should be ignored, got %q", got) + } + store.Remove("") // should not panic +} + +func TestSecretStore_MultipleSecrets(t *testing.T) { + store := NewSecretStore() + store.Add("pass1") + store.Add("pass2") + + got := store.Mask("url=rtsp://user:pass1@host and also pwd=pass2&rate=0") + if strings.Contains(got, "pass1") || strings.Contains(got, "pass2") { + t.Errorf("both passwords should be masked, got %q", got) + } +} + +func TestSecretStore_ConcurrentAccess(t *testing.T) { + store := NewSecretStore() + var wg sync.WaitGroup + + // Simulate concurrent scans adding/removing/masking + for i := 0; i < 100; i++ { + wg.Add(3) + secret := "secret" + string(rune('A'+i%26)) + + go func(s string) { + defer wg.Done() + store.Add(s) + }(secret) + + go func() { + defer wg.Done() + _ = store.Mask("some text with secretA in it") + }() + + go func(s string) { + defer wg.Done() + store.Remove(s) + }(secret) + } + + wg.Wait() +} + +func TestSecretMaskingHandler_MasksStringAttrs(t *testing.T) { + var buf bytes.Buffer + store := NewSecretStore() + store.Add("mypassword") + + inner := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + handler := NewSecretMaskingHandler(inner, store) + log := slog.New(handler) + + log.Debug("testing stream", "url", "rtsp://admin:mypassword@192.168.1.10/stream") + + output := buf.String() + if strings.Contains(output, "mypassword") { + t.Errorf("password should be masked in output: %s", output) + } + if !strings.Contains(output, "***") { + t.Errorf("expected *** in output: %s", output) + } +} + +func TestSecretMaskingHandler_MasksMessage(t *testing.T) { + var buf bytes.Buffer + store := NewSecretStore() + store.Add("secretpwd") + + inner := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + handler := NewSecretMaskingHandler(inner, store) + log := slog.New(handler) + + log.Debug("failed with secretpwd in message") + + output := buf.String() + if strings.Contains(output, "secretpwd") { + t.Errorf("password should be masked in message: %s", output) + } +} + +func TestSecretMaskingHandler_MasksErrorValues(t *testing.T) { + var buf bytes.Buffer + store := NewSecretStore() + store.Add("r6wnm0wlix") + + inner := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + handler := NewSecretMaskingHandler(inner, store) + log := slog.New(handler) + + err := errors.New(`Get "http://10.0.20.111/cgi-bin/encoder?PWD=r6wnm0wlix&USER=admin": dial tcp`) + log.Debug("request failed", "error", err) + + output := buf.String() + if strings.Contains(output, "r6wnm0wlix") { + t.Errorf("password should be masked in error: %s", output) + } +} + +func TestSecretMaskingHandler_NoSecretsPassthrough(t *testing.T) { + var buf bytes.Buffer + store := NewSecretStore() + + inner := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + handler := NewSecretMaskingHandler(inner, store) + log := slog.New(handler) + + log.Debug("normal message", "key", "value") + + output := buf.String() + if !strings.Contains(output, "normal message") || !strings.Contains(output, "value") { + t.Errorf("output should pass through unchanged: %s", output) + } +} + +func TestSecretMaskingHandler_MasksMultipleOccurrences(t *testing.T) { + var buf bytes.Buffer + store := NewSecretStore() + store.Add("secret123") + + inner := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + handler := NewSecretMaskingHandler(inner, store) + log := slog.New(handler) + + log.Debug("test", + "url1", "rtsp://user:secret123@host1/stream", + "url2", "http://host2/snap?pwd=secret123", + "path", "/user=admin_password=secret123_channel=1", + ) + + output := buf.String() + if strings.Contains(output, "secret123") { + t.Errorf("all occurrences should be masked: %s", output) + } +} + +func TestSecretMaskingHandler_Enabled(t *testing.T) { + store := NewSecretStore() + inner := slog.NewTextHandler(&bytes.Buffer{}, &slog.HandlerOptions{Level: slog.LevelInfo}) + handler := NewSecretMaskingHandler(inner, store) + + if handler.Enabled(context.Background(), slog.LevelDebug) { + t.Error("debug should be disabled when level is info") + } + if !handler.Enabled(context.Background(), slog.LevelInfo) { + t.Error("info should be enabled") + } +} + +func TestSecretMaskingHandler_WithAttrs(t *testing.T) { + var buf bytes.Buffer + store := NewSecretStore() + store.Add("secretval") + + inner := slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}) + handler := NewSecretMaskingHandler(inner, store) + child := handler.WithAttrs([]slog.Attr{slog.String("static", "has secretval inside")}) + log := slog.New(child) + + log.Debug("test") + + output := buf.String() + if strings.Contains(output, "secretval") { + t.Errorf("pre-set attr should be masked: %s", output) + } +}