package gitlab import ( "fmt" "net/url" "os" "path/filepath" "slices" "strings" "sync" "time" "github.com/trufflesecurity/trufflehog/v3/pkg/common" "github.com/trufflesecurity/trufflehog/v3/pkg/context" "github.com/trufflesecurity/trufflehog/v3/pkg/feature" "github.com/trufflesecurity/trufflehog/v3/pkg/giturl" "github.com/trufflesecurity/trufflehog/v3/pkg/log" "github.com/trufflesecurity/trufflehog/v3/pkg/pb/source_metadatapb" "github.com/trufflesecurity/trufflehog/v3/pkg/pb/sourcespb" "github.com/trufflesecurity/trufflehog/v3/pkg/sanitizer" "github.com/trufflesecurity/trufflehog/v3/pkg/sources" "github.com/trufflesecurity/trufflehog/v3/pkg/sources/git" gogit "github.com/go-git/go-git/v5" "github.com/gobwas/glob" gitlab "gitlab.com/gitlab-org/api/client-go" "golang.org/x/oauth2" "golang.org/x/sync/errgroup" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/anypb" ) const SourceType = sourcespb.SourceType_SOURCE_TYPE_GITLAB // This is the URL for gitlab hosted at gitlab.com const gitlabBaseURL = "https://gitlab.com/" type Source struct { name string sourceID sources.SourceID jobID sources.JobID verify bool authMethod string user string password string token string url string repos []string groupIds []string // These lists are checked both during enumeration and when ChunkUnit is called. This means that if they're modified // between enumeration and individual unit scans, units will be scanned only if they pass the filter during // enumeration and also if they pass the filter during unit scanning. This means that units can be "removed" from // the enumerated list post-enumeration by modifying the filters, but they can never be added post-enumeration. ignoreRepos []string includeRepos []string // This is an experimental flag used to investigate some suspicious behavior we've seen with very large GitLab // organizations that have lots of group sharing. enumerateSharedProjects bool useCustomContentWriter bool git *git.Git scanOptions *git.ScanOptions resumeInfoSlice []string resumeInfoMutex sync.Mutex sources.Progress jobPool *errgroup.Group sources.CommonSourceUnitUnmarshaller useAuthInUrl bool clonePath string noCleanup bool printLegacyJSON bool projectsPerPage int64 // cache of repo URL to project info, used when generating metadata for chunks repoToProjCache repoToProjectCache } // WithCustomContentWriter sets the useCustomContentWriter flag on the source. func (s *Source) WithCustomContentWriter() { s.useCustomContentWriter = true } // Ensure the Source satisfies the interfaces at compile time. var _ sources.Source = (*Source)(nil) var _ sources.SourceUnitUnmarshaller = (*Source)(nil) var _ sources.Validator = (*Source)(nil) var _ sources.SourceUnitEnumChunker = (*Source)(nil) // Type returns the type of source. // It is used for matching source types in configuration and job input. func (s *Source) Type() sourcespb.SourceType { return SourceType } func (s *Source) SourceID() sources.SourceID { return s.sourceID } func (s *Source) JobID() sources.JobID { return s.jobID } // globRepoFilter is a wrapper around cache.Cache that filters out repos // based on include and exclude globs. type globRepoFilter struct { include, exclude []glob.Glob } func newGlobRepoFilter(include, exclude []string, onCompileErr func(err error, pattern string)) *globRepoFilter { includeGlobs := make([]glob.Glob, 0, len(include)) excludeGlobs := make([]glob.Glob, 0, len(exclude)) for _, ig := range include { g, err := glob.Compile(ig) if err != nil { onCompileErr(err, ig) continue } includeGlobs = append(includeGlobs, g) } for _, eg := range exclude { g, err := glob.Compile(eg) if err != nil { onCompileErr(err, eg) continue } excludeGlobs = append(excludeGlobs, g) } return &globRepoFilter{include: includeGlobs, exclude: excludeGlobs} } func (c *globRepoFilter) ignoreRepo(s string) bool { for _, g := range c.exclude { if g.Match(s) { return true } } return false } func (c *globRepoFilter) includeRepo(s string) bool { if len(c.include) == 0 { return true } for _, g := range c.include { if g.Match(s) { return true } } return false } // Init returns an initialized Gitlab source. func (s *Source) Init(ctx context.Context, name string, jobId sources.JobID, sourceId sources.SourceID, verify bool, connection *anypb.Any, concurrency int) error { s.name = name s.sourceID = sourceId s.jobID = jobId s.verify = verify s.jobPool = &errgroup.Group{} s.jobPool.SetLimit(concurrency) if err := git.CmdCheck(); err != nil { return err } var conn sourcespb.GitLab err := anypb.UnmarshalTo(connection, &conn, proto.UnmarshalOptions{}) if err != nil { return fmt.Errorf("error unmarshalling connection: %w", err) } s.repos = conn.GetRepositories() s.groupIds = conn.GetGroupIds() s.ignoreRepos = conn.GetIgnoreRepos() s.includeRepos = conn.GetIncludeRepos() s.enumerateSharedProjects = !conn.ExcludeProjectsSharedIntoGroups s.clonePath = conn.GetClonePath() s.noCleanup = conn.GetNoCleanup() s.printLegacyJSON = conn.GetPrintLegacyJson() s.projectsPerPage = feature.GitlabProjectsPerPage.Load() if s.projectsPerPage > 100 { return fmt.Errorf("invalid config: maximum allowed projects per page for gitlab is 100") } // configuration uses the inverse logic of the `useAuthInUrl` flag. s.useAuthInUrl = !conn.RemoveAuthInUrl ctx.Logger().V(3).Info("setting ignore repos patterns", "patterns", s.ignoreRepos) ctx.Logger().V(3).Info("setting include repos patterns", "patterns", s.includeRepos) switch cred := conn.GetCredential().(type) { case *sourcespb.GitLab_Token: s.authMethod = "TOKEN" s.token = cred.Token log.RedactGlobally(s.token) case *sourcespb.GitLab_Oauth: s.authMethod = "OAUTH" s.token = cred.Oauth.RefreshToken log.RedactGlobally(s.token) // TODO: is it okay if there is no client id and secret? Might be an issue when marshalling config to proto case *sourcespb.GitLab_BasicAuth: s.authMethod = "BASIC_AUTH" s.user = cred.BasicAuth.Username s.password = cred.BasicAuth.Password // We may need the password as a token if the user is using an access_token with basic auth. s.token = cred.BasicAuth.Password log.RedactGlobally(cred.BasicAuth.Password) default: return fmt.Errorf("invalid configuration given for source %q (%s)", name, s.Type().String()) } s.url, err = normalizeGitlabEndpoint(conn.Endpoint) if err != nil { return err } cfg := &git.Config{ SourceName: s.name, JobID: s.jobID, SourceID: s.sourceID, SourceType: s.Type(), Verify: s.verify, SkipBinaries: conn.GetSkipBinaries(), SkipArchives: conn.GetSkipArchives(), Concurrency: concurrency, SourceMetadataFunc: func(info git.SourceMetadataInfo) *source_metadatapb.MetaData { gitlabMetadata := &source_metadatapb.Gitlab{ Commit: sanitizer.UTF8(info.Commit), File: sanitizer.UTF8(info.File), Email: sanitizer.UTF8(info.Email), Repository: sanitizer.UTF8(info.Repository), RepositoryLocalPath: sanitizer.UTF8(info.RepositoryLocalPath), Link: giturl.GenerateLink(info.Repository, info.Commit, info.File, info.Line), Timestamp: sanitizer.UTF8(info.Timestamp), Line: info.Line, } proj, ok := s.repoToProjCache.get(info.Repository) if ok { gitlabMetadata.ProjectId = int64(proj.id) gitlabMetadata.ProjectName = proj.name gitlabMetadata.ProjectOwner = proj.owner } return &source_metadatapb.MetaData{ Data: &source_metadatapb.MetaData_Gitlab{ Gitlab: gitlabMetadata, }, } }, UseCustomContentWriter: s.useCustomContentWriter, AuthInUrl: s.useAuthInUrl, } s.git = git.NewGit(cfg) s.repoToProjCache = repoToProjectCache{ cache: make(map[string]*project), } return nil } // Chunks emits chunks of bytes over a channel. func (s *Source) Chunks(ctx context.Context, chunksChan chan *sources.Chunk, targets ...sources.ChunkingTarget) error { // Start client. apiClient, err := s.newClient() if err != nil { return err } // If targets are provided, we're only scanning the data in those targets. // Otherwise, we're scanning all data. // This allows us to only scan the commit where a vulnerability was found. if len(targets) > 0 { return s.scanTargets(ctx, apiClient, targets, chunksChan) } gitlabReposScanned.WithLabelValues(s.name).Set(0) // Get repo within target. repos, errs := normalizeRepos(s.repos) for _, repoErr := range errs { ctx.Logger().Info("error normalizing repo", "error", repoErr) } // End early if we had errors getting specified repos but none were validated. if len(errs) > 0 && len(repos) == 0 { return fmt.Errorf("all specified repos had validation issues, ending scan") } // Get all repos if not specified. if len(repos) == 0 { ctx.Logger().Info("no repositories configured, enumerating") ignoreRepo := buildIgnorer(s.includeRepos, s.ignoreRepos, func(err error, pattern string) { ctx.Logger().Error(err, "could not compile include/exclude repo glob", "glob", pattern) }) reporter := sources.VisitorReporter{ VisitUnit: func(ctx context.Context, unit sources.SourceUnit) error { id, _ := unit.SourceUnitID() repos = append(repos, id) return ctx.Err() }, } if err := s.listProjects(ctx, apiClient, ignoreRepo, reporter); err != nil { return err } } else { gitlabReposEnumerated.WithLabelValues(s.name).Set(float64(len(repos))) // ensure project details for specified repos are cached // this is required to populate metadata during chunking for _, repo := range repos { s.ensureProjectInCache(ctx, repo) } } s.repos = repos // We must sort the repos so we can resume later if necessary. slices.Sort(s.repos) 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 { ctx = context.WithValues(ctx, "scan_type", "targeted") for _, tgt := range targets { if err := s.scanTarget(ctx, client, tgt, chunksChan); err != nil { ctx.Logger().Error(err, "error scanning target") } } return nil } func (s *Source) scanTarget(ctx context.Context, client *gitlab.Client, target sources.ChunkingTarget, chunksChan chan *sources.Chunk) error { metaType, ok := target.QueryCriteria.GetData().(*source_metadatapb.MetaData_Gitlab) if !ok { return fmt.Errorf("unable to cast metadata type for targeted scan") } meta := metaType.Gitlab projID, sha := int(meta.GetProjectId()), meta.GetCommit() if projID == 0 || sha == "" { return fmt.Errorf("project ID and commit SHA must be provided for targeted scan") } aCtx := context.WithValues(ctx, "project_id", projID, "commit", sha) diffs, _, err := client.Commits.GetCommitDiff(projID, sha, new(gitlab.GetCommitDiffOptions), gitlab.WithContext(ctx)) if err != nil { return fmt.Errorf("error fetching diffs for commit %s: %w", sha, err) } for _, diff := range diffs { if diff.Diff == "" { aCtx.Logger().V(4).Info("skipping empty diff", "file", diff.NewPath) continue } chunk := &sources.Chunk{ SourceType: s.Type(), SourceName: s.name, SourceID: s.SourceID(), JobID: s.JobID(), SecretID: target.SecretID, Data: []byte(diff.Diff), SourceMetadata: &source_metadatapb.MetaData{ Data: &source_metadatapb.MetaData_Gitlab{Gitlab: meta}, }, SourceVerify: s.verify, } if err := common.CancellableWrite(ctx, chunksChan, chunk); err != nil { return err } } return nil } func (s *Source) Validate(ctx context.Context) []error { // The client is only used to query Gitlab for a repo list - it's not used to actually clone anything. Thus, we // don't use it if there is a list of explicitly configured repos. However, constructing it validates that the // configured authentication method is sensible, so we'll do it here. apiClient, err := s.newClient() if err != nil { return []error{err} } _, _, err = apiClient.Users.CurrentUser() if err != nil { return []error{fmt.Errorf("gitlab authentication failed using method %v: %w", s.authMethod, err)} } explicitlyConfiguredRepos, errs := normalizeRepos(s.repos) if len(explicitlyConfiguredRepos) > 0 { user := s.user if user == "" { user = "placeholder" } // We only check reachability for explicitly configured repositories. The purpose of source validation is to // help users validate their configuration files, and Gitlab telling us about repositories that it won't let us // access isn't a local configuration issue. for _, r := range explicitlyConfiguredRepos { if err := git.PingRepoUsingToken(ctx, s.token, r, user); err != nil { err = fmt.Errorf("could not reach git repository %q: %w", r, err) errs = append(errs, err) } } if len(s.ignoreRepos) > 0 { errs = append( errs, fmt.Errorf("both repositories and ignore patterns were explicitly configured; ignore patterns will not be used"), ) } } if len(explicitlyConfiguredRepos) > 0 || len(errs) > 0 { return errs } ignoreProject := buildIgnorer(s.includeRepos, s.ignoreRepos, func(err error, pattern string) { errs = append(errs, fmt.Errorf("could not compile include/exclude repo pattern %q: %w", pattern, err)) }) // Query GitLab for the list of configured repos. var repos []string visitor := sources.VisitorReporter{ VisitUnit: func(ctx context.Context, unit sources.SourceUnit) error { id, _ := unit.SourceUnitID() repos = append(repos, id) return nil }, } if err := s.listProjects(ctx, apiClient, ignoreProject, visitor); err != nil { errs = append(errs, err) return errs } if len(repos) == 0 { errs = append(errs, fmt.Errorf("ignore patterns excluded all projects")) } return errs } func (s *Source) newClient() (*gitlab.Client, error) { // Initialize a new api instance. switch s.authMethod { case "OAUTH": ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: s.token}) apiClient, err := gitlab.NewAuthSourceClient( gitlab.OAuthTokenSource{TokenSource: ts}, gitlab.WithBaseURL(s.url), gitlab.WithCustomRetryWaitMinMax(time.Second, 5*time.Second), gitlab.WithCustomRetryMax(3), ) if err != nil { return nil, fmt.Errorf("could not create Gitlab OAUTH client for %q: %w", s.url, err) } return apiClient, nil case "BASIC_AUTH": apiClient, err := gitlab.NewAuthSourceClient( &gitlab.PasswordCredentialsAuthSource{Username: s.user, Password: s.password}, gitlab.WithBaseURL(s.url), gitlab.WithCustomRetryWaitMinMax(time.Second, 5*time.Second), gitlab.WithCustomRetryMax(3), ) if err != nil { return nil, fmt.Errorf("could not create Gitlab BASICAUTH client for %q: %w", s.url, err) } // If the user is using an access_token rather than a username/password, then basic auth // will not work. In this case, we test to see if basic auth would work, and if it does not, // we proceed with an OAuth client using the access_token (s.password) as the token. // At this point, s.token is already set to s.password if s.basicAuthSuccessful(apiClient) { return apiClient, nil } fallthrough case "TOKEN": ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: s.token}) apiClient, err := gitlab.NewAuthSourceClient( gitlab.OAuthTokenSource{TokenSource: ts}, gitlab.WithBaseURL(s.url), gitlab.WithCustomRetryWaitMinMax(time.Second, 5*time.Second), gitlab.WithCustomRetryMax(3), ) if err != nil { return nil, fmt.Errorf("could not create Gitlab TOKEN client for %q: %w", s.url, err) } return apiClient, nil default: return nil, fmt.Errorf("invalid auth method %q", s.authMethod) } } func (s *Source) basicAuthSuccessful(apiClient *gitlab.Client) bool { user, resp, err := apiClient.Users.CurrentUser() if err != nil { return false } if resp.StatusCode != 200 { return false } if user != nil { return true } return false } // getAllProjectRepos enumerates all GitLab projects using the provided API client. // The reporter is used to report the valid repository found for projects that are not ignored. func (s *Source) getAllProjectRepos( ctx context.Context, apiClient *gitlab.Client, ignoreRepo func(string) bool, reporter sources.UnitReporter, ) error { gitlabReposEnumerated.WithLabelValues(s.name).Set(0) // Projects without repo will get user projects, groups projects, and subgroup projects. user, _, err := apiClient.Users.CurrentUser() if err != nil { return fmt.Errorf("unable to authenticate using %s: %w", s.authMethod, err) } uniqueProjects := make(map[int64]*gitlab.Project) // Record the projectsWithNamespace for logging. var projectsWithNamespace []string // Used to filter out duplicate projects. processProjects := func(ctx context.Context, projList []*gitlab.Project) error { for _, proj := range projList { ctx := context.WithValues(ctx, "project_id", proj.ID, "project_name", proj.NameWithNamespace) // Skip projects we've already seen. if _, exists := uniqueProjects[proj.ID]; exists { ctx.Logger().V(3).Info("skipping project", "reason", "ID already seen") continue } // Skip projects configured to be ignored. if ignoreRepo(proj.PathWithNamespace) { ctx.Logger().V(3).Info("skipping project", "reason", "ignored in config") continue } // Record that we've seen this project. uniqueProjects[proj.ID] = proj // Report an error if we could not convert the project into a URL. if _, err := url.Parse(proj.HTTPURLToRepo); err != nil { ctx.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. ctx.Logger().V(3).Info("accepting project") s.cacheGitlabProject(proj) 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 } } return nil } const ( orderBy = "id" ) // Trufflehog default per page 100 unless set to other value through feature flag. If 0 provided in feature flag gitlab default it to 20 listOpts := gitlab.ListOptions{PerPage: s.projectsPerPage} projectQueryOptions := &gitlab.ListProjectsOptions{OrderBy: gitlab.Ptr(orderBy), ListOptions: listOpts} for { userProjects, res, err := apiClient.Projects.ListUserProjects(user.ID, projectQueryOptions) if err != nil { err = fmt.Errorf("received error on listing user projects: %w", err) if err := reporter.UnitErr(ctx, err); err != nil { return err } break } ctx.Logger().V(3).Info("listed user projects", "count", len(userProjects)) if err := processProjects(ctx, userProjects); err != nil { return err } projectQueryOptions.Page = res.NextPage if res.NextPage == 0 { break } } listGroupsOptions := gitlab.ListGroupsOptions{ ListOptions: listOpts, AllAvailable: gitlab.Ptr(false), // This actually grabs public groups on public GitLab if set to true. TopLevelOnly: gitlab.Ptr(false), Owned: gitlab.Ptr(false), } if s.url != gitlabBaseURL { listGroupsOptions.AllAvailable = gitlab.Ptr(true) } ctx.Logger().Info("beginning group enumeration", "list_options", listOpts, "all_available", *listGroupsOptions.AllAvailable) gitlabGroupsEnumerated.WithLabelValues(s.name).Set(0) var groups []*gitlab.Group for { groupList, res, err := apiClient.Groups.ListGroups(&listGroupsOptions) if err != nil { err = fmt.Errorf("received error on listing groups, you probably don't have permissions to do that: %w", err) if err := reporter.UnitErr(ctx, err); err != nil { return err } break } ctx.Logger().V(3).Info("listed groups", "count", len(groupList)) groups = append(groups, groupList...) gitlabGroupsEnumerated.WithLabelValues(s.name).Add(float64(len(groupList))) listGroupsOptions.Page = res.NextPage if res.NextPage == 0 { break } } ctx.Logger().Info("got groups", "group_count", len(groups)) for _, group := range groups { ctx := context.WithValue(ctx, "group_id", group.ID) listGroupProjectOptions := &gitlab.ListGroupProjectsOptions{ ListOptions: listOpts, OrderBy: gitlab.Ptr(orderBy), IncludeSubGroups: gitlab.Ptr(true), WithShared: gitlab.Ptr(s.enumerateSharedProjects), } for { grpPrjs, res, err := apiClient.Groups.ListGroupProjects(group.ID, listGroupProjectOptions) if err != nil { err = fmt.Errorf( "received error on listing group projects for %q, you probably don't have permissions to do that: %w", group.FullPath, err, ) if err := reporter.UnitErr(ctx, err); err != nil { return err } break } ctx.Logger().V(3).Info("listed group projects", "count", len(grpPrjs)) if err := processProjects(ctx, grpPrjs); err != nil { return err } listGroupProjectOptions.Page = res.NextPage if res.NextPage == 0 { break } } } ctx.Logger().Info("Enumerated GitLab projects", "count", len(projectsWithNamespace)) return nil } // getAllProjectReposV2 uses simplified logic to enumerate through all projects using list-all-projects API. // The reporter is used to report the valid repository found for projects that are not ignored. func (s *Source) getAllProjectReposV2( ctx context.Context, apiClient *gitlab.Client, ignoreRepo func(string) bool, reporter sources.UnitReporter, ) error { gitlabReposEnumerated.WithLabelValues(s.name).Set(0) // example: https://gitlab.com/gitlab-org/api/client-go/-/blob/main/examples/pagination.go#L55 listOpts := gitlab.ListOptions{ OrderBy: "id", Pagination: "keyset", // https://docs.gitlab.com/api/rest/#keyset-based-pagination // Trufflehog default per page 100 unless set to other value through feature flag. If 0 provided in feature flag gitlab default it to 20 PerPage: s.projectsPerPage, Sort: "asc", } projectQueryOptions := &gitlab.ListProjectsOptions{ ListOptions: listOpts, // Return only limited fields for each project Simple: gitlab.Ptr(true), } // for gitlab.com instance, include only projects where the user is a member. if s.url == gitlabBaseURL { projectQueryOptions.Membership = gitlab.Ptr(true) } ctx.Logger().Info("starting projects enumeration", "list_options", listOpts) // totalCount tracks the total number of projects processed by this enumeration. // It includes all projects fetched from the API, even those later skipped by ignore rules. totalCount := 0 requestOptions := []gitlab.RequestOptionFunc{gitlab.WithContext(ctx)} // Pagination loop: Continue fetching pages until the API indicates there are no more. for { // Fetch a page of projects from the GitLab API using the current query options. projects, resp, err := apiClient.Projects.ListProjects(projectQueryOptions, requestOptions...) if err != nil { err = fmt.Errorf("received error on listing projects, you might not have permissions to do that: %w", err) if err := reporter.UnitErr(ctx, err); err != nil { return err } // break on error as with error we will not have any response and no next page break } // Log the batch size for debugging and monitoring. ctx.Logger().V(3).Info("listed projects batch", "batch_size", len(projects), "running_total", totalCount) // Process each project in the current page. for _, project := range projects { projCtx := context.WithValues(ctx, "project_id", project.ID, "project_name", project.NameWithNamespace) totalCount++ // skip projects configured to be ignored. if ignoreRepo(project.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(project.HTTPURLToRepo); err != nil { projCtx.Logger().V(3).Info("skipping project", "reason", "URL parse failure", "url", project.HTTPURLToRepo, "parse_error", err) err = fmt.Errorf("could not parse url %q given by project: %w", project.HTTPURLToRepo, err) if err := reporter.UnitErr(ctx, err); err != nil { return err } continue } // report the unit. projCtx.Logger().V(3).Info("accepting project") s.cacheGitlabProject(project) unit := git.SourceUnit{Kind: git.UnitRepo, ID: project.HTTPURLToRepo} gitlabReposEnumerated.WithLabelValues(s.name).Inc() if err := reporter.UnitOk(ctx, unit); err != nil { return err } } // if next page is empty, break the loop if resp == nil || resp.NextLink == "" { // No more pages to fetch. This is the normal loop exit condition. // It also acts as a safety stop if the current request failed. break } // Only update the token for the next page if we have a valid, non-empty link. requestOptions = []gitlab.RequestOptionFunc{ gitlab.WithContext(ctx), gitlab.WithKeysetPaginationParameters(resp.NextLink), } } ctx.Logger().Info("Enumerated GitLab projects", "count", totalCount) 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" ) listOpts := gitlab.ListOptions{PerPage: s.projectsPerPage} 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") s.cacheGitlabProject(proj) 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 { // 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) ctx.Logger().V(2).Info("filtered repos to resume", "before", len(s.repos), "after", len(reposToScan)) s.repos = reposToScan scanErrs := sources.NewScanErrors() for i, repo := range s.repos { repoURL := repo s.jobPool.Go(func() error { logger := ctx.Logger().WithValues("repo", repoURL) if common.IsDone(ctx) { // We are returning nil instead of the scanErrors slice here because // we don't want to mark this scan as errored if we cancelled it. logger.V(2).Info("Skipping repo because context was cancelled") return nil } if len(repoURL) == 0 { logger.V(2).Info("Skipping empty repo") return nil } s.setProgressCompleteWithRepo(i, progressIndexOffset, repoURL) // Ensure the repo is removed from the resume info after being scanned. defer func(s *Source) { s.resumeInfoMutex.Lock() defer s.resumeInfoMutex.Unlock() s.resumeInfoSlice = sources.RemoveRepoFromResumeInfo(s.resumeInfoSlice, repoURL) }(s) var path string var repo *gogit.Repository var err error if s.authMethod == "UNAUTHENTICATED" { path, repo, err = git.CloneRepoUsingUnauthenticated(ctx, repoURL, s.clonePath) } else { // If a username is not provided we need to use a default one in order to clone a private repo. // Not setting "placeholder" as s.user on purpose in case any downstream services rely on a "" value for s.user. user := s.user if user == "" { user = "placeholder" } path, repo, err = git.CloneRepoUsingToken(ctx, s.token, repoURL, s.clonePath, user, s.useAuthInUrl) } if err != nil { scanErrs.Add(err) return nil } // remove the path only if it was created as a temporary path, or if it is a clone path and --no-cleanup is not set. // if legacy JSON is enabled, don't remove the directory because we need it for outputting legacy JSON. if !s.printLegacyJSON { if strings.HasPrefix(path, filepath.Join(os.TempDir(), "trufflehog")) || (!s.noCleanup && s.clonePath != "") { defer os.RemoveAll(path) } } logger.V(2).Info("starting scan", "num", i+1, "total", len(s.repos)) if err = s.git.ScanRepo(ctx, repo, path, s.scanOptions, sources.ChanReporter{Ch: chunksChan}); err != nil { scanErrs.Add(err) return nil } gitlabReposScanned.WithLabelValues(s.name).Inc() logger.V(2).Info("completed scan", "num", i+1, "total", len(s.repos)) return nil }) } _ = s.jobPool.Wait() if scanErrs.Count() > 0 { ctx.Logger().V(2).Info("encountered errors while scanning", "count", scanErrs.Count(), "errors", scanErrs) } s.SetProgressComplete(len(s.repos), len(s.repos), "Completed Gitlab scan", "") return nil } // setProgressCompleteWithRepo calls the s.SetProgressComplete after safely setting up the encoded resume info string. func (s *Source) setProgressCompleteWithRepo(index int, offset int, repoURL string) { s.resumeInfoMutex.Lock() defer s.resumeInfoMutex.Unlock() // Add the repoURL to the resume info slice. s.resumeInfoSlice = append(s.resumeInfoSlice, repoURL) slices.Sort(s.resumeInfoSlice) // Make the resume info string from the slice. encodedResumeInfo := sources.EncodeResumeInfo(s.resumeInfoSlice) // Add the offset to both the index and the repos to give the proper place and proper repo count. s.SetProgressComplete(index+offset, len(s.repos)+offset, fmt.Sprintf("Repo: %s", repoURL), encodedResumeInfo) } func (s *Source) WithScanOptions(scanOptions *git.ScanOptions) { s.scanOptions = scanOptions } func buildIgnorer(include, exclude []string, onCompile func(err error, pattern string)) func(repo string) bool { // compile and load globRepoFilter globRepoFilter := newGlobRepoFilter(include, exclude, onCompile) f := func(repo string) bool { if !globRepoFilter.includeRepo(repo) || globRepoFilter.ignoreRepo(repo) { return true } return false } return f } func normalizeRepos(repos []string) ([]string, []error) { // Optimistically allocate space for all valid repositories. validRepos := make([]string, 0, len(repos)) var errs []error for _, prj := range repos { repo, err := giturl.NormalizeGitlabRepo(prj) if err != nil { errs = append(errs, fmt.Errorf("unable to normalize gitlab repo url %q: %w", prj, err)) continue } validRepos = append(validRepos, repo) } return validRepos, errs } // normalizeGitlabEndpoint ensures that if an endpoint is going to gitlab.com, we use https://gitlab.com/ as the endpoint. // If we see the protocol is http, we error, because this shouldn't be used. // Otherwise, it ensures we are using https as our protocol, if none was provided. func normalizeGitlabEndpoint(gitlabEndpoint string) (string, error) { if gitlabEndpoint == "" { return gitlabBaseURL, nil } gitlabURL, err := url.Parse(gitlabEndpoint) if err != nil { return "", err } // We probably didn't receive a URL with a scheme, which messed up the parsing. if gitlabURL.Host == "" { gitlabURL, err = url.Parse("https://" + gitlabEndpoint) if err != nil { return "", err } } // If the host is gitlab.com, this is the cloud version, which has only one valid endpoint. if gitlabURL.Host == "gitlab.com" { return gitlabBaseURL, nil } // Beyond here, on-prem gitlab is being used, so we have to mostly leave things as-is. if gitlabURL.Scheme != "https" { return "", fmt.Errorf("https was not used as URL scheme, but is required. Please use https") } // The gitlab library wants trailing slashes. if !strings.HasSuffix(gitlabURL.Path, "/") { gitlabURL.Path = gitlabURL.Path + "/" } return gitlabURL.String(), nil } // Enumerate reports all GitLab repositories to be scanned to the reporter. If // none are configured, it will find all repositories within all projects that // the configured user has access to, while respecting the configured ignore // rules. func (s *Source) Enumerate(ctx context.Context, reporter sources.UnitReporter) error { // Start client. apiClient, err := s.newClient() if err != nil { return err } // Get repos within target. repos, errs := normalizeRepos(s.repos) for _, repoErr := range errs { ctx.Logger().Info("error normalizing repo", "error", repoErr) if err := reporter.UnitErr(ctx, repoErr); err != nil { return err } } // End early if we had errors getting specified repos but none were validated. if len(errs) > 0 && len(repos) == 0 { return fmt.Errorf("all configured repos had validation issues") } // Report all repos if specified. if len(repos) > 0 { gitlabReposEnumerated.WithLabelValues(s.name).Set(0) for _, repo := range repos { unit := git.SourceUnit{Kind: git.UnitRepo, ID: repo} if err := reporter.UnitOk(ctx, unit); err != nil { return err } gitlabReposEnumerated.WithLabelValues(s.name).Inc() } return nil } // Otherwise, enumerate all repos. ignoreRepo := buildIgnorer(s.includeRepos, s.ignoreRepos, func(err error, pattern string) { ctx.Logger().Error(err, "could not compile include/exclude repo glob", "glob", pattern) // TODO: Handle error returned from UnitErr. _ = reporter.UnitErr(ctx, fmt.Errorf("could not compile include/exclude repo glob: %w", err)) }) if err := s.listProjects(ctx, apiClient, ignoreRepo, reporter); err != nil { return err } return nil } // ChunkUnit downloads and reports chunks for the given GitLab repository unit. func (s *Source) ChunkUnit(ctx context.Context, unit sources.SourceUnit, reporter sources.ChunkReporter) error { repoURL, _ := unit.SourceUnitID() ignoreRepo := buildIgnorer(s.includeRepos, s.ignoreRepos, func(err error, pattern string) { ctx.Logger().Error(err, "could not compile include/exclude repo glob", "glob", pattern) }) if ignoreRepo(repoURL) { ctx.Logger().V(3).Info("skipping project", "reason", "ignored in config") return nil } var path string var repo *gogit.Repository var err error if s.authMethod == "UNAUTHENTICATED" { path, repo, err = git.CloneRepoUsingUnauthenticated(ctx, repoURL, s.clonePath) } else { // If a username is not provided we need to use a default one in order to clone a private repo. // Not setting "placeholder" as s.user on purpose in case any downstream services rely on a "" value for s.user. user := s.user if user == "" { user = "placeholder" } path, repo, err = git.CloneRepoUsingToken(ctx, s.token, repoURL, s.clonePath, user, s.useAuthInUrl) } if err != nil { return err } // remove the path only if it was created as a temporary path, or if it is a clone path and --no-cleanup is not set. // if legacy JSON is enabled, don't remove the directory because we need it for outputting legacy JSON. if !s.printLegacyJSON { if strings.HasPrefix(path, filepath.Join(os.TempDir(), "trufflehog")) || (!s.noCleanup && s.clonePath != "") { defer os.RemoveAll(path) } } // ensure project details are cached // this is required to populate metadata during chunking s.ensureProjectInCache(ctx, repoURL) return s.git.ScanRepo(ctx, repo, path, s.scanOptions, reporter) } // ensureProjectInCache checks if the project for the given repo URL is in the cache, // and if not, queries the GitLab API to fetch the project and adds it to the cache. func (s *Source) ensureProjectInCache(ctx context.Context, repoUrl string) { // check if project is already in cache if _, ok := s.repoToProjCache.get(repoUrl); ok { return } // query project proj, err := s.getGitlabProject(ctx, repoUrl) if err != nil { ctx.Logger().Error(err, "could not fetch project for repo", "repo", repoUrl) return } // add to cache s.cacheGitlabProject(proj) } func (s *Source) getGitlabProject(ctx context.Context, repoUrl string) (*gitlab.Project, error) { apiClient, err := s.newClient() if err != nil { return nil, fmt.Errorf("could not create api client: %w", err) } // extract project path from repo URL // https://gitlab.com/testermctestface/testy.git => testermctestface/testy url, err := url.Parse(repoUrl) if err != nil { return nil, fmt.Errorf("could not parse repo URL: %w", err) } repoPath := strings.TrimPrefix(strings.TrimSuffix(url.Path, ".git"), "/") proj, _, err := apiClient.Projects.GetProject(repoPath, nil, gitlab.WithContext(ctx)) if err != nil { return nil, fmt.Errorf("could not query project metadata: %w", err) } return proj, nil } func (s *Source) cacheGitlabProject(gitlabProj *gitlab.Project) { proj := &project{ id: gitlabProj.ID, name: gitlabProj.NameWithNamespace, } if gitlabProj.Owner != nil { proj.owner = gitlabProj.Owner.Email if proj.owner == "" { proj.owner = gitlabProj.Owner.Username } } s.repoToProjCache.set(gitlabProj.HTTPURLToRepo, proj) }