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 {