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..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" @@ -32,21 +34,22 @@ 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 Java or Kotlin project into a 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: `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 Arguments: - project - Path to a project to compile (required) -`, + 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] @@ -72,25 +75,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 +101,7 @@ Arguments: }); err == nil { out.Blank() printCompileSummary(absOutputProjectModelPath) - suggest("To scan project run", utils.BuildScanCommandFromCompile(projectRoot, absOutputProjectModelPath)) + suggest(compileNextStep(absProjectRoot, projectRoot, absOutputProjectModelPath)) } else { out.InteractiveBlank() failWith(1, fmt.Sprintf("Native compile has failed: %s", err), dockerCompileSuggestion()) @@ -109,10 +112,10 @@ 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 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 { @@ -190,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 fa2a4a613f..134d2cd3d0 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") @@ -52,8 +47,8 @@ needed to compile ahead of time or to see compilation errors on their own.`, 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/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/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..9150c740dc --- /dev/null +++ b/cli/cmd/exit_codes.go @@ -0,0 +1,42 @@ +package cmd + +import ( + "fmt" + "strings" + + "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, capitalize(analyzer.ExitMessage(code))) + if code == analyzer.ExitException && exceptionNote != "" { + rows += " " + exceptionNote + } + } + 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 + ` + 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..71b3e405a5 100644 --- a/cli/cmd/health.go +++ b/cli/cmd/health.go @@ -27,15 +27,12 @@ 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 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 -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. - -The exit code is non-zero when any selected component is missing.`, +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() @@ -46,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") } @@ -105,6 +102,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/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..c4d362567e 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") @@ -61,7 +55,8 @@ nothing about the project under analysis, decide what the models compile against } 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]))) }, } @@ -72,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/project.go b/cli/cmd/project.go index 60e31d5d20..aa2c714a9c 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,11 @@ 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. - -This command generates a project model, automatically detecting dependencies and project structure. -Additional packages have to be specified to enhance the generated configuration. - -Examples: - # Classpath analysis - opentaint project --output ./project-model --source-root /path/to/source \ - --classpath /path/to/app.jar --package com.example`, + Short: "Create a project model from precompiled JARs or classes", + 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). @@ -269,12 +264,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 +277,15 @@ Examples: func init() { rootCmd.AddCommand(projectCmd) - projectCmd.Flags().StringVarP(&OutputDir, "output", "o", "", "Output directory for project.yaml") + 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", "", "Source root directory") + projectCmd.Flags().StringVar(&SourceRoot, "source-root", "", "Project source directory (required)") _ = 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{}, "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 entries (classes or JAR files)") + 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 project model") - projectCmd.Flags().StringVar(&ProjectLogFile, "log-file", "", "Path to the log file (default: /logs/.log)") + 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", "", logFileUsage) } diff --git a/cli/cmd/prune.go b/cli/cmd/prune.go index a4da4e6b84..30ed965964 100644 --- a/cli/cmd/prune.go +++ b/cli/cmd/prune.go @@ -57,24 +57,10 @@ func resolveCategories() (utils.PruneCategory, error) { var pruneCmd = &cobra.Command{ Use: "prune", - Short: "Remove stale downloaded artifacts from ~/.opentaint", - Long: `Remove stale downloaded artifacts from the local cache (~/.opentaint). - -Identifies artifacts that are no longer needed: -- Old versions of analyzer JARs, autobuilder JARs, and rules -- Downloaded JDK/JRE versions that don't match the current version -- Cached project models - -Use category flags to prune selectively: - --artifacts Stale analyzer and autobuilder JARs - --rules Stale rules directories - --jdk Old JDK/JRE versions - --models Cached project models - --logs Project log files - --install Install-tier lib and JRE artifacts (requires re-download) - -Without category flags, prunes: artifacts + rules + jdk + models. -With --all: prunes everything including logs and install-tier.`, + Short: "Remove stale downloads, cached project models, and logs", + Long: `Remove stale downloads, cached project models, and logs from ~/.opentaint + +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 { @@ -84,23 +70,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 +116,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") + } }, } @@ -153,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 ff95db860f..e0898f4779 100644 --- a/cli/cmd/pull.go +++ b/cli/cmd/pull.go @@ -17,15 +17,8 @@ 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 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). @@ -40,7 +33,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 +43,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..b187f0e6e3 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 Java and Kotlin projects. It follows tainted data from untrusted sources to dangerous sinks + +Quick start: + 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, @@ -116,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 6df354bae9..90c10a48d5 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,24 @@ 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 Java or Kotlin project for vulnerabilities", Args: cobra.MaximumNArgs(1), - Long: `This command automatically detects Java/Kotlin build systems, builds the project, and analyzes it + Long: `Detect the build system of a Java or Kotlin project, compile the project, and analyze it Arguments: - source-path - Path to the project sources (default: current directory) + [source-path] Root directory of the project (default: the current directory) -Use --project-model to scan a pre-compiled project model instead of compiling from sources. -`, +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 with your own rules in addition to the built-in ones + opentaint scan ./app --ruleset builtin --ruleset ./rules -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 ./app --timeout 30m --max-memory 16G`, Annotations: map[string]string{"PrintConfig": "true"}, Run: func(cmd *cobra.Command, args []string) { if scanFlags.DebugRunAnalysisOnSelectedEntryPoints != "" { @@ -94,7 +104,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) } @@ -116,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, "Filter active rules by 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, "Timeout for analysis") + 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"}, "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 file or directory, 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-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"}, "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().Int64Var(&globals.Config.Scan.CodeFlowLimit, "code-flow-limit", 0, "Maximum number of code flows to include in the report (0 = unlimited)") + 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 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) @@ -148,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. @@ -176,7 +188,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 +206,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 +299,7 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) { } if cfg.DryRun { - runDryRun("Compilation and analysis") + runDryRun("the build and scan") return } @@ -300,25 +312,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 +349,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 +360,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 +416,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 +424,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 +482,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 +525,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 +596,7 @@ func resolveScanPlan(cfg ScanConfig, absUserProjectRoot string) scanPlan { } else { out.Error("Another scan is currently analyzing this project") } - suggest("To scan an existing model instead", utils.NewScanCommand("").WithProjectModel("").Build()) + suggest("To scan an existing model instead, run:", utils.NewScanCommand("").WithProjectModel("").Build()) os.Exit(1) } if lockErr != nil { diff --git a/cli/cmd/suggest.go b/cli/cmd/suggest.go index 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..929a354fc9 100644 --- a/cli/cmd/summary.go +++ b/cli/cmd/summary.go @@ -9,14 +9,18 @@ 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 the number of findings by severity and the rules that found them. With --show-findings, also list the findings Arguments: - sarif - Path to a sarif file -`, + 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/**" + + # 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 { @@ -39,6 +43,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,17 +69,17 @@ var summaryCodeFlow string func init() { rootCmd.AddCommand(summaryCmd) - summaryCmd.Flags().BoolVar(&showFindings, "show-findings", false, "Show all issues from Sarif file") - 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 of this SARIF level: error, warning, note, none (repeatable)") - summaryCmd.Flags().StringArrayVar(&summaryRuleIDs, "rule-id", nil, "Show only findings for this rule: full id, leaf name, or glob (repeatable)") - summaryCmd.Flags().StringArrayVar(&summaryFingerprints, "partial-fingerprint", nil, "Show only findings whose partial fingerprint starts with this value (git-hash style, repeatable)") - summaryCmd.Flags().StringVar(&summaryFingerprintKey, "partial-fingerprint-key", "", "partialFingerprints key matched by --partial-fingerprint (default vulnerabilityWithTraceHash/v1)") - summaryCmd.Flags().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(&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. @@ -137,7 +148,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..f9ca54c225 100644 --- a/cli/cmd/test.go +++ b/cli/cmd/test.go @@ -9,25 +9,29 @@ 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 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 "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", + 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 of dataflow models. A model test checks that a model passes taint from source to sink`, } // 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) @@ -36,21 +40,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().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 b22181be31..ea56c76485 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,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 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 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 -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 +Arguments: + Parent directory of the sinks and sources projects -Positive and negative samples are specified via rule-test.yaml. - -Use --dependency to add compile-only Maven dependencies for the samples.`, +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 { @@ -50,28 +46,33 @@ Use --dependency to add compile-only Maven dependencies for the samples.`, if err := testrule.Scaffold(dir); err != nil { out.Fatalf("Failed to scaffold rule test project: %s", err) } - fmt.Printf("Rule test project (%s) initialized at %s\n", kind, dir) + out.Printf("Rule test project (%s) initialized at %s", kind, dir) + } + 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, 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))}, + ) } + 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. - -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 - -Positive and negative samples are specified via rule-test.yaml. + 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 -The model under test is supplied separately at test time with ---dataflow-models. +Arguments: + Directory of the new test project -Use --dependency to add compile-only Maven dependencies for the samples.`, +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 { @@ -80,7 +81,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, 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/cmd/test_model_run.go b/cli/cmd/test_model_run.go index 88eabec863..86732e71ab 100644 --- a/cli/cmd/test_model_run.go +++ b/cli/cmd/test_model_run.go @@ -18,13 +18,17 @@ 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. + 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 -A built-in source-to-sink harness rule is applied automatically; positive samples reference the -model-rule.yaml rule with id "model-rule". +Arguments: + Test project compiled with "opentaint compile" + +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: ` # 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-*") @@ -37,6 +41,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..bc8f21e2cc 100644 --- a/cli/cmd/test_rule_reachability.go +++ b/cli/cmd/test_rule_reachability.go @@ -8,12 +8,19 @@ 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 report that shows why the rule does or does not fire. The library source and sink rules that the rule refers to are included -Referenced library source and sink rules are collected and analyzed automatically.`, +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) + +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: ` # 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) { @@ -38,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 3f78deb108..e1d23124b6 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" @@ -24,15 +25,22 @@ 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. + 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 + +Arguments: + Test project compiled with "opentaint compile" + +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: ` # 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{ label: "Rule tests", + passedLine: "All rule tests passed.", tempDir: "opentaint-test-rules-*", rulesets: testRulesRuleset, outputDir: testRulesOutputDir, @@ -48,6 +56,7 @@ compiled project model. type testProjectOptions struct { label string + passedLine string tempDir string rulesets []string outputDir string @@ -104,7 +113,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 +133,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 +142,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, "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 1d3a5c1bda..be12d772a3 100644 --- a/cli/cmd/update.go +++ b/cli/cmd/update.go @@ -20,13 +20,12 @@ 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 opentaint to the latest release or to [version]. Downgrades are not supported -This command detects how opentaint was installed and provides appropriate -instructions for package manager installations. For binary installations, -it performs an in-place update. +Arguments: + [version] Release to update to, such as 1.2.3 (default: the latest release) -Only upgrades are supported — downgrading to an older version is refused.`, +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 @@ -35,11 +34,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 +74,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 +93,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 +105,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 +131,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") }, } @@ -140,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") } diff --git a/cli/internal/analyzer/exit.go b/cli/internal/analyzer/exit.go index 6d38e75992..9e97afbdad 100644 --- a/cli/internal/analyzer/exit.go +++ b/cli/internal/analyzer/exit.go @@ -37,9 +37,9 @@ func ExitMessage(code int) string { case ExitConfigError: return "project configuration error" case ExitTimeout: - return "analysis timed out — try increasing --timeout or --max-memory" + return "analysis timed out: try increasing --timeout or --max-memory" case ExitOOM: - return "out of memory — try increasing --max-memory (e.g. --max-memory 16G)" + return "out of memory: try increasing --max-memory (e.g. --max-memory 16G)" case ExitException: return "unhandled analyzer exception" default: diff --git a/cli/internal/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) + } +}