diff --git a/internal/cli/patrol.go b/internal/cli/patrol.go index 5f29b6e..f5a2c71 100644 --- a/internal/cli/patrol.go +++ b/internal/cli/patrol.go @@ -6,12 +6,13 @@ import ( "os/exec" "sheriff/internal/config" "sheriff/internal/git" - "sheriff/internal/gitlab" "sheriff/internal/patrol" + "sheriff/internal/repo" "sheriff/internal/scanner" "sheriff/internal/slack" "strings" + "github.com/rs/zerolog/log" "github.com/urfave/cli/v2" ) @@ -124,7 +125,7 @@ func PatrolAction(cCtx *cli.Context) error { slackToken := cCtx.String(slackTokenFlag) // Create services - gitlabService, err := gitlab.New(gitlabToken) + gitlabService, err := repo.NewGitlabService(gitlabToken) if err != nil { return errors.Join(errors.New("failed to create GitLab service"), err) } @@ -149,7 +150,8 @@ func PatrolAction(cCtx *cli.Context) error { 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) + log.Err(warn).Msg("Patrol was partially successful, some errors occurred.") + return cli.Exit("Exiting patrol with partial success", 1) } return nil diff --git a/internal/patrol/patrol.go b/internal/patrol/patrol.go index 051bf9a..488ec28 100644 --- a/internal/patrol/patrol.go +++ b/internal/patrol/patrol.go @@ -8,15 +8,14 @@ import ( "os" "sheriff/internal/config" "sheriff/internal/git" - "sheriff/internal/gitlab" "sheriff/internal/publish" + "sheriff/internal/repo" "sheriff/internal/scanner" "sheriff/internal/slack" "sync" "github.com/elliotchance/pie/v2" "github.com/rs/zerolog/log" - gogitlab "github.com/xanzy/go-gitlab" "golang.org/x/exp/slices" ) @@ -30,7 +29,7 @@ type securityPatroller interface { // sheriffService is the implementation of the SecurityPatroller interface. type sheriffService struct { - gitlabService gitlab.IService + gitlabService repo.IService slackService slack.IService gitService git.IService osvService scanner.VulnScanner[scanner.OsvReport] @@ -39,7 +38,7 @@ type sheriffService struct { // New creates a new securityPatroller service. // It contains the main "loop" logic of this tool. // A "patrol" is defined as scanning GitLab groups for vulnerabilities and publishing reports where needed. -func New(gitlabService gitlab.IService, slackService slack.IService, gitService git.IService, osvService scanner.VulnScanner[scanner.OsvReport]) securityPatroller { +func New(gitlabService repo.IService, slackService slack.IService, gitService git.IService, osvService scanner.VulnScanner[scanner.OsvReport]) securityPatroller { return &sheriffService{ gitlabService: gitlabService, slackService: slackService, @@ -127,10 +126,10 @@ func (s *sheriffService) scanAndGetReports(locations []config.ProjectLocation) ( wg.Add(1) go func(reportsChan chan<- scanner.Report) { defer wg.Done() - log.Info().Str("project", project.Name).Msg("Scanning project") + log.Info().Str("project", project.Path).Msg("Scanning project") if report, err := s.scanProject(project); err != nil { - log.Error().Err(err).Str("project", project.Name).Msg("Failed to scan project, skipping.") - err = errors.Join(fmt.Errorf("failed to scan project %v", project.Name), err) + log.Error().Err(err).Str("project", project.Path).Msg("Failed to scan project, skipping.") + err = errors.Join(fmt.Errorf("failed to scan project %v", project.Path), err) warn = errors.Join(err, warn) reportsChan <- scanner.Report{Project: project, Error: true} } else { @@ -154,7 +153,7 @@ func (s *sheriffService) scanAndGetReports(locations []config.ProjectLocation) ( } // scanProject scans a project for vulnerabilities using the osv scanner. -func (s *sheriffService) scanProject(project gogitlab.Project) (report *scanner.Report, err error) { +func (s *sheriffService) scanProject(project repo.Project) (report *scanner.Report, err error) { dir, err := os.MkdirTemp(tempScanDir, fmt.Sprintf("%v-", project.Name)) if err != nil { return nil, errors.Join(errors.New("failed to create project temporary directory"), err) @@ -162,23 +161,23 @@ func (s *sheriffService) scanProject(project gogitlab.Project) (report *scanner. defer os.RemoveAll(dir) // Clone the project - log.Info().Str("project", project.Name).Str("dir", dir).Msg("Cloning project") - if err = s.gitService.Clone(dir, project.HTTPURLToRepo); err != nil { + log.Info().Str("project", project.Path).Str("dir", dir).Msg("Cloning project") + if err = s.gitService.Clone(dir, project.RepoUrl); err != nil { return nil, errors.Join(errors.New("failed to clone project"), err) } - config := config.GetProjectConfiguration(project.NameWithNamespace, dir) + config := config.GetProjectConfiguration(project.Path, dir) // Scan the project - log.Info().Str("project", project.Name).Msg("Running osv-scanner") + log.Info().Str("project", project.Path).Msg("Running osv-scanner") osvReport, err := s.osvService.Scan(dir) if err != nil { - log.Error().Err(err).Str("project", project.Name).Msg("Failed to run osv-scanner") + log.Error().Err(err).Str("project", project.Path).Msg("Failed to run osv-scanner") return nil, errors.Join(errors.New("failed to run osv-scanner"), err) } r := s.osvService.GenerateReport(project, osvReport) - log.Info().Str("project", project.Name).Msg("Finished scanning with osv-scanner") + log.Info().Str("project", project.Path).Msg("Finished scanning with osv-scanner") r.ProjectConfig = config diff --git a/internal/patrol/patrol_test.go b/internal/patrol/patrol_test.go index 576a1b3..16a6f1e 100644 --- a/internal/patrol/patrol_test.go +++ b/internal/patrol/patrol_test.go @@ -2,13 +2,13 @@ package patrol import ( "sheriff/internal/config" + "sheriff/internal/repo" "sheriff/internal/scanner" "testing" "github.com/slack-go/slack" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" - "github.com/xanzy/go-gitlab" ) func TestNewService(t *testing.T) { @@ -19,7 +19,7 @@ func TestNewService(t *testing.T) { func TestScanNoProjects(t *testing.T) { mockGitlabService := &mockGitlabService{} - mockGitlabService.On("GetProjectList", []string{"group/to/scan"}).Return([]gitlab.Project{}, nil) + mockGitlabService.On("GetProjectList", []string{"group/to/scan"}).Return([]repo.Project{}, nil) mockSlackService := &mockSlackService{} @@ -49,7 +49,7 @@ func TestScanNoProjects(t *testing.T) { func TestScanNonVulnerableProject(t *testing.T) { mockGitlabService := &mockGitlabService{} - mockGitlabService.On("GetProjectList", []string{"group/to/scan"}).Return([]gitlab.Project{{Name: "Hello World", HTTPURLToRepo: "https://gitlab.com/group/to/scan.git"}}, nil) + mockGitlabService.On("GetProjectList", []string{"group/to/scan"}).Return([]repo.Project{{Name: "Hello World", RepoUrl: "https://gitlab.com/group/to/scan.git"}}, nil) mockGitlabService.On("CloseVulnerabilityIssue", mock.Anything).Return(nil) mockSlackService := &mockSlackService{} @@ -82,8 +82,8 @@ func TestScanNonVulnerableProject(t *testing.T) { func TestScanVulnerableProject(t *testing.T) { mockGitlabService := &mockGitlabService{} - mockGitlabService.On("GetProjectList", []string{"group/to/scan"}).Return([]gitlab.Project{{Name: "Hello World", HTTPURLToRepo: "https://gitlab.com/group/to/scan.git"}}, nil) - mockGitlabService.On("OpenVulnerabilityIssue", mock.Anything, mock.Anything).Return(&gitlab.Issue{}, nil) + mockGitlabService.On("GetProjectList", []string{"group/to/scan"}).Return([]repo.Project{{Name: "Hello World", RepoUrl: "https://gitlab.com/group/to/scan.git"}}, nil) + mockGitlabService.On("OpenVulnerabilityIssue", mock.Anything, mock.Anything).Return(&repo.Issue{}, nil) mockSlackService := &mockSlackService{} mockSlackService.On("PostMessage", "channel", mock.Anything).Return("", nil) @@ -190,19 +190,19 @@ type mockGitlabService struct { mock.Mock } -func (c *mockGitlabService) GetProjectList(paths []string) ([]gitlab.Project, error) { +func (c *mockGitlabService) GetProjectList(paths []string) ([]repo.Project, error) { args := c.Called(paths) - return args.Get(0).([]gitlab.Project), args.Error(1) + return args.Get(0).([]repo.Project), args.Error(1) } -func (c *mockGitlabService) CloseVulnerabilityIssue(project gitlab.Project) error { +func (c *mockGitlabService) CloseVulnerabilityIssue(project repo.Project) error { args := c.Called(project) return args.Error(0) } -func (c *mockGitlabService) OpenVulnerabilityIssue(project gitlab.Project, report string) (*gitlab.Issue, error) { +func (c *mockGitlabService) OpenVulnerabilityIssue(project repo.Project, report string) (*repo.Issue, error) { args := c.Called(project, report) - return args.Get(0).(*gitlab.Issue), args.Error(1) + return args.Get(0).(*repo.Issue), args.Error(1) } type mockSlackService struct { @@ -232,7 +232,7 @@ func (c *mockOSVService) Scan(dir string) (*scanner.OsvReport, error) { return args.Get(0).(*scanner.OsvReport), args.Error(1) } -func (c *mockOSVService) GenerateReport(p gitlab.Project, r *scanner.OsvReport) scanner.Report { +func (c *mockOSVService) GenerateReport(p repo.Project, r *scanner.OsvReport) scanner.Report { args := c.Called(p, r) return args.Get(0).(scanner.Report) } diff --git a/internal/publish/to_console.go b/internal/publish/to_console.go index 7237717..90a5b25 100644 --- a/internal/publish/to_console.go +++ b/internal/publish/to_console.go @@ -32,7 +32,7 @@ func formatReportsMessageForConsole(scanReports []scanner.Report) string { r.WriteString(fmt.Sprintf("Total number of projects scanned: %v\n", len(scanReports))) for _, report := range scanReports { r.WriteString(fmt.Sprintln("---------------------------------")) - r.WriteString(fmt.Sprintf("%v\n", report.Project.PathWithNamespace)) + r.WriteString(fmt.Sprintf("%v\n", report.Project.Path)) r.WriteString(fmt.Sprintf("\tProject URL: %v\n", report.Project.WebURL)) r.WriteString(fmt.Sprintf("\tNumber of vulnerabilities: %v\n", len(report.Vulnerabilities))) } diff --git a/internal/publish/to_console_test.go b/internal/publish/to_console_test.go index c6ebf54..e973d3e 100644 --- a/internal/publish/to_console_test.go +++ b/internal/publish/to_console_test.go @@ -1,17 +1,17 @@ package publish import ( + "sheriff/internal/repo" "sheriff/internal/scanner" "testing" "github.com/stretchr/testify/assert" - gogitlab "github.com/xanzy/go-gitlab" ) func TestFormatReportMessageForConsole(t *testing.T) { reports := []scanner.Report{ { - Project: gogitlab.Project{ + Project: repo.Project{ Name: "project1", WebURL: "http://example.com", }, @@ -27,7 +27,7 @@ func TestFormatReportMessageForConsole(t *testing.T) { }, }, { - Project: gogitlab.Project{ + Project: repo.Project{ Name: "project2", WebURL: "http://example2.com", }, diff --git a/internal/publish/to_gitlab.go b/internal/publish/to_gitlab.go index fed4847..30b819b 100644 --- a/internal/publish/to_gitlab.go +++ b/internal/publish/to_gitlab.go @@ -3,7 +3,7 @@ package publish import ( "errors" "fmt" - "sheriff/internal/gitlab" + "sheriff/internal/repo" "sheriff/internal/scanner" "strconv" "sync" @@ -22,7 +22,7 @@ var now = time.Now // PublishAsGitlabIssues creates or updates GitLab Issue reports for the given reports // It will add the Issue URL to the Report if it was created or updated successfully -func PublishAsGitlabIssues(reports []scanner.Report, s gitlab.IService) (warn error) { +func PublishAsGitlabIssues(reports []scanner.Report, s repo.IService) (warn error) { var wg sync.WaitGroup for i := 0; i < len(reports); i++ { wg.Add(1) @@ -30,16 +30,16 @@ func PublishAsGitlabIssues(reports []scanner.Report, s gitlab.IService) (warn er defer wg.Done() if reports[i].IsVulnerable { if issue, err := s.OpenVulnerabilityIssue(reports[i].Project, formatGitlabIssue(reports[i])); err != nil { - log.Error().Err(err).Str("project", reports[i].Project.Name).Msg("Failed to open or update issue") - err = fmt.Errorf("failed to open or update issue for project %v", reports[i].Project.Name) + log.Error().Err(err).Str("project", reports[i].Project.Path).Msg("Failed to open or update issue") + err = fmt.Errorf("failed to open or update issue for project %v", reports[i].Project.Path) warn = errors.Join(err, warn) } else { reports[i].IssueUrl = issue.WebURL } } else { if err := s.CloseVulnerabilityIssue(reports[i].Project); err != nil { - log.Error().Err(err).Str("project", reports[i].Project.Name).Msg("Failed to close issue") - err = fmt.Errorf("failed to close issue for project %v", reports[i].Project.Name) + log.Error().Err(err).Str("project", reports[i].Project.Path).Msg("Failed to close issue") + err = fmt.Errorf("failed to close issue for project %v", reports[i].Project.Path) warn = errors.Join(err, warn) } } diff --git a/internal/publish/to_gitlab_test.go b/internal/publish/to_gitlab_test.go index 52a5b93..eb64b29 100644 --- a/internal/publish/to_gitlab_test.go +++ b/internal/publish/to_gitlab_test.go @@ -1,13 +1,13 @@ package publish import ( + "sheriff/internal/repo" "sheriff/internal/scanner" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" - "github.com/xanzy/go-gitlab" ) // Severities are grouped by severity score kind @@ -164,7 +164,7 @@ func TestMarkdownBoolean(t *testing.T) { func TestPublishAsGitlabIssues(t *testing.T) { mockGitlabService := &mockGitlabService{} - mockGitlabService.On("OpenVulnerabilityIssue", mock.Anything, mock.Anything).Return(&gitlab.Issue{WebURL: "https://my-issue.com"}, nil) + mockGitlabService.On("OpenVulnerabilityIssue", mock.Anything, mock.Anything).Return(&repo.Issue{WebURL: "https://my-issue.com"}, nil) reports := []scanner.Report{ { IsVulnerable: true, @@ -206,17 +206,17 @@ type mockGitlabService struct { mock.Mock } -func (c *mockGitlabService) GetProjectList(paths []string) ([]gitlab.Project, error) { +func (c *mockGitlabService) GetProjectList(paths []string) ([]repo.Project, error) { args := c.Called(paths) - return args.Get(0).([]gitlab.Project), args.Error(1) + return args.Get(0).([]repo.Project), args.Error(1) } -func (c *mockGitlabService) CloseVulnerabilityIssue(project gitlab.Project) error { +func (c *mockGitlabService) CloseVulnerabilityIssue(project repo.Project) error { args := c.Called(project) return args.Error(0) } -func (c *mockGitlabService) OpenVulnerabilityIssue(project gitlab.Project, report string) (*gitlab.Issue, error) { +func (c *mockGitlabService) OpenVulnerabilityIssue(project repo.Project, report string) (*repo.Issue, error) { args := c.Called(project, report) - return args.Get(0).(*gitlab.Issue), args.Error(1) + return args.Get(0).(*repo.Issue), args.Error(1) } diff --git a/internal/publish/to_slack.go b/internal/publish/to_slack.go index 1fa0163..9127a74 100644 --- a/internal/publish/to_slack.go +++ b/internal/publish/to_slack.go @@ -82,7 +82,7 @@ func formatSpecificChannelSlackMessage(report scanner.Report) []goslack.MsgOptio // Texts title := fmt.Sprintf("Sheriff Report %v", time.Now().Format("2006-01-02")) - subtitle := fmt.Sprintf("Project: <%s|*%s*>", report.Project.WebURL, report.Project.PathWithNamespace) + subtitle := fmt.Sprintf("Project: <%s|*%s*>", report.Project.WebURL, report.Project.Path) var subtitleFullReport string if report.IssueUrl != "" { subtitleFullReport = fmt.Sprintf("Full report: <%s|*Full report*>", report.IssueUrl) diff --git a/internal/publish/to_slack_test.go b/internal/publish/to_slack_test.go index cbbacde..60c208e 100644 --- a/internal/publish/to_slack_test.go +++ b/internal/publish/to_slack_test.go @@ -2,13 +2,13 @@ package publish import ( "sheriff/internal/config" + "sheriff/internal/repo" "sheriff/internal/scanner" "testing" "github.com/slack-go/slack" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" - gogitlab "github.com/xanzy/go-gitlab" ) func TestPublishAsGeneralSlackMessage(t *testing.T) { @@ -102,7 +102,7 @@ func TestFormatReportMessage(t *testing.T) { reportBySeverityKind := map[scanner.SeverityScoreKind][]scanner.Report{ scanner.Critical: { { - Project: gogitlab.Project{ + Project: repo.Project{ Name: "project1", WebURL: "http://example.com", }, @@ -118,7 +118,7 @@ func TestFormatReportMessage(t *testing.T) { }, scanner.High: { { - Project: gogitlab.Project{ + Project: repo.Project{ Name: "project2", WebURL: "http://example2.com", }, diff --git a/internal/gitlab/gitlab.go b/internal/repo/gitlab.go similarity index 68% rename from internal/gitlab/gitlab.go rename to internal/repo/gitlab.go index f3c2724..0d24095 100644 --- a/internal/gitlab/gitlab.go +++ b/internal/repo/gitlab.go @@ -1,4 +1,4 @@ -package gitlab +package repo import ( "errors" @@ -10,64 +10,56 @@ import ( "github.com/xanzy/go-gitlab" ) -const VulnerabilityIssueTitle = "Sheriff - 🚨 Vulnerability report" - -// IService is the interface of the GitLab service as needed by sheriff -type IService interface { - GetProjectList(paths []string) (projects []gitlab.Project, warn error) - CloseVulnerabilityIssue(project gitlab.Project) error - OpenVulnerabilityIssue(project gitlab.Project, report string) (*gitlab.Issue, error) -} - -type service struct { +type gitlabService struct { client iclient } -// New creates a new GitLab service -func New(gitlabToken string) (IService, error) { +// NewGitlabService creates a new GitLab service +func NewGitlabService(gitlabToken string) (IService, error) { gitlabClient, err := gitlab.NewClient(gitlabToken) if err != nil { return nil, err } - s := service{&client{client: gitlabClient}} + s := gitlabService{&client{client: gitlabClient}} return &s, nil } -func (s *service) GetProjectList(paths []string) (projects []gitlab.Project, warn error) { +func (s *gitlabService) GetProjectList(paths []string) (projects []Project, warn error) { projects, pwarn := s.gatherProjectsFromGroupsOrProjects(paths) if pwarn != nil { pwarn = errors.Join(errors.New("errors occured when gathering projects"), pwarn) warn = errors.Join(pwarn, warn) } - projectsNamespaces := pie.Map(projects, func(p gitlab.Project) string { return p.PathWithNamespace }) + projectsNamespaces := pie.Map(projects, func(p Project) string { return p.Path }) log.Info().Strs("projects", projectsNamespaces).Msg("Projects to scan") return projects, warn } // CloseVulnerabilityIssue closes the vulnerability issue for the given project -func (s *service) CloseVulnerabilityIssue(project gitlab.Project) (err error) { +func (s *gitlabService) CloseVulnerabilityIssue(project Project) (err error) { issue, err := s.getVulnerabilityIssue(project) if err != nil { return errors.Join(errors.New("failed to fetch current list of issues"), err) } if issue == nil { - log.Info().Str("project", project.Name).Msg("No issue to close, nothing to do") + log.Info().Str("project", project.Path).Msg("No issue to close, nothing to do") return } if issue.State == "closed" { - log.Info().Str("project", project.Name).Msg("Issue already closed") + log.Info().Str("project", project.Path).Msg("Issue already closed") return } - if issue, _, err = s.client.UpdateIssue(project.ID, issue.IID, &gitlab.UpdateIssueOptions{ + issue, _, err = s.client.UpdateIssue(project.ID, issue.ID, &gitlab.UpdateIssueOptions{ StateEvent: gitlab.Ptr("close"), - }); err != nil { + }) + if err != nil { return errors.Join(errors.New("failed to update issue"), err) } @@ -75,40 +67,45 @@ func (s *service) CloseVulnerabilityIssue(project gitlab.Project) (err error) { return errors.New("failed to close issue") } - log.Info().Str("project", project.Name).Msg("Issue closed") + log.Info().Str("project", project.Path).Msg("Issue closed") return } // OpenVulnerabilityIssue opens or updates the vulnerability issue for the given project -func (s *service) OpenVulnerabilityIssue(project gitlab.Project, report string) (issue *gitlab.Issue, err error) { - issue, err = s.getVulnerabilityIssue(project) +func (s *gitlabService) OpenVulnerabilityIssue(project Project, report string) (issue *Issue, err error) { + gitlabIssue, err := s.getVulnerabilityIssue(project) if err != nil { - return nil, errors.Join(fmt.Errorf("[%v] Failed to fetch current list of issues", project.Name), err) + return nil, errors.Join(fmt.Errorf("[%v] Failed to fetch current list of issues", project.Path), err) } - if issue == nil { - log.Info().Str("project", project.Name).Msg("Creating new issue") + if gitlabIssue == nil { + log.Info().Str("project", project.Path).Msg("Creating new issue") - issue, _, err = s.client.CreateIssue(project.ID, &gitlab.CreateIssueOptions{ + gitlabIssue, _, err := s.client.CreateIssue(project.ID, &gitlab.CreateIssueOptions{ Title: gitlab.Ptr(VulnerabilityIssueTitle), Description: &report, }) if err != nil { - return nil, errors.Join(fmt.Errorf("[%v] failed to create new issue", project.Name), err) + return nil, errors.Join(fmt.Errorf("[%v] failed to create new issue", project.Path), err) } - return + return mapIssuePtr(gitlabIssue), nil } - log.Info().Str("project", project.Name).Str("issue", issue.Title).Msg("Updating existing issue") + log.Info().Str("project", project.Path).Int("issue", gitlabIssue.IID).Msg("Updating existing issue") - issue, _, err = s.client.UpdateIssue(project.ID, issue.IID, &gitlab.UpdateIssueOptions{ + if updatedIssue, _, err := s.client.UpdateIssue(project.ID, gitlabIssue.IID, &gitlab.UpdateIssueOptions{ Description: &report, StateEvent: gitlab.Ptr("reopen"), - }) - if err != nil { - return nil, errors.Join(fmt.Errorf("[%v] Failed to update issue", project.Name), err) + }); err != nil { + return nil, errors.Join(fmt.Errorf("[%v] Failed to update issue", project.Path), err) + } else { + if updatedIssue.State != "opened" { + return nil, errors.New("failed to reopen issue") + } + + issue = mapIssuePtr(updatedIssue) } return @@ -116,7 +113,7 @@ func (s *service) OpenVulnerabilityIssue(project gitlab.Project, report string) // This function receives a list of paths which can be gitlab projects or groups // and returns the list of projects within those paths and the list of projects contained within those groups and their subgroups. -func (s *service) gatherProjectsFromGroupsOrProjects(paths []string) (projects []gitlab.Project, warn error) { +func (s *gitlabService) gatherProjectsFromGroupsOrProjects(paths []string) (projects []Project, warn error) { for _, path := range paths { gp, gpwarn, gerr := s.getProjectsFromGroupOrProject(path) if gerr != nil { @@ -143,23 +140,27 @@ func (s *service) gatherProjectsFromGroupsOrProjects(paths []string) (projects [ // // If it succeeds then it returns all projects of that group & its subgroups. // If it fails then it tries to get the path as a project. -func (s *service) getProjectsFromGroupOrProject(path string) (projects []gitlab.Project, warn error, err error) { +func (s *gitlabService) getProjectsFromGroupOrProject(path string) (projects []Project, warn error, err error) { gp, gpwarn, gperr := s.listGroupProjects(path) if gperr != nil { log.Debug().Str("path", path).Msg("failed to fetch as group. trying as project") p, _, perr := s.client.GetProject(path, &gitlab.GetProjectOptions{}) if perr != nil { - return nil, nil, errors.Join(fmt.Errorf("failed to get group %v", path), gperr) + return nil, errors.Join(fmt.Errorf("failed to get group %v", path), gperr), nil + } else if p == nil { + return nil, fmt.Errorf("unexpected nil project %v", path), nil } - return []gitlab.Project{*p}, nil, nil + return []Project{mapProject(*p)}, nil, nil } - return gp, gpwarn, nil + ps := pie.Map(gp, mapProject) + + return ps, gpwarn, nil } // getVulnerabilityIssue returns the vulnerability issue for the given project -func (s *service) getVulnerabilityIssue(project gitlab.Project) (issue *gitlab.Issue, err error) { +func (s *gitlabService) getVulnerabilityIssue(project Project) (issue *gitlab.Issue, err error) { issues, _, err := s.client.ListProjectIssues(project.ID, &gitlab.ListProjectIssuesOptions{ Search: gitlab.Ptr(VulnerabilityIssueTitle), In: gitlab.Ptr("title"), @@ -169,6 +170,10 @@ func (s *service) getVulnerabilityIssue(project gitlab.Project) (issue *gitlab.I } if len(issues) > 0 { + if issues[0] == nil { + return nil, fmt.Errorf("unexpected nil issue %v", project.Path) + } + issue = issues[0] } @@ -176,7 +181,7 @@ func (s *service) getVulnerabilityIssue(project gitlab.Project) (issue *gitlab.I } // listGroupProjects returns the list of projects for the given group ID -func (s *service) listGroupProjects(path string) (projects []gitlab.Project, warn error, err error) { +func (s *gitlabService) listGroupProjects(path string) (projects []gitlab.Project, warn error, err error) { projectPtrs, response, err := s.client.ListGroupProjects(path, &gitlab.ListGroupProjectsOptions{ Archived: gitlab.Ptr(false), @@ -219,7 +224,7 @@ func ToChan[T any](s []T) <-chan T { } // listGroupNextProjects returns the list of projects for the given group ID from the next pages -func (s *service) listGroupNextProjects(path string, totalPages int) (projects []gitlab.Project, warn error) { +func (s *gitlabService) listGroupNextProjects(path string, totalPages int) (projects []gitlab.Project, warn error) { var wg sync.WaitGroup nextProjectsChan := make(chan []gitlab.Project, totalPages) warnChan := make(chan error, totalPages) @@ -269,7 +274,7 @@ func (s *service) listGroupNextProjects(path string, totalPages int) (projects [ return } -func filterUniqueProjects(projects []gitlab.Project) (filteredProjects []gitlab.Project) { +func filterUniqueProjects(projects []Project) (filteredProjects []Project) { projectsNamespaces := make(map[int]bool) for _, project := range projects { @@ -293,3 +298,32 @@ func dereferenceProjectsPointers(projects []*gitlab.Project) (filteredProjects [ return } + +func mapProject(p gitlab.Project) Project { + return Project{ + ID: p.ID, + Name: p.Name, + Path: p.PathWithNamespace, + WebURL: p.WebURL, + RepoUrl: p.HTTPURLToRepo, + Platform: "gitlab", + } +} + +func mapIssue(i gitlab.Issue) Issue { + return Issue{ + Title: i.Title, + WebURL: i.WebURL, + Platform: "gitlab", + } +} + +func mapIssuePtr(i *gitlab.Issue) *Issue { + if i == nil { + return nil + } + + issue := mapIssue(*i) + + return &issue +} diff --git a/internal/gitlab/client.go b/internal/repo/gitlab_client.go similarity index 99% rename from internal/gitlab/client.go rename to internal/repo/gitlab_client.go index eeb30b1..ece98b0 100644 --- a/internal/gitlab/client.go +++ b/internal/repo/gitlab_client.go @@ -1,5 +1,5 @@ // Package gitlab provides a GitLab service to interact with the GitLab API. -package gitlab +package repo // This client is a thin wrapper around the go-gitlab library. It provides an interface to the GitLab client // The main purpose of this client is to provide an interface to the GitLab client which can be mocked in tests. diff --git a/internal/gitlab/gitlab_test.go b/internal/repo/gitlab_test.go similarity index 92% rename from internal/gitlab/gitlab_test.go rename to internal/repo/gitlab_test.go index 37e7e48..dc0978c 100644 --- a/internal/gitlab/gitlab_test.go +++ b/internal/repo/gitlab_test.go @@ -1,4 +1,4 @@ -package gitlab +package repo import ( "errors" @@ -10,7 +10,7 @@ import ( ) func TestNewService(t *testing.T) { - s, err := New("token") + s, err := NewGitlabService("token") assert.Nil(t, err) assert.NotNil(t, s) @@ -20,7 +20,7 @@ func TestGetProjectListWithTopLevelGroup(t *testing.T) { mockClient := mockClient{} mockClient.On("ListGroupProjects", "group", mock.Anything, mock.Anything).Return([]*gitlab.Project{{Name: "Hello World"}}, &gitlab.Response{}, nil) - svc := service{&mockClient} + svc := gitlabService{&mockClient} projects, err := svc.GetProjectList([]string{"group"}) @@ -34,7 +34,7 @@ func TestGetProjectListWithSubGroup(t *testing.T) { mockClient := mockClient{} mockClient.On("ListGroupProjects", "group/subgroup", mock.Anything, mock.Anything).Return([]*gitlab.Project{{Name: "Hello World"}}, &gitlab.Response{}, nil) - svc := service{&mockClient} + svc := gitlabService{&mockClient} projects, err := svc.GetProjectList([]string{"group/subgroup"}) @@ -49,7 +49,7 @@ func TestGetProjectListWithProjects(t *testing.T) { mockClient.On("ListGroupProjects", "group/subgroup/project", mock.Anything, mock.Anything).Return([]*gitlab.Project{}, &gitlab.Response{}, errors.New("no group")) mockClient.On("GetProject", "group/subgroup/project", mock.Anything, mock.Anything).Return(&gitlab.Project{Name: "Hello World", PathWithNamespace: "group/subgroup/project"}, &gitlab.Response{}, nil) - svc := service{&mockClient} + svc := gitlabService{&mockClient} projects, err := svc.GetProjectList([]string{"group/subgroup/project"}) @@ -69,7 +69,7 @@ func TestGetProjectListWithGroupAndProjects(t *testing.T) { mockClient.On("ListGroupProjects", project1.PathWithNamespace, mock.Anything, mock.Anything).Return([]*gitlab.Project{}, &gitlab.Response{}, errors.New("no group")) mockClient.On("GetProject", project1.PathWithNamespace, mock.Anything, mock.Anything).Return(project1, &gitlab.Response{}, nil) - svc := service{&mockClient} + svc := gitlabService{&mockClient} projects, err := svc.GetProjectList([]string{"group", "group/subgroup", project1.PathWithNamespace}) @@ -105,7 +105,7 @@ func TestGetProjectListWithNextPage(t *testing.T) { }, }, mock.Anything).Return([]*gitlab.Project{project2}, &gitlab.Response{NextPage: 0, TotalPages: 2}, nil) - svc := service{&mockClient} + svc := gitlabService{&mockClient} projects, err := svc.GetProjectList([]string{"group/subgroup"}) @@ -121,9 +121,9 @@ func TestCloseVulnerabilityIssue(t *testing.T) { mockClient.On("ListProjectIssues", mock.Anything, mock.Anything, mock.Anything).Return([]*gitlab.Issue{{State: "opened"}}, nil, nil) mockClient.On("UpdateIssue", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&gitlab.Issue{State: "closed"}, nil, nil) - svc := service{&mockClient} + svc := gitlabService{&mockClient} - err := svc.CloseVulnerabilityIssue(gitlab.Project{}) + err := svc.CloseVulnerabilityIssue(Project{}) assert.Nil(t, err) mockClient.AssertExpectations(t) @@ -133,9 +133,9 @@ func TestCloseVulnerabilityIssueAlreadyClosed(t *testing.T) { mockClient := mockClient{} mockClient.On("ListProjectIssues", mock.Anything, mock.Anything, mock.Anything).Return([]*gitlab.Issue{{State: "closed"}}, nil, nil) - svc := service{&mockClient} + svc := gitlabService{&mockClient} - err := svc.CloseVulnerabilityIssue(gitlab.Project{}) + err := svc.CloseVulnerabilityIssue(Project{}) assert.Nil(t, err) mockClient.AssertExpectations(t) @@ -145,9 +145,9 @@ func TestCloseVulnerabilityIssueNoIssue(t *testing.T) { mockClient := mockClient{} mockClient.On("ListProjectIssues", mock.Anything, mock.Anything, mock.Anything).Return([]*gitlab.Issue{}, nil, nil) - svc := service{&mockClient} + svc := gitlabService{&mockClient} - err := svc.CloseVulnerabilityIssue(gitlab.Project{}) + err := svc.CloseVulnerabilityIssue(Project{}) assert.Nil(t, err) mockClient.AssertExpectations(t) @@ -156,18 +156,18 @@ func TestCloseVulnerabilityIssueNoIssue(t *testing.T) { func TestOpenVulnerabilityIssue(t *testing.T) { mockClient := mockClient{} mockClient.On("ListProjectIssues", mock.Anything, mock.Anything, mock.Anything).Return([]*gitlab.Issue{}, nil, nil) - mockClient.On("CreateIssue", mock.Anything, mock.Anything, mock.Anything).Return(&gitlab.Issue{ID: 666}, nil, nil) + mockClient.On("CreateIssue", mock.Anything, mock.Anything, mock.Anything).Return(&gitlab.Issue{Title: "666"}, nil, nil) - svc := service{&mockClient} + svc := gitlabService{&mockClient} - i, err := svc.OpenVulnerabilityIssue(gitlab.Project{}, "report") + i, err := svc.OpenVulnerabilityIssue(Project{}, "report") assert.Nil(t, err) assert.NotNil(t, i) - assert.Equal(t, 666, i.ID) + assert.Equal(t, "666", i.Title) } func TestFilterUniqueProjects(t *testing.T) { - projects := []gitlab.Project{ + projects := []Project{ {ID: 1}, {ID: 1}, {ID: 2}, diff --git a/internal/repo/repo.go b/internal/repo/repo.go new file mode 100644 index 0000000..e64600b --- /dev/null +++ b/internal/repo/repo.go @@ -0,0 +1,27 @@ +package repo + +const VulnerabilityIssueTitle = "Sheriff - 🚨 Vulnerability report" + +type Project struct { + ID int + Name string + Path string + WebURL string + RepoUrl string + Platform string +} + +type Issue struct { + ID int + Title string + WebURL string + Open bool + Platform string +} + +// IService is the interface of the GitLab service as needed by sheriff +type IService interface { + GetProjectList(paths []string) (projects []Project, warn error) + CloseVulnerabilityIssue(project Project) error + OpenVulnerabilityIssue(project Project, report string) (*Issue, error) +} diff --git a/internal/scanner/osv.go b/internal/scanner/osv.go index a61e40c..4d41a90 100644 --- a/internal/scanner/osv.go +++ b/internal/scanner/osv.go @@ -3,13 +3,13 @@ package scanner import ( "encoding/json" "path/filepath" + "sheriff/internal/repo" "sheriff/internal/shell" "strconv" "time" "github.com/elliotchance/pie/v2" "github.com/rs/zerolog/log" - gogitlab "github.com/xanzy/go-gitlab" ) type osvReferenceKind string @@ -136,7 +136,7 @@ func (s *osvScanner) Scan(dir string) (*OsvReport, error) { } // GenerateReport generates a Report struct from the OsvReport. -func (s *osvScanner) GenerateReport(p gogitlab.Project, r *OsvReport) Report { +func (s *osvScanner) GenerateReport(p repo.Project, r *OsvReport) Report { if r == nil { return Report{ Project: p, diff --git a/internal/scanner/osv_test.go b/internal/scanner/osv_test.go index 8a36685..3dc79a8 100644 --- a/internal/scanner/osv_test.go +++ b/internal/scanner/osv_test.go @@ -1,6 +1,7 @@ package scanner import ( + "sheriff/internal/repo" "sheriff/internal/shell" "testing" @@ -8,7 +9,6 @@ import ( "os" "github.com/stretchr/testify/assert" - "github.com/xanzy/go-gitlab" ) func TestReadOSVJson(t *testing.T) { @@ -96,7 +96,7 @@ func (m *mockCommandRunner) Run(shell.CommandInput) (shell.CommandOutput, error) func TestGenerateReportOSV(t *testing.T) { mockReport := createMockReport("10.0") s := osvScanner{} - got := s.GenerateReport(gitlab.Project{}, mockReport) + got := s.GenerateReport(repo.Project{}, mockReport) assert.NotNil(t, got) assert.Len(t, got.Vulnerabilities, 1) @@ -132,7 +132,7 @@ func TestGenerateReportOSVHasCorrectSeverityKind(t *testing.T) { for input, want := range testCases { t.Run(input, func(t *testing.T) { mockReport := createMockReport(input) - got := s.GenerateReport(gitlab.Project{}, mockReport) + got := s.GenerateReport(repo.Project{}, mockReport) assert.NotNil(t, got) assert.Equal(t, want, got.Vulnerabilities[0].SeverityScoreKind) @@ -156,7 +156,7 @@ func TestReportContainsHasAvailableFix(t *testing.T) { }, }, }) - got := s.GenerateReport(gitlab.Project{}, mockReport) + got := s.GenerateReport(repo.Project{}, mockReport) assert.NotNil(t, got) assert.Len(t, got.Vulnerabilities, 1) diff --git a/internal/scanner/vulnscanner.go b/internal/scanner/vulnscanner.go index 48b9b76..f11761e 100644 --- a/internal/scanner/vulnscanner.go +++ b/internal/scanner/vulnscanner.go @@ -3,8 +3,7 @@ package scanner import ( "sheriff/internal/config" - - gogitlab "github.com/xanzy/go-gitlab" + "sheriff/internal/repo" ) type SeverityScoreKind string @@ -48,7 +47,7 @@ type Vulnerability struct { // Report is the main report representation of a project vulnerability scan. type Report struct { - Project gogitlab.Project + Project repo.Project ProjectConfig config.ProjectConfig // Contains the project-level configuration that users of sheriff may have in their repository IsVulnerable bool Vulnerabilities []Vulnerability @@ -62,5 +61,5 @@ type VulnScanner[T any] interface { // Scan runs a vulnerability scan on the given directory Scan(dir string) (*T, error) // GenerateReport maps the report from the scanner to our internal representation of vulnerability reports. - GenerateReport(p gogitlab.Project, r *T) Report + GenerateReport(p repo.Project, r *T) Report }