From d8e2f6ca516449caf1d82765c6bdc2f27a107a56 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Tue, 3 Nov 2026 09:31:52 +0000 Subject: [PATCH 1/4] refactor(cli): Rework the help text and end-of-run output Every command's Long text follows simplified technical English: short sentences, one instruction per sentence, active voice. Every command has an Examples section that ends with recipes for complete use cases. The end of a run prints the analyzer error, then what to do next: the log file, a retry with a larger --timeout or --max-memory for the analyzer exit codes that call for it, or a re-run with --debug. Re-run suggestions quote shell metacharacters, and drop --dry-run when the dry run tells you how to run for real. The exit-code tables of scan and the test commands are built from the analyzer's own exit messages. The test tables keep the note that an unhandled analyzer exception prints the analyzer log path. The docs and skills that mirror this help are updated in a later branch. --- cli/cmd/analyzer_inputs.go | 4 +- cli/cmd/compile.go | 38 ++++--- cli/cmd/dry_run.go | 36 ++++++- cli/cmd/exit_codes.go | 33 +++++++ cli/cmd/health.go | 26 +++-- cli/cmd/project.go | 46 +++++---- cli/cmd/prune.go | 56 ++++++----- cli/cmd/pull.go | 35 ++++--- cli/cmd/rerun_test.go | 158 ++++++++++++++++++++++++++++++ cli/cmd/root.go | 11 ++- cli/cmd/scan.go | 124 +++++++++++++++++------ cli/cmd/suggest.go | 143 ++++++++++++++++++++++----- cli/cmd/summary.go | 51 +++++++--- cli/cmd/test.go | 38 ++++--- cli/cmd/test_init.go | 73 +++++++++----- cli/cmd/test_model_run.go | 19 +++- cli/cmd/test_rule_reachability.go | 29 +++++- cli/cmd/test_rule_run.go | 67 ++++++++++--- cli/cmd/update.go | 34 ++++--- cli/internal/analyzer/exit.go | 4 +- 20 files changed, 805 insertions(+), 220 deletions(-) create mode 100644 cli/cmd/exit_codes.go create mode 100644 cli/cmd/rerun_test.go diff --git a/cli/cmd/analyzer_inputs.go b/cli/cmd/analyzer_inputs.go index 2d7da3a483..907a122ba4 100644 --- a/cli/cmd/analyzer_inputs.go +++ b/cli/cmd/analyzer_inputs.go @@ -8,13 +8,13 @@ import ( func addDataflowModelsFlag(cmd *cobra.Command, paths *[]string) { cmd.Flags().StringArrayVar(paths, "dataflow-models", nil, - "Dataflow model project, build output, or class directory (repeatable)") + "Dataflow models: a model project, build output, or class directory (repeatable)") addDeprecatedFlagAlias(cmd.Flags(), "dataflow-models", "dataflow-approximations") } func addPassthroughModelsFlag(cmd *cobra.Command, paths *[]string) { cmd.Flags().StringArrayVar(paths, "passthrough-models", nil, - "Pass-through model YAML file or directory (repeatable)") + "Pass-through models: a YAML file or a directory of them (repeatable)") addDeprecatedFlagAlias(cmd.Flags(), "passthrough-models", "passthrough-approximations") } diff --git a/cli/cmd/compile.go b/cli/cmd/compile.go index 3d04172990..c63dc01098 100644 --- a/cli/cmd/compile.go +++ b/cli/cmd/compile.go @@ -32,21 +32,33 @@ func currentCompileBuilder(projectPath string) *utils.OpentaintCommandBuilder { // dockerCompileSuggestion builds the "try Docker-based compilation" fallback hint. func dockerCompileSuggestion() output.Suggestion { return output.Suggestion{ - Description: dockerFallbackHintPrefix + "compilation:", + Description: "If the required Java is missing, set JAVA_HOME or compile in a container instead:", Command: utils.BuildCompileCommandWithDocker(currentCompileBuilder(""), ProjectPath, OutputProjectModelPath), } } // compileCmd represents the compile command var compileCmd = &cobra.Command{ - Use: "compile project", - Short: "Compile your Java or Kotlin project", + Use: "compile ", + Short: "Compile a project into a reusable project model", Args: cobra.ExactArgs(1), // require exactly one argument - Long: `This command takes a required path to the project, automatically detects Java/Kotlin build system, modules and dependencies and compiles project model. + Long: `Compile a project into a project model that you can scan many times. OpenTaint finds the build system, collects the modules and dependencies, and builds the project. -Arguments: - project - Path to a project to compile (required) -`, +The project argument is the path to the project root. It is required. Use --output to set the project model directory. This directory must not exist before the command runs. + +Later scans can use the model without a new build. This makes repeated scans fast. + +To scan the model, use "opentaint scan --project-model".`, + Example: ` # Compile the current directory into a project model + opentaint compile . -o ./model + + # Make sure the inputs are correct, without a build + opentaint compile . -o ./model --dry-run + + # Recipe: compile one time, then scan with different settings + opentaint compile ./my-app -o ./model + opentaint scan --project-model ./model --ruleset ./team-rules -o team.sarif + opentaint scan --project-model ./model --severity error -o errors.sarif`, Annotations: map[string]string{"PrintConfig": "true"}, Run: func(cmd *cobra.Command, args []string) { ProjectPath = args[0] @@ -72,25 +84,25 @@ Arguments: sb.Line() } sb.FieldNode("Project", absProjectRoot). - FieldNode("Output project model", absOutputProjectModelPath). + FieldNode("Project model", absOutputProjectModelPath). FieldNode("Autobuilder", utils.ArtifactVersionWithPath(globals.ArtifactByKind("autobuilder"))). Render() if DryRunCompile { out.Blank() failOnInvalidInputs(func() error { return validation.ValidateCompileInputs(absProjectRoot, absOutputProjectModelPath) }) - runDryRun("Compilation") + runDryRun("compilation") return } autobuilderJarPath, err := ensureAutobuilderAvailable() if err != nil { - out.Fatalf("Native compile preparation failed: %s", err) + failf("Native compile preparation failed: %s", err) } compileJavaRunner := newAutobuilderJavaRunner() if _, err := compileJavaRunner.EnsureJava(); err != nil { - out.Fatalf("Failed to resolve Java for compilation: %s", err) + failf("Failed to resolve Java for compilation: %s", err) } if err := out.RunWithSpinner("Compiling project model", func() error { @@ -98,7 +110,7 @@ Arguments: }); err == nil { out.Blank() printCompileSummary(absOutputProjectModelPath) - suggest("To scan project run", utils.BuildScanCommandFromCompile(projectRoot, absOutputProjectModelPath)) + 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 +121,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..dd2edaa238 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 { @@ -9,5 +12,34 @@ func failOnInvalidInputs(validate func() error) { } 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()) +} + +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, " ") +} + +func shellQuote(arg string) string { + if arg != "" && !strings.ContainsFunc(arg, shellUnsafe) { + return arg + } + return "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'" +} + +func shellUnsafe(r rune) bool { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + return false + case strings.ContainsRune("_@%+=:,./-", r): + return false + } + return true } diff --git a/cli/cmd/exit_codes.go b/cli/cmd/exit_codes.go new file mode 100644 index 0000000000..d985602ae0 --- /dev/null +++ b/cli/cmd/exit_codes.go @@ -0,0 +1,33 @@ +package cmd + +import ( + "fmt" + + "github.com/seqra/opentaint/internal/analyzer" +) + +// analyzerExitCodeRows lists the analyzer exit codes. exceptionNote, when set, follows the +// unhandled exception row. +func analyzerExitCodeRows(exceptionNote string) string { + rows := "" + for _, code := range []int{analyzer.ExitException, analyzer.ExitOOM, analyzer.ExitTimeout, analyzer.ExitConfigError} { + rows += fmt.Sprintf("\n %-3d %s", code, analyzer.ExitMessage(code)) + if code == analyzer.ExitException && exceptionNote != "" { + rows += " " + exceptionNote + } + } + return rows +} + +func scanExitCodesHelp(completedLine string) string { + return `Exit codes: + 0 ` + completedLine + ` + 1 General failure (configuration or infrastructure error)` + analyzerExitCodeRows("") +} + +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("(the analyzer log path is printed)") +} diff --git a/cli/cmd/health.go b/cli/cmd/health.go index 0954bbbf47..4878de84dd 100644 --- a/cli/cmd/health.go +++ b/cli/cmd/health.go @@ -27,15 +27,26 @@ type healthComponent struct { var healthCmd = &cobra.Command{ Use: "health", - Short: "Show resolved dependency paths", - Long: `Show the on-disk paths OpenTaint uses for the autobuilder, analyzer, -built-in rules, and Java runtime. + Short: "Show dependency paths and report missing components", + Long: `Show the paths of the components on this computer. The components are the autobuilder, the analyzer, the built-in rules, and the Java runtime. The command shows if each component is present. -Use --autobuilder, --analyzer, --rules, or --runtime to select components. When -exactly one component is selected, only its path is printed. The command does -not download artifacts except built-in rules, which are fetched on demand. +To select components, use --autobuilder, --analyzer, --rules, or --runtime. With no flag, the command shows all four components. If you select exactly one component, only its path is printed. This output is good for scripts. -The exit code is non-zero when any selected component is missing.`, +Only the built-in rules are downloaded when they are missing. No other component is downloaded. + +If a selected component is missing, the command exits with a code that is not zero. To download the missing components, run "opentaint pull".`, + Example: ` # Show all components and their paths + opentaint health + + # Print only the analyzer JAR path, for a script + opentaint health --analyzer + + # Make sure the Java runtime is present + opentaint health --runtime + + # Recipe: use the built-in rules path in a script + RULES=$(opentaint health --rules) + opentaint scan . --ruleset "$RULES" --ruleset ./extra-rules -o report.sarif`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return runHealth() @@ -105,6 +116,7 @@ func runHealth() error { } sb.Render() if len(missing) > 0 { + out.Suggest("To download the missing components, run:", "opentaint pull") return fmt.Errorf("missing components: %s", strings.Join(missing, ", ")) } return nil diff --git a/cli/cmd/project.go b/cli/cmd/project.go index 60e31d5d20..53b9229cf5 100644 --- a/cli/cmd/project.go +++ b/cli/cmd/project.go @@ -181,7 +181,7 @@ 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)) + suggest("To scan the generated model, run:", utils.BuildScanCommandFromCompile(c.outputDir, c.outputDir)) return nil } @@ -218,16 +218,28 @@ var ( var projectCmd = &cobra.Command{ Use: "project", - Short: "Create a project model directory containing a project.yaml configuration from precompiled JARs or classes", - Long: `Create a project model directory containing a project.yaml configuration from precompiled JARs or classes. + Short: "Create a project model from precompiled JARs or classes", + Long: `Create a project model from JARs or classes that are already compiled. No build occurs. OpenTaint examines the classpath, finds the modules and dependencies, and writes a project.yaml file. -This command generates a project model, automatically detecting dependencies and project structure. -Additional packages have to be specified to enhance the generated configuration. +Use this command when you have compiled artifacts but no build. To build a model from sources, use "opentaint compile". -Examples: - # Classpath analysis - opentaint project --output ./project-model --source-root /path/to/source \ - --classpath /path/to/app.jar --package com.example`, +All inputs are flags. Give the source path with --source-root. Give the compiled classes or JARs with --classpath. Give the packages to include with --package. Add more JAR files with --dependency. + +Use --output to set the project model directory. This directory must not exist before the command runs. + +Before the first run, run "opentaint pull" one time. To scan the model, use "opentaint scan --project-model".`, + Example: ` # Create a project model from a compiled JAR + opentaint project --source-root ./src --classpath ./app.jar --package com.example -o ./model + + # Add more dependency JARs to the classpath + opentaint project --source-root ./src --classpath ./app.jar --dependency ./lib.jar --package com.example -o ./model + + # Make sure the inputs are correct, without a model + opentaint project --source-root ./src --classpath ./app.jar --package com.example -o ./model --dry-run + + # Recipe: scan a vendor JAR that you cannot build + opentaint project --source-root ./src --classpath ./vendor-app.jar --package com.vendor -o ./model + opentaint scan --project-model ./model -o report.sarif`, Run: func(cmd *cobra.Command, args []string) { config := NewJavaAutobuilder(). WithOutputDir(OutputDir). @@ -269,12 +281,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 +294,15 @@ Examples: func init() { rootCmd.AddCommand(projectCmd) - projectCmd.Flags().StringVarP(&OutputDir, "output", "o", "", "Output directory for project.yaml") + projectCmd.Flags().StringVarP(&OutputDir, "output", "o", "", "Directory to write the generated project model (required, must not exist)") _ = projectCmd.MarkFlagRequired("output") - projectCmd.Flags().StringVar(&SourceRoot, "source-root", "", "Source root directory") + projectCmd.Flags().StringVar(&SourceRoot, "source-root", "", "Path to the project source root") _ = projectCmd.MarkFlagRequired("source-root") - projectCmd.Flags().StringArrayVar(&Dependencies, "dependency", []string{}, "Project dependencies (JAR files)") - projectCmd.Flags().StringArrayVar(&Packages, "package", []string{}, "Project packages") + projectCmd.Flags().StringArrayVar(&Dependencies, "dependency", []string{}, "Additional dependency JAR file on the compile classpath (repeatable)") + projectCmd.Flags().StringArrayVar(&Packages, "package", []string{}, "Package to include in the generated model (repeatable)") _ = projectCmd.MarkFlagRequired("package") - projectCmd.Flags().StringArrayVar(&Classpaths, "classpath", []string{}, "Classpath entries (classes or JAR files)") + projectCmd.Flags().StringArrayVar(&Classpaths, "classpath", []string{}, "Classpath entry: a compiled classes directory or a JAR file (repeatable)") _ = projectCmd.MarkFlagRequired("classpath") - projectCmd.Flags().BoolVar(&DryRunProject, "dry-run", false, "Validate inputs and show what would run without generating project model") + projectCmd.Flags().BoolVar(&DryRunProject, "dry-run", false, "Validate inputs and show what would run without generating the project model") projectCmd.Flags().StringVar(&ProjectLogFile, "log-file", "", "Path to the log file (default: /logs/.log)") } diff --git a/cli/cmd/prune.go b/cli/cmd/prune.go index a4da4e6b84..aab398e193 100644 --- a/cli/cmd/prune.go +++ b/cli/cmd/prune.go @@ -57,24 +57,27 @@ func resolveCategories() (utils.PruneCategory, error) { var pruneCmd = &cobra.Command{ Use: "prune", - Short: "Remove stale downloaded artifacts from ~/.opentaint", - Long: `Remove stale downloaded artifacts from the local cache (~/.opentaint). - -Identifies artifacts that are no longer needed: -- Old versions of analyzer JARs, autobuilder JARs, and rules -- Downloaded JDK/JRE versions that don't match the current version -- Cached project models - -Use category flags to prune selectively: - --artifacts Stale analyzer and autobuilder JARs - --rules Stale rules directories - --jdk Old JDK/JRE versions - --models Cached project models - --logs Project log files - --install Install-tier lib and JRE artifacts (requires re-download) - -Without category flags, prunes: artifacts + rules + jdk + models. -With --all: prunes everything including logs and install-tier.`, + Short: "Remove old downloaded artifacts from the cache", + Long: `Remove old downloaded artifacts from the local cache (~/.opentaint). The command removes analyzer and autobuilder JARs that a newer version replaced, old rules, JDK and JRE versions that do not match the configuration, and cached project models. + +To select categories, use --artifacts, --rules, --jdk, --models, --logs, or --install. With no category flag, the command removes artifacts, rules, jdk, and models. The --all flag removes everything, with logs and install-tier artifacts included. Do not give --all together with a category flag. + +To see the deletions without a removal, use --dry-run. To skip the confirmation prompt, use --yes. To download the toolchain again, run "opentaint pull".`, + Example: ` # Remove the default categories, with a confirmation prompt + opentaint prune + + # Remove only the old JDK and JRE versions + opentaint prune --jdk + + # Remove everything, with logs and install-tier artifacts included + opentaint prune --all + + # See what the command would delete, without a deletion + opentaint prune --dry-run + + # Recipe: get disk space back, keep the current toolchain + opentaint prune --dry-run + opentaint prune --yes`, Run: func(cmd *cobra.Command, args []string) { categories, err := resolveCategories() if err != nil { @@ -84,23 +87,23 @@ With --all: prunes everything including logs and install-tier.`, // Acquire global prune lock pruneLockPath, err := utils.PruneLockPath() if err != nil { - out.Fatalf("Failed to resolve prune lock path: %s", err) + failf("Failed to resolve prune lock path: %s", err) } pruneLock, err := utils.TryLockExclusive(pruneLockPath, utils.LockMeta{ PID: os.Getpid(), Command: "prune", }) if err == utils.ErrLocked { - out.Fatal("Another prune is already running") + failWith(1, "Another prune is already running") } if err != nil { - out.Fatalf("Failed to acquire prune lock: %s", err) + failf("Failed to acquire prune lock: %s", err) } defer pruneLock.Unlock() result, err := utils.ScanForStaleArtifacts(categories) if err != nil { - out.Fatalf("Failed to scan for stale artifacts: %s", err) + failf("Failed to scan for stale artifacts: %s", err) } // Display skipped projects @@ -130,22 +133,27 @@ With --all: prunes everything including logs and install-tier.`, Render() if pruneDryRun { - out.Print("Dry run mode. No files were deleted.") + out.Print("Dry run complete. No files were deleted.") + suggest("To delete these artifacts, run:", withFlag(rerunWithoutDryRun(), "--yes")) return } if !pruneYes { if !out.Confirm("Delete these artifacts?", false) { out.Print("Prune cancelled.") + suggest("To prune without confirming, run:", withFlag(rerunWithoutDryRun(), "--yes")) return } } if err := utils.DeleteArtifacts(result.Stale); err != nil { - out.Fatalf("Failed to delete artifacts: %s", err) + failf("Failed to delete artifacts: %s", err) } out.Successf("Pruned %d items, freed %s", result.TotalCount, output.FormatSize(result.TotalSize)) + if pruneInstall || pruneAll { + suggest("To restore the removed components, run:", "opentaint pull") + } }, } diff --git a/cli/cmd/pull.go b/cli/cmd/pull.go index ff95db860f..503ab24428 100644 --- a/cli/cmd/pull.go +++ b/cli/cmd/pull.go @@ -17,15 +17,21 @@ import ( var pullCmd = &cobra.Command{ Use: "pull", - Short: "Download autobuilder, analyzer binaries, rules and Java runtime", - Long: `Download all necessary binaries and assets: -- OpenTaint autobuilder JAR -- OpenTaint analyzer JAR -- OpenTaint rules archive -- Java runtime (Temurin JRE) - -This prepares the environment with all required dependencies for offline analysis. -When bundled artifacts are present (from a release archive), they will be used directly.`, + Short: "Download the analysis toolchain and Java runtime", + Long: `Download the toolchain into the local cache. The toolchain contains the analyzer, the autobuilder, the built-in rules, and a Java runtime. After the download, OpenTaint can build and scan projects without network access. + +If a release archive supplied bundled artifacts, OpenTaint uses them. They are not downloaded again. + +Run "opentaint pull" one time before your first scan. To remove old downloads, use "opentaint prune".`, + Example: ` # Download the toolchain before the first scan + opentaint pull + + # Download a different Java runtime version + opentaint pull --java-version 17 + + # Recipe: prepare a machine that will have no network access + opentaint pull + opentaint health`, Run: func(cmd *cobra.Command, args []string) { out.Section("OpenTaint Pull"). Field("Autobuilder", globals.Config.Autobuilder.Version). @@ -40,7 +46,7 @@ When bundled artifacts are present (from a release archive), they will be used d installCurrent := utils.IsInstallCurrent() if !installCurrent { if err := utils.CleanInstallDir(); err != nil { - out.Fatalf("Failed to clean install directory: %s", err) + failf("Failed to clean install directory: %s", err) } } @@ -50,26 +56,29 @@ When bundled artifacts are present (from a release archive), they will be used d for _, spec := range artifacts { node, err := downloadArtifact(spec, installNextToBinary, installCurrent) if err != nil { - out.Fatalf("Failed to download %s: %s", spec.Kind(), err) + failf("Failed to download %s: %s", spec.Kind(), err) } summaryNodes = append(summaryNodes, node) } javaNode, err := downloadJava(installNextToBinary, installCurrent) if err != nil { - out.Fatalf("Failed to download Java: %s", err) + failf("Failed to download Java: %s", err) } summaryNodes = append(summaryNodes, javaNode) // Write version marker after all downloads succeed if err := utils.WriteInstallVersionMarker(); err != nil { - out.Fatalf("Failed to write install version marker: %s", err) + failf("Failed to write install version marker: %s", err) } out.Blank() out.Section("Pull Summary"). Child(summaryNodes...). Render() + + out.Successf("Pull completed.") + suggest("To scan your project, run:", "opentaint scan .") }, } diff --git a/cli/cmd/rerun_test.go b/cli/cmd/rerun_test.go new file mode 100644 index 0000000000..a57cd8aee5 --- /dev/null +++ b/cli/cmd/rerun_test.go @@ -0,0 +1,158 @@ +package cmd + +import ( + "os" + "testing" + "time" + + "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) + } + if got := withFlag("opentaint scan path--debug", "--debug"); got != "opentaint scan path--debug --debug" { + t.Fatalf("withFlag substring match = %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", + "12G": "16G", + "15G": "16G", + "1024m": "2048m", + "0G": "16G", + "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, 10*time.Minute, "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 15m0s" + if timeoutRetry.Command != want { + t.Fatalf("timeout retry command = %q, want %q", timeoutRetry.Command, want) + } + if _, ok := retrySuggestion(analyzer.ExitTimeout, 15*time.Minute, "8G"); ok { + t.Fatal("timeout at cap must not produce a retry suggestion") + } + if _, ok := retrySuggestion(analyzer.ExitOOM, 15*time.Minute, "16G"); ok { + t.Fatal("memory at cap must not produce a retry suggestion") + } + if retry, ok := retrySuggestion(analyzer.ExitTimeout, 10*time.Minute, "8G"); !ok || retry.Command != "opentaint scan . --max-memory 8G --timeout 15m0s" { + t.Fatalf("timeout cap retry = %+v ok=%t", retry, ok) + } + + if _, ok := retrySuggestion(analyzer.ExitException, 900e9, "8G"); ok { + t.Fatal("exception exit code must not produce a retry suggestion") + } +} + +func TestShellQuoteLeavesInertArgumentsAlone(t *testing.T) { + for _, arg := range []string{"opentaint", "report.sarif", "--max-memory=8G", "path/to/file.yaml", "a-b_c.d,e:f@g%h+i"} { + if got := shellQuote(arg); got != arg { + t.Errorf("shellQuote(%q) = %q, want unchanged", arg, got) + } + } +} + +func TestShellQuoteQuotesShellMetacharacters(t *testing.T) { + cases := map[string]string{ + "demo-rule-*": "'demo-rule-*'", + "java/security/**": "'java/security/**'", + "$HOME": "'$HOME'", + "a;b": "'a;b'", + "a|b": "'a|b'", + "a b": "'a b'", + "it's": `'it'\''s'`, + "": "''", + "a>b": "'a>b'", + "`cmd`": "'`cmd`'", + } + for in, want := range cases { + if got := shellQuote(in); got != want { + t.Errorf("shellQuote(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 214028ec8b..e457002dad 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -33,9 +33,14 @@ var updateHintCh = make(chan string, 1) // rootCmd represents the base command when called without any subcommands var rootCmd = &cobra.Command{ - Use: "opentaint", - Short: "OpenTaint Analyzer", - Long: `OpenTaint is a CLI tool that analyzes Java and Kotlin projects to find vulnerabilities`, + Use: "opentaint", + Short: "Find vulnerabilities in source code with taint analysis", + Long: `OpenTaint finds vulnerabilities in your code. It follows tainted data from untrusted sources to dangerous sinks. Java and Kotlin projects are supported. + +Quick start: + 1. Run "opentaint pull" one time. This downloads the toolchain. + 2. Run "opentaint scan ." to scan a project. + 3. Run "opentaint summary --show-findings" to read the findings.`, SilenceErrors: true, SilenceUsage: true, diff --git a/cli/cmd/scan.go b/cli/cmd/scan.go index 6df354bae9..7233cd40eb 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" @@ -73,15 +74,40 @@ func (p scanPlan) title() string { // scanCmd represents the scan command var scanCmd = &cobra.Command{ Use: "scan [source-path]", - Short: "Scan your Java or Kotlin project", + Short: "Scan a project for vulnerabilities", Args: cobra.MaximumNArgs(1), - Long: `This command automatically detects Java/Kotlin build systems, builds the project, and analyzes it + Long: `Scan a project and find vulnerabilities. OpenTaint finds the build system, builds the project, and does a taint analysis. -Arguments: - source-path - Path to the project sources (default: current directory) +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. -Use --project-model to scan a pre-compiled project model instead of compiling from sources. -`, +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. + +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 the report to a known path + opentaint scan ./my-app -o report.sarif + + # Scan a project model that is already compiled + opentaint scan --project-model ./model -o report.sarif + + # Use your own rules and show only errors + opentaint scan . --ruleset ./rules --severity error -o report.sarif + + # Give a large project more time and memory + 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 != "" { @@ -94,7 +120,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) } @@ -123,19 +149,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") @@ -176,7 +202,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), } } @@ -194,7 +220,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) @@ -287,7 +313,7 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { } if cfg.DryRun { - runDryRun("Compilation and analysis") + runDryRun("the build and scan") return } @@ -300,25 +326,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) } } @@ -337,7 +363,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) @@ -348,7 +374,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 @@ -404,7 +430,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) @@ -412,7 +438,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 @@ -470,15 +496,41 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { if analyzerLog, ok := analyzerLogSuggestion(analyzerFail, nativeBuilder.LogsFile()); ok { suggestions = append(suggestions, analyzerLog) } + 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.") + } + 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 %s findings were reported.", 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...) @@ -487,6 +539,18 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { } } +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{ @@ -546,7 +610,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 bd64b5a54d..0512f6fefc 100644 --- a/cli/cmd/suggest.go +++ b/cli/cmd/suggest.go @@ -3,24 +3,135 @@ 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) } -// 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. +const ( + maxRetryMemory = 16 + maxRetryTimeout = 15 * time.Minute +) + +func withFlag(command, flag string) string { + for _, token := range strings.Fields(command) { + if token == flag || strings.HasPrefix(token, flag+"=") { + return command + } + } + return command + " " + flag +} + +func retrySuggestion(exitCode int, timeout time.Duration, maxMemory string) (output.Suggestion, bool) { + switch exitCode { + case analyzer.ExitOOM: + if memoryGib(maxMemory) >= maxRetryMemory { + return output.Suggestion{}, false + } + next := doubleMemory(maxMemory) + return output.Suggestion{ + Description: "To retry with more memory, run:", + Command: rerunReplacingFlag(next, "--max-memory"), + }, true + case analyzer.ExitTimeout: + if timeout >= maxRetryTimeout { + return output.Suggestion{}, false + } + next := timeout * 2 + if next <= timeout || next > maxRetryTimeout { + next = maxRetryTimeout + } + return output.Suggestion{ + Description: "To retry with a longer timeout, run:", + Command: rerunReplacingFlag(next.String(), "--timeout", "-t"), + }, true + } + return output.Suggestion{}, false +} + +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, " ") +} + +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 || n <= 0 { + return "16G" + } + if memoryGib(value)*2 >= maxRetryMemory { + return "16G" + } + return fmt.Sprintf("%d%s", n*2, suffix) +} + +func memoryGib(value string) int { + digits := 0 + for digits < len(value) && value[digits] >= '0' && value[digits] <= '9' { + digits++ + } + suffix := value[digits:] + if digits == 0 { + return 0 + } + n, err := strconv.ParseInt(value[:digits], 10, 64) + if err != nil || n <= 0 { + return 0 + } + switch suffix { + case "G", "g": + return int(n) + case "M", "m": + return int(n / 1024) + case "K", "k": + return int(n / (1024 * 1024)) + case "": + return int(n / (1024 * 1024 * 1024)) + default: + return 0 + } +} + func logSuggestion() (output.Suggestion, bool) { if globals.LogPath == "" { return output.Suggestion{}, false @@ -44,9 +155,6 @@ func analyzerLogSuggestion(fail *analyzer.Error, logsFile string) (output.Sugges }, true } -// appendLogSuggestion appends the log-file pointer to s when a log file is -// active and returns s unchanged otherwise. It centralizes the "lead with the -// log" idiom shared by buildFailSuggestions and the scan summary tail. func appendLogSuggestion(s []output.Suggestion) []output.Suggestion { if logSug, ok := logSuggestion(); ok { return append(s, logSug) @@ -54,29 +162,16 @@ func appendLogSuggestion(s []output.Suggestion) []output.Suggestion { return s } -// buildFailSuggestions returns a pointer to the log file (when one exists) -// followed by the contextual hints. The log pointer leads so the user always -// sees where to look for full details first. Building onto a fresh slice avoids -// mutating a caller's backing array when contextual is passed via the spread form. func buildFailSuggestions(contextual []output.Suggestion) []output.Suggestion { return append(appendLogSuggestion(nil), contextual...) } -// failWith prints an error message, renders a single Suggestions block leading -// with a pointer to the log file (when one exists) followed by any contextual -// hints, then exits the process with the given code. Use it for operational -// compile/scan failures; pure input/argument errors stay on out.Fatalf with -// their own usage hints. func failWith(code int, message string, contextual ...output.Suggestion) { out.Error(message) out.Suggestions(buildFailSuggestions(contextual)...) os.Exit(code) } -// failf formats an error message and fails with exit code 1 and no contextual -// suggestion. The log-file pointer is still added when a log file exists. Use -// it as the drop-in for bare out.Fatalf at operational compile/scan failure -// sites; see failWith. func failf(format string, args ...any) { failWith(1, fmt.Sprintf(format, args...)) } diff --git a/cli/cmd/summary.go b/cli/cmd/summary.go index 0959b5257c..9b6da780ce 100644 --- a/cli/cmd/summary.go +++ b/cli/cmd/summary.go @@ -9,14 +9,34 @@ import ( // summaryCmd represents the summary command var summaryCmd = &cobra.Command{ - Use: "summary sarif", - Short: "Print summary of your sarif", + Use: "summary ", + Short: "Show a summary of a SARIF report", Args: cobra.ExactArgs(1), // require exactly one argument - Long: `Print summary of your sarif file + Long: `Show a summary of a SARIF report in the terminal. The summary counts the findings by severity. It also shows which rules ran and which rules found problems. -Arguments: - sarif - Path to a sarif file -`, +The sarif-report argument is the path to a SARIF report. It is required. Use a report from "opentaint scan" or "opentaint test". + +To see each finding, use --show-findings. To make the list smaller, use --severity, --rule-id, or --path. To see the full data flow, use --verbose-flow and --show-code-snippets. + +This command only reads the report. It does not write files.`, + Example: ` # Show a summary of a report + opentaint summary report.sarif + + # Show each finding with its location + opentaint summary report.sarif --show-findings + + # Show only the error-level findings + opentaint summary report.sarif --show-findings --severity error + + # Group the findings by rule + opentaint summary report.sarif --show-findings --group-by rule-id + + # 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 { @@ -39,6 +59,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 +85,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 +164,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 2142a7c757..27761dfdb7 100644 --- a/cli/cmd/test.go +++ b/cli/cmd/test.go @@ -9,17 +9,38 @@ import ( var testCmd = &cobra.Command{ Use: "test", Short: "Create and run rule and model tests", - Long: `Tools for creating test projects, running annotated rule and model tests, and debugging rule reachability.`, + Long: `Create and run tests for detection rules and dataflow models. Rule tests make sure that a rule finds the positive samples and ignores the negative samples. Model tests make sure that a model moves taint from source to sink. + +Workflow: + 1. Create a test project with init. + 2. Compile the project with "opentaint compile". + 3. Run the samples with "test rule run" or "test model run". + +To see why one rule does or does not fire, use "test rule reachability".`, } var testRuleCmd = &cobra.Command{ Use: "rule", Short: "Create, run, and debug detection-rule tests", + Long: `Create, run, and debug tests for taint detection rules. A rule test makes sure that a rule finds the positive samples and ignores the negative samples. + +Workflow: + 1. Create a test project with "test rule init". + 2. Compile the project with "opentaint compile". + 3. Run the samples with "test rule run". + +To see why one rule does or does not fire, use "test rule reachability".`, } var testModelCmd = &cobra.Command{ Use: "model", Short: "Create and run dataflow model tests", + Long: `Create and run tests for dataflow models. A model test makes sure that a model moves taint from source to sink in your samples. + +Workflow: + 1. Create a test project with "test model init". + 2. Compile the project with "opentaint compile". + 3. Run the samples with "test model run --dataflow-models ".`, } // testApproximationCmd is the deprecated name of testModelCmd. @@ -36,21 +57,10 @@ 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 (the analyzer log path is printed) - 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, passthrough *[]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().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)") addDataflowModelsFlag(cmd, dataflow) addPassthroughModelsFlag(cmd, passthrough) } diff --git a/cli/cmd/test_init.go b/cli/cmd/test_init.go index b22181be31..12629243fa 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/testmodel" "github.com/seqra/opentaint/internal/testproject" "github.com/seqra/opentaint/internal/testrule" @@ -18,19 +19,26 @@ var initRuleSourcesOnly bool var testRuleInitCmd = &cobra.Command{ Use: "init ", Short: "Create rule test projects with source and sink harnesses", - Long: `Create one or two Gradle test projects under . The sinks -project tests sink rules against a generic Taint source; the sources project -tests source rules against a generic Taint sink. Use --sinks-only or ---sources-only when only one project is needed. + Long: `Create one or two Gradle test projects for detection-rule tests. The sinks project tests sink rules with a generic taint source. The sources project tests source rules with a generic taint sink. -Each project includes: - - build.gradle.kts with compile-only dependencies, settings.gradle.kts - - src/main/java/test/ with Taint.java (the generic source()/sink()) for test sample sources - - test-rules/java/lib/test/generic-{source,sink}.yaml marker rules for test-only joins +The output-dir argument is the parent directory for the new projects. The default creates the two projects, in output-dir/sinks and output-dir/sources. To create one project only, use --sinks-only or --sources-only. To add compile-only Maven dependencies for the samples, use --dependency. -Positive and negative samples are specified via rule-test.yaml. +Each project contains a rule-test.yaml file and a Taint.java harness. Declare your positive and negative samples in rule-test.yaml. -Use --dependency to add compile-only Maven dependencies for the samples.`, +Then compile the project with "opentaint compile" and run the samples with "opentaint test rule run".`, + Example: ` # Create the sinks and the sources test projects + opentaint test rule init ./rule-tests + + # Create only the sinks project + opentaint test rule init ./rule-tests --sinks-only + + # Add a compile-only dependency for the samples + opentaint test rule init ./rule-tests --dependency + + # Recipe: from an empty directory to a first test run + opentaint test rule init ./rule-tests + opentaint compile ./rule-tests/sinks -o ./rule-tests/sinks/model + opentaint test rule run ./rule-tests/sinks/model --ruleset ./my-rules`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { if initRuleSinksOnly && initRuleSourcesOnly { @@ -50,28 +58,42 @@ 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) } + var suggestions []output.Suggestion + for _, kind := range kinds { + dir := filepath.Join(args[0], kind) + modelDir := filepath.Join(dir, "model") + suggestions = append(suggestions, + output.Suggestion{Description: fmt.Sprintf("To add %s test samples, edit:", kind), Command: filepath.Join(dir, "rule-test.yaml")}, + output.Suggestion{Description: fmt.Sprintf("To compile the %s test project, run:", kind), Command: fmt.Sprintf("opentaint compile %s -o %s", shellQuote(dir), shellQuote(modelDir))}, + output.Suggestion{Description: fmt.Sprintf("To run the %s tests, run:", kind), Command: fmt.Sprintf("opentaint test rule run %s", shellQuote(modelDir))}, + ) + } + out.Suggestions(suggestions...) }, } var testModelInitCmd = &cobra.Command{ Use: "init ", Short: "Create a dataflow model test project", - Long: `Create a minimal Gradle project for testing OpenTaint dataflow models. + Long: `Create a Gradle test project for dataflow model tests. The project contains a fixed source-to-sink rule. The samples are checked against this rule. + +The output-dir argument is the directory for the new project. To add compile-only Maven dependencies for the samples, use --dependency. The model under test is not part of the project. Give it at run time with --dataflow-models. -The project includes: - - build.gradle.kts with compile-only dependencies - - settings.gradle.kts - - model-rule.yaml, the fixed source-to-sink rule the samples are checked against - - src/main/java/test/ with Taint.java (the fixed source() and sink()) for test sample sources +The project contains a rule-test.yaml file, a Taint.java source and sink, and a fixed source-to-sink harness rule. Declare your positive and negative samples in rule-test.yaml. -Positive and negative samples are specified via rule-test.yaml. +Then compile the project with "opentaint compile" and run the samples with "opentaint test model run".`, + Example: ` # Create a dataflow model test project + opentaint test model init ./model-test -The model under test is supplied separately at test time with ---dataflow-models. + # Add a compile-only dependency for the samples + opentaint test model init ./model-test --dependency -Use --dependency to add compile-only Maven dependencies for the samples.`, + # Recipe: from an empty directory to a first test run + opentaint test model init ./model-test + opentaint compile ./model-test -o ./model-test/model + opentaint test model run ./model-test/model --dataflow-models ./my-model`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { if err := testproject.Bootstrap(args[0], "model-test-project", initModelProjectDeps); err != nil { @@ -80,7 +102,14 @@ Use --dependency to add compile-only Maven dependencies for the samples.`, if err := testmodel.Scaffold(args[0]); err != nil { out.Fatalf("Failed to scaffold model test project: %s", err) } - fmt.Printf("Model test project initialized at %s\n", args[0]) + out.Printf("Model 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", shellQuote(dir), shellQuote(modelDir))}, + output.Suggestion{Description: "To run the tests, run:", Command: fmt.Sprintf("opentaint test model run %s --dataflow-models ", shellQuote(modelDir))}, + ) }, } diff --git a/cli/cmd/test_model_run.go b/cli/cmd/test_model_run.go index 88eabec863..948a65a662 100644 --- a/cli/cmd/test_model_run.go +++ b/cli/cmd/test_model_run.go @@ -19,12 +19,24 @@ var ( var testModelRunCmd = &cobra.Command{ Use: "run ", Short: "Run dataflow model tests on a compiled project model", - Long: `Run the samples specified in rule-test.yaml with the supplied dataflow models applied. + Long: `Run the samples that rule-test.yaml declares, with your dataflow models 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 model-rule. -A built-in source-to-sink harness rule is applied automatically; positive samples reference the -model-rule.yaml rule with id "model-rule". +The project-model argument is a compiled project model directory from "opentaint compile". Give the model under test with --dataflow-models. + +The command writes test-result.json and a test-results.sarif report to --output. If --output is not set, it writes to a temporary directory. + +Compile the test project before you run the tests. To read the results, use "opentaint summary". ` + testExitCodesHelp("All model tests passed"), + Example: ` # Run a dataflow model test on a compiled model + opentaint test model run ./model-test/model --dataflow-models ./my-model + + # Write the results to a directory + opentaint test model run ./model-test/model --dataflow-models ./my-model -o ./results + + # Recipe: change a model, then make sure the tests stay green + opentaint test model run ./model-test/model --dataflow-models ./my-model -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-model-rule-*") @@ -37,6 +49,7 @@ model-rule.yaml rule with id "model-rule". runTestProject(args[0], testProjectOptions{ label: "Model tests", + passedLine: "All model tests passed.", tempDir: "opentaint-test-models-*", rulesets: []string{ruleDir}, outputDir: testModelOutputDir, diff --git a/cli/cmd/test_rule_reachability.go b/cli/cmd/test_rule_reachability.go index ecb0bf3231..20714bdf83 100644 --- a/cli/cmd/test_rule_reachability.go +++ b/cli/cmd/test_rule_reachability.go @@ -8,12 +8,31 @@ var reachabilityEntryPoint string var testRuleReachabilityCmd = &cobra.Command{ Use: "reachability [source-path]", - Short: "Trace why a rule can or cannot reach its facts", - Long: `Scan a project with one rule and write a sibling fact-reachability SARIF -report (debug-ifds-fact-reachability.sarif) next to the main one. Use this to -debug why a rule does or does not fire. + Short: "Show why a rule does or does not fire", + Long: `Scan a project with one rule and write a fact-reachability SARIF report. The report shows why the rule does or does not fire. Library source and sink rules that the rule points to are included automatically. -Referenced library source and sink rules are collected and analyzed automatically.`, +The rule-id argument selects the rule. The source-path argument is the project root. It is optional. The default is the current directory. To use a compiled model, use --project-model. Do not give source-path and --project-model together. To start the analysis from one method, use --entry-points. + +The report name is debug-ifds-fact-reachability.sarif. It is written adjacent to the main SARIF report. + +Before the first run, run "opentaint pull" one time. To read the report, use "opentaint summary". + +` + scanExitCodesHelp("Reachability analysis completed"), + Example: ` # Show why a rule does or does not fire on the current directory + opentaint test rule reachability . + + # Examine a rule on a compiled project model + opentaint test rule reachability --project-model ./model + + # Start the analysis from one entry-point method + opentaint test rule reachability . --entry-points com.example.App#main + + # Make sure the inputs are correct, without a scan + opentaint test rule reachability . --dry-run + + # Recipe: find why a new rule stays silent + opentaint test rule reachability . -o report.sarif + opentaint summary debug-ifds-fact-reachability.sarif --show-findings --verbose-flow`, Annotations: map[string]string{"PrintConfig": "true"}, Args: cobra.RangeArgs(1, 2), Run: func(cmd *cobra.Command, args []string) { diff --git a/cli/cmd/test_rule_run.go b/cli/cmd/test_rule_run.go index 3f78deb108..d7b7dca5ad 100644 --- a/cli/cmd/test_rule_run.go +++ b/cli/cmd/test_rule_run.go @@ -7,6 +7,7 @@ import ( "time" "github.com/seqra/opentaint/internal/analyzer" + "github.com/seqra/opentaint/internal/output" "github.com/seqra/opentaint/internal/utils" "github.com/seqra/opentaint/internal/utils/log" "github.com/spf13/cobra" @@ -25,14 +26,32 @@ var ( var testRuleRunCmd = &cobra.Command{ Use: "run ", Short: "Run detection-rule tests on a compiled project model", - Long: `Run detection rules against the samples specified in rule-test.yaml in the -compiled project model. + Long: `Run detection rules on the samples that rule-test.yaml declares. The command reports which samples passed. The built-in rules are always included. + +The project-model argument is a compiled project model directory from "opentaint compile". To add your own rules, use --ruleset. To run only specified rules, use --rule-id. To apply models, use --dataflow-models or --passthrough-models. + +The command writes test-result.json and a test-results.sarif report to --output. If --output is not set, it writes to a temporary directory. + +Compile the test project before you run the tests. To read the results, use "opentaint summary". ` + testExitCodesHelp("All rule tests passed"), + Example: ` # Run the built-in rules on a compiled model + opentaint test rule run ./rule-tests/sinks/model + + # Test your own rules and write the results to a directory + opentaint test rule run ./rule-tests/sinks/model --ruleset ./rules -o ./results + + # Run only one rule + opentaint test rule run ./rule-tests/sinks/model --rule-id + + # Recipe: change a rule, then make sure the tests stay green + opentaint test rule run ./rule-tests/sinks/model --ruleset ./rules -o ./results + opentaint summary ./results/test-results.sarif --show-findings`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { runTestProject(args[0], testProjectOptions{ label: "Rule tests", + passedLine: "All rule tests passed.", tempDir: "opentaint-test-rules-*", rulesets: testRulesRuleset, outputDir: testRulesOutputDir, @@ -48,6 +67,7 @@ compiled project model. type testProjectOptions struct { label string + passedLine string tempDir string rulesets []string outputDir string @@ -104,7 +124,7 @@ func runTestProject(projectModelArg string, opts testProjectOptions) { if opts.includeBuiltinRules { rulesPath, err := utils.EnsureRulesPath(out) if err != nil { - out.Fatalf("Failed to prepare built-in rules: %s", err) + failf("Failed to prepare built-in rules: %s", err) } builder.AddRuleSet(rulesPath) } @@ -124,7 +144,7 @@ func runTestProject(projectModelArg string, opts testProjectOptions) { analyzerJarPath, err := ensureAnalyzerAvailable() if err != nil { - out.Fatalf("Failed to resolve analyzer: %s", err) + failf("Failed to resolve analyzer: %s", err) } builder.SetJarPath(analyzerJarPath) @@ -133,47 +153,62 @@ func runTestProject(projectModelArg string, opts testProjectOptions) { javaRunner := newAnalyzerJavaRunner() if _, err := javaRunner.EnsureJava(); err != nil { - out.Fatalf("Failed to resolve Java for analyzer: %s", err) + failf("Failed to resolve Java for analyzer: %s", err) } cmdErr, err := scanProject(builder, javaRunner) if err != nil { - out.Fatalf("%s failed: %s", opts.label, err) + failf("%s failed: %s", opts.label, err) } analyzerFail := analyzer.Classify(cmdErr) - if analyzerFail != nil { - out.Error(analyzerFail.Message) - } resultPath := filepath.Join(outputDir, "test-result.json") - fmt.Printf("Results directory: %s\n", outputDir) - fmt.Printf("Test results: %s\n", resultPath) + out.Printf("Results directory: %s", outputDir) + out.Printf("Test results: %s", resultPath) if analyzerFail != nil { + out.Error(analyzerFail.Message) + suggestions := appendLogSuggestion(nil) if analyzerLog, ok := analyzerLogSuggestion(analyzerFail, builder.LogsFile()); ok { - out.Suggestions(analyzerLog) + suggestions = append(suggestions, analyzerLog) + } + 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(suggestions, 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, &testRulesPassthrough) testRuleRunCmd.Flags().StringArrayVar(&testRulesRuleID, "rule-id", nil, "Run only rules with this ID (repeatable)") } diff --git a/cli/cmd/update.go b/cli/cmd/update.go index 1d3a5c1bda..6ef0484d9b 100644 --- a/cli/cmd/update.go +++ b/cli/cmd/update.go @@ -20,13 +20,25 @@ var ( var updateCmd = &cobra.Command{ Use: "update [version]", Short: "Update opentaint to the latest version", - Long: `Update opentaint to the latest version (or a specific version). + Long: `Update the opentaint binary to the latest release. To get a specified version, give the version argument. Only upgrades are possible. The command refuses a version that is older than the current one. -This command detects how opentaint was installed and provides appropriate -instructions for package manager installations. For binary installations, -it performs an in-place update. +If opentaint was installed with Homebrew or npm, the command does not change the binary. It shows the correct package-manager command. -Only upgrades are supported — downgrading to an older version is refused.`, +To see the latest version without a download, use --check. To skip the confirmation prompt, use --yes. + +After a successful update, remove the old artifacts with "opentaint prune".`, + Example: ` # Update to the latest release + opentaint update + + # See if a newer version is available, without a download + opentaint update --check + + # Update to a specified version without a prompt + opentaint update 1.2.3 --yes + + # Recipe: update, then remove the artifacts of the old version + opentaint update --yes + opentaint prune --yes`, Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { // Check installation method first @@ -35,11 +47,11 @@ Only upgrades are supported — downgrading to an older version is refused.`, switch method { case utils.InstallMethodHomebrew: out.Print("opentaint was installed via Homebrew.") - out.Print("Run: brew upgrade --cask opentaint") + suggest("To update, run:", "brew upgrade --cask opentaint") return case utils.InstallMethodNpm: out.Print("opentaint was installed via npm.") - out.Print("Run: npm install -g @seqra/opentaint@latest") + suggest("To update, run:", "npm install -g @seqra/opentaint@latest") return } @@ -75,7 +87,7 @@ Only upgrades are supported — downgrading to an older version is refused.`, out.Warnf("Could not compare versions: %s", err) out.Printf("Current: %s, Latest: %s", currentVersion, targetVersion) if !updateYes { - out.Print("Use --yes to proceed anyway.") + suggest("To proceed anyway, run:", withFlag(rerunWithoutDryRun(), "--yes")) return } } @@ -94,9 +106,8 @@ Only upgrades are supported — downgrading to an older version is refused.`, out.Section("Update Available"). Field("Current version", fmt.Sprintf("v%s", currentVersion)). Field("Latest version", fmt.Sprintf("v%s", targetVersion)). - Line(). - Text("Run 'opentaint update' to update."). Render() + suggest("To update, run:", "opentaint update") return } @@ -107,6 +118,7 @@ Only upgrades are supported — downgrading to an older version is refused.`, if !updateYes { if !out.Confirm("Proceed with update?", false) { out.Print("Update cancelled.") + suggest("To update without confirming, run:", "opentaint update --yes") return } } @@ -132,7 +144,7 @@ Only upgrades are supported — downgrading to an older version is refused.`, } out.Successf("Successfully updated to v%s", targetVersion) - suggest("To clean up old artifacts run", "opentaint prune") + suggest("To clean up old artifacts, run:", "opentaint prune") }, } diff --git a/cli/internal/analyzer/exit.go b/cli/internal/analyzer/exit.go index 6d38e75992..9e97afbdad 100644 --- a/cli/internal/analyzer/exit.go +++ b/cli/internal/analyzer/exit.go @@ -37,9 +37,9 @@ func ExitMessage(code int) string { case ExitConfigError: return "project configuration error" case ExitTimeout: - return "analysis timed out — try increasing --timeout or --max-memory" + return "analysis timed out: try increasing --timeout or --max-memory" case ExitOOM: - return "out of memory — try increasing --max-memory (e.g. --max-memory 16G)" + return "out of memory: try increasing --max-memory (e.g. --max-memory 16G)" case ExitException: return "unhandled analyzer exception" default: From 61d76c50bf0dc589a4f0f205c4365fd3f17a691d Mon Sep 17 00:00:00 2001 From: Gr-i-niy Date: Tue, 3 Nov 2026 10:08:04 +0000 Subject: [PATCH 2/4] docs(cli): make the command help short and accurate Every Long text says what the command does in one or two sentences and keeps only the facts that the flag list and usage line do not show: that --output must not exist, that downgrades are not supported, how a Homebrew or npm install is updated, which categories prune removes by default. Positional arguments are described in an Arguments section and named when required and [arg] when optional. Paragraphs end without a period. Examples remain where the use is not obvious from the flags, one command each: scan, compile, project, health, summary, model init, and the test commands. pull, update, prune, and the command groups have none. Flag help is checked against the code: - --java-version selects the Java that compiles projects when no system Java is found. The analyzer always runs on the default Java version - --code-flow-limit limits the code flows of each finding - scan -o defaults to /sources/opentaint.sarif - --log-file defaults to a timestamped file in ~/.opentaint/logs - --ruleset replaces the built-in rules unless builtin is also given - scan and the other commands download a missing component, so the help no longer says to run pull before the first scan The analyzer rows of the exit-code tables start with a capital letter, like the rows above them. --- cli/cmd/compile.go | 27 +++++--------- cli/cmd/compile_models_cmd.go | 11 ++---- cli/cmd/exit_codes.go | 11 +++++- cli/cmd/health.go | 26 ++++---------- cli/cmd/logging.go | 3 ++ cli/cmd/model.go | 22 +++++------- cli/cmd/project.go | 37 ++++++------------- cli/cmd/prune.go | 35 +++++------------- cli/cmd/pull.go | 17 ++------- cli/cmd/root.go | 26 +++++++------- cli/cmd/scan.go | 60 ++++++++++++------------------- cli/cmd/summary.go | 50 +++++++++----------------- cli/cmd/test.go | 33 ++++++----------- cli/cmd/test_init.go | 45 +++++++---------------- cli/cmd/test_model_run.go | 22 ++++-------- cli/cmd/test_rule_reachability.go | 31 +++++----------- cli/cmd/test_rule_run.go | 29 +++++---------- cli/cmd/update.go | 23 +++--------- 18 files changed, 164 insertions(+), 344 deletions(-) diff --git a/cli/cmd/compile.go b/cli/cmd/compile.go index c63dc01098..1e218a6a3b 100644 --- a/cli/cmd/compile.go +++ b/cli/cmd/compile.go @@ -40,25 +40,14 @@ func dockerCompileSuggestion() output.Suggestion { // compileCmd represents the compile command var compileCmd = &cobra.Command{ Use: "compile ", - Short: "Compile a project into a reusable project model", + Short: "Compile a Java or Kotlin project into a project model", Args: cobra.ExactArgs(1), // require exactly one argument - 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. + Long: `Detect the build system of a Java or Kotlin project, compile the project, and write a project model that "opentaint scan --project-model" analyzes without a new build -The project argument is the path to the project root. It is required. Use --output to set the project model directory. This directory must not exist before the command runs. - -Later scans can use the model without a new build. This makes repeated scans fast. - -To scan the model, use "opentaint scan --project-model".`, - Example: ` # Compile the current directory into a project model - opentaint compile . -o ./model - - # Make sure the inputs are correct, without a build - opentaint compile . -o ./model --dry-run - - # Recipe: compile one time, then scan with different settings - opentaint compile ./my-app -o ./model - opentaint scan --project-model ./model --ruleset ./team-rules -o team.sarif - opentaint scan --project-model ./model --severity error -o errors.sarif`, +Arguments: + Root directory of the project`, + Example: ` # Compile a project into ./app-model + opentaint compile ./app -o ./app-model`, Annotations: map[string]string{"PrintConfig": "true"}, Run: func(cmd *cobra.Command, args []string) { ProjectPath = args[0] @@ -121,10 +110,10 @@ To scan the model, use "opentaint scan --project-model".`, func init() { rootCmd.AddCommand(compileCmd) - compileCmd.Flags().StringVarP(&OutputProjectModelPath, "output", "o", "", `Path to the project model directory to create (required, must not exist)`) + compileCmd.Flags().StringVarP(&OutputProjectModelPath, "output", "o", "", "Path to the project model 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)") + compileCmd.Flags().StringVar(&CompileLogFile, "log-file", "", logFileUsage) } func compile(absProjectRoot, absOutputProjectModelPath, autobuilderJarPath string, javaRunner java.JavaRunner) error { diff --git a/cli/cmd/compile_models_cmd.go b/cli/cmd/compile_models_cmd.go index fa2a4a613f..6ddda9ac29 100644 --- a/cli/cmd/compile_models_cmd.go +++ b/cli/cmd/compile_models_cmd.go @@ -13,15 +13,10 @@ var compileModelsOutput string var compileModelsCmd = &cobra.Command{ Use: "models ", Short: "Compile a dataflow model project", - Long: `Compile the dataflow models in against the -dependencies that project pins in its own build file. + Long: `Compile the dataflow models of a model project against the library versions that its build.gradle.kts pins -This is the model project, not the project to scan: its build.gradle.kts lists the exact -library versions the models are written against, so the same models compile identically -wherever they are applied. - -Scanning and testing build a model project on demand, so running this is only -needed to compile ahead of time or to see compilation errors on their own.`, +Arguments: + Model project created with "opentaint model init"`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { projectPath := log.AbsPathOrExit(args[0], "model-project") diff --git a/cli/cmd/exit_codes.go b/cli/cmd/exit_codes.go index d985602ae0..9150c740dc 100644 --- a/cli/cmd/exit_codes.go +++ b/cli/cmd/exit_codes.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "strings" "github.com/seqra/opentaint/internal/analyzer" ) @@ -11,7 +12,7 @@ import ( func analyzerExitCodeRows(exceptionNote string) string { rows := "" for _, code := range []int{analyzer.ExitException, analyzer.ExitOOM, analyzer.ExitTimeout, analyzer.ExitConfigError} { - rows += fmt.Sprintf("\n %-3d %s", code, analyzer.ExitMessage(code)) + rows += fmt.Sprintf("\n %-3d %s", code, capitalize(analyzer.ExitMessage(code))) if code == analyzer.ExitException && exceptionNote != "" { rows += " " + exceptionNote } @@ -19,6 +20,14 @@ func analyzerExitCodeRows(exceptionNote string) string { return rows } +// capitalize upper-cases the first letter, so the analyzer rows match the rows above them. +func capitalize(s string) string { + if s == "" { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + func scanExitCodesHelp(completedLine string) string { return `Exit codes: 0 ` + completedLine + ` diff --git a/cli/cmd/health.go b/cli/cmd/health.go index 4878de84dd..71b3e405a5 100644 --- a/cli/cmd/health.go +++ b/cli/cmd/health.go @@ -27,26 +27,12 @@ type healthComponent struct { var healthCmd = &cobra.Command{ Use: "health", - Short: "Show dependency paths and report missing components", - Long: `Show the paths of the components on this computer. The components are the autobuilder, the analyzer, the built-in rules, and the Java runtime. The command shows if each component is present. + Short: "Show the paths of the analyzer, autobuilder, built-in rules, and Java runtime", + Long: `Show the versions and paths of the analyzer, autobuilder, built-in rules, and Java runtime. The command exits with a nonzero code when a component is missing -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. - -Only the built-in rules are downloaded when they are missing. No other component is downloaded. - -If a selected component is missing, the command exits with a code that is not zero. To download the missing components, run "opentaint pull".`, - Example: ` # Show all components and their paths - opentaint health - - # Print only the analyzer JAR path, for a script - opentaint health --analyzer - - # Make sure the Java runtime is present - opentaint health --runtime - - # Recipe: use the built-in rules path in a script - RULES=$(opentaint health --rules) - opentaint scan . --ruleset "$RULES" --ruleset ./extra-rules -o report.sarif`, +With one component flag, the command prints only the path of that component`, + Example: ` # Store the path of the built-in rules for a script + rules_dir=$(opentaint health --rules)`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return runHealth() @@ -57,7 +43,7 @@ func init() { rootCmd.AddCommand(healthCmd) healthCmd.Flags().BoolVar(&healthAutobuilder, "autobuilder", false, "Print only the autobuilder JAR path") healthCmd.Flags().BoolVar(&healthAnalyzer, "analyzer", false, "Print only the analyzer JAR path") - healthCmd.Flags().BoolVar(&healthRules, "rules", false, "Print only the built-in rules path, downloading rules if needed") + healthCmd.Flags().BoolVar(&healthRules, "rules", false, "Print only the built-in rules path (downloads the rules when missing)") healthCmd.Flags().BoolVar(&healthRuntime, "runtime", false, "Print only the Java runtime path") } diff --git a/cli/cmd/logging.go b/cli/cmd/logging.go index 3d5cea6b1f..c63da436c0 100644 --- a/cli/cmd/logging.go +++ b/cli/cmd/logging.go @@ -7,6 +7,9 @@ import ( "github.com/seqra/opentaint/internal/utils/log" ) +// logFileUsage describes the --log-file flag of the commands that write a project log by default. +const logFileUsage = "Path to the log file (default: a timestamped file in ~/.opentaint/logs)" + // activateLogging opens the log file and configures the output printer's log writer. // If logFilePath is set, it uses that directly. Otherwise, it writes a timestamped // log file into logDir. If both are empty, no log file is opened. diff --git a/cli/cmd/model.go b/cli/cmd/model.go index 371b4ed129..ddfb279125 100644 --- a/cli/cmd/model.go +++ b/cli/cmd/model.go @@ -13,28 +13,22 @@ var initModelDeps []string var modelCmd = &cobra.Command{ Use: "model", Short: "Create dataflow model projects", - Long: `A dataflow model project holds code-based models of how taint moves through -methods the analyzer cannot see into, together with the exact dependency versions those -models are written against. + Long: `A dataflow model describes how taint moves through library methods that the analyzer cannot see into. A model project holds such models and pins the library versions they are written against -Compile one with 'opentaint compile models', and apply it with the ---dataflow-models flag of 'opentaint scan' or 'opentaint test model run'.`, +Compile a model project with "opentaint compile models" and apply it with --dataflow-models`, } var modelInitCmd = &cobra.Command{ Use: "init ", Short: "Create a dataflow model project", - Long: `Create a Gradle project for OpenTaint dataflow models. + Long: `Create a Gradle project for dataflow models -The project includes: - - build.gradle.kts with compile-only dependencies, settings.gradle.kts - - libs/opentaint-approximations-api.jar, the @Approximate annotations and the - OpentaintNdUtil / ArgumentTypeContext support types - - src/main/java/ for the model sources, one @Approximate class per target class +Arguments: + Directory of the new model project -Use --dependency to pin the libraries the models are written against, at the exact versions -the target application uses. Those pins are the models' compile environment: they, and -nothing about the project under analysis, decide what the models compile against.`, +Give each library that the models are written against with --dependency, at the version that the scanned application uses. To change the dependencies of an existing project, run the command again with the full list`, + Example: ` # Create a project for models of jackson-databind + opentaint model init ./jackson-models --dependency com.fasterxml.jackson.core:jackson-databind:2.17.2`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { projectPath := log.AbsPathOrExit(args[0], "output-dir") diff --git a/cli/cmd/project.go b/cli/cmd/project.go index 53b9229cf5..aa2c714a9c 100644 --- a/cli/cmd/project.go +++ b/cli/cmd/project.go @@ -219,27 +219,10 @@ var ( var projectCmd = &cobra.Command{ Use: "project", Short: "Create a project model from precompiled JARs or classes", - Long: `Create a project model from JARs or classes that are already compiled. No build occurs. OpenTaint examines the classpath, finds the modules and dependencies, and writes a project.yaml file. - -Use this command when you have compiled artifacts but no build. To build a model from sources, use "opentaint compile". - -All inputs are flags. Give the source path with --source-root. Give the compiled classes or JARs with --classpath. Give the packages to include with --package. Add more JAR files with --dependency. - -Use --output to set the project model directory. This directory must not exist before the command runs. - -Before the first run, run "opentaint pull" one time. To scan the model, use "opentaint scan --project-model".`, - Example: ` # Create a project model from a compiled JAR - opentaint project --source-root ./src --classpath ./app.jar --package com.example -o ./model - - # Add more dependency JARs to the classpath - opentaint project --source-root ./src --classpath ./app.jar --dependency ./lib.jar --package com.example -o ./model - - # Make sure the inputs are correct, without a model - opentaint project --source-root ./src --classpath ./app.jar --package com.example -o ./model --dry-run - - # Recipe: scan a vendor JAR that you cannot build - opentaint project --source-root ./src --classpath ./vendor-app.jar --package com.vendor -o ./model - opentaint scan --project-model ./model -o report.sarif`, + Long: `Create a project model from compiled classes or JARs without a build. Use it when the project cannot be built with "opentaint compile"`, + Example: ` # Create a project model from an application JAR and its library JARs + opentaint project --source-root ./src --classpath ./app.jar --package com.example \ + --dependency ./lib/guava.jar --dependency ./lib/jackson-databind.jar -o ./app-model`, Run: func(cmd *cobra.Command, args []string) { config := NewJavaAutobuilder(). WithOutputDir(OutputDir). @@ -294,15 +277,15 @@ Before the first run, run "opentaint pull" one time. To scan the model, use "ope func init() { rootCmd.AddCommand(projectCmd) - projectCmd.Flags().StringVarP(&OutputDir, "output", "o", "", "Directory to write the generated project model (required, must not exist)") + projectCmd.Flags().StringVarP(&OutputDir, "output", "o", "", "Path to the project model to create (required, must not exist)") _ = projectCmd.MarkFlagRequired("output") - projectCmd.Flags().StringVar(&SourceRoot, "source-root", "", "Path to the project source root") + projectCmd.Flags().StringVar(&SourceRoot, "source-root", "", "Project source directory (required)") _ = projectCmd.MarkFlagRequired("source-root") - 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.Flags().StringArrayVar(&Dependencies, "dependency", []string{}, "Library JAR the project depends on (repeatable)") + projectCmd.Flags().StringArrayVar(&Packages, "package", []string{}, "Package of the project's own code (repeatable, required)") _ = projectCmd.MarkFlagRequired("package") - projectCmd.Flags().StringArrayVar(&Classpaths, "classpath", []string{}, "Classpath entry: a compiled classes directory or a JAR file (repeatable)") + projectCmd.Flags().StringArrayVar(&Classpaths, "classpath", []string{}, "Class directory or JAR of the project (repeatable, required)") _ = projectCmd.MarkFlagRequired("classpath") 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)") + projectCmd.Flags().StringVar(&ProjectLogFile, "log-file", "", logFileUsage) } diff --git a/cli/cmd/prune.go b/cli/cmd/prune.go index aab398e193..30ed965964 100644 --- a/cli/cmd/prune.go +++ b/cli/cmd/prune.go @@ -57,27 +57,10 @@ func resolveCategories() (utils.PruneCategory, error) { var pruneCmd = &cobra.Command{ Use: "prune", - 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. + Short: "Remove stale downloads, cached project models, and logs", + Long: `Remove stale downloads, cached project models, and logs from ~/.opentaint -To select categories, use --artifacts, --rules, --jdk, --models, --logs, or --install. With no category flag, the command removes artifacts, rules, jdk, and models. The --all flag removes everything, with logs and install-tier artifacts included. Do not give --all together with a category flag. - -To see the deletions without a removal, use --dry-run. To skip the confirmation prompt, use --yes. To download the toolchain again, run "opentaint pull".`, - Example: ` # Remove the default categories, with a confirmation prompt - opentaint prune - - # Remove only the old JDK and JRE versions - opentaint prune --jdk - - # Remove everything, with logs and install-tier artifacts included - opentaint prune --all - - # See what the command would delete, without a deletion - opentaint prune --dry-run - - # Recipe: get disk space back, keep the current toolchain - opentaint prune --dry-run - opentaint prune --yes`, +Without a category flag, the command removes the stale analyzer, autobuilder, and rules downloads, the old Java runtimes, and the cached project models. With --all, it also removes the logs and the artifacts in ~/.opentaint/install`, Run: func(cmd *cobra.Command, args []string) { categories, err := resolveCategories() if err != nil { @@ -161,12 +144,12 @@ func init() { rootCmd.AddCommand(pruneCmd) pruneCmd.Flags().BoolVar(&pruneDryRun, "dry-run", false, "Show what would be deleted without deleting") - pruneCmd.Flags().BoolVar(&pruneYes, "yes", false, "Skip interactive confirmation") - pruneCmd.Flags().BoolVar(&pruneAll, "all", false, "Prune everything including logs and install-tier artifacts") - pruneCmd.Flags().BoolVar(&pruneArtifacts, "artifacts", false, "Prune stale analyzer and autobuilder JARs") - pruneCmd.Flags().BoolVar(&pruneRules, "rules", false, "Prune stale rules directories") - pruneCmd.Flags().BoolVar(&pruneJDK, "jdk", false, "Prune old JDK/JRE versions") + pruneCmd.Flags().BoolVar(&pruneYes, "yes", false, "Skip the confirmation prompt") + pruneCmd.Flags().BoolVar(&pruneAll, "all", false, "Prune all categories") + pruneCmd.Flags().BoolVar(&pruneArtifacts, "artifacts", false, "Prune stale analyzer and autobuilder downloads") + pruneCmd.Flags().BoolVar(&pruneRules, "rules", false, "Prune stale rules downloads") + pruneCmd.Flags().BoolVar(&pruneJDK, "jdk", false, "Prune old JDK and JRE downloads") pruneCmd.Flags().BoolVar(&pruneModels, "models", false, "Prune cached project models") pruneCmd.Flags().BoolVar(&pruneLogs, "logs", false, "Prune project log files") - pruneCmd.Flags().BoolVar(&pruneInstall, "install", false, "Prune install-tier lib and JRE artifacts (requires re-download on next run)") + pruneCmd.Flags().BoolVar(&pruneInstall, "install", false, "Prune the artifacts in ~/.opentaint/install (they are downloaded again on the next run)") } diff --git a/cli/cmd/pull.go b/cli/cmd/pull.go index 503ab24428..e0898f4779 100644 --- a/cli/cmd/pull.go +++ b/cli/cmd/pull.go @@ -17,21 +17,8 @@ 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, and a Java runtime. After the download, OpenTaint can build and scan projects without network access. - -If a release archive supplied bundled artifacts, OpenTaint uses them. They are not downloaded again. - -Run "opentaint pull" one time before your first scan. To remove old downloads, use "opentaint prune".`, - Example: ` # Download the toolchain before the first scan - opentaint pull - - # Download a different Java runtime version - opentaint pull --java-version 17 - - # Recipe: prepare a machine that will have no network access - opentaint pull - opentaint health`, + Short: "Download the analyzer, autobuilder, built-in rules, and Java runtime", + Long: `Download the analyzer, autobuilder, built-in rules, and Java runtime. Components bundled with the release archive are used instead of a download`, 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 e457002dad..b187f0e6e3 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -35,12 +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 in your code. It follows tainted data from untrusted sources to dangerous sinks. Java and Kotlin projects are supported. + Long: `OpenTaint finds vulnerabilities in Java and Kotlin projects. It follows tainted data from untrusted sources to dangerous sinks 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.`, + opentaint pull Download the analysis toolchain + opentaint scan . -o report.sarif Scan the project in the current directory + opentaint summary report.sarif --show-findings Show the findings of the report`, SilenceErrors: true, SilenceUsage: true, @@ -121,41 +121,41 @@ func init() { rootCmd.PersistentFlags().BoolVarP(&globals.Config.Output.Quiet, "quiet", "q", false, "Suppress interactive UI elements (spinners, progress bars) and JAR streaming") _ = viper.BindPFlag("output.quiet", rootCmd.PersistentFlags().Lookup("quiet")) - rootCmd.PersistentFlags().StringVar(&globals.Config.Analyzer.Version, "analyzer-version", globals.AnalyzerBindVersion, "Version of opentaint analyzer") + rootCmd.PersistentFlags().StringVar(&globals.Config.Analyzer.Version, "analyzer-version", globals.AnalyzerBindVersion, "Analyzer version to use") _ = rootCmd.PersistentFlags().MarkHidden("analyzer-version") _ = viper.BindPFlag("analyzer.version", rootCmd.PersistentFlags().Lookup("analyzer-version")) - rootCmd.PersistentFlags().StringVar(&globals.Config.Autobuilder.Version, "autobuilder-version", globals.AutobuilderBindVersion, "Version of opentaint autobuilder") + rootCmd.PersistentFlags().StringVar(&globals.Config.Autobuilder.Version, "autobuilder-version", globals.AutobuilderBindVersion, "Autobuilder version to use") _ = rootCmd.PersistentFlags().MarkHidden("autobuilder-version") _ = viper.BindPFlag("autobuilder.version", rootCmd.PersistentFlags().Lookup("autobuilder-version")) - rootCmd.PersistentFlags().StringVar(&globals.Config.Rules.Version, "rules-version", globals.RulesBindVersion, "Version of opentaint rules") + rootCmd.PersistentFlags().StringVar(&globals.Config.Rules.Version, "rules-version", globals.RulesBindVersion, "Rules version to use") _ = rootCmd.PersistentFlags().MarkHidden("rules-version") _ = viper.BindPFlag("rules.version", rootCmd.PersistentFlags().Lookup("rules-version")) - rootCmd.PersistentFlags().IntVar(&globals.Config.Java.Version, "java-version", globals.DefaultJavaVersion, "Java version to use for running analyzer") + rootCmd.PersistentFlags().IntVar(&globals.Config.Java.Version, "java-version", globals.DefaultJavaVersion, "Java version to compile projects with when no system Java is found") _ = viper.BindPFlag("java.version", rootCmd.PersistentFlags().Lookup("java-version")) - rootCmd.PersistentFlags().StringVar(&globals.Config.Github.Token, "github-token", "", "Token for docker image pull from ghcr.io") + rootCmd.PersistentFlags().StringVar(&globals.Config.Github.Token, "github-token", "", "GitHub token for downloading releases") _ = rootCmd.PersistentFlags().MarkHidden("github-token") _ = viper.BindPFlag("github.token", rootCmd.PersistentFlags().Lookup("github-token")) - rootCmd.PersistentFlags().StringVar(&globals.Config.Owner, "owner", globals.RepoOwner, "Organization owner for GitHub repositories") + rootCmd.PersistentFlags().StringVar(&globals.Config.Owner, "owner", globals.RepoOwner, "Owner of the GitHub repository to download releases from") _ = rootCmd.PersistentFlags().MarkHidden("owner") _ = viper.BindPFlag("owner", rootCmd.PersistentFlags().Lookup("owner")) - rootCmd.PersistentFlags().StringVar(&globals.Config.Repo, "repo", globals.RepoName, "GitHub repository name") + rootCmd.PersistentFlags().StringVar(&globals.Config.Repo, "repo", globals.RepoName, "GitHub repository to download releases from") _ = rootCmd.PersistentFlags().MarkHidden("repo") _ = viper.BindPFlag("repo", rootCmd.PersistentFlags().Lookup("repo")) rootCmd.PersistentFlags().BoolVar(&globals.Config.SkipVerify, "skip-verify", false, "Skip SHA256 checksum verification of downloaded artifacts") _ = viper.BindPFlag("skip-verify", rootCmd.PersistentFlags().Lookup("skip-verify")) - rootCmd.PersistentFlags().StringVar(&globals.Config.Analyzer.JarPath, "analyzer-jar", "", "Path to analyzer JAR (dev override, skips download)") + rootCmd.PersistentFlags().StringVar(&globals.Config.Analyzer.JarPath, "analyzer-jar", "", "Path to a local analyzer JAR to use instead of downloading one") _ = rootCmd.PersistentFlags().MarkHidden("analyzer-jar") _ = viper.BindPFlag("analyzer.jar_path", rootCmd.PersistentFlags().Lookup("analyzer-jar")) - rootCmd.PersistentFlags().StringVar(&globals.Config.Autobuilder.JarPath, "autobuilder-jar", "", "Path to autobuilder JAR (dev override, skips download)") + rootCmd.PersistentFlags().StringVar(&globals.Config.Autobuilder.JarPath, "autobuilder-jar", "", "Path to a local autobuilder JAR to use instead of downloading one") _ = rootCmd.PersistentFlags().MarkHidden("autobuilder-jar") _ = viper.BindPFlag("autobuilder.jar_path", rootCmd.PersistentFlags().Lookup("autobuilder-jar")) } diff --git a/cli/cmd/scan.go b/cli/cmd/scan.go index 7233cd40eb..90c10a48d5 100644 --- a/cli/cmd/scan.go +++ b/cli/cmd/scan.go @@ -74,40 +74,24 @@ func (p scanPlan) title() string { // scanCmd represents the scan command var scanCmd = &cobra.Command{ Use: "scan [source-path]", - Short: "Scan a project for vulnerabilities", + Short: "Scan a Java or Kotlin project for vulnerabilities", Args: cobra.MaximumNArgs(1), - Long: `Scan a project and find vulnerabilities. OpenTaint finds the build system, builds the project, and does a taint analysis. + Long: `Detect the build system of a Java or Kotlin project, compile the project, and analyze it -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. +Arguments: + [source-path] Root directory of the project (default: the current directory) -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. - -Before your first scan, run "opentaint pull" one time. To read a report again later, use "opentaint summary". +To scan a compiled project model, give --project-model instead of [source-path]. The report is written to --output, or to the project model directory when --output is not set ` + scanExitCodesHelp("Scan completed"), - Example: ` # Scan the current directory with the built-in rules - opentaint scan . - - # Scan a project and write the report to a known path - opentaint scan ./my-app -o report.sarif - - # Scan a project model that is already compiled - opentaint scan --project-model ./model -o report.sarif + Example: ` # Scan with your own rules in addition to the built-in ones + opentaint scan ./app --ruleset builtin --ruleset ./rules -o report.sarif - # Use your own rules and show only errors - opentaint scan . --ruleset ./rules --severity error -o report.sarif + # Scan a project model compiled with "opentaint compile" + opentaint scan --project-model ./app-model -o report.sarif # Give a large project more time and memory - 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`, + opentaint scan ./app --timeout 30m --max-memory 16G`, Annotations: map[string]string{"PrintConfig": "true"}, Run: func(cmd *cobra.Command, args []string) { if scanFlags.DebugRunAnalysisOnSelectedEntryPoints != "" { @@ -142,31 +126,33 @@ func init() { addEntryPointsFlag(scanCmd) } +// entryPointsUsage describes the --entry-points flag of scan and test rule reachability. +const entryPointsUsage = "Start analysis from a fully qualified method such as com.example.Class#method, or '*' for every method" + func addEntryPointsFlag(cmd *cobra.Command) { - cmd.Flags().StringVar(&scanFlags.DebugRunAnalysisOnSelectedEntryPoints, "entry-points", "", - "Start analysis from a fully qualified method such as com.example.Class#method, or '*' for every method") + cmd.Flags().StringVar(&scanFlags.DebugRunAnalysisOnSelectedEntryPoints, "entry-points", "", entryPointsUsage) _ = cmd.Flags().MarkHidden("entry-points") } func addRuleIDFlag(cmd *cobra.Command) { - cmd.Flags().StringArrayVar(&scanFlags.RuleID, "rule-id", nil, "Run only rules with this ID (repeatable)") + cmd.Flags().StringArrayVar(&scanFlags.RuleID, "rule-id", nil, "Run only the rule with this ID (repeatable)") } func addScanFlags(cmd *cobra.Command) { - cmd.Flags().DurationVarP(&globals.Config.Scan.Timeout, "timeout", "t", 900*time.Second, "Maximum wall-clock time for analysis (e.g. 30m, 1h)") + cmd.Flags().DurationVarP(&globals.Config.Scan.Timeout, "timeout", "t", 900*time.Second, "Analysis timeout (e.g. 30m, 1h)") - cmd.Flags().StringArrayVar(&scanFlags.Ruleset, "ruleset", []string{"builtin"}, "Rules to run: a YAML file, a directory of .yml or .yaml files, or builtin for the built-in rules (repeatable)") + cmd.Flags().StringArrayVar(&scanFlags.Ruleset, "ruleset", []string{"builtin"}, "Rules file or directory, or builtin for the built-in rules (repeatable)") - 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().BoolVar(&scanFlags.SemgrepCompatibilitySarif, "semgrep-compatibility-sarif", true, "Use Semgrep-style dotted rule IDs in the SARIF report") + cmd.Flags().StringVarP(&scanFlags.SarifReportPath, "output", "o", "", "Path to the SARIF report (default: /sources/opentaint.sarif)") - cmd.Flags().StringArrayVar(&scanFlags.Severity, "severity", []string{"warning", "error"}, "Run only rules at these severity levels: note, warning, error (repeatable)") + cmd.Flags().StringArrayVar(&scanFlags.Severity, "severity", []string{"warning", "error"}, "Run only the rules of this severity: note, warning, or 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().Int64Var(&globals.Config.Scan.CodeFlowLimit, "code-flow-limit", 0, "Maximum number of code flows per finding (0 = unlimited)") cmd.Flags().BoolVar(&scanFlags.DryRun, "dry-run", false, "Validate inputs and show what would run without compiling or scanning") cmd.Flags().BoolVar(&scanFlags.Recompile, "recompile", false, "Force recompilation even if a cached project model exists") cmd.Flags().StringVar(&scanFlags.ProjectModelPath, "project-model", "", "Path to a pre-compiled project model (skips compilation)") - cmd.Flags().StringVar(&scanFlags.LogFile, "log-file", "", "Path to the log file (default: /logs/.log)") + cmd.Flags().StringVar(&scanFlags.LogFile, "log-file", "", logFileUsage) addPassthroughModelsFlag(cmd, &scanFlags.PassthroughModels) @@ -174,7 +160,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") - cmd.Flags().IntVar(&scanFlags.IterationLimit, "iteration-limit", 0, "Cap the iterative-deepening fact depth at N field accessors and drop facts over it (0 = unlimited)") + cmd.Flags().IntVar(&scanFlags.IterationLimit, "iteration-limit", 0, "Cap the iterative-deepening fact depth at N field accessors (0 = unlimited)") } // currentScanBuilder returns a builder pre-populated with the user's current scan flags. diff --git a/cli/cmd/summary.go b/cli/cmd/summary.go index 9b6da780ce..929a354fc9 100644 --- a/cli/cmd/summary.go +++ b/cli/cmd/summary.go @@ -12,31 +12,15 @@ var summaryCmd = &cobra.Command{ Use: "summary ", Short: "Show a summary of a SARIF report", Args: cobra.ExactArgs(1), // require exactly one argument - 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. + Long: `Show the number of findings by severity and the rules that found them. With --show-findings, also list the findings -The sarif-report argument is the path to a SARIF report. It is required. Use a report from "opentaint scan" or "opentaint test". +Arguments: + SARIF report of "opentaint scan" or "opentaint test"`, + Example: ` # List the error findings in src/main + opentaint summary report.sarif --show-findings --severity error --path "src/main/**" -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.`, - Example: ` # Show a summary of a report - opentaint summary report.sarif - - # Show each finding with its location - opentaint summary report.sarif --show-findings - - # Show only the error-level findings - opentaint summary report.sarif --show-findings --severity error - - # Group the findings by rule - opentaint summary report.sarif --show-findings --group-by rule-id - - # 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`, + # Show every code-flow step of the path-traversal findings, with the code + opentaint summary report.sarif --show-findings --rule-id path-traversal --verbose-flow --show-code-snippets`, Run: func(cmd *cobra.Command, args []string) { for _, s := range summarySeverities { @@ -85,17 +69,17 @@ var summaryCodeFlow string func init() { rootCmd.AddCommand(summaryCmd) - 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().BoolVar(&showFindings, "show-findings", false, "List the findings") + summaryCmd.Flags().BoolVar(&showCodeSnippets, "show-code-snippets", false, "Show code snippets of findings") + summaryCmd.Flags().BoolVar(&verboseFlow, "verbose-flow", false, "Show every code-flow step, not only the source and sink") 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(&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(&summaryCodeFlow, "code-flow", "", "Render code flows: \"all\", a 1-based index, or unset (first only)") + summaryCmd.Flags().StringArrayVar(&summarySeverities, "severity", nil, "Show only findings of this SARIF level: error, warning, note, or none (repeatable)") + summaryCmd.Flags().StringArrayVar(&summaryRuleIDs, "rule-id", nil, "Show only findings of 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 prefix (repeatable)") + summaryCmd.Flags().StringVar(&summaryFingerprintKey, "partial-fingerprint-key", "", "SARIF partialFingerprints key that --partial-fingerprint matches (default vulnerabilityWithTraceHash/v1)") + summaryCmd.Flags().IntVar(&summaryMaxNestingLevel, "max-nesting-level", -1, "Hide code-flow steps deeper than this call-nesting level (-1 = off)") + summaryCmd.Flags().StringVar(&summaryGroupBy, "group-by", "", "Group findings by severity, rule-id, or file-path (default file-path)") + summaryCmd.Flags().StringVar(&summaryCodeFlow, "code-flow", "", "Code flows to show for each finding: all or a 1-based index (default: the first)") } // currentSummaryBuilder returns a builder pre-populated with the user's current summary flags. diff --git a/cli/cmd/test.go b/cli/cmd/test.go index 27761dfdb7..612ad51869 100644 --- a/cli/cmd/test.go +++ b/cli/cmd/test.go @@ -9,38 +9,25 @@ import ( var testCmd = &cobra.Command{ Use: "test", Short: "Create and run rule and model tests", - Long: `Create and run tests for detection rules and dataflow models. Rule tests make sure that a rule finds the positive samples and ignores the negative samples. Model tests make sure that a model moves taint from source to sink. + Long: `Create and run tests of detection rules and dataflow models. A test project declares positive samples, which must produce a finding, and negative samples, which must not 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 model run". - -To see why one rule does or does not fire, use "test rule reachability".`, + 1. Create a test project with "test rule init" or "test model init" + 2. Write the samples and declare them in rule-test.yaml + 3. Compile the test project with "opentaint compile" + 4. Run the samples with "test rule run" or "test model run"`, } var testRuleCmd = &cobra.Command{ Use: "rule", - Short: "Create, run, and debug detection-rule tests", - Long: `Create, run, and debug tests for taint detection rules. A rule test makes sure that a rule finds the positive samples and ignores the negative samples. - -Workflow: - 1. Create a test project with "test rule init". - 2. Compile the project with "opentaint compile". - 3. Run the samples with "test rule run". - -To see why one rule does or does not fire, use "test rule reachability".`, + Short: "Create, run, and debug rule tests", + Long: `Create and run tests of detection rules. To see why a rule does or does not fire on a project, use "test rule reachability"`, } var testModelCmd = &cobra.Command{ Use: "model", Short: "Create and run dataflow model tests", - Long: `Create and run tests for dataflow models. A model test makes sure that a model moves taint from source to sink in your samples. - -Workflow: - 1. Create a test project with "test model init". - 2. Compile the project with "opentaint compile". - 3. Run the samples with "test model run --dataflow-models ".`, + Long: `Create and run tests of dataflow models. A model test checks that a model passes taint from source to sink`, } // testApproximationCmd is the deprecated name of testModelCmd. @@ -58,8 +45,8 @@ func init() { } func addTestRunFlags(cmd *cobra.Command, outputDir *string, timeout *time.Duration, maxMemory *string, dataflow, passthrough *[]string) { - 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().StringVarP(outputDir, "output", "o", "", "Directory for test-result.json and test-results.sarif (default: a temporary directory)") + cmd.Flags().DurationVar(timeout, "timeout", 600*time.Second, "Analysis timeout (e.g. 30m, 1h)") cmd.Flags().StringVar(maxMemory, "max-memory", "8G", "Maximum analyzer heap size (e.g. 8G, 1024m)") addDataflowModelsFlag(cmd, dataflow) addPassthroughModelsFlag(cmd, passthrough) diff --git a/cli/cmd/test_init.go b/cli/cmd/test_init.go index 12629243fa..3e903d00fc 100644 --- a/cli/cmd/test_init.go +++ b/cli/cmd/test_init.go @@ -19,26 +19,14 @@ 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 with a generic taint source. The sources project tests source rules with a generic taint sink. + Long: `Create Gradle test projects for detection rules. The sinks project tests sink rules against a generic taint source, and the sources project tests source rules against a generic taint sink -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. +Arguments: + Parent directory of the sinks and sources projects -Each project contains a rule-test.yaml file and a Taint.java harness. Declare your positive and negative samples in rule-test.yaml. - -Then compile the project with "opentaint compile" and run the samples with "opentaint test rule run".`, - Example: ` # Create the sinks and the sources test projects - opentaint test rule init ./rule-tests - - # Create only the sinks project - opentaint test rule init ./rule-tests --sinks-only - - # Add a compile-only dependency for the samples - opentaint test rule init ./rule-tests --dependency - - # Recipe: from an empty directory to a first test run - opentaint test rule init ./rule-tests - opentaint compile ./rule-tests/sinks -o ./rule-tests/sinks/model - opentaint test rule run ./rule-tests/sinks/model --ruleset ./my-rules`, +Declare the positive and negative samples in the rule-test.yaml file of each project`, + Example: ` # Create only the sinks project, with a library that the samples call + opentaint test rule init ./rule-tests --sinks-only --dependency org.springframework:spring-jdbc:6.1.14`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { if initRuleSinksOnly && initRuleSourcesOnly { @@ -77,23 +65,14 @@ Then compile the project with "opentaint compile" and run the samples with "open var testModelInitCmd = &cobra.Command{ Use: "init ", Short: "Create a dataflow model test project", - Long: `Create a Gradle test project for dataflow model tests. The project contains a fixed source-to-sink rule. The samples are checked against this rule. - -The output-dir argument is the directory for the new project. To add compile-only Maven dependencies for the samples, use --dependency. The model under test is not part of the project. Give it at run time with --dataflow-models. - -The project contains a rule-test.yaml file, a Taint.java source and sink, and a fixed source-to-sink harness rule. Declare your positive and negative samples in rule-test.yaml. - -Then compile the project with "opentaint compile" and run the samples with "opentaint test model run".`, - Example: ` # Create a dataflow model test project - opentaint test model init ./model-test + Long: `Create a Gradle test project for dataflow models. A sample passes taint from Taint.source() through the modeled method to Taint.sink(), and the fixed rule model-rule checks that the taint reaches the sink - # Add a compile-only dependency for the samples - opentaint test model init ./model-test --dependency +Arguments: + Directory of the new test project - # Recipe: from an empty directory to a first test run - opentaint test model init ./model-test - opentaint compile ./model-test -o ./model-test/model - opentaint test model run ./model-test/model --dataflow-models ./my-model`, +Declare the positive and negative samples in rule-test.yaml. The model under test is given to "opentaint test model run" with --dataflow-models`, + Example: ` # Create a test project whose samples call jackson-databind + opentaint test model init ./model-test --dependency com.fasterxml.jackson.core:jackson-databind:2.17.2`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { if err := testproject.Bootstrap(args[0], "model-test-project", initModelProjectDeps); err != nil { diff --git a/cli/cmd/test_model_run.go b/cli/cmd/test_model_run.go index 948a65a662..86732e71ab 100644 --- a/cli/cmd/test_model_run.go +++ b/cli/cmd/test_model_run.go @@ -18,25 +18,17 @@ var ( var testModelRunCmd = &cobra.Command{ Use: "run ", - Short: "Run dataflow model tests on a compiled project model", - Long: `Run the samples that rule-test.yaml declares, with your dataflow models 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 model-rule. + Short: "Run dataflow model tests on a compiled test project", + Long: `Run the samples that rule-test.yaml declares with the --dataflow-models models applied and report which samples passed -The project-model argument is a compiled project model directory from "opentaint compile". Give the model under test with --dataflow-models. +Arguments: + Test project compiled with "opentaint compile" -The command writes test-result.json and a test-results.sarif report to --output. If --output is not set, it writes to a temporary directory. - -Compile the test project before you run the tests. To read the results, use "opentaint summary". +The results are written to test-result.json and test-results.sarif in --output, or in a temporary directory when --output is not set ` + testExitCodesHelp("All model tests passed"), - Example: ` # Run a dataflow model test on a compiled model - opentaint test model run ./model-test/model --dataflow-models ./my-model - - # Write the results to a directory - opentaint test model run ./model-test/model --dataflow-models ./my-model -o ./results - - # Recipe: change a model, then make sure the tests stay green - opentaint test model run ./model-test/model --dataflow-models ./my-model -o ./results - opentaint summary ./results/test-results.sarif --show-findings`, + Example: ` # Test a model project and keep the results + opentaint test model run ./model-test/model --dataflow-models ./jackson-models -o ./results`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { ruleDir, err := os.MkdirTemp("", "opentaint-model-rule-*") diff --git a/cli/cmd/test_rule_reachability.go b/cli/cmd/test_rule_reachability.go index 20714bdf83..bc8f21e2cc 100644 --- a/cli/cmd/test_rule_reachability.go +++ b/cli/cmd/test_rule_reachability.go @@ -9,30 +9,18 @@ var reachabilityEntryPoint string var testRuleReachabilityCmd = &cobra.Command{ Use: "reachability [source-path]", 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. + Long: `Scan a project with one rule and write a fact-reachability report that shows why the rule does or does not fire. The library source and sink rules that the rule refers to are included -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. +Arguments: + Full ID of the rule, such as java/security/path-traversal.yaml:path-traversal + [source-path] Root directory of the project (default: the current directory) -The report name is debug-ifds-fact-reachability.sarif. It is written adjacent to the main SARIF report. - -Before the first run, run "opentaint pull" one time. To read the report, use "opentaint summary". +To use a compiled project model, give --project-model instead of [source-path]. The report debug-ifds-fact-reachability.sarif is written next to the SARIF report ` + scanExitCodesHelp("Reachability analysis completed"), - Example: ` # Show why a rule does or does not fire on the current directory - opentaint test rule reachability . - - # Examine a rule on a compiled project model - opentaint test rule reachability --project-model ./model - - # Start the analysis from one entry-point method - opentaint test rule reachability . --entry-points com.example.App#main - - # Make sure the inputs are correct, without a scan - opentaint test rule reachability . --dry-run - - # Recipe: find why a new rule stays silent - opentaint test rule reachability . -o report.sarif - opentaint summary debug-ifds-fact-reachability.sarif --show-findings --verbose-flow`, + Example: ` # Debug the path-traversal rule from one entry point + opentaint test rule reachability java/security/path-traversal.yaml:path-traversal ./app \ + --entry-points com.example.FileController#download`, Annotations: map[string]string{"PrintConfig": "true"}, Args: cobra.RangeArgs(1, 2), Run: func(cmd *cobra.Command, args []string) { @@ -57,6 +45,5 @@ func reachabilityScanConfig(base ScanConfig, ruleID, entryPoint string) ScanConf func init() { testRuleCmd.AddCommand(testRuleReachabilityCmd) addScanFlags(testRuleReachabilityCmd) - testRuleReachabilityCmd.Flags().StringVar(&reachabilityEntryPoint, "entry-points", "", - "Start analysis from a fully qualified method such as com.example.Class#method") + testRuleReachabilityCmd.Flags().StringVar(&reachabilityEntryPoint, "entry-points", "", entryPointsUsage) } diff --git a/cli/cmd/test_rule_run.go b/cli/cmd/test_rule_run.go index d7b7dca5ad..e1d23124b6 100644 --- a/cli/cmd/test_rule_run.go +++ b/cli/cmd/test_rule_run.go @@ -25,28 +25,17 @@ var ( var testRuleRunCmd = &cobra.Command{ Use: "run ", - Short: "Run detection-rule tests on a compiled project model", - Long: `Run detection rules on the samples that rule-test.yaml declares. The command reports which samples passed. The built-in rules are always included. + Short: "Run rule tests on a compiled test project", + Long: `Run the built-in rules and the --ruleset rules on the samples that rule-test.yaml declares and report which samples passed -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 --dataflow-models or --passthrough-models. +Arguments: + Test project compiled with "opentaint compile" -The command writes test-result.json and a test-results.sarif report to --output. If --output is not set, it writes to a temporary directory. - -Compile the test project before you run the tests. To read the results, use "opentaint summary". +The results are written to test-result.json and test-results.sarif in --output, or in a temporary directory when --output is not set ` + testExitCodesHelp("All rule tests passed"), - Example: ` # Run the built-in rules on a compiled model - opentaint test rule run ./rule-tests/sinks/model - - # Test your own rules and write the results to a directory - opentaint test rule run ./rule-tests/sinks/model --ruleset ./rules -o ./results - - # Run only one rule - opentaint test rule run ./rule-tests/sinks/model --rule-id - - # Recipe: change a rule, then make sure the tests stay green - opentaint test rule run ./rule-tests/sinks/model --ruleset ./rules -o ./results - opentaint summary ./results/test-results.sarif --show-findings`, + Example: ` # Test your rules on the sinks project and keep the results + opentaint test rule run ./rule-tests/sinks/model --ruleset ./rules -o ./results`, Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { runTestProject(args[0], testProjectOptions{ @@ -208,7 +197,7 @@ func runTestProject(projectModelArg string, opts testProjectOptions) { func init() { testRuleCmd.AddCommand(testRuleRunCmd) - testRuleRunCmd.Flags().StringArrayVar(&testRulesRuleset, "ruleset", nil, "Ruleset to test: a YAML file or a directory of .yml or .yaml files (repeatable)") + testRuleRunCmd.Flags().StringArrayVar(&testRulesRuleset, "ruleset", nil, "Rules file or directory to load in addition to the built-in rules (repeatable)") addTestRunFlags(testRuleRunCmd, &testRulesOutputDir, &testRulesTimeout, &testRulesMaxMemory, &testRulesDataflow, &testRulesPassthrough) - testRuleRunCmd.Flags().StringArrayVar(&testRulesRuleID, "rule-id", nil, "Run only rules with this ID (repeatable)") + testRuleRunCmd.Flags().StringArrayVar(&testRulesRuleID, "rule-id", nil, "Run only the rule with this ID (repeatable)") } diff --git a/cli/cmd/update.go b/cli/cmd/update.go index 6ef0484d9b..be12d772a3 100644 --- a/cli/cmd/update.go +++ b/cli/cmd/update.go @@ -20,25 +20,12 @@ var ( var updateCmd = &cobra.Command{ Use: "update [version]", Short: "Update opentaint to the latest version", - Long: `Update the opentaint binary to the latest release. To get a specified version, give the version argument. Only upgrades are possible. The command refuses a version that is older than the current one. + Long: `Update opentaint to the latest release or to [version]. Downgrades are not supported -If opentaint was installed with Homebrew or npm, the command does not change the binary. It shows the correct package-manager command. +Arguments: + [version] Release to update to, such as 1.2.3 (default: the latest release) -To see the latest version without a download, use --check. To skip the confirmation prompt, use --yes. - -After a successful update, remove the old artifacts with "opentaint prune".`, - Example: ` # Update to the latest release - opentaint update - - # See if a newer version is available, without a download - opentaint update --check - - # Update to a specified version without a prompt - opentaint update 1.2.3 --yes - - # Recipe: update, then remove the artifacts of the old version - opentaint update --yes - opentaint prune --yes`, +When opentaint was installed with Homebrew or npm, the command prints the package manager command to run instead`, Args: cobra.MaximumNArgs(1), Run: func(cmd *cobra.Command, args []string) { // Check installation method first @@ -152,5 +139,5 @@ func init() { rootCmd.AddCommand(updateCmd) updateCmd.Flags().BoolVar(&updateCheck, "check", false, "Check for updates without downloading") - updateCmd.Flags().BoolVar(&updateYes, "yes", false, "Skip confirmation prompt") + updateCmd.Flags().BoolVar(&updateYes, "yes", false, "Skip the confirmation prompt") } From 0deddfe20d55c4827a9e0014cc1b5026e39af941 Mon Sep 17 00:00:00 2001 From: Gr-i-niy Date: Tue, 3 Nov 2026 10:08:19 +0000 Subject: [PATCH 3/4] feat(cli): create rule-test.yaml in new test projects test rule init and test model init now write the rule-test.yaml file that the analyzer reads from the test project root. Before, the file was missing, the init hint asked to edit it, and a test run without it ended with an analyzer exception. The model test template declares the fixed rule model-rule.yaml#model-rule with empty positive and negative lists. The rule test template has an empty tests list, because the rule under test is not known at init, and shows the entry format in a comment. An existing rule-test.yaml holds the samples of the user and is kept. The next-step hints follow the test flow: - compile of a test project suggests test rule run or test model run instead of a scan - model init suggests test model run with the new model project - compile models suggests a scan of the sources with the models --- cli/cmd/compile.go | 17 +++++- cli/cmd/compile_models_cmd.go | 4 +- cli/cmd/compile_test.go | 48 +++++++++++++++++ cli/cmd/model.go | 3 +- cli/cmd/test_init.go | 4 +- cli/internal/testmodel/example/rule-test.yaml | 7 +++ cli/internal/testmodel/testmodel.go | 17 +++++- cli/internal/testmodel/testmodel_test.go | 52 +++++++++++++++++++ cli/internal/testproject/rule_test_file.go | 25 +++++++++ .../testproject/rule_test_file_test.go | 40 ++++++++++++++ cli/internal/testrule/example/rule-test.yaml | 9 ++++ cli/internal/testrule/testrule.go | 11 +++- cli/internal/testrule/testrule_test.go | 30 +++++++++++ 13 files changed, 257 insertions(+), 10 deletions(-) create mode 100644 cli/cmd/compile_test.go create mode 100644 cli/internal/testmodel/example/rule-test.yaml create mode 100644 cli/internal/testmodel/testmodel_test.go create mode 100644 cli/internal/testproject/rule_test_file.go create mode 100644 cli/internal/testproject/rule_test_file_test.go create mode 100644 cli/internal/testrule/example/rule-test.yaml create mode 100644 cli/internal/testrule/testrule_test.go diff --git a/cli/cmd/compile.go b/cli/cmd/compile.go index 1e218a6a3b..e5e17a520c 100644 --- a/cli/cmd/compile.go +++ b/cli/cmd/compile.go @@ -9,6 +9,8 @@ import ( "github.com/spf13/cobra" "github.com/seqra/opentaint/internal/globals" + "github.com/seqra/opentaint/internal/testmodel" + "github.com/seqra/opentaint/internal/testproject" "github.com/seqra/opentaint/internal/utils" "github.com/seqra/opentaint/internal/utils/java" "github.com/seqra/opentaint/internal/utils/log" @@ -99,7 +101,7 @@ Arguments: }); err == nil { out.Blank() printCompileSummary(absOutputProjectModelPath) - suggest("To scan the compiled project model, run:", utils.BuildScanCommandFromCompile(projectRoot, absOutputProjectModelPath)) + suggest(compileNextStep(absProjectRoot, projectRoot, absOutputProjectModelPath)) } else { out.InteractiveBlank() failWith(1, fmt.Sprintf("Native compile has failed: %s", err), dockerCompileSuggestion()) @@ -191,3 +193,16 @@ func compileProjectWithLogs(absOutputProjectModelPath, absProjectRoot, autobuild return nil } + +// compileNextStep returns the hint printed after a successful compile. A compiled test project is +// tested, not scanned. +func compileNextStep(absProjectRoot, projectRoot, absOutputProjectModelPath string) (string, string) { + if !utils.PathExists(filepath.Join(absProjectRoot, testproject.RuleTestFileName)) { + return "To scan the compiled project model, run:", utils.BuildScanCommandFromCompile(projectRoot, absOutputProjectModelPath) + } + projectModel := shellQuote(absOutputProjectModelPath) + if testmodel.IsTestProject(absProjectRoot) { + return "To run the model tests, run:", "opentaint test model run " + projectModel + " --dataflow-models " + } + return "To run the rule tests, run:", "opentaint test rule run " + projectModel +} diff --git a/cli/cmd/compile_models_cmd.go b/cli/cmd/compile_models_cmd.go index 6ddda9ac29..134d2cd3d0 100644 --- a/cli/cmd/compile_models_cmd.go +++ b/cli/cmd/compile_models_cmd.go @@ -47,8 +47,8 @@ Arguments: Field("Model project", projectPath). Field("Compiled models written to", models.ClassesDir(outputPath)). Render() - suggest("To apply these models in a scan run", - fmt.Sprintf("opentaint scan --dataflow-models %s", args[0])) + suggest("To scan with these models, run:", + fmt.Sprintf("opentaint scan --dataflow-models %s", shellQuote(args[0]))) }, } diff --git a/cli/cmd/compile_test.go b/cli/cmd/compile_test.go new file mode 100644 index 0000000000..e19d8791b9 --- /dev/null +++ b/cli/cmd/compile_test.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/seqra/opentaint/internal/testmodel" + "github.com/seqra/opentaint/internal/testproject" + "github.com/seqra/opentaint/internal/testrule" +) + +func TestCompileNextStep(t *testing.T) { + plain := t.TempDir() + if err := os.WriteFile(filepath.Join(plain, "pom.xml"), nil, 0o644); err != nil { + t.Fatal(err) + } + ruleTest := t.TempDir() + if err := testrule.Scaffold(ruleTest); err != nil { + t.Fatal(err) + } + modelTest := t.TempDir() + if err := testmodel.Scaffold(modelTest); err != nil { + t.Fatal(err) + } + + for name, tc := range map[string]struct{ dir, want string }{ + "project": {plain, "opentaint scan "}, + "rule tests": {ruleTest, "opentaint test rule run /out"}, + "model tests": {modelTest, "opentaint test model run /out --dataflow-models "}, + "rule-test.yaml only": {writeRuleTest(t), "opentaint test rule run /out"}, + } { + t.Run(name, func(t *testing.T) { + if _, command := compileNextStep(tc.dir, tc.dir, "/out"); !strings.HasPrefix(command, tc.want) { + t.Errorf("command = %q, want the prefix %q", command, tc.want) + } + }) + } +} + +func writeRuleTest(t *testing.T) string { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, testproject.RuleTestFileName), []byte("tests: []\n"), 0o644); err != nil { + t.Fatal(err) + } + return dir +} diff --git a/cli/cmd/model.go b/cli/cmd/model.go index ddfb279125..ab90d278e0 100644 --- a/cli/cmd/model.go +++ b/cli/cmd/model.go @@ -55,7 +55,8 @@ Give each library that the models are written against with --dependency, at the } else { fmt.Printf("Model project initialized at %s\n", projectPath) } - suggest("To compile the models run", fmt.Sprintf("opentaint compile models %s", args[0])) + suggest("After you write the models, test them with:", + fmt.Sprintf("opentaint test model run --dataflow-models %s", shellQuote(args[0]))) }, } diff --git a/cli/cmd/test_init.go b/cli/cmd/test_init.go index 3e903d00fc..ea56c76485 100644 --- a/cli/cmd/test_init.go +++ b/cli/cmd/test_init.go @@ -53,7 +53,7 @@ Declare the positive and negative samples in the rule-test.yaml file of each pro dir := filepath.Join(args[0], kind) modelDir := filepath.Join(dir, "model") suggestions = append(suggestions, - output.Suggestion{Description: fmt.Sprintf("To add %s test samples, edit:", kind), Command: filepath.Join(dir, "rule-test.yaml")}, + output.Suggestion{Description: fmt.Sprintf("To add %s test samples, edit:", kind), Command: filepath.Join(dir, testproject.RuleTestFileName)}, output.Suggestion{Description: fmt.Sprintf("To compile the %s test project, run:", kind), Command: fmt.Sprintf("opentaint compile %s -o %s", shellQuote(dir), shellQuote(modelDir))}, output.Suggestion{Description: fmt.Sprintf("To run the %s tests, run:", kind), Command: fmt.Sprintf("opentaint test rule run %s", shellQuote(modelDir))}, ) @@ -85,7 +85,7 @@ Declare the positive and negative samples in rule-test.yaml. The model under tes 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 add your test samples, edit:", Command: filepath.Join(dir, testproject.RuleTestFileName)}, output.Suggestion{Description: "To compile the test project, run:", Command: fmt.Sprintf("opentaint compile %s -o %s", shellQuote(dir), shellQuote(modelDir))}, output.Suggestion{Description: "To run the tests, run:", Command: fmt.Sprintf("opentaint test model run %s --dataflow-models ", shellQuote(modelDir))}, ) diff --git a/cli/internal/testmodel/example/rule-test.yaml b/cli/internal/testmodel/example/rule-test.yaml new file mode 100644 index 0000000000..2b6b2e0fec --- /dev/null +++ b/cli/internal/testmodel/example/rule-test.yaml @@ -0,0 +1,7 @@ +# Samples are entry-point methods given as #, for example test.Samples#positive +# A positive sample passes taint from Taint.source() to Taint.sink() through the modeled method +# A negative sample does not +tests: + - rule-id: model-rule.yaml#model-rule + positive: [] + negative: [] diff --git a/cli/internal/testmodel/testmodel.go b/cli/internal/testmodel/testmodel.go index 3a3b525232..8b24ab836b 100644 --- a/cli/internal/testmodel/testmodel.go +++ b/cli/internal/testmodel/testmodel.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" + "github.com/seqra/opentaint/internal/testproject" "github.com/seqra/opentaint/internal/utils" ) @@ -17,6 +18,9 @@ var fixedRule []byte //go:embed example/src/main/java/test/Taint.java var taintJava []byte +//go:embed example/rule-test.yaml +var ruleTestTemplate []byte + func WriteFixedRule(dir string) (string, error) { path := filepath.Join(dir, fixedRuleFileName) if err := os.WriteFile(path, fixedRule, 0o644); err != nil { @@ -25,9 +29,18 @@ func WriteFixedRule(dir string) (string, error) { return path, nil } +// IsTestProject reports whether dir is a model test project: it holds the fixed rule. +func IsTestProject(dir string) bool { + info, err := os.Stat(filepath.Join(dir, fixedRuleFileName)) + return err == nil && !info.IsDir() +} + func Scaffold(projectDir string) error { - return utils.WriteFiles(map[string][]byte{ + if err := utils.WriteFiles(map[string][]byte{ filepath.Join(projectDir, fixedRuleFileName): fixedRule, filepath.Join(projectDir, "src", "main", "java", "test", "Taint.java"): taintJava, - }) + }); err != nil { + return err + } + return testproject.WriteRuleTestFile(projectDir, ruleTestTemplate) } diff --git a/cli/internal/testmodel/testmodel_test.go b/cli/internal/testmodel/testmodel_test.go new file mode 100644 index 0000000000..5bb9cdc81e --- /dev/null +++ b/cli/internal/testmodel/testmodel_test.go @@ -0,0 +1,52 @@ +package testmodel + +import ( + "os" + "path/filepath" + "testing" + + "gopkg.in/yaml.v2" +) + +type ruleTests struct { + Tests []struct { + RuleID string `yaml:"rule-id"` + Positive []string `yaml:"positive"` + Negative []string `yaml:"negative"` + } `yaml:"tests"` +} + +type ruleFile struct { + Rules []struct { + ID string `yaml:"id"` + } `yaml:"rules"` +} + +// The rule-test.yaml template must point at the fixed rule that test model run applies. +func TestScaffoldWritesRuleTestForTheFixedRule(t *testing.T) { + dir := t.TempDir() + if err := Scaffold(dir); err != nil { + t.Fatalf("Scaffold: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "rule-test.yaml")) + if err != nil { + t.Fatal(err) + } + var tests ruleTests + if err := yaml.Unmarshal(data, &tests); err != nil { + t.Fatalf("rule-test.yaml is not valid YAML: %v", err) + } + + var rule ruleFile + if err := yaml.Unmarshal(fixedRule, &rule); err != nil || len(rule.Rules) != 1 { + t.Fatalf("fixed rule: %v, %d rules", err, len(rule.Rules)) + } + want := fixedRuleFileName + "#" + rule.Rules[0].ID + if len(tests.Tests) != 1 || tests.Tests[0].RuleID != want { + t.Fatalf("rule-test.yaml tests = %+v, want one test of %s", tests.Tests, want) + } + if len(tests.Tests[0].Positive) != 0 || len(tests.Tests[0].Negative) != 0 { + t.Errorf("the template has samples: %+v", tests.Tests[0]) + } +} diff --git a/cli/internal/testproject/rule_test_file.go b/cli/internal/testproject/rule_test_file.go new file mode 100644 index 0000000000..4ff48bdc85 --- /dev/null +++ b/cli/internal/testproject/rule_test_file.go @@ -0,0 +1,25 @@ +package testproject + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/seqra/opentaint/internal/utils" +) + +// RuleTestFileName is the file in the test project root that declares the test samples. +const RuleTestFileName = "rule-test.yaml" + +// WriteRuleTestFile writes the rule-test.yaml template into projectDir. An existing file holds +// the samples of the user, so it is kept. +func WriteRuleTestFile(projectDir string, template []byte) error { + path := filepath.Join(projectDir, RuleTestFileName) + if _, err := os.Stat(path); err == nil { + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("check %s: %w", path, err) + } + return utils.WriteFiles(map[string][]byte{path: template}) +} diff --git a/cli/internal/testproject/rule_test_file_test.go b/cli/internal/testproject/rule_test_file_test.go new file mode 100644 index 0000000000..2038fbc791 --- /dev/null +++ b/cli/internal/testproject/rule_test_file_test.go @@ -0,0 +1,40 @@ +package testproject + +import ( + "os" + "path/filepath" + "testing" +) + +func TestWriteRuleTestFileWritesTemplate(t *testing.T) { + dir := t.TempDir() + if err := WriteRuleTestFile(dir, []byte("tests: []\n")); err != nil { + t.Fatalf("WriteRuleTestFile: %v", err) + } + got, err := os.ReadFile(filepath.Join(dir, RuleTestFileName)) + if err != nil { + t.Fatal(err) + } + if string(got) != "tests: []\n" { + t.Errorf("rule-test.yaml = %q, want the template", got) + } +} + +func TestWriteRuleTestFileKeepsExistingSamples(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, RuleTestFileName) + existing := "tests:\n - rule-id: my.yaml#my\n" + if err := os.WriteFile(path, []byte(existing), 0o644); err != nil { + t.Fatal(err) + } + if err := WriteRuleTestFile(dir, []byte("tests: []\n")); err != nil { + t.Fatalf("WriteRuleTestFile: %v", err) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(got) != existing { + t.Errorf("rule-test.yaml = %q, want the existing file kept", got) + } +} diff --git a/cli/internal/testrule/example/rule-test.yaml b/cli/internal/testrule/example/rule-test.yaml new file mode 100644 index 0000000000..bc51fc4cc5 --- /dev/null +++ b/cli/internal/testrule/example/rule-test.yaml @@ -0,0 +1,9 @@ +# Add one entry for each rule under test. The rule-id is the path of the rule file relative to +# its ruleset root and the ID of the rule. Samples are entry-point methods given as # +# +# - rule-id: java/security/my-rule.yaml#my-rule +# positive: +# - test.MySamples#positive +# negative: +# - test.MySamples#negative +tests: [] diff --git a/cli/internal/testrule/testrule.go b/cli/internal/testrule/testrule.go index e72405c6af..d0789be1da 100644 --- a/cli/internal/testrule/testrule.go +++ b/cli/internal/testrule/testrule.go @@ -4,6 +4,7 @@ import ( _ "embed" "path/filepath" + "github.com/seqra/opentaint/internal/testproject" "github.com/seqra/opentaint/internal/utils" ) @@ -16,6 +17,9 @@ var genericSource []byte //go:embed example/rules/java/lib/test/generic-sink.yaml var genericSink []byte +//go:embed example/rule-test.yaml +var ruleTestTemplate []byte + const ( markersDir = "test-rules" genericSourceRule = "java/lib/test/generic-source.yaml" @@ -23,9 +27,12 @@ const ( ) func Scaffold(projectDir string) error { - return utils.WriteFiles(map[string][]byte{ + if err := utils.WriteFiles(map[string][]byte{ filepath.Join(projectDir, "src", "main", "java", "test", "Taint.java"): taintJava, filepath.Join(projectDir, markersDir, filepath.FromSlash(genericSourceRule)): genericSource, filepath.Join(projectDir, markersDir, filepath.FromSlash(genericSinkRule)): genericSink, - }) + }); err != nil { + return err + } + return testproject.WriteRuleTestFile(projectDir, ruleTestTemplate) } diff --git a/cli/internal/testrule/testrule_test.go b/cli/internal/testrule/testrule_test.go new file mode 100644 index 0000000000..6f62af9520 --- /dev/null +++ b/cli/internal/testrule/testrule_test.go @@ -0,0 +1,30 @@ +package testrule + +import ( + "os" + "path/filepath" + "testing" + + "gopkg.in/yaml.v2" +) + +func TestScaffoldWritesAnEmptyRuleTest(t *testing.T) { + dir := t.TempDir() + if err := Scaffold(dir); err != nil { + t.Fatalf("Scaffold: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "rule-test.yaml")) + if err != nil { + t.Fatal(err) + } + var doc struct { + Tests *[]any `yaml:"tests"` + } + if err := yaml.Unmarshal(data, &doc); err != nil { + t.Fatalf("rule-test.yaml is not valid YAML: %v", err) + } + if doc.Tests == nil || len(*doc.Tests) != 0 { + t.Errorf("rule-test.yaml must hold an empty tests list, got:\n%s", data) + } +} From 7faeaca01c9177862ca7dda8f4bd35376d91b742 Mon Sep 17 00:00:00 2001 From: Gr-i-niy Date: Tue, 3 Nov 2026 10:08:23 +0000 Subject: [PATCH 4/4] fix(cli): show the new group's help for a deprecated group opentaint approximation --help and opentaint test approximation --help printed an empty usage: cobra does not list the subcommands of a group when they are deprecated, and all of them are. The help of a deprecated group is now the help of the group that replaces it, after the deprecation notice. The subcommands keep their own help. --- cli/cmd/deprecated.go | 20 ++++++++++++++++++++ cli/cmd/deprecated_test.go | 30 ++++++++++++++++++++++++++++++ cli/cmd/model.go | 6 +----- cli/cmd/test.go | 6 +----- 4 files changed, 52 insertions(+), 10 deletions(-) diff --git a/cli/cmd/deprecated.go b/cli/cmd/deprecated.go index 130ceacaa1..c43aef86d5 100644 --- a/cli/cmd/deprecated.go +++ b/cli/cmd/deprecated.go @@ -23,6 +23,26 @@ func deprecatedCommand(cmd *cobra.Command, oldName, newPath string) *cobra.Comma return alias } +// deprecatedGroup returns a hidden command group under the old name of group. Its subcommands are +// deprecated too, so cobra lists none of them. The help of the old group is therefore the help of +// group, after the deprecation notice. +func deprecatedGroup(group *cobra.Command, oldName, newPath string) *cobra.Command { + alias := &cobra.Command{ + Use: oldName, + Short: group.Short, + Deprecated: "use '" + newPath + "' instead", + } + alias.SetHelpFunc(func(c *cobra.Command, args []string) { + if c != alias { + // The subcommands inherit this function. They keep the help of the root. + c.Root().HelpFunc()(c, args) + return + } + _ = group.Help() + }) + return alias +} + // addDeprecatedFlagAlias registers oldName as a hidden, deprecated name of the flag newName. // Both names set the same value, so they can be mixed on one command line. func addDeprecatedFlagAlias(flags *pflag.FlagSet, newName, oldName string) { diff --git a/cli/cmd/deprecated_test.go b/cli/cmd/deprecated_test.go index 959fe6e1b7..45d1cb73f2 100644 --- a/cli/cmd/deprecated_test.go +++ b/cli/cmd/deprecated_test.go @@ -1,6 +1,7 @@ package cmd import ( + "bytes" "reflect" "strings" "testing" @@ -78,3 +79,32 @@ func TestModelFlagsOnAnalyzerCommands(t *testing.T) { } } } + +func TestDeprecatedGroupHelpIsTheHelpOfTheNewGroup(t *testing.T) { + var buf bytes.Buffer + rootCmd.SetOut(&buf) + t.Cleanup(func() { rootCmd.SetOut(nil) }) + + for _, tc := range []struct { + old []string + want string + }{ + {[]string{"approximation"}, "opentaint model [command]"}, + {[]string{"test", "approximation"}, "opentaint test model [command]"}, + // A subcommand of a deprecated group keeps its own help. + {[]string{"approximation", "init"}, "opentaint approximation init "}, + {[]string{"test", "approximation", "run"}, "opentaint test approximation run "}, + } { + t.Run(strings.Join(tc.old, " "), func(t *testing.T) { + command, _, err := rootCmd.Find(tc.old) + if err != nil { + t.Fatal(err) + } + buf.Reset() + command.HelpFunc()(command, nil) + if !strings.Contains(buf.String(), tc.want) { + t.Errorf("help does not contain %q:\n%s", tc.want, buf.String()) + } + }) + } +} diff --git a/cli/cmd/model.go b/cli/cmd/model.go index ab90d278e0..c4d362567e 100644 --- a/cli/cmd/model.go +++ b/cli/cmd/model.go @@ -67,11 +67,7 @@ func init() { modelInitCmd.Flags().StringArrayVar(&initModelDeps, "dependency", nil, "Compile-only Maven dependency coordinates the models are written against (repeatable)") - approximationCmd := &cobra.Command{ - Use: "approximation", - Short: modelCmd.Short, - Deprecated: "use 'opentaint model' instead", - } + approximationCmd := deprecatedGroup(modelCmd, "approximation", "opentaint model") approximationCmd.AddCommand(deprecatedCommand(modelInitCmd, "init", "opentaint model init")) rootCmd.AddCommand(approximationCmd) } diff --git a/cli/cmd/test.go b/cli/cmd/test.go index 612ad51869..f9ca54c225 100644 --- a/cli/cmd/test.go +++ b/cli/cmd/test.go @@ -31,11 +31,7 @@ var testModelCmd = &cobra.Command{ } // testApproximationCmd is the deprecated name of testModelCmd. -var testApproximationCmd = &cobra.Command{ - Use: "approximation", - Short: testModelCmd.Short, - Deprecated: "use 'opentaint test model' instead", -} +var testApproximationCmd = deprecatedGroup(testModelCmd, "approximation", "opentaint test model") func init() { rootCmd.AddCommand(testCmd)