From 96ca98f58c30cdc02b95b75379c4a19365c88e5d Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Sun, 23 Aug 2026 23:35:55 +0200 Subject: [PATCH 01/36] refactor(cli): Rework the help text and end-of-run output --- cli/cmd/compile.go | 34 +++++---- cli/cmd/dry_run.go | 34 ++++++++- cli/cmd/exit_codes.go | 36 +++++++++ cli/cmd/health.go | 20 +++-- cli/cmd/project.go | 41 +++++----- cli/cmd/prune.go | 52 +++++++------ cli/cmd/pull.go | 31 ++++---- cli/cmd/rerun_test.go | 115 ++++++++++++++++++++++++++++ cli/cmd/root.go | 8 +- cli/cmd/scan.go | 121 ++++++++++++++++++++++-------- cli/cmd/suggest.go | 87 +++++++++++++++++++-- cli/cmd/summary.go | 47 +++++++++--- cli/cmd/test.go | 27 +++---- cli/cmd/test_approximation_run.go | 17 ++++- cli/cmd/test_init.go | 61 +++++++++------ cli/cmd/test_rule_reachability.go | 23 +++++- cli/cmd/test_rule_run.go | 63 ++++++++++++---- cli/cmd/update.go | 28 ++++--- cli/internal/analyzer/exit.go | 4 +- docs/README.md | 4 +- docs/installation.md | 2 +- docs/usage.md | 40 +++++----- 22 files changed, 671 insertions(+), 224 deletions(-) create mode 100644 cli/cmd/exit_codes.go create mode 100644 cli/cmd/rerun_test.go diff --git a/cli/cmd/compile.go b/cli/cmd/compile.go index 3d33c8d337..965b3973f8 100644 --- a/cli/cmd/compile.go +++ b/cli/cmd/compile.go @@ -32,21 +32,28 @@ 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: `OpenTaint detects the build system, resolves the project's modules and dependencies, and compiles the project into a reusable model. -Arguments: - project - Path to a project to compile (required) -`, +The project argument is the path to the project root and is required. Pass --output to name the project model directory to create; it must not already exist. + +The project model is written to the --output directory and can be reused by later scans without rebuilding. + +Run opentaint pull once before your first compile to fetch the toolchain. Analyze the resulting project model with opentaint scan --project-model.`, + Example: ` # Compile the current directory into a project model + opentaint compile . -o ./model + + # Validate inputs without compiling + opentaint compile . -o ./model --dry-run`, Annotations: map[string]string{"PrintConfig": "true"}, Run: func(cmd *cobra.Command, args []string) { ProjectPath = args[0] @@ -72,25 +79,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 +105,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 +117,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/dry_run.go b/cli/cmd/dry_run.go index e0c2f7276a..c6086cb4e6 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,33 @@ 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 (spaces, quotes); plain arguments pass through unchanged. +func shellQuote(arg string) string { + if arg != "" && !strings.ContainsAny(arg, " \t'\"") { + return arg + } + return "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'" } diff --git a/cli/cmd/exit_codes.go b/cli/cmd/exit_codes.go new file mode 100644 index 0000000000..5de9a0912e --- /dev/null +++ b/cli/cmd/exit_codes.go @@ -0,0 +1,36 @@ +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 test-failure code (scan, test rule +// reachability). +func scanExitCodesHelp(completedLine string) string { + return `Exit codes: + 0 ` + completedLine + ` + 1 General failure (configuration or infrastructure error)` + 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/health.go b/cli/cmd/health.go index 0954bbbf47..4e3de53bd6 100644 --- a/cli/cmd/health.go +++ b/cli/cmd/health.go @@ -27,15 +27,20 @@ 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 on-disk paths for the autobuilder, analyzer, built-in rules, and Java runtime, and report whether 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. +Select components with --autobuilder, --analyzer, --rules, or --runtime; with no flag, all four are reported. When exactly one component is selected, only its path is printed, which suits scripting. Only the built-in rules are fetched on demand; no other artifact is downloaded. -The exit code is non-zero when any selected component is missing.`, +The command exits non-zero when any selected component is missing. Download the missing components with opentaint pull.`, + Example: ` # Report all components and their paths + opentaint health + + # Print only the analyzer JAR path, for scripting + opentaint health --analyzer + + # Check the Java runtime + opentaint health --runtime`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return runHealth() @@ -105,6 +110,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..0e8ff5cd23 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,22 @@ 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 directly from precompiled JARs or classes, without running a build. OpenTaint inspects the supplied classpath, detects the modules and dependencies, and writes a project.yaml describing the project for later analysis. Use this when you already have compiled artifacts; opentaint compile builds a model from sources instead. -This command generates a project model, automatically detecting dependencies and project structure. -Additional packages have to be specified to enhance the generated configuration. +All inputs are supplied as flags. Provide --source-root for the project sources, one or more --classpath entries for the compiled classes or JARs, and one or more --package names to include. Add --dependency for extra JAR files on the compile classpath. -Examples: - # Classpath analysis - opentaint project --output ./project-model --source-root /path/to/source \ - --classpath /path/to/app.jar --package com.example`, +The project model is written to the required --output directory, which must not already exist, and contains the generated project.yaml. + +Run opentaint pull once beforehand to fetch the autobuilder. Scan the generated model with opentaint scan --project-model .`, + Example: ` # Generate a project model from a compiled JAR + opentaint project --source-root ./src --classpath ./app.jar --package com.example -o ./model + + # Add extra dependency JARs to the classpath + opentaint project --source-root ./src --classpath ./app.jar --dependency ./lib.jar --package com.example -o ./model + + # Validate the inputs without generating anything + opentaint project --source-root ./src --classpath ./app.jar --package com.example -o ./model --dry-run`, Run: func(cmd *cobra.Command, args []string) { config := NewJavaAutobuilder(). WithOutputDir(OutputDir). @@ -269,12 +276,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 +289,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..162a3a4c27 100644 --- a/cli/cmd/prune.go +++ b/cli/cmd/prune.go @@ -57,24 +57,23 @@ 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 stale downloaded artifacts from the cache", + Long: `Remove stale downloaded artifacts from the local cache (~/.opentaint): superseded analyzer and autobuilder JARs, old rules, JDK and JRE versions that no longer match the configured one, and cached project models. + +Select categories with --artifacts, --rules, --jdk, --models, --logs, or --install. Without a category flag, prune removes artifacts, rules, jdk, and models; --all removes everything, including logs and install-tier artifacts, and cannot be combined with a specific category flag. + +Preview the deletions with --dry-run, and skip the confirmation prompt with --yes. Restore install-tier artifacts afterward with opentaint pull.`, + Example: ` # Prune the default categories after confirming + opentaint prune + + # Prune only old JDK and JRE versions + opentaint prune --jdk + + # Prune everything, including logs and install-tier artifacts + opentaint prune --all + + # Preview what would be deleted without deleting + opentaint prune --dry-run`, Run: func(cmd *cobra.Command, args []string) { categories, err := resolveCategories() if err != nil { @@ -84,23 +83,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 +129,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..d73b5fa38f 100644 --- a/cli/cmd/pull.go +++ b/cli/cmd/pull.go @@ -17,15 +17,17 @@ 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 analyzer, autobuilder, built-in rules, go-ssa-server, and a bundled Java runtime into the local cache. OpenTaint uses these to build and analyze projects without further network access. + +When bundled artifacts from a release archive are present, they are used directly instead of downloading. + +Run opentaint pull once before your first scan. Remove stale downloads later with opentaint prune.`, + Example: ` # Download the toolchain before the first scan + opentaint pull + + # Fetch a different Java runtime version + opentaint pull --java-version 17`, Run: func(cmd *cobra.Command, args []string) { out.Section("OpenTaint Pull"). Field("Autobuilder", globals.Config.Autobuilder.Version). @@ -40,7 +42,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 +52,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..310b263b6d --- /dev/null +++ b/cli/cmd/rerun_test.go @@ -0,0 +1,115 @@ +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") + } +} diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 214028ec8b..85a2239267 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -33,9 +33,11 @@ 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 by tracing tainted data from untrusted sources to sensitive sinks. Java, Kotlin, and Go projects are supported. + +Run opentaint pull once to fetch the toolchain, opentaint scan to analyze a project, and opentaint summary to re-inspect a SARIF report.`, SilenceErrors: true, SilenceUsage: true, diff --git a/cli/cmd/scan.go b/cli/cmd/scan.go index 9750dcdeb9..8c88b43747 100644 --- a/cli/cmd/scan.go +++ b/cli/cmd/scan.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" "github.com/seqra/opentaint/internal/analyzer" @@ -72,15 +73,31 @@ 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 for vulnerabilities. OpenTaint detects the build system, builds the project, and runs taint analysis over the result. -Arguments: - source-path - Path to the project sources (default: current directory) +The optional source-path argument is the project root and defaults to the current directory. Pass --project-model to analyze a pre-compiled project model instead of building; source-path and --project-model are mutually exclusive. -Use --project-model to scan a pre-compiled project model instead of compiling from sources. -`, +Findings are written as a SARIF report to --output, or into the project model directory when unset, and summarized on completion. + +Run opentaint pull once before your first scan to fetch the toolchain. Re-inspect a report later with opentaint summary. + +` + scanExitCodesHelp("Scan completed"), + Example: ` # Scan the current directory with the built-in rules + opentaint scan . + + # Scan a project and write a SARIF report + opentaint scan ./my-app -o report.sarif + + # Analyze a pre-compiled project model instead of building + opentaint scan --project-model ./model -o report.sarif + + # Run a custom ruleset and report only errors + opentaint scan . --ruleset ./rules --severity error -o report.sarif + + # Give a large project more time and memory + opentaint scan . --timeout 30m --max-memory 16G -o report.sarif`, Annotations: map[string]string{"PrintConfig": "true"}, Run: func(cmd *cobra.Command, args []string) { if scanFlags.DebugRunAnalysisOnSelectedEntryPoints != "" { @@ -93,7 +110,7 @@ Use --project-model to scan a pre-compiled project model instead of compiling fr func prepareScanConfig(cfg ScanConfig, args []string) ScanConfig { 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) } @@ -122,19 +139,19 @@ 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)") } 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") @@ -143,7 +160,7 @@ func addScanFlags(cmd *cobra.Command) { cmd.Flags().StringArrayVar(&scanFlags.PassthroughApproximations, "passthrough-approximations", nil, "Pass-through approximation YAML file or directory (repeatable)") - cmd.Flags().StringArrayVar(&scanFlags.DataflowApproximations, "dataflow-approximations", nil, "Dataflow approximation class directory or Java source directory (repeatable)") + cmd.Flags().StringArrayVar(&scanFlags.DataflowApproximations, "dataflow-approximations", nil, "Dataflow approximation class directory or Java source directory (Java analysis only, repeatable)") cmd.Flags().BoolVar(&scanFlags.TrackExternalMethods, "track-external-methods", false, "Write external-method coverage files next to the SARIF report") } @@ -172,7 +189,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 +207,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) @@ -283,7 +300,7 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { } if cfg.DryRun { - runDryRun("Compilation and analysis") + runDryRun("the build and scan") return } @@ -296,25 +313,25 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { } 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 +350,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 +361,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 @@ -397,7 +414,7 @@ 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) @@ -406,7 +423,7 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { 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,15 +478,43 @@ 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) + } } if report != nil { // 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(), - }) + 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...) @@ -478,6 +523,20 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { } } +// 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 { if cfg.ProjectModelPath != "" { return scanPlan{ @@ -537,7 +596,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..58231fa4ec 100644 --- a/cli/cmd/summary.go +++ b/cli/cmd/summary.go @@ -9,14 +9,30 @@ import ( // summaryCmd represents the summary command var summaryCmd = &cobra.Command{ - Use: "summary sarif", - Short: "Print summary of your sarif", + Use: "summary ", + Short: "Summarize a SARIF report", Args: cobra.ExactArgs(1), // require exactly one argument - Long: `Print summary of your sarif file + Long: `Summarize a SARIF report on the terminal. OpenTaint counts the findings by severity, groups them, and shows which rules ran and which produced results. -Arguments: - sarif - Path to a sarif file -`, +The required positional argument is the path to a SARIF report, such as one written by opentaint scan or opentaint test. Pass --show-findings to list every finding; narrow the listing with --severity, --rule-id, or --path, and expand code flows with --show-code-snippets and --verbose-flow. + +The report is read only: the summary and any findings are printed to the terminal and nothing is written to disk. + +Run opentaint scan to produce the report this command reads.`, + Example: ` # Print a summary of a report + opentaint summary report.sarif + + # List every finding with its location + opentaint summary report.sarif --show-findings + + # Show only error-level findings + opentaint summary report.sarif --show-findings --severity error + + # Group the listing by rule + opentaint summary report.sarif --show-findings --group-by rule-id + + # Trace one rule with full code flow and snippets + opentaint summary report.sarif --show-findings --rule-id --show-code-snippets --verbose-flow`, Run: func(cmd *cobra.Command, args []string) { for _, s := range summarySeverities { @@ -39,6 +55,13 @@ Arguments: out.Fatalf("Failed to load SARIF report: %s", err) } printSarifSummary(report, absSarifPath, summaryFilters(), summaryListingOptions(dim, codeFlowSel)) + + if !showFindings && sarif.GenerateSummary(report.Filter(summaryFilters())).TotalFindings > 0 { + out.Suggest( + "To list the findings, run:", + currentSummaryBuilder(absSarifPath).WithShowFindings().Build(), + ) + } }, } @@ -58,16 +81,16 @@ var summaryCodeFlow string 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(&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 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().StringVar(&summaryFingerprintKey, "partial-fingerprint-key", "", "partialFingerprints key matched by --partial-fingerprint; defaults to vulnerabilityWithTraceHash/v1") 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)") } @@ -137,7 +160,7 @@ func printSarifSummary(report *sarif.Report, absSarifPath string, filters sarif. if showFindings && 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..8c815e022e 100644 --- a/cli/cmd/test.go +++ b/cli/cmd/test.go @@ -9,17 +9,25 @@ 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, run, and debug rule and approximation tests. Rule tests check detection rules against annotated sample projects; approximation tests check dataflow approximations the same way. + +Scaffold a project with init, compile it with opentaint compile, then run the samples with test rule run or test approximation run. Use test rule reachability to debug why a single rule does or does not fire.`, } var testRuleCmd = &cobra.Command{ Use: "rule", Short: "Create, run, and debug detection-rule tests", + Long: `Create, run, and debug taint detection-rule tests. Rule tests check that a rule fires on positive samples and stays silent on negative ones. + +Scaffold a test project with test rule init, compile it with opentaint compile, then run the samples with test rule run. Use test rule reachability to trace why a single rule does or does not fire.`, } var testApproximationCmd = &cobra.Command{ Use: "approximation", Short: "Create and run dataflow-approximation tests", + Long: `Create and run dataflow-approximation tests. Approximation tests check that a dataflow approximation carries taint from source to sink across your samples. + +Scaffold a test project with test approximation init, compile it with opentaint compile, then run the samples with test approximation run, supplying the approximation under test with --dataflow-approximations.`, } func init() { @@ -28,20 +36,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)") + cmd.Flags().StringArrayVar(dataflow, "dataflow-approximations", nil, "Dataflow approximation class directory or Java source directory (Java analysis only, repeatable)") } diff --git a/cli/cmd/test_approximation_run.go b/cli/cmd/test_approximation_run.go index 018e16c03d..ca0210e9f3 100644 --- a/cli/cmd/test_approximation_run.go +++ b/cli/cmd/test_approximation_run.go @@ -17,13 +17,21 @@ 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 declared in rule-test.yaml with the supplied dataflow approximations applied and report which passed. A fixed source-to-sink harness rule is applied automatically; positive samples reference it by 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, produced by opentaint compile. Supply the approximation under test with --dataflow-approximations. + +Results are written as test-result.json and a test-results.sarif report to --output, or to a temporary directory when unset. + +Compile the test project with opentaint compile before running. Inspect the results afterward with opentaint summary. ` + testExitCodesHelp("All approximation tests passed"), + Example: ` # Run an approximation test against a compiled model + opentaint test approximation run ./approx-test/model --dataflow-approximations ./approx + + # Write results to a directory + opentaint test approximation run ./approx-test/model --dataflow-approximations ./approx -o ./results`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { ruleDir, err := os.MkdirTemp("", "opentaint-approx-rule-*") @@ -36,6 +44,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..a14105bc6d 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,21 @@ 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 against a generic taint source; the sources project tests source rules against 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 the projects are created under. By default both are scaffolded, as output-dir/sinks and output-dir/sources; pass --sinks-only or --sources-only to create just one. Use --dependency to add compile-only Maven dependencies for the samples. -Positive and negative samples are specified via rule-test.yaml. +Each project ships a rule-test.yaml where you declare the positive and negative samples, plus a Taint.java source and sink harness. -Use --dependency to add compile-only Maven dependencies for the samples.`, +After editing rule-test.yaml, compile the project with opentaint compile and run the samples with opentaint test rule run.`, + Example: ` # Scaffold both the sinks and sources test projects + opentaint test rule init ./rule-tests + + # Scaffold 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 `, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { if initRuleSinksOnly && initRuleSourcesOnly { @@ -50,28 +53,33 @@ 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 pins a fixed source-to-sink rule that the samples are checked against. -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 output-dir argument is the directory the project is created in. Use --dependency to add compile-only Maven dependencies for the samples. The approximation under test is not baked in; supply it at run time with --dataflow-approximations. -Positive and negative samples are specified via rule-test.yaml. +The project ships a rule-test.yaml where you declare the positive and negative samples, plus a Taint.java source and sink and the fixed approximation-rule.yaml. -The approximation under test is supplied separately at test time with ---dataflow-approximations. +After editing rule-test.yaml, compile the project with opentaint compile and run the samples with opentaint test approximation run.`, + Example: ` # Scaffold an approximation test project + opentaint test approximation init ./approx-test -Use --dependency to add compile-only Maven dependencies for the samples.`, + # Add a compile-only dependency for the samples + opentaint test approximation init ./approx-test --dependency `, 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 +88,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 --dataflow-approximations ", modelDir)}, + ) }, } diff --git a/cli/cmd/test_rule_reachability.go b/cli/cmd/test_rule_reachability.go index ecb0bf3231..58bfc62333 100644 --- a/cli/cmd/test_rule_reachability.go +++ b/cli/cmd/test_rule_reachability.go @@ -9,11 +9,26 @@ 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. + Long: `Scan a project with a single rule and write a fact-reachability SARIF report so you can see why that rule does or does not fire. Referenced library source and sink rules are collected and analyzed automatically. -Referenced library source and sink rules are collected and analyzed automatically.`, +The rule-id argument selects the one rule to trace. The optional source-path argument is the project root and defaults to the current directory. Pass --project-model to trace a pre-compiled model instead; source-path and --project-model are mutually exclusive. Use --entry-points to start the analysis from a specific method. + +The report is written as debug-ifds-fact-reachability.sarif next to the main SARIF report. + +Run opentaint pull once before your first run to fetch the toolchain. Open the reachability report afterward with opentaint summary. + +` + scanExitCodesHelp("Reachability analysis completed"), + Example: ` # Trace a rule against the current directory + opentaint test rule reachability . + + # Trace a rule against a pre-compiled project model + opentaint test rule reachability --project-model ./model + + # Start the analysis from a specific entry-point method + opentaint test rule reachability . --entry-points com.example.App#main + + # Validate inputs without compiling or scanning + opentaint test rule reachability . --dry-run`, Annotations: map[string]string{"PrintConfig": "true"}, Args: cobra.RangeArgs(1, 2), Run: func(cmd *cobra.Command, args []string) { diff --git a/cli/cmd/test_rule_run.go b/cli/cmd/test_rule_run.go index 4b380c617b..83dbc3b6e7 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,28 @@ 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 against the samples declared in rule-test.yaml and report which passed. The built-in rules are always included. + +The project-model argument is a compiled project model directory, produced by opentaint compile. Add your own rules with --ruleset, narrow the run to specific rules with --rule-id, and apply approximations with --dataflow-approximations or --passthrough-approximations. + +Results are written as test-result.json and a test-results.sarif report to --output, or to a temporary directory when unset. + +Compile the test project with opentaint compile before running. Inspect the results afterward with opentaint summary. ` + testExitCodesHelp("All rule tests passed"), + Example: ` # Run the built-in rules against a compiled model + opentaint test rule run ./rule-tests/sinks/model + + # Test a custom ruleset and write 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 `, 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 +63,7 @@ compiled project model. type testProjectOptions struct { label string + passedLine string // success status line; matches the documented exit-code 0 row tempDir string rulesets []string outputDir string @@ -104,7 +120,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 +140,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,44 +149,61 @@ 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)") diff --git a/cli/cmd/update.go b/cli/cmd/update.go index 1d3a5c1bda..a1dd5ef0e8 100644 --- a/cli/cmd/update.go +++ b/cli/cmd/update.go @@ -20,13 +20,19 @@ 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 in place to the latest release, or to the optional version argument. Only upgrades are supported; downgrading to an older version is refused. -This command detects how opentaint was installed and provides appropriate -instructions for package manager installations. For binary installations, -it performs an in-place update. +Homebrew and npm installations print the matching package-manager command instead of updating in place. Pass --check to report the latest version without downloading, or --yes to skip the confirmation prompt. -Only upgrades are supported — downgrading to an older version is refused.`, +After a successful update, remove superseded artifacts with opentaint prune.`, + Example: ` # Update to the latest release + opentaint update + + # Check for a newer version without installing + opentaint update --check + + # Update to a specific version without prompting + opentaint update 1.2.3 --yes`, Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { // Check installation method first @@ -35,11 +41,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 +81,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 +100,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 +112,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 +138,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/docs/README.md b/docs/README.md index 760695b752..cb3fe42fdf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -128,11 +128,11 @@ opentaint summary --show-findings --verbose-flow --show-code-snippets results.sa | 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 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 | diff --git a/docs/installation.md b/docs/installation.md index c1e400cc8f..67ecb6ac87 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, the Go toolchain for Go modules). Java runtime is bundled with release archives. ## Homebrew (Linux/macOS) diff --git a/docs/usage.md b/docs/usage.md index 84f9359ed2..6f777013ea 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -78,20 +78,20 @@ 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 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 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, Gradle, or go.mod), 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,10 +100,10 @@ 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`) | @@ -115,7 +115,7 @@ These flags are to work with custom approximations: |------|-------------| | `--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) | +| `--dataflow-approximations` | Apply dataflow approximation classes or Java source directories (Java analysis only, 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. @@ -129,7 +129,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 | |------|-------------| @@ -169,13 +169,13 @@ opentaint test approximation run .opentaint/test-compiled/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. ### opentaint compile -Compiles Java and Kotlin projects and generates project models for analysis. Useful when you want to separate compilation from scanning or need to inspect the project model. +Compiles Java, Kotlin, and Go projects and generates project models for analysis. Useful when you want to separate compilation from scanning or need to inspect the project model. ```bash opentaint compile --output ./my-project-model /path/to/project @@ -184,7 +184,7 @@ 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`) | @@ -197,7 +197,7 @@ 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) | @@ -224,12 +224,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 From d69761f385d8618b9bf8775ab3233762776d0af6 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Sun, 23 Aug 2026 23:36:13 +0200 Subject: [PATCH 02/36] refactor(cli): Rename the model flags to --passthrough-models and --java-models --- cli/cmd/analyzer_inputs.go | 4 ++-- cli/cmd/compile_approximations.go | 2 +- cli/cmd/scan.go | 10 +++++++--- cli/cmd/test.go | 6 ++++-- cli/cmd/test_approximation_run.go | 6 +++--- cli/cmd/test_init.go | 4 ++-- cli/cmd/test_rule_run.go | 6 ++++-- cli/internal/utils/opentaint_command_builder.go | 4 ++-- docs/usage.md | 6 +++--- .../references/java.md.j2 | 2 +- .../create-rule/references/debugging.md.j2 | 2 +- skills-templates/create-rule/sections/workflow.md | 2 +- skills-templates/debug-rule/sections/workflow.md | 4 ++-- skills-templates/run-scan/sections/workflow.md | 4 ++-- skills-templates/shared/debugging.md | 2 +- .../references/debugging.md | 2 +- .../create-dataflow-approximation/references/java.md | 2 +- skills/create-rule/SKILL.md | 2 +- skills/create-rule/references/debugging.md | 4 ++-- skills/debug-rule/SKILL.md | 4 ++-- skills/run-scan/SKILL.md | 4 ++-- 21 files changed, 45 insertions(+), 37 deletions(-) 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/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/scan.go b/cli/cmd/scan.go index 8c88b43747..4b6a996418 100644 --- a/cli/cmd/scan.go +++ b/cli/cmd/scan.go @@ -158,9 +158,13 @@ func addScanFlags(cmd *cobra.Command) { 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)") + cmd.Flags().StringArrayVar(&scanFlags.PassthroughApproximations, "passthrough-models", nil, "Pass-through models: a YAML file or a directory of them (repeatable)") + cmd.Flags().StringArrayVar(&scanFlags.PassthroughApproximations, "passthrough-approximations", nil, "Pass-through models: a YAML file or a directory of them (repeatable)") + _ = cmd.Flags().MarkDeprecated("passthrough-approximations", "use --passthrough-models") - cmd.Flags().StringArrayVar(&scanFlags.DataflowApproximations, "dataflow-approximations", nil, "Dataflow approximation class directory or Java source directory (Java analysis only, repeatable)") + cmd.Flags().StringArrayVar(&scanFlags.DataflowApproximations, "java-models", nil, "Java dataflow models: a compiled class directory or a Java source directory (repeatable)") + cmd.Flags().StringArrayVar(&scanFlags.DataflowApproximations, "dataflow-approximations", nil, "Java dataflow models: a compiled class directory or a Java source directory (repeatable)") + _ = cmd.Flags().MarkDeprecated("dataflow-approximations", "use --java-models") cmd.Flags().BoolVar(&scanFlags.TrackExternalMethods, "track-external-methods", false, "Write external-method coverage files next to the SARIF report") } @@ -418,7 +422,7 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { } 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() diff --git a/cli/cmd/test.go b/cli/cmd/test.go index 8c815e022e..0ff6670ee3 100644 --- a/cli/cmd/test.go +++ b/cli/cmd/test.go @@ -27,7 +27,7 @@ var testApproximationCmd = &cobra.Command{ Short: "Create and run dataflow-approximation tests", Long: `Create and run dataflow-approximation tests. Approximation tests check that a dataflow approximation carries taint from source to sink across your samples. -Scaffold a test project with test approximation init, compile it with opentaint compile, then run the samples with test approximation run, supplying the approximation under test with --dataflow-approximations.`, +Scaffold a test project with test approximation init, compile it with opentaint compile, then run the samples with test approximation run, supplying the approximation under test with --java-models.`, } func init() { @@ -40,5 +40,7 @@ func addTestRunFlags(cmd *cobra.Command, outputDir *string, timeout *time.Durati cmd.Flags().StringVarP(outputDir, "output", "o", "", "Directory for test-result.json and test-results.sarif") 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)") - cmd.Flags().StringArrayVar(dataflow, "dataflow-approximations", nil, "Dataflow approximation class directory or Java source directory (Java analysis only, repeatable)") + cmd.Flags().StringArrayVar(dataflow, "java-models", nil, "Java dataflow models: a compiled class directory or a Java source directory (repeatable)") + cmd.Flags().StringArrayVar(dataflow, "dataflow-approximations", nil, "Java dataflow models: a compiled class directory or a Java source directory (repeatable)") + _ = cmd.Flags().MarkDeprecated("dataflow-approximations", "use --java-models") } diff --git a/cli/cmd/test_approximation_run.go b/cli/cmd/test_approximation_run.go index ca0210e9f3..a8ca1d7df1 100644 --- a/cli/cmd/test_approximation_run.go +++ b/cli/cmd/test_approximation_run.go @@ -20,7 +20,7 @@ var testApproximationRunCmd = &cobra.Command{ Short: "Run dataflow-approximation tests on a compiled project model", Long: `Run the samples declared in rule-test.yaml with the supplied dataflow approximations applied and report which passed. A fixed source-to-sink harness rule is applied automatically; positive samples reference it by id approximation-rule. -The project-model argument is a compiled project model directory, produced by opentaint compile. Supply the approximation under test with --dataflow-approximations. +The project-model argument is a compiled project model directory, produced by opentaint compile. Supply the approximation under test with --java-models. Results are written as test-result.json and a test-results.sarif report to --output, or to a temporary directory when unset. @@ -28,10 +28,10 @@ Compile the test project with opentaint compile before running. Inspect the resu ` + testExitCodesHelp("All approximation tests passed"), Example: ` # Run an approximation test against a compiled model - opentaint test approximation run ./approx-test/model --dataflow-approximations ./approx + opentaint test approximation run ./approx-test/model --java-models ./approx # Write results to a directory - opentaint test approximation run ./approx-test/model --dataflow-approximations ./approx -o ./results`, + opentaint test approximation run ./approx-test/model --java-models ./approx -o ./results`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { ruleDir, err := os.MkdirTemp("", "opentaint-approx-rule-*") diff --git a/cli/cmd/test_init.go b/cli/cmd/test_init.go index a14105bc6d..22ab6b66e7 100644 --- a/cli/cmd/test_init.go +++ b/cli/cmd/test_init.go @@ -70,7 +70,7 @@ var testApproximationInitCmd = &cobra.Command{ Short: "Create a dataflow-approximation test project", Long: `Create a Gradle test project for dataflow-approximation tests. The project pins a fixed source-to-sink rule that the samples are checked against. -The output-dir argument is the directory the project is created in. Use --dependency to add compile-only Maven dependencies for the samples. The approximation under test is not baked in; supply it at run time with --dataflow-approximations. +The output-dir argument is the directory the project is created in. Use --dependency to add compile-only Maven dependencies for the samples. The approximation under test is not baked in; supply it at run time with --java-models. The project ships a rule-test.yaml where you declare the positive and negative samples, plus a Taint.java source and sink and the fixed approximation-rule.yaml. @@ -94,7 +94,7 @@ After editing rule-test.yaml, compile the project with opentaint compile and run 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 --dataflow-approximations ", 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_run.go b/cli/cmd/test_rule_run.go index 83dbc3b6e7..ef6b8484a6 100644 --- a/cli/cmd/test_rule_run.go +++ b/cli/cmd/test_rule_run.go @@ -28,7 +28,7 @@ var testRuleRunCmd = &cobra.Command{ Short: "Run detection-rule tests on a compiled project model", Long: `Run detection rules against the samples declared in rule-test.yaml and report which passed. The built-in rules are always included. -The project-model argument is a compiled project model directory, produced by opentaint compile. Add your own rules with --ruleset, narrow the run to specific rules with --rule-id, and apply approximations with --dataflow-approximations or --passthrough-approximations. +The project-model argument is a compiled project model directory, produced by opentaint compile. Add your own rules with --ruleset, narrow the run to specific rules with --rule-id, and apply models with --java-models or --passthrough-models. Results are written as test-result.json and a test-results.sarif report to --output, or to a temporary directory when unset. @@ -206,5 +206,7 @@ func init() { 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)") + testRuleRunCmd.Flags().StringArrayVar(&testRulesPassthrough, "passthrough-models", nil, "Pass-through models: a YAML file or a directory of them (repeatable)") + testRuleRunCmd.Flags().StringArrayVar(&testRulesPassthrough, "passthrough-approximations", nil, "Pass-through models: a YAML file or a directory of them (repeatable)") + _ = testRuleRunCmd.Flags().MarkDeprecated("passthrough-approximations", "use --passthrough-models") } diff --git a/cli/internal/utils/opentaint_command_builder.go b/cli/internal/utils/opentaint_command_builder.go index 1356a557bb..369f62cd51 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 diff --git a/docs/usage.md b/docs/usage.md index 6f777013ea..fcaf3ad2f9 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -114,8 +114,8 @@ 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 (Java analysis only, 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. @@ -163,7 +163,7 @@ 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 | 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. From d2e674f6d545c51d3bbea63715f555099f58f4b1 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Mon, 24 Aug 2026 01:01:08 +0200 Subject: [PATCH 03/36] style(cli): drop semicolons from help text, messages, and comments House style: no semicolons in prose. Semicolon-joined sentences in the Long descriptions, flag help, the dry-run status line, and comments are split into separate sentences, and '; defaults to X' flag suffixes become '(defaults to X)'. The mirrored line in docs/usage.md is updated to match. --- cli/cmd/compile.go | 2 +- cli/cmd/dry_run.go | 4 ++-- cli/cmd/health.go | 2 +- cli/cmd/project.go | 2 +- cli/cmd/prune.go | 2 +- cli/cmd/scan.go | 4 ++-- cli/cmd/summary.go | 6 +++--- cli/cmd/test.go | 2 +- cli/cmd/test_approximation_run.go | 2 +- cli/cmd/test_init.go | 6 +++--- cli/cmd/test_rule_reachability.go | 2 +- cli/cmd/test_rule_run.go | 6 +++--- cli/cmd/update.go | 2 +- docs/usage.md | 2 +- 14 files changed, 22 insertions(+), 22 deletions(-) diff --git a/cli/cmd/compile.go b/cli/cmd/compile.go index 965b3973f8..dfec1651f4 100644 --- a/cli/cmd/compile.go +++ b/cli/cmd/compile.go @@ -44,7 +44,7 @@ var compileCmd = &cobra.Command{ Args: cobra.ExactArgs(1), // require exactly one argument Long: `OpenTaint detects the build system, resolves the project's modules and dependencies, and compiles the project into a reusable model. -The project argument is the path to the project root and is required. Pass --output to name the project model directory to create; it must not already exist. +The project argument is the path to the project root and is required. Pass --output to name the project model directory to create. It must not already exist. The project model is written to the --output directory and can be reused by later scans without rebuilding. diff --git a/cli/cmd/dry_run.go b/cli/cmd/dry_run.go index c6086cb4e6..39d483a556 100644 --- a/cli/cmd/dry_run.go +++ b/cli/cmd/dry_run.go @@ -14,7 +14,7 @@ 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.Printf("Dry run complete. Inputs validated; %s skipped.", skippedAction) + out.Printf("Dry run complete. Inputs validated, %s skipped.", skippedAction) suggest("To run for real, run:", rerunWithoutDryRun()) } @@ -34,7 +34,7 @@ func rerunWithoutDryRun() string { } // shellQuote single-quotes an argument that would break when copy-pasted into -// a shell (spaces, quotes); plain arguments pass through unchanged. +// a shell (spaces, quotes). Plain arguments pass through unchanged. func shellQuote(arg string) string { if arg != "" && !strings.ContainsAny(arg, " \t'\"") { return arg diff --git a/cli/cmd/health.go b/cli/cmd/health.go index 4e3de53bd6..df3f1b2dc3 100644 --- a/cli/cmd/health.go +++ b/cli/cmd/health.go @@ -30,7 +30,7 @@ var healthCmd = &cobra.Command{ Short: "Show dependency paths and report missing components", Long: `Show the on-disk paths for the autobuilder, analyzer, built-in rules, and Java runtime, and report whether each component is present. -Select components with --autobuilder, --analyzer, --rules, or --runtime; with no flag, all four are reported. When exactly one component is selected, only its path is printed, which suits scripting. Only the built-in rules are fetched on demand; no other artifact is downloaded. +Select components with --autobuilder, --analyzer, --rules, or --runtime. With no flag, all four are reported. When exactly one component is selected, only its path is printed, which suits scripting. Only the built-in rules are fetched on demand. No other artifact is downloaded. The command exits non-zero when any selected component is missing. Download the missing components with opentaint pull.`, Example: ` # Report all components and their paths diff --git a/cli/cmd/project.go b/cli/cmd/project.go index 0e8ff5cd23..5f3e1d5d69 100644 --- a/cli/cmd/project.go +++ b/cli/cmd/project.go @@ -220,7 +220,7 @@ var ( var projectCmd = &cobra.Command{ Use: "project", Short: "Create a project model from precompiled JARs or classes", - Long: `Create a project model directly from precompiled JARs or classes, without running a build. OpenTaint inspects the supplied classpath, detects the modules and dependencies, and writes a project.yaml describing the project for later analysis. Use this when you already have compiled artifacts; opentaint compile builds a model from sources instead. + Long: `Create a project model directly from precompiled JARs or classes, without running a build. OpenTaint inspects the supplied classpath, detects the modules and dependencies, and writes a project.yaml describing the project for later analysis. Use this when you already have compiled artifacts. To build a model from sources, use opentaint compile instead. All inputs are supplied as flags. Provide --source-root for the project sources, one or more --classpath entries for the compiled classes or JARs, and one or more --package names to include. Add --dependency for extra JAR files on the compile classpath. diff --git a/cli/cmd/prune.go b/cli/cmd/prune.go index 162a3a4c27..1fc24300b2 100644 --- a/cli/cmd/prune.go +++ b/cli/cmd/prune.go @@ -60,7 +60,7 @@ var pruneCmd = &cobra.Command{ Short: "Remove stale downloaded artifacts from the cache", Long: `Remove stale downloaded artifacts from the local cache (~/.opentaint): superseded analyzer and autobuilder JARs, old rules, JDK and JRE versions that no longer match the configured one, and cached project models. -Select categories with --artifacts, --rules, --jdk, --models, --logs, or --install. Without a category flag, prune removes artifacts, rules, jdk, and models; --all removes everything, including logs and install-tier artifacts, and cannot be combined with a specific category flag. +Select categories with --artifacts, --rules, --jdk, --models, --logs, or --install. Without a category flag, prune removes artifacts, rules, jdk, and models. The --all flag removes everything, including logs and install-tier artifacts, and cannot be combined with a specific category flag. Preview the deletions with --dry-run, and skip the confirmation prompt with --yes. Restore install-tier artifacts afterward with opentaint pull.`, Example: ` # Prune the default categories after confirming diff --git a/cli/cmd/scan.go b/cli/cmd/scan.go index 4b6a996418..85bc267dd2 100644 --- a/cli/cmd/scan.go +++ b/cli/cmd/scan.go @@ -77,7 +77,7 @@ var scanCmd = &cobra.Command{ Args: cobra.MaximumNArgs(1), Long: `Scan a project for vulnerabilities. OpenTaint detects the build system, builds the project, and runs taint analysis over the result. -The optional source-path argument is the project root and defaults to the current directory. Pass --project-model to analyze a pre-compiled project model instead of building; source-path and --project-model are mutually exclusive. +The optional source-path argument is the project root and defaults to the current directory. Pass --project-model to analyze a pre-compiled project model instead of building. The source-path argument and --project-model are mutually exclusive. Findings are written as a SARIF report to --output, or into the project model directory when unset, and summarized on completion. @@ -495,7 +495,7 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { if analyzerFail == nil { out.Successf("Reachability analysis completed.") } - // The reachability report is the command's deliverable; point at it, + // 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{ diff --git a/cli/cmd/summary.go b/cli/cmd/summary.go index 58231fa4ec..7be7d9f7db 100644 --- a/cli/cmd/summary.go +++ b/cli/cmd/summary.go @@ -14,7 +14,7 @@ var summaryCmd = &cobra.Command{ Args: cobra.ExactArgs(1), // require exactly one argument Long: `Summarize a SARIF report on the terminal. OpenTaint counts the findings by severity, groups them, and shows which rules ran and which produced results. -The required positional argument is the path to a SARIF report, such as one written by opentaint scan or opentaint test. Pass --show-findings to list every finding; narrow the listing with --severity, --rule-id, or --path, and expand code flows with --show-code-snippets and --verbose-flow. +The required positional argument is the path to a SARIF report, such as one written by opentaint scan or opentaint test. Pass --show-findings to list every finding. Narrow the listing with --severity, --rule-id, or --path, and expand code flows with --show-code-snippets and --verbose-flow. The report is read only: the summary and any findings are printed to the terminal and nothing is written to disk. @@ -88,9 +88,9 @@ func init() { 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 partial fingerprint starts with this value (git-hash style, repeatable)") - summaryCmd.Flags().StringVar(&summaryFingerprintKey, "partial-fingerprint-key", "", "partialFingerprints key matched by --partial-fingerprint; defaults to vulnerabilityWithTraceHash/v1") + summaryCmd.Flags().StringVar(&summaryFingerprintKey, "partial-fingerprint-key", "", "partialFingerprints key matched by --partial-fingerprint (defaults to vulnerabilityWithTraceHash/v1)") 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; defaults to 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)") } diff --git a/cli/cmd/test.go b/cli/cmd/test.go index 0ff6670ee3..8598c601b6 100644 --- a/cli/cmd/test.go +++ b/cli/cmd/test.go @@ -9,7 +9,7 @@ import ( var testCmd = &cobra.Command{ Use: "test", Short: "Create and run rule and approximation tests", - Long: `Create, run, and debug rule and approximation tests. Rule tests check detection rules against annotated sample projects; approximation tests check dataflow approximations the same way. + Long: `Create, run, and debug rule and approximation tests. Rule tests check detection rules against annotated sample projects. Approximation tests check dataflow approximations the same way. Scaffold a project with init, compile it with opentaint compile, then run the samples with test rule run or test approximation run. Use test rule reachability to debug why a single rule does or does not fire.`, } diff --git a/cli/cmd/test_approximation_run.go b/cli/cmd/test_approximation_run.go index a8ca1d7df1..6bb796a3a6 100644 --- a/cli/cmd/test_approximation_run.go +++ b/cli/cmd/test_approximation_run.go @@ -18,7 +18,7 @@ var ( var testApproximationRunCmd = &cobra.Command{ Use: "run ", Short: "Run dataflow-approximation tests on a compiled project model", - Long: `Run the samples declared in rule-test.yaml with the supplied dataflow approximations applied and report which passed. A fixed source-to-sink harness rule is applied automatically; positive samples reference it by id approximation-rule. + Long: `Run the samples declared in rule-test.yaml with the supplied dataflow approximations applied and report which passed. A fixed source-to-sink harness rule is applied automatically. Positive samples reference it by id approximation-rule. The project-model argument is a compiled project model directory, produced by opentaint compile. Supply the approximation under test with --java-models. diff --git a/cli/cmd/test_init.go b/cli/cmd/test_init.go index 22ab6b66e7..c947ad9aa6 100644 --- a/cli/cmd/test_init.go +++ b/cli/cmd/test_init.go @@ -19,9 +19,9 @@ 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 for detection-rule tests. The sinks project tests sink rules against a generic taint source; the sources project tests source rules against a generic taint sink. + Long: `Create one or two Gradle test projects for detection-rule tests. The sinks project tests sink rules against a generic taint source. The sources project tests source rules against a generic taint sink. -The output-dir argument is the parent directory the projects are created under. By default both are scaffolded, as output-dir/sinks and output-dir/sources; pass --sinks-only or --sources-only to create just one. Use --dependency to add compile-only Maven dependencies for the samples. +The output-dir argument is the parent directory the projects are created under. By default both are scaffolded, as output-dir/sinks and output-dir/sources. Pass --sinks-only or --sources-only to create just one. Use --dependency to add compile-only Maven dependencies for the samples. Each project ships a rule-test.yaml where you declare the positive and negative samples, plus a Taint.java source and sink harness. @@ -70,7 +70,7 @@ var testApproximationInitCmd = &cobra.Command{ Short: "Create a dataflow-approximation test project", Long: `Create a Gradle test project for dataflow-approximation tests. The project pins a fixed source-to-sink rule that the samples are checked against. -The output-dir argument is the directory the project is created in. Use --dependency to add compile-only Maven dependencies for the samples. The approximation under test is not baked in; supply it at run time with --java-models. +The output-dir argument is the directory the project is created in. Use --dependency to add compile-only Maven dependencies for the samples. The approximation under test is not baked in. Supply it at run time with --java-models. The project ships a rule-test.yaml where you declare the positive and negative samples, plus a Taint.java source and sink and the fixed approximation-rule.yaml. diff --git a/cli/cmd/test_rule_reachability.go b/cli/cmd/test_rule_reachability.go index 58bfc62333..5d61019b28 100644 --- a/cli/cmd/test_rule_reachability.go +++ b/cli/cmd/test_rule_reachability.go @@ -11,7 +11,7 @@ var testRuleReachabilityCmd = &cobra.Command{ Short: "Trace why a rule can or cannot reach its facts", Long: `Scan a project with a single rule and write a fact-reachability SARIF report so you can see why that rule does or does not fire. Referenced library source and sink rules are collected and analyzed automatically. -The rule-id argument selects the one rule to trace. The optional source-path argument is the project root and defaults to the current directory. Pass --project-model to trace a pre-compiled model instead; source-path and --project-model are mutually exclusive. Use --entry-points to start the analysis from a specific method. +The rule-id argument selects the one rule to trace. The optional source-path argument is the project root and defaults to the current directory. Pass --project-model to trace a pre-compiled model instead. The source-path argument and --project-model are mutually exclusive. Use --entry-points to start the analysis from a specific method. The report is written as debug-ifds-fact-reachability.sarif next to the main SARIF report. diff --git a/cli/cmd/test_rule_run.go b/cli/cmd/test_rule_run.go index ef6b8484a6..f6a1492a8e 100644 --- a/cli/cmd/test_rule_run.go +++ b/cli/cmd/test_rule_run.go @@ -63,7 +63,7 @@ Compile the test project with opentaint compile before running. Inspect the resu type testProjectOptions struct { label string - passedLine string // success status line; matches the documented exit-code 0 row + passedLine string // success status line, matching the documented exit-code 0 row tempDir string rulesets []string outputDir string @@ -165,8 +165,8 @@ func runTestProject(projectModelArg string, opts testProjectOptions) { 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. + // 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"), diff --git a/cli/cmd/update.go b/cli/cmd/update.go index a1dd5ef0e8..19d83a8e46 100644 --- a/cli/cmd/update.go +++ b/cli/cmd/update.go @@ -20,7 +20,7 @@ var ( var updateCmd = &cobra.Command{ Use: "update [version]", Short: "Update opentaint to the latest version", - Long: `Update the opentaint binary in place to the latest release, or to the optional version argument. Only upgrades are supported; downgrading to an older version is refused. + Long: `Update the opentaint binary in place to the latest release, or to the optional version argument. Only upgrades are supported. Downgrading to an older version is refused. Homebrew and npm installations print the matching package-manager command instead of updating in place. Pass --check to report the latest version without downloading, or --yes to skip the confirmation prompt. diff --git a/docs/usage.md b/docs/usage.md index fcaf3ad2f9..ac216644f5 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -129,7 +129,7 @@ opentaint health --rules opentaint health --analyzer ``` -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`. +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 | |------|-------------| From dbe650ad4b100aa59321ddb8d275241df8b6889a Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Mon, 24 Aug 2026 01:32:14 +0200 Subject: [PATCH 04/36] style(docs): drop the remaining semicolons in usage.md The four spots predate this branch (the external-methods paragraph, the summary section intro, and the global --color bullet). Rewritten as separate sentences to match the no-semicolon prose rule. --- docs/usage.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index ac216644f5..00089ab0d7 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -117,7 +117,7 @@ These flags are to work with custom approximations: | `--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 @@ -190,9 +190,9 @@ opentaint scan --project-model ./my-project-model ### 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 | @@ -267,6 +267,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. From 311bf5e0b82b5634c1b33d4eb26858330d8007f1 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Mon, 24 Aug 2026 03:16:36 +0200 Subject: [PATCH 05/36] fix(cli): keep values from both spellings of renamed flags, quote shell metacharacters Registering a renamed flag and its deprecated alias as two stock pflag StringArrayVar flags bound to one slice made the alias's first value replace whatever the new spelling had already collected, silently dropping models from the analysis. The two spellings now share an appending value type, so mixed invocations accumulate in command-line order. Applies to --passthrough-models and --java-models on scan and the test-run commands. shellQuote quoted only whitespace and quote characters, so a suggested re-run containing a glob or a variable was mangled by the shell on paste. It now quotes anything outside the shlex-style inert set. --- cli/cmd/dry_run.go | 17 ++++++++++-- cli/cmd/flag_alias.go | 42 ++++++++++++++++++++++++++++ cli/cmd/flag_alias_test.go | 56 ++++++++++++++++++++++++++++++++++++++ cli/cmd/rerun_test.go | 28 +++++++++++++++++++ cli/cmd/scan.go | 8 ++---- cli/cmd/test.go | 4 +-- cli/cmd/test_rule_run.go | 4 +-- 7 files changed, 145 insertions(+), 14 deletions(-) create mode 100644 cli/cmd/flag_alias.go create mode 100644 cli/cmd/flag_alias_test.go diff --git a/cli/cmd/dry_run.go b/cli/cmd/dry_run.go index 39d483a556..914db0609c 100644 --- a/cli/cmd/dry_run.go +++ b/cli/cmd/dry_run.go @@ -34,10 +34,23 @@ func rerunWithoutDryRun() string { } // shellQuote single-quotes an argument that would break when copy-pasted into -// a shell (spaces, quotes). Plain arguments pass through unchanged. +// 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.ContainsAny(arg, " \t'\"") { + 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/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/rerun_test.go b/cli/cmd/rerun_test.go index 310b263b6d..2d2291f24a 100644 --- a/cli/cmd/rerun_test.go +++ b/cli/cmd/rerun_test.go @@ -113,3 +113,31 @@ func TestRetrySuggestion(t *testing.T) { 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/scan.go b/cli/cmd/scan.go index 85bc267dd2..4f266d8bbf 100644 --- a/cli/cmd/scan.go +++ b/cli/cmd/scan.go @@ -158,13 +158,9 @@ func addScanFlags(cmd *cobra.Command) { 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-models", nil, "Pass-through models: a YAML file or a directory of them (repeatable)") - cmd.Flags().StringArrayVar(&scanFlags.PassthroughApproximations, "passthrough-approximations", nil, "Pass-through models: a YAML file or a directory of them (repeatable)") - _ = cmd.Flags().MarkDeprecated("passthrough-approximations", "use --passthrough-models") + 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, "java-models", nil, "Java dataflow models: a compiled class directory or a Java source directory (repeatable)") - cmd.Flags().StringArrayVar(&scanFlags.DataflowApproximations, "dataflow-approximations", nil, "Java dataflow models: a compiled class directory or a Java source directory (repeatable)") - _ = cmd.Flags().MarkDeprecated("dataflow-approximations", "use --java-models") + 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") } diff --git a/cli/cmd/test.go b/cli/cmd/test.go index 8598c601b6..05ea6d546f 100644 --- a/cli/cmd/test.go +++ b/cli/cmd/test.go @@ -40,7 +40,5 @@ func addTestRunFlags(cmd *cobra.Command, outputDir *string, timeout *time.Durati cmd.Flags().StringVarP(outputDir, "output", "o", "", "Directory for test-result.json and test-results.sarif") 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)") - cmd.Flags().StringArrayVar(dataflow, "java-models", nil, "Java dataflow models: a compiled class directory or a Java source directory (repeatable)") - cmd.Flags().StringArrayVar(dataflow, "dataflow-approximations", nil, "Java dataflow models: a compiled class directory or a Java source directory (repeatable)") - _ = cmd.Flags().MarkDeprecated("dataflow-approximations", "use --java-models") + 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_rule_run.go b/cli/cmd/test_rule_run.go index f6a1492a8e..55e9ddc7bf 100644 --- a/cli/cmd/test_rule_run.go +++ b/cli/cmd/test_rule_run.go @@ -206,7 +206,5 @@ func init() { 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-models", nil, "Pass-through models: a YAML file or a directory of them (repeatable)") - testRuleRunCmd.Flags().StringArrayVar(&testRulesPassthrough, "passthrough-approximations", nil, "Pass-through models: a YAML file or a directory of them (repeatable)") - _ = testRuleRunCmd.Flags().MarkDeprecated("passthrough-approximations", "use --passthrough-models") + addRenamedStringArrayFlag(testRuleRunCmd.Flags(), &testRulesPassthrough, "passthrough-models", "passthrough-approximations", "Pass-through models: a YAML file or a directory of them (repeatable)") } From 134146db2b1d0f731b6727a8bc70313ba55fbe35 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Mon, 24 Aug 2026 10:43:32 +0200 Subject: [PATCH 06/36] docs(cli): rework the command help in simplified technical English Every command's Long text now follows ASD-STE100 style: short sentences, one instruction per sentence, active voice, and simple words. Every command has an Examples section, and each Examples section ends with recipes that show complete use cases as command sequences, from the first scan on a new machine to the rule-test edit loop. The summary, prune, and reachability Shorts are simplified to match, and the docs tables that quote them are synced. --- cli/cmd/compile.go | 17 +++++++++------ cli/cmd/health.go | 20 +++++++++++------ cli/cmd/project.go | 22 ++++++++++++------- cli/cmd/prune.go | 22 +++++++++++-------- cli/cmd/pull.go | 14 +++++++----- cli/cmd/root.go | 7 ++++-- cli/cmd/scan.go | 25 ++++++++++++++------- cli/cmd/summary.go | 26 ++++++++++++---------- cli/cmd/test.go | 25 +++++++++++++++------ cli/cmd/test_approximation_run.go | 18 ++++++++++------ cli/cmd/test_init.go | 36 ++++++++++++++++++++----------- cli/cmd/test_rule_reachability.go | 24 ++++++++++++--------- cli/cmd/test_rule_run.go | 18 ++++++++++------ cli/cmd/update.go | 18 ++++++++++------ docs/installation.md | 2 +- docs/usage.md | 4 ++-- 16 files changed, 190 insertions(+), 108 deletions(-) diff --git a/cli/cmd/compile.go b/cli/cmd/compile.go index dfec1651f4..8da1600dcf 100644 --- a/cli/cmd/compile.go +++ b/cli/cmd/compile.go @@ -42,18 +42,23 @@ var compileCmd = &cobra.Command{ Use: "compile ", Short: "Compile a project into a reusable project model", Args: cobra.ExactArgs(1), // require exactly one argument - Long: `OpenTaint detects the build system, resolves the project's modules and dependencies, and compiles the project into a reusable 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. -The project argument is the path to the project root and is required. Pass --output to name the project model directory to create. It must not already exist. +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. -The project model is written to the --output directory and can be reused by later scans without rebuilding. +Later scans can use the model without a new build. This makes repeated scans fast. -Run opentaint pull once before your first compile to fetch the toolchain. Analyze the resulting project model with opentaint scan --project-model.`, +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 - # Validate inputs without compiling - opentaint compile . -o ./model --dry-run`, + # 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] diff --git a/cli/cmd/health.go b/cli/cmd/health.go index df3f1b2dc3..4878de84dd 100644 --- a/cli/cmd/health.go +++ b/cli/cmd/health.go @@ -28,19 +28,25 @@ type healthComponent struct { var healthCmd = &cobra.Command{ Use: "health", Short: "Show dependency paths and report missing components", - Long: `Show the on-disk paths for the autobuilder, analyzer, built-in rules, and Java runtime, and report whether each component is present. + 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. -Select components with --autobuilder, --analyzer, --rules, or --runtime. With no flag, all four are reported. When exactly one component is selected, only its path is printed, which suits scripting. Only the built-in rules are fetched on demand. No other artifact is downloaded. +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 command exits non-zero when any selected component is missing. Download the missing components with opentaint pull.`, - Example: ` # Report all components and their paths +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 scripting + # Print only the analyzer JAR path, for a script opentaint health --analyzer - # Check the Java runtime - opentaint health --runtime`, + # 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() diff --git a/cli/cmd/project.go b/cli/cmd/project.go index 5f3e1d5d69..ed7236c3fe 100644 --- a/cli/cmd/project.go +++ b/cli/cmd/project.go @@ -220,21 +220,27 @@ var ( var projectCmd = &cobra.Command{ Use: "project", Short: "Create a project model from precompiled JARs or classes", - Long: `Create a project model directly from precompiled JARs or classes, without running a build. OpenTaint inspects the supplied classpath, detects the modules and dependencies, and writes a project.yaml describing the project for later analysis. Use this when you already have compiled artifacts. To build a model from sources, use opentaint compile instead. + 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. -All inputs are supplied as flags. Provide --source-root for the project sources, one or more --classpath entries for the compiled classes or JARs, and one or more --package names to include. Add --dependency for extra JAR files on the compile classpath. +Use this command when you have compiled artifacts but no build. To build a model from sources, use "opentaint compile". -The project model is written to the required --output directory, which must not already exist, and contains the generated project.yaml. +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. -Run opentaint pull once beforehand to fetch the autobuilder. Scan the generated model with opentaint scan --project-model .`, - Example: ` # Generate a project model from a compiled JAR +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 extra dependency JARs to the classpath + # Add more dependency JARs to the classpath opentaint project --source-root ./src --classpath ./app.jar --dependency ./lib.jar --package com.example -o ./model - # Validate the inputs without generating anything - opentaint project --source-root ./src --classpath ./app.jar --package com.example -o ./model --dry-run`, + # 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). diff --git a/cli/cmd/prune.go b/cli/cmd/prune.go index 1fc24300b2..aab398e193 100644 --- a/cli/cmd/prune.go +++ b/cli/cmd/prune.go @@ -57,23 +57,27 @@ func resolveCategories() (utils.PruneCategory, error) { var pruneCmd = &cobra.Command{ Use: "prune", - Short: "Remove stale downloaded artifacts from the cache", - Long: `Remove stale downloaded artifacts from the local cache (~/.opentaint): superseded analyzer and autobuilder JARs, old rules, JDK and JRE versions that no longer match the configured one, and cached project models. + 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. -Select categories with --artifacts, --rules, --jdk, --models, --logs, or --install. Without a category flag, prune removes artifacts, rules, jdk, and models. The --all flag removes everything, including logs and install-tier artifacts, and cannot be combined with a specific category flag. +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. -Preview the deletions with --dry-run, and skip the confirmation prompt with --yes. Restore install-tier artifacts afterward with opentaint pull.`, - Example: ` # Prune the default categories after confirming +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 - # Prune only old JDK and JRE versions + # Remove only the old JDK and JRE versions opentaint prune --jdk - # Prune everything, including logs and install-tier artifacts + # Remove everything, with logs and install-tier artifacts included opentaint prune --all - # Preview what would be deleted without deleting - opentaint prune --dry-run`, + # 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 { diff --git a/cli/cmd/pull.go b/cli/cmd/pull.go index d73b5fa38f..8dfbb3e397 100644 --- a/cli/cmd/pull.go +++ b/cli/cmd/pull.go @@ -18,16 +18,20 @@ import ( var pullCmd = &cobra.Command{ Use: "pull", Short: "Download the analysis toolchain and Java runtime", - Long: `Download the analyzer, autobuilder, built-in rules, go-ssa-server, and a bundled Java runtime into the local cache. OpenTaint uses these to build and analyze projects without further network access. + Long: `Download the toolchain into the local cache. The toolchain contains the analyzer, the autobuilder, the built-in rules, the go-ssa-server, and a Java runtime. After the download, OpenTaint can build and scan projects without network access. -When bundled artifacts from a release archive are present, they are used directly instead of downloading. +If a release archive supplied bundled artifacts, OpenTaint uses them. They are not downloaded again. -Run opentaint pull once before your first scan. Remove stale downloads later with opentaint prune.`, +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 - # Fetch a different Java runtime version - opentaint pull --java-version 17`, + # 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). diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 85a2239267..19a90e4b79 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -35,9 +35,12 @@ var updateHintCh = make(chan string, 1) var rootCmd = &cobra.Command{ Use: "opentaint", Short: "Find vulnerabilities in source code with taint analysis", - Long: `OpenTaint finds vulnerabilities by tracing tainted data from untrusted sources to sensitive sinks. Java, Kotlin, and Go projects are supported. + Long: `OpenTaint finds vulnerabilities in your code. It follows tainted data from untrusted sources to dangerous sinks. Java, Kotlin, and Go projects are supported. -Run opentaint pull once to fetch the toolchain, opentaint scan to analyze a project, and opentaint summary to re-inspect a SARIF report.`, +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, diff --git a/cli/cmd/scan.go b/cli/cmd/scan.go index 4f266d8bbf..7b7af94afe 100644 --- a/cli/cmd/scan.go +++ b/cli/cmd/scan.go @@ -75,29 +75,38 @@ var scanCmd = &cobra.Command{ Use: "scan [source-path]", Short: "Scan a project for vulnerabilities", Args: cobra.MaximumNArgs(1), - Long: `Scan a project for vulnerabilities. OpenTaint detects the build system, builds the project, and runs taint analysis over the result. + Long: `Scan a project and find vulnerabilities. OpenTaint finds the build system, builds the project, and does a taint analysis. -The optional source-path argument is the project root and defaults to the current directory. Pass --project-model to analyze a pre-compiled project model instead of building. The source-path argument and --project-model are mutually exclusive. +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. -Findings are written as a SARIF report to --output, or into the project model directory when unset, and summarized on completion. +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. -Run opentaint pull once before your first scan to fetch the toolchain. Re-inspect a report later with opentaint summary. +Before your first scan, run "opentaint pull" one time. To read a report again later, use "opentaint summary". ` + scanExitCodesHelp("Scan completed"), Example: ` # Scan the current directory with the built-in rules opentaint scan . - # Scan a project and write a SARIF report + # Scan a project and write the report to a known path opentaint scan ./my-app -o report.sarif - # Analyze a pre-compiled project model instead of building + # Scan a project model that is already compiled opentaint scan --project-model ./model -o report.sarif - # Run a custom ruleset and report only errors + # Use your own rules and show only errors opentaint scan . --ruleset ./rules --severity error -o report.sarif # Give a large project more time and memory - opentaint scan . --timeout 30m --max-memory 16G -o report.sarif`, + opentaint scan . --timeout 30m --max-memory 16G -o report.sarif + + # Recipe: first scan on a new machine + opentaint pull + opentaint scan . -o report.sarif + opentaint summary report.sarif --show-findings + + # Recipe: build one time, then scan many times + opentaint compile ./my-app -o ./model + opentaint scan --project-model ./model -o report.sarif`, Annotations: map[string]string{"PrintConfig": "true"}, Run: func(cmd *cobra.Command, args []string) { if scanFlags.DebugRunAnalysisOnSelectedEntryPoints != "" { diff --git a/cli/cmd/summary.go b/cli/cmd/summary.go index 7be7d9f7db..9b6da780ce 100644 --- a/cli/cmd/summary.go +++ b/cli/cmd/summary.go @@ -10,29 +10,33 @@ import ( // summaryCmd represents the summary command var summaryCmd = &cobra.Command{ Use: "summary ", - Short: "Summarize a SARIF report", + Short: "Show a summary of a SARIF report", Args: cobra.ExactArgs(1), // require exactly one argument - Long: `Summarize a SARIF report on the terminal. OpenTaint counts the findings by severity, groups them, and shows which rules ran and which produced results. + 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 required positional argument is the path to a SARIF report, such as one written by opentaint scan or opentaint test. Pass --show-findings to list every finding. Narrow the listing with --severity, --rule-id, or --path, and expand code flows with --show-code-snippets and --verbose-flow. +The sarif-report argument is the path to a SARIF report. It is required. Use a report from "opentaint scan" or "opentaint test". -The report is read only: the summary and any findings are printed to the terminal and nothing is written to disk. +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. -Run opentaint scan to produce the report this command reads.`, - Example: ` # Print a summary of a report +This command only reads the report. It does not write files.`, + Example: ` # Show a summary of a report opentaint summary report.sarif - # List every finding with its location + # Show each finding with its location opentaint summary report.sarif --show-findings - # Show only error-level findings + # Show only the error-level findings opentaint summary report.sarif --show-findings --severity error - # Group the listing by rule + # Group the findings by rule opentaint summary report.sarif --show-findings --group-by rule-id - # Trace one rule with full code flow and snippets - opentaint summary report.sarif --show-findings --rule-id --show-code-snippets --verbose-flow`, + # 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 + + # 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 { diff --git a/cli/cmd/test.go b/cli/cmd/test.go index 05ea6d546f..6b3b25a55c 100644 --- a/cli/cmd/test.go +++ b/cli/cmd/test.go @@ -9,25 +9,38 @@ import ( var testCmd = &cobra.Command{ Use: "test", Short: "Create and run rule and approximation tests", - Long: `Create, run, and debug rule and approximation tests. Rule tests check detection rules against annotated sample projects. Approximation tests check dataflow approximations the same way. + 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. -Scaffold a project with init, compile it with opentaint compile, then run the samples with test rule run or test approximation run. Use test rule reachability to debug why a single rule does or does not fire.`, +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 taint detection-rule tests. Rule tests check that a rule fires on positive samples and stays silent on negative ones. + 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". -Scaffold a test project with test rule init, compile it with opentaint compile, then run the samples with test rule run. Use test rule reachability to trace why a single rule does or does not fire.`, +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 dataflow-approximation tests. Approximation tests check that a dataflow approximation carries taint from source to sink across your samples. + 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. -Scaffold a test project with test approximation init, compile it with opentaint compile, then run the samples with test approximation run, supplying the approximation under test with --java-models.`, +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() { diff --git a/cli/cmd/test_approximation_run.go b/cli/cmd/test_approximation_run.go index 6bb796a3a6..9f688c1f47 100644 --- a/cli/cmd/test_approximation_run.go +++ b/cli/cmd/test_approximation_run.go @@ -18,20 +18,24 @@ var ( var testApproximationRunCmd = &cobra.Command{ Use: "run ", Short: "Run dataflow-approximation tests on a compiled project model", - Long: `Run the samples declared in rule-test.yaml with the supplied dataflow approximations applied and report which passed. A fixed source-to-sink harness rule is applied automatically. Positive samples reference it by id approximation-rule. + 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. -The project-model argument is a compiled project model directory, produced by opentaint compile. Supply the approximation under test with --java-models. +The project-model argument is a compiled project model directory from "opentaint compile". Give the approximation under test with --java-models. -Results are written as test-result.json and a test-results.sarif report to --output, or to a temporary directory when unset. +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 with opentaint compile before running. Inspect the results afterward with opentaint summary. +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 against a compiled model + Example: ` # Run an approximation test on a compiled model opentaint test approximation run ./approx-test/model --java-models ./approx - # Write results to a directory - opentaint test approximation run ./approx-test/model --java-models ./approx -o ./results`, + # 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-*") diff --git a/cli/cmd/test_init.go b/cli/cmd/test_init.go index c947ad9aa6..0686204fb3 100644 --- a/cli/cmd/test_init.go +++ b/cli/cmd/test_init.go @@ -19,21 +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 for detection-rule tests. The sinks project tests sink rules against a generic taint source. The sources project tests source rules against a generic taint sink. + 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. -The output-dir argument is the parent directory the projects are created under. By default both are scaffolded, as output-dir/sinks and output-dir/sources. Pass --sinks-only or --sources-only to create just one. Use --dependency to add compile-only Maven dependencies for the samples. +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. -Each project ships a rule-test.yaml where you declare the positive and negative samples, plus a Taint.java source and sink harness. +Each project contains a rule-test.yaml file and a Taint.java harness. Declare your positive and negative samples in rule-test.yaml. -After editing rule-test.yaml, compile the project with opentaint compile and run the samples with opentaint test rule run.`, - Example: ` # Scaffold both the sinks and sources test projects +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 - # Scaffold only the sinks project + # 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 `, + 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 { @@ -68,18 +73,23 @@ After editing rule-test.yaml, compile the project with opentaint compile and run var testApproximationInitCmd = &cobra.Command{ Use: "init ", Short: "Create a dataflow-approximation test project", - Long: `Create a Gradle test project for dataflow-approximation tests. The project pins a fixed source-to-sink rule that the samples are checked against. + 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 the project is created in. Use --dependency to add compile-only Maven dependencies for the samples. The approximation under test is not baked in. Supply it at run time with --java-models. +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 ships a rule-test.yaml where you declare the positive and negative samples, plus a Taint.java source and sink and the fixed approximation-rule.yaml. +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. -After editing rule-test.yaml, compile the project with opentaint compile and run the samples with opentaint test approximation run.`, - Example: ` # Scaffold an approximation test project +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 # Add a compile-only dependency for the samples - opentaint test approximation init ./approx-test --dependency `, + opentaint test approximation init ./approx-test --dependency + + # 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 { diff --git a/cli/cmd/test_rule_reachability.go b/cli/cmd/test_rule_reachability.go index 5d61019b28..20714bdf83 100644 --- a/cli/cmd/test_rule_reachability.go +++ b/cli/cmd/test_rule_reachability.go @@ -8,27 +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 a single rule and write a fact-reachability SARIF report so you can see why that rule does or does not fire. Referenced library source and sink rules are collected and analyzed automatically. + 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. -The rule-id argument selects the one rule to trace. The optional source-path argument is the project root and defaults to the current directory. Pass --project-model to trace a pre-compiled model instead. The source-path argument and --project-model are mutually exclusive. Use --entry-points to start the analysis from a specific method. +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 is written as debug-ifds-fact-reachability.sarif next to the main SARIF report. +The report name is debug-ifds-fact-reachability.sarif. It is written adjacent to the main SARIF report. -Run opentaint pull once before your first run to fetch the toolchain. Open the reachability report afterward with opentaint summary. +Before the first run, run "opentaint pull" one time. To read the report, use "opentaint summary". ` + scanExitCodesHelp("Reachability analysis completed"), - Example: ` # Trace a rule against the current directory + Example: ` # Show why a rule does or does not fire on the current directory opentaint test rule reachability . - # Trace a rule against a pre-compiled project model + # Examine a rule on a compiled project model opentaint test rule reachability --project-model ./model - # Start the analysis from a specific entry-point method + # Start the analysis from one entry-point method opentaint test rule reachability . --entry-points com.example.App#main - # Validate inputs without compiling or scanning - opentaint test rule reachability . --dry-run`, + # 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) { diff --git a/cli/cmd/test_rule_run.go b/cli/cmd/test_rule_run.go index 55e9ddc7bf..15a1e3c4b9 100644 --- a/cli/cmd/test_rule_run.go +++ b/cli/cmd/test_rule_run.go @@ -26,23 +26,27 @@ var ( var testRuleRunCmd = &cobra.Command{ Use: "run ", Short: "Run detection-rule tests on a compiled project model", - Long: `Run detection rules against the samples declared in rule-test.yaml and report which passed. The built-in rules are always included. + 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, produced by opentaint compile. Add your own rules with --ruleset, narrow the run to specific rules with --rule-id, and apply models with --java-models or --passthrough-models. +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. -Results are written as test-result.json and a test-results.sarif report to --output, or to a temporary directory when unset. +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 with opentaint compile before running. Inspect the results afterward with opentaint summary. +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 against a compiled model + Example: ` # Run the built-in rules on a compiled model opentaint test rule run ./rule-tests/sinks/model - # Test a custom ruleset and write results to a directory + # 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 `, + 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{ diff --git a/cli/cmd/update.go b/cli/cmd/update.go index 19d83a8e46..6ef0484d9b 100644 --- a/cli/cmd/update.go +++ b/cli/cmd/update.go @@ -20,19 +20,25 @@ var ( var updateCmd = &cobra.Command{ Use: "update [version]", Short: "Update opentaint to the latest version", - Long: `Update the opentaint binary in place to the latest release, or to the optional version argument. Only upgrades are supported. Downgrading to an older version is refused. + 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. -Homebrew and npm installations print the matching package-manager command instead of updating in place. Pass --check to report the latest version without downloading, or --yes to skip the confirmation prompt. +If opentaint was installed with Homebrew or npm, the command does not change the binary. It shows the correct package-manager command. -After a successful update, remove superseded artifacts with opentaint prune.`, +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 - # Check for a newer version without installing + # See if a newer version is available, without a download opentaint update --check - # Update to a specific version without prompting - opentaint update 1.2.3 --yes`, + # 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 diff --git a/docs/installation.md b/docs/installation.md index 67ecb6ac87..34fdf59711 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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/usage.md b/docs/usage.md index 00089ab0d7..bbf0f46006 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -87,7 +87,7 @@ Use [CodeChecker](https://github.com/Ericsson/codechecker) for advanced result m | `opentaint test approximation` | Create and run dataflow-approximation tests | | `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 @@ -155,7 +155,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 From f79367d6a15c853c2999e0893a98fbff7f32b5e4 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Tue, 25 Aug 2026 03:44:18 +0200 Subject: [PATCH 07/36] docs(cli): keep Go support out of this branch's help and docs The help rewrite mentioned Go in the root and pull help, and the docs mentioned go.mod, the go-ssa-server, and the Go toolchain. Go support lands in its own branch. That branch states it, in its own words. --- cli/cmd/pull.go | 2 +- cli/cmd/root.go | 2 +- docs/installation.md | 2 +- docs/usage.md | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cli/cmd/pull.go b/cli/cmd/pull.go index 8dfbb3e397..503ab24428 100644 --- a/cli/cmd/pull.go +++ b/cli/cmd/pull.go @@ -18,7 +18,7 @@ import ( var pullCmd = &cobra.Command{ Use: "pull", 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, the go-ssa-server, and a Java runtime. After the download, OpenTaint can build and scan projects without network access. + 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. diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 19a90e4b79..e457002dad 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -35,7 +35,7 @@ var updateHintCh = make(chan string, 1) var rootCmd = &cobra.Command{ 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, Kotlin, and Go projects are supported. + 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. diff --git a/docs/installation.md b/docs/installation.md index 34fdf59711..8d76abffef 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,6 +1,6 @@ # Installation -**Prerequisites:** Same build requirements as your project (Maven or Gradle for Java/Kotlin, the Go toolchain for Go modules). 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) diff --git a/docs/usage.md b/docs/usage.md index bbf0f46006..7a60d4812b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -91,7 +91,7 @@ Use [CodeChecker](https://github.com/Ericsson/codechecker) for advanced result m ### opentaint scan -Automatically detects the project's build system (Maven, Gradle, or go.mod), builds the project, and runs taint analysis over the result. 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. @@ -175,7 +175,7 @@ Rule and approximation test runs write `test-result.json` and `test-results.sari ### opentaint compile -Compiles Java, Kotlin, and Go projects and generates project models for analysis. Useful when you want to separate compilation from scanning or need to inspect the project model. +Compiles Java and Kotlin projects and generates project models for analysis. Useful when you want to separate compilation from scanning or need to inspect the project model. ```bash opentaint compile --output ./my-project-model /path/to/project From a86bb2bb137a104f070be8ae77671548357a84eb Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 18:24:31 +0200 Subject: [PATCH 08/36] feat(cli): SARIF baseline comparison and suppression primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the report-level engine for baselines and suppressions: - identity.go: fingerprint-key resolution (source/sink hash by default), identity lookup and git-style prefix resolution - baseline.go: new/unchanged/updated/absent classification, baselineState and baselineGuid application, run GUID stamping - suppress.go: the read rule for suppression status, accept/defer/unsuppress, verbatim inheritance from a baseline, and summary counters - save.go: atomic report writing PropertyBag now preserves unknown keys as raw JSON. It previously modelled only "tags", so any read-modify-write of a report silently dropped the rest of every property bag — which the round-trip test caught. --- cli/internal/sarif/baseline.go | 178 +++++++++++++++++ cli/internal/sarif/baseline_test.go | 253 ++++++++++++++++++++++++ cli/internal/sarif/identity.go | 98 +++++++++ cli/internal/sarif/identity_test.go | 130 ++++++++++++ cli/internal/sarif/property_bag.go | 77 ++++++++ cli/internal/sarif/property_bag_test.go | 100 ++++++++++ cli/internal/sarif/sarif.go | 6 + cli/internal/sarif/save.go | 47 +++++ cli/internal/sarif/save_test.go | 154 +++++++++++++++ cli/internal/sarif/suppress.go | 216 ++++++++++++++++++++ cli/internal/sarif/suppress_test.go | 252 +++++++++++++++++++++++ 11 files changed, 1511 insertions(+) create mode 100644 cli/internal/sarif/baseline.go create mode 100644 cli/internal/sarif/baseline_test.go create mode 100644 cli/internal/sarif/identity.go create mode 100644 cli/internal/sarif/identity_test.go create mode 100644 cli/internal/sarif/property_bag.go create mode 100644 cli/internal/sarif/property_bag_test.go create mode 100644 cli/internal/sarif/save.go create mode 100644 cli/internal/sarif/save_test.go create mode 100644 cli/internal/sarif/suppress.go create mode 100644 cli/internal/sarif/suppress_test.go diff --git a/cli/internal/sarif/baseline.go b/cli/internal/sarif/baseline.go new file mode 100644 index 0000000000..efd32d6ecc --- /dev/null +++ b/cli/internal/sarif/baseline.go @@ -0,0 +1,178 @@ +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 + + // 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 + // Absent lists the baseline results that no longer appear — the fixed + // findings. They are reported, never written back into the current report. + Absent []*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). +func (c *Comparison) StateOf(r *Result) BaselineState { + if c == nil { + return "" + } + return c.states[r] +} + +// CompareToBaseline classifies every result in current against baseline, using +// key as the identity fingerprint. Results that match are additionally compared +// on the full-trace fingerprint to tell "unchanged" from "updated". +// +// A baseline that holds results but none carrying key is rejected: silently +// classifying everything as new would hide exactly the findings a baseline +// exists to remember. +func CompareToBaseline(current, baseline *Report, key string) (*Comparison, error) { + baselineResults := baseline.Results() + + byIdentity := make(map[string][]*Result, len(baselineResults)) + for _, r := range baselineResults { + id, ok := Identity(r, key) + if !ok { + continue + } + byIdentity[id] = append(byIdentity[id], r) + } + if len(baselineResults) > 0 && len(byIdentity) == 0 { + return nil, fmt.Errorf( + "no result in the baseline carries the %q fingerprint; "+ + "it was produced with a different fingerprint key or without fingerprints", key) + } + + cmp := &Comparison{ + states: make(map[*Result]BaselineState), + Counts: make(map[BaselineState]int), + BaselineGUID: baseline.RunGUID(), + } + + matched := make(map[string]bool, len(byIdentity)) + for _, r := range current.Results() { + id, ok := Identity(r, key) + if !ok { + cmp.Unmatchable++ + continue + } + + previous, found := byIdentity[id] + if !found { + cmp.states[r] = New + cmp.Counts[New]++ + continue + } + + matched[id] = true + state := Updated + if sameTrace(r, previous) { + state = Unchanged + } + cmp.states[r] = state + cmp.Counts[state]++ + } + + for id, results := range byIdentity { + if matched[id] { + continue + } + cmp.Absent = append(cmp.Absent, results...) + } + cmp.Counts[Absent] = len(cmp.Absent) + + return cmp, nil +} + +// sameTrace reports whether the current result's full-trace fingerprint equals +// that of any baseline result sharing its identity. A missing trace fingerprint +// on either side counts as unchanged: the finer comparison is unavailable, and +// claiming "updated" on missing data would be noise. +func sameTrace(current *Result, previous []*Result) bool { + currentTrace, ok := Identity(current, TraceFingerprintKey) + if !ok { + return true + } + for _, p := range previous { + previousTrace, ok := Identity(p, TraceFingerprintKey) + if !ok || previousTrace == currentTrace { + return true + } + } + return false +} + +// Apply writes the comparison into the report: result.baselineState on every +// matched result, and run.baselineGuid on every run when the baseline had a +// guid to cite. Unmatchable results are left untouched. +func (c *Comparison) Apply(report *Report) { + for _, r := range report.Results() { + state, ok := c.states[r] + if !ok { + continue + } + value := state + r.BaselineState = &value + } + if c.BaselineGUID == "" { + return + } + for i := range report.Runs { + guid := c.BaselineGUID + report.Runs[i].BaselineGUID = &guid + } +} + +// 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..61fa0150d5 --- /dev/null +++ b/cli/internal/sarif/baseline_test.go @@ -0,0 +1,253 @@ +package sarif + +import ( + "regexp" + "testing" +) + +// fp builds a partialFingerprints map from a source/sink hash and a trace hash. +func fp(sourceSink, trace string) map[string]string { + m := map[string]string{} + if sourceSink != "" { + m[SourceSinkFingerprintKey] = sourceSink + } + if trace != "" { + m[TraceFingerprintKey] = trace + } + return m +} + +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, SourceSinkFingerprintKey) + 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 TestCompareWithTraceKeyNeverReportsUpdated(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, TraceFingerprintKey) + 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 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, SourceSinkFingerprintKey) + 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, SourceSinkFingerprintKey) + 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, SourceSinkFingerprintKey) + 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{}, + SourceSinkFingerprintKey, + ) + 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, SourceSinkFingerprintKey) + 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{{}}}, + SourceSinkFingerprintKey, + ); 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, SourceSinkFingerprintKey) + 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, SourceSinkFingerprintKey) + 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) { + baseline := makeReport(makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a"))) + current := makeReport(makeResult("nofp", Error, "b.java", 2, nil)) + + cmp, err := CompareToBaseline(current, baseline, SourceSinkFingerprintKey) + 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) + } +} + +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) + } +} diff --git a/cli/internal/sarif/identity.go b/cli/internal/sarif/identity.go new file mode 100644 index 0000000000..a65f93e4db --- /dev/null +++ b/cli/internal/sarif/identity.go @@ -0,0 +1,98 @@ +package sarif + +import ( + "fmt" + "sort" + "strings" +) + +// Fingerprint keys emitted by the analyzer under result.partialFingerprints. +// +// TraceFingerprintKey hashes the rule id, the sink, and every location on every +// trace: an exact identity that changes whenever anything on the flow path +// moves. SourceSinkFingerprintKey hashes the rule id, the sink, and the source +// (first) location of each trace, so it survives refactoring of the +// intermediate call path. +const ( + TraceFingerprintKey = "vulnerabilityWithTraceHash/v1" + SourceSinkFingerprintKey = "vulnerabilitySourceSinkHash/v1" +) + +// DefaultIdentityKey is the fingerprint key used to decide whether a finding in +// one report is "the same finding" as one in another report. The source/sink +// hash is the default because a suppression or baseline entry should survive +// edits to helper methods the flow happens to pass through. +const DefaultIdentityKey = SourceSinkFingerprintKey + +// ResolveIdentityKey normalizes a user-supplied identity key, falling back to +// DefaultIdentityKey when unset. Any key is accepted — a report may carry +// fingerprints this build does not know about — but a blank one is rejected +// rather than silently matching nothing. +func ResolveIdentityKey(key string) (string, error) { + if key == "" { + return DefaultIdentityKey, nil + } + trimmed := strings.TrimSpace(key) + if trimmed == "" { + return "", fmt.Errorf("fingerprint key must not be blank") + } + return trimmed, nil +} + +// 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 single result whose identity fingerprint starts with +// prefix, git-style. An empty, unmatched, or ambiguous prefix is an error: a +// suppression must name exactly one finding, never "whichever matched first". +func ResolvePrefix(report *Report, key, prefix string) (*Result, error) { + if prefix == "" { + return nil, fmt.Errorf("fingerprint prefix must not be empty") + } + + var matches []*Result + var values []string + for _, r := range report.Results() { + fp, ok := Identity(r, key) + if !ok || !strings.HasPrefix(fp, prefix) { + continue + } + matches = append(matches, r) + values = append(values, fp) + } + + switch len(matches) { + case 0: + return nil, fmt.Errorf("no finding matches fingerprint %q (key %s)", prefix, key) + case 1: + return matches[0], nil + default: + sort.Strings(values) + return nil, fmt.Errorf("fingerprint %q is ambiguous, it matches %d findings: %s", + prefix, len(matches), strings.Join(values, ", ")) + } +} diff --git a/cli/internal/sarif/identity_test.go b/cli/internal/sarif/identity_test.go new file mode 100644 index 0000000000..930ee07fb1 --- /dev/null +++ b/cli/internal/sarif/identity_test.go @@ -0,0 +1,130 @@ +package sarif + +import ( + "strings" + "testing" +) + +func TestResolveIdentityKeyDefaultsToSourceSink(t *testing.T) { + key, err := ResolveIdentityKey("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if key != SourceSinkFingerprintKey { + t.Errorf("got %q, want %q", key, SourceSinkFingerprintKey) + } +} + +func TestResolveIdentityKeyAcceptsExplicitKey(t *testing.T) { + key, err := ResolveIdentityKey(TraceFingerprintKey) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if key != TraceFingerprintKey { + t.Errorf("got %q, want %q", key, TraceFingerprintKey) + } +} + +func TestResolveIdentityKeyRejectsBlank(t *testing.T) { + if _, err := ResolveIdentityKey(" "); err == nil { + t.Error("expected error for whitespace-only key") + } +} + +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{SourceSinkFingerprintKey: "q3Vf9k2nAAA"}), + makeResult("b", Error, "b.java", 2, map[string]string{SourceSinkFingerprintKey: "8bc1d2xxBBB"}), + ) + r, err := ResolvePrefix(report, SourceSinkFingerprintKey, "q3Vf9k") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if *r.RuleID != "a" { + t.Errorf("resolved to rule %q, want a", *r.RuleID) + } +} + +func TestResolvePrefixExactValueMatches(t *testing.T) { + report := makeReport( + makeResult("a", Error, "a.java", 1, map[string]string{SourceSinkFingerprintKey: "q3Vf9k2nAAA"}), + ) + if _, err := ResolvePrefix(report, SourceSinkFingerprintKey, "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{SourceSinkFingerprintKey: "q3Vf9kAAA"}), + makeResult("b", Error, "b.java", 2, map[string]string{SourceSinkFingerprintKey: "q3Vf9kBBB"}), + ) + _, err := ResolvePrefix(report, SourceSinkFingerprintKey, "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{SourceSinkFingerprintKey: "q3Vf9kAAA"}), + ) + if _, err := ResolvePrefix(report, SourceSinkFingerprintKey, "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{SourceSinkFingerprintKey: "q3Vf9kAAA"}), + ) + if _, err := ResolvePrefix(report, SourceSinkFingerprintKey, ""); err == nil { + t.Error("expected empty prefix to error rather than match everything") + } +} diff --git a/cli/internal/sarif/property_bag.go b/cli/internal/sarif/property_bag.go new file mode 100644 index 0000000000..072af56549 --- /dev/null +++ b/cli/internal/sarif/property_bag.go @@ -0,0 +1,77 @@ +package sarif + +import ( + "bytes" + "encoding/json" + "sort" +) + +// UnmarshalJSON decodes a property bag, lifting "tags" into the typed field and +// keeping every other key as raw JSON in Extra. Raw JSON rather than any: +// re-encoding through map[string]any would reformat numbers and can lose +// precision on integers beyond float64's exact range. +func (p *PropertyBag) UnmarshalJSON(data []byte) error { + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + + p.Tags = nil + p.Extra = nil + + for key, value := range raw { + if key == "tags" { + var tags []string + if err := json.Unmarshal(value, &tags); err == nil { + p.Tags = tags + continue + } + // Not a string array: keep it verbatim rather than dropping it. + } + if p.Extra == nil { + p.Extra = make(map[string]json.RawMessage, len(raw)) + } + p.Extra[key] = value + } + return nil +} + +// MarshalJSON re-emits the bag with its preserved keys. Keys are sorted so that +// rewriting an unchanged report produces byte-identical output. +func (p PropertyBag) MarshalJSON() ([]byte, error) { + keys := make([]string, 0, len(p.Extra)+1) + values := make(map[string]json.RawMessage, len(p.Extra)+1) + + for key, value := range p.Extra { + keys = append(keys, key) + values[key] = value + } + if len(p.Tags) > 0 { + encoded, err := json.Marshal(p.Tags) + if err != nil { + return nil, err + } + if _, clash := values["tags"]; !clash { + keys = append(keys, "tags") + } + values["tags"] = encoded + } + sort.Strings(keys) + + var buf bytes.Buffer + buf.WriteByte('{') + for i, key := range keys { + if i > 0 { + buf.WriteByte(',') + } + encodedKey, err := json.Marshal(key) + if err != nil { + return nil, err + } + buf.Write(encodedKey) + buf.WriteByte(':') + buf.Write(values[key]) + } + buf.WriteByte('}') + return buf.Bytes(), nil +} diff --git a/cli/internal/sarif/property_bag_test.go b/cli/internal/sarif/property_bag_test.go new file mode 100644 index 0000000000..23020a6459 --- /dev/null +++ b/cli/internal/sarif/property_bag_test.go @@ -0,0 +1,100 @@ +package sarif + +import ( + "encoding/json" + "testing" +) + +func TestPropertyBagPreservesUnknownKeys(t *testing.T) { + const in = `{"tags":["CWE-89"],"precision":"high","confidence":0.75,"nested":{"a":[1,2]}}` + var bag PropertyBag + if err := json.Unmarshal([]byte(in), &bag); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(bag.Tags) != 1 || bag.Tags[0] != "CWE-89" { + t.Errorf("tags not decoded: %v", bag.Tags) + } + + out, err := json.Marshal(bag) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var before, after map[string]any + if err := json.Unmarshal([]byte(in), &before); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(out, &after); err != nil { + t.Fatal(err) + } + for k, v := range before { + got, ok := after[k] + if !ok { + t.Errorf("key %q was dropped", k) + continue + } + if toJSON(t, got) != toJSON(t, v) { + t.Errorf("key %q changed: %s -> %s", k, toJSON(t, v), toJSON(t, got)) + } + } +} + +func TestPropertyBagPreservesLargeIntegersExactly(t *testing.T) { + const in = `{"id":9007199254740993}` + var bag PropertyBag + if err := json.Unmarshal([]byte(in), &bag); err != nil { + t.Fatalf("unmarshal: %v", err) + } + out, err := json.Marshal(bag) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(out) != in { + t.Errorf("got %s, want %s", out, in) + } +} + +func TestPropertyBagWithOnlyTags(t *testing.T) { + bag := PropertyBag{Tags: []string{"a", "b"}} + out, err := json.Marshal(bag) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(out) != `{"tags":["a","b"]}` { + t.Errorf("got %s", out) + } +} + +func TestPropertyBagEmptyMarshalsToEmptyObject(t *testing.T) { + out, err := json.Marshal(PropertyBag{}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(out) != `{}` { + t.Errorf("got %s, want {}", out) + } +} + +func TestPropertyBagNonStringTagsAreNotLost(t *testing.T) { + // A malformed bag must still round-trip rather than silently dropping tags. + const in = `{"tags":"not-an-array"}` + var bag PropertyBag + if err := json.Unmarshal([]byte(in), &bag); err != nil { + t.Fatalf("unmarshal: %v", err) + } + out, err := json.Marshal(bag) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(out) != in { + t.Errorf("got %s, want %s", out, in) + } +} + +func toJSON(t *testing.T, v any) string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return string(b) +} diff --git a/cli/internal/sarif/sarif.go b/cli/internal/sarif/sarif.go index 7553c909af..e609be5cb5 100644 --- a/cli/internal/sarif/sarif.go +++ b/cli/internal/sarif/sarif.go @@ -217,9 +217,15 @@ type Address struct { // Key/value pairs that provide additional information about the special locations. // // Key/value pairs that provide additional information about the version control details. +// Property bags are the one open-ended part of the SARIF schema: any key is +// legal. Extra holds every key other than "tags" verbatim so that reading a +// report, modifying it and writing it back never discards tool metadata. See +// property_bag.go for the marshalling. type PropertyBag struct { // A set of distinct strings that provide additional information. Tags []string `json:"tags,omitempty"` + // Every other key in the bag, preserved as raw JSON. + Extra map[string]json.RawMessage `json:"-"` } // A single artifact. In some cases, this artifact might be nested within another artifact. diff --git a/cli/internal/sarif/save.go b/cli/internal/sarif/save.go new file mode 100644 index 0000000000..09494e5230 --- /dev/null +++ b/cli/internal/sarif/save.go @@ -0,0 +1,47 @@ +package sarif + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// SaveReport writes report to path as indented JSON. The write goes to a +// temporary file in the destination directory and is then renamed over path, so +// a crash mid-write can never leave a truncated report behind — which matters +// because triage rewrites reports in place. +func SaveReport(report *Report, path string) error { + data, err := json.MarshalIndent(report, "", " ") + if err != nil { + return fmt.Errorf("failed to encode sarif report: %w", err) + } + data = append(data, '\n') + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + + tmp, err := os.CreateTemp(dir, ".sarif-*.tmp") + if err != nil { + return fmt.Errorf("failed to create temporary report file: %w", err) + } + tmpName := tmp.Name() + defer 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) + } + if err := os.Chmod(tmpName, 0o644); err != nil { + return fmt.Errorf("failed to set report permissions: %w", err) + } + if err := os.Rename(tmpName, path); err != nil { + return fmt.Errorf("failed to replace sarif report: %w", err) + } + return nil +} diff --git a/cli/internal/sarif/save_test.go b/cli/internal/sarif/save_test.go new file mode 100644 index 0000000000..cc74d92ba5 --- /dev/null +++ b/cli/internal/sarif/save_test.go @@ -0,0 +1,154 @@ +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) + } +} diff --git a/cli/internal/sarif/suppress.go b/cli/internal/sarif/suppress.go new file mode 100644 index 0000000000..f4cba5a041 --- /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, key string) int { + byIdentity := make(map[string]*Suppression) + for _, r := range baseline.Results() { + id, ok := Identity(r, key) + 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, key) + 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..11bca25d54 --- /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, SourceSinkFingerprintKey) + 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, SourceSinkFingerprintKey); 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, SourceSinkFingerprintKey); 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, SourceSinkFingerprintKey); 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) + } +} From c43bea96216022ccd23b579ccb683e9f30b32f07 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 18:29:41 +0200 Subject: [PATCH 09/36] feat(cli): triage engine, failure gate and summary subsections internal/triage applies one pass of baseline inheritance, accept/defer decisions and baseline comparison over a report, and is the single implementation behind the triage command, scan's annotation step and summary's read-only view. Gate decides whether findings fail the build: suppressed findings never count, and with a baseline only new (or uncomparable) ones do. The summary grows Baseline and Suppressions subsections, rendered only when they apply, and the finding listing hides suppressed results unless asked for them. --- cli/cmd/summary.go | 2 +- cli/internal/sarif/filter.go | 48 ++++- cli/internal/sarif/group.go | 5 + cli/internal/sarif/listing.go | 17 +- cli/internal/sarif/print_findings.go | 11 ++ cli/internal/sarif/triage_summary_test.go | 128 +++++++++++++ cli/internal/sarif/triage_view.go | 101 +++++++++++ cli/internal/sarif/triage_view_test.go | 100 +++++++++++ cli/internal/sarif/utils.go | 40 +++-- cli/internal/triage/gate.go | 90 ++++++++++ cli/internal/triage/gate_test.go | 136 ++++++++++++++ cli/internal/triage/triage.go | 161 +++++++++++++++++ cli/internal/triage/triage_test.go | 208 ++++++++++++++++++++++ 13 files changed, 1030 insertions(+), 17 deletions(-) create mode 100644 cli/internal/sarif/triage_summary_test.go create mode 100644 cli/internal/sarif/triage_view.go create mode 100644 cli/internal/sarif/triage_view_test.go create mode 100644 cli/internal/triage/gate.go create mode 100644 cli/internal/triage/gate_test.go create mode 100644 cli/internal/triage/triage.go create mode 100644 cli/internal/triage/triage_test.go diff --git a/cli/cmd/summary.go b/cli/cmd/summary.go index 9b6da780ce..a8b6d007e9 100644 --- a/cli/cmd/summary.go +++ b/cli/cmd/summary.go @@ -160,7 +160,7 @@ func printSarifSummary(report *sarif.Report, absSarifPath string, filters sarif. out.Blank() } - filtered.PrintSummary(out, absSarifPath) + filtered.PrintSummary(out, absSarifPath, view) if showFindings && hasOmittedFlow && !verboseFlow { out.Suggest( diff --git a/cli/internal/sarif/filter.go b/cli/internal/sarif/filter.go index 20c6492002..efd8ce94a9 100644 --- a/cli/internal/sarif/filter.go +++ b/cli/internal/sarif/filter.go @@ -19,13 +19,15 @@ type Filters struct { 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) + 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. 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 @@ -68,9 +70,53 @@ func (f Filters) matches(r *Result) bool { if len(f.Fingerprints) > 0 && !matchFingerprint(r, f.FingerprintKey, f.Fingerprints) { return false } + if len(f.BaselineStates) > 0 && !matchBaselineState(r, f.BaselineStates) { + return false + } return true } +// 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 { diff --git a/cli/internal/sarif/group.go b/cli/internal/sarif/group.go index 2c444477a2..1c010bbbe8 100644 --- a/cli/internal/sarif/group.go +++ b/cli/internal/sarif/group.go @@ -23,6 +23,11 @@ type ListingOptions struct { 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 } // ParseGroupDimension converts a --group-by flag value into a GroupDimension. 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..f718a15edf 100644 --- a/cli/internal/sarif/print_findings.go +++ b/cli/internal/sarif/print_findings.go @@ -96,6 +96,17 @@ 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 { + findingNode.Child(out.FieldItem("Baseline", string(*result.BaselineState))) + } + 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)) diff --git a/cli/internal/sarif/triage_summary_test.go b/cli/internal/sarif/triage_summary_test.go new file mode 100644 index 0000000000..5792b75242 --- /dev/null +++ b/cli/internal/sarif/triage_summary_test.go @@ -0,0 +1,128 @@ +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, SourceSinkFingerprintKey) + 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", "Fixed"} { + 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 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, SourceSinkFingerprintKey) + + 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, SourceSinkFingerprintKey) + + 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) + } +} diff --git a/cli/internal/sarif/triage_view.go b/cli/internal/sarif/triage_view.go new file mode 100644 index 0000000000..fd2aee30e5 --- /dev/null +++ b/cli/internal/sarif/triage_view.go @@ -0,0 +1,101 @@ +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 + + // 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 +} + +// 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}, + {"Updated", Updated}, + } { + if count := v.Comparison.Counts[entry.state]; count > 0 { + items = append(items, out.FieldItem(entry.label, count)) + } + } + // "Fixed" reads better than SARIF's "absent" for a finding that is gone. + if count := v.Comparison.Counts[Absent]; count > 0 { + items = append(items, out.FieldItem("Fixed", count)) + } + if v.Comparison.Unmatchable > 0 { + items = append(items, out.FieldItem("Not comparable", v.Comparison.Unmatchable)) + } + + 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..ef8b4941ab --- /dev/null +++ b/cli/internal/triage/gate.go @@ -0,0 +1,90 @@ +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 { + if len(g.Severities) == 0 { + return true + } + level := strings.ToLower(string(sarif.LevelOf(r))) + for _, s := range g.Severities { + if strings.ToLower(strings.TrimSpace(s)) == level { + return true + } + } + return false +} + +// ParseGateSeverities validates --error-on-severity values. +func ParseGateSeverities(values []string) ([]string, error) { + var out []string + for _, v := range values { + normalized := strings.ToLower(strings.TrimSpace(v)) + if normalized == "" { + continue + } + if err := sarif.ValidateSeverity(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..91e3d04b8d --- /dev/null +++ b/cli/internal/triage/gate_test.go @@ -0,0 +1,136 @@ +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 TestGateCountsUpdatedFindingsAsNew(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") + } +} diff --git a/cli/internal/triage/triage.go b/cli/internal/triage/triage.go new file mode 100644 index 0000000000..4f570c329a --- /dev/null +++ b/cli/internal/triage/triage.go @@ -0,0 +1,161 @@ +// 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 + // FingerprintKey selects the identity fingerprint ("" = default). + FingerprintKey string + + // 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; then explicit accept/defer +// decisions from this run overwrite them; then the baseline comparison is +// computed over the final state. +func Apply(report *sarif.Report, opts Options) (*Outcome, error) { + key, err := sarif.ResolveIdentityKey(opts.FingerprintKey) + if err != nil { + return nil, err + } + 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} + changed := false + + if opts.Baseline != nil { + view.Inherited = sarif.InheritSuppressions(report, opts.Baseline, key) + changed = changed || view.Inherited > 0 + } + + added, err := applyDecisions(report, key, opts) + if err != nil { + return nil, err + } + view.Added = added + changed = changed || added > 0 + + removed, err := applyUnsuppressions(report, key, opts.Unsuppress) + if err != nil { + return nil, err + } + changed = changed || removed > 0 + + if opts.Baseline != nil { + comparison, err := sarif.CompareToBaseline(report, opts.Baseline, key) + if err != nil { + return nil, err + } + view.Comparison = comparison + if opts.WriteBaselineState { + comparison.Apply(report) + view.StateWritten = true + changed = true + } + } + + 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, key string, opts Options) (int, error) { + type decision struct { + result *sarif.Result + accept bool + } + + var decisions []decision + for _, prefix := range opts.Accept { + r, err := sarif.ResolvePrefix(report, key, prefix) + if err != nil { + return 0, err + } + decisions = append(decisions, decision{result: r, accept: true}) + } + for _, prefix := range opts.Defer { + r, err := sarif.ResolvePrefix(report, key, prefix) + if err != nil { + return 0, err + } + 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, key string, prefixes []string) (int, error) { + var targets []*sarif.Result + for _, prefix := range prefixes { + r, err := sarif.ResolvePrefix(report, key, prefix) + if err != nil { + return 0, err + } + targets = append(targets, r) + } + + 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..ed662e8264 --- /dev/null +++ b/cli/internal/triage/triage_test.go @@ -0,0 +1,208 @@ +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.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) + } +} From 5f44843be47b28aed8123a32ebef9770c82bf63e Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 18:34:12 +0200 Subject: [PATCH 10/36] feat(cli): triage command, and baseline/gate flags on scan and summary - opentaint triage: compare a report against a baseline, record accept ("won't fix") and defer ("not fixing for now") decisions by fingerprint prefix, and write the annotated report - scan: --baseline, --baseline-state, --error-on-findings, --error-on-severity, --fingerprint-key; the report the analyzer wrote is rewritten only when triage actually changed it - summary: --baseline, --baseline-state, --suppressed; runs the same engine read-only so the file is never touched Read-only mode still annotates the in-memory report, otherwise --baseline-state could not filter on a state nobody had written. --- cli/cmd/scan.go | 56 +++++++++- cli/cmd/summary.go | 85 +++++++++++++-- cli/cmd/triage.go | 165 +++++++++++++++++++++++++++++ cli/internal/sarif/triage_view.go | 6 ++ cli/internal/triage/triage.go | 16 ++- cli/internal/triage/triage_test.go | 50 +++++++++ 6 files changed, 363 insertions(+), 15 deletions(-) create mode 100644 cli/cmd/triage.go diff --git a/cli/cmd/scan.go b/cli/cmd/scan.go index 7b7af94afe..dd0235582d 100644 --- a/cli/cmd/scan.go +++ b/cli/cmd/scan.go @@ -11,6 +11,7 @@ import ( "github.com/seqra/opentaint/internal/load_trace" "github.com/seqra/opentaint/internal/rules" "github.com/seqra/opentaint/internal/sarif" + "github.com/seqra/opentaint/internal/triage" "github.com/seqra/opentaint/internal/validation" "github.com/seqra/opentaint/internal/version" @@ -39,6 +40,12 @@ type ScanConfig struct { DataflowApproximations []string TrackExternalMethods bool + Baseline string + WriteBaselineState bool + FingerprintKey string + ErrorOnFindings bool + ErrorOnSeverity []string + DebugFactReachabilitySarif bool DebugRunAnalysisOnSelectedEntryPoints string ExpandRuleRefs bool @@ -172,6 +179,10 @@ func addScanFlags(cmd *cobra.Command) { 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, &scanFlags.FingerprintKey) + cmd.Flags().BoolVar(&scanFlags.WriteBaselineState, "baseline-state", false, "Write result.baselineState and run.baselineGuid into the report") + addGateFlags(cmd, &scanFlags.ErrorOnFindings, &scanFlags.ErrorOnSeverity) } // currentScanBuilder returns a builder pre-populated with the user's current scan flags. @@ -263,6 +274,19 @@ 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("--baseline-state needs a --baseline to compare against") + } + if cfg.Baseline != "" { + loadBaselineOrExit(cfg.Baseline, absSarifReportPath) + } + sarifReportName := filepath.Base(absSarifReportPath) localVersion := utils.ArtifactDisplayVersion(globals.ArtifactByKind("analyzer")) @@ -491,10 +515,12 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { suggestions = append(suggestions, retry) } } + var view *sarif.TriageView if report != nil { + view = triageScanReport(cfg, report, absSarifReportPath) // 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}) + printSarifSummary(report, absSarifReportPath, sarif.Filters{}, sarif.ListingOptions{MaxNestingLevel: -1}, view, false) switch { case cfg.DebugFactReachabilitySarif: if analyzerFail == nil { @@ -530,6 +556,34 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { if analyzerFail != nil { os.Exit(analyzerFail.ExitCode) } + if report != nil { + exitOnGate(triage.Gate{Enabled: cfg.ErrorOnFindings, Severities: gateSeverities}, report, 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. +func triageScanReport(cfg ScanConfig, report *sarif.Report, absSarifReportPath string) *sarif.TriageView { + opts := triage.Options{ + WriteBaselineState: cfg.WriteBaselineState, + FingerprintKey: cfg.FingerprintKey, + } + if cfg.Baseline != "" { + opts.Baseline, opts.BaselinePath = loadBaselineOrExit(cfg.Baseline, absSarifReportPath) + } + + outcome, err := triage.Apply(report, opts) + 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 diff --git a/cli/cmd/summary.go b/cli/cmd/summary.go index a8b6d007e9..7dbc5f7960 100644 --- a/cli/cmd/summary.go +++ b/cli/cmd/summary.go @@ -2,6 +2,7 @@ package cmd import ( "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" @@ -53,14 +54,26 @@ This command only reads the report. It does not write files.`, 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 !showFindings && sarif.GenerateSummary(report.Filter(summaryFilters())).TotalFindings > 0 { + // 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(), @@ -69,6 +82,26 @@ This command only reads the report. It does not write files.`, }, } +// 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, + FingerprintKey: summaryFingerprintKey, + ReadOnly: true, + }) + if err != nil { + out.Fatalf("%s", err) + } + return outcome.View +} + var showFindings bool var showCodeSnippets bool var verboseFlow bool @@ -77,10 +110,14 @@ var summaryPaths []string var summarySeverities []string var summaryRuleIDs []string var summaryFingerprints []string -var summaryFingerprintKey string +var summaryPartialFingerprintKey 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 summaryFingerprintKey string +var summaryShowSuppressed bool func init() { rootCmd.AddCommand(summaryCmd) @@ -92,10 +129,34 @@ func init() { 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 partial fingerprint starts with this value (git-hash style, repeatable)") - summaryCmd.Flags().StringVar(&summaryFingerprintKey, "partial-fingerprint-key", "", "partialFingerprints key matched by --partial-fingerprint (defaults to vulnerabilityWithTraceHash/v1)") + summaryCmd.Flags().StringVar(&summaryPartialFingerprintKey, "partial-fingerprint-key", "", "partialFingerprints key matched by --partial-fingerprint (defaults to vulnerabilityWithTraceHash/v1)") 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 (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, &summaryFingerprintKey) + summaryCmd.Flags().StringArrayVar(&summaryBaselineStates, "baseline-state", nil, "Show only findings in this baseline state: new, unchanged, updated, absent (repeatable, needs --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, fingerprintKey *string) { + cmd.Flags().StringVar(baseline, "baseline", "", "Previous SARIF report to compare against and inherit suppressions from") + cmd.Flags().StringVar(fingerprintKey, "fingerprint-key", "", "partialFingerprints key identifying a finding across reports (default "+sarif.DefaultIdentityKey+")") +} + +// 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. @@ -116,7 +177,7 @@ func currentSummaryBuilder(sarifPath string) *utils.OpentaintCommandBuilder { builder.WithSeverity(summarySeverities) builder.WithRuleID(summaryRuleIDs) builder.WithPartialFingerprint(summaryFingerprints) - builder.WithPartialFingerprintKey(summaryFingerprintKey) + builder.WithPartialFingerprintKey(summaryPartialFingerprintKey) builder.WithMaxNestingLevel(summaryMaxNestingLevel) builder.WithGroupBy(summaryGroupBy) builder.WithCodeFlow(summaryCodeFlow) @@ -132,7 +193,7 @@ func summaryFilters() sarif.Filters { Severities: summarySeverities, RuleIDs: summaryRuleIDs, Fingerprints: summaryFingerprints, - FingerprintKey: summaryFingerprintKey, + FingerprintKey: summaryPartialFingerprintKey, } } @@ -146,23 +207,27 @@ func summaryListingOptions(dim sarif.GroupDimension, codeFlowSel sarif.CodeFlowS VerboseFlow: verboseFlow, MaxNestingLevel: summaryMaxNestingLevel, GroupBy: dim, - FingerprintKey: summaryFingerprintKey, + FingerprintKey: summaryPartialFingerprintKey, 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) hasOmittedFlow := false - if showFindings { + if list { hasOmittedFlow = filtered.PrintAll(out, opts) out.Blank() } filtered.PrintSummary(out, absSarifPath, view) - if showFindings && hasOmittedFlow && !verboseFlow { + if list && hasOmittedFlow && !verboseFlow { out.Suggest( "To see the full code flow and code snippets, run:", currentSummaryBuilder(absSarifPath).WithVerboseFlow().WithShowCodeSnippets().Build(), diff --git a/cli/cmd/triage.go b/cli/cmd/triage.go new file mode 100644 index 0000000000..2afa156fef --- /dev/null +++ b/cli/cmd/triage.go @@ -0,0 +1,165 @@ +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 + FingerprintKey string + Accept []string + Defer []string + Unsuppress []string + Justification string + Output string + ErrorOnFindings bool + ErrorOnSeverity []string + ShowSuppressed bool + ShowFindings bool +} + +var triageFlags TriageConfig + +var triageCmd = &cobra.Command{ + Use: "triage sarif", + Short: "Compare a SARIF report against a baseline and record suppressions", + Args: cobra.ExactArgs(1), + Long: `Compare a SARIF report against a baseline and record accept/defer decisions + +Findings are identified by fingerprint, so a decision survives edits elsewhere +in the code. Nothing is ever deleted from the report: an accepted or deferred +finding stays in the file, marked with a SARIF suppression that records who +decided what and why. + +Arguments: + sarif - Path to the SARIF report to triage + +A finding is named by a fingerprint prefix, git-style — the value shown as +"Fingerprint:" by 'opentaint summary --show-findings'. + +Examples: + # See what changed since the last release, without modifying anything + 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 for now + opentaint triage scan.sarif --defer 8bc1d2 --justification "waiting on OT-412" + + # Carry earlier decisions forward and fail if anything new turned up + opentaint triage scan.sarif --baseline release.sarif -o triaged.sarif \ + --error-on-findings + +Exit codes: + 0 Triage completed + 1 General failure (bad input, unreadable report) + 2 Findings remain and --error-on-findings was set`, + + Run: func(cmd *cobra.Command, args []string) { + runTriage(triageFlags, args[0]) + }, +} + +func init() { + rootCmd.AddCommand(triageCmd) + + addBaselineFlags(triageCmd, &triageFlags.Baseline, &triageFlags.FingerprintKey) + triageCmd.Flags().BoolVar(&triageFlags.WriteBaselineState, "baseline-state", false, "Write result.baselineState and run.baselineGuid into the report") + 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().StringVar(&triageFlags.Justification, "justification", "", "Why the finding is accepted or deferred (required with --accept/--defer)") + triageCmd.Flags().StringVarP(&triageFlags.Output, "output", "o", "", "Write the triaged report here (default: rewrite 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, "List the findings, 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 (new ones only, with --baseline)") + cmd.Flags().StringArrayVar(severities, "error-on-severity", nil, "Restrict --error-on-findings to these levels: error, warning, note, none (repeatable, default all)") +} + +func runTriage(cfg TriageConfig, reportPath string) { + gateSeverities, err := triage.ParseGateSeverities(cfg.ErrorOnSeverity) + 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, + FingerprintKey: cfg.FingerprintKey, + Accept: cfg.Accept, + Defer: cfg.Defer, + Unsuppress: cfg.Unsuppress, + Justification: cfg.Justification, + } + if cfg.Baseline != "" { + opts.Baseline, opts.BaselinePath = loadBaselineOrExit(cfg.Baseline, absReportPath) + } else if cfg.WriteBaselineState { + out.Fatalf("--baseline-state needs a --baseline to compare against") + } + + outcome, err := triage.Apply(report, opts) + if err != nil { + out.Fatalf("%s", err) + } + + outputPath := absReportPath + if cfg.Output != "" { + outputPath = log.AbsPathOrExit(cfg.Output, "output") + } + // 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) +} + +// 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/internal/sarif/triage_view.go b/cli/internal/sarif/triage_view.go index fd2aee30e5..044d87036f 100644 --- a/cli/internal/sarif/triage_view.go +++ b/cli/internal/sarif/triage_view.go @@ -19,6 +19,9 @@ type TriageView struct { // 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 @@ -60,6 +63,9 @@ func (v *TriageView) baselineItems(out *output.Printer) []any { items = append(items, out.FieldItem("Not comparable", v.Comparison.Unmatchable)) } + if v.ReadOnly { + return items + } written := "no" if v.StateWritten { written = "yes" diff --git a/cli/internal/triage/triage.go b/cli/internal/triage/triage.go index 4f570c329a..02b6fb4f4d 100644 --- a/cli/internal/triage/triage.go +++ b/cli/internal/triage/triage.go @@ -20,6 +20,11 @@ type Options struct { WriteBaselineState bool // FingerprintKey selects the identity fingerprint ("" = default). FingerprintKey string + // 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 @@ -57,7 +62,7 @@ func Apply(report *sarif.Report, opts Options) (*Outcome, error) { return nil, fmt.Errorf("a justification is required to suppress a finding: pass --justification") } - view := &sarif.TriageView{BaselinePath: opts.BaselinePath} + view := &sarif.TriageView{BaselinePath: opts.BaselinePath, ReadOnly: opts.ReadOnly} changed := false if opts.Baseline != nil { @@ -84,13 +89,16 @@ func Apply(report *sarif.Report, opts Options) (*Outcome, error) { return nil, err } view.Comparison = comparison - if opts.WriteBaselineState { + if opts.WriteBaselineState || opts.ReadOnly { comparison.Apply(report) - view.StateWritten = true - changed = true + 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) diff --git a/cli/internal/triage/triage_test.go b/cli/internal/triage/triage_test.go index ed662e8264..bfca61293f 100644 --- a/cli/internal/triage/triage_test.go +++ b/cli/internal/triage/triage_test.go @@ -206,3 +206,53 @@ func TestApplySuppressionStatsCoverTheWholeReport(t *testing.T) { 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") + } +} From f62961f17d105d77fc3b9b3976f3a5b2061d695a Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 18:38:37 +0200 Subject: [PATCH 11/36] feat(cli): rules.only and rules.exclude allow/deny lists in the config Rule lists are scan-time rule selection, not suppression: an excluded rule never loads, so it produces nothing to suppress. The analyzer only supports inclusion, so exclusion is implemented by enumerating every rule id in the ruleset and subtracting, then re-expanding join refs so that excluding a library rule cannot silently break a rule that joins against it. Resolution happens before the --dry-run bail-out, so a list that selects nothing is reported by --dry-run rather than after a full compile. The --rule-id flag continues to win over the config file. --- cli/cmd/scan.go | 47 ++++++-- cli/internal/globals/global.go | 6 + cli/internal/rules/select.go | 138 ++++++++++++++++++++++ cli/internal/rules/select_test.go | 186 ++++++++++++++++++++++++++++++ 4 files changed, 369 insertions(+), 8 deletions(-) create mode 100644 cli/internal/rules/select.go create mode 100644 cli/internal/rules/select_test.go diff --git a/cli/cmd/scan.go b/cli/cmd/scan.go index dd0235582d..8264a21bfa 100644 --- a/cli/cmd/scan.go +++ b/cli/cmd/scan.go @@ -202,6 +202,40 @@ func currentScanBuilder(cfg ScanConfig, sourcePath string) *utils.OpentaintComma return b } +// resolveRuleIDs determines which rules the analyzer should run: the --rule-id +// flag when given, otherwise the configured rules.only / rules.exclude lists. +// The flag wins over the config file, as it does everywhere else; honoring both +// would silently intersect two selections the user never asked to combine. +// Returns nil when nothing restricts the rules, which runs the whole ruleset. +func resolveRuleIDs(cfg ScanConfig, absRuleSetPaths []RulesetType) []string { + var rulesetRoots []string + for _, r := range absRuleSetPaths { + rulesetRoots = append(rulesetRoots, r.Path) + } + + if len(cfg.RuleID) > 0 { + if cfg.ExpandRuleRefs { + return rules.ExpandRuleIDs(cfg.RuleID, rulesetRoots) + } + return cfg.RuleID + } + + selected, err := rules.Select(configuredRuleSelection(), rulesetRoots) + if err != nil { + out.Fatalf("%s", err) + } + return selected +} + +// configuredRuleSelection reads the rules.only / rules.exclude allow and deny +// lists from the configuration file. +func configuredRuleSelection() rules.Selection { + return rules.Selection{ + Only: globals.Config.Rules.Only, + Exclude: globals.Config.Rules.Exclude, + } +} + func isDefaultSeverity(sev []string) bool { return len(sev) == 2 && sev[0] == "warning" && sev[1] == "error" } @@ -332,6 +366,11 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { out.Fatalf("Input validation failed: %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. + ruleIDs := resolveRuleIDs(cfg, absRuleSetPaths) + if cfg.DryRun { runDryRun("the build and scan") return @@ -423,14 +462,6 @@ 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 { nativeBuilder.AddRuleID(ruleID) } diff --git a/cli/internal/globals/global.go b/cli/internal/globals/global.go index 8bcba3f393..ef07d3bd33 100644 --- a/cli/internal/globals/global.go +++ b/cli/internal/globals/global.go @@ -68,6 +68,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..ccf5ce58b5 --- /dev/null +++ b/cli/internal/rules/select.go @@ -0,0 +1,138 @@ +package rules + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/bmatcuk/doublestar/v4" + "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 + } + for _, r := range rf.Rules { + if r.ID == "" { + continue + } + ids = append(ids, filepath.ToSlash(relPath)+":"+r.ID) + } + return nil + }) + } + return ids +} + +func isRuleFile(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + return ext == ".yaml" || ext == ".yml" +} + +// Select resolves a Selection against the ruleset roots and returns the rule ids +// to pass to the analyzer, or nil when the selection restricts nothing (in which +// case the analyzer runs every rule, as it always has). +// +// The analyzer only supports inclusion, so an exclusion list is applied by +// enumerating every rule and subtracting. Rules referenced by the survivors are +// then 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. +func Select(selection Selection, roots []string) ([]string, error) { + if !selection.Active() { + return nil, nil + } + + all := ListRuleIDs(roots) + if len(all) == 0 { + return nil, fmt.Errorf("rules.only/rules.exclude are configured but no rules were found in the ruleset") + } + + var kept []string + for _, id := range all { + if len(selection.Only) > 0 && !matchesAny(id, selection.Only) { + continue + } + if matchesAny(id, selection.Exclude) { + continue + } + kept = append(kept, id) + } + if len(kept) == 0 { + return nil, fmt.Errorf("rules.only/rules.exclude select no rules at all; nothing would be scanned") + } + + expanded := ExpandRuleIDs(kept, roots) + sort.Strings(expanded) + return expanded, nil +} + +func matchesAny(id string, patterns []string) bool { + for _, p := range patterns { + if matchesPattern(id, p) { + return true + } + } + return false +} + +// matchesPattern matches a rule id as a full "path.yaml:id", as a bare leaf +// name, or as a doublestar glob over either. Globbing the leaf as well as the +// full id is what makes the natural "sqli-*" work; matching only the full id +// would silently select nothing, since the leaf never contains the path. +func matchesPattern(id, pattern string) bool { + pattern = strings.TrimSpace(pattern) + if pattern == "" { + return false + } + if id == pattern { + return true + } + if matched, err := doublestar.Match(pattern, id); err == nil && matched { + return true + } + _, leaf, ok := splitRuleID(id) + if !ok { + return false + } + if leaf == pattern { + return true + } + matched, err := doublestar.Match(pattern, leaf) + return err == nil && matched +} diff --git a/cli/internal/rules/select_test.go b/cli/internal/rules/select_test.go new file mode 100644 index 0000000000..a6a2e1d4f2 --- /dev/null +++ b/cli/internal/rules/select_test.go @@ -0,0 +1,186 @@ +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 TestMatchesPattern(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}, // leaf + {"java/security/**", true}, // glob over the path + {"sql-*", true}, // glob over the leaf + {"java/**/sqli.yaml:*", true}, + {"sql-injection-jdbc", false}, + {"go/**", false}, + {"", false}, + } + for _, tc := range cases { + if got := matchesPattern(id, tc.pattern); got != tc.want { + t.Errorf("matchesPattern(%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 != nil { + t.Errorf("got %v, want nil: 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) != 1 || got[0] != "a.yaml:keep-me" { + t.Errorf("got %v", got) + } +} + +func TestSelectExclude(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{Exclude: []string{"drop-me"}}, []string{root}) + if err != nil { + t.Fatalf("select: %v", err) + } + if len(got) != 1 || got[0] != "a.yaml:keep-me" { + t.Errorf("got %v", got) + } +} + +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{"sqli-*"}, Exclude: []string{"sqli-two"}}, []string{root}) + if err != nil { + t.Fatalf("select: %v", err) + } + if len(got) != 1 || got[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) + if len(got) != 2 || got[1] != "security/sqli.yaml:sql-injection" || got[0] != "lib/sources.yaml:servlet-source" { + t.Errorf("got %v, want the rule plus the library rule it joins", got) + } +} + +func TestSelectReAddsAnExcludedRuleThatSurvivorsNeed(t *testing.T) { + // Excluding a library rule that a kept rule joins against would produce a + // rule that cannot match anything. Reference expansion brings it back. + 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{Exclude: []string{"lib/**"}}, []string{root}) + if err != nil { + t.Fatalf("select: %v", err) + } + if len(got) != 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") + } +} From 85ed37c9364b8a8e66856f32b8b53bf170fe68bc Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 18:40:11 +0200 Subject: [PATCH 12/36] docs(cli): document triage, baselines, suppressions and rule lists Adds the opentaint triage reference, the baseline/gating flags on scan and summary, a Baselines and suppressions section explaining that presence in a baseline is not acceptance, and the rules.only/rules.exclude configuration keys. Suggestion builders learn the new flags so rebuilt commands keep them. --- cli/cmd/scan.go | 7 +- cli/cmd/summary.go | 4 + .../utils/opentaint_command_builder.go | 61 ++++++++++ docs/configuration.md | 35 ++++++ docs/usage.md | 106 ++++++++++++++++++ 5 files changed, 212 insertions(+), 1 deletion(-) diff --git a/cli/cmd/scan.go b/cli/cmd/scan.go index 8264a21bfa..bd4724fe66 100644 --- a/cli/cmd/scan.go +++ b/cli/cmd/scan.go @@ -195,7 +195,12 @@ func currentScanBuilder(cfg ScanConfig, sourcePath string) *utils.OpentaintComma WithRuleID(cfg.RuleID). WithPassthroughApproximations(cfg.PassthroughApproximations). WithDataflowApproximations(cfg.DataflowApproximations). - WithTrackExternalMethods(cfg.TrackExternalMethods) + WithTrackExternalMethods(cfg.TrackExternalMethods). + WithBaseline(cfg.Baseline). + WithBaselineState(cfg.WriteBaselineState). + WithFingerprintKey(cfg.FingerprintKey). + WithErrorOnFindings(cfg.ErrorOnFindings). + WithErrorOnSeverity(cfg.ErrorOnSeverity) if !isDefaultSeverity(cfg.Severity) { b.WithSeverity(cfg.Severity) } diff --git a/cli/cmd/summary.go b/cli/cmd/summary.go index 7dbc5f7960..b12ed23e03 100644 --- a/cli/cmd/summary.go +++ b/cli/cmd/summary.go @@ -181,6 +181,10 @@ func currentSummaryBuilder(sarifPath string) *utils.OpentaintCommandBuilder { builder.WithMaxNestingLevel(summaryMaxNestingLevel) builder.WithGroupBy(summaryGroupBy) builder.WithCodeFlow(summaryCodeFlow) + builder.WithBaseline(summaryBaseline) + builder.WithFingerprintKey(summaryFingerprintKey) + builder.WithBaselineStateFilter(summaryBaselineStates) + builder.WithSuppressed(summaryShowSuppressed) return builder } diff --git a/cli/internal/utils/opentaint_command_builder.go b/cli/internal/utils/opentaint_command_builder.go index 369f62cd51..52ef86347a 100644 --- a/cli/internal/utils/opentaint_command_builder.go +++ b/cli/internal/utils/opentaint_command_builder.go @@ -389,3 +389,64 @@ 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 +} + +// WithBaselineState sets the --baseline-state flag. +func (cb *OpentaintCommandBuilder) WithBaselineState(enabled bool) *OpentaintCommandBuilder { + if enabled { + cb.boolFlags["baseline-state"] = true + } + return cb +} + +// WithFingerprintKey sets the --fingerprint-key flag. +func (cb *OpentaintCommandBuilder) WithFingerprintKey(key string) *OpentaintCommandBuilder { + if key != "" { + cb.flags["fingerprint-key"] = key + } + 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 +} diff --git a/docs/configuration.md b/docs/configuration.md index 09381d8be6..ba01e246cc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -27,6 +27,11 @@ output: # Java runtime settings java: version: 23 + +# Which rules the analyzer runs +rules: + only: [] # if set, only these rules run + exclude: [reflected-xss-*] # these rules never run ``` ### Available Options @@ -39,6 +44,36 @@ java: | `output.color` | Color mode: `auto`, `always`, `never` | `auto` | | `output.quiet` | Suppress interactive console output (spinners, progress bars, JAR streaming) | `false` | | `java.version` | Java version for running the analyzer | `23` | +| `rules.only` | Run only the rules matching these patterns | all rules | +| `rules.exclude` | Never run the rules matching these patterns | none | + +### Selecting rules + +`rules.only` and `rules.exclude` control which rules the analyzer loads. They +are rule *selection*, not suppression: an excluded rule never runs, so it +produces nothing in the report and nothing to review later. 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`, a bare rule name, or a +glob over either: + +```yaml +rules: + only: + - sql-injection* # every rule whose name starts with sql-injection + - java/security/** # every rule under that directory + exclude: + - reflected-xss-in-servlet-app +``` + +`exclude` is applied after `only`. A library rule that a selected rule joins +against is always kept, 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, following the usual rule that flags +outrank the configuration file. The per-run log file (`~/.opentaint/logs//.log`) always captures full JAR subprocess output regardless of these flags. They control diff --git a/docs/usage.md b/docs/usage.md index 7a60d4812b..890fdabab1 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -82,6 +82,7 @@ Use [CodeChecker](https://github.com/Ericsson/codechecker) for advanced result m | `opentaint compile` | Build project model separately from scanning | | `opentaint project` | Create project model from precompiled JARs/classes | | `opentaint summary` | View SARIF analysis results | +| `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 | @@ -107,6 +108,20 @@ On the first run, the compiled project model is cached in `~/.opentaint/cache/`. | `--dry-run` | Validate inputs and show what would run without compiling or scanning | | `--log-file` | Path to the log file (default: `/logs/.log`) | +#### Baseline and gating flags + +| Flag | Description | +|------|-------------| +| `--baseline` | Previous SARIF report to compare against and inherit suppressions from | +| `--baseline-state` | Write `result.baselineState` and `run.baselineGuid` into the report (needs `--baseline`) | +| `--fingerprint-key` | partialFingerprints key identifying a finding across reports (default `vulnerabilitySourceSinkHash/v1`) | +| `--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: `error`, `warning`, `note`, `none` (repeatable, default all) | + +With `--baseline`, findings the baseline already accepted stay suppressed and +the summary reports how many are new, unchanged, updated, or fixed. See +[Baselines and suppressions](#baselines-and-suppressions). + #### Rule-authoring flags These flags are to work with custom approximations: @@ -208,9 +223,100 @@ reflects the full set the tool ran. | `--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/fixed counts. The file is never modified. | +| `--baseline-state` | Show only findings in this state: `new`, `unchanged`, `updated`, `absent` (repeatable, needs `--baseline`) | +| `--suppressed` | Include suppressed findings in the listing (hidden by default) | +| `--fingerprint-key` | partialFingerprints key identifying a finding across reports (default `vulnerabilitySourceSinkHash/v1`) | 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 | +| `--baseline-state` | Write `result.baselineState` and `run.baselineGuid` into the 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` | Write the triaged report here (default: rewrite the input in place) | +| `--show-findings` | List the findings, not just the summary | +| `--suppressed` | Include suppressed findings in the listing | +| `--fingerprint-key` | partialFingerprints key identifying a finding across reports (default `vulnerabilitySourceSinkHash/v1`) | +| `--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 (repeatable, default 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 things are built on +it, both expressed in SARIF 2.1.0's own vocabulary. + +**Baseline comparison** answers "is this new?". `--baseline old.sarif` +classifies every finding as new, unchanged, updated (same source and sink, a +different path through the code), or fixed. By default this only affects what is +printed; `--baseline-state` also writes `result.baselineState` and +`run.baselineGuid` into the report. Findings are matched by fingerprint, not by +line number, so moving code around does not invent new findings. + +**Suppression** answers "did a human accept this?". Presence in a baseline is +not acceptance — a baseline entry that carries no suppression only makes a +finding `unchanged`. A finding is suppressed only when someone decided so with +`opentaint triage`, which writes a SARIF suppression: + +| Decision | `suppression.status` | Meaning | +|----------|----------------------|---------| +| `--accept` | `accepted` | The team will not fix this | +| `--defer` | `underReview` | The team is not fixing this for now | + +Both hide the finding from the listing and from the failure gate, and both +require a justification. A deferral does not expire on its own; the summary's +`Deferred` count is what keeps it visible. + +Decisions travel forward through the baseline. A finding matching a baseline +entry that carries a suppression inherits it verbatim — same status, same +justification, same guid — so a decision is authored once and re-attached by +every later scan for as long as the fingerprint matches. When the code is fixed +and the finding disappears, the decision retires with it. + +A typical CI setup keeps the last accepted report and fails only on new work: + +```bash +opentaint scan --baseline baselines/main.sarif -o scan.sarif \ + --error-on-findings --error-on-severity error,warning . +``` + +Suppressions read from a baseline are interpreted conservatively: an entry whose +status is `rejected`, or anything unrecognised, never hides a finding, and the +summary counts it under `Not honored` so nothing disappears quietly. + ### opentaint project Create project models from precompiled JARs or classes when source code isn't available. From 3fe940d37c97906e1e819c293e947fa78248de94 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 18:40:44 +0200 Subject: [PATCH 13/36] docs: list triage in the docs README command table --- docs/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/README.md b/docs/README.md index cb3fe42fdf..75c4353953 100644 --- a/docs/README.md +++ b/docs/README.md @@ -124,6 +124,7 @@ 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 | @@ -132,6 +133,7 @@ opentaint summary --show-findings --verbose-flow --show-code-snippets results.sa | `opentaint compile` | Build project model separately | | `opentaint project` | Create model from precompiled JARs | | `opentaint summary` | View SARIF results | +| `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 | From 76ef09610a77a120aac70c0828e00ea6ad5f60df Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 23 Jul 2026 18:55:26 +0200 Subject: [PATCH 14/36] =?UTF-8?q?refactor(cli):=20review=20cleanups=20?= =?UTF-8?q?=E2=80=94=20reuse=20matchers,=20single=20baseline=20load?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - rename TestGateCountsUpdatedFindingsAsNew: the body asserts the opposite (updated findings do not trip the gate) - Gate.inScope reuses sarif.MatchesSeverity (exported) instead of re-implementing the case-insensitive level match - fingerprintValue delegates to Identity instead of duplicating the lookup - scan loads the baseline once, before compile, and passes it through to the triage step instead of reading the file a second time --- cli/cmd/scan.go | 22 +++++++++++----------- cli/internal/sarif/filter.go | 9 +++++---- cli/internal/sarif/filter_test.go | 6 +++--- cli/internal/triage/gate.go | 11 +---------- cli/internal/triage/gate_test.go | 2 +- 5 files changed, 21 insertions(+), 29 deletions(-) diff --git a/cli/cmd/scan.go b/cli/cmd/scan.go index bd4724fe66..46c954a87b 100644 --- a/cli/cmd/scan.go +++ b/cli/cmd/scan.go @@ -322,8 +322,10 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { if cfg.WriteBaselineState && cfg.Baseline == "" { out.Fatalf("--baseline-state needs a --baseline to compare against") } + var baseline *sarif.Report + var absBaselinePath string if cfg.Baseline != "" { - loadBaselineOrExit(cfg.Baseline, absSarifReportPath) + baseline, absBaselinePath = loadBaselineOrExit(cfg.Baseline, absSarifReportPath) } sarifReportName := filepath.Base(absSarifReportPath) @@ -553,7 +555,7 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { } var view *sarif.TriageView if report != nil { - view = triageScanReport(cfg, report, absSarifReportPath) + 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}, view, false) @@ -600,17 +602,15 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { // 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. -func triageScanReport(cfg ScanConfig, report *sarif.Report, absSarifReportPath string) *sarif.TriageView { - opts := triage.Options{ +// 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, FingerprintKey: cfg.FingerprintKey, - } - if cfg.Baseline != "" { - opts.Baseline, opts.BaselinePath = loadBaselineOrExit(cfg.Baseline, absSarifReportPath) - } - - outcome, err := triage.Apply(report, opts) + Baseline: baseline, + BaselinePath: absBaselinePath, + }) if err != nil { out.Fatalf("%s", err) } diff --git a/cli/internal/sarif/filter.go b/cli/internal/sarif/filter.go index efd8ce94a9..27932b0e8d 100644 --- a/cli/internal/sarif/filter.go +++ b/cli/internal/sarif/filter.go @@ -61,7 +61,7 @@ func (f Filters) matches(r *Result) bool { if len(f.Paths) > 0 && !matchPath(r, f.Paths) { return false } - if len(f.Severities) > 0 && !matchSeverity(r, f.Severities) { + if len(f.Severities) > 0 && !MatchesSeverity(r, f.Severities) { return false } if len(f.RuleIDs) > 0 && !matchRuleID(r, f.RuleIDs) { @@ -136,9 +136,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 { @@ -192,7 +192,8 @@ func fingerprintValue(r *Result, key string) string { if key == "" { key = DefaultFingerprintKey } - return r.PartialFingerprints[key] + v, _ := Identity(r, key) + return v } // matchFingerprint reports whether the result's partialFingerprints value under diff --git a/cli/internal/sarif/filter_test.go b/cli/internal/sarif/filter_test.go index 2175de600d..e4c7d7eb95 100644 --- a/cli/internal/sarif/filter_test.go +++ b/cli/internal/sarif/filter_test.go @@ -22,14 +22,14 @@ func TestMatchPath(t *testing.T) { func TestMatchSeverity(t *testing.T) { r := makeResult("r", Error, "a.java", 1, nil) - if !matchSeverity(&r, []string{"ERROR"}) { + if !MatchesSeverity(&r, []string{"ERROR"}) { t.Error("expected case-insensitive error match") } - if matchSeverity(&r, []string{"warning"}) { + if MatchesSeverity(&r, []string{"warning"}) { t.Error("expected warning not to match an error") } nilLevel := Result{Locations: r.Locations} - if !matchSeverity(&nilLevel, []string{"note"}) { + if !MatchesSeverity(&nilLevel, []string{"note"}) { t.Error("expected nil level to be treated as note") } } diff --git a/cli/internal/triage/gate.go b/cli/internal/triage/gate.go index ef8b4941ab..e343f563c8 100644 --- a/cli/internal/triage/gate.go +++ b/cli/internal/triage/gate.go @@ -61,16 +61,7 @@ func counts(r *sarif.Result, view *sarif.TriageView) bool { } func (g Gate) inScope(r *sarif.Result) bool { - if len(g.Severities) == 0 { - return true - } - level := strings.ToLower(string(sarif.LevelOf(r))) - for _, s := range g.Severities { - if strings.ToLower(strings.TrimSpace(s)) == level { - return true - } - } - return false + return len(g.Severities) == 0 || sarif.MatchesSeverity(r, g.Severities) } // ParseGateSeverities validates --error-on-severity values. diff --git a/cli/internal/triage/gate_test.go b/cli/internal/triage/gate_test.go index 91e3d04b8d..53a43acf2e 100644 --- a/cli/internal/triage/gate_test.go +++ b/cli/internal/triage/gate_test.go @@ -81,7 +81,7 @@ func TestGateWithBaselineDoesNotTripWhenNothingIsNew(t *testing.T) { } } -func TestGateCountsUpdatedFindingsAsNew(t *testing.T) { +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}) From 0603408f8bb3fae525fc9efe7ad1bc1dd8a50438 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Fri, 24 Jul 2026 13:45:12 +0200 Subject: [PATCH 15/36] feat(cli): --exclude-rule-id flag as the counterpart of --rule-id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rule exclusion was config-only (rules.exclude); this adds the flag analogue on scan. It overrides the configured exclude list, and composes with --rule-id — both were asked for explicitly, so exclusion subtracts from the selection instead of one flag silently winning. Emptying the selection is an error either way, caught by --dry-run before any compile. --- cli/cmd/scan.go | 36 +++++++++++++------ cli/internal/rules/select.go | 20 +++++++++++ cli/internal/rules/select_test.go | 28 +++++++++++++++ .../utils/opentaint_command_builder.go | 10 ++++++ docs/configuration.md | 6 ++-- docs/usage.md | 2 ++ 6 files changed, 89 insertions(+), 13 deletions(-) diff --git a/cli/cmd/scan.go b/cli/cmd/scan.go index 46c954a87b..4672e68343 100644 --- a/cli/cmd/scan.go +++ b/cli/cmd/scan.go @@ -36,6 +36,7 @@ type ScanConfig struct { Recompile bool LogFile string RuleID []string + ExcludeRuleID []string PassthroughApproximations []string DataflowApproximations []string TrackExternalMethods bool @@ -156,6 +157,7 @@ func addEntryPointsFlag(cmd *cobra.Command) { func addRuleIDFlag(cmd *cobra.Command) { cmd.Flags().StringArrayVar(&scanFlags.RuleID, "rule-id", nil, "Run only rules with this ID (repeatable)") + cmd.Flags().StringArrayVar(&scanFlags.ExcludeRuleID, "exclude-rule-id", nil, "Never run rules matching this ID: full id, bare name, or glob (repeatable; overrides rules.exclude from the config)") } func addScanFlags(cmd *cobra.Command) { @@ -193,6 +195,7 @@ 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). @@ -207,10 +210,12 @@ func currentScanBuilder(cfg ScanConfig, sourcePath string) *utils.OpentaintComma return b } -// resolveRuleIDs determines which rules the analyzer should run: the --rule-id -// flag when given, otherwise the configured rules.only / rules.exclude lists. -// The flag wins over the config file, as it does everywhere else; honoring both -// would silently intersect two selections the user never asked to combine. +// resolveRuleIDs determines which rules the analyzer should run. +// +// --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 nil when nothing restricts the rules, which runs the whole ruleset. func resolveRuleIDs(cfg ScanConfig, absRuleSetPaths []RulesetType) []string { var rulesetRoots []string @@ -219,26 +224,35 @@ func resolveRuleIDs(cfg ScanConfig, absRuleSetPaths []RulesetType) []string { } if len(cfg.RuleID) > 0 { + ids, err := rules.ApplyExclusions(cfg.RuleID, cfg.ExcludeRuleID) + if err != nil { + out.Fatalf("%s", err) + } if cfg.ExpandRuleRefs { - return rules.ExpandRuleIDs(cfg.RuleID, rulesetRoots) + ids = rules.ExpandRuleIDs(ids, rulesetRoots) } - return cfg.RuleID + return ids } - selected, err := rules.Select(configuredRuleSelection(), rulesetRoots) + selected, err := rules.Select(configuredRuleSelection(cfg), rulesetRoots) if err != nil { out.Fatalf("%s", err) } return selected } -// configuredRuleSelection reads the rules.only / rules.exclude allow and deny -// lists from the configuration file. -func configuredRuleSelection() rules.Selection { - return rules.Selection{ +// 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. +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 { diff --git a/cli/internal/rules/select.go b/cli/internal/rules/select.go index ccf5ce58b5..124f41d023 100644 --- a/cli/internal/rules/select.go +++ b/cli/internal/rules/select.go @@ -136,3 +136,23 @@ func matchesPattern(id, pattern string) bool { matched, err := doublestar.Match(pattern, leaf) return err == nil && matched } + +// 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 +} diff --git a/cli/internal/rules/select_test.go b/cli/internal/rules/select_test.go index a6a2e1d4f2..4524c618d1 100644 --- a/cli/internal/rules/select_test.go +++ b/cli/internal/rules/select_test.go @@ -184,3 +184,31 @@ func TestSelectWithNoRulesFoundIsAnError(t *testing.T) { 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") + } +} diff --git a/cli/internal/utils/opentaint_command_builder.go b/cli/internal/utils/opentaint_command_builder.go index 52ef86347a..273673de0b 100644 --- a/cli/internal/utils/opentaint_command_builder.go +++ b/cli/internal/utils/opentaint_command_builder.go @@ -450,3 +450,13 @@ func (cb *OpentaintCommandBuilder) WithBaselineStateFilter(states []string) *Ope } 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/docs/configuration.md b/docs/configuration.md index ba01e246cc..fe248540c0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -72,8 +72,10 @@ 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, following the usual rule that flags -outrank the configuration file. +The `--rule-id` flag overrides both lists, and the `--exclude-rule-id` flag +overrides `rules.exclude`, following the usual rule that flags outrank the +configuration file. The two flags compose: `--rule-id` selects, then +`--exclude-rule-id` subtracts. The per-run log file (`~/.opentaint/logs//.log`) always captures full JAR subprocess output regardless of these flags. They control diff --git a/docs/usage.md b/docs/usage.md index 890fdabab1..fa96f2456c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -107,6 +107,8 @@ On the first run, the compiled project model is cached in `~/.opentaint/cache/`. | `--ruleset` | Rules to run: a YAML file, a directory of rules files, or `builtin` (default: `builtin`) | | `--dry-run` | Validate inputs and show what would run without compiling or scanning | | `--log-file` | Path to the log file (default: `/logs/.log`) | +| `--rule-id` | Run only rules with this ID (repeatable) | +| `--exclude-rule-id` | Never run rules matching this ID: full id, bare name, or glob (repeatable; overrides `rules.exclude` from the config, composes with `--rule-id`) | #### Baseline and gating flags From 6ff77432206eb2b44db794db459cb86d30c4004a Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Fri, 24 Jul 2026 13:55:32 +0200 Subject: [PATCH 16/36] =?UTF-8?q?refactor(cli):=20one=20rule-id=20grammar?= =?UTF-8?q?=20=E2=80=94=20rule=20selection=20reuses=20summary's=20matcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit matchesPattern in rules/select.go had invented a fourth match form, globbing the bare leaf name, which summary's --rule-id filter does not support. Export the string-level matcher as sarif.MatchesRuleID and delegate rule selection (rules.only/rules.exclude, --exclude-rule-id) to it, so one grammar holds everywhere: exact full path.yaml:id, exact bare name, or a doublestar glob over the full id only. Docs examples updated off the removed leaf-glob form. --- cli/cmd/scan.go | 2 +- cli/internal/rules/select.go | 39 +++++-------------------------- cli/internal/rules/select_test.go | 16 ++++++------- cli/internal/sarif/filter.go | 16 ++++++++----- docs/configuration.md | 14 ++++++----- docs/usage.md | 2 +- 6 files changed, 34 insertions(+), 55 deletions(-) diff --git a/cli/cmd/scan.go b/cli/cmd/scan.go index 4672e68343..61ffafa882 100644 --- a/cli/cmd/scan.go +++ b/cli/cmd/scan.go @@ -157,7 +157,7 @@ func addEntryPointsFlag(cmd *cobra.Command) { func addRuleIDFlag(cmd *cobra.Command) { cmd.Flags().StringArrayVar(&scanFlags.RuleID, "rule-id", nil, "Run only rules with this ID (repeatable)") - cmd.Flags().StringArrayVar(&scanFlags.ExcludeRuleID, "exclude-rule-id", nil, "Never run rules matching this ID: full id, bare name, or glob (repeatable; overrides rules.exclude from the config)") + 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) { diff --git a/cli/internal/rules/select.go b/cli/internal/rules/select.go index 124f41d023..5278d55b3d 100644 --- a/cli/internal/rules/select.go +++ b/cli/internal/rules/select.go @@ -8,7 +8,7 @@ import ( "sort" "strings" - "github.com/bmatcuk/doublestar/v4" + "github.com/seqra/opentaint/internal/sarif" "gopkg.in/yaml.v2" ) @@ -102,39 +102,12 @@ func Select(selection Selection, roots []string) ([]string, error) { return 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 { - for _, p := range patterns { - if matchesPattern(id, p) { - return true - } - } - return false -} - -// matchesPattern matches a rule id as a full "path.yaml:id", as a bare leaf -// name, or as a doublestar glob over either. Globbing the leaf as well as the -// full id is what makes the natural "sqli-*" work; matching only the full id -// would silently select nothing, since the leaf never contains the path. -func matchesPattern(id, pattern string) bool { - pattern = strings.TrimSpace(pattern) - if pattern == "" { - return false - } - if id == pattern { - return true - } - if matched, err := doublestar.Match(pattern, id); err == nil && matched { - return true - } - _, leaf, ok := splitRuleID(id) - if !ok { - return false - } - if leaf == pattern { - return true - } - matched, err := doublestar.Match(pattern, leaf) - return err == nil && matched + return sarif.MatchesRuleID(id, patterns) } // ApplyExclusions filters an explicit rule-id list (--rule-id) by exclusion diff --git a/cli/internal/rules/select_test.go b/cli/internal/rules/select_test.go index 4524c618d1..042f0954a7 100644 --- a/cli/internal/rules/select_test.go +++ b/cli/internal/rules/select_test.go @@ -66,24 +66,24 @@ func TestListRuleIDsMergesRoots(t *testing.T) { } } -func TestMatchesPattern(t *testing.T) { +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}, // leaf - {"java/security/**", true}, // glob over the path - {"sql-*", true}, // glob over the leaf + {"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 := matchesPattern(id, tc.pattern); got != tc.want { - t.Errorf("matchesPattern(%q, %q) = %v, want %v", id, tc.pattern, got, tc.want) + if got := matchesAny(id, []string{tc.pattern}); got != tc.want { + t.Errorf("matchesAny(%q, [%q]) = %v, want %v", id, tc.pattern, got, tc.want) } } } @@ -129,7 +129,7 @@ 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{"sqli-*"}, Exclude: []string{"sqli-two"}}, []string{root}) + got, err := Select(Selection{Only: []string{"a.yaml:sqli-*"}, Exclude: []string{"sqli-two"}}, []string{root}) if err != nil { t.Fatalf("select: %v", err) } @@ -187,7 +187,7 @@ func TestSelectWithNoRulesFoundIsAnError(t *testing.T) { 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-*"}) + got, err := ApplyExclusions(ids, []string{"*:drop-*"}) if err != nil { t.Fatalf("apply: %v", err) } diff --git a/cli/internal/sarif/filter.go b/cli/internal/sarif/filter.go index 27932b0e8d..83197f7e2c 100644 --- a/cli/internal/sarif/filter.go +++ b/cli/internal/sarif/filter.go @@ -161,13 +161,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 diff --git a/docs/configuration.md b/docs/configuration.md index fe248540c0..ad47b2ec68 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -30,8 +30,8 @@ java: # Which rules the analyzer runs rules: - only: [] # if set, only these rules run - exclude: [reflected-xss-*] # these rules never run + only: [] # if set, only these rules run + exclude: [reflected-xss-in-servlet-app] # these rules never run ``` ### Available Options @@ -54,14 +54,16 @@ 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`, a bare rule name, or a -glob over either: +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* # every rule whose name starts with sql-injection - - java/security/** # every rule under that directory + - sql-injection # exact rule name + - java/security/** # every rule under that directory + - java/security/sqli.yaml:* # every rule in that file exclude: - reflected-xss-in-servlet-app ``` diff --git a/docs/usage.md b/docs/usage.md index fa96f2456c..b415da62ba 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -108,7 +108,7 @@ On the first run, the compiled project model is cached in `~/.opentaint/cache/`. | `--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 (repeatable; overrides `rules.exclude` from the config, composes with `--rule-id`) | +| `--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 From b5984bc05b34d9e9fe31a1c793cf2127affcd94a Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Fri, 24 Jul 2026 14:06:51 +0200 Subject: [PATCH 17/36] feat(cli): warn when a rule selection pattern matches no rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifying that --exclude-rule-id reaches the analyzer as concrete --semgrep-rule-id args surfaced a silence: excluding a typo'd rule id matched nothing and looked effective. Selection.Unmatched reports patterns that select no rule, and scan warns for each — on both the config-list path and the --rule-id/--exclude-rule-id flag path. The configuration example also used a rule id that does not exist in the shipped ruleset; replaced with a real one. --- cli/cmd/scan.go | 16 +++++++++++++++- cli/internal/rules/select.go | 26 ++++++++++++++++++++++++++ cli/internal/rules/select_test.go | 19 +++++++++++++++++++ docs/configuration.md | 7 +++++-- 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/cli/cmd/scan.go b/cli/cmd/scan.go index 61ffafa882..3aa4d7e946 100644 --- a/cli/cmd/scan.go +++ b/cli/cmd/scan.go @@ -228,19 +228,33 @@ func resolveRuleIDs(cfg ScanConfig, absRuleSetPaths []RulesetType) []string { if err != nil { out.Fatalf("%s", err) } + warnUnmatchedRulePatterns(rules.Selection{Exclude: cfg.ExcludeRuleID}, cfg.RuleID) if cfg.ExpandRuleRefs { ids = rules.ExpandRuleIDs(ids, rulesetRoots) } return ids } - selected, err := rules.Select(configuredRuleSelection(cfg), rulesetRoots) + 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. diff --git a/cli/internal/rules/select.go b/cli/internal/rules/select.go index 5278d55b3d..e7d49f9bb4 100644 --- a/cli/internal/rules/select.go +++ b/cli/internal/rules/select.go @@ -129,3 +129,29 @@ func ApplyExclusions(ids, patterns []string) ([]string, error) { } 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 index 042f0954a7..177135732e 100644 --- a/cli/internal/rules/select_test.go +++ b/cli/internal/rules/select_test.go @@ -212,3 +212,22 @@ func TestApplyExclusionsEmptyingTheListIsAnError(t *testing.T) { 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) + } +} diff --git a/docs/configuration.md b/docs/configuration.md index ad47b2ec68..9392f50311 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -31,7 +31,7 @@ java: # Which rules the analyzer runs rules: only: [] # if set, only these rules run - exclude: [reflected-xss-in-servlet-app] # these rules never run + exclude: [cookie-missing-httponly] # these rules never run ``` ### Available Options @@ -65,9 +65,12 @@ rules: - java/security/** # every rule under that directory - java/security/sqli.yaml:* # every rule in that file exclude: - - reflected-xss-in-servlet-app + - 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`. A library rule that a selected rule joins against is always kept, 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 From af6f0eacbc9af9b7076366ab93795fb276e79ef4 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Fri, 24 Jul 2026 14:24:07 +0200 Subject: [PATCH 18/36] feat(cli): resolve rule exclusion to the analyzer's --semgrep-rule-id-exclude An exclusion-only selection lowers to just the excluded concrete ids, passed via --semgrep-rule-id-exclude, instead of expanding into the ~150-rule inclusion complement. rules.Select now returns Resolved{Include, Exclude}; the allow-list path keeps CLI-side subtraction and join-ref re-expansion, since inclusion must be exact ids anyway. --rule-id composed with --exclude-rule-id still subtracts CLI-side (tiny explicit lists). Requires the analyzer's --semgrep-rule-id-exclude option (separate core branch); older jars reject it, so exclusion fails fast until the analyzer version is bumped. --- cli/cmd/command_builder.go | 10 +++++++ cli/cmd/command_builder_test.go | 30 +++++++++++++++++++++ cli/cmd/scan.go | 19 ++++++++----- cli/internal/rules/select.go | 42 ++++++++++++++++++++--------- cli/internal/rules/select_test.go | 44 ++++++++++++++++++------------- docs/configuration.md | 12 +++++---- 6 files changed, 115 insertions(+), 42 deletions(-) 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/scan.go b/cli/cmd/scan.go index 3aa4d7e946..68ef31cab3 100644 --- a/cli/cmd/scan.go +++ b/cli/cmd/scan.go @@ -210,20 +210,24 @@ func currentScanBuilder(cfg ScanConfig, sourcePath string) *utils.OpentaintComma return b } -// resolveRuleIDs determines which rules the analyzer should run. +// 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 nil when nothing restricts the rules, which runs the whole ruleset. -func resolveRuleIDs(cfg ScanConfig, absRuleSetPaths []RulesetType) []string { +// 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) @@ -232,7 +236,7 @@ func resolveRuleIDs(cfg ScanConfig, absRuleSetPaths []RulesetType) []string { if cfg.ExpandRuleRefs { ids = rules.ExpandRuleIDs(ids, rulesetRoots) } - return ids + return rules.Resolved{Include: ids} } selection := configuredRuleSelection(cfg) @@ -404,7 +408,7 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { // 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. - ruleIDs := resolveRuleIDs(cfg, absRuleSetPaths) + resolvedRules := resolveRuleIDs(cfg, absRuleSetPaths) if cfg.DryRun { runDryRun("the build and scan") @@ -497,9 +501,12 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { if maxMemory != "" { nativeBuilder.SetMaxMemory(maxMemory) } - 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) diff --git a/cli/internal/rules/select.go b/cli/internal/rules/select.go index e7d49f9bb4..fb361adcf6 100644 --- a/cli/internal/rules/select.go +++ b/cli/internal/rules/select.go @@ -65,41 +65,57 @@ func isRuleFile(path string) bool { return ext == ".yaml" || ext == ".yml" } -// Select resolves a Selection against the ruleset roots and returns the rule ids -// to pass to the analyzer, or nil when the selection restricts nothing (in which -// case the analyzer runs every rule, as it always has). +// 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. // -// The analyzer only supports inclusion, so an exclusion list is applied by -// enumerating every rule and subtracting. Rules referenced by the survivors are -// then 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. -func Select(selection Selection, roots []string) ([]string, error) { +// 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 nil, nil + return Resolved{}, nil } all := ListRuleIDs(roots) if len(all) == 0 { - return nil, fmt.Errorf("rules.only/rules.exclude are configured but no rules were found in the ruleset") + return Resolved{}, fmt.Errorf("rules.only/rules.exclude are configured but no rules were found in the ruleset") } - var kept []string + 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 nil, fmt.Errorf("rules.only/rules.exclude select no rules at all; nothing would be scanned") + 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 expanded, nil + return Resolved{Include: expanded}, nil } // matchesAny delegates to the one rule-id grammar (sarif.MatchesRuleID), so a diff --git a/cli/internal/rules/select_test.go b/cli/internal/rules/select_test.go index 177135732e..9b1e123dec 100644 --- a/cli/internal/rules/select_test.go +++ b/cli/internal/rules/select_test.go @@ -94,8 +94,8 @@ func TestSelectWithNeitherListReturnsNothing(t *testing.T) { if err != nil { t.Fatalf("select: %v", err) } - if got != nil { - t.Errorf("got %v, want nil: with no lists the analyzer runs every rule", got) + if got.Include != nil || got.Exclude != nil { + t.Errorf("got %+v, want zero: with no lists the analyzer runs every rule", got) } } @@ -107,12 +107,14 @@ func TestSelectOnly(t *testing.T) { if err != nil { t.Fatalf("select: %v", err) } - if len(got) != 1 || got[0] != "a.yaml:keep-me" { - t.Errorf("got %v", got) + if len(got.Include) != 1 || got.Include[0] != "a.yaml:keep-me" || len(got.Exclude) != 0 { + t.Errorf("got %+v", got) } } -func TestSelectExclude(t *testing.T) { +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", }) @@ -120,8 +122,11 @@ func TestSelectExclude(t *testing.T) { if err != nil { t.Fatalf("select: %v", err) } - if len(got) != 1 || got[0] != "a.yaml:keep-me" { - t.Errorf("got %v", got) + 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) } } @@ -133,8 +138,8 @@ func TestSelectExcludeAppliesAfterOnly(t *testing.T) { if err != nil { t.Fatalf("select: %v", err) } - if len(got) != 1 || got[0] != "a.yaml:sqli-one" { - t.Errorf("got %v", got) + if len(got.Include) != 1 || got.Include[0] != "a.yaml:sqli-one" { + t.Errorf("got %+v", got) } } @@ -147,25 +152,28 @@ func TestSelectPullsInReferencedRules(t *testing.T) { if err != nil { t.Fatalf("select: %v", err) } - sort.Strings(got) - if len(got) != 2 || got[1] != "security/sqli.yaml:sql-injection" || got[0] != "lib/sources.yaml:servlet-source" { - t.Errorf("got %v, want the rule plus the library rule it joins", got) + 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 TestSelectReAddsAnExcludedRuleThatSurvivorsNeed(t *testing.T) { - // Excluding a library rule that a kept rule joins against would produce a - // rule that cannot match anything. Reference expansion brings it back. +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{Exclude: []string{"lib/**"}}, []string{root}) + got, err := Select(Selection{Only: []string{"**"}, Exclude: []string{"lib/**"}}, []string{root}) if err != nil { t.Fatalf("select: %v", err) } - if len(got) != 2 { - t.Errorf("got %v, want the excluded library rule restored", got) + if len(got.Include) != 2 { + t.Errorf("got %+v, want the excluded library rule restored", got) } } diff --git a/docs/configuration.md b/docs/configuration.md index 9392f50311..146a1c9560 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -71,11 +71,13 @@ rules: 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`. A library rule that a selected rule joins -against is always kept, 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. +`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 From ba6ed853354641a872f72ba81eb85d14773efe74 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Tue, 28 Jul 2026 15:39:18 +0200 Subject: [PATCH 19/36] refactor(cli): split baseline-state into a write switch and a display filter The single --baseline-state name carried two grammars: a boolean on scan/triage that persisted result.baselineState into the file, and a value list on summary that filtered the listing. Rename the scan/triage switch to --write-baseline-state (matching its WriteBaselineState field and its intent), leaving --baseline-state exclusively as the summary filter. The filter help now spells out the accepted values (new | unchanged | updated | absent). No behavior change beyond the flag name; --error-on-severity is unchanged. --- cli/cmd/scan.go | 6 +++--- cli/cmd/summary.go | 2 +- cli/cmd/triage.go | 4 ++-- cli/internal/utils/opentaint_command_builder.go | 6 +++--- docs/usage.md | 11 ++++++----- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/cli/cmd/scan.go b/cli/cmd/scan.go index 68ef31cab3..82a651d053 100644 --- a/cli/cmd/scan.go +++ b/cli/cmd/scan.go @@ -183,7 +183,7 @@ func addScanFlags(cmd *cobra.Command) { cmd.Flags().BoolVar(&scanFlags.TrackExternalMethods, "track-external-methods", false, "Write external-method coverage files next to the SARIF report") addBaselineFlags(cmd, &scanFlags.Baseline, &scanFlags.FingerprintKey) - cmd.Flags().BoolVar(&scanFlags.WriteBaselineState, "baseline-state", false, "Write result.baselineState and run.baselineGuid into the report") + 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) } @@ -200,7 +200,7 @@ func currentScanBuilder(cfg ScanConfig, sourcePath string) *utils.OpentaintComma WithDataflowApproximations(cfg.DataflowApproximations). WithTrackExternalMethods(cfg.TrackExternalMethods). WithBaseline(cfg.Baseline). - WithBaselineState(cfg.WriteBaselineState). + WithWriteBaselineState(cfg.WriteBaselineState). WithFingerprintKey(cfg.FingerprintKey). WithErrorOnFindings(cfg.ErrorOnFindings). WithErrorOnSeverity(cfg.ErrorOnSeverity) @@ -352,7 +352,7 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { out.Fatalf("%s", err) } if cfg.WriteBaselineState && cfg.Baseline == "" { - out.Fatalf("--baseline-state needs a --baseline to compare against") + out.Fatalf("--write-baseline-state needs a --baseline to compare against") } var baseline *sarif.Report var absBaselinePath string diff --git a/cli/cmd/summary.go b/cli/cmd/summary.go index b12ed23e03..85a399a1cb 100644 --- a/cli/cmd/summary.go +++ b/cli/cmd/summary.go @@ -134,7 +134,7 @@ func init() { 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, &summaryFingerprintKey) - summaryCmd.Flags().StringArrayVar(&summaryBaselineStates, "baseline-state", nil, "Show only findings in this baseline state: new, unchanged, updated, absent (repeatable, needs --baseline)") + summaryCmd.Flags().StringArrayVar(&summaryBaselineStates, "baseline-state", nil, "Show only findings whose baseline state is one of: new | unchanged | updated | absent (repeatable, needs --baseline)") summaryCmd.Flags().BoolVar(&summaryShowSuppressed, "suppressed", false, "Include suppressed findings in the listing") } diff --git a/cli/cmd/triage.go b/cli/cmd/triage.go index 2afa156fef..4776b146b6 100644 --- a/cli/cmd/triage.go +++ b/cli/cmd/triage.go @@ -77,7 +77,7 @@ func init() { rootCmd.AddCommand(triageCmd) addBaselineFlags(triageCmd, &triageFlags.Baseline, &triageFlags.FingerprintKey) - triageCmd.Flags().BoolVar(&triageFlags.WriteBaselineState, "baseline-state", false, "Write result.baselineState and run.baselineGuid into the report") + 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)") @@ -117,7 +117,7 @@ func runTriage(cfg TriageConfig, reportPath string) { if cfg.Baseline != "" { opts.Baseline, opts.BaselinePath = loadBaselineOrExit(cfg.Baseline, absReportPath) } else if cfg.WriteBaselineState { - out.Fatalf("--baseline-state needs a --baseline to compare against") + out.Fatalf("--write-baseline-state needs a --baseline to compare against") } outcome, err := triage.Apply(report, opts) diff --git a/cli/internal/utils/opentaint_command_builder.go b/cli/internal/utils/opentaint_command_builder.go index 273673de0b..a0ba01db2c 100644 --- a/cli/internal/utils/opentaint_command_builder.go +++ b/cli/internal/utils/opentaint_command_builder.go @@ -398,10 +398,10 @@ func (cb *OpentaintCommandBuilder) WithBaseline(path string) *OpentaintCommandBu return cb } -// WithBaselineState sets the --baseline-state flag. -func (cb *OpentaintCommandBuilder) WithBaselineState(enabled bool) *OpentaintCommandBuilder { +// WithWriteBaselineState sets the --write-baseline-state flag (scan/triage). +func (cb *OpentaintCommandBuilder) WithWriteBaselineState(enabled bool) *OpentaintCommandBuilder { if enabled { - cb.boolFlags["baseline-state"] = true + cb.boolFlags["write-baseline-state"] = true } return cb } diff --git a/docs/usage.md b/docs/usage.md index b415da62ba..d3e122f752 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -115,7 +115,7 @@ On the first run, the compiled project model is cached in `~/.opentaint/cache/`. | Flag | Description | |------|-------------| | `--baseline` | Previous SARIF report to compare against and inherit suppressions from | -| `--baseline-state` | Write `result.baselineState` and `run.baselineGuid` into the report (needs `--baseline`) | +| `--write-baseline-state` | Persist `result.baselineState` and `run.baselineGuid` into the output report (needs `--baseline`) | | `--fingerprint-key` | partialFingerprints key identifying a finding across reports (default `vulnerabilitySourceSinkHash/v1`) | | `--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: `error`, `warning`, `note`, `none` (repeatable, default all) | @@ -226,7 +226,7 @@ reflects the full set the tool ran. | `--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/fixed counts. The file is never modified. | -| `--baseline-state` | Show only findings in this state: `new`, `unchanged`, `updated`, `absent` (repeatable, needs `--baseline`) | +| `--baseline-state` | Show only findings whose state is one of `new` \| `unchanged` \| `updated` \| `absent` (repeatable, needs `--baseline`) | | `--suppressed` | Include suppressed findings in the listing (hidden by default) | | `--fingerprint-key` | partialFingerprints key identifying a finding across reports (default `vulnerabilitySourceSinkHash/v1`) | @@ -252,7 +252,7 @@ opentaint triage scan.sarif --defer 8bc1d2 --justification "waiting on OT-412" | Flag | Description | |------|-------------| | `--baseline` | Previous SARIF report to compare against and inherit suppressions from | -| `--baseline-state` | Write `result.baselineState` and `run.baselineGuid` into the report (needs `--baseline`) | +| `--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) | @@ -284,8 +284,9 @@ it, both expressed in SARIF 2.1.0's own vocabulary. **Baseline comparison** answers "is this new?". `--baseline old.sarif` classifies every finding as new, unchanged, updated (same source and sink, a different path through the code), or fixed. By default this only affects what is -printed; `--baseline-state` also writes `result.baselineState` and -`run.baselineGuid` into the report. Findings are matched by fingerprint, not by +printed; `--write-baseline-state` also persists `result.baselineState` and +`run.baselineGuid` into the report. (Not to be confused with `summary +--baseline-state `, which *filters* the listing by state.) Findings are matched by fingerprint, not by line number, so moving code around does not invent new findings. **Suppression** answers "did a human accept this?". Presence in a baseline is From 9506b1dc1457a71a19bbd1e6e266cc9a7a08b75a Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Tue, 28 Jul 2026 22:52:28 +0200 Subject: [PATCH 20/36] fix(cli): accept comma-separated --error-on-severity values The flag validated each repeatable value as a single token, so the natural --error-on-severity error,warning was rejected with "invalid severity". Split each value on commas so the comma form and the repeated-flag form are equivalent. Purely CLI-side (the gate never reaches the analyzer); the docs already used the comma form, which now works. --- cli/cmd/triage.go | 2 +- cli/internal/triage/gate.go | 21 ++++++++++++------- cli/internal/triage/gate_test.go | 36 ++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/cli/cmd/triage.go b/cli/cmd/triage.go index 4776b146b6..5176ab2ab5 100644 --- a/cli/cmd/triage.go +++ b/cli/cmd/triage.go @@ -91,7 +91,7 @@ func init() { // 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 (new ones only, with --baseline)") - cmd.Flags().StringArrayVar(severities, "error-on-severity", nil, "Restrict --error-on-findings to these levels: error, warning, note, none (repeatable, default all)") + cmd.Flags().StringArrayVar(severities, "error-on-severity", nil, "Restrict --error-on-findings to these levels: error, warning, note, none (comma-separated or repeated; default all)") } func runTriage(cfg TriageConfig, reportPath string) { diff --git a/cli/internal/triage/gate.go b/cli/internal/triage/gate.go index e343f563c8..61809c05ac 100644 --- a/cli/internal/triage/gate.go +++ b/cli/internal/triage/gate.go @@ -64,18 +64,23 @@ func (g Gate) inScope(r *sarif.Result) bool { return len(g.Severities) == 0 || sarif.MatchesSeverity(r, g.Severities) } -// ParseGateSeverities validates --error-on-severity values. +// 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 { - normalized := strings.ToLower(strings.TrimSpace(v)) - if normalized == "" { - continue - } - if err := sarif.ValidateSeverity(normalized); err != nil { - return nil, err + for _, token := range strings.Split(v, ",") { + normalized := strings.ToLower(strings.TrimSpace(token)) + if normalized == "" { + continue + } + if err := sarif.ValidateSeverity(normalized); err != nil { + return nil, err + } + out = append(out, normalized) } - out = append(out, normalized) } return out, nil } diff --git a/cli/internal/triage/gate_test.go b/cli/internal/triage/gate_test.go index 53a43acf2e..1fc9dcfae1 100644 --- a/cli/internal/triage/gate_test.go +++ b/cli/internal/triage/gate_test.go @@ -134,3 +134,39 @@ func TestParseGateSeverities(t *testing.T) { 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) + } +} From d9836ca3392a7ef59c928a43e9a9bb90f6ffb3ae Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 30 Jul 2026 11:01:28 +0200 Subject: [PATCH 21/36] docs: add Baselines, suppressions & CI gating guide A dedicated task-oriented guide for the new capabilities: the two-axis mental model (baseline state vs. suppression), the full lifecycle from establishing a baseline through triage inheritance to gating, and reference tables for baseline states, suppression statuses, fingerprint identity, the failure gate + exit codes, and rule selection. Includes copy-paste GitHub Actions (actions/cache to carry the baseline) and GitLab recipes, plus the SARIF 2.1.0 conformance notes. Linked from the docs index (Guides + CI/CD) and the usage guide, whose long inline section is trimmed to a concise pointer keeping the flag tables. --- docs/README.md | 2 + docs/baselines-and-suppressions.md | 390 +++++++++++++++++++++++++++++ docs/usage.md | 58 ++--- 3 files changed, 413 insertions(+), 37 deletions(-) create mode 100644 docs/baselines-and-suppressions.md diff --git a/docs/README.md b/docs/README.md index 75c4353953..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 @@ -168,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..14050d4b28 --- /dev/null +++ b/docs/baselines-and-suppressions.md @@ -0,0 +1,390 @@ +# 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. `--accept`, `--defer`, and `--unsuppress` are +repeatable; one `--justification` applies to every decision in the invocation. + +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, gone now (i.e. fixed) | + +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. + +`absent` (fixed) findings are counted and can be listed, but are never written +into the output report — surfacing a fixed finding as a live alert would be wrong. + +### Finding identity + +Findings are matched across reports by a **fingerprint**, not by line number, so +moving code around does not invent new findings. Two fingerprints exist: + +| Key | Hashes | Behavior | +|-----|--------|----------| +| `vulnerabilitySourceSinkHash/v1` | rule + source + sink | Survives edits to the call path between source and sink. **Default for baseline matching.** | +| `vulnerabilityWithTraceHash/v1` | rule + every step of every trace | Exact; changes if anything on the path moves. | + +`--fingerprint-key` overrides the identity key on `scan`, `triage`, and +`summary`. The source→sink hash is the default because a decision should survive +refactoring of an unrelated helper the flow happens to pass through. The finer +trace hash is what distinguishes `unchanged` from `updated`. + +Comparing reports built with different fingerprint keys is a hard error, not a +silent zero-match. Findings that carry no fingerprint at all (a report produced +without fingerprints) 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: +- 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/usage.md b/docs/usage.md index d3e122f752..2a01931812 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -278,47 +278,31 @@ Exit codes: ## Baselines and suppressions -A baseline is just a SARIF report you kept. Two independent things are built on -it, both expressed in SARIF 2.1.0's own vocabulary. - -**Baseline comparison** answers "is this new?". `--baseline old.sarif` -classifies every finding as new, unchanged, updated (same source and sink, a -different path through the code), or fixed. By default this only affects what is -printed; `--write-baseline-state` also persists `result.baselineState` and -`run.baselineGuid` into the report. (Not to be confused with `summary ---baseline-state `, which *filters* the listing by state.) Findings are matched by fingerprint, not by -line number, so moving code around does not invent new findings. - -**Suppression** answers "did a human accept this?". Presence in a baseline is -not acceptance — a baseline entry that carries no suppression only makes a -finding `unchanged`. A finding is suppressed only when someone decided so with -`opentaint triage`, which writes a SARIF suppression: - -| Decision | `suppression.status` | Meaning | -|----------|----------------------|---------| -| `--accept` | `accepted` | The team will not fix this | -| `--defer` | `underReview` | The team is not fixing this for now | - -Both hide the finding from the listing and from the failure gate, and both -require a justification. A deferral does not expire on its own; the summary's -`Deferred` count is what keeps it visible. - -Decisions travel forward through the baseline. A finding matching a baseline -entry that carries a suppression inherits it verbatim — same status, same -justification, same guid — so a decision is authored once and re-attached by -every later scan for as long as the fingerprint matches. When the code is fixed -and the finding disappears, the decision retires with it. - -A typical CI setup keeps the last accepted report and fails only on new work: +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 -opentaint scan --baseline baselines/main.sarif -o scan.sarif \ - --error-on-findings --error-on-severity error,warning . +# 1. Scan once; 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 . ``` -Suppressions read from a baseline are interpreted conservatively: an entry whose -status is `rejected`, or anything unrecognised, never hides a finding, and the -summary counts it under `Not honored` so nothing disappears quietly. +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 From 8d2b4241d7f3ab47b8a4a5a714259bc009adcb73 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Wed, 5 Aug 2026 14:29:42 +0200 Subject: [PATCH 22/36] fix(cli): one fingerprint identity, honest baseline-state and Fixed counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field-testing baselines on three Stirling-PDF releases surfaced six defects. The fingerprint summary printed was not the one triage resolved: the listing showed vulnerabilityWithTraceHash/v1 while triage --accept matched vulnerabilitySourceSinkHash/v1, so the documented copy-paste workflow always failed. One --fingerprint-key now governs baseline matching, the printed value, --partial-fingerprint, and the triage prefix; --partial-fingerprint-key is a deprecated alias. summary --baseline-state with no states to read printed "0 findings" and exited 0 — a clean bill of health for a report nobody compared. It now reads persisted states (so it works standalone on a --write-baseline-state report) and errors when there are none. --baseline-state absent could never match, since fixed findings live only in the baseline; it now lists them. Excluding a rule reported its baseline findings as Fixed, indistinguishable from real fixes. Findings whose rule did not run in the current scan are counted separately as "Rule not run". Under a display filter the Baseline and Suppressions groups still described the whole report; the counts are now recomputed over what was printed. A repeated --justification silently applied the last one to every decision in the run. It is now an error. --error-on-severity reported "invalid --severity"; it names the flag the user typed. --- cli/cmd/summary.go | 68 +++++++++++++++++-- cli/cmd/triage.go | 32 +++++++-- cli/cmd/triage_flags_test.go | 54 +++++++++++++++ cli/internal/sarif/baseline.go | 69 ++++++++++++++++++- cli/internal/sarif/baseline_test.go | 80 +++++++++++++++++++++++ cli/internal/sarif/filter.go | 49 +++++++++++--- cli/internal/sarif/filter_test.go | 2 +- cli/internal/sarif/render_test.go | 6 +- cli/internal/sarif/triage_summary_test.go | 80 +++++++++++++++++++++++ cli/internal/sarif/triage_view.go | 56 ++++++++++++++++ cli/internal/triage/gate.go | 2 +- docs/baselines-and-suppressions.md | 41 ++++++++++-- docs/usage.md | 8 +-- 13 files changed, 512 insertions(+), 35 deletions(-) create mode 100644 cli/cmd/triage_flags_test.go diff --git a/cli/cmd/summary.go b/cli/cmd/summary.go index 85a399a1cb..000bb48add 100644 --- a/cli/cmd/summary.go +++ b/cli/cmd/summary.go @@ -1,6 +1,8 @@ package cmd import ( + "fmt" + "github.com/seqra/opentaint/internal/sarif" "github.com/seqra/opentaint/internal/triage" "github.com/seqra/opentaint/internal/utils" @@ -58,12 +60,16 @@ This command only reads the report. It does not write files.`, if err != nil { out.Fatalf("%s", err) } + resolveSummaryFingerprintKey() absSarifPath := log.AbsPathOrExit(args[0], "sarif path") report, err := sarif.LoadReport(absSarifPath) if err != nil { out.Fatalf("Failed to load SARIF report: %s", err) } + 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. @@ -82,6 +88,42 @@ This command only reads the report. It does not write files.`, }, } +// resolveSummaryFingerprintKey collapses --fingerprint-key and the older +// --partial-fingerprint-key into the single key summary uses for everything it +// does with fingerprints: baseline matching, --partial-fingerprint, and the +// value printed as "Fingerprint:". One key means the fingerprint the listing +// shows is always the one `triage --accept` resolves. +func resolveSummaryFingerprintKey() { + if summaryPartialFingerprintKey == "" { + return + } + if summaryFingerprintKey != "" && summaryFingerprintKey != summaryPartialFingerprintKey { + out.Fatalf("--fingerprint-key %q and --partial-fingerprint-key %q disagree: pass --fingerprint-key alone", + summaryFingerprintKey, summaryPartialFingerprintKey) + } + // cobra already prints the deprecation notice for the flag itself. + summaryFingerprintKey = summaryPartialFingerprintKey +} + +// 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 + } + 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 { @@ -128,13 +170,16 @@ func init() { 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 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 partial fingerprint starts with this value (git-hash style, repeatable)") - summaryCmd.Flags().StringVar(&summaryPartialFingerprintKey, "partial-fingerprint-key", "", "partialFingerprints key matched by --partial-fingerprint (defaults to vulnerabilityWithTraceHash/v1)") + summaryCmd.Flags().StringArrayVar(&summaryFingerprints, "partial-fingerprint", nil, "Show only findings whose fingerprint starts with this value (git-hash style, repeatable)") + summaryCmd.Flags().StringVar(&summaryPartialFingerprintKey, "partial-fingerprint-key", "", "Deprecated alias for --fingerprint-key") + if err := summaryCmd.Flags().MarkDeprecated("partial-fingerprint-key", "use --fingerprint-key"); err != nil { + panic(err) + } 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 (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, &summaryFingerprintKey) - summaryCmd.Flags().StringArrayVar(&summaryBaselineStates, "baseline-state", nil, "Show only findings whose baseline state is one of: new | unchanged | updated | absent (repeatable, needs --baseline)") + summaryCmd.Flags().StringArrayVar(&summaryBaselineStates, "baseline-state", nil, "Show only findings whose baseline state is one of: new | unchanged | updated | absent (repeatable; reads states persisted by --write-baseline-state, or computed now from --baseline)") summaryCmd.Flags().BoolVar(&summaryShowSuppressed, "suppressed", false, "Include suppressed findings in the listing") } @@ -142,7 +187,7 @@ func init() { // a report against a baseline. func addBaselineFlags(cmd *cobra.Command, baseline *string, fingerprintKey *string) { cmd.Flags().StringVar(baseline, "baseline", "", "Previous SARIF report to compare against and inherit suppressions from") - cmd.Flags().StringVar(fingerprintKey, "fingerprint-key", "", "partialFingerprints key identifying a finding across reports (default "+sarif.DefaultIdentityKey+")") + cmd.Flags().StringVar(fingerprintKey, "fingerprint-key", "", "partialFingerprints key identifying a finding: matched across reports, shown in the listing, and resolved by triage (default "+sarif.DefaultIdentityKey+")") } // loadBaselineOrExit resolves and loads a baseline report, refusing to use the @@ -197,7 +242,7 @@ func summaryFilters() sarif.Filters { Severities: summarySeverities, RuleIDs: summaryRuleIDs, Fingerprints: summaryFingerprints, - FingerprintKey: summaryPartialFingerprintKey, + FingerprintKey: summaryFingerprintKey, } } @@ -211,7 +256,7 @@ func summaryListingOptions(dim sarif.GroupDimension, codeFlowSel sarif.CodeFlowS VerboseFlow: verboseFlow, MaxNestingLevel: summaryMaxNestingLevel, GroupBy: dim, - FingerprintKey: summaryPartialFingerprintKey, + FingerprintKey: summaryFingerprintKey, CodeFlows: codeFlowSel, ShowSuppressed: summaryShowSuppressed, } @@ -222,10 +267,19 @@ func summaryListingOptions(dim sarif.GroupDimension, codeFlowSel sarif.CodeFlowS // 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) hasOmittedFlow := false if list { - hasOmittedFlow = filtered.PrintAll(out, opts) + // Fixed 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() } diff --git a/cli/cmd/triage.go b/cli/cmd/triage.go index 5176ab2ab5..d4f172da08 100644 --- a/cli/cmd/triage.go +++ b/cli/cmd/triage.go @@ -22,7 +22,7 @@ type TriageConfig struct { Accept []string Defer []string Unsuppress []string - Justification string + Justifications []string Output string ErrorOnFindings bool ErrorOnSeverity []string @@ -47,7 +47,9 @@ Arguments: sarif - Path to the SARIF report to triage A finding is named by a fingerprint prefix, git-style — the value shown as -"Fingerprint:" by 'opentaint summary --show-findings'. +"Fingerprint:" by 'opentaint summary --show-findings'. Both commands read the +same key, so the value on screen is the value to paste here; --fingerprint-key +changes it on either side. Examples: # See what changed since the last release, without modifying anything @@ -81,7 +83,7 @@ func init() { 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().StringVar(&triageFlags.Justification, "justification", "", "Why the finding is accepted or deferred (required with --accept/--defer)") + 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", "", "Write the triaged report here (default: rewrite the input in place)") addGateFlags(triageCmd, &triageFlags.ErrorOnFindings, &triageFlags.ErrorOnSeverity) triageCmd.Flags().BoolVar(&triageFlags.ShowSuppressed, "suppressed", false, "Include suppressed findings in the listing") @@ -99,6 +101,10 @@ func runTriage(cfg TriageConfig, reportPath string) { 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) @@ -112,7 +118,7 @@ func runTriage(cfg TriageConfig, reportPath string) { Accept: cfg.Accept, Defer: cfg.Defer, Unsuppress: cfg.Unsuppress, - Justification: cfg.Justification, + Justification: justification, } if cfg.Baseline != "" { opts.Baseline, opts.BaselinePath = loadBaselineOrExit(cfg.Baseline, absReportPath) @@ -140,11 +146,29 @@ func runTriage(cfg TriageConfig, reportPath string) { printSarifSummary(report, outputPath, sarif.Filters{}, sarif.ListingOptions{ MaxNestingLevel: -1, ShowSuppressed: cfg.ShowSuppressed, + FingerprintKey: cfg.FingerprintKey, }, 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) diff --git a/cli/cmd/triage_flags_test.go b/cli/cmd/triage_flags_test.go new file mode 100644 index 0000000000..237310b0ef --- /dev/null +++ b/cli/cmd/triage_flags_test.go @@ -0,0 +1,54 @@ +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) + } +} diff --git a/cli/internal/sarif/baseline.go b/cli/internal/sarif/baseline.go index efd32d6ecc..43cc71480f 100644 --- a/cli/internal/sarif/baseline.go +++ b/cli/internal/sarif/baseline.go @@ -17,6 +17,10 @@ type Comparison struct { // Absent lists the baseline results that no longer appear — the fixed // findings. 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 @@ -87,17 +91,80 @@ func CompareToBaseline(current, baseline *Report, key string) (*Comparison, erro cmp.Counts[state]++ } + executed := current.executedRuleIDs() for id, results := range byIdentity { if matched[id] { continue } - cmp.Absent = append(cmp.Absent, results...) + 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) return cmp, nil } +// 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 fixed +// 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 { + fixed := *r + state := Absent + fixed.BaselineState = &state + results = append(results, fixed) + } + 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 fixed. +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] +} + // sameTrace reports whether the current result's full-trace fingerprint equals // that of any baseline result sharing its identity. A missing trace fingerprint // on either side counts as unchanged: the finer comparison is unavailable, and diff --git a/cli/internal/sarif/baseline_test.go b/cli/internal/sarif/baseline_test.go index 61fa0150d5..fefdad3b06 100644 --- a/cli/internal/sarif/baseline_test.go +++ b/cli/internal/sarif/baseline_test.go @@ -251,3 +251,83 @@ func TestReportBaselineGUIDReadsFirstRun(t *testing.T) { 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, SourceSinkFingerprintKey) + 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, SourceSinkFingerprintKey) + 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; only the copy may be") + } +} diff --git a/cli/internal/sarif/filter.go b/cli/internal/sarif/filter.go index 83197f7e2c..5ebd2b573f 100644 --- a/cli/internal/sarif/filter.go +++ b/cli/internal/sarif/filter.go @@ -7,10 +7,6 @@ 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 { @@ -18,7 +14,7 @@ type Filters struct { 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) + FingerprintKey string // partialFingerprints key to match ("" = DefaultIdentityKey) BaselineStates []string // SARIF baselineState values: new/unchanged/updated/absent } @@ -76,6 +72,36 @@ func (f Filters) matches(r *Result) bool { return true } +// matchesAs is matches for a result whose baseline state is known from the +// comparison rather than carried on the result itself. Fixed 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 fixed 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. @@ -191,10 +217,11 @@ func MatchesRuleID(full string, values []string) bool { // 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. +// identity key is used — the same one triage resolves prefixes against, so a +// fingerprint shown in the listing can always be pasted into triage --accept. func fingerprintValue(r *Result, key string) string { if key == "" { - key = DefaultFingerprintKey + key = DefaultIdentityKey } v, _ := Identity(r, key) return v @@ -221,8 +248,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 e4c7d7eb95..6a56409ea5 100644 --- a/cli/internal/sarif/filter_test.go +++ b/cli/internal/sarif/filter_test.go @@ -36,7 +36,7 @@ func TestMatchSeverity(t *testing.T) { func TestMatchFingerprint(t *testing.T) { r := makeResult("r", Error, "a.java", 1, map[string]string{ - DefaultFingerprintKey: "abc123def456", + DefaultIdentityKey: "abc123def456", }) if !matchFingerprint(&r, "", []string{"abc123"}) { t.Error("expected git-style prefix match on default key") diff --git a/cli/internal/sarif/render_test.go b/cli/internal/sarif/render_test.go index fec56cd8a4..2e704c09fc 100644 --- a/cli/internal/sarif/render_test.go +++ b/cli/internal/sarif/render_test.go @@ -18,7 +18,7 @@ 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", + DefaultIdentityKey: "abcdefghijklmnopqrstuv", }) if got := fingerprintAbbrev(&r, ""); got != "abcdefghijkl" { // 12 chars t.Errorf("fingerprintAbbrev = %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{DefaultIdentityKey: "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", + DefaultIdentityKey: "abc123def456ghi", }) out := renderListing(t, makeReport(r), ListingOptions{MaxNestingLevel: -1}) if !strings.Contains(out, "Fingerprint:") { diff --git a/cli/internal/sarif/triage_summary_test.go b/cli/internal/sarif/triage_summary_test.go index 5792b75242..64471088a5 100644 --- a/cli/internal/sarif/triage_summary_test.go +++ b/cli/internal/sarif/triage_summary_test.go @@ -126,3 +126,83 @@ func TestSummarySuppressionsGroupReportsNotHonored(t *testing.T) { 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, SourceSinkFingerprintKey) + 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("Fixed = %d, want 1: only the xss finding survives the filter", got) + } + if got := view.Comparison.Counts[Absent]; got != 2 { + t.Errorf("unrestricted Fixed = %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, SourceSinkFingerprintKey) + 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("Fixed = %d, want 1", got) + } + + other := Filters{BaselineStates: []string{"new"}} + if got := view.Restrict(current.Filter(other), other).Comparison.Counts[Absent]; got != 0 { + t.Errorf("Fixed = %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, DefaultIdentityKey, shown) + if err != nil { + t.Fatalf("the fingerprint the listing shows does not resolve: %v", err) + } + if got, _ := Identity(resolved, DefaultIdentityKey); got != "source-sink-value" { + t.Errorf("resolved %q, want the source-sink value", got) + } +} diff --git a/cli/internal/sarif/triage_view.go b/cli/internal/sarif/triage_view.go index 044d87036f..0eedaf238f 100644 --- a/cli/internal/sarif/triage_view.go +++ b/cli/internal/sarif/triage_view.go @@ -31,6 +31,56 @@ type TriageView struct { 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, + Counts: make(map[BaselineState]int), + BaselineGUID: c.BaselineGUID, + } + for _, r := range filtered.Results() { + if r.BaselineState == nil { + out.Unmatchable++ + continue + } + out.Counts[*r.BaselineState]++ + } + 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. @@ -59,6 +109,12 @@ func (v *TriageView) baselineItems(out *output.Printer) []any { if count := v.Comparison.Counts[Absent]; count > 0 { items = append(items, out.FieldItem("Fixed", count)) } + // Baseline findings whose rule did not run are deliberately not folded into + // "Fixed": 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)) } diff --git a/cli/internal/triage/gate.go b/cli/internal/triage/gate.go index 61809c05ac..f9ee89a925 100644 --- a/cli/internal/triage/gate.go +++ b/cli/internal/triage/gate.go @@ -76,7 +76,7 @@ func ParseGateSeverities(values []string) ([]string, error) { if normalized == "" { continue } - if err := sarif.ValidateSeverity(normalized); err != nil { + if err := sarif.ValidateSeverityFor("--error-on-severity", normalized); err != nil { return nil, err } out = append(out, normalized) diff --git a/docs/baselines-and-suppressions.md b/docs/baselines-and-suppressions.md index 14050d4b28..52adccd588 100644 --- a/docs/baselines-and-suppressions.md +++ b/docs/baselines-and-suppressions.md @@ -73,7 +73,9 @@ opentaint triage baselines/main.sarif \ 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. `--accept`, `--defer`, and `--unsuppress` are -repeatable; one `--justification` applies to every decision in the invocation. +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)): @@ -144,8 +146,26 @@ left byte-for-byte unchanged. Two flags control it: > 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. -`absent` (fixed) findings are counted and can be listed, but are never written -into the output report — surfacing a fixed finding as a live alert would be wrong. +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` (fixed) findings are never written into the output report — surfacing a +fixed 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 @@ -154,11 +174,17 @@ moving code around does not invent new findings. Two fingerprints exist: | Key | Hashes | Behavior | |-----|--------|----------| -| `vulnerabilitySourceSinkHash/v1` | rule + source + sink | Survives edits to the call path between source and sink. **Default for baseline matching.** | +| `vulnerabilitySourceSinkHash/v1` | rule + source + sink | Survives edits to the call path between source and sink. **Default.** | | `vulnerabilityWithTraceHash/v1` | rule + every step of every trace | Exact; changes if anything on the path moves. | -`--fingerprint-key` overrides the identity key on `scan`, `triage`, and -`summary`. The source→sink hash is the default because a decision should survive +`--fingerprint-key` selects it, and one key governs everything a command does +with fingerprints: baseline matching, 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`. (`--partial-fingerprint-key` is a deprecated +alias for `--fingerprint-key`.) + +The source→sink hash is the default because a decision should survive refactoring of an unrelated helper the flow happens to pass through. The finer trace hash is what distinguishes `unchanged` from `updated`. @@ -274,6 +300,9 @@ doublestar glob over the full id — the same grammar as `summary --rule-id`. `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 + `Fixed`, 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 diff --git a/docs/usage.md b/docs/usage.md index 2a01931812..5c38b74ce4 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -220,15 +220,15 @@ reflects the full set the tool ran. | `--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. | +| `--partial-fingerprint-key` | Deprecated alias for `--fingerprint-key` | | `--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/fixed counts. The file is never modified. | -| `--baseline-state` | Show only findings whose state is one of `new` \| `unchanged` \| `updated` \| `absent` (repeatable, needs `--baseline`) | +| `--baseline-state` | Show only findings whose state is one of `new` \| `unchanged` \| `updated` \| `absent` (repeatable). Reads the states persisted by `--write-baseline-state`, or the ones `--baseline` computes now; `absent` lists the fixed findings from the baseline. | | `--suppressed` | Include suppressed findings in the listing (hidden by default) | -| `--fingerprint-key` | partialFingerprints key identifying a finding across reports (default `vulnerabilitySourceSinkHash/v1`) | +| `--fingerprint-key` | partialFingerprints key identifying a finding (default `vulnerabilitySourceSinkHash/v1`). One key governs baseline matching, the `Fingerprint:` value shown, `--partial-fingerprint`, and the prefix `triage` resolves. | Filters combine as OR within a dimension and AND across dimensions. From c4edf21de2d797fabeb30f38a7e9eb28eb2cdba4 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Wed, 5 Aug 2026 14:46:06 +0200 Subject: [PATCH 23/36] docs: report on finding-fingerprint stability Nine runs of one unchanged project show that vulnerabilitySourceSinkHash/v1 changes between runs: 28 of 36 fingerprints stay the same. The report gives the cause chain from the graph construction to the fingerprint, the measurements at each step, the two causes the tests exclude, the one question that stays open, and the recommended change. Written in ASD-STE100 Simplified Technical English. --- docs/reports/fingerprint-stability.md | 283 ++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 docs/reports/fingerprint-stability.md diff --git a/docs/reports/fingerprint-stability.md b/docs/reports/fingerprint-stability.md new file mode 100644 index 0000000000..122b38c15b --- /dev/null +++ b/docs/reports/fingerprint-stability.md @@ -0,0 +1,283 @@ +# 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. + +--- + +## 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. From eb2643750570195b2879ad4e14f53451857da028 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Fri, 7 Aug 2026 11:02:28 +0200 Subject: [PATCH 24/36] feat(cli): sink-hash identity, and short names for the fingerprint keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Analyzer 908e924b3 emits a third fingerprint, vulnerabilitySinkHash/v1, which hashes the sink statement; 06c8d25e9 puts the rule id into it, so it is now the "rule and sink only" identity that docs/reports/fingerprint-stability.md asked for. The CLI accepts it, so a decision can be made about a vulnerable statement rather than about one route into it: it survives a change to where the untrusted data comes from, and one entry then covers every caller. --fingerprint-key also takes short names now — trace, source-sink, sink — so a user does not have to type a versioned SARIF key to choose an identity. Any other value still passes through unchanged, because a report may carry fingerprints this build does not know. All three keys hash the rule id, so matching stays a plain fingerprint comparison: no key can make two rules that fire on one statement look like one finding. The default stays source-sink. The stability report recommended promoting the sink hash to default, but that recommendation assumed the source/sink hash was unstable, and 908e924b3 fixed that; the report now carries a status section saying so. --- cli/cmd/summary.go | 3 +- cli/internal/sarif/identity.go | 38 +++++++++++++----- cli/internal/sarif/identity_test.go | 55 +++++++++++++++++++++++++++ docs/baselines-and-suppressions.md | 35 ++++++++++------- docs/reports/fingerprint-stability.md | 36 ++++++++++++++++++ docs/usage.md | 6 +-- 6 files changed, 147 insertions(+), 26 deletions(-) diff --git a/cli/cmd/summary.go b/cli/cmd/summary.go index 000bb48add..a6ef19345b 100644 --- a/cli/cmd/summary.go +++ b/cli/cmd/summary.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "strings" "github.com/seqra/opentaint/internal/sarif" "github.com/seqra/opentaint/internal/triage" @@ -187,7 +188,7 @@ func init() { // a report against a baseline. func addBaselineFlags(cmd *cobra.Command, baseline *string, fingerprintKey *string) { cmd.Flags().StringVar(baseline, "baseline", "", "Previous SARIF report to compare against and inherit suppressions from") - cmd.Flags().StringVar(fingerprintKey, "fingerprint-key", "", "partialFingerprints key identifying a finding: matched across reports, shown in the listing, and resolved by triage (default "+sarif.DefaultIdentityKey+")") + cmd.Flags().StringVar(fingerprintKey, "fingerprint-key", "", "Which fingerprint identifies a finding across reports, in the listing and in triage: "+strings.Join(sarif.IdentityAliases, " | ")+", or a partialFingerprints key (default source-sink)") } // loadBaselineOrExit resolves and loads a baseline report, refusing to use the diff --git a/cli/internal/sarif/identity.go b/cli/internal/sarif/identity.go index a65f93e4db..7907093a76 100644 --- a/cli/internal/sarif/identity.go +++ b/cli/internal/sarif/identity.go @@ -6,18 +6,34 @@ import ( "strings" ) -// Fingerprint keys emitted by the analyzer under result.partialFingerprints. +// 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 hashes the rule id, the sink, and every location on every -// trace: an exact identity that changes whenever anything on the flow path -// moves. SourceSinkFingerprintKey hashes the rule id, the sink, and the source -// (first) location of each trace, so it survives refactoring of the -// intermediate call path. +// 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" ) +// identityAliases are the short names accepted for the keys above, so a user +// writes --fingerprint-key sink rather than the versioned SARIF key. +var identityAliases = map[string]string{ + "trace": TraceFingerprintKey, + "source-sink": SourceSinkFingerprintKey, + "sourcesink": SourceSinkFingerprintKey, + "sink": SinkFingerprintKey, +} + +// IdentityAliases lists the short names in coarsening order, for help text. +var IdentityAliases = []string{"trace", "source-sink", "sink"} + // DefaultIdentityKey is the fingerprint key used to decide whether a finding in // one report is "the same finding" as one in another report. The source/sink // hash is the default because a suppression or baseline entry should survive @@ -25,9 +41,10 @@ const ( const DefaultIdentityKey = SourceSinkFingerprintKey // ResolveIdentityKey normalizes a user-supplied identity key, falling back to -// DefaultIdentityKey when unset. Any key is accepted — a report may carry -// fingerprints this build does not know about — but a blank one is rejected -// rather than silently matching nothing. +// DefaultIdentityKey when unset and expanding the short aliases. Any other key +// is accepted as written — a report may carry fingerprints this build does not +// know about — but a blank one is rejected rather than silently matching +// nothing. func ResolveIdentityKey(key string) (string, error) { if key == "" { return DefaultIdentityKey, nil @@ -36,6 +53,9 @@ func ResolveIdentityKey(key string) (string, error) { if trimmed == "" { return "", fmt.Errorf("fingerprint key must not be blank") } + if full, ok := identityAliases[strings.ToLower(trimmed)]; ok { + return full, nil + } return trimmed, nil } diff --git a/cli/internal/sarif/identity_test.go b/cli/internal/sarif/identity_test.go index 930ee07fb1..9c00172516 100644 --- a/cli/internal/sarif/identity_test.go +++ b/cli/internal/sarif/identity_test.go @@ -128,3 +128,58 @@ func TestResolvePrefixEmptyIsAnError(t *testing.T) { t.Error("expected empty prefix to error rather than match everything") } } + +func TestResolveIdentityKeyExpandsAliases(t *testing.T) { + cases := map[string]string{ + "sink": SinkFingerprintKey, + "SINK": SinkFingerprintKey, + " source-sink ": SourceSinkFingerprintKey, + "sourcesink": SourceSinkFingerprintKey, + "trace": TraceFingerprintKey, + } + for in, want := range cases { + got, err := ResolveIdentityKey(in) + if err != nil { + t.Fatalf("ResolveIdentityKey(%q): %v", in, err) + } + if got != want { + t.Errorf("ResolveIdentityKey(%q) = %q, want %q", in, got, want) + } + } +} + +func TestResolveIdentityKeyPassesUnknownKeysThrough(t *testing.T) { + got, err := ResolveIdentityKey("somethingElse/v9") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "somethingElse/v9" { + t.Errorf("got %q, want the key unchanged", got) + } +} + +// 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, SinkFingerprintKey) + 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/docs/baselines-and-suppressions.md b/docs/baselines-and-suppressions.md index 52adccd588..f46d5a7200 100644 --- a/docs/baselines-and-suppressions.md +++ b/docs/baselines-and-suppressions.md @@ -170,24 +170,33 @@ 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. Two fingerprints exist: - -| Key | Hashes | Behavior | -|-----|--------|----------| -| `vulnerabilitySourceSinkHash/v1` | rule + source + sink | Survives edits to the call path between source and sink. **Default.** | -| `vulnerabilityWithTraceHash/v1` | rule + every step of every trace | Exact; changes if anything on the path moves. | - -`--fingerprint-key` selects it, and one key governs everything a command does -with fingerprints: baseline matching, 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`. (`--partial-fingerprint-key` is a deprecated -alias for `--fingerprint-key`.) +moving code around does not invent new findings. Three fingerprints exist, from +the most exact identity to the coarsest: + +| `--fingerprint-key` | Full key | Hashes | Behavior | +|-----|-----|--------|----------| +| `trace` | `vulnerabilityWithTraceHash/v1` | rule + every step of every trace | Exact; changes if anything on the path moves. | +| `source-sink` | `vulnerabilitySourceSinkHash/v1` | rule + source + sink | Survives edits to the call path between source and sink. **Default.** | +| `sink` | `vulnerabilitySinkHash/v1` | the sink statement alone | Survives a change to where the untrusted data comes from. | + +`--fingerprint-key` takes the short name or the full key, and one key governs +everything a command does with fingerprints: baseline matching, 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`. (`--partial-fingerprint-key` is a +deprecated alias for `--fingerprint-key`.) The source→sink hash is the default because a decision should survive refactoring of an unrelated helper the flow happens to pass through. The finer trace hash is what distinguishes `unchanged` from `updated`. +Choose `sink` when you care about the vulnerable statement rather than how data +reaches it — one decision on `Runtime.exec(cmd)` then covers every route into it, +and stays put when a new caller adds another one. It is the coarsest identity, so +it also merges the most: several findings that differ only in their source become +one entry, and suppressing it suppresses them all. All three hash the rule id, so +no fingerprint ever spans two rules that fire on one statement. + Comparing reports built with different fingerprint keys is a hard error, not a silent zero-match. Findings that carry no fingerprint at all (a report produced without fingerprints) are reported as-is and counted as "not comparable." diff --git a/docs/reports/fingerprint-stability.md b/docs/reports/fingerprint-stability.md index 122b38c15b..16eb2488dc 100644 --- a/docs/reports/fingerprint-stability.md +++ b/docs/reports/fingerprint-stability.md @@ -6,6 +6,42 @@ --- +## Status: corrected in analyzer 908e924b3 + +**Do not use sections 1 to 7 as a statement about the analyzer of today.** They +describe the analyzer of 2026.08.01. The commit `908e924b3` ("Small fixes", +PR #336) corrects the cause. Read this section first, then read the rest as the +record of the defect. + +What the commit changes: the event order in the IFDS scheduler. An analyzer that +holds unprocessed zero-to-zero edges now goes first. This order comes from the +content, not from the time at which a thread puts an event into the queue. + +Measurement after the change, on the reproduction project +(`projects/local/taint-nondeterminism` in the regression harness), with 10 runs +and 5 different thread counts: + +| Measurement | Before | After | +|---|---|---| +| `vulnerabilitySourceSinkHash/v1` set | changes between runs | the same in all 10 runs | +| Complete SARIF results, all fields | change between runs | the same in all runs | +| Delayed-analyzer set in each round | changes between runs | the same in all runs | + +The same commit also 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`. + +Two parts of section 8 are **not** done, and must not be done as written: + +- **Do not make the sink hash the default key.** Section 8 asks for this because + the source/sink hash was not stable. The source/sink hash is stable now. The + sink hash is coarser: it merges the findings that have the same sink and + different sources. +- Change 2 (make the flow selection stable) is not done. The commit removes the + effect on the report. It does not sort the graph. + +--- + ## 1. Read this first Five facts. Read only this section if you have no time. diff --git a/docs/usage.md b/docs/usage.md index 5c38b74ce4..c7f578e570 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -116,7 +116,7 @@ On the first run, the compiled project model is cached in `~/.opentaint/cache/`. |------|-------------| | `--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`) | -| `--fingerprint-key` | partialFingerprints key identifying a finding across reports (default `vulnerabilitySourceSinkHash/v1`) | +| `--fingerprint-key` | Which fingerprint identifies a finding across reports: `trace`, `source-sink` (default), `sink`, or a full partialFingerprints key | | `--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: `error`, `warning`, `note`, `none` (repeatable, default all) | @@ -228,7 +228,7 @@ reflects the full set the tool ran. | `--baseline` | Compare against this SARIF report and show new/unchanged/updated/fixed counts. The file is never modified. | | `--baseline-state` | Show only findings whose state is one of `new` \| `unchanged` \| `updated` \| `absent` (repeatable). Reads the states persisted by `--write-baseline-state`, or the ones `--baseline` computes now; `absent` lists the fixed findings from the baseline. | | `--suppressed` | Include suppressed findings in the listing (hidden by default) | -| `--fingerprint-key` | partialFingerprints key identifying a finding (default `vulnerabilitySourceSinkHash/v1`). One key governs baseline matching, the `Fingerprint:` value shown, `--partial-fingerprint`, and the prefix `triage` resolves. | +| `--fingerprint-key` | Which fingerprint identifies a finding: `trace` (exact), `source-sink` (default), `sink` (the sink statement alone), or a full partialFingerprints key. One key governs baseline matching, the `Fingerprint:` value shown, `--partial-fingerprint`, and the prefix `triage` resolves. | Filters combine as OR within a dimension and AND across dimensions. @@ -260,7 +260,7 @@ opentaint triage scan.sarif --defer 8bc1d2 --justification "waiting on OT-412" | `--output`, `-o` | Write the triaged report here (default: rewrite the input in place) | | `--show-findings` | List the findings, not just the summary | | `--suppressed` | Include suppressed findings in the listing | -| `--fingerprint-key` | partialFingerprints key identifying a finding across reports (default `vulnerabilitySourceSinkHash/v1`) | +| `--fingerprint-key` | Which fingerprint identifies a finding across reports: `trace`, `source-sink` (default), `sink`, or a full partialFingerprints key | | `--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 (repeatable, default all) | From b4144d99e9112521d814d0826231313e57b52aa6 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Fri, 7 Aug 2026 12:24:32 +0200 Subject: [PATCH 25/36] feat(cli): default to the sink identity, and name what moved under it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sink hash becomes the default --fingerprint-key. It names the vulnerable statement and the rule, and nothing else, so a baseline entry or a triage decision survives every edit to how the untrusted data reaches the sink. It costs no precision. The analyzer reports one finding per rule and sink (VulnerabilityIdentity in TaintAnalysisUnitStorage.kt), so the coarsest key is still one fingerprint per finding: on the reproduction project, 180 findings and 180 distinct sink hashes. docs/reports/fingerprint-stability.md measured the same on Stirling-PDF, 36 of 36, and asked for exactly this change. A coarser identity leaves more underneath it, so a matched finding now reports what moved. SARIF has one word for all of it, "updated", which does not distinguish a sink that acquired a new source from a call path that was refactored — the first deserves a look, the second usually does not. The summary now reads "Updated, source changed" and "Updated, path changed", while result.baselineState stays "updated" for both, so --baseline-state keeps selecting either. The refinement follows the key ladder sink -> source-sink -> trace, so it also answers for a non-default identity: under source-sink only a moved path is reportable, because a moved source is already a different finding, and under trace nothing refines further. The coarsest difference wins, since a source that moves drags its trace along and the source is the more meaningful of the two. --- cli/internal/sarif/baseline.go | 89 +++++++++++++-- cli/internal/sarif/baseline_test.go | 128 ++++++++++++++++++++-- cli/internal/sarif/identity.go | 26 ++++- cli/internal/sarif/identity_test.go | 6 +- cli/internal/sarif/triage_summary_test.go | 4 +- cli/internal/sarif/triage_view.go | 16 ++- cli/internal/triage/triage_test.go | 1 + docs/baselines-and-suppressions.md | 39 +++++-- docs/reports/fingerprint-stability.md | 21 ++-- docs/usage.md | 6 +- 10 files changed, 287 insertions(+), 49 deletions(-) diff --git a/cli/internal/sarif/baseline.go b/cli/internal/sarif/baseline.go index 43cc71480f..8d2459271d 100644 --- a/cli/internal/sarif/baseline.go +++ b/cli/internal/sarif/baseline.go @@ -9,11 +9,15 @@ import ( // 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 + states map[*Result]BaselineState + changes map[*Result]Change // 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 — the fixed // findings. They are reported, never written back into the current report. Absent []*Result @@ -37,6 +41,45 @@ func (c *Comparison) StateOf(r *Result) BaselineState { return c.states[r] } +// 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 "" + } +} + +// 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] +} + // CompareToBaseline classifies every result in current against baseline, using // key as the identity fingerprint. Results that match are additionally compared // on the full-trace fingerprint to tell "unchanged" from "updated". @@ -63,10 +106,13 @@ func CompareToBaseline(current, baseline *Report, key string) (*Comparison, erro cmp := &Comparison{ states: make(map[*Result]BaselineState), + changes: make(map[*Result]Change), Counts: make(map[BaselineState]int), + ChangeCounts: make(map[Change]int), BaselineGUID: baseline.RunGUID(), } + refinements := finerKeys(key) matched := make(map[string]bool, len(byIdentity)) for _, r := range current.Results() { id, ok := Identity(r, key) @@ -83,9 +129,13 @@ func CompareToBaseline(current, baseline *Report, key string) (*Comparison, erro } matched[id] = true + change := changeUnder(r, previous, refinements) state := Updated - if sameTrace(r, previous) { + if change == ChangeNone { state = Unchanged + } else { + cmp.changes[r] = change + cmp.ChangeCounts[change]++ } cmp.states[r] = state cmp.Counts[state]++ @@ -165,18 +215,37 @@ func ranInCurrentScan(r *Result, executed map[string]bool) bool { return executed[*r.RuleID] } -// sameTrace reports whether the current result's full-trace fingerprint equals -// that of any baseline result sharing its identity. A missing trace fingerprint -// on either side counts as unchanged: the finer comparison is unavailable, and -// claiming "updated" on missing data would be noise. -func sameTrace(current *Result, previous []*Result) bool { - currentTrace, ok := Identity(current, TraceFingerprintKey) +// changeUnder reports the coarsest thing that moved below a finding's identity. +// The refinements 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, refinements []string) Change { + for _, key := range refinements { + if sameUnder(current, previous, key) { + continue + } + switch key { + case SourceSinkFingerprintKey: + return ChangeSource + default: + return ChangePath + } + } + return ChangeNone +} + +// sameUnder reports whether the current result's fingerprint under key equals +// that of any baseline result sharing its identity. A missing fingerprint on +// either side counts as the same: the finer comparison is unavailable, and +// claiming a change on missing data would be noise. +func sameUnder(current *Result, previous []*Result, key string) bool { + currentValue, ok := Identity(current, key) if !ok { return true } for _, p := range previous { - previousTrace, ok := Identity(p, TraceFingerprintKey) - if !ok || previousTrace == currentTrace { + previousValue, ok := Identity(p, key) + if !ok || previousValue == currentValue { return true } } diff --git a/cli/internal/sarif/baseline_test.go b/cli/internal/sarif/baseline_test.go index fefdad3b06..f4762b478e 100644 --- a/cli/internal/sarif/baseline_test.go +++ b/cli/internal/sarif/baseline_test.go @@ -5,18 +5,32 @@ import ( "testing" ) -// fp builds a partialFingerprints map from a source/sink hash and a trace hash. -func fp(sourceSink, trace string) map[string]string { +// 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{} - if sourceSink != "" { - m[SourceSinkFingerprintKey] = sourceSink - } - if trace != "" { - m[TraceFingerprintKey] = trace + 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")), @@ -331,3 +345,103 @@ func TestWithAbsentAddsFixedFindingsForDisplayOnly(t *testing.T) { t.Error("the baseline result itself was stamped; 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, SinkFingerprintKey) + 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, SinkFingerprintKey) + 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) + } +} + +// Choosing a finer identity leaves less to refine: under source/sink, a moved +// source is a different finding, not an updated one. +func TestChangeUnderSourceSinkIdentityOnlyReportsPath(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-a", "trace-a2"))) + + cmp, err := CompareToBaseline(current, baseline, SourceSinkFingerprintKey) + if err != nil { + t.Fatalf("compare: %v", err) + } + if got := cmp.ChangeOf(current.Results()[0]); got != ChangePath { + t.Errorf("change = %q, want %q", got, ChangePath) + } + if got := cmp.ChangeCounts[ChangeSource]; got != 0 { + t.Errorf("source-changed count = %d, want 0 under a source-binding identity", got) + } +} + +// The trace hash is the finest key, so nothing refines it: a match is a match. +func TestChangeUnderTraceIdentityIsAlwaysNone(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", 9, fps("sink-z", "src-z", "trace-a"))) + + cmp, err := CompareToBaseline(current, baseline, TraceFingerprintKey) + if err != nil { + t.Fatalf("compare: %v", err) + } + r := current.Results()[0] + if got := cmp.StateOf(r); got != Unchanged { + t.Errorf("state = %q, want unchanged", got) + } + if got := cmp.ChangeOf(r); got != ChangeNone { + t.Errorf("change = %q, want none", got) + } +} diff --git a/cli/internal/sarif/identity.go b/cli/internal/sarif/identity.go index 7907093a76..b7e1aaede5 100644 --- a/cli/internal/sarif/identity.go +++ b/cli/internal/sarif/identity.go @@ -35,10 +35,28 @@ var identityAliases = map[string]string{ var IdentityAliases = []string{"trace", "source-sink", "sink"} // DefaultIdentityKey is the fingerprint key used to decide whether a finding in -// one report is "the same finding" as one in another report. The source/sink -// hash is the default because a suppression or baseline entry should survive -// edits to helper methods the flow happens to pass through. -const DefaultIdentityKey = SourceSinkFingerprintKey +// one report is "the same finding" as one in another report. The sink hash is +// the default because it names the vulnerable statement and nothing else, so a +// decision survives every edit to how the untrusted data reaches it. The +// analyzer already reports one finding per rule and sink, so the coarsest key +// loses no findings — it only stops them from changing identity. +const DefaultIdentityKey = SinkFingerprintKey + +// identityLadder is the keys ordered coarsest to finest. Each one adds detail to +// the one before it, which is what lets a matched finding say what moved. +var identityLadder = []string{SinkFingerprintKey, SourceSinkFingerprintKey, TraceFingerprintKey} + +// finerKeys returns the keys that refine key, nearest first. A key outside the +// ladder is refined by the trace hash alone: an unrecognized identity may still +// be compared for an exact match, which is all the trace hash reports. +func finerKeys(key string) []string { + for i, k := range identityLadder { + if k == key { + return identityLadder[i+1:] + } + } + return []string{TraceFingerprintKey} +} // ResolveIdentityKey normalizes a user-supplied identity key, falling back to // DefaultIdentityKey when unset and expanding the short aliases. Any other key diff --git a/cli/internal/sarif/identity_test.go b/cli/internal/sarif/identity_test.go index 9c00172516..e668aff748 100644 --- a/cli/internal/sarif/identity_test.go +++ b/cli/internal/sarif/identity_test.go @@ -5,13 +5,13 @@ import ( "testing" ) -func TestResolveIdentityKeyDefaultsToSourceSink(t *testing.T) { +func TestResolveIdentityKeyDefaultsToSink(t *testing.T) { key, err := ResolveIdentityKey("") if err != nil { t.Fatalf("unexpected error: %v", err) } - if key != SourceSinkFingerprintKey { - t.Errorf("got %q, want %q", key, SourceSinkFingerprintKey) + if key != SinkFingerprintKey { + t.Errorf("got %q, want %q", key, SinkFingerprintKey) } } diff --git a/cli/internal/sarif/triage_summary_test.go b/cli/internal/sarif/triage_summary_test.go index 64471088a5..c0b295274d 100644 --- a/cli/internal/sarif/triage_summary_test.go +++ b/cli/internal/sarif/triage_summary_test.go @@ -202,7 +202,7 @@ func TestDisplayFingerprintIsTheOneTriageResolves(t *testing.T) { if err != nil { t.Fatalf("the fingerprint the listing shows does not resolve: %v", err) } - if got, _ := Identity(resolved, DefaultIdentityKey); got != "source-sink-value" { - t.Errorf("resolved %q, want the source-sink value", got) + if got, _ := Identity(resolved, DefaultIdentityKey); got != "sink-of-source-sink-value" { + t.Errorf("resolved %q, want the value under the default key", got) } } diff --git a/cli/internal/sarif/triage_view.go b/cli/internal/sarif/triage_view.go index 0eedaf238f..1f969a440c 100644 --- a/cli/internal/sarif/triage_view.go +++ b/cli/internal/sarif/triage_view.go @@ -99,12 +99,26 @@ func (v *TriageView) baselineItems(out *output.Printer) []any { }{ {"New", New}, {"Unchanged", Unchanged}, - {"Updated", Updated}, } { 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)) + } // "Fixed" reads better than SARIF's "absent" for a finding that is gone. if count := v.Comparison.Counts[Absent]; count > 0 { items = append(items, out.FieldItem("Fixed", count)) diff --git a/cli/internal/triage/triage_test.go b/cli/internal/triage/triage_test.go index bfca61293f..8bf1626d24 100644 --- a/cli/internal/triage/triage_test.go +++ b/cli/internal/triage/triage_test.go @@ -20,6 +20,7 @@ func result(ruleID, identity string, trace string) sarif.Result { }, }}, PartialFingerprints: map[string]string{ + sarif.SinkFingerprintKey: identity, sarif.SourceSinkFingerprintKey: identity, sarif.TraceFingerprintKey: trace, }, diff --git a/docs/baselines-and-suppressions.md b/docs/baselines-and-suppressions.md index f46d5a7200..fd3eace747 100644 --- a/docs/baselines-and-suppressions.md +++ b/docs/baselines-and-suppressions.md @@ -176,8 +176,8 @@ the most exact identity to the coarsest: | `--fingerprint-key` | Full key | Hashes | Behavior | |-----|-----|--------|----------| | `trace` | `vulnerabilityWithTraceHash/v1` | rule + every step of every trace | Exact; changes if anything on the path moves. | -| `source-sink` | `vulnerabilitySourceSinkHash/v1` | rule + source + sink | Survives edits to the call path between source and sink. **Default.** | -| `sink` | `vulnerabilitySinkHash/v1` | the sink statement alone | Survives a change to where the untrusted data comes from. | +| `source-sink` | `vulnerabilitySourceSinkHash/v1` | rule + source + sink | Survives edits to the call path between source and sink. | +| `sink` | `vulnerabilitySinkHash/v1` | rule + sink | Survives any change to how the untrusted data reaches the sink. **Default.** | `--fingerprint-key` takes the short name or the full key, and one key governs everything a command does with fingerprints: baseline matching, the prefix @@ -186,16 +186,33 @@ and what `--partial-fingerprint` matches. That is why a fingerprint copied off the screen always names a finding to `triage`. (`--partial-fingerprint-key` is a deprecated alias for `--fingerprint-key`.) -The source→sink hash is the default because a decision should survive -refactoring of an unrelated helper the flow happens to pass through. The finer -trace hash is what distinguishes `unchanged` from `updated`. +The sink hash is the default because it names the vulnerable statement and +nothing else, so a decision survives every edit to how the data gets there. It +costs nothing in precision: the analyzer already reports one finding per rule and +sink, so the coarsest key is still one fingerprint per finding — it only stops +findings from changing identity. All three hash the rule id, so no fingerprint +ever spans two rules that fire on one statement. -Choose `sink` when you care about the vulnerable statement rather than how data -reaches it — one decision on `Runtime.exec(cmd)` then covers every route into it, -and stays put when a new caller adds another one. It is the coarsest identity, so -it also merges the most: several findings that differ only in their source become -one entry, and suppressing it suppresses them all. All three hash the rule id, so -no fingerprint ever spans two rules that fire on one statement. +Pick a finer key when the route is part of what you are deciding about. Under +`source-sink`, data arriving at a known-dangerous sink from a *new* source is a +new finding that must be triaged again; under `sink`, an existing decision covers +it. + +### 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. The distinction narrows with a finer identity: under +`source-sink` a moved source is `new` + `absent` rather than `updated`, and under +`trace` nothing is left to refine, so a match is always `unchanged`. Comparing reports built with different fingerprint keys is a hard error, not a silent zero-match. Findings that carry no fingerprint at all (a report produced diff --git a/docs/reports/fingerprint-stability.md b/docs/reports/fingerprint-stability.md index 16eb2488dc..3698898337 100644 --- a/docs/reports/fingerprint-stability.md +++ b/docs/reports/fingerprint-stability.md @@ -31,14 +31,19 @@ The same commit also 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`. -Two parts of section 8 are **not** done, and must not be done as written: - -- **Do not make the sink hash the default key.** Section 8 asks for this because - the source/sink hash was not stable. The source/sink hash is stable now. The - sink hash is coarser: it merges the findings that have the same sink and - different sources. -- Change 2 (make the flow selection stable) is not done. The commit removes the - effect on the report. It does not sort the graph. +Change 1 of section 8 is fully done. The sink hash is also the default key of +the CLI. 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. + +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. The commit removes the +effect on the report. It does not sort the graph. --- diff --git a/docs/usage.md b/docs/usage.md index c7f578e570..068a8a8636 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -116,7 +116,7 @@ On the first run, the compiled project model is cached in `~/.opentaint/cache/`. |------|-------------| | `--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`) | -| `--fingerprint-key` | Which fingerprint identifies a finding across reports: `trace`, `source-sink` (default), `sink`, or a full partialFingerprints key | +| `--fingerprint-key` | Which fingerprint identifies a finding across reports: `trace`, `source-sink`, `sink` (default), or a full partialFingerprints key | | `--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: `error`, `warning`, `note`, `none` (repeatable, default all) | @@ -228,7 +228,7 @@ reflects the full set the tool ran. | `--baseline` | Compare against this SARIF report and show new/unchanged/updated/fixed counts. The file is never modified. | | `--baseline-state` | Show only findings whose state is one of `new` \| `unchanged` \| `updated` \| `absent` (repeatable). Reads the states persisted by `--write-baseline-state`, or the ones `--baseline` computes now; `absent` lists the fixed findings from the baseline. | | `--suppressed` | Include suppressed findings in the listing (hidden by default) | -| `--fingerprint-key` | Which fingerprint identifies a finding: `trace` (exact), `source-sink` (default), `sink` (the sink statement alone), or a full partialFingerprints key. One key governs baseline matching, the `Fingerprint:` value shown, `--partial-fingerprint`, and the prefix `triage` resolves. | +| `--fingerprint-key` | Which fingerprint identifies a finding: `trace` (exact), `source-sink`, `sink` (rule and sink only; default), or a full partialFingerprints key. One key governs baseline matching, the `Fingerprint:` value shown, `--partial-fingerprint`, and the prefix `triage` resolves. | Filters combine as OR within a dimension and AND across dimensions. @@ -260,7 +260,7 @@ opentaint triage scan.sarif --defer 8bc1d2 --justification "waiting on OT-412" | `--output`, `-o` | Write the triaged report here (default: rewrite the input in place) | | `--show-findings` | List the findings, not just the summary | | `--suppressed` | Include suppressed findings in the listing | -| `--fingerprint-key` | Which fingerprint identifies a finding across reports: `trace`, `source-sink` (default), `sink`, or a full partialFingerprints key | +| `--fingerprint-key` | Which fingerprint identifies a finding across reports: `trace`, `source-sink`, `sink` (default), or a full partialFingerprints key | | `--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 (repeatable, default all) | From 677cba9767a4af2a68c1329148bf05dcdd73fadc Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Fri, 7 Aug 2026 14:23:52 +0200 Subject: [PATCH 26/36] docs: retract the claim that #336 fixed the fingerprint drift The status section said the cause was repaired. It is not, and the commit was never meant to repair it. Measured on the reproduction project, 3 runs each: 278/300/314 statements with differing facts before #336, 268/392/364 after, out of 5244. The measurement that produced the wrong claim compared a probe hash that mixes in a processed-event counter. That counter varies between runs of both builds, so it could never separate them; the delayed-analyzer composition it was meant to sense is in fact identical in both. The reproduction project also turns out not to answer the fingerprint question at all: its fingerprints are stable before and after, because each of its findings has a single route from source to sink. Reproducing the drift needs a project where a finding has several. This makes the sink hash the mitigation rather than a convenience, which is noted where it is documented as the default. --- docs/baselines-and-suppressions.md | 5 +- docs/reports/fingerprint-stability.md | 70 ++++++++++++++++----------- 2 files changed, 47 insertions(+), 28 deletions(-) diff --git a/docs/baselines-and-suppressions.md b/docs/baselines-and-suppressions.md index fd3eace747..e096ea3b31 100644 --- a/docs/baselines-and-suppressions.md +++ b/docs/baselines-and-suppressions.md @@ -187,7 +187,10 @@ the screen always names a finding to `triage`. (`--partial-fingerprint-key` is a deprecated alias for `--fingerprint-key`.) The sink hash is the default because it names the vulnerable statement and -nothing else, so a decision survives every edit to how the data gets there. It +nothing else, so a decision survives every edit to how the data gets there — +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`). It costs nothing in precision: the analyzer already reports one finding per rule and sink, so the coarsest key is still one fingerprint per finding — it only stops findings from changing identity. All three hash the rule id, so no fingerprint diff --git a/docs/reports/fingerprint-stability.md b/docs/reports/fingerprint-stability.md index 3698898337..5162549c00 100644 --- a/docs/reports/fingerprint-stability.md +++ b/docs/reports/fingerprint-stability.md @@ -6,44 +6,60 @@ --- -## Status: corrected in analyzer 908e924b3 +## Status: not corrected. Change 1 is done -**Do not use sections 1 to 7 as a statement about the analyzer of today.** They -describe the analyzer of 2026.08.01. The commit `908e924b3` ("Small fixes", -PR #336) corrects the cause. Read this section first, then read the rest as the -record of the defect. +Sections 1 to 7 still apply. The cause is **not** repaired. -What the commit changes: the event order in the IFDS scheduler. An analyzer that -holds unprocessed zero-to-zero edges now goes first. This order comes from the -content, not from the time at which a thread puts an event into the queue. +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. -Measurement after the change, on the reproduction project -(`projects/local/taint-nondeterminism` in the regression harness), with 10 runs -and 5 different thread counts: +**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 | Before | After | -|---|---|---| -| `vulnerabilitySourceSinkHash/v1` set | changes between runs | the same in all 10 runs | -| Complete SARIF results, all fields | change between runs | the same in all runs | -| Delayed-analyzer set in each round | changes between runs | the same in all runs | - -The same commit also 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`. +**Measurement.** Runs of the same code on the reproduction project +(`projects/local/taint-nondeterminism` in the regression harness), which reports +5244 statements with facts: -Change 1 of section 8 is fully done. The sink hash is also the default key of -the CLI. 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. +| 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. The commit removes the -effect on the report. It does not sort the graph. +Change 2 (make the flow selection stable) is **not** done. This is the change +that repairs the cause. --- From 76bfc96572983cb9ec7685484de5f0a29d457e66 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Sat, 8 Aug 2026 23:22:13 +0200 Subject: [PATCH 27/36] fix(cli): stop reporting "path changed", the signal behind it is noise The line is derived from vulnerabilityWithTraceHash/v1, and measurement on Stirling-PDF says that fingerprint is not reproducible: over 20 runs of the same analyzer on the same code, 19 disagree with the modal set, and ~1540 of ~1850 trace fingerprints are absent from at least one run. Comparing two such runs reports ~131 findings as "Updated, path changed" when nothing changed at all. So the line was close to 100% false positives, and it presented a known-unstable value as if it were a property of the code. Those findings now fall through to the plain "Updated" count, which is honest: something below the identity moved, and we cannot yet say what. ChangeSource keeps being reported. It comes from the source/sink hash, which is stable once duplicate sources are excluded from the digest. The classification itself is kept, with the reason it is unreported recorded on the constant, so restoring the line is a one-word change once the trace hash is deterministic. --- cli/internal/sarif/baseline.go | 5 ++++- cli/internal/sarif/triage_view.go | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/cli/internal/sarif/baseline.go b/cli/internal/sarif/baseline.go index 8d2459271d..56a69b1427 100644 --- a/cli/internal/sarif/baseline.go +++ b/cli/internal/sarif/baseline.go @@ -55,7 +55,10 @@ const ( // 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. + // call path. NOT REPORTED: it is derived from the full-trace fingerprint, + // which the analyzer does not yet compute deterministically -- two runs over + // unchanged code disagree on ~95% of it, so the line was ~100% false + // positives. Restore it in triage_view.go once the trace hash is stable. ChangePath Change = "path" ) diff --git a/cli/internal/sarif/triage_view.go b/cli/internal/sarif/triage_view.go index 1f969a440c..0313f9dd5c 100644 --- a/cli/internal/sarif/triage_view.go +++ b/cli/internal/sarif/triage_view.go @@ -108,7 +108,9 @@ func (v *TriageView) baselineItems(out *output.Printer) []any { // 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} { + // ChangePath is deliberately absent: see the note on it in baseline.go. Its + // findings fall through to the plain "Updated" line below. + for _, change := range []Change{ChangeSource} { count := v.Comparison.ChangeCounts[change] if count == 0 { continue From 35bccdd5ff2ddd66e86d29670d293f1e3f1f7679 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Sun, 9 Aug 2026 20:09:00 +0200 Subject: [PATCH 28/36] Revert "fix(cli): stop reporting "path changed", the signal behind it is noise" This reverts commit cbf058ceb127c0c06d488cff069eaff3fee6171a. --- cli/internal/sarif/baseline.go | 5 +---- cli/internal/sarif/triage_view.go | 4 +--- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/cli/internal/sarif/baseline.go b/cli/internal/sarif/baseline.go index 56a69b1427..8d2459271d 100644 --- a/cli/internal/sarif/baseline.go +++ b/cli/internal/sarif/baseline.go @@ -55,10 +55,7 @@ const ( // 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. NOT REPORTED: it is derived from the full-trace fingerprint, - // which the analyzer does not yet compute deterministically -- two runs over - // unchanged code disagree on ~95% of it, so the line was ~100% false - // positives. Restore it in triage_view.go once the trace hash is stable. + // call path. Usually a refactoring of the code in between. ChangePath Change = "path" ) diff --git a/cli/internal/sarif/triage_view.go b/cli/internal/sarif/triage_view.go index 0313f9dd5c..1f969a440c 100644 --- a/cli/internal/sarif/triage_view.go +++ b/cli/internal/sarif/triage_view.go @@ -108,9 +108,7 @@ func (v *TriageView) baselineItems(out *output.Printer) []any { // 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 - // ChangePath is deliberately absent: see the note on it in baseline.go. Its - // findings fall through to the plain "Updated" line below. - for _, change := range []Change{ChangeSource} { + for _, change := range []Change{ChangeSource, ChangePath} { count := v.Comparison.ChangeCounts[change] if count == 0 { continue From c0876aaef2862fd7105f2bcd1a4699ae02b89ffa Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Mon, 24 Aug 2026 00:39:02 +0200 Subject: [PATCH 29/36] polish(cli): bring triage/baseline help up to the reworked help style triage gets the structured Long (prose paragraphs, positional-arg sentence, cross-command pointers, exit-code block) and a cobra Example section instead of inline Examples. scan documents exit code 2 via a gate-aware exit-codes helper and gains a baseline-gating example; summary explains --baseline and --baseline-state in its Long and examples. Flag help is normalized to the house grammar: comma lists instead of pipes, '; defaults to X' instead of '(default X)', and the --fingerprint-key help no longer claims source-sink is the default when sink is. docs/usage.md tables are synced with the changed strings. --- cli/cmd/exit_codes.go | 13 ++++++++-- cli/cmd/scan.go | 13 ++++++++-- cli/cmd/summary.go | 14 ++++++++--- cli/cmd/triage.go | 55 ++++++++++++++++++++----------------------- docs/usage.md | 10 ++++---- 5 files changed, 63 insertions(+), 42 deletions(-) diff --git a/cli/cmd/exit_codes.go b/cli/cmd/exit_codes.go index 5de9a0912e..b4077aaa4d 100644 --- a/cli/cmd/exit_codes.go +++ b/cli/cmd/exit_codes.go @@ -18,14 +18,23 @@ func analyzerExitCodeRows() string { } // scanExitCodesHelp renders the exit-codes block for commands that forward -// analyzer exit codes but have no test-failure code (scan, test rule -// reachability). +// 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 { diff --git a/cli/cmd/scan.go b/cli/cmd/scan.go index 82a651d053..99c3e87474 100644 --- a/cli/cmd/scan.go +++ b/cli/cmd/scan.go @@ -89,9 +89,11 @@ The source-path argument is the project root. It is optional. The default is the OpenTaint writes the findings to a SARIF report. Use --output to set the report path. If --output is not set, the report goes into the project model directory. A summary is shown when the scan completes. +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". -` + scanExitCodesHelp("Scan completed"), +` + gateExitCodesHelp("Scan completed"), Example: ` # Scan the current directory with the built-in rules opentaint scan . @@ -104,6 +106,9 @@ Before your first scan, run "opentaint pull" one time. To read a report again la # 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 @@ -114,7 +119,11 @@ Before your first scan, run "opentaint pull" one time. To read a report again la # Recipe: build one time, then scan many times opentaint compile ./my-app -o ./model - opentaint scan --project-model ./model -o report.sarif`, + 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 != "" { diff --git a/cli/cmd/summary.go b/cli/cmd/summary.go index a6ef19345b..33960b222c 100644 --- a/cli/cmd/summary.go +++ b/cli/cmd/summary.go @@ -22,7 +22,9 @@ The sarif-report argument is the path to a SARIF report. It is required. Use a r 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. -This command only reads the report. It does not write files.`, +To compare with a previous report, use --baseline. The summary then shows which findings are new, unchanged, updated, or fixed. 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 @@ -35,6 +37,12 @@ This command only reads the report. It does not write files.`, # 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 @@ -180,7 +188,7 @@ func init() { 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, &summaryFingerprintKey) - summaryCmd.Flags().StringArrayVar(&summaryBaselineStates, "baseline-state", nil, "Show only findings whose baseline state is one of: new | unchanged | updated | absent (repeatable; reads states persisted by --write-baseline-state, or computed now from --baseline)") + 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") } @@ -188,7 +196,7 @@ func init() { // a report against a baseline. func addBaselineFlags(cmd *cobra.Command, baseline *string, fingerprintKey *string) { cmd.Flags().StringVar(baseline, "baseline", "", "Previous SARIF report to compare against and inherit suppressions from") - cmd.Flags().StringVar(fingerprintKey, "fingerprint-key", "", "Which fingerprint identifies a finding across reports, in the listing and in triage: "+strings.Join(sarif.IdentityAliases, " | ")+", or a partialFingerprints key (default source-sink)") + cmd.Flags().StringVar(fingerprintKey, "fingerprint-key", "", "Which fingerprint identifies a finding across reports: "+strings.Join(sarif.IdentityAliases, ", ")+", or a partialFingerprints key; defaults to sink") } // loadBaselineOrExit resolves and loads a baseline report, refusing to use the diff --git a/cli/cmd/triage.go b/cli/cmd/triage.go index d4f172da08..625d4f0df1 100644 --- a/cli/cmd/triage.go +++ b/cli/cmd/triage.go @@ -33,42 +33,37 @@ type TriageConfig struct { var triageFlags TriageConfig var triageCmd = &cobra.Command{ - Use: "triage sarif", - Short: "Compare a SARIF report against a baseline and record suppressions", + Use: "triage ", + Short: "Compare a report against a baseline and record suppressions", Args: cobra.ExactArgs(1), - Long: `Compare a SARIF report against a baseline and record accept/defer decisions + Long: `Compare a SARIF report against a baseline and record triage decisions. Accepting a finding means it will not be fixed; deferring means it is not being fixed for now. Both are recorded as SARIF suppressions, which any SARIF consumer honors. -Findings are identified by fingerprint, so a decision survives edits elsewhere -in the code. Nothing is ever deleted from the report: an accepted or deferred -finding stays in the file, marked with a SARIF suppression that records who -decided what and why. +The required positional argument is the path to the SARIF report to triage, such as one written by opentaint scan. Findings are identified by fingerprint, so a decision survives edits elsewhere in the code. Nothing is ever deleted: an accepted or deferred finding stays in the report, marked with a suppression that records the decision and its justification. -Arguments: - sarif - Path to the SARIF report to triage +Name a finding by a fingerprint prefix, git-style: the value shown as "Fingerprint:" by opentaint summary --show-findings. Both commands read the same key, so the value on screen is the value to paste here; --fingerprint-key changes it on both sides. An ambiguous or unknown prefix is an error, never a guess. -A finding is named by a fingerprint prefix, git-style — the value shown as -"Fingerprint:" by 'opentaint summary --show-findings'. Both commands read the -same key, so the value on screen is the value to paste here; --fingerprint-key -changes it on either side. +The triaged report is rewritten in place, or written to --output when set. With --baseline, decisions recorded in the baseline are inherited by the matching findings first, so a chain of reports carries its triage history forward. -Examples: - # See what changed since the last release, without modifying anything - 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 for now - opentaint triage scan.sarif --defer 8bc1d2 --justification "waiting on OT-412" - - # Carry earlier decisions forward and fail if anything new turned up - opentaint triage scan.sarif --baseline release.sarif -o triaged.sarif \ - --error-on-findings +Run opentaint scan to produce the report this command triages. Review the result with 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 modifying anything + 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 is not being fixed for now + opentaint triage report.sarif --defer 8bc1d2 --justification "waiting on OT-412" + + # Remove an earlier decision + opentaint triage report.sarif --unsuppress q3Vf9k + + # Carry decisions forward and fail if anything new turned up + opentaint triage report.sarif --baseline release.sarif -o triaged.sarif --error-on-findings`, Run: func(cmd *cobra.Command, args []string) { runTriage(triageFlags, args[0]) @@ -84,16 +79,16 @@ func init() { 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", "", "Write the triaged report here (default: rewrite the input in place)") + 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, "List the findings, not just the summary") + 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 (new ones only, with --baseline)") - cmd.Flags().StringArrayVar(severities, "error-on-severity", nil, "Restrict --error-on-findings to these levels: error, warning, note, none (comma-separated or repeated; default all)") + 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) { diff --git a/docs/usage.md b/docs/usage.md index 068a8a8636..57d3cd37ab 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -118,7 +118,7 @@ On the first run, the compiled project model is cached in `~/.opentaint/cache/`. | `--write-baseline-state` | Persist `result.baselineState` and `run.baselineGuid` into the output report (needs `--baseline`) | | `--fingerprint-key` | Which fingerprint identifies a finding across reports: `trace`, `source-sink`, `sink` (default), or a full partialFingerprints key | | `--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: `error`, `warning`, `note`, `none` (repeatable, default all) | +| `--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 fixed. See @@ -226,7 +226,7 @@ reflects the full set the tool ran. | `--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/fixed counts. The file is never modified. | -| `--baseline-state` | Show only findings whose state is one of `new` \| `unchanged` \| `updated` \| `absent` (repeatable). Reads the states persisted by `--write-baseline-state`, or the ones `--baseline` computes now; `absent` lists the fixed findings from the baseline. | +| `--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 fixed findings from the baseline. | | `--suppressed` | Include suppressed findings in the listing (hidden by default) | | `--fingerprint-key` | Which fingerprint identifies a finding: `trace` (exact), `source-sink`, `sink` (rule and sink only; default), or a full partialFingerprints key. One key governs baseline matching, the `Fingerprint:` value shown, `--partial-fingerprint`, and the prefix `triage` resolves. | @@ -257,12 +257,12 @@ opentaint triage scan.sarif --defer 8bc1d2 --justification "waiting on OT-412" | `--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` | Write the triaged report here (default: rewrite the input in place) | -| `--show-findings` | List the findings, not just the summary | +| `--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 | | `--fingerprint-key` | Which fingerprint identifies a finding across reports: `trace`, `source-sink`, `sink` (default), or a full partialFingerprints key | | `--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 (repeatable, default all) | +| `--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 From abd405a4e63fc5b1f66bda6a98502c0b810ab87c Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Mon, 24 Aug 2026 01:06:16 +0200 Subject: [PATCH 30/36] style(cli): drop semicolons from prose in the triage and baseline work Same rule as on the base branch: no semicolons in written prose. Semicolon joins in the triage/scan/summary help text, flag descriptions, error messages, comments, and the two docs pages are rewritten as separate sentences, commas, or parentheticals. Go statement syntax is untouched. --- cli/cmd/scan.go | 4 ++-- cli/cmd/summary.go | 6 +++--- cli/cmd/triage.go | 12 ++++++------ cli/internal/rules/select.go | 4 ++-- cli/internal/sarif/baseline.go | 6 +++--- cli/internal/sarif/baseline_test.go | 4 ++-- cli/internal/sarif/suppress.go | 2 +- cli/internal/sarif/triage_summary_test.go | 2 +- cli/internal/sarif/triage_view.go | 2 +- cli/internal/triage/triage.go | 4 ++-- docs/baselines-and-suppressions.md | 22 +++++++++++----------- docs/usage.md | 18 +++++++++--------- 12 files changed, 43 insertions(+), 43 deletions(-) diff --git a/cli/cmd/scan.go b/cli/cmd/scan.go index 99c3e87474..5fa9aefd5d 100644 --- a/cli/cmd/scan.go +++ b/cli/cmd/scan.go @@ -166,7 +166,7 @@ func addEntryPointsFlag(cmd *cobra.Command) { func addRuleIDFlag(cmd *cobra.Command) { cmd.Flags().StringArrayVar(&scanFlags.RuleID, "rule-id", nil, "Run only rules with this ID (repeatable)") - cmd.Flags().StringArrayVar(&scanFlags.ExcludeRuleID, "exclude-rule-id", nil, "Never run rules matching this ID: full id, bare name, or glob over the full id (repeatable; overrides rules.exclude from the config)") + 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) { @@ -222,7 +222,7 @@ func currentScanBuilder(cfg ScanConfig, sourcePath string) *utils.OpentaintComma // 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 +// --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. diff --git a/cli/cmd/summary.go b/cli/cmd/summary.go index 33960b222c..83ffc0b415 100644 --- a/cli/cmd/summary.go +++ b/cli/cmd/summary.go @@ -188,7 +188,7 @@ func init() { 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, &summaryFingerprintKey) - 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().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") } @@ -196,7 +196,7 @@ func init() { // a report against a baseline. func addBaselineFlags(cmd *cobra.Command, baseline *string, fingerprintKey *string) { cmd.Flags().StringVar(baseline, "baseline", "", "Previous SARIF report to compare against and inherit suppressions from") - cmd.Flags().StringVar(fingerprintKey, "fingerprint-key", "", "Which fingerprint identifies a finding across reports: "+strings.Join(sarif.IdentityAliases, ", ")+", or a partialFingerprints key; defaults to sink") + cmd.Flags().StringVar(fingerprintKey, "fingerprint-key", "", "Which fingerprint identifies a finding across reports: "+strings.Join(sarif.IdentityAliases, ", ")+", or a partialFingerprints key (defaults to sink)") } // loadBaselineOrExit resolves and loads a baseline report, refusing to use the @@ -272,7 +272,7 @@ func summaryListingOptions(dim sarif.GroupDimension, codeFlowSel sarif.CodeFlowS } // printSarifSummary renders the optional finding listing followed by the scan -// summary. list controls whether the listing is printed; each command owns its +// 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) diff --git a/cli/cmd/triage.go b/cli/cmd/triage.go index 625d4f0df1..28175ee04b 100644 --- a/cli/cmd/triage.go +++ b/cli/cmd/triage.go @@ -36,11 +36,11 @@ 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 against a baseline and record triage decisions. Accepting a finding means it will not be fixed; deferring means it is not being fixed for now. Both are recorded as SARIF suppressions, which any SARIF consumer honors. + Long: `Compare a SARIF report against a baseline and record triage decisions. Accepting a finding means it will not be fixed. Deferring means it is not being fixed for now. Both are recorded as SARIF suppressions, which any SARIF consumer honors. The required positional argument is the path to the SARIF report to triage, such as one written by opentaint scan. Findings are identified by fingerprint, so a decision survives edits elsewhere in the code. Nothing is ever deleted: an accepted or deferred finding stays in the report, marked with a suppression that records the decision and its justification. -Name a finding by a fingerprint prefix, git-style: the value shown as "Fingerprint:" by opentaint summary --show-findings. Both commands read the same key, so the value on screen is the value to paste here; --fingerprint-key changes it on both sides. An ambiguous or unknown prefix is an error, never a guess. +Name a finding by a fingerprint prefix, git-style: the value shown as "Fingerprint:" by opentaint summary --show-findings. Both commands read the same key, so the value on screen is the value to paste here. The --fingerprint-key flag changes it on both sides. An ambiguous or unknown prefix is an error, never a guess. The triaged report is rewritten in place, or written to --output when set. With --baseline, decisions recorded in the baseline are inherited by the matching findings first, so a chain of reports carries its triage history forward. @@ -78,8 +78,8 @@ func init() { 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") + 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") @@ -87,8 +87,8 @@ func init() { // 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)") + 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) { diff --git a/cli/internal/rules/select.go b/cli/internal/rules/select.go index fb361adcf6..47372c09c5 100644 --- a/cli/internal/rules/select.go +++ b/cli/internal/rules/select.go @@ -105,7 +105,7 @@ func Select(selection Selection, roots []string) (Resolved, error) { 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") + return Resolved{}, fmt.Errorf("rules.only/rules.exclude select no rules at all: nothing would be scanned") } if len(selection.Only) == 0 { @@ -141,7 +141,7 @@ func ApplyExclusions(ids, patterns []string) ([]string, error) { } } 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 nil, fmt.Errorf("--exclude-rule-id excludes every rule selected by --rule-id: nothing would be scanned") } return kept, nil } diff --git a/cli/internal/sarif/baseline.go b/cli/internal/sarif/baseline.go index 8d2459271d..d9aa7b19bc 100644 --- a/cli/internal/sarif/baseline.go +++ b/cli/internal/sarif/baseline.go @@ -100,7 +100,7 @@ func CompareToBaseline(current, baseline *Report, key string) (*Comparison, erro } if len(baselineResults) > 0 && len(byIdentity) == 0 { return nil, fmt.Errorf( - "no result in the baseline carries the %q fingerprint; "+ + "no result in the baseline carries the %q fingerprint: "+ "it was produced with a different fingerprint key or without fingerprints", key) } @@ -162,7 +162,7 @@ func CompareToBaseline(current, baseline *Report, key string) (*Comparison, erro // 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 fixed // 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 +// 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 { @@ -302,7 +302,7 @@ func EnsureRunGUIDs(report *Report) { } // 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. +// 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 { diff --git a/cli/internal/sarif/baseline_test.go b/cli/internal/sarif/baseline_test.go index f4762b478e..c9a4fe3f35 100644 --- a/cli/internal/sarif/baseline_test.go +++ b/cli/internal/sarif/baseline_test.go @@ -342,7 +342,7 @@ func TestWithAbsentAddsFixedFindingsForDisplayOnly(t *testing.T) { t.Error("the added result is not marked absent") } if gone.BaselineState != nil { - t.Error("the baseline result itself was stamped; only the copy may be") + t.Error("the baseline result itself was stamped, but only the copy may be") } } @@ -391,7 +391,7 @@ func TestChangeUnderSinkIdentityNamesWhatMoved(t *testing.T) { 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) + t.Errorf("updated count = %d, want 2 (every change is still one SARIF state)", got) } } diff --git a/cli/internal/sarif/suppress.go b/cli/internal/sarif/suppress.go index f4cba5a041..08ed6c5d84 100644 --- a/cli/internal/sarif/suppress.go +++ b/cli/internal/sarif/suppress.go @@ -126,7 +126,7 @@ func Unsuppress(r *Result) bool { // // 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. +// suppression is left alone. Its own decision is the newer one. func InheritSuppressions(current, baseline *Report, key string) int { byIdentity := make(map[string]*Suppression) for _, r := range baseline.Results() { diff --git a/cli/internal/sarif/triage_summary_test.go b/cli/internal/sarif/triage_summary_test.go index c0b295274d..6d474dbb62 100644 --- a/cli/internal/sarif/triage_summary_test.go +++ b/cli/internal/sarif/triage_summary_test.go @@ -154,7 +154,7 @@ func TestRestrictCountsOnlyWhatTheFilterKept(t *testing.T) { 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 + // 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("Fixed = %d, want 1: only the xss finding survives the filter", got) diff --git a/cli/internal/sarif/triage_view.go b/cli/internal/sarif/triage_view.go index 1f969a440c..1bd0590d1c 100644 --- a/cli/internal/sarif/triage_view.go +++ b/cli/internal/sarif/triage_view.go @@ -82,7 +82,7 @@ func (c *Comparison) restrict(filtered *Report, f Filters) *Comparison { } // baselineItems renders the Baseline group, or nil when no baseline applies. -// Zero-valued state counts are omitted so the group stays readable; the states +// 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 { diff --git a/cli/internal/triage/triage.go b/cli/internal/triage/triage.go index 02b6fb4f4d..6b650ba2e6 100644 --- a/cli/internal/triage/triage.go +++ b/cli/internal/triage/triage.go @@ -50,8 +50,8 @@ type Outcome struct { // 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; then explicit accept/defer -// decisions from this run overwrite them; then the baseline comparison is +// 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) { key, err := sarif.ResolveIdentityKey(opts.FingerprintKey) diff --git a/docs/baselines-and-suppressions.md b/docs/baselines-and-suppressions.md index e096ea3b31..491ed9ee56 100644 --- a/docs/baselines-and-suppressions.md +++ b/docs/baselines-and-suppressions.md @@ -27,7 +27,7 @@ but valid). Nothing about being in the baseline makes a finding "accepted" — o 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. +report, marked. The CLI filters them at display and gate time, not in the file. ## Quick start @@ -73,7 +73,7 @@ opentaint triage baselines/main.sarif \ 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. `--accept`, `--defer`, and `--unsuppress` are -repeatable; one `--justification` applies to every decision in the invocation, +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. @@ -175,7 +175,7 @@ the most exact identity to the coarsest: | `--fingerprint-key` | Full key | Hashes | Behavior | |-----|-----|--------|----------| -| `trace` | `vulnerabilityWithTraceHash/v1` | rule + every step of every trace | Exact; changes if anything on the path moves. | +| `trace` | `vulnerabilityWithTraceHash/v1` | rule + every step of every trace | Exact. Changes if anything on the path moves. | | `source-sink` | `vulnerabilitySourceSinkHash/v1` | rule + source + sink | Survives edits to the call path between source and sink. | | `sink` | `vulnerabilitySinkHash/v1` | rule + sink | Survives any change to how the untrusted data reaches the sink. **Default.** | @@ -198,7 +198,7 @@ ever spans two rules that fire on one statement. Pick a finer key when the route is part of what you are deciding about. Under `source-sink`, data arriving at a known-dangerous sink from a *new* source is a -new finding that must be triaged again; under `sink`, an existing decision covers +new finding that must be triaged again. Under `sink`, an existing decision covers it. ### What changed underneath @@ -234,7 +234,7 @@ not in an in-source comment). The verdict is carried by the SARIF `status`: | `--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 +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 @@ -272,7 +272,7 @@ Suppressions └─ Added this run: 1 (triage only) ``` -`opentaint summary --show-findings` hides suppressed findings by default; add +`opentaint summary --show-findings` hides suppressed findings by default. Add `--suppressed` to list them with their justification. ## The gate @@ -280,7 +280,7 @@ Suppressions | 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. | +| `--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 @@ -291,7 +291,7 @@ closed — they count. | Code | Meaning | |------|---------| -| `0` | Completed; gate not tripped | +| `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) | @@ -325,7 +325,7 @@ 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 +`--rule-id` overrides the config lists. `--exclude-rule-id` overrides `rules.exclude`. Notes: @@ -351,7 +351,7 @@ 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.) +scans without gating. Every later PR gates against the latest main report.) ```yaml name: opentaint @@ -370,7 +370,7 @@ jobs: 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. + # the save target below. On a PR, restore-keys falls back to it read-only. - name: Restore baseline uses: actions/cache@v4 with: diff --git a/docs/usage.md b/docs/usage.md index 57d3cd37ab..96fd6e4082 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -108,7 +108,7 @@ On the first run, the compiled project model is cached in `~/.opentaint/cache/`. | `--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`) | +| `--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 @@ -117,8 +117,8 @@ On the first run, the compiled project model is cached in `~/.opentaint/cache/`. | `--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`) | | `--fingerprint-key` | Which fingerprint identifies a finding across reports: `trace`, `source-sink`, `sink` (default), or a full partialFingerprints key | -| `--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) | +| `--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 fixed. See @@ -226,9 +226,9 @@ reflects the full set the tool ran. | `--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/fixed 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 fixed findings from the baseline. | +| `--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 fixed findings from the baseline. | | `--suppressed` | Include suppressed findings in the listing (hidden by default) | -| `--fingerprint-key` | Which fingerprint identifies a finding: `trace` (exact), `source-sink`, `sink` (rule and sink only; default), or a full partialFingerprints key. One key governs baseline matching, the `Fingerprint:` value shown, `--partial-fingerprint`, and the prefix `triage` resolves. | +| `--fingerprint-key` | Which fingerprint identifies a finding: `trace` (exact), `source-sink`, `sink` (rule and sink only, the default), or a full partialFingerprints key. One key governs baseline matching, the `Fingerprint:` value shown, `--partial-fingerprint`, and the prefix `triage` resolves. | Filters combine as OR within a dimension and AND across dimensions. @@ -257,12 +257,12 @@ opentaint triage scan.sarif --defer 8bc1d2 --justification "waiting on OT-412" | `--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 | +| `--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 | | `--fingerprint-key` | Which fingerprint identifies a finding across reports: `trace`, `source-sink`, `sink` (default), or a full partialFingerprints key | -| `--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) | +| `--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 @@ -285,7 +285,7 @@ answers *"did a human accept this?"* (suppression). Presence in a baseline is `unchanged`, it does not hide it. ```bash -# 1. Scan once; keep the report as the baseline. +# 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). From 93202b0c5489bae1f66cdb379b4a9b0a2105d416 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Mon, 24 Aug 2026 03:25:18 +0200 Subject: [PATCH 31/36] fix(cli): close the review findings on rule selection, identity, and baseline safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rule selection: a ruleset root that is a single YAML file now produces the ':' ids the analyzer matches (it names a file root by the empty relative path), so exclusions and only-lists work for file rulesets. The built-in rules are fetched before an active selection resolves, so a fresh install no longer fails with 'no rules were found'. Identity: the sink/source-sink/trace aliases are expanded in summary and triage before the listing and filter paths consume them, so the fingerprint on screen is always the one a decision resolves. Results sharing one fingerprint value resolve together — one decision covers every duplicate — instead of being permanently ambiguous. Baseline safety: triage refuses an --output that would overwrite the baseline, scan validates the baseline carries the identity key before compiling instead of failing after the analysis, and --baseline-state absent without --baseline is an error rather than a silently empty listing. A filtered summary keeps the source-changed/path-changed attribution, recovered by identity value since filtered results are copies. Suggested follow-up commands no longer repeat the deprecated --partial-fingerprint-key next to --fingerprint-key. --- cli/cmd/scan.go | 38 +++++++++--- cli/cmd/summary.go | 44 +++++++++++--- cli/cmd/triage.go | 27 +++++++-- cli/cmd/triage_flags_test.go | 23 ++++++++ cli/internal/rules/select.go | 9 ++- cli/internal/rules/select_test.go | 32 ++++++++++ cli/internal/sarif/baseline.go | 71 +++++++++++++++++++---- cli/internal/sarif/baseline_test.go | 13 +++++ cli/internal/sarif/identity.go | 29 +++++---- cli/internal/sarif/identity_test.go | 24 +++++++- cli/internal/sarif/triage_summary_test.go | 35 ++++++++++- cli/internal/sarif/triage_view.go | 17 +++++- cli/internal/triage/triage.go | 16 +++-- cli/internal/triage/triage_test.go | 28 +++++++++ docs/baselines-and-suppressions.md | 3 +- docs/usage.md | 2 +- 16 files changed, 352 insertions(+), 59 deletions(-) diff --git a/cli/cmd/scan.go b/cli/cmd/scan.go index 5fa9aefd5d..57bc509167 100644 --- a/cli/cmd/scan.go +++ b/cli/cmd/scan.go @@ -271,6 +271,13 @@ func warnUnmatchedRulePatterns(selection rules.Selection, all []string) { // 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, @@ -367,6 +374,13 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { var absBaselinePath string if cfg.Baseline != "" { baseline, absBaselinePath = loadBaselineOrExit(cfg.Baseline, absSarifReportPath) + identityKey, keyErr := sarif.ResolveIdentityKey(cfg.FingerprintKey) + if keyErr != nil { + out.Fatalf("%s", keyErr) + } + if err := sarif.CheckBaselineIdentity(baseline, identityKey); err != nil { + out.Fatalf("%s", err) + } } sarifReportName := filepath.Base(absSarifReportPath) @@ -414,6 +428,23 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { out.Fatalf("Input validation failed: %s", err) } + hasBuiltin := false + for _, ruleSetPath := range absRuleSetPaths { + if ruleSetPath.Builtin { + hasBuiltin = true + 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. @@ -424,13 +455,6 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { return } - hasBuiltin := false - for _, ruleSetPath := range absRuleSetPaths { - if ruleSetPath.Builtin { - hasBuiltin = true - break - } - } if hasBuiltin { if _, err := utils.EnsureRulesPath(out); err != nil { failf("Failed to prepare built-in rules: %s", err) diff --git a/cli/cmd/summary.go b/cli/cmd/summary.go index 83ffc0b415..c0f7757bbc 100644 --- a/cli/cmd/summary.go +++ b/cli/cmd/summary.go @@ -101,17 +101,32 @@ This command only reads the report. It does not write files. To record decisions // --partial-fingerprint-key into the single key summary uses for everything it // does with fingerprints: baseline matching, --partial-fingerprint, and the // value printed as "Fingerprint:". One key means the fingerprint the listing -// shows is always the one `triage --accept` resolves. +// shows is always the one `triage --accept` resolves. The short aliases +// (sink, source-sink, trace) are expanded here, so the listing and filter +// paths — which look the key up verbatim in partialFingerprints — see the +// same full key the triage engine resolves. func resolveSummaryFingerprintKey() { - if summaryPartialFingerprintKey == "" { - return + full := func(key string) string { + if key == "" { + return "" + } + resolved, err := sarif.ResolveIdentityKey(key) + if err != nil { + out.Fatalf("%s", err) + } + return resolved } - if summaryFingerprintKey != "" && summaryFingerprintKey != summaryPartialFingerprintKey { - out.Fatalf("--fingerprint-key %q and --partial-fingerprint-key %q disagree: pass --fingerprint-key alone", - summaryFingerprintKey, summaryPartialFingerprintKey) + + newKey, oldKey := full(summaryFingerprintKey), full(summaryPartialFingerprintKey) + if oldKey != "" { + if newKey != "" && newKey != oldKey { + out.Fatalf("--fingerprint-key %q and --partial-fingerprint-key %q disagree: pass --fingerprint-key alone", + summaryFingerprintKey, summaryPartialFingerprintKey) + } + // cobra already prints the deprecation notice for the flag itself. + newKey = oldKey } - // cobra already prints the deprecation notice for the flag itself. - summaryFingerprintKey = summaryPartialFingerprintKey + summaryFingerprintKey = newKey } // requireBaselineStates refuses a --baseline-state filter that cannot mean @@ -123,6 +138,15 @@ func requireBaselineStates(report *sarif.Report, states []string, baseline strin if len(states) == 0 || baseline != "" { return nil } + // The absent state can never be satisfied from the report alone: fixed + // 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 : " + + "fixed findings live in the baseline and are never written into the current report") + } + } for _, r := range report.Results() { if r.BaselineState != nil { return nil @@ -231,7 +255,9 @@ func currentSummaryBuilder(sarifPath string) *utils.OpentaintCommandBuilder { builder.WithSeverity(summarySeverities) builder.WithRuleID(summaryRuleIDs) builder.WithPartialFingerprint(summaryFingerprints) - builder.WithPartialFingerprintKey(summaryPartialFingerprintKey) + // The deprecated --partial-fingerprint-key is not re-suggested: its value + // was folded into summaryFingerprintKey, which the line below emits under + // the flag's current name. builder.WithMaxNestingLevel(summaryMaxNestingLevel) builder.WithGroupBy(summaryGroupBy) builder.WithCodeFlow(summaryCodeFlow) diff --git a/cli/cmd/triage.go b/cli/cmd/triage.go index 28175ee04b..f8607afdb4 100644 --- a/cli/cmd/triage.go +++ b/cli/cmd/triage.go @@ -107,9 +107,16 @@ func runTriage(cfg TriageConfig, reportPath string) { out.Fatalf("Failed to load SARIF report: %s", err) } + // The aliases (sink, source-sink, trace) are expanded once here, so the + // listing shows fingerprints under the same full key the decisions resolve. + identityKey, err := sarif.ResolveIdentityKey(cfg.FingerprintKey) + if err != nil { + out.Fatalf("%s", err) + } + opts := triage.Options{ WriteBaselineState: cfg.WriteBaselineState, - FingerprintKey: cfg.FingerprintKey, + FingerprintKey: identityKey, Accept: cfg.Accept, Defer: cfg.Defer, Unsuppress: cfg.Unsuppress, @@ -121,15 +128,23 @@ func runTriage(cfg TriageConfig, reportPath string) { 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) } - outputPath := absReportPath - if cfg.Output != "" { - outputPath = log.AbsPathOrExit(cfg.Output, "output") - } // 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 { @@ -141,7 +156,7 @@ func runTriage(cfg TriageConfig, reportPath string) { printSarifSummary(report, outputPath, sarif.Filters{}, sarif.ListingOptions{ MaxNestingLevel: -1, ShowSuppressed: cfg.ShowSuppressed, - FingerprintKey: cfg.FingerprintKey, + FingerprintKey: identityKey, }, outcome.View, cfg.ShowFindings) exitOnGate(triage.Gate{Enabled: cfg.ErrorOnFindings, Severities: gateSeverities}, report, outcome.View) diff --git a/cli/cmd/triage_flags_test.go b/cli/cmd/triage_flags_test.go index 237310b0ef..f6106f8481 100644 --- a/cli/cmd/triage_flags_test.go +++ b/cli/cmd/triage_flags_test.go @@ -52,3 +52,26 @@ func TestRequireBaselineStatesAcceptsAPersistedReport(t *testing.T) { 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/internal/rules/select.go b/cli/internal/rules/select.go index 47372c09c5..bcd1459d41 100644 --- a/cli/internal/rules/select.go +++ b/cli/internal/rules/select.go @@ -48,11 +48,18 @@ func ListRuleIDs(roots []string) []string { 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, filepath.ToSlash(relPath)+":"+r.ID) + ids = append(ids, prefix+":"+r.ID) } return nil }) diff --git a/cli/internal/rules/select_test.go b/cli/internal/rules/select_test.go index 9b1e123dec..c598b8874c 100644 --- a/cli/internal/rules/select_test.go +++ b/cli/internal/rules/select_test.go @@ -239,3 +239,35 @@ func TestUnmatchedIsEmptyWhenEverythingMatches(t *testing.T) { 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 index d9aa7b19bc..06dcbc3bf4 100644 --- a/cli/internal/sarif/baseline.go +++ b/cli/internal/sarif/baseline.go @@ -12,6 +12,13 @@ type Comparison struct { states map[*Result]BaselineState changes map[*Result]Change + // key is the identity fingerprint the comparison ran under, and + // changesByIdentity records what moved per identity value. Together they + // let a filtered view — which holds copies of the classified results — + // recover the change attribution by fingerprint rather than by pointer. + key string + changesByIdentity map[string]Change + // 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 @@ -33,12 +40,20 @@ type Comparison struct { } // StateOf returns the state computed for a result, or "" when the result could -// not be matched (no identity fingerprint). +// 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 "" } - return c.states[r] + 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 @@ -80,6 +95,21 @@ func (c *Comparison) ChangeOf(r *Result) Change { 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 coarse key's usual granularity. +func (c *Comparison) changeOfIdentity(r *Result) Change { + if c == nil || c.changesByIdentity == nil { + return ChangeNone + } + id, ok := Identity(r, c.key) + if !ok { + return ChangeNone + } + return c.changesByIdentity[id] +} + // CompareToBaseline classifies every result in current against baseline, using // key as the identity fingerprint. Results that match are additionally compared // on the full-trace fingerprint to tell "unchanged" from "updated". @@ -87,6 +117,26 @@ func (c *Comparison) ChangeOf(r *Result) Change { // A baseline that holds results but none carrying key is rejected: silently // classifying everything as new would hide exactly the findings a baseline // exists to remember. +// CheckBaselineIdentity reports whether the baseline can be compared under the +// given identity key. A baseline that holds results but none carrying the key +// was produced with a different fingerprint key or without fingerprints, and +// comparing against it would silently classify every finding as new. The check +// is cheap, so callers that pay for a scan before comparing can run it first. +func CheckBaselineIdentity(baseline *Report, key string) error { + results := baseline.Results() + if len(results) == 0 { + return nil + } + for _, r := range results { + if _, ok := Identity(r, key); ok { + return nil + } + } + return fmt.Errorf( + "no result in the baseline carries the %q fingerprint: "+ + "it was produced with a different fingerprint key or without fingerprints", key) +} + func CompareToBaseline(current, baseline *Report, key string) (*Comparison, error) { baselineResults := baseline.Results() @@ -99,17 +149,17 @@ func CompareToBaseline(current, baseline *Report, key string) (*Comparison, erro byIdentity[id] = append(byIdentity[id], r) } if len(baselineResults) > 0 && len(byIdentity) == 0 { - return nil, fmt.Errorf( - "no result in the baseline carries the %q fingerprint: "+ - "it was produced with a different fingerprint key or without fingerprints", key) + return nil, CheckBaselineIdentity(baseline, key) } cmp := &Comparison{ - states: make(map[*Result]BaselineState), - changes: make(map[*Result]Change), - Counts: make(map[BaselineState]int), - ChangeCounts: make(map[Change]int), - BaselineGUID: baseline.RunGUID(), + states: make(map[*Result]BaselineState), + changes: make(map[*Result]Change), + key: key, + changesByIdentity: make(map[string]Change), + Counts: make(map[BaselineState]int), + ChangeCounts: make(map[Change]int), + BaselineGUID: baseline.RunGUID(), } refinements := finerKeys(key) @@ -135,6 +185,7 @@ func CompareToBaseline(current, baseline *Report, key string) (*Comparison, erro state = Unchanged } else { cmp.changes[r] = change + cmp.changesByIdentity[id] = change cmp.ChangeCounts[change]++ } cmp.states[r] = state diff --git a/cli/internal/sarif/baseline_test.go b/cli/internal/sarif/baseline_test.go index c9a4fe3f35..70cc5dae01 100644 --- a/cli/internal/sarif/baseline_test.go +++ b/cli/internal/sarif/baseline_test.go @@ -445,3 +445,16 @@ func TestChangeUnderTraceIdentityIsAlwaysNone(t *testing.T) { t.Errorf("change = %q, want none", got) } } + +func TestCheckBaselineIdentity(t *testing.T) { + if err := CheckBaselineIdentity(&Report{}, SourceSinkFingerprintKey); 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, SourceSinkFingerprintKey); err != nil { + t.Errorf("baseline carries the key: %v", err) + } + if err := CheckBaselineIdentity(carrying, "some-other-key/v1"); err == nil { + t.Error("expected an error for a key no baseline result carries") + } +} diff --git a/cli/internal/sarif/identity.go b/cli/internal/sarif/identity.go index b7e1aaede5..c66c9c5e97 100644 --- a/cli/internal/sarif/identity.go +++ b/cli/internal/sarif/identity.go @@ -104,33 +104,40 @@ func (report *Report) Results() []*Result { return out } -// ResolvePrefix finds the single result whose identity fingerprint starts with -// prefix, git-style. An empty, unmatched, or ambiguous prefix is an error: a -// suppression must name exactly one finding, never "whichever matched first". -func ResolvePrefix(report *Report, key, prefix string) (*Result, error) { +// 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 under the +// coarse default key 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, key, prefix string) ([]*Result, error) { if prefix == "" { return nil, fmt.Errorf("fingerprint prefix must not be empty") } var matches []*Result - var values []string + distinct := map[string]bool{} for _, r := range report.Results() { fp, ok := Identity(r, key) if !ok || !strings.HasPrefix(fp, prefix) { continue } matches = append(matches, r) - values = append(values, fp) + distinct[fp] = true } - switch len(matches) { - case 0: + if len(matches) == 0 { return nil, fmt.Errorf("no finding matches fingerprint %q (key %s)", prefix, key) - case 1: - return matches[0], nil - default: + } + 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 index e668aff748..6d5a2c1dcd 100644 --- a/cli/internal/sarif/identity_test.go +++ b/cli/internal/sarif/identity_test.go @@ -79,12 +79,30 @@ func TestResolvePrefixFindsUniqueMatch(t *testing.T) { makeResult("a", Error, "a.java", 1, map[string]string{SourceSinkFingerprintKey: "q3Vf9k2nAAA"}), makeResult("b", Error, "b.java", 2, map[string]string{SourceSinkFingerprintKey: "8bc1d2xxBBB"}), ) - r, err := ResolvePrefix(report, SourceSinkFingerprintKey, "q3Vf9k") + matched, err := ResolvePrefix(report, SourceSinkFingerprintKey, "q3Vf9k") if err != nil { t.Fatalf("unexpected error: %v", err) } - if *r.RuleID != "a" { - t.Errorf("resolved to rule %q, want a", *r.RuleID) + 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 coarse sink key 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{SourceSinkFingerprintKey: "q3Vf9kSAME"}), + makeResult("a", Error, "a.java", 9, map[string]string{SourceSinkFingerprintKey: "q3Vf9kSAME"}), + ) + matched, err := ResolvePrefix(report, SourceSinkFingerprintKey, "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)) } } diff --git a/cli/internal/sarif/triage_summary_test.go b/cli/internal/sarif/triage_summary_test.go index 6d474dbb62..309c00a9ef 100644 --- a/cli/internal/sarif/triage_summary_test.go +++ b/cli/internal/sarif/triage_summary_test.go @@ -202,7 +202,40 @@ func TestDisplayFingerprintIsTheOneTriageResolves(t *testing.T) { if err != nil { t.Fatalf("the fingerprint the listing shows does not resolve: %v", err) } - if got, _ := Identity(resolved, DefaultIdentityKey); got != "sink-of-source-sink-value" { + if len(resolved) != 1 { + t.Fatalf("resolved %d results, want 1", len(resolved)) + } + if got, _ := Identity(resolved[0], DefaultIdentityKey); got != "sink-of-source-sink-value" { t.Errorf("resolved %q, want the value under the default 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, SourceSinkFingerprintKey) + 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 index 1bd0590d1c..d2a6d38c7d 100644 --- a/cli/internal/sarif/triage_view.go +++ b/cli/internal/sarif/triage_view.go @@ -56,9 +56,13 @@ func (c *Comparison) restrict(filtered *Report, f Filters) *Comparison { return nil } out := &Comparison{ - states: c.states, - Counts: make(map[BaselineState]int), - BaselineGUID: c.BaselineGUID, + states: c.states, + changes: c.changes, + key: c.key, + changesByIdentity: c.changesByIdentity, + Counts: make(map[BaselineState]int), + ChangeCounts: make(map[Change]int), + BaselineGUID: c.BaselineGUID, } for _, r := range filtered.Results() { if r.BaselineState == nil { @@ -66,6 +70,13 @@ func (c *Comparison) restrict(filtered *Report, f Filters) *Comparison { 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) { diff --git a/cli/internal/triage/triage.go b/cli/internal/triage/triage.go index 6b650ba2e6..a6c21a0af1 100644 --- a/cli/internal/triage/triage.go +++ b/cli/internal/triage/triage.go @@ -119,18 +119,22 @@ func applyDecisions(report *sarif.Report, key string, opts Options) (int, error) var decisions []decision for _, prefix := range opts.Accept { - r, err := sarif.ResolvePrefix(report, key, prefix) + matched, err := sarif.ResolvePrefix(report, key, prefix) if err != nil { return 0, err } - decisions = append(decisions, decision{result: r, accept: true}) + for _, r := range matched { + decisions = append(decisions, decision{result: r, accept: true}) + } } for _, prefix := range opts.Defer { - r, err := sarif.ResolvePrefix(report, key, prefix) + matched, err := sarif.ResolvePrefix(report, key, prefix) if err != nil { return 0, err } - decisions = append(decisions, decision{result: r}) + for _, r := range matched { + decisions = append(decisions, decision{result: r}) + } } for _, d := range decisions { @@ -152,11 +156,11 @@ func applyDecisions(report *sarif.Report, key string, opts Options) (int, error) func applyUnsuppressions(report *sarif.Report, key string, prefixes []string) (int, error) { var targets []*sarif.Result for _, prefix := range prefixes { - r, err := sarif.ResolvePrefix(report, key, prefix) + matched, err := sarif.ResolvePrefix(report, key, prefix) if err != nil { return 0, err } - targets = append(targets, r) + targets = append(targets, matched...) } removed := 0 diff --git a/cli/internal/triage/triage_test.go b/cli/internal/triage/triage_test.go index 8bf1626d24..67f5ee4fba 100644 --- a/cli/internal/triage/triage_test.go +++ b/cli/internal/triage/triage_test.go @@ -257,3 +257,31 @@ func TestApplyReadOnlyStillInheritsSuppressions(t *testing.T) { 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/docs/baselines-and-suppressions.md b/docs/baselines-and-suppressions.md index 491ed9ee56..cbb5697155 100644 --- a/docs/baselines-and-suppressions.md +++ b/docs/baselines-and-suppressions.md @@ -72,7 +72,8 @@ opentaint triage baselines/main.sarif \ 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. `--accept`, `--defer`, and `--unsuppress` are +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. diff --git a/docs/usage.md b/docs/usage.md index 96fd6e4082..053b734b24 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -226,7 +226,7 @@ reflects the full set the tool ran. | `--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/fixed 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 fixed findings from the baseline. | +| `--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 fixed findings from the baseline and always needs `--baseline`. | | `--suppressed` | Include suppressed findings in the listing (hidden by default) | | `--fingerprint-key` | Which fingerprint identifies a finding: `trace` (exact), `source-sink`, `sink` (rule and sink only, the default), or a full partialFingerprints key. One key governs baseline matching, the `Fingerprint:` value shown, `--partial-fingerprint`, and the prefix `triage` resolves. | From 26e37df4ff22ce69590e5651a44833c50ce2ca52 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Mon, 24 Aug 2026 10:25:23 +0200 Subject: [PATCH 32/36] fix(cli): satisfy errcheck on SaveReport's best-effort cleanup golangci-lint (errcheck) flags the unchecked os.Remove and tmp.Close on the error paths of the atomic write. Both are deliberate best-effort calls whose errors carry no signal, so they are discarded explicitly. --- cli/internal/sarif/save.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/internal/sarif/save.go b/cli/internal/sarif/save.go index 09494e5230..9b2ff298aa 100644 --- a/cli/internal/sarif/save.go +++ b/cli/internal/sarif/save.go @@ -28,10 +28,10 @@ func SaveReport(report *Report, path string) error { return fmt.Errorf("failed to create temporary report file: %w", err) } tmpName := tmp.Name() - defer os.Remove(tmpName) // no-op once the rename below succeeds + defer func() { _ = os.Remove(tmpName) }() // no-op once the rename below succeeds if _, err := tmp.Write(data); err != nil { - tmp.Close() + _ = tmp.Close() return fmt.Errorf("failed to write sarif report: %w", err) } if err := tmp.Close(); err != nil { From 415433228e0215ad646724aac4574987e0630483 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Mon, 24 Aug 2026 10:45:23 +0200 Subject: [PATCH 33/36] docs(cli): triage help in simplified technical English, with recipes The triage Long follows the ASD-STE100 style of the base branch: short sentences, one instruction per sentence, and simple words. The examples gain two recipes, the decision-by-decision triage loop and the baseline roll-forward after a release. The baseline paragraphs that this branch adds to the scan and summary help follow the same style, and scan gains a CI-gate recipe. --- cli/cmd/triage.go | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/cli/cmd/triage.go b/cli/cmd/triage.go index f8607afdb4..48d6d75e03 100644 --- a/cli/cmd/triage.go +++ b/cli/cmd/triage.go @@ -36,34 +36,44 @@ 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 against a baseline and record triage decisions. Accepting a finding means it will not be fixed. Deferring means it is not being fixed for now. Both are recorded as SARIF suppressions, which any SARIF consumer honors. + 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 required positional argument is the path to the SARIF report to triage, such as one written by opentaint scan. Findings are identified by fingerprint, so a decision survives edits elsewhere in the code. Nothing is ever deleted: an accepted or deferred finding stays in the report, marked with a suppression that records the decision and its justification. +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. -Name a finding by a fingerprint prefix, git-style: the value shown as "Fingerprint:" by opentaint summary --show-findings. Both commands read the same key, so the value on screen is the value to paste here. The --fingerprint-key flag changes it on both sides. An ambiguous or unknown prefix is an error, never a guess. +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 key. Thus the value on the screen is the value to paste. The --fingerprint-key flag changes the key for both commands. A prefix that is unknown, or that matches two different values, causes an error. The command does not guess. -The triaged report is rewritten in place, or written to --output when set. With --baseline, decisions recorded in the baseline are inherited by the matching findings first, so a chain of reports carries its triage history forward. +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. -Run opentaint scan to produce the report this command triages. Review the result with opentaint summary. +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 modifying anything + 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 is not being fixed for now + # 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 - # Carry decisions forward and fail if anything new turned up - opentaint triage report.sarif --baseline release.sarif -o triaged.sarif --error-on-findings`, + # 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]) From 173faf7ccb6e757f48411b810d39d07fc7bfcd44 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Mon, 24 Aug 2026 22:35:48 +0200 Subject: [PATCH 34/36] feat(cli): stop calling every absent finding fixed, name what remains of it An absent baseline finding was always summarized as Fixed, but a fingerprint disappears whenever the code it hashes moves, so a gone hash does not prove a gone finding. The comparison now looks for what remains before the summary makes that claim: - a current result matching under a coarser fingerprint proves the sink is still reported, shown as "Gone, sink still reported" - a new result of the same rule in the same file hints the finding moved with its hash, shown as "Gone, possibly moved" - only absences with nothing left behind keep the Fixed label The listing prints the qualifier next to the absent state, and updated findings get their source/path-changed note there too. --- cli/cmd/summary.go | 3 + cli/internal/sarif/baseline.go | 155 +++++++++++++++++++++- cli/internal/sarif/baseline_test.go | 122 +++++++++++++++++ cli/internal/sarif/group.go | 3 + cli/internal/sarif/identity.go | 17 +++ cli/internal/sarif/print_findings.go | 6 +- cli/internal/sarif/triage_summary_test.go | 30 +++++ cli/internal/sarif/triage_view.go | 31 +++-- docs/baselines-and-suppressions.md | 19 ++- 9 files changed, 368 insertions(+), 18 deletions(-) diff --git a/cli/cmd/summary.go b/cli/cmd/summary.go index c0f7757bbc..ab82b77576 100644 --- a/cli/cmd/summary.go +++ b/cli/cmd/summary.go @@ -305,6 +305,9 @@ func printSarifSummary(report *sarif.Report, absSarifPath string, filters sarif. // 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 list { diff --git a/cli/internal/sarif/baseline.go b/cli/internal/sarif/baseline.go index 06dcbc3bf4..e8c0def497 100644 --- a/cli/internal/sarif/baseline.go +++ b/cli/internal/sarif/baseline.go @@ -19,6 +19,11 @@ type Comparison struct { key string 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 @@ -86,6 +91,74 @@ func (c Change) Label() string { } } +// 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 around the finding +// moves. So the comparison looks for what remains of the finding before the +// summary claims "fixed". +type Remnant string + +const ( + // RemnantNone means nothing in the current report points at the finding. + // The summary reports it as fixed. + RemnantNone Remnant = "" + // RemnantSameSink means a current result carries the same fingerprint + // under a coarser key, so the sink is still reported. The identity + // changed, the finding did not go away. + RemnantSameSink Remnant = "sink" + // RemnantSameRuleFile 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. + RemnantSameRuleFile Remnant = "moved" +) + +// Label describes a remnant in the words a report uses. +func (r Remnant) Label() string { + switch r { + case RemnantSameSink: + return "sink still reported" + case RemnantSameRuleFile: + return "possibly moved" + default: + 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, c.key) + 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 { @@ -153,13 +226,14 @@ func CompareToBaseline(current, baseline *Report, key string) (*Comparison, erro } cmp := &Comparison{ - states: make(map[*Result]BaselineState), - changes: make(map[*Result]Change), - key: key, - changesByIdentity: make(map[string]Change), - Counts: make(map[BaselineState]int), - ChangeCounts: make(map[Change]int), - BaselineGUID: baseline.RunGUID(), + states: make(map[*Result]BaselineState), + changes: make(map[*Result]Change), + key: key, + changesByIdentity: make(map[string]Change), + remnantsByIdentity: make(map[string]Remnant), + Counts: make(map[BaselineState]int), + ChangeCounts: make(map[Change]int), + BaselineGUID: baseline.RunGUID(), } refinements := finerKeys(key) @@ -206,10 +280,77 @@ func CompareToBaseline(current, baseline *Report, key string) (*Comparison, erro } } 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. Exact evidence first: a match under a coarser +// fingerprint proves the sink is still reported. Then the heuristic: a new +// finding of the same rule in the same file suggests the finding moved and +// its hash moved with it. +func (c *Comparison) attributeAbsent(current *Report) { + if len(c.Absent) == 0 { + return + } + + currentResults := current.Results() + valuesUnder := map[string]map[string]bool{} + for _, key := range coarserKeys(c.key) { + values := make(map[string]bool, len(currentResults)) + for _, r := range currentResults { + if v, ok := Identity(r, key); ok { + values[v] = true + } + } + valuesUnder[key] = values + } + newRuleFiles := map[string]bool{} + for _, r := range currentResults { + if c.states[r] != New { + continue + } + if rf, ok := ruleFileKey(r); ok { + newRuleFiles[rf] = true + } + } + + for _, r := range c.Absent { + remnant := RemnantNone + for _, key := range coarserKeys(c.key) { + if v, ok := Identity(r, key); ok && valuesUnder[key][v] { + remnant = RemnantSameSink + break + } + } + if remnant == RemnantNone { + if rf, ok := ruleFileKey(r); ok && newRuleFiles[rf] { + remnant = RemnantSameRuleFile + } + } + if remnant == RemnantNone { + continue + } + if id, ok := Identity(r, c.key); ok { + c.remnantsByIdentity[id] = remnant + } + } +} + +// 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 fixed // findings — which live in the baseline and never in the current report — can be diff --git a/cli/internal/sarif/baseline_test.go b/cli/internal/sarif/baseline_test.go index 70cc5dae01..497f1bfe13 100644 --- a/cli/internal/sarif/baseline_test.go +++ b/cli/internal/sarif/baseline_test.go @@ -446,6 +446,128 @@ func TestChangeUnderTraceIdentityIsAlwaysNone(t *testing.T) { } } +// Under a fine identity, a source change makes the old identity absent and the +// new one appear. The sink hash still matches, so the finding is not "fixed". +func TestRemnantSameSinkUnderFineIdentity(t *testing.T) { + baseline := makeReport(makeResult("a", Error, "a.java", 1, fps("sink-a", "src-old", "trace-old"))) + current := makeReport(makeResult("a", Error, "a.java", 1, fps("sink-a", "src-new", "trace-new"))) + + cmp, err := CompareToBaseline(current, baseline, SourceSinkFingerprintKey) + 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 != RemnantSameSink { + t.Errorf("remnant = %q, want %q", got, RemnantSameSink) + } + if got := cmp.StateOf(current.Results()[0]); got != New { + t.Errorf("the drifted identity should still classify as new, got %q", got) + } +} + +// Under the sink identity, no coarser hash exists. A new finding of the same +// rule in the same file is the hint that the sink hash itself drifted. +func TestRemnantPossiblyMovedUnderSinkIdentity(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, SinkFingerprintKey) + 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 = RemnantSameRuleFile + } + 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, SinkFingerprintKey) + 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 exact evidence outranks the heuristic: when the sink is provably still +// reported, the finding is not merely "possibly moved". +func TestRemnantPrefersSameSinkOverSameRuleFile(t *testing.T) { + baseline := makeReport(makeResult("a", Error, "a.java", 1, fps("sink-a", "src-old", "trace-old"))) + current := makeReport(makeResult("a", Error, "a.java", 1, fps("sink-a", "src-new", "trace-new"))) + + cmp, err := CompareToBaseline(current, baseline, SourceSinkFingerprintKey) + if err != nil { + t.Fatalf("compare: %v", err) + } + if got := cmp.RemnantOf(cmp.Absent[0]); got != RemnantSameSink { + t.Errorf("remnant = %q, want %q", got, RemnantSameSink) + } +} + +// 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-a", "src-old", "trace-old"))) + current := makeReport(makeResult("a", Error, "a.java", 1, fps("sink-a", "src-new", "trace-new"))) + + cmp, err := CompareToBaseline(current, baseline, SourceSinkFingerprintKey) + if err != nil { + t.Fatalf("compare: %v", err) + } + listing := current.WithAbsent(cmp.Absent) + copyOfGone := listing.Results()[1] + if got := cmp.RemnantOf(copyOfGone); got != RemnantSameSink { + t.Errorf("remnant on copy = %q, want %q", got, RemnantSameSink) + } + if got := cmp.StateNote(copyOfGone); got != "sink still reported" { + t.Errorf("state note = %q, want %q", got, "sink still reported") + } +} + +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, SinkFingerprintKey) + 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{}, SourceSinkFingerprintKey); err != nil { t.Errorf("an empty baseline is comparable: %v", err) diff --git a/cli/internal/sarif/group.go b/cli/internal/sarif/group.go index 1c010bbbe8..f3e6f4b16d 100644 --- a/cli/internal/sarif/group.go +++ b/cli/internal/sarif/group.go @@ -28,6 +28,9 @@ type ListingOptions struct { // 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 index c66c9c5e97..579df36bb2 100644 --- a/cli/internal/sarif/identity.go +++ b/cli/internal/sarif/identity.go @@ -58,6 +58,23 @@ func finerKeys(key string) []string { return []string{TraceFingerprintKey} } +// coarserKeys returns the keys that key refines, nearest first. A key outside +// the ladder has no coarser keys, so nothing can be said about what an absence +// under it leaves behind. +func coarserKeys(key string) []string { + for i, k := range identityLadder { + if k != key { + continue + } + out := make([]string, i) + for j := range out { + out[j] = identityLadder[i-1-j] + } + return out + } + return nil +} + // ResolveIdentityKey normalizes a user-supplied identity key, falling back to // DefaultIdentityKey when unset and expanding the short aliases. Any other key // is accepted as written — a report may carry fingerprints this build does not diff --git a/cli/internal/sarif/print_findings.go b/cli/internal/sarif/print_findings.go index f718a15edf..f194130a62 100644 --- a/cli/internal/sarif/print_findings.go +++ b/cli/internal/sarif/print_findings.go @@ -97,7 +97,11 @@ func (report *Report) buildFindingTree(out *output.Printer, result *Result, runI findingNode.Child(out.FieldItem("Location", locStr)) if result.BaselineState != nil { - findingNode.Child(out.FieldItem("Baseline", string(*result.BaselineState))) + 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) diff --git a/cli/internal/sarif/triage_summary_test.go b/cli/internal/sarif/triage_summary_test.go index 309c00a9ef..9c8697078f 100644 --- a/cli/internal/sarif/triage_summary_test.go +++ b/cli/internal/sarif/triage_summary_test.go @@ -57,6 +57,36 @@ func TestSummaryBaselineGroup(t *testing.T) { } } +func TestSummaryBaselineGroupHedgesAbsencesWithRemnants(t *testing.T) { + baseline := makeReport( + // The identity drifts but the sink hash survives: provably still there. + makeResult("a", Error, "a.java", 1, fps("sink-a", "src-old", "trace-old")), + // Everything drifts, and a new same-rule finding sits in the same file. + makeResult("b", Error, "b.java", 2, fps("sink-b-old", "src-b-old", "trace-b-old")), + // Genuinely gone. + makeResult("c", Error, "c.java", 3, fps("sink-c", "src-c", "trace-c")), + ) + report := makeReport( + makeResult("a", Error, "a.java", 1, fps("sink-a", "src-new", "trace-new")), + makeResult("b", Error, "b.java", 4, fps("sink-b-new", "src-b-new", "trace-b-new")), + ) + cmp, err := CompareToBaseline(report, baseline, SourceSinkFingerprintKey) + if err != nil { + t.Fatalf("compare: %v", err) + } + + out := renderSummary(t, report, &TriageView{BaselinePath: "b.sarif", Comparison: cmp}) + + for _, want := range []string{"Gone, sink still reported", "Gone, possibly moved", "Fixed"} { + if !strings.Contains(out, want) { + t.Errorf("missing %q in summary:\n%s", want, out) + } + } + if strings.Count(out, "Fixed") != 1 { + t.Errorf("exactly one Fixed 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"))) diff --git a/cli/internal/sarif/triage_view.go b/cli/internal/sarif/triage_view.go index d2a6d38c7d..5aebfaf5b9 100644 --- a/cli/internal/sarif/triage_view.go +++ b/cli/internal/sarif/triage_view.go @@ -56,13 +56,14 @@ func (c *Comparison) restrict(filtered *Report, f Filters) *Comparison { return nil } out := &Comparison{ - states: c.states, - changes: c.changes, - key: c.key, - changesByIdentity: c.changesByIdentity, - Counts: make(map[BaselineState]int), - ChangeCounts: make(map[Change]int), - BaselineGUID: c.BaselineGUID, + states: c.states, + changes: c.changes, + key: c.key, + 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 { @@ -130,8 +131,20 @@ func (v *TriageView) baselineItems(out *output.Printer) []any { if rest := v.Comparison.Counts[Updated] - attributed; rest > 0 { items = append(items, out.FieldItem("Updated", rest)) } - // "Fixed" reads better than SARIF's "absent" for a finding that is gone. - if count := v.Comparison.Counts[Absent]; count > 0 { + // An absent finding is not always a fixed one: the hash may have changed + // while the finding stayed. Absences that left a trace in the current + // report get their own hedged lines, and "Fixed" — which reads better than + // SARIF's "absent" — keeps only the ones with nothing left behind. + remnants := map[Remnant]int{} + for _, r := range v.Comparison.Absent { + remnants[v.Comparison.RemnantOf(r)]++ + } + for _, remnant := range []Remnant{RemnantSameSink, RemnantSameRuleFile} { + if count := remnants[remnant]; count > 0 { + items = append(items, out.FieldItem("Gone, "+remnant.Label(), count)) + } + } + if count := remnants[RemnantNone]; count > 0 { items = append(items, out.FieldItem("Fixed", count)) } // Baseline findings whose rule did not run are deliberately not folded into diff --git a/docs/baselines-and-suppressions.md b/docs/baselines-and-suppressions.md index cbb5697155..8d52eb602b 100644 --- a/docs/baselines-and-suppressions.md +++ b/docs/baselines-and-suppressions.md @@ -133,7 +133,7 @@ Given `--baseline old.sarif`, every current finding is classified: | `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, gone now (i.e. fixed) | +| `absent` | In the baseline, not in this scan — fixed, unless something still points at it (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: @@ -218,6 +218,23 @@ selects either. The distinction narrows with a finer identity: under `source-sink` a moved source is `new` + `absent` rather than `updated`, and under `trace` nothing is left to refine, so a match is always `unchanged`. +An absence gets the same scrutiny before the summary calls it fixed. A +fingerprint disappears whenever the code it hashes 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 | +|------|---------| +| `Fixed` | Nothing in the current report points at the finding. | +| `Gone, sink still reported` | A current finding carries the same hash under a coarser key, so the sink is provably still reported. The identity changed, the finding did not go away. | +| `Gone, possibly moved` | 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. | + +`Gone, sink still reported` needs a coarser key to check against, so it appears +under `source-sink` and `trace` but never under the default `sink` key. All +three lines are `absent` in SARIF terms: `--baseline-state absent` selects them +all, and the listing prints the qualifier next to each finding's `Baseline:` +state. + Comparing reports built with different fingerprint keys is a hard error, not a silent zero-match. Findings that carry no fingerprint at all (a report produced without fingerprints) are reported as-is and counted as "not comparable." From 20bdf425d11c38e5d20e52aef20c7bd863ee47f6 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Tue, 25 Aug 2026 00:22:16 +0200 Subject: [PATCH 35/36] feat(cli): fix the finding identity to the sink hash, drop --fingerprint-key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The identity of a finding is now always the sink hash. The finer hashes (source-sink, trace) no longer ever decide identity: the comparison reads them only to describe what happened to a finding — the existing "Updated, source changed" and "Updated, path changed" attribution. A configurable identity bought little and cost a lot: under a finer key every source or path change split one finding into a new + absent pair, and decisions silently detached from findings that never went away. With the key fixed, the absent-finding attribution also simplifies, and its labels change per review: - "Fixed" becomes "Absent": the finding is gone and nothing points at it, but the summary no longer claims someone fixed it - "Gone, possibly moved" becomes "Possibly drifted": a new finding of the same rule in the same file hints the sink hash moved with the code - "Gone, sink still reported" is gone with the finer keys that made it possible Removed flags: --fingerprint-key (scan, triage, summary) and the deprecated --partial-fingerprint-key. --partial-fingerprint still filters, by the identity value. A baseline without sink hashes is now rejected with a pointer at the analyzer being too old. --- cli/cmd/scan.go | 11 +- cli/cmd/summary.go | 74 ++------- cli/cmd/triage.go | 14 +- cli/internal/sarif/baseline.go | 141 ++++++---------- cli/internal/sarif/baseline_test.go | 150 ++++-------------- cli/internal/sarif/filter.go | 37 ++--- cli/internal/sarif/filter_test.go | 13 +- cli/internal/sarif/group.go | 1 - cli/internal/sarif/identity.go | 97 +++-------- cli/internal/sarif/identity_test.go | 89 ++--------- cli/internal/sarif/print_findings.go | 12 +- cli/internal/sarif/render_test.go | 10 +- cli/internal/sarif/suppress.go | 6 +- cli/internal/sarif/suppress_test.go | 8 +- cli/internal/sarif/triage_summary_test.go | 47 +++--- cli/internal/sarif/triage_view.go | 16 +- cli/internal/triage/triage.go | 24 ++- .../utils/opentaint_command_builder.go | 16 -- .../utils/opentaint_command_builder_test.go | 2 - docs/baselines-and-suppressions.md | 84 ++++------ docs/usage.md | 10 +- 21 files changed, 250 insertions(+), 612 deletions(-) diff --git a/cli/cmd/scan.go b/cli/cmd/scan.go index 57bc509167..90c36188b3 100644 --- a/cli/cmd/scan.go +++ b/cli/cmd/scan.go @@ -43,7 +43,6 @@ type ScanConfig struct { Baseline string WriteBaselineState bool - FingerprintKey string ErrorOnFindings bool ErrorOnSeverity []string @@ -191,7 +190,7 @@ func addScanFlags(cmd *cobra.Command) { cmd.Flags().BoolVar(&scanFlags.TrackExternalMethods, "track-external-methods", false, "Write external-method coverage files next to the SARIF report") - addBaselineFlags(cmd, &scanFlags.Baseline, &scanFlags.FingerprintKey) + 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) } @@ -210,7 +209,6 @@ func currentScanBuilder(cfg ScanConfig, sourcePath string) *utils.OpentaintComma WithTrackExternalMethods(cfg.TrackExternalMethods). WithBaseline(cfg.Baseline). WithWriteBaselineState(cfg.WriteBaselineState). - WithFingerprintKey(cfg.FingerprintKey). WithErrorOnFindings(cfg.ErrorOnFindings). WithErrorOnSeverity(cfg.ErrorOnSeverity) if !isDefaultSeverity(cfg.Severity) { @@ -374,11 +372,7 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { var absBaselinePath string if cfg.Baseline != "" { baseline, absBaselinePath = loadBaselineOrExit(cfg.Baseline, absSarifReportPath) - identityKey, keyErr := sarif.ResolveIdentityKey(cfg.FingerprintKey) - if keyErr != nil { - out.Fatalf("%s", keyErr) - } - if err := sarif.CheckBaselineIdentity(baseline, identityKey); err != nil { + if err := sarif.CheckBaselineIdentity(baseline); err != nil { out.Fatalf("%s", err) } } @@ -675,7 +669,6 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { 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, - FingerprintKey: cfg.FingerprintKey, Baseline: baseline, BaselinePath: absBaselinePath, }) diff --git a/cli/cmd/summary.go b/cli/cmd/summary.go index ab82b77576..ffd4ca2950 100644 --- a/cli/cmd/summary.go +++ b/cli/cmd/summary.go @@ -2,7 +2,6 @@ package cmd import ( "fmt" - "strings" "github.com/seqra/opentaint/internal/sarif" "github.com/seqra/opentaint/internal/triage" @@ -22,7 +21,7 @@ The sarif-report argument is the path to a SARIF report. It is required. Use a r 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 fixed. Use --baseline-state to show only the findings in one of those states. +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 @@ -69,7 +68,6 @@ This command only reads the report. It does not write files. To record decisions if err != nil { out.Fatalf("%s", err) } - resolveSummaryFingerprintKey() absSarifPath := log.AbsPathOrExit(args[0], "sarif path") report, err := sarif.LoadReport(absSarifPath) @@ -97,38 +95,6 @@ This command only reads the report. It does not write files. To record decisions }, } -// resolveSummaryFingerprintKey collapses --fingerprint-key and the older -// --partial-fingerprint-key into the single key summary uses for everything it -// does with fingerprints: baseline matching, --partial-fingerprint, and the -// value printed as "Fingerprint:". One key means the fingerprint the listing -// shows is always the one `triage --accept` resolves. The short aliases -// (sink, source-sink, trace) are expanded here, so the listing and filter -// paths — which look the key up verbatim in partialFingerprints — see the -// same full key the triage engine resolves. -func resolveSummaryFingerprintKey() { - full := func(key string) string { - if key == "" { - return "" - } - resolved, err := sarif.ResolveIdentityKey(key) - if err != nil { - out.Fatalf("%s", err) - } - return resolved - } - - newKey, oldKey := full(summaryFingerprintKey), full(summaryPartialFingerprintKey) - if oldKey != "" { - if newKey != "" && newKey != oldKey { - out.Fatalf("--fingerprint-key %q and --partial-fingerprint-key %q disagree: pass --fingerprint-key alone", - summaryFingerprintKey, summaryPartialFingerprintKey) - } - // cobra already prints the deprecation notice for the flag itself. - newKey = oldKey - } - summaryFingerprintKey = newKey -} - // 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 @@ -138,13 +104,13 @@ func requireBaselineStates(report *sarif.Report, states []string, baseline strin if len(states) == 0 || baseline != "" { return nil } - // The absent state can never be satisfied from the report alone: fixed + // 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 : " + - "fixed findings live in the baseline and are never written into the current report") + "absent findings live in the baseline and are never written into the current report") } } for _, r := range report.Results() { @@ -166,10 +132,9 @@ func applyTriageForDisplay(report *sarif.Report, absSarifPath string) *sarif.Tri baseline, absBaselinePath := loadBaselineOrExit(summaryBaseline, absSarifPath) outcome, err := triage.Apply(report, triage.Options{ - Baseline: baseline, - BaselinePath: absBaselinePath, - FingerprintKey: summaryFingerprintKey, - ReadOnly: true, + Baseline: baseline, + BaselinePath: absBaselinePath, + ReadOnly: true, }) if err != nil { out.Fatalf("%s", err) @@ -185,13 +150,11 @@ var summaryPaths []string var summarySeverities []string var summaryRuleIDs []string var summaryFingerprints []string -var summaryPartialFingerprintKey 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 summaryFingerprintKey string var summaryShowSuppressed bool func init() { @@ -204,23 +167,18 @@ func init() { 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().StringVar(&summaryPartialFingerprintKey, "partial-fingerprint-key", "", "Deprecated alias for --fingerprint-key") - if err := summaryCmd.Flags().MarkDeprecated("partial-fingerprint-key", "use --fingerprint-key"); err != nil { - panic(err) - } 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 (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, &summaryFingerprintKey) + 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, fingerprintKey *string) { +func addBaselineFlags(cmd *cobra.Command, baseline *string) { cmd.Flags().StringVar(baseline, "baseline", "", "Previous SARIF report to compare against and inherit suppressions from") - cmd.Flags().StringVar(fingerprintKey, "fingerprint-key", "", "Which fingerprint identifies a finding across reports: "+strings.Join(sarif.IdentityAliases, ", ")+", or a partialFingerprints key (defaults to sink)") } // loadBaselineOrExit resolves and loads a baseline report, refusing to use the @@ -255,14 +213,10 @@ func currentSummaryBuilder(sarifPath string) *utils.OpentaintCommandBuilder { builder.WithSeverity(summarySeverities) builder.WithRuleID(summaryRuleIDs) builder.WithPartialFingerprint(summaryFingerprints) - // The deprecated --partial-fingerprint-key is not re-suggested: its value - // was folded into summaryFingerprintKey, which the line below emits under - // the flag's current name. builder.WithMaxNestingLevel(summaryMaxNestingLevel) builder.WithGroupBy(summaryGroupBy) builder.WithCodeFlow(summaryCodeFlow) builder.WithBaseline(summaryBaseline) - builder.WithFingerprintKey(summaryFingerprintKey) builder.WithBaselineStateFilter(summaryBaselineStates) builder.WithSuppressed(summaryShowSuppressed) return builder @@ -273,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, } } @@ -291,7 +244,6 @@ func summaryListingOptions(dim sarif.GroupDimension, codeFlowSel sarif.CodeFlowS VerboseFlow: verboseFlow, MaxNestingLevel: summaryMaxNestingLevel, GroupBy: dim, - FingerprintKey: summaryFingerprintKey, CodeFlows: codeFlowSel, ShowSuppressed: summaryShowSuppressed, } @@ -311,7 +263,7 @@ func printSarifSummary(report *sarif.Report, absSarifPath string, filters sarif. hasOmittedFlow := false if list { - // Fixed findings live in the baseline, so they only reach the listing when + // 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 { diff --git a/cli/cmd/triage.go b/cli/cmd/triage.go index 48d6d75e03..94ceed0987 100644 --- a/cli/cmd/triage.go +++ b/cli/cmd/triage.go @@ -18,7 +18,6 @@ const ExitFindings = 2 type TriageConfig struct { Baseline string WriteBaselineState bool - FingerprintKey string Accept []string Defer []string Unsuppress []string @@ -40,7 +39,7 @@ var triageCmd = &cobra.Command{ 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 key. Thus the value on the screen is the value to paste. The --fingerprint-key flag changes the key for both commands. A prefix that is unknown, or that matches two different values, causes an error. The command does not guess. +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. @@ -83,7 +82,7 @@ Exit codes: func init() { rootCmd.AddCommand(triageCmd) - addBaselineFlags(triageCmd, &triageFlags.Baseline, &triageFlags.FingerprintKey) + 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)") @@ -117,16 +116,8 @@ func runTriage(cfg TriageConfig, reportPath string) { out.Fatalf("Failed to load SARIF report: %s", err) } - // The aliases (sink, source-sink, trace) are expanded once here, so the - // listing shows fingerprints under the same full key the decisions resolve. - identityKey, err := sarif.ResolveIdentityKey(cfg.FingerprintKey) - if err != nil { - out.Fatalf("%s", err) - } - opts := triage.Options{ WriteBaselineState: cfg.WriteBaselineState, - FingerprintKey: identityKey, Accept: cfg.Accept, Defer: cfg.Defer, Unsuppress: cfg.Unsuppress, @@ -166,7 +157,6 @@ func runTriage(cfg TriageConfig, reportPath string) { printSarifSummary(report, outputPath, sarif.Filters{}, sarif.ListingOptions{ MaxNestingLevel: -1, ShowSuppressed: cfg.ShowSuppressed, - FingerprintKey: identityKey, }, outcome.View, cfg.ShowFindings) exitOnGate(triage.Gate{Enabled: cfg.ErrorOnFindings, Severities: gateSeverities}, report, outcome.View) diff --git a/cli/internal/sarif/baseline.go b/cli/internal/sarif/baseline.go index e8c0def497..228e416610 100644 --- a/cli/internal/sarif/baseline.go +++ b/cli/internal/sarif/baseline.go @@ -12,11 +12,9 @@ type Comparison struct { states map[*Result]BaselineState changes map[*Result]Change - // key is the identity fingerprint the comparison ran under, and - // changesByIdentity records what moved per identity value. Together they - // let a filtered view — which holds copies of the classified results — - // recover the change attribution by fingerprint rather than by pointer. - key string + // 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 @@ -30,8 +28,8 @@ type Comparison struct { // 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 — the fixed - // findings. They are reported, never written back into the current report. + // 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 @@ -93,35 +91,26 @@ func (c Change) Label() string { // 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 around the finding -// moves. So the comparison looks for what remains of the finding before the -// summary claims "fixed". +// 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. - // The summary reports it as fixed. RemnantNone Remnant = "" - // RemnantSameSink means a current result carries the same fingerprint - // under a coarser key, so the sink is still reported. The identity - // changed, the finding did not go away. - RemnantSameSink Remnant = "sink" - // RemnantSameRuleFile 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. - RemnantSameRuleFile Remnant = "moved" + // 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 { - switch r { - case RemnantSameSink: - return "sink still reported" - case RemnantSameRuleFile: - return "possibly moved" - default: - return "" + if r == RemnantDrifted { + return "possibly drifted" } + return "" } // RemnantOf returns what the current report still shows of an absent finding. @@ -131,7 +120,7 @@ func (c *Comparison) RemnantOf(r *Result) Remnant { if c == nil || c.remnantsByIdentity == nil { return RemnantNone } - id, ok := Identity(r, c.key) + id, ok := Identity(r, IdentityKey) if !ok { return RemnantNone } @@ -171,64 +160,61 @@ func (c *Comparison) ChangeOf(r *Result) Change { // 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 coarse key's usual granularity. +// 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, c.key) + id, ok := Identity(r, IdentityKey) if !ok { return ChangeNone } return c.changesByIdentity[id] } -// CompareToBaseline classifies every result in current against baseline, using -// key as the identity fingerprint. Results that match are additionally compared -// on the full-trace fingerprint to tell "unchanged" from "updated". -// -// A baseline that holds results but none carrying key is rejected: silently -// classifying everything as new would hide exactly the findings a baseline -// exists to remember. -// CheckBaselineIdentity reports whether the baseline can be compared under the -// given identity key. A baseline that holds results but none carrying the key -// was produced with a different fingerprint key or without fingerprints, and -// comparing against it would silently classify every finding as new. The check -// is cheap, so callers that pay for a scan before comparing can run it first. -func CheckBaselineIdentity(baseline *Report, key string) error { +// 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, key); ok { + if _, ok := Identity(r, IdentityKey); ok { return nil } } return fmt.Errorf( "no result in the baseline carries the %q fingerprint: "+ - "it was produced with a different fingerprint key or without fingerprints", key) + "it was produced without fingerprints, or by an analyzer too old to emit this one", IdentityKey) } -func CompareToBaseline(current, baseline *Report, key string) (*Comparison, error) { +// 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, key) + 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, key) + return nil, CheckBaselineIdentity(baseline) } cmp := &Comparison{ states: make(map[*Result]BaselineState), changes: make(map[*Result]Change), - key: key, changesByIdentity: make(map[string]Change), remnantsByIdentity: make(map[string]Remnant), Counts: make(map[BaselineState]int), @@ -236,10 +222,9 @@ func CompareToBaseline(current, baseline *Report, key string) (*Comparison, erro BaselineGUID: baseline.RunGUID(), } - refinements := finerKeys(key) matched := make(map[string]bool, len(byIdentity)) for _, r := range current.Results() { - id, ok := Identity(r, key) + id, ok := Identity(r, IdentityKey) if !ok { cmp.Unmatchable++ continue @@ -253,7 +238,7 @@ func CompareToBaseline(current, baseline *Report, key string) (*Comparison, erro } matched[id] = true - change := changeUnder(r, previous, refinements) + change := changeUnder(r, previous) state := Updated if change == ChangeNone { state = Unchanged @@ -286,28 +271,15 @@ func CompareToBaseline(current, baseline *Report, key string) (*Comparison, erro } // attributeAbsent records, for every absent finding, whatever the current -// report still shows of it. Exact evidence first: a match under a coarser -// fingerprint proves the sink is still reported. Then the heuristic: a new -// finding of the same rule in the same file suggests the finding moved and -// its hash moved with it. +// 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 } - currentResults := current.Results() - valuesUnder := map[string]map[string]bool{} - for _, key := range coarserKeys(c.key) { - values := make(map[string]bool, len(currentResults)) - for _, r := range currentResults { - if v, ok := Identity(r, key); ok { - values[v] = true - } - } - valuesUnder[key] = values - } newRuleFiles := map[string]bool{} - for _, r := range currentResults { + for _, r := range current.Results() { if c.states[r] != New { continue } @@ -317,23 +289,12 @@ func (c *Comparison) attributeAbsent(current *Report) { } for _, r := range c.Absent { - remnant := RemnantNone - for _, key := range coarserKeys(c.key) { - if v, ok := Identity(r, key); ok && valuesUnder[key][v] { - remnant = RemnantSameSink - break - } - } - if remnant == RemnantNone { - if rf, ok := ruleFileKey(r); ok && newRuleFiles[rf] { - remnant = RemnantSameRuleFile - } - } - if remnant == RemnantNone { + rf, ok := ruleFileKey(r) + if !ok || !newRuleFiles[rf] { continue } - if id, ok := Identity(r, c.key); ok { - c.remnantsByIdentity[id] = remnant + if id, ok := Identity(r, IdentityKey); ok { + c.remnantsByIdentity[id] = RemnantDrifted } } } @@ -352,7 +313,7 @@ func ruleFileKey(r *Result) (string, bool) { } // 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 fixed +// 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. @@ -369,10 +330,10 @@ func (report *Report) WithAbsent(absent []*Result) *Report { results := make([]Result, 0, len(run.Results)+len(absent)) results = append(results, run.Results...) for _, r := range absent { - fixed := *r + gone := *r state := Absent - fixed.BaselineState = &state - results = append(results, fixed) + gone.BaselineState = &state + results = append(results, gone) } run.Results = results out.Runs[0] = run @@ -381,7 +342,7 @@ func (report *Report) WithAbsent(absent []*Result) *Report { // 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 fixed. +// 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 { @@ -408,11 +369,11 @@ func ranInCurrentScan(r *Result, executed map[string]bool) bool { } // changeUnder reports the coarsest thing that moved below a finding's identity. -// The refinements are ordered nearest-first, so the first one that differs is +// 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, refinements []string) Change { - for _, key := range refinements { +func changeUnder(current *Result, previous []*Result) Change { + for _, key := range refiningKeys { if sameUnder(current, previous, key) { continue } diff --git a/cli/internal/sarif/baseline_test.go b/cli/internal/sarif/baseline_test.go index 497f1bfe13..a285681449 100644 --- a/cli/internal/sarif/baseline_test.go +++ b/cli/internal/sarif/baseline_test.go @@ -43,7 +43,7 @@ func TestCompareClassifiesNewUnchangedUpdatedAbsent(t *testing.T) { makeResult("fresh", Error, "d.java", 4, fp("id-fresh", "trace-fresh")), ) - cmp, err := CompareToBaseline(current, baseline, SourceSinkFingerprintKey) + cmp, err := CompareToBaseline(current, baseline) if err != nil { t.Fatalf("compare: %v", err) } @@ -71,24 +71,11 @@ func TestCompareClassifiesNewUnchangedUpdatedAbsent(t *testing.T) { } } -func TestCompareWithTraceKeyNeverReportsUpdated(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, TraceFingerprintKey) - 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 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, SourceSinkFingerprintKey) + cmp, err := CompareToBaseline(current, baseline) if err != nil { t.Fatalf("compare: %v", err) } @@ -104,7 +91,7 @@ func TestCompareCountsUnmatchableResultsSeparately(t *testing.T) { makeResult("nofp", Error, "b.java", 2, nil), ) - cmp, err := CompareToBaseline(current, baseline, SourceSinkFingerprintKey) + cmp, err := CompareToBaseline(current, baseline) if err != nil { t.Fatalf("compare: %v", err) } @@ -126,7 +113,7 @@ func TestCompareDuplicateIdentitiesBothMatch(t *testing.T) { makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a")), ) - cmp, err := CompareToBaseline(current, baseline, SourceSinkFingerprintKey) + cmp, err := CompareToBaseline(current, baseline) if err != nil { t.Fatalf("compare: %v", err) } @@ -142,7 +129,6 @@ func TestCompareEmptyBaselineMakesEverythingNew(t *testing.T) { cmp, err := CompareToBaseline( makeReport(makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a"))), &Report{}, - SourceSinkFingerprintKey, ) if err != nil { t.Fatalf("compare: %v", err) @@ -156,7 +142,7 @@ 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, SourceSinkFingerprintKey) + _, err := CompareToBaseline(current, baseline) if err == nil { t.Fatal("expected an error when no baseline result carries the identity key") } @@ -168,7 +154,6 @@ func TestCompareEmptyBaselineIsNotAKeyMismatch(t *testing.T) { if _, err := CompareToBaseline( makeReport(makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a"))), &Report{Runs: []Run{{}}}, - SourceSinkFingerprintKey, ); err != nil { t.Errorf("unexpected error: %v", err) } @@ -183,7 +168,7 @@ func TestApplyWritesBaselineStateAndGUID(t *testing.T) { makeResult("fresh", Error, "b.java", 2, fp("id-fresh", "trace-fresh")), ) - cmp, err := CompareToBaseline(current, baseline, SourceSinkFingerprintKey) + cmp, err := CompareToBaseline(current, baseline) if err != nil { t.Fatalf("compare: %v", err) } @@ -205,7 +190,7 @@ 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, SourceSinkFingerprintKey) + cmp, err := CompareToBaseline(current, baseline) if err != nil { t.Fatalf("compare: %v", err) } @@ -223,7 +208,7 @@ func TestApplyLeavesUnmatchableResultsUnannotated(t *testing.T) { baseline := makeReport(makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a"))) current := makeReport(makeResult("nofp", Error, "b.java", 2, nil)) - cmp, err := CompareToBaseline(current, baseline, SourceSinkFingerprintKey) + cmp, err := CompareToBaseline(current, baseline) if err != nil { t.Fatalf("compare: %v", err) } @@ -291,7 +276,7 @@ func TestCompareKeepsExcludedRuleOutOfFixed(t *testing.T) { makeResult("kept", Error, "a.java", 1, fp("id-kept", "trace-kept")), ), "kept") - cmp, err := CompareToBaseline(current, baseline, SourceSinkFingerprintKey) + cmp, err := CompareToBaseline(current, baseline) if err != nil { t.Fatalf("compare: %v", err) } @@ -313,7 +298,7 @@ func TestCompareTreatsMissingRuleListAsEverythingRan(t *testing.T) { baseline := makeReport(makeResult("a", Error, "a.java", 1, fp("id-a", "trace-a"))) current := makeReport() - cmp, err := CompareToBaseline(current, baseline, SourceSinkFingerprintKey) + cmp, err := CompareToBaseline(current, baseline) if err != nil { t.Fatalf("compare: %v", err) } @@ -360,7 +345,7 @@ func TestChangeUnderSinkIdentityNamesWhatMoved(t *testing.T) { makeResult("c", Error, "c.java", 3, fps("sink-c", "src-c", "trace-c-longer")), // path moved ) - cmp, err := CompareToBaseline(current, baseline, SinkFingerprintKey) + cmp, err := CompareToBaseline(current, baseline) if err != nil { t.Fatalf("compare: %v", err) } @@ -401,7 +386,7 @@ 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, SinkFingerprintKey) + cmp, err := CompareToBaseline(current, baseline) if err != nil { t.Fatalf("compare: %v", err) } @@ -410,66 +395,9 @@ func TestChangeReportsTheCoarsestThingThatMoved(t *testing.T) { } } -// Choosing a finer identity leaves less to refine: under source/sink, a moved -// source is a different finding, not an updated one. -func TestChangeUnderSourceSinkIdentityOnlyReportsPath(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-a", "trace-a2"))) - - cmp, err := CompareToBaseline(current, baseline, SourceSinkFingerprintKey) - if err != nil { - t.Fatalf("compare: %v", err) - } - if got := cmp.ChangeOf(current.Results()[0]); got != ChangePath { - t.Errorf("change = %q, want %q", got, ChangePath) - } - if got := cmp.ChangeCounts[ChangeSource]; got != 0 { - t.Errorf("source-changed count = %d, want 0 under a source-binding identity", got) - } -} - -// The trace hash is the finest key, so nothing refines it: a match is a match. -func TestChangeUnderTraceIdentityIsAlwaysNone(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", 9, fps("sink-z", "src-z", "trace-a"))) - - cmp, err := CompareToBaseline(current, baseline, TraceFingerprintKey) - if err != nil { - t.Fatalf("compare: %v", err) - } - r := current.Results()[0] - if got := cmp.StateOf(r); got != Unchanged { - t.Errorf("state = %q, want unchanged", got) - } - if got := cmp.ChangeOf(r); got != ChangeNone { - t.Errorf("change = %q, want none", got) - } -} - -// Under a fine identity, a source change makes the old identity absent and the -// new one appear. The sink hash still matches, so the finding is not "fixed". -func TestRemnantSameSinkUnderFineIdentity(t *testing.T) { - baseline := makeReport(makeResult("a", Error, "a.java", 1, fps("sink-a", "src-old", "trace-old"))) - current := makeReport(makeResult("a", Error, "a.java", 1, fps("sink-a", "src-new", "trace-new"))) - - cmp, err := CompareToBaseline(current, baseline, SourceSinkFingerprintKey) - 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 != RemnantSameSink { - t.Errorf("remnant = %q, want %q", got, RemnantSameSink) - } - if got := cmp.StateOf(current.Results()[0]); got != New { - t.Errorf("the drifted identity should still classify as new, got %q", got) - } -} - -// Under the sink identity, no coarser hash exists. A new finding of the same -// rule in the same file is the hint that the sink hash itself drifted. -func TestRemnantPossiblyMovedUnderSinkIdentity(t *testing.T) { +// 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")), @@ -478,7 +406,7 @@ func TestRemnantPossiblyMovedUnderSinkIdentity(t *testing.T) { makeResult("a", Error, "a.java", 12, fps("sink-new", "src-a2", "trace-a2")), ) - cmp, err := CompareToBaseline(current, baseline, SinkFingerprintKey) + cmp, err := CompareToBaseline(current, baseline) if err != nil { t.Fatalf("compare: %v", err) } @@ -488,7 +416,7 @@ func TestRemnantPossiblyMovedUnderSinkIdentity(t *testing.T) { for _, r := range cmp.Absent { want := RemnantNone if *r.RuleID == "a" { - want = RemnantSameRuleFile + want = RemnantDrifted } if got := cmp.RemnantOf(r); got != want { t.Errorf("rule %s: remnant = %q, want %q", *r.RuleID, got, want) @@ -507,7 +435,7 @@ func TestRemnantIgnoresMatchedFindingsOfTheSameRule(t *testing.T) { makeResult("a", Error, "a.java", 10, fps("sink-kept", "src-a", "trace-a")), ) - cmp, err := CompareToBaseline(current, baseline, SinkFingerprintKey) + cmp, err := CompareToBaseline(current, baseline) if err != nil { t.Fatalf("compare: %v", err) } @@ -519,38 +447,23 @@ func TestRemnantIgnoresMatchedFindingsOfTheSameRule(t *testing.T) { } } -// The exact evidence outranks the heuristic: when the sink is provably still -// reported, the finding is not merely "possibly moved". -func TestRemnantPrefersSameSinkOverSameRuleFile(t *testing.T) { - baseline := makeReport(makeResult("a", Error, "a.java", 1, fps("sink-a", "src-old", "trace-old"))) - current := makeReport(makeResult("a", Error, "a.java", 1, fps("sink-a", "src-new", "trace-new"))) - - cmp, err := CompareToBaseline(current, baseline, SourceSinkFingerprintKey) - if err != nil { - t.Fatalf("compare: %v", err) - } - if got := cmp.RemnantOf(cmp.Absent[0]); got != RemnantSameSink { - t.Errorf("remnant = %q, want %q", got, RemnantSameSink) - } -} - // 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-a", "src-old", "trace-old"))) - current := makeReport(makeResult("a", Error, "a.java", 1, fps("sink-a", "src-new", "trace-new"))) + 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, SourceSinkFingerprintKey) + 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 != RemnantSameSink { - t.Errorf("remnant on copy = %q, want %q", got, RemnantSameSink) + if got := cmp.RemnantOf(copyOfGone); got != RemnantDrifted { + t.Errorf("remnant on copy = %q, want %q", got, RemnantDrifted) } - if got := cmp.StateNote(copyOfGone); got != "sink still reported" { - t.Errorf("state note = %q, want %q", got, "sink still reported") + if got := cmp.StateNote(copyOfGone); got != "possibly drifted" { + t.Errorf("state note = %q, want %q", got, "possibly drifted") } } @@ -558,7 +471,7 @@ 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, SinkFingerprintKey) + cmp, err := CompareToBaseline(current, baseline) if err != nil { t.Fatalf("compare: %v", err) } @@ -569,14 +482,15 @@ func TestStateNoteNamesWhatMovedUnderUpdated(t *testing.T) { } func TestCheckBaselineIdentity(t *testing.T) { - if err := CheckBaselineIdentity(&Report{}, SourceSinkFingerprintKey); err != nil { + 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, SourceSinkFingerprintKey); err != nil { - t.Errorf("baseline carries the key: %v", err) + if err := CheckBaselineIdentity(carrying); err != nil { + t.Errorf("baseline carries the identity fingerprint: %v", err) } - if err := CheckBaselineIdentity(carrying, "some-other-key/v1"); err == nil { - t.Error("expected an error for a key no baseline result carries") + 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 5ebd2b573f..060ed275aa 100644 --- a/cli/internal/sarif/filter.go +++ b/cli/internal/sarif/filter.go @@ -13,14 +13,11 @@ 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 ("" = DefaultIdentityKey) + 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 || len(f.BaselineStates) > 0 @@ -63,7 +60,7 @@ func (f Filters) matches(r *Result) bool { 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) { @@ -73,7 +70,7 @@ func (f Filters) matches(r *Result) bool { } // matchesAs is matches for a result whose baseline state is known from the -// comparison rather than carried on the result itself. Fixed findings live in +// 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 { @@ -85,7 +82,7 @@ func (f Filters) matchesAs(r *Result, state BaselineState) bool { return stateless.matches(r) } -// WantsAbsent reports whether the filter asks for fixed findings, which the +// 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 { @@ -215,23 +212,19 @@ func MatchesRuleID(full string, 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 -// identity key is used — the same one triage resolves prefixes against, so a -// fingerprint shown in the listing can always be pasted into triage --accept. -func fingerprintValue(r *Result, key string) string { - if key == "" { - key = DefaultIdentityKey - } - v, _ := Identity(r, 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 } diff --git a/cli/internal/sarif/filter_test.go b/cli/internal/sarif/filter_test.go index 6a56409ea5..6d1ac33806 100644 --- a/cli/internal/sarif/filter_test.go +++ b/cli/internal/sarif/filter_test.go @@ -36,16 +36,17 @@ func TestMatchSeverity(t *testing.T) { func TestMatchFingerprint(t *testing.T) { r := makeResult("r", Error, "a.java", 1, map[string]string{ - DefaultIdentityKey: "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 f3e6f4b16d..f922a70301 100644 --- a/cli/internal/sarif/group.go +++ b/cli/internal/sarif/group.go @@ -21,7 +21,6 @@ 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 diff --git a/cli/internal/sarif/identity.go b/cli/internal/sarif/identity.go index 579df36bb2..7e9fa00603 100644 --- a/cli/internal/sarif/identity.go +++ b/cli/internal/sarif/identity.go @@ -22,77 +22,19 @@ const ( SinkFingerprintKey = "vulnerabilitySinkHash/v1" ) -// identityAliases are the short names accepted for the keys above, so a user -// writes --fingerprint-key sink rather than the versioned SARIF key. -var identityAliases = map[string]string{ - "trace": TraceFingerprintKey, - "source-sink": SourceSinkFingerprintKey, - "sourcesink": SourceSinkFingerprintKey, - "sink": SinkFingerprintKey, -} - -// IdentityAliases lists the short names in coarsening order, for help text. -var IdentityAliases = []string{"trace", "source-sink", "sink"} - -// DefaultIdentityKey is the fingerprint key used to decide whether a finding in -// one report is "the same finding" as one in another report. The sink hash is -// the default because it names the vulnerable statement and nothing else, so a -// decision survives every edit to how the untrusted data reaches it. The -// analyzer already reports one finding per rule and sink, so the coarsest key -// loses no findings — it only stops them from changing identity. -const DefaultIdentityKey = SinkFingerprintKey - -// identityLadder is the keys ordered coarsest to finest. Each one adds detail to -// the one before it, which is what lets a matched finding say what moved. -var identityLadder = []string{SinkFingerprintKey, SourceSinkFingerprintKey, TraceFingerprintKey} - -// finerKeys returns the keys that refine key, nearest first. A key outside the -// ladder is refined by the trace hash alone: an unrecognized identity may still -// be compared for an exact match, which is all the trace hash reports. -func finerKeys(key string) []string { - for i, k := range identityLadder { - if k == key { - return identityLadder[i+1:] - } - } - return []string{TraceFingerprintKey} -} - -// coarserKeys returns the keys that key refines, nearest first. A key outside -// the ladder has no coarser keys, so nothing can be said about what an absence -// under it leaves behind. -func coarserKeys(key string) []string { - for i, k := range identityLadder { - if k != key { - continue - } - out := make([]string, i) - for j := range out { - out[j] = identityLadder[i-1-j] - } - return out - } - return nil -} +// 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 -// ResolveIdentityKey normalizes a user-supplied identity key, falling back to -// DefaultIdentityKey when unset and expanding the short aliases. Any other key -// is accepted as written — a report may carry fingerprints this build does not -// know about — but a blank one is rejected rather than silently matching -// nothing. -func ResolveIdentityKey(key string) (string, error) { - if key == "" { - return DefaultIdentityKey, nil - } - trimmed := strings.TrimSpace(key) - if trimmed == "" { - return "", fmt.Errorf("fingerprint key must not be blank") - } - if full, ok := identityAliases[strings.ToLower(trimmed)]; ok { - return full, nil - } - return trimmed, nil -} +// 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 @@ -123,12 +65,11 @@ func (report *Report) Results() []*Result { // 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 under the -// coarse default key 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, key, prefix string) ([]*Result, error) { +// 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") } @@ -136,7 +77,7 @@ func ResolvePrefix(report *Report, key, prefix string) ([]*Result, error) { var matches []*Result distinct := map[string]bool{} for _, r := range report.Results() { - fp, ok := Identity(r, key) + fp, ok := Identity(r, IdentityKey) if !ok || !strings.HasPrefix(fp, prefix) { continue } @@ -145,7 +86,7 @@ func ResolvePrefix(report *Report, key, prefix string) ([]*Result, error) { } if len(matches) == 0 { - return nil, fmt.Errorf("no finding matches fingerprint %q (key %s)", prefix, key) + return nil, fmt.Errorf("no finding matches fingerprint %q", prefix) } if len(distinct) > 1 { values := make([]string, 0, len(distinct)) diff --git a/cli/internal/sarif/identity_test.go b/cli/internal/sarif/identity_test.go index 6d5a2c1dcd..cdab2549a8 100644 --- a/cli/internal/sarif/identity_test.go +++ b/cli/internal/sarif/identity_test.go @@ -5,32 +5,6 @@ import ( "testing" ) -func TestResolveIdentityKeyDefaultsToSink(t *testing.T) { - key, err := ResolveIdentityKey("") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if key != SinkFingerprintKey { - t.Errorf("got %q, want %q", key, SinkFingerprintKey) - } -} - -func TestResolveIdentityKeyAcceptsExplicitKey(t *testing.T) { - key, err := ResolveIdentityKey(TraceFingerprintKey) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if key != TraceFingerprintKey { - t.Errorf("got %q, want %q", key, TraceFingerprintKey) - } -} - -func TestResolveIdentityKeyRejectsBlank(t *testing.T) { - if _, err := ResolveIdentityKey(" "); err == nil { - t.Error("expected error for whitespace-only key") - } -} - func TestIdentityReadsChosenKey(t *testing.T) { r := makeResult("rule", Error, "a.java", 1, map[string]string{ SourceSinkFingerprintKey: "src-sink-hash", @@ -76,10 +50,10 @@ func TestResultsIteratesEveryRun(t *testing.T) { func TestResolvePrefixFindsUniqueMatch(t *testing.T) { report := makeReport( - makeResult("a", Error, "a.java", 1, map[string]string{SourceSinkFingerprintKey: "q3Vf9k2nAAA"}), - makeResult("b", Error, "b.java", 2, map[string]string{SourceSinkFingerprintKey: "8bc1d2xxBBB"}), + 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, SourceSinkFingerprintKey, "q3Vf9k") + matched, err := ResolvePrefix(report, "q3Vf9k") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -90,14 +64,14 @@ func TestResolvePrefixFindsUniqueMatch(t *testing.T) { // 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 coarse sink key such duplicates are +// 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{SourceSinkFingerprintKey: "q3Vf9kSAME"}), - makeResult("a", Error, "a.java", 9, map[string]string{SourceSinkFingerprintKey: "q3Vf9kSAME"}), + 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, SourceSinkFingerprintKey, "q3Vf9kSAME") + matched, err := ResolvePrefix(report, "q3Vf9kSAME") if err != nil { t.Fatalf("duplicates of one identity must resolve, got: %v", err) } @@ -108,19 +82,19 @@ func TestResolvePrefixReturnsAllDuplicatesOfOneIdentity(t *testing.T) { func TestResolvePrefixExactValueMatches(t *testing.T) { report := makeReport( - makeResult("a", Error, "a.java", 1, map[string]string{SourceSinkFingerprintKey: "q3Vf9k2nAAA"}), + makeResult("a", Error, "a.java", 1, map[string]string{SinkFingerprintKey: "q3Vf9k2nAAA"}), ) - if _, err := ResolvePrefix(report, SourceSinkFingerprintKey, "q3Vf9k2nAAA"); err != nil { + 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{SourceSinkFingerprintKey: "q3Vf9kAAA"}), - makeResult("b", Error, "b.java", 2, map[string]string{SourceSinkFingerprintKey: "q3Vf9kBBB"}), + 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, SourceSinkFingerprintKey, "q3Vf9k") + _, err := ResolvePrefix(report, "q3Vf9k") if err == nil { t.Fatal("expected ambiguous prefix to error") } @@ -131,51 +105,22 @@ func TestResolvePrefixAmbiguousIsAnError(t *testing.T) { func TestResolvePrefixNoMatchIsAnError(t *testing.T) { report := makeReport( - makeResult("a", Error, "a.java", 1, map[string]string{SourceSinkFingerprintKey: "q3Vf9kAAA"}), + makeResult("a", Error, "a.java", 1, map[string]string{SinkFingerprintKey: "q3Vf9kAAA"}), ) - if _, err := ResolvePrefix(report, SourceSinkFingerprintKey, "zzzz"); err == nil { + 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{SourceSinkFingerprintKey: "q3Vf9kAAA"}), + makeResult("a", Error, "a.java", 1, map[string]string{SinkFingerprintKey: "q3Vf9kAAA"}), ) - if _, err := ResolvePrefix(report, SourceSinkFingerprintKey, ""); err == nil { + if _, err := ResolvePrefix(report, ""); err == nil { t.Error("expected empty prefix to error rather than match everything") } } -func TestResolveIdentityKeyExpandsAliases(t *testing.T) { - cases := map[string]string{ - "sink": SinkFingerprintKey, - "SINK": SinkFingerprintKey, - " source-sink ": SourceSinkFingerprintKey, - "sourcesink": SourceSinkFingerprintKey, - "trace": TraceFingerprintKey, - } - for in, want := range cases { - got, err := ResolveIdentityKey(in) - if err != nil { - t.Fatalf("ResolveIdentityKey(%q): %v", in, err) - } - if got != want { - t.Errorf("ResolveIdentityKey(%q) = %q, want %q", in, got, want) - } - } -} - -func TestResolveIdentityKeyPassesUnknownKeysThrough(t *testing.T) { - got, err := ResolveIdentityKey("somethingElse/v9") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != "somethingElse/v9" { - t.Errorf("got %q, want the key unchanged", got) - } -} - // 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) { @@ -186,7 +131,7 @@ func TestCompareOnSinkHashSeparatesRulesOnOneStatement(t *testing.T) { makeResult("xss", Error, "a.java", 1, sink("xss-s1")), // new: different rule ) - cmp, err := CompareToBaseline(current, baseline, SinkFingerprintKey) + cmp, err := CompareToBaseline(current, baseline) if err != nil { t.Fatalf("compare: %v", err) } diff --git a/cli/internal/sarif/print_findings.go b/cli/internal/sarif/print_findings.go index f194130a62..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) @@ -297,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/render_test.go b/cli/internal/sarif/render_test.go index 2e704c09fc..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{ - DefaultIdentityKey: "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{DefaultIdentityKey: "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{ - DefaultIdentityKey: "abc123def456ghi", + IdentityKey: "abc123def456ghi", }) out := renderListing(t, makeReport(r), ListingOptions{MaxNestingLevel: -1}) if !strings.Contains(out, "Fingerprint:") { diff --git a/cli/internal/sarif/suppress.go b/cli/internal/sarif/suppress.go index 08ed6c5d84..428dc839b8 100644 --- a/cli/internal/sarif/suppress.go +++ b/cli/internal/sarif/suppress.go @@ -127,10 +127,10 @@ func Unsuppress(r *Result) bool { // 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, key string) int { +func InheritSuppressions(current, baseline *Report) int { byIdentity := make(map[string]*Suppression) for _, r := range baseline.Results() { - id, ok := Identity(r, key) + id, ok := Identity(r, IdentityKey) if !ok { continue } @@ -147,7 +147,7 @@ func InheritSuppressions(current, baseline *Report, key string) int { if len(r.Suppressions) > 0 { continue } - id, ok := Identity(r, key) + id, ok := Identity(r, IdentityKey) if !ok { continue } diff --git a/cli/internal/sarif/suppress_test.go b/cli/internal/sarif/suppress_test.go index 11bca25d54..84beb64b01 100644 --- a/cli/internal/sarif/suppress_test.go +++ b/cli/internal/sarif/suppress_test.go @@ -150,7 +150,7 @@ func TestInheritCopiesSuppressionVerbatim(t *testing.T) { makeResult("b", Error, "b.java", 2, fp("id-b", "trace-id-b")), ) - n := InheritSuppressions(current, baseline, SourceSinkFingerprintKey) + n := InheritSuppressions(current, baseline) if n != 1 { t.Fatalf("inherited %d, want 1", n) } @@ -178,7 +178,7 @@ 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, SourceSinkFingerprintKey); n != 0 { + 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]) { @@ -190,7 +190,7 @@ 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, SourceSinkFingerprintKey); n != 0 { + 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" { @@ -202,7 +202,7 @@ 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, SourceSinkFingerprintKey); n != 0 { + if n := InheritSuppressions(current, baseline); n != 0 { t.Errorf("inherited %d, want 0", n) } if IsSuppressed(current.Results()[0]) { diff --git a/cli/internal/sarif/triage_summary_test.go b/cli/internal/sarif/triage_summary_test.go index 9c8697078f..f6affad839 100644 --- a/cli/internal/sarif/triage_summary_test.go +++ b/cli/internal/sarif/triage_summary_test.go @@ -37,7 +37,7 @@ func TestSummaryBaselineGroup(t *testing.T) { 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, SourceSinkFingerprintKey) + cmp, err := CompareToBaseline(report, baseline) if err != nil { t.Fatalf("compare: %v", err) } @@ -47,7 +47,7 @@ func TestSummaryBaselineGroup(t *testing.T) { Comparison: cmp, }) - for _, want := range []string{"Baseline", "reports/main.sarif", "New", "Unchanged", "Fixed"} { + 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) } @@ -59,38 +59,35 @@ func TestSummaryBaselineGroup(t *testing.T) { func TestSummaryBaselineGroupHedgesAbsencesWithRemnants(t *testing.T) { baseline := makeReport( - // The identity drifts but the sink hash survives: provably still there. - makeResult("a", Error, "a.java", 1, fps("sink-a", "src-old", "trace-old")), - // Everything drifts, and a new same-rule finding sits in the same file. - makeResult("b", Error, "b.java", 2, fps("sink-b-old", "src-b-old", "trace-b-old")), + // 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", 1, fps("sink-a", "src-new", "trace-new")), - makeResult("b", Error, "b.java", 4, fps("sink-b-new", "src-b-new", "trace-b-new")), + makeResult("a", Error, "a.java", 4, fps("sink-a-new", "src-a-new", "trace-a-new")), ) - cmp, err := CompareToBaseline(report, baseline, SourceSinkFingerprintKey) + 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{"Gone, sink still reported", "Gone, possibly moved", "Fixed"} { + 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, "Fixed") != 1 { - t.Errorf("exactly one Fixed line expected:\n%s", 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, SourceSinkFingerprintKey) + cmp, _ := CompareToBaseline(report, baseline) out := renderSummary(t, report, &TriageView{BaselinePath: "b.sarif", Comparison: cmp}) if strings.Contains(out, "Updated") { @@ -104,7 +101,7 @@ func TestSummaryBaselineGroupOmitsZeroUpdated(t *testing.T) { 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, SourceSinkFingerprintKey) + cmp, _ := CompareToBaseline(report, baseline) out := renderSummary(t, report, &TriageView{BaselinePath: "b.sarif", Comparison: cmp}) if !strings.Contains(out, "Not comparable") { @@ -168,7 +165,7 @@ func TestRestrictCountsOnlyWhatTheFilterKept(t *testing.T) { makeResult("xss", Error, "b.java", 2, fp("id-fresh", "trace-fresh")), // new ), "sql", "xss") - cmp, err := CompareToBaseline(current, baseline, SourceSinkFingerprintKey) + cmp, err := CompareToBaseline(current, baseline) if err != nil { t.Fatalf("compare: %v", err) } @@ -187,10 +184,10 @@ func TestRestrictCountsOnlyWhatTheFilterKept(t *testing.T) { // 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("Fixed = %d, want 1: only the xss finding survives the filter", got) + 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 Fixed = %d, want 2", got) + t.Errorf("unrestricted Absent = %d, want 2", got) } if got := restricted.Suppressions.Total; got != 1 { t.Errorf("Suppressions.Total = %d, want 1", got) @@ -205,7 +202,7 @@ 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, SourceSinkFingerprintKey) + cmp, err := CompareToBaseline(current, baseline) if err != nil { t.Fatalf("compare: %v", err) } @@ -214,12 +211,12 @@ func TestRestrictKeepsFixedFindingsTheFilterNames(t *testing.T) { filters := Filters{BaselineStates: []string{"absent"}} restricted := view.Restrict(current.Filter(filters), filters) if got := restricted.Comparison.Counts[Absent]; got != 1 { - t.Errorf("Fixed = %d, want 1", got) + 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("Fixed = %d, want 0 when the filter does not name absent", got) + t.Errorf("Absent = %d, want 0 when the filter does not name absent", got) } } @@ -227,16 +224,16 @@ 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, DefaultIdentityKey, shown) + 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], DefaultIdentityKey); got != "sink-of-source-sink-value" { - t.Errorf("resolved %q, want the value under the default key", got) + if got, _ := Identity(resolved[0], IdentityKey); got != "sink-of-source-sink-value" { + t.Errorf("resolved %q, want the value under the identity key", got) } } @@ -252,7 +249,7 @@ func TestRestrictKeepsChangeAttribution(t *testing.T) { makeResult("xss", Warning, "b.java", 2, fp("id-b", "trace-b")), ), "sql", "xss") - cmp, err := CompareToBaseline(current, baseline, SourceSinkFingerprintKey) + cmp, err := CompareToBaseline(current, baseline) if err != nil { t.Fatalf("compare: %v", err) } diff --git a/cli/internal/sarif/triage_view.go b/cli/internal/sarif/triage_view.go index 5aebfaf5b9..990776428c 100644 --- a/cli/internal/sarif/triage_view.go +++ b/cli/internal/sarif/triage_view.go @@ -58,7 +58,6 @@ func (c *Comparison) restrict(filtered *Report, f Filters) *Comparison { out := &Comparison{ states: c.states, changes: c.changes, - key: c.key, changesByIdentity: c.changesByIdentity, remnantsByIdentity: c.remnantsByIdentity, Counts: make(map[BaselineState]int), @@ -132,23 +131,20 @@ func (v *TriageView) baselineItems(out *output.Printer) []any { 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. Absences that left a trace in the current - // report get their own hedged lines, and "Fixed" — which reads better than - // SARIF's "absent" — keeps only the ones with nothing left behind. + // 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)]++ } - for _, remnant := range []Remnant{RemnantSameSink, RemnantSameRuleFile} { - if count := remnants[remnant]; count > 0 { - items = append(items, out.FieldItem("Gone, "+remnant.Label(), count)) - } + 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("Fixed", count)) + items = append(items, out.FieldItem("Absent", count)) } // Baseline findings whose rule did not run are deliberately not folded into - // "Fixed": excluding a rule would otherwise read as having resolved every + // "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)) diff --git a/cli/internal/triage/triage.go b/cli/internal/triage/triage.go index a6c21a0af1..f5f2c97ada 100644 --- a/cli/internal/triage/triage.go +++ b/cli/internal/triage/triage.go @@ -18,8 +18,6 @@ type Options struct { // WriteBaselineState persists result.baselineState and run.baselineGuid. // Without it the comparison only drives what is printed. WriteBaselineState bool - // FingerprintKey selects the identity fingerprint ("" = default). - FingerprintKey string // 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 @@ -54,10 +52,6 @@ type Outcome struct { // 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) { - key, err := sarif.ResolveIdentityKey(opts.FingerprintKey) - if err != nil { - return nil, err - } if opts.suppressing() && opts.Justification == "" { return nil, fmt.Errorf("a justification is required to suppress a finding: pass --justification") } @@ -66,25 +60,25 @@ func Apply(report *sarif.Report, opts Options) (*Outcome, error) { changed := false if opts.Baseline != nil { - view.Inherited = sarif.InheritSuppressions(report, opts.Baseline, key) + view.Inherited = sarif.InheritSuppressions(report, opts.Baseline) changed = changed || view.Inherited > 0 } - added, err := applyDecisions(report, key, opts) + added, err := applyDecisions(report, opts) if err != nil { return nil, err } view.Added = added changed = changed || added > 0 - removed, err := applyUnsuppressions(report, key, opts.Unsuppress) + 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, key) + comparison, err := sarif.CompareToBaseline(report, opts.Baseline) if err != nil { return nil, err } @@ -111,7 +105,7 @@ func Apply(report *sarif.Report, opts Options) (*Outcome, error) { // 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, key string, opts Options) (int, error) { +func applyDecisions(report *sarif.Report, opts Options) (int, error) { type decision struct { result *sarif.Result accept bool @@ -119,7 +113,7 @@ func applyDecisions(report *sarif.Report, key string, opts Options) (int, error) var decisions []decision for _, prefix := range opts.Accept { - matched, err := sarif.ResolvePrefix(report, key, prefix) + matched, err := sarif.ResolvePrefix(report, prefix) if err != nil { return 0, err } @@ -128,7 +122,7 @@ func applyDecisions(report *sarif.Report, key string, opts Options) (int, error) } } for _, prefix := range opts.Defer { - matched, err := sarif.ResolvePrefix(report, key, prefix) + matched, err := sarif.ResolvePrefix(report, prefix) if err != nil { return 0, err } @@ -153,10 +147,10 @@ func applyDecisions(report *sarif.Report, key string, opts Options) (int, error) // applyUnsuppressions resolves every prefix before removing anything, for the // same all-or-nothing reason as applyDecisions. -func applyUnsuppressions(report *sarif.Report, key string, prefixes []string) (int, error) { +func applyUnsuppressions(report *sarif.Report, prefixes []string) (int, error) { var targets []*sarif.Result for _, prefix := range prefixes { - matched, err := sarif.ResolvePrefix(report, key, prefix) + matched, err := sarif.ResolvePrefix(report, prefix) if err != nil { return 0, err } diff --git a/cli/internal/utils/opentaint_command_builder.go b/cli/internal/utils/opentaint_command_builder.go index a0ba01db2c..2d57d22c7c 100644 --- a/cli/internal/utils/opentaint_command_builder.go +++ b/cli/internal/utils/opentaint_command_builder.go @@ -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 { @@ -406,14 +398,6 @@ func (cb *OpentaintCommandBuilder) WithWriteBaselineState(enabled bool) *Opentai return cb } -// WithFingerprintKey sets the --fingerprint-key flag. -func (cb *OpentaintCommandBuilder) WithFingerprintKey(key string) *OpentaintCommandBuilder { - if key != "" { - cb.flags["fingerprint-key"] = key - } - return cb -} - // WithErrorOnFindings sets the --error-on-findings flag. func (cb *OpentaintCommandBuilder) WithErrorOnFindings(enabled bool) *OpentaintCommandBuilder { if enabled { 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/baselines-and-suppressions.md b/docs/baselines-and-suppressions.md index 8d52eb602b..9f3c86c80f 100644 --- a/docs/baselines-and-suppressions.md +++ b/docs/baselines-and-suppressions.md @@ -133,7 +133,7 @@ Given `--baseline old.sarif`, every current finding is classified: | `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 — fixed, unless something still points at it (see [What changed underneath](#what-changed-underneath)) | +| `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: @@ -164,43 +164,34 @@ 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` (fixed) findings are never written into the output report — surfacing a -fixed finding as a live alert would be wrong — but `--baseline-state absent` +`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. Three fingerprints exist, from -the most exact identity to the coarsest: - -| `--fingerprint-key` | Full key | Hashes | Behavior | -|-----|-----|--------|----------| -| `trace` | `vulnerabilityWithTraceHash/v1` | rule + every step of every trace | Exact. Changes if anything on the path moves. | -| `source-sink` | `vulnerabilitySourceSinkHash/v1` | rule + source + sink | Survives edits to the call path between source and sink. | -| `sink` | `vulnerabilitySinkHash/v1` | rule + sink | Survives any change to how the untrusted data reaches the sink. **Default.** | - -`--fingerprint-key` takes the short name or the full key, and one key governs -everything a command does with fingerprints: baseline matching, 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`. (`--partial-fingerprint-key` is a -deprecated alias for `--fingerprint-key`.) - -The sink hash is the default because it names the vulnerable statement and -nothing else, so a decision survives every edit to how the data gets there — +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`). It -costs nothing in precision: the analyzer already reports one finding per rule and -sink, so the coarsest key is still one fingerprint per finding — it only stops -findings from changing identity. All three hash the rule id, so no fingerprint -ever spans two rules that fire on one statement. - -Pick a finer key when the route is part of what you are deciding about. Under -`source-sink`, data arriving at a known-dangerous sink from a *new* source is a -new finding that must be triaged again. Under `sink`, an existing decision covers -it. +`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 @@ -214,30 +205,23 @@ SARIF has one word for all of it — `updated` — so the summary says which: | `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. The distinction narrows with a finer identity: under -`source-sink` a moved source is `new` + `absent` rather than `updated`, and under -`trace` nothing is left to refine, so a match is always `unchanged`. +selects either. -An absence gets the same scrutiny before the summary calls it fixed. A -fingerprint disappears whenever the code it hashes moves, so a gone hash does -not prove a gone finding. The summary reports what the current scan still shows -of each absent finding: +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 | |------|---------| -| `Fixed` | Nothing in the current report points at the finding. | -| `Gone, sink still reported` | A current finding carries the same hash under a coarser key, so the sink is provably still reported. The identity changed, the finding did not go away. | -| `Gone, possibly moved` | 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. | +| `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. | -`Gone, sink still reported` needs a coarser key to check against, so it appears -under `source-sink` and `trace` but never under the default `sink` key. All -three lines are `absent` in SARIF terms: `--baseline-state absent` selects them -all, and the listing prints the qualifier next to each finding's `Baseline:` -state. +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. -Comparing reports built with different fingerprint keys is a hard error, not a -silent zero-match. Findings that carry no fingerprint at all (a report produced -without fingerprints) are reported as-is and counted as "not comparable." +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 @@ -349,7 +333,7 @@ doublestar glob over the full id — the same grammar as `summary --rule-id`. 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 - `Fixed`, because its absence says nothing about whether anyone fixed it. + `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 diff --git a/docs/usage.md b/docs/usage.md index 053b734b24..d64721b1dd 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -116,12 +116,11 @@ On the first run, the compiled project model is cached in `~/.opentaint/cache/`. |------|-------------| | `--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`) | -| `--fingerprint-key` | Which fingerprint identifies a finding across reports: `trace`, `source-sink`, `sink` (default), or a full partialFingerprints key | | `--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 fixed. See +the summary reports how many are new, unchanged, updated, or absent. See [Baselines and suppressions](#baselines-and-suppressions). #### Rule-authoring flags @@ -221,14 +220,12 @@ reflects the full set the tool ran. | `--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 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. | -| `--partial-fingerprint-key` | Deprecated alias for `--fingerprint-key` | | `--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/fixed 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 fixed findings from the baseline and always needs `--baseline`. | +| `--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) | -| `--fingerprint-key` | Which fingerprint identifies a finding: `trace` (exact), `source-sink`, `sink` (rule and sink only, the default), or a full partialFingerprints key. One key governs baseline matching, the `Fingerprint:` value shown, `--partial-fingerprint`, and the prefix `triage` resolves. | Filters combine as OR within a dimension and AND across dimensions. @@ -260,7 +257,6 @@ opentaint triage scan.sarif --defer 8bc1d2 --justification "waiting on OT-412" | `--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 | -| `--fingerprint-key` | Which fingerprint identifies a finding across reports: `trace`, `source-sink`, `sink` (default), or a full partialFingerprints key | | `--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) | From 74a95381ab6ea7d8d9db9c90bd2106771982f65b Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Wed, 26 Aug 2026 15:48:04 +0200 Subject: [PATCH 36/36] fix(cli): harden baseline and suppression handling --- cli/cmd/root.go | 1 + cli/cmd/scan.go | 33 +++++++++- cli/cmd/test_rule_reachability.go | 2 +- cli/cmd/test_rule_reachability_test.go | 87 ++++++++++++++++++++++++++ cli/internal/globals/global.go | 1 + cli/internal/sarif/baseline.go | 72 ++++++++++++--------- cli/internal/sarif/baseline_test.go | 29 +++++++++ cli/internal/sarif/result_json.go | 21 +++++++ cli/internal/sarif/save.go | 43 ++++++++++++- cli/internal/sarif/save_test.go | 55 ++++++++++++++++ docs/configuration.md | 2 + 11 files changed, 310 insertions(+), 36 deletions(-) create mode 100644 cli/internal/sarif/result_json.go diff --git a/cli/cmd/root.go b/cli/cmd/root.go index e457002dad..22fc99df25 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -182,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 90c36188b3..3842c84359 100644 --- a/cli/cmd/scan.go +++ b/cli/cmd/scan.go @@ -17,6 +17,7 @@ import ( "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" @@ -128,11 +129,12 @@ Before your first scan, run "opentaint pull" one time. To read a report again la 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:", @@ -150,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) diff --git a/cli/cmd/test_rule_reachability.go b/cli/cmd/test_rule_reachability.go index 20714bdf83..3b20bfd50c 100644 --- a/cli/cmd/test_rule_reachability.go +++ b/cli/cmd/test_rule_reachability.go @@ -40,7 +40,7 @@ Before the first run, run "opentaint pull" one time. To read the report, use "op 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/internal/globals/global.go b/cli/internal/globals/global.go index ef07d3bd33..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 { diff --git a/cli/internal/sarif/baseline.go b/cli/internal/sarif/baseline.go index 228e416610..174590150f 100644 --- a/cli/internal/sarif/baseline.go +++ b/cli/internal/sarif/baseline.go @@ -373,56 +373,66 @@ func ranInCurrentScan(r *Result, executed map[string]bool) bool { // 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 { - if sameUnder(current, previous, key) { - continue - } - switch key { - case SourceSinkFingerprintKey: - return ChangeSource - default: - return ChangePath + candidates = matchingUnder(current, candidates, key) + if len(candidates) == 0 { + switch key { + case SourceSinkFingerprintKey: + return ChangeSource + default: + return ChangePath + } } } return ChangeNone } -// sameUnder reports whether the current result's fingerprint under key equals -// that of any baseline result sharing its identity. A missing fingerprint on -// either side counts as the same: the finer comparison is unavailable, and -// claiming a change on missing data would be noise. -func sameUnder(current *Result, previous []*Result, key string) bool { +// 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 true + return previous } + matches := make([]*Result, 0, len(previous)) for _, p := range previous { previousValue, ok := Identity(p, key) if !ok || previousValue == currentValue { - return true + matches = append(matches, p) } } - return false + return matches } // Apply writes the comparison into the report: result.baselineState on every -// matched result, and run.baselineGuid on every run when the baseline had a -// guid to cite. Unmatchable results are left untouched. +// 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 _, r := range report.Results() { - state, ok := c.states[r] - if !ok { - continue + 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 } - value := state - r.BaselineState = &value - } - if c.BaselineGUID == "" { - return - } - for i := range report.Runs { - guid := c.BaselineGUID - report.Runs[i].BaselineGUID = &guid } } diff --git a/cli/internal/sarif/baseline_test.go b/cli/internal/sarif/baseline_test.go index a285681449..4abc9f9da7 100644 --- a/cli/internal/sarif/baseline_test.go +++ b/cli/internal/sarif/baseline_test.go @@ -205,7 +205,9 @@ func TestApplyOmitsBaselineGUIDWhenBaselineHasNone(t *testing.T) { } 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) @@ -217,6 +219,9 @@ func TestApplyLeavesUnmatchableResultsUnannotated(t *testing.T) { 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) { @@ -395,6 +400,30 @@ func TestChangeReportsTheCoarsestThingThatMoved(t *testing.T) { } } +// 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) { 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/save.go b/cli/internal/sarif/save.go index 9b2ff298aa..44edb2d92b 100644 --- a/cli/internal/sarif/save.go +++ b/cli/internal/sarif/save.go @@ -12,7 +12,7 @@ import ( // a crash mid-write can never leave a truncated report behind — which matters // because triage rewrites reports in place. func SaveReport(report *Report, path string) error { - data, err := json.MarshalIndent(report, "", " ") + data, err := json.MarshalIndent(reportWithConsistentSuppressions(report), "", " ") if err != nil { return fmt.Errorf("failed to encode sarif report: %w", err) } @@ -37,7 +37,13 @@ func SaveReport(report *Report, path string) error { if err := tmp.Close(); err != nil { return fmt.Errorf("failed to write sarif report: %w", err) } - if err := os.Chmod(tmpName, 0o644); err != nil { + 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 { @@ -45,3 +51,36 @@ func SaveReport(report *Report, path string) error { } 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 index cc74d92ba5..94699a4063 100644 --- a/cli/internal/sarif/save_test.go +++ b/cli/internal/sarif/save_test.go @@ -152,3 +152,58 @@ func TestSaveReportOverwritesAtomically(t *testing.T) { 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/docs/configuration.md b/docs/configuration.md index 146a1c9560..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: @@ -40,6 +41,7 @@ rules: |---------|-------------|---------| | `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` |