Skip to content

Commit

Permalink
feat(#17): rework cli interface
Browse files Browse the repository at this point in the history
  • Loading branch information
sacha-c committed Dec 5, 2024
1 parent 6addde5 commit 29df26b
Show file tree
Hide file tree
Showing 13 changed files with 261 additions and 304 deletions.
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))
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("unsupport 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

0 comments on commit 29df26b

Please sign in to comment.