Skip to content

Commit cc87960

Browse files
Add exit codes and resolution strictness options to CLI scan command (#332)
* Add exit codes and resolution strictness options to CLI scan command Signed-off-by: bpatra3 <bpatra3@opentext.com> * Implement proper error handling and exit codes for scan resolution failures Signed-off-by: bpatra3 <bpatra3@opentext.com> --------- Signed-off-by: bpatra3 <bpatra3@opentext.com>
1 parent 9116dbb commit cc87960

10 files changed

Lines changed: 529 additions & 33 deletions

File tree

‎README.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,29 @@ Once you've installed the CLI, you're ready to scan your project. You can scan a
7474

7575
When the scan is complete, you will see the total number of vulnerabilities found and a list of automation rules that have been evaluated. Read more about automations [here](https://debricked.com/docs/automation/automation-overview.html#automation-overview).
7676

77+
### Exit codes
78+
| Code | Meaning |
79+
| ---- | ------- |
80+
| 0 | The scan completed and no triggered automation rule failed the pipeline |
81+
| 1 | The scan failed. This covers triggered automation rules configured to fail the pipeline, resolution failures (see below), and errors such as a bad access token or an unreachable service |
82+
| 3 | The scan completed, but some (not all) dependency files failed to resolve. Only produced by `--resolution-strictness=3` |
83+
84+
Failed resolution of dependency files affects the scan and its exit code according to
85+
`--resolution-strictness` (default `1`):
86+
87+
| Level | Meaning |
88+
| ----- | ------- |
89+
| 0 | Always continue the scan, even if any or all files failed to resolve |
90+
| 1 | Exit with code 1 if all files failed to resolve, otherwise continue the scan |
91+
| 2 | Exit with code 1 if any file failed to resolve, otherwise continue the scan |
92+
| 3 | Exit with code 1 if all files failed to resolve. If some but not all files failed to resolve, complete the scan and then exit with code 3 |
93+
94+
A resolution failure typically means the relevant package manager is not installed or not on the
95+
`PATH` (for example `mvn` or `composer`).
96+
97+
If Debricked's scan queue is long, the CLI stops polling for progress and exits with code 1, having
98+
printed a link to the results. Pass `--pass-on-timeout` to exit 0 in that case instead.
99+
77100
### Docker
78101
To make a scan directly through Docker based on your current working directory, you can use the following command:
79102
```sh

‎internal/cmd/scan/scan.go‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@ import (
88
"strconv"
99
"strings"
1010

11+
"github.com/debricked/cli/internal/cmd/cmderror"
1112
"github.com/debricked/cli/internal/file"
13+
"github.com/debricked/cli/internal/resolution"
1214
"github.com/debricked/cli/internal/scan"
1315
"github.com/fatih/color"
1416
"github.com/spf13/cobra"
@@ -36,6 +38,7 @@ var passOnDowntime bool
3638
var regenerate int
3739
var repositoryName string
3840
var repositoryUrl string
41+
var resolutionStrictness int
3942
var verbose bool
4043
var versionHint bool
4144
var sbom string
@@ -72,6 +75,7 @@ const (
7275
TagCommitAsReleaseEnv = "TAG_COMMIT_AS_RELEASE"
7376
ExperimentalFlag = "experimental"
7477
GenerateCommitNameFlag = "generate-commit-name"
78+
ResolutionStrictnessFlag = "resolution-strictness"
7579
)
7680

7781
var scanCmdError error
@@ -162,9 +166,21 @@ $ debricked scan . --inclusion '**/node_modules/**'`)
162166
}, "\n")
163167
cmd.Flags().BoolVar(&verbose, VerboseFlag, true, verboseDoc)
164168
cmd.Flags().BoolVar(&debug, DebugFlag, false, "write all debug output to stderr")
165-
cmd.Flags().BoolVarP(&passOnDowntime, PassOnTimeOut, "p", false, "pass scan if there is a service access timeout")
169+
cmd.Flags().BoolVarP(&passOnDowntime, PassOnTimeOut, "p", false, "pass scan if there is a service access timeout, or if the scan is still queued once progress polling gives up")
166170
cmd.Flags().BoolVar(&noResolve, NoResolveFlag, false, `disables resolution of manifest files that lack lock files. Resolving manifest files enables more accurate dependency scanning since the whole dependency tree will be analysed.
167171
For example, if there is a "go.mod" in the target path, its dependencies are going to get resolved onto a lock file, and latter scanned.`)
172+
resolutionStrictnessDoc := strings.Join(
173+
[]string{
174+
"Allows you to configure how failed resolution of manifest files affects the scan and its exit code.\n",
175+
"Strictness Level | Meaning",
176+
"---------------- | -------",
177+
"0 | Always continue the scan, even if any or all files failed to resolve",
178+
"1 (default) | Exit with code 1 if all files failed to resolve, otherwise continue the scan",
179+
"2 | Exit with code 1 if any file failed to resolve, otherwise continue the scan",
180+
"3 | Exit with code 1 if all files failed to resolve. If some but not all files failed to resolve, complete the scan and then exit with code 3",
181+
"\nExample:\n$ debricked scan . --resolution-strictness=3",
182+
}, "\n")
183+
cmd.Flags().IntVar(&resolutionStrictness, ResolutionStrictnessFlag, int(resolution.FailIfAllFail), resolutionStrictnessDoc)
168184
cmd.Flags().BoolVar(&noFingerprint, NoFingerprintFlag, false, "Toggle fingerprinting for undeclared component identification. Can be run as a standalone command [fingerprint] with more granular options.")
169185
cmd.Flags().BoolVar(&callgraph, CallGraphFlag, false, `Enables call graph generation during scan.`)
170186
cmd.Flags().StringVar(&javaCallgraphEngine, JavaCallgraphEngineFlag, "soot", "Java call graph engine to use during scan callgraph generation: soot or sootup.")
@@ -232,6 +248,11 @@ func RunE(s *scan.IScanner) func(_ *cobra.Command, args []string) error {
232248
tagCommitAsRelease = viper.GetBool(TagCommitAsReleaseFlag)
233249
}
234250

251+
strictness, err := resolution.GetStrictnessLevel(viper.GetInt(ResolutionStrictnessFlag))
252+
if err != nil {
253+
return err
254+
}
255+
235256
options := scan.DebrickedOptions{
236257
Path: path,
237258
Resolve: !viper.GetBool(NoResolveFlag),
@@ -261,14 +282,16 @@ func RunE(s *scan.IScanner) func(_ *cobra.Command, args []string) error {
261282
MinFingerprintContentLength: viper.GetInt(MinFingerprintContentLengthFlag),
262283
TagCommitAsRelease: tagCommitAsRelease,
263284
Experimental: viper.GetBool(ExperimentalFlag),
285+
ResolutionStrictness: strictness,
264286
}
265287
if s != nil {
266288
scanCmdError = (*s).Scan(options)
267289
} else {
268290
scanCmdError = errors.New("scanner was nil")
269291
}
270292

271-
if scanCmdError == scan.FailPipelineErr {
293+
var cmdErr cmderror.CommandError
294+
if scanCmdError == scan.FailPipelineErr || scanCmdError == scan.LongQueueErr || errors.As(scanCmdError, &cmdErr) {
272295
cmd.SilenceUsage = true
273296
cmd.SilenceErrors = true
274297

‎internal/cmd/scan/scan_test.go‎

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package scan
22

33
import (
4+
"errors"
45
"testing"
56

7+
"github.com/debricked/cli/internal/cmd/cmderror"
8+
"github.com/debricked/cli/internal/resolution"
69
"github.com/debricked/cli/internal/scan"
710
"github.com/spf13/cobra"
811
"github.com/spf13/viper"
@@ -26,6 +29,7 @@ func TestNewScanCmd(t *testing.T) {
2629
JavaCallgraphEngineFlag: "",
2730
CallGraphUploadTimeoutFlag: "",
2831
CallGraphGenerateTimeoutFlag: "",
32+
ResolutionStrictnessFlag: "",
2933
}
3034
flags := cmd.Flags()
3135
for name, shorthand := range flagAssertions {
@@ -83,11 +87,124 @@ func TestRunEFailPipelineErr(t *testing.T) {
8387

8488
err := runE(cmd, nil)
8589

86-
assert.Error(t, err, scan.FailPipelineErr)
90+
assert.ErrorIs(t, err, scan.FailPipelineErr)
8791
assert.True(t, cmd.SilenceUsage, "failed to assert that usage was silenced")
8892
assert.True(t, cmd.SilenceErrors, "failed to assert that errors were silenced")
8993
}
9094

95+
func TestRunELongQueueErr(t *testing.T) {
96+
var s scan.IScanner
97+
mock := &scannerMock{}
98+
mock.setErr(scan.LongQueueErr)
99+
s = mock
100+
runE := RunE(&s)
101+
cmd := &cobra.Command{}
102+
103+
err := runE(cmd, nil)
104+
105+
assert.ErrorIs(t, err, scan.LongQueueErr)
106+
assert.True(t, cmd.SilenceUsage, "failed to assert that usage was silenced")
107+
assert.True(t, cmd.SilenceErrors, "failed to assert that errors were silenced")
108+
}
109+
110+
func TestRunECommandError(t *testing.T) {
111+
var s scan.IScanner
112+
mock := &scannerMock{}
113+
cmdErr := cmderror.CommandError{Code: 3, Err: errors.New("partial resolution failure")}
114+
mock.setErr(cmdErr)
115+
s = mock
116+
runE := RunE(&s)
117+
cmd := &cobra.Command{}
118+
119+
err := runE(cmd, nil)
120+
121+
var gotCmdErr cmderror.CommandError
122+
assert.True(t, errors.As(err, &gotCmdErr), "expected CommandError to be preserved")
123+
assert.Equal(t, 3, gotCmdErr.Code, "expected exit code 3 to be preserved")
124+
assert.True(t, cmd.SilenceUsage, "failed to assert that usage was silenced")
125+
assert.True(t, cmd.SilenceErrors, "failed to assert that errors were silenced")
126+
}
127+
128+
func TestRunEResolutionStrictness(t *testing.T) {
129+
cases := []struct {
130+
name string
131+
flag interface{}
132+
expected resolution.StrictnessLevel
133+
}{
134+
{name: "default", flag: nil, expected: resolution.FailIfAllFail},
135+
{name: "no fail", flag: 0, expected: resolution.NoFail},
136+
{name: "fail if all fail", flag: 1, expected: resolution.FailIfAllFail},
137+
{name: "fail if any fail", flag: 2, expected: resolution.FailIfAnyFail},
138+
{name: "fail or warn", flag: 3, expected: resolution.FailOrWarn},
139+
}
140+
141+
for _, c := range cases {
142+
t.Run(c.name, func(t *testing.T) {
143+
viper.Reset()
144+
if c.flag != nil {
145+
viper.Set(ResolutionStrictnessFlag, c.flag)
146+
} else {
147+
// Mirror the flag default that PreRun would have bound.
148+
viper.SetDefault(ResolutionStrictnessFlag, int(resolution.FailIfAllFail))
149+
}
150+
defer viper.Reset()
151+
152+
var s scan.IScanner
153+
mock := &scannerMock{}
154+
s = mock
155+
runE := RunE(&s)
156+
157+
err := runE(&cobra.Command{}, nil)
158+
159+
assert.NoError(t, err)
160+
options, ok := mock.options.(scan.DebrickedOptions)
161+
assert.True(t, ok, "failed to assert that scan options were passed")
162+
assert.Equal(t, c.expected, options.ResolutionStrictness)
163+
})
164+
}
165+
}
166+
167+
// "debricked files find" binds DEBRICKED_STRICT to the global viper key
168+
// "strict". The scan flag must not share that key, or setting the env var for
169+
// one command would silently change resolution behaviour in the other.
170+
func TestRunEStrictEnvDoesNotAffectResolutionStrictness(t *testing.T) {
171+
viper.Reset()
172+
viper.SetEnvPrefix("DEBRICKED")
173+
viper.AutomaticEnv()
174+
viper.MustBindEnv("strict")
175+
viper.SetDefault(ResolutionStrictnessFlag, int(resolution.FailIfAllFail))
176+
t.Setenv("DEBRICKED_STRICT", "3")
177+
defer viper.Reset()
178+
179+
var s scan.IScanner
180+
mock := &scannerMock{}
181+
s = mock
182+
runE := RunE(&s)
183+
184+
err := runE(&cobra.Command{}, nil)
185+
186+
assert.NoError(t, err)
187+
options, ok := mock.options.(scan.DebrickedOptions)
188+
assert.True(t, ok, "failed to assert that scan options were passed")
189+
assert.Equal(t, resolution.FailIfAllFail, options.ResolutionStrictness)
190+
}
191+
192+
func TestRunEInvalidResolutionStrictness(t *testing.T) {
193+
viper.Reset()
194+
viper.Set(ResolutionStrictnessFlag, 4)
195+
defer viper.Reset()
196+
197+
var s scan.IScanner
198+
mock := &scannerMock{}
199+
s = mock
200+
runE := RunE(&s)
201+
202+
err := runE(&cobra.Command{}, nil)
203+
204+
assert.ErrorContains(t, err, "invalid strictness level: 4")
205+
assert.Nil(t, mock.options, "failed to assert that the scan was not started")
206+
}
207+
91208
func TestRunEError(t *testing.T) {
92209
runE := RunE(nil)
93210
err := runE(nil, []string{"."})
@@ -102,9 +219,13 @@ func TestPreRun(t *testing.T) {
102219

103220
type scannerMock struct {
104221
err error
222+
// options records the options of the most recent Scan call.
223+
options scan.IOptions
105224
}
106225

107-
func (s *scannerMock) Scan(_ scan.IOptions) error {
226+
func (s *scannerMock) Scan(o scan.IOptions) error {
227+
s.options = o
228+
108229
return s.err
109230
}
110231

‎internal/resolution/resolver.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ const (
3030
FailOrWarn
3131
)
3232

33+
// WarnExitCode is produced by FailOrWarn when some, but not all, files failed to
34+
// resolve. It is the only resolution exit code that lets a scan run to
35+
// completion - every other non-zero code aborts it.
36+
const WarnExitCode = 3
37+
3338
func GetStrictnessLevel(level int) (StrictnessLevel, error) {
3439
switch level {
3540
case 0:
@@ -141,7 +146,7 @@ func (r Resolver) failOrWarnLogic(errorCount, jobCount int) (int, error) {
141146
return 1, nil
142147
}
143148

144-
return 3, nil
149+
return WarnExitCode, nil
145150
}
146151

147152
func (r Resolver) Resolve(paths []string, options IOptions) (IResolution, error) {

‎internal/resolution/testdata/resolver_mock.go‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@ import (
99
)
1010

1111
type ResolverMock struct {
12-
Err error
13-
files []string
12+
Err error
13+
// Options records the options of the most recent Resolve call.
14+
Options resolution.IOptions
15+
files []string
1416
}
1517

1618
func (r *ResolverMock) SetNpmPreferred(_ bool) {
1719
}
1820

19-
func (r *ResolverMock) Resolve(_ []string, _ resolution.IOptions) (resolution.IResolution, error) {
21+
func (r *ResolverMock) Resolve(_ []string, options resolution.IOptions) (resolution.IResolution, error) {
22+
r.Options = options
2023
for _, f := range r.files {
2124
createdFile, err := os.Create(f)
2225
if err != nil {

0 commit comments

Comments
 (0)