diff --git a/.github/workflows/release-cli.yaml b/.github/workflows/release-cli.yaml index afed35662..9f5104c35 100644 --- a/.github/workflows/release-cli.yaml +++ b/.github/workflows/release-cli.yaml @@ -169,8 +169,29 @@ jobs: mkdir -p lib/rules tar -xzf /tmp/opentaint-rules.tar.gz -C lib/rules + cp internal/globals/versions.yaml lib/.versions + if [ ! -f lib/.versions ]; then + echo "::error::lib/.versions was not created; the bundled release would ship without a version marker" >&2 + exit 1 + fi + echo "Bundled artifacts:" ls -la lib/ + - + name: Set up JDK for test-util jar + if: ${{ steps.release_version.outputs.status == 'succeeded' }} + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + - + name: Build and embed test-util jar + if: ${{ steps.release_version.outputs.status == 'succeeded' }} + run: | + set -euo pipefail + (cd core && ./gradlew :opentaint-sast-test-util:jar) + (cd cli && go generate ./...) + cp core/opentaint-sast-test-util/build/libs/opentaint-sast-test-util.jar cli/lib/ - name: Run GoReleaser if: ${{ steps.release_version.outputs.status == 'succeeded' }} diff --git a/.gitignore b/.gitignore index 27bf36d6d..8ec53bdf4 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ config.local.* **/.gradle **/build +/install/ +core/**/bin/ # Ignore all hidden files and directories .* diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..c1664478c --- /dev/null +++ b/Makefile @@ -0,0 +1,70 @@ +MAKE ?= make +GRADLEW := $(CURDIR)/core/gradlew +INSTALL ?= install + +PREFIX ?= /usr/local +BINDIR ?= $(PREFIX)/bin +LIBDIR ?= $(PREFIX)/lib + +CORE_DIR := core +CLI_DIR := cli + +CLI_BINARY_NAME := opentaint +CLI_DEV_BINARY_NAME := opentaint-dev +ANALYZER_TASK := :projectAnalyzerJar +AUTOBUILDER_TASK := opentaint-jvm-autobuilder:projectAutoBuilderJar +TEST_UTIL_TASK := :opentaint-sast-test-util:jar + +ANALYZER_JAR := $(CORE_DIR)/build/libs/opentaint-project-analyzer.jar +AUTOBUILDER_JAR := $(CORE_DIR)/opentaint-jvm-autobuilder/build/libs/opentaint-project-auto-builder.jar +TEST_UTIL_JAR := $(CORE_DIR)/opentaint-sast-test-util/build/libs/opentaint-sast-test-util.jar +RULES_SRC := rules/ruleset +INSTALLED_ANALYZER_JAR := $(LIBDIR)/$(notdir $(ANALYZER_JAR)) +INSTALLED_AUTOBUILDER_JAR := $(LIBDIR)/$(notdir $(AUTOBUILDER_JAR)) +INSTALLED_RULES_DIR := $(LIBDIR)/rules +INSTALLED_CLI_BINARY := $(BINDIR)/$(CLI_BINARY_NAME) +INSTALLED_DEV_BINARY := $(BINDIR)/$(CLI_DEV_BINARY_NAME) + +.PHONY: all core projectAnalyzerJar core/autobuilder core/opentaint-sast-test-util cli install clean + +all: core cli + +core: + cd $(CORE_DIR) && $(GRADLEW) $(ANALYZER_TASK) $(AUTOBUILDER_TASK) $(TEST_UTIL_TASK) + +projectAnalyzerJar: + cd $(CORE_DIR) && $(GRADLEW) $(ANALYZER_TASK) + +core/autobuilder: + cd $(CORE_DIR) && $(GRADLEW) $(AUTOBUILDER_TASK) + +core/opentaint-sast-test-util: + cd $(CORE_DIR) && $(GRADLEW) $(TEST_UTIL_TASK) + +cli: core/opentaint-sast-test-util + $(MAKE) -C $(CLI_DIR) build + +install: core + mkdir -p $(BINDIR) $(LIBDIR) + $(MAKE) -C $(CLI_DIR) install PREFIX=$(PREFIX) BINDIR=$(abspath $(BINDIR)) + $(INSTALL) -m 0644 $(ANALYZER_JAR) $(INSTALLED_ANALYZER_JAR) + $(INSTALL) -m 0644 $(AUTOBUILDER_JAR) $(INSTALLED_AUTOBUILDER_JAR) + $(INSTALL) -m 0644 $(TEST_UTIL_JAR) $(LIBDIR)/$(notdir $(TEST_UTIL_JAR)) + rm -rf $(INSTALLED_RULES_DIR) + mkdir -p $(INSTALLED_RULES_DIR) + cp -R $(RULES_SRC)/. $(INSTALLED_RULES_DIR)/ + printf '%s\n' \ + '#!/bin/sh' \ + 'set -eu' \ + 'if command -v realpath >/dev/null 2>&1; then SELF=$$(realpath "$$0"); else SELF=$$0; fi' \ + 'BIN_DIR=$$(CDPATH= cd -- "$$(dirname -- "$$SELF")" && pwd -P)' \ + 'PREFIX_DIR=$$(CDPATH= cd -- "$$BIN_DIR/.." && pwd)' \ + 'LIB_DIR="$$PREFIX_DIR/lib"' \ + 'exec "$$BIN_DIR/$(CLI_BINARY_NAME)" --experimental --analyzer-jar "$$LIB_DIR/$(notdir $(ANALYZER_JAR))" --autobuilder-jar "$$LIB_DIR/$(notdir $(AUTOBUILDER_JAR))" "$$@"' \ + > $(INSTALLED_DEV_BINARY) + chmod 0755 $(INSTALLED_DEV_BINARY) + $(INSTALLED_CLI_BINARY) pull + +clean: + $(MAKE) -C $(CLI_DIR) clean + cd $(CORE_DIR) && $(GRADLEW) clean diff --git a/README.md b/README.md index ae3444225..3fea7ddf8 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,16 @@ brew install --cask seqra/tap/opentaint irm https://opentaint.org/install.ps1 | iex ``` +**Install via npm (Linux/macOS/Windows):** +```bash +npm install -g @seqra/opentaint +``` + +**Or run instantly with npx — no install required (needs Node.js):** +```bash +npx @seqra/opentaint scan +``` + **Scan your project:** ```bash opentaint scan @@ -141,6 +151,24 @@ For more options, see [Installation](docs/README.md#installation) and [Usage](do --- +## AI Agent Workflows + +OpenTaint includes agent skills that turn static analysis into an end-to-end application-security workflow. Install them with: + +```bash +npx skills add https://github.com/seqra/opentaint +``` + +The `appsec-agent` skill orchestrates a full project assessment: build the project, run OpenTaint, discover the attack surface, add targeted rules, model missing library data flows, triage findings, and optionally generate dynamic proof-of-concept checks for confirmed vulnerabilities. + +Included skills cover the common security-analysis loop: + +- **Scan and triage:** `build-project`, `run-scan`, `analyze-findings`, `generate-poc` +- **Coverage expansion:** `triage-dependencies`, `discover-attack-surface`, `create-test-project`, `create-rule`, `assemble-lib-rules` +- **Dataflow modeling:** `analyze-external-methods`, `create-pass-through-approximation`, `create-dataflow-approximation`, `debug-rule`, `report-analyzer-issue` + +--- + ## Documentation Full guides — installation, usage, configuration, CI/CD integration: **[Documentation](docs/README.md)**. diff --git a/cli/.gitignore b/cli/.gitignore index 5a99dd502..8329bd621 100644 --- a/cli/.gitignore +++ b/cli/.gitignore @@ -3,7 +3,7 @@ bin/ !npm/bin/ dist/ dist-npm/ -lib/ +/lib/ /opentaint # Operating system files diff --git a/cli/Makefile b/cli/Makefile new file mode 100644 index 000000000..fca2d5733 --- /dev/null +++ b/cli/Makefile @@ -0,0 +1,27 @@ +GO ?= go + +BINARY_NAME ?= opentaint +BUILD_DIR ?= bin +BINARY_PATH := $(BUILD_DIR)/$(BINARY_NAME) + +PREFIX ?= /usr/local +BINDIR ?= $(PREFIX)/bin +INSTALL_GOBIN := $(abspath $(BINDIR)) + +.PHONY: all generate build install clean + +all: build + +generate: + $(GO) generate ./... + +build: generate + mkdir -p $(BUILD_DIR) + $(GO) build -o $(BINARY_PATH) . + +install: build + mkdir -p $(INSTALL_GOBIN) + install -m 0755 $(BINARY_PATH) $(INSTALL_GOBIN)/$(BINARY_NAME) + +clean: + rm -f $(BINARY_PATH) diff --git a/cli/cmd/analyzer_inputs.go b/cli/cmd/analyzer_inputs.go new file mode 100644 index 000000000..3290a995f --- /dev/null +++ b/cli/cmd/analyzer_inputs.go @@ -0,0 +1,22 @@ +package cmd + +import ( + "github.com/seqra/opentaint/internal/utils/log" +) + +func addDataflowApproximations(b *AnalyzerBuilder, paths []string, analyzerJarPath, projectModelDir string) { + for _, approxPath := range paths { + absApproxPath := log.AbsPathOrExit(approxPath, "dataflow-approximations") + compiledPath, err := compileApproximationsIfNeeded(absApproxPath, analyzerJarPath, projectModelDir) + if err != nil { + out.Fatalf("Approximation compilation failed: %s", err) + } + b.AddDataflowApproximations(compiledPath) + } +} + +func addPassthroughApproximations(b *AnalyzerBuilder, paths []string) { + for _, passthrough := range paths { + b.AddPassthroughApproximations(log.AbsPathOrExit(passthrough, "passthrough-approximations")) + } +} diff --git a/cli/cmd/artifacts.go b/cli/cmd/artifacts.go index 9d662b0ce..e033a90a4 100644 --- a/cli/cmd/artifacts.go +++ b/cli/cmd/artifacts.go @@ -4,8 +4,36 @@ import ( "errors" "fmt" "os" + + "github.com/seqra/opentaint/internal/globals" + "github.com/seqra/opentaint/internal/utils" ) +func ensureArtifactJar(def globals.ArtifactDef) (string, error) { + path, err := utils.ResolveJarPath(def) + if err != nil { + return "", fmt.Errorf("failed to construct path to the %s: %w", def.Kind(), err) + } + if def.Override != "" { + return path, nil + } + + if err := ensureArtifactAvailable(def.Kind(), def.Version, path, func() error { + return utils.DownloadGithubReleaseAsset(globals.Config.Owner, globals.Config.Repo, def.Version, def.AssetName, path, globals.Config.Github.Token, globals.Config.SkipVerify, out) + }); err != nil { + return "", err + } + return path, nil +} + +func ensureAnalyzerAvailable() (string, error) { + return ensureArtifactJar(globals.ArtifactByKind("analyzer")) +} + +func ensureAutobuilderAvailable() (string, error) { + return ensureArtifactJar(globals.ArtifactByKind("autobuilder")) +} + func ensureArtifactAvailable(name, version, artifactPath string, download func() error) error { if _, err := os.Stat(artifactPath); err == nil { return nil diff --git a/cli/cmd/command_builder.go b/cli/cmd/command_builder.go index aa353a07b..6b4fd245e 100644 --- a/cli/cmd/command_builder.go +++ b/cli/cmd/command_builder.go @@ -42,26 +42,28 @@ func NewAutobuilderBuilder() *AutobuilderBuilder { type AnalyzerBuilder struct { *BaseCommandBuilder - projectPath string - outputDir string - sarifFileName string - sarifCodeFlowLimit int64 - sarifToolVersion string - sarifToolSemanticVersion string - sarifUriBase string - semgrepCompatibility bool - partialFingerprints bool - ifdsAnalysisTimeout int64 - severities []string - ruleSetPaths []string - ruleLoadTracePath string - jarPath string - maxMemory string - ruleIDs []string - approximationsConfig []string - dataflowApproximations []string - trackExternalMethods bool - debugFactReachabilitySarif bool + projectPath string + outputDir string + sarifFileName string + sarifCodeFlowLimit int64 + sarifToolVersion string + sarifToolSemanticVersion string + sarifUriBase string + semgrepCompatibility bool + partialFingerprints bool + ifdsAnalysisTimeout int64 + severities []string + ruleSetPaths []string + ruleLoadTracePath string + jarPath string + maxMemory string + ruleIDs []string + passthroughApproximations []string + dataflowApproximations []string + trackExternalMethods bool + debugFactReachabilitySarif bool + runRuleTests bool + debugRunAnalysisOnSelectedEntryPoints string } func (a *AnalyzerBuilder) SetProject(projectPath string) *AnalyzerBuilder { @@ -144,8 +146,8 @@ func (a *AnalyzerBuilder) AddRuleID(ruleID string) *AnalyzerBuilder { return a } -func (a *AnalyzerBuilder) AddApproximationsConfig(configPath string) *AnalyzerBuilder { - a.approximationsConfig = append(a.approximationsConfig, configPath) +func (a *AnalyzerBuilder) AddPassthroughApproximations(path string) *AnalyzerBuilder { + a.passthroughApproximations = append(a.passthroughApproximations, path) return a } @@ -164,6 +166,16 @@ func (a *AnalyzerBuilder) EnableDebugFactReachabilitySarif() *AnalyzerBuilder { return a } +func (a *AnalyzerBuilder) SetDebugRunAnalysisOnSelectedEntryPoints(entryPoints string) *AnalyzerBuilder { + a.debugRunAnalysisOnSelectedEntryPoints = entryPoints + return a +} + +func (a *AnalyzerBuilder) EnableRunRuleTests() *AnalyzerBuilder { + a.runRuleTests = true + return a +} + func (a *AnalyzerBuilder) BuildNativeCommand() []string { // For native execution, create a temporary logs directory tempLogsDir, err := os.MkdirTemp("", "opentaint-*") @@ -237,8 +249,8 @@ func (a *AnalyzerBuilder) BuildNativeCommand() []string { flags = append(flags, "--semgrep-rule-id", ruleID) } - for _, configPath := range a.approximationsConfig { - flags = append(flags, "--approximations-config", configPath) + for _, passthrough := range a.passthroughApproximations { + flags = append(flags, "--passthrough-approximations", passthrough) } for _, approxPath := range a.dataflowApproximations { @@ -253,6 +265,14 @@ func (a *AnalyzerBuilder) BuildNativeCommand() []string { flags = append(flags, "--debug-fact-reachability-sarif") } + if a.debugRunAnalysisOnSelectedEntryPoints != "" { + flags = append(flags, "--debug-run-analysis-on-selected-entry-points", a.debugRunAnalysisOnSelectedEntryPoints) + } + + if a.runRuleTests { + flags = append(flags, "--debug-run-rule-tests") + } + return append(command, flags...) } diff --git a/cli/cmd/compile.go b/cli/cmd/compile.go index 531dbff3b..3d33c8d33 100644 --- a/cli/cmd/compile.go +++ b/cli/cmd/compile.go @@ -73,7 +73,7 @@ Arguments: } sb.FieldNode("Project", absProjectRoot). FieldNode("Output project model", absOutputProjectModelPath). - FieldNode("Autobuilder", utils.ArtifactDisplayVersion(globals.ArtifactByKind("autobuilder"), globals.Config.Autobuilder.JarPath)). + FieldNode("Autobuilder", utils.ArtifactVersionWithPath(globals.ArtifactByKind("autobuilder"))). Render() if DryRunCompile { @@ -88,11 +88,7 @@ Arguments: out.Fatalf("Native compile preparation failed: %s", err) } - compileJavaRunner := java.NewJavaRunner(). - WithSkipVerify(globals.Config.SkipVerify). - WithDebugOutput(out.DebugStream("Autobuilder")). - TrySystem(). - TrySpecificVersion(globals.Config.Java.Version) + compileJavaRunner := newAutobuilderJavaRunner() if _, err := compileJavaRunner.EnsureJava(); err != nil { out.Fatalf("Failed to resolve Java for compilation: %s", err) } @@ -119,25 +115,6 @@ func init() { compileCmd.Flags().StringVar(&CompileLogFile, "log-file", "", "Path to the log file (default: /logs/.log)") } -func ensureAutobuilderAvailable() (string, error) { - if globals.Config.Autobuilder.JarPath != "" { - return globals.Config.Autobuilder.JarPath, nil - } - - autobuilderJarPath, err := utils.GetAutobuilderJarPath(globals.Config.Autobuilder.Version) - if err != nil { - return "", fmt.Errorf("failed to construct path to the autobuilder: %w", err) - } - - if err = ensureArtifactAvailable("autobuilder", globals.Config.Autobuilder.Version, autobuilderJarPath, func() error { - return utils.DownloadGithubReleaseAsset(globals.Config.Owner, globals.Config.Repo, globals.Config.Autobuilder.Version, globals.AutobuilderAssetName, autobuilderJarPath, globals.Config.Github.Token, globals.Config.SkipVerify, out) - }); err != nil { - return "", err - } - - return autobuilderJarPath, nil -} - func compile(absProjectRoot, absOutputProjectModelPath, autobuilderJarPath string, javaRunner java.JavaRunner) error { if err := validation.ValidateCompileInputs(absProjectRoot, absOutputProjectModelPath); err != nil { return err diff --git a/cli/cmd/health.go b/cli/cmd/health.go new file mode 100644 index 000000000..0954bbbf4 --- /dev/null +++ b/cli/cmd/health.go @@ -0,0 +1,159 @@ +package cmd + +import ( + "fmt" + "os" + "strconv" + "strings" + + "github.com/seqra/opentaint/internal/globals" + "github.com/seqra/opentaint/internal/utils" + "github.com/spf13/cobra" +) + +var ( + healthAutobuilder bool + healthAnalyzer bool + healthRules bool + healthRuntime bool +) + +type healthComponent struct { + name string + version string + path string + present bool +} + +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. + +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.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runHealth() + }, +} + +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(&healthRuntime, "runtime", false, "Print only the Java runtime path") +} + +func runHealth() error { + var requested []string + if healthAutobuilder { + requested = append(requested, "autobuilder") + } + if healthAnalyzer { + requested = append(requested, "analyzer") + } + if healthRules { + requested = append(requested, "rules") + } + if healthRuntime { + requested = append(requested, "runtime") + } + if len(requested) == 0 { + requested = []string{"autobuilder", "analyzer", "rules", "runtime"} + } + + components := make([]healthComponent, 0, len(requested)) + for _, key := range requested { + components = append(components, resolveHealthComponent(key)) + } + + if len(requested) == 1 { + c := components[0] + if c.path != "" { + fmt.Println(c.path) + } + if !c.present { + if c.path == "" { + return fmt.Errorf("%s could not be resolved", c.name) + } + return fmt.Errorf("%s missing at %s", c.name, c.path) + } + return nil + } + + sb := out.Section("OpenTaint Health") + th := out.Theme() + var missing []string + for _, c := range components { + node := out.GroupItem(th.FieldKey.Render(c.name)) + if c.version != "" { + node.Child(th.FieldValue.Render(c.version)) + } + path := c.path + if !c.present { + path += " " + th.Error.Render("missing") + missing = append(missing, c.name) + } + node.Child(th.FieldValue.Render(path)) + sb.Child(node) + } + sb.Render() + if len(missing) > 0 { + return fmt.Errorf("missing components: %s", strings.Join(missing, ", ")) + } + return nil +} + +func resolveHealthComponent(key string) healthComponent { + switch key { + case "autobuilder", "analyzer": + return resolveJarComponent(key) + case "rules": + return resolveRulesComponent() + case "runtime": + return resolveRuntimeComponent() + default: + return healthComponent{name: key} + } +} + +func resolveJarComponent(kind string) healthComponent { + def := globals.ArtifactByKind(kind) + path, err := utils.ResolveJarPath(def) + version := utils.ArtifactVersion(def) + return healthComponent{def.Name, version, path, err == nil && utils.PathExists(path)} +} + +func resolveRulesComponent() healthComponent { + c := healthComponent{name: "Rules", version: utils.ArtifactVersion(globals.ArtifactByKind("rules"))} + path, err := utils.EnsureRulesPath(out) + c.path = path + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to prepare built-in rules: %s\n", err) + return c + } + c.present = utils.PathExists(path) + return c +} + +func resolveRuntimeComponent() healthComponent { + c := healthComponent{ + name: "Runtime", + version: "Java " + strconv.Itoa(globals.DefaultJavaVersion) + " (builtin)", + } + if jre := utils.FindCurrentManagedJRE(); jre != nil { + c.path = utils.JavaBinaryPath(jre.Path) + c.present = true + return c + } + if jre := utils.GetInstallJREPath(); jre != "" { + c.path = utils.JavaBinaryPath(jre) + } + return c +} diff --git a/cli/cmd/health_test.go b/cli/cmd/health_test.go new file mode 100644 index 000000000..fbc1ab5b6 --- /dev/null +++ b/cli/cmd/health_test.go @@ -0,0 +1,67 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/seqra/opentaint/internal/globals" + "github.com/seqra/opentaint/internal/utils" +) + +func TestResolveHealthComponentUsesAnalyzerJarOverride(t *testing.T) { + orig := globals.Config.Analyzer.JarPath + t.Cleanup(func() { globals.Config.Analyzer.JarPath = orig }) + globals.Config.Analyzer.JarPath = "/tmp/custom-analyzer.jar" + + c := resolveHealthComponent("analyzer") + if c.path != globals.Config.Analyzer.JarPath { + t.Fatalf("health analyzer path = %q, want override %q", c.path, globals.Config.Analyzer.JarPath) + } + if c.version != "custom" { + t.Fatalf("health analyzer version = %q, want %q", c.version, "custom") + } +} + +func TestResolveHealthComponentUsesAutobuilderJarOverride(t *testing.T) { + orig := globals.Config.Autobuilder.JarPath + t.Cleanup(func() { globals.Config.Autobuilder.JarPath = orig }) + globals.Config.Autobuilder.JarPath = "/tmp/custom-autobuilder.jar" + + c := resolveHealthComponent("autobuilder") + if c.path != globals.Config.Autobuilder.JarPath { + t.Fatalf("health autobuilder path = %q, want override %q", c.path, globals.Config.Autobuilder.JarPath) + } +} + +func TestResolveRuntimeComponentIgnoresSystemJava(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + + c := resolveHealthComponent("runtime") + if c.present { + t.Fatalf("runtime present = true with no managed JRE; health must not report a runtime the analyzer won't use (path %q)", c.path) + } +} + +func TestResolveRuntimeComponentFindsManagedJRE(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + jreBin := filepath.Join(home, ".opentaint", "install", "jre", "bin") + if err := os.MkdirAll(jreBin, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(jreBin, "java"), []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + if err := utils.WriteInstallVersionMarker(); err != nil { + t.Fatal(err) + } + + c := resolveHealthComponent("runtime") + if !c.present { + t.Fatalf("runtime present = false, want true with managed JRE at %s", jreBin) + } + if want := filepath.Join(jreBin, "java"); c.path != want { + t.Errorf("runtime path = %q, want %q", c.path, want) + } +} diff --git a/cli/cmd/java_runners.go b/cli/cmd/java_runners.go new file mode 100644 index 000000000..560e342e8 --- /dev/null +++ b/cli/cmd/java_runners.go @@ -0,0 +1,22 @@ +package cmd + +import ( + "github.com/seqra/opentaint/internal/globals" + "github.com/seqra/opentaint/internal/utils/java" +) + +func newAnalyzerJavaRunner() java.JavaRunner { + return java.NewJavaRunner(). + WithSkipVerify(globals.Config.SkipVerify). + WithDebugOutput(out.DebugStream("Analyzer")). + WithImageType(java.AdoptiumImageJRE). + TrySpecificVersion(globals.DefaultJavaVersion) +} + +func newAutobuilderJavaRunner() java.JavaRunner { + return java.NewJavaRunner(). + WithSkipVerify(globals.Config.SkipVerify). + WithDebugOutput(out.DebugStream("Autobuilder")). + TrySystem(). + TrySpecificVersion(globals.Config.Java.Version) +} diff --git a/cli/cmd/project.go b/cli/cmd/project.go index 9b7564ad0..124111c1f 100644 --- a/cli/cmd/project.go +++ b/cli/cmd/project.go @@ -121,14 +121,8 @@ func (c *JavaAutobuilderConfig) validate() error { } func (c *JavaAutobuilderConfig) runAutobuilder() error { - autobuilderJarPath, err := utils.GetAutobuilderJarPath(globals.Config.Autobuilder.Version) + autobuilderJarPath, err := ensureAutobuilderAvailable() if err != nil { - return fmt.Errorf("failed to construct path to the autobuilder: %w", err) - } - - if err = ensureArtifactAvailable("autobuilder", globals.Config.Autobuilder.Version, autobuilderJarPath, func() error { - return utils.DownloadGithubReleaseAsset(globals.Config.Owner, globals.Config.Repo, globals.Config.Autobuilder.Version, globals.AutobuilderAssetName, autobuilderJarPath, globals.Config.Github.Token, globals.Config.SkipVerify, out) - }); err != nil { return err } diff --git a/cli/cmd/pull.go b/cli/cmd/pull.go index e67c03251..ff95db860 100644 --- a/cli/cmd/pull.go +++ b/cli/cmd/pull.go @@ -76,6 +76,10 @@ When bundled artifacts are present (from a release archive), they will be used d func downloadArtifact(spec globals.ArtifactDef, installNextToBinary, installCurrent bool) (*tree.Tree, error) { node := out.GroupItem(fmt.Sprintf("%s %s", spec.Name, spec.Version)) + if spec.Override != "" { + node.Child(fmt.Sprintf("Config override active: scans use %s", spec.Override)) + } + tiers, err := utils.ArtifactTiers(spec) if err != nil { return node, err diff --git a/cli/cmd/root.go b/cli/cmd/root.go index b074efa0e..214028ec8 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -40,6 +40,8 @@ var rootCmd = &cobra.Command{ SilenceUsage: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + initConfig(cmd) + applyExperimentalFlagVisibility(cmd.Root(), experimentalMode) if err := log.SetUpLogs(); err != nil { @@ -93,7 +95,6 @@ func Execute() { } func init() { - cobra.OnInitialize(initConfig) configureExperimentalFlagVisibility() // Here you will define your flags and configuration settings. @@ -154,8 +155,9 @@ func init() { _ = viper.BindPFlag("autobuilder.jar_path", rootCmd.PersistentFlags().Lookup("autobuilder-jar")) } -// initConfig reads in config file and ENV variables if set. -func initConfig() { +func initConfig(cmd *cobra.Command) { + bindScanFlags(cmd) + if globals.ConfigFile != "" { // Use config file from the flag. viper.SetConfigFile(globals.ConfigFile) @@ -169,6 +171,19 @@ func initConfig() { _ = viper.Unmarshal(&globals.Config) } +func bindScanFlags(cmd *cobra.Command) { + for key, name := range map[string]string{ + "scan.timeout": "timeout", + "scan.ruleset": "ruleset", + "scan.max_memory": "max-memory", + "scan.code_flow_limit": "code-flow-limit", + } { + if f := cmd.Flags().Lookup(name); f != nil { + _ = viper.BindPFlag(key, f) + } + } +} + // hasNestedKey reports whether a dotted key path is present in a viper settings map. // Each path segment must resolve to a non-nil value; intermediate segments must be maps. func hasNestedKey(m map[string]any, parts []string) bool { diff --git a/cli/cmd/scan.go b/cli/cmd/scan.go index f5fb4df4a..cab1cece6 100644 --- a/cli/cmd/scan.go +++ b/cli/cmd/scan.go @@ -8,13 +8,13 @@ import ( "github.com/seqra/opentaint/internal/analyzer" "github.com/seqra/opentaint/internal/load_trace" + "github.com/seqra/opentaint/internal/rules" "github.com/seqra/opentaint/internal/sarif" "github.com/seqra/opentaint/internal/validation" "github.com/seqra/opentaint/internal/version" "github.com/seqra/opentaint/internal/utils/project" "github.com/spf13/cobra" - "github.com/spf13/viper" "github.com/seqra/opentaint/internal/globals" "github.com/seqra/opentaint/internal/output" @@ -23,60 +23,52 @@ import ( "github.com/seqra/opentaint/internal/utils/log" ) -var ( - UserProjectPath string - ProjectModelPath string - SarifReportPath string - SemgrepCompatibilitySarif bool - Severity []string - Ruleset []string - DryRunScan bool - Recompile bool - ScanLogFile string - RuleID []string - ApproximationsConfig []string - DataflowApproximations []string - TrackExternalMethods bool - DebugFactReachabilitySarif bool -) +type ScanConfig struct { + UserProjectPath string + ProjectModelPath string + SarifReportPath string + SemgrepCompatibilitySarif bool + Severity []string + Ruleset []string + DryRun bool + Recompile bool + LogFile string + RuleID []string + PassthroughApproximations []string + DataflowApproximations []string + TrackExternalMethods bool + + DebugFactReachabilitySarif bool + DebugRunAnalysisOnSelectedEntryPoints string + ExpandRuleRefs bool +} + +var scanFlags ScanConfig type RulesetType struct { Path string Builtin bool } -type ScanMode int - -const ( - Scan ScanMode = iota - CompileAndScan -) - const ( dryRunScanProjectModelPath = "opentaint-scan-dry-run/project-model" dryRunRuleLoadTraceFileName = "opentaint-rule-load-trace.dry-run.json" ) -func (m ScanMode) String() string { - switch m { - case Scan: - return "OpenTaint Scan" - case CompileAndScan: - return "OpenTaint Compile and Scan" - default: - return "Unknown" - } -} - -// scanConfig holds the resolved paths and flags for a scan invocation. -type scanConfig struct { - mode ScanMode +type scanPlan struct { absProjectModel string // absolute path to the project model (always the cache dir when projectCachePath is set) projectCachePath string // cache dir for this project (empty for explicit model / dry-run) needsCompilation bool // true when compilation is needed before scanning cacheLock *utils.FileLock } +func (p scanPlan) title() string { + if p.needsCompilation { + return "OpenTaint Compile and Scan" + } + return "OpenTaint Scan" +} + // scanCmd represents the scan command var scanCmd = &cobra.Command{ Use: "scan [source-path]", @@ -91,81 +83,92 @@ Use --project-model to scan a pre-compiled project model instead of compiling fr `, Annotations: map[string]string{"PrintConfig": "true"}, Run: func(cmd *cobra.Command, args []string) { - if len(args) > 0 && ProjectModelPath != "" { - out.Error("Cannot use both a source path argument and --project-model flag") - suggest("Use either a source path or --project-model", - utils.NewScanCommand("").Build()+"\n "+utils.NewScanCommand("").WithProjectModel("").Build()) - os.Exit(1) - } - if Recompile && ProjectModelPath != "" { - out.Fatalf("Cannot use --recompile with --project-model; the flag only applies when compiling from sources") - } - if len(args) > 0 { - UserProjectPath = args[0] - } else { - UserProjectPath = "." - } - scan(cmd) + runScan(cmd, prepareScanConfig(scanFlags, args)) }, } +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", + utils.NewScanCommand("").Build()+"\n "+utils.NewScanCommand("").WithProjectModel("").Build()) + os.Exit(1) + } + if cfg.Recompile && cfg.ProjectModelPath != "" { + out.Fatalf("Cannot use --recompile with --project-model; the flag only applies when compiling from sources") + } + if len(args) > 0 { + cfg.UserProjectPath = args[0] + } else { + cfg.UserProjectPath = "." + } + return cfg +} + func init() { rootCmd.AddCommand(scanCmd) + addScanFlags(scanCmd) + addRuleIDFlag(scanCmd) +} - scanCmd.Flags().DurationVarP(&globals.Config.Scan.Timeout, "timeout", "t", 900*time.Second, "Timeout for analysis") - _ = viper.BindPFlag("scan.timeout", scanCmd.Flags().Lookup("timeout")) +func addRuleIDFlag(cmd *cobra.Command) { + cmd.Flags().StringArrayVar(&scanFlags.RuleID, "rule-id", nil, "Filter active rules by ID (repeatable)") +} - scanCmd.Flags().StringArrayVar(&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") - _ = viper.BindPFlag("scan.ruleset", scanCmd.Flags().Lookup("ruleset")) +func addScanFlags(cmd *cobra.Command) { + cmd.Flags().DurationVarP(&globals.Config.Scan.Timeout, "timeout", "t", 900*time.Second, "Timeout for analysis") - scanCmd.Flags().BoolVar(&SemgrepCompatibilitySarif, "semgrep-compatibility-sarif", true, "Use Semgrep compatible ruleId") - scanCmd.Flags().StringVarP(&SarifReportPath, "output", "o", "", "Path to the SARIF-report output file") + 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") - scanCmd.Flags().StringArrayVar(&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)") - scanCmd.Flags().StringVar(&globals.Config.Scan.MaxMemory, "max-memory", "8G", "Maximum memory for the analyzer (e.g., 1024m, 8G, 81920k, 83886080)") - _ = viper.BindPFlag("scan.max_memory", scanCmd.Flags().Lookup("max-memory")) - scanCmd.Flags().Int64Var(&globals.Config.Scan.CodeFlowLimit, "code-flow-limit", 0, "Maximum number of code flows to include in the report (0 = unlimited)") - _ = viper.BindPFlag("scan.code_flow_limit", scanCmd.Flags().Lookup("code-flow-limit")) - scanCmd.Flags().BoolVar(&DryRunScan, "dry-run", false, "Validate inputs and show what would run without compiling or scanning") - scanCmd.Flags().BoolVar(&Recompile, "recompile", false, "Force recompilation even if a cached project model exists") - scanCmd.Flags().StringVar(&ProjectModelPath, "project-model", "", "Path to a pre-compiled project model (skips compilation)") - scanCmd.Flags().StringVar(&ScanLogFile, "log-file", "", "Path to the log file (default: /logs/.log)") - scanCmd.Flags().StringArrayVar(&RuleID, "rule-id", nil, "Filter active rules by ID (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") - scanCmd.Flags().StringArrayVar(&ApproximationsConfig, "approximations-config", nil, "YAML passThrough approximations config (OVERRIDE mode, repeatable)") - _ = scanCmd.Flags().MarkHidden("approximations-config") + 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().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)") - scanCmd.Flags().StringArrayVar(&DataflowApproximations, "dataflow-approximations", nil, "Directory of compiled approximation class files (repeatable)") - _ = scanCmd.Flags().MarkHidden("dataflow-approximations") + cmd.Flags().StringArrayVar(&scanFlags.PassthroughApproximations, "passthrough-approximations", nil, "Pass-through approximation YAML file or directory (repeatable)") - scanCmd.Flags().BoolVar(&TrackExternalMethods, "track-external-methods", false, "Write external-methods-{without,with}-rules.yaml next to the SARIF report") - _ = scanCmd.Flags().MarkHidden("track-external-methods") + cmd.Flags().StringArrayVar(&scanFlags.DataflowApproximations, "dataflow-approximations", nil, "Dataflow approximation class directory or Java source directory (repeatable)") - scanCmd.Flags().BoolVar(&DebugFactReachabilitySarif, "debug-fact-reachability-sarif", false, "Generate SARIF with fact reachability info (debug; use with a single rule only)") - _ = scanCmd.Flags().MarkHidden("debug-fact-reachability-sarif") + cmd.Flags().BoolVar(&scanFlags.TrackExternalMethods, "track-external-methods", false, "Write external-method coverage files next to the SARIF report") } // currentScanBuilder returns a builder pre-populated with the user's current scan flags. -// All scan command suggestions should use this as the base to ensure that adding a new -// flag in one place automatically propagates to every suggestion. -func currentScanBuilder(sourcePath string) *utils.OpentaintCommandBuilder { - return utils.NewScanCommand(sourcePath). - WithOutput(SarifReportPath). +func currentScanBuilder(cfg ScanConfig, sourcePath string) *utils.OpentaintCommandBuilder { + b := utils.NewScanCommand(sourcePath). + WithOutput(cfg.SarifReportPath). WithTimeout(globals.Config.Scan.Timeout). - WithRuleset(Ruleset). - WithSemgrepCompatibility(SemgrepCompatibilitySarif) + WithRuleset(cfg.Ruleset). + WithSemgrepCompatibility(cfg.SemgrepCompatibilitySarif). + WithRuleID(cfg.RuleID). + WithPassthroughApproximations(cfg.PassthroughApproximations). + WithDataflowApproximations(cfg.DataflowApproximations). + WithTrackExternalMethods(cfg.TrackExternalMethods) + if !isDefaultSeverity(cfg.Severity) { + b.WithSeverity(cfg.Severity) + } + return b +} + +func isDefaultSeverity(sev []string) bool { + return len(sev) == 2 && sev[0] == "warning" && sev[1] == "error" } // dockerScanSuggestion builds the "try Docker-based scan" fallback hint. -func dockerScanSuggestion(projectRoot, sarifReportPath string) output.Suggestion { +func dockerScanSuggestion(cfg ScanConfig, projectRoot, sarifReportPath string) output.Suggestion { return output.Suggestion{ Description: dockerFallbackHintPrefix + "scan:", - Command: utils.BuildScanCommandWithDocker(currentScanBuilder(""), projectRoot, sarifReportPath, Ruleset), + Command: utils.BuildScanCommandWithDocker(currentScanBuilder(cfg, ""), projectRoot, sarifReportPath, cfg.Ruleset), } } -func scan(cmd *cobra.Command) { - userProjectPath := filepath.Clean(UserProjectPath) +func runScan(cmd *cobra.Command, cfg ScanConfig) { + userProjectPath := filepath.Clean(cfg.UserProjectPath) absUserProjectRoot := log.AbsPathOrExit(userProjectPath, "project path") if !utils.IsSupportedArch() { @@ -173,33 +176,33 @@ func scan(cmd *cobra.Command) { } // When compiling from sources, validate the source folder looks like a Java/Kotlin project - if ProjectModelPath == "" { + if cfg.ProjectModelPath == "" { 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("").WithProjectModel(absUserProjectRoot).Build()) + suggest("Use --project-model to scan a pre-compiled model", currentScanBuilder(cfg, "").WithProjectModel(absUserProjectRoot).Build()) os.Exit(1) } out.FatalErr(err) } } - cfg := resolveScanConfig(absUserProjectRoot) + plan := resolveScanPlan(cfg, absUserProjectRoot) defer func() { - if cfg.cacheLock != nil { - cfg.cacheLock.Unlock() + if plan.cacheLock != nil { + plan.cacheLock.Unlock() } }() // Activate logging - if !DryRunScan { - activateLoggingForProject(ScanLogFile, absUserProjectRoot) + if !cfg.DryRun { + activateLoggingForProject(cfg.LogFile, absUserProjectRoot) } - absProjectModelPath := cfg.absProjectModel + absProjectModelPath := plan.absProjectModel var absRuleSetPaths []RulesetType - var userRuleSetPath = Ruleset + var userRuleSetPath = cfg.Ruleset for _, ruleset := range userRuleSetPath { switch ruleset { @@ -218,19 +221,19 @@ func scan(cmd *cobra.Command) { } var absSarifReportPath string - if SarifReportPath != "" { - absSarifReportPath = log.AbsPathOrExit(SarifReportPath, "output") + if cfg.SarifReportPath != "" { + absSarifReportPath = log.AbsPathOrExit(cfg.SarifReportPath, "output") } else { absSarifReportPath = utils.DefaultSarifReportPath(absProjectModelPath) } sarifReportName := filepath.Base(absSarifReportPath) - localVersion := utils.ArtifactDisplayVersion(globals.ArtifactByKind("analyzer"), globals.Config.Analyzer.JarPath) + localVersion := utils.ArtifactDisplayVersion(globals.ArtifactByKind("analyzer")) localSemanticVersion := version.GetVersion() var sourceRoot string - if !cfg.needsCompilation { + if !plan.needsCompilation { if parsedSourceRoot, err := project.GetSourceRoot(absProjectModelPath); err != nil { out.Fatalf("Failed to parse sourceRoot from project.yaml: %v", err) } else { @@ -243,14 +246,14 @@ func scan(cmd *cobra.Command) { uriBase := fmt.Sprintf("%s%s", sourceRoot, string(filepath.Separator)) var absSemgrepRuleLoadTracePath string - if DryRunScan { + if cfg.DryRun { absSemgrepRuleLoadTracePath = filepath.Join(os.TempDir(), dryRunRuleLoadTraceFileName) } else { absSemgrepRuleLoadTracePath = setupSemgrepRuleLoadTrace() } // Display scan information in tree format - printScanInfo(cmd, cfg, absSemgrepRuleLoadTracePath, absUserProjectRoot, absRuleSetPaths, localVersion) + printScanInfo(cmd, plan, absSemgrepRuleLoadTracePath, absUserProjectRoot, absRuleSetPaths) var nonBuiltinRulesetPaths []string for _, r := range absRuleSetPaths { @@ -259,68 +262,65 @@ func scan(cmd *cobra.Command) { } } - maxMemory, err := validation.ValidateScanInputs(absUserProjectRoot, absProjectModelPath, absSarifReportPath, nonBuiltinRulesetPaths, Severity, globals.Config.Scan.MaxMemory, cfg.mode == Scan) + maxMemory, err := validation.ValidateScanInputs(absUserProjectRoot, absProjectModelPath, absSarifReportPath, nonBuiltinRulesetPaths, cfg.Severity, globals.Config.Scan.MaxMemory, !plan.needsCompilation) if err != nil { out.Fatalf("Input validation failed: %s", err) } - if DryRunScan { + if cfg.DryRun { runDryRun("Compilation and analysis") return } + hasBuiltin := false for _, ruleSetPath := range absRuleSetPaths { - if !ruleSetPath.Builtin { - continue + if ruleSetPath.Builtin { + hasBuiltin = true + break } - if _, err := os.Stat(ruleSetPath.Path); err == nil { - continue - } - if err := utils.DownloadAndUnpackGithubReleaseAsset(globals.Config.Owner, globals.Config.Repo, globals.Config.Rules.Version, globals.RulesAssetName, ruleSetPath.Path, globals.Config.Github.Token, globals.Config.SkipVerify, out); err != nil { - out.Fatalf("Unexpected error occurred while trying to download ruleset: %s", err) + } + if hasBuiltin { + if _, err := utils.EnsureRulesPath(out); err != nil { + out.Fatalf("Failed to prepare built-in rules: %s", err) } } - if cfg.needsCompilation { + if plan.needsCompilation { autobuilderJarPath, err := ensureAutobuilderAvailable() if err != nil { out.Fatalf("Native compile preparation failed: %s", err) } - compileJavaRunner := java.NewJavaRunner(). - WithSkipVerify(globals.Config.SkipVerify). - WithDebugOutput(out.DebugStream("Autobuilder")). - TrySystem(). - TrySpecificVersion(globals.Config.Java.Version) + compileJavaRunner := newAutobuilderJavaRunner() if _, err := compileJavaRunner.EnsureJava(); err != nil { out.Fatalf("Failed to resolve Java for compilation: %s", err) } // Wipe any residue from a prior crashed compile before writing new output. - if cfg.projectCachePath != "" { - if err := os.RemoveAll(cfg.absProjectModel); err != nil { + if plan.projectCachePath != "" { + if err := os.RemoveAll(plan.absProjectModel); err != nil { out.Fatalf("Failed to prepare cache directory: %s", err) } } if err := out.RunWithSpinner("Compiling project model", func() error { - return compile(absUserProjectRoot, cfg.absProjectModel, autobuilderJarPath, compileJavaRunner) + return compile(absUserProjectRoot, plan.absProjectModel, autobuilderJarPath, compileJavaRunner) }); err != nil { - if cfg.projectCachePath != "" { - _ = os.RemoveAll(cfg.absProjectModel) + if plan.projectCachePath != "" { + _ = os.RemoveAll(plan.absProjectModel) } - failWith(1, "Native compile has failed: "+err.Error(), dockerScanSuggestion(absUserProjectRoot, absSarifReportPath)) + failWith(1, "Native compile has failed: "+err.Error(), dockerScanSuggestion(cfg, absUserProjectRoot, absSarifReportPath)) } out.Blank() // Mark the cache as valid, then downgrade to a reader so other scans // can run the analyzer against the freshly-compiled model in parallel. - if cfg.projectCachePath != "" { - if err := utils.MarkCompileComplete(cfg.projectCachePath); err != nil { - _ = os.RemoveAll(cfg.absProjectModel) + if plan.projectCachePath != "" { + if err := utils.MarkCompileComplete(plan.projectCachePath); err != nil { + _ = os.RemoveAll(plan.absProjectModel) out.Fatalf("Failed to mark model complete: %s", err) } - if err := cfg.cacheLock.Downgrade(); err != nil { + if err := plan.cacheLock.Downgrade(); err != nil { output.LogInfof("Cache lock downgrade failed, continuing under exclusive: %v", err) } } @@ -346,10 +346,10 @@ func scan(cmd *cobra.Command) { SetIfdsAnalysisTimeout(int64(globals.Config.Scan.Timeout / time.Second)). SetRuleLoadTracePath(absSemgrepRuleLoadTracePath). EnablePartialFingerprints() - if SemgrepCompatibilitySarif { + if cfg.SemgrepCompatibilitySarif { nativeBuilder.EnableSemgrepCompatibility() } - for _, severity := range Severity { + for _, severity := range cfg.Severity { nativeBuilder.AddSeverity(severity) } for _, absRuleSetPath := range absRuleSetPaths { @@ -358,19 +358,27 @@ func scan(cmd *cobra.Command) { if maxMemory != "" { nativeBuilder.SetMaxMemory(maxMemory) } - for _, ruleID := range RuleID { - nativeBuilder.AddRuleID(ruleID) + ruleIDs := cfg.RuleID + if cfg.ExpandRuleRefs && len(ruleIDs) > 0 { + var roots []string + for _, r := range absRuleSetPaths { + roots = append(roots, r.Path) + } + ruleIDs = rules.ExpandRuleIDs(ruleIDs, roots) } - for _, approxConfig := range ApproximationsConfig { - absApproxConfig := log.AbsPathOrExit(approxConfig, "approximations-config") - nativeBuilder.AddApproximationsConfig(absApproxConfig) + for _, ruleID := range ruleIDs { + nativeBuilder.AddRuleID(ruleID) } - if TrackExternalMethods { + addPassthroughApproximations(nativeBuilder, cfg.PassthroughApproximations) + if cfg.TrackExternalMethods { nativeBuilder.SetTrackExternalMethods(true) } - if DebugFactReachabilitySarif { + if cfg.DebugFactReachabilitySarif { nativeBuilder.EnableDebugFactReachabilitySarif() } + if cfg.DebugRunAnalysisOnSelectedEntryPoints != "" { + nativeBuilder.SetDebugRunAnalysisOnSelectedEntryPoints(cfg.DebugRunAnalysisOnSelectedEntryPoints) + } analyzerJarPath, err := ensureAnalyzerAvailable() if err != nil { @@ -379,20 +387,9 @@ func scan(cmd *cobra.Command) { nativeBuilder.SetJarPath(analyzerJarPath) // Process --dataflow-approximations: auto-compile .java sources if needed - for _, approxPath := range DataflowApproximations { - absApproxPath := log.AbsPathOrExit(approxPath, "dataflow-approximations") - compiledPath, compileErr := compileApproximationsIfNeeded(absApproxPath, analyzerJarPath, absProjectModelPath) - if compileErr != nil { - out.Fatalf("Approximation compilation failed: %s", compileErr) - } - nativeBuilder.AddDataflowApproximations(compiledPath) - } + addDataflowApproximations(nativeBuilder, cfg.DataflowApproximations, analyzerJarPath, absProjectModelPath) - analyzerJavaRunner := java.NewJavaRunner(). - WithSkipVerify(globals.Config.SkipVerify). - WithDebugOutput(out.DebugStream("Analyzer")). - WithImageType(java.AdoptiumImageJRE). - TrySpecificVersion(globals.DefaultJavaVersion) + analyzerJavaRunner := newAnalyzerJavaRunner() if _, err := analyzerJavaRunner.EnsureJava(); err != nil { out.Fatalf("Failed to resolve Java for analyzer: %s", err) } @@ -466,18 +463,16 @@ func scan(cmd *cobra.Command) { } } -func resolveScanConfig(absUserProjectRoot string) scanConfig { - if ProjectModelPath != "" { - return scanConfig{ - mode: Scan, - absProjectModel: log.AbsPathOrExit(filepath.Clean(ProjectModelPath), "project model path"), +func resolveScanPlan(cfg ScanConfig, absUserProjectRoot string) scanPlan { + if cfg.ProjectModelPath != "" { + return scanPlan{ + absProjectModel: log.AbsPathOrExit(filepath.Clean(cfg.ProjectModelPath), "project model path"), } } - if DryRunScan { + if cfg.DryRun { dryRunPath := filepath.Join(os.TempDir(), dryRunScanProjectModelPath) - return scanConfig{ - mode: CompileAndScan, + return scanPlan{ absProjectModel: dryRunPath, needsCompilation: true, } @@ -493,13 +488,12 @@ func resolveScanConfig(absUserProjectRoot string) scanConfig { // Fast path: if we're not forced to recompile and the cache looks // complete on disk, take a shared lock and re-check under the lock. - if !Recompile && utils.IsCachedModelComplete(projectCachePath) { + if !cfg.Recompile && utils.IsCachedModelComplete(projectCachePath) { sharedLock, sharedErr := utils.TryLockShared(cacheLockPath) if sharedErr == nil { if utils.IsCachedModelComplete(projectCachePath) { output.LogDebugf("Reusing cached model at: %s", cachedModelPath) - return scanConfig{ - mode: Scan, + return scanPlan{ absProjectModel: cachedModelPath, projectCachePath: projectCachePath, cacheLock: sharedLock, @@ -535,8 +529,7 @@ func resolveScanConfig(absUserProjectRoot string) scanConfig { out.Fatalf("Failed to acquire cache lock: %s", lockErr) } - return scanConfig{ - mode: CompileAndScan, + return scanPlan{ absProjectModel: cachedModelPath, projectCachePath: projectCachePath, needsCompilation: true, @@ -544,26 +537,26 @@ func resolveScanConfig(absUserProjectRoot string) scanConfig { } } -func printScanInfo(cmd *cobra.Command, cfg scanConfig, absSemgrepRuleLoadTracePath string, absUserProjectRoot string, absRuleSetPaths []RulesetType, analyzerVersion string) { - sb := out.Section(cfg.mode.String()) +func printScanInfo(cmd *cobra.Command, plan scanPlan, absSemgrepRuleLoadTracePath string, absUserProjectRoot string, absRuleSetPaths []RulesetType) { + sb := out.Section(plan.title()) addConfigFields(cmd, sb) if globals.Config.Output.Debug { sb.FieldNode("Rule load trace", absSemgrepRuleLoadTracePath) sb.Line() } - if cfg.needsCompilation { + if plan.needsCompilation { sb.FieldNode("Project", absUserProjectRoot) - if cfg.projectCachePath != "" { - sb.FieldNode("Project model", cfg.absProjectModel) + if plan.projectCachePath != "" { + sb.FieldNode("Project model", plan.absProjectModel) } - sb.FieldNode("Autobuilder", utils.ArtifactDisplayVersion(globals.ArtifactByKind("autobuilder"), globals.Config.Autobuilder.JarPath)) + sb.FieldNode("Autobuilder", utils.ArtifactVersionWithPath(globals.ArtifactByKind("autobuilder"))) } else { - sb.FieldNode("Project model", cfg.absProjectModel) + sb.FieldNode("Project model", plan.absProjectModel) } - sb.FieldNode("Analyzer", analyzerVersion) + sb.FieldNode("Analyzer", utils.ArtifactVersionWithPath(globals.ArtifactByKind("analyzer"))) for _, r := range absRuleSetPaths { if r.Builtin { - sb.FieldNode("Bundled ruleset", utils.ArtifactDisplayVersion(globals.ArtifactByKind("rules"), "")) + sb.FieldNode("Bundled ruleset", utils.ArtifactVersionWithPath(globals.ArtifactByKind("rules"))) } else { sb.FieldNode("User ruleset", r.Path) } @@ -585,25 +578,6 @@ func setupSemgrepRuleLoadTrace() string { return absSemgrepRuleLoadTracePath } -func ensureAnalyzerAvailable() (string, error) { - if globals.Config.Analyzer.JarPath != "" { - return globals.Config.Analyzer.JarPath, nil - } - - analyzerJarPath, err := utils.GetAnalyzerJarPath(globals.Config.Analyzer.Version) - if err != nil { - return "", fmt.Errorf("failed to construct path to the analyzer: %w", err) - } - - if err := ensureArtifactAvailable("analyzer", globals.Config.Analyzer.Version, analyzerJarPath, func() error { - return utils.DownloadGithubReleaseAsset(globals.Config.Owner, globals.Config.Repo, globals.Config.Analyzer.Version, globals.AnalyzerAssetName, analyzerJarPath, globals.Config.Github.Token, globals.Config.SkipVerify, out) - }); err != nil { - return "", err - } - - return analyzerJarPath, nil -} - func scanProject(analyzerBuilder *AnalyzerBuilder, javaRunner java.JavaRunner) (*java.JavaCommandError, error) { analyzerCommand := analyzerBuilder.BuildNativeCommand() diff --git a/cli/cmd/test.go b/cli/cmd/test.go new file mode 100644 index 000000000..409240606 --- /dev/null +++ b/cli/cmd/test.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "time" + + "github.com/spf13/cobra" +) + +var testCmd = &cobra.Command{ + Use: "test", + Short: "Create and run rule and approximation tests", + Long: `Tools for creating test projects, running annotated rule and approximation tests, and debugging rule reachability.`, +} + +var testRuleCmd = &cobra.Command{ + Use: "rule", + Short: "Create, run, and debug detection-rule tests", +} + +var testApproximationCmd = &cobra.Command{ + Use: "approximation", + Short: "Create and run dataflow-approximation tests", +} + +func init() { + rootCmd.AddCommand(testCmd) + testCmd.AddCommand(testRuleCmd) + testCmd.AddCommand(testApproximationCmd) +} + +func testExitCodesHelp(passedLine string) string { + return `Exit codes: + 0 ` + passedLine + ` + 1 General failure (configuration or infrastructure error) + 2 One or more tests failed (false negatives/positives or skipped samples) + 252 Unhandled analyzer exception + 253 Out of memory (try increasing --max-memory) + 254 Analysis timed out (try increasing --timeout) + 255 Project configuration error` +} + +func addTestRunFlags(cmd *cobra.Command, outputDir *string, timeout *time.Duration, maxMemory *string, dataflow *[]string) { + cmd.Flags().StringVarP(outputDir, "output", "o", "", "Directory for test-result.json and test-results.sarif") + cmd.Flags().DurationVar(timeout, "timeout", 600*time.Second, "Analysis timeout") + cmd.Flags().StringVar(maxMemory, "max-memory", "8G", "Maximum analyzer heap size (e.g., 8G)") + cmd.Flags().StringArrayVar(dataflow, "dataflow-approximations", nil, "Dataflow approximation class directory or Java source directory (repeatable)") +} diff --git a/cli/cmd/test_approximation_run.go b/cli/cmd/test_approximation_run.go new file mode 100644 index 000000000..926b89d63 --- /dev/null +++ b/cli/cmd/test_approximation_run.go @@ -0,0 +1,52 @@ +package cmd + +import ( + "os" + "time" + + "github.com/seqra/opentaint/internal/testapprox" + "github.com/spf13/cobra" +) + +var ( + testApproxOutputDir string + testApproxTimeout time.Duration + testApproxMaxMemory string + testApproxDataflow []string +) + +var testApproximationRunCmd = &cobra.Command{ + Use: "run ", + Short: "Run dataflow approximation tests on a compiled project model", + Long: `Run annotated samples with the supplied dataflow approximations applied. + +A built-in source-to-sink harness rule is applied automatically; positive samples reference it as +` + "`@PositiveRuleSample(value = \"approximation-rule.yaml\", id = \"approximation-rule\")`" + `. + +` + testExitCodesHelp("All approximation tests passed"), + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + ruleDir, err := os.MkdirTemp("", "opentaint-approx-rule-*") + if err != nil { + out.Fatalf("Failed to create temp dir for harness rule: %s", err) + } + if _, err := testapprox.WriteFixedRule(ruleDir); err != nil { + out.Fatalf("Failed to materialize harness rule: %s", err) + } + + runTestProject(args[0], testProjectOptions{ + label: "Approximation tests", + tempDir: "opentaint-test-approximations-*", + rulesets: []string{ruleDir}, + outputDir: testApproxOutputDir, + timeout: testApproxTimeout, + maxMemory: testApproxMaxMemory, + dataflowApprox: testApproxDataflow, + }) + }, +} + +func init() { + testApproximationCmd.AddCommand(testApproximationRunCmd) + addTestRunFlags(testApproximationRunCmd, &testApproxOutputDir, &testApproxTimeout, &testApproxMaxMemory, &testApproxDataflow) +} diff --git a/cli/cmd/test_init.go b/cli/cmd/test_init.go new file mode 100644 index 000000000..66488d8d8 --- /dev/null +++ b/cli/cmd/test_init.go @@ -0,0 +1,106 @@ +package cmd + +import ( + "fmt" + "path/filepath" + + "github.com/seqra/opentaint/internal/testapprox" + "github.com/seqra/opentaint/internal/testproject" + "github.com/seqra/opentaint/internal/testrule" + "github.com/seqra/opentaint/internal/testutil" + "github.com/spf13/cobra" +) + +var initRuleProjectDeps []string +var initApproxProjectDeps []string +var initRuleSinksOnly bool +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. + +Each project includes: + - build.gradle.kts with compile-only dependencies, settings.gradle.kts + - libs/opentaint-sast-test-util.jar (provides @PositiveRuleSample and @NegativeRuleSample) + - 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 + +Use --dependency to add compile-only Maven dependencies for the samples.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + if initRuleSinksOnly && initRuleSourcesOnly { + out.Fatalf("--sinks-only and --sources-only are mutually exclusive") + } + kinds := []string{"sinks", "sources"} + if initRuleSinksOnly { + kinds = []string{"sinks"} + } else if initRuleSourcesOnly { + kinds = []string{"sources"} + } + jarSrc, err := testutil.ResolveJar() + if err != nil { + out.Fatalf("Failed to resolve test-util JAR: %s", err) + } + for _, kind := range kinds { + dir := filepath.Join(args[0], kind) + if err := testproject.Bootstrap(dir, "opentaint-rule-test-"+kind, initRuleProjectDeps, jarSrc); err != nil { + out.Fatalf("Failed to bootstrap test project: %s", err) + } + 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) + } + }, +} + +var testApproximationInitCmd = &cobra.Command{ + Use: "init ", + Short: "Create a dataflow approximation test project", + Long: `Create a minimal Gradle project for testing OpenTaint dataflow approximations. + +The project includes: + - build.gradle.kts with compile-only dependencies + - settings.gradle.kts + - libs/opentaint-sast-test-util.jar (provides @PositiveRuleSample and @NegativeRuleSample annotations) + - approximation-rule.yaml, the fixed source-to-sink rule the samples are checked against + - src/main/java/test/ with Taint.java (the fixed source() and sink()) for test sample sources + +The approximation under test is supplied separately at test time with +--dataflow-approximations. + +Use --dependency to add compile-only Maven dependencies for the samples.`, + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + jarSrc, err := testutil.ResolveJar() + if err != nil { + out.Fatalf("Failed to resolve test-util JAR: %s", err) + } + if err := testproject.Bootstrap(args[0], "approximation-test-project", initApproxProjectDeps, jarSrc); err != nil { + out.Fatalf("Failed to bootstrap test project: %s", err) + } + if err := testapprox.Scaffold(args[0]); err != nil { + out.Fatalf("Failed to scaffold approximation project: %s", err) + } + fmt.Printf("Approximation test project initialized at %s\n", args[0]) + }, +} + +func init() { + testRuleCmd.AddCommand(testRuleInitCmd) + testRuleInitCmd.Flags().StringArrayVar(&initRuleProjectDeps, "dependency", nil, + "Compile-only Maven dependency coordinates for generated samples (repeatable)") + testRuleInitCmd.Flags().BoolVar(&initRuleSinksOnly, "sinks-only", false, + "Create only the sinks test project") + testRuleInitCmd.Flags().BoolVar(&initRuleSourcesOnly, "sources-only", false, + "Create only the sources test project") + + testApproximationCmd.AddCommand(testApproximationInitCmd) + testApproximationInitCmd.Flags().StringArrayVar(&initApproxProjectDeps, "dependency", nil, + "Compile-only Maven dependency coordinates for generated samples (repeatable)") +} diff --git a/cli/cmd/test_rule_reachability.go b/cli/cmd/test_rule_reachability.go new file mode 100644 index 000000000..ecb0bf323 --- /dev/null +++ b/cli/cmd/test_rule_reachability.go @@ -0,0 +1,43 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +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. + +Referenced library source and sink rules are collected and analyzed automatically.`, + Annotations: map[string]string{"PrintConfig": "true"}, + Args: cobra.RangeArgs(1, 2), + Run: func(cmd *cobra.Command, args []string) { + if reachabilityEntryPoint != "" { + out.Warn("on Spring projects this method is added to the auto-discovered entry points, not used to restrict them") + } + cfg := reachabilityScanConfig(scanFlags, args[0], reachabilityEntryPoint) + runScan(cmd, prepareScanConfig(cfg, args[1:])) + }, +} + +func reachabilityScanConfig(base ScanConfig, ruleID, entryPoint string) ScanConfig { + base.RuleID = []string{ruleID} + base.DebugFactReachabilitySarif = true + base.ExpandRuleRefs = true + if entryPoint != "" { + base.DebugRunAnalysisOnSelectedEntryPoints = entryPoint + } + return base +} + +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") +} diff --git a/cli/cmd/test_rule_reachability_test.go b/cli/cmd/test_rule_reachability_test.go new file mode 100644 index 000000000..280a7dced --- /dev/null +++ b/cli/cmd/test_rule_reachability_test.go @@ -0,0 +1,103 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/seqra/opentaint/internal/globals" + "github.com/spf13/viper" +) + +func TestReachabilityScanConfigAppliesPresets(t *testing.T) { + base := ScanConfig{ + Ruleset: []string{"builtin"}, + Severity: []string{"warning"}, + } + + cfg := reachabilityScanConfig(base, "security/sqli.yaml:sql-injection", "com.example.A#m") + + if len(cfg.RuleID) != 1 || cfg.RuleID[0] != "security/sqli.yaml:sql-injection" { + t.Fatalf("RuleID = %v, want [security/sqli.yaml:sql-injection]", cfg.RuleID) + } + if !cfg.DebugFactReachabilitySarif { + t.Error("DebugFactReachabilitySarif = false, want true") + } + if !cfg.ExpandRuleRefs { + t.Error("ExpandRuleRefs = false, want true") + } + if cfg.DebugRunAnalysisOnSelectedEntryPoints != "com.example.A#m" { + t.Errorf("entry points = %q, want com.example.A#m", cfg.DebugRunAnalysisOnSelectedEntryPoints) + } + + if len(cfg.Ruleset) != 1 || cfg.Ruleset[0] != "builtin" { + t.Errorf("Ruleset = %v, want base [builtin]", cfg.Ruleset) + } + if len(cfg.Severity) != 1 || cfg.Severity[0] != "warning" { + t.Errorf("Severity = %v, want base [warning]", cfg.Severity) + } +} + +func TestReachabilityScanConfigOmitsEmptyEntryPoint(t *testing.T) { + cfg := reachabilityScanConfig(ScanConfig{}, "r", "") + if cfg.DebugRunAnalysisOnSelectedEntryPoints != "" { + t.Errorf("entry points = %q, want empty when no entry point given", cfg.DebugRunAnalysisOnSelectedEntryPoints) + } +} + +func TestReachabilityExplicitFlagsSurviveConfig(t *testing.T) { + origTimeout := globals.Config.Scan.Timeout + origMaxMemory := globals.Config.Scan.MaxMemory + t.Cleanup(func() { + globals.Config.Scan.Timeout = origTimeout + globals.Config.Scan.MaxMemory = origMaxMemory + globals.ConfigFile = "" + viper.Reset() + testRuleReachabilityCmd.Flags().Lookup("timeout").Changed = false + testRuleReachabilityCmd.Flags().Lookup("max-memory").Changed = false + }) + + cfgFile := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(cfgFile, []byte("scan:\n timeout: 300s\n max_memory: 4G\n"), 0o644); err != nil { + t.Fatal(err) + } + globals.ConfigFile = cfgFile + + if err := testRuleReachabilityCmd.Flags().Set("timeout", "777s"); err != nil { + t.Fatal(err) + } + if err := testRuleReachabilityCmd.Flags().Set("max-memory", "16G"); err != nil { + t.Fatal(err) + } + + initConfig(testRuleReachabilityCmd) + + if got := globals.Config.Scan.Timeout; got != 777*time.Second { + t.Errorf("Timeout = %v, want 777s (explicit flag must beat config file)", got) + } + if got := globals.Config.Scan.MaxMemory; got != "16G" { + t.Errorf("MaxMemory = %q, want 16G (explicit flag must beat config file)", got) + } +} + +func TestScanConfigFileAppliesWhenFlagUnset(t *testing.T) { + origTimeout := globals.Config.Scan.Timeout + t.Cleanup(func() { + globals.Config.Scan.Timeout = origTimeout + globals.ConfigFile = "" + viper.Reset() + }) + + cfgFile := filepath.Join(t.TempDir(), "config.yaml") + if err := os.WriteFile(cfgFile, []byte("scan:\n timeout: 123s\n"), 0o644); err != nil { + t.Fatal(err) + } + globals.ConfigFile = cfgFile + + initConfig(scanCmd) + + if got := globals.Config.Scan.Timeout; got != 123*time.Second { + t.Errorf("Timeout = %v, want config-file 123s when flag not passed", got) + } +} diff --git a/cli/cmd/test_rule_run.go b/cli/cmd/test_rule_run.go new file mode 100644 index 000000000..028c77db5 --- /dev/null +++ b/cli/cmd/test_rule_run.go @@ -0,0 +1,177 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/seqra/opentaint/internal/analyzer" + "github.com/seqra/opentaint/internal/utils" + "github.com/seqra/opentaint/internal/utils/log" + "github.com/spf13/cobra" +) + +var ( + testRulesRuleset []string + testRulesOutputDir string + testRulesTimeout time.Duration + testRulesMaxMemory string + testRulesRuleID []string + testRulesDataflow []string + testRulesPassthrough []string +) + +var testRuleRunCmd = &cobra.Command{ + Use: "run ", + Short: "Run detection-rule tests on a compiled project model", + Long: `Run detection rules against samples annotated with @PositiveRuleSample and +@NegativeRuleSample in the compiled project model. + +` + testExitCodesHelp("All rule tests passed"), + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + runTestProject(args[0], testProjectOptions{ + label: "Rule tests", + tempDir: "opentaint-test-rules-*", + rulesets: testRulesRuleset, + outputDir: testRulesOutputDir, + timeout: testRulesTimeout, + maxMemory: testRulesMaxMemory, + ruleIDs: testRulesRuleID, + dataflowApprox: testRulesDataflow, + passthroughApprox: testRulesPassthrough, + includeBuiltinRules: true, + }) + }, +} + +type testProjectOptions struct { + label string + tempDir string + rulesets []string + outputDir string + timeout time.Duration + maxMemory string + ruleIDs []string + dataflowApprox []string + passthroughApprox []string + includeBuiltinRules bool +} + +func runTestProject(projectModelArg string, opts testProjectOptions) { + projectPath := log.AbsPathOrExit(projectModelArg, "project-model") + nativeProjectPath := filepath.Join(projectPath, "project.yaml") + + if _, err := os.Stat(nativeProjectPath); err != nil { + if os.IsNotExist(err) { + out.Fatalf("Project model not found: %s", nativeProjectPath) + } + out.Fatalf("Cannot access project model %s: %s", nativeProjectPath, err) + } + + maxMemory, err := utils.ParseMemoryValue(opts.maxMemory) + if err != nil { + out.Fatalf("Invalid --max-memory value: %s", err) + } + + outputDir := opts.outputDir + if outputDir == "" { + tmpDir, err := os.MkdirTemp("", opts.tempDir) + if err != nil { + out.Fatalf("Failed to create temp dir: %s", err) + } + outputDir = tmpDir + } else { + outputDir = log.AbsPathOrExit(outputDir, "output") + if err := os.MkdirAll(outputDir, 0o755); err != nil { + out.Fatalf("Failed to create output directory: %s", err) + } + } + + timeoutSeconds := int64(opts.timeout / time.Second) + if timeoutSeconds <= 0 { + timeoutSeconds = 600 + } + + builder := NewAnalyzerBuilder(). + SetProject(nativeProjectPath). + SetOutputDir(outputDir). + SetSarifFileName("test-results.sarif"). + SetIfdsAnalysisTimeout(timeoutSeconds). + EnableRunRuleTests() + + if opts.includeBuiltinRules { + rulesPath, err := utils.EnsureRulesPath(out) + if err != nil { + out.Fatalf("Failed to prepare built-in rules: %s", err) + } + builder.AddRuleSet(rulesPath) + } + + if maxMemory != "" { + builder.SetMaxMemory(maxMemory) + } + + for _, rs := range opts.rulesets { + absPath := log.AbsPathOrExit(rs, "ruleset") + builder.AddRuleSet(absPath) + } + + for _, ruleID := range opts.ruleIDs { + builder.AddRuleID(ruleID) + } + + analyzerJarPath, err := ensureAnalyzerAvailable() + if err != nil { + out.Fatalf("Failed to resolve analyzer: %s", err) + } + builder.SetJarPath(analyzerJarPath) + + addDataflowApproximations(builder, opts.dataflowApprox, analyzerJarPath, projectPath) + addPassthroughApproximations(builder, opts.passthroughApprox) + + javaRunner := newAnalyzerJavaRunner() + if _, err := javaRunner.EnsureJava(); err != nil { + out.Fatalf("Failed to resolve Java for analyzer: %s", err) + } + + cmdErr, err := scanProject(builder, javaRunner) + if err != nil { + out.Fatalf("%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) + + if analyzerFail != nil { + 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) + } + fmt.Printf("Passed: %d, failed: %d (false negatives: %d, false positives: %d, skipped: %d), disabled: %d\n", + len(tr.Success), tr.Failed(), len(tr.FalseNegative), len(tr.FalsePositive), len(tr.Skipped), len(tr.Disabled)) + if tr.Failed() > 0 { + out.Error(fmt.Sprintf("%s failed", opts.label)) + os.Exit(2) + } + + fmt.Printf("%s completed successfully\n", opts.label) +} + +func init() { + testRuleCmd.AddCommand(testRuleRunCmd) + + testRuleRunCmd.Flags().StringArrayVar(&testRulesRuleset, "ruleset", nil, "Ruleset file or directory to test (repeatable)") + addTestRunFlags(testRuleRunCmd, &testRulesOutputDir, &testRulesTimeout, &testRulesMaxMemory, &testRulesDataflow) + testRuleRunCmd.Flags().StringArrayVar(&testRulesRuleID, "rule-id", nil, "Run only rules with this ID (repeatable)") + testRuleRunCmd.Flags().StringArrayVar(&testRulesPassthrough, "passthrough-approximations", nil, "Pass-through approximation YAML file or directory (repeatable)") +} diff --git a/cli/internal/analyzer/testresult.go b/cli/internal/analyzer/testresult.go new file mode 100644 index 000000000..d9501666b --- /dev/null +++ b/cli/internal/analyzer/testresult.go @@ -0,0 +1,36 @@ +package analyzer + +import ( + "encoding/json" + "fmt" + "os" +) + +type TestSampleInfo struct { + ClassName string `json:"className"` + MethodName string `json:"methodName"` +} + +type TestResult struct { + Success []TestSampleInfo `json:"success"` + FalseNegative []TestSampleInfo `json:"falseNegative"` + FalsePositive []TestSampleInfo `json:"falsePositive"` + Skipped []TestSampleInfo `json:"skipped"` + Disabled []TestSampleInfo `json:"disabled"` +} + +func (tr *TestResult) Failed() int { + return len(tr.FalseNegative) + len(tr.FalsePositive) + len(tr.Skipped) +} + +func LoadTestResult(path string) (*TestResult, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var tr TestResult + if err := json.Unmarshal(data, &tr); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + return &tr, nil +} diff --git a/cli/internal/analyzer/testresult_test.go b/cli/internal/analyzer/testresult_test.go new file mode 100644 index 000000000..edcdd783d --- /dev/null +++ b/cli/internal/analyzer/testresult_test.go @@ -0,0 +1,39 @@ +package analyzer + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadTestResult(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test-result.json") + content := `{ + "success": [{"className": "test.Ok", "methodName": "m", "rule": {"ruleId": "r1"}}], + "falseNegative": [{"className": "test.Missed", "methodName": null, "rule": {"ruleId": "r2"}}], + "falsePositive": [], + "skipped": [{"className": "test.NoRule", "methodName": "x", "rule": {"ruleId": "gone"}}], + "disabled": [] +}` + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + tr, err := LoadTestResult(path) + if err != nil { + t.Fatalf("LoadTestResult: %v", err) + } + if len(tr.Success) != 1 || tr.Success[0].ClassName != "test.Ok" { + t.Errorf("Success = %+v, want one test.Ok entry", tr.Success) + } + if got := tr.Failed(); got != 2 { + t.Errorf("Failed() = %d, want 2 (1 falseNegative + 1 skipped)", got) + } +} + +func TestLoadTestResultMissingFile(t *testing.T) { + if _, err := LoadTestResult(filepath.Join(t.TempDir(), "nope.json")); err == nil { + t.Fatal("expected error for missing file") + } +} diff --git a/cli/internal/globals/artifacts.go b/cli/internal/globals/artifacts.go index 1bdef8c6c..95487b32a 100644 --- a/cli/internal/globals/artifacts.go +++ b/cli/internal/globals/artifacts.go @@ -12,7 +12,8 @@ type ArtifactDef struct { CacheSuffix string // cache filename suffix (".jar", "") BindVersion string // compile-time bind version Version string // user-configured version - Unpack bool // unpack tar.gz; also implies dir-based cache entry + Override string + Unpack bool // unpack tar.gz; also implies dir-based cache entry } // CacheName returns the cache filename/dirname for this artifact version. @@ -49,6 +50,7 @@ func Artifacts() []ArtifactDef { CacheSuffix: ".jar", BindVersion: AutobuilderBindVersion, Version: Config.Autobuilder.Version, + Override: Config.Autobuilder.JarPath, }, { Name: "Analyzer", @@ -59,6 +61,7 @@ func Artifacts() []ArtifactDef { CacheSuffix: ".jar", BindVersion: AnalyzerBindVersion, Version: Config.Analyzer.Version, + Override: Config.Analyzer.JarPath, }, { Name: "Rules", diff --git a/cli/internal/output/output_test.go b/cli/internal/output/output_test.go index 4dedf2504..ab497b199 100644 --- a/cli/internal/output/output_test.go +++ b/cli/internal/output/output_test.go @@ -117,8 +117,8 @@ func TestSectionFieldNodeRendersValueAsLeaf(t *testing.T) { Render() got := buf.String() - if !strings.Contains(got, "Project:") { - t.Errorf("expected 'Project:' parent node, got %q", got) + if !strings.Contains(got, "Project") { + t.Errorf("expected 'Project' parent node, got %q", got) } if !strings.Contains(got, "/home/me/projects/hertzbeat") { t.Errorf("expected path value in output, got %q", got) @@ -126,7 +126,7 @@ func TestSectionFieldNodeRendersValueAsLeaf(t *testing.T) { keyLine := -1 valLine := -1 for i, line := range strings.Split(got, "\n") { - if strings.Contains(line, "Project:") { + if strings.Contains(line, "Project") { keyLine = i } if strings.Contains(line, "/home/me/projects/hertzbeat") { @@ -134,7 +134,7 @@ func TestSectionFieldNodeRendersValueAsLeaf(t *testing.T) { } } if keyLine == -1 || valLine == -1 || valLine != keyLine+1 { - t.Errorf("expected path on the line directly below 'Project:', got key=%d val=%d in %q", keyLine, valLine, got) + t.Errorf("expected path on the line directly below 'Project', got key=%d val=%d in %q", keyLine, valLine, got) } } diff --git a/cli/internal/output/section.go b/cli/internal/output/section.go index 6aa20ad77..6b19b6cb8 100644 --- a/cli/internal/output/section.go +++ b/cli/internal/output/section.go @@ -61,7 +61,7 @@ func (sb *SectionBuilder) StyledField(key string, value any, valueStyle lipgloss func (sb *SectionBuilder) FieldNode(key string, value any) *SectionBuilder { th := sb.printer.theme - root := th.FieldKey.Render(key + ":") + root := th.FieldKey.Render(key) leaf := th.FieldValue.Render(fmt.Sprint(value)) sb.items = append(sb.items, sb.printer.GroupItem(root, leaf)) return sb diff --git a/cli/internal/rules/refs.go b/cli/internal/rules/refs.go new file mode 100644 index 000000000..f407f3a70 --- /dev/null +++ b/cli/internal/rules/refs.go @@ -0,0 +1,98 @@ +package rules + +import ( + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v2" +) + +type ruleFile struct { + Rules []struct { + ID string `yaml:"id"` + Join struct { + Refs []struct { + Rule string `yaml:"rule"` + } `yaml:"refs"` + } `yaml:"join"` + } `yaml:"rules"` +} + +func ExpandRuleIDs(ruleIDs []string, rulesetRoots []string) []string { + seen := make(map[string]bool, len(ruleIDs)) + var result []string + queue := append([]string(nil), ruleIDs...) + + for len(queue) > 0 { + id := queue[0] + queue = queue[1:] + if seen[id] { + continue + } + seen[id] = true + result = append(result, id) + + for _, ref := range refsOf(id, rulesetRoots) { + if !seen[ref] { + queue = append(queue, ref) + } + } + } + return result +} + +func refsOf(id string, rulesetRoots []string) []string { + relPath, shortID, ok := splitRuleID(id) + if !ok { + return nil + } + rf, ok := loadRuleFile(relPath, rulesetRoots) + if !ok { + return nil + } + for _, r := range rf.Rules { + if r.ID != shortID { + continue + } + var refs []string + for _, ref := range r.Join.Refs { + if full := refToRuleID(ref.Rule, relPath); full != "" { + refs = append(refs, full) + } + } + return refs + } + return nil +} + +func splitRuleID(id string) (relPath, shortID string, ok bool) { + idx := strings.LastIndex(id, ":") + if idx < 0 { + return "", "", false + } + return id[:idx], id[idx+1:], true +} + +func refToRuleID(ref, currentRelPath string) string { + idx := strings.LastIndex(ref, "#") + if idx < 0 { + return currentRelPath + ":" + ref + } + return ref[:idx] + ":" + ref[idx+1:] +} + +func loadRuleFile(relPath string, rulesetRoots []string) (ruleFile, bool) { + for _, root := range rulesetRoots { + data, err := os.ReadFile(filepath.Join(root, filepath.FromSlash(relPath))) + if err != nil { + continue + } + var rf ruleFile + if err := yaml.Unmarshal(data, &rf); err != nil { + continue + } + return rf, true + } + return ruleFile{}, false +} diff --git a/cli/internal/rules/refs_test.go b/cli/internal/rules/refs_test.go new file mode 100644 index 000000000..ebf11b07f --- /dev/null +++ b/cli/internal/rules/refs_test.go @@ -0,0 +1,132 @@ +package rules + +import ( + "os" + "path/filepath" + "testing" +) + +func writeRule(t *testing.T, root, relPath, content string) { + t.Helper() + full := filepath.Join(root, filepath.FromSlash(relPath)) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestExpandRuleIDs_CollectsJoinRefs(t *testing.T) { + root := t.TempDir() + writeRule(t, root, "java/security/xss.yaml", ` +rules: + - id: xss + mode: join + join: + refs: + - rule: java/lib/generic/src.yaml#src + as: untrusted + - rule: java/lib/generic/sink.yaml#sink + as: sink +`) + writeRule(t, root, "java/lib/generic/src.yaml", "rules:\n - id: src\n options: {lib: true}\n") + writeRule(t, root, "java/lib/generic/sink.yaml", "rules:\n - id: sink\n options: {lib: true}\n") + + got := ExpandRuleIDs([]string{"java/security/xss.yaml:xss"}, []string{root}) + want := []string{ + "java/security/xss.yaml:xss", + "java/lib/generic/src.yaml:src", + "java/lib/generic/sink.yaml:sink", + } + assertEqual(t, got, want) +} + +func TestExpandRuleIDs_Transitive(t *testing.T) { + root := t.TempDir() + writeRule(t, root, "a.yaml", "rules:\n - id: a\n join:\n refs:\n - rule: b.yaml#b\n") + writeRule(t, root, "b.yaml", "rules:\n - id: b\n join:\n refs:\n - rule: c.yaml#c\n") + writeRule(t, root, "c.yaml", "rules:\n - id: c\n") + + got := ExpandRuleIDs([]string{"a.yaml:a"}, []string{root}) + assertEqual(t, got, []string{"a.yaml:a", "b.yaml:b", "c.yaml:c"}) +} + +func TestExpandRuleIDs_CycleAndDuplicates(t *testing.T) { + root := t.TempDir() + writeRule(t, root, "a.yaml", "rules:\n - id: a\n join:\n refs:\n - rule: b.yaml#b\n") + writeRule(t, root, "b.yaml", "rules:\n - id: b\n join:\n refs:\n - rule: a.yaml#a\n") + + got := ExpandRuleIDs([]string{"a.yaml:a", "a.yaml:a"}, []string{root}) + assertEqual(t, got, []string{"a.yaml:a", "b.yaml:b"}) +} + +func TestExpandRuleIDs_UnresolvedPassesThrough(t *testing.T) { + root := t.TempDir() + got := ExpandRuleIDs([]string{"does/not/exist.yaml:x"}, []string{root}) + assertEqual(t, got, []string{"does/not/exist.yaml:x"}) +} + +func TestExpandRuleIDs_MultipleRoots(t *testing.T) { + builtin := t.TempDir() + custom := t.TempDir() + writeRule(t, custom, "java/security/my.yaml", "rules:\n - id: my\n join:\n refs:\n - rule: java/lib/generic/src.yaml#src\n") + writeRule(t, builtin, "java/lib/generic/src.yaml", "rules:\n - id: src\n") + + got := ExpandRuleIDs([]string{"java/security/my.yaml:my"}, []string{builtin, custom}) + assertEqual(t, got, []string{"java/security/my.yaml:my", "java/lib/generic/src.yaml:src"}) +} + +func assertEqual(t *testing.T, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} + +func TestExpandRuleIDs_BareSameFileRef(t *testing.T) { + root := t.TempDir() + writeRule(t, root, "java/security/deser.yaml", ` +rules: + - id: unsafe-deserialization + mode: join + join: + refs: + - rule: unsafe-object-mapper-sink + as: sink + - id: unsafe-object-mapper-sink + options: {lib: true} +`) + + got := ExpandRuleIDs([]string{"java/security/deser.yaml:unsafe-deserialization"}, []string{root}) + want := []string{ + "java/security/deser.yaml:unsafe-deserialization", + "java/security/deser.yaml:unsafe-object-mapper-sink", + } + assertEqual(t, got, want) +} + +func TestExpandRuleIDs_BareRefTransitive(t *testing.T) { + root := t.TempDir() + writeRule(t, root, "a.yaml", ` +rules: + - id: a + join: + refs: + - rule: helper + - id: helper + join: + refs: + - rule: b.yaml#b +`) + writeRule(t, root, "b.yaml", "rules:\n - id: b\n") + + got := ExpandRuleIDs([]string{"a.yaml:a"}, []string{root}) + want := []string{"a.yaml:a", "a.yaml:helper", "b.yaml:b"} + assertEqual(t, got, want) +} diff --git a/cli/internal/testapprox/example/approximation-rule.yaml b/cli/internal/testapprox/example/approximation-rule.yaml new file mode 100644 index 000000000..0f0ce705a --- /dev/null +++ b/cli/internal/testapprox/example/approximation-rule.yaml @@ -0,0 +1,15 @@ +rules: + - id: approximation-rule + severity: ERROR + message: Tainted value from Taint.source() reached Taint.sink() through an approximated method + metadata: + short-description: Approximation test source-to-sink flow + languages: + - java + mode: taint + pattern-sources: + - pattern: test.Taint.source() + pattern-sinks: + - patterns: + - pattern: test.Taint.sink($VALUE) + - focus-metavariable: $VALUE diff --git a/cli/internal/testapprox/example/src/main/java/test/Taint.java b/cli/internal/testapprox/example/src/main/java/test/Taint.java new file mode 100644 index 000000000..3dede3dc8 --- /dev/null +++ b/cli/internal/testapprox/example/src/main/java/test/Taint.java @@ -0,0 +1,14 @@ +package test; + +public final class Taint { + + private Taint() { + } + + public static String source() { + return ""; + } + + public static void sink(String value) { + } +} diff --git a/cli/internal/testapprox/testapprox.go b/cli/internal/testapprox/testapprox.go new file mode 100644 index 000000000..3a686c11c --- /dev/null +++ b/cli/internal/testapprox/testapprox.go @@ -0,0 +1,33 @@ +package testapprox + +import ( + _ "embed" + "fmt" + "os" + "path/filepath" + + "github.com/seqra/opentaint/internal/utils" +) + +const fixedRuleFileName = "approximation-rule.yaml" + +//go:embed example/approximation-rule.yaml +var fixedRule []byte + +//go:embed example/src/main/java/test/Taint.java +var taintJava []byte + +func WriteFixedRule(dir string) (string, error) { + path := filepath.Join(dir, fixedRuleFileName) + if err := os.WriteFile(path, fixedRule, 0o644); err != nil { + return "", fmt.Errorf("write fixed approximation rule: %w", err) + } + return path, nil +} + +func Scaffold(projectDir string) error { + return utils.WriteFiles(map[string][]byte{ + filepath.Join(projectDir, fixedRuleFileName): fixedRule, + filepath.Join(projectDir, "src", "main", "java", "test", "Taint.java"): taintJava, + }) +} diff --git a/cli/internal/testproject/testproject.go b/cli/internal/testproject/testproject.go new file mode 100644 index 000000000..df84b47c4 --- /dev/null +++ b/cli/internal/testproject/testproject.go @@ -0,0 +1,49 @@ +package testproject + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/seqra/opentaint/internal/testutil" + "github.com/seqra/opentaint/internal/utils" +) + +func Bootstrap(outputDir, projectName string, dependencies []string, testUtilJarSrc string) error { + if err := utils.CopyFile(testUtilJarSrc, filepath.Join(outputDir, "libs", testutil.JarName)); err != nil { + return fmt.Errorf("copy test-util JAR: %w", err) + } + return utils.WriteFiles(map[string][]byte{ + filepath.Join(outputDir, "build.gradle.kts"): buildGradle(dependencies), + filepath.Join(outputDir, "settings.gradle.kts"): settingsGradle(projectName), + }) +} + +func buildGradle(dependencies []string) []byte { + var sb strings.Builder + fmt.Fprintf(&sb, `plugins { + java +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +repositories { + mavenCentral() +} + +dependencies { + compileOnly(files("libs/%s")) +`, testutil.JarName) + for _, dep := range dependencies { + fmt.Fprintf(&sb, " compileOnly(%q)\n", dep) + } + sb.WriteString("}\n") + return []byte(sb.String()) +} + +func settingsGradle(projectName string) []byte { + return fmt.Appendf(nil, "rootProject.name = %q\n", projectName) +} diff --git a/cli/internal/testproject/testproject_test.go b/cli/internal/testproject/testproject_test.go new file mode 100644 index 000000000..74da2f086 --- /dev/null +++ b/cli/internal/testproject/testproject_test.go @@ -0,0 +1,43 @@ +package testproject + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/seqra/opentaint/internal/testutil" +) + +func TestBootstrapWritesGradleLayoutAndJar(t *testing.T) { + dir := t.TempDir() + jarSrc := filepath.Join(t.TempDir(), testutil.JarName) + if err := os.WriteFile(jarSrc, []byte("fake-jar"), 0o644); err != nil { + t.Fatal(err) + } + + if err := Bootstrap(dir, "my-test-project", []string{"com.foo:bar:1.0"}, jarSrc); err != nil { + t.Fatalf("Bootstrap: %v", err) + } + + build, err := os.ReadFile(filepath.Join(dir, "build.gradle.kts")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(build), "libs/"+testutil.JarName) { + t.Errorf("build.gradle.kts must reference libs/%s, got:\n%s", testutil.JarName, build) + } + if !strings.Contains(string(build), `compileOnly("com.foo:bar:1.0")`) { + t.Errorf("build.gradle.kts missing dependency, got:\n%s", build) + } + if _, err := os.Stat(filepath.Join(dir, "libs", testutil.JarName)); err != nil { + t.Errorf("jar not copied to libs/: %v", err) + } + settings, err := os.ReadFile(filepath.Join(dir, "settings.gradle.kts")) + if err != nil { + t.Fatal(err) + } + if want := `rootProject.name = "my-test-project"`; !strings.Contains(string(settings), want) { + t.Errorf("settings.gradle.kts missing %q, got:\n%s", want, settings) + } +} diff --git a/cli/internal/testrule/example/rules/java/lib/test/generic-sink.yaml b/cli/internal/testrule/example/rules/java/lib/test/generic-sink.yaml new file mode 100644 index 000000000..af016b3bc --- /dev/null +++ b/cli/internal/testrule/example/rules/java/lib/test/generic-sink.yaml @@ -0,0 +1,13 @@ +rules: + - id: generic-taint-sink + options: + lib: true + severity: NOTE + message: Generic dangerous-operation marker (test.Taint.sink) + metadata: + short-description: Generic test taint sink + languages: + - java + patterns: + - pattern: test.Taint.sink($VALUE) + - focus-metavariable: $VALUE diff --git a/cli/internal/testrule/example/rules/java/lib/test/generic-source.yaml b/cli/internal/testrule/example/rules/java/lib/test/generic-source.yaml new file mode 100644 index 000000000..8a1432fda --- /dev/null +++ b/cli/internal/testrule/example/rules/java/lib/test/generic-source.yaml @@ -0,0 +1,13 @@ +rules: + - id: generic-taint-source + options: + lib: true + severity: NOTE + message: Generic untrusted-data marker (test.Taint.source) + metadata: + short-description: Generic test taint source + languages: + - java + patterns: + - pattern: | + $UNTRUSTED = test.Taint.source(); diff --git a/cli/internal/testrule/example/src/main/java/test/Taint.java b/cli/internal/testrule/example/src/main/java/test/Taint.java new file mode 100644 index 000000000..8df75faa1 --- /dev/null +++ b/cli/internal/testrule/example/src/main/java/test/Taint.java @@ -0,0 +1,21 @@ +package test; + +/** + * Generic taint marker for rule test projects. {@code source()} is generic so it + * assigns to any type without a cast (it erases to {@code Object}); {@code sink(Object)} + * accepts any value. Matched by the bundled generic-source / generic-sink lib rules, so a + * package's source/sink lib rules can be exercised against a fixed, type-agnostic counterpart. + */ +public final class Taint { + + private Taint() { + } + + @SuppressWarnings("unchecked") + public static T source() { + return (T) new Object(); + } + + public static void sink(Object value) { + } +} diff --git a/cli/internal/testrule/testrule.go b/cli/internal/testrule/testrule.go new file mode 100644 index 000000000..e72405c6a --- /dev/null +++ b/cli/internal/testrule/testrule.go @@ -0,0 +1,31 @@ +package testrule + +import ( + _ "embed" + "path/filepath" + + "github.com/seqra/opentaint/internal/utils" +) + +//go:embed example/src/main/java/test/Taint.java +var taintJava []byte + +//go:embed example/rules/java/lib/test/generic-source.yaml +var genericSource []byte + +//go:embed example/rules/java/lib/test/generic-sink.yaml +var genericSink []byte + +const ( + markersDir = "test-rules" + genericSourceRule = "java/lib/test/generic-source.yaml" + genericSinkRule = "java/lib/test/generic-sink.yaml" +) + +func Scaffold(projectDir string) error { + return 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, + }) +} diff --git a/cli/internal/testutil/.gitignore b/cli/internal/testutil/.gitignore new file mode 100644 index 000000000..7a1fe0c3a --- /dev/null +++ b/cli/internal/testutil/.gitignore @@ -0,0 +1,2 @@ +# Generated by go:generate from core/opentaint-sast-test-util/build/libs/ +jar/*.jar diff --git a/cli/internal/testutil/generate_jar.go b/cli/internal/testutil/generate_jar.go new file mode 100644 index 000000000..0a4fcb36b --- /dev/null +++ b/cli/internal/testutil/generate_jar.go @@ -0,0 +1,24 @@ +//go:build ignore + +package main + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/seqra/opentaint/internal/utils" +) + +const ( + jarName = "opentaint-sast-test-util.jar" + sourceJar = "../../../core/opentaint-sast-test-util/build/libs/opentaint-sast-test-util.jar" + outputDir = "jar" +) + +func main() { + if err := utils.CopyFile(sourceJar, filepath.Join(outputDir, jarName)); err != nil { + fmt.Fprintf(os.Stderr, "generate test-util jar: %v; build it with 'cd ../../../core && ./gradlew :opentaint-sast-test-util:jar'\n", err) + os.Exit(1) + } +} diff --git a/cli/internal/testutil/jar/README.md b/cli/internal/testutil/jar/README.md new file mode 100644 index 000000000..60f06c0c4 --- /dev/null +++ b/cli/internal/testutil/jar/README.md @@ -0,0 +1,4 @@ +This directory intentionally contains a tracked placeholder so `go build ./...` +works before `go generate` creates `opentaint-sast-test-util.jar`. + +Generated JAR files in this directory are ignored by Git. diff --git a/cli/internal/testutil/testutil.go b/cli/internal/testutil/testutil.go new file mode 100644 index 000000000..8e81c5bef --- /dev/null +++ b/cli/internal/testutil/testutil.go @@ -0,0 +1,110 @@ +package testutil + +import ( + "crypto/sha256" + "embed" + "encoding/hex" + "fmt" + "os" + "path" + "path/filepath" + "strings" + + "github.com/seqra/opentaint/internal/utils" +) + +//go:generate go run ./generate_jar.go + +//go:embed jar/* +var jarFiles embed.FS + +const JarName = "opentaint-sast-test-util.jar" + +func ResolveJar() (string, error) { + if libPath := utils.GetBundledLibPath(); libPath != "" { + candidate := filepath.Join(libPath, JarName) + if utils.PathExists(candidate) { + return candidate, nil + } + } + + if libPath := utils.GetInstallLibPath(); libPath != "" { + candidate := filepath.Join(libPath, JarName) + if utils.PathExists(candidate) { + return candidate, nil + } + } + + if exe, err := os.Executable(); err == nil { + exe, _ = filepath.EvalSymlinks(exe) + dir := filepath.Dir(exe) + for range 4 { + candidate := filepath.Join(dir, "core", "opentaint-sast-test-util", "build", "libs", JarName) + if utils.PathExists(candidate) { + return candidate, nil + } + dir = filepath.Dir(dir) + } + } + + if extracted, err := extractJar(); err == nil { + return extracted, nil + } + + return "", fmt.Errorf( + "%s not found; build it with 'cd core && ./gradlew :opentaint-sast-test-util:jar' or reinstall opentaint", + JarName, + ) +} + +func contentHash(jarData []byte) string { + h := sha256.Sum256(jarData) + return hex.EncodeToString(h[:]) +} + +func extractJar() (string, error) { + jarData, err := embeddedJarData() + if err != nil { + return "", err + } + + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot determine home directory: %w", err) + } + extractDir := filepath.Join(home, ".opentaint", "test-util") + extractPath := filepath.Join(extractDir, JarName) + markerPath := filepath.Join(extractDir, ".content-hash") + wantHash := contentHash(jarData) + + if !needsExtract(markerPath, wantHash) && utils.PathExists(extractPath) { + return extractPath, nil + } + + if err := os.MkdirAll(extractDir, 0o755); err != nil { + return "", fmt.Errorf("create dir: %w", err) + } + if err := os.WriteFile(extractPath, jarData, 0o644); err != nil { + return "", fmt.Errorf("write JAR: %w", err) + } + if err := os.WriteFile(markerPath, []byte(wantHash+"\n"), 0o644); err != nil { + return "", fmt.Errorf("write marker: %w", err) + } + return extractPath, nil +} + +func embeddedJarData() ([]byte, error) { + jarData, err := jarFiles.ReadFile(path.Join("jar", JarName)) + if err != nil { + return nil, fmt.Errorf("embedded %s is missing; build it with 'cd core && ./gradlew :opentaint-sast-test-util:jar', then run 'cd cli && go generate ./internal/testutil': %w", JarName, err) + } + return jarData, nil +} + +func needsExtract(markerPath, wantHash string) bool { + data, err := os.ReadFile(markerPath) + if err != nil { + return true + } + return strings.TrimSpace(string(data)) != wantHash +} diff --git a/cli/internal/utils/bundled_path_test.go b/cli/internal/utils/bundled_path_test.go new file mode 100644 index 000000000..39bbf0d57 --- /dev/null +++ b/cli/internal/utils/bundled_path_test.go @@ -0,0 +1,54 @@ +package utils + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveBundledDir_FHSLayout(t *testing.T) { + prefix := t.TempDir() + binDir := filepath.Join(prefix, "bin") + libDir := filepath.Join(prefix, "lib") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(libDir, 0o755); err != nil { + t.Fatal(err) + } + + if got := resolveBundledDir(binDir, "lib"); got != libDir { + t.Errorf("resolveBundledDir(FHS) = %q, want %q (sibling lib)", got, libDir) + } +} + +func TestResolveBundledDir_FlatLayout(t *testing.T) { + dir := t.TempDir() + libDir := filepath.Join(dir, "lib") + if err := os.MkdirAll(libDir, 0o755); err != nil { + t.Fatal(err) + } + + if got := resolveBundledDir(dir, "lib"); got != libDir { + t.Errorf("resolveBundledDir(flat) = %q, want %q", got, libDir) + } +} + +func TestResolveBundledDir_NoneFallsBackToFlat(t *testing.T) { + binDir := filepath.Join(t.TempDir(), "bin") + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatal(err) + } + + got := resolveBundledDir(binDir, "jre") + want := filepath.Join(binDir, "jre") + if got != want { + t.Errorf("resolveBundledDir(none) = %q, want %q (flat default)", got, want) + } +} + +func TestResolveBundledDir_EmptyExeDir(t *testing.T) { + if got := resolveBundledDir("", "lib"); got != "" { + t.Errorf("resolveBundledDir(\"\") = %q, want empty", got) + } +} diff --git a/cli/internal/utils/copy_file.go b/cli/internal/utils/copy_file.go new file mode 100644 index 000000000..b4758edb4 --- /dev/null +++ b/cli/internal/utils/copy_file.go @@ -0,0 +1,32 @@ +package utils + +import ( + "fmt" + "io" + "os" +) + +func CopyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return fmt.Errorf("open source: %w", err) + } + defer func() { _ = in.Close() }() + + if err := EnsureParentDir(dst); err != nil { + return err + } + + out, err := os.Create(dst) + if err != nil { + return fmt.Errorf("create destination: %w", err) + } + if _, err := io.Copy(out, in); err != nil { + _ = out.Close() + return fmt.Errorf("copy: %w", err) + } + if err := out.Close(); err != nil { + return fmt.Errorf("close destination: %w", err) + } + return nil +} diff --git a/cli/internal/utils/display_version.go b/cli/internal/utils/display_version.go index ce585a205..a92dfa7d1 100644 --- a/cli/internal/utils/display_version.go +++ b/cli/internal/utils/display_version.go @@ -2,24 +2,52 @@ package utils import ( "fmt" + "strings" "github.com/seqra/opentaint/internal/globals" ) -func ArtifactDisplayVersion(def globals.ArtifactDef, jarPathOverride string) string { - resolvedPath := "" - if jarPathOverride == "" && def.Version == "" { - resolvedPath, _ = resolveArtifactPath(def) +func ArtifactDisplayVersion(def globals.ArtifactDef) string { + tier, path, bundledRelease := artifactResolution(def) + return displayVersion(def.Version, def.Override, tier, path, bundledRelease) +} + +func ArtifactVersionWithPath(def globals.ArtifactDef) string { + return strings.TrimPrefix(ArtifactDisplayVersion(def), def.Kind()+"/") +} + +func ArtifactVersion(def globals.ArtifactDef) string { + tier, _, bundledRelease := artifactResolution(def) + if isCustomArtifact(def.Version, def.Override, tier, bundledRelease) { + return "custom" } - return displayVersion(def.Version, jarPathOverride, resolvedPath) + return strings.TrimPrefix(def.Version, def.Kind()+"/") } -func displayVersion(version, overridePath, resolvedPath string) string { - if overridePath != "" { - return customLabel(overridePath) +func artifactResolution(def globals.ArtifactDef) (tier, path string, bundledRelease bool) { + if def.Override == "" { + tier, path, _ = resolveArtifactTier(def) + if tier == TierBundled { + bundledRelease = IsBundledRelease() + } } - if version == "" { - return customLabel(resolvedPath) + return tier, path, bundledRelease +} + +func isCustomArtifact(version, overridePath, resolvedTier string, bundledRelease bool) bool { + if overridePath != "" || version == "" { + return true + } + return resolvedTier == TierBundled && !bundledRelease +} + +func displayVersion(version, overridePath, resolvedTier, resolvedPath string, bundledRelease bool) string { + if isCustomArtifact(version, overridePath, resolvedTier, bundledRelease) { + path := overridePath + if path == "" { + path = resolvedPath + } + return customLabel(path) } return version } diff --git a/cli/internal/utils/display_version_test.go b/cli/internal/utils/display_version_test.go index 2d612f3b4..7df12c528 100644 --- a/cli/internal/utils/display_version_test.go +++ b/cli/internal/utils/display_version_test.go @@ -8,16 +8,18 @@ import ( func TestDisplayVersion(t *testing.T) { tests := []struct { - name string - version string - overridePath string - resolvedPath string - want string + name string + version string + overridePath string + resolvedTier string + resolvedPath string + bundledRelease bool + want string }{ { - name: "pinned version, no override", + name: "pinned version, no override, managed install tier", version: "analyzer/2026.05.27.68ab20a", - overridePath: "", + resolvedTier: TierInstall, resolvedPath: "/opt/opentaint/lib/opentaint-project-analyzer.jar", want: "analyzer/2026.05.27.68ab20a", }, @@ -25,13 +27,14 @@ func TestDisplayVersion(t *testing.T) { name: "jar-path override wins over a present version", version: "analyzer/2026.05.27.68ab20a", overridePath: "/home/dev/build/analyzer.jar", + resolvedTier: TierBundled, resolvedPath: "/home/dev/build/analyzer.jar", want: "custom (/home/dev/build/analyzer.jar)", }, { name: "empty pin falls back to resolved path", version: "", - overridePath: "", + resolvedTier: TierCache, resolvedPath: "/opt/opentaint/lib/opentaint-project-analyzer.jar", want: "custom (/opt/opentaint/lib/opentaint-project-analyzer.jar)", }, @@ -39,31 +42,53 @@ func TestDisplayVersion(t *testing.T) { name: "override takes precedence over empty pin", version: "", overridePath: "/home/dev/build/analyzer.jar", + resolvedTier: TierInstall, resolvedPath: "/opt/opentaint/lib/opentaint-project-analyzer.jar", want: "custom (/home/dev/build/analyzer.jar)", }, + { + name: "bundled tier without release marker is a local build", + version: "rules/v0.2.0", + resolvedTier: TierBundled, + resolvedPath: "/opt/opentaint/lib/rules", + want: "custom (/opt/opentaint/lib/rules)", + }, + { + name: "bundled tier with release marker keeps the pinned version", + version: "analyzer/2026.06.09.fc56601", + resolvedTier: TierBundled, + resolvedPath: "/home/user/.opentaint/install/lib/opentaint-project-analyzer.jar", + bundledRelease: true, + want: "analyzer/2026.06.09.fc56601", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := displayVersion(tt.version, tt.overridePath, tt.resolvedPath) + got := displayVersion(tt.version, tt.overridePath, tt.resolvedTier, tt.resolvedPath, tt.bundledRelease) if got != tt.want { - t.Errorf("displayVersion(%q, %q, %q) = %q, want %q", - tt.version, tt.overridePath, tt.resolvedPath, got, tt.want) + t.Errorf("displayVersion(%q, %q, %q, %q, %v) = %q, want %q", + tt.version, tt.overridePath, tt.resolvedTier, tt.resolvedPath, tt.bundledRelease, got, tt.want) } }) } } -func TestArtifactDisplayVersion(t *testing.T) { - analyzer := globals.ArtifactByKind("analyzer") - - override := analyzer.WithVersion("analyzer/2026.05.27.68ab20a") - if got := ArtifactDisplayVersion(override, "/home/dev/analyzer.jar"); got != "custom (/home/dev/analyzer.jar)" { +func TestArtifactDisplayVersionOverride(t *testing.T) { + override := globals.ArtifactByKind("analyzer").WithVersion("analyzer/2026.05.27.68ab20a") + override.Override = "/home/dev/analyzer.jar" + if got := ArtifactDisplayVersion(override); got != "custom (/home/dev/analyzer.jar)" { t.Errorf("override case: got %q, want %q", got, "custom (/home/dev/analyzer.jar)") } +} - pinned := analyzer.WithVersion("analyzer/2026.05.27.68ab20a") - if got := ArtifactDisplayVersion(pinned, ""); got != "analyzer/2026.05.27.68ab20a" { - t.Errorf("pinned case: got %q, want %q", got, "analyzer/2026.05.27.68ab20a") +func TestArtifactVersionShortVariants(t *testing.T) { + custom := globals.ArtifactByKind("analyzer").WithVersion("analyzer/2026.05.27.68ab20a") + custom.Override = "/home/dev/analyzer.jar" + + if got := ArtifactVersionWithPath(custom); got != "custom (/home/dev/analyzer.jar)" { + t.Errorf("WithPath custom: got %q, want %q", got, "custom (/home/dev/analyzer.jar)") + } + if got := ArtifactVersion(custom); got != "custom" { + t.Errorf("bare custom: got %q, want %q", got, "custom") } } diff --git a/cli/internal/utils/ensure_rules.go b/cli/internal/utils/ensure_rules.go new file mode 100644 index 000000000..0b8806edd --- /dev/null +++ b/cli/internal/utils/ensure_rules.go @@ -0,0 +1,24 @@ +package utils + +import ( + "github.com/seqra/opentaint/internal/globals" + "github.com/seqra/opentaint/internal/output" +) + +func EnsureRulesPath(printer *output.Printer) (string, error) { + path, err := GetRulesPath(globals.Config.Rules.Version) + if err != nil { + return "", err + } + if PathExists(path) { + return path, nil + } + if err := DownloadAndUnpackGithubReleaseAsset( + globals.Config.Owner, globals.Config.Repo, + globals.Config.Rules.Version, globals.RulesAssetName, + path, globals.Config.Github.Token, globals.Config.SkipVerify, printer, + ); err != nil { + return path, err + } + return path, nil +} diff --git a/cli/internal/utils/java/runner.go b/cli/internal/utils/java/runner.go index a2878250f..392fd7262 100644 --- a/cli/internal/utils/java/runner.go +++ b/cli/internal/utils/java/runner.go @@ -355,8 +355,7 @@ func NewJavaRunner() JavaRunner { } func (j *javaRunner) findBundledJRE() string { - tiers := utils.CurrentTiers(utils.ManagedJRETiers(), utils.IsInstallCurrent()) - if tier := utils.FindExistingJRE(tiers); tier != nil { + if tier := utils.FindCurrentManagedJRE(); tier != nil { return utils.JavaBinaryPath(tier.Path) } return "" diff --git a/cli/internal/utils/opentaint_command_builder.go b/cli/internal/utils/opentaint_command_builder.go index f3b746f30..1356a557b 100644 --- a/cli/internal/utils/opentaint_command_builder.go +++ b/cli/internal/utils/opentaint_command_builder.go @@ -181,6 +181,31 @@ func (cb *OpentaintCommandBuilder) WithRuleID(ruleIDs []string) *OpentaintComman return cb } +func (cb *OpentaintCommandBuilder) WithPassthroughApproximations(paths []string) *OpentaintCommandBuilder { + for _, p := range paths { + if p != "" { + cb.arrayFlags["passthrough-approximations"] = append(cb.arrayFlags["passthrough-approximations"], p) + } + } + return cb +} + +func (cb *OpentaintCommandBuilder) WithDataflowApproximations(paths []string) *OpentaintCommandBuilder { + for _, p := range paths { + if p != "" { + cb.arrayFlags["dataflow-approximations"] = append(cb.arrayFlags["dataflow-approximations"], p) + } + } + return cb +} + +func (cb *OpentaintCommandBuilder) WithTrackExternalMethods(enabled bool) *OpentaintCommandBuilder { + if enabled { + cb.boolFlags["track-external-methods"] = true + } + return cb +} + // WithPartialFingerprint adds repeatable --partial-fingerprint filters. func (cb *OpentaintCommandBuilder) WithPartialFingerprint(fingerprints []string) *OpentaintCommandBuilder { for _, f := range fingerprints { diff --git a/cli/internal/utils/opentaint_home.go b/cli/internal/utils/opentaint_home.go index 44377f4b4..c397e10ce 100644 --- a/cli/internal/utils/opentaint_home.go +++ b/cli/internal/utils/opentaint_home.go @@ -30,8 +30,7 @@ func GetOpenTaintHome() (string, error) { return path, nil } -// pathExists reports whether a path exists on disk. -func pathExists(p string) bool { +func PathExists(p string) bool { _, err := os.Stat(p) return err == nil } @@ -50,22 +49,30 @@ func exeDir() string { return filepath.Dir(exe) } +func resolveBundledDir(exeDir, name string) string { + if exeDir == "" { + return "" + } + flat := filepath.Join(exeDir, name) + if PathExists(flat) { + return flat + } + if sibling := filepath.Join(exeDir, "..", name); PathExists(sibling) { + return sibling + } + return flat +} + // GetBundledLibPath returns the path to the bundled lib directory next to the binary. // Returns empty string if the path cannot be determined. func GetBundledLibPath() string { - if dir := exeDir(); dir != "" { - return filepath.Join(dir, "lib") - } - return "" + return resolveBundledDir(exeDir(), "lib") } // GetBundledJREPath returns the path to the bundled JRE directory next to the binary. // Returns empty string if the path cannot be determined. func GetBundledJREPath() string { - if dir := exeDir(); dir != "" { - return filepath.Join(dir, "jre") - } - return "" + return resolveBundledDir(exeDir(), "jre") } // GetInstallDir returns the path to ~/.opentaint/install/. @@ -96,6 +103,24 @@ func GetInstallJREPath() string { return "" } +// VersionMarkerName is the byte-for-byte copy of the embedded versions.yaml +// dropped alongside an artifact tier so a later run can detect whether that +// tier matches the current bind version. Used both next to the binary (bundled +// tier) and in ~/.opentaint/install/ (install tier). +const VersionMarkerName = ".versions" + +func IsBundledRelease() bool { + lib := GetBundledLibPath() + if lib == "" { + return false + } + data, err := os.ReadFile(filepath.Join(lib, VersionMarkerName)) + if err != nil { + return false + } + return bytes.Equal(data, globals.GetVersionsYAML()) +} + // IsInstallCurrent reports whether the install-tier version marker matches // the embedded versions.yaml. Returns false if the marker is missing or differs. func IsInstallCurrent() bool { @@ -103,7 +128,7 @@ func IsInstallCurrent() bool { if installDir == "" { return false } - data, err := os.ReadFile(filepath.Join(installDir, ".versions")) + data, err := os.ReadFile(filepath.Join(installDir, VersionMarkerName)) if err != nil { return false } @@ -120,7 +145,7 @@ func WriteInstallVersionMarker() error { if err := os.MkdirAll(installDir, 0o755); err != nil { return err } - return os.WriteFile(filepath.Join(installDir, ".versions"), globals.GetVersionsYAML(), 0o644) + return os.WriteFile(filepath.Join(installDir, VersionMarkerName), globals.GetVersionsYAML(), 0o644) } // CleanInstallDir removes the install-tier lib and jre directories along with @@ -130,7 +155,7 @@ func CleanInstallDir() error { if installDir == "" { return nil } - for _, sub := range []string{"lib", "jre", ".versions"} { + for _, sub := range []string{"lib", "jre", VersionMarkerName} { if err := os.RemoveAll(filepath.Join(installDir, sub)); err != nil { return err } @@ -151,35 +176,35 @@ func ReconcileInstallMarker() { return } for _, def := range globals.Artifacts() { - if !pathExists(filepath.Join(installLib, def.LibSubpath)) { + if !PathExists(filepath.Join(installLib, def.LibSubpath)) { return } } _ = WriteInstallVersionMarker() } -// resolveArtifactPath resolves the path for an artifact by checking tiers in order: -// 1. Bundled path (next to binary) — only if version matches bindVersion -// 2. Install path (~/.opentaint/install/lib/) — only if version matches bindVersion -// 3. Cache path (~/.opentaint/) -func resolveArtifactPath(def globals.ArtifactDef) (string, error) { +func resolveArtifactTier(def globals.ArtifactDef) (string, string, error) { tiers, err := ArtifactTiers(def) if err != nil { - return "", err + return "", "", err } if found := FindExisting(CurrentTiers(tiers, IsInstallCurrent())); found != nil { - return found.Path, nil + return found.Name, found.Path, nil } - // Return last tier as default download target (even if artifact not yet downloaded) - return tiers[len(tiers)-1].Path, nil + last := tiers[len(tiers)-1] + return last.Name, last.Path, nil } -func GetAutobuilderJarPath(version string) (string, error) { - return resolveArtifactPath(globals.ArtifactByKind("autobuilder").WithVersion(version)) +func resolveArtifactPath(def globals.ArtifactDef) (string, error) { + _, path, err := resolveArtifactTier(def) + return path, err } -func GetAnalyzerJarPath(version string) (string, error) { - return resolveArtifactPath(globals.ArtifactByKind("analyzer").WithVersion(version)) +func ResolveJarPath(def globals.ArtifactDef) (string, error) { + if def.Override != "" { + return def.Override, nil + } + return resolveArtifactPath(def) } func GetRulesPath(version string) (string, error) { diff --git a/cli/internal/utils/opentaint_home_test.go b/cli/internal/utils/opentaint_home_test.go index 962e33bc7..5ca353560 100644 --- a/cli/internal/utils/opentaint_home_test.go +++ b/cli/internal/utils/opentaint_home_test.go @@ -37,7 +37,7 @@ func TestIsInstallCurrent_StaleMarker(t *testing.T) { t.Fatal(err) } // Write a marker with different content - if err := os.WriteFile(filepath.Join(installDir, ".versions"), []byte("old-content"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(installDir, VersionMarkerName), []byte("old-content"), 0o644); err != nil { t.Fatal(err) } @@ -57,7 +57,7 @@ func TestCleanInstallDir(t *testing.T) { // Create install dirs with content createTestFile(t, filepath.Join(libDir, "artifact.jar"), 100) createTestFile(t, filepath.Join(jreDir, "bin", "java"), 50) - if err := os.WriteFile(filepath.Join(installDir, ".versions"), []byte("marker"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(installDir, VersionMarkerName), []byte("marker"), 0o644); err != nil { t.Fatal(err) } @@ -65,8 +65,8 @@ func TestCleanInstallDir(t *testing.T) { t.Fatalf("CleanInstallDir() error = %v", err) } - // Verify lib, jre, and .versions are removed - for _, p := range []string{libDir, jreDir, filepath.Join(installDir, ".versions")} { + // Verify lib, jre, and the version marker are removed + for _, p := range []string{libDir, jreDir, filepath.Join(installDir, VersionMarkerName)} { if _, err := os.Stat(p); !os.IsNotExist(err) { t.Errorf("expected %s to be removed", p) } @@ -106,7 +106,7 @@ func TestWriteInstallVersionMarker_Content(t *testing.T) { t.Fatalf("WriteInstallVersionMarker() error = %v", err) } - markerPath := filepath.Join(home, ".opentaint", "install", ".versions") + markerPath := filepath.Join(home, ".opentaint", "install", VersionMarkerName) data, err := os.ReadFile(markerPath) if err != nil { t.Fatalf("failed to read marker: %v", err) diff --git a/cli/internal/utils/tier.go b/cli/internal/utils/tier.go index e179906f4..e709cdc05 100644 --- a/cli/internal/utils/tier.go +++ b/cli/internal/utils/tier.go @@ -116,6 +116,10 @@ func JRETiers(javaVersion int, cacheDir string) []Tier { return tiers } +func FindCurrentManagedJRE() *Tier { + return FindExistingJRE(CurrentTiers(ManagedJRETiers(), IsInstallCurrent())) +} + // ManagedJRETiers returns the bundled and install JRE tiers (excluding cache). // Used to find a pre-installed JRE without triggering a download. func ManagedJRETiers() []Tier { diff --git a/cli/internal/utils/updater.go b/cli/internal/utils/updater.go index 96b603702..235e36d23 100644 --- a/cli/internal/utils/updater.go +++ b/cli/internal/utils/updater.go @@ -199,8 +199,8 @@ func SelfUpdate(archivePath, installDir string) error { // Preserve the installation style: if bundled artifacts exist next to the // binary, update them in place. Otherwise, place into the install tier // (~/.opentaint/install/) so bare-binary installations stay bare. - libBundled := pathExists(filepath.Join(installDir, "lib")) - jreBundled := pathExists(filepath.Join(installDir, "jre")) + libBundled := PathExists(filepath.Join(installDir, "lib")) + jreBundled := PathExists(filepath.Join(installDir, "jre")) if err := updateArtifactDir(tmpDir, "lib", libBundled, installDir); err != nil { output.LogInfof("Failed to update lib directory: %v", err) @@ -215,7 +215,7 @@ func SelfUpdate(archivePath, installDir string) error { // (called from PersistentPreRunE) handles it on the new binary's first run. if !libBundled || !jreBundled { if dir := GetInstallDir(); dir != "" { - _ = os.Remove(filepath.Join(dir, ".versions")) + _ = os.Remove(filepath.Join(dir, VersionMarkerName)) } } diff --git a/cli/internal/utils/write_files.go b/cli/internal/utils/write_files.go new file mode 100644 index 000000000..7c7f87926 --- /dev/null +++ b/cli/internal/utils/write_files.go @@ -0,0 +1,18 @@ +package utils + +import ( + "fmt" + "os" +) + +func WriteFiles(files map[string][]byte) error { + for path, content := range files { + if err := EnsureParentDir(path); err != nil { + return err + } + if err := os.WriteFile(path, content, 0o644); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + } + return nil +} diff --git a/docs/README.md b/docs/README.md index ae2eaf161..35484236d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -103,6 +103,7 @@ Each finding includes the HTTP endpoint, making it easy to map your application' | Method | Command | |--------|---------| | **Homebrew** (Linux/macOS) | `brew install --cask seqra/tap/opentaint` | +| **npm / npx** (Linux/macOS/Windows) | `npm install -g @seqra/opentaint` — or `npx @seqra/opentaint scan` to run without installing (needs Node.js) | | **Install script** (Linux/macOS) | `curl -fsSL https://opentaint.org/install.sh \| bash` | | **Install script** (Windows PowerShell) | `irm https://opentaint.org/install.ps1 \| iex` | | **Install script** (Windows CMD) | `curl -fsSL https://opentaint.org/install.cmd -o install.cmd && install.cmd && del install.cmd` | @@ -119,6 +120,7 @@ For detailed instructions, see [Installation Guide](installation.md). ```bash opentaint scan # Scan current directory +npx @seqra/opentaint scan # Run without installing (needs Node.js) opentaint scan --output results.sarif # Scan with explicit output path opentaint summary --show-findings results.sarif # View results opentaint summary --show-findings --verbose-flow --show-code-snippets results.sarif # Full detail @@ -130,6 +132,9 @@ opentaint summary --show-findings --verbose-flow --show-code-snippets results.sa | `opentaint compile` | Build project model separately | | `opentaint project` | Create model from precompiled JARs | | `opentaint summary` | View SARIF results | +| `opentaint health` | Show resolved analyzer, autobuilder, rules, and runtime paths | +| `opentaint test rule` | Scaffold, test, and debug detection rules | +| `opentaint test approximation` | Scaffold and test dataflow approximations | | `opentaint pull` | Download dependencies | | `opentaint update` | Update to latest version | | `opentaint prune` | Remove stale artifacts and cached models | diff --git a/docs/installation.md b/docs/installation.md index c31fa2999..d8d6b2d45 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -12,9 +12,13 @@ brew install --cask seqra/tap/opentaint If you have Node.js installed, you can install opentaint from npm. The package bundles the analyzer, rules, and a Java runtime, so no separate Java install is required. -Run without installing: +Run without installing — `npx` downloads the package and runs any command directly: ```bash +# Scan the current directory +npx @seqra/opentaint scan + +# Quick smoke test npx @seqra/opentaint --version ``` diff --git a/docs/usage.md b/docs/usage.md index 295a71fa7..84f9359ed 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -1,5 +1,7 @@ # Usage +> **Run without installing:** every `opentaint ` below can be run install-free with `npx @seqra/opentaint ` (requires Node.js), e.g. `npx @seqra/opentaint scan`. See [Installation](installation.md#npm). + ## Scanning Projects ```bash @@ -80,6 +82,9 @@ Use [CodeChecker](https://github.com/Ericsson/codechecker) for advanced result m | `opentaint compile` | Build project model separately from scanning | | `opentaint project` | Create project model from precompiled JARs/classes | | `opentaint summary` | View SARIF analysis results | +| `opentaint health` | Show resolved paths for the analyzer, autobuilder, rules, and Java runtime | +| `opentaint test rule` | Create, run, and debug detection-rule tests | +| `opentaint test approximation` | Create and run dataflow-approximation tests | | `opentaint pull` | Download analyzer dependencies | | `opentaint update` | Update to latest version | | `opentaint prune` | Remove stale downloaded artifacts and cached models | @@ -102,6 +107,72 @@ On the first run, the compiled project model is cached in `~/.opentaint/cache/`. | `--dry-run` | Validate inputs and show what would run without compiling or scanning | | `--log-file` | Path to the log file (default: `/logs/.log`) | +#### Rule-authoring flags + +These flags are to work with custom approximations: + +| Flag | Description | +|------|-------------| +| `--track-external-methods` | Write external-method coverage files next to the SARIF report | +| `--passthrough-approximations` | Apply pass-through approximation YAML files or directories (repeatable) | +| `--dataflow-approximations` | Apply dataflow approximation classes or Java source directories (repeatable) | + +Use external-method tracking when a scan may miss flows through library methods. The dropped-methods file shows where taint was killed because no model was available; the approximated-methods file shows methods already covered by built-in or custom models. + +### opentaint health + +Show the on-disk paths OpenTaint uses for its dependencies: + +```bash +opentaint health +opentaint health --rules +opentaint health --analyzer +``` + +With no flags, `health` shows the autobuilder, analyzer, built-in rules, and Java runtime. With a single component flag, it prints only the bare path, which is useful for scripts. + +| Flag | Description | +|------|-------------| +| `--autobuilder` | Print only the autobuilder JAR path | +| `--analyzer` | Print only the analyzer JAR path | +| `--rules` | Print only the built-in rules path, downloading rules if needed | +| `--runtime` | Print only the Java runtime path | + +### opentaint test + +The `test` command group is tooling for rule and approximation development. + +#### Rule tests + +```bash +opentaint test rule init .opentaint/test-projects/my-rule +opentaint compile .opentaint/test-projects/my-rule/sinks -o .opentaint/test-compiled/my-rule/sinks +opentaint test rule run .opentaint/test-compiled/my-rule/sinks --ruleset .opentaint/rules --ruleset .opentaint/test-projects/my-rule/sinks/test-rules +opentaint test rule reachability java/security/my-rule.yaml:my-rule --project-model .opentaint/test-compiled/my-rule/sinks --ruleset builtin --ruleset .opentaint/rules +``` + +| Command | Description | +|---------|-------------| +| `opentaint test rule init ` | Create source and sink test projects with annotated sample support | +| `opentaint test rule run ` | Run detection-rule tests on a compiled project model | +| `opentaint test rule reachability [source-path]` | Trace why a rule can or cannot reach its facts | + +#### Approximation tests + +```bash +opentaint test approximation init .opentaint/test-projects/my-approximation +opentaint compile .opentaint/test-projects/my-approximation -o .opentaint/test-compiled/my-approximation +opentaint test approximation run .opentaint/test-compiled/my-approximation \ + --dataflow-approximations .opentaint/dataflow/my-approximation +``` + +| Command | Description | +|---------|-------------| +| `opentaint test approximation init ` | Create a test project with a fixed `Taint.source()` to `Taint.sink(...)` harness | +| `opentaint test approximation run ` | Run dataflow approximation tests on a compiled project model | + +Rule and approximation test runs write `test-result.json` and `test-results.sarif` to the selected output directory. + ### opentaint compile Compiles Java and Kotlin projects and generates project models for analysis. Useful when you want to separate compilation from scanning or need to inspect the project model. diff --git a/skills/analyze-external-methods/SKILL.md b/skills/analyze-external-methods/SKILL.md new file mode 100644 index 000000000..6fbb3da9c --- /dev/null +++ b/skills/analyze-external-methods/SKILL.md @@ -0,0 +1,91 @@ +--- +name: analyze-external-methods +description: Analyze and group an OpenTaint scan's dropped external methods and decide what to approximate or skip. Use when a dropped-external-methods.yaml needs turning into approximation targets +license: Apache-2.0 +metadata: + author: opentaint + version: "0.2" +--- + +# Skill: Analyze External Methods + +Read the methods where the analyzer lost track of the data, group them by library and kind, and record per group what to model and how — so the right skill can build each approximation + +## Inputs + +From the caller; if omitted, fall back to the default. Ask only when a required input is missing and has no sensible default + +- Dropped methods `` — methods where the analyzer dropped the data for lack of a model. Default: `.opentaint/results/dropped-external-methods.yaml` +- Tracking directory `` — where approximation tracking files are written. Default: `.opentaint/tracking` +- Project root `` — sources and build files, to resolve which library owns each method. Default: current directory + +## Workflow + +Requires ``, without it there's nothing to group + +### 1. Group by package and kind + +Every method in `` is a place the data is lost for lack of a model — model all of them. First decide each method's kind: + +- passthrough — data moves by a simple from→to copy: a getter, arg→result, builder, container field, collection `add`/`get`, `StringBuilder.append`, `Stream.collect` +- dataflow — data flows through a lambda/callback/functional interface or an async chain + +Group by package AND kind — one tracking file per (package, kind): `-passthrough.yaml` for the simple copies, `-dataflow.yaml` for the lambda/callback/async ones. `` is the dotted Java package with `.` replaced by `-` (e.g. `reactor.core.publisher` → `reactor-core-publisher`) so it's filesystem-friendly; the YAML `package:` field keeps the real dotted name. Kind is the only split (no finer sub-groups). Each unit is one agent's work + +### 2. Flag methods to skip + +The one exception: a few methods the engine asks about don't affect the data flow — logging, metrics (e.g. `org.slf4j.Logger#info`). List those in `skipped.yaml` instead of an approximation group; the default call-to-return behavior is already correct for them + +## Output + +- One `/approximations/-.yaml` per (package, kind), with `stages.description: done` and its `methods` (each `target` + `type`); a dataflow unit also carries `dependencies` (the library's exact Maven GAV its test project needs) +- `/approximations/skipped.yaml` listing the skip methods +- A brief summary to the caller: one line per unit (package, kind, method count) plus the skip count. Don't paste the method lists back — the tracking files hold them + +## Tracking + +Create one file per (package, kind); fill only the discovery-stage fields. The two kinds differ — passThrough is written and verified by the scan, dataflow is built and tested on a test project: + +```yaml +# -passthrough.yaml — simple copies, no test project +package: com.foo +artifact: null +stages: + description: done + written: pending +notes: > + DTO getters returning fields that carry the data +methods: + - target: "com.foo.Wrapper#getValue" + type: passthrough +``` + +```yaml +# -dataflow.yaml — lambda/callback/async, tested on a test project +package: com.foo +artifact: null +dependencies: # exact GAV the test project needs, from the build files + - com.foo:foo-core:1.2.3 +stages: + description: done + test_project: pending + tests_passing: pending +notes: > + Reactor operators carrying data through the mapper +methods: + - target: "com.foo.Reactor#flatMap" + type: dataflow +``` + +```yaml +# skipped.yaml — engine asks to approximate these, but they don't affect the data flow +methods: + - "org.slf4j.Logger#info" + - "org.slf4j.Logger#debug" +``` + +## Gotchas + +- Model every method in `` — each is a real place the data is lost; don't second-guess the list. The only exceptions are the obvious methods that don't move data, which you move to `skipped.yaml` +- Approximate only external library methods — never an application-internal class. If one shows up as a candidate, drop it +- One file = one (package, kind) = one agent: passThrough and dataflow go in separate files; never put a method in two, or two agents collide diff --git a/skills/analyze-findings/SKILL.md b/skills/analyze-findings/SKILL.md new file mode 100644 index 000000000..da21ea622 --- /dev/null +++ b/skills/analyze-findings/SKILL.md @@ -0,0 +1,68 @@ +--- +name: analyze-findings +description: Triage OpenTaint findings — split a rule's results into distinct vulnerabilities and classify each true positive or false positive. Use when scan findings need a TP/FP verdict +license: Apache-2.0 +metadata: + author: opentaint + version: "0.2" +--- + +# Skill: Analyze Findings + +A finding file bundles all of one rule's results. Read each result's code flow, split the bundle into distinct vulnerabilities, and give each a TP/FP verdict on its own evidence + +## Inputs + +From the caller; if omitted, fall back to the default. Ask only when a required input is missing and has no sensible default + +- Findings to triage `` — the finding tracking file(s); each bundles all of one rule's SARIF results in `sarif_hashes` +- SARIF report `` — the raw scan output holding the code-flow traces. Default: `.opentaint/results/report.sarif` + +## Workflow + +### 1. One result at a time — STOP checklist + +For each hash in the bundle, before any verdict: + +- found its SARIF result via `sarif_hashes` and read the raw `codeFlows[]` +- walk every step, source → hops → sink, confirming it's the same tainted value end to end +- judging each result on its own trace — no verdict shared across results just because they share the rule + +### 2. Split the bundle into logical findings + +The results in the file all fired one rule, but may be several different vulnerabilities. Keep results that are the same vulnerability (same sink, same essential flow) together as one finding; move genuinely distinct ones (different sink, or a different flow) into their own finding file with a new `finding_name` and their `sarif_hashes` + +### 3. Classify and record + +Verdict each logical finding from its flow: + +- TP — the source is attacker-controlled, the sink is genuinely dangerous with that input, and nothing sanitizes it in between +- FP — a sanitizer/validator neutralizes it, the source isn't actually attacker-controlled (config, constant, server-set), the sink is safe for this input (parameterized, escaped), or the path is infeasible. Record which one + +Set `verdict` and append the reasoning to `notes`, below the analyzer report already seeded there + +## Output + +- Each logical finding in its own file with `verdict` set and the rationale in `notes` +- A brief summary to the caller: one line per finding — name, verdict, one-clause reason + +## Tracking + +Editing an existing finding touches only `verdict` and `notes`. A split also creates a new finding file — give it the full shape, copying `rule_id` from the bundle and moving over the results' `sarif_hashes` and their analyzer report: + +```yaml +finding_name: # a fresh docker-like name for the split-off vuln +sarif_hashes: [, ...] # hashes matching this logical vulnerability +rule_id: java/security/sqli.yaml:sqli # same rule as the bundle it came from +verdict: TP # pending | TP | FP +notes: > + + triage: @RequestParam orderBy is attacker-controlled; reaches ${} in SelectProvider unsanitized → TP +poc: pending +poc_script: null +``` + +## Gotchas + +- Bulk verdicts are the most common triage error — many results under one shared rationale with the traces unread. One trace, one judgment +- A rule's bundle is not one finding — split distinct vulnerabilities apart, but keep true duplicates (same sink and flow) together as one finding with multiple `sarif_hashes` diff --git a/skills/appsec-agent/SKILL.md b/skills/appsec-agent/SKILL.md new file mode 100644 index 000000000..312742473 --- /dev/null +++ b/skills/appsec-agent/SKILL.md @@ -0,0 +1,269 @@ +--- +name: appsec-agent +description: Run an end-to-end application-security analysis on a JVM project with OpenTaint — build, scan, model missing library methods, triage, and confirm vulnerabilities. Use when the user asks to find vulnerabilities, run SAST, or scan a Java/Kotlin app for security issues +license: Apache-2.0 +metadata: + author: opentaint + version: "0.2" +--- + +# AppSec Agent + +Orchestrate an end-to-end OpenTaint analysis of a JVM project: run the workflow the user picks by dispatching each step to a subagent that loads one leaf skill, verifying the artifact it returns, and tracking progress. The leaf work is never done here. OpenTaint is a dataflow (taint) SAST analyzer; the goal is real, confirmed vulnerabilities. + +The run is one pipeline of a few steps, each gated by the chosen workflow; a step's detail lives in a reference loaded when you reach it, while what every workflow shares stays in this file. Default to the current directory when no target is named. + +Keep every artifact under one `.opentaint/` directory at the project root — models, rules, configs, approximations, test projects, results, tracking, PoCs, reports. Don't scatter files outside it. + +## Setup + +Before anything else, confirm `opentaint` is on PATH (`command -v opentaint` / `opentaint --version`). If it's missing, don't proceed silently — tell the user and ask to install it, offering the command for their platform; run an install only on explicit confirmation: + +macOS / Linux — try in order: + +1. Homebrew — `brew install --cask seqra/tap/opentaint` +2. npm — `npm install -g @seqra/opentaint` +3. shell script — `curl -fsSL https://opentaint.org/install.sh | bash` + +Windows — try in order: + +1. npm — `npm install -g @seqra/opentaint` +2. PowerShell script — `irm https://opentaint.org/install.ps1 | iex` + +After installing, run `opentaint health` to confirm the autobuilder/analyzer/rules/runtime resolve. + +## Choose a workflow + +Begin by asking the user both things in a single AskUserQuestion call — two questions, scan level and triage level, presented together (never one call then another). Record the chosen `scan_level` and `triage_level` in `state.yaml`: + +1. Scan level — `lite` · `normal` · `deep` + - lite — build + scan with existing rules + - normal — + approximation iteration + - deep — + discover-attack-surface for project-used dependency members + new rules (fixed first) +2. Triage level — `static` · `dynamic` + - static — classify findings from the model, no running app + - dynamic — + a PoC per confirmed TP. This launches a few test services on the user's current machine (local instances and ports); they're torn down at the end of the run. Make that clear in the option + +The run is one fixed pipeline; the two levels decide which steps execute. Walk it top to bottom — when you reach a step your levels include, load its reference and do it; skip the bracketed steps your levels omit. Don't load a step's reference until you reach it. + +``` +build → references/build.md every run +[deep] discover project-used lib rules → references/discover-rules.md deep scan +scan → references/scan.md every run +[normal/deep] approximation iteration → references/approximations.md normal, deep scan +triage (generate findings + classify) → references/triage.md every run +[dynamic] PoC + assemble vulnerabilities → references/poc.md dynamic triage +``` + +From inside any step, when a rule or approximation won't behave, load references/escalation.md. Only the approximation iteration loops (it re-scans internally); new rules are fixed before it. + +## Delegation + +Every block's work runs in subagents. Dispatch each with this template: + +``` +Invoke the Skill tool with skill_id= first, then do the task. +Inputs: + : # one line per input the skill lists +Return: + , plus the exact command you ran to verify +Do not run `opentaint scan`. Do not write `.opentaint/vulnerabilities.md`. +``` + +Universal rules — every dispatch, every workflow: + +- open the prompt with the Skill-load line — the subagent has none of this context until it loads its skill +- pass resolved paths (the ``-keyed `.opentaint/...` paths from Working directory layout), never the placeholder tokens +- read the named output artifact yourself before continuing — a claim is not an artifact +- only run-scan scans the main project model; rule/approximation/triage subagents don't — the one exception is a create-rule agent running a diagnostic `--track-external-methods` scan of its own test project (never the main model) +- only you write `.opentaint/vulnerabilities.md` and `.opentaint/tracking/state.yaml` +- never swap the project model mid-analysis; every run uses the same model +- never triage yourself — verdicts come only from analyze-findings subagents + +Orchestration practices: + +- Units fan out in parallel — independent `` paths, no races +- the sole sequential exception is PoC (shared app state and ports); see references/poc.md +- Steps within a unit are sequential via the artifact on disk — dispatch step N only after step N−1's named artifact exists; never bundle steps into one dispatch +- write `state.yaml` at each fan-out join — a phase flips to `done` only once every unit's artifact exists on disk + +## Resource limits + +Two limits apply to every fan-out — a global one against rate-limiting, and a tighter one against memory: + +- Global cap of 7 — never dispatch more than 7 subagents at once, of any kind. Bursting more reliably trips transient rate-limiting. It binds light and heavy agents alike. Treat 7 as a starting ceiling: each time a subagent comes back rate-limited, drop the cap by 1 for the rest of the run +- RAM-heavy agents each spawn a heavy `opentaint` JVM, so they take a tighter memory bound on top of the global cap. The heavy set is exactly `build-project`, `run-scan`, `create-rule`, `create-dataflow-approximation`, and sometimes `debug-rule` (when it traces a real scan). Compute the bound at run start and never dispatch more than this many heavy subagents at once: + - cores — `nproc` (Linux) / `sysctl -n hw.ncpu` (macOS) + - free memory in GB — `free -g` (Linux, the `available` column) / `sysctl -n hw.memsize` ÷ 1024³ (macOS) + - `cap_heavy = max(1, min(cores, floor(free_GB / 2), 7))` — budget ~2 GB per concurrent JVM +- Every other agent is not RAM-bound — discover-attack-surface, create-test-project (compiles once), triage-dependencies, analyze-external-methods, analyze-findings, create-pass-through-approximation, assemble-lib-rules, generate-poc. They're held only by the global cap of 7 + +It's machine state, not run state — recompute on resume, don't track it. PoC is already sequential. + +## State and resumption + +You are the only writer of `.opentaint/tracking/state.yaml` — it records the chosen levels and every phase's status, written after each fan-out join. + +On start, and after any compaction, reconstruct position from artifacts before doing anything — never replay a completed phase: + +- read `state.yaml` and the `tracking/` tree +- skip any phase whose artifact exists: `project.yaml` → build; `coverage.yaml` with every entry `done` → discover; a lib unit's `tests_passing: done` → that package's lib rules, and a `rules/join/.yaml` per vuln class → joins assembled; `report.sarif` → scan; an approximation unit's `artifact` (plus `tests_passing` for dataflow) → that unit; a finding with `verdict` set → triaged; with `poc` set → PoC'd +- detect new work from artifacts, not memory: finding files with `verdict: pending` (a fresh or reset scan) → triage; methods in `dropped-external-methods.yaml` not yet in any approximation unit → approximations + +## Tracking layout + +The single source of truth for the tracking schema; each skill writes only its own slice (named in its block reference). The `#` comments in the YAML below are for understanding only — never copy them into produced files. + +``` +.opentaint/tracking/ + state.yaml # you only — levels + phase status + coverage.yaml # triage-dependencies seeds, discover-attack-surface flips — one entry per dependency package weighed (deep) + usage/.yaml # discover-attack-surface writes project-used package members (deep) + findings/.yaml # one per logical finding (from the SARIF→finding script; split by triage) + rules/lib/.yaml # per-package project-used rule plan — new source/sink lib rules (discover plans; create-* build + test vs the marker) (deep) + rules/join/.yaml # per-vuln-class security join (assemble-lib-rules writes; main scan verifies) (deep) + approximations/-passthrough.yaml # simple from→to copies; write-only, scan-verified + approximations/-dataflow.yaml # lambda/callback/async; tested on a test project + approximations/skipped.yaml # methods the engine asks for but that carry no taint + poc-servers.yaml # generate-poc — instances it started; you reap them at end of PoC phase +``` + +state.yaml: + +```yaml +scan_level: deep # lite | normal | deep +triage_level: dynamic # static | dynamic +phases: # pending | in_progress | done + build: done + discover: done # deep only + rules: done # deep only; fixed first + scan: done + approximations: in_progress # normal/deep; iterative, rescans within + triage: pending + poc: pending # dynamic triage +``` + +coverage.yaml — seeded by triage-dependencies and flipped by discover-attack-surface (deep): one entry per dependency package weighed, so you can see which libraries were drilled and which were dismissed. A `pending` entry is a flagged library awaiting its depth pass; the rule plan lives in `rules/lib/.yaml`, not here: + +```yaml +packages: + - package: org.springframework.web.reactive.function + status: done # pending (flagged, awaiting depth) | done (drilled or dismissed) + notes: > + free-form — what was found and why +``` + +findings/.yaml — created by the SARIF→finding script; `verdict`/`notes` by analyze-findings; `poc`/`poc_script` by generate-poc: + +```yaml +finding_name: brave-hopper +sarif_hashes: [, ...] +rule_id: java/security/sqli.yaml:sqli +verdict: pending # pending | TP | FP +notes: > # analyzer report, then triage and PoC notes + +poc: pending # pending | confirmed | failed +poc_script: null # path under .opentaint/pocs/ once generate-poc writes one +``` + +rules/lib/.yaml — per-package rule plan for project-used sources/sinks only; `description` fields + `sources`/`sinks` by discover-attack-surface, `test_project` by create-test-project, `tests_passing` + `rule_id`s + `artifact` by create-rule. `coverage: new` ⇒ write a pattern, `expand` ⇒ ref the built-in plus the missing used methods: + +```yaml +package: org.springframework.web.reactive.function.client +dependencies: [org.springframework:spring-webflux:6.1.0] +builtin_coverage: partial # partial | none +artifact: null # create-rule +sources: + - idea: ServerRequest body/params — untrusted request data + coverage: new # new | expand + builtin: null + rule_id: null +sinks: + - vuln_class: ssrf + idea: WebClient.post/put().uri($UNTRUSTED) + coverage: expand + builtin: java/lib/generic/ssrf-sinks.yaml#java-ssrf-sink + rule_id: null +stages: # pending | in_progress | done + description: done + test_project: pending + tests_passing: pending +notes: > + free-form +``` + +rules/join/.yaml — one file per vuln class, written by assemble-lib-rules after the lib rules exist and verified by the main scan. A join references exactly ONE sink rule, so a class with several sinks holds several joins — one entry under `joins:` per sink rule, each its own file/id: + +```yaml +name: ssrf +sources: + - ref: java/lib/generic/servlet-untrusted-data-source.yaml#java-servlet-untrusted-data-source + - ref: java/lib/spring/webflux-request-source.yaml#webflux-request-source +joins: + - rule_id: java/security/ssrf-webclient-ssrf-sink-lib-ext.yaml:ssrf-webclient-ssrf-sink-lib-ext + artifact: .opentaint/rules/java/security/ssrf-webclient-ssrf-sink-lib-ext.yaml + sink: { new: java/lib/spring/webclient-ssrf-sink.yaml#webclient-ssrf-sink } + - rule_id: java/security/ssrf-java-ssrf-sink-lib-ext.yaml:ssrf-java-ssrf-sink-lib-ext + artifact: .opentaint/rules/java/security/ssrf-java-ssrf-sink-lib-ext.yaml + sink: { builtin: java/lib/generic/ssrf-sinks.yaml#java-ssrf-sink } +stages: # pending | in_progress | done + written: done + verified: pending +notes: > + free-form +``` + +approximations/-.yaml — created by analyze-external-methods (`description` + `methods`); `` = the dotted package with `.` -> `-` (the YAML `package:` field keeps the real dotted name). The stages differ by kind: + +```yaml +package: com.foo +artifact: null # added once the file exists +stages: + description: done + written: pending # passthrough only (write-only, scan-verified) + # test_project / tests_passing # dataflow only (built and tested) +# dependencies: [...] # dataflow only — the GAVs its test project needs +methods: + - target: "com.foo.Wrapper#getValue" + type: passthrough # passthrough | dataflow (matches the file kind) +notes: > + free-form +``` + +approximations/skipped.yaml: + +```yaml +methods: # engine asks to approximate these, but they carry no taint + - "org.slf4j.Logger#info" +``` + +## Working directory layout + +``` +/.opentaint/ + project/ # built project model (project.yaml) + rules/java/{lib/generic,lib/spring,security}/ # custom rules + pass-through/.yaml # passThrough approximation configs + dataflow// # code-based (dataflow) approximation sources, per unit + test-projects// # per-unit test project sources; a rule unit holds sinks/ and sources/ sub-projects, each with a test-rules/ (the generic markers + that side's test join — test-only, never loaded by the main scan) + test-compiled// # per-unit compiled test model (a rule unit: sinks/ and sources/ models); delete once the unit's tests pass — large and unused after + test-results// # per-unit test outputs + results/ + report.sarif + dropped-external-methods.yaml # taint-killing methods → approximate + approximated-external-methods.yaml # already modeled + pocs/.py # PoC scripts + issues/.md # engine-issue reports + tracking/ # see Tracking layout + vulnerabilities.md # you assemble this from confirmed findings +``` + +## Key constraints + +- the engine models stored / second-order injection (data persisted then read back) on its own — no source, sink-side, or propagator needs to be added for the store→read path +- approximations apply only to external library methods — never an application-internal class +- `--passthrough-approximations` merges with built-ins at the rule level; a provided rule overrides a built-in only when it matches one already there — it does not replace the built-in set +- both approximation dir flags walk the tree recursively, so the final scan points at the parent dirs and applies every unit +- `--rule-id` drops every rule not named, including library `refs` — list them all when restricting +- a custom DATAFLOW approximation targeting a class that already has a built-in dataflow approximation errors at load (one class, one approximation); passThrough configs never error this way — they merge at the rule level (see above) +- a custom dataflow approximation overrides a passThrough for the same method — the passThrough→dataflow fallback when a passThrough won't converge; remove that method's passThrough config when re-planning it as dataflow, before the dataflow one is tested or scanned, to avoid override issues diff --git a/skills/appsec-agent/references/approximations.md b/skills/appsec-agent/references/approximations.md new file mode 100644 index 000000000..ca53c10c1 --- /dev/null +++ b/skills/appsec-agent/references/approximations.md @@ -0,0 +1,15 @@ +# Approximation iteration + +Every dropped method MUST end up either modeled (a passthrough/dataflow unit) or in `skipped.yaml` — no exceptions, no "good enough". This loop does not finish while any method in `dropped-external-methods.yaml` is still unclassified. Do not stop early because the important-looking ones are done, because a batch is large, or because the remaining methods seem minor — an unmodeled method silently kills taint and hides real findings. Keep iterating until the only thing left dropped is the skip set. + +Loop until stabilization: + +1. analyze-external-methods — Inputs: dropped-file `.opentaint/results/dropped-external-methods.yaml`, tracking-dir `.opentaint/tracking`, ``. Writes one `approximations/-passthrough.yaml` and/or `-dataflow.yaml` per package, plus `skipped.yaml`, only for methods not already in a unit. Returns one line per unit +2. Fan out per unit (capped per SKILL.md § Resource limits — these units compile and scan): + - passthrough → create-pass-through-approximation — Inputs: `` from the unit, ``, config-file `.opentaint/pass-through/.yaml`. Write-only; sets `written` + `artifact`. No test project + - dataflow → two sequential dispatches per unit: first create-test-project (dataflow shape) produces `.opentaint/test-compiled/` and sets `test_project: done`; on its return, dispatch create-dataflow-approximation against that model (approx-src `.opentaint/dataflow/`) — sets `tests_passing` + `artifact` (`test approximation run` auto-applies its own fixed rule — nothing to pass) +3. Re-scan (references/scan.md) with both approximation dirs pointing at the parents (`.opentaint/pass-through`, `.opentaint/dataflow`) +4. Pass-through verify (no separate skill): the scan agent reports any method you modeled that is still in `dropped-external-methods.yaml`, or any config load error. Re-invoke that package's create-pass-through-approximation agent to fix (matcher / from→to / YAML), then rescan. When that agent reports the passThrough won't converge (after ~2 fixes, no clear cause), don't keep re-invoking it — a passThrough copy can't express this method's propagation. Re-plan that method as a dataflow unit (drop its passThrough config first so the two don't collide) and run it through the create-test-project → create-dataflow-approximation pipeline; the custom dataflow overrides the passThrough. A dataflow method that still drops despite passing its isolated test is an escalation case (references/escalation.md), not a re-write +5. Stabilization: keep classifying until every method in `dropped-external-methods.yaml` is either modeled (a passthrough/dataflow unit) or listed in `skipped.yaml`, and a rescan surfaces no new dropped methods — i.e. the only thing left dropped is the skip set. Otherwise feed the newly dropped methods back into step 1 + +Set `phases.approximations: in_progress` across the loop, `done` at stabilization. diff --git a/skills/appsec-agent/references/build.md b/skills/appsec-agent/references/build.md new file mode 100644 index 000000000..0159222ab --- /dev/null +++ b/skills/appsec-agent/references/build.md @@ -0,0 +1,3 @@ +# Build + +Delegate build-project. Inputs: ``, model-out `.opentaint/project`, any build constraints (Java version, submodules, `--package` filters). Verify `.opentaint/project/project.yaml` exists, is non-empty, and — for a multi-module project — covers the expected module count, not just that the file is present. Set `phases.build: done`. diff --git a/skills/appsec-agent/references/discover-rules.md b/skills/appsec-agent/references/discover-rules.md new file mode 100644 index 000000000..1a0824c83 --- /dev/null +++ b/skills/appsec-agent/references/discover-rules.md @@ -0,0 +1,24 @@ +# Discover + new rules + +## Triage dependencies + +Delegate triage-dependencies. Inputs: ``, model-dir `.opentaint/project`, tracking-dir `.opentaint/tracking`. It reads `project.yaml`'s dependency list and writes `tracking/coverage.yaml` (`package` / `status` / `notes`) — one `status: pending` entry per library that could introduce a source or sink, dismissals summarised — returning one line per flagged library. Don't ask for the full list back. + +## Discover attack surface + +Fan out discover-attack-surface in parallel, one agent per `pending` package in `coverage.yaml` (capped per SKILL.md § Resource limits). Inputs each: ``, deps-dir `.opentaint/project/dependencies`, model-dir `.opentaint/project`, tracking-dir `.opentaint/tracking`. Each agent first scopes the package to functions/classes used by the project, running discover-attack-surface's bundled `scripts/package-usages.sh` and saving the package's method usages to `tracking/usage/.yaml`, then reviews source/config for indirect reachability. It settles built-in coverage for that used scope (full ⇒ no unit, just `coverage.yaml` done; partial ⇒ expand only the missing used methods; none ⇒ plan used members from scratch). It writes the package's project-used rule plan `tracking/rules/lib/.yaml` (new vs expand; sinks tagged by vuln class), writing no rule and running no test, then flips its `coverage.yaml` entry to `done`. Returns the sources/sinks planned. + +Then a quick area cross-check over project-used boundaries only: across network, persistence, environment, serialization, rendering, naming, execution, messaging — is every boundary the project reaches through a dependency either covered by built-ins or now carrying a lib unit? If a reachable boundary has a relevant dependency but produced no unit and no clear reason, dispatch a depth pass for it. Set `phases.discover: done` once every `coverage.yaml` entry is `done`. + +## Per-package lib rules + +Build the lib rules from the `tracking/rules/lib/.yaml` units. Fan out per package (capped per SKILL.md § Resource limits — each unit compiles and scans); each unit is a two-step pipeline, dispatched one step at a time after the prior step's artifact: + +1. create-test-project — Inputs: `` = the lib unit's sources/sinks, ``, `` `.opentaint/tracking/rules/lib/.yaml`, test-project `.opentaint/test-projects/`, test-compiled `.opentaint/test-compiled/`, dependencies from the unit. Scaffolds the `sinks/` and/or `sources/` marker projects (`test rule init`, `--sinks-only`/`--sources-only` for a one-sided package), writes the generic-marker counterpart samples, compiles each sub-project. Sets `test_project: done` +2. create-rule — Inputs: requirements (the lib unit), test-compiled `.opentaint/test-compiled/`, test-project `.opentaint/test-projects/`, rules-dir `.opentaint/rules`, ``, and on a re-dispatch the approximation dirs `.opentaint/pass-through` / `.opentaint/dataflow`. Writes the package's source lib rules + per-vuln-class sink lib rules into `.opentaint/rules`, the test joins against the markers into each test project's `test-rules`, and iterates `test rule run` per sub-project until every sample passes; sets `tests_passing: done` and the lib rules' `rule_id`s/`artifact` + +If create-rule reports the test project drops a library method on the rule's flow, route the dropped methods through the approximation loop (references/approximations.md), then re-dispatch create-rule with the approximation dirs. If it reports non-convergence with nothing dropped, load references/escalation.md. Set `phases.rules: done` once every lib unit's `tests_passing` is done. + +## Assemble joins + +Once the per-package lib rules are done, delegate assemble-lib-rules. Inputs: lib-units `.opentaint/tracking/rules/lib`, rules-dir `.opentaint/rules`, tracking-dir `.opentaint/tracking`. With every created lib rule in one view it writes the security joins — one `tracking/rules/join/.yaml` per vuln class (listing its joins) plus one `.opentaint/rules/java/security/--lib-ext.yaml` per join (a join refs exactly one sink, so a class with several sinks yields several joins) — merging built-in + created sources with the new sinks, and created sources with built-in sinks (new-end combinations only). These carry no test project; the main scan verifies them (references/scan.md). One agent for the global view; fan out by vuln class only if there are many. diff --git a/skills/appsec-agent/references/escalation.md b/skills/appsec-agent/references/escalation.md new file mode 100644 index 000000000..9babf54db --- /dev/null +++ b/skills/appsec-agent/references/escalation.md @@ -0,0 +1,7 @@ +# Escalation block + +These skills write no tracking files. + +1. debug-rule — Inputs: the `` to trace (for an approximation, the rule whose sample routes taint through the modeled method), the `` and `` of the run that showed the problem, ``, and the approximation dirs if the flow depends on them. Returns a diagnosis: rule fix, missing library model, or engine issue +2. Route by cause: a rule cause goes back to create-rule (references/discover-rules.md); a model cause back to the relevant create-*-approximation agent (references/approximations.md) — either to add a missing unit, or to override a built-in that debug-rule shows isn't propagating (you write the override tracking unit for the specific method, since analyze-external-methods didn't produce one); an engine cause goes to step 3 +3. report-analyzer-issue — Inputs: the ``, the existing `` / ``, the `` (rule full id, or the approximation's target methods), and `` (you decide whether to also file at github.com/seqra/opentaint). It writes `.opentaint/issues/.md` diff --git a/skills/appsec-agent/references/poc.md b/skills/appsec-agent/references/poc.md new file mode 100644 index 000000000..0ce5f1ba7 --- /dev/null +++ b/skills/appsec-agent/references/poc.md @@ -0,0 +1,14 @@ +# PoC + +Run PoCs one subagent at a time, never in parallel — concurrent exploits race on shared app state and ports. For each TP finding: + +- first finding: generate-poc with no `` — it builds and starts the app and returns the `` it started +- every later finding: pass that `` so the agent reuses the running instance + +When a finding needs several services (app + DB + broker + …), have generate-poc bring them all up with one `docker compose` on a shared network, registered as a single `compose` entry — one command then tears the stack down. + +Inputs each time: `` = the TP finding file, ``, poc-dir `.opentaint/pocs`, and `` once known. Each sets `poc` (`confirmed`/`failed`) + `poc_script`; a `failed` repro does not flip the triage verdict. Each PoC subagent registers any instance it starts in `.opentaint/tracking/poc-servers.yaml` — that registry, not memory, is what's running (so a reuse-or-start decision and teardown both survive compaction). + +After all PoCs, assemble `.opentaint/vulnerabilities.md` from the confirmed findings yourself (subagents never write it; see SKILL.md). + +Then tear down — you own this, run it directly (don't dispatch a subagent). Read `poc-servers.yaml` and stop every instance it lists — always terminate, no keep-vs-shutdown prompt. From each entry's `kind` + `ref` (`process` → `kill `, `container` → `docker stop `, `compose` → `docker compose -f down`), confirm its `port` is free, and empty the registry. Only after teardown set `phases.poc: done`. diff --git a/skills/appsec-agent/references/scan.md b/skills/appsec-agent/references/scan.md new file mode 100644 index 000000000..04ed80bbd --- /dev/null +++ b/skills/appsec-agent/references/scan.md @@ -0,0 +1,5 @@ +# Scan + +Delegate run-scan. Inputs: model-dir `.opentaint/project`, ruleset `builtin` + `.opentaint/rules`, report `.opentaint/results/report.sarif`; on normal/deep also config-dir `.opentaint/pass-through` and approx-dir `.opentaint/dataflow` (both dir flags walk the tree recursively, so the parents apply every unit). Require a concise return — finding counts per rule, the methods still in `dropped-external-methods.yaml` that sit on a source→sink path, and any config load/parse errors — not the SARIF body. The files persist on disk for the next steps. Set `phases.scan: done`. + +On deep runs, if the scan flags an issue with a created rule — a rule that failed to load/parse, a join that should fire but didn't, or an own rule that false-positives — dispatch create-rule to fix that rule (references/discover-rules.md), then rescan before continuing. diff --git a/skills/appsec-agent/references/triage.md b/skills/appsec-agent/references/triage.md new file mode 100644 index 000000000..e1226e67d --- /dev/null +++ b/skills/appsec-agent/references/triage.md @@ -0,0 +1,11 @@ +# Triage + +The scan must be stable first. + +## Generate finding files + +Run this skill's bundled `scripts/sarif-to-findings.py` over `.opentaint/results/report.sarif` (`python3 /scripts/sarif-to-findings.py .opentaint/results/report.sarif -o .opentaint/tracking/findings` — the script lives in the skill directory, not the project; the project-relative paths are arguments). It writes one `tracking/findings/.yaml` per rule and is idempotent — a rescan adds new result hashes and resets changed findings to `pending`. This is a deterministic script with no context cost, so run it yourself, not via a subagent. + +## Classify — never in main + +Fan out analyze-findings, one subagent per finding file (the rule bundle is the bucket). Inputs: `` = the finding file, report `.opentaint/results/report.sarif`. The agent reads each result's `codeFlows[]`, splits the bundle into distinct logical findings, and sets `verdict` + `notes` on each. Return: one line per logical finding (name, verdict, one-clause reason). Assign no verdicts yourself. Set `phases.triage: done`. diff --git a/skills/appsec-agent/scripts/sarif-to-findings.py b/skills/appsec-agent/scripts/sarif-to-findings.py new file mode 100644 index 000000000..ba461185d --- /dev/null +++ b/skills/appsec-agent/scripts/sarif-to-findings.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +""" +sarif-to-findings.py — turn an OpenTaint SARIF report into per-rule finding +tracking files under .opentaint/tracking/findings/. + +One file per rule_id, bundling that rule's result hashes into sarif_hashes. +Grouping is trivial (by rule_id) — no clustering. The triage skill +(analyze-findings) later splits a rule's bundle into distinct logical findings. + +Idempotent: re-running after a re-scan adds only result hashes not already +present in any of that rule's finding files, resets the touched file's verdict +to `pending`, and leaves existing verdict/notes/poc and triage splits intact. + +SARIF assumptions — adjust the two helpers below if the real OpenTaint SARIF +differs: +- result.ruleId holds the full rule id (e.g. java/security/sqli.yaml:sqli) +- a stable per-result hash comes from result.fingerprints / partialFingerprints + when present, else is computed from ruleId + locations + code-flow locations +- result.message.text seeds the analyzer report in `notes` +""" +import argparse +import glob +import hashlib +import json +import re +from pathlib import Path + +ADJ = ["brave", "calm", "eager", "fuzzy", "gentle", "jolly", "keen", "lucid", + "merry", "noble", "proud", "quiet", "rapid", "sly", "tidy", "vivid", + "witty", "zesty", "amber", "bold"] +NOUN = ["hopper", "eagle", "otter", "falcon", "maple", "comet", "harbor", + "willow", "pixel", "river", "ember", "cobra", "lotus", "raven", + "quartz", "badger", "cedar", "drake", "finch", "gull"] + + +def docker_name(seed, taken): + """Stable adjective-noun slug from the rule id; suffixed on collision.""" + h = int(hashlib.sha1(seed.encode()).hexdigest(), 16) + base = f"{ADJ[h % len(ADJ)]}-{NOUN[(h // len(ADJ)) % len(NOUN)]}" + name, n = base, 2 + while name in taken: + name, n = f"{base}-{n}", n + 1 + return name + + +_FP_PREFERENCE = ("vulnerabilitySourceSinkHash", "vulnerabilityWithTraceHash") + + +def result_hash(res): + fp = res.get("fingerprints") or res.get("partialFingerprints") + if isinstance(fp, dict) and fp: + for pref in _FP_PREFERENCE: + for k, v in fp.items(): + if k.startswith(pref): + return str(v)[:16] + return str(sorted(fp.values())[0])[:16] + parts = [res.get("ruleId", "")] + locs = list(res.get("locations", [])) + for cf in res.get("codeFlows", []): + for tf in cf.get("threadFlows", []): + locs += [st.get("location", {}) for st in tf.get("locations", [])] + for loc in locs: + pl = loc.get("physicalLocation", {}) + parts.append(pl.get("artifactLocation", {}).get("uri", "")) + parts.append(json.dumps(pl.get("region", {}), sort_keys=True)) + return hashlib.sha1("|".join(parts).encode()).hexdigest()[:16] + + +def scan_results(sarif): + """rule_id -> {hash: message}""" + out = {} + for run in sarif.get("runs") or []: + for res in run.get("results") or []: + rid = res.get("ruleId") or "unknown" + msg = (res.get("message", {}) or {}).get("text", "").strip() + out.setdefault(rid, {})[result_hash(res)] = msg + return out + + +NAME_RE = re.compile(r'^finding_name:\s*(.+?)\s*$', re.M) +RULE_RE = re.compile(r'^rule_id:\s*(.+?)\s*$', re.M) +HASHES_RE = re.compile(r'^sarif_hashes:\s*\[(.*)\]\s*$', re.M) +HASHES_BLOCK_RE = re.compile(r'^sarif_hashes:\s*\n((?:[ \t]+-[^\n]*\n?)+)', re.M) + + +def parse_hashes(text): + """Hashes from either flow style ([a, b]) or block style (- a / - b).""" + m = HASHES_RE.search(text) + if m: + return [h.strip() for h in m.group(1).split(",") if h.strip()] + m = HASHES_BLOCK_RE.search(text) + if m: + return [ln.strip().lstrip("-").strip() + for ln in m.group(1).splitlines() if ln.strip().lstrip("-").strip()] + return [] + + +def replace_hashes(text, merged): + """Rewrite the sarif_hashes entry (either style) as a flow list; if the key + is missing entirely, prepend it so merged hashes are never silently lost.""" + line = "sarif_hashes: " + fmt_list(merged) + if HASHES_RE.search(text): + return HASHES_RE.sub(lambda m: line, text, count=1) + if HASHES_BLOCK_RE.search(text): + return HASHES_BLOCK_RE.sub(line + "\n", text, count=1) + return line + "\n" + text + + +def parse_existing(text): + name = NAME_RE.search(text) + rid = RULE_RE.search(text) + return (name.group(1) if name else None, + rid.group(1) if rid else None, + parse_hashes(text)) + + +def fmt_list(hashes): + return "[" + ", ".join(hashes) + "]" + + +def new_file_text(name, rid, hashes, notes): + body = "\n".join(" " + ln for ln in (notes or "(no analyzer message)").splitlines()) + return (f"finding_name: {name}\n" + f"sarif_hashes: {fmt_list(hashes)}\n" + f"rule_id: {rid}\n" + f"verdict: pending\n" + f"notes: >\n{body}\n" + f"poc: pending\n" + f"poc_script: null\n") + + +def main(): + ap = argparse.ArgumentParser( + description="SARIF -> per-rule finding tracking files (idempotent)") + ap.add_argument("sarif", help="path to report.sarif") + ap.add_argument("-o", "--out", default=".opentaint/tracking/findings", + help="findings dir (default: .opentaint/tracking/findings)") + args = ap.parse_args() + + by_rule = scan_results(json.loads(Path(args.sarif).read_text(encoding="utf-8"))) + + out = Path(args.out) + out.mkdir(parents=True, exist_ok=True) + + existing = {} + taken = set() + for p in sorted(glob.glob(str(out / "*.yaml"))): + name, rid, hashes = parse_existing(Path(p).read_text(encoding="utf-8")) + if name: + taken.add(name) + if rid: + existing.setdefault(rid, []).append((Path(p), hashes)) + + created = updated = unchanged = 0 + for rid, hashmap in sorted(by_rule.items()): + scanned = set(hashmap) + files = existing.get(rid) + if not files: + name = docker_name(rid, taken) + taken.add(name) + notes = "\n".join(sorted({m for m in hashmap.values() if m})) + (out / f"{name}.yaml").write_text( + new_file_text(name, rid, sorted(scanned), notes), encoding="utf-8") + created += 1 + continue + already = set().union(*(set(h) for _, h in files)) + new = sorted(scanned - already) + if not new: + unchanged += 1 + continue + path, hashes = files[0] + merged = sorted(set(hashes) | set(new)) + text = path.read_text(encoding="utf-8") + text = replace_hashes(text, merged) + text = re.sub(r'^verdict:\s*.+$', "verdict: pending", text, count=1, flags=re.M) + path.write_text(text, encoding="utf-8") + updated += 1 + + print(f"findings: {created} created, {updated} updated, {unchanged} unchanged " + f"({len(by_rule)} rules in scan)") + + +if __name__ == "__main__": + main() diff --git a/skills/assemble-lib-rules/SKILL.md b/skills/assemble-lib-rules/SKILL.md new file mode 100644 index 000000000..f53d77ada --- /dev/null +++ b/skills/assemble-lib-rules/SKILL.md @@ -0,0 +1,106 @@ +--- +name: assemble-lib-rules +description: Write the per-vuln-class security join rules that merge the created source/sink lib rules with the built-ins. Use after the per-package lib rules are created and tested, to wire them into project-level joins +license: Apache-2.0 +metadata: + author: opentaint + version: "0.2" +--- + +# Skill: Assemble Lib Rules + +The per-package passes author source and sink lib rules but never pair them across packages. With every created lib rule and the whole built-in set in front of you, write the security joins — one per vuln class, each merging the created rules with the built-ins, mirroring the built-in security rules. These are verified by the main scan, not a test project + +## Inputs + +From the caller; if omitted, fall back to the default. Ask only when a required input is missing and has no sensible default + +- Lib units `` — the per-package lib tracking files (`rules/lib/.yaml`) with the created source/sink `rule_id`s and their vuln classes. Default: `.opentaint/tracking/rules/lib/` +- Rules directory `` — where the security joins are written. Default: `.opentaint/rules` +- Tracking directory `` — where the join records are written. Default: `.opentaint/tracking` + +Built-in rules are available at `opentaint health --rules` + +## Workflow + +### 1. Read the created lib rules and the built-ins + +Read every per-package lib unit in `` (the source/sink `rule_id`s create-rule wrote, sinks carrying their `vuln_class`) and the built-in source/sink lib rules (`opentaint health --rules`). Collect every source rule (built-in + created) and every sink rule grouped by vuln class + +### 2. Write one security join per (vuln class, sink rule) + +A join references exactly ONE right-hand (sink) rule — you cannot merge several sinks into one join. So a vuln class with more than one relevant sink becomes several joins: one per sink rule, each refing all the relevant sources on the left. Sources are many; the sink is always one. + +For each vuln class, and within it each sink rule that needs new wiring, write `/java/security/--lib-ext.yaml` with `mode: join`, refing the relevant sources + that one sink, wiring only new-end combinations in `on:`: + +- a created (new) sink ← from every relevant source (built-in + created) +- a built-in sink ← from created sources only (built-in source → built-in sink is already covered by the built-in join — repeating it double-reports) + +Two rules that bite here: + +- Unique id — use `id: --lib-ext`, never the bare class name; a custom join named `ssrf`/`xxe`/`path-traversal` collides silently with the built-in join of that id and is dropped with no error (only the scan's rule statistics reveal it) +- Same metavariable both sides — every `on:` clause connects the metavariable both lib rules bind (`$UNTRUSTED` by convention) as `source.$UNTRUSTED -> sink.$UNTRUSTED`; don't invent a new name on either end, or the join won't connect + +```yaml +# java/security/ssrf-webclient-ssrf-sink-lib-ext.yaml +rules: + - id: ssrf-webclient-ssrf-sink-lib-ext + severity: ERROR + message: Untrusted data reaches an SSRF sink + metadata: + cwe: CWE-918 + short-description: SSRF via untrusted input + languages: [java] + mode: join + join: + refs: + - rule: java/lib/generic/servlet-untrusted-data-source.yaml#java-servlet-untrusted-data-source + as: servlet-source + - rule: java/lib/spring/webflux-request-source.yaml#webflux-request-source + as: webflux-source + - rule: java/lib/spring/webclient-ssrf-sink.yaml#webclient-ssrf-sink + as: sink + on: + - 'servlet-source.$UNTRUSTED -> sink.$UNTRUSTED' + - 'webflux-source.$UNTRUSTED -> sink.$UNTRUSTED' +``` + +The same class's built-in sink is a second file (`ssrf-java-ssrf-sink-lib-ext.yaml`), refing only the created sources → that built-in sink. The `#` comments in these examples are for you — don't copy them into the rules you write + +### 3. Stop — the main scan verifies + +These joins carry no test project — the main scan applies them. Write them and stop; if the scan shows a join didn't load or fire, the orchestrator re-dispatches create-rule to fix it + +## Output + +- One `/java/security/--lib-ext.yaml` per (vuln class, sink rule), each refing all relevant sources + its one sink +- One `/rules/join/.yaml` per vuln class, listing every join it produced, with `stages.written: done` +- A brief summary to the caller: one line per join (class, sink, source count, which ends are new) + +## Tracking + +`/rules/join/.yaml` — one file per vuln class, listing each join (one per sink rule), verified by the main scan: + +```yaml +name: ssrf +sources: + - ref: java/lib/generic/servlet-untrusted-data-source.yaml#java-servlet-untrusted-data-source + - ref: java/lib/spring/webflux-request-source.yaml#webflux-request-source +joins: + - rule_id: java/security/ssrf-webclient-ssrf-sink-lib-ext.yaml:ssrf-webclient-ssrf-sink-lib-ext + artifact: .opentaint/rules/java/security/ssrf-webclient-ssrf-sink-lib-ext.yaml + sink: { new: java/lib/spring/webclient-ssrf-sink.yaml#webclient-ssrf-sink } + - rule_id: java/security/ssrf-java-ssrf-sink-lib-ext.yaml:ssrf-java-ssrf-sink-lib-ext + artifact: .opentaint/rules/java/security/ssrf-java-ssrf-sink-lib-ext.yaml + sink: { builtin: java/lib/generic/ssrf-sinks.yaml#java-ssrf-sink } +stages: + written: done + verified: pending +notes: > + free-form +``` + +## Gotchas + +- One join references exactly one sink — a class with N relevant sinks yields N joins, each aggregating every relevant source; never pack two sinks into one join +- Ref the existing lib rules (built-in + created); never re-declare a source or sink diff --git a/skills/build-project/SKILL.md b/skills/build-project/SKILL.md new file mode 100644 index 000000000..f19de1da7 --- /dev/null +++ b/skills/build-project/SKILL.md @@ -0,0 +1,68 @@ +--- +name: build-project +description: Build a Java/Kotlin project for opentaint analysis and produce a project.yaml model. Use whenever an opentaint scan needs a project model and `opentaint compile` may need help +license: Apache-2.0 +metadata: + author: opentaint + version: "0.2" +--- + +# Skill: Build Project + +Build a target project into an opentaint project model. The model is this skill's only output + +## Inputs + +From the caller; if omitted, fall back to the default. Ask only when a required input is missing and has no sensible default + +- Project root `` — the project to build. Default: current directory +- Model output directory `` — where to write the model. Default: `.opentaint/project` +- Build constraints (optional) — required Java version, submodules to initialize, `--package` filters for `opentaint project` + +## Workflow + +### 1. Determine project type + +- `build.gradle` / `build.gradle.kts` → Gradle +- `pom.xml` → Maven +- pre-compiled JAR/WAR → classpath mode +- existing `project.yaml` → already built, reuse it + +### 2a. Gradle/Maven — autobuilder + +```bash +opentaint compile -o +``` + +### 2b. Autobuilder fails — manual build + `opentaint project` + +Build manually, then create the model from the artifacts. Always pass `--package` to restrict analysis to project code — without it the analyzer walks third-party libraries and hangs + +```bash +./gradlew build -x test # Gradle +mvn package -DskipTests # Maven + +opentaint project \ + --output \ + --source-root \ + --classpath \ + --package +``` + +Multi-module: repeat `--classpath` and `--package` per module + +### 3. Verify + +`/project.yaml` exists and is non-empty + +## Output + +The project model directory containing `project.yaml` (default `.opentaint/project`, or the caller's path). Report that path back + +## Gotchas + +- Analysis hangs → `--package` was omitted in `opentaint project`; the analyzer is processing third-party libraries. Re-run with `--package` +- Build tool not found → use the wrapper (`./gradlew`, `./mvnw`) or install the tool +- Compilation errors → check the autobuilder log, fix the build, retry; if it can't be fixed, fall back to 2b +- Java version mismatch → set `JAVA_HOME` to the version the project needs (opentaint itself needs Java 21+) +- Missing dependencies → initialize submodules (`git submodule update --init`) diff --git a/skills/create-dataflow-approximation/SKILL.md b/skills/create-dataflow-approximation/SKILL.md new file mode 100644 index 000000000..0953de1ea --- /dev/null +++ b/skills/create-dataflow-approximation/SKILL.md @@ -0,0 +1,139 @@ +--- +name: create-dataflow-approximation +description: Model a library method's taint propagation as code-based dataflow approximation and refine it against a test project until the sample passes. Use for a dropped external method whose propagation a passThrough copy cannot express +license: Apache-2.0 +metadata: + author: opentaint + version: "0.2" +--- + +# Skill: Create Dataflow Approximation + +Write a code-based approximation for a library method whose taint propagation depends on lambdas, callbacks, or async chains, then test it against the prepared test project and fix until the approximation sample passes + +## Inputs + +From the caller; if omitted, fall back to the default. Ask only when a required input is missing and has no sensible default + +- Methods to model `` — the target method(s) and how taint flows through them, from the tracking file's `methods` (all `type: dataflow`) +- Tracking file `` — the dataflow approximation unit (`-dataflow`, e.g. `reactor-core-publisher-dataflow`). Default: `.opentaint/tracking/approximations/.yaml` +- Approximation sources `` — this package's own directory for the `.java` approximation files. Default: `.opentaint/dataflow/` +- Compiled test project `` — the per-package compiled model to test against. Default: `.opentaint/test-compiled/` + +## Workflow + +### 1. Write the approximation source + +Create Java files in ``. Target the EXACT class named in `dropped-external-methods.yaml` — `@Approximate` matches only that class (unlike passThrough's `overrides: true`), and the dropped FQN reflects how the analyzer resolved the call: an interface-typed receiver (`Map m = ...; m.computeIfAbsent(...)`) drops `java.util.Map#computeIfAbsent`; a concrete one (`new HashMap<>()`) drops `java.util.HashMap#computeIfAbsent`. Don't substitute a supertype or subtype. Model the real propagation — never leave the body empty (it silently drops taint); when unsure how taint flows through the method, read the library source rather than guessing: + +```java +package com.example.approximations; + +import org.opentaint.ir.approximation.annotation.Approximate; +import org.opentaint.jvm.dataflow.approximations.ArgumentTypeContext; +import org.opentaint.jvm.dataflow.approximations.OpentaintNdUtil; + +import java.util.function.Function; + +@Approximate(com.example.lib.ReactiveProcessor.class) +public class ReactiveProcessor { + + // Model: taint on this flows through the function to the result + public Object transform(@ArgumentTypeContext Function fn) throws Throwable { + com.example.lib.ReactiveProcessor self = + (com.example.lib.ReactiveProcessor) (Object) this; + if (OpentaintNdUtil.nextBool()) return null; + Object input = self.getValue(); + return fn.apply(input); + } + + // Model: taint on this flows to the consumer argument + public void subscribe(@ArgumentTypeContext java.util.function.Consumer consumer) { + com.example.lib.ReactiveProcessor self = + (com.example.lib.ReactiveProcessor) (Object) this; + if (OpentaintNdUtil.nextBool()) { + consumer.accept(self.getValue()); + } + } +} +``` + +Wrapper-returning operators (a `Mono`/`Flux`, `Optional`, `Stream`, a builder — anything where the taint stays inside a container): declare the real concrete return type, not `Object`; in the `nextBool()` branch `return self`, not `null`; and extract → apply → re-wrap so a downstream extractor (`block`, `get`, …) can pull the tainted value back out: + +```java +@Approximate(reactor.core.publisher.Mono.class) +public class Mono { + public reactor.core.publisher.Mono map(@ArgumentTypeContext Function fn) throws Throwable { + reactor.core.publisher.Mono self = (reactor.core.publisher.Mono) (Object) this; + if (OpentaintNdUtil.nextBool()) return self; + Object up = self.block(); // extract upstream element + return reactor.core.publisher.Mono.justOrEmpty(fn.apply(up)); // apply mapper, re-wrap + } +} +``` + +### 2. Test against the test project + +Run `test approximation run` over `` applying only this package's sources (``); iterate the source until the sample passes: + +```bash +opentaint test approximation run \ + -o .opentaint/test-results/ \ + --dataflow-approximations +``` + +`test approximation run` applies its own bundled fixed source→sink rule automatically — you don't author or pass one. The CLI auto-compiles the `.java` sources against the analyzer JAR (for `@Approximate`, `OpentaintNdUtil`, `ArgumentTypeContext`) and the project's dependencies; if compilation fails it reports the errors and aborts before the tests. The sample that routes taint through the method is a `falseNegative` until the model propagates it. Read `.opentaint/test-results//test-result.json`: + +- still `falseNegative` → the `@Approximate(...)` target class or a method signature doesn't match what the analyzer sees, or the body doesn't route taint from the real source to the modeled result/argument; diagnose the mismatch, don't rationalize a non-result. Most common: target-class mismatch with the dropped FQN — re-target the exact dropped class and match the cast (`(java.util.HashMap) (Object) this`) +- `falsePositive` (a negative sample fired) → the model is over-broad: it taints a read it shouldn't, e.g. data fetched under a different key/field than it was stored under. Narrow the propagation until the negative stays non-firing while the positive passes + +### 3. When the sample won't pass after a couple of fixes + +After ~2 fix attempts without a clearer cause — `@Approximate` target matches the dropped FQN, the body propagates from the modeled source slot to the result/argument, but the sample is still `falseNegative` — don't keep guessing. Leave `tests_passing: pending` and report non-convergence to the caller; the orchestrator escalates to debug-rule for a fact-reachability trace through the approximation point + +## Key patterns + +| Pattern | Usage | +|---|---| +| `@Approximate(TargetClass.class)` | Link the approximation to its target class. Must be on the compile classpath (a project dependency or a JDK type) | +| `(TargetClass) (Object) this` | Cast to reach the real object's methods | +| `@ArgumentTypeContext` | On lambda / functional-interface parameters | +| `OpentaintNdUtil.nextBool()` | Non-deterministic branch — the analyzer considers both paths | + +## Output + +- The approximation source(s) under `` +- Tracking updated: `artifact` and `stages.tests_passing` (per Tracking) +- Report the source path, a one-line test summary, and the exact `test approximation run` command used + +## Tracking + +In ``, once the source exists and its sample passes: + +```yaml +artifact: .opentaint/dataflow//com/example/approximations/ReactiveProcessor.java +stages: + tests_passing: done +``` + +Do not touch other stages or fields + +## Constraints + +- Also the passThrough fallback — when a passThrough for a method won't converge, the orchestrator re-plans it here; target the same dropped class and the dataflow approximation overrides the passThrough (the orchestrator removes the stale passThrough config before this one is tested) +- Java 8 source compatibility +- Put the `@Approximate` classes in a neutral package (e.g. `com.example.approximations`) — never the target library's own package. Inside the library's package every bare FQN resolves to your approximation's non-generic class instead of the real type, breaking compilation wholesale +- Model every method and overload the unit lists, not only the shapes you happen to have a sample for — an under-covered unit silently drops taint through the overloads you skipped +- One approximation class per target class — a strict bijection enforced at load (duplicates throw `IllegalArgumentException`). Built-in dataflow approximations are first-priority and presumed correct; you cannot override them — see Troubleshooting if debug-rule traces a kill to one +- Method signatures must match the target class methods exactly +- Don't unpack or grep the analyzer JAR for built-in models or signatures — its internals aren't a stable API; go through the CLI + +## Troubleshooting + +When debug-rule traces a taint kill to an external method, walk this in order: + +1. Confirm the method has a built-in — `approximated-external-methods.yaml` lists it (if you didn't pass an approximation to the scan, the listing is the bundled set) +2. Confirm from the debug-rule trace that taint dies at exactly that method +3. Classify the gap: + - fits a from→to copy → write a passthrough override (built-in passthroughs are overrideable by design) + - truly needs dataflow shape (lambdas/callbacks/async) → engine issue; built-in dataflows aren't locally overrideable — report it upstream diff --git a/skills/create-pass-through-approximation/SKILL.md b/skills/create-pass-through-approximation/SKILL.md new file mode 100644 index 000000000..1a81f90d8 --- /dev/null +++ b/skills/create-pass-through-approximation/SKILL.md @@ -0,0 +1,223 @@ +--- +name: create-pass-through-approximation +description: Model a library method's taint propagation as a passThrough approximation config. Use for a dropped external method whose propagation is simple copying +license: Apache-2.0 +metadata: + author: opentaint + version: "0.2" +--- + +# Skill: Create PassThrough Approximation + +Write passThrough propagation rules for external library methods + +## Inputs + +From the caller; if omitted, fall back to the default. Ask only when a required input is missing and has no sensible default + +- Methods to model `` — the target method(s) and what each propagates, from the tracking file's `methods` (all `type: passthrough`) +- Tracking file `` — the passThrough approximation unit. Default: `.opentaint/tracking/approximations/.yaml` +- Config output `` — where to write the passThrough approximation. Default: `.opentaint/pass-through/.yaml` +- Test model `` (optional) — any compiled model to dry-run the config against for a load/parse check. Default: `.opentaint/project` if it exists, else any `.opentaint/test-compiled/*` model + +## Workflow + +### 1. Write the passThrough config + +Write `passThrough:` copies into ``. When an object carries taint between calls — a setter stores it and a getter returns it later, or a builder holds it — route through a virtual slot, an access path `[, .##java.lang.Object]`: +- the slot name is nominal — the engine never resolves it, so it need not be a real field +- type it `java.lang.Object` — a concrete type can fail the read-out type-check and drop the taint +- the writer and reader must name the identical `Class#slot#java.lang.Object` triple, or the taint drops + +Getter / setter pair — the writer stores into the slot, the getter reads the same slot back to `result`: +```yaml +passThrough: +- function: org.springframework.http.HttpEntity#setBody + copy: + - from: arg(0) + to: + - this + - .org.springframework.http.HttpEntity#Body#java.lang.Object +- function: org.springframework.http.HttpEntity#getBody + copy: + - from: + - this + - .org.springframework.http.HttpEntity#Body#java.lang.Object + to: result +``` + +Several writers sharing one slot — any of them taints the object, the reader pulls it back: +```yaml +passThrough: +- function: org.apache.tools.ant.types.FileSet#setDir + copy: + - from: arg(0) + to: + - this + - .org.apache.tools.ant.types.FileSet#path#java.lang.Object +- function: org.apache.tools.ant.types.FileSet#setFile + copy: + - from: arg(0) + to: + - this + - .org.apache.tools.ant.types.FileSet#path#java.lang.Object +``` + +Cross-type builder — when a builder method consumes an argument and returns a *different* type, carry the taint along both the chained receiver (for further calls on `this`) and the returned object, slot included. Four copies: arg → returned-value slot, arg → builder slot, whole builder → returned value, builder slot → returned-value slot: +```yaml +passThrough: +- function: org.springframework.ldap.query.LdapQueryBuilder#filter + copy: + - from: arg(0) + to: + - result + - .org.springframework.ldap.query.LdapQuery#filter#java.lang.Object + - from: arg(0) + to: + - this + - .org.springframework.ldap.query.LdapQueryBuilder#filter#java.lang.Object + - from: this + to: result + - from: + - this + - .org.springframework.ldap.query.LdapQueryBuilder#filter#java.lang.Object + to: + - result + - .org.springframework.ldap.query.LdapQuery#filter#java.lang.Object +``` + +Builder terminal — a no-arg `build()` / `toX()` that returns a new object carrying what the builder accumulated; no argument is involved, so copy each slot from `this` to the matching slot on `result` (the setters that filled the builder slot are separate rules of their own): +```yaml +passThrough: +- function: com.google.common.collect.ImmutableMap$Builder#build + copy: + - from: + - this + - .java.util.Map#MapKey#java.lang.Object + to: + - result + - .java.util.Map#MapKey#java.lang.Object + - from: + - this + - .java.util.Map#MapValue#java.lang.Object + to: + - result + - .java.util.Map#MapValue#java.lang.Object +``` + +Conditional propagation — gate a rule with a `condition` (the copy still routes through a slot): +```yaml +passThrough: +- function: com.example.lib.Parser#parse + condition: + typeIs: java.lang.String + pos: arg(0) + copy: + - from: arg(0) + to: + - this + - .com.example.lib.Parser#parsed#java.lang.Object +``` + +Full config — every function in one top-level `passThrough:` list (quote `[*]` — unquoted it parses as a YAML alias): +```yaml +passThrough: +- function: org.springframework.beans.MutablePropertyValues#add + copy: + - from: arg(1) + to: + - this + - .org.springframework.beans.PropertyValue#Value#java.lang.Object +- function: org.springframework.beans.PropertyValue#getValue + overrides: false + copy: + - from: + - this + - .org.springframework.beans.PropertyValue#Value#java.lang.Object + to: result +- function: org.springframework.beans.PropertyValues#getPropertyValues + copy: + - from: + - this + - .java.lang.Iterable#Element#java.lang.Object + to: + - result + - '[*]' +``` + +### 2. Optional — dry-run the config for load errors + +There's no dedicated load-check command. ONLY when invoked standalone — never under the appsec-agent orchestrator, whose subagents must not run `opentaint scan` (the orchestrator's scan phase verifies the config instead): if a compiled `` is present you can catch YAML load/parse errors early by running a quick scan with the config applied (won't verify propagation — there's no matching flow — only that the config loads): + +```bash +opentaint scan --project-model \ + -o .opentaint/test-results//passthrough-loadcheck.sarif \ + --ruleset builtin \ + --passthrough-approximations +``` + +A config error aborts the scan with the parse/load message — fix the YAML and re-run. Nice-to-have, not required; skip it when no model is around + +### 3. Verification is the scan + +There's no test project for passThrough. The main scan applies `` and the scan agent reports back. You're re-invoked to fix the config when that scan shows: + +- a method you modeled still in `dropped-external-methods.yaml` → the `function` matcher didn't match (check package, class, name, `overrides`), or the `from`/`to` doesn't land on the tainted position +- the flow still doesn't surface though the method is no longer dropped → most often a broken channel: the writer and reader name different `Class#slot#java.lang.Object` triples, or the slot isn't typed `java.lang.Object` +- a config load / parse error → fix the YAML (an unknown `condition` key, a bad position, or a 2-part field modifier all fail to load) + +Never invoke or grep the analyzer JAR — its internals aren't a stable API; for built-in rules use `opentaint health --rules`, for everything else the CLI + +### 4. When the config won't converge + +After ~2 fix re-invocations without a clearer cause — matcher fields and `from`/`to` checked, writer/reader slots confirmed identical, the modeled method no longer in `dropped-external-methods.yaml`, but the scan still doesn't surface the flow — don't keep guessing at the copy. Report non-convergence to the caller: a passThrough can't express this method's propagation, so the fix is a dataflow approximation for it (a custom dataflow overrides the passThrough). The orchestrator re-plans the method as a dataflow unit and removes this passThrough config before the dataflow one is tested + +## Output + +- The passThrough config at `` +- Tracking updated: `written` + `artifact` (per Tracking) +- Report the config path and the methods modeled + +## Tracking + +In ``, once the config is written: + +```yaml +artifact: .opentaint/pass-through/.yaml +stages: + written: done +``` + +Do not touch other stages or fields + +## Reference + +Position bases +- `this`, `result`, `arg(0)`, `arg(1)`, … +- `any()` — expands to every argument matching the classifier (a cartesian product across positions, bound consistently), not a single argument. Rare — prefer an explicit `arg(N)` + +Access-path modifiers (list form `[, ]`) +- `.##` — a field or virtual slot; type it `java.lang.Object`. The slot name is arbitrary (a descriptive name, or the conventional `` for a generic carrier) +- `[*]` — array element (no leading dot). For `java.util` collections this does *not* carry element taint; route it through the conventional `.java.lang.Iterable#Element#java.lang.Object` slot instead (as the built-in `List`/`Collection` models do) + +Function matching +- Simple: `package.Class#method` +- Complex: `{package, class, name}` — for one hard-to-name function, not for matching many at once (see Gotchas) + +Overrides +- `overrides: true` (default): applies to the class and all subclasses +- `overrides: false`: exact class only + +Conditions (the only keys that load from YAML) +- take a `pos: `: `typeIs`, `constantMatches`, `constantEq`, `tainted` +- take the position directly, no `pos:` field: `isConstant`, `isNull` — adding `pos:` fails to load +- nest other conditions: `anyOf`, `allOf`, `not` +- `constantGt` / `constantLt` load but crash the scan when actually evaluated against a constant (their string-typed bound fails an engine type-check) — avoid until fixed + +## Gotchas + +- The `#` comments in the examples here are for you — don't copy them into the config you write; keep produced YAML comment-free +- The approximation merges with built-ins at the rule level — a provided rule overrides a built-in only if it matches one. Don't redefine a method already in `approximated-external-methods.yaml` unless debug-rule shows the built-in isn't propagating taint here, then override deliberately +- A wrong argument position copies the wrong value — point `from`/`to` at the tainted one +- In doubt about how a method moves taint — which argument or field reaches the result — read the library's source rather than guessing +- Model one function per rule — don't use a regex/wildcard `pattern:` matcher (e.g. `name: get.*`, `class: .*`) or `arg(*)` to cover many functions at once; it over-models, copying taint through methods you never vetted and manufacturing false positives. Write an explicit `function:` per method diff --git a/skills/create-rule/SKILL.md b/skills/create-rule/SKILL.md new file mode 100644 index 000000000..682d76989 --- /dev/null +++ b/skills/create-rule/SKILL.md @@ -0,0 +1,188 @@ +--- +name: create-rule +description: Author and verify an OpenTaint detection rule for a vulnerability class on JVM code. Use whenever a rule needs to be created for an uncovered vulnerability, or an existing rule needs a false-positive or false-negative fix +license: Apache-2.0 +metadata: + author: opentaint + version: "0.2" +--- + +# Skill: Create Rule + +Per package, author the new source/sink lib rules the requirements name, wire each to the generic `Taint` marker in a test join, and verify against the package's marker test projects until every sample passes + +Two roles: the **main** one authors a package's lib rules (above); a **fix** narrows or broadens a created rule the main scan later flags. The cross-package security joins are written by assemble-lib-rules, not here + +## Inputs + +From the caller; if omitted, fall back to the default. Ask only when a required input is missing and has no sensible default + +- Requirements `` — the per-package lib unit naming the new sources/sinks (a tracking file), or for a fix the rule to change +- Compiled test projects `` — the marker models to verify against. Default: `.opentaint/test-compiled//sinks` and `.opentaint/test-compiled//sources` (`` = the package-kebab) +- Test project `` — the sources tree; the test joins go in each side's `//test-rules` (only `test rule run` loads them, never the main scan). Default: `.opentaint/test-projects/` +- Rules directory `` — where the lib rules are written. Default: `.opentaint/rules` +- Tracking file `` — the lib unit file. Default: `.opentaint/tracking/rules/lib/.yaml` +- Approximation directories `` / `` (optional) — apply on a re-dispatch when the test project needs a library model that's now built. Default: none + +Built-in rules are available at `opentaint health --rules` + +## Workflow + +### 1. Check existing coverage + +Browse builtin rules at `opentaint health --rules` for source/sink library rules to reference. A `refs` to a built-in source/sink is cheaper and more accurate than a new one + +### 2. Wire sources and sinks + +Prefer referencing built-in source/sink library rules; write a custom one only when no built-in fits. Derive each pattern from the requirements' fully-qualified names and annotations + +Reference built-ins: + +```yaml +refs: + - rule: java/lib/generic/servlet-untrusted-data-source.yaml#java-servlet-untrusted-data-source + as: servlet-source + - rule: java/lib/spring/untrusted-data-source.yaml#spring-untrusted-data-source + as: spring-source +``` + +Custom source library rule (`/java/lib/generic/my-source.yaml`), if no built-in fits: + +```yaml +rules: + - id: my-custom-source + options: + lib: true + severity: NOTE + message: Custom untrusted data source + languages: [java] + patterns: + - pattern-either: + - patterns: + - pattern: | + $RETURNTYPE $METHOD(HttpServletRequest $UNTRUSTED, ...) { ... } + - metavariable-pattern: + metavariable: $METHOD + pattern-either: + - pattern: doGet + - pattern: doPost +``` + +Custom sink library rule (`/java/lib/generic/my-sink.yaml`): + +```yaml +rules: + - id: my-custom-sink + options: + lib: true + severity: NOTE + message: Custom dangerous operation + languages: [java] + mode: taint + pattern-sinks: + - patterns: + - pattern-either: + - pattern: (java.sql.Statement $S).executeQuery($UNTRUSTED) + - pattern: (java.sql.Statement $S).execute($UNTRUSTED) + - focus-metavariable: $UNTRUSTED +``` + +### 3. Write the test joins (against the generic marker) + +A lib rule emits nothing alone — to exercise it you need a join. Write one test join per sub-project into its `test-rules/java/security/`, wiring your new lib rules to the generic `Taint` marker. These live only in the test project (never ``), so the main scan never loads them. Name each `-sinks` / `-sources` so the samples' `value`/`id` resolve: + +- `sinks/` → `-sinks`: ref the generic source + every new sink lib rule, wiring `src.$UNTRUSTED -> .$UNTRUSTED` for each +- `sources/` → `-sources`: ref every new source lib rule + the generic sink, wiring `.$UNTRUSTED -> sink.$VALUE` for each + +```yaml +# .opentaint/test-projects//sinks/test-rules/java/security/-sinks.yaml +rules: + - id: -sinks + severity: ERROR + message: Tainted value reaches a sink under test + metadata: + cwe: CWE-000 + short-description: test join for the package's sinks + languages: [java] + mode: join + join: + refs: + - rule: java/lib/test/generic-source.yaml#generic-taint-source + as: src + - rule: java/lib//my-new-sink.yaml#my-new-sink + as: sink + on: + - 'src.$UNTRUSTED -> sink.$UNTRUSTED' +``` + +The marker rules resolve from the sub-project's `test-rules` root, your lib rules from `` — pass both to `test rule run`. Metavariable names must match across `refs` and `on` + +### 4. Test until success + +Run the tests against each compiled sub-project, loading your lib rules (``) and the test joins + markers (`//test-rules`); iterate until every sample passes: + +```bash +opentaint test rule run /sinks \ + -o .opentaint/test-results//sinks \ + --ruleset --ruleset /sinks/test-rules +``` + +`test rule run` auto-loads the built-in rules, so pass only your custom rulesets — a literal `builtin` here would be treated as a path. When the caller passed `` / ``, append `--passthrough-approximations ` / `--dataflow-approximations ` — without them a library method the test flow relies on drops taint and the positive can't pass. Read `.opentaint/test-results//sinks/test-result.json`: + +- `falseNegative` (positive didn't trigger) → patterns too narrow; broaden `pattern-either`, check metavariable names match across branches and between `refs` and `on` +- `falsePositive` (negative triggered) → patterns too broad; add `pattern-not`, `pattern-not-inside`, `pattern-sanitizers`, or `metavariable-regex` +- `skipped` / `disabled` → the rule wasn't exercised; fix the annotation `value`/`id`, or enable the rule + +### 5. When a positive won't pass after a couple of fixes + +A `@PositiveRuleSample` that won't trigger after ~2 fix attempts may have a cause no rule edit can fix — a library method on its flow killing taint. Before escalating, scan that sub-project's model with `--track-external-methods` (add the marker `test-rules` so the join resolves): + +```bash +opentaint scan --project-model /sinks \ + -o .opentaint/test-results//sinks/diag.sarif \ + --ruleset builtin --ruleset --ruleset /sinks/test-rules \ + --track-external-methods +``` + +Read `dropped-external-methods.yaml` next to it; either way leave `tests_passing: pending`: + +- a dropped method on the failing sample's source→sink path → that's the cause, not the rule: report which methods need a model, to be approximated before you're re-dispatched +- nothing dropped and no clear rule cause → report non-convergence for escalation, rather than editing blindly + +## Output + +- The new lib rule file(s) under ``, and the test join(s) under each test project's `test-rules/` +- Tracking updated: the lib rules' `rule_id`s/`artifact`, `stages.tests_passing` (per Tracking) +- Report the lib rule ids, a one-line test summary per sub-project, and the exact `test rule run` command used +- If blocked (step 5): leave `tests_passing: pending` and report the cause instead + +## Tracking + +In ``, once the lib rules exist and every sub-project's samples pass: + +```yaml +artifact: .opentaint/rules/java/lib/generic/my-sink.yaml +stages: + tests_passing: done +``` + +## Constraints + +- Library rules MUST have `options.lib: true` and `severity: NOTE` +- Security rules (the joins) MUST have `metadata.cwe` and `metadata.short-description` +- Source/sink metavariable names must match across `refs` and `on` clauses, or the join won't connect; bind the tainted value as `$UNTRUSTED` in every lib source/sink rule, so the security joins assemble-lib-rules writes later reference one consistent name +- The `rule:` path in `refs` is relative to the ruleset root — a marker ref resolves under the test project's `test-rules`, a lib ref under `` +- Rule IDs must be globally unique +- For simple structural patterns (no dataflow), omit `mode:` (uses default mode) +- Custom library rules go under `/java/lib/generic/` or `/java/lib/spring/` (for Spring-specific), mirroring the built-in layout — never directly under `java/lib/`; the test joins go in the test project's `test-rules/java/security/`, never `` + + +## Gotchas + +- A wrong argument position in `(..., $UNTRUSTED, ...)` focuses the wrong parameter — point `focus-metavariable` at the tainted one +- Refine the rule, never the test project — don't edit or weaken samples here; if one is wrong, hand it back upstream +- A positive that won't pass because a library method drops taint is not a rule bug — don't broaden the rule to force it; surface it for approximation (step 5) +- The `#` comments in the examples here are for you — don't copy them into the rule files you write; keep produced YAML comment-free +- An implicit-receiver pattern `this.method(...)` is unsupported ("Failed to transform pattern: ThisExpr") — match the unqualified call as a bare `method($X)` pattern instead +- A structural (no-source) sink and a taint-flow sink can't share one join id — the engine forbids one id being both; if a class needs both, split them into separate rules/joins +- Don't unpack or grep the analyzer JAR for built-in rules — its internals aren't a stable API; read the YAMLs from `opentaint health --rules` diff --git a/skills/create-test-project/SKILL.md b/skills/create-test-project/SKILL.md new file mode 100644 index 000000000..54d8cf3da --- /dev/null +++ b/skills/create-test-project/SKILL.md @@ -0,0 +1,96 @@ +--- +name: create-test-project +description: Create an OpenTaint test project with annotated positive/negative samples for verifying a rule or approximation. Use when a rule or approximation needs a test project to check against +license: Apache-2.0 +metadata: + author: opentaint + version: "0.2" +--- + +# Skill: Create Test Project + +Build a minimal compiled test project whose annotated samples reproduce the flow a rule or approximation is checked against. The compiled model is the deliverable; its sources sit alongside it + +## Inputs + +From the caller; if omitted, fall back to the default. Ask only when a required input is missing and has no sensible default + +- What to test `` — a rule's requirements, or the package's methods to exercise +- Project root `` — the real sources the requirements point into. Default: current directory +- Tracking file `` — the rule or approximation file this test serves. Default: `.opentaint/tracking/rules/lib/.yaml` or `.opentaint/tracking/approximations/.yaml` +- Test project `` — sources. Default: `.opentaint/test-projects/` (a rule project holds a `sinks/` and/or `sources/` sub-project under it) +- Compiled output `` — the model. Default: `.opentaint/test-compiled/` (one model per sub-project: `/sinks`, `/sources`) +- Dependencies — exact Maven coordinates the samples need; default: the `dependencies` list in ``; with no tracking file, derive them from the project's `build.gradle`/`pom.xml` + +`` is the package (``) for a rule, or the dataflow approximation unit (`-dataflow`, e.g. `reactor-core-publisher-dataflow`) for an approximation; the two never share a folder + +## Workflow + +### 1. Init the project + +Pick the scaffold by shape, then pass each coordinate from the tracking file's `dependencies` as a `--dependency`: + +- a rule → `test rule init` — scaffolds a `sinks/` and a `sources/` sub-project under ``, each with `Taint.java` (the generic `source()`/`sink()`) and the generic marker lib rules in its `test-rules/`. Pass `--sinks-only` / `--sources-only` for a package with only one side, so you get a single sub-project +- a dataflow approximation → `test approximation init` (Gradle build + the test-util jar, plus `Taint.java` and the fixed `approximation-rule.yaml` the harness applies) + +```bash +# rule test projects — both sides (this package has new sinks and new sources) +opentaint test rule init \ + --dependency "org.springframework:spring-webflux:6.1.0" +# sink-only package +opentaint test rule init --sinks-only \ + --dependency "org.mybatis:mybatis:3.5.13" + +# dataflow approximation test project +opentaint test approximation init \ + --dependency "io.projectreactor:reactor-core:3.8.5" +``` + +### 2. Read the real signatures, then write samples + +The requirements name sources and sinks. For each new source and new sink, read its real method signature from the package jar in `.opentaint/project/dependencies` (with `javap`) — the pattern matches on that, so a sample built on the wrong signature compiles but verifies nothing. The flow is minimal, not the app's real path, and the counterpart is always the generic `Taint` marker (so types always fit — never a real source/sink): + +- a **sink** sample (in the `sinks/` sub-project): assign `test.Taint.source()` to a local of the sink argument's type, then pass it in — `String t = test.Taint.source(); pkg.theSink(t);` (the generic `source()` infers the type, no cast) +- a **source** sample (in the `sources/` sub-project): call the new source, then pass its value into `test.Taint.sink(...)` — `var v = pkg.theSource(); test.Taint.sink(v);` (`sink` takes `Object`, so any type fits) + +Write Java samples under `//src/main/java/test/`, each annotated with its expected verdict — `@PositiveRuleSample` (must flag) or `@NegativeRuleSample` (must not). `value`/`id` point at that sub-project's test join, which create-rule writes: `value = "java/security/-sinks.yaml", id = "-sinks"` for sink samples, `-sources` for source samples (`` = the package-kebab). `value` is the rule path relative to the test-rules root, `id` the short id — not the full `--rule-id` used by `opentaint scan`. One expected verdict per sample + +Load and follow `references/rule.md` (for a rule) or `references/approximation.md` (for a dataflow approximation) + +### 3. Compile + +Compile each project to its own model — a rule's `sinks/` and `sources/` sub-projects separately; an approximation's single project once: + +```bash +# rule +opentaint compile /sinks -o /sinks +opentaint compile /sources -o /sources +# approximation +opentaint compile -o +``` + +A clean compile is the deliverable. If one won't build, fix that project's samples or dependencies before handing off + +## Output + +- The compiled model(s) (``, per sub-project for a rule) plus their sources (``); report the paths and the exact `compile` command(s) used +- The tracking file's `test_project` stage marked done (see Tracking) + +## Tracking + +In ``, set only the test-project stage (`in_progress` while building, `done` once it compiles): + +```yaml +stages: + test_project: done +``` + +Do not touch other stages or fields + +## Gotchas + +- One expected verdict per sample +- One unit per `` folder — never write into another unit's project, so concurrent agents don't race +- The scaffold (`test rule init` / `test approximation init`) defaults to Java 8 — bump `source/targetCompatibility` when the samples use a library needing Java 17/21 (Spring 7, spring-data 4, Lucene 10, Jackson 3). Set `release` on the running JDK; a Gradle `toolchain{}` block fails here (only JDK 21 is locatable, with no download repo) +- A positive must route the marker `source()` into the sink — a sink whose only untrusted input is a bare method parameter with no in-sample source (e.g. `getValue(Expression e)`) can't be satisfied by any taint-flow join; feed the parameter from `test.Taint.source()` or the sample is unprovable +- For library-method behavior the requirements don't pin down (does it sanitize? propagate taint?), read the dependency or its docs rather than guessing diff --git a/skills/create-test-project/references/approximation.md b/skills/create-test-project/references/approximation.md new file mode 100644 index 000000000..65fcab1c9 --- /dev/null +++ b/skills/create-test-project/references/approximation.md @@ -0,0 +1,51 @@ +# Dataflow approximation test project + +## How it tests + +`opentaint test approximation run` applies one fixed source → sink rule automatically — you do not author or pass a rule. That rule matches a fixed pair, `test.Taint.source()` and `test.Taint.sink(...)`, provided by the `Taint` helper scaffolded into the project. Your samples route taint from `Taint.source()` through the method being approximated into `Taint.sink(...)`. Granularity is per sample (`className#methodName`), so the one fixed rule covers every sample — a broken approximation only flips its own sample + +`opentaint test approximation init ` scaffolds the Gradle build, `Taint.java`, and the `approximation-rule.yaml` reference — you add only the samples (under `src/main/java/test/`) + +## Positive sample + +Put samples under `src/main/java/test/`, each a public method annotated with the fixed rule. A positive sends `Taint.source()` through the approximated method into `Taint.sink(...)`; it stays a `falseNegative` until the approximation propagates the taint, then flips to `success`. One positive per method being approximated + +```java +package test; + +import org.opentaint.sast.test.util.PositiveRuleSample; + +import java.util.HashMap; +import java.util.Map; + +public class ApproximationSamples { + + @PositiveRuleSample(value = "approximation-rule.yaml", id = "approximation-rule") + public void taintReachesSink() { + String tainted = Taint.source(); + Map cache = new HashMap<>(); + String routed = cache.computeIfAbsent(tainted, k -> k); // the approximated method + Taint.sink(routed); + } +} +``` + +## Negative sample — only for shared state + +Add a `@NegativeRuleSample` only when the method holds state that taint must not cross — a container, cache, registry, or builder where you store under one key/field and read from another. Write a negative that stores tainted data under one variable and reads a different one; with a correct model the read stays clean, so the sample must not fire. For plain propagation (argument → result, or a value through a callback) the positive alone proves the model — skip the negative + +```java + @NegativeRuleSample(value = "approximation-rule.yaml", id = "approximation-rule") + public void taintDoesNotCrossKeys() { + Map cache = new HashMap<>(); + cache.put("k1", Taint.source()); // taint stored under one key + Taint.sink(cache.get("k2")); // a different key — must stay clean + } +``` + +A negative that fires (`falsePositive` in `test-result.json`) means the model is over-broad — it taints a read it shouldn't. Narrow the approximation until the negative stays non-firing while the positive still passes + +## Notes + +- `value`/`id` always reference the fixed rule: `approximation-rule.yaml` / `approximation-rule`. `test approximation run` applies its own bundled copy, so the project's `approximation-rule.yaml` is only a reference — what matters is that samples call `test.Taint.source()` / `test.Taint.sink(...)` +- the sample's receiver type fixes the dropped method's fully-qualified name, and the approximation must `@Approximate` that exact class — so mirror the real call's receiver type. An interface-typed receiver (`Map m`, e.g. a method parameter) drops `java.util.Map#computeIfAbsent`; a concrete `Map cache = new HashMap<>()` drops `java.util.HashMap#computeIfAbsent`. The `new HashMap<>()` form above is just one case — match whichever the real flow uses diff --git a/skills/create-test-project/references/rule.md b/skills/create-test-project/references/rule.md new file mode 100644 index 000000000..20f4041c9 --- /dev/null +++ b/skills/create-test-project/references/rule.md @@ -0,0 +1,44 @@ +# Rule test project + +## Samples + +The fixed counterpart is always the generic `Taint` marker (scaffolded by `test rule init`), never a real source/sink — so types fit cast-free and the sample only exercises the rule under test. + +- `@PositiveRuleSample` — a minimal flow that must flag, with real sink/source signatures and no extra hops: + - **sink** under test → ` t = test.Taint.source(); pkg.theSink(t);` — declare the local as the sink argument's type; the generic `source()` infers it, no cast + - **source** under test → `var v = pkg.theSource(); test.Taint.sink(v);` — `sink` takes `Object`, so any type fits + One positive per new sink (in `sinks/`) and per new source (in `sources/`); `value`/`id` point at that sub-project's test join (`-sinks` / `-sources`, `` = the package-kebab) +- `@NegativeRuleSample` — the safe (sanitized or parameterized) variant of the same, which must not flag. Keep it realistic, not stripped to constants + +```java +package test; + +import org.opentaint.sast.test.util.PositiveRuleSample; +import org.opentaint.sast.test.util.NegativeRuleSample; +import java.sql.Connection; +import java.sql.Statement; + +// sinks/ sub-project — a SQL sink fed by the generic marker source +public class SqlSinkTest { + private Connection db; + + @PositiveRuleSample(value = "java/security/jdbc-sinks.yaml", id = "jdbc-sinks") + public void vulnerable() throws Exception { + String input = test.Taint.source(); // generic marker: infers String, no cast + Statement stmt = db.createStatement(); + stmt.executeQuery("SELECT * FROM users WHERE id = " + input); + } + + @NegativeRuleSample(value = "java/security/jdbc-sinks.yaml", id = "jdbc-sinks") + public void safe() throws Exception { + String input = test.Taint.source(); + var pstmt = db.prepareStatement("SELECT * FROM users WHERE id = ?"); + pstmt.setString(1, input); + pstmt.executeQuery(); + } +} +``` + +## Spring-entry flows + +If the flow only fires through a Spring entry point (controller → bean → sink), a plain method sample will be a `falseNegative`. Use the multi-module Spring layout — read `spring-multimodule.md` and follow it diff --git a/skills/create-test-project/references/spring-multimodule.md b/skills/create-test-project/references/spring-multimodule.md new file mode 100644 index 000000000..3694a0c7e --- /dev/null +++ b/skills/create-test-project/references/spring-multimodule.md @@ -0,0 +1,60 @@ +# Spring multi-module test projects + +Load this when a plain method-level sample returns `falseNegative` because the flow only fires through a Spring entry point (controller → bean → sink). Some rules only trigger inside a full Spring MVC entry-point graph — a `@PositiveRuleSample` on a bare method won't trigger them, because the tainted data must flow from a discovered `@Controller`. + +For these rules, create one dedicated Gradle sub-project per sample. Each sub-project is a complete, minimal Spring application containing exactly one `@PositiveRuleSample` or `@NegativeRuleSample`. Split positive and negative cases into separate sub-projects, e.g. `xss-spring-test-positive` and `xss-spring-test-negative`. + +## How detection works + +`TestProjectAnalyzer` computes a `testSetName` per module as `module.moduleSourceRoot.relativeTo(project.sourceRoot)`, with `/` replaced by `-` (see `core/src/main/kotlin/org/opentaint/jvm/sast/project/TestProjectAnalyzer.kt`). If the name starts with `spring-app-tests`, the module is treated as a Spring test set: + +- All sample annotations in the module are collected as usual +- Each sample is wrapped in a `SpringTestSample` that uses the Spring dispatcher method as the analysis entry point instead of the annotated method itself +- Taint therefore originates from real `@Controller` request parameters and must reach the annotated sink method through normal Spring wiring + +Consequence: the annotated method is only a marker for which rule to run and the expected verdict. The actual vulnerable/safe flow must be reachable from a controller in the same module. Keep each module to a single annotation so the verdict is unambiguous. + +## Project layout + +Multi-module Gradle build where every `spring-app-tests/` directory is its own sub-project: + +``` +/ +├── settings.gradle.kts +├── build.gradle.kts +└── spring-app-tests/ + ├── xss-spring-test-positive/ + │ ├── build.gradle.kts + │ └── src/main/java/test/ + │ ├── VulnerableController.java // @Controller with the tainted flow + │ └── VulnerableSink.java // carries the single @PositiveRuleSample + └── xss-spring-test-negative/ + ├── build.gradle.kts + └── src/main/java/test/ + ├── SafeController.java + └── SafeSink.java // carries the single @NegativeRuleSample +``` + +`settings.gradle.kts` should auto-discover every `spring-app-tests/*/build.gradle.kts` so adding a case only needs a new directory. See `rules/test/settings.gradle.kts` in the OpenTaint repo for a reference implementation. + +## Required dependencies + +Each Spring sub-project needs at least: + +- `compileOnly` on `opentaint-sast-test-util` (the sample annotations) +- `org.springframework:spring-webmvc` and `spring-context` (so `@Controller` is recognized) +- Any libraries the sample itself uses (servlet-api, JDBC, etc.) + +## Compile + +```bash +opentaint compile -o +``` + +Each `spring-app-tests/` sub-project becomes an independent test set and appears as its own entry in `test-result.json`. + +## Common pitfalls + +- No `@Controller` in the module → `TestProjectAnalyzer` logs `No spring entry point found` and the sample is analyzed without Spring context, usually a false negative. Always include a controller that reaches the sink +- More than one annotation per module → results become ambiguous; keep it to one sample per sub-project +- Module path not starting with `spring-app-tests` → `isSpringAppTestSet()` returns false and the sample runs as a regular method-level test, so Spring flows won't trigger diff --git a/skills/debug-rule/SKILL.md b/skills/debug-rule/SKILL.md new file mode 100644 index 000000000..565ecfae6 --- /dev/null +++ b/skills/debug-rule/SKILL.md @@ -0,0 +1,84 @@ +--- +name: debug-rule +description: Debug a rule or approximation that behaves unexpectedly by tracing where taint is dropped. Use when its samples won't pass after repeated attempts, or it passes tests but is wrong on a real scan +license: Apache-2.0 +metadata: + author: opentaint + version: "0.2" +--- + +# Skill: Debug Rule + +Diagnose why a rule or approximation behaves unexpectedly on a model — samples that won't pass after repeated attempts, a missed flow, or a spurious finding on a real scan — by tracing where taint is dropped, and decide who owns the fix: the rule, a missing library model, or the engine + +## Inputs + +From the caller; if omitted, fall back to the default. Ask only when a required input is missing and has no sensible default + +- Rules `` — the security rule to trace AND every library rule it `refs` (source/sink), each as `.yaml:`; fact-reachability runs only the rules you list and silently disconnects the join if a ref is missing. For an approximation, trace the rule whose sample routes taint through the approximated method +- Project model `` — the model where the behavior shows up. Default: `.opentaint/test-compiled/` for a test project, or `.opentaint/project` for a main scan +- Ruleset `` — Default: `builtin` plus `.opentaint/rules` +- Output directory `` — where the debug SARIF lands. Default: `.opentaint/test-results/` for a test model, or `.opentaint/results` for a main scan +- Dropped external methods `` — the list from the run that showed the problem. Default: `dropped-external-methods.yaml` next to that run's SARIF +- Approximation directories `` / `` (optional) — apply when the behavior depends on them, so the debug run matches the run that showed the problem. Default: `.opentaint/pass-through`, `.opentaint/dataflow` + +## Workflow + +### 1. Precondition — library model complete + +Open `` from the run that showed the problem. If any method on the source→sink path is listed, STOP and model it (passThrough or dataflow), re-run, then debug — that missing model is the cause, not the engine. A method you already approximated that is still listed means the approximation isn't matching the real signature; fix it there. Debug only once no method on the path remains; if no `` exists, produce one with a `--track-external-methods` run + +### 2. Localize the kill — fact-reachability SARIF + +Pass the single rule to debug as the positional `` — its library `refs` (source/sink) are collected and analyzed automatically, so you don't list them: + +```bash +opentaint test rule reachability \ + --project-model \ + -o /report.sarif \ + --ruleset builtin --ruleset +``` + +The debug output is the sibling file `/debug-ifds-fact-reachability.sarif`, NOT the `-o` SARIF. The `-o` file is the regular rule run (findings only); the per-instruction fact-reachability data — what shows where taint dies — lives only in the sibling. Read the sibling; the `-o` SARIF only tells you whether the rule fired, not why + +When the thing under debug is an approximation (or the flow depends on one), append `--passthrough-approximations ` / `--dataflow-approximations ` so the trace runs with it applied — taint dying at the approximated call then means the approximation isn't propagating: wrong signature (still in ``), empty body, or wrong from→to. For a missed detection (a `@PositiveRuleSample` that won't pass, or a flow absent from a scan): confirm a fact exists at the source — if not, the gap is in `pattern-sources` — then walk the facts to the last instruction still carrying the fact and the first where it's gone; that gap is where taint dies. For a spurious detection, do the reverse: find where a fact appears with no tainted input reaching it + +### 3. Isolate an entry point (optional) + +When the run misses the flow and you suspect the entry method is never reached, force analysis onto it with the same `reachability` command plus `--entry-points` set to a method FQN: + +```bash +opentaint test rule reachability \ + --entry-points "com.example.Controller#handle" \ + --project-model \ + -o /report.sarif \ + --ruleset builtin --ruleset +``` + +A finding that appears here but not in the full run points to entry-point discovery / reachability, not the dataflow; if it still doesn't appear, localize the kill with step 2. On Spring projects the flag is **additive, not restrictive**: auto-discovered endpoints stay and your method is added if absent — use it only to force-include a method the analyzer never starts from (an endpoint Spring didn't recognize); you can't narrow to a single method + +### 4. Classify the cause + +An engine bug is the least likely outcome by far — assume it last. Nearly every taint kill is a missing or wrong library model (an un-approximated method, or an approximation whose signature/from→to is off) or a rule defect; both are tedious to rule out, but that's not a reason to jump to "engine". Exhaust the first two before you even consider the third. + +The killing instruction decides who owns the fix: + +- external library method → missing or broken model. If the method is NOT in `approximated-external-methods.yaml`, step 1 should have caught it (route to analyze-external-methods + create-*-approximation). If it IS listed (a built-in claims to model it) yet taint dies here, the built-in is wrong for this case — write your own override: passthrough overrides at the rule level, so prefer a passthrough config for the specific method; a dataflow override conflicts with built-ins at load, so fall back to passthrough on that method, or if only a dataflow shape can express the propagation, treat it as an engine issue +- something the rule should handle — a mistaken sanitizer, an unmatched sink or source variant → fix the rule +- a plain instruction the engine should propagate through (assignment, cast, field read, an already-modeled call), with the rule correct and model complete → engine issue; route to report-analyzer-issue with the trace + +## Output + +- The diagnosis: `file:line` and instruction where taint is killed (or spuriously introduced), and which of the three causes it is +- For an engine issue, the fact-reachability trace from `debug-ifds-fact-reachability.sarif` up to the last reachable fact — report-analyzer-issue's input +- The exact debug command(s) used and the model they ran against + +## Tracking + +None — diagnostic, writes no tracking file + +## Gotchas + +- Don't reach for an "engine" verdict because ruling out a model or rule cause is tedious — a missing/wrong approximation or a rule gap is overwhelmingly more likely. Classify engine only when the killing instruction is a plain propagation (assignment, cast, field read, an already-modeled call) with the model proven complete and the rule proven correct +- One rule per fact-reachability run; across many rules the report is unusably huge +- Debug the exact run that showed the problem — same model, rulesets, approximation dirs — or you debug something else; never swap the model mid-analysis diff --git a/skills/discover-attack-surface/SKILL.md b/skills/discover-attack-surface/SKILL.md new file mode 100644 index 000000000..9fcaecbae --- /dev/null +++ b/skills/discover-attack-surface/SKILL.md @@ -0,0 +1,110 @@ +--- +name: discover-attack-surface +description: Analyze project-used members of a dependency package for potential sources and sinks not covered by the built-in rules. Use for the depth pass of attack-surface discovery, one package at a time, after triage-dependencies flags it +license: Apache-2.0 +metadata: + author: opentaint + version: "0.2" +--- + +# Skill: Discover Attack Surface + +Take one library the triage flagged, settle what the built-in rules already cover for the package members this project uses, and write that project-used rule plan — the untrusted-data sources and dangerous sinks actually relevant to this project — for the next phase to build + +## Inputs + +From the caller; if omitted, fall back to the default. Ask only when a required input is missing and has no sensible default + +- Package `` — the flagged library to drill (a `pending` entry in `coverage.yaml`) +- Dependency jars `` — the project's resolved dependency jars, one per library. Default: `.opentaint/project/dependencies` +- Project model `` — the built model. Default: `.opentaint/project` +- Tracking directory `` — where the coverage record and the per-package lib units live. Default: `.opentaint/tracking` + +## Workflow + +### 1. Settle built-in coverage first + +Before planning anything, see what the built-ins already match for this package's project-used members — read the lib rules (`opentaint health --rules`) plus `.opentaint/rules`. Decide one of: + +- **full** — the built-ins already match the project-used package sources/sinks → write no lib unit, flip the `coverage.yaml` entry to `done` with a `builtin_coverage: full` note, and stop. Don't drill further +- **partial** — built-ins match some project-used methods/overloads/classes but miss others → plan only the missing used members (`coverage: expand`, ref the built-in for the rest) +- **none** — plan the package's project-used surface from scratch + +### 2. Scope project-used sources and sinks + +Find the package's jar in `` only to confirm the dependency identity and inspect signatures/docs for members already in scope (match the artifact from the dependency GAV; `unzip -l | grep ` confirms it owns the package). To get the bytecode-derived list of package methods the project statically references, run this skill's bundled `scripts/package-usages.sh ` (Windows: `scripts/package-usages.ps1`; the scripts live in the skill directory, not the project) and save its output to `/usage/.yaml` (create `usage/` if needed). It reads `moduleClasses`/`packages:` from `project.yaml` and disassembles the project's **own** compiled classes only — a model's `moduleClasses` can mix project + dependency jars/dirs, so when the modules carry a `packages:` list only classes under those roots are scanned, otherwise `moduleClasses` is already project-only — then prints the deduped `// Method`/`// InterfaceMethod` call sites whose owner is in ``. + +This catches only bytecode invocations, so it misses members reached through annotations, class literals, casts, reflection, dynamic proxies, framework/container dispatch, config strings, or generated code absent from the model. Treat the output as the main used-in-project scope, then inspect app source, dependency API/source, and framework configuration only to classify those used members and to add indirectly reached members the bytecode list cannot show. Do not enumerate the whole package API. Never disassemble the analyzer jar — only the project's own classes + +- **sources** — the exact place untrusted data first enters from a boundary (network, persistence, serialization, messaging, execution): a method that *returns* attacker-controlled data — HTTP/RPC request data, a message-broker payload. NOT a method that merely passes data it was handed along — that's a propagator the engine already handles, not a source. General, not class-tagged +- **sinks** — dangerous operations (query construction, command/file/path ops, deserialization, template/EL, LDAP/JNDI, reflection); tag each with its vuln class (`ssrf`, `sqli`, `path-traversal`, …) + +Verify each is real before recording: a source genuinely attacker-controlled, a sink genuinely dangerous with tainted input. Don't trace a flow between them — the analyzer pairs them at scan time + +### 3. Write the package's rule plan + +Write `/rules/lib/.yaml` — only the project-used new sources and sinks, grouped by `vuln_class`, the dependency GAV, `stages.description: done`, and each `coverage: new` or `expand`. Then flip the package's `coverage.yaml` entry to `status: done`. `` is the dotted package with `.` → `-`; the `package:` field keeps the real dotted name + +## Output + +- A `/rules/lib/.yaml` rule plan for project-used members only (or, for `full` coverage, none — just the coverage note) +- A `/usage/.yaml` package usage snapshot from `package-usages.sh` +- The package's `coverage.yaml` entry set `status: done` with a one-line `notes` +- A brief summary to the caller: the sources and sinks planned (one line each, marked `new` / `expand`). The unit holds the detail — don't paste it back + +## Tracking + +`/coverage.yaml` — flip this package's entry when done: + +```yaml + - package: org.springframework.web.reactive.function.client + status: done + notes: WebClient request methods — SSRF sink; built-ins cover get(), expand with post()/put(); no new source +``` + +`/usage/.yaml` — temporary-but-persisted project-used scope. Keep it next to the rule plans so resumed agents can reuse it instead of rerunning extraction: + +```yaml +functions: + - function: "org.springframework.web.reactive.function.client.WebClient#get()Lorg/springframework/web/reactive/function/client/WebClient$RequestHeadersUriSpec;" +classes: + - class: "org.springframework.web.reactive.function.client.WebClient" +``` + +`/rules/lib/.yaml` — the rule plan; fill only the discovery-stage fields (create-test-project and create-rule fill the rest): + +```yaml +package: org.springframework.web.reactive.function.client +dependencies: + - org.springframework:spring-webflux:6.1.0 +builtin_coverage: partial # partial | none +sources: # general, not class-tagged + - idea: ServerRequest body/params/headers — untrusted request data + coverage: new # new | expand + builtin: null + rule_id: null +sinks: # grouped by vuln class + - vuln_class: ssrf + idea: WebClient.get/post/put().uri($UNTRUSTED) + coverage: expand + builtin: java/lib/generic/ssrf-sinks.yaml#java-ssrf-sink + rule_id: null +stages: + description: done + test_project: pending + tests_passing: pending +notes: > + free-form +``` + +## Engine notes + +- Spring projects: the analyzer auto-discovers Spring endpoints, so `network` inbound sources are largely ones the built-ins already see — focus on the sinks +- Generic projects: the analyzer treats all public/protected methods of public classes as entry points +- Stored / second-order injection (data persisted then read back) is modeled by the engine on its own — don't plan a source for the read-back or a propagator for the store→read path + +## Gotchas + +- Plan, don't write — record source/sink ideas only; the lib rules are written and tested in the next phase +- Don't re-declare a source or sink a built-in already matches — `coverage: expand` with only the missing used methods, or fold it into `full` coverage +- Don't add unused package APIs just because they look security-relevant — this phase scopes rules to what the project uses or reaches indirectly diff --git a/skills/discover-attack-surface/scripts/package-usages.ps1 b/skills/discover-attack-surface/scripts/package-usages.ps1 new file mode 100644 index 000000000..174c95783 --- /dev/null +++ b/skills/discover-attack-surface/scripts/package-usages.ps1 @@ -0,0 +1,44 @@ +param( + [Parameter(Mandatory)][string]$ModelDir, + [Parameter(Mandatory)][string]$Package +) + +$pp = $Package -replace '\.','/' +$yaml = Get-Content (Join-Path $ModelDir 'project.yaml') + +function Get-YamlList([string]$key) { + $f = $false + foreach ($l in $yaml) { + if ($l -match "^\s*$key:\s*$") { $f = $true; continue } + if ($f) { + if ($l -match '^\s*-\s+(.+?)\s*$' -and $l -notmatch ':') { $Matches[1] } + elseif ($l -match ':') { $f = $false } + } + } +} + +$roots = (Get-YamlList 'packages' | ForEach-Object { [regex]::Escape(($_ -replace '\.','/')) }) -join '|' + +$out = foreach ($e in (Get-YamlList 'moduleClasses')) { + $p = Join-Path $ModelDir $e + if (Test-Path -LiteralPath $p -PathType Container) { + $base = (Resolve-Path -LiteralPath $p).Path + $names = Get-ChildItem -LiteralPath $p -Recurse -Filter *.class | + ForEach-Object { ($_.FullName.Substring($base.Length).TrimStart('\','/') -replace '\.class$','') -replace '[\\/]','.' } + } else { + $names = & jar tf $p | Where-Object { $_ -match '\.class$' } | + ForEach-Object { ($_ -replace '\.class$','') -replace '/','.' } + } + if ($roots) { $names = $names | Where-Object { ($_ -replace '\.','/') -match "^($roots)/" } } + if ($names) { + $argfile = New-TemporaryFile + $names | Set-Content -LiteralPath $argfile + & javap -c -p -classpath $p "@$argfile" 2>$null + Remove-Item -LiteralPath $argfile + } +} + +$out | + Select-String -Pattern ("// (Interface)?Method " + [regex]::Escape($pp) + "/\S+") -AllMatches | + ForEach-Object { $_.Matches } | ForEach-Object { $_.Value } | + Sort-Object -Unique diff --git a/skills/discover-attack-surface/scripts/package-usages.sh b/skills/discover-attack-surface/scripts/package-usages.sh new file mode 100755 index 000000000..d4c2bbf80 --- /dev/null +++ b/skills/discover-attack-surface/scripts/package-usages.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +MODEL=$1; PKG=$2 +[ -n "$MODEL" ] && [ -n "$PKG" ] || { echo "usage: package-usages.sh " >&2; exit 2; } +pp=${PKG//.//} + +ylist(){ awk -v k="$1" '$0~"^[[:space:]]*"k":[[:space:]]*$"{f=1;next} f&&/^[[:space:]]*-[[:space:]]/&&$0!~/:/{sub(/^[^-]*-[[:space:]]*/,"");print;next} f&&/:/{f=0}' "$MODEL/project.yaml"; } + +roots=$(ylist packages | tr . / | paste -sd'|' -) +ylist moduleClasses | while IFS= read -r e; do + p="$MODEL/$e" + { if [ -d "$p" ]; then (cd "$p" && find . -name '*.class' | sed 's#^\./##'); else jar tf "$p" | grep '\.class$'; fi; } \ + | { [ -n "$roots" ] && grep -E "^($roots)/" || cat; } \ + | sed 's#\.class$##; s#/#.#g' | xargs -r javap -c -p -classpath "$p" 2>/dev/null +done | grep -oE '// (Interface)?Method '"$pp"'/[^ ]+' | sort -u diff --git a/skills/generate-poc/SKILL.md b/skills/generate-poc/SKILL.md new file mode 100644 index 000000000..27fa85d5e --- /dev/null +++ b/skills/generate-poc/SKILL.md @@ -0,0 +1,102 @@ +--- +name: generate-poc +description: Reproduce a true-positive finding against the running application. Use when a finding needs dynamic confirmation +license: Apache-2.0 +metadata: + author: opentaint + version: "0.2" +--- + +# Skill: Generate PoC + +Try to make the vulnerability actually fire on a running instance via a Python script, and record the outcome — confirmed or failed + +## Inputs + +From the caller; if omitted, fall back to the default. Ask only when a required input is missing and has no sensible default + +- Finding `` — the TP finding file. Default: `.opentaint/tracking/findings/.yaml` (name is required) +- Project root `` — sources to build and run. Default: current directory +- App endpoint `` (optional) — base URL if the app is already running +- PoC directory `` — where the PoC script is saved. Default: `.opentaint/pocs` + +## Workflow + +### 1. Start the app + +Reuse `` if given. Otherwise build and start the app the way the project expects (`spring-boot:run`, `java -jar`, `docker compose`, …), wait until it's listening, and note the base URL. The PoC must hit a live instance + +When the app needs backing services (DB, broker, cache, …), bring them all up with one `docker compose` on a shared network rather than starting each by hand, and register it as a single `compose` entry + +Bind to `127.0.0.1` (`--server.address=127.0.0.1`, `docker run -p 127.0.0.1:8080:8080`, a compose override on the port mapping) — never `0.0.0.0` or a public interface: a live exploit must not be reachable off-host. A specific non-local IP is fine when the test genuinely needs one, but never the public wildcard + +Once it's listening, record it in the registry (see § Tracking) so the orchestrator can reap it later + +### 2. Map the finding to a live request + +From the finding's source location find the entry point — the route and method, and the param / header / body field that carries the tainted input — and a payload that drives it to the sink. Common shapes: + +- SQL injection — `?id=1' OR '1'='1` +- command injection — `?cmd=;cat /etc/passwd` +- path traversal — `?path=../../../etc/passwd` +- XSS — `?q=` +- SSRF — `?url=http://169.254.169.254/latest/meta-data/` +- XXE — an XML body with `` + +### 3. Write and run the PoC script + +Write a self-contained Python script to `/.py` that does any setup (auth, seed state), sends the request, and asserts the observable evidence — so it's re-runnable and self-checking. + +Run it. Confirmation needs observable proof — rows returned, file contents, command output, a time delay, an out-of-band callback, an injection-revealing error and so on + +### 4. Record the outcome + +- confirmed — the script fired and proved the vuln. Set `poc: confirmed`, record `poc_script`, and in `notes` describe the working sequence (setup → request(s) → observed evidence), not just the final request +- failed — after several attempts you couldn't confirm the finding, or the app/route couldn't be reached. Set `poc: failed`, save the script, and in `notes` record the variants you tried and why each didn't fire + +## Output + +- The PoC script at `/.py` +- The finding's `poc` set to `confirmed` or `failed`, `poc_script` recorded, evidence/reason in `notes` +- If you started the app, register it in `.opentaint/tracking/poc-servers.yaml` and leave it running so the next PoC can reuse it; report the ``. You do not stop it — the orchestrator tears down every registered instance at the end of the PoC phase +- Report the outcome to the caller; if failed, call out that the finding is unconfirmed. Do not write `.opentaint/vulnerabilities.md` — main assembles that from the confirmed findings + +## Tracking + +If you started an instance, append it to `.opentaint/tracking/poc-servers.yaml` (PoCs run one at a time, so the append never races) — the orchestrator reads this to tear instances down (`kind` + `ref` give it the stop command): + +```yaml +servers: + - kind: process # process | container | compose + port: 8080 + ref: "12345" # pid | container id/name | compose file path +``` + +In ``, set `poc` and `poc_script` and append the result to `notes`: + +```yaml +poc: confirmed # confirmed | failed +poc_script: .opentaint/pocs/brave-hopper.py +notes: > + + poc: logged in as a seeded user (POST /login), then GET /api/orders?orderBy=id);SELECT pg_sleep(5)-- + — the injected ORDER BY delayed the response ~5s while a benign orderBy=id returned instantly → time-based SQLi confirmed +``` + +Failed instead — narrate the attempts, not a single request: + +```yaml +poc: failed +poc_script: .opentaint/pocs/brave-hopper.py +notes: > + + poc: tried ' OR 1=1--, a UNION SELECT, and time-based pg_sleep on /api/orders and /api/orders/search; + every variant returned 400 — orderBy is whitelisted to column names server-side → could not reproduce +``` + +## Gotchas + +- Reproduce, don't theorize — a script you didn't run, or a 200 with no observable effect, is not a confirmation +- failed ≠ false positive — couldn't-reproduce isn't proof the code is safe (auth, missing state, wrong payload). Record `failed` and DO NOT flip `verdict` here +- Don't bind a started instance to `0.0.0.0` or a public interface — a running exploit must stay off-host (localhost, or a specific IP the test needs) +- Don't stop instances you started or skip registering them — the orchestrator owns teardown and can only reap what's in `poc-servers.yaml` diff --git a/skills/report-analyzer-issue/SKILL.md b/skills/report-analyzer-issue/SKILL.md new file mode 100644 index 000000000..3b6ee68cf --- /dev/null +++ b/skills/report-analyzer-issue/SKILL.md @@ -0,0 +1,59 @@ +--- +name: report-analyzer-issue +description: Write an OpenTaint engine-issue report from a confirmed diagnosis, optionally opening a GitHub issue. Use when engine-side issue got confirmed and requires report +license: Apache-2.0 +metadata: + author: opentaint + version: "0.2" +--- + +# Skill: Report Analyzer Issue + +Turn a confirmed engine-level diagnosis into a self-contained `.opentaint/issues/.md` report, and optionally a GitHub issue. It only writes the report from the diagnosis, the test project, and the rule or approximation it concerns — it runs no analysis of its own + +## Inputs + +From the caller; if omitted, fall back to the default. Ask only when a required input is missing and has no sensible default + +- Diagnosis `` — debug-rule's engine-level conclusion: where taint dies (`file:line` + instruction), the fact-reachability trace up to the last reachable fact, and observed vs expected verdict +- Test project `` / `` — the project the artifact was tested on and debug-rule traced, already built by create-test-project. Default: `.opentaint/test-projects/` / `.opentaint/test-compiled/` +- Artifact `` — the rule or approximation the issue concerns: a rule's full id and ruleset, or the approximation's target method(s) +- Issue file `` — where to write the report. Default: `.opentaint/issues/.md`; `` is a short kebab-case symptom name (a filename — no spaces or hashes) +- Open a GitHub issue `` (optional) — whether to also file at github.com/seqra/opentaint; the main agent decides and passes this. Default: no + +## Workflow + +### 1. Gate — require an engine diagnosis + +File a report only for an engine issue debug-rule already confirmed. The diagnosis must establish all three; if any is missing, return to the caller and ask for debugging first — don't verify or run anything yourself: + +- not a rule fix — the rule's patterns are correct; debug-rule ruled out tightening or broadening it +- not a missing model — no method on the source→sink path remains in `dropped-external-methods.yaml` +- it is the engine — taint is dropped at an instruction the engine should propagate through + +### 2. Write the report + +Write `` — this file is the deliverable; never return the diagnosis as chat text only. Assemble from the inputs: + +- Test project — `` path, the test command (`test rule run` / `test approximation run`), and the failing `test-result.json` snippet (e.g. a `@PositiveRuleSample` stuck at `falseNegative`) +- Rule / approximation — the ``: a rule's full id and ruleset, or the approximation's target method(s) +- Observed vs expected — e.g. expected a finding at `Sink.java:42`; observed none +- Where the dataflow dies — `file:line` and the instruction, quoted up to the last reachable fact +- Ruled-out causes — the three gate points +- Hypothesis — 1–3 sentences on what the engine is likely doing wrong there; a hypothesis, not a fix + +Keep it to about one screen plus the test project + +### 3. File on GitHub (only if asked) + +When `` is set, file the same content to the fixed repo: + +```bash +gh issue create --repo seqra/opentaint \ + --title ": " \ + --body-file +``` + +## Output + +- The written `` (always), and the issue URL if one was filed diff --git a/skills/run-scan/SKILL.md b/skills/run-scan/SKILL.md new file mode 100644 index 000000000..0f9bd32f1 --- /dev/null +++ b/skills/run-scan/SKILL.md @@ -0,0 +1,65 @@ +--- +name: run-scan +description: Run an OpenTaint scan on project and produces the SARIF report. Use whenever the user asks to scan or re-scan a project +license: Apache-2.0 +metadata: + author: opentaint + version: "0.2" +--- + +# Skill: Run Scan + +Run an OpenTaint scan over a project and collect results + +## Inputs + +From the caller; if omitted, fall back to the default. Ask only when a required input is missing and has no sensible default + +- Target `` / `` — pre-compiled model or source project directory. Default: model at `.opentaint/project` +- Ruleset `` — Default: `builtin` plus `.opentaint/rules` if present +- Rule IDs `` (optional) — full IDs to restrict the scan to, omit to run all loaded rules +- SARIF output `` — Default: `.opentaint/results/report.sarif` +- PassThrough config `` (optional) — a passThrough YAML file or a directory of them. Default: `.opentaint/pass-through` +- Dataflow approximations directory `` (optional) — Default: `.opentaint/dataflow` + +## Workflow + +Point at the code either way: a source project (CLI compiles it) as the positional `scan `, or a pre-built model via `--project-model `. If project model provided prefer using it instead of source project + +```bash +opentaint scan --project-model \ + -o \ + --ruleset builtin --ruleset \ + --track-external-methods +``` + +Append optional flags as needed: + +- `--rule-id ` — restrict to specific rules (repeatable); omit to run all loaded rules +- `--passthrough-approximations ` — apply passThrough configs from a YAML file or a directory of them (OVERRIDE: merged with built-ins at the rule level, a provided rule overrides a built-in only when it matches one; repeatable) +- `--dataflow-approximations ` — apply code-based approximations (Java sources, auto-compiled; or pre-compiled `.class` dirs, passed through as-is) + +## Output + +Three files, all next to the SARIF report: + +1. `` — findings with code-flow traces +2. `dropped-external-methods.yaml` — methods where dataflow facts were killed (no approximation model) → candidates to approximate; possible source of false negatives +3. `approximated-external-methods.yaml` — methods already modeled + +## Key Flags + +| Flag | Purpose | +|---|---| +| `--project-model` | Pre-compiled model directory (omit to scan a source project via the positional arg) | +| `--ruleset` | Rule directory (repeatable); `builtin` for built-ins | +| `--rule-id` | Restrict to specific full rule IDs (repeatable) | +| `--passthrough-approximations` | passThrough configs: a YAML file or directory of them (OVERRIDE, repeatable) | +| `--dataflow-approximations` | Directory of Java sources or compiled classes (repeatable) | +| `--track-external-methods` | Emit `dropped-external-methods.yaml` + `approximated-external-methods.yaml` next to the SARIF | +| `--timeout` | Analysis timeout (default 900s) | + +## Gotchas + +- Paths fall back to the `.opentaint/` layout when the caller omits them; the caller can override any of them +- Duplicate approximation targeting the same class as a built-in errors out diff --git a/skills/triage-dependencies/SKILL.md b/skills/triage-dependencies/SKILL.md new file mode 100644 index 000000000..633e30bfa --- /dev/null +++ b/skills/triage-dependencies/SKILL.md @@ -0,0 +1,70 @@ +--- +name: triage-dependencies +description: Mark which of a project's dependency libraries could introduce taint sources or sinks. Use to start attack-surface discovery +license: Apache-2.0 +metadata: + author: opentaint + version: "0.2" +--- + +# Skill: Triage Dependencies + +Read the project's dependency libraries and mark which ones touch a trust boundary — a place untrusted data can enter (source) or a dangerous operation it can reach (sink) — so depth analysis runs only on the libraries that can matter + +## Inputs + +From the caller; if omitted, fall back to the default. Ask only when a required input is missing and has no sensible default + +- Project root `` — the project sources and build files. Default: current directory +- Project model `` — the built model; its `project.yaml` lists every dependency. Default: `.opentaint/project` +- Tracking directory `` — where the coverage record is written. Default: `.opentaint/tracking` + +## Workflow + +### 1. List the dependencies + +Read `/project.yaml` — its `dependencies:` is every jar on the classpath. Resolve each to the library it is. Most of a large project's jars are transitive infrastructure + +### 2. Mark each library + +For each library decide: could it introduce an attacker-controlled source (e.g. HTTP/RPC request data, message-broker payloads and so on) or a dangerous sink (e.g. query construction, command/file/path ops, deserialization, template/EL, LDAP/JNDI, reflection and so on)? + +- clearly irrelevant — build/Gradle plugins, logging, annotations, bytecode tooling (ASM, byte-buddy), test libraries, pure data structures: dismiss +- clearly relevant — web frameworks, query/ORM libraries, HTTP clients, deserializers, template engines, LDAP/JNDI, scripting: flag +- unsure — do a brief peek: grep `` sources for the library's package imports or call sites. If the app never references it and nothing transitive exposes it to untrusted data, dismiss; otherwise flag + +A library the app references only for safe, constant, or framework-internal use is not a flag — flag where untrusted data plausibly enters or a dangerous call is plausibly reachable + +### 3. Record coverage + +Write `/coverage.yaml` (schema below). One `pending` entry per flagged library — these are the depth work-list. Record dismissals as a single bulk entry summarising the categories ruled out, not one row per jar; add an individual `done` row only for a library a reader might expect to be flagged but isn't, with a one-line reason + +## Output + +- `/coverage.yaml` — flagged libraries `status: pending`, dismissals summarised +- A brief summary to the caller: one line per flagged library (package, why) and the dismissed count. The file holds the detail — don't paste it back + +## Tracking + +`/coverage.yaml` — one entry per weighed library: + +```yaml +packages: + - package: org.springframework.web.reactive.function # flagged → depth work-list + status: pending # pending | done + notes: WebFlux functional routing — ServerRequest request data (source); WebClient (SSRF sink) + - package: org.springframework.data.r2dbc + status: pending + notes: reactive DB access — check for string-built query sinks + - package: + status: done # bulk dismissal + notes: > + logging (logback/slf4j), build plugins, annotations, ASM/byte-buddy, test libs, + data structures — no source/sink surface +``` + +## Gotchas + +- Don't grep dependency jars to decide — judge from the library's identity and the app's own usage in `` sources +- Flag on plausibility, not certainty — depth analysis confirms or drops it; a missed library is a missed vulnerability on all other stages, an over-flag only costs one depth pass +