Scan GitLab Groups (#4320)
* gitlab groups init * added list group projects api * list group projects updated * added duplicate repo scan check * comments addressed * added error when repo and group id flags are provided at the same time * added test case for gitlab group projects
This commit is contained in:
@@ -136,6 +136,7 @@ var (
|
|||||||
gitlabScanEndpoint = gitlabScan.Flag("endpoint", "GitLab endpoint.").Default("https://gitlab.com").String()
|
gitlabScanEndpoint = gitlabScan.Flag("endpoint", "GitLab endpoint.").Default("https://gitlab.com").String()
|
||||||
gitlabScanRepos = gitlabScan.Flag("repo", "GitLab repo url. You can repeat this flag. Leave empty to scan all repos accessible with provided credential. Example: https://gitlab.com/org/repo.git").Strings()
|
gitlabScanRepos = gitlabScan.Flag("repo", "GitLab repo url. You can repeat this flag. Leave empty to scan all repos accessible with provided credential. Example: https://gitlab.com/org/repo.git").Strings()
|
||||||
gitlabScanToken = gitlabScan.Flag("token", "GitLab token. Can be provided with environment variable GITLAB_TOKEN.").Envar("GITLAB_TOKEN").Required().String()
|
gitlabScanToken = gitlabScan.Flag("token", "GitLab token. Can be provided with environment variable GITLAB_TOKEN.").Envar("GITLAB_TOKEN").Required().String()
|
||||||
|
gitlabScanGroupIds = gitlabScan.Flag("group-id", "GitLab group ID. If provided, it will scan the group and its subgroups. You can repeat this flag.").Strings()
|
||||||
gitlabScanIncludePaths = gitlabScan.Flag("include-paths", "Path to file with newline separated regexes for files to include in scan.").Short('i').String()
|
gitlabScanIncludePaths = gitlabScan.Flag("include-paths", "Path to file with newline separated regexes for files to include in scan.").Short('i').String()
|
||||||
gitlabScanExcludePaths = gitlabScan.Flag("exclude-paths", "Path to file with newline separated regexes for files to exclude in scan.").Short('x').String()
|
gitlabScanExcludePaths = gitlabScan.Flag("exclude-paths", "Path to file with newline separated regexes for files to exclude in scan.").Short('x').String()
|
||||||
gitlabScanIncludeRepos = gitlabScan.Flag("include-repos", `Repositories to include in an org scan. This can also be a glob pattern. You can repeat this flag. Must use Gitlab repo full name. Example: "trufflesecurity/trufflehog", "trufflesecurity/t*"`).Strings()
|
gitlabScanIncludeRepos = gitlabScan.Flag("include-repos", `Repositories to include in an org scan. This can also be a glob pattern. You can repeat this flag. Must use Gitlab repo full name. Example: "trufflesecurity/trufflehog", "trufflesecurity/t*"`).Strings()
|
||||||
@@ -788,10 +789,15 @@ func runSingleScan(ctx context.Context, cmd string, cfg engine.Config) (metrics,
|
|||||||
return scanMetrics, fmt.Errorf("could not create filter: %v", err)
|
return scanMetrics, fmt.Errorf("could not create filter: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(*gitlabScanRepos) > 0 && len(*gitlabScanGroupIds) > 0 {
|
||||||
|
return scanMetrics, fmt.Errorf("invalid config: you cannot specify both repositories and groups at the same time")
|
||||||
|
}
|
||||||
|
|
||||||
cfg := sources.GitlabConfig{
|
cfg := sources.GitlabConfig{
|
||||||
Endpoint: *gitlabScanEndpoint,
|
Endpoint: *gitlabScanEndpoint,
|
||||||
Token: *gitlabScanToken,
|
Token: *gitlabScanToken,
|
||||||
Repos: *gitlabScanRepos,
|
Repos: *gitlabScanRepos,
|
||||||
|
GroupIds: *gitlabScanGroupIds,
|
||||||
IncludeRepos: *gitlabScanIncludeRepos,
|
IncludeRepos: *gitlabScanIncludeRepos,
|
||||||
ExcludeRepos: *gitlabScanExcludeRepos,
|
ExcludeRepos: *gitlabScanExcludeRepos,
|
||||||
Filter: filter,
|
Filter: filter,
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ func (e *Engine) ScanGitLab(ctx context.Context, c sources.GitlabConfig) (source
|
|||||||
connection.Repositories = c.Repos
|
connection.Repositories = c.Repos
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(c.GroupIds) > 0 {
|
||||||
|
connection.GroupIds = c.GroupIds
|
||||||
|
}
|
||||||
|
|
||||||
if len(c.IncludeRepos) > 0 {
|
if len(c.IncludeRepos) > 0 {
|
||||||
connection.IncludeRepos = c.IncludeRepos
|
connection.IncludeRepos = c.IncludeRepos
|
||||||
}
|
}
|
||||||
|
|||||||
+521
-511
File diff suppressed because it is too large
Load Diff
+139
-23
@@ -44,6 +44,7 @@ type Source struct {
|
|||||||
token string
|
token string
|
||||||
url string
|
url string
|
||||||
repos []string
|
repos []string
|
||||||
|
groupIds []string
|
||||||
ignoreRepos []string
|
ignoreRepos []string
|
||||||
includeRepos []string
|
includeRepos []string
|
||||||
|
|
||||||
@@ -158,6 +159,7 @@ func (s *Source) Init(ctx context.Context, name string, jobId sources.JobID, sou
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.repos = conn.GetRepositories()
|
s.repos = conn.GetRepositories()
|
||||||
|
s.groupIds = conn.GetGroupIds()
|
||||||
s.ignoreRepos = conn.GetIgnoreRepos()
|
s.ignoreRepos = conn.GetIgnoreRepos()
|
||||||
s.includeRepos = conn.GetIncludeRepos()
|
s.includeRepos = conn.GetIncludeRepos()
|
||||||
s.enumerateSharedProjects = !conn.ExcludeProjectsSharedIntoGroups
|
s.enumerateSharedProjects = !conn.ExcludeProjectsSharedIntoGroups
|
||||||
@@ -266,14 +268,9 @@ func (s *Source) Chunks(ctx context.Context, chunksChan chan *sources.Chunk, tar
|
|||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if feature.UseSimplifiedGitlabEnumeration.Load() {
|
|
||||||
if err := s.getAllProjectReposV2(ctx, apiClient, ignoreRepo, reporter); err != nil {
|
if err := s.listProjects(ctx, apiClient, ignoreRepo, reporter); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := s.getAllProjectRepos(ctx, apiClient, ignoreRepo, reporter); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
@@ -287,6 +284,21 @@ func (s *Source) Chunks(ctx context.Context, chunksChan chan *sources.Chunk, tar
|
|||||||
return s.scanRepos(ctx, chunksChan)
|
return s.scanRepos(ctx, chunksChan)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Source) listProjects(ctx context.Context,
|
||||||
|
apiClient *gitlab.Client,
|
||||||
|
ignoreProject func(string) bool,
|
||||||
|
visitor sources.UnitReporter) error {
|
||||||
|
if len(s.groupIds) > 0 {
|
||||||
|
return s.getAllProjectReposInGroups(ctx, apiClient, ignoreProject, visitor)
|
||||||
|
}
|
||||||
|
|
||||||
|
if feature.UseSimplifiedGitlabEnumeration.Load() {
|
||||||
|
return s.getAllProjectReposV2(ctx, apiClient, ignoreProject, visitor)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.getAllProjectRepos(ctx, apiClient, ignoreProject, visitor)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Source) scanTargets(ctx context.Context, client *gitlab.Client, targets []sources.ChunkingTarget, chunksChan chan *sources.Chunk) error {
|
func (s *Source) scanTargets(ctx context.Context, client *gitlab.Client, targets []sources.ChunkingTarget, chunksChan chan *sources.Chunk) error {
|
||||||
ctx = context.WithValues(ctx, "scan_type", "targeted")
|
ctx = context.WithValues(ctx, "scan_type", "targeted")
|
||||||
for _, tgt := range targets {
|
for _, tgt := range targets {
|
||||||
@@ -401,16 +413,9 @@ func (s *Source) Validate(ctx context.Context) []error {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
if feature.UseSimplifiedGitlabEnumeration.Load() {
|
if err := s.listProjects(ctx, apiClient, ignoreProject, visitor); err != nil {
|
||||||
if err := s.getAllProjectReposV2(ctx, apiClient, ignoreProject, visitor); err != nil {
|
errs = append(errs, err)
|
||||||
errs = append(errs, err)
|
return errs
|
||||||
return errs
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err := s.getAllProjectRepos(ctx, apiClient, ignoreProject, visitor); err != nil {
|
|
||||||
errs = append(errs, err)
|
|
||||||
return errs
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(repos) == 0 {
|
if len(repos) == 0 {
|
||||||
@@ -478,7 +483,6 @@ func (s *Source) getAllProjectRepos(
|
|||||||
reporter sources.UnitReporter,
|
reporter sources.UnitReporter,
|
||||||
) error {
|
) error {
|
||||||
gitlabReposEnumerated.WithLabelValues(s.name).Set(0)
|
gitlabReposEnumerated.WithLabelValues(s.name).Set(0)
|
||||||
|
|
||||||
// Projects without repo will get user projects, groups projects, and subgroup projects.
|
// Projects without repo will get user projects, groups projects, and subgroup projects.
|
||||||
user, _, err := apiClient.Users.CurrentUser()
|
user, _, err := apiClient.Users.CurrentUser()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -728,6 +732,118 @@ func (s *Source) getAllProjectReposV2(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getAllProjectReposInGroups fetches all projects in a GitLab group and its subgroups.
|
||||||
|
// It uses the group projects API with include_subgroups=true parameter.
|
||||||
|
func (s *Source) getAllProjectReposInGroups(
|
||||||
|
ctx context.Context,
|
||||||
|
apiClient *gitlab.Client,
|
||||||
|
ignoreRepo func(string) bool,
|
||||||
|
reporter sources.UnitReporter,
|
||||||
|
) error {
|
||||||
|
gitlabReposEnumerated.WithLabelValues(s.name).Set(0)
|
||||||
|
gitlabGroupsEnumerated.WithLabelValues(s.name).Set(float64(len(s.groupIds)))
|
||||||
|
|
||||||
|
processedProjects := make(map[string]bool)
|
||||||
|
|
||||||
|
var projectsWithNamespace []string
|
||||||
|
const (
|
||||||
|
orderBy = "id"
|
||||||
|
paginationLimit = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
listOpts := gitlab.ListOptions{PerPage: paginationLimit}
|
||||||
|
projectOpts := &gitlab.ListGroupProjectsOptions{
|
||||||
|
ListOptions: listOpts,
|
||||||
|
OrderBy: gitlab.Ptr(orderBy),
|
||||||
|
IncludeSubGroups: gitlab.Ptr(true),
|
||||||
|
WithShared: gitlab.Ptr(true),
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non gitlab.com instances, you might want to adjust access levels
|
||||||
|
if s.url != gitlabBaseURL {
|
||||||
|
projectOpts.MinAccessLevel = gitlab.Ptr(gitlab.GuestPermissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Logger().Info("starting group projects enumeration",
|
||||||
|
"group_ids", s.groupIds,
|
||||||
|
"include_subgroups", true,
|
||||||
|
"list_options", listOpts)
|
||||||
|
|
||||||
|
for _, groupID := range s.groupIds {
|
||||||
|
groupCtx := context.WithValues(ctx, "group_id", groupID)
|
||||||
|
|
||||||
|
projectOpts.Page = 0
|
||||||
|
groupCtx.Logger().V(2).Info("processing group", "group_id", groupID)
|
||||||
|
|
||||||
|
for {
|
||||||
|
projects, res, err := apiClient.Groups.ListGroupProjects(groupID, projectOpts)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("received error on listing projects for group %s: %w", groupID, err)
|
||||||
|
if err := reporter.UnitErr(ctx, err); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
groupCtx.Logger().V(3).Info("listed group projects", "count", len(projects))
|
||||||
|
|
||||||
|
for _, proj := range projects {
|
||||||
|
projCtx := context.WithValues(ctx,
|
||||||
|
"project_id", proj.ID,
|
||||||
|
"project_name", proj.NameWithNamespace,
|
||||||
|
"group_id", groupID)
|
||||||
|
|
||||||
|
if processedProjects[proj.HTTPURLToRepo] {
|
||||||
|
projCtx.Logger().V(3).Info("skipping project", "reason", "already processed")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
processedProjects[proj.HTTPURLToRepo] = true
|
||||||
|
|
||||||
|
// skip projects configured to be ignored.
|
||||||
|
if ignoreRepo(proj.PathWithNamespace) {
|
||||||
|
projCtx.Logger().V(3).Info("skipping project", "reason", "ignored in config")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// report an error if we could not convert the project into a URL.
|
||||||
|
if _, err := url.Parse(proj.HTTPURLToRepo); err != nil {
|
||||||
|
projCtx.Logger().V(3).Info("skipping project",
|
||||||
|
"reason", "URL parse failure",
|
||||||
|
"url", proj.HTTPURLToRepo,
|
||||||
|
"parse_error", err)
|
||||||
|
|
||||||
|
err = fmt.Errorf("could not parse url %q given by project: %w", proj.HTTPURLToRepo, err)
|
||||||
|
if err := reporter.UnitErr(ctx, err); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// report the unit.
|
||||||
|
projCtx.Logger().V(3).Info("accepting project")
|
||||||
|
|
||||||
|
unit := git.SourceUnit{Kind: git.UnitRepo, ID: proj.HTTPURLToRepo}
|
||||||
|
gitlabReposEnumerated.WithLabelValues(s.name).Inc()
|
||||||
|
projectsWithNamespace = append(projectsWithNamespace, proj.NameWithNamespace)
|
||||||
|
|
||||||
|
if err := reporter.UnitOk(ctx, unit); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle pagination.
|
||||||
|
projectOpts.Page = res.NextPage
|
||||||
|
if res.NextPage == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Logger().Info("Enumerated GitLab group projects", "count", len(projectsWithNamespace))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Source) scanRepos(ctx context.Context, chunksChan chan *sources.Chunk) error {
|
func (s *Source) scanRepos(ctx context.Context, chunksChan chan *sources.Chunk) error {
|
||||||
// If there is resume information available, limit this scan to only the repos that still need scanning.
|
// If there is resume information available, limit this scan to only the repos that still need scanning.
|
||||||
reposToScan, progressIndexOffset := sources.FilterReposToResume(s.repos, s.GetProgress().EncodedResumeInfo)
|
reposToScan, progressIndexOffset := sources.FilterReposToResume(s.repos, s.GetProgress().EncodedResumeInfo)
|
||||||
@@ -937,11 +1053,11 @@ func (s *Source) Enumerate(ctx context.Context, reporter sources.UnitReporter) e
|
|||||||
_ = reporter.UnitErr(ctx, fmt.Errorf("could not compile include/exclude repo glob: %w", err))
|
_ = reporter.UnitErr(ctx, fmt.Errorf("could not compile include/exclude repo glob: %w", err))
|
||||||
})
|
})
|
||||||
|
|
||||||
if feature.UseSimplifiedGitlabEnumeration.Load() {
|
if err := s.listProjects(ctx, apiClient, ignoreRepo, reporter); err != nil {
|
||||||
return s.getAllProjectReposV2(ctx, apiClient, ignoreRepo, reporter)
|
return err
|
||||||
} else {
|
|
||||||
return s.getAllProjectRepos(ctx, apiClient, ignoreRepo, reporter)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChunkUnit downloads and reports chunks for the given GitLab repository unit.
|
// ChunkUnit downloads and reports chunks for the given GitLab repository unit.
|
||||||
|
|||||||
@@ -136,6 +136,41 @@ func TestSource_Scan(t *testing.T) {
|
|||||||
},
|
},
|
||||||
wantReposScanned: 1,
|
wantReposScanned: 1,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "token auth, group projects enumeration with include_subgroups",
|
||||||
|
init: init{
|
||||||
|
name: "test source group enumeration",
|
||||||
|
connection: &sourcespb.GitLab{
|
||||||
|
Credential: &sourcespb.GitLab_Token{
|
||||||
|
Token: token,
|
||||||
|
},
|
||||||
|
GroupIds: []string{"15013490"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantChunk: &sources.Chunk{
|
||||||
|
SourceType: sourcespb.SourceType_SOURCE_TYPE_GITLAB,
|
||||||
|
SourceName: "test source group enumeration",
|
||||||
|
},
|
||||||
|
wantReposScanned: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "token auth, group projects enumeration with include_subgroups and exclude repositories",
|
||||||
|
init: init{
|
||||||
|
name: "test source group enumeration with exclude repos",
|
||||||
|
connection: &sourcespb.GitLab{
|
||||||
|
Credential: &sourcespb.GitLab_Token{
|
||||||
|
Token: token,
|
||||||
|
},
|
||||||
|
GroupIds: []string{"15013490"},
|
||||||
|
IgnoreRepos: []string{"tes1188/test-user-count"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantChunk: &sources.Chunk{
|
||||||
|
SourceType: sourcespb.SourceType_SOURCE_TYPE_GITLAB,
|
||||||
|
SourceName: "test source group enumeration with exclude repos",
|
||||||
|
},
|
||||||
|
wantReposScanned: 4,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -342,6 +342,8 @@ type GitlabConfig struct {
|
|||||||
Token string
|
Token string
|
||||||
// Repos is the list of repositories to scan.
|
// Repos is the list of repositories to scan.
|
||||||
Repos []string
|
Repos []string
|
||||||
|
// GroupIds is the list of groups to scan.
|
||||||
|
GroupIds []string
|
||||||
// Filter is the filter to use to scan the source.
|
// Filter is the filter to use to scan the source.
|
||||||
Filter *common.Filter
|
Filter *common.Filter
|
||||||
// SkipBinaries allows skipping binary files from the scan.
|
// SkipBinaries allows skipping binary files from the scan.
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ message GitLab {
|
|||||||
repeated string include_repos = 9;
|
repeated string include_repos = 9;
|
||||||
bool exclude_projects_shared_into_groups = 10;
|
bool exclude_projects_shared_into_groups = 10;
|
||||||
bool remove_auth_in_url = 11;
|
bool remove_auth_in_url = 11;
|
||||||
|
repeated string group_ids = 12;
|
||||||
}
|
}
|
||||||
|
|
||||||
message GitHub {
|
message GitHub {
|
||||||
|
|||||||
Reference in New Issue
Block a user