Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand All @@ -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))
}
Comment thread
intel352 marked this conversation as resolved.

Expand Down
84 changes: 83 additions & 1 deletion engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand Down
27 changes: 22 additions & 5 deletions module/step_output_redactor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment thread
intel352 marked this conversation as resolved.
}

// 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 {
Expand Down
60 changes: 60 additions & 0 deletions module/step_output_redactor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Comment thread
intel352 marked this conversation as resolved.
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",
Expand Down
Loading