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
30 changes: 30 additions & 0 deletions sast-engine/cmd/ci.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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...")

Expand Down Expand Up @@ -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)")
Expand Down
46 changes: 46 additions & 0 deletions sast-engine/cmd/disable_rule.go
Original file line number Diff line number Diff line change
@@ -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
}
49 changes: 49 additions & 0 deletions sast-engine/cmd/disable_rule_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
}
})
}
}
30 changes: 30 additions & 0 deletions sast-engine/cmd/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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")
Expand Down
Loading