diff --git a/internal/cli/patrol.go b/internal/cli/patrol.go index 9118b31..355f8bf 100644 --- a/internal/cli/patrol.go +++ b/internal/cli/patrol.go @@ -3,8 +3,8 @@ package cli import ( "errors" "fmt" - "net/url" "os/exec" + "sheriff/internal/config" "sheriff/internal/git" "sheriff/internal/gitlab" "sheriff/internal/patrol" @@ -101,26 +101,35 @@ var PatrolFlags = []cli.Flag{ } func PatrolAction(cCtx *cli.Context) error { - verbose := cCtx.Bool(verboseFlag) - - // Parse options - locations, err := parseUrls(cCtx.StringSlice(urlFlag)) + 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), + }) if err != nil { - return errors.Join(errors.New("failed to parse `--url` options"), err) + return errors.Join(errors.New("failed to get patrol configuration"), err) } + // Get tokens + gitlabToken := cCtx.String(gitlabTokenFlag) + slackToken := cCtx.String(slackTokenFlag) + // Create services - gitlabService, err := gitlab.New(cCtx.String(gitlabTokenFlag)) + gitlabService, err := gitlab.New(gitlabToken) if err != nil { return errors.Join(errors.New("failed to create GitLab service"), err) } - slackService, err := slack.New(cCtx.String(slackTokenFlag), verbose) + slackService, err := slack.New(slackToken, config.Verbose) if err != nil { return errors.Join(errors.New("failed to create Slack service"), err) } - gitService := git.New(cCtx.String(gitlabTokenFlag)) + gitService := git.New(gitlabToken) osvService := scanner.NewOsvScanner() patrolService := patrol.New(gitlabService, slackService, gitService, osvService) @@ -128,21 +137,11 @@ func PatrolAction(cCtx *cli.Context) error { // Check whether the necessary scanners are available missingScanners := getMissingScanners(necessaryScanners) if len(missingScanners) > 0 { - return fmt.Errorf("Cannot find all necessary scanners in $PATH, missing: %v", strings.Join(missingScanners, ", ")) + return fmt.Errorf("cannot find all necessary scanners in $PATH, missing: %v", strings.Join(missingScanners, ", ")) } // Do the patrol - if warn, err := patrolService.Patrol( - patrol.PatrolArgs{ - Locations: locations, - ReportToIssue: cCtx.Bool(reportToIssueFlag), - ReportToEmails: cCtx.StringSlice(reportToEmailFlag), - ReportToSlackChannel: cCtx.String(reportToSlackChannel), - EnableProjectReportTo: cCtx.Bool(reportEnableProjectReportToFlag), - SilentReport: cCtx.Bool(silentReportFlag), - Verbose: verbose, - }, - ); err != nil { + if warn, err := patrolService.Patrol(config); err != nil { return errors.Join(errors.New("failed to scan"), err) } else if warn != nil { return cli.Exit("Patrol was partially successful, some errors occurred. Check the logs for more information.", 1) @@ -151,38 +150,6 @@ func PatrolAction(cCtx *cli.Context) error { return nil } -func parseUrls(uris []string) ([]patrol.ProjectLocation, error) { - locations := make([]patrol.ProjectLocation, len(uris)) - for i, uri := range uris { - parsed, err := url.Parse(uri) - 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) - } - - if parsed.Scheme == string(patrol.Github) { - return nil, fmt.Errorf("github is currently unsupported, but is on our roadmap 😃") // TODO #9 - } else if parsed.Scheme != string(patrol.Gitlab) { - return nil, fmt.Errorf("unsupported platform %v", parsed.Scheme) - } - - path, err := url.JoinPath(parsed.Host, parsed.Path) - if err != nil { - return nil, fmt.Errorf("failed to join host and path %v", uri) - } - - locations[i] = patrol.ProjectLocation{ - Type: patrol.PlatformType(parsed.Scheme), - Path: path, - } - } - - return locations, nil -} - func getMissingScanners(necessary []string) []string { missingScanners := make([]string, 0, len(necessary)) for _, scanner := range necessary { diff --git a/internal/cli/patrol_test.go b/internal/cli/patrol_test.go index 8d54c9f..999226b 100644 --- a/internal/cli/patrol_test.go +++ b/internal/cli/patrol_test.go @@ -2,8 +2,6 @@ package cli import ( "flag" - "fmt" - "sheriff/internal/patrol" "testing" "github.com/stretchr/testify/assert" @@ -26,35 +24,6 @@ func TestPatrolActionEmptyRun(t *testing.T) { assert.Nil(t, err) } -func TestParseUrls(t *testing.T) { - testCases := []struct { - paths []string - wantProjectLocation *patrol.ProjectLocation - wantError bool - }{ - {[]string{"gitlab://namespace/project"}, &patrol.ProjectLocation{Type: "gitlab", Path: "namespace/project"}, false}, - {[]string{"gitlab://namespace/subgroup/project"}, &patrol.ProjectLocation{Type: "gitlab", Path: "namespace/subgroup/project"}, false}, - {[]string{"gitlab://namespace"}, &patrol.ProjectLocation{Type: "gitlab", Path: "namespace"}, false}, - {[]string{"github://organization"}, &patrol.ProjectLocation{Type: "github", Path: "organization"}, true}, - {[]string{"github://organization/project"}, &patrol.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}, - } - - for _, tc := range testCases { - urls, err := parseUrls(tc.paths) - - fmt.Print(urls) - - if tc.wantError { - assert.NotNil(t, err) - } else { - assert.Equal(t, tc.wantProjectLocation, &(urls[0])) - } - } -} - func TestGetMissingScanners(t *testing.T) { testCases := []struct { scanners []string diff --git a/internal/config/patrol.go b/internal/config/patrol.go new file mode 100644 index 0000000..9ee1b29 --- /dev/null +++ b/internal/config/patrol.go @@ -0,0 +1,91 @@ +package config + +import ( + "errors" + "fmt" + "net/url" +) + +type PlatformType string + +const ( + Gitlab PlatformType = "gitlab" + Github PlatformType = "github" +) + +type ProjectLocation struct { + Type PlatformType + Path string +} + +type PatrolConfig struct { + Locations []ProjectLocation + ReportToEmails []string + ReportToSlackChannel string + ReportToIssue bool + EnableProjectReportTo bool + SilentReport bool + Verbose bool +} + +type PatrolCLIOpts struct { + Urls []string + ReportToEmails []string + ReportToSlackChannel string + ReportToIssue bool + EnableProjectReportTo bool + SilentReport bool + Verbose bool +} + +func GetPatrolConfiguration(cliOpts PatrolCLIOpts) (patrolConfig PatrolConfig, err error) { + // Parse options + locations, err := parseUrls(cliOpts.Urls) + if err != nil { + return patrolConfig, errors.Join(errors.New("failed to parse `--url` options"), err) + } + + patrolConfig = PatrolConfig{ + Locations: locations, + ReportToIssue: cliOpts.ReportToIssue, + ReportToEmails: cliOpts.ReportToEmails, + ReportToSlackChannel: cliOpts.ReportToSlackChannel, + EnableProjectReportTo: cliOpts.EnableProjectReportTo, + SilentReport: cliOpts.SilentReport, + 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) + 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) + } + + if parsed.Scheme == string(Github) { + return nil, fmt.Errorf("github is currently unsupported, but is on our roadmap 😃") // TODO #9 + } else if parsed.Scheme != string(Gitlab) { + return nil, fmt.Errorf("unsupported platform %v", parsed.Scheme) + } + + path, err := url.JoinPath(parsed.Host, parsed.Path) + if err != nil { + return nil, fmt.Errorf("failed to join host and path %v", uri) + } + + locations[i] = ProjectLocation{ + Type: PlatformType(parsed.Scheme), + Path: path, + } + } + + return locations, nil +} diff --git a/internal/config/patrol_test.go b/internal/config/patrol_test.go new file mode 100644 index 0000000..d3d7341 --- /dev/null +++ b/internal/config/patrol_test.go @@ -0,0 +1,37 @@ +package config + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseUrls(t *testing.T) { + testCases := []struct { + paths []string + wantProjectLocation *ProjectLocation + wantError bool + }{ + {[]string{"gitlab://namespace/project"}, &ProjectLocation{Type: "gitlab", Path: "namespace/project"}, false}, + {[]string{"gitlab://namespace/subgroup/project"}, &ProjectLocation{Type: "gitlab", Path: "namespace/subgroup/project"}, false}, + {[]string{"gitlab://namespace"}, &ProjectLocation{Type: "gitlab", Path: "namespace"}, false}, + {[]string{"github://organization"}, &ProjectLocation{Type: "github", Path: "organization"}, true}, + {[]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}, + } + + for _, tc := range testCases { + urls, err := parseUrls(tc.paths) + + fmt.Print(urls) + + if tc.wantError { + assert.NotNil(t, err) + } else { + assert.Equal(t, tc.wantProjectLocation, &(urls[0])) + } + } +} diff --git a/internal/patrol/config.go b/internal/config/project.go similarity index 60% rename from internal/patrol/config.go rename to internal/config/project.go index 7caf4d6..7a72463 100644 --- a/internal/patrol/config.go +++ b/internal/config/project.go @@ -1,18 +1,28 @@ -package patrol +package config import ( "errors" "os" - "sheriff/internal/scanner" "github.com/BurntSushi/toml" "github.com/elliotchance/pie/v2" "github.com/rs/zerolog/log" ) -func getConfiguration(filename string) (config scanner.ProjectConfig, found bool, err error) { +type AcknowledgedVuln struct { + Code string `toml:"code"` + Reason string `toml:"reason"` +} + +type ProjectConfig struct { + ReportToSlackChannel string `toml:"report-to-slack-channel"` + SlackChannel string `toml:"slack-channel"` // TODO #27: Break in v1.0. Kept for backwards-compatibility + Acknowledged []AcknowledgedVuln `toml:"acknowledged"` +} + +func GetConfiguration(filename string) (config ProjectConfig, found bool, err error) { if _, err := os.Stat(filename); os.IsNotExist(err) { - return scanner.ProjectConfig{}, false, nil + return ProjectConfig{}, false, nil } else if err != nil { return config, false, errors.Join(errors.New("unexpected error when attempting to get project configuration"), err) } diff --git a/internal/config/project_test.go b/internal/config/project_test.go new file mode 100644 index 0000000..50016e6 --- /dev/null +++ b/internal/config/project_test.go @@ -0,0 +1,32 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetConfiguration(t *testing.T) { + testCases := []struct { + filename string + wantFound bool + wantErr bool + 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: ""}}}}, + } + + for _, tc := range testCases { + t.Run(tc.filename, func(t *testing.T) { + got, found, err := GetConfiguration(tc.filename) + + assert.Equal(t, tc.wantFound, found) + assert.Equal(t, err != nil, tc.wantErr) + assert.Equal(t, tc.wantConfig, got) + }) + } +} diff --git a/internal/patrol/testdata/invalid.toml b/internal/config/testdata/invalid.toml similarity index 100% rename from internal/patrol/testdata/invalid.toml rename to internal/config/testdata/invalid.toml diff --git a/internal/patrol/testdata/valid.toml b/internal/config/testdata/valid.toml similarity index 100% rename from internal/patrol/testdata/valid.toml rename to internal/config/testdata/valid.toml diff --git a/internal/patrol/testdata/valid_with_ack.toml b/internal/config/testdata/valid_with_ack.toml similarity index 100% rename from internal/patrol/testdata/valid_with_ack.toml rename to internal/config/testdata/valid_with_ack.toml diff --git a/internal/patrol/testdata/valid_with_ack_alt.toml b/internal/config/testdata/valid_with_ack_alt.toml similarity index 100% rename from internal/patrol/testdata/valid_with_ack_alt.toml rename to internal/config/testdata/valid_with_ack_alt.toml diff --git a/internal/patrol/config_test.go b/internal/patrol/config_test.go deleted file mode 100644 index 81f71b0..0000000 --- a/internal/patrol/config_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package patrol - -import ( - "sheriff/internal/scanner" - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestGetConfiguration(t *testing.T) { - testCases := []struct { - filename string - wantFound bool - wantErr bool - wantConfig scanner.ProjectConfig - }{ - {"testdata/valid.toml", true, false, scanner.ProjectConfig{ReportToSlackChannel: "the-devils-slack-channel"}}, - {"testdata/invalid.toml", true, true, scanner.ProjectConfig{}}, - {"testdata/nonexistent.toml", false, false, scanner.ProjectConfig{}}, - {"testdata/valid_with_ack.toml", true, false, scanner.ProjectConfig{Acknowledged: []scanner.AcknowledgedVuln{{Code: "CSV111", Reason: "not relevant"}, {Code: "CSV222", Reason: ""}}}}, - {"testdata/valid_with_ack_alt.toml", true, false, scanner.ProjectConfig{Acknowledged: []scanner.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) - - assert.Equal(t, tc.wantFound, found) - assert.Equal(t, err != nil, tc.wantErr) - assert.Equal(t, tc.wantConfig, got) - }) - } -} diff --git a/internal/patrol/patrol.go b/internal/patrol/patrol.go index 291759a..b1dc26c 100644 --- a/internal/patrol/patrol.go +++ b/internal/patrol/patrol.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path" + "sheriff/internal/config" "sheriff/internal/git" "sheriff/internal/gitlab" "sheriff/internal/publish" @@ -22,33 +23,10 @@ import ( const tempScanDir = "tmp_scans" const projectConfigFileName = "sheriff.toml" -type PlatformType string - -const ( - Gitlab PlatformType = "gitlab" - Github PlatformType = "github" -) - -type ProjectLocation struct { - Type PlatformType - Path string -} - -// PatrolArgs is a struct to store the arguments for the main patrol function. -type PatrolArgs struct { - Locations []ProjectLocation - ReportToEmails []string - ReportToSlackChannel string - ReportToIssue bool - EnableProjectReportTo bool - SilentReport bool - Verbose bool -} - // securityPatroller is the interface of the main security scanner service of this tool. type securityPatroller interface { // Scans the given Gitlab groups and projects, creates and publishes the necessary reports - Patrol(args PatrolArgs) (warn error, err error) + Patrol(args config.PatrolConfig) (warn error, err error) } // sheriffService is the implementation of the SecurityPatroller interface. @@ -72,7 +50,7 @@ func New(gitlabService gitlab.IService, slackService slack.IService, gitService } // Patrol scans the given Gitlab groups and projects, creates and publishes the necessary reports. -func (s *sheriffService) Patrol(args PatrolArgs) (warn error, err error) { +func (s *sheriffService) Patrol(args config.PatrolConfig) (warn error, err error) { scanReports, swarn, err := s.scanAndGetReports(args.Locations) if err != nil { return nil, errors.Join(errors.New("failed to scan projects"), err) @@ -100,7 +78,7 @@ func (s *sheriffService) Patrol(args PatrolArgs) (warn error, err error) { if args.ReportToSlackChannel != "" { log.Info().Str("slackChannel", args.ReportToSlackChannel).Msg("Posting report to slack channel") - paths := pie.Map(args.Locations, func(v ProjectLocation) string { return v.Path }) + 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 { log.Error().Err(err).Msg("Failed to post slack report") err = errors.Join(errors.New("failed to post slack report"), err) @@ -123,7 +101,7 @@ func (s *sheriffService) Patrol(args PatrolArgs) (warn error, err error) { return warn, nil } -func (s *sheriffService) scanAndGetReports(locations []ProjectLocation) (reports []scanner.Report, warn error, err error) { +func (s *sheriffService) scanAndGetReports(locations []config.ProjectLocation) (reports []scanner.Report, warn error, err error) { // Create a temporary directory to store the scans err = os.MkdirAll(tempScanDir, os.ModePerm) if err != nil { @@ -133,8 +111,8 @@ func (s *sheriffService) scanAndGetReports(locations []ProjectLocation) (reports log.Info().Str("path", tempScanDir).Msg("Created temporary directory") gitlabLocs := pie.Map( - pie.Filter(locations, func(v ProjectLocation) bool { return v.Type == Gitlab }), - func(v ProjectLocation) string { return v.Path }, + pie.Filter(locations, func(v config.ProjectLocation) bool { return v.Type == config.Gitlab }), + func(v config.ProjectLocation) string { return v.Path }, ) log.Info().Strs("locations", gitlabLocs).Msg("Getting the list of projects to scan") @@ -191,7 +169,7 @@ func (s *sheriffService) scanProject(project gogitlab.Project) (report *scanner. return nil, errors.Join(errors.New("failed to clone project"), err) } - config, found, err := getConfiguration(path.Join(dir, projectConfigFileName)) + 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 { @@ -220,7 +198,7 @@ func (s *sheriffService) scanProject(project gogitlab.Project) (report *scanner. // markVulnsAsAcknowledgedInReport marks vulnerabilities as acknowledged in the report // if the user has acknowledged them in the project configuration. // It modifies the given report in place. -func markVulnsAsAcknowledgedInReport(report *scanner.Report, config scanner.ProjectConfig) { +func markVulnsAsAcknowledgedInReport(report *scanner.Report, config config.ProjectConfig) { ackCodes := make(map[string]bool, len(config.Acknowledged)) AckReasons := make(map[string]string, len(config.Acknowledged)) for _, ack := range config.Acknowledged { diff --git a/internal/patrol/patrol_test.go b/internal/patrol/patrol_test.go index b644b39..5b09939 100644 --- a/internal/patrol/patrol_test.go +++ b/internal/patrol/patrol_test.go @@ -1,6 +1,7 @@ package patrol import ( + "sheriff/internal/config" "sheriff/internal/scanner" "testing" @@ -30,8 +31,8 @@ func TestScanNoProjects(t *testing.T) { svc := New(mockGitlabService, mockSlackService, mockGitService, mockOSVService) - warn, err := svc.Patrol(PatrolArgs{ - Locations: []ProjectLocation{{Type: Gitlab, Path: "group/to/scan"}}, + warn, err := svc.Patrol(config.PatrolConfig{ + Locations: []config.ProjectLocation{{Type: config.Gitlab, Path: "group/to/scan"}}, ReportToEmails: []string{}, ReportToSlackChannel: "channel", ReportToIssue: true, @@ -63,8 +64,8 @@ func TestScanNonVulnerableProject(t *testing.T) { svc := New(mockGitlabService, mockSlackService, mockGitService, mockOSVService) - warn, err := svc.Patrol(PatrolArgs{ - Locations: []ProjectLocation{{Type: Gitlab, Path: "group/to/scan"}}, + warn, err := svc.Patrol(config.PatrolConfig{ + Locations: []config.ProjectLocation{{Type: config.Gitlab, Path: "group/to/scan"}}, ReportToEmails: []string{}, ReportToSlackChannel: "channel", ReportToIssue: true, @@ -104,8 +105,8 @@ func TestScanVulnerableProject(t *testing.T) { svc := New(mockGitlabService, mockSlackService, mockGitService, mockOSVService) - warn, err := svc.Patrol(PatrolArgs{ - Locations: []ProjectLocation{{Type: Gitlab, Path: "group/to/scan"}}, + warn, err := svc.Patrol(config.PatrolConfig{ + Locations: []config.ProjectLocation{{Type: config.Gitlab, Path: "group/to/scan"}}, ReportToEmails: []string{}, ReportToSlackChannel: "channel", ReportToIssue: true, @@ -134,8 +135,8 @@ func TestMarkVulnsAsAcknowledgedInReport(t *testing.T) { }, }, } - config := scanner.ProjectConfig{ - Acknowledged: []scanner.AcknowledgedVuln{ + config := config.ProjectConfig{ + Acknowledged: []config.AcknowledgedVuln{ { Code: "CVE-1", Reason: "This is a reason", diff --git a/internal/publish/to_slack_test.go b/internal/publish/to_slack_test.go index 398337f..722d166 100644 --- a/internal/publish/to_slack_test.go +++ b/internal/publish/to_slack_test.go @@ -1,6 +1,7 @@ package publish import ( + "sheriff/internal/config" "sheriff/internal/scanner" "testing" @@ -40,7 +41,7 @@ func TestPublishAsSpecificChannelSlackMessage(t *testing.T) { Id: "CVE-2021-1234", }, }, - ProjectConfig: scanner.ProjectConfig{ReportToSlackChannel: "channel"}, + ProjectConfig: config.ProjectConfig{ReportToSlackChannel: "channel"}, } _ = PublishAsSpecificChannelSlackMessage([]scanner.Report{report}, mockSlackService) diff --git a/internal/scanner/vulnscanner.go b/internal/scanner/vulnscanner.go index f66cad9..cf68fd3 100644 --- a/internal/scanner/vulnscanner.go +++ b/internal/scanner/vulnscanner.go @@ -2,6 +2,8 @@ package scanner import ( + "sheriff/internal/config" + gogitlab "github.com/xanzy/go-gitlab" ) @@ -44,21 +46,10 @@ type Vulnerability struct { AckReason string // Optional reason for acknowledging the vulnerability } -type AcknowledgedVuln struct { - Code string `toml:"code"` - Reason string `toml:"reason"` -} - -type ProjectConfig struct { - ReportToSlackChannel string `toml:"report-to-slack-channel"` - SlackChannel string `toml:"slack-channel"` // TODO #27: Break in v1.0. Kept for backwards-compatibility - Acknowledged []AcknowledgedVuln `toml:"acknowledged"` -} - // Report is the main report representation of a project vulnerability scan. type Report struct { Project gogitlab.Project - ProjectConfig ProjectConfig // Contains the project-level configuration that users of sheriff may have in their repository + ProjectConfig config.ProjectConfig // Contains the project-level configuration that users of sheriff may have in their repository IsVulnerable bool Vulnerabilities []Vulnerability IssueUrl string // URL of the GitLab issue. Conditionally set if --gitlab-issue is passed