From 97d2227d94a850cc9bb2a183f7814149a1a0e484 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 20 May 2026 12:23:57 -0400 Subject: [PATCH 1/2] fix(engine): redact sensitive workflow result logs --- engine.go | 3 +- engine_test.go | 45 ++++++++++++++++++++++++++++- module/step_output_redactor.go | 21 ++++++++++---- module/step_output_redactor_test.go | 29 +++++++++++++++++++ 4 files changed, 91 insertions(+), 7 deletions(-) diff --git a/engine.go b/engine.go index 2ff42f4d..e01806e5 100644 --- a/engine.go +++ b/engine.go @@ -844,7 +844,8 @@ func (e *StdEngine) TriggerWorkflow(ctx context.Context, workflowType string, ac // Log execution results in debug mode e.logger.Info(fmt.Sprintf("Workflow '%s' executed successfully", workflowType)) - for k, v := range results { + logResults := module.RedactStepOutput(results) + for k, v := range logResults { e.logger.Debug(fmt.Sprintf(" Result %s: %v", k, v)) } diff --git a/engine_test.go b/engine_test.go index 09663a63..b6146094 100644 --- a/engine_test.go +++ b/engine_test.go @@ -226,6 +226,48 @@ func TestEngineTriggerWorkflow(t *testing.T) { } } +func TestEngineTriggerWorkflow_RedactsSensitiveResultsInDebugLogs(t *testing.T) { + app := newMockApplication() + engine := NewStdEngine(app, app.Logger()) + loadAllPlugins(t, engine) + + handler := &mockWorkflowHandler{ + name: "mock.handler", + handlesFor: []string{"sensitive-workflow"}, + results: map[string]any{ + "token": "jwt.secret.value", + "status": "ok", + "password": "hunter2", + "profile": map[string]any{ + "api_key": "sk_test_secret", + "name": "Alice", + }, + }, + } + engine.RegisterWorkflowHandler(handler) + + holder := &module.PipelineResultHolder{} + ctx := context.WithValue(context.Background(), module.PipelineResultContextKey, holder) + if err := engine.TriggerWorkflow(ctx, "sensitive-workflow", "run", map[string]any{}); err != nil { + t.Fatalf("TriggerWorkflow failed: %v", err) + } + + logText := strings.Join(app.logger.logs, "\n") + for _, leaked := range []string{"jwt.secret.value", "hunter2", "sk_test_secret"} { + if strings.Contains(logText, leaked) { + t.Fatalf("debug logs leaked sensitive result value %q:\n%s", leaked, logText) + } + } + if !strings.Contains(logText, module.RedactionPlaceholder) { + t.Fatalf("debug logs should include redaction placeholder, got:\n%s", logText) + } + + raw := holder.Get() + if raw["token"] != "jwt.secret.value" { + t.Fatalf("pipeline result holder must preserve raw token for response handling, got %#v", raw["token"]) + } +} + // Mock implementations for testing // mockApplication implements modular.Application @@ -550,6 +592,7 @@ func (t *mockTrigger) Configure(app modular.Application, triggerConfig any) erro type mockWorkflowHandler struct { name string handlesFor []string + results map[string]any } func (h *mockWorkflowHandler) Name() string { @@ -565,7 +608,7 @@ func (h *mockWorkflowHandler) ConfigureWorkflow(app modular.Application, workflo } func (h *mockWorkflowHandler) ExecuteWorkflow(ctx context.Context, workflowType string, action string, data map[string]any) (map[string]any, error) { - return nil, nil + return h.results, nil } func TestEngine_AddModuleType(t *testing.T) { diff --git a/module/step_output_redactor.go b/module/step_output_redactor.go index 7d8562f3..9639a876 100644 --- a/module/step_output_redactor.go +++ b/module/step_output_redactor.go @@ -61,15 +61,26 @@ func redactMap(m map[string]any, patterns []string) map[string]any { out[k] = RedactionPlaceholder continue } - if nested, ok := v.(map[string]any); ok { - out[k] = redactMap(nested, patterns) - } else { - out[k] = v - } + out[k] = redactValue(v, patterns) } return out } +func redactValue(v any, patterns []string) any { + switch val := v.(type) { + case map[string]any: + return redactMap(val, patterns) + case []any: + out := make([]any, len(val)) + for i, item := range val { + out[i] = redactValue(item, patterns) + } + return out + default: + return v + } +} + // isSensitiveField returns true when the lowercased field name contains any of // the patterns and is not exempted by a safe/reference suffix. func isSensitiveField(name string, patterns []string) bool { diff --git a/module/step_output_redactor_test.go b/module/step_output_redactor_test.go index 6c82bc23..84153115 100644 --- a/module/step_output_redactor_test.go +++ b/module/step_output_redactor_test.go @@ -97,6 +97,35 @@ func TestRedactStepOutput_NestedMaps(t *testing.T) { } } +func TestRedactStepOutput_NestedSlices(t *testing.T) { + output := map[string]any{ + "rows": []any{ + map[string]any{ + "name": "alice", + "access_token": "row-token", + }, + "plain", + }, + } + + got := RedactStepOutput(output) + + rows, ok := got["rows"].([]any) + if !ok { + t.Fatalf("rows should remain a []any, got %T", got["rows"]) + } + first, ok := rows[0].(map[string]any) + if !ok { + t.Fatalf("first row should remain a map, got %T", rows[0]) + } + if first["access_token"] != RedactionPlaceholder { + t.Fatalf("nested slice token should be redacted, got %#v", first["access_token"]) + } + if first["name"] != "alice" || rows[1] != "plain" { + t.Fatalf("non-sensitive slice values should be preserved, got %#v", rows) + } +} + func TestRedactStepOutput_OriginalNotModified(t *testing.T) { original := map[string]any{ "password": "hunter2", From 95f7acbcfec7c18dc8c9e496bf12646f7d8842b0 Mon Sep 17 00:00:00 2001 From: Jon Langevin Date: Wed, 20 May 2026 12:36:04 -0400 Subject: [PATCH 2/2] fix(engine): redact workflow input logs --- engine.go | 3 ++- engine_test.go | 39 +++++++++++++++++++++++++++++ module/step_output_redactor.go | 6 +++++ module/step_output_redactor_test.go | 31 +++++++++++++++++++++++ 4 files changed, 78 insertions(+), 1 deletion(-) diff --git a/engine.go b/engine.go index e01806e5..20babd81 100644 --- a/engine.go +++ b/engine.go @@ -823,7 +823,8 @@ func (e *StdEngine) TriggerWorkflow(ctx context.Context, workflowType string, ac e.logger.Info(fmt.Sprintf("Triggered workflow '%s' with action '%s'", workflowType, action)) // Log the data in debug mode - for k, v := range data { + logData := module.RedactStepOutput(data) + for k, v := range logData { e.logger.Debug(fmt.Sprintf(" %s: %v", k, v)) } diff --git a/engine_test.go b/engine_test.go index b6146094..0bc2a8be 100644 --- a/engine_test.go +++ b/engine_test.go @@ -268,6 +268,43 @@ func TestEngineTriggerWorkflow_RedactsSensitiveResultsInDebugLogs(t *testing.T) } } +func TestEngineTriggerWorkflow_RedactsSensitiveInputInDebugLogs(t *testing.T) { + app := newMockApplication() + engine := NewStdEngine(app, app.Logger()) + loadAllPlugins(t, engine) + + handler := &mockWorkflowHandler{ + name: "mock.handler", + handlesFor: []string{"sensitive-input-workflow"}, + } + engine.RegisterWorkflowHandler(handler) + + data := map[string]any{ + "username": "alice", + "password": "hunter2", + "body": map[string]any{ + "access_token": "jwt.secret.value", + "display_name": "Alice", + }, + } + if err := engine.TriggerWorkflow(context.Background(), "sensitive-input-workflow", "run", data); err != nil { + t.Fatalf("TriggerWorkflow failed: %v", err) + } + + logText := strings.Join(app.logger.logs, "\n") + for _, leaked := range []string{"hunter2", "jwt.secret.value"} { + if strings.Contains(logText, leaked) { + t.Fatalf("debug logs leaked sensitive input value %q:\n%s", leaked, logText) + } + } + if !strings.Contains(logText, module.RedactionPlaceholder) { + t.Fatalf("debug logs should include redaction placeholder, got:\n%s", logText) + } + if handler.lastData["password"] != "hunter2" { + t.Fatalf("workflow handler must receive raw input data, got %#v", handler.lastData["password"]) + } +} + // Mock implementations for testing // mockApplication implements modular.Application @@ -593,6 +630,7 @@ type mockWorkflowHandler struct { name string handlesFor []string results map[string]any + lastData map[string]any } func (h *mockWorkflowHandler) Name() string { @@ -608,6 +646,7 @@ func (h *mockWorkflowHandler) ConfigureWorkflow(app modular.Application, workflo } func (h *mockWorkflowHandler) ExecuteWorkflow(ctx context.Context, workflowType string, action string, data map[string]any) (map[string]any, error) { + h.lastData = data return h.results, nil } diff --git a/module/step_output_redactor.go b/module/step_output_redactor.go index 9639a876..18645349 100644 --- a/module/step_output_redactor.go +++ b/module/step_output_redactor.go @@ -70,6 +70,12 @@ func redactValue(v any, patterns []string) any { switch val := v.(type) { case map[string]any: return redactMap(val, patterns) + case []map[string]any: + out := make([]map[string]any, len(val)) + for i, item := range val { + out[i] = redactMap(item, patterns) + } + return out case []any: out := make([]any, len(val)) for i, item := range val { diff --git a/module/step_output_redactor_test.go b/module/step_output_redactor_test.go index 84153115..77ad3dc7 100644 --- a/module/step_output_redactor_test.go +++ b/module/step_output_redactor_test.go @@ -126,6 +126,37 @@ func TestRedactStepOutput_NestedSlices(t *testing.T) { } } +func TestRedactStepOutput_TypedRowSlices(t *testing.T) { + output := map[string]any{ + "rows": []map[string]any{ + { + "name": "alice", + "access_token": "row-token", + }, + { + "name": "bob", + "password": "hunter2", + }, + }, + } + + got := RedactStepOutput(output) + + rows, ok := got["rows"].([]map[string]any) + if !ok { + t.Fatalf("rows should remain a []map[string]any, got %T", got["rows"]) + } + if rows[0]["access_token"] != RedactionPlaceholder { + t.Fatalf("typed row token should be redacted, got %#v", rows[0]["access_token"]) + } + if rows[1]["password"] != RedactionPlaceholder { + t.Fatalf("typed row password should be redacted, got %#v", rows[1]["password"]) + } + if rows[0]["name"] != "alice" || rows[1]["name"] != "bob" { + t.Fatalf("non-sensitive typed row values should be preserved, got %#v", rows) + } +} + func TestRedactStepOutput_OriginalNotModified(t *testing.T) { original := map[string]any{ "password": "hunter2",