diff --git a/cli/cmd/command_builder.go b/cli/cmd/command_builder.go index 263c45e58c..5e96569382 100644 --- a/cli/cmd/command_builder.go +++ b/cli/cmd/command_builder.go @@ -63,6 +63,7 @@ type AnalyzerBuilder struct { jarPath string maxMemory string ruleIDs []string + ruleIDExcludes []string passthroughApproximations []string dataflowApproximations []string trackExternalMethods bool @@ -156,6 +157,11 @@ func (a *AnalyzerBuilder) AddRuleID(ruleID string) *AnalyzerBuilder { return a } +func (a *AnalyzerBuilder) AddRuleIDExclude(ruleID string) *AnalyzerBuilder { + a.ruleIDExcludes = append(a.ruleIDExcludes, ruleID) + return a +} + func (a *AnalyzerBuilder) AddPassthroughApproximations(path string) *AnalyzerBuilder { a.passthroughApproximations = append(a.passthroughApproximations, path) return a @@ -263,6 +269,10 @@ func (a *AnalyzerBuilder) BuildNativeCommand() []string { flags = append(flags, "--semgrep-rule-id", ruleID) } + for _, ruleID := range a.ruleIDExcludes { + flags = append(flags, "--semgrep-rule-id-exclude", ruleID) + } + for _, passthrough := range a.passthroughApproximations { flags = append(flags, "--passthrough-approximations", passthrough) } diff --git a/cli/cmd/command_builder_test.go b/cli/cmd/command_builder_test.go index 903585a70f..a4f0e70a2c 100644 --- a/cli/cmd/command_builder_test.go +++ b/cli/cmd/command_builder_test.go @@ -2,6 +2,7 @@ package cmd import ( "reflect" + "strings" "testing" ) @@ -99,3 +100,32 @@ func TestAutobuilderBuildNativeCommandRoutesDependenciesToDependencyFlag(t *test t.Fatalf("package com.example not passed as --pkg; command was %v", cmd) } } + +func TestAnalyzerBuilderEmitsRuleIDIncludeAndExclude(t *testing.T) { + cmd := NewAnalyzerBuilder(). + SetProject("p.yaml"). + AddRuleID("a.yaml:keep"). + AddRuleIDExclude("a.yaml:drop"). + BuildNativeCommand() + + joined := strings.Join(cmd, " ") + if !strings.Contains(joined, "--semgrep-rule-id a.yaml:keep") { + t.Errorf("missing inclusion flag: %s", joined) + } + if !strings.Contains(joined, "--semgrep-rule-id-exclude a.yaml:drop") { + t.Errorf("missing exclusion flag: %s", joined) + } +} + +func TestAnalyzerBuilderExclusionOnlyEmitsNoInclusionFlags(t *testing.T) { + cmd := NewAnalyzerBuilder(). + SetProject("p.yaml"). + AddRuleIDExclude("a.yaml:drop"). + BuildNativeCommand() + + for i, arg := range cmd { + if arg == "--semgrep-rule-id" { + t.Errorf("unexpected inclusion flag at %d: %v", i, cmd) + } + } +} diff --git a/cli/cmd/exit_codes.go b/cli/cmd/exit_codes.go index 7f950d70c5..76ba3b27f0 100644 --- a/cli/cmd/exit_codes.go +++ b/cli/cmd/exit_codes.go @@ -20,6 +20,13 @@ func scanExitCodesHelp(completedLine string) string { 1 General failure (configuration or infrastructure error)` + analyzerExitCodeRows() } +func gateExitCodesHelp(completedLine string) string { + return `Exit codes: + 0 ` + completedLine + ` + 1 General failure (configuration or infrastructure error) + 2 Findings remain and --error-on-findings was set` + analyzerExitCodeRows() +} + func testExitCodesHelp(passedLine string) string { return `Exit codes: 0 ` + passedLine + ` diff --git a/cli/cmd/scan.go b/cli/cmd/scan.go index fdb5e2cc05..bb56fcace1 100644 --- a/cli/cmd/scan.go +++ b/cli/cmd/scan.go @@ -14,6 +14,7 @@ import ( "github.com/seqra/opentaint/internal/load_trace" "github.com/seqra/opentaint/internal/rules" "github.com/seqra/opentaint/internal/sarif" + "github.com/seqra/opentaint/internal/triage" "github.com/seqra/opentaint/internal/validation" "github.com/seqra/opentaint/internal/version" @@ -38,6 +39,9 @@ type ScanConfig struct { Recompile bool LogFile string RuleID []string + ExcludeRuleID []string + ErrorOnFindings bool + ErrorOnSeverity []string PassthroughApproximations []string DataflowApproximations []string TrackExternalMethods bool @@ -85,9 +89,11 @@ The source-path argument is the project root. It is optional. The default is the OpenTaint writes the findings to a SARIF report. Use --output to set the report path. If --output is not set, the report goes into the project model directory. A summary is shown when the scan completes. +Use --error-on-findings to exit with code 2 when findings remain. Use --error-on-severity to limit which levels count. + Before your first scan, run "opentaint pull" one time. For a Go project, the "go" command must also be on your PATH. To read a report again later, use "opentaint summary". -` + scanExitCodesHelp("Scan completed"), +` + gateExitCodesHelp("Scan completed"), Example: ` # Scan the current directory with the built-in rules opentaint scan . @@ -100,6 +106,9 @@ Before your first scan, run "opentaint pull" one time. For a Go project, the "go # Use your own rules and show only errors opentaint scan . --ruleset ./rules --severity error -o report.sarif + # Fail CI when the scan reports an error + opentaint scan . --error-on-findings --error-on-severity error -o report.sarif + # Give a large project more time and memory opentaint scan . --timeout 30m --max-memory 16G -o report.sarif @@ -153,9 +162,11 @@ func addEntryPointsFlag(cmd *cobra.Command) { func addRuleIDFlag(cmd *cobra.Command) { cmd.Flags().StringArrayVar(&scanFlags.RuleID, "rule-id", nil, "Run only rules with this ID (repeatable)") + cmd.Flags().StringArrayVar(&scanFlags.ExcludeRuleID, "exclude-rule-id", nil, "Never run rules matching this ID: full id, bare name, or glob over the full id (repeatable, overrides rules.exclude from the config)") } func addScanFlags(cmd *cobra.Command) { + addGateFlags(cmd, &scanFlags.ErrorOnFindings, &scanFlags.ErrorOnSeverity) cmd.Flags().DurationVarP(&globals.Config.Scan.Timeout, "timeout", "t", 900*time.Second, "Maximum wall-clock time for analysis (e.g. 30m, 1h)") cmd.Flags().StringArrayVar(&scanFlags.Ruleset, "ruleset", []string{"builtin"}, "Rules to run: a YAML file, a directory of .yml or .yaml files, or builtin for the built-in rules (repeatable)") @@ -188,6 +199,9 @@ func currentScanBuilder(cfg ScanConfig, sourcePath string) *utils.OpentaintComma WithRuleset(cfg.Ruleset). WithSemgrepCompatibility(cfg.SemgrepCompatibilitySarif). WithRuleID(cfg.RuleID). + WithExcludeRuleID(cfg.ExcludeRuleID). + WithErrorOnFindings(cfg.ErrorOnFindings). + WithErrorOnSeverity(cfg.ErrorOnSeverity). WithPassthroughApproximations(cfg.PassthroughApproximations). WithDataflowApproximations(cfg.DataflowApproximations). WithTrackExternalMethods(cfg.TrackExternalMethods). @@ -198,6 +212,76 @@ func currentScanBuilder(cfg ScanConfig, sourcePath string) *utils.OpentaintComma return b } +// resolveRuleIDs determines which rules the analyzer should run, as exact +// inclusion and exclusion ids (patterns never reach the analyzer). +// +// --rule-id wins over the config lists, as flags do everywhere else. Honoring +// a flag and rules.only together would silently intersect two selections the +// user never asked to combine. --exclude-rule-id overrides rules.exclude the +// same way, and composes with --rule-id since both were asked for explicitly. +// Returns the zero value when nothing restricts the rules, which runs the +// whole ruleset. +func resolveRuleIDs(cfg ScanConfig, absRuleSetPaths []RulesetType) rules.Resolved { + var rulesetRoots []string + for _, r := range absRuleSetPaths { + rulesetRoots = append(rulesetRoots, r.Path) + } + + if len(cfg.RuleID) > 0 { + // The explicit list is small, so exclusions are subtracted right here + // and the analyzer sees only the survivors. + ids, err := rules.ApplyExclusions(cfg.RuleID, cfg.ExcludeRuleID) + if err != nil { + out.Fatalf("%s", err) + } + warnUnmatchedRulePatterns(rules.Selection{Exclude: cfg.ExcludeRuleID}, cfg.RuleID) + if cfg.ExpandRuleRefs { + ids = rules.ExpandRuleIDs(ids, rulesetRoots) + } + return rules.Resolved{Include: ids} + } + + selection := configuredRuleSelection(cfg) + selected, err := rules.Select(selection, rulesetRoots) + if err != nil { + out.Fatalf("%s", err) + } + if selection.Active() { + warnUnmatchedRulePatterns(selection, rules.ListRuleIDs(rulesetRoots)) + } + return selected +} + +// warnUnmatchedRulePatterns surfaces selection patterns that matched no rule. +// A pattern matching nothing is usually a typo, and staying silent would make +// an exclusion look effective when it never was. +func warnUnmatchedRulePatterns(selection rules.Selection, all []string) { + for _, pattern := range selection.Unmatched(all) { + out.Warnf("Rule pattern %q matches no rule in the active ruleset", pattern) + } +} + +// configuredRuleSelection merges the rules.only / rules.exclude lists from the +// configuration file with the --exclude-rule-id flag, which overrides the +// configured exclude list when set. +// ruleSelectionActive reports whether any rule allow/deny input is in play — +// the flags or the config lists. Only then does rule resolution read the +// ruleset from disk. +func ruleSelectionActive(cfg ScanConfig) bool { + return len(cfg.RuleID) > 0 || len(cfg.ExcludeRuleID) > 0 || configuredRuleSelection(cfg).Active() +} + +func configuredRuleSelection(cfg ScanConfig) rules.Selection { + selection := rules.Selection{ + Only: globals.Config.Rules.Only, + Exclude: globals.Config.Rules.Exclude, + } + if len(cfg.ExcludeRuleID) > 0 { + selection.Exclude = cfg.ExcludeRuleID + } + return selection +} + func isDefaultSeverity(sev []string) bool { return len(sev) == 2 && sev[0] == "warning" && sev[1] == "error" } @@ -211,6 +295,10 @@ func dockerScanSuggestion(cfg ScanConfig, projectRoot, sarifReportPath string) o } func runScan(cmd *cobra.Command, cfg ScanConfig) { + gateSeverities, err := triage.ParseGateSeverities(cfg.ErrorOnSeverity) + if err != nil { + out.Fatalf("%s", err) + } userProjectPath := filepath.Clean(cfg.UserProjectPath) absUserProjectRoot := log.AbsPathOrExit(userProjectPath, "project path") @@ -315,11 +403,6 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { out.Fatalf("Input validation failed: %s", err) } - if cfg.DryRun { - runDryRun("the build and scan") - return - } - hasBuiltin := false for _, ruleSetPath := range absRuleSetPaths { if ruleSetPath.Builtin { @@ -328,6 +411,17 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { } } + if hasBuiltin && ruleSelectionActive(cfg) { + if _, err := utils.EnsureRulesPath(out); err != nil { + failf("Failed to prepare built-in rules: %s", err) + } + } + resolvedRules := resolveRuleIDs(cfg, absRuleSetPaths) + if cfg.DryRun { + runDryRun("the build and scan") + return + } + // Go projects: the analyzer starts a go-ssa-server process, and that server // runs the `go` toolchain. The decision about the Go wiring occurs here, // after the --dry-run return and before the compile and the analyzer @@ -428,17 +522,12 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { if maxMemory != "" { nativeBuilder.SetMaxMemory(maxMemory) } - ruleIDs := cfg.RuleID - if cfg.ExpandRuleRefs && len(ruleIDs) > 0 { - var roots []string - for _, r := range absRuleSetPaths { - roots = append(roots, r.Path) - } - ruleIDs = rules.ExpandRuleIDs(ruleIDs, roots) - } - for _, ruleID := range ruleIDs { + for _, ruleID := range resolvedRules.Include { nativeBuilder.AddRuleID(ruleID) } + for _, ruleID := range resolvedRules.Exclude { + nativeBuilder.AddRuleIDExclude(ruleID) + } addPassthroughApproximations(nativeBuilder, cfg.PassthroughApproximations) if cfg.TrackExternalMethods { nativeBuilder.SetTrackExternalMethods(true) @@ -572,6 +661,9 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { if analyzerFail != nil { os.Exit(analyzerFail.ExitCode) } + if report != nil { + exitOnGate(triage.Gate{Enabled: cfg.ErrorOnFindings, Severities: gateSeverities}, report) + } } func noteSeverityScanCommand(cfg ScanConfig) string { @@ -842,3 +934,24 @@ func scanProject(analyzerBuilder *AnalyzerBuilder, javaRunner java.JavaRunner) ( return javaRunner.ExecuteJavaCommand(analyzerCommand, commandSucceeded) } + +const ExitFindings = 2 + +func addGateFlags(cmd *cobra.Command, errorOnFindings *bool, severities *[]string) { + cmd.Flags().BoolVar(errorOnFindings, "error-on-findings", false, "Exit with code 2 when findings remain") + cmd.Flags().StringArrayVar(severities, "error-on-severity", nil, "Restrict --error-on-findings to these levels: note, warning, error, none (repeatable or comma-separated, defaults to all)") +} + +func exitOnGate(gate triage.Gate, report *sarif.Report) { + count, tripped := gate.Evaluate(report) + if !tripped { + return + } + out.Blank() + scope := "finding" + if count != 1 { + scope = "findings" + } + out.Error(fmt.Sprintf("%d %s reported (--error-on-findings)", count, scope)) + os.Exit(ExitFindings) +} diff --git a/cli/internal/globals/global.go b/cli/internal/globals/global.go index fb23d6b5aa..1ab73e59be 100644 --- a/cli/internal/globals/global.go +++ b/cli/internal/globals/global.go @@ -86,6 +86,12 @@ type Autobuilder struct { type Rules struct { Version string `mapstructure:"version"` + // Only and Exclude control which rules the analyzer runs. They are rule + // selection, not suppression: an excluded rule never loads, so it produces + // nothing in the report. Entries match a full "path.yaml:id", a bare rule + // name, or a glob over either. + Only []string `mapstructure:"only"` + Exclude []string `mapstructure:"exclude"` } type GoServer struct { diff --git a/cli/internal/rules/select.go b/cli/internal/rules/select.go new file mode 100644 index 0000000000..bcd1459d41 --- /dev/null +++ b/cli/internal/rules/select.go @@ -0,0 +1,180 @@ +package rules + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/seqra/opentaint/internal/sarif" + "gopkg.in/yaml.v2" +) + +// Selection is the allow/deny list of rule ids from the configuration file. +// These control which rules the analyzer runs at all — they are not +// suppressions, and an excluded rule produces nothing to suppress. +type Selection struct { + Only []string // if non-empty, only rules matching these run + Exclude []string // rules matching these never run +} + +// Active reports whether the selection restricts anything. +func (s Selection) Active() bool { + return len(s.Only) > 0 || len(s.Exclude) > 0 +} + +// ListRuleIDs returns every rule id defined under the given ruleset roots, in +// the ".yaml:" form the analyzer matches on. +// Files that cannot be read or parsed are skipped: a malformed rule file is the +// rule loader's problem to report, not a reason to fail rule selection. +func ListRuleIDs(roots []string) []string { + var ids []string + for _, root := range roots { + _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || !isRuleFile(path) { + return nil + } + relPath, relErr := filepath.Rel(root, path) + if relErr != nil { + return nil + } + data, readErr := os.ReadFile(path) + if readErr != nil { + return nil + } + var rf ruleFile + if yaml.Unmarshal(data, &rf) != nil { + return nil + } + prefix := filepath.ToSlash(relPath) + if prefix == "." { + // A root that is a single rule file has no relative path. The + // analyzer names such a ruleset by Path.relativeTo(root), which + // is empty for the root itself, so the id it matches is ":". + prefix = "" + } + for _, r := range rf.Rules { + if r.ID == "" { + continue + } + ids = append(ids, prefix+":"+r.ID) + } + return nil + }) + } + return ids +} + +func isRuleFile(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + return ext == ".yaml" || ext == ".yml" +} + +// Resolved is a rule selection lowered to the exact ids the analyzer accepts: +// Include feeds --semgrep-rule-id (empty = run everything), Exclude feeds +// --semgrep-rule-id-exclude. Patterns never reach the analyzer. +type Resolved struct { + Include []string + Exclude []string +} + +// Select resolves a Selection against the ruleset roots. +// +// An exclusion-only selection resolves to just the excluded ids — excluding +// one rule passes one exclusion arg, not the 150-rule complement. When an +// allow-list is present the inclusion list is unavoidable (the analyzer +// matches exact ids), so exclusions are subtracted from it CLI-side and rules +// referenced by the survivors are pulled back in: a rule whose joined library +// rule was excluded could never match anything, which is a silently broken +// scan rather than a narrower one. On the exclusion side the analyzer itself +// resolves join refs past exclusions, so no such repair is needed. +func Select(selection Selection, roots []string) (Resolved, error) { + if !selection.Active() { + return Resolved{}, nil + } + + all := ListRuleIDs(roots) + if len(all) == 0 { + return Resolved{}, fmt.Errorf("rules.only/rules.exclude are configured but no rules were found in the ruleset") + } + + var kept, excluded []string + for _, id := range all { + if len(selection.Only) > 0 && !matchesAny(id, selection.Only) { + continue + } + if matchesAny(id, selection.Exclude) { + excluded = append(excluded, id) + continue + } + kept = append(kept, id) + } + if len(kept) == 0 { + return Resolved{}, fmt.Errorf("rules.only/rules.exclude select no rules at all: nothing would be scanned") + } + + if len(selection.Only) == 0 { + sort.Strings(excluded) + return Resolved{Exclude: excluded}, nil + } + + expanded := ExpandRuleIDs(kept, roots) + sort.Strings(expanded) + return Resolved{Include: expanded}, nil +} + +// matchesAny delegates to the one rule-id grammar (sarif.MatchesRuleID), so a +// pattern behaves identically in rules.only/rules.exclude, --exclude-rule-id, +// and summary's --rule-id filter: exact full "path.yaml:id", exact bare name, +// or a doublestar glob over the full id. +func matchesAny(id string, patterns []string) bool { + return sarif.MatchesRuleID(id, patterns) +} + +// ApplyExclusions filters an explicit rule-id list (--rule-id) by exclusion +// patterns (--exclude-rule-id), so the two flags compose instead of one +// silently winning. Emptying the list is an error: every id in it was asked +// for by name, so excluding them all leaves a scan that checks nothing. +func ApplyExclusions(ids, patterns []string) ([]string, error) { + if len(patterns) == 0 { + return ids, nil + } + var kept []string + for _, id := range ids { + if !matchesAny(id, patterns) { + kept = append(kept, id) + } + } + if len(ids) > 0 && len(kept) == 0 { + return nil, fmt.Errorf("--exclude-rule-id excludes every rule selected by --rule-id: nothing would be scanned") + } + return kept, nil +} + +// Unmatched returns the selection patterns that match none of the given rule +// ids, in Only-then-Exclude order. A pattern matching nothing is usually a +// typo'd rule name, and silently ignoring it would make an exclusion look +// effective when it never was — the caller should surface these. +func (s Selection) Unmatched(all []string) []string { + var unmatched []string + for _, pattern := range append(append([]string{}, s.Only...), s.Exclude...) { + if pattern == "" { + continue + } + if !anyIDMatches(all, pattern) { + unmatched = append(unmatched, pattern) + } + } + return unmatched +} + +func anyIDMatches(all []string, pattern string) bool { + for _, id := range all { + if matchesAny(id, []string{pattern}) { + return true + } + } + return false +} diff --git a/cli/internal/rules/select_test.go b/cli/internal/rules/select_test.go new file mode 100644 index 0000000000..c598b8874c --- /dev/null +++ b/cli/internal/rules/select_test.go @@ -0,0 +1,273 @@ +package rules + +import ( + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +// ruleset writes a ruleset tree and returns its root. +func ruleset(t *testing.T, files map[string]string) string { + t.Helper() + root := t.TempDir() + for name, content := range files { + path := filepath.Join(root, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + return root +} + +func TestListRuleIDs(t *testing.T) { + root := ruleset(t, map[string]string{ + "java/security/sqli.yaml": "rules:\n - id: sql-injection\n - id: sql-injection-jdbc\n", + "java/security/xss.yml": "rules:\n - id: reflected-xss\n", + "java/lib/sources.yaml": "rules:\n - id: servlet-source\n", + "README.md": "not a ruleset file", + }) + + got := ListRuleIDs([]string{root}) + sort.Strings(got) + want := []string{ + "java/lib/sources.yaml:servlet-source", + "java/security/sqli.yaml:sql-injection", + "java/security/sqli.yaml:sql-injection-jdbc", + "java/security/xss.yml:reflected-xss", + } + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("got:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} + +func TestListRuleIDsSkipsUnparseableFiles(t *testing.T) { + root := ruleset(t, map[string]string{ + "good.yaml": "rules:\n - id: good-rule\n", + "bad.yaml": "this: [is: not: valid: yaml", + }) + got := ListRuleIDs([]string{root}) + if len(got) != 1 || got[0] != "good.yaml:good-rule" { + t.Errorf("got %v, want just the parseable rule", got) + } +} + +func TestListRuleIDsMergesRoots(t *testing.T) { + a := ruleset(t, map[string]string{"a.yaml": "rules:\n - id: rule-a\n"}) + b := ruleset(t, map[string]string{"b.yaml": "rules:\n - id: rule-b\n"}) + got := ListRuleIDs([]string{a, b}) + sort.Strings(got) + if len(got) != 2 || got[0] != "a.yaml:rule-a" || got[1] != "b.yaml:rule-b" { + t.Errorf("got %v", got) + } +} + +func TestMatchesAnyUsesTheSummaryRuleIDGrammar(t *testing.T) { + const id = "java/security/sqli.yaml:sql-injection" + cases := []struct { + pattern string + want bool + }{ + {"java/security/sqli.yaml:sql-injection", true}, // full id + {"sql-injection", true}, // exact leaf + {"java/security/**", true}, // glob over the full id + {"java/**/sqli.yaml:*", true}, + {"sql-*", false}, // globs match the FULL id only, same as summary --rule-id + {"sql-injection-jdbc", false}, + {"go/**", false}, + {"", false}, + } + for _, tc := range cases { + if got := matchesAny(id, []string{tc.pattern}); got != tc.want { + t.Errorf("matchesAny(%q, [%q]) = %v, want %v", id, tc.pattern, got, tc.want) + } + } +} + +func TestSelectWithNeitherListReturnsNothing(t *testing.T) { + root := ruleset(t, map[string]string{"a.yaml": "rules:\n - id: rule-a\n"}) + got, err := Select(Selection{}, []string{root}) + if err != nil { + t.Fatalf("select: %v", err) + } + if got.Include != nil || got.Exclude != nil { + t.Errorf("got %+v, want zero: with no lists the analyzer runs every rule", got) + } +} + +func TestSelectOnly(t *testing.T) { + root := ruleset(t, map[string]string{ + "a.yaml": "rules:\n - id: keep-me\n - id: drop-me\n", + }) + got, err := Select(Selection{Only: []string{"keep-me"}}, []string{root}) + if err != nil { + t.Fatalf("select: %v", err) + } + if len(got.Include) != 1 || got.Include[0] != "a.yaml:keep-me" || len(got.Exclude) != 0 { + t.Errorf("got %+v", got) + } +} + +func TestSelectExcludeResolvesToConcreteExcludedIDs(t *testing.T) { + // Exclusion alone must NOT expand into a giant inclusion list: the analyzer + // has --semgrep-rule-id-exclude, so only the excluded ids are passed. + root := ruleset(t, map[string]string{ + "a.yaml": "rules:\n - id: keep-me\n - id: drop-me\n", + }) + got, err := Select(Selection{Exclude: []string{"drop-me"}}, []string{root}) + if err != nil { + t.Fatalf("select: %v", err) + } + if len(got.Include) != 0 { + t.Errorf("no inclusion list expected, got %v", got.Include) + } + if len(got.Exclude) != 1 || got.Exclude[0] != "a.yaml:drop-me" { + t.Errorf("got %v, want the one excluded id", got.Exclude) + } +} + +func TestSelectExcludeAppliesAfterOnly(t *testing.T) { + root := ruleset(t, map[string]string{ + "a.yaml": "rules:\n - id: sqli-one\n - id: sqli-two\n - id: xss\n", + }) + got, err := Select(Selection{Only: []string{"a.yaml:sqli-*"}, Exclude: []string{"sqli-two"}}, []string{root}) + if err != nil { + t.Fatalf("select: %v", err) + } + if len(got.Include) != 1 || got.Include[0] != "a.yaml:sqli-one" { + t.Errorf("got %+v", got) + } +} + +func TestSelectPullsInReferencedRules(t *testing.T) { + root := ruleset(t, map[string]string{ + "security/sqli.yaml": "rules:\n - id: sql-injection\n join:\n refs:\n - rule: lib/sources.yaml#servlet-source\n", + "lib/sources.yaml": "rules:\n - id: servlet-source\n", + }) + got, err := Select(Selection{Only: []string{"sql-injection"}}, []string{root}) + if err != nil { + t.Fatalf("select: %v", err) + } + sort.Strings(got.Include) + if len(got.Include) != 2 || got.Include[1] != "security/sqli.yaml:sql-injection" || got.Include[0] != "lib/sources.yaml:servlet-source" { + t.Errorf("got %+v, want the rule plus the library rule it joins", got) + } +} + +func TestSelectOnlyReAddsAnExcludedRuleThatSurvivorsNeed(t *testing.T) { + // On the inclusion path, excluding a library rule that a kept rule joins + // against would produce a rule that cannot match anything. Reference + // expansion brings it back. (On the exclusion-only path the analyzer + // resolves join refs past the exclusion itself, covered by the jar-side + // RuleIdExcludeTest.) + root := ruleset(t, map[string]string{ + "security/sqli.yaml": "rules:\n - id: sql-injection\n join:\n refs:\n - rule: lib/sources.yaml#servlet-source\n", + "lib/sources.yaml": "rules:\n - id: servlet-source\n", + }) + got, err := Select(Selection{Only: []string{"**"}, Exclude: []string{"lib/**"}}, []string{root}) + if err != nil { + t.Fatalf("select: %v", err) + } + if len(got.Include) != 2 { + t.Errorf("got %+v, want the excluded library rule restored", got) + } +} + +func TestSelectEmptyResultIsAnError(t *testing.T) { + root := ruleset(t, map[string]string{"a.yaml": "rules:\n - id: rule-a\n"}) + if _, err := Select(Selection{Only: []string{"nothing-matches-this"}}, []string{root}); err == nil { + t.Error("expected an error rather than a scan with zero rules") + } + if _, err := Select(Selection{Exclude: []string{"**"}}, []string{root}); err == nil { + t.Error("excluding everything should error rather than scan with zero rules") + } +} + +func TestSelectWithNoRulesFoundIsAnError(t *testing.T) { + if _, err := Select(Selection{Only: []string{"x"}}, []string{t.TempDir()}); err == nil { + t.Error("expected an error when the ruleset holds no rules at all") + } +} + +func TestApplyExclusionsFiltersAnExplicitList(t *testing.T) { + ids := []string{"a.yaml:keep-me", "a.yaml:drop-me", "b.yaml:drop-me-too"} + got, err := ApplyExclusions(ids, []string{"*:drop-*"}) + if err != nil { + t.Fatalf("apply: %v", err) + } + if len(got) != 1 || got[0] != "a.yaml:keep-me" { + t.Errorf("got %v", got) + } +} + +func TestApplyExclusionsWithNoPatternsIsIdentity(t *testing.T) { + ids := []string{"a.yaml:x"} + got, err := ApplyExclusions(ids, nil) + if err != nil { + t.Fatalf("apply: %v", err) + } + if len(got) != 1 || got[0] != "a.yaml:x" { + t.Errorf("got %v", got) + } +} + +func TestApplyExclusionsEmptyingTheListIsAnError(t *testing.T) { + if _, err := ApplyExclusions([]string{"a.yaml:x"}, []string{"**"}); err == nil { + t.Error("excluding every explicitly requested rule should error, not scan nothing") + } +} + +func TestUnmatchedReportsPatternsThatSelectNothing(t *testing.T) { + all := []string{"a.yaml:keep-me", "java/security/sqli.yaml:sql-injection"} + sel := Selection{ + Only: []string{"keep-me", "no-such-rule"}, + Exclude: []string{"java/**", "typo-*"}, + } + got := sel.Unmatched(all) + if len(got) != 2 || got[0] != "no-such-rule" || got[1] != "typo-*" { + t.Errorf("got %v, want [no-such-rule typo-*]", got) + } +} + +func TestUnmatchedIsEmptyWhenEverythingMatches(t *testing.T) { + all := []string{"a.yaml:x"} + if got := (Selection{Exclude: []string{"x"}}).Unmatched(all); got != nil { + t.Errorf("got %v, want nil", got) + } +} + +// A ruleset root that is a single YAML file has no relative path, and the +// analyzer names such a ruleset "" — its ids are ":". The CLI must produce +// the same form or every exclusion against a file ruleset silently misses. +func TestListRuleIDsFileRootMatchesAnalyzerForm(t *testing.T) { + root := ruleset(t, map[string]string{ + "my-rules.yaml": "rules:\n - id: rule-one\n - id: rule-two\n", + }) + file := filepath.Join(root, "my-rules.yaml") + + got := ListRuleIDs([]string{file}) + sort.Strings(got) + want := []string{":rule-one", ":rule-two"} + if strings.Join(got, "\n") != strings.Join(want, "\n") { + t.Errorf("got:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n")) + } +} + +func TestSelectExcludeOnFileRulesetResolvesToAnalyzerIDs(t *testing.T) { + root := ruleset(t, map[string]string{ + "my-rules.yaml": "rules:\n - id: rule-one\n - id: rule-two\n", + }) + file := filepath.Join(root, "my-rules.yaml") + + resolved, err := Select(Selection{Exclude: []string{"rule-one"}}, []string{file}) + if err != nil { + t.Fatalf("select: %v", err) + } + if len(resolved.Exclude) != 1 || resolved.Exclude[0] != ":rule-one" { + t.Errorf("exclusion resolved to %v, want [:rule-one]", resolved.Exclude) + } +} diff --git a/cli/internal/sarif/filter.go b/cli/internal/sarif/filter.go index 20c6492002..7c53f5dd35 100644 --- a/cli/internal/sarif/filter.go +++ b/cli/internal/sarif/filter.go @@ -59,7 +59,7 @@ func (f Filters) matches(r *Result) bool { if len(f.Paths) > 0 && !matchPath(r, f.Paths) { return false } - if len(f.Severities) > 0 && !matchSeverity(r, f.Severities) { + if len(f.Severities) > 0 && !MatchesSeverity(r, f.Severities) { return false } if len(f.RuleIDs) > 0 && !matchRuleID(r, f.RuleIDs) { @@ -92,7 +92,7 @@ func matchPath(r *Result, patterns []string) bool { // matchSeverity reports whether the result's level equals any supplied level // (case-insensitive). A nil/empty level is treated as "note". -func matchSeverity(r *Result, levels []string) bool { +func MatchesSeverity(r *Result, levels []string) bool { actual := strings.ToLower(string(findingLevel(r))) for _, l := range levels { if strings.ToLower(strings.TrimSpace(l)) == actual { @@ -118,10 +118,10 @@ func ruleLeaf(id string) string { // matchRuleID reports whether the result's rule-id matches any supplied value as // a full-id exact match, a leaf exact match, or a doublestar glob over the full id. func matchRuleID(r *Result, values []string) bool { - if r.RuleID == nil { - return false - } - full := *r.RuleID + return r.RuleID != nil && MatchesRuleID(*r.RuleID, values) +} + +func MatchesRuleID(full string, values []string) bool { leaf := ruleLeaf(full) for _, v := range values { // skip blank values (cobra StringArrayVar can yield them) so an empty @@ -170,8 +170,12 @@ var validSeverities = map[string]bool{"error": true, "warning": true, "note": tr // ValidateSeverity returns an error if level is not a recognized SARIF level. func ValidateSeverity(level string) error { + return ValidateSeverityFor("--severity", level) +} + +func ValidateSeverityFor(flag, level string) error { if validSeverities[strings.ToLower(strings.TrimSpace(level))] { return nil } - return fmt.Errorf("invalid --severity %q: valid values are error, warning, note, none", level) + return fmt.Errorf("invalid %s %q: valid values are error, warning, note, none", flag, level) } diff --git a/cli/internal/sarif/filter_test.go b/cli/internal/sarif/filter_test.go index 2175de600d..e4c7d7eb95 100644 --- a/cli/internal/sarif/filter_test.go +++ b/cli/internal/sarif/filter_test.go @@ -22,14 +22,14 @@ func TestMatchPath(t *testing.T) { func TestMatchSeverity(t *testing.T) { r := makeResult("r", Error, "a.java", 1, nil) - if !matchSeverity(&r, []string{"ERROR"}) { + if !MatchesSeverity(&r, []string{"ERROR"}) { t.Error("expected case-insensitive error match") } - if matchSeverity(&r, []string{"warning"}) { + if MatchesSeverity(&r, []string{"warning"}) { t.Error("expected warning not to match an error") } nilLevel := Result{Locations: r.Locations} - if !matchSeverity(&nilLevel, []string{"note"}) { + if !MatchesSeverity(&nilLevel, []string{"note"}) { t.Error("expected nil level to be treated as note") } } diff --git a/cli/internal/sarif/property_bag.go b/cli/internal/sarif/property_bag.go new file mode 100644 index 0000000000..072af56549 --- /dev/null +++ b/cli/internal/sarif/property_bag.go @@ -0,0 +1,77 @@ +package sarif + +import ( + "bytes" + "encoding/json" + "sort" +) + +// UnmarshalJSON decodes a property bag, lifting "tags" into the typed field and +// keeping every other key as raw JSON in Extra. Raw JSON rather than any: +// re-encoding through map[string]any would reformat numbers and can lose +// precision on integers beyond float64's exact range. +func (p *PropertyBag) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + p.Tags = nil + p.Extra = nil + + for key, value := range raw { + if key == "tags" { + var tags []string + if err := json.Unmarshal(value, &tags); err == nil { + p.Tags = tags + continue + } + // Not a string array: keep it verbatim rather than dropping it. + } + if p.Extra == nil { + p.Extra = make(map[string]json.RawMessage, len(raw)) + } + p.Extra[key] = value + } + return nil +} + +// MarshalJSON re-emits the bag with its preserved keys. Keys are sorted so that +// rewriting an unchanged report produces byte-identical output. +func (p PropertyBag) MarshalJSON() ([]byte, error) { + keys := make([]string, 0, len(p.Extra)+1) + values := make(map[string]json.RawMessage, len(p.Extra)+1) + + for key, value := range p.Extra { + keys = append(keys, key) + values[key] = value + } + if len(p.Tags) > 0 { + encoded, err := json.Marshal(p.Tags) + if err != nil { + return nil, err + } + if _, clash := values["tags"]; !clash { + keys = append(keys, "tags") + } + values["tags"] = encoded + } + sort.Strings(keys) + + var buf bytes.Buffer + buf.WriteByte('{') + for i, key := range keys { + if i > 0 { + buf.WriteByte(',') + } + encodedKey, err := json.Marshal(key) + if err != nil { + return nil, err + } + buf.Write(encodedKey) + buf.WriteByte(':') + buf.Write(values[key]) + } + buf.WriteByte('}') + return buf.Bytes(), nil +} diff --git a/cli/internal/sarif/property_bag_test.go b/cli/internal/sarif/property_bag_test.go new file mode 100644 index 0000000000..23020a6459 --- /dev/null +++ b/cli/internal/sarif/property_bag_test.go @@ -0,0 +1,100 @@ +package sarif + +import ( + "encoding/json" + "testing" +) + +func TestPropertyBagPreservesUnknownKeys(t *testing.T) { + const in = `{"tags":["CWE-89"],"precision":"high","confidence":0.75,"nested":{"a":[1,2]}}` + var bag PropertyBag + if err := json.Unmarshal([]byte(in), &bag); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(bag.Tags) != 1 || bag.Tags[0] != "CWE-89" { + t.Errorf("tags not decoded: %v", bag.Tags) + } + + out, err := json.Marshal(bag) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var before, after map[string]any + if err := json.Unmarshal([]byte(in), &before); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(out, &after); err != nil { + t.Fatal(err) + } + for k, v := range before { + got, ok := after[k] + if !ok { + t.Errorf("key %q was dropped", k) + continue + } + if toJSON(t, got) != toJSON(t, v) { + t.Errorf("key %q changed: %s -> %s", k, toJSON(t, v), toJSON(t, got)) + } + } +} + +func TestPropertyBagPreservesLargeIntegersExactly(t *testing.T) { + const in = `{"id":9007199254740993}` + var bag PropertyBag + if err := json.Unmarshal([]byte(in), &bag); err != nil { + t.Fatalf("unmarshal: %v", err) + } + out, err := json.Marshal(bag) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(out) != in { + t.Errorf("got %s, want %s", out, in) + } +} + +func TestPropertyBagWithOnlyTags(t *testing.T) { + bag := PropertyBag{Tags: []string{"a", "b"}} + out, err := json.Marshal(bag) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(out) != `{"tags":["a","b"]}` { + t.Errorf("got %s", out) + } +} + +func TestPropertyBagEmptyMarshalsToEmptyObject(t *testing.T) { + out, err := json.Marshal(PropertyBag{}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(out) != `{}` { + t.Errorf("got %s, want {}", out) + } +} + +func TestPropertyBagNonStringTagsAreNotLost(t *testing.T) { + // A malformed bag must still round-trip rather than silently dropping tags. + const in = `{"tags":"not-an-array"}` + var bag PropertyBag + if err := json.Unmarshal([]byte(in), &bag); err != nil { + t.Fatalf("unmarshal: %v", err) + } + out, err := json.Marshal(bag) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(out) != in { + t.Errorf("got %s, want %s", out, in) + } +} + +func toJSON(t *testing.T, v any) string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return string(b) +} diff --git a/cli/internal/sarif/sarif.go b/cli/internal/sarif/sarif.go index 7553c909af..e609be5cb5 100644 --- a/cli/internal/sarif/sarif.go +++ b/cli/internal/sarif/sarif.go @@ -217,9 +217,15 @@ type Address struct { // Key/value pairs that provide additional information about the special locations. // // Key/value pairs that provide additional information about the version control details. +// Property bags are the one open-ended part of the SARIF schema: any key is +// legal. Extra holds every key other than "tags" verbatim so that reading a +// report, modifying it and writing it back never discards tool metadata. See +// property_bag.go for the marshalling. type PropertyBag struct { // A set of distinct strings that provide additional information. Tags []string `json:"tags,omitempty"` + // Every other key in the bag, preserved as raw JSON. + Extra map[string]json.RawMessage `json:"-"` } // A single artifact. In some cases, this artifact might be nested within another artifact. diff --git a/cli/internal/sarif/save.go b/cli/internal/sarif/save.go new file mode 100644 index 0000000000..3a491b5b40 --- /dev/null +++ b/cli/internal/sarif/save.go @@ -0,0 +1,53 @@ +package sarif + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// SaveReport writes report to path as indented JSON. The write goes to a +// temporary file in the destination directory and is then renamed over path, so +// a crash mid-write can never leave a truncated report behind — which matters +// because triage rewrites reports in place. +func SaveReport(report *Report, path string) error { + data, err := json.MarshalIndent(report, "", " ") + if err != nil { + return fmt.Errorf("failed to encode sarif report: %w", err) + } + data = append(data, '\n') + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + tmp, err := os.CreateTemp(dir, ".sarif-*.tmp") + if err != nil { + return fmt.Errorf("failed to create temporary report file: %w", err) + } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() // no-op once the rename below succeeds + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("failed to write sarif report: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("failed to write sarif report: %w", err) + } + mode := os.FileMode(0o644) + if info, statErr := os.Stat(path); statErr == nil { + mode = info.Mode().Perm() + } else if !os.IsNotExist(statErr) { + return fmt.Errorf("failed to inspect existing report: %w", statErr) + } + if err := os.Chmod(tmpName, mode); err != nil { + return fmt.Errorf("failed to set report permissions: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("failed to replace sarif report: %w", err) + } + return nil +} diff --git a/cli/internal/sarif/save_test.go b/cli/internal/sarif/save_test.go new file mode 100644 index 0000000000..0e673992e4 --- /dev/null +++ b/cli/internal/sarif/save_test.go @@ -0,0 +1,172 @@ +package sarif + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "testing" +) + +// A report shaped like real analyzer output: schema/version envelope, tool +// driver with rules, uri bases, a result with fingerprints and a code flow. +const realisticSarif = `{ + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "version": "2.1.0", + "runs": [ + { + "tool": { + "driver": { + "name": "OpenTaint", + "version": "1.2.3", + "semanticVersion": "1.2.3", + "rules": [ + { + "id": "java.sqli", + "name": "java.sqli", + "shortDescription": {"text": "SQL injection"}, + "properties": {"tags": ["CWE-89"], "precision": "high"} + } + ] + } + }, + "originalUriBaseIds": {"%SRCROOT%": {"uri": "/project"}}, + "results": [ + { + "ruleId": "java.sqli", + "level": "error", + "message": {"text": "Tainted value reaches a SQL sink"}, + "partialFingerprints": { + "vulnerabilityWithTraceHash/v1": "trace-hash-aaa", + "vulnerabilitySourceSinkHash/v1": "src-sink-aaa" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": {"uri": "src/Dao.java", "uriBaseId": "%SRCROOT%"}, + "region": {"startLine": 42, "startColumn": 9} + } + } + ], + "codeFlows": [ + { + "threadFlows": [ + { + "locations": [ + { + "location": { + "physicalLocation": { + "artifactLocation": {"uri": "src/Controller.java", "uriBaseId": "%SRCROOT%"}, + "region": {"startLine": 10} + }, + "logicalLocations": [{"fullyQualifiedName": "com.example.Controller#handle"}] + }, + "kinds": ["taint", "source"], + "executionOrder": 1 + } + ] + } + ] + } + ] + } + ] + } + ] +}` + +func TestSaveReportRoundTripsRealisticReport(t *testing.T) { + report, err := UnmarshalReport([]byte(realisticSarif)) + if err != nil { + t.Fatalf("unmarshal: %v", err) + } + + path := filepath.Join(t.TempDir(), "out.sarif") + if err := SaveReport(&report, path); err != nil { + t.Fatalf("save: %v", err) + } + + written, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read back: %v", err) + } + + // Compare as generic JSON so key order and indentation are irrelevant: the + // question is whether any field was dropped or altered by the round trip. + var before, after any + if err := json.Unmarshal([]byte(realisticSarif), &before); err != nil { + t.Fatalf("unmarshal expected: %v", err) + } + if err := json.Unmarshal(written, &after); err != nil { + t.Fatalf("unmarshal written: %v", err) + } + if !reflect.DeepEqual(before, after) { + t.Errorf("round trip lost or changed data\nbefore: %s\nafter: %s", realisticSarif, written) + } +} + +func TestSaveReportCreatesParentDirectories(t *testing.T) { + report := makeReport(makeResult("a", Error, "a.java", 1, nil)) + path := filepath.Join(t.TempDir(), "nested", "dir", "out.sarif") + if err := SaveReport(report, path); err != nil { + t.Fatalf("save: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Errorf("expected file at %s: %v", path, err) + } +} + +func TestSaveReportLeavesNoTempFileBehind(t *testing.T) { + dir := t.TempDir() + report := makeReport(makeResult("a", Error, "a.java", 1, nil)) + if err := SaveReport(report, filepath.Join(dir, "out.sarif")); err != nil { + t.Fatalf("save: %v", err) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("readdir: %v", err) + } + if len(entries) != 1 || entries[0].Name() != "out.sarif" { + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + t.Errorf("expected only out.sarif, got %v", names) + } +} + +func TestSaveReportOverwritesAtomically(t *testing.T) { + path := filepath.Join(t.TempDir(), "out.sarif") + if err := os.WriteFile(path, []byte("stale contents"), 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + report := makeReport(makeResult("a", Error, "a.java", 1, nil)) + if err := SaveReport(report, path); err != nil { + t.Fatalf("save: %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read back: %v", err) + } + if _, err := UnmarshalReport(data); err != nil { + t.Errorf("overwritten file is not valid SARIF: %v", err) + } +} + +func TestSaveReportPreservesExistingPermissions(t *testing.T) { + path := filepath.Join(t.TempDir(), "private.sarif") + if err := os.WriteFile(path, []byte("stale contents"), 0o600); err != nil { + t.Fatalf("seed: %v", err) + } + report := makeReport(makeResult("a", Error, "a.java", 1, nil)) + if err := SaveReport(report, path); err != nil { + t.Fatalf("save: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("permissions = %o, want 600", got) + } +} diff --git a/cli/internal/triage/gate.go b/cli/internal/triage/gate.go new file mode 100644 index 0000000000..dc25aa27c7 --- /dev/null +++ b/cli/internal/triage/gate.go @@ -0,0 +1,62 @@ +package triage + +import ( + "strings" + + "github.com/seqra/opentaint/internal/sarif" +) + +// Gate decides whether a report should fail the build. +// +// A finding counts when its level is in scope. +type Gate struct { + // Enabled turns the gate on (--error-on-findings). Off by default, which + // keeps the historical behavior of never failing on findings. + Enabled bool + // Severities restricts which SARIF levels count. Empty means every level. + Severities []string +} + +// Evaluate returns the number of findings that count and whether the gate trips. +func (g Gate) Evaluate(report *sarif.Report) (int, bool) { + if !g.Enabled { + return 0, false + } + + count := 0 + for _, run := range report.Runs { + for i := range run.Results { + r := &run.Results[i] + if !g.inScope(r) { + continue + } + count++ + } + } + return count, count > 0 +} + +func (g Gate) inScope(r *sarif.Result) bool { + return len(g.Severities) == 0 || sarif.MatchesSeverity(r, g.Severities) +} + +// ParseGateSeverities validates --error-on-severity values. The flag is +// repeatable, and each value may also be a comma-separated list, so +// "--error-on-severity error,warning" and "--error-on-severity error +// --error-on-severity warning" mean the same thing. +func ParseGateSeverities(values []string) ([]string, error) { + var out []string + for _, v := range values { + for _, token := range strings.Split(v, ",") { + normalized := strings.ToLower(strings.TrimSpace(token)) + if normalized == "" { + continue + } + if err := sarif.ValidateSeverityFor("--error-on-severity", normalized); err != nil { + return nil, err + } + out = append(out, normalized) + } + } + return out, nil +} diff --git a/cli/internal/triage/gate_test.go b/cli/internal/triage/gate_test.go new file mode 100644 index 0000000000..29fedcc9ae --- /dev/null +++ b/cli/internal/triage/gate_test.go @@ -0,0 +1,99 @@ +package triage + +import ( + "testing" + + "github.com/seqra/opentaint/internal/sarif" +) + +func result(ruleID string) sarif.Result { + level := sarif.Error + return sarif.Result{RuleID: &ruleID, Level: &level} +} +func report(results ...sarif.Result) *sarif.Report { + return &sarif.Report{Runs: []sarif.Run{{Results: results}}} +} +func lvlptr(level sarif.Level) *sarif.Level { return &level } +func warn(ruleID string) sarif.Result { + r := result(ruleID) + r.Level = lvlptr(sarif.Warning) + return r +} + +func TestGateDisabledNeverTrips(t *testing.T) { + rep := report(result("a")) + count, tripped := Gate{}.Evaluate(rep) + if tripped { + t.Error("a disabled gate must never trip") + } + if count != 0 { + t.Errorf("count: got %d, want 0", count) + } +} + +func TestGateCountsEveryFindingWithoutBaseline(t *testing.T) { + rep := report(result("a"), warn("b")) + count, tripped := Gate{Enabled: true}.Evaluate(rep) + if !tripped || count != 2 { + t.Errorf("got (%d, %v), want (2, true)", count, tripped) + } +} + +func TestGateRestrictsToSeverities(t *testing.T) { + rep := report(result("a"), warn("b")) + + count, tripped := Gate{Enabled: true, Severities: []string{"error"}}.Evaluate(rep) + if count != 1 || !tripped { + t.Errorf("got (%d, %v), want (1, true)", count, tripped) + } + + count, tripped = Gate{Enabled: true, Severities: []string{"note"}}.Evaluate(rep) + if count != 0 || tripped { + t.Errorf("got (%d, %v), want (0, false)", count, tripped) + } +} + +func TestParseGateSeverities(t *testing.T) { + if _, err := ParseGateSeverities([]string{"error", "warning"}); err != nil { + t.Errorf("unexpected error: %v", err) + } + if _, err := ParseGateSeverities([]string{"critical"}); err == nil { + t.Error("expected an error for an unknown severity") + } +} + +func TestParseGateSeveritiesSplitsCommaSeparated(t *testing.T) { + got, err := ParseGateSeverities([]string{"error,warning"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 2 || got[0] != "error" || got[1] != "warning" { + t.Errorf("got %v, want [error warning]", got) + } +} + +func TestParseGateSeveritiesMixesCommaAndRepeatedFlags(t *testing.T) { + got, err := ParseGateSeverities([]string{"error, warning", "note"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 3 { + t.Errorf("got %v, want error warning note", got) + } +} + +func TestParseGateSeveritiesRejectsBadTokenInsideAList(t *testing.T) { + if _, err := ParseGateSeverities([]string{"error,bogus"}); err == nil { + t.Error("expected an error for a bad token in a comma list") + } +} + +func TestParseGateSeveritiesIgnoresEmptyTokens(t *testing.T) { + got, err := ParseGateSeverities([]string{"error,,warning,"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 2 { + t.Errorf("got %v, want [error warning]", got) + } +} diff --git a/cli/internal/utils/opentaint_command_builder.go b/cli/internal/utils/opentaint_command_builder.go index 6117c91b80..c218876083 100644 --- a/cli/internal/utils/opentaint_command_builder.go +++ b/cli/internal/utils/opentaint_command_builder.go @@ -396,3 +396,28 @@ func BuildScanCommandFromCompile(projectPath, projectModelPath string) string { WithOutput(outputPath). Build() } + +func (cb *OpentaintCommandBuilder) WithErrorOnFindings(enabled bool) *OpentaintCommandBuilder { + if enabled { + cb.boolFlags["error-on-findings"] = true + } + return cb +} + +func (cb *OpentaintCommandBuilder) WithErrorOnSeverity(severities []string) *OpentaintCommandBuilder { + for _, s := range severities { + if s != "" { + cb.arrayFlags["error-on-severity"] = append(cb.arrayFlags["error-on-severity"], s) + } + } + return cb +} + +func (cb *OpentaintCommandBuilder) WithExcludeRuleID(ruleIDs []string) *OpentaintCommandBuilder { + for _, id := range ruleIDs { + if id != "" { + cb.arrayFlags["exclude-rule-id"] = append(cb.arrayFlags["exclude-rule-id"], id) + } + } + return cb +} diff --git a/docs/configuration.md b/docs/configuration.md index 09381d8be6..8d32ae6a81 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -27,6 +27,11 @@ output: # Java runtime settings java: version: 23 + +# Which rules the analyzer runs +rules: + only: [] # if set, only these rules run + exclude: [cookie-missing-httponly] # these rules never run ``` ### Available Options @@ -39,6 +44,44 @@ java: | `output.color` | Color mode: `auto`, `always`, `never` | `auto` | | `output.quiet` | Suppress interactive console output (spinners, progress bars, JAR streaming) | `false` | | `java.version` | Java version for running the analyzer | `23` | +| `rules.only` | Run only the rules matching these patterns | all rules | +| `rules.exclude` | Never run the rules matching these patterns | none | + +### Selecting rules + +`rules.only` and `rules.exclude` control which rules the analyzer loads. They +are rule *selection*, not suppression: an excluded rule never runs, so it +produces nothing in the report and nothing to review later. + +Each entry matches a full `path/to/file.yaml:rule-id` exactly, a bare rule name +exactly, or a doublestar glob over the full id — the same grammar as the +summary command's `--rule-id` filter. Globs never match the bare name alone: + +```yaml +rules: + only: + - sql-injection # exact rule name + - java/security/** # every rule under that directory + - java/security/sqli.yaml:* # every rule in that file + exclude: + - cookie-missing-httponly +``` + +A pattern that matches no rule in the active ruleset produces a warning, so a +typo'd exclusion cannot silently look effective. + +`exclude` is applied after `only`. An exclusion-only list is passed to the +analyzer as the excluded rule ids themselves — excluding one rule adds one +argument, not the whole ruleset's complement. A library rule that a selected +rule joins against always keeps working, even if a pattern excluded it, since +dropping it would leave a rule that can never match. A selection that ends up +matching no rules is an error rather than a scan that silently checks +nothing — `--dry-run` reports it without compiling. + +The `--rule-id` flag overrides both lists, and the `--exclude-rule-id` flag +overrides `rules.exclude`, following the usual rule that flags outrank the +configuration file. The two flags compose: `--rule-id` selects, then +`--exclude-rule-id` subtracts. The per-run log file (`~/.opentaint/logs//.log`) always captures full JAR subprocess output regardless of these flags. They control diff --git a/docs/usage.md b/docs/usage.md index e8a3636572..1a09390f42 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -111,6 +111,8 @@ On the first run, the compiled project model is cached in `~/.opentaint/cache/`. | `--ruleset` | Rules to run: a YAML file, a directory of rules files, or `builtin` (default: `builtin`) | | `--dry-run` | Validate inputs and show what would run without compiling or scanning | | `--log-file` | Path to the log file (default: `/logs/.log`) | +| `--rule-id` | Run only rules with this ID (repeatable) | +| `--exclude-rule-id` | Never run rules matching this ID: full id, bare name, or glob over the full id — the same matching as summary's `--rule-id` filter (repeatable, overrides `rules.exclude` from the config, composes with `--rule-id`) | #### Rule-authoring flags @@ -332,3 +334,25 @@ These options apply to all commands: - `--color string` — Color mode (`auto`, `always`, `never`), defaults to `auto` (detects terminal) For persistent configuration using files or environment variables, see the [Configuration](configuration.md) documentation. + +### Fail a CI job on findings + +By default a successful scan exits with code 0 even when it finds issues. Use +`--error-on-findings` to exit with code 2 when findings remain. Analyzer failures +keep their existing exit codes. `--error-on-severity` restricts the findings that +count. It accepts repeated flags and comma-separated SARIF levels: `error`, +`warning`, `note`, and `none`. Without a severity filter every level counts. + +```bash +opentaint scan . --error-on-findings --error-on-severity error,warning +``` + +Use `--exclude-rule-id` to skip rules before analysis. It accepts a full rule ID, +a bare rule name, or a glob over the full ID, and can be repeated. With +`--rule-id`, exclusions are subtracted from the explicit selection. Excluding +every selected rule is an error. Quote glob patterns to keep the shell from +expanding them. + +```bash +opentaint scan . --exclude-rule-id 'security/experimental/**' +```