Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,10 +107,9 @@ 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
gitlab-groups = ["namespace/group", "namespace/group/cool-repo"]
gitlab-projects = ["namespace/group/cool-repo"]
report-slack-channel = "sheriff-report-test"
report-gitlab-issue = true
url = ["namespace/group", "namespace/group/cool-repo"]
report-to-slack-channel = "sheriff-report-test"
report-to-gitlab-issue = true
```

And if you wish to specify a different file, you can do so with `sheriff patrol --config your-config-file.toml`.
Expand Down
133 changes: 64 additions & 69 deletions internal/cli/patrol.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,17 @@ package cli
import (
"errors"
"fmt"
"regexp"
"net/url"
"sheriff/internal/git"
"sheriff/internal/gitlab"
"sheriff/internal/patrol"
"sheriff/internal/scanner"
"sheriff/internal/slack"

zerolog "github.com/rs/zerolog/log"
"github.com/urfave/cli/v2"
"github.com/urfave/cli/v2/altsrc"
)

// Regexes very loosely defined based on GitLab's reserved names:
// https://docs.gitlab.com/ee/user/reserved_names.html#limitations-on-usernames-project-and-group-names-and-slugs
// In reality the regex should be more restrictive about special characters, for now we're just checking for slashes and non-whitespace characters.
const groupPathRegex = "^\\S+(\\/\\S+)*$" // Matches paths like "group" or "group/subgroup" ...
const projectPathRegex = "^\\S+(\\/\\S+)+$" // Matches paths like "group/project" or "group/subgroup/project" ...

type CommandCategory string

const (
Expand All @@ -32,73 +25,59 @@ const (

const configFlag = "config"
const verboseFlag = "verbose"
const testingFlag = "testing"
const groupsFlag = "gitlab-groups"
const projectsFlag = "gitlab-projects"
const reportSlackChannelFlag = "report-slack-channel"
const reportSlackProjectChannelFlag = "report-slack-project-channel"
const reportGitlabFlag = "report-gitlab-issue"
const silentReport = "silent"
const publicSlackChannelFlag = "public-slack-channel"
const urlFlag = "url"
const reportToEmailFlag = "report-to-email"
const reportToIssueFlag = "report-to-issue"
const reportToSlackChannel = "report-to-slack-channel"
const reportEnableProjectReportToFlag = "report-enable-project-report-to"
const silentReportFlag = "silent"
const gitlabTokenFlag = "gitlab-token"
const slackTokenFlag = "slack-token"

var sensitiveFlags = []string{gitlabTokenFlag, slackTokenFlag}

var PatrolFlags = []cli.Flag{
&cli.StringFlag{
Name: configFlag,
Value: "sheriff.toml",
Name: configFlag,
Aliases: []string{"c"},
Value: "sheriff.toml",
},
&cli.BoolFlag{
Name: verboseFlag,
Aliases: []string{"v"},
Usage: "Enable verbose logging",
Category: string(Miscellaneous),
Value: false,
},
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{
Name: groupsFlag,
Usage: "Gitlab groups to scan for vulnerabilities (list argument which can be repeated)",
Name: urlFlag,
Usage: "Groups and projects to scan for vulnerabilities (list argument which can be repeated)",
Category: string(Scanning),
Action: validatePaths(groupPathRegex),
}),
altsrc.NewStringSliceFlag(&cli.StringSliceFlag{
Name: projectsFlag,
Usage: "Gitlab projects to scan for vulnerabilities (list argument which can be repeated)",
Category: string(Scanning),
Action: validatePaths(projectPathRegex),
}),
altsrc.NewBoolFlag(&cli.BoolFlag{
Name: testingFlag,
Usage: "Enable testing mode. This can enable features that are not safe for production use.",
Category: string(Miscellaneous),
Value: false,
}),
altsrc.NewStringFlag(&cli.StringFlag{
Name: reportSlackChannelFlag,
Usage: "Enable reporting to Slack through messages in the specified channel.",
Name: reportToEmailFlag,
Usage: "Enable reporting to the provided list of emails",
Category: string(Reporting),
}),
altsrc.NewBoolFlag(&cli.BoolFlag{
Name: reportSlackProjectChannelFlag,
Usage: "Enable reporting to Slack through messages in the specified project's channel. Requires a project-level configuration file specifying the channel.",
Name: reportToIssueFlag,
Usage: "Enable or disable reporting to the project's issue on the associated platform (gitlab, github, ...)",
Category: string(Reporting),
}),
altsrc.NewBoolFlag(&cli.BoolFlag{
Name: reportGitlabFlag,
Usage: "Enable reporting to GitLab through issue creation in projects affected by vulnerabilities.",
altsrc.NewStringFlag(&cli.StringFlag{
Name: reportToSlackChannel,
Usage: "Enable reporting to the provided slack channel",
Category: string(Reporting),
Value: false,
}),
altsrc.NewBoolFlag(&cli.BoolFlag{
Name: silentReport,
Usage: "Disable report output to stdout.",
Name: reportEnableProjectReportToFlag,
Usage: "Enable project-level configuration for '--report-to-*'.",
Category: string(Reporting),
Value: false,
Value: true,
}),
altsrc.NewBoolFlag(&cli.BoolFlag{
Name: publicSlackChannelFlag,
Usage: "Allow the slack report to be posted to a public channel. Note that reports may contain sensitive information which should not be disclosed on a public channel, for this reason this flag will only be enabled when combined with the testing flag.",
Name: silentReportFlag,
Usage: "Disable report output to stdout.",
Category: string(Reporting),
Value: false,
}),
Expand All @@ -121,10 +100,10 @@ var PatrolFlags = []cli.Flag{
func PatrolAction(cCtx *cli.Context) error {
verbose := cCtx.Bool(verboseFlag)

var publicChannelsEnabled bool
if cCtx.Bool(testingFlag) {
zerolog.Warn().Msg("Testing mode enabled. This may enable features that are not safe for production use.")
publicChannelsEnabled = cCtx.Bool(publicSlackChannelFlag)
// Parse options
locations, err := parseUrls(cCtx.StringSlice(urlFlag))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, if only there was a way to cast/validate directly in the cli...

if err != nil {
return errors.Join(errors.New("failed to parse `--url` options"), err)
}

// Create services
Expand All @@ -133,7 +112,7 @@ func PatrolAction(cCtx *cli.Context) error {
return errors.Join(errors.New("failed to create GitLab service"), err)
}

slackService, err := slack.New(cCtx.String(slackTokenFlag), publicChannelsEnabled, verbose)
slackService, err := slack.New(cCtx.String(slackTokenFlag), verbose)
if err != nil {
return errors.Join(errors.New("failed to create Slack service"), err)
}
Expand All @@ -145,13 +124,15 @@ func PatrolAction(cCtx *cli.Context) error {

// Do the patrol
if warn, err := patrolService.Patrol(
cCtx.StringSlice(groupsFlag),
cCtx.StringSlice(projectsFlag),
cCtx.Bool(reportGitlabFlag),
cCtx.String(reportSlackChannelFlag),
cCtx.Bool(reportSlackProjectChannelFlag),
cCtx.Bool(silentReport),
verbose,
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 {
return errors.Join(errors.New("failed to scan"), err)
} else if warn != nil {
Expand All @@ -161,20 +142,34 @@ func PatrolAction(cCtx *cli.Context) error {
return nil
}

func validatePaths(regex string) func(*cli.Context, []string) error {
return func(_ *cli.Context, groups []string) (err error) {
rgx, err := regexp.Compile(regex)
if err != nil {
return err
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)
}

for _, path := range groups {
matched := rgx.Match([]byte(path))
if !parsed.IsAbs() {
return nil, fmt.Errorf("url missing platform scheme %v", uri)
}

if !matched {
return fmt.Errorf("invalid group path: %v", path)
}
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
}

return locations, nil
}
49 changes: 19 additions & 30 deletions internal/cli/patrol_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package cli

import (
"flag"
"fmt"
"sheriff/internal/patrol"
"testing"

"github.com/stretchr/testify/assert"
Expand All @@ -16,44 +18,31 @@ func TestPatrolActionEmptyRun(t *testing.T) {
assert.Nil(t, err)
}

func TestValidatePathGroupPathRegex(t *testing.T) {
func TestParseUrls(t *testing.T) {
testCases := []struct {
paths []string
want bool
paths []string
wantProjectLocation *patrol.ProjectLocation
wantError bool
}{
{[]string{"group"}, true},
{[]string{"group/subgroup"}, true},
{[]string{"group/subgroup", "not a path"}, false},
{[]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 {
err := validatePaths(groupPathRegex)(nil, tc.paths)
urls, err := parseUrls(tc.paths)

if tc.want {
assert.Nil(t, err)
} else {
assert.NotNil(t, err)
}
}
}

func TestValidatePathProjectPathRegex(t *testing.T) {
testCases := []struct {
paths []string
want bool
}{
{[]string{"project"}, false}, // top-level projects don't exist
{[]string{"group/project"}, true},
{[]string{"group/project", "not a path"}, false},
}
fmt.Print(urls)

for _, tc := range testCases {
err := validatePaths(projectPathRegex)(nil, tc.paths)

if tc.want {
assert.Nil(t, err, tc.paths)
if tc.wantError {
assert.NotNil(t, err)
} else {
assert.NotNil(t, err, tc.paths)
assert.Equal(t, tc.wantProjectLocation, &(urls[0]))
}
}
}
12 changes: 6 additions & 6 deletions internal/gitlab/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import (
)

type iclient interface {
ListGroups(opt *gitlab.ListGroupsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Group, *gitlab.Response, error)
ListGroupProjects(groupId int, opt *gitlab.ListGroupProjectsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Project, *gitlab.Response, error)
GetProject(pid interface{}, opt *gitlab.GetProjectOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Project, *gitlab.Response, error)
ListGroupProjects(gid interface{}, opt *gitlab.ListGroupProjectsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Project, *gitlab.Response, error)
ListProjectIssues(projectId interface{}, opt *gitlab.ListProjectIssuesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Issue, *gitlab.Response, error)
CreateIssue(projectId interface{}, opt *gitlab.CreateIssueOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Issue, *gitlab.Response, error)
UpdateIssue(projectId interface{}, issueId int, opt *gitlab.UpdateIssueOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Issue, *gitlab.Response, error)
Expand All @@ -21,12 +21,12 @@ type client struct {
client *gitlab.Client
}

func (c *client) ListGroups(opt *gitlab.ListGroupsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Group, *gitlab.Response, error) {
return c.client.Groups.ListGroups(opt, options...)
func (c *client) GetProject(gid interface{}, opt *gitlab.GetProjectOptions, options ...gitlab.RequestOptionFunc) (*gitlab.Project, *gitlab.Response, error) {
return c.client.Projects.GetProject(gid, opt, options...)
}

func (c *client) ListGroupProjects(groupId int, opt *gitlab.ListGroupProjectsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Project, *gitlab.Response, error) {
return c.client.Groups.ListGroupProjects(groupId, opt, options...)
func (c *client) ListGroupProjects(gid interface{}, opt *gitlab.ListGroupProjectsOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Project, *gitlab.Response, error) {
return c.client.Groups.ListGroupProjects(gid, opt, options...)
}

func (c *client) ListProjectIssues(projectId interface{}, opt *gitlab.ListProjectIssuesOptions, options ...gitlab.RequestOptionFunc) ([]*gitlab.Issue, *gitlab.Response, error) {
Expand Down
Loading
Loading