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
@@ -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)
}
}