diff --git a/.gitignore b/.gitignore
index 1d13741..c6186ad 100644
--- a/.gitignore
+++ b/.gitignore
@@ -29,4 +29,4 @@ go.work.sum
tmp_scans/
# Sheriff configuration file, untracked for use in local development
-sheriff.toml
\ No newline at end of file
+/sheriff.toml
\ No newline at end of file
diff --git a/README.md b/README.md
index f01eca4..b6b646c 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,21 @@ Sheriff is a tool to scan repositories and generate security reports.
- [CLI flags](#cli-flags)
- [Environment variables](#environment-variables)
- [Configuration file](#configuration-file)
+ - [Configuration options](#configuration-options)
+ - [Miscellaneous](#miscellaneous)
+ - [config](#config)
+ - [verbose](#verbose)
+ - [Scanning](#scanning)
+ - [targets](#targets)
+ - [Reporting](#reporting)
+ - [report to issue](#report-to-issue)
+ - [report to email (TODO #12)](#report-to-email-todo-12)
+ - [report to slack channels](#report-to-slack-channels)
+ - [enable project report to](#enable-project-report-to)
+ - [silent](#silent)
+ - [Tokens](#tokens)
+ - [gitlab token](#gitlab-token)
+ - [slack token](#slack-token)
- [Supported platforms](#supported-platforms)
- [Source code hosting services](#source-code-hosting-services)
- [Messaging services](#messaging-services)
@@ -28,7 +43,7 @@ Sheriff is a tool to scan repositories and generate security reports.
## Quick Usage
```sh
-sheriff patrol --url gitlab://your-namespace-or-group --report-to-issue
+sheriff patrol --target gitlab://your-namespace-or-group --report-to-issue
```
## How it works
@@ -104,9 +119,10 @@ Only the **Reporting** and **Scanning** sections of configuration parameters are
In this case you may choose to create a config file such as the following:
```toml
-url = ["namespace/group", "namespace/group/cool-repo"]
-report-to-slack-channel = "sheriff-report-test"
-report-to-gitlab-issue = true
+targets = ["namespace/group", "namespace/group/cool-repo"]
+[report.to]
+slack-channel = "sheriff-report-test"
+issue = true
```
And if you wish to specify a different file, you can do so with `sheriff patrol --config your-config-file.toml`.
@@ -114,6 +130,98 @@ And if you wish to specify a different file, you can do so with `sheriff patrol
> [!NOTE]
> When using several types of configurations at once there is an order of preference: **cli flags** > **env vars** > **config file**
+### Configuration options
+
+#### Miscellaneous
+
+##### config
+
+| CLI options | File config |
+|---|---|
+| `--config` | - |
+
+Sets the path of your sheriff configuration file
+
+##### verbose
+
+| CLI options | File config |
+|---|---|
+| `--verbose`/`-v` | - |
+
+Sets the log level to verbose
+
+#### Scanning
+
+##### targets
+
+| CLI options | File config |
+|---|---|
+| (repeatable) `--target` | `targets` |
+
+Sets the list of groups and projects to be scanned.
+The expected format of a target is `platform://path/to/your/group-or-project`
+
+For example:
+`--target gitlab://namespace/group --target github://organization/project`
+
+#### Reporting
+
+##### report to issue
+
+| CLI options | File config |
+|---|---|
+| `--report-to-issue` | [report.to]
issue |
+
+Enables reporting to an issue on the project's platform
+
+##### report to email (TODO #12)
+
+| CLI options | File config |
+|---|---|
+| (repeatable) `--report-to-email` | [report.to]
emails |
+
+Sets the list of email to which a full scan report should be sent
+
+##### report to slack channels
+
+| CLI options | File config |
+|---|---|
+| (repeatable) `--report-to-slack-channels` | [report.to]
slack-channels |
+
+##### enable project report to
+
+| CLI options | File config |
+|---|---|
+| `--report-to-enable-project-report-to` | [report.to]
enable-project-report-to |
+
+Enable project-level configuration `report-to` to allow projects to control where their individual reports are sent
+
+##### silent
+
+| CLI options | File config |
+|---|---|
+| `--report-silent` | [report]
silent |
+
+Disable printing the report in the bash output
+
+#### Tokens
+
+##### gitlab token
+
+| ENV VAR |
+|---|
+| `$GITLAB_TOKEN` |
+
+Sets the token to be used when fetching projects from gitlab
+
+##### slack token
+
+| ENV VAR |
+|---|
+| `$SLACK_TOKEN` |
+
+Sets the token to be used when reporting the security report on slack
+
## Supported platforms
### Source code hosting services
diff --git a/internal/cli/app.go b/internal/cli/app.go
index bec5293..f0720e7 100644
--- a/internal/cli/app.go
+++ b/internal/cli/app.go
@@ -22,7 +22,7 @@ This file is formatted in TOML and can contain any of the flags that can be set
`,
Flags: PatrolFlags,
Action: PatrolAction,
- Before: CombineBeforeFuncs(ConfigureLogs, GetConfigFileLoader(PatrolFlags, configFlag), LogArguments),
+ Before: ConfigureLogs,
},
},
}
diff --git a/internal/cli/patrol.go b/internal/cli/patrol.go
index 355f8bf..5f29b6e 100644
--- a/internal/cli/patrol.go
+++ b/internal/cli/patrol.go
@@ -13,7 +13,6 @@ import (
"strings"
"github.com/urfave/cli/v2"
- "github.com/urfave/cli/v2/altsrc"
)
type CommandCategory string
@@ -27,7 +26,7 @@ const (
const configFlag = "config"
const verboseFlag = "verbose"
-const urlFlag = "url"
+const targetFlag = "target"
const reportToEmailFlag = "report-to-email"
const reportToIssueFlag = "report-to-issue"
const reportToSlackChannel = "report-to-slack-channel"
@@ -36,7 +35,6 @@ const silentReportFlag = "silent"
const gitlabTokenFlag = "gitlab-token"
const slackTokenFlag = "slack-token"
-var sensitiveFlags = []string{gitlabTokenFlag, slackTokenFlag}
var necessaryScanners = []string{scanner.OsvCommandName}
var PatrolFlags = []cli.Flag{
@@ -52,38 +50,38 @@ var PatrolFlags = []cli.Flag{
Category: string(Miscellaneous),
Value: false,
},
- altsrc.NewStringSliceFlag(&cli.StringSliceFlag{
- Name: urlFlag,
+ &cli.StringSliceFlag{
+ Name: targetFlag,
Usage: "Groups and projects to scan for vulnerabilities (list argument which can be repeated)",
Category: string(Scanning),
- }),
- altsrc.NewStringSliceFlag(&cli.StringSliceFlag{
+ },
+ &cli.StringSliceFlag{
Name: reportToEmailFlag,
Usage: "Enable reporting to the provided list of emails",
Category: string(Reporting),
- }),
- altsrc.NewBoolFlag(&cli.BoolFlag{
+ },
+ &cli.BoolFlag{
Name: reportToIssueFlag,
Usage: "Enable or disable reporting to the project's issue on the associated platform (gitlab, github, ...)",
Category: string(Reporting),
- }),
- altsrc.NewStringFlag(&cli.StringFlag{
+ },
+ &cli.StringSliceFlag{
Name: reportToSlackChannel,
- Usage: "Enable reporting to the provided slack channel",
+ Usage: "Enable reporting to the provided slack channels",
Category: string(Reporting),
- }),
- altsrc.NewBoolFlag(&cli.BoolFlag{
+ },
+ &cli.BoolFlag{
Name: reportEnableProjectReportToFlag,
Usage: "Enable project-level configuration for '--report-to-*'.",
Category: string(Reporting),
Value: true,
- }),
- altsrc.NewBoolFlag(&cli.BoolFlag{
+ },
+ &cli.BoolFlag{
Name: silentReportFlag,
Usage: "Disable report output to stdout.",
Category: string(Reporting),
Value: false,
- }),
+ },
// Secret tokens
&cli.StringFlag{
Name: gitlabTokenFlag,
@@ -102,13 +100,20 @@ var PatrolFlags = []cli.Flag{
func PatrolAction(cCtx *cli.Context) error {
config, err := config.GetPatrolConfiguration(config.PatrolCLIOpts{
- Urls: cCtx.StringSlice(urlFlag),
- ReportToIssue: cCtx.Bool(reportToIssueFlag),
- ReportToEmails: cCtx.StringSlice(reportToEmailFlag),
- ReportToSlackChannel: cCtx.String(reportToSlackChannel),
- EnableProjectReportTo: cCtx.Bool(reportEnableProjectReportToFlag),
- SilentReport: cCtx.Bool(silentReportFlag),
- Verbose: cCtx.Bool(verboseFlag),
+ PatrolCommonOpts: config.PatrolCommonOpts{
+ Targets: getStringSliceIfSet(cCtx, targetFlag),
+ Report: config.PatrolReportOpts{
+ To: config.PatrolReportToOpts{
+ Issue: getBoolIfSet(cCtx, reportToIssueFlag),
+ Emails: getStringSliceIfSet(cCtx, reportToEmailFlag),
+ SlackChannels: getStringSliceIfSet(cCtx, reportToSlackChannel),
+ EnableProjectReportTo: getBoolIfSet(cCtx, reportEnableProjectReportToFlag),
+ },
+ SilentReport: getBoolIfSet(cCtx, silentReportFlag),
+ },
+ },
+ Config: cCtx.String(configFlag),
+ Verbose: cCtx.Bool(verboseFlag),
})
if err != nil {
return errors.Join(errors.New("failed to get patrol configuration"), err)
diff --git a/internal/cli/utils.go b/internal/cli/utils.go
index 9e3614a..f5f0e5d 100644
--- a/internal/cli/utils.go
+++ b/internal/cli/utils.go
@@ -1,74 +1,42 @@
package cli
import (
- "errors"
- "fmt"
- "os"
"sheriff/internal/log"
- "slices"
zerolog "github.com/rs/zerolog/log"
"github.com/urfave/cli/v2"
- "github.com/urfave/cli/v2/altsrc"
)
-func CombineBeforeFuncs(beforeFuncs ...cli.BeforeFunc) cli.BeforeFunc {
- return func(cCtx *cli.Context) error {
- for _, beforeFunc := range beforeFuncs {
- if err := beforeFunc(cCtx); err != nil {
- return err
- }
- }
- return nil
- }
-}
-
func ConfigureLogs(cCtx *cli.Context) error {
log.ConfigureLogs(cCtx.Bool(verboseFlag))
zerolog.Info().Msg("Logging configured")
return nil
}
-func GetConfigFileLoader(flags []cli.Flag, fileNameFlag string) cli.BeforeFunc {
- return func(cCtx *cli.Context) error {
- fileName := cCtx.String(fileNameFlag)
- if _, err := os.Stat(fileName); err == nil {
- // Config file exists
- zerolog.Info().Str("file", fileName).Msg("Loading configuration file")
- return altsrc.InitInputSourceWithContext(flags, func(cCtx *cli.Context) (altsrc.InputSourceContext, error) {
- return altsrc.NewTomlSourceFromFile(fileName)
- })(cCtx)
- } else if errors.Is(err, os.ErrNotExist) {
- if cCtx.IsSet(fileNameFlag) {
- // Config file was explicitly set but does not exist
- return fmt.Errorf("config file %v does not exist", fileName)
- }
- zerolog.Info().Str("file", fileName).Msg("No configuration file found")
- return nil // No config file, do nothing
- } else {
- // Error stating config file
- return errors.Join(fmt.Errorf("failed to stat config file %s", fileName), err)
- }
+func getStringSliceIfSet(cCtx *cli.Context, flagName string) *[]string {
+ if cCtx.IsSet(flagName) {
+ v := cCtx.StringSlice(flagName)
+ return &v
}
-}
-func LogArguments(cCtx *cli.Context) error {
- flags := cCtx.FlagNames()
+ return nil
+}
- flagList := make([]string, 0, len(flags))
+func getStringIfSet(cCtx *cli.Context, flagName string) *string {
+ if cCtx.IsSet(flagName) {
+ v := cCtx.String(flagName)
+ print(v)
+ return &v
+ }
- for _, flag := range flags {
- val := cCtx.Value(flag)
- if slices.Contains(sensitiveFlags, flag) {
- val = "REDACTED"
- } else if sliceVal, ok := val.(cli.StringSlice); ok {
- val = sliceVal.String()
- }
+ return nil
+}
- flagList = append(flagList, fmt.Sprintf("%s=%v", flag, val))
+func getBoolIfSet(cCtx *cli.Context, flagName string) *bool {
+ if cCtx.IsSet(flagName) {
+ v := cCtx.Bool(flagName)
+ return &v
}
- zerolog.Info().Strs("arguments", flagList).Msg("Running with configuration")
-
return nil
}
diff --git a/internal/cli/utils_test.go b/internal/cli/utils_test.go
index 8311c4e..5d84d88 100644
--- a/internal/cli/utils_test.go
+++ b/internal/cli/utils_test.go
@@ -2,40 +2,15 @@ package cli
import (
"flag"
+ "strconv"
+ "strings"
"testing"
"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/mock"
"github.com/urfave/cli/v2"
)
-func TestCombineBeforeFuncs(t *testing.T) {
- mockBeforeFuncs := new(mockBeforeFuncs)
- mockBeforeFuncs.On("Func1", mock.Anything).Return(nil)
- mockBeforeFuncs.On("Func2", mock.Anything).Return(nil)
-
- before_func := CombineBeforeFuncs(mockBeforeFuncs.Func1, mockBeforeFuncs.Func2)
-
- _ = before_func(nil)
-
- mockBeforeFuncs.AssertExpectations(t)
-}
-
-type mockBeforeFuncs struct {
- mock.Mock
-}
-
-func (m *mockBeforeFuncs) Func1(cCtx *cli.Context) error {
- args := m.Called(cCtx)
- return args.Error(0)
-}
-
-func (m *mockBeforeFuncs) Func2(cCtx *cli.Context) error {
- args := m.Called(cCtx)
- return args.Error(0)
-}
-
func TestConfigureLogs(t *testing.T) {
testCases := map[bool]zerolog.Level{
true: zerolog.DebugLevel,
@@ -53,26 +28,127 @@ func TestConfigureLogs(t *testing.T) {
}
}
-// Tests for ConfigFileLoader when no file is found.
-// There should be an equivalent test for when the file is found, but it's tough.
-func TestConfigFileLoaderNoFile(t *testing.T) {
- flag := flag.NewFlagSet("config", flag.ContinueOnError)
- flag.String("config", "nonexistent", "")
- context := cli.NewContext(nil, flag, nil)
+func TestGetStringIfSettest(t *testing.T) {
+ want := "hello"
+ flagName := "testFlag"
- beforeFunc := GetConfigFileLoader(nil, "config")
- err := beforeFunc(context)
+ flag := flag.NewFlagSet("", flag.ContinueOnError)
+ flag.String(flagName, "", "")
+ _ = flag.Set(flagName, want)
+ cCtx := cli.NewContext(nil, flag, nil)
- assert.Nil(t, err)
+ got := getStringIfSet(cCtx, flagName)
+
+ assert.Equal(t, want, *got)
}
-func TestLogArguments(t *testing.T) {
- flag := flag.NewFlagSet("flag", flag.ContinueOnError)
- flag.String("some-flag", "", "")
- _ = flag.Set("some-flag", "value")
- context := cli.NewContext(nil, flag, nil)
+func TestGetStringIfSet(t *testing.T) {
+ testCases := []struct {
+ name string
+ want string
+ set bool
+ }{{
+ name: "value",
+ want: "hello",
+ set: true,
+ }, {
+ name: "nil",
+ want: "",
+ set: false,
+ }}
+
+ flagName := "testFlag"
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ flag := flag.NewFlagSet("", flag.ContinueOnError)
+ flag.String(flagName, "", "")
+ if tc.set {
+ _ = flag.Set(flagName, tc.want)
+ }
+ cCtx := cli.NewContext(nil, flag, nil)
+
+ got := getStringIfSet(cCtx, flagName)
+
+ if tc.set {
+ assert.NotNil(t, got)
+ assert.Equal(t, tc.want, *got)
+ } else {
+ assert.Nil(t, got)
+ }
+ })
+ }
+}
- _ = LogArguments(context)
+func TestGetStringSliceIfSet(t *testing.T) {
+ testCases := []struct {
+ name string
+ want []string
+ set bool
+ }{{
+ name: "value",
+ want: []string{"hello", "world"},
+ set: true,
+ }, {
+ name: "nil",
+ want: []string{},
+ set: false,
+ }}
+
+ flagName := "testFlag"
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ flag := flag.NewFlagSet("", flag.ContinueOnError)
+ flag.Var(&cli.StringSlice{}, flagName, "")
+ if tc.set {
+ _ = flag.Set(flagName, strings.Join(tc.want, ", "))
+ }
+ cCtx := cli.NewContext(nil, flag, nil)
+
+ got := getStringSliceIfSet(cCtx, flagName)
+
+ if tc.set {
+ assert.NotNil(t, got)
+ assert.Equal(t, tc.want, *got)
+ } else {
+ assert.Nil(t, got)
+ }
+ })
+ }
+}
- // How to assert that the log message was correct?
+func TestGetBoolIfSet(t *testing.T) {
+ testCases := []struct {
+ name string
+ want bool
+ set bool
+ }{{
+ name: "value",
+ want: true,
+ set: true,
+ }, {
+ name: "nil",
+ want: false,
+ set: false,
+ }}
+
+ flagName := "testFlag"
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ flag := flag.NewFlagSet("", flag.ContinueOnError)
+ flag.Bool(flagName, false, "")
+ if tc.set {
+ _ = flag.Set(flagName, strconv.FormatBool(tc.want))
+ }
+ cCtx := cli.NewContext(nil, flag, nil)
+
+ got := getBoolIfSet(cCtx, flagName)
+
+ if tc.set {
+ assert.NotNil(t, got)
+ assert.Equal(t, tc.want, *got)
+ } else {
+ assert.Nil(t, got)
+ }
+ })
+ }
}
diff --git a/internal/config/patrol.go b/internal/config/patrol.go
index 9ee1b29..4a2341d 100644
--- a/internal/config/patrol.go
+++ b/internal/config/patrol.go
@@ -4,6 +4,8 @@ import (
"errors"
"fmt"
"net/url"
+
+ zerolog "github.com/rs/zerolog/log"
)
type PlatformType string
@@ -21,53 +23,110 @@ type ProjectLocation struct {
type PatrolConfig struct {
Locations []ProjectLocation
ReportToEmails []string
- ReportToSlackChannel string
+ ReportToSlackChannels []string
ReportToIssue bool
EnableProjectReportTo bool
SilentReport bool
Verbose bool
}
+// Options common in both the CLI options & file options
+type PatrolReportToOpts struct {
+ Emails *[]string `toml:"emails"`
+ SlackChannels *[]string `toml:"slack-channels"`
+ Issue *bool `toml:"issue"`
+ EnableProjectReportTo *bool `toml:"enable-project-report-to"`
+}
+
+type PatrolReportOpts struct {
+ SilentReport *bool `toml:"silent"`
+ To PatrolReportToOpts `toml:"to"`
+}
+
+type PatrolCommonOpts struct {
+ Targets *[]string `toml:"targets"`
+ Report PatrolReportOpts `toml:"report"`
+}
+
+// PatrolCLIOpts are the options only available from CLI configuration
type PatrolCLIOpts struct {
- Urls []string
- ReportToEmails []string
- ReportToSlackChannel string
- ReportToIssue bool
- EnableProjectReportTo bool
- SilentReport bool
- Verbose bool
+ Config string
+ Verbose bool
+ PatrolCommonOpts
}
-func GetPatrolConfiguration(cliOpts PatrolCLIOpts) (patrolConfig PatrolConfig, err error) {
- // Parse options
- locations, err := parseUrls(cliOpts.Urls)
+// PatrolFileOpts are the options only available from File configuration
+type PatrolFileOpts struct {
+ PatrolCommonOpts
+}
+
+func GetPatrolConfiguration(cliOpts PatrolCLIOpts) (config PatrolConfig, err error) {
+ zerolog.Debug().Interface("cli options", cliOpts).Msg("Running with cli options")
+ var tomlOpts PatrolFileOpts
+ if cliOpts.Config != "" {
+ found, err := getTOMLFile(cliOpts.Config, &tomlOpts)
+ if !found {
+ return config, fmt.Errorf("failed to find configuration file %v", cliOpts.Config)
+ } else if err != nil {
+ return config, errors.Join(errors.New("failed to parse patrol configuration file"), err)
+ }
+
+ zerolog.Debug().Interface("file config", tomlOpts).Msg("Running with file configuration")
+ }
+
+ config, err = mergeConfigs(cliOpts, tomlOpts)
if err != nil {
- return patrolConfig, errors.Join(errors.New("failed to parse `--url` options"), err)
+ return config, errors.Join(errors.New("failed to merge CLI and file config"), err)
}
- patrolConfig = PatrolConfig{
- Locations: locations,
- ReportToIssue: cliOpts.ReportToIssue,
- ReportToEmails: cliOpts.ReportToEmails,
- ReportToSlackChannel: cliOpts.ReportToSlackChannel,
- EnableProjectReportTo: cliOpts.EnableProjectReportTo,
- SilentReport: cliOpts.SilentReport,
+ zerolog.Info().Interface("config", config).Msg("Running with configuration")
+
+ return
+}
+
+func mergeConfigs(cliOpts PatrolCLIOpts, fileOpts PatrolFileOpts) (config PatrolConfig, err error) {
+ locations := getCliOrFileOption(cliOpts.Targets, fileOpts.Targets, []string{})
+ parsedLocations, err := parseTargets(locations)
+ if err != nil {
+ return config, errors.Join(errors.New("could not parse targets from CLI options"), err)
+ }
+
+ config = PatrolConfig{
+ Locations: parsedLocations,
+ ReportToIssue: getCliOrFileOption(cliOpts.Report.To.Issue, fileOpts.Report.To.Issue, false),
+ ReportToEmails: getCliOrFileOption(cliOpts.Report.To.Emails, fileOpts.Report.To.Emails, []string{}),
+ ReportToSlackChannels: getCliOrFileOption(cliOpts.Report.To.SlackChannels, fileOpts.Report.To.SlackChannels, []string{}),
+ EnableProjectReportTo: getCliOrFileOption(cliOpts.Report.To.EnableProjectReportTo, fileOpts.Report.To.EnableProjectReportTo, false),
+ SilentReport: getCliOrFileOption(cliOpts.Report.SilentReport, fileOpts.Report.SilentReport, false),
Verbose: cliOpts.Verbose,
}
return
}
-func parseUrls(uris []string) ([]ProjectLocation, error) {
- locations := make([]ProjectLocation, len(uris))
- for i, uri := range uris {
- parsed, err := url.Parse(uri)
+// getCliOrFileOption returns valueA if != nil, otherwise valueB if != nil, otherwise the provided default value
+func getCliOrFileOption[T interface{}](valueA *T, valueB *T, def T) (r T) {
+ if valueA != nil {
+ return *valueA
+ }
+
+ if valueB != nil {
+ return *valueB
+ }
+
+ return def
+}
+
+func parseTargets(targets []string) ([]ProjectLocation, error) {
+ locations := make([]ProjectLocation, len(targets))
+ for i, t := range targets {
+ parsed, err := url.Parse(t)
if err != nil || parsed == nil {
return nil, errors.Join(fmt.Errorf("failed to parse uri"), err)
}
if !parsed.IsAbs() {
- return nil, fmt.Errorf("url missing platform scheme %v", uri)
+ return nil, fmt.Errorf("target missing platform scheme %v", t)
}
if parsed.Scheme == string(Github) {
@@ -78,7 +137,7 @@ func parseUrls(uris []string) ([]ProjectLocation, error) {
path, err := url.JoinPath(parsed.Host, parsed.Path)
if err != nil {
- return nil, fmt.Errorf("failed to join host and path %v", uri)
+ return nil, fmt.Errorf("failed to join host and path %v", t)
}
locations[i] = ProjectLocation{
diff --git a/internal/config/patrol_test.go b/internal/config/patrol_test.go
index d3d7341..5ddaa24 100644
--- a/internal/config/patrol_test.go
+++ b/internal/config/patrol_test.go
@@ -1,12 +1,81 @@
package config
import (
- "fmt"
"testing"
"github.com/stretchr/testify/assert"
)
+func TestGetPatrolConfiguration(t *testing.T) {
+ want := PatrolConfig{
+ Locations: []ProjectLocation{{Type: Gitlab, Path: "group1"}, {Type: Gitlab, Path: "group2/project1"}},
+ ReportToEmails: []string{"some-email@gmail.com"},
+ ReportToSlackChannels: []string{"report-slack-channel"},
+ ReportToIssue: true,
+ EnableProjectReportTo: true,
+ SilentReport: true,
+ Verbose: true,
+ }
+
+ got, err := GetPatrolConfiguration(PatrolCLIOpts{
+ Config: "testdata/patrol/valid.toml",
+ Verbose: true,
+ })
+
+ assert.Nil(t, err)
+ assert.Equal(t, want, got)
+}
+
+func TestGetPatrolConfigurationCLIOverridesFile(t *testing.T) {
+ want := PatrolConfig{
+ Locations: []ProjectLocation{{Type: Gitlab, Path: "group1"}, {Type: Gitlab, Path: "group2/project1"}},
+ ReportToEmails: []string{"email@gmail.com", "other@gmail.com"},
+ ReportToSlackChannels: []string{"other-slack-channel"},
+ ReportToIssue: false,
+ EnableProjectReportTo: false, // Here we test overriding with a zero-value, which works!
+ SilentReport: false,
+ Verbose: true,
+ }
+
+ got, err := GetPatrolConfiguration(PatrolCLIOpts{
+ Config: "testdata/patrol/valid.toml",
+ Verbose: true,
+ PatrolCommonOpts: PatrolCommonOpts{
+ Targets: &[]string{"gitlab://group1", "gitlab://group2/project1"},
+ Report: PatrolReportOpts{
+ To: PatrolReportToOpts{
+ Emails: &want.ReportToEmails,
+ SlackChannels: &want.ReportToSlackChannels,
+ Issue: &want.ReportToIssue,
+ EnableProjectReportTo: &want.EnableProjectReportTo,
+ },
+ SilentReport: &want.SilentReport,
+ },
+ },
+ })
+
+ assert.Nil(t, err)
+ assert.Equal(t, want, got)
+}
+
+func TestGetPatrolConfigurationInvalidFile(t *testing.T) {
+ _, err := GetPatrolConfiguration(PatrolCLIOpts{
+ Config: "testdata/patrol/invalid.toml",
+ Verbose: true,
+ })
+
+ assert.NotNil(t, err)
+}
+
+func TestGetPatrolConfigurationInexistentFile(t *testing.T) {
+ _, err := GetPatrolConfiguration(PatrolCLIOpts{
+ Config: "testdata/patrol/inexistent.toml",
+ Verbose: true,
+ })
+
+ assert.NotNil(t, err)
+}
+
func TestParseUrls(t *testing.T) {
testCases := []struct {
paths []string
@@ -20,18 +89,16 @@ func TestParseUrls(t *testing.T) {
{[]string{"github://organization/project"}, &ProjectLocation{Type: "github", Path: "organization/project"}, true},
{[]string{"unknown://namespace/project"}, nil, true},
{[]string{"unknown://not a path"}, nil, true},
- {[]string{"not a url"}, nil, true},
+ {[]string{"not a target"}, nil, true},
}
for _, tc := range testCases {
- urls, err := parseUrls(tc.paths)
-
- fmt.Print(urls)
+ targets, err := parseTargets(tc.paths)
if tc.wantError {
assert.NotNil(t, err)
} else {
- assert.Equal(t, tc.wantProjectLocation, &(urls[0]))
+ assert.Equal(t, tc.wantProjectLocation, &(targets[0]))
}
}
}
diff --git a/internal/config/project.go b/internal/config/project.go
index 7a72463..4cd2f9e 100644
--- a/internal/config/project.go
+++ b/internal/config/project.go
@@ -1,14 +1,13 @@
package config
import (
- "errors"
- "os"
+ "path"
- "github.com/BurntSushi/toml"
- "github.com/elliotchance/pie/v2"
"github.com/rs/zerolog/log"
)
+const projectConfigFileName = "sheriff.toml"
+
type AcknowledgedVuln struct {
Code string `toml:"code"`
Reason string `toml:"reason"`
@@ -20,27 +19,15 @@ type ProjectConfig struct {
Acknowledged []AcknowledgedVuln `toml:"acknowledged"`
}
-func GetConfiguration(filename string) (config ProjectConfig, found bool, err error) {
- if _, err := os.Stat(filename); os.IsNotExist(err) {
- return ProjectConfig{}, false, nil
- } else if err != nil {
- return config, false, errors.Join(errors.New("unexpected error when attempting to get project configuration"), err)
- }
-
- m, err := toml.DecodeFile(filename, &config)
+func GetProjectConfiguration(projectName string, dir string) (config ProjectConfig) {
+ found, err := getTOMLFile(path.Join(dir, projectConfigFileName), &config)
if err != nil {
- return config, true, errors.Join(errors.New("failed to decode project configuration"), err)
- }
-
- if undecoded := m.Undecoded(); len(undecoded) > 0 {
- keys := pie.Map(undecoded, func(u toml.Key) string { return u.String() })
-
- log.Warn().Strs("keys", keys).Msg("Found undecoded keys in project configuration")
- }
-
- if config.SlackChannel != "" {
- config.ReportToSlackChannel = config.SlackChannel
+ log.Error().Err(err).Str("project", projectName).Msg("Failed to read project configuration. Running with empty configuration.")
+ } else if found {
+ log.Info().Str("project", projectName).Msg("Found project configuration")
+ } else {
+ log.Info().Str("project", projectName).Msg("No project configuration found. Using default")
}
- return config, true, nil
+ return
}
diff --git a/internal/config/project_test.go b/internal/config/project_test.go
index 50016e6..6ab5d32 100644
--- a/internal/config/project_test.go
+++ b/internal/config/project_test.go
@@ -1,6 +1,7 @@
package config
import (
+ "fmt"
"testing"
"github.com/stretchr/testify/assert"
@@ -8,24 +9,20 @@ import (
func TestGetConfiguration(t *testing.T) {
testCases := []struct {
- filename string
- wantFound bool
- wantErr bool
+ foldername string
wantConfig ProjectConfig
}{
- {"testdata/valid.toml", true, false, ProjectConfig{ReportToSlackChannel: "the-devils-slack-channel"}},
- {"testdata/invalid.toml", true, true, ProjectConfig{}},
- {"testdata/nonexistent.toml", false, false, ProjectConfig{}},
- {"testdata/valid_with_ack.toml", true, false, ProjectConfig{Acknowledged: []AcknowledgedVuln{{Code: "CSV111", Reason: "not relevant"}, {Code: "CSV222", Reason: ""}}}},
- {"testdata/valid_with_ack_alt.toml", true, false, ProjectConfig{Acknowledged: []AcknowledgedVuln{{Code: "CSV111", Reason: "not relevant"}, {Code: "CSV222", Reason: ""}}}},
+ {"valid", ProjectConfig{ReportToSlackChannel: "the-devils-slack-channel"}},
+ {"invalid", ProjectConfig{}},
+ {"nonexistent", ProjectConfig{}},
+ {"valid_with_ack", ProjectConfig{Acknowledged: []AcknowledgedVuln{{Code: "CSV111", Reason: "not relevant"}, {Code: "CSV222", Reason: ""}}}},
+ {"valid_with_ack_alt", ProjectConfig{Acknowledged: []AcknowledgedVuln{{Code: "CSV111", Reason: "not relevant"}, {Code: "CSV222", Reason: ""}}}},
}
for _, tc := range testCases {
- t.Run(tc.filename, func(t *testing.T) {
- got, found, err := GetConfiguration(tc.filename)
+ t.Run(tc.foldername, func(t *testing.T) {
+ got := GetProjectConfiguration("", fmt.Sprintf("testdata/project/%v", tc.foldername))
- assert.Equal(t, tc.wantFound, found)
- assert.Equal(t, err != nil, tc.wantErr)
assert.Equal(t, tc.wantConfig, got)
})
}
diff --git a/internal/config/testdata/invalid.toml b/internal/config/testdata/patrol/invalid.toml
similarity index 100%
rename from internal/config/testdata/invalid.toml
rename to internal/config/testdata/patrol/invalid.toml
diff --git a/internal/config/testdata/patrol/valid.toml b/internal/config/testdata/patrol/valid.toml
new file mode 100644
index 0000000..44b1a99
--- /dev/null
+++ b/internal/config/testdata/patrol/valid.toml
@@ -0,0 +1,10 @@
+targets = ["gitlab://group1", "gitlab://group2/project1"]
+
+[report]
+silent = true
+
+[report.to]
+emails = ["some-email@gmail.com"]
+slack-channels = ["report-slack-channel"]
+issue = true
+enable-project-report-to = true
diff --git a/internal/config/testdata/project/invalid/sheriff.toml b/internal/config/testdata/project/invalid/sheriff.toml
new file mode 100644
index 0000000..f687a52
--- /dev/null
+++ b/internal/config/testdata/project/invalid/sheriff.toml
@@ -0,0 +1 @@
+invalid \ toml
\ No newline at end of file
diff --git a/internal/config/testdata/valid.toml b/internal/config/testdata/project/valid/sheriff.toml
similarity index 100%
rename from internal/config/testdata/valid.toml
rename to internal/config/testdata/project/valid/sheriff.toml
diff --git a/internal/config/testdata/valid_with_ack.toml b/internal/config/testdata/project/valid_with_ack/sheriff.toml
similarity index 100%
rename from internal/config/testdata/valid_with_ack.toml
rename to internal/config/testdata/project/valid_with_ack/sheriff.toml
diff --git a/internal/config/testdata/valid_with_ack_alt.toml b/internal/config/testdata/project/valid_with_ack_alt/sheriff.toml
similarity index 100%
rename from internal/config/testdata/valid_with_ack_alt.toml
rename to internal/config/testdata/project/valid_with_ack_alt/sheriff.toml
diff --git a/internal/config/toml.go b/internal/config/toml.go
new file mode 100644
index 0000000..397adc8
--- /dev/null
+++ b/internal/config/toml.go
@@ -0,0 +1,32 @@
+package config
+
+import (
+ "errors"
+ "os"
+
+ "github.com/BurntSushi/toml"
+ "github.com/elliotchance/pie/v2"
+ "github.com/rs/zerolog/log"
+)
+
+// getTOMLFile parses and sets passed config pointer by value
+func getTOMLFile[T interface{}](filename string, config *T) (found bool, err error) {
+ if _, err := os.Stat(filename); os.IsNotExist(err) {
+ return false, nil
+ } else if err != nil {
+ return false, errors.Join(errors.New("unexpected error when attempting to read file"), err)
+ }
+
+ m, err := toml.DecodeFile(filename, config)
+ if err != nil {
+ return true, errors.Join(errors.New("failed to decode TOML file"), err)
+ }
+
+ if undecoded := m.Undecoded(); len(undecoded) > 0 {
+ keys := pie.Map(undecoded, func(u toml.Key) string { return u.String() })
+
+ log.Warn().Strs("keys", keys).Msg("Found undecoded keys in TOML file")
+ }
+
+ return true, nil
+}
diff --git a/internal/patrol/patrol.go b/internal/patrol/patrol.go
index b1dc26c..e86a3de 100644
--- a/internal/patrol/patrol.go
+++ b/internal/patrol/patrol.go
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"os"
- "path"
"sheriff/internal/config"
"sheriff/internal/git"
"sheriff/internal/gitlab"
@@ -21,7 +20,6 @@ import (
)
const tempScanDir = "tmp_scans"
-const projectConfigFileName = "sheriff.toml"
// securityPatroller is the interface of the main security scanner service of this tool.
type securityPatroller interface {
@@ -75,11 +73,12 @@ func (s *sheriffService) Patrol(args config.PatrolConfig) (warn error, err error
}
if s.slackService != nil {
- if args.ReportToSlackChannel != "" {
- log.Info().Str("slackChannel", args.ReportToSlackChannel).Msg("Posting report to slack channel")
+ if len(args.ReportToSlackChannels) > 0 {
+ // TODO #36 support sending to multiple slack channels (for now only the first is sent)
+ log.Info().Str("slackChannel", args.ReportToSlackChannels[0]).Msg("Posting report to slack channel")
paths := pie.Map(args.Locations, func(v config.ProjectLocation) string { return v.Path })
- if err := publish.PublishAsGeneralSlackMessage(args.ReportToSlackChannel, scanReports, paths, s.slackService); err != nil {
+ if err := publish.PublishAsGeneralSlackMessage(args.ReportToSlackChannels[0], scanReports, paths, s.slackService); err != nil {
log.Error().Err(err).Msg("Failed to post slack report")
err = errors.Join(errors.New("failed to post slack report"), err)
warn = errors.Join(err, warn)
@@ -169,14 +168,7 @@ func (s *sheriffService) scanProject(project gogitlab.Project) (report *scanner.
return nil, errors.Join(errors.New("failed to clone project"), err)
}
- config, found, err := config.GetConfiguration(path.Join(dir, projectConfigFileName))
- if err != nil {
- log.Error().Err(err).Str("project", project.PathWithNamespace).Msg("Failed to read project configuration. Running with empty configuration.")
- } else if found {
- log.Info().Str("project", project.PathWithNamespace).Msg("Found project configuration")
- } else {
- log.Info().Str("project", project.PathWithNamespace).Msg("No project configuration found. Using default")
- }
+ config := config.GetProjectConfiguration(project.NameWithNamespace, dir)
// Scan the project
log.Info().Str("project", project.Name).Msg("Running osv-scanner")
diff --git a/internal/patrol/patrol_test.go b/internal/patrol/patrol_test.go
index 5b09939..f4c878f 100644
--- a/internal/patrol/patrol_test.go
+++ b/internal/patrol/patrol_test.go
@@ -34,7 +34,7 @@ func TestScanNoProjects(t *testing.T) {
warn, err := svc.Patrol(config.PatrolConfig{
Locations: []config.ProjectLocation{{Type: config.Gitlab, Path: "group/to/scan"}},
ReportToEmails: []string{},
- ReportToSlackChannel: "channel",
+ ReportToSlackChannels: []string{"channel"},
ReportToIssue: true,
EnableProjectReportTo: true,
Verbose: true,
@@ -67,7 +67,7 @@ func TestScanNonVulnerableProject(t *testing.T) {
warn, err := svc.Patrol(config.PatrolConfig{
Locations: []config.ProjectLocation{{Type: config.Gitlab, Path: "group/to/scan"}},
ReportToEmails: []string{},
- ReportToSlackChannel: "channel",
+ ReportToSlackChannels: []string{"channel"},
ReportToIssue: true,
EnableProjectReportTo: true,
Verbose: true,
@@ -108,7 +108,7 @@ func TestScanVulnerableProject(t *testing.T) {
warn, err := svc.Patrol(config.PatrolConfig{
Locations: []config.ProjectLocation{{Type: config.Gitlab, Path: "group/to/scan"}},
ReportToEmails: []string{},
- ReportToSlackChannel: "channel",
+ ReportToSlackChannels: []string{"channel"},
ReportToIssue: true,
EnableProjectReportTo: true,
Verbose: true,
diff --git a/internal/publish/to_slack.go b/internal/publish/to_slack.go
index e93c144..b920c59 100644
--- a/internal/publish/to_slack.go
+++ b/internal/publish/to_slack.go
@@ -140,7 +140,7 @@ func formatSummary(reportsBySeverityKind map[scanner.SeverityScoreKind][]scanner
true, false,
),
)
- subtitleGroups := formatSubtitleList("urls", paths)
+ subtitleGroups := formatSubtitleList("targets", paths)
subtitleCount := goslack.NewContextBlock("subtitleCount", goslack.NewTextBlockObject("mrkdwn", fmt.Sprintf("Total projects scanned: %v", totalReports), false, false))
counts := pie.Map(severityScoreOrder, func(kind scanner.SeverityScoreKind) *goslack.TextBlockObject {