diff --git a/cli/cmd/analyzer_inputs.go b/cli/cmd/analyzer_inputs.go index 3290a995fa..3ed3a45711 100644 --- a/cli/cmd/analyzer_inputs.go +++ b/cli/cmd/analyzer_inputs.go @@ -6,7 +6,7 @@ import ( func addDataflowApproximations(b *AnalyzerBuilder, paths []string, analyzerJarPath, projectModelDir string) { for _, approxPath := range paths { - absApproxPath := log.AbsPathOrExit(approxPath, "dataflow-approximations") + absApproxPath := log.AbsPathOrExit(approxPath, "java-models") compiledPath, err := compileApproximationsIfNeeded(absApproxPath, analyzerJarPath, projectModelDir) if err != nil { out.Fatalf("Approximation compilation failed: %s", err) @@ -17,6 +17,6 @@ func addDataflowApproximations(b *AnalyzerBuilder, paths []string, analyzerJarPa func addPassthroughApproximations(b *AnalyzerBuilder, paths []string) { for _, passthrough := range paths { - b.AddPassthroughApproximations(log.AbsPathOrExit(passthrough, "passthrough-approximations")) + b.AddPassthroughApproximations(log.AbsPathOrExit(passthrough, "passthrough-models")) } } diff --git a/cli/cmd/command_builder.go b/cli/cmd/command_builder.go index 64bfcf4f0c..47c97cd225 100644 --- a/cli/cmd/command_builder.go +++ b/cli/cmd/command_builder.go @@ -58,6 +58,7 @@ type AnalyzerBuilder struct { jarPath string maxMemory string ruleIDs []string + ruleIDExcludes []string passthroughApproximations []string dataflowApproximations []string trackExternalMethods bool @@ -146,6 +147,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 @@ -249,6 +255,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/compile.go b/cli/cmd/compile.go index 3d33c8d337..8da1600dcf 100644 --- a/cli/cmd/compile.go +++ b/cli/cmd/compile.go @@ -32,21 +32,33 @@ func currentCompileBuilder(projectPath string) *utils.OpentaintCommandBuilder { // dockerCompileSuggestion builds the "try Docker-based compilation" fallback hint. func dockerCompileSuggestion() output.Suggestion { return output.Suggestion{ - Description: dockerFallbackHintPrefix + "compilation:", + Description: "If the required Java is missing, set JAVA_HOME or compile in a container instead:", Command: utils.BuildCompileCommandWithDocker(currentCompileBuilder(""), ProjectPath, OutputProjectModelPath), } } // compileCmd represents the compile command var compileCmd = &cobra.Command{ - Use: "compile project", - Short: "Compile your Java or Kotlin project", + Use: "compile ", + Short: "Compile a project into a reusable project model", Args: cobra.ExactArgs(1), // require exactly one argument - Long: `This command takes a required path to the project, automatically detects Java/Kotlin build system, modules and dependencies and compiles project model. + Long: `Compile a project into a project model that you can scan many times. OpenTaint finds the build system, collects the modules and dependencies, and builds the project. -Arguments: - project - Path to a project to compile (required) -`, +The project argument is the path to the project root. It is required. Use --output to set the project model directory. This directory must not exist before the command runs. + +Later scans can use the model without a new build. This makes repeated scans fast. + +Before your first compile, run "opentaint pull" one time. To scan the model, use "opentaint scan --project-model".`, + Example: ` # Compile the current directory into a project model + opentaint compile . -o ./model + + # Make sure the inputs are correct, without a build + opentaint compile . -o ./model --dry-run + + # Recipe: compile one time, then scan with different settings + opentaint compile ./my-app -o ./model + opentaint scan --project-model ./model --ruleset ./team-rules -o team.sarif + opentaint scan --project-model ./model --severity error -o errors.sarif`, Annotations: map[string]string{"PrintConfig": "true"}, Run: func(cmd *cobra.Command, args []string) { ProjectPath = args[0] @@ -72,25 +84,25 @@ Arguments: sb.Line() } sb.FieldNode("Project", absProjectRoot). - FieldNode("Output project model", absOutputProjectModelPath). + FieldNode("Project model", absOutputProjectModelPath). FieldNode("Autobuilder", utils.ArtifactVersionWithPath(globals.ArtifactByKind("autobuilder"))). Render() if DryRunCompile { out.Blank() failOnInvalidInputs(func() error { return validation.ValidateCompileInputs(absProjectRoot, absOutputProjectModelPath) }) - runDryRun("Compilation") + runDryRun("compilation") return } autobuilderJarPath, err := ensureAutobuilderAvailable() if err != nil { - out.Fatalf("Native compile preparation failed: %s", err) + failf("Native compile preparation failed: %s", err) } compileJavaRunner := newAutobuilderJavaRunner() if _, err := compileJavaRunner.EnsureJava(); err != nil { - out.Fatalf("Failed to resolve Java for compilation: %s", err) + failf("Failed to resolve Java for compilation: %s", err) } if err := out.RunWithSpinner("Compiling project model", func() error { @@ -98,7 +110,8 @@ Arguments: }); err == nil { out.Blank() printCompileSummary(absOutputProjectModelPath) - suggest("To scan project run", utils.BuildScanCommandFromCompile(projectRoot, absOutputProjectModelPath)) + out.Successf("Compilation completed.") + suggest("To scan the compiled project model, run:", utils.BuildScanCommandFromCompile(projectRoot, absOutputProjectModelPath)) } else { out.InteractiveBlank() failWith(1, fmt.Sprintf("Native compile has failed: %s", err), dockerCompileSuggestion()) @@ -109,7 +122,7 @@ Arguments: func init() { rootCmd.AddCommand(compileCmd) - compileCmd.Flags().StringVarP(&OutputProjectModelPath, "output", "o", "", `Path to the result project model`) + compileCmd.Flags().StringVarP(&OutputProjectModelPath, "output", "o", "", `Path to the project model directory to create (required, must not exist)`) _ = compileCmd.MarkFlagRequired("output") compileCmd.Flags().BoolVar(&DryRunCompile, "dry-run", false, "Validate inputs and show what would run without compiling") compileCmd.Flags().StringVar(&CompileLogFile, "log-file", "", "Path to the log file (default: /logs/.log)") diff --git a/cli/cmd/compile_approximations.go b/cli/cmd/compile_approximations.go index ed61db7b1f..651d5e3c12 100644 --- a/cli/cmd/compile_approximations.go +++ b/cli/cmd/compile_approximations.go @@ -18,7 +18,7 @@ import ( // bundles approximation support sources (OpentaintNdUtil, ArgumentTypeContext). const approxClassesJarPrefix = "opentaint-dataflow-approximations/" -// compileApproximationsIfNeeded checks whether a --dataflow-approximations directory +// compileApproximationsIfNeeded checks whether a --java-models directory // contains .java source files. If so, it compiles them using javac (with the // analyzer JAR + project dependencies on the classpath) and returns the path to // the compiled .class output directory. If the directory already contains only diff --git a/cli/cmd/dry_run.go b/cli/cmd/dry_run.go index e0c2f7276a..914db0609c 100644 --- a/cli/cmd/dry_run.go +++ b/cli/cmd/dry_run.go @@ -1,6 +1,9 @@ package cmd -import "fmt" +import ( + "os" + "strings" +) func failOnInvalidInputs(validate func() error) { if err := validate(); err != nil { @@ -8,6 +11,46 @@ func failOnInvalidInputs(validate func() error) { } } +// runDryRun prints the standard dry-run tail: a status line naming the skipped +// action, then a suggestion to repeat the same invocation without --dry-run. func runDryRun(skippedAction string) { - out.Print(fmt.Sprintf("Dry run mode. Inputs validated. %s skipped.", skippedAction)) + out.Printf("Dry run complete. Inputs validated, %s skipped.", skippedAction) + suggest("To run for real, run:", rerunWithoutDryRun()) +} + +// rerunWithoutDryRun reconstructs the current invocation with the --dry-run +// flag removed, so the dry-run tail can suggest the real run verbatim. It works +// from os.Args, which keeps it correct for every command that shares this tail +// (scan, compile, project, test rule reachability). +func rerunWithoutDryRun() string { + args := []string{"opentaint"} + for _, arg := range os.Args[1:] { + if arg == "--dry-run" || strings.HasPrefix(arg, "--dry-run=") { + continue + } + args = append(args, shellQuote(arg)) + } + return strings.Join(args, " ") +} + +// shellQuote single-quotes an argument that would break when copy-pasted into +// a shell. Only arguments made of known-inert characters pass through +// unchanged, so globs, variables, and separators survive the round trip. +func shellQuote(arg string) string { + if arg != "" && !strings.ContainsFunc(arg, shellUnsafe) { + return arg + } + return "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'" +} + +// shellUnsafe reports whether a character can change the meaning of an +// unquoted shell word. The safe set mirrors Python's shlex.quote. +func shellUnsafe(r rune) bool { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + return false + case strings.ContainsRune("_@%+=:,./-", r): + return false + } + return true } diff --git a/cli/cmd/exit_codes.go b/cli/cmd/exit_codes.go new file mode 100644 index 0000000000..b4077aaa4d --- /dev/null +++ b/cli/cmd/exit_codes.go @@ -0,0 +1,45 @@ +package cmd + +import ( + "fmt" + + "github.com/seqra/opentaint/internal/analyzer" +) + +// analyzerExitCodeRows renders the forwarded analyzer exit codes (252-255) as +// help rows. The row text is generated from analyzer.ExitMessage so the +// documented codes can never drift from the runtime failure messages. +func analyzerExitCodeRows() string { + rows := "" + for _, code := range []int{analyzer.ExitException, analyzer.ExitOOM, analyzer.ExitTimeout, analyzer.ExitConfigError} { + rows += fmt.Sprintf("\n %-3d %s", code, analyzer.ExitMessage(code)) + } + return rows +} + +// scanExitCodesHelp renders the exit-codes block for commands that forward +// analyzer exit codes but have no failure gate (test rule reachability). +func scanExitCodesHelp(completedLine string) string { + return `Exit codes: + 0 ` + completedLine + ` + 1 General failure (configuration or infrastructure error)` + analyzerExitCodeRows() +} + +// gateExitCodesHelp renders the exit-codes block for scan, which adds exit +// code 2 for the --error-on-findings gate on top of the forwarded analyzer +// codes. +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() +} + +// testExitCodesHelp renders the exit-codes block for the test-run commands, +// which add exit code 2 for sample failures. +func testExitCodesHelp(passedLine string) string { + return `Exit codes: + 0 ` + passedLine + ` + 1 General failure (configuration or infrastructure error) + 2 One or more tests failed (false negatives, false positives, or skipped samples)` + analyzerExitCodeRows() +} diff --git a/cli/cmd/flag_alias.go b/cli/cmd/flag_alias.go new file mode 100644 index 0000000000..30d86fe233 --- /dev/null +++ b/cli/cmd/flag_alias.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "strings" + + "github.com/spf13/pflag" +) + +// renamedStringArray backs a renamed flag and its deprecated alias. pflag's +// stringArrayValue replaces the bound slice on each flag's own first value, so +// two stock flags bound to one slice silently drop whatever the other spelling +// already collected. Appending unconditionally keeps the values of both +// spellings, in command-line order. +type renamedStringArray struct { + target *[]string +} + +func (v renamedStringArray) String() string { + if len(*v.target) == 0 { + return "" + } + return "[" + strings.Join(*v.target, ",") + "]" +} + +func (v renamedStringArray) Set(s string) error { + *v.target = append(*v.target, s) + return nil +} + +func (v renamedStringArray) Type() string { + return "stringArray" +} + +// addRenamedStringArrayFlag registers a flag under its new name and its +// deprecated old spelling, both accumulating into the same slice. +func addRenamedStringArrayFlag(fs *pflag.FlagSet, target *[]string, name, deprecated, usage string) { + fs.Var(renamedStringArray{target}, name, usage) + fs.Var(renamedStringArray{target}, deprecated, usage) + if err := fs.MarkDeprecated(deprecated, "use --"+name); err != nil { + panic(err) + } +} diff --git a/cli/cmd/flag_alias_test.go b/cli/cmd/flag_alias_test.go new file mode 100644 index 0000000000..f0220ad99f --- /dev/null +++ b/cli/cmd/flag_alias_test.go @@ -0,0 +1,56 @@ +package cmd + +import ( + "reflect" + "testing" + + "github.com/spf13/pflag" +) + +func parseRenamed(t *testing.T, args []string) []string { + t.Helper() + var target []string + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + addRenamedStringArrayFlag(fs, &target, "passthrough-models", "passthrough-approximations", "usage") + if err := fs.Parse(args); err != nil { + t.Fatalf("parse %v: %v", args, err) + } + return target +} + +func TestRenamedFlagAccumulatesAcrossBothSpellings(t *testing.T) { + cases := [][]string{ + {"--passthrough-models", "a.yaml", "--passthrough-approximations", "b.yaml"}, + {"--passthrough-approximations", "a.yaml", "--passthrough-models", "b.yaml"}, + } + for _, args := range cases { + got := parseRenamed(t, args) + if len(got) != 2 { + t.Errorf("args %v: got %v, want both values kept", args, got) + } + } +} + +func TestRenamedFlagKeepsRepeatsInOrder(t *testing.T) { + got := parseRenamed(t, []string{ + "--passthrough-models", "a.yaml", + "--passthrough-models", "b.yaml", + "--passthrough-approximations", "c.yaml", + }) + if want := []string{"a.yaml", "b.yaml", "c.yaml"}; !reflect.DeepEqual(got, want) { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestRenamedFlagAliasIsDeprecatedAndHidden(t *testing.T) { + var target []string + fs := pflag.NewFlagSet("test", pflag.ContinueOnError) + addRenamedStringArrayFlag(fs, &target, "passthrough-models", "passthrough-approximations", "usage") + alias := fs.Lookup("passthrough-approximations") + if alias == nil || alias.Deprecated == "" { + t.Fatal("alias must be registered and marked deprecated") + } + if fs.Lookup("passthrough-models").Deprecated != "" { + t.Error("the new spelling must not be deprecated") + } +} diff --git a/cli/cmd/health.go b/cli/cmd/health.go index 0954bbbf47..4878de84dd 100644 --- a/cli/cmd/health.go +++ b/cli/cmd/health.go @@ -27,15 +27,26 @@ type healthComponent struct { var healthCmd = &cobra.Command{ Use: "health", - Short: "Show resolved dependency paths", - Long: `Show the on-disk paths OpenTaint uses for the autobuilder, analyzer, -built-in rules, and Java runtime. + Short: "Show dependency paths and report missing components", + Long: `Show the paths of the components on this computer. The components are the autobuilder, the analyzer, the built-in rules, and the Java runtime. The command shows if each component is present. -Use --autobuilder, --analyzer, --rules, or --runtime to select components. When -exactly one component is selected, only its path is printed. The command does -not download artifacts except built-in rules, which are fetched on demand. +To select components, use --autobuilder, --analyzer, --rules, or --runtime. With no flag, the command shows all four components. If you select exactly one component, only its path is printed. This output is good for scripts. -The exit code is non-zero when any selected component is missing.`, +Only the built-in rules are downloaded when they are missing. No other component is downloaded. + +If a selected component is missing, the command exits with a code that is not zero. To download the missing components, run "opentaint pull".`, + Example: ` # Show all components and their paths + opentaint health + + # Print only the analyzer JAR path, for a script + opentaint health --analyzer + + # Make sure the Java runtime is present + opentaint health --runtime + + # Recipe: use the built-in rules path in a script + RULES=$(opentaint health --rules) + opentaint scan . --ruleset "$RULES" --ruleset ./extra-rules -o report.sarif`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return runHealth() @@ -105,6 +116,7 @@ func runHealth() error { } sb.Render() if len(missing) > 0 { + out.Suggest("To download the missing components, run:", "opentaint pull") return fmt.Errorf("missing components: %s", strings.Join(missing, ", ")) } return nil diff --git a/cli/cmd/project.go b/cli/cmd/project.go index 60e31d5d20..ed7236c3fe 100644 --- a/cli/cmd/project.go +++ b/cli/cmd/project.go @@ -181,7 +181,8 @@ func (c *JavaAutobuilderConfig) printProjectSummary(config *project.Config) erro projectYamlPath := filepath.Join(c.outputDir, "project.yaml") c.logProjectSummary(projectYamlPath, config) - suggest("To scan project run", utils.BuildScanCommandFromCompile(c.outputDir, c.outputDir)) + out.Successf("Project model generated.") + suggest("To scan the generated model, run:", utils.BuildScanCommandFromCompile(c.outputDir, c.outputDir)) return nil } @@ -218,16 +219,28 @@ var ( var projectCmd = &cobra.Command{ Use: "project", - Short: "Create a project model directory containing a project.yaml configuration from precompiled JARs or classes", - Long: `Create a project model directory containing a project.yaml configuration from precompiled JARs or classes. + Short: "Create a project model from precompiled JARs or classes", + Long: `Create a project model from JARs or classes that are already compiled. No build occurs. OpenTaint examines the classpath, finds the modules and dependencies, and writes a project.yaml file. -This command generates a project model, automatically detecting dependencies and project structure. -Additional packages have to be specified to enhance the generated configuration. +Use this command when you have compiled artifacts but no build. To build a model from sources, use "opentaint compile". -Examples: - # Classpath analysis - opentaint project --output ./project-model --source-root /path/to/source \ - --classpath /path/to/app.jar --package com.example`, +All inputs are flags. Give the source path with --source-root. Give the compiled classes or JARs with --classpath. Give the packages to include with --package. Add more JAR files with --dependency. + +Use --output to set the project model directory. This directory must not exist before the command runs. + +Before the first run, run "opentaint pull" one time. To scan the model, use "opentaint scan --project-model".`, + Example: ` # Create a project model from a compiled JAR + opentaint project --source-root ./src --classpath ./app.jar --package com.example -o ./model + + # Add more dependency JARs to the classpath + opentaint project --source-root ./src --classpath ./app.jar --dependency ./lib.jar --package com.example -o ./model + + # Make sure the inputs are correct, without a model + opentaint project --source-root ./src --classpath ./app.jar --package com.example -o ./model --dry-run + + # Recipe: scan a vendor JAR that you cannot build + opentaint project --source-root ./src --classpath ./vendor-app.jar --package com.vendor -o ./model + opentaint scan --project-model ./model -o report.sarif`, Run: func(cmd *cobra.Command, args []string) { config := NewJavaAutobuilder(). WithOutputDir(OutputDir). @@ -269,12 +282,12 @@ Examples: if DryRunProject { failOnInvalidInputs(config.validate) - runDryRun("Project generation") + runDryRun("project-model generation") return } if err := config.Execute(); err != nil { - out.Fatalf("Failed to generate project configuration: %s", err) + failf("Failed to generate project configuration: %s", err) } }, } @@ -282,15 +295,15 @@ Examples: func init() { rootCmd.AddCommand(projectCmd) - projectCmd.Flags().StringVarP(&OutputDir, "output", "o", "", "Output directory for project.yaml") + projectCmd.Flags().StringVarP(&OutputDir, "output", "o", "", "Directory to write the generated project model (required, must not exist)") _ = projectCmd.MarkFlagRequired("output") - projectCmd.Flags().StringVar(&SourceRoot, "source-root", "", "Source root directory") + projectCmd.Flags().StringVar(&SourceRoot, "source-root", "", "Path to the project source root") _ = projectCmd.MarkFlagRequired("source-root") - projectCmd.Flags().StringArrayVar(&Dependencies, "dependency", []string{}, "Project dependencies (JAR files)") - projectCmd.Flags().StringArrayVar(&Packages, "package", []string{}, "Project packages") + projectCmd.Flags().StringArrayVar(&Dependencies, "dependency", []string{}, "Additional dependency JAR file on the compile classpath (repeatable)") + projectCmd.Flags().StringArrayVar(&Packages, "package", []string{}, "Package to include in the generated model (repeatable)") _ = projectCmd.MarkFlagRequired("package") - projectCmd.Flags().StringArrayVar(&Classpaths, "classpath", []string{}, "Classpath entries (classes or JAR files)") + projectCmd.Flags().StringArrayVar(&Classpaths, "classpath", []string{}, "Classpath entry: a compiled classes directory or a JAR file (repeatable)") _ = projectCmd.MarkFlagRequired("classpath") - projectCmd.Flags().BoolVar(&DryRunProject, "dry-run", false, "Validate inputs and show what would run without generating project model") + projectCmd.Flags().BoolVar(&DryRunProject, "dry-run", false, "Validate inputs and show what would run without generating the project model") projectCmd.Flags().StringVar(&ProjectLogFile, "log-file", "", "Path to the log file (default: /logs/.log)") } diff --git a/cli/cmd/prune.go b/cli/cmd/prune.go index a4da4e6b84..aab398e193 100644 --- a/cli/cmd/prune.go +++ b/cli/cmd/prune.go @@ -57,24 +57,27 @@ func resolveCategories() (utils.PruneCategory, error) { var pruneCmd = &cobra.Command{ Use: "prune", - Short: "Remove stale downloaded artifacts from ~/.opentaint", - Long: `Remove stale downloaded artifacts from the local cache (~/.opentaint). - -Identifies artifacts that are no longer needed: -- Old versions of analyzer JARs, autobuilder JARs, and rules -- Downloaded JDK/JRE versions that don't match the current version -- Cached project models - -Use category flags to prune selectively: - --artifacts Stale analyzer and autobuilder JARs - --rules Stale rules directories - --jdk Old JDK/JRE versions - --models Cached project models - --logs Project log files - --install Install-tier lib and JRE artifacts (requires re-download) - -Without category flags, prunes: artifacts + rules + jdk + models. -With --all: prunes everything including logs and install-tier.`, + Short: "Remove old downloaded artifacts from the cache", + Long: `Remove old downloaded artifacts from the local cache (~/.opentaint). The command removes analyzer and autobuilder JARs that a newer version replaced, old rules, JDK and JRE versions that do not match the configuration, and cached project models. + +To select categories, use --artifacts, --rules, --jdk, --models, --logs, or --install. With no category flag, the command removes artifacts, rules, jdk, and models. The --all flag removes everything, with logs and install-tier artifacts included. Do not give --all together with a category flag. + +To see the deletions without a removal, use --dry-run. To skip the confirmation prompt, use --yes. To download the toolchain again, run "opentaint pull".`, + Example: ` # Remove the default categories, with a confirmation prompt + opentaint prune + + # Remove only the old JDK and JRE versions + opentaint prune --jdk + + # Remove everything, with logs and install-tier artifacts included + opentaint prune --all + + # See what the command would delete, without a deletion + opentaint prune --dry-run + + # Recipe: get disk space back, keep the current toolchain + opentaint prune --dry-run + opentaint prune --yes`, Run: func(cmd *cobra.Command, args []string) { categories, err := resolveCategories() if err != nil { @@ -84,23 +87,23 @@ With --all: prunes everything including logs and install-tier.`, // Acquire global prune lock pruneLockPath, err := utils.PruneLockPath() if err != nil { - out.Fatalf("Failed to resolve prune lock path: %s", err) + failf("Failed to resolve prune lock path: %s", err) } pruneLock, err := utils.TryLockExclusive(pruneLockPath, utils.LockMeta{ PID: os.Getpid(), Command: "prune", }) if err == utils.ErrLocked { - out.Fatal("Another prune is already running") + failWith(1, "Another prune is already running") } if err != nil { - out.Fatalf("Failed to acquire prune lock: %s", err) + failf("Failed to acquire prune lock: %s", err) } defer pruneLock.Unlock() result, err := utils.ScanForStaleArtifacts(categories) if err != nil { - out.Fatalf("Failed to scan for stale artifacts: %s", err) + failf("Failed to scan for stale artifacts: %s", err) } // Display skipped projects @@ -130,22 +133,27 @@ With --all: prunes everything including logs and install-tier.`, Render() if pruneDryRun { - out.Print("Dry run mode. No files were deleted.") + out.Print("Dry run complete. No files were deleted.") + suggest("To delete these artifacts, run:", withFlag(rerunWithoutDryRun(), "--yes")) return } if !pruneYes { if !out.Confirm("Delete these artifacts?", false) { out.Print("Prune cancelled.") + suggest("To prune without confirming, run:", withFlag(rerunWithoutDryRun(), "--yes")) return } } if err := utils.DeleteArtifacts(result.Stale); err != nil { - out.Fatalf("Failed to delete artifacts: %s", err) + failf("Failed to delete artifacts: %s", err) } out.Successf("Pruned %d items, freed %s", result.TotalCount, output.FormatSize(result.TotalSize)) + if pruneInstall || pruneAll { + suggest("To restore the removed components, run:", "opentaint pull") + } }, } diff --git a/cli/cmd/pull.go b/cli/cmd/pull.go index ff95db860f..503ab24428 100644 --- a/cli/cmd/pull.go +++ b/cli/cmd/pull.go @@ -17,15 +17,21 @@ import ( var pullCmd = &cobra.Command{ Use: "pull", - Short: "Download autobuilder, analyzer binaries, rules and Java runtime", - Long: `Download all necessary binaries and assets: -- OpenTaint autobuilder JAR -- OpenTaint analyzer JAR -- OpenTaint rules archive -- Java runtime (Temurin JRE) - -This prepares the environment with all required dependencies for offline analysis. -When bundled artifacts are present (from a release archive), they will be used directly.`, + Short: "Download the analysis toolchain and Java runtime", + Long: `Download the toolchain into the local cache. The toolchain contains the analyzer, the autobuilder, the built-in rules, and a Java runtime. After the download, OpenTaint can build and scan projects without network access. + +If a release archive supplied bundled artifacts, OpenTaint uses them. They are not downloaded again. + +Run "opentaint pull" one time before your first scan. To remove old downloads, use "opentaint prune".`, + Example: ` # Download the toolchain before the first scan + opentaint pull + + # Download a different Java runtime version + opentaint pull --java-version 17 + + # Recipe: prepare a machine that will have no network access + opentaint pull + opentaint health`, Run: func(cmd *cobra.Command, args []string) { out.Section("OpenTaint Pull"). Field("Autobuilder", globals.Config.Autobuilder.Version). @@ -40,7 +46,7 @@ When bundled artifacts are present (from a release archive), they will be used d installCurrent := utils.IsInstallCurrent() if !installCurrent { if err := utils.CleanInstallDir(); err != nil { - out.Fatalf("Failed to clean install directory: %s", err) + failf("Failed to clean install directory: %s", err) } } @@ -50,26 +56,29 @@ When bundled artifacts are present (from a release archive), they will be used d for _, spec := range artifacts { node, err := downloadArtifact(spec, installNextToBinary, installCurrent) if err != nil { - out.Fatalf("Failed to download %s: %s", spec.Kind(), err) + failf("Failed to download %s: %s", spec.Kind(), err) } summaryNodes = append(summaryNodes, node) } javaNode, err := downloadJava(installNextToBinary, installCurrent) if err != nil { - out.Fatalf("Failed to download Java: %s", err) + failf("Failed to download Java: %s", err) } summaryNodes = append(summaryNodes, javaNode) // Write version marker after all downloads succeed if err := utils.WriteInstallVersionMarker(); err != nil { - out.Fatalf("Failed to write install version marker: %s", err) + failf("Failed to write install version marker: %s", err) } out.Blank() out.Section("Pull Summary"). Child(summaryNodes...). Render() + + out.Successf("Pull completed.") + suggest("To scan your project, run:", "opentaint scan .") }, } diff --git a/cli/cmd/rerun_test.go b/cli/cmd/rerun_test.go new file mode 100644 index 0000000000..2d2291f24a --- /dev/null +++ b/cli/cmd/rerun_test.go @@ -0,0 +1,143 @@ +package cmd + +import ( + "os" + "testing" + + "github.com/seqra/opentaint/internal/analyzer" +) + +func withOSArgs(t *testing.T, args []string) { + t.Helper() + saved := os.Args + os.Args = args + t.Cleanup(func() { os.Args = saved }) +} + +func TestRerunWithoutDryRunStripsFlag(t *testing.T) { + withOSArgs(t, []string{"/usr/bin/opentaint", "scan", "./proj", "--dry-run", "--color", "never"}) + got := rerunWithoutDryRun() + want := "opentaint scan ./proj --color never" + if got != want { + t.Fatalf("rerunWithoutDryRun() = %q, want %q", got, want) + } +} + +func TestRerunWithoutDryRunStripsEqualsForm(t *testing.T) { + withOSArgs(t, []string{"opentaint", "compile", ".", "--dry-run=true", "-o", "./model"}) + got := rerunWithoutDryRun() + want := "opentaint compile . -o ./model" + if got != want { + t.Fatalf("rerunWithoutDryRun() = %q, want %q", got, want) + } +} + +func TestRerunWithoutDryRunQuotesSpaces(t *testing.T) { + withOSArgs(t, []string{"opentaint", "scan", "my project", "--dry-run"}) + got := rerunWithoutDryRun() + want := "opentaint scan 'my project'" + if got != want { + t.Fatalf("rerunWithoutDryRun() = %q, want %q", got, want) + } +} + +func TestWithFlag(t *testing.T) { + if got := withFlag("opentaint prune", "--yes"); got != "opentaint prune --yes" { + t.Fatalf("withFlag append = %q", got) + } + if got := withFlag("opentaint prune --yes", "--yes"); got != "opentaint prune --yes" { + t.Fatalf("withFlag no-op = %q", got) + } +} + +func TestRerunReplacingFlagValueForm(t *testing.T) { + withOSArgs(t, []string{"opentaint", "scan", ".", "--max-memory", "8G"}) + got := rerunReplacingFlag("16G", "--max-memory") + want := "opentaint scan . --max-memory 16G" + if got != want { + t.Fatalf("rerunReplacingFlag() = %q, want %q", got, want) + } +} + +func TestRerunReplacingFlagAliasAndEqualsForm(t *testing.T) { + withOSArgs(t, []string{"opentaint", "scan", ".", "-t", "15m", "--timeout=10m"}) + got := rerunReplacingFlag("30m0s", "--timeout", "-t") + want := "opentaint scan . --timeout 30m0s" + if got != want { + t.Fatalf("rerunReplacingFlag() = %q, want %q", got, want) + } +} + +func TestRerunReplacingFlagAppendsWhenAbsent(t *testing.T) { + withOSArgs(t, []string{"opentaint", "test", "rule", "run", "./model"}) + got := rerunReplacingFlag("16G", "--max-memory") + want := "opentaint test rule run ./model --max-memory 16G" + if got != want { + t.Fatalf("rerunReplacingFlag() = %q, want %q", got, want) + } +} + +func TestDoubleMemory(t *testing.T) { + cases := map[string]string{ + "8G": "16G", + "1024m": "2048m", + "83886080": "167772160", + "weird": "16G", + "": "16G", + } + for in, want := range cases { + if got := doubleMemory(in); got != want { + t.Fatalf("doubleMemory(%q) = %q, want %q", in, got, want) + } + } +} + +func TestRetrySuggestion(t *testing.T) { + withOSArgs(t, []string{"opentaint", "scan", ".", "--max-memory", "8G"}) + + oom, ok := retrySuggestion(analyzer.ExitOOM, 900e9, "8G") + if !ok || oom.Description != "To retry with more memory, run:" || oom.Command != "opentaint scan . --max-memory 16G" { + t.Fatalf("OOM retry = %+v ok=%t", oom, ok) + } + + timeoutRetry, ok := retrySuggestion(analyzer.ExitTimeout, 900e9, "8G") + if !ok || timeoutRetry.Description != "To retry with a longer timeout, run:" { + t.Fatalf("timeout retry = %+v ok=%t", timeoutRetry, ok) + } + want := "opentaint scan . --max-memory 8G --timeout 30m0s" + if timeoutRetry.Command != want { + t.Fatalf("timeout retry command = %q, want %q", timeoutRetry.Command, want) + } + + if _, ok := retrySuggestion(analyzer.ExitException, 900e9, "8G"); ok { + t.Fatal("exception exit code must not produce a retry suggestion") + } +} + +func TestShellQuoteLeavesInertArgumentsAlone(t *testing.T) { + for _, arg := range []string{"opentaint", "report.sarif", "--max-memory=8G", "path/to/file.yaml", "a-b_c.d,e:f@g%h+i"} { + if got := shellQuote(arg); got != arg { + t.Errorf("shellQuote(%q) = %q, want unchanged", arg, got) + } + } +} + +func TestShellQuoteQuotesShellMetacharacters(t *testing.T) { + cases := map[string]string{ + "demo-rule-*": "'demo-rule-*'", + "java/security/**": "'java/security/**'", + "$HOME": "'$HOME'", + "a;b": "'a;b'", + "a|b": "'a|b'", + "a b": "'a b'", + "it's": `'it'\''s'`, + "": "''", + "a>b": "'a>b'", + "`cmd`": "'`cmd`'", + } + for in, want := range cases { + if got := shellQuote(in); got != want { + t.Errorf("shellQuote(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 214028ec8b..22fc99df25 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -33,9 +33,14 @@ var updateHintCh = make(chan string, 1) // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ - Use: "opentaint", - Short: "OpenTaint Analyzer", - Long: `OpenTaint is a CLI tool that analyzes Java and Kotlin projects to find vulnerabilities`, + Use: "opentaint", + Short: "Find vulnerabilities in source code with taint analysis", + Long: `OpenTaint finds vulnerabilities in your code. It follows tainted data from untrusted sources to dangerous sinks. Java and Kotlin projects are supported. + +Quick start: + 1. Run "opentaint pull" one time. This downloads the toolchain. + 2. Run "opentaint scan ." to scan a project. + 3. Run "opentaint summary --show-findings" to read the findings.`, SilenceErrors: true, SilenceUsage: true, @@ -177,6 +182,7 @@ func bindScanFlags(cmd *cobra.Command) { "scan.ruleset": "ruleset", "scan.max_memory": "max-memory", "scan.code_flow_limit": "code-flow-limit", + "scan.baseline": "baseline", } { if f := cmd.Flags().Lookup(name); f != nil { _ = viper.BindPFlag(key, f) diff --git a/cli/cmd/scan.go b/cli/cmd/scan.go index 9750dcdeb9..3842c84359 100644 --- a/cli/cmd/scan.go +++ b/cli/cmd/scan.go @@ -4,17 +4,20 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" "github.com/seqra/opentaint/internal/analyzer" "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" "github.com/seqra/opentaint/internal/utils/project" "github.com/spf13/cobra" + "github.com/spf13/viper" "github.com/seqra/opentaint/internal/globals" "github.com/seqra/opentaint/internal/output" @@ -34,10 +37,16 @@ type ScanConfig struct { Recompile bool LogFile string RuleID []string + ExcludeRuleID []string PassthroughApproximations []string DataflowApproximations []string TrackExternalMethods bool + Baseline string + WriteBaselineState bool + ErrorOnFindings bool + ErrorOnSeverity []string + DebugFactReachabilitySarif bool DebugRunAnalysisOnSelectedEntryPoints string ExpandRuleRefs bool @@ -72,28 +81,63 @@ func (p scanPlan) title() string { // scanCmd represents the scan command var scanCmd = &cobra.Command{ Use: "scan [source-path]", - Short: "Scan your Java or Kotlin project", + Short: "Scan a project for vulnerabilities", Args: cobra.MaximumNArgs(1), - Long: `This command automatically detects Java/Kotlin build systems, builds the project, and analyzes it + Long: `Scan a project and find vulnerabilities. OpenTaint finds the build system, builds the project, and does a taint analysis. + +The source-path argument is the project root. It is optional. The default is the current directory. To scan a project model that is already compiled, use --project-model. Do not give source-path and --project-model together. + +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. + +To compare with a previous report, use --baseline. The scan then keeps the suppressions from the baseline. With --error-on-findings, only new findings that are not suppressed cause a failure. To record decisions about findings, use "opentaint triage". + +Before your first scan, run "opentaint pull" one time. To read a report again later, use "opentaint summary". + +` + gateExitCodesHelp("Scan completed"), + Example: ` # Scan the current directory with the built-in rules + opentaint scan . + + # Scan a project and write the report to a known path + opentaint scan ./my-app -o report.sarif + + # Scan a project model that is already compiled + opentaint scan --project-model ./model -o report.sarif + + # Use your own rules and show only errors + opentaint scan . --ruleset ./rules --severity error -o report.sarif + + # Fail CI only on findings that are new since the baseline + opentaint scan . --baseline main.sarif --error-on-findings -o report.sarif + + # Give a large project more time and memory + opentaint scan . --timeout 30m --max-memory 16G -o report.sarif -Arguments: - source-path - Path to the project sources (default: current directory) + # Recipe: first scan on a new machine + opentaint pull + opentaint scan . -o report.sarif + opentaint summary report.sarif --show-findings -Use --project-model to scan a pre-compiled project model instead of compiling from sources. -`, + # Recipe: build one time, then scan many times + opentaint compile ./my-app -o ./model + opentaint scan --project-model ./model -o report.sarif + + # Recipe: a CI gate that fails only on new findings + opentaint scan . --baseline baselines/main.sarif --error-on-findings -o report.sarif + opentaint summary report.sarif --baseline baselines/main.sarif --baseline-state new --show-findings`, Annotations: map[string]string{"PrintConfig": "true"}, Run: func(cmd *cobra.Command, args []string) { if scanFlags.DebugRunAnalysisOnSelectedEntryPoints != "" { out.Warn("on Spring projects this method is added to the auto-discovered entry points, not used to restrict them") } - runScan(cmd, prepareScanConfig(scanFlags, args)) + runScan(cmd, prepareScanConfig(cmd, scanFlags, args)) }, } -func prepareScanConfig(cfg ScanConfig, args []string) ScanConfig { +func prepareScanConfig(cmd *cobra.Command, cfg ScanConfig, args []string) ScanConfig { + cfg.Baseline = configuredScanBaseline(cmd, cfg.Baseline) if len(args) > 0 && cfg.ProjectModelPath != "" { out.Error("Cannot use both a source path argument and --project-model flag") - suggest("Use either a source path or --project-model", + suggest("Use either a source path or --project-model:", utils.NewScanCommand("").Build()+"\n "+utils.NewScanCommand("").WithProjectModel("").Build()) os.Exit(1) } @@ -108,6 +152,33 @@ func prepareScanConfig(cfg ScanConfig, args []string) ScanConfig { return cfg } +// configuredScanBaseline applies scan.baseline when --baseline was not given. +// A path written in a config file is relative to that file; a flag or +// OPENTAINT_SCAN_BASELINE value remains relative to the process working +// directory. This keeps checked-in project configs relocatable without +// changing the established meaning of command-line paths. +func configuredScanBaseline(cmd *cobra.Command, flagValue string) string { + if flag := cmd.Flags().Lookup("baseline"); flag != nil && flag.Changed { + return flagValue + } + + value := globals.Config.Scan.Baseline + if value == "" || filepath.IsAbs(value) { + return value + } + if _, fromEnvironment := os.LookupEnv("OPENTAINT_SCAN_BASELINE"); fromEnvironment { + return value + } + if viper.ConfigFileUsed() == "" { + return value + } + configPath, err := filepath.Abs(viper.ConfigFileUsed()) + if err != nil { + return value + } + return filepath.Clean(filepath.Join(filepath.Dir(configPath), value)) +} + func init() { rootCmd.AddCommand(scanCmd) addScanFlags(scanCmd) @@ -122,30 +193,35 @@ func addEntryPointsFlag(cmd *cobra.Command) { } func addRuleIDFlag(cmd *cobra.Command) { - cmd.Flags().StringArrayVar(&scanFlags.RuleID, "rule-id", nil, "Filter active rules by ID (repeatable)") + 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) { - cmd.Flags().DurationVarP(&globals.Config.Scan.Timeout, "timeout", "t", 900*time.Second, "Timeout for analysis") + 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"}, "YAML rules file, directory of YAML rules files ending in .yml or .yaml, or `builtin` to scan with built-in rules") + 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)") - cmd.Flags().BoolVar(&scanFlags.SemgrepCompatibilitySarif, "semgrep-compatibility-sarif", true, "Use Semgrep compatible ruleId") - cmd.Flags().StringVarP(&scanFlags.SarifReportPath, "output", "o", "", "Path to the SARIF-report output file") + cmd.Flags().BoolVar(&scanFlags.SemgrepCompatibilitySarif, "semgrep-compatibility-sarif", true, "Use Semgrep-compatible rule IDs in the SARIF report") + cmd.Flags().StringVarP(&scanFlags.SarifReportPath, "output", "o", "", "Path to write the SARIF report") - cmd.Flags().StringArrayVar(&scanFlags.Severity, "severity", []string{"warning", "error"}, "Report findings only from rules matching the supplied severity level. By default only warning and error rules are run (note, warning, error)") - cmd.Flags().StringVar(&globals.Config.Scan.MaxMemory, "max-memory", "8G", "Maximum memory for the analyzer (e.g., 1024m, 8G, 81920k, 83886080)") + cmd.Flags().StringArrayVar(&scanFlags.Severity, "severity", []string{"warning", "error"}, "Run only rules at these severity levels: note, warning, error (repeatable)") + cmd.Flags().StringVar(&globals.Config.Scan.MaxMemory, "max-memory", "8G", "Maximum analyzer heap size (e.g. 8G, 1024m)") cmd.Flags().Int64Var(&globals.Config.Scan.CodeFlowLimit, "code-flow-limit", 0, "Maximum number of code flows to include in the report (0 = unlimited)") cmd.Flags().BoolVar(&scanFlags.DryRun, "dry-run", false, "Validate inputs and show what would run without compiling or scanning") cmd.Flags().BoolVar(&scanFlags.Recompile, "recompile", false, "Force recompilation even if a cached project model exists") cmd.Flags().StringVar(&scanFlags.ProjectModelPath, "project-model", "", "Path to a pre-compiled project model (skips compilation)") cmd.Flags().StringVar(&scanFlags.LogFile, "log-file", "", "Path to the log file (default: /logs/.log)") - cmd.Flags().StringArrayVar(&scanFlags.PassthroughApproximations, "passthrough-approximations", nil, "Pass-through approximation YAML file or directory (repeatable)") + addRenamedStringArrayFlag(cmd.Flags(), &scanFlags.PassthroughApproximations, "passthrough-models", "passthrough-approximations", "Pass-through models: a YAML file or a directory of them (repeatable)") - cmd.Flags().StringArrayVar(&scanFlags.DataflowApproximations, "dataflow-approximations", nil, "Dataflow approximation class directory or Java source directory (repeatable)") + addRenamedStringArrayFlag(cmd.Flags(), &scanFlags.DataflowApproximations, "java-models", "dataflow-approximations", "Java dataflow models: a compiled class directory or a Java source directory (repeatable)") cmd.Flags().BoolVar(&scanFlags.TrackExternalMethods, "track-external-methods", false, "Write external-method coverage files next to the SARIF report") + + addBaselineFlags(cmd, &scanFlags.Baseline) + cmd.Flags().BoolVar(&scanFlags.WriteBaselineState, "write-baseline-state", false, "Persist result.baselineState and run.baselineGuid into the output report (needs --baseline)") + addGateFlags(cmd, &scanFlags.ErrorOnFindings, &scanFlags.ErrorOnSeverity) } // currentScanBuilder returns a builder pre-populated with the user's current scan flags. @@ -156,15 +232,90 @@ func currentScanBuilder(cfg ScanConfig, sourcePath string) *utils.OpentaintComma WithRuleset(cfg.Ruleset). WithSemgrepCompatibility(cfg.SemgrepCompatibilitySarif). WithRuleID(cfg.RuleID). + WithExcludeRuleID(cfg.ExcludeRuleID). WithPassthroughApproximations(cfg.PassthroughApproximations). WithDataflowApproximations(cfg.DataflowApproximations). - WithTrackExternalMethods(cfg.TrackExternalMethods) + WithTrackExternalMethods(cfg.TrackExternalMethods). + WithBaseline(cfg.Baseline). + WithWriteBaselineState(cfg.WriteBaselineState). + WithErrorOnFindings(cfg.ErrorOnFindings). + WithErrorOnSeverity(cfg.ErrorOnSeverity) if !isDefaultSeverity(cfg.Severity) { b.WithSeverity(cfg.Severity) } 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" } @@ -172,7 +323,7 @@ func isDefaultSeverity(sev []string) bool { // dockerScanSuggestion builds the "try Docker-based scan" fallback hint. func dockerScanSuggestion(cfg ScanConfig, projectRoot, sarifReportPath string) output.Suggestion { return output.Suggestion{ - Description: dockerFallbackHintPrefix + "scan:", + Description: "If the required Java is missing, set JAVA_HOME or scan in a container instead:", Command: utils.BuildScanCommandWithDocker(currentScanBuilder(cfg, ""), projectRoot, sarifReportPath, cfg.Ruleset), } } @@ -190,7 +341,7 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { if err := validation.ValidateSourceProject(absUserProjectRoot); err != nil { if validation.IsProjectModel(absUserProjectRoot) { out.ErrorErr(err) - suggest("Use --project-model to scan a pre-compiled model", currentScanBuilder(cfg, "").WithProjectModel(absUserProjectRoot).Build()) + suggest("Use --project-model to scan a pre-compiled model:", currentScanBuilder(cfg, "").WithProjectModel(absUserProjectRoot).Build()) os.Exit(1) } out.FatalErr(err) @@ -237,6 +388,24 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { absSarifReportPath = utils.DefaultSarifReportPath(absProjectModelPath) } + // Validate the triage flags before compiling: a typo in --baseline should + // not surface only after a fifteen-minute analysis. + gateSeverities, err := triage.ParseGateSeverities(cfg.ErrorOnSeverity) + if err != nil { + out.Fatalf("%s", err) + } + if cfg.WriteBaselineState && cfg.Baseline == "" { + out.Fatalf("--write-baseline-state needs a --baseline to compare against") + } + var baseline *sarif.Report + var absBaselinePath string + if cfg.Baseline != "" { + baseline, absBaselinePath = loadBaselineOrExit(cfg.Baseline, absSarifReportPath) + if err := sarif.CheckBaselineIdentity(baseline); err != nil { + out.Fatalf("%s", err) + } + } + sarifReportName := filepath.Base(absSarifReportPath) localVersion := utils.ArtifactDisplayVersion(globals.ArtifactByKind("analyzer")) @@ -282,11 +451,6 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { out.Fatalf("Input validation failed: %s", err) } - if cfg.DryRun { - runDryRun("Compilation and analysis") - return - } - hasBuiltin := false for _, ruleSetPath := range absRuleSetPaths { if ruleSetPath.Builtin { @@ -294,27 +458,47 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { break } } + + // Rule selections resolve against the rule files on disk, so the built-in + // rules must be fetched before an active selection is resolved — a fresh + // install has not downloaded them yet. + if hasBuiltin && ruleSelectionActive(cfg) { + if _, err := utils.EnsureRulesPath(out); err != nil { + failf("Failed to prepare built-in rules: %s", err) + } + } + + // Resolve the active rules before the dry-run bail-out, so that a bad + // rules.only/rules.exclude list is reported by --dry-run and never after a + // full compile. + resolvedRules := resolveRuleIDs(cfg, absRuleSetPaths) + + if cfg.DryRun { + runDryRun("the build and scan") + return + } + if hasBuiltin { if _, err := utils.EnsureRulesPath(out); err != nil { - out.Fatalf("Failed to prepare built-in rules: %s", err) + failf("Failed to prepare built-in rules: %s", err) } } if plan.needsCompilation { autobuilderJarPath, err := ensureAutobuilderAvailable() if err != nil { - out.Fatalf("Native compile preparation failed: %s", err) + failf("Native compile preparation failed: %s", err) } compileJavaRunner := newAutobuilderJavaRunner() if _, err := compileJavaRunner.EnsureJava(); err != nil { - out.Fatalf("Failed to resolve Java for compilation: %s", err) + failf("Failed to resolve Java for compilation: %s", err) } // Wipe any residue from a prior crashed compile before writing new output. if plan.projectCachePath != "" { if err := os.RemoveAll(plan.absProjectModel); err != nil { - out.Fatalf("Failed to prepare cache directory: %s", err) + failf("Failed to prepare cache directory: %s", err) } } @@ -333,7 +517,7 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { if plan.projectCachePath != "" { if err := utils.MarkCompileComplete(plan.projectCachePath); err != nil { _ = os.RemoveAll(plan.absProjectModel) - out.Fatalf("Failed to mark model complete: %s", err) + failf("Failed to mark model complete: %s", err) } if err := plan.cacheLock.Downgrade(); err != nil { output.LogInfof("Cache lock downgrade failed, continuing under exclusive: %v", err) @@ -344,7 +528,7 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { } if err := utils.EnsureParentDir(absSarifReportPath); err != nil { - out.Fatalf("Failed to create output directory: %s", err) + failf("Failed to create output directory: %s", err) } // Update builder with native paths for native execution @@ -373,17 +557,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) @@ -397,16 +576,16 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { analyzerJarPath, err := ensureAnalyzerAvailable() if err != nil { - out.Fatalf("Native scan preparation failed: %s", err) + failf("Native scan preparation failed: %s", err) } nativeBuilder.SetJarPath(analyzerJarPath) - // Process --dataflow-approximations: auto-compile .java sources if needed + // Process --java-models: auto-compile .java sources if needed addDataflowApproximations(nativeBuilder, cfg.DataflowApproximations, analyzerJarPath, absProjectModelPath) analyzerJavaRunner := newAnalyzerJavaRunner() if _, err := analyzerJavaRunner.EnsureJava(); err != nil { - out.Fatalf("Failed to resolve Java for analyzer: %s", err) + failf("Failed to resolve Java for analyzer: %s", err) } var analyzerFail *analyzer.Error @@ -461,21 +640,90 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { var suggestions []output.Suggestion if analyzerFail != nil { suggestions = appendLogSuggestion(suggestions) + if retry, ok := retrySuggestion(analyzerFail.ExitCode, globals.Config.Scan.Timeout, globals.Config.Scan.MaxMemory); ok { + suggestions = append(suggestions, retry) + } } + var view *sarif.TriageView if report != nil { + view = triageScanReport(cfg, report, absSarifReportPath, baseline, absBaselinePath) // Scan does not expose summary's filter/group flags, so pass zero values: // no filtering, default group dimension, first-flow code-flow selection. - printSarifSummary(report, absSarifReportPath, sarif.Filters{}, sarif.ListingOptions{MaxNestingLevel: -1}) - suggestions = append(suggestions, output.Suggestion{ - Description: "To view findings run", - Command: utils.NewSummaryCommand(absSarifReportPath).WithShowFindings().Build(), - }) + printSarifSummary(report, absSarifReportPath, sarif.Filters{}, sarif.ListingOptions{MaxNestingLevel: -1}, view, false) + switch { + case cfg.DebugFactReachabilitySarif: + if analyzerFail == nil { + out.Successf("Reachability analysis completed.") + } + // The reachability report is the command's deliverable. Point at it, + // never at the main SARIF. + reachabilityReportPath := filepath.Join(filepath.Dir(absSarifReportPath), "debug-ifds-fact-reachability.sarif") + suggestions = append(suggestions, output.Suggestion{ + Description: "To view the reachability report, run:", + Command: utils.NewSummaryCommand(reachabilityReportPath).WithShowFindings().Build(), + }) + case sarif.GenerateSummary(report).TotalFindings > 0: + if analyzerFail == nil { + out.Successf("Scan completed.") + } + suggestions = append(suggestions, output.Suggestion{ + Description: "To view the findings, run:", + Command: utils.NewSummaryCommand(absSarifReportPath).WithShowFindings().Build(), + }) + case analyzerFail == nil: + out.Successf("Scan completed. No vulnerabilities found at %s severity.", strings.Join(cfg.Severity, " or ")) + if isDefaultSeverity(cfg.Severity) { + suggestions = append(suggestions, output.Suggestion{ + Description: "To also check note-level rules, run:", + Command: noteSeverityScanCommand(cfg), + }) + } + } } out.Suggestions(suggestions...) if analyzerFail != nil { os.Exit(analyzerFail.ExitCode) } + if report != nil { + exitOnGate(triage.Gate{Enabled: cfg.ErrorOnFindings, Severities: gateSeverities}, report, view) + } +} + +// triageScanReport applies the baseline and any inherited suppressions to the +// report the analyzer just wrote, rewriting the file when that changed it. With +// no baseline and no annotation requested, the report is left exactly as the +// analyzer produced it. The baseline was loaded (and validated) before the +// compile step, so a bad path fails fast and the file is read only once. +func triageScanReport(cfg ScanConfig, report *sarif.Report, absSarifReportPath string, baseline *sarif.Report, absBaselinePath string) *sarif.TriageView { + outcome, err := triage.Apply(report, triage.Options{ + WriteBaselineState: cfg.WriteBaselineState, + Baseline: baseline, + BaselinePath: absBaselinePath, + }) + if err != nil { + out.Fatalf("%s", err) + } + if outcome.Changed { + if err := sarif.SaveReport(report, absSarifReportPath); err != nil { + out.Fatalf("Failed to write report: %s", err) + } + } + return outcome.View +} + +// noteSeverityScanCommand builds the follow-up command for a clean scan: the +// same invocation narrowed to the note-level rules the default run skips. +func noteSeverityScanCommand(cfg ScanConfig) string { + sourcePath := cfg.UserProjectPath + if cfg.ProjectModelPath != "" { + sourcePath = "" + } + b := currentScanBuilder(cfg, sourcePath).WithSeverity([]string{"note"}) + if cfg.ProjectModelPath != "" { + b.WithProjectModel(cfg.ProjectModelPath) + } + return b.Build() } func resolveScanPlan(cfg ScanConfig, absUserProjectRoot string) scanPlan { @@ -537,7 +785,7 @@ func resolveScanPlan(cfg ScanConfig, absUserProjectRoot string) scanPlan { } else { out.Error("Another scan is currently analyzing this project") } - suggest("To scan an existing model instead", utils.NewScanCommand("").WithProjectModel("").Build()) + suggest("To scan an existing model instead, run:", utils.NewScanCommand("").WithProjectModel("").Build()) os.Exit(1) } if lockErr != nil { diff --git a/cli/cmd/suggest.go b/cli/cmd/suggest.go index 9508a86cb0..416c76e694 100644 --- a/cli/cmd/suggest.go +++ b/cli/cmd/suggest.go @@ -3,20 +3,97 @@ package cmd import ( "fmt" "os" + "strconv" + "strings" + "time" + "github.com/seqra/opentaint/internal/analyzer" "github.com/seqra/opentaint/internal/globals" "github.com/seqra/opentaint/internal/output" ) -// dockerFallbackHintPrefix is the shared lead-in for the Docker-based fallback -// hints emitted when native compilation can't find a suitable Java. compile and -// scan complete it with their respective "compilation:" / "scan:" suffix. -const dockerFallbackHintPrefix = "If native compilation fails due to missing required Java, set JAVA_HOME according to the project's requirements or try Docker-based " - func suggest(description, command string) { out.Suggest(description, command) } +// withFlag appends flag to the command string when it is not already present, +// for suggestions that re-run the current invocation with one extra flag. +func withFlag(command, flag string) string { + if strings.Contains(command, flag) { + return command + } + return command + " " + flag +} + +// retrySuggestion builds the "re-run with more resources" hint for a resource +// analyzer failure. The second result is false for exit codes where a plain +// retry would not help (unhandled exception, configuration error). +func retrySuggestion(exitCode int, timeout time.Duration, maxMemory string) (output.Suggestion, bool) { + switch exitCode { + case analyzer.ExitOOM: + return output.Suggestion{ + Description: "To retry with more memory, run:", + Command: rerunReplacingFlag(doubleMemory(maxMemory), "--max-memory"), + }, true + case analyzer.ExitTimeout: + return output.Suggestion{ + Description: "To retry with a longer timeout, run:", + Command: rerunReplacingFlag((timeout * 2).String(), "--timeout", "-t"), + }, true + } + return output.Suggestion{}, false +} + +// rerunReplacingFlag reconstructs the current invocation with the named flag +// (any alias, in both "--flag value" and "--flag=value" forms) replaced by the +// given value, appended as names[0]. +func rerunReplacingFlag(value string, names ...string) string { + args := []string{"opentaint"} + skipNext := false + for _, arg := range os.Args[1:] { + if skipNext { + skipNext = false + continue + } + matched := false + for _, name := range names { + if arg == name { + matched = true + skipNext = true + break + } + if strings.HasPrefix(arg, name+"=") { + matched = true + break + } + } + if matched { + continue + } + args = append(args, shellQuote(arg)) + } + args = append(args, names[0], shellQuote(value)) + return strings.Join(args, " ") +} + +// doubleMemory doubles a memory value like 8G or 1024m, falling back to the +// runtime failure message's own 16G example when the value does not parse. +func doubleMemory(value string) string { + digits := 0 + for digits < len(value) && value[digits] >= '0' && value[digits] <= '9' { + digits++ + } + suffix := value[digits:] + if digits == 0 || len(suffix) > 1 { + return "16G" + } + n, err := strconv.ParseInt(value[:digits], 10, 64) + if err != nil { + return "16G" + } + return fmt.Sprintf("%d%s", n*2, suffix) +} + // logSuggestion returns a Suggestion pointing at the active log file. The // second result is false when no log file is active (e.g. failures that occur // before logging is activated), in which case callers omit it. diff --git a/cli/cmd/summary.go b/cli/cmd/summary.go index 0959b5257c..ffd4ca2950 100644 --- a/cli/cmd/summary.go +++ b/cli/cmd/summary.go @@ -1,7 +1,10 @@ package cmd import ( + "fmt" + "github.com/seqra/opentaint/internal/sarif" + "github.com/seqra/opentaint/internal/triage" "github.com/seqra/opentaint/internal/utils" "github.com/seqra/opentaint/internal/utils/log" "github.com/spf13/cobra" @@ -9,14 +12,42 @@ import ( // summaryCmd represents the summary command var summaryCmd = &cobra.Command{ - Use: "summary sarif", - Short: "Print summary of your sarif", + Use: "summary ", + Short: "Show a summary of a SARIF report", Args: cobra.ExactArgs(1), // require exactly one argument - Long: `Print summary of your sarif file + Long: `Show a summary of a SARIF report in the terminal. The summary counts the findings by severity. It also shows which rules ran and which rules found problems. + +The sarif-report argument is the path to a SARIF report. It is required. Use a report from "opentaint scan" or "opentaint test". + +To see each finding, use --show-findings. To make the list smaller, use --severity, --rule-id, or --path. To see the full data flow, use --verbose-flow and --show-code-snippets. + +To compare with a previous report, use --baseline. The summary then shows which findings are new, unchanged, updated, or absent. Use --baseline-state to show only the findings in one of those states. + +This command only reads the report. It does not write files. To record decisions about findings, use "opentaint triage".`, + Example: ` # Show a summary of a report + opentaint summary report.sarif + + # Show each finding with its location + opentaint summary report.sarif --show-findings + + # Show only the error-level findings + opentaint summary report.sarif --show-findings --severity error + + # Group the findings by rule + opentaint summary report.sarif --show-findings --group-by rule-id + + # Show what changed since a previous report + opentaint summary report.sarif --baseline main.sarif + + # Show only the findings that are new since the baseline + opentaint summary report.sarif --baseline main.sarif --baseline-state new --show-findings + + # Recipe: examine one rule in full detail + opentaint summary report.sarif --show-findings --group-by rule-id + opentaint summary report.sarif --show-findings --rule-id --verbose-flow --show-code-snippets -Arguments: - sarif - Path to a sarif file -`, + # Recipe: read the findings for one part of the code + opentaint summary report.sarif --show-findings --path "src/main/**" --severity error`, Run: func(cmd *cobra.Command, args []string) { for _, s := range summarySeverities { @@ -33,15 +64,84 @@ Arguments: out.Fatalf("%s", err) } + states, err := sarif.ParseBaselineStates(summaryBaselineStates) + if err != nil { + out.Fatalf("%s", err) + } + absSarifPath := log.AbsPathOrExit(args[0], "sarif path") report, err := sarif.LoadReport(absSarifPath) if err != nil { out.Fatalf("Failed to load SARIF report: %s", err) } - printSarifSummary(report, absSarifPath, summaryFilters(), summaryListingOptions(dim, codeFlowSel)) + if err := requireBaselineStates(report, states, summaryBaseline); err != nil { + out.Fatalf("%s", err) + } + + // summary never writes: the baseline comparison and any inherited + // suppressions are applied to the in-memory copy for display only. + view := applyTriageForDisplay(report, absSarifPath) + + filters := summaryFilters() + filters.BaselineStates = states + printSarifSummary(report, absSarifPath, filters, summaryListingOptions(dim, codeFlowSel), view, showFindings) + + if !showFindings && sarif.GenerateSummary(report.Filter(filters)).TotalFindings > 0 { + out.Suggest( + "To list the findings, run:", + currentSummaryBuilder(absSarifPath).WithShowFindings().Build(), + ) + } }, } +// requireBaselineStates refuses a --baseline-state filter that cannot mean +// anything. The filter reads result.baselineState, which a report only carries +// after a comparison persisted it, so filtering a report that has none would +// silently print "0 findings" — a clean bill of health for a report nobody +// compared against anything. +func requireBaselineStates(report *sarif.Report, states []string, baseline string) error { + if len(states) == 0 || baseline != "" { + return nil + } + // The absent state can never be satisfied from the report alone: absent + // findings live only in the baseline, and --write-baseline-state never + // writes them into the current report. + for _, state := range states { + if state == string(sarif.Absent) { + return fmt.Errorf("--baseline-state absent needs --baseline : " + + "absent findings live in the baseline and are never written into the current report") + } + } + for _, r := range report.Results() { + if r.BaselineState != nil { + return nil + } + } + return fmt.Errorf("--baseline-state needs baseline states to filter on: no result in this report carries one.\n" + + "Compare against a baseline now with --baseline , or produce a report that keeps them with " + + "'opentaint scan --baseline --write-baseline-state'") +} + +// applyTriageForDisplay runs a read-only triage pass so that summary can show +// baseline states and inherited suppressions without touching the file. +func applyTriageForDisplay(report *sarif.Report, absSarifPath string) *sarif.TriageView { + if summaryBaseline == "" { + return &sarif.TriageView{Suppressions: sarif.CollectSuppressionStats(report)} + } + + baseline, absBaselinePath := loadBaselineOrExit(summaryBaseline, absSarifPath) + outcome, err := triage.Apply(report, triage.Options{ + Baseline: baseline, + BaselinePath: absBaselinePath, + ReadOnly: true, + }) + if err != nil { + out.Fatalf("%s", err) + } + return outcome.View +} + var showFindings bool var showCodeSnippets bool var verboseFlow bool @@ -50,25 +150,49 @@ var summaryPaths []string var summarySeverities []string var summaryRuleIDs []string var summaryFingerprints []string -var summaryFingerprintKey string var summaryGroupBy string var summaryMaxNestingLevel = -1 // -1 = no cap; >= 0 collapses deeper flow steps var summaryCodeFlow string +var summaryBaseline string +var summaryBaselineStates []string +var summaryShowSuppressed bool func init() { rootCmd.AddCommand(summaryCmd) - summaryCmd.Flags().BoolVar(&showFindings, "show-findings", false, "Show all issues from Sarif file") + summaryCmd.Flags().BoolVar(&showFindings, "show-findings", false, "Show every finding in the SARIF report") summaryCmd.Flags().BoolVar(&showCodeSnippets, "show-code-snippets", false, "Show finding related code snippets") summaryCmd.Flags().BoolVar(&verboseFlow, "verbose-flow", false, "Show full code flow steps for findings") summaryCmd.Flags().StringArrayVar(&summaryPaths, "path", nil, "Show only findings whose file path matches this glob (** supported, repeatable)") - summaryCmd.Flags().StringArrayVar(&summarySeverities, "severity", nil, "Show only findings of this SARIF level: error, warning, note, none (repeatable)") - summaryCmd.Flags().StringArrayVar(&summaryRuleIDs, "rule-id", nil, "Show only findings for this rule: full id, leaf name, or glob (repeatable)") - summaryCmd.Flags().StringArrayVar(&summaryFingerprints, "partial-fingerprint", nil, "Show only findings whose partial fingerprint starts with this value (git-hash style, repeatable)") - summaryCmd.Flags().StringVar(&summaryFingerprintKey, "partial-fingerprint-key", "", "partialFingerprints key matched by --partial-fingerprint (default vulnerabilityWithTraceHash/v1)") + summaryCmd.Flags().StringArrayVar(&summarySeverities, "severity", nil, "Show only findings at these SARIF levels: note, warning, error, none (repeatable)") + summaryCmd.Flags().StringArrayVar(&summaryRuleIDs, "rule-id", nil, "Show only findings from this rule: full id, leaf name, or glob (repeatable)") + summaryCmd.Flags().StringArrayVar(&summaryFingerprints, "partial-fingerprint", nil, "Show only findings whose fingerprint starts with this value (git-hash style, repeatable)") summaryCmd.Flags().IntVar(&summaryMaxNestingLevel, "max-nesting-level", -1, "Collapse code-flow steps deeper than this call-nesting level (-1 = no cap)") - summaryCmd.Flags().StringVar(&summaryGroupBy, "group-by", "", "Group the --show-findings listing by: severity, rule-id, file-path (default file-path)") + summaryCmd.Flags().StringVar(&summaryGroupBy, "group-by", "", "Group the --show-findings listing by: severity, rule-id, file-path (defaults to file-path)") summaryCmd.Flags().StringVar(&summaryCodeFlow, "code-flow", "", "Render code flows: \"all\", a 1-based index, or unset (first only)") + addBaselineFlags(summaryCmd, &summaryBaseline) + summaryCmd.Flags().StringArrayVar(&summaryBaselineStates, "baseline-state", nil, "Show only findings in these baseline states: new, unchanged, updated, absent (repeatable, reads states written by --write-baseline-state or computed from --baseline)") + summaryCmd.Flags().BoolVar(&summaryShowSuppressed, "suppressed", false, "Include suppressed findings in the listing") +} + +// addBaselineFlags registers the flags shared by every command that can compare +// a report against a baseline. +func addBaselineFlags(cmd *cobra.Command, baseline *string) { + cmd.Flags().StringVar(baseline, "baseline", "", "Previous SARIF report to compare against and inherit suppressions from") +} + +// loadBaselineOrExit resolves and loads a baseline report, refusing to use the +// report under inspection as its own baseline. +func loadBaselineOrExit(baselinePath, absReportPath string) (*sarif.Report, string) { + absBaselinePath := log.AbsPathOrExit(baselinePath, "baseline") + if absBaselinePath == absReportPath { + out.Fatalf("The baseline and the report are the same file: %s", absBaselinePath) + } + baseline, err := sarif.LoadReport(absBaselinePath) + if err != nil { + out.Fatalf("Failed to load baseline report: %s", err) + } + return baseline, absBaselinePath } // currentSummaryBuilder returns a builder pre-populated with the user's current summary flags. @@ -89,10 +213,12 @@ func currentSummaryBuilder(sarifPath string) *utils.OpentaintCommandBuilder { builder.WithSeverity(summarySeverities) builder.WithRuleID(summaryRuleIDs) builder.WithPartialFingerprint(summaryFingerprints) - builder.WithPartialFingerprintKey(summaryFingerprintKey) builder.WithMaxNestingLevel(summaryMaxNestingLevel) builder.WithGroupBy(summaryGroupBy) builder.WithCodeFlow(summaryCodeFlow) + builder.WithBaseline(summaryBaseline) + builder.WithBaselineStateFilter(summaryBaselineStates) + builder.WithSuppressed(summaryShowSuppressed) return builder } @@ -101,11 +227,10 @@ func currentSummaryBuilder(sarifPath string) *utils.OpentaintCommandBuilder { // flag globals are at their defaults. func summaryFilters() sarif.Filters { return sarif.Filters{ - Paths: summaryPaths, - Severities: summarySeverities, - RuleIDs: summaryRuleIDs, - Fingerprints: summaryFingerprints, - FingerprintKey: summaryFingerprintKey, + Paths: summaryPaths, + Severities: summarySeverities, + RuleIDs: summaryRuleIDs, + Fingerprints: summaryFingerprints, } } @@ -119,25 +244,40 @@ func summaryListingOptions(dim sarif.GroupDimension, codeFlowSel sarif.CodeFlowS VerboseFlow: verboseFlow, MaxNestingLevel: summaryMaxNestingLevel, GroupBy: dim, - FingerprintKey: summaryFingerprintKey, CodeFlows: codeFlowSel, + ShowSuppressed: summaryShowSuppressed, } } -func printSarifSummary(report *sarif.Report, absSarifPath string, filters sarif.Filters, opts sarif.ListingOptions) { +// printSarifSummary renders the optional finding listing followed by the scan +// summary. list controls whether the listing is printed. Each command owns its +// own --show-findings flag. +func printSarifSummary(report *sarif.Report, absSarifPath string, filters sarif.Filters, opts sarif.ListingOptions, view *sarif.TriageView, list bool) { filtered := report.Filter(filters) + // Every number printed below must describe the findings printed above it, so + // the counts are recomputed over whatever survived the filters. + view = view.Restrict(filtered, filters) + if view != nil { + opts.Comparison = view.Comparison + } hasOmittedFlow := false - if showFindings { - hasOmittedFlow = filtered.PrintAll(out, opts) + if list { + // Absent findings live in the baseline, so they only reach the listing when + // the reader explicitly asks for them. + listing := filtered + if filters.WantsAbsent() && view != nil && view.Comparison != nil { + listing = filtered.WithAbsent(view.Comparison.Absent) + } + hasOmittedFlow = listing.PrintAll(out, opts) out.Blank() } - filtered.PrintSummary(out, absSarifPath) + filtered.PrintSummary(out, absSarifPath, view) - if showFindings && hasOmittedFlow && !verboseFlow { + if list && hasOmittedFlow && !verboseFlow { out.Suggest( - "To see full code flow and code snippets, use:", + "To see the full code flow and code snippets, run:", currentSummaryBuilder(absSarifPath).WithVerboseFlow().WithShowCodeSnippets().Build(), ) } diff --git a/cli/cmd/test.go b/cli/cmd/test.go index 409240606c..6b3b25a55c 100644 --- a/cli/cmd/test.go +++ b/cli/cmd/test.go @@ -9,17 +9,38 @@ import ( var testCmd = &cobra.Command{ Use: "test", Short: "Create and run rule and approximation tests", - Long: `Tools for creating test projects, running annotated rule and approximation tests, and debugging rule reachability.`, + Long: `Create and run tests for detection rules and for dataflow approximations. Rule tests make sure that a rule finds the positive samples and ignores the negative samples. Approximation tests make sure that a dataflow approximation moves taint from source to sink. + +Workflow: + 1. Create a test project with init. + 2. Compile the project with "opentaint compile". + 3. Run the samples with "test rule run" or "test approximation run". + +To see why one rule does or does not fire, use "test rule reachability".`, } var testRuleCmd = &cobra.Command{ Use: "rule", Short: "Create, run, and debug detection-rule tests", + Long: `Create, run, and debug tests for taint detection rules. A rule test makes sure that a rule finds the positive samples and ignores the negative samples. + +Workflow: + 1. Create a test project with "test rule init". + 2. Compile the project with "opentaint compile". + 3. Run the samples with "test rule run". + +To see why one rule does or does not fire, use "test rule reachability".`, } var testApproximationCmd = &cobra.Command{ Use: "approximation", Short: "Create and run dataflow-approximation tests", + Long: `Create and run tests for dataflow approximations. An approximation test makes sure that an approximation moves taint from source to sink in your samples. + +Workflow: + 1. Create a test project with "test approximation init". + 2. Compile the project with "opentaint compile". + 3. Run the samples with "test approximation run --java-models ".`, } func init() { @@ -28,20 +49,9 @@ func init() { testCmd.AddCommand(testApproximationCmd) } -func testExitCodesHelp(passedLine string) string { - return `Exit codes: - 0 ` + passedLine + ` - 1 General failure (configuration or infrastructure error) - 2 One or more tests failed (false negatives/positives or skipped samples) - 252 Unhandled analyzer exception - 253 Out of memory (try increasing --max-memory) - 254 Analysis timed out (try increasing --timeout) - 255 Project configuration error` -} - func addTestRunFlags(cmd *cobra.Command, outputDir *string, timeout *time.Duration, maxMemory *string, dataflow *[]string) { cmd.Flags().StringVarP(outputDir, "output", "o", "", "Directory for test-result.json and test-results.sarif") - cmd.Flags().DurationVar(timeout, "timeout", 600*time.Second, "Analysis timeout") - cmd.Flags().StringVar(maxMemory, "max-memory", "8G", "Maximum analyzer heap size (e.g., 8G)") - cmd.Flags().StringArrayVar(dataflow, "dataflow-approximations", nil, "Dataflow approximation class directory or Java source directory (repeatable)") + cmd.Flags().DurationVar(timeout, "timeout", 600*time.Second, "Maximum wall-clock time for analysis (e.g. 30m, 1h)") + cmd.Flags().StringVar(maxMemory, "max-memory", "8G", "Maximum analyzer heap size (e.g. 8G, 1024m)") + addRenamedStringArrayFlag(cmd.Flags(), dataflow, "java-models", "dataflow-approximations", "Java dataflow models: a compiled class directory or a Java source directory (repeatable)") } diff --git a/cli/cmd/test_approximation_run.go b/cli/cmd/test_approximation_run.go index 018e16c03d..9f688c1f47 100644 --- a/cli/cmd/test_approximation_run.go +++ b/cli/cmd/test_approximation_run.go @@ -17,13 +17,25 @@ var ( var testApproximationRunCmd = &cobra.Command{ Use: "run ", - Short: "Run dataflow approximation tests on a compiled project model", - Long: `Run the samples specified in rule-test.yaml with the supplied dataflow approximations applied. + Short: "Run dataflow-approximation tests on a compiled project model", + Long: `Run the samples that rule-test.yaml declares, with your dataflow approximations applied. The command reports which samples passed. A fixed source-to-sink harness rule is applied automatically. Positive samples point to it with the id approximation-rule. -A built-in source-to-sink harness rule is applied automatically; positive samples reference the -approximation-rule.yaml rule with id "approximation-rule". +The project-model argument is a compiled project model directory from "opentaint compile". Give the approximation under test with --java-models. + +The command writes test-result.json and a test-results.sarif report to --output. If --output is not set, it writes to a temporary directory. + +Compile the test project before you run the tests. To read the results, use "opentaint summary". ` + testExitCodesHelp("All approximation tests passed"), + Example: ` # Run an approximation test on a compiled model + opentaint test approximation run ./approx-test/model --java-models ./approx + + # Write the results to a directory + opentaint test approximation run ./approx-test/model --java-models ./approx -o ./results + + # Recipe: change an approximation, then make sure the tests stay green + opentaint test approximation run ./approx-test/model --java-models ./approx -o ./results + opentaint summary ./results/test-results.sarif --show-findings`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { ruleDir, err := os.MkdirTemp("", "opentaint-approx-rule-*") @@ -36,6 +48,7 @@ approximation-rule.yaml rule with id "approximation-rule". runTestProject(args[0], testProjectOptions{ label: "Approximation tests", + passedLine: "All approximation tests passed.", tempDir: "opentaint-test-approximations-*", rulesets: []string{ruleDir}, outputDir: testApproxOutputDir, diff --git a/cli/cmd/test_init.go b/cli/cmd/test_init.go index 2c4966f822..0686204fb3 100644 --- a/cli/cmd/test_init.go +++ b/cli/cmd/test_init.go @@ -4,6 +4,7 @@ import ( "fmt" "path/filepath" + "github.com/seqra/opentaint/internal/output" "github.com/seqra/opentaint/internal/testapprox" "github.com/seqra/opentaint/internal/testproject" "github.com/seqra/opentaint/internal/testrule" @@ -18,19 +19,26 @@ var initRuleSourcesOnly bool var testRuleInitCmd = &cobra.Command{ Use: "init ", Short: "Create rule test projects with source and sink harnesses", - Long: `Create one or two Gradle test projects under . The sinks -project tests sink rules against a generic Taint source; the sources project -tests source rules against a generic Taint sink. Use --sinks-only or ---sources-only when only one project is needed. + Long: `Create one or two Gradle test projects for detection-rule tests. The sinks project tests sink rules with a generic taint source. The sources project tests source rules with a generic taint sink. -Each project includes: - - build.gradle.kts with compile-only dependencies, settings.gradle.kts - - src/main/java/test/ with Taint.java (the generic source()/sink()) for test sample sources - - test-rules/java/lib/test/generic-{source,sink}.yaml marker rules for test-only joins +The output-dir argument is the parent directory for the new projects. The default creates the two projects, in output-dir/sinks and output-dir/sources. To create one project only, use --sinks-only or --sources-only. To add compile-only Maven dependencies for the samples, use --dependency. -Positive and negative samples are specified via rule-test.yaml. +Each project contains a rule-test.yaml file and a Taint.java harness. Declare your positive and negative samples in rule-test.yaml. -Use --dependency to add compile-only Maven dependencies for the samples.`, +Then compile the project with "opentaint compile" and run the samples with "opentaint test rule run".`, + Example: ` # Create the sinks and the sources test projects + opentaint test rule init ./rule-tests + + # Create only the sinks project + opentaint test rule init ./rule-tests --sinks-only + + # Add a compile-only dependency for the samples + opentaint test rule init ./rule-tests --dependency + + # Recipe: from an empty directory to a first test run + opentaint test rule init ./rule-tests + opentaint compile ./rule-tests/sinks -o ./rule-tests/sinks/model + opentaint test rule run ./rule-tests/sinks/model --ruleset ./my-rules`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { if initRuleSinksOnly && initRuleSourcesOnly { @@ -50,28 +58,38 @@ Use --dependency to add compile-only Maven dependencies for the samples.`, if err := testrule.Scaffold(dir); err != nil { out.Fatalf("Failed to scaffold rule test project: %s", err) } - fmt.Printf("Rule test project (%s) initialized at %s\n", kind, dir) + out.Printf("Rule test project (%s) initialized at %s", kind, dir) } + dir := filepath.Join(args[0], kinds[0]) + modelDir := filepath.Join(dir, "model") + out.Suggestions( + output.Suggestion{Description: "To add your test samples, edit:", Command: filepath.Join(dir, "rule-test.yaml")}, + output.Suggestion{Description: "To compile the test project, run:", Command: fmt.Sprintf("opentaint compile %s -o %s", dir, modelDir)}, + output.Suggestion{Description: "To run the tests, run:", Command: fmt.Sprintf("opentaint test rule run %s", modelDir)}, + ) }, } var testApproximationInitCmd = &cobra.Command{ Use: "init ", - Short: "Create a dataflow approximation test project", - Long: `Create a minimal Gradle project for testing OpenTaint dataflow approximations. + Short: "Create a dataflow-approximation test project", + Long: `Create a Gradle test project for dataflow-approximation tests. The project contains a fixed source-to-sink rule. The samples are checked against this rule. + +The output-dir argument is the directory for the new project. To add compile-only Maven dependencies for the samples, use --dependency. The approximation under test is not part of the project. Give it at run time with --java-models. -The project includes: - - build.gradle.kts with compile-only dependencies - - settings.gradle.kts - - approximation-rule.yaml, the fixed source-to-sink rule the samples are checked against - - src/main/java/test/ with Taint.java (the fixed source() and sink()) for test sample sources +The project contains a rule-test.yaml file, a Taint.java source and sink, and the fixed approximation-rule.yaml. Declare your positive and negative samples in rule-test.yaml. -Positive and negative samples are specified via rule-test.yaml. +Then compile the project with "opentaint compile" and run the samples with "opentaint test approximation run".`, + Example: ` # Create an approximation test project + opentaint test approximation init ./approx-test -The approximation under test is supplied separately at test time with ---dataflow-approximations. + # Add a compile-only dependency for the samples + opentaint test approximation init ./approx-test --dependency -Use --dependency to add compile-only Maven dependencies for the samples.`, + # Recipe: from an empty directory to a first test run + opentaint test approximation init ./approx-test + opentaint compile ./approx-test -o ./approx-test/model + opentaint test approximation run ./approx-test/model --java-models ./my-approximation`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { if err := testproject.Bootstrap(args[0], "approximation-test-project", initApproxProjectDeps); err != nil { @@ -80,7 +98,14 @@ Use --dependency to add compile-only Maven dependencies for the samples.`, if err := testapprox.Scaffold(args[0]); err != nil { out.Fatalf("Failed to scaffold approximation project: %s", err) } - fmt.Printf("Approximation test project initialized at %s\n", args[0]) + out.Printf("Approximation test project initialized at %s", args[0]) + dir := args[0] + modelDir := filepath.Join(dir, "model") + out.Suggestions( + output.Suggestion{Description: "To add your test samples, edit:", Command: filepath.Join(dir, "rule-test.yaml")}, + output.Suggestion{Description: "To compile the test project, run:", Command: fmt.Sprintf("opentaint compile %s -o %s", dir, modelDir)}, + output.Suggestion{Description: "To run the tests, run:", Command: fmt.Sprintf("opentaint test approximation run %s --java-models ", modelDir)}, + ) }, } diff --git a/cli/cmd/test_rule_reachability.go b/cli/cmd/test_rule_reachability.go index ecb0bf3231..3b20bfd50c 100644 --- a/cli/cmd/test_rule_reachability.go +++ b/cli/cmd/test_rule_reachability.go @@ -8,12 +8,31 @@ var reachabilityEntryPoint string var testRuleReachabilityCmd = &cobra.Command{ Use: "reachability [source-path]", - Short: "Trace why a rule can or cannot reach its facts", - Long: `Scan a project with one rule and write a sibling fact-reachability SARIF -report (debug-ifds-fact-reachability.sarif) next to the main one. Use this to -debug why a rule does or does not fire. + Short: "Show why a rule does or does not fire", + Long: `Scan a project with one rule and write a fact-reachability SARIF report. The report shows why the rule does or does not fire. Library source and sink rules that the rule points to are included automatically. -Referenced library source and sink rules are collected and analyzed automatically.`, +The rule-id argument selects the rule. The source-path argument is the project root. It is optional. The default is the current directory. To use a compiled model, use --project-model. Do not give source-path and --project-model together. To start the analysis from one method, use --entry-points. + +The report name is debug-ifds-fact-reachability.sarif. It is written adjacent to the main SARIF report. + +Before the first run, run "opentaint pull" one time. To read the report, use "opentaint summary". + +` + scanExitCodesHelp("Reachability analysis completed"), + Example: ` # Show why a rule does or does not fire on the current directory + opentaint test rule reachability . + + # Examine a rule on a compiled project model + opentaint test rule reachability --project-model ./model + + # Start the analysis from one entry-point method + opentaint test rule reachability . --entry-points com.example.App#main + + # Make sure the inputs are correct, without a scan + opentaint test rule reachability . --dry-run + + # Recipe: find why a new rule stays silent + opentaint test rule reachability . -o report.sarif + opentaint summary debug-ifds-fact-reachability.sarif --show-findings --verbose-flow`, Annotations: map[string]string{"PrintConfig": "true"}, Args: cobra.RangeArgs(1, 2), Run: func(cmd *cobra.Command, args []string) { @@ -21,7 +40,7 @@ Referenced library source and sink rules are collected and analyzed automaticall out.Warn("on Spring projects this method is added to the auto-discovered entry points, not used to restrict them") } cfg := reachabilityScanConfig(scanFlags, args[0], reachabilityEntryPoint) - runScan(cmd, prepareScanConfig(cfg, args[1:])) + runScan(cmd, prepareScanConfig(cmd, cfg, args[1:])) }, } diff --git a/cli/cmd/test_rule_reachability_test.go b/cli/cmd/test_rule_reachability_test.go index 280a7dced4..10fae042f7 100644 --- a/cli/cmd/test_rule_reachability_test.go +++ b/cli/cmd/test_rule_reachability_test.go @@ -101,3 +101,90 @@ func TestScanConfigFileAppliesWhenFlagUnset(t *testing.T) { t.Errorf("Timeout = %v, want config-file 123s when flag not passed", got) } } + +func TestScanBaselineFromConfigResolvesRelativeToConfigFile(t *testing.T) { + origBaseline := globals.Config.Scan.Baseline + origFlagValue := scanFlags.Baseline + baselineFlag := scanCmd.Flags().Lookup("baseline") + origChanged := baselineFlag.Changed + t.Cleanup(func() { + globals.Config.Scan.Baseline = origBaseline + scanFlags.Baseline = origFlagValue + baselineFlag.Changed = origChanged + globals.ConfigFile = "" + viper.Reset() + }) + + configDir := t.TempDir() + configFile := filepath.Join(configDir, "config.yaml") + if err := os.WriteFile(configFile, []byte("scan:\n baseline: baselines/main.sarif\n"), 0o644); err != nil { + t.Fatal(err) + } + globals.ConfigFile = configFile + scanFlags.Baseline = "" + baselineFlag.Changed = false + + initConfig(scanCmd) + cfg := prepareScanConfig(scanCmd, scanFlags, nil) + want := filepath.Join(configDir, "baselines", "main.sarif") + if cfg.Baseline != want { + t.Errorf("Baseline = %q, want config-relative %q", cfg.Baseline, want) + } +} + +func TestScanBaselineFlagOverridesConfigWithoutRebasing(t *testing.T) { + origBaseline := globals.Config.Scan.Baseline + origFlagValue := scanFlags.Baseline + baselineFlag := scanCmd.Flags().Lookup("baseline") + origChanged := baselineFlag.Changed + t.Cleanup(func() { + globals.Config.Scan.Baseline = origBaseline + scanFlags.Baseline = origFlagValue + baselineFlag.Changed = origChanged + globals.ConfigFile = "" + viper.Reset() + }) + + configFile := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(configFile, []byte("scan:\n baseline: from-config.sarif\n"), 0o644); err != nil { + t.Fatal(err) + } + globals.ConfigFile = configFile + scanFlags.Baseline = "from-flag.sarif" + baselineFlag.Changed = true + + initConfig(scanCmd) + cfg := prepareScanConfig(scanCmd, scanFlags, nil) + if cfg.Baseline != "from-flag.sarif" { + t.Errorf("Baseline = %q, want unchanged flag path", cfg.Baseline) + } +} + +func TestScanBaselineEnvironmentOverridesConfigWithoutRebasing(t *testing.T) { + origBaseline := globals.Config.Scan.Baseline + origFlagValue := scanFlags.Baseline + baselineFlag := scanCmd.Flags().Lookup("baseline") + origChanged := baselineFlag.Changed + t.Cleanup(func() { + globals.Config.Scan.Baseline = origBaseline + scanFlags.Baseline = origFlagValue + baselineFlag.Changed = origChanged + globals.ConfigFile = "" + viper.Reset() + }) + + configFile := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(configFile, []byte("scan:\n baseline: from-config.sarif\n"), 0o644); err != nil { + t.Fatal(err) + } + globals.ConfigFile = configFile + scanFlags.Baseline = "" + baselineFlag.Changed = false + t.Setenv("OPENTAINT_SCAN_BASELINE", "from-environment.sarif") + + initConfig(scanCmd) + cfg := prepareScanConfig(scanCmd, scanFlags, nil) + if cfg.Baseline != "from-environment.sarif" { + t.Errorf("Baseline = %q, want unchanged environment path", cfg.Baseline) + } +} diff --git a/cli/cmd/test_rule_run.go b/cli/cmd/test_rule_run.go index 4b380c617b..15a1e3c4b9 100644 --- a/cli/cmd/test_rule_run.go +++ b/cli/cmd/test_rule_run.go @@ -7,6 +7,7 @@ import ( "time" "github.com/seqra/opentaint/internal/analyzer" + "github.com/seqra/opentaint/internal/output" "github.com/seqra/opentaint/internal/utils" "github.com/seqra/opentaint/internal/utils/log" "github.com/spf13/cobra" @@ -25,14 +26,32 @@ var ( var testRuleRunCmd = &cobra.Command{ Use: "run ", Short: "Run detection-rule tests on a compiled project model", - Long: `Run detection rules against the samples specified in rule-test.yaml in the -compiled project model. + Long: `Run detection rules on the samples that rule-test.yaml declares. The command reports which samples passed. The built-in rules are always included. + +The project-model argument is a compiled project model directory from "opentaint compile". To add your own rules, use --ruleset. To run only specified rules, use --rule-id. To apply models, use --java-models or --passthrough-models. + +The command writes test-result.json and a test-results.sarif report to --output. If --output is not set, it writes to a temporary directory. + +Compile the test project before you run the tests. To read the results, use "opentaint summary". ` + testExitCodesHelp("All rule tests passed"), + Example: ` # Run the built-in rules on a compiled model + opentaint test rule run ./rule-tests/sinks/model + + # Test your own rules and write the results to a directory + opentaint test rule run ./rule-tests/sinks/model --ruleset ./rules -o ./results + + # Run only one rule + opentaint test rule run ./rule-tests/sinks/model --rule-id + + # Recipe: change a rule, then make sure the tests stay green + opentaint test rule run ./rule-tests/sinks/model --ruleset ./rules -o ./results + opentaint summary ./results/test-results.sarif --show-findings`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { runTestProject(args[0], testProjectOptions{ label: "Rule tests", + passedLine: "All rule tests passed.", tempDir: "opentaint-test-rules-*", rulesets: testRulesRuleset, outputDir: testRulesOutputDir, @@ -48,6 +67,7 @@ compiled project model. type testProjectOptions struct { label string + passedLine string // success status line, matching the documented exit-code 0 row tempDir string rulesets []string outputDir string @@ -104,7 +124,7 @@ func runTestProject(projectModelArg string, opts testProjectOptions) { if opts.includeBuiltinRules { rulesPath, err := utils.EnsureRulesPath(out) if err != nil { - out.Fatalf("Failed to prepare built-in rules: %s", err) + failf("Failed to prepare built-in rules: %s", err) } builder.AddRuleSet(rulesPath) } @@ -124,7 +144,7 @@ func runTestProject(projectModelArg string, opts testProjectOptions) { analyzerJarPath, err := ensureAnalyzerAvailable() if err != nil { - out.Fatalf("Failed to resolve analyzer: %s", err) + failf("Failed to resolve analyzer: %s", err) } builder.SetJarPath(analyzerJarPath) @@ -133,45 +153,62 @@ func runTestProject(projectModelArg string, opts testProjectOptions) { javaRunner := newAnalyzerJavaRunner() if _, err := javaRunner.EnsureJava(); err != nil { - out.Fatalf("Failed to resolve Java for analyzer: %s", err) + failf("Failed to resolve Java for analyzer: %s", err) } cmdErr, err := scanProject(builder, javaRunner) if err != nil { - out.Fatalf("%s failed: %s", opts.label, err) + failf("%s failed: %s", opts.label, err) } analyzerFail := analyzer.Classify(cmdErr) - if analyzerFail != nil { - out.Error(analyzerFail.Message) - } resultPath := filepath.Join(outputDir, "test-result.json") - fmt.Printf("Results directory: %s\n", outputDir) - fmt.Printf("Test results: %s\n", resultPath) + out.Printf("Results directory: %s", outputDir) + out.Printf("Test results: %s", resultPath) if analyzerFail != nil { + out.Error(analyzerFail.Message) + // Test runs do not activate file logging, so the log pointer is usually + // absent. For resource failures suggest the retry with more resources. + // Otherwise the --debug re-run is the actionable way to see what failed. + hint := output.Suggestion{ + Description: "To stream the analyzer output, re-run with --debug:", + Command: withFlag(rerunWithoutDryRun(), "--debug"), + } + if retry, ok := retrySuggestion(analyzerFail.ExitCode, opts.timeout, opts.maxMemory); ok { + hint = retry + } + out.Suggestions(append(appendLogSuggestion(nil), hint)...) os.Exit(analyzerFail.ExitCode) } tr, err := analyzer.LoadTestResult(resultPath) if err != nil { - out.Fatalf("%s produced no readable test-result.json: %s", opts.label, err) + failf("%s produced no readable test-result.json: %s", opts.label, err) } - fmt.Printf("Passed: %d, failed: %d (false negatives: %d, false positives: %d, skipped: %d), disabled: %d\n", + out.Printf("Passed: %d, failed: %d (false negatives: %d, false positives: %d, skipped: %d), disabled: %d", len(tr.Success), tr.Failed(), len(tr.FalseNegative), len(tr.FalsePositive), len(tr.Skipped), len(tr.Disabled)) + + viewResultsCommand := utils.NewSummaryCommand(filepath.Join(outputDir, "test-results.sarif")).WithShowFindings().Build() + if tr.Failed() > 0 { out.Error(fmt.Sprintf("%s failed", opts.label)) + out.Suggestions(append(appendLogSuggestion(nil), output.Suggestion{ + Description: "To inspect the failing samples, run:", + Command: viewResultsCommand, + })...) os.Exit(2) } - fmt.Printf("%s completed successfully\n", opts.label) + out.Successf("%s", opts.passedLine) + suggest("To view the test results, run:", viewResultsCommand) } func init() { testRuleCmd.AddCommand(testRuleRunCmd) - testRuleRunCmd.Flags().StringArrayVar(&testRulesRuleset, "ruleset", nil, "Ruleset file or directory to test (repeatable)") + testRuleRunCmd.Flags().StringArrayVar(&testRulesRuleset, "ruleset", nil, "Ruleset to test: a YAML file or a directory of .yml or .yaml files (repeatable)") addTestRunFlags(testRuleRunCmd, &testRulesOutputDir, &testRulesTimeout, &testRulesMaxMemory, &testRulesDataflow) testRuleRunCmd.Flags().StringArrayVar(&testRulesRuleID, "rule-id", nil, "Run only rules with this ID (repeatable)") - testRuleRunCmd.Flags().StringArrayVar(&testRulesPassthrough, "passthrough-approximations", nil, "Pass-through approximation YAML file or directory (repeatable)") + addRenamedStringArrayFlag(testRuleRunCmd.Flags(), &testRulesPassthrough, "passthrough-models", "passthrough-approximations", "Pass-through models: a YAML file or a directory of them (repeatable)") } diff --git a/cli/cmd/triage.go b/cli/cmd/triage.go new file mode 100644 index 0000000000..94ceed0987 --- /dev/null +++ b/cli/cmd/triage.go @@ -0,0 +1,199 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/seqra/opentaint/internal/sarif" + "github.com/seqra/opentaint/internal/triage" + "github.com/seqra/opentaint/internal/utils/log" + "github.com/spf13/cobra" +) + +// ExitFindings is returned when --error-on-findings is set and findings remain. +// It matches the "results failed the check" code used by `opentaint test`, and +// stays clear of 1 (general failure) and 252-255 (analyzer failures). +const ExitFindings = 2 + +type TriageConfig struct { + Baseline string + WriteBaselineState bool + Accept []string + Defer []string + Unsuppress []string + Justifications []string + Output string + ErrorOnFindings bool + ErrorOnSeverity []string + ShowSuppressed bool + ShowFindings bool +} + +var triageFlags TriageConfig + +var triageCmd = &cobra.Command{ + Use: "triage ", + Short: "Compare a report against a baseline and record suppressions", + Args: cobra.ExactArgs(1), + Long: `Compare a SARIF report with a baseline and record triage decisions. To accept a finding means: the team will not fix it. To defer a finding means: the team will not fix it now. Both decisions become SARIF suppressions. All SARIF tools obey them. + +The sarif-report argument is the path to the report to triage. It is required. Use a report from "opentaint scan". A fingerprint identifies each finding. Thus a decision stays attached when other code changes. The command deletes nothing. An accepted or deferred finding stays in the report. A suppression marks it and keeps the decision and its justification. + +To name a finding, give a prefix of its fingerprint, as with a git hash. Use the value that "opentaint summary --show-findings" shows as "Fingerprint:". The two commands read the same value. Thus the value on the screen is the value to paste. A prefix that is unknown, or that matches two different values, causes an error. The command does not guess. + +The command writes the triaged report in place. To write it to a different path, use --output. With --baseline, findings first get the decisions that the baseline recorded for them. Thus a sequence of reports keeps its triage history. + +Use "opentaint scan" to make the report that this command triages. To read the result, use "opentaint summary". + +Exit codes: + 0 Triage completed + 1 General failure (bad input, unreadable report) + 2 Findings remain and --error-on-findings was set`, + Example: ` # See what changed since the last release, without a change to the files + opentaint triage report.sarif --baseline release.sarif + + # Record that a finding will not be fixed + opentaint triage report.sarif --accept q3Vf9k --justification "sink is a constant" + + # Record that a finding will not be fixed now + opentaint triage report.sarif --defer 8bc1d2 --justification "waiting on OT-412" + + # Remove an earlier decision + opentaint triage report.sarif --unsuppress q3Vf9k + + # Keep earlier decisions and fail if a new finding appeared + opentaint triage report.sarif --baseline release.sarif -o triaged.sarif --error-on-findings + + # Recipe: triage a fresh report, one decision at a time + opentaint summary report.sarif --show-findings + opentaint triage report.sarif --accept --justification "why it is safe" + opentaint triage report.sarif --defer --justification "why it can wait" + opentaint summary report.sarif --suppressed + + # Recipe: roll the baseline forward after a release + opentaint triage report.sarif --baseline baselines/main.sarif -o triaged.sarif + cp triaged.sarif baselines/main.sarif`, + + Run: func(cmd *cobra.Command, args []string) { + runTriage(triageFlags, args[0]) + }, +} + +func init() { + rootCmd.AddCommand(triageCmd) + + addBaselineFlags(triageCmd, &triageFlags.Baseline) + triageCmd.Flags().BoolVar(&triageFlags.WriteBaselineState, "write-baseline-state", false, "Persist result.baselineState and run.baselineGuid into the output report (needs --baseline)") + triageCmd.Flags().StringArrayVar(&triageFlags.Accept, "accept", nil, "Accept the finding with this fingerprint prefix: won't fix (repeatable)") + triageCmd.Flags().StringArrayVar(&triageFlags.Defer, "defer", nil, "Defer the finding with this fingerprint prefix: not fixing for now (repeatable)") + triageCmd.Flags().StringArrayVar(&triageFlags.Unsuppress, "unsuppress", nil, "Remove the suppression from the finding with this fingerprint prefix (repeatable)") + triageCmd.Flags().StringArrayVar(&triageFlags.Justifications, "justification", nil, "Why the finding is accepted or deferred (required with --accept/--defer, one per run)") + triageCmd.Flags().StringVarP(&triageFlags.Output, "output", "o", "", "Path to write the triaged report (defaults to rewriting the input in place)") + addGateFlags(triageCmd, &triageFlags.ErrorOnFindings, &triageFlags.ErrorOnSeverity) + triageCmd.Flags().BoolVar(&triageFlags.ShowSuppressed, "suppressed", false, "Include suppressed findings in the listing") + triageCmd.Flags().BoolVar(&triageFlags.ShowFindings, "show-findings", false, "Show every finding, not just the summary") +} + +// addGateFlags registers the failure-gate flags shared by scan and triage. +func addGateFlags(cmd *cobra.Command, errorOnFindings *bool, severities *[]string) { + cmd.Flags().BoolVar(errorOnFindings, "error-on-findings", false, "Exit with code 2 when findings remain (with --baseline, only new ones count)") + 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 runTriage(cfg TriageConfig, reportPath string) { + gateSeverities, err := triage.ParseGateSeverities(cfg.ErrorOnSeverity) + if err != nil { + out.Fatalf("%s", err) + } + justification, err := singleJustification(cfg.Justifications) + if err != nil { + out.Fatalf("%s", err) + } + + absReportPath := log.AbsPathOrExit(reportPath, "sarif path") + report, err := sarif.LoadReport(absReportPath) + if err != nil { + out.Fatalf("Failed to load SARIF report: %s", err) + } + + opts := triage.Options{ + WriteBaselineState: cfg.WriteBaselineState, + Accept: cfg.Accept, + Defer: cfg.Defer, + Unsuppress: cfg.Unsuppress, + Justification: justification, + } + if cfg.Baseline != "" { + opts.Baseline, opts.BaselinePath = loadBaselineOrExit(cfg.Baseline, absReportPath) + } else if cfg.WriteBaselineState { + out.Fatalf("--write-baseline-state needs a --baseline to compare against") + } + + outputPath := absReportPath + if cfg.Output != "" { + outputPath = log.AbsPathOrExit(cfg.Output, "output") + } + // Overwriting the baseline would destroy the history the comparison and + // the inherited suppressions are anchored to. The input side of the same + // mistake is rejected in loadBaselineOrExit. + if cfg.Baseline != "" && outputPath == opts.BaselinePath { + out.Fatalf("--output would overwrite the baseline: %s\n"+ + "Write the triaged report to another path", outputPath) + } + + outcome, err := triage.Apply(report, opts) + if err != nil { + out.Fatalf("%s", err) + } + + // Writing an unchanged report to its own path would be pure churn, but an + // explicit -o means "put a copy here" and is always honored. + if outcome.Changed || outputPath != absReportPath { + if err := sarif.SaveReport(report, outputPath); err != nil { + out.Fatalf("Failed to write report: %s", err) + } + } + + printSarifSummary(report, outputPath, sarif.Filters{}, sarif.ListingOptions{ + MaxNestingLevel: -1, + ShowSuppressed: cfg.ShowSuppressed, + }, outcome.View, cfg.ShowFindings) + + exitOnGate(triage.Gate{Enabled: cfg.ErrorOnFindings, Severities: gateSeverities}, report, outcome.View) +} + +// singleJustification enforces that one triage run records one reason. The flag +// is repeatable only so that passing it twice can be caught: a second +// --justification would otherwise overwrite the first, silently filing every +// decision in the run under the wrong reason. +func singleJustification(values []string) (string, error) { + switch len(values) { + case 0: + return "", nil + case 1: + return values[0], nil + default: + return "", fmt.Errorf("--justification was given %d times, but one run records one reason.\n"+ + "Run triage once per justification, or pass a single --justification covering every finding in this run", + len(values)) + } +} + +// exitOnGate reports the gate verdict and exits with ExitFindings when it trips. +func exitOnGate(gate triage.Gate, report *sarif.Report, view *sarif.TriageView) { + count, tripped := gate.Evaluate(report, view) + if !tripped { + return + } + out.Blank() + scope := "finding" + if count != 1 { + scope = "findings" + } + qualifier := "" + if view != nil && view.Comparison != nil { + qualifier = "new " + } + out.Error(fmt.Sprintf("%d %s%s reported (--error-on-findings)", count, qualifier, scope)) + os.Exit(ExitFindings) +} diff --git a/cli/cmd/triage_flags_test.go b/cli/cmd/triage_flags_test.go new file mode 100644 index 0000000000..f6106f8481 --- /dev/null +++ b/cli/cmd/triage_flags_test.go @@ -0,0 +1,77 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/seqra/opentaint/internal/sarif" +) + +func TestSingleJustificationRejectsMoreThanOne(t *testing.T) { + got, err := singleJustification([]string{"first reason", "second reason"}) + if err == nil { + t.Fatalf("two justifications were accepted, returning %q", got) + } + if !strings.Contains(err.Error(), "one run records one reason") { + t.Errorf("unhelpful error: %v", err) + } +} + +func TestSingleJustificationPassesThroughOneOrNone(t *testing.T) { + if got, err := singleJustification(nil); got != "" || err != nil { + t.Errorf("got (%q, %v), want empty", got, err) + } + if got, err := singleJustification([]string{"why"}); got != "why" || err != nil { + t.Errorf("got (%q, %v), want (\"why\", nil)", got, err) + } +} + +func TestRequireBaselineStatesRefusesAFilterThatCannotMatch(t *testing.T) { + report := &sarif.Report{Runs: []sarif.Run{{Results: []sarif.Result{{}}}}} + + err := requireBaselineStates(report, []string{"new"}, "") + if err == nil { + t.Fatal("filtering a report with no baseline states silently reported nothing") + } + if !strings.Contains(err.Error(), "--write-baseline-state") { + t.Errorf("the error does not say how to get states: %v", err) + } + + if err := requireBaselineStates(report, []string{"new"}, "baseline.sarif"); err != nil { + t.Errorf("a comparison supplies the states, so this must pass: %v", err) + } + if err := requireBaselineStates(report, nil, ""); err != nil { + t.Errorf("no filter, nothing to require: %v", err) + } +} + +func TestRequireBaselineStatesAcceptsAPersistedReport(t *testing.T) { + state := sarif.New + report := &sarif.Report{Runs: []sarif.Run{{Results: []sarif.Result{{BaselineState: &state}}}}} + if err := requireBaselineStates(report, []string{"new"}, ""); err != nil { + t.Errorf("a report carrying states filters without a baseline: %v", err) + } +} + +func TestRequireBaselineStatesAbsentAlwaysNeedsABaseline(t *testing.T) { + // Persisted states satisfy the guard for new/unchanged/updated, but absent + // findings are never written into a report, so the filter can only ever be + // served by a live comparison. + state := sarif.New + report := &sarif.Report{Runs: []sarif.Run{{Results: []sarif.Result{{BaselineState: &state}}}}} + + err := requireBaselineStates(report, []string{"absent"}, "") + if err == nil { + t.Fatal("absent without --baseline silently lists nothing and must be refused") + } + if !strings.Contains(err.Error(), "--baseline") { + t.Errorf("the error should point at --baseline: %v", err) + } + + if err := requireBaselineStates(report, []string{"absent"}, "baseline.sarif"); err != nil { + t.Errorf("with --baseline the comparison supplies absent findings: %v", err) + } + if err := requireBaselineStates(report, []string{"new", "absent"}, ""); err == nil { + t.Error("a mixed filter naming absent still needs --baseline") + } +} diff --git a/cli/cmd/update.go b/cli/cmd/update.go index 1d3a5c1bda..6ef0484d9b 100644 --- a/cli/cmd/update.go +++ b/cli/cmd/update.go @@ -20,13 +20,25 @@ var ( var updateCmd = &cobra.Command{ Use: "update [version]", Short: "Update opentaint to the latest version", - Long: `Update opentaint to the latest version (or a specific version). + Long: `Update the opentaint binary to the latest release. To get a specified version, give the version argument. Only upgrades are possible. The command refuses a version that is older than the current one. -This command detects how opentaint was installed and provides appropriate -instructions for package manager installations. For binary installations, -it performs an in-place update. +If opentaint was installed with Homebrew or npm, the command does not change the binary. It shows the correct package-manager command. -Only upgrades are supported — downgrading to an older version is refused.`, +To see the latest version without a download, use --check. To skip the confirmation prompt, use --yes. + +After a successful update, remove the old artifacts with "opentaint prune".`, + Example: ` # Update to the latest release + opentaint update + + # See if a newer version is available, without a download + opentaint update --check + + # Update to a specified version without a prompt + opentaint update 1.2.3 --yes + + # Recipe: update, then remove the artifacts of the old version + opentaint update --yes + opentaint prune --yes`, Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { // Check installation method first @@ -35,11 +47,11 @@ Only upgrades are supported — downgrading to an older version is refused.`, switch method { case utils.InstallMethodHomebrew: out.Print("opentaint was installed via Homebrew.") - out.Print("Run: brew upgrade --cask opentaint") + suggest("To update, run:", "brew upgrade --cask opentaint") return case utils.InstallMethodNpm: out.Print("opentaint was installed via npm.") - out.Print("Run: npm install -g @seqra/opentaint@latest") + suggest("To update, run:", "npm install -g @seqra/opentaint@latest") return } @@ -75,7 +87,7 @@ Only upgrades are supported — downgrading to an older version is refused.`, out.Warnf("Could not compare versions: %s", err) out.Printf("Current: %s, Latest: %s", currentVersion, targetVersion) if !updateYes { - out.Print("Use --yes to proceed anyway.") + suggest("To proceed anyway, run:", withFlag(rerunWithoutDryRun(), "--yes")) return } } @@ -94,9 +106,8 @@ Only upgrades are supported — downgrading to an older version is refused.`, out.Section("Update Available"). Field("Current version", fmt.Sprintf("v%s", currentVersion)). Field("Latest version", fmt.Sprintf("v%s", targetVersion)). - Line(). - Text("Run 'opentaint update' to update."). Render() + suggest("To update, run:", "opentaint update") return } @@ -107,6 +118,7 @@ Only upgrades are supported — downgrading to an older version is refused.`, if !updateYes { if !out.Confirm("Proceed with update?", false) { out.Print("Update cancelled.") + suggest("To update without confirming, run:", "opentaint update --yes") return } } @@ -132,7 +144,7 @@ Only upgrades are supported — downgrading to an older version is refused.`, } out.Successf("Successfully updated to v%s", targetVersion) - suggest("To clean up old artifacts run", "opentaint prune") + suggest("To clean up old artifacts, run:", "opentaint prune") }, } diff --git a/cli/internal/analyzer/exit.go b/cli/internal/analyzer/exit.go index 6d38e75992..9e97afbdad 100644 --- a/cli/internal/analyzer/exit.go +++ b/cli/internal/analyzer/exit.go @@ -37,9 +37,9 @@ func ExitMessage(code int) string { case ExitConfigError: return "project configuration error" case ExitTimeout: - return "analysis timed out — try increasing --timeout or --max-memory" + return "analysis timed out: try increasing --timeout or --max-memory" case ExitOOM: - return "out of memory — try increasing --max-memory (e.g. --max-memory 16G)" + return "out of memory: try increasing --max-memory (e.g. --max-memory 16G)" case ExitException: return "unhandled analyzer exception" default: diff --git a/cli/internal/globals/global.go b/cli/internal/globals/global.go index 8bcba3f393..a5e6381e17 100644 --- a/cli/internal/globals/global.go +++ b/cli/internal/globals/global.go @@ -44,6 +44,7 @@ type Scan struct { Timeout time.Duration `mapstructure:"timeout"` MaxMemory string `mapstructure:"max_memory"` CodeFlowLimit int64 `mapstructure:"code_flow_limit"` + Baseline string `mapstructure:"baseline"` } type Output struct { @@ -68,6 +69,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 Java 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/baseline.go b/cli/internal/sarif/baseline.go new file mode 100644 index 0000000000..174590150f --- /dev/null +++ b/cli/internal/sarif/baseline.go @@ -0,0 +1,477 @@ +package sarif + +import ( + "crypto/rand" + "fmt" +) + +// Comparison is the classification of a report's results against a baseline +// report. States are keyed by result pointer, so a Comparison is only valid for +// the exact *Report it was computed from. +type Comparison struct { + states map[*Result]BaselineState + changes map[*Result]Change + + // changesByIdentity records what moved per identity value. It lets a + // filtered view — which holds copies of the classified results — recover + // the change attribution by fingerprint rather than by pointer. + changesByIdentity map[string]Change + + // remnantsByIdentity records, per absent identity value, what the current + // report still shows of the finding. Keyed by identity for the same reason + // as changesByIdentity: the listing displays copies. + remnantsByIdentity map[string]Remnant + + // Counts holds the number of current results in each state, plus the number + // of baseline results with no match in the current report under Absent. + Counts map[BaselineState]int + // ChangeCounts holds the number of Updated results per kind of change, so a + // report can say a source moved rather than only that something did. + ChangeCounts map[Change]int + // Absent lists the baseline results that no longer appear. They are + // reported, never written back into the current report. + Absent []*Result + // NotRun lists baseline results whose rule did not run in the current scan, + // so their absence says nothing about whether they were fixed. Counting them + // as fixed would report a rule exclusion as a wave of resolved findings. + NotRun []*Result + // Unmatchable counts current results carrying no identity fingerprint, which + // therefore cannot be compared at all. + Unmatchable int + // BaselineGUID is the baseline run's automation guid, or "" if it has none. + BaselineGUID string +} + +// StateOf returns the state computed for a result, or "" when the result could +// not be matched (no identity fingerprint). Results that are copies of the +// classified ones — a filtered listing copies results — miss the pointer map, +// so the state the comparison wrote onto the result itself is the fallback. +func (c *Comparison) StateOf(r *Result) BaselineState { + if c == nil { + return "" + } + if state, ok := c.states[r]; ok { + return state + } + if r != nil && r.BaselineState != nil { + return *r.BaselineState + } + return "" +} + +// Change says what moved underneath the identity of a finding that matched the +// baseline. SARIF's baselineState has one value for all of it — "updated" — but +// the two cases mean different things to whoever reads the report, so they are +// counted and named apart. +type Change string + +const ( + // ChangeNone is a finding that matched with nothing below it moved. + ChangeNone Change = "" + // ChangeSource is the same sink reached from a different source: the data + // now arrives by a route that was not in the baseline. Worth a look — a new + // entry point can reach code that was already known to be dangerous. + ChangeSource Change = "source" + // ChangePath is the same source and the same sink, joined by a different + // call path. Usually a refactoring of the code in between. + ChangePath Change = "path" +) + +// Label describes a change in the words a report uses. +func (c Change) Label() string { + switch c { + case ChangeSource: + return "source changed" + case ChangePath: + return "path changed" + default: + return "" + } +} + +// Remnant is the evidence that an absent baseline finding may still exist in +// the current report under a different identity. An absence only proves that +// the hash is gone, and the hash changes when the code it covers moves. So the +// comparison looks for what remains of the finding before the summary reports +// the absence as a plain fact. +type Remnant string + +const ( + // RemnantNone means nothing in the current report points at the finding. + RemnantNone Remnant = "" + // RemnantDrifted means a new current result reports the same rule in the + // same file. That is a hint, not proof: the absent finding may have moved + // and taken its hash with it, or the new finding may be unrelated. + RemnantDrifted Remnant = "drifted" +) + +// Label describes a remnant in the words a report uses. +func (r Remnant) Label() string { + if r == RemnantDrifted { + return "possibly drifted" + } + return "" +} + +// RemnantOf returns what the current report still shows of an absent finding. +// The lookup runs by identity value, so it works both on the baseline results +// themselves and on the display copies that WithAbsent makes. +func (c *Comparison) RemnantOf(r *Result) Remnant { + if c == nil || c.remnantsByIdentity == nil { + return RemnantNone + } + id, ok := Identity(r, IdentityKey) + if !ok { + return RemnantNone + } + return c.remnantsByIdentity[id] +} + +// StateNote qualifies a result's baseline state for display: what moved under +// an updated finding, what remains of an absent one. Returns "" when there is +// nothing to add. +func (c *Comparison) StateNote(r *Result) string { + if c == nil || r == nil || r.BaselineState == nil { + return "" + } + switch *r.BaselineState { + case Updated: + // The pointer map is exact for the classified results. Display copies + // miss it and fall back to the identity lookup. + if change := c.changes[r]; change != ChangeNone { + return change.Label() + } + return c.changeOfIdentity(r).Label() + case Absent: + return c.RemnantOf(r).Label() + } + return "" +} + +// ChangeOf returns what moved under a matched result, or ChangeNone when +// nothing did or the result was not matched at all. +func (c *Comparison) ChangeOf(r *Result) Change { + if c == nil { + return ChangeNone + } + return c.changes[r] +} + +// changeOfIdentity looks up what moved under a result by its identity value, +// for results that are copies of the ones the comparison classified and so +// miss the pointer-keyed map. Two updated results sharing one identity share +// one recorded change, which is the identity's granularity. +func (c *Comparison) changeOfIdentity(r *Result) Change { + if c == nil || c.changesByIdentity == nil { + return ChangeNone + } + id, ok := Identity(r, IdentityKey) + if !ok { + return ChangeNone + } + return c.changesByIdentity[id] +} + +// CheckBaselineIdentity reports whether the baseline can be compared at all. A +// baseline that holds results but none carrying the identity fingerprint was +// produced without fingerprints, and comparing against it would silently +// classify every finding as new — hiding exactly the findings a baseline +// exists to remember. The check is cheap, so callers that pay for a scan +// before comparing can run it first. +func CheckBaselineIdentity(baseline *Report) error { + results := baseline.Results() + if len(results) == 0 { + return nil + } + for _, r := range results { + if _, ok := Identity(r, IdentityKey); ok { + return nil + } + } + return fmt.Errorf( + "no result in the baseline carries the %q fingerprint: "+ + "it was produced without fingerprints, or by an analyzer too old to emit this one", IdentityKey) +} + +// CompareToBaseline classifies every result in current against baseline. The +// sink hash is the identity. Results that match are additionally compared on +// the finer fingerprints to tell "unchanged" from "updated" and to say what +// moved. +func CompareToBaseline(current, baseline *Report) (*Comparison, error) { + baselineResults := baseline.Results() + + byIdentity := make(map[string][]*Result, len(baselineResults)) + for _, r := range baselineResults { + id, ok := Identity(r, IdentityKey) + if !ok { + continue + } + byIdentity[id] = append(byIdentity[id], r) + } + if len(baselineResults) > 0 && len(byIdentity) == 0 { + return nil, CheckBaselineIdentity(baseline) + } + + cmp := &Comparison{ + states: make(map[*Result]BaselineState), + changes: make(map[*Result]Change), + changesByIdentity: make(map[string]Change), + remnantsByIdentity: make(map[string]Remnant), + Counts: make(map[BaselineState]int), + ChangeCounts: make(map[Change]int), + BaselineGUID: baseline.RunGUID(), + } + + matched := make(map[string]bool, len(byIdentity)) + for _, r := range current.Results() { + id, ok := Identity(r, IdentityKey) + if !ok { + cmp.Unmatchable++ + continue + } + + previous, found := byIdentity[id] + if !found { + cmp.states[r] = New + cmp.Counts[New]++ + continue + } + + matched[id] = true + change := changeUnder(r, previous) + state := Updated + if change == ChangeNone { + state = Unchanged + } else { + cmp.changes[r] = change + cmp.changesByIdentity[id] = change + cmp.ChangeCounts[change]++ + } + cmp.states[r] = state + cmp.Counts[state]++ + } + + executed := current.executedRuleIDs() + for id, results := range byIdentity { + if matched[id] { + continue + } + for _, r := range results { + if !ranInCurrentScan(r, executed) { + cmp.NotRun = append(cmp.NotRun, r) + continue + } + cmp.Absent = append(cmp.Absent, r) + } + } + cmp.Counts[Absent] = len(cmp.Absent) + cmp.attributeAbsent(current) + + return cmp, nil +} + +// attributeAbsent records, for every absent finding, whatever the current +// report still shows of it: a new finding of the same rule in the same file +// suggests the absent finding moved and its hash moved with it. +func (c *Comparison) attributeAbsent(current *Report) { + if len(c.Absent) == 0 { + return + } + + newRuleFiles := map[string]bool{} + for _, r := range current.Results() { + if c.states[r] != New { + continue + } + if rf, ok := ruleFileKey(r); ok { + newRuleFiles[rf] = true + } + } + + for _, r := range c.Absent { + rf, ok := ruleFileKey(r) + if !ok || !newRuleFiles[rf] { + continue + } + if id, ok := Identity(r, IdentityKey); ok { + c.remnantsByIdentity[id] = RemnantDrifted + } + } +} + +// ruleFileKey pairs a result's rule id with the file of its primary location, +// which is as much identity as two reports share once every hash has changed. +func ruleFileKey(r *Result) (string, bool) { + if r.RuleID == nil || *r.RuleID == "" { + return "", false + } + loc, ok := primaryNodeLoc(r) + if !ok || loc.relFilePath == "" { + return "", false + } + return *r.RuleID + "\x00" + loc.relFilePath, true +} + +// WithAbsent returns a shallow copy of the report whose first run also carries +// the given baseline results, each stamped absent. It exists so that the absent +// findings — which live in the baseline and never in the current report — can be +// listed on request. Only the display path calls it. The copies never reach a +// report that is written back. +func (report *Report) WithAbsent(absent []*Result) *Report { + if len(absent) == 0 || len(report.Runs) == 0 { + return report + } + + out := *report + out.Runs = make([]Run, len(report.Runs)) + copy(out.Runs, report.Runs) + + run := out.Runs[0] + results := make([]Result, 0, len(run.Results)+len(absent)) + results = append(results, run.Results...) + for _, r := range absent { + gone := *r + state := Absent + gone.BaselineState = &state + results = append(results, gone) + } + run.Results = results + out.Runs[0] = run + return &out +} + +// executedRuleIDs returns the ids of the rules the run declares it executed, or +// nil when the report declares none — in which case nothing can be said about +// which rules ran and every unmatched baseline finding is treated as absent. +func (report *Report) executedRuleIDs() map[string]bool { + ids := map[string]bool{} + for i := range report.Runs { + for _, rule := range report.Runs[i].Tool.Driver.Rules { + if rule.ID != "" { + ids[rule.ID] = true + } + } + } + if len(ids) == 0 { + return nil + } + return ids +} + +// ranInCurrentScan reports whether the rule behind a baseline result was part of +// the current scan. A result without a rule id is assumed to have run: guessing +// "excluded" would hide a genuinely fixed finding. +func ranInCurrentScan(r *Result, executed map[string]bool) bool { + if executed == nil || r.RuleID == nil || *r.RuleID == "" { + return true + } + return executed[*r.RuleID] +} + +// changeUnder reports the coarsest thing that moved below a finding's identity. +// The refining keys are ordered nearest-first, so the first one that differs is +// the most meaningful description of the change: a source that moved is worth +// saying even though the path moved along with it. +func changeUnder(current *Result, previous []*Result) Change { + candidates := previous + for _, key := range refiningKeys { + candidates = matchingUnder(current, candidates, key) + if len(candidates) == 0 { + switch key { + case SourceSinkFingerprintKey: + return ChangeSource + default: + return ChangePath + } + } + } + return ChangeNone +} + +// matchingUnder keeps baseline results compatible with current under one +// refining key. The caller feeds the survivors into the next, finer key so a +// source match from one duplicate and a trace match from another cannot be +// combined into a false "unchanged" result. A missing fingerprint remains +// compatible because the finer comparison is unavailable on that pair. +func matchingUnder(current *Result, previous []*Result, key string) []*Result { + currentValue, ok := Identity(current, key) + if !ok { + return previous + } + matches := make([]*Result, 0, len(previous)) + for _, p := range previous { + previousValue, ok := Identity(p, key) + if !ok || previousValue == currentValue { + matches = append(matches, p) + } + } + return matches +} + +// Apply writes the comparison into the report: result.baselineState on every +// matched result, and run.baselineGuid when the baseline had a guid to cite and +// every result in that run received a state. SARIF requires every result in a +// run carrying baselineGuid to be classified, so a run with an unmatchable +// result must not claim that link. Unmatchable results are left untouched. +func (c *Comparison) Apply(report *Report) { + for runIdx := range report.Runs { + run := &report.Runs[runIdx] + complete := true + for resultIdx := range run.Results { + r := &run.Results[resultIdx] + state, ok := c.states[r] + if !ok { + complete = false + continue + } + value := state + r.BaselineState = &value + } + if c.BaselineGUID != "" && complete { + guid := c.BaselineGUID + run.BaselineGUID = &guid + } else { + run.BaselineGUID = nil + } + } +} + +// RunGUID returns the first run's automation guid, or "" when absent. This is +// what a later run cites as its baselineGuid. +func (report *Report) RunGUID() string { + for i := range report.Runs { + if details := report.Runs[i].AutomationDetails; details != nil && details.GUID != nil { + return *details.GUID + } + } + return "" +} + +// EnsureRunGUIDs stamps a v4 GUID into run.automationDetails.guid for every run +// that lacks one. The analyzer emits no automation details, so without this no +// report could ever be cited as a baseline by guid. Existing guids are kept. +func EnsureRunGUIDs(report *Report) { + for i := range report.Runs { + run := &report.Runs[i] + if run.AutomationDetails == nil { + run.AutomationDetails = &RunAutomationDetails{} + } + if run.AutomationDetails.GUID != nil && *run.AutomationDetails.GUID != "" { + continue + } + guid := newUUIDv4() + run.AutomationDetails.GUID = &guid + } +} + +// newUUIDv4 returns a random RFC 4122 version 4 UUID. Hand-rolled to avoid a +// dependency for sixteen bytes. rand.Read is documented never to fail. +func newUUIDv4() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + panic(fmt.Sprintf("crypto/rand failed: %v", err)) + } + b[6] = (b[6] & 0x0f) | 0x40 // version 4 + b[8] = (b[8] & 0x3f) | 0x80 // variant 10 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) +} diff --git a/cli/internal/sarif/baseline_test.go b/cli/internal/sarif/baseline_test.go new file mode 100644 index 0000000000..4abc9f9da7 --- /dev/null +++ b/cli/internal/sarif/baseline_test.go @@ -0,0 +1,525 @@ +package sarif + +import ( + "regexp" + "testing" +) + +// fps builds a partialFingerprints map from all three hashes, as the analyzer +// emits them. An empty value leaves that key out. +func fps(sink, sourceSink, trace string) map[string]string { + m := map[string]string{} + for key, value := range map[string]string{ + SinkFingerprintKey: sink, + SourceSinkFingerprintKey: sourceSink, + TraceFingerprintKey: trace, + } { + if value != "" { + m[key] = value + } + } + return m +} + +// fp is fps for a finding whose sink is implied by its source/sink hash, which +// is the common case: one sink, one source, one finding. +func fp(sourceSink, trace string) map[string]string { + sink := "" + if sourceSink != "" { + sink = "sink-of-" + sourceSink + } + return fps(sink, sourceSink, trace) +} + +func TestCompareClassifiesNewUnchangedUpdatedAbsent(t *testing.T) { + baseline := makeReport( + makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a")), + makeResult("b", Error, "b.java", 2, fp("id-b", "trace-b")), + makeResult("gone", Error, "c.java", 3, fp("id-gone", "trace-gone")), + ) + current := makeReport( + makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a")), // unchanged + makeResult("b", Error, "b.java", 9, fp("id-b", "trace-b-moved")), // updated + makeResult("fresh", Error, "d.java", 4, fp("id-fresh", "trace-fresh")), + ) + + cmp, err := CompareToBaseline(current, baseline) + if err != nil { + t.Fatalf("compare: %v", err) + } + + results := current.Results() + if got := cmp.StateOf(results[0]); got != Unchanged { + t.Errorf("first result: got %q, want unchanged", got) + } + if got := cmp.StateOf(results[1]); got != Updated { + t.Errorf("second result: got %q, want updated", got) + } + if got := cmp.StateOf(results[2]); got != New { + t.Errorf("third result: got %q, want new", got) + } + if cmp.Counts[Absent] != 1 { + t.Errorf("absent count: got %d, want 1", cmp.Counts[Absent]) + } + if len(cmp.Absent) != 1 || *cmp.Absent[0].RuleID != "gone" { + t.Errorf("absent results: got %v", cmp.Absent) + } + for state, want := range map[BaselineState]int{New: 1, Unchanged: 1, Updated: 1} { + if cmp.Counts[state] != want { + t.Errorf("%s count: got %d, want %d", state, cmp.Counts[state], want) + } + } +} + +func TestCompareTreatsMissingTraceHashAsUnchanged(t *testing.T) { + baseline := makeReport(makeResult("a", Error, "a.java", 1, fp("id-a", ""))) + current := makeReport(makeResult("a", Error, "a.java", 1, fp("id-a", ""))) + + cmp, err := CompareToBaseline(current, baseline) + if err != nil { + t.Fatalf("compare: %v", err) + } + if got := cmp.StateOf(current.Results()[0]); got != Unchanged { + t.Errorf("got %q, want unchanged", got) + } +} + +func TestCompareCountsUnmatchableResultsSeparately(t *testing.T) { + baseline := makeReport(makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a"))) + current := makeReport( + makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a")), + makeResult("nofp", Error, "b.java", 2, nil), + ) + + cmp, err := CompareToBaseline(current, baseline) + if err != nil { + t.Fatalf("compare: %v", err) + } + if cmp.Unmatchable != 1 { + t.Errorf("unmatchable: got %d, want 1", cmp.Unmatchable) + } + if got := cmp.StateOf(current.Results()[1]); got != "" { + t.Errorf("unmatchable result should have no state, got %q", got) + } + if cmp.Counts[New] != 0 { + t.Errorf("unmatchable must not be counted as new, got %d", cmp.Counts[New]) + } +} + +func TestCompareDuplicateIdentitiesBothMatch(t *testing.T) { + baseline := makeReport(makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a"))) + current := makeReport( + makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a")), + makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a")), + ) + + cmp, err := CompareToBaseline(current, baseline) + if err != nil { + t.Fatalf("compare: %v", err) + } + if cmp.Counts[Unchanged] != 2 { + t.Errorf("both duplicates should match: got %d unchanged", cmp.Counts[Unchanged]) + } + if cmp.Counts[Absent] != 0 { + t.Errorf("baseline entry was matched, want 0 absent, got %d", cmp.Counts[Absent]) + } +} + +func TestCompareEmptyBaselineMakesEverythingNew(t *testing.T) { + cmp, err := CompareToBaseline( + makeReport(makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a"))), + &Report{}, + ) + if err != nil { + t.Fatalf("compare: %v", err) + } + if cmp.Counts[New] != 1 { + t.Errorf("got %d new, want 1", cmp.Counts[New]) + } +} + +func TestCompareRejectsBaselineWithoutTheIdentityKey(t *testing.T) { + baseline := makeReport(makeResult("a", Error, "a.java", 1, fp("", "trace-a"))) + current := makeReport(makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a"))) + + _, err := CompareToBaseline(current, baseline) + if err == nil { + t.Fatal("expected an error when no baseline result carries the identity key") + } +} + +func TestCompareEmptyBaselineIsNotAKeyMismatch(t *testing.T) { + // A baseline with zero results has no fingerprints either, but that is a + // legitimate "nothing was known before", not a key mismatch. + if _, err := CompareToBaseline( + makeReport(makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a"))), + &Report{Runs: []Run{{}}}, + ); err != nil { + t.Errorf("unexpected error: %v", err) + } +} + +func TestApplyWritesBaselineStateAndGUID(t *testing.T) { + guid := "11111111-2222-3333-4444-555555555555" + baseline := makeReport(makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a"))) + baseline.Runs[0].AutomationDetails = &RunAutomationDetails{GUID: &guid} + current := makeReport( + makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a")), + makeResult("fresh", Error, "b.java", 2, fp("id-fresh", "trace-fresh")), + ) + + cmp, err := CompareToBaseline(current, baseline) + if err != nil { + t.Fatalf("compare: %v", err) + } + cmp.Apply(current) + + results := current.Results() + if results[0].BaselineState == nil || *results[0].BaselineState != Unchanged { + t.Errorf("first result state not written: %v", results[0].BaselineState) + } + if results[1].BaselineState == nil || *results[1].BaselineState != New { + t.Errorf("second result state not written: %v", results[1].BaselineState) + } + if current.Runs[0].BaselineGUID == nil || *current.Runs[0].BaselineGUID != guid { + t.Errorf("baselineGuid not written: %v", current.Runs[0].BaselineGUID) + } +} + +func TestApplyOmitsBaselineGUIDWhenBaselineHasNone(t *testing.T) { + baseline := makeReport(makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a"))) + current := makeReport(makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a"))) + + cmp, err := CompareToBaseline(current, baseline) + if err != nil { + t.Fatalf("compare: %v", err) + } + cmp.Apply(current) + + if current.Runs[0].BaselineGUID != nil { + t.Errorf("expected no baselineGuid, got %q", *current.Runs[0].BaselineGUID) + } + if current.Results()[0].BaselineState == nil { + t.Error("states should still be written without a baseline guid") + } +} + +func TestApplyLeavesUnmatchableResultsUnannotated(t *testing.T) { + guid := "11111111-2222-3333-4444-555555555555" + baseline := makeReport(makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a"))) + baseline.Runs[0].AutomationDetails = &RunAutomationDetails{GUID: &guid} + current := makeReport(makeResult("nofp", Error, "b.java", 2, nil)) + + cmp, err := CompareToBaseline(current, baseline) + if err != nil { + t.Fatalf("compare: %v", err) + } + cmp.Apply(current) + + if current.Results()[0].BaselineState != nil { + t.Errorf("unmatchable result was annotated: %v", *current.Results()[0].BaselineState) + } + if current.Runs[0].BaselineGUID != nil { + t.Errorf("baselineGuid was written although a result has no baselineState: %q", *current.Runs[0].BaselineGUID) + } +} + +func TestEnsureRunGUIDsStampsMissingOnesOnly(t *testing.T) { + existing := "11111111-2222-3333-4444-555555555555" + report := &Report{Runs: []Run{ + {AutomationDetails: &RunAutomationDetails{GUID: &existing}}, + {}, + }} + + EnsureRunGUIDs(report) + + if report.Runs[0].AutomationDetails.GUID == nil || *report.Runs[0].AutomationDetails.GUID != existing { + t.Error("existing guid was overwritten") + } + if report.Runs[1].AutomationDetails == nil || report.Runs[1].AutomationDetails.GUID == nil { + t.Fatal("missing guid was not stamped") + } + uuidV4 := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + if got := *report.Runs[1].AutomationDetails.GUID; !uuidV4.MatchString(got) { + t.Errorf("stamped guid %q is not a v4 uuid", got) + } +} + +func TestReportBaselineGUIDReadsFirstRun(t *testing.T) { + guid := "11111111-2222-3333-4444-555555555555" + report := &Report{Runs: []Run{{AutomationDetails: &RunAutomationDetails{GUID: &guid}}}} + if got := report.RunGUID(); got != guid { + t.Errorf("got %q, want %q", got, guid) + } + if got := (&Report{Runs: []Run{{}}}).RunGUID(); got != "" { + t.Errorf("got %q, want empty", got) + } +} + +// withRules declares the rules a run executed, which is how a comparison tells +// "this rule found nothing" from "this rule never ran". +func withRules(report *Report, ruleIDs ...string) *Report { + for i := range report.Runs { + var rules []ReportingDescriptor + for _, id := range ruleIDs { + rules = append(rules, ReportingDescriptor{ID: id}) + } + report.Runs[i].Tool.Driver.Rules = rules + } + return report +} + +func TestCompareKeepsExcludedRuleOutOfFixed(t *testing.T) { + baseline := makeReport( + makeResult("kept", Error, "a.java", 1, fp("id-kept", "trace-kept")), + makeResult("excluded", Error, "b.java", 2, fp("id-excluded", "trace-excluded")), + makeResult("kept", Error, "c.java", 3, fp("id-fixed", "trace-fixed")), + ) + // The current scan ran only "kept": "excluded" produced nothing because it + // never loaded, while id-fixed genuinely disappeared. + current := withRules(makeReport( + makeResult("kept", Error, "a.java", 1, fp("id-kept", "trace-kept")), + ), "kept") + + cmp, err := CompareToBaseline(current, baseline) + if err != nil { + t.Fatalf("compare: %v", err) + } + + if got := cmp.Counts[Absent]; got != 1 { + t.Errorf("Fixed = %d, want 1 (only the finding whose rule actually ran)", got) + } + if got := len(cmp.NotRun); got != 1 { + t.Fatalf("NotRun = %d, want 1", got) + } + if got := *cmp.NotRun[0].RuleID; got != "excluded" { + t.Errorf("NotRun holds %q, want the excluded rule", got) + } +} + +func TestCompareTreatsMissingRuleListAsEverythingRan(t *testing.T) { + // A report that declares no rules says nothing about what ran, so guessing + // "excluded" would hide genuinely fixed findings. + baseline := makeReport(makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a"))) + current := makeReport() + + cmp, err := CompareToBaseline(current, baseline) + if err != nil { + t.Fatalf("compare: %v", err) + } + if got := cmp.Counts[Absent]; got != 1 { + t.Errorf("Fixed = %d, want 1", got) + } + if len(cmp.NotRun) != 0 { + t.Errorf("NotRun = %d, want 0", len(cmp.NotRun)) + } +} + +func TestWithAbsentAddsFixedFindingsForDisplayOnly(t *testing.T) { + current := makeReport(makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a"))) + gone := makeResult("b", Error, "b.java", 2, fp("id-b", "trace-b")) + + listing := current.WithAbsent([]*Result{&gone}) + + if got := len(listing.Results()); got != 2 { + t.Fatalf("listing holds %d results, want 2", got) + } + if got := len(current.Results()); got != 1 { + t.Errorf("the source report grew to %d results: WithAbsent must not mutate it", got) + } + added := listing.Results()[1] + if added.BaselineState == nil || *added.BaselineState != Absent { + t.Error("the added result is not marked absent") + } + if gone.BaselineState != nil { + t.Error("the baseline result itself was stamped, but only the copy may be") + } +} + +// Under the default sink identity, a finding that keeps its sink but gains a +// different source is "updated", and the report must be able to say which. +func TestChangeUnderSinkIdentityNamesWhatMoved(t *testing.T) { + baseline := makeReport( + makeResult("a", Error, "a.java", 1, fps("sink-a", "src-a", "trace-a")), + makeResult("b", Error, "b.java", 2, fps("sink-b", "src-b", "trace-b")), + makeResult("c", Error, "c.java", 3, fps("sink-c", "src-c", "trace-c")), + ) + current := makeReport( + makeResult("a", Error, "a.java", 1, fps("sink-a", "src-a", "trace-a")), // nothing moved + makeResult("b", Error, "b.java", 2, fps("sink-b", "src-b-other", "trace-b2")), // source moved + makeResult("c", Error, "c.java", 3, fps("sink-c", "src-c", "trace-c-longer")), // path moved + ) + + cmp, err := CompareToBaseline(current, baseline) + if err != nil { + t.Fatalf("compare: %v", err) + } + + results := current.Results() + for _, tc := range []struct { + name string + idx int + state BaselineState + want Change + }{ + {"nothing moved", 0, Unchanged, ChangeNone}, + {"source moved", 1, Updated, ChangeSource}, + {"path moved", 2, Updated, ChangePath}, + } { + if got := cmp.StateOf(results[tc.idx]); got != tc.state { + t.Errorf("%s: state = %q, want %q", tc.name, got, tc.state) + } + if got := cmp.ChangeOf(results[tc.idx]); got != tc.want { + t.Errorf("%s: change = %q, want %q", tc.name, got, tc.want) + } + } + + if got := cmp.ChangeCounts[ChangeSource]; got != 1 { + t.Errorf("source-changed count = %d, want 1", got) + } + if got := cmp.ChangeCounts[ChangePath]; got != 1 { + t.Errorf("path-changed count = %d, want 1", got) + } + if got := cmp.Counts[Updated]; got != 2 { + t.Errorf("updated count = %d, want 2 (every change is still one SARIF state)", got) + } +} + +// A source that moves drags the trace with it. The report names the source, +// because that is the more meaningful of the two. +func TestChangeReportsTheCoarsestThingThatMoved(t *testing.T) { + baseline := makeReport(makeResult("a", Error, "a.java", 1, fps("sink-a", "src-a", "trace-a"))) + current := makeReport(makeResult("a", Error, "a.java", 1, fps("sink-a", "src-z", "trace-z"))) + + cmp, err := CompareToBaseline(current, baseline) + if err != nil { + t.Fatalf("compare: %v", err) + } + if got := cmp.ChangeOf(current.Results()[0]); got != ChangeSource { + t.Errorf("change = %q, want %q", got, ChangeSource) + } +} + +// Matching each refining hash against a different duplicate is not enough to +// prove that the current finding is unchanged. The source/trace pair must have +// existed together on one baseline result. +func TestChangeUnderDoesNotMixFingerprintsAcrossBaselineDuplicates(t *testing.T) { + baseline := makeReport( + makeResult("a", Error, "a.java", 1, fps("sink-a", "source-1", "trace-1")), + makeResult("a", Error, "a.java", 1, fps("sink-a", "source-2", "trace-2")), + ) + current := makeReport( + makeResult("a", Error, "a.java", 1, fps("sink-a", "source-1", "trace-2")), + ) + + cmp, err := CompareToBaseline(current, baseline) + if err != nil { + t.Fatalf("compare: %v", err) + } + if got := cmp.StateOf(current.Results()[0]); got != Updated { + t.Errorf("state = %q, want updated: no baseline result has source-1 and trace-2 together", got) + } + if got := cmp.ChangeOf(current.Results()[0]); got != ChangePath { + t.Errorf("change = %q, want %q", got, ChangePath) + } +} + +// A new finding of the same rule in the same file is the hint that the sink +// hash itself drifted. +func TestRemnantDriftedNeedsANewSameRuleFindingInTheSameFile(t *testing.T) { + baseline := makeReport( + makeResult("a", Error, "a.java", 10, fps("sink-old", "src-a", "trace-a")), + makeResult("b", Error, "b.java", 20, fps("sink-gone", "src-b", "trace-b")), + ) + current := makeReport( + makeResult("a", Error, "a.java", 12, fps("sink-new", "src-a2", "trace-a2")), + ) + + cmp, err := CompareToBaseline(current, baseline) + if err != nil { + t.Fatalf("compare: %v", err) + } + if len(cmp.Absent) != 2 { + t.Fatalf("absent = %d, want 2", len(cmp.Absent)) + } + for _, r := range cmp.Absent { + want := RemnantNone + if *r.RuleID == "a" { + want = RemnantDrifted + } + if got := cmp.RemnantOf(r); got != want { + t.Errorf("rule %s: remnant = %q, want %q", *r.RuleID, got, want) + } + } +} + +// Only new current findings hint at a move. A finding that matched the +// baseline is accounted for and says nothing about the absent one. +func TestRemnantIgnoresMatchedFindingsOfTheSameRule(t *testing.T) { + baseline := makeReport( + makeResult("a", Error, "a.java", 10, fps("sink-kept", "src-a", "trace-a")), + makeResult("a", Error, "a.java", 20, fps("sink-gone", "src-b", "trace-b")), + ) + current := makeReport( + makeResult("a", Error, "a.java", 10, fps("sink-kept", "src-a", "trace-a")), + ) + + cmp, err := CompareToBaseline(current, baseline) + if err != nil { + t.Fatalf("compare: %v", err) + } + if len(cmp.Absent) != 1 { + t.Fatalf("absent = %d, want 1", len(cmp.Absent)) + } + if got := cmp.RemnantOf(cmp.Absent[0]); got != RemnantNone { + t.Errorf("remnant = %q, want none: the same-rule finding was matched, not new", got) + } +} + +// The remnant lookup runs by identity, so the display copies that WithAbsent +// stamps resolve to the same remnant as the baseline results themselves. +func TestRemnantResolvesOnDisplayCopies(t *testing.T) { + baseline := makeReport(makeResult("a", Error, "a.java", 1, fps("sink-old", "src-old", "trace-old"))) + current := makeReport(makeResult("a", Error, "a.java", 4, fps("sink-new", "src-new", "trace-new"))) + + cmp, err := CompareToBaseline(current, baseline) + if err != nil { + t.Fatalf("compare: %v", err) + } + listing := current.WithAbsent(cmp.Absent) + copyOfGone := listing.Results()[1] + if got := cmp.RemnantOf(copyOfGone); got != RemnantDrifted { + t.Errorf("remnant on copy = %q, want %q", got, RemnantDrifted) + } + if got := cmp.StateNote(copyOfGone); got != "possibly drifted" { + t.Errorf("state note = %q, want %q", got, "possibly drifted") + } +} + +func TestStateNoteNamesWhatMovedUnderUpdated(t *testing.T) { + baseline := makeReport(makeResult("a", Error, "a.java", 1, fps("sink-a", "src-a", "trace-a"))) + current := makeReport(makeResult("a", Error, "a.java", 1, fps("sink-a", "src-b", "trace-b"))) + + cmp, err := CompareToBaseline(current, baseline) + if err != nil { + t.Fatalf("compare: %v", err) + } + cmp.Apply(current) + if got := cmp.StateNote(current.Results()[0]); got != "source changed" { + t.Errorf("state note = %q, want %q", got, "source changed") + } +} + +func TestCheckBaselineIdentity(t *testing.T) { + if err := CheckBaselineIdentity(&Report{}); err != nil { + t.Errorf("an empty baseline is comparable: %v", err) + } + carrying := makeReport(makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a"))) + if err := CheckBaselineIdentity(carrying); err != nil { + t.Errorf("baseline carries the identity fingerprint: %v", err) + } + traceOnly := makeReport(makeResult("a", Error, "a.java", 1, fp("", "trace-a"))) + if err := CheckBaselineIdentity(traceOnly); err == nil { + t.Error("expected an error for a baseline without the identity fingerprint") + } +} diff --git a/cli/internal/sarif/filter.go b/cli/internal/sarif/filter.go index 20c6492002..060ed275aa 100644 --- a/cli/internal/sarif/filter.go +++ b/cli/internal/sarif/filter.go @@ -7,25 +7,20 @@ import ( "github.com/bmatcuk/doublestar/v4" ) -// DefaultFingerprintKey is the partialFingerprints key matched by -// --partial-fingerprint when --partial-fingerprint-key is not supplied. -const DefaultFingerprintKey = "vulnerabilityWithTraceHash/v1" - // Filters describes the finding-selection criteria supplied on the summary // command. Empty fields mean "do not filter on this dimension". type Filters struct { Paths []string // doublestar globs against the relative file path Severities []string // SARIF levels: error/warning/note/none RuleIDs []string // full id, leaf, or doublestar glob over the full id - Fingerprints []string // git-style prefixes of the chosen fingerprint key's value - FingerprintKey string // partialFingerprints key to match ("" = DefaultFingerprintKey) + Fingerprints []string // git-style prefixes of the identity fingerprint's value + BaselineStates []string // SARIF baselineState values: new/unchanged/updated/absent } -// active reports whether any filter dimension is set. FingerprintKey is -// intentionally excluded: it only selects which key Fingerprints matches -// against, so it has no effect without Fingerprints set. +// active reports whether any filter dimension is set. func (f Filters) active() bool { - return len(f.Paths) > 0 || len(f.Severities) > 0 || len(f.RuleIDs) > 0 || len(f.Fingerprints) > 0 + return len(f.Paths) > 0 || len(f.Severities) > 0 || len(f.RuleIDs) > 0 || + len(f.Fingerprints) > 0 || len(f.BaselineStates) > 0 } // Filter returns a shallow copy of the report whose Runs[].Results contain only @@ -59,18 +54,92 @@ 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) { return false } - if len(f.Fingerprints) > 0 && !matchFingerprint(r, f.FingerprintKey, f.Fingerprints) { + if len(f.Fingerprints) > 0 && !matchFingerprint(r, f.Fingerprints) { + return false + } + if len(f.BaselineStates) > 0 && !matchBaselineState(r, f.BaselineStates) { return false } return true } +// matchesAs is matches for a result whose baseline state is known from the +// comparison rather than carried on the result itself. Absent findings live in +// the baseline report and are never stamped with a state, so they can only be +// filtered by a caller that already knows what they are. +func (f Filters) matchesAs(r *Result, state BaselineState) bool { + if len(f.BaselineStates) > 0 && !stateNamed(state, f.BaselineStates) { + return false + } + stateless := f + stateless.BaselineStates = nil + return stateless.matches(r) +} + +// WantsAbsent reports whether the filter asks for absent findings, which the +// caller must add to the listing from the baseline: they exist nowhere in the +// current report. +func (f Filters) WantsAbsent() bool { + return stateNamed(Absent, f.BaselineStates) +} + +// stateNamed reports whether states names the given baseline state. +func stateNamed(state BaselineState, states []string) bool { + for _, s := range states { + if strings.EqualFold(strings.TrimSpace(s), string(state)) { + return true + } + } + return false +} + +// matchBaselineState reports whether the result's baselineState equals any +// supplied value (case-insensitive). A result with no baselineState never +// matches: it was not compared against a baseline, so no state claim holds. +func matchBaselineState(r *Result, states []string) bool { + if r.BaselineState == nil { + return false + } + actual := strings.ToLower(string(*r.BaselineState)) + for _, s := range states { + if strings.ToLower(strings.TrimSpace(s)) == actual { + return true + } + } + return false +} + +// ParseBaselineStates validates --baseline-state values against the SARIF +// enumeration, returning them normalized. +func ParseBaselineStates(values []string) ([]string, error) { + valid := map[string]BaselineState{ + "new": New, + "unchanged": Unchanged, + "updated": Updated, + "absent": Absent, + } + var out []string + for _, v := range values { + normalized := strings.ToLower(strings.TrimSpace(v)) + if normalized == "" { + continue + } + state, ok := valid[normalized] + if !ok { + return nil, fmt.Errorf( + "invalid baseline state %q: valid values are new, unchanged, updated, absent", v) + } + out = append(out, string(state)) + } + return out, nil +} + // matchPath reports whether the result's primary location's relative file path // matches any of the doublestar glob patterns. func matchPath(r *Result, patterns []string) bool { @@ -90,9 +159,9 @@ func matchPath(r *Result, patterns []string) bool { return false } -// matchSeverity reports whether the result's level equals any supplied level +// MatchesSeverity 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 { @@ -115,13 +184,17 @@ func ruleLeaf(id string) string { return id } -// 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. +// matchRuleID reports whether the result's rule-id matches any supplied value. func matchRuleID(r *Result, values []string) bool { - if r.RuleID == nil { - return false - } - full := *r.RuleID + return r.RuleID != nil && MatchesRuleID(*r.RuleID, values) +} + +// MatchesRuleID reports whether a rule id matches any supplied value as a +// full-id exact match, a leaf exact match, or a doublestar glob over the full +// id — globs deliberately never match the bare leaf. This is the one rule-id +// grammar: summary's --rule-id filter and scan's rules.only/rules.exclude and +// --exclude-rule-id selection all use it. +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 @@ -139,21 +212,19 @@ func matchRuleID(r *Result, values []string) bool { return false } -// fingerprintValue returns the result's partialFingerprints value under key, or -// "" when the key is absent or its value is empty. When key is empty the default -// key is used. -func fingerprintValue(r *Result, key string) string { - if key == "" { - key = DefaultFingerprintKey - } - return r.PartialFingerprints[key] +// fingerprintValue returns the result's identity fingerprint, or "" when the +// result carries none. It is the same value triage resolves prefixes against, +// so a fingerprint shown in the listing can always be pasted into +// triage --accept. +func fingerprintValue(r *Result) string { + v, _ := Identity(r, IdentityKey) + return v } -// matchFingerprint reports whether the result's partialFingerprints value under -// key has any supplied value as a prefix (git short-hash style). When key is -// empty the default key is used. -func matchFingerprint(r *Result, key string, prefixes []string) bool { - val := fingerprintValue(r, key) +// matchFingerprint reports whether the result's identity fingerprint has any +// supplied value as a prefix (git short-hash style). +func matchFingerprint(r *Result, prefixes []string) bool { + val := fingerprintValue(r) if val == "" { return false } @@ -170,8 +241,14 @@ 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) +} + +// ValidateSeverityFor is ValidateSeverity for a caller whose flag is not +// --severity, so the message names the flag the user actually typed. +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..6d1ac33806 100644 --- a/cli/internal/sarif/filter_test.go +++ b/cli/internal/sarif/filter_test.go @@ -22,30 +22,31 @@ 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") } } func TestMatchFingerprint(t *testing.T) { r := makeResult("r", Error, "a.java", 1, map[string]string{ - DefaultFingerprintKey: "abc123def456", + IdentityKey: "abc123def456", }) - if !matchFingerprint(&r, "", []string{"abc123"}) { - t.Error("expected git-style prefix match on default key") + if !matchFingerprint(&r, []string{"abc123"}) { + t.Error("expected git-style prefix match") } - if matchFingerprint(&r, "", []string{"zzz"}) { + if matchFingerprint(&r, []string{"zzz"}) { t.Error("expected non-prefix not to match") } - if matchFingerprint(&r, "missing/key", []string{"abc"}) { - t.Error("expected absent key not to match") + bare := makeResult("r", Error, "a.java", 1, nil) + if matchFingerprint(&bare, []string{"abc"}) { + t.Error("expected a result without the identity fingerprint not to match") } } diff --git a/cli/internal/sarif/group.go b/cli/internal/sarif/group.go index 2c444477a2..f922a70301 100644 --- a/cli/internal/sarif/group.go +++ b/cli/internal/sarif/group.go @@ -21,8 +21,15 @@ type ListingOptions struct { VerboseFlow bool MaxNestingLevel int // < 0 means "no cap" (legacy flow rendering) GroupBy GroupDimension // default groupByFilePath - FingerprintKey string // "" = DefaultFingerprintKey CodeFlows CodeFlowSelection // zero value = render first flow only + // ShowSuppressed lists findings that carry an honored suppression. They are + // hidden by default: a suppressed finding is one somebody already decided + // about. Hiding happens here rather than in Filters so that the summary + // counts still see every result and can report how many were suppressed. + ShowSuppressed bool + // Comparison qualifies baseline states in the listing: what moved under an + // updated finding, what remains of an absent one. nil prints bare states. + Comparison *Comparison } // ParseGroupDimension converts a --group-by flag value into a GroupDimension. diff --git a/cli/internal/sarif/identity.go b/cli/internal/sarif/identity.go new file mode 100644 index 0000000000..7e9fa00603 --- /dev/null +++ b/cli/internal/sarif/identity.go @@ -0,0 +1,101 @@ +package sarif + +import ( + "fmt" + "sort" + "strings" +) + +// Fingerprint keys emitted by the analyzer under result.partialFingerprints, +// from the most exact identity to the coarsest. Every one of them hashes the +// rule id, so a fingerprint never spans two rules. +// +// TraceFingerprintKey adds the sink and every location on every trace: an exact +// identity that changes whenever anything on the flow path moves. +// SourceSinkFingerprintKey adds the sink and the source (first) location of each +// trace, so it survives refactoring of the intermediate call path. +// SinkFingerprintKey adds the sink alone, so it survives any change to where the +// untrusted data comes from. +const ( + TraceFingerprintKey = "vulnerabilityWithTraceHash/v1" + SourceSinkFingerprintKey = "vulnerabilitySourceSinkHash/v1" + SinkFingerprintKey = "vulnerabilitySinkHash/v1" +) + +// IdentityKey is the fingerprint that decides whether a finding in one report +// is "the same finding" as one in another report. It is always the sink hash: +// the sink hash names the vulnerable statement and nothing else, so a decision +// survives every edit to how the untrusted data reaches it. The analyzer +// reports one finding per rule and sink, so the coarsest key loses no findings +// — it only stops them from changing identity. The finer keys never match +// findings across reports. They only describe what moved underneath a finding. +const IdentityKey = SinkFingerprintKey + +// refiningKeys are the keys that refine the identity, nearest first. The first +// one that differs between two matched findings is the most meaningful +// description of what changed. +var refiningKeys = []string{SourceSinkFingerprintKey, TraceFingerprintKey} + +// Identity returns the result's value for the given fingerprint key. The second +// return is false when the result carries no such fingerprint, which means it +// cannot be matched against a baseline or named in a suppression. +func Identity(r *Result, key string) (string, bool) { + if r == nil || r.PartialFingerprints == nil { + return "", false + } + v, ok := r.PartialFingerprints[key] + if !ok || v == "" { + return "", false + } + return v, true +} + +// Results returns pointers to every result across every run, so callers can +// annotate results in place. +func (report *Report) Results() []*Result { + var out []*Result + for runIdx := range report.Runs { + run := &report.Runs[runIdx] + for resultIdx := range run.Results { + out = append(out, &run.Results[resultIdx]) + } + } + return out +} + +// ResolvePrefix finds the results whose identity fingerprint starts with +// prefix, git-style. All matches must share one fingerprint value: results +// with the same identity are the same finding to a decision, and one sink +// legitimately appears on several results. A prefix matching two distinct +// values is ambiguous, and an empty or unmatched prefix is an error — a +// decision names a finding, never "whichever matched first". +func ResolvePrefix(report *Report, prefix string) ([]*Result, error) { + if prefix == "" { + return nil, fmt.Errorf("fingerprint prefix must not be empty") + } + + var matches []*Result + distinct := map[string]bool{} + for _, r := range report.Results() { + fp, ok := Identity(r, IdentityKey) + if !ok || !strings.HasPrefix(fp, prefix) { + continue + } + matches = append(matches, r) + distinct[fp] = true + } + + if len(matches) == 0 { + return nil, fmt.Errorf("no finding matches fingerprint %q", prefix) + } + if len(distinct) > 1 { + values := make([]string, 0, len(distinct)) + for v := range distinct { + values = append(values, v) + } + sort.Strings(values) + return nil, fmt.Errorf("fingerprint %q is ambiguous, it matches %d findings: %s", + prefix, len(matches), strings.Join(values, ", ")) + } + return matches, nil +} diff --git a/cli/internal/sarif/identity_test.go b/cli/internal/sarif/identity_test.go new file mode 100644 index 0000000000..cdab2549a8 --- /dev/null +++ b/cli/internal/sarif/identity_test.go @@ -0,0 +1,148 @@ +package sarif + +import ( + "strings" + "testing" +) + +func TestIdentityReadsChosenKey(t *testing.T) { + r := makeResult("rule", Error, "a.java", 1, map[string]string{ + SourceSinkFingerprintKey: "src-sink-hash", + TraceFingerprintKey: "trace-hash", + }) + got, ok := Identity(&r, SourceSinkFingerprintKey) + if !ok || got != "src-sink-hash" { + t.Errorf("got (%q, %v), want (src-sink-hash, true)", got, ok) + } + got, ok = Identity(&r, TraceFingerprintKey) + if !ok || got != "trace-hash" { + t.Errorf("got (%q, %v), want (trace-hash, true)", got, ok) + } +} + +func TestIdentityMissingKeyIsNotIdentifiable(t *testing.T) { + r := makeResult("rule", Error, "a.java", 1, map[string]string{TraceFingerprintKey: "trace"}) + if _, ok := Identity(&r, SourceSinkFingerprintKey); ok { + t.Error("expected missing key to report not-identifiable") + } + + noPrints := makeResult("rule", Error, "a.java", 1, nil) + if _, ok := Identity(&noPrints, SourceSinkFingerprintKey); ok { + t.Error("expected nil partialFingerprints to report not-identifiable") + } +} + +func TestResultsIteratesEveryRun(t *testing.T) { + report := &Report{Runs: []Run{ + {Results: []Result{makeResult("a", Error, "a.java", 1, nil)}}, + {Results: []Result{makeResult("b", Error, "b.java", 2, nil), makeResult("c", Error, "c.java", 3, nil)}}, + }} + got := report.Results() + if len(got) != 3 { + t.Fatalf("got %d results, want 3", len(got)) + } + // Results must be pointers into the report so mutations stick. + got[0].Level = lvlptr(Note) + if *report.Runs[0].Results[0].Level != Note { + t.Error("Results() did not return pointers into the report") + } +} + +func TestResolvePrefixFindsUniqueMatch(t *testing.T) { + report := makeReport( + makeResult("a", Error, "a.java", 1, map[string]string{SinkFingerprintKey: "q3Vf9k2nAAA"}), + makeResult("b", Error, "b.java", 2, map[string]string{SinkFingerprintKey: "8bc1d2xxBBB"}), + ) + matched, err := ResolvePrefix(report, "q3Vf9k") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(matched) != 1 || *matched[0].RuleID != "a" { + t.Errorf("resolved %d results, want the one with rule a", len(matched)) + } +} + +// Two results sharing one identity value are the same finding to a decision, +// so an exact or prefix match on that value resolves to both rather than +// erroring as ambiguous — under the sink identity such duplicates are +// legitimate, and no longer prefix could ever separate them. +func TestResolvePrefixReturnsAllDuplicatesOfOneIdentity(t *testing.T) { + report := makeReport( + makeResult("a", Error, "a.java", 1, map[string]string{SinkFingerprintKey: "q3Vf9kSAME"}), + makeResult("a", Error, "a.java", 9, map[string]string{SinkFingerprintKey: "q3Vf9kSAME"}), + ) + matched, err := ResolvePrefix(report, "q3Vf9kSAME") + if err != nil { + t.Fatalf("duplicates of one identity must resolve, got: %v", err) + } + if len(matched) != 2 { + t.Errorf("got %d results, want both duplicates", len(matched)) + } +} + +func TestResolvePrefixExactValueMatches(t *testing.T) { + report := makeReport( + makeResult("a", Error, "a.java", 1, map[string]string{SinkFingerprintKey: "q3Vf9k2nAAA"}), + ) + if _, err := ResolvePrefix(report, "q3Vf9k2nAAA"); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestResolvePrefixAmbiguousIsAnError(t *testing.T) { + report := makeReport( + makeResult("a", Error, "a.java", 1, map[string]string{SinkFingerprintKey: "q3Vf9kAAA"}), + makeResult("b", Error, "b.java", 2, map[string]string{SinkFingerprintKey: "q3Vf9kBBB"}), + ) + _, err := ResolvePrefix(report, "q3Vf9k") + if err == nil { + t.Fatal("expected ambiguous prefix to error") + } + if !strings.Contains(err.Error(), "q3Vf9kAAA") || !strings.Contains(err.Error(), "q3Vf9kBBB") { + t.Errorf("error should list the candidates, got: %v", err) + } +} + +func TestResolvePrefixNoMatchIsAnError(t *testing.T) { + report := makeReport( + makeResult("a", Error, "a.java", 1, map[string]string{SinkFingerprintKey: "q3Vf9kAAA"}), + ) + if _, err := ResolvePrefix(report, "zzzz"); err == nil { + t.Error("expected unmatched prefix to error") + } +} + +func TestResolvePrefixEmptyIsAnError(t *testing.T) { + report := makeReport( + makeResult("a", Error, "a.java", 1, map[string]string{SinkFingerprintKey: "q3Vf9kAAA"}), + ) + if _, err := ResolvePrefix(report, ""); err == nil { + t.Error("expected empty prefix to error rather than match everything") + } +} + +// The analyzer hashes the rule id into every fingerprint, so two rules on one +// statement carry different sink hashes and must not be conflated. +func TestCompareOnSinkHashSeparatesRulesOnOneStatement(t *testing.T) { + sink := func(v string) map[string]string { return map[string]string{SinkFingerprintKey: v} } + baseline := makeReport(makeResult("sqli", Error, "a.java", 1, sink("sqli-s1"))) + current := makeReport( + makeResult("sqli", Error, "a.java", 1, sink("sqli-s1")), // unchanged + makeResult("xss", Error, "a.java", 1, sink("xss-s1")), // new: different rule + ) + + cmp, err := CompareToBaseline(current, baseline) + if err != nil { + t.Fatalf("compare: %v", err) + } + results := current.Results() + if got := cmp.StateOf(results[0]); got != Unchanged { + t.Errorf("same rule and sink: got %q, want unchanged", got) + } + if got := cmp.StateOf(results[1]); got != New { + t.Errorf("other rule on the same sink: got %q, want new", got) + } + if len(cmp.Absent) != 0 { + t.Errorf("nothing was fixed, but %d results are absent", len(cmp.Absent)) + } +} diff --git a/cli/internal/sarif/listing.go b/cli/internal/sarif/listing.go index 0d1b47b745..3f8a75258b 100644 --- a/cli/internal/sarif/listing.go +++ b/cli/internal/sarif/listing.go @@ -7,6 +7,12 @@ import ( "github.com/seqra/opentaint/internal/output" ) +// listable reports whether a result belongs in the detailed listing. Suppressed +// findings are omitted unless ShowSuppressed is set. +func (opts ListingOptions) listable(r *Result) bool { + return opts.ShowSuppressed || !IsSuppressed(r) +} + // PrintAll renders every finding in report as a grouped, sorted listing. It // returns true when at least one finding had its code flow truncated (so the // caller can offer a "--verbose-flow" hint). Groups are determined by @@ -15,7 +21,11 @@ import ( func (report *Report) PrintAll(out *output.Printer, opts ListingOptions) bool { totalFindings := 0 for _, run := range report.Runs { - totalFindings += len(run.Results) + for i := range run.Results { + if opts.listable(&run.Results[i]) { + totalFindings++ + } + } } if totalFindings == 0 { return false @@ -36,8 +46,11 @@ func (report *Report) PrintAll(out *output.Printer, opts ListingOptions) bool { for runIdx := range report.Runs { run := &report.Runs[runIdx] for resultIdx := range run.Results { - order++ result := &run.Results[resultIdx] + if !opts.listable(result) { + continue + } + order++ file := "" line := int64(-1) diff --git a/cli/internal/sarif/print_findings.go b/cli/internal/sarif/print_findings.go index d9e90fe625..5556f3ac6d 100644 --- a/cli/internal/sarif/print_findings.go +++ b/cli/internal/sarif/print_findings.go @@ -74,7 +74,7 @@ func (report *Report) buildFindingTree(out *output.Printer, result *Result, runI // (e.g. SARIF generated without generateFingerprint), fall back to the rule // id as the header and skip the Rule subfield (it would just duplicate it). header := rule - if fp := fingerprintAbbrev(result, opts.FingerprintKey); fp != "" { + if fp := fingerprintAbbrev(result); fp != "" { header = th.FieldKey.Render("Fingerprint:") + " " + fp } findingNode := out.GroupItem(header) @@ -96,6 +96,21 @@ func (report *Report) buildFindingTree(out *output.Printer, result *Result, runI findingNode.Child(out.FieldItem("Severity", coloredSeverity)) findingNode.Child(out.FieldItem("Location", locStr)) + if result.BaselineState != nil { + state := string(*result.BaselineState) + if note := opts.Comparison.StateNote(result); note != "" { + state += " (" + note + ")" + } + findingNode.Child(out.FieldItem("Baseline", state)) + } + if IsSuppressed(result) { + suppressedLine := StatusOf(result) + if justification := JustificationOf(result); justification != "" { + suppressedLine += ": " + justification + } + findingNode.Child(out.FieldItem("Suppressed", suppressedLine)) + } + total := len(result.CodeFlows) if total > 1 { findingNode.Child(out.FieldItem("Code flows", total)) @@ -282,11 +297,11 @@ func findingEndpoints(result *Result) []endpointInfo { return endpoints } -// fingerprintAbbrev returns a short, git-style prefix of the result's -// partialFingerprints value under key, for display in the listing. Returns "" -// when the key is absent. When key is empty the default key is used. -func fingerprintAbbrev(result *Result, key string) string { - val := fingerprintValue(result, key) +// fingerprintAbbrev returns a short, git-style prefix of the result's identity +// fingerprint, for display in the listing. Returns "" when the result carries +// none. +func fingerprintAbbrev(result *Result) string { + val := fingerprintValue(result) if val == "" { return "" } 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/render_test.go b/cli/internal/sarif/render_test.go index fec56cd8a4..62c65183ea 100644 --- a/cli/internal/sarif/render_test.go +++ b/cli/internal/sarif/render_test.go @@ -18,13 +18,13 @@ func renderListing(t *testing.T, report *Report, opts ListingOptions) string { func TestFingerprintAbbrev(t *testing.T) { r := makeResult("r", Error, "a.java", 1, map[string]string{ - DefaultFingerprintKey: "abcdefghijklmnopqrstuv", + IdentityKey: "abcdefghijklmnopqrstuv", }) - if got := fingerprintAbbrev(&r, ""); got != "abcdefghijkl" { // 12 chars + if got := fingerprintAbbrev(&r); got != "abcdefghijkl" { // 12 chars t.Errorf("fingerprintAbbrev = %q", got) } none := makeResult("r", Error, "a.java", 1, nil) - if got := fingerprintAbbrev(&none, ""); got != "" { + if got := fingerprintAbbrev(&none); got != "" { t.Errorf("expected empty abbrev, got %q", got) } } @@ -47,7 +47,7 @@ func TestPrintAllGroupsByRuleID(t *testing.T) { } func TestPrintAllShowsFingerprint(t *testing.T) { - r := makeResult("r", Error, "a.java", 1, map[string]string{DefaultFingerprintKey: "deadbeefcafe00"}) + r := makeResult("r", Error, "a.java", 1, map[string]string{IdentityKey: "deadbeefcafe00"}) out := renderListing(t, makeReport(r), ListingOptions{MaxNestingLevel: -1}) if !strings.Contains(out, "deadbeefcafe") { t.Errorf("expected abbreviated fingerprint in listing:\n%s", out) @@ -203,7 +203,7 @@ func TestPrintAllFingerprintHeaderHasRuleSubfield(t *testing.T) { // When a finding has a partial fingerprint, the finding's tree header is // "Fingerprint: " and the rule moves into a Rule: subfield. r := makeResult("my-rule", Error, "a.java", 1, map[string]string{ - DefaultFingerprintKey: "abc123def456ghi", + IdentityKey: "abc123def456ghi", }) out := renderListing(t, makeReport(r), ListingOptions{MaxNestingLevel: -1}) if !strings.Contains(out, "Fingerprint:") { diff --git a/cli/internal/sarif/result_json.go b/cli/internal/sarif/result_json.go new file mode 100644 index 0000000000..56f232f8ad --- /dev/null +++ b/cli/internal/sarif/result_json.go @@ -0,0 +1,21 @@ +package sarif + +import "encoding/json" + +// MarshalJSON preserves the distinction SARIF assigns to suppressions: nil +// means suppression information is unavailable, while an empty array means it +// was evaluated and the result is not suppressed. The generated struct uses +// omitempty, which otherwise collapses those two states. +func (r Result) MarshalJSON() ([]byte, error) { + type resultAlias Result + if r.Suppressions == nil { + return json.Marshal(resultAlias(r)) + } + return json.Marshal(struct { + resultAlias + Suppressions []Suppression `json:"suppressions"` + }{ + resultAlias: resultAlias(r), + Suppressions: r.Suppressions, + }) +} 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..44edb2d92b --- /dev/null +++ b/cli/internal/sarif/save.go @@ -0,0 +1,86 @@ +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(reportWithConsistentSuppressions(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 +} + +// reportWithConsistentSuppressions returns a shallow structural copy suitable +// for serialization. SARIF requires suppression information to be available +// for every result in a run or for none of them. Once any result has a +// suppression array, results without a decision therefore get an explicit +// empty array. The caller's in-memory report is not changed. +func reportWithConsistentSuppressions(report *Report) *Report { + if report == nil { + return nil + } + out := *report + out.Runs = append([]Run(nil), report.Runs...) + for runIdx := range out.Runs { + run := &out.Runs[runIdx] + available := false + for resultIdx := range run.Results { + if run.Results[resultIdx].Suppressions != nil { + available = true + break + } + } + if !available { + continue + } + run.Results = append([]Result(nil), run.Results...) + for resultIdx := range run.Results { + if run.Results[resultIdx].Suppressions == nil { + run.Results[resultIdx].Suppressions = []Suppression{} + } + } + } + return &out +} diff --git a/cli/internal/sarif/save_test.go b/cli/internal/sarif/save_test.go new file mode 100644 index 0000000000..94699a4063 --- /dev/null +++ b/cli/internal/sarif/save_test.go @@ -0,0 +1,209 @@ +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) + } +} + +func TestSaveReportWritesSuppressionAvailabilityForEveryResult(t *testing.T) { + report := makeReport( + makeResult("suppressed", Error, "a.java", 1, fp("a", "trace-a")), + makeResult("reported", Error, "b.java", 2, fp("b", "trace-b")), + ) + if err := Accept(report.Results()[0], "reviewed"); err != nil { + t.Fatalf("accept: %v", err) + } + + path := filepath.Join(t.TempDir(), "out.sarif") + if err := SaveReport(report, path); err != nil { + t.Fatalf("save: %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + var raw struct { + Runs []struct { + Results []map[string]json.RawMessage `json:"results"` + } `json:"runs"` + } + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatalf("decode: %v", err) + } + for i, result := range raw.Runs[0].Results { + value, present := result["suppressions"] + if !present { + t.Errorf("result %d omits suppressions while another result supplies suppression information", i) + continue + } + if i == 1 && string(value) != "[]" { + t.Errorf("unsuppressed result has suppressions = %s, want []", value) + } + } +} diff --git a/cli/internal/sarif/suppress.go b/cli/internal/sarif/suppress.go new file mode 100644 index 0000000000..428dc839b8 --- /dev/null +++ b/cli/internal/sarif/suppress.go @@ -0,0 +1,216 @@ +package sarif + +import ( + "fmt" + "strings" +) + +// Suppression semantics, per SARIF §3.35 and the read rule in the design: +// +// - status absent or "accepted" — suppressed. "accepted" is what triage +// --accept writes: the team will not fix this. +// - "underReview" — suppressed, and reported separately as deferred. This is +// what triage --defer writes: the team is not fixing it for now. +// - "rejected" — not suppressed. The suppression was explicitly denied, so +// reporting the finding is the whole point. +// - anything else — not suppressed, and counted so the report says so. +// +// Nothing but SARIF's own fields is written: kind, status, justification, guid. + +// honors reports whether a single suppression entry hides its result. +func honors(s *Suppression) bool { + if s.Status == nil { + return true + } + switch *s.Status { + case Accepted, UnderReview: + return true + default: + return false + } +} + +// IsSuppressed reports whether any suppression on the result is honored. +func IsSuppressed(r *Result) bool { + return honoredSuppression(r) != nil +} + +// honoredSuppression returns the first suppression entry that hides the result, +// or nil when none does. +func honoredSuppression(r *Result) *Suppression { + if r == nil { + return nil + } + for i := range r.Suppressions { + if honors(&r.Suppressions[i]) { + return &r.Suppressions[i] + } + } + return nil +} + +// IsDeferred reports whether the honored suppression is a deferral +// ("not fixing for now") rather than an acceptance ("won't fix"). +func IsDeferred(r *Result) bool { + s := honoredSuppression(r) + return s != nil && s.Status != nil && *s.Status == UnderReview +} + +// JustificationOf returns the justification of the honored suppression, or "" +// when the result is not suppressed or the entry carries no justification. +func JustificationOf(r *Result) string { + s := honoredSuppression(r) + if s == nil || s.Justification == nil { + return "" + } + return *s.Justification +} + +// StatusOf returns the honored suppression's status as a string, defaulting to +// "accepted" when the entry omits it (which is how the read rule treats it). +func StatusOf(r *Result) string { + s := honoredSuppression(r) + if s == nil { + return "" + } + if s.Status == nil { + return string(Accepted) + } + return string(*s.Status) +} + +// Accept records that the team will not fix this finding, writing an external +// suppression with status "accepted". Any suppression already on the result is +// replaced: a result carries one decision, the most recent one. +func Accept(r *Result, justification string) error { + return suppress(r, Accepted, justification) +} + +// Defer records that the team is not fixing this finding for now, writing an +// external suppression with status "underReview". +func Defer(r *Result, justification string) error { + return suppress(r, UnderReview, justification) +} + +func suppress(r *Result, status Status, justification string) error { + justification = strings.TrimSpace(justification) + if justification == "" { + return fmt.Errorf("a justification is required to suppress a finding") + } + guid := newUUIDv4() + statusValue := status + r.Suppressions = []Suppression{{ + Kind: External, + Status: &statusValue, + Justification: &justification, + GUID: &guid, + }} + return nil +} + +// Unsuppress removes every suppression from the result, reporting whether +// anything was removed. It only affects the report being triaged: if a baseline +// still carries the decision, the next scan inherits it again. +func Unsuppress(r *Result) bool { + if len(r.Suppressions) == 0 { + return false + } + r.Suppressions = nil + return true +} + +// InheritSuppressions copies honored suppressions from baseline results onto +// matching current results, and returns how many were copied. The copy is +// verbatim — same status, justification and guid — so a decision authored once +// stays attached to the finding across every later scan. +// +// Presence in the baseline is not acceptance: a baseline result without a +// suppression transmits nothing. A result that already carries its own +// suppression is left alone. Its own decision is the newer one. +func InheritSuppressions(current, baseline *Report) int { + byIdentity := make(map[string]*Suppression) + for _, r := range baseline.Results() { + id, ok := Identity(r, IdentityKey) + if !ok { + continue + } + if _, seen := byIdentity[id]; seen { + continue + } + if s := honoredSuppression(r); s != nil { + byIdentity[id] = s + } + } + + inherited := 0 + for _, r := range current.Results() { + if len(r.Suppressions) > 0 { + continue + } + id, ok := Identity(r, IdentityKey) + if !ok { + continue + } + source, found := byIdentity[id] + if !found { + continue + } + r.Suppressions = []Suppression{copySuppression(source)} + inherited++ + } + return inherited +} + +// copySuppression deep-copies the parts of a suppression we carry forward. +// Pointers are cloned so the two reports never share mutable state. +func copySuppression(s *Suppression) Suppression { + out := Suppression{Kind: s.Kind, Location: s.Location, Properties: s.Properties} + if s.Status != nil { + status := *s.Status + out.Status = &status + } + if s.Justification != nil { + justification := *s.Justification + out.Justification = &justification + } + if s.GUID != nil { + guid := *s.GUID + out.GUID = &guid + } + return out +} + +// SuppressionStats summarizes the suppression state of a report. +type SuppressionStats struct { + Total int // all results + Suppressed int // results hidden by an honored suppression + WontFix int // honored, status accepted (or absent) + Deferred int // honored, status underReview + NotHonored int // results carrying only rejected or unrecognised suppressions +} + +// Any reports whether the report contains any suppression at all, honored or +// not — the signal for whether to render the Suppressions summary group. +func (s SuppressionStats) Any() bool { + return s.Suppressed > 0 || s.NotHonored > 0 +} + +// CollectSuppressionStats walks the report and counts suppression states. +func CollectSuppressionStats(report *Report) SuppressionStats { + var stats SuppressionStats + for _, r := range report.Results() { + stats.Total++ + switch { + case IsSuppressed(r): + stats.Suppressed++ + if IsDeferred(r) { + stats.Deferred++ + } else { + stats.WontFix++ + } + case len(r.Suppressions) > 0: + stats.NotHonored++ + } + } + return stats +} diff --git a/cli/internal/sarif/suppress_test.go b/cli/internal/sarif/suppress_test.go new file mode 100644 index 0000000000..84beb64b01 --- /dev/null +++ b/cli/internal/sarif/suppress_test.go @@ -0,0 +1,252 @@ +package sarif + +import ( + "strings" + "testing" +) + +func statusPtr(s Status) *Status { return &s } + +// suppressed builds a result carrying one external suppression with the given +// status ("" means the status property is absent). +func suppressed(ruleID, sourceSink string, status Status, justification string) Result { + r := makeResult(ruleID, Error, "a.java", 1, fp(sourceSink, "trace-"+sourceSink)) + s := Suppression{Kind: External, Justification: strptr(justification)} + if status != "" { + s.Status = statusPtr(status) + } + r.Suppressions = []Suppression{s} + return r +} + +func TestIsSuppressedReadRule(t *testing.T) { + cases := []struct { + name string + result Result + want bool + }{ + {"no suppressions", makeResult("a", Error, "a.java", 1, nil), false}, + {"status absent", suppressed("a", "id", "", "why"), true}, + {"accepted", suppressed("a", "id", Accepted, "why"), true}, + {"under review", suppressed("a", "id", UnderReview, "why"), true}, + {"rejected", suppressed("a", "id", Rejected, "why"), false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := IsSuppressed(&tc.result); got != tc.want { + t.Errorf("got %v, want %v", got, tc.want) + } + }) + } +} + +func TestIsSuppressedUnknownStatusDoesNotHide(t *testing.T) { + r := suppressed("a", "id", Status("somethingElse"), "why") + if IsSuppressed(&r) { + t.Error("an unrecognised status must not hide a finding") + } +} + +func TestIsSuppressedAnyAcceptingEntryWins(t *testing.T) { + r := suppressed("a", "id", Rejected, "denied") + r.Suppressions = append(r.Suppressions, Suppression{ + Kind: External, + Status: statusPtr(Accepted), + }) + if !IsSuppressed(&r) { + t.Error("a result with one accepted suppression is suppressed") + } +} + +func TestAcceptWritesAcceptedStatus(t *testing.T) { + r := makeResult("a", Error, "a.java", 1, fp("id", "trace")) + if err := Accept(&r, "sink is a constant"); err != nil { + t.Fatalf("accept: %v", err) + } + if len(r.Suppressions) != 1 { + t.Fatalf("got %d suppressions, want 1", len(r.Suppressions)) + } + s := r.Suppressions[0] + if s.Kind != External { + t.Errorf("kind: got %q, want external", s.Kind) + } + if s.Status == nil || *s.Status != Accepted { + t.Errorf("status: got %v, want accepted", s.Status) + } + if s.Justification == nil || *s.Justification != "sink is a constant" { + t.Errorf("justification: got %v", s.Justification) + } + if s.GUID == nil || *s.GUID == "" { + t.Error("a guid must be generated") + } + if s.Properties != nil { + t.Error("no property bag should be written") + } + if s.Location != nil { + t.Error("an external suppression has no location") + } +} + +func TestDeferWritesUnderReviewStatus(t *testing.T) { + r := makeResult("a", Error, "a.java", 1, fp("id", "trace")) + if err := Defer(&r, "waiting on OT-412"); err != nil { + t.Fatalf("defer: %v", err) + } + s := r.Suppressions[0] + if s.Status == nil || *s.Status != UnderReview { + t.Errorf("status: got %v, want underReview", s.Status) + } + if !IsSuppressed(&r) { + t.Error("a deferred finding is suppressed") + } +} + +func TestAcceptRequiresJustification(t *testing.T) { + r := makeResult("a", Error, "a.java", 1, nil) + if err := Accept(&r, " "); err == nil { + t.Error("expected an error for a blank justification") + } + if len(r.Suppressions) != 0 { + t.Error("nothing should be written when validation fails") + } +} + +func TestAcceptReplacesAnExistingSuppression(t *testing.T) { + r := suppressed("a", "id", UnderReview, "deferred earlier") + if err := Accept(&r, "now decided: won't fix"); err != nil { + t.Fatalf("accept: %v", err) + } + if len(r.Suppressions) != 1 { + t.Fatalf("got %d suppressions, want 1", len(r.Suppressions)) + } + if *r.Suppressions[0].Status != Accepted { + t.Errorf("status not updated: %v", *r.Suppressions[0].Status) + } + if *r.Suppressions[0].Justification != "now decided: won't fix" { + t.Errorf("justification not updated: %v", *r.Suppressions[0].Justification) + } +} + +func TestUnsuppressRemovesTheEntry(t *testing.T) { + r := suppressed("a", "id", Accepted, "why") + if !Unsuppress(&r) { + t.Error("expected Unsuppress to report a change") + } + if len(r.Suppressions) != 0 { + t.Errorf("got %d suppressions, want 0", len(r.Suppressions)) + } + if Unsuppress(&r) { + t.Error("unsuppressing an unsuppressed result should report no change") + } +} + +func TestInheritCopiesSuppressionVerbatim(t *testing.T) { + guid := "11111111-2222-3333-4444-555555555555" + base := suppressed("a", "id-a", Accepted, "admin-only input") + base.Suppressions[0].GUID = &guid + baseline := makeReport(base) + current := makeReport( + makeResult("a", Error, "a.java", 1, fp("id-a", "trace-id-a")), + makeResult("b", Error, "b.java", 2, fp("id-b", "trace-id-b")), + ) + + n := InheritSuppressions(current, baseline) + if n != 1 { + t.Fatalf("inherited %d, want 1", n) + } + + got := current.Results()[0] + if len(got.Suppressions) != 1 { + t.Fatalf("got %d suppressions, want 1", len(got.Suppressions)) + } + s := got.Suppressions[0] + if s.GUID == nil || *s.GUID != guid { + t.Errorf("guid not inherited verbatim: %v", s.GUID) + } + if s.Justification == nil || *s.Justification != "admin-only input" { + t.Errorf("justification not inherited verbatim: %v", s.Justification) + } + if s.Status == nil || *s.Status != Accepted { + t.Errorf("status not inherited verbatim: %v", s.Status) + } + if len(current.Results()[1].Suppressions) != 0 { + t.Error("an unmatched result must not be suppressed") + } +} + +func TestInheritIgnoresBaselineEntriesWithoutSuppressions(t *testing.T) { + baseline := makeReport(makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a"))) + current := makeReport(makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a"))) + + if n := InheritSuppressions(current, baseline); n != 0 { + t.Errorf("inherited %d, want 0: presence in a baseline is not acceptance", n) + } + if IsSuppressed(current.Results()[0]) { + t.Error("a plain baseline entry must not suppress") + } +} + +func TestInheritDoesNotOverwriteAnExistingDecision(t *testing.T) { + baseline := makeReport(suppressed("a", "id-a", Accepted, "old decision")) + current := makeReport(suppressed("a", "id-a", UnderReview, "decided again just now")) + + if n := InheritSuppressions(current, baseline); n != 0 { + t.Errorf("inherited %d, want 0", n) + } + if *current.Results()[0].Suppressions[0].Justification != "decided again just now" { + t.Error("the result's own suppression was overwritten") + } +} + +func TestInheritSkipsRejectedBaselineEntries(t *testing.T) { + baseline := makeReport(suppressed("a", "id-a", Rejected, "denied")) + current := makeReport(makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a"))) + + if n := InheritSuppressions(current, baseline); n != 0 { + t.Errorf("inherited %d, want 0", n) + } + if IsSuppressed(current.Results()[0]) { + t.Error("a rejected suppression must not hide a finding") + } +} + +func TestSuppressionStatsBreakdown(t *testing.T) { + report := makeReport( + suppressed("a", "id-a", Accepted, "won't fix"), + suppressed("b", "id-b", Accepted, "won't fix either"), + suppressed("c", "id-c", UnderReview, "not now"), + suppressed("d", "id-d", Rejected, "denied"), + suppressed("e", "id-e", Status("weird"), "?"), + makeResult("f", Error, "f.java", 6, fp("id-f", "trace-f")), + ) + + stats := CollectSuppressionStats(report) + if stats.Total != 6 { + t.Errorf("total: got %d, want 6", stats.Total) + } + if stats.Suppressed != 3 { + t.Errorf("suppressed: got %d, want 3", stats.Suppressed) + } + if stats.WontFix != 2 { + t.Errorf("won't fix: got %d, want 2", stats.WontFix) + } + if stats.Deferred != 1 { + t.Errorf("deferred: got %d, want 1", stats.Deferred) + } + if stats.NotHonored != 2 { + t.Errorf("not honored: got %d, want 2 (rejected + unknown status)", stats.NotHonored) + } +} + +func TestJustificationOfReturnsTheHonoredEntry(t *testing.T) { + r := suppressed("a", "id", Rejected, "denied") + r.Suppressions = append(r.Suppressions, Suppression{ + Kind: External, + Status: statusPtr(Accepted), + Justification: strptr("the real reason"), + }) + got := JustificationOf(&r) + if !strings.Contains(got, "the real reason") { + t.Errorf("got %q, want the honored entry's justification", got) + } +} diff --git a/cli/internal/sarif/triage_summary_test.go b/cli/internal/sarif/triage_summary_test.go new file mode 100644 index 0000000000..f6affad839 --- /dev/null +++ b/cli/internal/sarif/triage_summary_test.go @@ -0,0 +1,268 @@ +package sarif + +import ( + "bytes" + "strings" + "testing" + + "github.com/seqra/opentaint/internal/output" +) + +func renderSummary(t *testing.T, report *Report, view *TriageView) string { + t.Helper() + var buf bytes.Buffer + report.PrintSummary(output.NewWithWriter(&buf), "/tmp/report.sarif", view) + return buf.String() +} + +func TestSummaryWithoutTriageHasNoNewGroups(t *testing.T) { + out := renderSummary(t, makeReport(makeResult("a", Error, "a.java", 1, nil)), nil) + if strings.Contains(out, "Baseline") { + t.Errorf("unexpected Baseline group:\n%s", out) + } + if strings.Contains(out, "Suppressions") { + t.Errorf("unexpected Suppressions group:\n%s", out) + } + if strings.Contains(out, "Reported") { + t.Errorf("Reported line should only appear when something is suppressed:\n%s", out) + } +} + +func TestSummaryBaselineGroup(t *testing.T) { + baseline := makeReport( + makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a")), + makeResult("gone", Error, "c.java", 3, fp("id-gone", "trace-gone")), + ) + report := makeReport( + makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a")), + makeResult("fresh", Error, "b.java", 2, fp("id-fresh", "trace-fresh")), + ) + cmp, err := CompareToBaseline(report, baseline) + if err != nil { + t.Fatalf("compare: %v", err) + } + + out := renderSummary(t, report, &TriageView{ + BaselinePath: "reports/main.sarif", + Comparison: cmp, + }) + + for _, want := range []string{"Baseline", "reports/main.sarif", "New", "Unchanged", "Absent"} { + if !strings.Contains(out, want) { + t.Errorf("missing %q in summary:\n%s", want, out) + } + } + if !strings.Contains(out, "Written to report") { + t.Errorf("summary must say whether states were persisted:\n%s", out) + } +} + +func TestSummaryBaselineGroupHedgesAbsencesWithRemnants(t *testing.T) { + baseline := makeReport( + // The sink hash drifts, and a new same-rule finding sits in the same file. + makeResult("a", Error, "a.java", 1, fps("sink-a-old", "src-a-old", "trace-a-old")), + // Genuinely gone. + makeResult("c", Error, "c.java", 3, fps("sink-c", "src-c", "trace-c")), + ) + report := makeReport( + makeResult("a", Error, "a.java", 4, fps("sink-a-new", "src-a-new", "trace-a-new")), + ) + cmp, err := CompareToBaseline(report, baseline) + if err != nil { + t.Fatalf("compare: %v", err) + } + + out := renderSummary(t, report, &TriageView{BaselinePath: "b.sarif", Comparison: cmp}) + + for _, want := range []string{"Possibly drifted", "Absent"} { + if !strings.Contains(out, want) { + t.Errorf("missing %q in summary:\n%s", want, out) + } + } + if strings.Count(out, "Absent") != 1 { + t.Errorf("exactly one Absent line expected:\n%s", out) + } +} + +func TestSummaryBaselineGroupOmitsZeroUpdated(t *testing.T) { + baseline := makeReport(makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a"))) + report := makeReport(makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a"))) + cmp, _ := CompareToBaseline(report, baseline) + + out := renderSummary(t, report, &TriageView{BaselinePath: "b.sarif", Comparison: cmp}) + if strings.Contains(out, "Updated") { + t.Errorf("zero-valued Updated line should be omitted:\n%s", out) + } + if !strings.Contains(out, "Unchanged") { + t.Errorf("non-zero Unchanged should be shown:\n%s", out) + } +} + +func TestSummaryBaselineGroupReportsUnmatchable(t *testing.T) { + baseline := makeReport(makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a"))) + report := makeReport(makeResult("nofp", Error, "b.java", 2, nil)) + cmp, _ := CompareToBaseline(report, baseline) + + out := renderSummary(t, report, &TriageView{BaselinePath: "b.sarif", Comparison: cmp}) + if !strings.Contains(out, "Not comparable") { + t.Errorf("unmatchable findings must be surfaced:\n%s", out) + } +} + +func TestSummarySuppressionsGroup(t *testing.T) { + report := makeReport( + suppressed("a", "id-a", Accepted, "won't fix"), + suppressed("b", "id-b", UnderReview, "not now"), + makeResult("c", Error, "c.java", 3, fp("id-c", "trace-c")), + ) + + out := renderSummary(t, report, &TriageView{ + Suppressions: CollectSuppressionStats(report), + Inherited: 1, + }) + + for _, want := range []string{"Suppressions", "Suppressed", "Won't fix", "Deferred", "Inherited from baseline"} { + if !strings.Contains(out, want) { + t.Errorf("missing %q in summary:\n%s", want, out) + } + } + if !strings.Contains(out, "Reported") { + t.Errorf("Findings group should report the unsuppressed count:\n%s", out) + } +} + +func TestSummarySuppressionsGroupShowsAddedOnlyWhenRelevant(t *testing.T) { + report := makeReport(suppressed("a", "id-a", Accepted, "won't fix")) + stats := CollectSuppressionStats(report) + + out := renderSummary(t, report, &TriageView{Suppressions: stats}) + if strings.Contains(out, "Added this run") { + t.Errorf("Added line should be omitted when nothing was added:\n%s", out) + } + + out = renderSummary(t, report, &TriageView{Suppressions: stats, Added: 1}) + if !strings.Contains(out, "Added this run") { + t.Errorf("Added line expected:\n%s", out) + } +} + +func TestSummarySuppressionsGroupReportsNotHonored(t *testing.T) { + report := makeReport(suppressed("a", "id-a", Rejected, "denied")) + out := renderSummary(t, report, &TriageView{Suppressions: CollectSuppressionStats(report)}) + if !strings.Contains(out, "Not honored") { + t.Errorf("rejected suppressions must be surfaced:\n%s", out) + } +} + +func TestRestrictCountsOnlyWhatTheFilterKept(t *testing.T) { + baseline := makeReport( + makeResult("sql", Error, "a.java", 1, fp("id-a", "trace-a")), + makeResult("xss", Error, "b.java", 2, fp("id-b", "trace-b")), + makeResult("sql", Error, "c.java", 3, fp("id-gone", "trace-gone")), + ) + current := withRules(makeReport( + makeResult("sql", Error, "a.java", 1, fp("id-a", "trace-a")), // unchanged + makeResult("xss", Error, "b.java", 2, fp("id-fresh", "trace-fresh")), // new + ), "sql", "xss") + + cmp, err := CompareToBaseline(current, baseline) + if err != nil { + t.Fatalf("compare: %v", err) + } + cmp.Apply(current) + view := &TriageView{Comparison: cmp, Suppressions: CollectSuppressionStats(current)} + + filters := Filters{RuleIDs: []string{"xss"}} + restricted := view.Restrict(current.Filter(filters), filters) + + if got := restricted.Comparison.Counts[New]; got != 1 { + t.Errorf("New = %d, want 1", got) + } + if got := restricted.Comparison.Counts[Unchanged]; got != 0 { + t.Errorf("Unchanged = %d, want 0: the unchanged finding belongs to another rule", got) + } + // Two baseline findings are gone (id-b under xss, id-gone under sql). The + // filter keeps only the xss one. + if got := restricted.Comparison.Counts[Absent]; got != 1 { + t.Errorf("Absent = %d, want 1: only the xss finding survives the filter", got) + } + if got := view.Comparison.Counts[Absent]; got != 2 { + t.Errorf("unrestricted Absent = %d, want 2", got) + } + if got := restricted.Suppressions.Total; got != 1 { + t.Errorf("Suppressions.Total = %d, want 1", got) + } + // The unrestricted view still describes the whole report. + if got := view.Comparison.Counts[Unchanged]; got != 1 { + t.Errorf("Restrict mutated the original view: Unchanged = %d, want 1", got) + } +} + +func TestRestrictKeepsFixedFindingsTheFilterNames(t *testing.T) { + baseline := makeReport(makeResult("sql", Error, "c.java", 3, fp("id-gone", "trace-gone"))) + current := withRules(makeReport(), "sql") + + cmp, err := CompareToBaseline(current, baseline) + if err != nil { + t.Fatalf("compare: %v", err) + } + view := &TriageView{Comparison: cmp} + + filters := Filters{BaselineStates: []string{"absent"}} + restricted := view.Restrict(current.Filter(filters), filters) + if got := restricted.Comparison.Counts[Absent]; got != 1 { + t.Errorf("Absent = %d, want 1", got) + } + + other := Filters{BaselineStates: []string{"new"}} + if got := view.Restrict(current.Filter(other), other).Comparison.Counts[Absent]; got != 0 { + t.Errorf("Absent = %d, want 0 when the filter does not name absent", got) + } +} + +func TestDisplayFingerprintIsTheOneTriageResolves(t *testing.T) { + r := makeResult("a", Error, "a.java", 1, fp("source-sink-value", "trace-value")) + report := makeReport(r) + + shown := fingerprintAbbrev(&report.Runs[0].Results[0]) + resolved, err := ResolvePrefix(report, shown) + if err != nil { + t.Fatalf("the fingerprint the listing shows does not resolve: %v", err) + } + if len(resolved) != 1 { + t.Fatalf("resolved %d results, want 1", len(resolved)) + } + if got, _ := Identity(resolved[0], IdentityKey); got != "sink-of-source-sink-value" { + t.Errorf("resolved %q, want the value under the identity key", got) + } +} + +// Filtering must not hide what moved under an updated finding: the filtered +// results are copies, so the attribution is recovered by identity value. +func TestRestrictKeepsChangeAttribution(t *testing.T) { + baseline := makeReport( + makeResult("sql", Error, "a.java", 1, fp("id-a", "trace-a")), + makeResult("xss", Warning, "b.java", 2, fp("id-b", "trace-b")), + ) + current := withRules(makeReport( + makeResult("sql", Error, "a.java", 1, fp("id-a", "trace-a-moved")), + makeResult("xss", Warning, "b.java", 2, fp("id-b", "trace-b")), + ), "sql", "xss") + + cmp, err := CompareToBaseline(current, baseline) + if err != nil { + t.Fatalf("compare: %v", err) + } + cmp.Apply(current) + view := &TriageView{Comparison: cmp} + + filters := Filters{Severities: []string{"error"}} + restricted := view.Restrict(current.Filter(filters), filters) + + if got := restricted.Comparison.Counts[Updated]; got != 1 { + t.Fatalf("Updated = %d, want 1", got) + } + if got := restricted.Comparison.ChangeCounts[ChangePath]; got != 1 { + t.Errorf("path-changed attribution lost under filter: ChangeCounts = %v", restricted.Comparison.ChangeCounts) + } +} diff --git a/cli/internal/sarif/triage_view.go b/cli/internal/sarif/triage_view.go new file mode 100644 index 0000000000..990776428c --- /dev/null +++ b/cli/internal/sarif/triage_view.go @@ -0,0 +1,197 @@ +package sarif + +import ( + "fmt" + + "github.com/seqra/opentaint/internal/output" +) + +// TriageView is the baseline and suppression state of a report, as computed by +// the command that is about to print it. A nil *TriageView means neither +// applies and the summary renders exactly as it did before triage existed. +type TriageView struct { + // BaselinePath is the baseline the report was compared against, shown so the + // reader can tell which report the counts are relative to. + BaselinePath string + // Comparison is the classification against that baseline, or nil when no + // baseline was supplied. + Comparison *Comparison + // StateWritten records whether baselineState was persisted into the report + // (--baseline-state) or only computed for display. + StateWritten bool + // ReadOnly means the command never writes the report, so reporting whether + // the state was persisted would be noise. + ReadOnly bool + + // Suppressions counts the suppression state of the report. + Suppressions SuppressionStats + // Inherited counts suppressions carried over from the baseline in this run. + Inherited int + // Added counts suppressions authored in this run (triage --accept/--defer). + Added int +} + +// Restrict returns a view whose counts describe only the findings that survived +// the display filters. Without it, a listing narrowed to one rule would still be +// summarized with the counts of the whole report — every number on screen would +// belong to a different set of findings than the one printed above it. +// +// filtered must be the result of report.Filter(f) for the report this view was +// computed from. +func (v *TriageView) Restrict(filtered *Report, f Filters) *TriageView { + if v == nil || !f.active() { + return v + } + restricted := *v + restricted.Suppressions = CollectSuppressionStats(filtered) + restricted.Comparison = v.Comparison.restrict(filtered, f) + return &restricted +} + +// restrict recounts a comparison over the filtered current results, and narrows +// the baseline-side lists (which have no counterpart in the current report) with +// the same filters. +func (c *Comparison) restrict(filtered *Report, f Filters) *Comparison { + if c == nil { + return nil + } + out := &Comparison{ + states: c.states, + changes: c.changes, + changesByIdentity: c.changesByIdentity, + remnantsByIdentity: c.remnantsByIdentity, + Counts: make(map[BaselineState]int), + ChangeCounts: make(map[Change]int), + BaselineGUID: c.BaselineGUID, + } + for _, r := range filtered.Results() { + if r.BaselineState == nil { + out.Unmatchable++ + continue + } + out.Counts[*r.BaselineState]++ + // The filtered results are copies, so the change attribution is + // recovered by identity value rather than by pointer. + if *r.BaselineState == Updated { + if change := c.changeOfIdentity(r); change != ChangeNone { + out.ChangeCounts[change]++ + } + } + } + for _, r := range c.Absent { + if f.matchesAs(r, Absent) { + out.Absent = append(out.Absent, r) + } + } + for _, r := range c.NotRun { + if f.matchesAs(r, Absent) { + out.NotRun = append(out.NotRun, r) + } + } + out.Counts[Absent] = len(out.Absent) + return out +} + +// baselineItems renders the Baseline group, or nil when no baseline applies. +// Zero-valued state counts are omitted so the group stays readable. The states +// that matter are the ones that happened. +func (v *TriageView) baselineItems(out *output.Printer) []any { + if v == nil || v.Comparison == nil { + return nil + } + + items := []any{} + if v.BaselinePath != "" { + items = append(items, out.FieldItem("Baseline", v.BaselinePath)) + } + for _, entry := range []struct { + label string + state BaselineState + }{ + {"New", New}, + {"Unchanged", Unchanged}, + } { + if count := v.Comparison.Counts[entry.state]; count > 0 { + items = append(items, out.FieldItem(entry.label, count)) + } + } + // "Updated" is one SARIF state covering two different findings-level events, + // so it is reported by what actually moved. Anything the comparison could not + // attribute stays under the plain label rather than being guessed at. + attributed := 0 + for _, change := range []Change{ChangeSource, ChangePath} { + count := v.Comparison.ChangeCounts[change] + if count == 0 { + continue + } + attributed += count + items = append(items, out.FieldItem("Updated, "+change.Label(), count)) + } + if rest := v.Comparison.Counts[Updated] - attributed; rest > 0 { + items = append(items, out.FieldItem("Updated", rest)) + } + // An absent finding is not always a fixed one: the hash may have changed + // while the finding stayed. An absence with a hint of that gets its own + // hedged line, and "Absent" keeps only the ones with nothing left behind. + remnants := map[Remnant]int{} + for _, r := range v.Comparison.Absent { + remnants[v.Comparison.RemnantOf(r)]++ + } + if count := remnants[RemnantDrifted]; count > 0 { + items = append(items, out.FieldItem("Possibly drifted", count)) + } + if count := remnants[RemnantNone]; count > 0 { + items = append(items, out.FieldItem("Absent", count)) + } + // Baseline findings whose rule did not run are deliberately not folded into + // "Absent": excluding a rule would otherwise read as having resolved every + // finding it ever produced. + if count := len(v.Comparison.NotRun); count > 0 { + items = append(items, out.FieldItem("Rule not run", count)) + } + if v.Comparison.Unmatchable > 0 { + items = append(items, out.FieldItem("Not comparable", v.Comparison.Unmatchable)) + } + + if v.ReadOnly { + return items + } + written := "no" + if v.StateWritten { + written = "yes" + } + return append(items, out.FieldItem("Written to report", written)) +} + +// suppressionItems renders the Suppressions group, or nil when the report +// carries no suppressions at all. +func (v *TriageView) suppressionItems(out *output.Printer) []any { + if v == nil || !v.Suppressions.Any() { + return nil + } + + stats := v.Suppressions + items := []any{ + out.FieldItem("Suppressed", suppressedOf(stats)), + } + if stats.WontFix > 0 { + items = append(items, out.FieldItem("Won't fix", stats.WontFix)) + } + if stats.Deferred > 0 { + items = append(items, out.FieldItem("Deferred", stats.Deferred)) + } + if stats.NotHonored > 0 { + items = append(items, out.FieldItem("Not honored", stats.NotHonored)) + } + if v.Inherited > 0 { + items = append(items, out.FieldItem("Inherited from baseline", v.Inherited)) + } + if v.Added > 0 { + items = append(items, out.FieldItem("Added this run", v.Added)) + } + return items +} + +func suppressedOf(stats SuppressionStats) string { + return fmt.Sprintf("%d of %d", stats.Suppressed, stats.Total) +} diff --git a/cli/internal/sarif/triage_view_test.go b/cli/internal/sarif/triage_view_test.go new file mode 100644 index 0000000000..bd48ae3e52 --- /dev/null +++ b/cli/internal/sarif/triage_view_test.go @@ -0,0 +1,100 @@ +package sarif + +import ( + "strings" + "testing" +) + +func newState(s BaselineState) *BaselineState { return &s } + +func TestFilterByBaselineState(t *testing.T) { + a := makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a")) + a.BaselineState = newState(New) + b := makeResult("b", Error, "b.java", 2, fp("id-b", "trace-b")) + b.BaselineState = newState(Unchanged) + c := makeResult("c", Error, "c.java", 3, fp("id-c", "trace-c")) + report := makeReport(a, b, c) + + got := report.Filter(Filters{BaselineStates: []string{"new"}}) + if len(got.Runs[0].Results) != 1 || *got.Runs[0].Results[0].RuleID != "a" { + t.Errorf("expected only the new finding, got %d results", len(got.Runs[0].Results)) + } + + got = report.Filter(Filters{BaselineStates: []string{"new", "unchanged"}}) + if len(got.Runs[0].Results) != 2 { + t.Errorf("expected 2 results, got %d", len(got.Runs[0].Results)) + } +} + +func TestFilterByBaselineStateIsCaseInsensitive(t *testing.T) { + a := makeResult("a", Error, "a.java", 1, nil) + a.BaselineState = newState(New) + got := makeReport(a).Filter(Filters{BaselineStates: []string{" NEW "}}) + if len(got.Runs[0].Results) != 1 { + t.Errorf("expected 1 result, got %d", len(got.Runs[0].Results)) + } +} + +func TestParseBaselineStatesValidatesValues(t *testing.T) { + if _, err := ParseBaselineStates([]string{"new", "absent"}); err != nil { + t.Errorf("unexpected error: %v", err) + } + if _, err := ParseBaselineStates([]string{"nope"}); err == nil { + t.Error("expected an error for an unknown baseline state") + } +} + +func TestPrintAllHidesSuppressedByDefault(t *testing.T) { + rendered := renderListing(t, makeReport( + suppressed("hidden.rule", "id-a", Accepted, "admin-only input"), + makeResult("shown.rule", Error, "b.java", 2, fp("id-b", "trace-b")), + ), ListingOptions{MaxNestingLevel: -1}) + + if strings.Contains(rendered, "hidden.rule") { + t.Errorf("suppressed finding should be hidden by default:\n%s", rendered) + } + if !strings.Contains(rendered, "shown.rule") { + t.Errorf("unsuppressed finding should be listed:\n%s", rendered) + } +} + +func TestPrintAllShowsSuppressedWithJustification(t *testing.T) { + rendered := renderListing(t, makeReport(suppressed("hidden.rule", "id-a", Accepted, "admin-only input")), ListingOptions{MaxNestingLevel: -1, ShowSuppressed: true}) + + if !strings.Contains(rendered, "hidden.rule") { + t.Errorf("finding should be listed with ShowSuppressed:\n%s", rendered) + } + if !strings.Contains(rendered, "admin-only input") { + t.Errorf("justification should be shown:\n%s", rendered) + } + if !strings.Contains(rendered, "accepted") { + t.Errorf("status should be shown:\n%s", rendered) + } +} + +func TestPrintAllShowsBaselineState(t *testing.T) { + r := makeResult("a.rule", Error, "a.java", 1, fp("id-a", "trace-a")) + r.BaselineState = newState(New) + rendered := renderListing(t, makeReport(r), ListingOptions{MaxNestingLevel: -1}) + + if !strings.Contains(rendered, "Baseline") || !strings.Contains(rendered, "new") { + t.Errorf("expected a baseline state field:\n%s", rendered) + } +} + +func TestPrintAllOmitsBaselineFieldWhenAbsent(t *testing.T) { + rendered := renderListing(t, makeReport(makeResult("a.rule", Error, "a.java", 1, nil)), + ListingOptions{MaxNestingLevel: -1}) + + if strings.Contains(rendered, "Baseline") { + t.Errorf("no baseline field expected without a comparison:\n%s", rendered) + } +} + +func TestPrintAllAllSuppressedRendersNothing(t *testing.T) { + rendered := renderListing(t, makeReport(suppressed("hidden.rule", "id-a", Accepted, "why")), + ListingOptions{MaxNestingLevel: -1}) + if strings.TrimSpace(rendered) != "" { + t.Errorf("expected no output, got:\n%s", rendered) + } +} diff --git a/cli/internal/sarif/utils.go b/cli/internal/sarif/utils.go index 9970f7f45e..6617ac2b7b 100644 --- a/cli/internal/sarif/utils.go +++ b/cli/internal/sarif/utils.go @@ -61,6 +61,10 @@ type RuleSummary struct { Notes int } +// LevelOf returns the result's SARIF level, defaulting to "note" when absent — +// the same reading the summary and the filters use. +func LevelOf(result *Result) Level { return findingLevel(result) } + func findingLevel(result *Result) Level { if result == nil || result.Level == nil || *result.Level == "" { return Note @@ -202,8 +206,11 @@ func pluralize(count int, singular string) string { return singular + "s" } -// PrintSummary prints a human-readable summary of the SARIF report -func (report *Report) PrintSummary(out *output.Printer, absSarifReportPath string) { +// PrintSummary prints a human-readable summary of the SARIF report. view is the +// baseline/suppression state to report alongside it, or nil when neither +// applies — in which case the output is exactly what it was before triage +// existed. +func (report *Report) PrintSummary(out *output.Printer, absSarifReportPath string, view *TriageView) { summary := GenerateSummary(report) ruleSummary := generateRuleSummary(report) @@ -244,17 +251,24 @@ func (report *Report) PrintSummary(out *output.Printer, absSarifReportPath strin rulesTriggered = out.FieldItem("Rules triggered", summary.TotalRulesTriggered) } - out.Section("Scan Summary"). - Group("Findings", - out.FieldItem("Total", totalLine), - out.FieldItem("Files affected", findingFiles(report)), - out.FieldItem("Rules executed", summary.TotalRulesExecuted), - rulesTriggered, - ). - Group("Output", - outputItems(out, absSarifReportPath)..., - ). - Render() + findings := []any{out.FieldItem("Total", totalLine)} + if view != nil && view.Suppressions.Suppressed > 0 { + findings = append(findings, out.FieldItem("Reported", view.Suppressions.Total-view.Suppressions.Suppressed)) + } + findings = append(findings, + out.FieldItem("Files affected", findingFiles(report)), + out.FieldItem("Rules executed", summary.TotalRulesExecuted), + rulesTriggered, + ) + + section := out.Section("Scan Summary").Group("Findings", findings...) + if items := view.baselineItems(out); len(items) > 0 { + section.Group("Baseline", items...) + } + if items := view.suppressionItems(out); len(items) > 0 { + section.Group("Suppressions", items...) + } + section.Group("Output", outputItems(out, absSarifReportPath)...).Render() } func outputItems(out *output.Printer, absSarifReportPath string) []any { diff --git a/cli/internal/triage/gate.go b/cli/internal/triage/gate.go new file mode 100644 index 0000000000..f9ee89a925 --- /dev/null +++ b/cli/internal/triage/gate.go @@ -0,0 +1,86 @@ +package triage + +import ( + "strings" + + "github.com/seqra/opentaint/internal/sarif" +) + +// Gate decides whether a report should fail the build. +// +// A finding counts when it is not suppressed and its level is in scope. With a +// baseline, only findings the comparison could not account for count: "new" +// ones, and ones it could not compare at all (no identity fingerprint), which +// fail closed rather than slipping through unnoticed. "unchanged" and "updated" +// findings existed before and do not fail the build. +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, view *sarif.TriageView) (int, bool) { + if !g.Enabled { + return 0, false + } + + count := 0 + for _, r := range report.Results() { + if sarif.IsSuppressed(r) { + continue + } + if !g.inScope(r) { + continue + } + if !counts(r, view) { + continue + } + count++ + } + return count, count > 0 +} + +// counts reports whether a finding is one the gate should care about given the +// baseline comparison, if any. +func counts(r *sarif.Result, view *sarif.TriageView) bool { + if view == nil || view.Comparison == nil { + return true + } + switch view.Comparison.StateOf(r) { + case sarif.New: + return true + case "": + // Not comparable against the baseline: fail closed. + return true + default: + return false + } +} + +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..1fc9dcfae1 --- /dev/null +++ b/cli/internal/triage/gate_test.go @@ -0,0 +1,172 @@ +package triage + +import ( + "testing" + + "github.com/seqra/opentaint/internal/sarif" +) + +func warn(ruleID, identity string) sarif.Result { + r := result(ruleID, identity, "trace-"+identity) + r.Level = lvlptr(sarif.Warning) + return r +} + +func TestGateDisabledNeverTrips(t *testing.T) { + rep := report(result("a", "id-a", "trace-a")) + out, _ := Apply(rep, Options{}) + count, tripped := Gate{}.Evaluate(rep, out.View) + 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", "id-a", "trace-a"), warn("b", "id-b")) + out, _ := Apply(rep, Options{}) + count, tripped := Gate{Enabled: true}.Evaluate(rep, out.View) + if !tripped || count != 2 { + t.Errorf("got (%d, %v), want (2, true)", count, tripped) + } +} + +func TestGateIgnoresSuppressedFindings(t *testing.T) { + rep := report(result("a", "id-aaa", "trace-a"), result("b", "id-bbb", "trace-b")) + out, err := Apply(rep, Options{Accept: []string{"id-aaa"}, Justification: "why"}) + if err != nil { + t.Fatal(err) + } + count, tripped := Gate{Enabled: true}.Evaluate(rep, out.View) + if count != 1 || !tripped { + t.Errorf("got (%d, %v), want (1, true)", count, tripped) + } +} + +func TestGateIgnoresDeferredFindings(t *testing.T) { + rep := report(result("a", "id-aaa", "trace-a")) + out, err := Apply(rep, Options{Defer: []string{"id-aaa"}, Justification: "not now"}) + if err != nil { + t.Fatal(err) + } + if _, tripped := (Gate{Enabled: true}).Evaluate(rep, out.View); tripped { + t.Error("a deferred finding must not trip the gate") + } +} + +func TestGateWithBaselineCountsOnlyNewFindings(t *testing.T) { + baseline := report(result("old", "id-old", "trace-old")) + rep := report(result("old", "id-old", "trace-old"), result("new", "id-new", "trace-new")) + out, err := Apply(rep, Options{Baseline: baseline}) + if err != nil { + t.Fatal(err) + } + count, tripped := Gate{Enabled: true}.Evaluate(rep, out.View) + if count != 1 || !tripped { + t.Errorf("got (%d, %v), want (1, true): only the new finding counts", count, tripped) + } +} + +func TestGateWithBaselineDoesNotTripWhenNothingIsNew(t *testing.T) { + baseline := report(result("old", "id-old", "trace-old")) + rep := report(result("old", "id-old", "trace-old")) + out, err := Apply(rep, Options{Baseline: baseline}) + if err != nil { + t.Fatal(err) + } + if _, tripped := (Gate{Enabled: true}).Evaluate(rep, out.View); tripped { + t.Error("an unchanged report must not trip the gate") + } +} + +func TestGateDoesNotCountUpdatedFindings(t *testing.T) { + baseline := report(result("a", "id-a", "trace-old")) + rep := report(result("a", "id-a", "trace-new")) + out, err := Apply(rep, Options{Baseline: baseline}) + if err != nil { + t.Fatal(err) + } + if _, tripped := (Gate{Enabled: true}).Evaluate(rep, out.View); tripped { + t.Error("an updated finding is the same accepted vulnerability through a new path, not a new finding") + } +} + +func TestGateCountsUncomparableFindings(t *testing.T) { + // A finding with no identity fingerprint cannot be matched against the + // baseline. Fail closed: it is reported and it counts. + baseline := report(result("old", "id-old", "trace-old")) + nofp := sarif.Result{RuleID: strptr("nofp"), Level: lvlptr(sarif.Error)} + rep := report(result("old", "id-old", "trace-old")) + rep.Runs[0].Results = append(rep.Runs[0].Results, nofp) + + out, err := Apply(rep, Options{Baseline: baseline}) + if err != nil { + t.Fatal(err) + } + count, tripped := Gate{Enabled: true}.Evaluate(rep, out.View) + if count != 1 || !tripped { + t.Errorf("got (%d, %v), want (1, true)", count, tripped) + } +} + +func TestGateRestrictsToSeverities(t *testing.T) { + rep := report(result("a", "id-a", "trace-a"), warn("b", "id-b")) + out, _ := Apply(rep, Options{}) + + count, tripped := Gate{Enabled: true, Severities: []string{"error"}}.Evaluate(rep, out.View) + if count != 1 || !tripped { + t.Errorf("got (%d, %v), want (1, true)", count, tripped) + } + + count, tripped = Gate{Enabled: true, Severities: []string{"note"}}.Evaluate(rep, out.View) + 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/triage/triage.go b/cli/internal/triage/triage.go new file mode 100644 index 0000000000..f5f2c97ada --- /dev/null +++ b/cli/internal/triage/triage.go @@ -0,0 +1,167 @@ +// Package triage applies baselines and suppressions to a SARIF report. It is +// the single implementation behind the `triage` command, the annotation step of +// `scan`, and the read-only view `summary` renders. +package triage + +import ( + "fmt" + + "github.com/seqra/opentaint/internal/sarif" +) + +// Options describes one triage pass over a report. +type Options struct { + // Baseline is the previously produced report to compare against, or nil. + Baseline *sarif.Report + // BaselinePath is that report's path, for display only. + BaselinePath string + // WriteBaselineState persists result.baselineState and run.baselineGuid. + // Without it the comparison only drives what is printed. + WriteBaselineState bool + // ReadOnly means the caller will never persist the report. The comparison is + // still applied to the in-memory copy so that --baseline-state can filter on + // it, but nothing is reported as written or changed. This is what summary + // uses. + ReadOnly bool + + // Accept, Defer and Unsuppress name findings by fingerprint prefix. + Accept []string + Defer []string + Unsuppress []string + // Justification is required whenever Accept or Defer is non-empty. + Justification string +} + +// suppressing reports whether the options author any new decision. +func (o Options) suppressing() bool { + return len(o.Accept) > 0 || len(o.Defer) > 0 +} + +// Outcome is what one triage pass produced. +type Outcome struct { + // View is the baseline and suppression state to print. + View *sarif.TriageView + // Changed reports whether the report was modified and needs writing back. + Changed bool +} + +// Apply runs a triage pass over report, mutating it in place. +// +// Order matters: suppressions are inherited from the baseline first, so that a +// decision made in a previous cycle is visible. Explicit accept/defer +// decisions from this run then overwrite them, and the baseline comparison is +// computed over the final state. +func Apply(report *sarif.Report, opts Options) (*Outcome, error) { + if opts.suppressing() && opts.Justification == "" { + return nil, fmt.Errorf("a justification is required to suppress a finding: pass --justification") + } + + view := &sarif.TriageView{BaselinePath: opts.BaselinePath, ReadOnly: opts.ReadOnly} + changed := false + + if opts.Baseline != nil { + view.Inherited = sarif.InheritSuppressions(report, opts.Baseline) + changed = changed || view.Inherited > 0 + } + + added, err := applyDecisions(report, opts) + if err != nil { + return nil, err + } + view.Added = added + changed = changed || added > 0 + + removed, err := applyUnsuppressions(report, opts.Unsuppress) + if err != nil { + return nil, err + } + changed = changed || removed > 0 + + if opts.Baseline != nil { + comparison, err := sarif.CompareToBaseline(report, opts.Baseline) + if err != nil { + return nil, err + } + view.Comparison = comparison + if opts.WriteBaselineState || opts.ReadOnly { + comparison.Apply(report) + view.StateWritten = opts.WriteBaselineState && !opts.ReadOnly + changed = changed || view.StateWritten + } + } + + if opts.ReadOnly { + changed = false + } + if changed { + // A report the CLI has written must be citable as the next baseline. + sarif.EnsureRunGUIDs(report) + } + + view.Suppressions = sarif.CollectSuppressionStats(report) + return &Outcome{View: view, Changed: changed}, nil +} + +// applyDecisions resolves each accept/defer prefix and records the decision. +// Every prefix is resolved before anything is written, so a typo in the second +// of three prefixes leaves the report untouched rather than half-triaged. +func applyDecisions(report *sarif.Report, opts Options) (int, error) { + type decision struct { + result *sarif.Result + accept bool + } + + var decisions []decision + for _, prefix := range opts.Accept { + matched, err := sarif.ResolvePrefix(report, prefix) + if err != nil { + return 0, err + } + for _, r := range matched { + decisions = append(decisions, decision{result: r, accept: true}) + } + } + for _, prefix := range opts.Defer { + matched, err := sarif.ResolvePrefix(report, prefix) + if err != nil { + return 0, err + } + for _, r := range matched { + decisions = append(decisions, decision{result: r}) + } + } + + for _, d := range decisions { + var err error + if d.accept { + err = sarif.Accept(d.result, opts.Justification) + } else { + err = sarif.Defer(d.result, opts.Justification) + } + if err != nil { + return 0, err + } + } + return len(decisions), nil +} + +// applyUnsuppressions resolves every prefix before removing anything, for the +// same all-or-nothing reason as applyDecisions. +func applyUnsuppressions(report *sarif.Report, prefixes []string) (int, error) { + var targets []*sarif.Result + for _, prefix := range prefixes { + matched, err := sarif.ResolvePrefix(report, prefix) + if err != nil { + return 0, err + } + targets = append(targets, matched...) + } + + removed := 0 + for _, r := range targets { + if sarif.Unsuppress(r) { + removed++ + } + } + return removed, nil +} diff --git a/cli/internal/triage/triage_test.go b/cli/internal/triage/triage_test.go new file mode 100644 index 0000000000..67f5ee4fba --- /dev/null +++ b/cli/internal/triage/triage_test.go @@ -0,0 +1,287 @@ +package triage + +import ( + "strings" + "testing" + + "github.com/seqra/opentaint/internal/sarif" +) + +func strptr(s string) *string { return &s } +func lvlptr(l sarif.Level) *sarif.Level { return &l } + +func result(ruleID, identity string, trace string) sarif.Result { + return sarif.Result{ + RuleID: strptr(ruleID), + Level: lvlptr(sarif.Error), + Locations: []sarif.Location{{ + PhysicalLocation: &sarif.PhysicalLocation{ + ArtifactLocation: &sarif.ArtifactLocation{URI: strptr(ruleID + ".java")}, + }, + }}, + PartialFingerprints: map[string]string{ + sarif.SinkFingerprintKey: identity, + sarif.SourceSinkFingerprintKey: identity, + sarif.TraceFingerprintKey: trace, + }, + } +} + +func report(results ...sarif.Result) *sarif.Report { + return &sarif.Report{Runs: []sarif.Run{{Results: results}}} +} + +func TestApplyWithNoOptionsChangesNothing(t *testing.T) { + r := report(result("a", "id-a", "trace-a")) + out, err := Apply(r, Options{}) + if err != nil { + t.Fatalf("apply: %v", err) + } + if out.Changed { + t.Error("expected no change") + } + if out.View.Comparison != nil { + t.Error("expected no comparison without a baseline") + } +} + +func TestApplyInheritsSuppressionsFromBaseline(t *testing.T) { + base := result("a", "id-a", "trace-a") + if err := sarif.Accept(&base, "admin-only input"); err != nil { + t.Fatal(err) + } + current := report(result("a", "id-a", "trace-a"), result("b", "id-b", "trace-b")) + + out, err := Apply(current, Options{Baseline: report(base)}) + if err != nil { + t.Fatalf("apply: %v", err) + } + if out.View.Inherited != 1 { + t.Errorf("inherited: got %d, want 1", out.View.Inherited) + } + if !sarif.IsSuppressed(current.Results()[0]) { + t.Error("matching finding should have inherited the suppression") + } + if !out.Changed { + t.Error("inheriting a suppression changes the report") + } +} + +func TestApplyComparesButDoesNotWriteStateByDefault(t *testing.T) { + current := report(result("a", "id-a", "trace-a"), result("b", "id-b", "trace-b")) + out, err := Apply(current, Options{Baseline: report(result("a", "id-a", "trace-a"))}) + if err != nil { + t.Fatalf("apply: %v", err) + } + if out.View.Comparison.Counts[sarif.New] != 1 { + t.Errorf("expected 1 new, got %d", out.View.Comparison.Counts[sarif.New]) + } + for _, r := range current.Results() { + if r.BaselineState != nil { + t.Error("baselineState must not be written without WriteBaselineState") + } + } + if out.View.StateWritten { + t.Error("StateWritten should be false") + } + if out.Changed { + t.Error("a comparison alone does not change the report") + } +} + +func TestApplyWritesStateWhenAsked(t *testing.T) { + current := report(result("a", "id-a", "trace-a")) + out, err := Apply(current, Options{ + Baseline: report(result("a", "id-a", "trace-a")), + WriteBaselineState: true, + }) + if err != nil { + t.Fatalf("apply: %v", err) + } + if current.Results()[0].BaselineState == nil { + t.Fatal("baselineState not written") + } + if !out.View.StateWritten || !out.Changed { + t.Error("writing state marks the report changed") + } + if current.RunGUID() == "" { + t.Error("a written report must be citable as a baseline: expected a run guid") + } +} + +func TestApplyAcceptsByFingerprintPrefix(t *testing.T) { + current := report(result("a", "id-aaa111", "trace-a"), result("b", "id-bbb222", "trace-b")) + out, err := Apply(current, Options{ + Accept: []string{"id-aaa"}, + Justification: "sink is a constant", + }) + if err != nil { + t.Fatalf("apply: %v", err) + } + if out.View.Added != 1 { + t.Errorf("added: got %d, want 1", out.View.Added) + } + first := current.Results()[0] + if !sarif.IsSuppressed(first) || sarif.StatusOf(first) != "accepted" { + t.Errorf("expected an accepted suppression, got %q", sarif.StatusOf(first)) + } + if sarif.IsSuppressed(current.Results()[1]) { + t.Error("the other finding must be untouched") + } +} + +func TestApplyDefersByFingerprintPrefix(t *testing.T) { + current := report(result("a", "id-aaa111", "trace-a")) + if _, err := Apply(current, Options{Defer: []string{"id-aaa"}, Justification: "waiting on OT-412"}); err != nil { + t.Fatalf("apply: %v", err) + } + if got := sarif.StatusOf(current.Results()[0]); got != "underReview" { + t.Errorf("status: got %q, want underReview", got) + } +} + +func TestApplyUnsuppresses(t *testing.T) { + r := result("a", "id-aaa111", "trace-a") + if err := sarif.Accept(&r, "was accepted"); err != nil { + t.Fatal(err) + } + current := report(r) + + out, err := Apply(current, Options{Unsuppress: []string{"id-aaa"}}) + if err != nil { + t.Fatalf("apply: %v", err) + } + if sarif.IsSuppressed(current.Results()[0]) { + t.Error("expected the suppression to be removed") + } + if !out.Changed { + t.Error("removing a suppression changes the report") + } +} + +func TestApplyRequiresJustificationForAccept(t *testing.T) { + current := report(result("a", "id-aaa111", "trace-a")) + _, err := Apply(current, Options{Accept: []string{"id-aaa"}}) + if err == nil || !strings.Contains(err.Error(), "justification") { + t.Errorf("expected a justification error, got %v", err) + } + if sarif.IsSuppressed(current.Results()[0]) { + t.Error("nothing should be suppressed when validation fails") + } +} + +func TestApplyRejectsUnknownFingerprint(t *testing.T) { + current := report(result("a", "id-aaa111", "trace-a")) + _, err := Apply(current, Options{Accept: []string{"zzz"}, Justification: "why"}) + if err == nil { + t.Error("expected an error for an unmatched fingerprint") + } +} + +func TestApplyRejectsAmbiguousFingerprint(t *testing.T) { + current := report(result("a", "id-aaa111", "trace-a"), result("b", "id-aaa222", "trace-b")) + _, err := Apply(current, Options{Accept: []string{"id-aaa"}, Justification: "why"}) + if err == nil || !strings.Contains(err.Error(), "ambiguous") { + t.Errorf("expected an ambiguity error, got %v", err) + } +} + +func TestApplyPropagatesBaselineKeyMismatch(t *testing.T) { + baseline := &sarif.Report{Runs: []sarif.Run{{Results: []sarif.Result{{ + RuleID: strptr("a"), + PartialFingerprints: map[string]string{"someOtherKey/v1": "x"}, + }}}}} + _, err := Apply(report(result("a", "id-a", "trace-a")), Options{Baseline: baseline}) + if err == nil { + t.Error("expected an error when the baseline lacks the identity key") + } +} + +func TestApplySuppressionStatsCoverTheWholeReport(t *testing.T) { + current := report(result("a", "id-aaa", "trace-a"), result("b", "id-bbb", "trace-b")) + out, err := Apply(current, Options{Accept: []string{"id-aaa"}, Justification: "why"}) + if err != nil { + t.Fatalf("apply: %v", err) + } + if out.View.Suppressions.Total != 2 || out.View.Suppressions.Suppressed != 1 { + t.Errorf("stats: got %+v", out.View.Suppressions) + } +} + +func TestApplyReadOnlyAnnotatesInMemoryWithoutClaimingToWrite(t *testing.T) { + // summary never writes the report, but it still needs baselineState on the + // in-memory copy so that --baseline-state can filter on it. + current := report(result("a", "id-a", "trace-a"), result("b", "id-b", "trace-b")) + out, err := Apply(current, Options{ + Baseline: report(result("a", "id-a", "trace-a")), + ReadOnly: true, + }) + if err != nil { + t.Fatalf("apply: %v", err) + } + states := []string{} + for _, r := range current.Results() { + if r.BaselineState == nil { + t.Fatal("read-only mode must still annotate the in-memory report") + } + states = append(states, string(*r.BaselineState)) + } + if states[0] != "unchanged" || states[1] != "new" { + t.Errorf("states: got %v", states) + } + if out.Changed { + t.Error("read-only mode must never mark the report as needing a write") + } + if out.View.StateWritten { + t.Error("read-only mode must not claim the state was persisted") + } + if !out.View.ReadOnly { + t.Error("the view should record that nothing will be written") + } +} + +func TestApplyReadOnlyStillInheritsSuppressions(t *testing.T) { + base := result("a", "id-a", "trace-a") + if err := sarif.Accept(&base, "admin-only"); err != nil { + t.Fatal(err) + } + current := report(result("a", "id-a", "trace-a")) + out, err := Apply(current, Options{Baseline: report(base), ReadOnly: true}) + if err != nil { + t.Fatal(err) + } + if !sarif.IsSuppressed(current.Results()[0]) { + t.Error("read-only display must still show inherited suppressions") + } + if out.Changed { + t.Error("read-only mode must not mark the report as changed") + } +} + +// Two results sharing one identity value are the same finding to a decision, +// so accepting that fingerprint suppresses both. Under the coarse default key +// such duplicates are legitimate and no longer prefix could separate them. +func TestApplyAcceptCoversAllDuplicatesOfOneIdentity(t *testing.T) { + current := report( + result("a", "id-same", "trace-1"), + result("a", "id-same", "trace-2"), + result("b", "id-other", "trace-b"), + ) + out, err := Apply(current, Options{ + Accept: []string{"id-same"}, + Justification: "sink is a constant", + }) + if err != nil { + t.Fatalf("apply: %v", err) + } + if out.View.Added != 2 { + t.Errorf("added: got %d, want both duplicates", out.View.Added) + } + results := current.Results() + if !sarif.IsSuppressed(results[0]) || !sarif.IsSuppressed(results[1]) { + t.Error("both duplicates must carry the suppression") + } + if sarif.IsSuppressed(results[2]) { + t.Error("the other finding must be untouched") + } +} diff --git a/cli/internal/utils/opentaint_command_builder.go b/cli/internal/utils/opentaint_command_builder.go index 1356a557bb..2d57d22c7c 100644 --- a/cli/internal/utils/opentaint_command_builder.go +++ b/cli/internal/utils/opentaint_command_builder.go @@ -184,7 +184,7 @@ func (cb *OpentaintCommandBuilder) WithRuleID(ruleIDs []string) *OpentaintComman func (cb *OpentaintCommandBuilder) WithPassthroughApproximations(paths []string) *OpentaintCommandBuilder { for _, p := range paths { if p != "" { - cb.arrayFlags["passthrough-approximations"] = append(cb.arrayFlags["passthrough-approximations"], p) + cb.arrayFlags["passthrough-models"] = append(cb.arrayFlags["passthrough-models"], p) } } return cb @@ -193,7 +193,7 @@ func (cb *OpentaintCommandBuilder) WithPassthroughApproximations(paths []string) func (cb *OpentaintCommandBuilder) WithDataflowApproximations(paths []string) *OpentaintCommandBuilder { for _, p := range paths { if p != "" { - cb.arrayFlags["dataflow-approximations"] = append(cb.arrayFlags["dataflow-approximations"], p) + cb.arrayFlags["java-models"] = append(cb.arrayFlags["java-models"], p) } } return cb @@ -216,14 +216,6 @@ func (cb *OpentaintCommandBuilder) WithPartialFingerprint(fingerprints []string) return cb } -// WithPartialFingerprintKey sets the --partial-fingerprint-key flag. -func (cb *OpentaintCommandBuilder) WithPartialFingerprintKey(key string) *OpentaintCommandBuilder { - if key != "" { - cb.flags["partial-fingerprint-key"] = key - } - return cb -} - // WithMaxNestingLevel sets the --max-nesting-level flag when level >= 0. func (cb *OpentaintCommandBuilder) WithMaxNestingLevel(level int) *OpentaintCommandBuilder { if level >= 0 { @@ -389,3 +381,66 @@ func BuildScanCommandFromCompile(projectPath, projectModelPath string) string { WithOutput(outputPath). Build() } + +// WithBaseline sets the --baseline flag. +func (cb *OpentaintCommandBuilder) WithBaseline(path string) *OpentaintCommandBuilder { + if path != "" { + cb.flags["baseline"] = path + } + return cb +} + +// WithWriteBaselineState sets the --write-baseline-state flag (scan/triage). +func (cb *OpentaintCommandBuilder) WithWriteBaselineState(enabled bool) *OpentaintCommandBuilder { + if enabled { + cb.boolFlags["write-baseline-state"] = true + } + return cb +} + +// WithErrorOnFindings sets the --error-on-findings flag. +func (cb *OpentaintCommandBuilder) WithErrorOnFindings(enabled bool) *OpentaintCommandBuilder { + if enabled { + cb.boolFlags["error-on-findings"] = true + } + return cb +} + +// WithErrorOnSeverity adds repeatable --error-on-severity filters. +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 +} + +// WithSuppressed sets the --suppressed flag. +func (cb *OpentaintCommandBuilder) WithSuppressed(enabled bool) *OpentaintCommandBuilder { + if enabled { + cb.boolFlags["suppressed"] = true + } + return cb +} + +// WithBaselineStateFilter adds repeatable --baseline-state selection values for +// the summary command, where the flag takes values rather than being a switch. +func (cb *OpentaintCommandBuilder) WithBaselineStateFilter(states []string) *OpentaintCommandBuilder { + for _, s := range states { + if s != "" { + cb.arrayFlags["baseline-state"] = append(cb.arrayFlags["baseline-state"], s) + } + } + return cb +} + +// WithExcludeRuleID adds repeatable --exclude-rule-id filters. +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/cli/internal/utils/opentaint_command_builder_test.go b/cli/internal/utils/opentaint_command_builder_test.go index 213dbfb0c6..ed85341e85 100644 --- a/cli/internal/utils/opentaint_command_builder_test.go +++ b/cli/internal/utils/opentaint_command_builder_test.go @@ -312,7 +312,6 @@ func TestSummaryFilterFlags(t *testing.T) { WithSeverity([]string{"error"}). WithRuleID([]string{"sql-injection"}). WithPartialFingerprint([]string{"abc123"}). - WithPartialFingerprintKey("vulnerabilitySourceSinkHash/v1"). WithMaxNestingLevel(3). WithGroupBy("severity"). Build() @@ -323,7 +322,6 @@ func TestSummaryFilterFlags(t *testing.T) { "--severity error", "--rule-id sql-injection", "--partial-fingerprint abc123", - "--partial-fingerprint-key vulnerabilitySourceSinkHash/v1", "--max-nesting-level 3", "--group-by severity", "--show-findings", diff --git a/docs/README.md b/docs/README.md index 760695b752..4bb8128435 100644 --- a/docs/README.md +++ b/docs/README.md @@ -16,6 +16,7 @@ - [Installation Guide](installation.md) - Full installation instructions - [Usage Guide](usage.md) - Comprehensive usage reference +- [Baselines & Suppressions](baselines-and-suppressions.md) - Baseline comparison, triage, and CI gating - [Configuration Guide](configuration.md) - All configuration options - [Docker](docker.md) - Run OpenTaint in containers and CI/CD pipelines - [Precompiled Classes and JARs Analysis](classes-and-jars-analysis.md) - Analyze pre-built artifacts when source compilation isn't available @@ -124,15 +125,17 @@ npx @seqra/opentaint scan # Run without installi opentaint scan --output results.sarif # Scan with explicit output path opentaint summary --show-findings results.sarif # View results opentaint summary --show-findings --verbose-flow --show-code-snippets results.sarif # Full detail +opentaint scan --baseline main.sarif --error-on-findings # Fail CI only on new findings ``` | Command | Description | |---------|-------------| -| `opentaint scan` | Analyze projects (auto-detects Maven/Gradle) | +| `opentaint scan` | Analyze projects (auto-detects the build system) | | `opentaint compile` | Build project model separately | | `opentaint project` | Create model from precompiled JARs | | `opentaint summary` | View SARIF results | -| `opentaint health` | Show resolved analyzer, autobuilder, rules, and runtime paths | +| `opentaint triage` | Compare against a baseline and record suppressions | +| `opentaint health` | Show dependency paths and report missing components | | `opentaint test rule` | Scaffold, test, and debug detection rules | | `opentaint test approximation` | Scaffold and test dataflow approximations | | `opentaint pull` | Download dependencies | @@ -166,6 +169,7 @@ For detailed configuration, see [Configuration Guide](configuration.md). - **GitHub Actions:** [seqra/opentaint/github](https://github.com/seqra/opentaint/tree/main/github) - **GitLab CI:** [seqra/opentaint/gitlab](https://github.com/seqra/opentaint/tree/main/gitlab) +- **Baseline gating** (fail only on *new* findings), triage, and copy-paste PR workflows: [Baselines & Suppressions](baselines-and-suppressions.md) --- diff --git a/docs/baselines-and-suppressions.md b/docs/baselines-and-suppressions.md new file mode 100644 index 0000000000..9f3c86c80f --- /dev/null +++ b/docs/baselines-and-suppressions.md @@ -0,0 +1,450 @@ +# Baselines, suppressions, and CI gating + +OpenTaint lets you adopt static analysis on an existing codebase without +drowning in the findings that were already there, and without hiding anything +silently. This guide covers three related capabilities: + +- **Baselines** — compare a scan against a previous report and tell what is new. +- **Suppressions** — record an explicit human decision to accept or defer a finding. +- **Gating** — fail a build on findings, optionally only on new ones. + +Everything is expressed in [SARIF 2.1.0](https://docs.oasis-open.org/sarif/sarif/v2.1.0/) +using the format's own fields, so any SARIF-aware tool (GitHub code scanning, +GitLab, IDEs) understands the output. + +## The mental model: two independent axes + +A finding sits on two axes that never interfere with each other. + +| Axis | Question | Where it lives | Set by | +|------|----------|----------------|--------| +| **Baseline state** | *Is this new?* | `result.baselineState` | `--baseline` comparison | +| **Suppression** | *Did a human accept this?* | `result.suppressions[]` | `opentaint triage` | + +The two are orthogonal. A finding can be old **and** unaccepted (it shows up as +`unchanged` and still counts). It can be new **and** already suppressed (rare, +but valid). Nothing about being in the baseline makes a finding "accepted" — only +a `triage` decision does that. + +Neither axis ever deletes a result. Suppressed and baselined findings stay in the +report, marked. The CLI filters them at display and gate time, not in the file. + +## Quick start + +```bash +# 1. Scan once. Keep the report — it is your baseline. +opentaint scan -o baseline.sarif . + +# 2. In CI, scan against it and fail only on new findings. +opentaint scan --baseline baseline.sarif --error-on-findings . +``` + +That is the whole ratchet: a codebase with 40 pre-existing findings does not turn +CI permanently red — only work introduced by the current change fails the build. + +## The lifecycle + +### 1. Establish a baseline + +A baseline is just a SARIF report you saved. There is no separate baseline +format and no suppressions file to maintain. + +```bash +opentaint scan -o baselines/main.sarif . +``` + +Commit that report (or store it as a CI artifact keyed to your default branch). + +### 2. Triage the findings you have reviewed + +`opentaint triage` records a decision about a finding directly in the report. +Two verdicts, each requiring a justification: + +```bash +# "Won't fix" — reviewed, accepted as not a real risk here. +opentaint triage baselines/main.sarif \ + --accept q3Vf9k --justification "MD5 is a cache key, not a secret hash" + +# "Not fixing yet" — real, but deferred. +opentaint triage baselines/main.sarif \ + --defer 8bc1d2 --justification "scheduled with the payments refactor (PAY-1420)" +``` + +A finding is named by a **fingerprint prefix**, git-style — the value shown as +`Fingerprint:` by `opentaint summary --show-findings`. An ambiguous or unknown +prefix is an error, never a guess. Several results carrying the same +fingerprint are the same finding, and one decision covers them all. `--accept`, `--defer`, and `--unsuppress` are +repeatable. One `--justification` applies to every decision in the invocation, +and passing it twice is an error rather than a silent "last one wins" — run +`triage` once per reason. + +Each decision is written as a SARIF suppression (see +[Suppression reference](#suppression-reference)): + +```json +"suppressions": [{ + "kind": "external", + "status": "accepted", + "guid": "3f2a…", + "justification": "MD5 is a cache key, not a secret hash" +}] +``` + +### 3. Decisions travel forward + +When a later scan runs with `--baseline`, a current finding that matches a +baseline entry carrying a suppression **inherits it verbatim** — same status, +same justification, same guid. A decision is authored once and re-attached by +every scan afterwards, for as long as the finding's fingerprint still matches. +When the code is fixed and the finding disappears, its decision retires with it. + +This is why suppressions live in the report and not in a config file that would +accumulate dead entries forever. + +### 4. Gate CI on new findings + +```bash +opentaint scan --baseline baselines/main.sarif \ + --error-on-findings --error-on-severity error,warning -o scan.sarif . +``` + +With `--baseline`, the gate counts only findings that are **new** and **not +suppressed**. Without a baseline, it counts every reported (non-suppressed) +finding. See [The gate](#the-gate). + +### 5. Explain what changed + +```bash +opentaint summary scan.sarif --baseline baselines/main.sarif \ + --baseline-state new --show-findings +``` + +`--baseline-state` here is a **display filter** — it narrows the listing to the +findings in the states you name. This is a different flag from +`scan --write-baseline-state` (see the warning under +[Baseline reference](#baseline-reference)). + +## Baseline reference + +Given `--baseline old.sarif`, every current finding is classified: + +| State | Meaning | +|-------|---------| +| `new` | In this scan, not in the baseline | +| `unchanged` | In both, identical trace | +| `updated` | In both — same source and sink, but the path through the code changed | +| `absent` | In the baseline, not in this scan. Usually fixed, but see [What changed underneath](#what-changed-underneath) | + +By default the comparison only affects **what is printed** — the SARIF file is +left byte-for-byte unchanged. Two flags control it: + +| Flag | Command | Effect | +|------|---------|--------| +| `--write-baseline-state` | `scan`, `triage` | **Switch.** Persists `result.baselineState` and `run.baselineGuid` into the output report. | +| `--baseline-state ` | `summary` | **Filter.** Shows only findings whose state is one of `new` \| `unchanged` \| `updated` \| `absent` (repeatable). | + +> **These are two different flags that share the word "baseline-state."** +> On `scan`/`triage` it is a boolean that *writes* the state into the file. +> On `summary` it takes a value and *filters* the listing. They do not overlap. + +The filter reads whichever states are available: the ones a previous +`--write-baseline-state` persisted into the report, or the ones a `--baseline` +on the same command line computes on the spot. Both work: + +```bash +# states computed now +opentaint summary scan.sarif --baseline main.sarif --baseline-state new --show-findings + +# states already in the file, written by the scan that produced it +opentaint scan --baseline main.sarif --write-baseline-state -o scan.sarif . +opentaint summary scan.sarif --baseline-state new --show-findings +``` + +Asking for a state when the report carries none and no baseline was given is an +error, not an empty listing — "0 findings" would read as a clean bill of health +for a report nobody compared against anything. + +`absent` findings are never written into the output report — surfacing a +resolved finding as a live alert would be wrong — but `--baseline-state absent` +lists them, read from the baseline, which is how you see what a change fixed. + +### Finding identity + +Findings are matched across reports by a **fingerprint**, not by line number, so +moving code around does not invent new findings. The identity is always the +**sink hash** (`vulnerabilitySinkHash/v1`): it hashes the rule and the sink, +the vulnerable statement itself, and nothing else. A decision therefore +survives every edit to how the untrusted data reaches that statement — +including the ones the analyzer makes on its own, since its choice of source is +not yet stable between runs of the same code (see +`docs/reports/fingerprint-stability.md`). This costs nothing in precision: the +analyzer reports one finding per rule and sink, so the sink hash is still one +fingerprint per finding. The hash covers the rule id, so it never spans two +rules that fire on one statement. + +One identity governs everything a command does with fingerprints: baseline +matching, suppression inheritance, the prefix `triage` resolves, the value +`summary --show-findings` prints as `Fingerprint:`, and what +`--partial-fingerprint` matches. That is why a fingerprint copied off the +screen always names a finding to `triage`. + +The analyzer also emits two finer hashes, `vulnerabilitySourceSinkHash/v1` +(rule + source + sink) and `vulnerabilityWithTraceHash/v1` (rule + every step +of every trace). They never decide identity. The comparison reads them to +describe what happened to a matched finding — see the next section. + +### What changed underneath + +A finding that matches the baseline can still have moved below its identity. +SARIF has one word for all of it — `updated` — so the summary says which: + +| Line | Meaning | +|------|---------| +| `Unchanged` | Nothing below the identity moved. | +| `Updated, source changed` | The same sink, now reached from a source that was not in the baseline. Worth a look: a new entry point reaches code already known to be dangerous. | +| `Updated, path changed` | The same source and sink, joined by a different call path. Usually a refactoring in between. | + +Both remain `updated` in the SARIF `baselineState`, so `--baseline-state updated` +selects either. + +An absence gets the same scrutiny. The sink hash disappears whenever the code +around the sink moves, so a gone hash does not prove a gone finding. The +summary reports what the current scan still shows of each absent finding: + +| Line | Meaning | +|------|---------| +| `Absent` | Nothing in the current report points at the finding. | +| `Possibly drifted` | A new finding reports the same rule in the same file. A hint, not proof: the absent finding may have moved and taken its hash with it, or the new finding may be unrelated. | + +Both lines are `absent` in SARIF terms: `--baseline-state absent` selects both, +and the listing prints the qualifier next to each finding's `Baseline:` state. + +A baseline whose results carry no fingerprints at all is rejected with a hard +error, not a silent zero-match. Current findings that carry no fingerprint are +reported as-is and counted as "not comparable." + +## Suppression reference + +A suppression is written only by `opentaint triage`, only with a justification, +and only ever as `kind: "external"` (the justification lives outside the source, +not in an in-source comment). The verdict is carried by the SARIF `status`: + +| `triage` flag | `suppression.status` | Meaning | Hidden from gate? | +|---------------|----------------------|---------|-------------------| +| `--accept` | `accepted` | The team will not fix this | Yes | +| `--defer` | `underReview` | The team is not fixing this for now | Yes | +| `--unsuppress` | *(removes the entry)* | Retract a decision | — | + +Both `--accept` and `--defer` hide the finding from the listing and from the +gate. A deferral does **not** expire on its own. The summary's `Deferred` count +keeps it visible so it can be revisited. + +`--unsuppress` removes the suppression from the report being triaged. It does not +"un-inherit": if the baseline still carries the decision, the next scan re-attaches +it. To retract a decision permanently, re-triage the baseline. + +### Reading suppressions conservatively + +When a baseline (or a report from another tool) is read, its suppressions are +interpreted defensively: + +| Status on the entry | Outcome | +|---------------------|---------| +| absent, or `accepted` | Suppressed | +| `underReview` | Suppressed, counted as deferred | +| `rejected` | **Not** suppressed — the suppression was explicitly denied | +| anything unrecognized | **Not** suppressed, counted under "Not honored" | + +A non-accepted or unknown status never hides a finding, and never disappears +silently — the summary surfaces it. + +> **Note on false positives.** SARIF 2.1.0 has no formal false-positive marker, +> and `status: "rejected"` means "the suppression request was rejected" (report +> it), not "this finding is wrong." Record that a finding is a false positive in +> the free-text `--justification`. + +### The summary Suppressions group + +``` +Suppressions +├─ Suppressed: 14 of 90 +├─ Won't fix: 9 (accepted) +├─ Deferred: 5 (under review) +├─ Inherited from baseline: 12 +└─ Added this run: 1 (triage only) +``` + +`opentaint summary --show-findings` hides suppressed findings by default. Add +`--suppressed` to list them with their justification. + +## The gate + +| Flag | Meaning | +|------|---------| +| `--error-on-findings` | Enable the gate. Off by default — without it, scans never fail on findings. | +| `--error-on-severity ` | Restrict the gate to these levels: `error`, `warning`, `note`, `none`. Comma-separated or repeated. Default is all reported levels. | + +A finding counts toward the gate when it is **not suppressed** and its level is +in scope. With `--baseline`, only **new** findings count (`unchanged` and +`updated` existed before). Findings that cannot be compared (no fingerprint) fail +closed — they count. + +### Exit codes + +| Code | Meaning | +|------|---------| +| `0` | Completed, gate not tripped | +| `2` | Findings remain and `--error-on-findings` was set | +| `1` | General failure (bad input, unreadable report) | +| `252`–`255` | Analyzer failure (exception, OOM, timeout, config error) | + +Exit `2` is deliberately distinct from `1` and from the analyzer codes, so CI can +tell "the scan found new problems" apart from "the scan itself broke." + +## Rule selection (a related scan-time control) + +Rule selection decides which rules the analyzer runs at all. It is **not** +suppression: an excluded rule never loads, so it produces nothing in the report +and there is nothing to review later. To hide a finding a rule *did* produce, +accept it with `triage` instead. + +Configure allow/deny lists in the config file: + +```yaml +rules: + only: # if set, only these rules run + - sql-injection # exact rule name + - java/security/** # glob over the full id + exclude: # these rules never run + - reflected-xss-in-servlet-app +``` + +Or on the command line: + +```bash +opentaint scan --exclude-rule-id java-jwt-decode-without-verify . +``` + +Each entry matches a full `path/to/file.yaml:rule-id`, a bare rule name, or a +doublestar glob over the full id — the same grammar as `summary --rule-id`. +`--rule-id` overrides the config lists. `--exclude-rule-id` overrides +`rules.exclude`. + +Notes: +- Excluding a rule does not fake a wave of fixes. A baseline finding whose rule + did not run in the current scan is reported as `Rule not run`, separately from + `Absent`, because its absence says nothing about whether anyone fixed it. +- A pattern matching no rule produces a warning, so a typo cannot silently look + effective. +- A selection that ends up matching **no** rules is an error, not a silent scan + of nothing — `--dry-run` reports it without compiling. +- Excluding a library rule that a surviving rule joins against keeps working: the + reference still resolves, so removing a rule never quietly breaks another. + +## CI/CD recipes + +The official [GitHub Action](https://github.com/seqra/opentaint/tree/main/github) +and [GitLab template](https://github.com/seqra/opentaint/tree/main/gitlab) wrap +`opentaint scan`. Baseline gating is driven by the CLI directly, as shown below. + +### GitHub Actions + +Persist the default-branch report with `actions/cache`, restore it on pull +requests, and gate on new findings. A cache written on the default branch is +readable from pull-request runs via `restore-keys`, which makes it a simple, +official way to carry the baseline forward. (The first run has no baseline and +scans without gating. Every later PR gates against the latest main report.) + +```yaml +name: opentaint +on: + push: + branches: [main] + pull_request: + +jobs: + scan: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install OpenTaint + run: curl -fsSL https://raw.githubusercontent.com/seqra/opentaint/main/scripts/install/install.sh | sh + + # Restore the most recent main baseline. On main, this key also becomes + # the save target below. On a PR, restore-keys falls back to it read-only. + - name: Restore baseline + uses: actions/cache@v4 + with: + path: baseline.sarif + key: opentaint-baseline-${{ github.run_id }} + restore-keys: opentaint-baseline- + + - name: Scan + run: | + if [ -f baseline.sarif ]; then + opentaint scan --baseline baseline.sarif \ + --error-on-findings --error-on-severity error,warning \ + -o opentaint.sarif . + else + opentaint scan -o opentaint.sarif . + fi + + # On main, the fresh report becomes the next baseline. + - name: Update baseline + if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch) + run: cp opentaint.sarif baseline.sarif + + # Optional: send to GitHub code scanning (suppressions & states are honored). + - name: Upload SARIF + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: opentaint.sarif +``` + +The `cache@v4` step saves `baseline.sarif` under `opentaint-baseline-` +at job end, so each main run leaves a fresh baseline that the next PR restores +via the `opentaint-baseline-` prefix. + +### GitLab CI + +```yaml +opentaint: + script: + - curl -fsSL https://raw.githubusercontent.com/seqra/opentaint/main/scripts/install/install.sh | sh + - | + if [ -f baseline.sarif ]; then + opentaint scan --baseline baseline.sarif \ + --error-on-findings --error-on-severity error,warning \ + -o gl-opentaint.sarif . + else + opentaint scan -o gl-opentaint.sarif . + fi + artifacts: + when: always + paths: + - gl-opentaint.sarif +``` + +Keep the main-branch `gl-opentaint.sarif` as the `baseline.sarif` for later +pipelines (via the package registry, a cache key, or a committed artifact). + +## SARIF conformance + +Every annotation is a standard SARIF 2.1.0 field, so third-party tools ingest the +report without OpenTaint-specific knowledge: + +- **§3.35 `suppression`** — `kind` (`external`), `status` (`accepted` / + `underReview`), `justification`, `guid`. +- **§3.27.24 `result.baselineState`** — `new` / `unchanged` / `updated` / + `absent`, written under `--write-baseline-state`. +- **§3.14.5 `run.baselineGuid`** — cites the baseline run's + `automationDetails.guid`, so the report is itself citable as a future baseline. + +No property bag or vendor extension is required for any of it. + +## See also + +- [Usage Guide](usage.md) — full command and flag reference (`scan`, `triage`, `summary`). +- [Configuration Guide](configuration.md) — the `rules.only` / `rules.exclude` config keys. diff --git a/docs/configuration.md b/docs/configuration.md index 09381d8be6..f56298fa27 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -17,6 +17,7 @@ opentaint scan --config /path/to/config.yaml /path/to/project scan: timeout: 15m max_memory: 16G + baseline: baselines/main.sarif # Output (terminal-side controls) output: @@ -27,6 +28,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 @@ -35,10 +41,50 @@ java: |---------|-------------|---------| | `scan.timeout` | Analysis timeout duration | `15m` | | `scan.max_memory` | Maximum memory for analyzer (e.g., `8G`, `1024m`) | `8G` | +| `scan.baseline` | Previous SARIF report used for comparison and suppression inheritance; relative paths resolve from the config file | none | | `output.debug` | Enable debug output (stream JAR subprocess output, show debug fields) | `false` | | `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. To hide a finding a +rule did produce, accept it with `opentaint triage` instead. + +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/installation.md b/docs/installation.md index c1e400cc8f..8d76abffef 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,6 +1,6 @@ # Installation -**Prerequisites:** Same build requirements as your Java/Kotlin project (Maven or Gradle, project dependencies). Java runtime is bundled with release archives. +**Prerequisites:** Same build requirements as your project (Maven or Gradle for Java/Kotlin). Java runtime is bundled with release archives. ## Homebrew (Linux/macOS) @@ -150,7 +150,7 @@ For package manager installations, `opentaint update` will show the appropriate ## Cleaning Up -Remove stale downloaded artifacts: +Remove old downloaded artifacts: ```bash opentaint prune # Interactive confirmation diff --git a/docs/reports/fingerprint-stability.md b/docs/reports/fingerprint-stability.md new file mode 100644 index 0000000000..5162549c00 --- /dev/null +++ b/docs/reports/fingerprint-stability.md @@ -0,0 +1,340 @@ +# Fingerprint stability report + +**Subject:** the fingerprint `vulnerabilitySourceSinkHash/v1` changes between two runs of the same code. +**Test project:** Stirling-PDF v2.14.2. **Analyzer:** 2026.08.01.a8995a6. **Rules:** v0.3.0. +**Style:** this document uses ASD-STE100 Simplified Technical English. + +--- + +## Status: not corrected. Change 1 is done + +Sections 1 to 7 still apply. The cause is **not** repaired. + +An earlier version of this section said that the commit `908e924b3` ("Small +fixes", PR #336) repairs the cause. That statement was wrong. The author of the +commit says it is not a repair for this defect, and a measurement agrees. + +**What #336 changes:** the order of the events in the IFDS scheduler, so that an +analyzer with unprocessed zero-to-zero edges goes first. It also stops the empty +notifications to the subscribers, and it clears a stale cache when the access-path +mode changes. It does not touch the delay of an analyzer, the increase of the +fact-depth limit, or the detection of quiescence. These are the parts that make +the result depend on the order. + +**Measurement.** Runs of the same code on the reproduction project +(`projects/local/taint-nondeterminism` in the regression harness), which reports +5244 statements with facts: + +| Measurement | Before #336 | After #336 (`06c8d25e9`) | +|---|---|---| +| Statements with different facts, 3 runs | 278, 300, 314 | 268, 392, 364 | +| `vulnerabilitySourceSinkHash/v1` set | the same in all runs | the same in all runs | +| Delayed-analyzer set in each round | the same in all runs | the same in all runs | + +The fact sets are not stable, before or after. The size of the difference does +not decrease. + +**The reproduction project cannot answer the fingerprint question.** Its +fingerprints do not change, before or after the commit — the value is the same +in both. Each of its findings has one route from the source to the sink, so a +different fact set cannot make the analyzer select a different source. The +measurements in sections 1 to 7, which used Stirling-PDF, had more than one route +for each finding. To test a fingerprint again, use a project with more than one +route to a sink. + +**What is done.** The commit `908e924b3` adds the key `vulnerabilitySinkHash/v1`, +and `06c8d25e9` puts the rule id into it. The key is now "rule and sink only" — +Change 1 of section 8, as written. The CLI accepts it as `--fingerprint-key sink` +and uses it as the default key. + +The measurement of section 8 gives the reason: the sink hash is unique for each +finding (36 of 36), because the analyzer makes one finding for each rule and each +sink. The coarser key thus loses no finding. It only stops a finding from +changing its identity. Because the cause is not repaired, this key is the +mitigation, not a convenience. + +A coarser key hides less than it looks. If the source moves, the finding stays +the same finding, and the CLI writes `Updated, source changed`. If only the path +moves, the CLI writes `Updated, path changed`. Use `--fingerprint-key +source-sink` when a new source must be a new finding. + +Change 2 (make the flow selection stable) is **not** done. This is the change +that repairs the cause. + +--- + +## 1. Read this first + +Five facts. Read only this section if you have no time. + +1. The analyzer gives a fingerprint to each finding. The CLI compares reports with the fingerprint. +2. The fingerprint is **not stable**. It changes between two runs of the same code. +3. In nine runs of one project, only **28 of 36** fingerprints stayed the same. +4. The cause is the list of flows. The analyzer puts each flow into the fingerprint. The list of flows changes in each run. +5. A fingerprint that contains only the rule and the sink is stable. **All 36 stayed the same in all nine runs.** + +**The one-line cause:** the fingerprint contains data that the analyzer selects, not data that the code contains. + +--- + +## 2. Why this is a problem + +Four effects. Each effect is a test result, not an opinion. + +| Effect | Test result | +|---|---| +| A build with no code change fails the gate | 4 new findings, exit code 2 | +| The report shows fixes that did not happen | 4 findings became "Fixed" | +| A triage decision goes away | 1 of 2 decisions did not move to the next run | +| The state `updated` has little value | only 8 of 36 trace fingerprints are stable | + +--- + +## 3. Words in this document + +Each word has one meaning. This document always uses the same word for the same thing. + +| Word | Meaning | +|---|---| +| finding | a security problem that the analyzer reports | +| sink | the statement where the problem occurs | +| source | the statement where the untrusted data starts | +| flow | one path from a source to a sink | +| fingerprint | a value that identifies a finding across reports | +| baseline | an older report. The CLI compares a new report with the baseline | +| graph | the internal data structure with the nodes and the edges of the flows | +| run | one start of the analyzer | +| CLI | the `opentaint` command-line program | +| SARIF | the report file format | + +--- + +## 4. The measurements + +**Summary: the findings are stable. The flows are not stable.** + +Nine runs. The same worktree. No code change between the runs. + +| Quantity | Result | +|---|---| +| Sinks that the analysis phase finds | 46 in each run | +| Findings in the report | 36 in each run | +| Rule and sink of each finding | the same in each run | +| Fingerprint `vulnerabilitySourceSinkHash/v1` | 28 of 36 stable | +| Fingerprint `vulnerabilityWithTraceHash/v1` | 8 of 36 stable | +| A fingerprint of the rule and the sink only | **36 of 36 stable** | + +### Example of the change + +One sink. Three runs. Three different lists of sources. + +``` +LocalStorageProvider.java:62 + run 1: FormFillController:265, FormFillController:332, SigningSessionController:97 + run 2: ServerCertificateController:52, SigningSessionController:97 + run 3: DatabaseController:43, ServerCertificateController:52, SigningSessionController:97 +``` + +The three lists are not the same. Only one source is in all three lists. + +--- + +## 5. The cause: five steps + +Read the steps in order. Step 1 is the origin. Step 5 is the effect that you see. + +### Step 1 — The graph is different in each run + +The analyzer builds a graph of the flows in the phase "Trace resolution". +This graph is **not the same in each run**. + +Measurement, two runs, 29 sinks with more than one flow: + +| Part of the graph | Number of sinks with a difference | +|---|---| +| Set of nodes | 8 of 29 | +| Count of nodes | 2 of 29 | +| Set of edges | 3 of 29 | +| Set of start nodes | 6 of 29 | + +Example: `DeletingRandomAccessFile#close()` has 2421 nodes in run A and 2406 nodes in run B. +The same sink has 3860 edges in run A and 3845 edges in run B. + +**Code:** `TraceResolver.kt`, `ParallelProcessingContext.kt`. +The analyzer runs this phase in parallel. The analyzer also stops the work in time slices of 100 ms +(`TraceResolver.kt:161-162`). + +### Step 2 — The graph gives a number to each method + +The analyzer gives a number to each node and to each method. The analyzer gives the numbers in the +order that it finds the nodes. A different graph gives different numbers. + +**Code:** `Source2SinkTraceGraph.getOrCreateNodeIdx`, `Source2SinkMethodTraceGraph.getOrCreateMethodIdx`. + +### Step 3 — The selection reads the methods in number order + +The analyzer keeps the methods in an `IntOpenHashSet`. This set reads the numbers in the order of the +numbers. Different numbers give a different order. + +Measurement: for **8 of 29** sinks, the set of methods was the same, but the order was different. +The set had the same 66 methods in both runs. + +### Step 4 — The first flow in the order wins + +The analyzer does not report all flows. The analyzer keeps a flow only if the flow shows a method that +no other kept flow shows. Therefore the first flow in the order wins. A different order gives a +different list of flows. + +**Code:** `MethodTraceSearch.kt:193-284`. + +```kotlin +if (addsNewNode(trace)) { // keep the flow only if it shows a new method + val res = collect(trace) + if (res != null) { result.add(res); markCovered(trace) } +} +``` + +Measurement: the list of flows was different for the same **8 of 29** sinks. + +### Step 5 — The fingerprint contains the flows + +The analyzer puts the source of each flow into the fingerprint. A different list of flows gives a +different fingerprint. + +**Code:** `SarifGenerator.kt:118-136`. + +```kotlin +digest.update(ruleId.toByteArray()) +digest.addLocationFingerprint(vulnerabilityLocation) // the sink +traces?.map { computeTraceFingerprint(it, kind) } + ?.sortedWith(Arrays::compare)?.forEach(digest::update) // each flow +``` + +The analyzer sorts the flows, but the analyzer does not remove the duplicates. One more flow or one +less flow changes the fingerprint. + +--- + +## 6. What the tests exclude + +**Summary: two usual causes are not the cause here.** + +| Possible cause | Test | Result | +|---|---|---| +| The order of the object hash codes | Start the analyzer with `-XX:hashCode=2` | No effect. 28 of 36 stable, as before | +| Ties in the sort of the nodes | Count the equal pairs in the sorted list | **0 ties.** The sort is a full order | +| Parallel work only | Start the analyzer with `-XX:ActiveProcessorCount=1` | Less change (32 of 36), but the change stays | + +Full experiment table. Each line is two runs of the same code. + +| Configuration | source-sink | trace | rule and sink | +|---|---|---|---| +| Default (6 workers) | 28/36 | 11/36 | 36/36 | +| `-XX:hashCode=2` | 28/36 | 8/36 | 36/36 | +| `-XX:ActiveProcessorCount=1` | 32/36 | 19/36 | 36/36 | +| Both options together | 30/36 | 14/36 | 36/36 | + +--- + +## 7. What is still unknown + +One question stays open. This document does not answer it. + +**Question:** which mechanism in Step 1 makes the graph different? + +Three candidates: + +1. The check `cancellation.isActive()` in the function `process`. +2. The memory guard that stops the work. +3. The state that the parallel tasks share. + +The difference of 15 nodes (2421 against 2406) looks like a stop of the work. It does not look like a +different order. But the tests do not show this. More work is necessary. + +--- + +## 8. The recommended change + +**Summary: remove the flows from the fingerprint.** + +The analyzer already has a definition of a finding. The file +`TaintAnalysisUnitStorage.kt:12-30` has this code: + +```kotlin +private data class VulnerabilityIdentity( + val ruleId: String, + val statement: CommonInst, +) +``` + +The analyzer groups the findings by the rule and the statement. This is the definition of one finding. +The fingerprint is more exact than this definition. The additional part is the part that changes. + +### Change 1 — a new fingerprint (small change, large effect) + +Write a third key `vulnerabilitySinkHash/v1`. Use the same function, but do not put the flows into it. +Then make this key the default key in the CLI (`cli/internal/sarif/identity.go:21-25`). + +Measurement of the three keys: + +| Key | Unique in one run | Stable in nine runs | Same finding in two versions | +|---|---|---|---| +| `vulnerabilitySourceSinkHash/v1` | yes | no — 28 of 36 | 28 | +| `vulnerabilityWithTraceHash/v1` | yes | no — 8 of 36 | 8 | +| Rule and sink only | **yes, 36 of 36** | **yes, 36 of 36** | **35** | + +The new key also gives the correct answer for the two versions of the project. +It reports **1 new finding and 0 fixed findings**. The new finding is in +`HardwareKeyStoreService.java:475`. This file is new in version 2.14. The old key reported 8 new +findings and 7 fixed findings for the same two versions. + +**Limit of this change:** a sink is a class, a method and an instruction number. If you add a +statement before the sink, the instruction number changes. The fingerprint then changes. In the test, +35 of 36 findings kept the fingerprint across two releases. This is better than the old key. + +### Change 2 — make the flow selection stable (larger change) + +Change 1 makes the fingerprint stable. Change 1 does not make the report stable. Do these tasks: + +1. Sort the nodes and the methods by their names. Do not use the order of the graph. +2. Stop the time slices. Use only the step count (`TraceResolver.kt:161-162`). +3. Break the ties in `MethodTraceSearch.kt:76-83` with a name, not with the heap order. + +### Change 3 — do not remove findings without a message (small change) + +The file `TaintAnalyzer.kt:211-213` removes each finding that has no flow. The analyzer does this +without a message. If the machine is slow, a finding can go away. The baseline then shows this finding +as "Fixed". Count these findings and write them in the report. + +--- + +## 9. Files to look at + +| File | What it does | +|---|---| +| `core/src/main/kotlin/org/opentaint/common/sast/sarif/SarifGenerator.kt:118-150` | Computes the fingerprint | +| `core/src/main/kotlin/org/opentaint/jvm/sast/sarif/JirSarifGenerator.kt:64-69` | Puts the class, the method and the instruction number into the fingerprint | +| `.../ap/ifds/trace/path/MethodTraceSearch.kt:193-284` | Selects the flows | +| `.../ap/ifds/trace/path/Source2SinkTraceGraph.kt` | Gives a number to each node | +| `.../ap/ifds/trace/path/Source2SinkMethodTraceGraph.kt` | Gives a number to each method | +| `.../ap/ifds/trace/TraceResolver.kt:161-162` | Stops the work after 100 ms | +| `.../ap/ifds/trace/ParallelProcessingContext.kt` | Runs the work in parallel | +| `.../ap/ifds/taint/TaintAnalysisUnitStorage.kt:12-30` | Defines one finding | + +--- + +## 10. How to do the test again + +Five steps. + +1. Build the analyzer. Use the command `./core/gradlew -p core :projectAnalyzerJar`. +2. Scan a project one time. Keep the report. +3. Scan the same project again. Do not change the code. +4. Compare the values of `partialFingerprints` in the two reports. +5. Compare the lists in `codeFlows` for each finding that has a different fingerprint. + +To see the internal data, apply the patch `drift-probe.patch`. Then set the environment variable +`OPENTAINT_DRIFT_DEBUG=1`. The analyzer then writes two lines for each finding: + +- `DRIFT-NODES` shows the size and the content of the graph. +- `DRIFT` shows the method order and the selected flows. diff --git a/docs/usage.md b/docs/usage.md index 84f9359ed2..d64721b1dd 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -78,20 +78,21 @@ Use [CodeChecker](https://github.com/Ericsson/codechecker) for advanced result m | Command | Description | |---------|-------------| -| `opentaint scan` | Analyze projects (auto-detects Maven/Gradle, builds, and scans) | +| `opentaint scan` | Analyze projects (auto-detects the build system, builds, and scans) | | `opentaint compile` | Build project model separately from scanning | | `opentaint project` | Create project model from precompiled JARs/classes | | `opentaint summary` | View SARIF analysis results | -| `opentaint health` | Show resolved paths for the analyzer, autobuilder, rules, and Java runtime | +| `opentaint triage` | Compare a report against a baseline and record suppressions | +| `opentaint health` | Show dependency paths and report missing components | | `opentaint test rule` | Create, run, and debug detection-rule tests | | `opentaint test approximation` | Create and run dataflow-approximation tests | -| `opentaint pull` | Download analyzer dependencies | +| `opentaint pull` | Download the analysis toolchain and Java runtime | | `opentaint update` | Update to latest version | -| `opentaint prune` | Remove stale downloaded artifacts and cached models | +| `opentaint prune` | Remove old downloaded artifacts and cached models | ### opentaint scan -Automatically detects Maven/Gradle projects, builds them, and performs security analysis. The source path defaults to the current directory when omitted. +Automatically detects the project's build system (Maven or Gradle), builds the project, and runs taint analysis over the result. The source path defaults to the current directory when omitted. On the first run, the compiled project model is cached in `~/.opentaint/cache/`. Subsequent scans of the same project reuse the cached model, skipping compilation entirely. @@ -100,12 +101,27 @@ On the first run, the compiled project model is cached in `~/.opentaint/cache/`. | `--output`, `-o` | Path to the SARIF report (default: `/sources/opentaint.sarif`) | | `--recompile` | Force recompilation even if a cached project model exists | | `--project-model` | Path to a pre-compiled project model (skips compilation) | -| `--timeout`, `-t` | Timeout for analysis (default: `15m`) | -| `--max-memory` | Maximum memory for the analyzer (default: `8G`) | -| `--severity` | Severity levels to report (default: `warning`, `error`) | -| `--ruleset` | YAML rules file or directory (default: `builtin`) | +| `--timeout`, `-t` | Maximum wall-clock time for analysis (default: `15m`) | +| `--max-memory` | Maximum analyzer heap size (default: `8G`) | +| `--severity` | Run only rules at these severity levels: `note`, `warning`, `error` (default: `warning`, `error`) | +| `--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`) | + +#### Baseline and gating flags + +| Flag | Description | +|------|-------------| +| `--baseline` | Previous SARIF report to compare against and inherit suppressions from | +| `--write-baseline-state` | Persist `result.baselineState` and `run.baselineGuid` into the output report (needs `--baseline`) | +| `--error-on-findings` | Exit with code 2 when findings remain (with `--baseline`, only new ones count) | +| `--error-on-severity` | Restrict `--error-on-findings` to these levels: `note`, `warning`, `error`, `none` (repeatable or comma-separated, defaults to all) | + +With `--baseline`, findings the baseline already accepted stay suppressed and +the summary reports how many are new, unchanged, updated, or absent. See +[Baselines and suppressions](#baselines-and-suppressions). #### Rule-authoring flags @@ -114,10 +130,10 @@ These flags are to work with custom approximations: | Flag | Description | |------|-------------| | `--track-external-methods` | Write external-method coverage files next to the SARIF report | -| `--passthrough-approximations` | Apply pass-through approximation YAML files or directories (repeatable) | -| `--dataflow-approximations` | Apply dataflow approximation classes or Java source directories (repeatable) | +| `--passthrough-models` | Apply pass-through model YAML files or directories (repeatable) | +| `--java-models` | Apply Java dataflow model classes or source directories (repeatable) | -Use external-method tracking when a scan may miss flows through library methods. The dropped-methods file shows where taint was killed because no model was available; the approximated-methods file shows methods already covered by built-in or custom models. +Use external-method tracking when a scan may miss flows through library methods. The dropped-methods file shows where taint was killed because no model was available. The approximated-methods file shows methods already covered by built-in or custom models. ### opentaint health @@ -129,7 +145,7 @@ opentaint health --rules opentaint health --analyzer ``` -With no flags, `health` shows the autobuilder, analyzer, built-in rules, and Java runtime. With a single component flag, it prints only the bare path, which is useful for scripts. +With no flags, `health` shows the autobuilder, analyzer, built-in rules, and Java runtime, and reports whether each is present. With a single component flag, it prints only the bare path, which is useful for scripts. The command exits non-zero when a selected component is missing. Fetch missing components with `opentaint pull`. | Flag | Description | |------|-------------| @@ -155,7 +171,7 @@ opentaint test rule reachability java/security/my-rule.yaml:my-rule --project-mo |---------|-------------| | `opentaint test rule init ` | Create source and sink test projects with annotated sample support | | `opentaint test rule run ` | Run detection-rule tests on a compiled project model | -| `opentaint test rule reachability [source-path]` | Trace why a rule can or cannot reach its facts | +| `opentaint test rule reachability [source-path]` | Show why a rule does or does not fire | #### Approximation tests @@ -163,13 +179,13 @@ opentaint test rule reachability java/security/my-rule.yaml:my-rule --project-mo opentaint test approximation init .opentaint/test-projects/my-approximation opentaint compile .opentaint/test-projects/my-approximation -o .opentaint/test-compiled/my-approximation opentaint test approximation run .opentaint/test-compiled/my-approximation \ - --dataflow-approximations .opentaint/dataflow/my-approximation + --java-models .opentaint/dataflow/my-approximation ``` | Command | Description | |---------|-------------| | `opentaint test approximation init ` | Create a test project with a fixed `Taint.source()` to `Taint.sink(...)` harness | -| `opentaint test approximation run ` | Run dataflow approximation tests on a compiled project model | +| `opentaint test approximation run ` | Run dataflow-approximation tests on a compiled project model | Rule and approximation test runs write `test-result.json` and `test-results.sarif` to the selected output directory. @@ -184,33 +200,106 @@ opentaint scan --project-model ./my-project-model | Flag | Description | |------|-------------| -| `--output`, `-o` | Path to the result project model (required) | +| `--output`, `-o` | Path to the project model directory to create (required, must not exist) | | `--dry-run` | Validate inputs and show what would run without compiling | | `--log-file` | Path to the log file (default: `/logs/.log`) | ### opentaint summary -View findings from a SARIF report. By default it prints the Scan Summary; add +View findings from a SARIF report. By default it prints the Scan Summary. Add `--show-findings` for the detailed listing. The filter flags below narrow the -whole summary (both the counts and the listing); `Rules executed` always +whole summary (both the counts and the listing). `Rules executed` always reflects the full set the tool ran. | Flag | Description | |------|-------------| -| `--show-findings` | Show all findings | +| `--show-findings` | Show every finding in the SARIF report | | `--show-code-snippets` | Show code snippets for each finding | | `--verbose-flow` | Show full code flow steps for each finding | | `--path` | Show only findings whose file path matches this glob (`**` supported, repeatable) | | `--severity` | Show only findings of this SARIF level: `error`, `warning`, `note`, `none` (repeatable) | | `--rule-id` | Show only findings for this rule: full id, leaf name (after `:` or last `.`), or glob over the full id (repeatable) | -| `--partial-fingerprint` | Show only findings whose partial fingerprint starts with this value, git-hash style (repeatable). With `--show-findings`, each finding's header reads `Fingerprint: ` — copy that value back into this flag to re-focus on it. | -| `--partial-fingerprint-key` | partialFingerprints key matched by `--partial-fingerprint` (default `vulnerabilityWithTraceHash/v1`) | +| `--partial-fingerprint` | Show only findings whose fingerprint starts with this value, git-hash style (repeatable). With `--show-findings`, each finding's header reads `Fingerprint: ` — copy that value back into this flag to re-focus on it, or into `triage --accept` to record a decision on it. | | `--max-nesting-level` | Collapse code-flow steps deeper than this call-nesting level (`-1` = no cap). Best-effort: depth is derived from step kinds and method names, so flows lacking method info may over-collapse | | `--group-by` | Group the `--show-findings` listing by `severity`, `rule-id`, or `file-path` (default `file-path`) | | `--code-flow` | Render code flows: `all`, a 1-based index, or unset (first flow only). On multi-flow findings the listing also shows a `Code flows: ` field. | +| `--baseline` | Compare against this SARIF report and show new/unchanged/updated/absent counts. The file is never modified. | +| `--baseline-state` | Show only findings in these baseline states: `new`, `unchanged`, `updated`, `absent` (repeatable). Reads the states written by `--write-baseline-state`, or the ones `--baseline` computes now. `absent` lists the findings that are gone since the baseline and always needs `--baseline`. | +| `--suppressed` | Include suppressed findings in the listing (hidden by default) | Filters combine as OR within a dimension and AND across dimensions. +### opentaint triage + +Compare a SARIF report against a baseline and record decisions about findings. +Nothing is ever deleted: an accepted or deferred finding stays in the report, +marked with a SARIF suppression recording what was decided and why. + +```bash +# What changed since the last release? Modifies nothing. +opentaint triage scan.sarif --baseline release.sarif + +# We will not fix this one +opentaint triage scan.sarif --accept q3Vf9k --justification "sink is a constant" + +# We are not fixing this one yet +opentaint triage scan.sarif --defer 8bc1d2 --justification "waiting on OT-412" +``` + +| Flag | Description | +|------|-------------| +| `--baseline` | Previous SARIF report to compare against and inherit suppressions from | +| `--write-baseline-state` | Persist `result.baselineState` and `run.baselineGuid` into the output report (needs `--baseline`) | +| `--accept` | Accept the finding with this fingerprint prefix — won't fix (repeatable) | +| `--defer` | Defer the finding with this fingerprint prefix — not fixing for now (repeatable) | +| `--unsuppress` | Remove the suppression from the finding with this fingerprint prefix (repeatable) | +| `--justification` | Why the finding is accepted or deferred (required with `--accept`/`--defer`) | +| `--output`, `-o` | Path to write the triaged report (defaults to rewriting the input in place) | +| `--show-findings` | Show every finding, not just the summary | +| `--suppressed` | Include suppressed findings in the listing | +| `--error-on-findings` | Exit with code 2 when findings remain (with `--baseline`, only new ones count) | +| `--error-on-severity` | Restrict `--error-on-findings` to these levels: `note`, `warning`, `error`, `none` (repeatable or comma-separated, defaults to all) | + +A finding is named by a fingerprint prefix, git-style — the value shown as +`Fingerprint:` by `opentaint summary --show-findings`. An ambiguous or unknown +prefix is an error, never a guess. + +Exit codes: + +| Code | Meaning | +|------|---------| +| 0 | Triage completed | +| 1 | General failure (bad input, unreadable report) | +| 2 | Findings remain and `--error-on-findings` was set | + +## Baselines and suppressions + +A baseline is just a SARIF report you kept. Two independent axes are built on it: +`--baseline` answers *"is this new?"* (baseline state), and `opentaint triage` +answers *"did a human accept this?"* (suppression). Presence in a baseline is +**not** acceptance — an un-triaged baseline entry only makes a finding +`unchanged`, it does not hide it. + +```bash +# 1. Scan once and keep the report as the baseline. +opentaint scan -o baselines/main.sarif . + +# 2. Record decisions you've reviewed (writes SARIF suppressions). +opentaint triage baselines/main.sarif --accept q3Vf9k --justification "input is admin-only" + +# 3. In CI, gate on new, non-suppressed findings only. +opentaint scan --baseline baselines/main.sarif --error-on-findings --error-on-severity error,warning . +``` + +Decisions travel forward: a finding that matches a suppressed baseline entry +inherits the decision verbatim, so it's authored once and re-applied by every +later scan until the code is fixed and the finding retires. The gate exits `2` +when it trips — distinct from `1` (tool error) and `252`–`255` (analyzer). + +For the full model, the baseline-state and suppression-status reference, finding +identity, rule selection, and copy-paste GitHub Actions / GitLab recipes, see the +dedicated guide: **[Baselines, suppressions, and CI gating](baselines-and-suppressions.md)**. + ### opentaint project Create project models from precompiled JARs or classes when source code isn't available. @@ -224,12 +313,12 @@ opentaint scan --project-model ./project-model | Flag | Description | |------|-------------| -| `--output`, `-o` | Output directory for project.yaml (required) | -| `--source-root` | Source root directory (required) | -| `--classpath` | Classpath entries — classes or JAR files (required) | -| `--package` | Project packages (required) | -| `--dependency` | Project dependencies — JAR files | -| `--dry-run` | Validate inputs and show what would run without generating project model | +| `--output`, `-o` | Directory to write the generated project model (required, must not exist) | +| `--source-root` | Path to the project source root (required) | +| `--classpath` | Classpath entries: compiled classes directories or JAR files (required, repeatable) | +| `--package` | Packages to include in the generated model (required, repeatable) | +| `--dependency` | Additional dependency JAR files on the compile classpath (repeatable) | +| `--dry-run` | Validate inputs and show what would run without generating the project model | | `--log-file` | Path to the log file (default: `/logs/.log`) | ## Model Caching @@ -267,6 +356,6 @@ These options apply to all commands: - `--java-version int` — Java version for analyzer (default: 21) - `--quiet` / `-q` — Suppress interactive output (spinners, progress bars, JAR streaming) - `--debug` / `-d` — Enable debug output (stream JAR subprocess output, show debug fields) -- `--color string` — Color mode (`auto`, `always`, `never`); defaults to `auto` (detects terminal) +- `--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. diff --git a/skills-templates/create-dataflow-approximation/references/java.md.j2 b/skills-templates/create-dataflow-approximation/references/java.md.j2 index 523eb6e5b7..943916079e 100644 --- a/skills-templates/create-dataflow-approximation/references/java.md.j2 +++ b/skills-templates/create-dataflow-approximation/references/java.md.j2 @@ -65,7 +65,7 @@ Run `test approximation run` over the compiled test project applying this batch' ```bash opentaint test approximation run .opentaint/test-compiled/ \ -o .opentaint/test-results/ \ - --dataflow-approximations .opentaint/dataflow/ + --java-models .opentaint/dataflow/ ``` `test approximation run` applies its own bundled fixed source→sink rule automatically — you don't author or pass one. The CLI auto-compiles the `.java` sources against the analyzer JAR (for `@Approximate`, `OpentaintNdUtil`, `ArgumentTypeContext`) and the project's dependencies; if compilation fails it reports the errors and aborts before the tests. A positive sample is a `falseNegative` until the model propagates taint. Read the result with the bundled script — it prints the pass/fail counts and names each failing sample, so you don't parse the JSON by hand: diff --git a/skills-templates/create-rule/references/debugging.md.j2 b/skills-templates/create-rule/references/debugging.md.j2 index dd8fce4a05..bd27742644 100644 --- a/skills-templates/create-rule/references/debugging.md.j2 +++ b/skills-templates/create-rule/references/debugging.md.j2 @@ -8,7 +8,7 @@ When a positive won't pass and the suspicion is a library method on its flow dro opentaint scan --project-model .opentaint/test-compiled// \ -o .opentaint/test-results///diag.sarif \ --ruleset builtin --ruleset .opentaint/rules --ruleset .opentaint/test-projects///test-rules \ - --passthrough-approximations .opentaint/pass-through \ + --passthrough-models .opentaint/pass-through \ --track-external-methods ``` diff --git a/skills-templates/create-rule/sections/workflow.md b/skills-templates/create-rule/sections/workflow.md index 61858ed2ef..8269c9db11 100644 --- a/skills-templates/create-rule/sections/workflow.md +++ b/skills-templates/create-rule/sections/workflow.md @@ -24,7 +24,7 @@ Run the rule tests directly as a foreground, blocking command and wait for exit opentaint test rule run .opentaint/test-compiled// \ -o .opentaint/test-results// \ --ruleset .opentaint/rules --ruleset .opentaint/test-projects///test-rules \ - --passthrough-approximations .opentaint/pass-through + --passthrough-models .opentaint/pass-through ``` `test rule run` auto-loads the built-in rules, so pass only your custom rulesets. Apply the passthrough approximations as-is, an empty one is harmless. Read the result with the bundled script — it prints the pass/fail counts and names the failing samples, so you never parse the JSON by hand: diff --git a/skills-templates/debug-rule/sections/workflow.md b/skills-templates/debug-rule/sections/workflow.md index 3a490974f6..888b296d24 100644 --- a/skills-templates/debug-rule/sections/workflow.md +++ b/skills-templates/debug-rule/sections/workflow.md @@ -7,8 +7,8 @@ opentaint test rule reachability \ --project-model \ -o /report.sarif \ --ruleset builtin --ruleset .opentaint/rules \ - --passthrough-approximations .opentaint/pass-through \ - --dataflow-approximations .opentaint/dataflow + --passthrough-models .opentaint/pass-through \ + --java-models .opentaint/dataflow ``` `` is `.opentaint/test-results/` for a test model, `.opentaint/results` for the main scan. The per-instruction facts are in the sibling `/debug-ifds-fact-reachability.sarif`, not the `-o` file — the `-o` SARIF only shows whether the rule fired. Read that sibling to find the kill: diff --git a/skills-templates/run-scan/sections/workflow.md b/skills-templates/run-scan/sections/workflow.md index aeab157e3a..6ddb900c28 100644 --- a/skills-templates/run-scan/sections/workflow.md +++ b/skills-templates/run-scan/sections/workflow.md @@ -10,8 +10,8 @@ opentaint scan --project-model .opentaint/project \ ``` - `--rule-id ` — restrict to specific rules (repeatable, one per input rule ID); every unnamed rule is dropped, including library `refs`, so list every id the restricted rules depend on. Omit to run all loaded rules -- `--passthrough-approximations .opentaint/pass-through` — add when that directory exists: passThrough configs override built-ins at the rule level, a provided rule overriding a built-in only when it matches one -- `--dataflow-approximations .opentaint/dataflow` — add when that directory exists: code-based approximations (sources auto-compiled; pre-compiled `.class` dirs passed through as-is) +- `--passthrough-models .opentaint/pass-through` — add when that directory exists: passThrough configs override built-ins at the rule level, a provided rule overriding a built-in only when it matches one +- `--java-models .opentaint/dataflow` — add when that directory exists: code-based approximations (sources auto-compiled; pre-compiled `.class` dirs passed through as-is) Both approximation-dir flags walk their trees recursively; pass each parent directory once, not every package or batch separately. diff --git a/skills-templates/shared/debugging.md b/skills-templates/shared/debugging.md index 8b7ce31714..8d80bd6ce8 100644 --- a/skills-templates/shared/debugging.md +++ b/skills-templates/shared/debugging.md @@ -15,7 +15,7 @@ opentaint test rule reachability \ - `` — the one rule whose sample routes taint through the code under test (`.yaml:`). One rule per run — across many rules the trace is unusably huge. Its library `refs` are collected automatically - read the sibling `/debug-ifds-fact-reachability.sarif`, not the `-o` file. The `-o` SARIF only shows default scan output. The sibling holds the per-instruction facts that show where taint dies -- apply the approximations the failing run used so the trace matches it: `--passthrough-approximations ` and/or `--dataflow-approximations `. Taint dying at an approximated call then means that approximation isn't propagating +- apply the approximations the failing run used so the trace matches it: `--passthrough-models ` and/or `--java-models `. Taint dying at an approximated call then means that approximation isn't propagating - debug the exact run that showed the problem — same model, rulesets, approximation dirs — or you're debugging something else ## Reading the trace diff --git a/skills/create-dataflow-approximation/references/debugging.md b/skills/create-dataflow-approximation/references/debugging.md index 8b7ce31714..8d80bd6ce8 100644 --- a/skills/create-dataflow-approximation/references/debugging.md +++ b/skills/create-dataflow-approximation/references/debugging.md @@ -15,7 +15,7 @@ opentaint test rule reachability \ - `` — the one rule whose sample routes taint through the code under test (`.yaml:`). One rule per run — across many rules the trace is unusably huge. Its library `refs` are collected automatically - read the sibling `/debug-ifds-fact-reachability.sarif`, not the `-o` file. The `-o` SARIF only shows default scan output. The sibling holds the per-instruction facts that show where taint dies -- apply the approximations the failing run used so the trace matches it: `--passthrough-approximations ` and/or `--dataflow-approximations `. Taint dying at an approximated call then means that approximation isn't propagating +- apply the approximations the failing run used so the trace matches it: `--passthrough-models ` and/or `--java-models `. Taint dying at an approximated call then means that approximation isn't propagating - debug the exact run that showed the problem — same model, rulesets, approximation dirs — or you're debugging something else ## Reading the trace diff --git a/skills/create-dataflow-approximation/references/java.md b/skills/create-dataflow-approximation/references/java.md index 523eb6e5b7..943916079e 100644 --- a/skills/create-dataflow-approximation/references/java.md +++ b/skills/create-dataflow-approximation/references/java.md @@ -65,7 +65,7 @@ Run `test approximation run` over the compiled test project applying this batch' ```bash opentaint test approximation run .opentaint/test-compiled/ \ -o .opentaint/test-results/ \ - --dataflow-approximations .opentaint/dataflow/ + --java-models .opentaint/dataflow/ ``` `test approximation run` applies its own bundled fixed source→sink rule automatically — you don't author or pass one. The CLI auto-compiles the `.java` sources against the analyzer JAR (for `@Approximate`, `OpentaintNdUtil`, `ArgumentTypeContext`) and the project's dependencies; if compilation fails it reports the errors and aborts before the tests. A positive sample is a `falseNegative` until the model propagates taint. Read the result with the bundled script — it prints the pass/fail counts and names each failing sample, so you don't parse the JSON by hand: diff --git a/skills/create-rule/SKILL.md b/skills/create-rule/SKILL.md index 323c893561..d3dfe112d7 100644 --- a/skills/create-rule/SKILL.md +++ b/skills/create-rule/SKILL.md @@ -49,7 +49,7 @@ Run the rule tests directly as a foreground, blocking command and wait for exit opentaint test rule run .opentaint/test-compiled// \ -o .opentaint/test-results// \ --ruleset .opentaint/rules --ruleset .opentaint/test-projects///test-rules \ - --passthrough-approximations .opentaint/pass-through + --passthrough-models .opentaint/pass-through ``` `test rule run` auto-loads the built-in rules, so pass only your custom rulesets. Apply the passthrough approximations as-is, an empty one is harmless. Read the result with the bundled script — it prints the pass/fail counts and names the failing samples, so you never parse the JSON by hand: diff --git a/skills/create-rule/references/debugging.md b/skills/create-rule/references/debugging.md index e6d8f8ad17..b280d46ea7 100644 --- a/skills/create-rule/references/debugging.md +++ b/skills/create-rule/references/debugging.md @@ -15,7 +15,7 @@ opentaint test rule reachability \ - `` — the one rule whose sample routes taint through the code under test (`.yaml:`). One rule per run — across many rules the trace is unusably huge. Its library `refs` are collected automatically - read the sibling `/debug-ifds-fact-reachability.sarif`, not the `-o` file. The `-o` SARIF only shows default scan output. The sibling holds the per-instruction facts that show where taint dies -- apply the approximations the failing run used so the trace matches it: `--passthrough-approximations ` and/or `--dataflow-approximations `. Taint dying at an approximated call then means that approximation isn't propagating +- apply the approximations the failing run used so the trace matches it: `--passthrough-models ` and/or `--java-models `. Taint dying at an approximated call then means that approximation isn't propagating - debug the exact run that showed the problem — same model, rulesets, approximation dirs — or you're debugging something else ## Reading the trace @@ -35,7 +35,7 @@ When a positive won't pass and the suspicion is a library method on its flow dro opentaint scan --project-model .opentaint/test-compiled// \ -o .opentaint/test-results///diag.sarif \ --ruleset builtin --ruleset .opentaint/rules --ruleset .opentaint/test-projects///test-rules \ - --passthrough-approximations .opentaint/pass-through \ + --passthrough-models .opentaint/pass-through \ --track-external-methods ``` diff --git a/skills/debug-rule/SKILL.md b/skills/debug-rule/SKILL.md index 0907d496d4..d5a1d464b2 100644 --- a/skills/debug-rule/SKILL.md +++ b/skills/debug-rule/SKILL.md @@ -30,8 +30,8 @@ opentaint test rule reachability \ --project-model \ -o /report.sarif \ --ruleset builtin --ruleset .opentaint/rules \ - --passthrough-approximations .opentaint/pass-through \ - --dataflow-approximations .opentaint/dataflow + --passthrough-models .opentaint/pass-through \ + --java-models .opentaint/dataflow ``` `` is `.opentaint/test-results/` for a test model, `.opentaint/results` for the main scan. The per-instruction facts are in the sibling `/debug-ifds-fact-reachability.sarif`, not the `-o` file — the `-o` SARIF only shows whether the rule fired. Read that sibling to find the kill: diff --git a/skills/run-scan/SKILL.md b/skills/run-scan/SKILL.md index 8c363c092f..c81bad9adc 100644 --- a/skills/run-scan/SKILL.md +++ b/skills/run-scan/SKILL.md @@ -33,8 +33,8 @@ opentaint scan --project-model .opentaint/project \ ``` - `--rule-id ` — restrict to specific rules (repeatable, one per input rule ID); every unnamed rule is dropped, including library `refs`, so list every id the restricted rules depend on. Omit to run all loaded rules -- `--passthrough-approximations .opentaint/pass-through` — add when that directory exists: passThrough configs override built-ins at the rule level, a provided rule overriding a built-in only when it matches one -- `--dataflow-approximations .opentaint/dataflow` — add when that directory exists: code-based approximations (sources auto-compiled; pre-compiled `.class` dirs passed through as-is) +- `--passthrough-models .opentaint/pass-through` — add when that directory exists: passThrough configs override built-ins at the rule level, a provided rule overriding a built-in only when it matches one +- `--java-models .opentaint/dataflow` — add when that directory exists: code-based approximations (sources auto-compiled; pre-compiled `.class` dirs passed through as-is) Both approximation-dir flags walk their trees recursively; pass each parent directory once, not every package or batch separately.