From af1a7add72fcb1c5a5b60999dbb861e2354442dc Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Thu, 11 Dec 2025 20:50:37 -0600 Subject: [PATCH] spindle/models create a secret mask for workflow logs Signed-off-by: Evan Jarrett --- spindle/engine/engine.go | 6 +- spindle/models/logger.go | 7 +- spindle/models/secret_mask.go | 51 +++++++++++ spindle/models/secret_mask_test.go | 135 +++++++++++++++++++++++++++++ 4 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 spindle/models/secret_mask.go create mode 100644 spindle/models/secret_mask_test.go diff --git a/spindle/engine/engine.go b/spindle/engine/engine.go index a6cf9f15..365b6743 100644 --- a/spindle/engine/engine.go +++ b/spindle/engine/engine.go @@ -70,7 +70,11 @@ func StartWorkflows(l *slog.Logger, vault secrets.Manager, cfg *config.Config, d } defer eng.DestroyWorkflow(ctx, wid) - wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid) + secretValues := make([]string, len(allSecrets)) + for i, s := range allSecrets { + secretValues[i] = s.Value + } + wfLogger, err := models.NewWorkflowLogger(cfg.Server.LogDir, wid, secretValues) if err != nil { l.Warn("failed to setup step logger; logs will not be persisted", "error", err) wfLogger = nil diff --git a/spindle/models/logger.go b/spindle/models/logger.go index 3a43ccbf..3736c50a 100644 --- a/spindle/models/logger.go +++ b/spindle/models/logger.go @@ -12,9 +12,10 @@ import ( type WorkflowLogger struct { file *os.File encoder *json.Encoder + mask *SecretMask } -func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) { +func NewWorkflowLogger(baseDir string, wid WorkflowId, secretValues []string) (*WorkflowLogger, error) { path := LogFilePath(baseDir, wid) file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) @@ -25,6 +26,7 @@ func NewWorkflowLogger(baseDir string, wid WorkflowId) (*WorkflowLogger, error) return &WorkflowLogger{ file: file, encoder: json.NewEncoder(file), + mask: NewSecretMask(secretValues), }, nil } @@ -62,6 +64,9 @@ type dataWriter struct { func (w *dataWriter) Write(p []byte) (int, error) { line := strings.TrimRight(string(p), "\r\n") + if w.logger.mask != nil { + line = w.logger.mask.Mask(line) + } entry := NewDataLogLine(w.idx, line, w.stream) if err := w.logger.encoder.Encode(entry); err != nil { return 0, err diff --git a/spindle/models/secret_mask.go b/spindle/models/secret_mask.go new file mode 100644 index 00000000..29b01d2d --- /dev/null +++ b/spindle/models/secret_mask.go @@ -0,0 +1,51 @@ +package models + +import ( + "encoding/base64" + "strings" +) + +// SecretMask replaces secret values in strings with "***". +type SecretMask struct { + replacer *strings.Replacer +} + +// NewSecretMask creates a mask for the given secret values. +// Also registers base64-encoded variants of each secret. +func NewSecretMask(values []string) *SecretMask { + var pairs []string + + for _, value := range values { + if value == "" { + continue + } + + pairs = append(pairs, value, "***") + + b64 := base64.StdEncoding.EncodeToString([]byte(value)) + if b64 != value { + pairs = append(pairs, b64, "***") + } + + b64NoPad := strings.TrimRight(b64, "=") + if b64NoPad != b64 && b64NoPad != value { + pairs = append(pairs, b64NoPad, "***") + } + } + + if len(pairs) == 0 { + return nil + } + + return &SecretMask{ + replacer: strings.NewReplacer(pairs...), + } +} + +// Mask replaces all registered secret values with "***". +func (m *SecretMask) Mask(input string) string { + if m == nil || m.replacer == nil { + return input + } + return m.replacer.Replace(input) +} diff --git a/spindle/models/secret_mask_test.go b/spindle/models/secret_mask_test.go new file mode 100644 index 00000000..e0ae5317 --- /dev/null +++ b/spindle/models/secret_mask_test.go @@ -0,0 +1,135 @@ +package models + +import ( + "encoding/base64" + "testing" +) + +func TestSecretMask_BasicMasking(t *testing.T) { + mask := NewSecretMask([]string{"mysecret123"}) + + input := "The password is mysecret123 in this log" + expected := "The password is *** in this log" + + result := mask.Mask(input) + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestSecretMask_Base64Encoded(t *testing.T) { + secret := "mysecret123" + mask := NewSecretMask([]string{secret}) + + b64 := base64.StdEncoding.EncodeToString([]byte(secret)) + input := "Encoded: " + b64 + expected := "Encoded: ***" + + result := mask.Mask(input) + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestSecretMask_Base64NoPadding(t *testing.T) { + // "test" encodes to "dGVzdA==" with padding + secret := "test" + mask := NewSecretMask([]string{secret}) + + b64NoPad := "dGVzdA" // base64 without padding + input := "Token: " + b64NoPad + expected := "Token: ***" + + result := mask.Mask(input) + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestSecretMask_MultipleSecrets(t *testing.T) { + mask := NewSecretMask([]string{"password1", "apikey123"}) + + input := "Using password1 and apikey123 for auth" + expected := "Using *** and *** for auth" + + result := mask.Mask(input) + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestSecretMask_MultipleOccurrences(t *testing.T) { + mask := NewSecretMask([]string{"secret"}) + + input := "secret appears twice: secret" + expected := "*** appears twice: ***" + + result := mask.Mask(input) + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestSecretMask_ShortValues(t *testing.T) { + mask := NewSecretMask([]string{"abc", "xy", ""}) + + if mask == nil { + t.Fatal("expected non-nil mask") + } + + input := "abc xy test" + expected := "*** *** test" + result := mask.Mask(input) + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} + +func TestSecretMask_NilMask(t *testing.T) { + var mask *SecretMask + + input := "some input text" + result := mask.Mask(input) + if result != input { + t.Errorf("expected %q, got %q", input, result) + } +} + +func TestSecretMask_EmptyInput(t *testing.T) { + mask := NewSecretMask([]string{"secret"}) + + result := mask.Mask("") + if result != "" { + t.Errorf("expected empty string, got %q", result) + } +} + +func TestSecretMask_NoMatch(t *testing.T) { + mask := NewSecretMask([]string{"secretvalue"}) + + input := "nothing to mask here" + result := mask.Mask(input) + if result != input { + t.Errorf("expected %q, got %q", input, result) + } +} + +func TestSecretMask_EmptySecretsList(t *testing.T) { + mask := NewSecretMask([]string{}) + + if mask != nil { + t.Error("expected nil mask for empty secrets list") + } +} + +func TestSecretMask_EmptySecretsFiltered(t *testing.T) { + mask := NewSecretMask([]string{"ab", "validpassword", "", "xyz"}) + + input := "Using validpassword here" + expected := "Using *** here" + + result := mask.Mask(input) + if result != expected { + t.Errorf("expected %q, got %q", expected, result) + } +} -- 2.43.0