Skip to content

Commit 4d07fc4

Browse files
mbarberoCopilotfproulx-boostsecurityclaude
authored
Add --fail-on-violation flag to exit non-zero when violations are detected (#392)
* Add --fail-on-violation flag to exit non-zero when violations are detected 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 <mikael.barbero@eclipse-foundation.org> * Address PR review feedback for --fail-on-violation - Add SilenceUsage to prevent Cobra printing usage text on errors - Extract checkViolations helper to deduplicate violation checks - Improve error message from empty Msg("") to Msg("command failed") - Rewrite tests to exercise checkViolations directly - Fix missing space in README "organization (analyze_org)" Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Signed-off-by: Mikaël Barbero <mikael.barbero@eclipse-foundation.org> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: François Proulx <francois@boostsecurity.io> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a54a29a commit 4d07fc4

7 files changed

Lines changed: 164 additions & 18 deletions

File tree

README.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -105,15 +105,16 @@ poutine analyze_org my-org/project --token "$GL_TOKEN" --scm gitlab --scm-base-u
105105
### Configuration Options
106106

107107
```
108-
--token SCM access token (required for the commands analyze_repo, analyze_org) (env: GH_TOKEN)
109-
--format Output format (default: pretty, json, sarif)
110-
--ignore-forks Ignore forked repositories in the organization(analyze_org)
111-
--scm SCM platform (default: github, gitlab)
112-
--scm-base-url Base URI of the self-hosted SCM instance
113-
--threads Number of threads to use (default: 2)
114-
--config Path to the configuration file (default: .poutine.yml)
115-
--skip Add rules to the skip list for the current run (can be specified multiple times)
116-
--verbose Enable debug logging
108+
--token SCM access token (required for the commands analyze_repo, analyze_org) (env: GH_TOKEN)
109+
--format Output format (default: pretty, json, sarif)
110+
--ignore-forks Ignore forked repositories in the organization (analyze_org)
111+
--scm SCM platform (default: github, gitlab)
112+
--scm-base-url Base URI of the self-hosted SCM instance
113+
--threads Number of threads to use (default: 2)
114+
--config Path to the configuration file (default: .poutine.yml)
115+
--skip Add rules to the skip list for the current run (can be specified multiple times)
116+
--verbose Enable debug logging
117+
--fail-on-violation Exit with a non-zero code (10) when violations are found
117118
```
118119

119120
See [.poutine.sample.yml](.poutine.sample.yml) for an example configuration file.

cmd/analyzeLocal.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,15 @@ Example: poutine analyze_local /path/to/repo`,
3737

3838
analyzer := analyze.NewAnalyzer(localScmClient, localGitClient, formatter, config, opaClient)
3939

40-
_, err = analyzer.AnalyzeLocalRepo(ctx, repoPath)
40+
result, err := analyzer.AnalyzeLocalRepo(ctx, repoPath)
4141
if err != nil {
4242
return fmt.Errorf("failed to analyze repoPath %s: %w", repoPath, err)
4343
}
4444

45+
if err := checkViolations(result); err != nil {
46+
return err
47+
}
48+
4549
return nil
4650
},
4751
}

cmd/analyzeOrg.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,15 @@ Note: This command will scan all repositories in the organization except those t
3232

3333
org := args[0]
3434

35-
_, err = analyzer.AnalyzeOrg(ctx, org, &threads)
35+
results, err := analyzer.AnalyzeOrg(ctx, org, &threads)
3636
if err != nil {
3737
return fmt.Errorf("failed to analyze org %s: %w", org, err)
3838
}
3939

40+
if err := checkViolations(results...); err != nil {
41+
return err
42+
}
43+
4044
return nil
4145
},
4246
}

cmd/analyzeRepo.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,15 @@ Example Scanning a remote Github Repository: poutine analyze_repo org/repo --tok
2626

2727
repo := args[0]
2828

29-
_, err = analyzer.AnalyzeRepo(ctx, repo, ref)
29+
result, err := analyzer.AnalyzeRepo(ctx, repo, ref)
3030
if err != nil {
3131
return fmt.Errorf("failed to analyze repo %s: %w", repo, err)
3232
}
3333

34+
if err := checkViolations(result); err != nil {
35+
return err
36+
}
37+
3438
return nil
3539
},
3640
}

cmd/analyzeRepoStaleBranches.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,15 @@ Example Scanning a remote Github Repository: poutine analyze_repo_stale_branches
3737
return fmt.Errorf("error compiling regex: %w", err)
3838
}
3939

40-
_, err = analyzer.AnalyzeStaleBranches(ctx, repo, &threads, &expand, reg)
40+
result, err := analyzer.AnalyzeStaleBranches(ctx, repo, &threads, &expand, reg)
4141
if err != nil {
4242
return fmt.Errorf("failed to analyze repo %s: %w", repo, err)
4343
}
4444

45+
if err := checkViolations(result); err != nil {
46+
return err
47+
}
48+
4549
return nil
4650
},
4751
}

cmd/fail_on_violation_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package cmd
2+
3+
import (
4+
"testing"
5+
6+
"github.com/boostsecurityio/poutine/models"
7+
"github.com/boostsecurityio/poutine/results"
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestErrViolationsFound(t *testing.T) {
13+
require.EqualError(t, ErrViolationsFound, "poutine: violations found")
14+
assert.Implements(t, (*error)(nil), ErrViolationsFound)
15+
}
16+
17+
func TestExitCodeViolations(t *testing.T) {
18+
assert.Equal(t, 10, exitCodeViolations)
19+
}
20+
21+
func TestFailOnViolationFlag(t *testing.T) {
22+
flag := RootCmd.PersistentFlags().Lookup("fail-on-violation")
23+
require.NotNil(t, flag, "--fail-on-violation flag should be registered")
24+
assert.Equal(t, "false", flag.DefValue, "--fail-on-violation should default to false")
25+
}
26+
27+
func TestCheckViolations(t *testing.T) {
28+
pkgWithFindings := &models.PackageInsights{
29+
FindingsResults: results.FindingsResult{
30+
Findings: []results.Finding{
31+
{RuleId: "injection", Purl: "pkg:github/example/repo"},
32+
},
33+
},
34+
}
35+
pkgNoFindings := &models.PackageInsights{
36+
FindingsResults: results.FindingsResult{
37+
Findings: []results.Finding{},
38+
},
39+
}
40+
41+
t.Run("returns nil when failOnViolation is false", func(t *testing.T) {
42+
failOnViolation = false
43+
defer func() { failOnViolation = false }()
44+
45+
assert.NoError(t, checkViolations(pkgWithFindings))
46+
})
47+
48+
t.Run("returns ErrViolationsFound when findings exist", func(t *testing.T) {
49+
failOnViolation = true
50+
defer func() { failOnViolation = false }()
51+
52+
assert.ErrorIs(t, checkViolations(pkgWithFindings), ErrViolationsFound)
53+
})
54+
55+
t.Run("returns nil when no findings", func(t *testing.T) {
56+
failOnViolation = true
57+
defer func() { failOnViolation = false }()
58+
59+
assert.NoError(t, checkViolations(pkgNoFindings))
60+
})
61+
62+
t.Run("returns nil for nil package", func(t *testing.T) {
63+
failOnViolation = true
64+
defer func() { failOnViolation = false }()
65+
66+
assert.NoError(t, checkViolations(nil))
67+
})
68+
69+
t.Run("returns nil when called with no args", func(t *testing.T) {
70+
failOnViolation = true
71+
defer func() { failOnViolation = false }()
72+
73+
assert.NoError(t, checkViolations())
74+
})
75+
}
76+
77+
func TestCheckViolationsOrg(t *testing.T) {
78+
pkgWithFindings := &models.PackageInsights{
79+
FindingsResults: results.FindingsResult{
80+
Findings: []results.Finding{
81+
{RuleId: "injection", Purl: "pkg:github/example/repo"},
82+
},
83+
},
84+
}
85+
pkgNoFindings := &models.PackageInsights{}
86+
87+
t.Run("returns ErrViolationsFound when any package has findings", func(t *testing.T) {
88+
failOnViolation = true
89+
defer func() { failOnViolation = false }()
90+
91+
err := checkViolations(pkgNoFindings, pkgWithFindings)
92+
assert.ErrorIs(t, err, ErrViolationsFound)
93+
})
94+
95+
t.Run("returns nil when no packages have findings", func(t *testing.T) {
96+
failOnViolation = true
97+
defer func() { failOnViolation = false }()
98+
99+
err := checkViolations(pkgNoFindings, pkgNoFindings)
100+
assert.NoError(t, err)
101+
})
102+
}

cmd/root.go

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cmd
33
import (
44
"context"
55
"embed"
6+
"errors"
67
"fmt"
78
"os"
89
"os/signal"
@@ -42,18 +43,39 @@ var cfgFile string
4243
var config *models.Config = models.DefaultConfig()
4344
var skipRules []string
4445
var allowedRules []string
46+
var failOnViolation bool
47+
48+
// ErrViolationsFound is returned when violations are detected and --fail-on-violation is set.
49+
var ErrViolationsFound = errors.New("poutine: violations found")
50+
51+
// checkViolations returns ErrViolationsFound if --fail-on-violation is set
52+
// and any of the provided packages contain findings.
53+
func checkViolations(pkgs ...*models.PackageInsights) error {
54+
if !failOnViolation {
55+
return nil
56+
}
57+
for _, pkg := range pkgs {
58+
if pkg != nil && len(pkg.FindingsResults.Findings) > 0 {
59+
return ErrViolationsFound
60+
}
61+
}
62+
return nil
63+
}
4564

4665
var legacyFlags = []string{"-token", "-format", "-verbose", "-scm", "-scm-base-uri", "-threads"}
4766

4867
const (
49-
exitCodeErr = 1
50-
exitCodeInterrupt = 2
68+
exitCodeErr = 1
69+
exitCodeInterrupt = 2
70+
exitCodeViolations = 10
5171
)
5272

5373
// RootCmd represents the base command when called without any subcommands
5474
var RootCmd = &cobra.Command{
55-
Use: "poutine",
56-
Short: "A Supply Chain Vulnerability Scanner for Build Pipelines",
75+
Use: "poutine",
76+
SilenceUsage: true,
77+
SilenceErrors: true,
78+
Short: "A Supply Chain Vulnerability Scanner for Build Pipelines",
5779
Long: `A Supply Chain Vulnerability Scanner for Build Pipelines
5880
By BoostSecurity.io - https://github.com/boostsecurityio/poutine `,
5981
PersistentPreRun: func(cmd *cobra.Command, args []string) {
@@ -95,7 +117,11 @@ func Execute() {
95117

96118
err := RootCmd.ExecuteContext(ctx)
97119
if err != nil {
98-
log.Error().Err(err).Msg("")
120+
if errors.Is(err, ErrViolationsFound) {
121+
log.Info().Msg("violations found")
122+
os.Exit(exitCodeViolations)
123+
}
124+
log.Error().Err(err).Msg("command failed")
99125
os.Exit(exitCodeErr)
100126
}
101127
}
@@ -120,6 +146,7 @@ func init() {
120146
RootCmd.PersistentFlags().BoolVarP(&config.Quiet, "quiet", "q", false, "Disable progress output")
121147
RootCmd.PersistentFlags().StringSliceVar(&skipRules, "skip", []string{}, "Adds rules to the configured skip list for the current run (optional)")
122148
RootCmd.PersistentFlags().StringSliceVar(&allowedRules, "allowed-rules", []string{}, "Overwrite the configured allowedRules list for the current run (optional)")
149+
RootCmd.PersistentFlags().BoolVar(&failOnViolation, "fail-on-violation", false, "Exit with a non-zero code (10) when violations are found")
123150

124151
_ = viper.BindPFlag("quiet", RootCmd.PersistentFlags().Lookup("quiet"))
125152
}

0 commit comments

Comments
 (0)