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:
+3
-3
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user