Fix credentials leaking in debug logs (#4)

Add a secret-masking slog.Handler that automatically replaces registered
passwords with "***" in all log output. Secrets are registered per-scan
when a discovery request arrives and unregistered when it completes.

This approach masks credentials everywhere they appear in logs — URL
userinfo, query parameters, path segments, and Go HTTP error messages —
without modifying any business logic in scanner, builder, tester, or
ONVIF components. API responses are unaffected and still return full
URLs with credentials for frontend use.
This commit is contained in:
eduard256
2026-03-20 11:03:01 +00:00
parent e269e243da
commit 8cf05a1576
7 changed files with 422 additions and 9 deletions
+3 -3
View File
@@ -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)
+11
View File
@@ -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,
+5 -1
View File
@@ -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)
+9 -3
View File
@@ -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 {
+3 -2
View File
@@ -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
+188
View File
@@ -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
}
@@ -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)
}
}