diff --git a/engine.go b/engine.go index 2ff42f4d..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)) } @@ -844,7 +845,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..0bc2a8be 100644 --- a/engine_test.go +++ b/engine_test.go @@ -226,6 +226,85 @@ 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"]) + } +} + +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 @@ -550,6 +629,8 @@ func (t *mockTrigger) Configure(app modular.Application, triggerConfig any) erro type mockWorkflowHandler struct { name string handlesFor []string + results map[string]any + lastData map[string]any } func (h *mockWorkflowHandler) Name() string { @@ -565,7 +646,8 @@ 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 + h.lastData = data + 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..18645349 100644 --- a/module/step_output_redactor.go +++ b/module/step_output_redactor.go @@ -61,15 +61,32 @@ 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 []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 { + 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..77ad3dc7 100644 --- a/module/step_output_redactor_test.go +++ b/module/step_output_redactor_test.go @@ -97,6 +97,66 @@ 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_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",