diff --git a/internal/campaign/campaign.go b/internal/campaign/campaign.go index 783264f9..8cf0dc82 100644 --- a/internal/campaign/campaign.go +++ b/internal/campaign/campaign.go @@ -123,6 +123,15 @@ func ParseYAML(data []byte) (*Campaign, error) { if s.DependsOn == s.ID { return nil, fmt.Errorf("campaign %q step %q: self-dependency", c.Name, s.ID) } + // A when clause is evaluated against the predecessor's output, so it is + // only meaningful with a depends_on. Without one, the Runner never + // reaches the when check and the step runs UNCONDITIONALLY — an operator + // gating a destructive tool with `when:` would get a silently-inert + // guard (fail-open). Reject at load so the misconfiguration surfaces + // loudly instead of running the tool. + if s.When != "" && s.DependsOn == "" { + return nil, fmt.Errorf("campaign %q step %q: when clause requires depends_on (a when is evaluated against the predecessor's output; without one the step would run unconditionally)", c.Name, s.ID) + } } // Third pass: declaration-order check. The Runner iterates steps in diff --git a/internal/campaign/campaign_test.go b/internal/campaign/campaign_test.go index bd72858a..e27ea86c 100644 --- a/internal/campaign/campaign_test.go +++ b/internal/campaign/campaign_test.go @@ -513,3 +513,23 @@ func (r *recordingExecutor) Run(ctx context.Context, tool string, _ map[string]i } return "ok", nil } + +// TestParseYAML_RejectsWhenWithoutDependsOn pins the fail-closed fix for the +// inert-gate bug: a `when` clause is evaluated against the predecessor's output, +// so without a depends_on the Runner never reaches the when check and the step +// runs UNCONDITIONALLY. An operator gating a destructive tool with `when:` would +// otherwise get a silently-inert guard. Such a campaign must be rejected at load. +func TestParseYAML_RejectsWhenWithoutDependsOn(t *testing.T) { + yamlDoc := `campaign: ungated-when +steps: + - id: scan + tool: wifi_scan_ap + - id: gated + tool: wifi_sniff_pmkid + when: contains "AUTHORIZED" +` + _, err := ParseYAML([]byte(yamlDoc)) + if err == nil || !strings.Contains(err.Error(), "when clause requires depends_on") { + t.Errorf("a when without depends_on must be rejected (else the gate runs unconditionally): %v", err) + } +}