From 3de2f423bbb47ded98fb2ff5d18b6c27b59c93cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mika=C3=ABl=20Barbero?= Date: Tue, 3 Mar 2026 12:19:01 +0100 Subject: [PATCH] Add --fail-on-violation flag to exit non-zero when violations are detected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit poutine always exited with code 0 after analysis, even when violations were found, making it unsuitable for use as a blocking CI gate. This adds an opt-in --fail-on-violation flag that exits with code 10 when violations are detected. Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Signed-off-by: Mikaƫl Barbero --- README.md | 19 ++--- cmd/analyzeLocal.go | 6 +- cmd/analyzeOrg.go | 10 ++- cmd/analyzeRepo.go | 6 +- cmd/analyzeRepoStaleBranches.go | 6 +- cmd/fail_on_violation_test.go | 123 ++++++++++++++++++++++++++++++++ cmd/root.go | 20 ++++-- 7 files changed, 173 insertions(+), 17 deletions(-) create mode 100644 cmd/fail_on_violation_test.go diff --git a/README.md b/README.md index 217660cd..24d35639 100644 --- a/README.md +++ b/README.md @@ -105,15 +105,16 @@ poutine analyze_org my-org/project --token "$GL_TOKEN" --scm gitlab --scm-base-u ### Configuration Options ``` ---token SCM access token (required for the commands analyze_repo, analyze_org) (env: GH_TOKEN) ---format Output format (default: pretty, json, sarif) ---ignore-forks Ignore forked repositories in the organization(analyze_org) ---scm SCM platform (default: github, gitlab) ---scm-base-url Base URI of the self-hosted SCM instance ---threads Number of threads to use (default: 2) ---config Path to the configuration file (default: .poutine.yml) ---skip Add rules to the skip list for the current run (can be specified multiple times) ---verbose Enable debug logging +--token SCM access token (required for the commands analyze_repo, analyze_org) (env: GH_TOKEN) +--format Output format (default: pretty, json, sarif) +--ignore-forks Ignore forked repositories in the organization(analyze_org) +--scm SCM platform (default: github, gitlab) +--scm-base-url Base URI of the self-hosted SCM instance +--threads Number of threads to use (default: 2) +--config Path to the configuration file (default: .poutine.yml) +--skip Add rules to the skip list for the current run (can be specified multiple times) +--verbose Enable debug logging +--fail-on-violation Exit with a non-zero code (10) when violations are found ``` See [.poutine.sample.yml](.poutine.sample.yml) for an example configuration file. diff --git a/cmd/analyzeLocal.go b/cmd/analyzeLocal.go index 0f71154a..f0e34287 100644 --- a/cmd/analyzeLocal.go +++ b/cmd/analyzeLocal.go @@ -37,11 +37,15 @@ Example: poutine analyze_local /path/to/repo`, analyzer := analyze.NewAnalyzer(localScmClient, localGitClient, formatter, config, opaClient) - _, err = analyzer.AnalyzeLocalRepo(ctx, repoPath) + result, err := analyzer.AnalyzeLocalRepo(ctx, repoPath) if err != nil { return fmt.Errorf("failed to analyze repoPath %s: %w", repoPath, err) } + if failOnViolation && result != nil && len(result.FindingsResults.Findings) > 0 { + return ErrViolationsFound + } + return nil }, } diff --git a/cmd/analyzeOrg.go b/cmd/analyzeOrg.go index 146f087a..92c17570 100644 --- a/cmd/analyzeOrg.go +++ b/cmd/analyzeOrg.go @@ -32,11 +32,19 @@ Note: This command will scan all repositories in the organization except those t org := args[0] - _, err = analyzer.AnalyzeOrg(ctx, org, &threads) + results, err := analyzer.AnalyzeOrg(ctx, org, &threads) if err != nil { return fmt.Errorf("failed to analyze org %s: %w", org, err) } + if failOnViolation { + for _, pkg := range results { + if pkg != nil && len(pkg.FindingsResults.Findings) > 0 { + return ErrViolationsFound + } + } + } + return nil }, } diff --git a/cmd/analyzeRepo.go b/cmd/analyzeRepo.go index 979f6512..b6b8f72d 100644 --- a/cmd/analyzeRepo.go +++ b/cmd/analyzeRepo.go @@ -26,11 +26,15 @@ Example Scanning a remote Github Repository: poutine analyze_repo org/repo --tok repo := args[0] - _, err = analyzer.AnalyzeRepo(ctx, repo, ref) + result, err := analyzer.AnalyzeRepo(ctx, repo, ref) if err != nil { return fmt.Errorf("failed to analyze repo %s: %w", repo, err) } + if failOnViolation && result != nil && len(result.FindingsResults.Findings) > 0 { + return ErrViolationsFound + } + return nil }, } diff --git a/cmd/analyzeRepoStaleBranches.go b/cmd/analyzeRepoStaleBranches.go index 27debff0..5b4254a2 100644 --- a/cmd/analyzeRepoStaleBranches.go +++ b/cmd/analyzeRepoStaleBranches.go @@ -37,11 +37,15 @@ Example Scanning a remote Github Repository: poutine analyze_repo_stale_branches return fmt.Errorf("error compiling regex: %w", err) } - _, err = analyzer.AnalyzeStaleBranches(ctx, repo, &threads, &expand, reg) + result, err := analyzer.AnalyzeStaleBranches(ctx, repo, &threads, &expand, reg) if err != nil { return fmt.Errorf("failed to analyze repo %s: %w", repo, err) } + if failOnViolation && result != nil && len(result.FindingsResults.Findings) > 0 { + return ErrViolationsFound + } + return nil }, } diff --git a/cmd/fail_on_violation_test.go b/cmd/fail_on_violation_test.go new file mode 100644 index 00000000..230d3245 --- /dev/null +++ b/cmd/fail_on_violation_test.go @@ -0,0 +1,123 @@ +package cmd + +import ( + "errors" + "testing" + + "github.com/boostsecurityio/poutine/models" + "github.com/boostsecurityio/poutine/results" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestErrViolationsFound(t *testing.T) { + assert.EqualError(t, ErrViolationsFound, "poutine: violations found") + assert.True(t, errors.Is(ErrViolationsFound, ErrViolationsFound)) +} + +func TestExitCodeViolations(t *testing.T) { + assert.Equal(t, 10, exitCodeViolations) +} + +func TestFailOnViolationFlag(t *testing.T) { + flag := RootCmd.PersistentFlags().Lookup("fail-on-violation") + require.NotNil(t, flag, "--fail-on-violation flag should be registered") + assert.Equal(t, "false", flag.DefValue, "--fail-on-violation should default to false") +} + +func TestFailOnViolationLogic(t *testing.T) { + pkgWithFindings := &models.PackageInsights{ + FindingsResults: results.FindingsResult{ + Findings: []results.Finding{ + {RuleId: "injection", Purl: "pkg:github/example/repo"}, + }, + }, + } + pkgNoFindings := &models.PackageInsights{ + FindingsResults: results.FindingsResult{ + Findings: []results.Finding{}, + }, + } + + t.Run("failOnViolation=false with findings returns no error", func(t *testing.T) { + // Simulate the logic in command handlers + fov := false + pkg := pkgWithFindings + var err error + if fov && pkg != nil && len(pkg.FindingsResults.Findings) > 0 { + err = ErrViolationsFound + } + assert.NoError(t, err) + }) + + t.Run("failOnViolation=true with findings returns ErrViolationsFound", func(t *testing.T) { + fov := true + pkg := pkgWithFindings + var err error + if fov && pkg != nil && len(pkg.FindingsResults.Findings) > 0 { + err = ErrViolationsFound + } + assert.ErrorIs(t, err, ErrViolationsFound) + }) + + t.Run("failOnViolation=true with no findings returns no error", func(t *testing.T) { + fov := true + pkg := pkgNoFindings + var err error + if fov && pkg != nil && len(pkg.FindingsResults.Findings) > 0 { + err = ErrViolationsFound + } + assert.NoError(t, err) + }) + + t.Run("failOnViolation=true with nil result returns no error", func(t *testing.T) { + fov := true + var pkg *models.PackageInsights + var err error + if fov && pkg != nil && len(pkg.FindingsResults.Findings) > 0 { + err = ErrViolationsFound + } + assert.NoError(t, err) + }) +} + +func TestFailOnViolationOrgLogic(t *testing.T) { + pkgWithFindings := &models.PackageInsights{ + FindingsResults: results.FindingsResult{ + Findings: []results.Finding{ + {RuleId: "injection", Purl: "pkg:github/example/repo"}, + }, + }, + } + pkgNoFindings := &models.PackageInsights{} + + t.Run("failOnViolation=true with findings in org returns ErrViolationsFound", func(t *testing.T) { + fov := true + pkgs := []*models.PackageInsights{pkgNoFindings, pkgWithFindings} + var err error + if fov { + for _, pkg := range pkgs { + if pkg != nil && len(pkg.FindingsResults.Findings) > 0 { + err = ErrViolationsFound + break + } + } + } + assert.ErrorIs(t, err, ErrViolationsFound) + }) + + t.Run("failOnViolation=true with no findings in org returns no error", func(t *testing.T) { + fov := true + pkgs := []*models.PackageInsights{pkgNoFindings, pkgNoFindings} + var err error + if fov { + for _, pkg := range pkgs { + if pkg != nil && len(pkg.FindingsResults.Findings) > 0 { + err = ErrViolationsFound + break + } + } + } + assert.NoError(t, err) + }) +} diff --git a/cmd/root.go b/cmd/root.go index aca440fc..8cc616b5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -3,6 +3,7 @@ package cmd import ( "context" "embed" + "errors" "fmt" "os" "os/signal" @@ -42,18 +43,24 @@ var cfgFile string var config *models.Config = models.DefaultConfig() var skipRules []string var allowedRules []string +var failOnViolation bool + +// ErrViolationsFound is returned when violations are detected and --fail-on-violation is set. +var ErrViolationsFound = errors.New("poutine: violations found") var legacyFlags = []string{"-token", "-format", "-verbose", "-scm", "-scm-base-uri", "-threads"} const ( - exitCodeErr = 1 - exitCodeInterrupt = 2 + exitCodeErr = 1 + exitCodeInterrupt = 2 + exitCodeViolations = 10 ) // RootCmd represents the base command when called without any subcommands var RootCmd = &cobra.Command{ - Use: "poutine", - Short: "A Supply Chain Vulnerability Scanner for Build Pipelines", + Use: "poutine", + SilenceErrors: true, + Short: "A Supply Chain Vulnerability Scanner for Build Pipelines", Long: `A Supply Chain Vulnerability Scanner for Build Pipelines By BoostSecurity.io - https://github.com/boostsecurityio/poutine `, PersistentPreRun: func(cmd *cobra.Command, args []string) { @@ -95,6 +102,10 @@ func Execute() { err := RootCmd.ExecuteContext(ctx) if err != nil { + if errors.Is(err, ErrViolationsFound) { + log.Info().Msg("violations found") + os.Exit(exitCodeViolations) + } log.Error().Err(err).Msg("") os.Exit(exitCodeErr) } @@ -120,6 +131,7 @@ func init() { RootCmd.PersistentFlags().BoolVarP(&config.Quiet, "quiet", "q", false, "Disable progress output") RootCmd.PersistentFlags().StringSliceVar(&skipRules, "skip", []string{}, "Adds rules to the configured skip list for the current run (optional)") RootCmd.PersistentFlags().StringSliceVar(&allowedRules, "allowed-rules", []string{}, "Overwrite the configured allowedRules list for the current run (optional)") + RootCmd.PersistentFlags().BoolVar(&failOnViolation, "fail-on-violation", false, "Exit with a non-zero code (10) when violations are found") _ = viper.BindPFlag("quiet", RootCmd.PersistentFlags().Lookup("quiet")) }