Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions cli/cmd/command_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ type AnalyzerBuilder struct {
jarPath string
maxMemory string
ruleIDs []string
ruleIDExcludes []string
passthroughApproximations []string
dataflowApproximations []string
trackExternalMethods bool
Expand Down Expand Up @@ -146,6 +147,11 @@ func (a *AnalyzerBuilder) AddRuleID(ruleID string) *AnalyzerBuilder {
return a
}

func (a *AnalyzerBuilder) AddRuleIDExclude(ruleID string) *AnalyzerBuilder {
a.ruleIDExcludes = append(a.ruleIDExcludes, ruleID)
return a
}

func (a *AnalyzerBuilder) AddPassthroughApproximations(path string) *AnalyzerBuilder {
a.passthroughApproximations = append(a.passthroughApproximations, path)
return a
Expand Down Expand Up @@ -249,6 +255,10 @@ func (a *AnalyzerBuilder) BuildNativeCommand() []string {
flags = append(flags, "--semgrep-rule-id", ruleID)
}

for _, ruleID := range a.ruleIDExcludes {
flags = append(flags, "--semgrep-rule-id-exclude", ruleID)
}

for _, passthrough := range a.passthroughApproximations {
flags = append(flags, "--passthrough-approximations", passthrough)
}
Expand Down
30 changes: 30 additions & 0 deletions cli/cmd/command_builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cmd

import (
"reflect"
"strings"
"testing"
)

Expand Down Expand Up @@ -99,3 +100,32 @@ func TestAutobuilderBuildNativeCommandRoutesDependenciesToDependencyFlag(t *test
t.Fatalf("package com.example not passed as --pkg; command was %v", cmd)
}
}

func TestAnalyzerBuilderEmitsRuleIDIncludeAndExclude(t *testing.T) {
cmd := NewAnalyzerBuilder().
SetProject("p.yaml").
AddRuleID("a.yaml:keep").
AddRuleIDExclude("a.yaml:drop").
BuildNativeCommand()

joined := strings.Join(cmd, " ")
if !strings.Contains(joined, "--semgrep-rule-id a.yaml:keep") {
t.Errorf("missing inclusion flag: %s", joined)
}
if !strings.Contains(joined, "--semgrep-rule-id-exclude a.yaml:drop") {
t.Errorf("missing exclusion flag: %s", joined)
}
}

func TestAnalyzerBuilderExclusionOnlyEmitsNoInclusionFlags(t *testing.T) {
cmd := NewAnalyzerBuilder().
SetProject("p.yaml").
AddRuleIDExclude("a.yaml:drop").
BuildNativeCommand()

for i, arg := range cmd {
if arg == "--semgrep-rule-id" {
t.Errorf("unexpected inclusion flag at %d: %v", i, cmd)
}
}
}
147 changes: 136 additions & 11 deletions cli/cmd/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/seqra/opentaint/internal/load_trace"
"github.com/seqra/opentaint/internal/rules"
"github.com/seqra/opentaint/internal/sarif"
"github.com/seqra/opentaint/internal/triage"
"github.com/seqra/opentaint/internal/validation"
"github.com/seqra/opentaint/internal/version"

Expand All @@ -34,10 +35,17 @@ type ScanConfig struct {
Recompile bool
LogFile string
RuleID []string
ExcludeRuleID []string
PassthroughApproximations []string
DataflowApproximations []string
TrackExternalMethods bool

Baseline string
WriteBaselineState bool
FingerprintKey string
ErrorOnFindings bool
ErrorOnSeverity []string

DebugFactReachabilitySarif bool
DebugRunAnalysisOnSelectedEntryPoints string
ExpandRuleRefs bool
Expand Down Expand Up @@ -113,6 +121,7 @@ func init() {

func addRuleIDFlag(cmd *cobra.Command) {
cmd.Flags().StringArrayVar(&scanFlags.RuleID, "rule-id", nil, "Filter active rules by ID (repeatable)")
cmd.Flags().StringArrayVar(&scanFlags.ExcludeRuleID, "exclude-rule-id", nil, "Never run rules matching this ID: full id, bare name, or glob over the full id (repeatable; overrides rules.exclude from the config)")
}

func addScanFlags(cmd *cobra.Command) {
Expand All @@ -136,6 +145,10 @@ func addScanFlags(cmd *cobra.Command) {
cmd.Flags().StringArrayVar(&scanFlags.DataflowApproximations, "dataflow-approximations", nil, "Dataflow approximation class directory or Java source directory (repeatable)")

cmd.Flags().BoolVar(&scanFlags.TrackExternalMethods, "track-external-methods", false, "Write external-method coverage files next to the SARIF report")

addBaselineFlags(cmd, &scanFlags.Baseline, &scanFlags.FingerprintKey)
cmd.Flags().BoolVar(&scanFlags.WriteBaselineState, "write-baseline-state", false, "Persist result.baselineState and run.baselineGuid into the output report (needs --baseline)")
addGateFlags(cmd, &scanFlags.ErrorOnFindings, &scanFlags.ErrorOnSeverity)
}

// currentScanBuilder returns a builder pre-populated with the user's current scan flags.
Expand All @@ -146,15 +159,84 @@ func currentScanBuilder(cfg ScanConfig, sourcePath string) *utils.OpentaintComma
WithRuleset(cfg.Ruleset).
WithSemgrepCompatibility(cfg.SemgrepCompatibilitySarif).
WithRuleID(cfg.RuleID).
WithExcludeRuleID(cfg.ExcludeRuleID).
WithPassthroughApproximations(cfg.PassthroughApproximations).
WithDataflowApproximations(cfg.DataflowApproximations).
WithTrackExternalMethods(cfg.TrackExternalMethods)
WithTrackExternalMethods(cfg.TrackExternalMethods).
WithBaseline(cfg.Baseline).
WithWriteBaselineState(cfg.WriteBaselineState).
WithFingerprintKey(cfg.FingerprintKey).
WithErrorOnFindings(cfg.ErrorOnFindings).
WithErrorOnSeverity(cfg.ErrorOnSeverity)
if !isDefaultSeverity(cfg.Severity) {
b.WithSeverity(cfg.Severity)
}
return b
}

// resolveRuleIDs determines which rules the analyzer should run, as exact
// inclusion and exclusion ids (patterns never reach the analyzer).
//
// --rule-id wins over the config lists, as flags do everywhere else; honoring
// a flag and rules.only together would silently intersect two selections the
// user never asked to combine. --exclude-rule-id overrides rules.exclude the
// same way, and composes with --rule-id since both were asked for explicitly.
// Returns the zero value when nothing restricts the rules, which runs the
// whole ruleset.
func resolveRuleIDs(cfg ScanConfig, absRuleSetPaths []RulesetType) rules.Resolved {
var rulesetRoots []string
for _, r := range absRuleSetPaths {
rulesetRoots = append(rulesetRoots, r.Path)
}

if len(cfg.RuleID) > 0 {
// The explicit list is small, so exclusions are subtracted right here
// and the analyzer sees only the survivors.
ids, err := rules.ApplyExclusions(cfg.RuleID, cfg.ExcludeRuleID)
if err != nil {
out.Fatalf("%s", err)
}
warnUnmatchedRulePatterns(rules.Selection{Exclude: cfg.ExcludeRuleID}, cfg.RuleID)
if cfg.ExpandRuleRefs {
ids = rules.ExpandRuleIDs(ids, rulesetRoots)
}
return rules.Resolved{Include: ids}
}

selection := configuredRuleSelection(cfg)
selected, err := rules.Select(selection, rulesetRoots)
if err != nil {
out.Fatalf("%s", err)
}
if selection.Active() {
warnUnmatchedRulePatterns(selection, rules.ListRuleIDs(rulesetRoots))
}
return selected
}

// warnUnmatchedRulePatterns surfaces selection patterns that matched no rule.
// A pattern matching nothing is usually a typo, and staying silent would make
// an exclusion look effective when it never was.
func warnUnmatchedRulePatterns(selection rules.Selection, all []string) {
for _, pattern := range selection.Unmatched(all) {
out.Warnf("Rule pattern %q matches no rule in the active ruleset", pattern)
}
}

// configuredRuleSelection merges the rules.only / rules.exclude lists from the
// configuration file with the --exclude-rule-id flag, which overrides the
// configured exclude list when set.
func configuredRuleSelection(cfg ScanConfig) rules.Selection {
selection := rules.Selection{
Only: globals.Config.Rules.Only,
Exclude: globals.Config.Rules.Exclude,
}
if len(cfg.ExcludeRuleID) > 0 {
selection.Exclude = cfg.ExcludeRuleID
}
return selection
}

func isDefaultSeverity(sev []string) bool {
return len(sev) == 2 && sev[0] == "warning" && sev[1] == "error"
}
Expand Down Expand Up @@ -227,6 +309,21 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) {
absSarifReportPath = utils.DefaultSarifReportPath(absProjectModelPath)
}

// Validate the triage flags before compiling: a typo in --baseline should
// not surface only after a fifteen-minute analysis.
gateSeverities, err := triage.ParseGateSeverities(cfg.ErrorOnSeverity)
if err != nil {
out.Fatalf("%s", err)
}
if cfg.WriteBaselineState && cfg.Baseline == "" {
out.Fatalf("--write-baseline-state needs a --baseline to compare against")
}
var baseline *sarif.Report
var absBaselinePath string
if cfg.Baseline != "" {
baseline, absBaselinePath = loadBaselineOrExit(cfg.Baseline, absSarifReportPath)
}

sarifReportName := filepath.Base(absSarifReportPath)

localVersion := utils.ArtifactDisplayVersion(globals.ArtifactByKind("analyzer"))
Expand Down Expand Up @@ -272,6 +369,11 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) {
out.Fatalf("Input validation failed: %s", err)
}

// Resolve the active rules before the dry-run bail-out, so that a bad
// rules.only/rules.exclude list is reported by --dry-run and never after a
// full compile.
resolvedRules := resolveRuleIDs(cfg, absRuleSetPaths)

if cfg.DryRun {
runDryRun("Compilation and analysis")
return
Expand Down Expand Up @@ -363,17 +465,12 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) {
if maxMemory != "" {
nativeBuilder.SetMaxMemory(maxMemory)
}
ruleIDs := cfg.RuleID
if cfg.ExpandRuleRefs && len(ruleIDs) > 0 {
var roots []string
for _, r := range absRuleSetPaths {
roots = append(roots, r.Path)
}
ruleIDs = rules.ExpandRuleIDs(ruleIDs, roots)
}
for _, ruleID := range ruleIDs {
for _, ruleID := range resolvedRules.Include {
nativeBuilder.AddRuleID(ruleID)
}
for _, ruleID := range resolvedRules.Exclude {
nativeBuilder.AddRuleIDExclude(ruleID)
}
addPassthroughApproximations(nativeBuilder, cfg.PassthroughApproximations)
if cfg.TrackExternalMethods {
nativeBuilder.SetTrackExternalMethods(true)
Expand Down Expand Up @@ -452,10 +549,12 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) {
if analyzerFail != nil {
suggestions = appendLogSuggestion(suggestions)
}
var view *sarif.TriageView
if report != nil {
view = triageScanReport(cfg, report, absSarifReportPath, baseline, absBaselinePath)
// Scan does not expose summary's filter/group flags, so pass zero values:
// no filtering, default group dimension, first-flow code-flow selection.
printSarifSummary(report, absSarifReportPath, sarif.Filters{}, sarif.ListingOptions{MaxNestingLevel: -1})
printSarifSummary(report, absSarifReportPath, sarif.Filters{}, sarif.ListingOptions{MaxNestingLevel: -1}, view, false)
suggestions = append(suggestions, output.Suggestion{
Description: "To view findings run",
Command: utils.NewSummaryCommand(absSarifReportPath).WithShowFindings().Build(),
Expand All @@ -466,6 +565,32 @@ func runScan(cmd *cobra.Command, cfg ScanConfig) {
if analyzerFail != nil {
os.Exit(analyzerFail.ExitCode)
}
if report != nil {
exitOnGate(triage.Gate{Enabled: cfg.ErrorOnFindings, Severities: gateSeverities}, report, view)
}
}

// triageScanReport applies the baseline and any inherited suppressions to the
// report the analyzer just wrote, rewriting the file when that changed it. With
// no baseline and no annotation requested, the report is left exactly as the
// analyzer produced it. The baseline was loaded (and validated) before the
// compile step, so a bad path fails fast and the file is read only once.
func triageScanReport(cfg ScanConfig, report *sarif.Report, absSarifReportPath string, baseline *sarif.Report, absBaselinePath string) *sarif.TriageView {
outcome, err := triage.Apply(report, triage.Options{
WriteBaselineState: cfg.WriteBaselineState,
FingerprintKey: cfg.FingerprintKey,
Baseline: baseline,
BaselinePath: absBaselinePath,
})
if err != nil {
out.Fatalf("%s", err)
}
if outcome.Changed {
if err := sarif.SaveReport(report, absSarifReportPath); err != nil {
out.Fatalf("Failed to write report: %s", err)
}
}
return outcome.View
}

func resolveScanPlan(cfg ScanConfig, absUserProjectRoot string) scanPlan {
Expand Down
Loading
Loading