diff --git a/sast-engine/cmd/ci.go b/sast-engine/cmd/ci.go index 772449e2..b23c99d6 100644 --- a/sast-engine/cmd/ci.go +++ b/sast-engine/cmd/ci.go @@ -71,6 +71,7 @@ Examples: failOnStr, _ := cmd.Flags().GetString("fail-on") skipTests, _ := cmd.Flags().GetBool("skip-tests") rawExcludes, _ := cmd.Flags().GetStringArray("exclude") + rawDisableRules, _ := cmd.Flags().GetStringArray("disable-rule") baseRef, _ := cmd.Flags().GetString("base") headRef, _ := cmd.Flags().GetString("head") noDiff, _ := cmd.Flags().GetBool("no-diff") @@ -138,6 +139,11 @@ Examples: return err } + disabledRules, err := validateDisableRules(rawDisableRules) + if err != nil { + return err + } + if outputFormat != "sarif" && outputFormat != "json" && outputFormat != "csv" { analytics.ReportEventWithProperties(analytics.CIFailed, map[string]any{ "error_type": "validation", @@ -327,6 +333,29 @@ Examples: } logger.Statistic("Loaded %d rules", len(rules)) + // Apply --disable-rule. Mirrors --exclude: cheap up-front filter on the + // rule slice before any matcher work runs. Rule IDs are matched + // case-sensitively because the loader emits them verbatim. + if len(disabledRules) > 0 { + disabledSet := make(map[string]struct{}, len(disabledRules)) + for _, id := range disabledRules { + disabledSet[id] = struct{}{} + } + kept := rules[:0] + skipped := 0 + for _, r := range rules { + if _, drop := disabledSet[r.Rule.ID]; drop { + skipped++ + continue + } + kept = append(kept, r) + } + rules = kept + if skipped > 0 { + logger.Statistic("Disabled %d rules via --disable-rule", skipped) + } + } + // Execute rules against callgraph logger.Progress("Running security scan...") @@ -521,6 +550,7 @@ func init() { ciCmd.Flags().String("fail-on", "", "Fail with exit code 1 if findings match severities (e.g., critical,high)") ciCmd.Flags().Bool("skip-tests", true, "Skip test files (test_*.py, *_test.py, conftest.py, etc.)") ciCmd.Flags().StringArray("exclude", nil, "Exclude files or directories from the scan. Repo-relative path prefix; repeatable. e.g. --exclude rules/ --exclude sast-engine/test-fixtures") + ciCmd.Flags().StringArray("disable-rule", nil, "Disable a rule by ID; repeatable. e.g. --disable-rule SAST-CMD-001 --disable-rule GO-SSRF-001. IDs must match [A-Za-z0-9_-]{1,64}.") ciCmd.Flags().String("base", "", "Base git ref for diff-aware scanning (auto-detected in CI)") ciCmd.Flags().String("head", "HEAD", "Head git ref for diff-aware scanning") ciCmd.Flags().Bool("no-diff", false, "Disable diff-aware scanning (scan all files)") diff --git a/sast-engine/cmd/disable_rule.go b/sast-engine/cmd/disable_rule.go new file mode 100644 index 00000000..7c4cd67b --- /dev/null +++ b/sast-engine/cmd/disable_rule.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "fmt" + "regexp" + "strings" +) + +// Rule IDs are uppercase alphanumeric + dash, length 1..64. Mirrors the +// shape rule authors actually use in code-pathfinder/rules (e.g. +// SAST-CMD-001, GO-SSRF-001, DOCKER-BP-005). Anything outside that +// alphabet is rejected at the boundary so weird input from a downstream +// caller (cpf-executor passing argv from a D1 lookup) can't smuggle in +// shell metacharacters or path-traversal patterns. +var ruleIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) + +// validateDisableRules normalizes and validates a list of rule IDs to +// disable. Empty input returns nil. Whitespace is trimmed; exact +// duplicates after trim are deduped. Each remaining ID must match +// ruleIDPattern, otherwise the whole list is rejected so a single bad +// ID doesn't silently strip itself away. +func validateDisableRules(input []string) ([]string, error) { + if len(input) == 0 { + return nil, nil + } + seen := make(map[string]struct{}, len(input)) + var out []string + for _, raw := range input { + id := strings.TrimSpace(raw) + if id == "" { + continue + } + if len(id) > 64 { + return nil, fmt.Errorf("rule id too long: %q (max 64)", id) + } + if !ruleIDPattern.MatchString(id) { + return nil, fmt.Errorf("invalid rule id %q: must match [A-Za-z0-9_-]{1,64}", id) + } + if _, dup := seen[id]; dup { + continue + } + seen[id] = struct{}{} + out = append(out, id) + } + return out, nil +} diff --git a/sast-engine/cmd/disable_rule_test.go b/sast-engine/cmd/disable_rule_test.go new file mode 100644 index 00000000..f16ad813 --- /dev/null +++ b/sast-engine/cmd/disable_rule_test.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "strings" + "testing" +) + +func TestValidateDisableRules(t *testing.T) { + tests := []struct { + name string + input []string + want []string + wantErr string + }{ + {name: "empty", input: nil, want: nil}, + {name: "single valid", input: []string{"SAST-CMD-001"}, want: []string{"SAST-CMD-001"}}, + {name: "multiple valid", input: []string{"SAST-CMD-001", "GO-SSRF-001"}, want: []string{"SAST-CMD-001", "GO-SSRF-001"}}, + {name: "dedup", input: []string{"SAST-CMD-001", "SAST-CMD-001"}, want: []string{"SAST-CMD-001"}}, + {name: "trim whitespace", input: []string{" SAST-CMD-001 "}, want: []string{"SAST-CMD-001"}}, + {name: "drop empty entries", input: []string{"SAST-CMD-001", "", "GO-SSRF-001"}, want: []string{"SAST-CMD-001", "GO-SSRF-001"}}, + {name: "reject too long", input: []string{strings.Repeat("X", 65)}, wantErr: "rule id too long"}, + {name: "reject invalid chars", input: []string{"BAD ID"}, wantErr: "invalid rule id"}, + {name: "reject path-like", input: []string{"foo/bar"}, wantErr: "invalid rule id"}, + {name: "reject newline", input: []string{"BAD\nID"}, wantErr: "invalid rule id"}, + {name: "reject null byte", input: []string{"BAD\x00ID"}, wantErr: "invalid rule id"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := validateDisableRules(tc.input) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("want err containing %q, got %v", tc.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("unexpected err: %v", err) + } + if len(got) != len(tc.want) { + t.Fatalf("want %v, got %v", tc.want, got) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("index %d: want %q, got %q", i, tc.want[i], got[i]) + } + } + }) + } +} diff --git a/sast-engine/cmd/scan.go b/sast-engine/cmd/scan.go index 03cb84bf..ecf6c705 100644 --- a/sast-engine/cmd/scan.go +++ b/sast-engine/cmd/scan.go @@ -69,6 +69,7 @@ Examples: outputFile, _ := cmd.Flags().GetString("output-file") skipTests, _ := cmd.Flags().GetBool("skip-tests") rawExcludes, _ := cmd.Flags().GetStringArray("exclude") + rawDisableRules, _ := cmd.Flags().GetStringArray("disable-rule") diffAware, _ := cmd.Flags().GetBool("diff-aware") baseRef, _ := cmd.Flags().GetString("base") headRef, _ := cmd.Flags().GetString("head") @@ -104,6 +105,11 @@ Examples: return err } + disabledRules, err := validateDisableRules(rawDisableRules) + if err != nil { + return err + } + // Setup logger with appropriate verbosity verbosity := output.VerbosityDefault if debug { @@ -313,6 +319,29 @@ Examples: } logger.Statistic("Loaded %d rules", len(rules)) + // Apply --disable-rule. Mirrors --exclude: cheap up-front filter on the + // rule slice before any matcher work runs. Rule IDs are matched + // case-sensitively because the loader emits them verbatim. + if len(disabledRules) > 0 { + disabledSet := make(map[string]struct{}, len(disabledRules)) + for _, id := range disabledRules { + disabledSet[id] = struct{}{} + } + kept := rules[:0] + skipped := 0 + for _, r := range rules { + if _, drop := disabledSet[r.Rule.ID]; drop { + skipped++ + continue + } + kept = append(kept, r) + } + rules = kept + if skipped > 0 { + logger.Statistic("Disabled %d rules via --disable-rule", skipped) + } + } + // Validate that at least one type of rule was loaded if len(rules) == 0 && len(containerDetections) == 0 { analytics.ReportEventWithProperties(analytics.ScanFailed, map[string]any{ @@ -1146,6 +1175,7 @@ func init() { scanCmd.Flags().String("fail-on", "", "Fail with exit code 1 if findings match severities (e.g., critical,high)") scanCmd.Flags().Bool("skip-tests", true, "Skip test files (test_*.py, *_test.py, conftest.py, etc.)") scanCmd.Flags().StringArray("exclude", nil, "Exclude files or directories from the scan. Repo-relative path prefix; repeatable. e.g. --exclude rules/ --exclude sast-engine/test-fixtures") + scanCmd.Flags().StringArray("disable-rule", nil, "Disable a rule by ID; repeatable. e.g. --disable-rule SAST-CMD-001 --disable-rule GO-SSRF-001. IDs must match [A-Za-z0-9_-]{1,64}.") scanCmd.Flags().Bool("diff-aware", false, "Enable diff-aware scanning (only report findings in changed files)") scanCmd.Flags().String("base", "", "Base git ref for diff-aware scanning (required with --diff-aware)") scanCmd.Flags().String("head", "HEAD", "Head git ref for diff-aware scanning")