package github import ( "encoding/json" "errors" "fmt" "io" "math/rand/v2" "net/http" "net/url" "os" "path/filepath" "slices" "sort" "strings" "sync" "sync/atomic" "time" "github.com/go-git/go-git/v5/plumbing" "github.com/gobwas/glob" "github.com/google/go-github/v67/github" "github.com/shurcooL/githubv4" "golang.org/x/sync/errgroup" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/anypb" "github.com/trufflesecurity/trufflehog/v3/pkg/cache" "github.com/trufflesecurity/trufflehog/v3/pkg/cache/simple" "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/handlers" "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" ) const ( SourceType = sourcespb.SourceType_SOURCE_TYPE_GITHUB unauthGithubOrgRateLimt = 30 defaultPagination = 100 membersAppPagination = 500 ) type Source struct { name string sourceID sources.SourceID jobID sources.JobID verify bool orgsCache cache.Cache[string] memberCache map[string]struct{} repos []string filteredRepoCache *filteredRepoCache repoInfoCache repoInfoCache totalRepoSize int // total size of all repos in kb useCustomContentWriter bool git *git.Git scanOptMu sync.Mutex // protects the scanOptions scanOptions *git.ScanOptions conn *sourcespb.GitHub jobPool *errgroup.Group resumeInfoMutex sync.Mutex resumeInfoSlice []string connector Connector appInstallationsMu sync.Mutex appInstallations []*github.Installation appInstallationsLoaded bool includePRComments bool includeIssueComments bool ignoreGists bool includeGistComments bool commentsTimeframeDays uint32 sources.Progress useAuthInUrl bool // pass credentials in the repository urls for cloning } // -------------------------------------------------------------------------------- // RepoUnit and GistUnit are implementations of SourceUnit used during // enumeration. The different types aren't strictly necessary, but are a bit // more explicit and allow type checking/safety. var _ sources.SourceUnit = (*RepoUnit)(nil) var _ sources.SourceUnit = (*GistUnit)(nil) type RepoUnit struct { Name string `json:"name"` URL string `json:"url"` // InstallationID is the GitHub App installation that owns this repo, set // during scan-all-installations enumeration. Carrying it on the unit lets // ChunkUnit clone with the correct installation token without re-deriving // the mapping, which otherwise re-lists every installation's repos on every // scanned unit (see INT-790). Zero when the source is not an installation // scan or the unit predates this field. InstallationID int64 `json:"installation_id,omitempty"` } func (r RepoUnit) SourceUnitID() (string, sources.SourceUnitKind) { return r.URL, "repo" } func (r RepoUnit) Display() string { return r.Name } type GistUnit struct { Name string `json:"name"` URL string `json:"url"` } func (g GistUnit) SourceUnitID() (string, sources.SourceUnitKind) { return g.URL, "gist" } func (g GistUnit) Display() string { return g.Name } // unitEnvelope is just the JSON equivalent of apipb.SourceUnit, so we can // unmarshal source unit payloads. type unitEnvelope struct { ID string `json:"id"` Kind sources.SourceUnitKind `json:"kind,omitempty"` Name string `json:"display,omitempty"` UnitData []byte `json:"unit_data,omitempty"` } func (u unitEnvelope) SourceUnitID() (string, sources.SourceUnitKind) { return u.ID, u.Kind } func (u unitEnvelope) Display() string { return u.Name } func unmarshalSourceUnit[unitType sources.SourceUnit](data []byte) (unitType, error) { u := new(unitType) if err := json.Unmarshal(data, u); err != nil { return *new(unitType), err } return *u, nil } func (s *Source) UnmarshalSourceUnit(data []byte) (sources.SourceUnit, error) { env, err := unmarshalSourceUnit[unitEnvelope](data) if err != nil { return nil, err } switch env.Kind { case "": // data is a raw SourceUnit, no envelope // Look for a raw RepoUnit first ru, err := unmarshalSourceUnit[RepoUnit](data) if err != nil { return nil, err } if ru.URL != "" { // This is the test for a valid RepoUnit return ru, nil } if env.ID == "" { // This is the test for a valid generic GitHub Source Unit return nil, errors.New("not a github source unit") } return env, nil case "repo": if len(env.UnitData) > 0 { return unmarshalSourceUnit[RepoUnit](env.UnitData) } return RepoUnit{Name: env.Name, URL: env.ID}, nil case "gist": if len(env.UnitData) > 0 { return unmarshalSourceUnit[GistUnit](env.UnitData) } return GistUnit{Name: env.Name, URL: env.ID}, nil default: return nil, fmt.Errorf("unrecognized source unit kind %q", env.Kind) } } // -------------------------------------------------------------------------------- // WithCustomContentWriter sets the useCustomContentWriter flag on the source. func (s *Source) WithCustomContentWriter() { s.useCustomContentWriter = true } func (s *Source) WithScanOptions(scanOptions *git.ScanOptions) { s.scanOptions = scanOptions } func (s *Source) setScanOptions(base, head string) { s.scanOptMu.Lock() defer s.scanOptMu.Unlock() s.scanOptions.BaseHash = base s.scanOptions.HeadHash = head } // Ensure the Source satisfies the interfaces at compile time var _ sources.Source = (*Source)(nil) var _ sources.SourceUnitUnmarshaller = (*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 } // filteredRepoCache is a wrapper around cache.Cache that filters out repos // based on include and exclude globs. type filteredRepoCache struct { cache.Cache[string] include, exclude []glob.Glob } func (s *Source) newFilteredRepoCache(ctx context.Context, c cache.Cache[string], include, exclude []string) *filteredRepoCache { 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 { ctx.Logger().V(1).Info("invalid include glob", "include_value", ig, "err", err) continue } includeGlobs = append(includeGlobs, g) } for _, eg := range exclude { g, err := glob.Compile(eg) if err != nil { ctx.Logger().V(1).Info("invalid exclude glob", "exclude_value", eg, "err", err) continue } excludeGlobs = append(excludeGlobs, g) } return &filteredRepoCache{Cache: c, include: includeGlobs, exclude: excludeGlobs} } // Set overrides the cache.Cache Set method to filter out repos based on // include and exclude globs. func (c *filteredRepoCache) Set(key, val string) { if c.ignoreRepo(key) { return } if !c.includeRepo(key) { return } c.Cache.Set(key, val) } func (c *filteredRepoCache) ignoreRepo(s string) bool { for _, g := range c.exclude { if g.Match(s) { return true } } return false } func (c *filteredRepoCache) includeRepo(s string) bool { if len(c.include) == 0 { return true } for _, g := range c.include { if g.Match(s) { return true } } return false } // wantRepo returns true if the repository should be included based on include/exclude patterns func (c *filteredRepoCache) wantRepo(s string) bool { return !c.ignoreRepo(s) && c.includeRepo(s) } // Init returns an initialized GitHub source. func (s *Source) Init(aCtx context.Context, name string, jobID sources.JobID, sourceID sources.SourceID, verify bool, connection *anypb.Any, concurrency int) error { err := git.CmdCheck() if err != nil { return err } s.name = name s.sourceID = sourceID s.jobID = jobID s.verify = verify s.jobPool = &errgroup.Group{} s.jobPool.SetLimit(concurrency) // Setup scan options if it wasn't provided. if s.scanOptions == nil { s.scanOptions = &git.ScanOptions{} } var conn sourcespb.GitHub err = anypb.UnmarshalTo(connection, &conn, proto.UnmarshalOptions{}) if err != nil { return fmt.Errorf("error unmarshalling connection: %w", err) } s.conn = &conn // configuration uses the inverse logic of the `useAuthInUrl` flag. s.useAuthInUrl = !s.conn.RemoveAuthInUrl s.resetAppInstallations() connector, err := newConnector(aCtx, s) if err != nil { return fmt.Errorf("could not create connector: %w", err) } s.connector = connector s.orgsCache = simple.NewCache[string]() for _, org := range s.conn.Organizations { s.orgsCache.Set(org, org) } s.memberCache = make(map[string]struct{}) s.filteredRepoCache = s.newFilteredRepoCache(aCtx, simple.NewCache[string](), append(s.conn.GetRepositories(), s.conn.GetIncludeRepos()...), s.conn.GetIgnoreRepos(), ) s.repos = s.conn.Repositories for _, repo := range s.repos { r, err := s.normalizeRepo(repo) if err != nil { aCtx.Logger().Error(err, "invalid repository", "repo", repo) continue } s.filteredRepoCache.Set(repo, r) } s.repoInfoCache = newRepoInfoCache() s.includeIssueComments = s.conn.IncludeIssueComments s.includePRComments = s.conn.IncludePullRequestComments s.ignoreGists = s.conn.GetIgnoreGists() s.includeGistComments = s.conn.IncludeGistComments s.commentsTimeframeDays = s.conn.CommentsTimeframeDays // Head or base should only be used with incoming webhooks if (len(s.conn.Head) > 0 || len(s.conn.Base) > 0) && len(s.repos) != 1 { return fmt.Errorf("cannot specify head or base with multiple repositories") } if connector, ok := s.connector.(*appConnector); ok && s.conn.ScanAllInstallations { if _, err := s.getAppInstallations(aCtx, connector); err != nil { return err } if len(s.repos) > 0 { if err := s.mapExplicitReposToInstallations(aCtx, connector); 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 { return &source_metadatapb.MetaData{ Data: &source_metadatapb.MetaData_Github{ Github: &source_metadatapb.Github{ Commit: sanitizer.UTF8(info.Commit), File: sanitizer.UTF8(info.File), Email: sanitizer.UTF8(info.Email), Repository: sanitizer.UTF8(info.Repository), Link: giturl.GenerateLink(info.Repository, info.Commit, info.File, info.Line), Timestamp: sanitizer.UTF8(info.Timestamp), Line: info.Line, Visibility: s.visibilityOf(aCtx, info.Repository), RepositoryLocalPath: sanitizer.UTF8(info.RepositoryLocalPath), }, }, } }, UseCustomContentWriter: s.useCustomContentWriter, AuthInUrl: s.useAuthInUrl, } s.git = git.NewGit(cfg) return nil } // Validate is used by enterprise CLI to validate the GitHub config file. func (s *Source) Validate(ctx context.Context) []error { /* Uses the rate limit API (docs: https://docs.github.com/en/rest/rate-limit) because: - Works with all auth types: user tokens, PATs, App credentials, and unauthenticated requests - Returns 401 for invalid credentials but works with no auth (as unauthenticated) - Doesn't consume API quota when called */ // When scanAllInstallations is set with no githubApp.installationId, // there's no default installation token to call RateLimit with (see // appConnector.HasDefaultInstallation). Validate the App credentials // directly against the app-level (JWT-authenticated) client instead. if connector, ok := s.connector.(*appConnector); ok && !connector.HasDefaultInstallation() { if _, _, err := connector.InstallationClient().Apps.Get(ctx, ""); err != nil { return []error{err} } return nil } if _, _, err := s.connector.APIClient().RateLimit.Get(ctx); err != nil { return []error{err} } return nil } func (s *Source) visibilityOf(ctx context.Context, repoURL string) source_metadatapb.Visibility { // It isn't possible to get the visibility of a wiki. // We must use the visibility of the corresponding repository. if _, ok := s.repoInfoCache.get(repoURL); !ok { if parentRepoURL, isWiki := repoCloneURLForWikiCloneURL(repoURL); isWiki { repoURL = parentRepoURL } } repoInfo, ok := s.repoInfoCache.get(repoURL) if !ok { // This should never happen. err := fmt.Errorf("no repoInfo for URL: %s", repoURL) ctx.Logger().Error(err, "failed to get repository visibility") return source_metadatapb.Visibility_unknown } return repoInfo.visibility } // Chunks emits chunks of bytes over a channel. func (s *Source) Chunks(ctx context.Context, chunksChan chan *sources.Chunk, targets ...sources.ChunkingTarget) error { chunksReporter := sources.ChanReporter{Ch: chunksChan} // 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 { errs := s.scanTargets(ctx, targets, chunksReporter) return errors.Join(errs...) } // Reset consumption and rate limit metrics on each run. githubNumRateLimitEncountered.WithLabelValues(s.name).Set(0) githubSecondsSpentRateLimited.WithLabelValues(s.name).Set(0) githubReposScanned.WithLabelValues(s.name).Set(0) // We don't care about handling enumerated values as they happen during // the normal Chunks flow because we enumerate and scan in two steps. var enumerationErrs []error noopReporter := sources.VisitorReporter{ VisitUnit: func(context.Context, sources.SourceUnit) error { return nil }, VisitErr: func(ctx context.Context, err error) error { enumerationErrs = append(enumerationErrs, err) return ctx.Err() }, } err := s.Enumerate(ctx, noopReporter) if err != nil { return errors.Join(fmt.Errorf("error enumerating: %w", err), errors.Join(enumerationErrs...)) } return errors.Join(append(enumerationErrs, s.scan(ctx, chunksReporter))...) } // Enumerate enumerates the GitHub source based on authentication method and // user configuration. It populates s.filteredRepoCache, s.repoInfoCache, // s.memberCache, s.totalRepoSize, s.orgsCache, and s.repos. Additionally, // repositories and gists are reported to the provided UnitReporter. func (s *Source) Enumerate(ctx context.Context, reporter sources.UnitReporter) error { seenUnits := make(map[sources.SourceUnit]struct{}) // Wrapper reporter to deduplicate and filter found units. dedupeReporter := sources.VisitorReporter{ VisitUnit: func(ctx context.Context, su sources.SourceUnit) error { // Only report units that passed the user configured filter. name := su.Display() if !s.filteredRepoCache.Exists(name) { return ctx.Err() } // Only report a unit once. if _, ok := seenUnits[su]; ok { return ctx.Err() } seenUnits[su] = struct{}{} return reporter.UnitOk(ctx, su) }, VisitErr: reporter.UnitErr, } // Report any values that were already configured. // This compensates for differences in enumeration logic between `--org` and `--repo`. // See: https://github.com/trufflesecurity/trufflehog/pull/2379#discussion_r1487454788 for _, name := range s.filteredRepoCache.Keys() { url, _ := s.filteredRepoCache.Get(name) url, err := s.ensureRepoInfoCache(ctx, url, &unitErrorReporter{reporter}) if err != nil { if err := dedupeReporter.UnitErr(ctx, err); err != nil { return err } continue } var installationID int64 = 0 if ac, ok := s.connector.(*appConnector); ok { ac.ensureRepoInstallation(url, name) installationID, _ = ac.installationIDForRepo(url) } ru := RepoUnit{Name: name, URL: url, InstallationID: installationID} if err := dedupeReporter.UnitOk(ctx, ru); err != nil { return err } } // I'm not wild about switching on the connector type here (as opposed to dispatching to the connector itself) but // this felt like a compromise that allowed me to isolate connection logic without rewriting the entire source. switch c := s.connector.(type) { case *appConnector: if err := s.enumerateWithApp(ctx, c, dedupeReporter); err != nil { return err } case *basicAuthConnector: if err := s.enumerateBasicAuth(ctx, dedupeReporter); err != nil { return err } case *tokenConnector: if err := s.enumerateWithToken(ctx, c.IsGithubEnterprise(), dedupeReporter); err != nil { return err } case *unauthenticatedConnector: s.enumerateUnauthenticated(ctx, dedupeReporter) } // If explicit repositories were provided, use them directly without filtering // Otherwise, rebuild s.repos from the filteredRepoCache if len(s.conn.Repositories) > 0 { // Explicit repositories bypass filtering - use them as-is s.repos = s.conn.Repositories ctx.Logger().V(1).Info("Using explicit repositories", "count", len(s.repos)) } else { // No explicit repositories - rebuild from enumerated cache with filtering s.repos = make([]string, 0, s.filteredRepoCache.Count()) // Double make sure that all enumerated repositories in the // filteredRepoCache have an entry in the repoInfoCache. for _, repo := range s.filteredRepoCache.Values() { // Extract the repository name from the URL for filtering repoName := extractRepoNameFromUrl(repo) // Final filter check - only include repositories that pass the filter if s.filteredRepoCache.wantRepo(repoName) { ctx := context.WithValue(ctx, "repo", repo) repo, err := s.ensureRepoInfoCache(ctx, repo, &unitErrorReporter{reporter}) if err != nil { ctx.Logger().Error(err, "error caching repo info") _ = dedupeReporter.UnitErr(ctx, fmt.Errorf("error caching repo info: %w", err)) } s.repos = append(s.repos, repo) } } } githubReposEnumerated.WithLabelValues(s.name).Set(float64(len(s.repos))) ctx.Logger().Info("Completed enumeration", "num_repos", len(s.repos), "num_orgs", s.orgsCache.Count(), "num_members", len(s.memberCache)) // We must sort the repos so we can resume later if necessary. sort.Strings(s.repos) return nil } // ensureRepoInfoCache checks that s.repoInfoCache has an entry for the // provided repository URL. If not, it fetches and stores the metadata for the // repository. In some cases, the gist URL needs to be normalized, which is // returned by this function. func (s *Source) ensureRepoInfoCache(ctx context.Context, repo string, reporter errorReporter) (string, error) { if _, ok := s.repoInfoCache.get(repo); ok { return repo, nil } ctx.Logger().V(2).Info("Caching repository info") _, urlParts, err := getRepoURLParts(repo) if err != nil { return repo, fmt.Errorf("failed to parse repository URL: %w", err) } if !s.ignoreGists && isGistUrl(urlParts) { if connector, ok := s.connector.(*appConnector); ok && !connector.HasDefaultInstallation() { return repo, fmt.Errorf("cannot fetch gist %q: githubApp.installationId is required as a fallback installation to scan gists together with scanAllInstallations", repo) } // Cache gist info. for { gistID := extractGistID(urlParts) gist, _, err := s.connector.APIClient().Gists.Get(ctx, gistID) // Normalize the URL to the Gist's pull URL. // See https://github.com/trufflesecurity/trufflehog/pull/2625#issuecomment-2025507937 repo = gist.GetGitPullURL() if s.handleRateLimit(ctx, err, reporter) { continue } if err != nil { return repo, fmt.Errorf("failed to fetch gist: %w", err) } s.cacheGistInfo(gist) break } } else { // Cache repository info. for { apiClient, err := s.connector.APIClientForRepo(repo) if err != nil { return repo, fmt.Errorf("failed to create API client for repository: %w", err) } ghRepo, _, err := apiClient.Repositories.Get(ctx, urlParts[1], urlParts[2]) if s.handleRateLimit(ctx, err, reporter) { continue } if err != nil { return repo, fmt.Errorf("failed to fetch repository: %w", err) } s.cacheRepoInfo(ghRepo) // Handle repository redirects: the API returns the canonical CloneURL // which may differ from the original URL (e.g. org rename). Cache info // under the original URL too so scanRepo can look it up. if cloneURL := ghRepo.GetCloneURL(); cloneURL != repo { if info, ok := s.repoInfoCache.get(cloneURL); ok { s.repoInfoCache.put(repo, info) } } break } } return repo, nil } func (s *Source) enumerateBasicAuth(ctx context.Context, reporter sources.UnitReporter) error { for _, org := range s.orgsCache.Keys() { orgCtx := context.WithValue(ctx, "account", org) userType, err := s.getReposByOrgOrUser(ctx, org, true, reporter) if err != nil { orgCtx.Logger().Error(err, "error fetching repos for org or user") continue } // TODO: This modifies s.memberCache but it doesn't look like // we do anything with it. if userType == organization && s.conn.ScanUsers { if err := s.addMembersByOrg(ctx, org, reporter); err != nil { orgCtx.Logger().Error(err, "Unable to add members by org") } } } return nil } func (s *Source) enumerateUnauthenticated(ctx context.Context, reporter sources.UnitReporter) { if s.orgsCache.Count() > unauthGithubOrgRateLimt { ctx.Logger().Info("You may experience rate limiting when using the unauthenticated GitHub api. Consider using an authenticated scan instead.") } for _, org := range s.orgsCache.Keys() { orgCtx := context.WithValue(ctx, "account", org) userType, err := s.getReposByOrgOrUser(ctx, org, false, reporter) if err != nil { orgCtx.Logger().Error(err, "error fetching repos for org or user") continue } if userType == organization && s.conn.ScanUsers { orgCtx.Logger().Info("WARNING: Enumerating unauthenticated does not support scanning organization members (--include-members)") } } } func (s *Source) enumerateWithToken(ctx context.Context, isGithubEnterprise bool, reporter sources.UnitReporter) error { ctx.Logger().V(1).Info("Enumerating with token") var ghUser *github.User var err error for { ghUser, _, err = s.connector.APIClient().Users.Get(ctx, "") if s.handleRateLimitWithUnitReporter(ctx, reporter, err) { continue } if err != nil { return fmt.Errorf("error getting user: %w", err) } break } specificScope := len(s.repos) > 0 || s.orgsCache.Count() > 0 if !specificScope { // Enumerate the user's orgs and repos if none were specified. if err := s.getReposByUser(ctx, ghUser.GetLogin(), true, reporter); err != nil { ctx.Logger().Error(err, "Unable to fetch repos for the current user", "user", ghUser.GetLogin()) } if err := s.addUserGistsToCache(ctx, ghUser.GetLogin(), reporter); err != nil { ctx.Logger().Error(err, "Unable to fetch gists for the current user", "user", ghUser.GetLogin()) } if isGithubEnterprise { s.addAllVisibleOrgs(ctx, reporter) } else { // Scan for orgs is default with a token. // GitHub App enumerates the repos that were assigned to it in GitHub App settings. s.addOrgsByUser(ctx, ghUser.GetLogin(), reporter) } } if len(s.orgsCache.Keys()) > 0 { for _, org := range s.orgsCache.Keys() { orgCtx := context.WithValue(ctx, "account", org) userType, err := s.getReposByOrgOrUser(ctx, org, true, reporter) if err != nil { orgCtx.Logger().Error(err, "Unable to fetch repos for org or user") continue } if userType == organization && s.conn.ScanUsers { if err := s.addMembersByOrg(ctx, org, reporter); err != nil { orgCtx.Logger().Error(err, "Unable to add members for org") } } } if s.conn.ScanUsers && len(s.memberCache) > 0 { ctx.Logger().Info("Fetching repos for org members", "org_count", s.orgsCache.Count(), "member_count", len(s.memberCache)) s.addReposForMembers(ctx, reporter) } } return nil } func (s *Source) enumerateWithApp(ctx context.Context, connector *appConnector, reporter sources.UnitReporter) error { // If no repos were provided, enumerate them. if len(s.repos) == 0 { if s.conn.ScanAllInstallations { if err := s.enumerateAllInstallationRepos(ctx, connector, reporter); err != nil { return err } } else { if err := s.getReposByApp(ctx, reporter); err != nil { return err } } // Check if we need to find user repos. if s.conn.ScanUsers { err := s.addMembersByApp(ctx, connector, reporter) if err != nil { return err } ctx.Logger().Info("Scanning repos", "org_members", len(s.memberCache)) if !connector.HasDefaultInstallation() { // Member repos/gists live outside any installation's own // listing, so there is no installation token to fetch them // with (see appConnector.HasDefaultInstallation). Skip with a // clear message instead of letting each per-member API call // fail against installation ID 0. ctx.Logger().Info("Skipping org member repos and gists: githubApp.installationId is required as a fallback installation to scan members together with scanAllInstallations", "org_members", len(s.memberCache)) return nil } // TODO: Replace loop below with a call to s.addReposForMembers(ctx, reporter) for member := range s.memberCache { logger := ctx.Logger().WithValues("member", member) if err := s.addUserGistsToCache(ctx, member, reporter); err != nil { logger.Error(err, "error fetching gists by user") } // TODO: Add authenticated user list repo for app token. It does support as per docs but need to test it before we enable it here. // docs: https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repositories-for-the-authenticated-user if err := s.getReposByUser(ctx, member, false, reporter); err != nil { logger.Error(err, "error fetching repos by user") } } } } return nil } func (s *Source) resetAppInstallations() { s.appInstallationsMu.Lock() defer s.appInstallationsMu.Unlock() s.appInstallations = nil s.appInstallationsLoaded = false } func (s *Source) getAppInstallations(ctx context.Context, connector *appConnector) ([]*github.Installation, error) { s.appInstallationsMu.Lock() defer s.appInstallationsMu.Unlock() if s.appInstallationsLoaded { return s.appInstallations, nil } installs, err := s.listAppInstallations(ctx, connector, defaultPagination) if err != nil { return nil, err } s.appInstallations = installs s.appInstallationsLoaded = true return s.appInstallations, nil } func (s *Source) listAppInstallations( ctx context.Context, connector *appConnector, perPage int, ) ([]*github.Installation, error) { installationClient := connector.InstallationClient() opts := &github.ListOptions{PerPage: perPage} var allInstalls []*github.Installation for { installs, res, err := installationClient.Apps.ListInstallations(ctx, opts) if s.handleRateLimit(ctx, err) { continue } if err != nil { return nil, fmt.Errorf("could not list installations: %w", err) } allInstalls = append(allInstalls, installs...) if res == nil || res.NextPage == 0 { break } opts.Page = res.NextPage } return allInstalls, nil } func (s *Source) listAppInstallationRepoPage( ctx context.Context, client *github.Client, opts *github.ListOptions, handleRateLimit func(error) bool, ) ([]*github.Repository, *github.Response, error) { for { result, res, err := client.Apps.ListRepos(ctx, opts) if handleRateLimit(err) { continue } if result == nil { return nil, res, err } if result.Repositories == nil { return []*github.Repository{}, res, err } return result.Repositories, res, err } } // enumerateAllInstallationRepos discovers repos from every installation of the // GitHub App. For each installation, it creates a per-installation API client // and records the installation ID for each repo so Clone uses the correct token. // Per-installation failures are reported through the unit reporter and don't // abort enumeration of the remaining installations. func (s *Source) enumerateAllInstallationRepos(ctx context.Context, connector *appConnector, reporter sources.UnitReporter) error { installs, err := s.getAppInstallations(ctx, connector) if err != nil { return err } for _, install := range installs { installID := install.GetID() account := install.GetAccount().GetLogin() installCtx := context.WithValues(ctx, "org", account, "installation_id", installID) installCtx.Logger().Info("Enumerating repos from GitHub App installation") client, err := connector.APIClientForInstallation(installID) if err != nil { installCtx.Logger().Error(err, "could not create API client for installation") if err := reporter.UnitErr(installCtx, fmt.Errorf("could not create API client for installation %d: %w", installID, err)); err != nil { return err } continue } listRepos := func(ctx context.Context, _ string, opts repoListOptions) ([]*github.Repository, *github.Response, error) { repos, resp, err := s.listAppInstallationRepoPage(ctx, client, opts.getListOptions(), func(err error) bool { return s.handleRateLimitWithUnitReporter(ctx, reporter, err) }) for _, r := range repos { connector.setRepoInstallationForRepoName(r.GetCloneURL(), r.GetName(), installID) } return repos, resp, err } if err := s.processRepos(installCtx, account, reporter, listRepos, &appListOptions{ ListOptions: github.ListOptions{PerPage: defaultPagination}, }); err != nil { installCtx.Logger().Error(err, "error enumerating repos for installation") if err := reporter.UnitErr(installCtx, fmt.Errorf("error enumerating repos for installation %d: %w", installID, err)); err != nil { return err } continue } } return nil } func (s *Source) mapExplicitReposToInstallations(ctx context.Context, connector *appConnector) error { return s.mapReposToInstallations(ctx, connector, s.repos) } type repoMappingRequest struct { original string normalized string lookupURLs []string lookupKeys []string } func (s *Source) mapReposToInstallations(ctx context.Context, connector *appConnector, repos []string) error { if len(repos) == 0 { return nil } wantedRepos := make(map[string]repoMappingRequest, len(repos)) for _, repo := range repos { normalized, err := s.normalizeRepo(repo) if err != nil { return fmt.Errorf("could not normalize configured repo %q: %w", repo, err) } if connector.hasRepoInstallation(repo) || connector.hasRepoInstallation(normalized) { continue } request := repoMappingRequest{ original: repo, normalized: normalized, lookupURLs: repoURLsForInstallationLookup(normalized), } for _, lookupURL := range request.lookupURLs { key, err := repoHostOwnerNameKey(lookupURL) if err != nil { return fmt.Errorf("could not parse configured repo %q: %w", repo, err) } request.lookupKeys = append(request.lookupKeys, key) } // During installation repo listing, only allow exact matches. If no // exact repo exists, mapRemainingReposByMetadata tries wiki-parent // fallbacks afterward. wantedRepos[request.lookupKeys[0]] = request } if len(wantedRepos) == 0 { return nil } installs, err := s.getAppInstallations(ctx, connector) if err != nil { return err } var errs error clients := make(map[int64]*github.Client, len(installs)) installedRepoKeys := make(map[int64]map[string]struct{}, len(installs)) for _, install := range installs { installID := install.GetID() client, err := connector.APIClientForInstallation(installID) if err != nil { errs = errors.Join(errs, fmt.Errorf("could not create API client for installation %d: %w", installID, err)) continue } clients[installID] = client opts := &github.ListOptions{PerPage: defaultPagination} for { repos, res, err := s.listAppInstallationRepoPage(ctx, client, opts, func(err error) bool { return s.handleRateLimit(ctx, err) }) if err != nil { errs = errors.Join(errs, fmt.Errorf("could not list repos for installation %d: %w", installID, err)) break } if repos != nil { if installedRepoKeys[installID] == nil { installedRepoKeys[installID] = make(map[string]struct{}, len(repos)) } for _, repo := range repos { key, err := repoHostOwnerNameKey(repo.GetCloneURL()) if err == nil { installedRepoKeys[installID][key] = struct{}{} } } s.recordInstallationRepoMatches(connector, installID, repos, wantedRepos) if len(wantedRepos) == 0 { if errs != nil { ctx.Logger().Error(errs, "some installations could not be fully enumerated while mapping repos") } return nil } } if res == nil || res.NextPage == 0 { break } opts.Page = res.NextPage } } if len(wantedRepos) > 0 { errs = errors.Join(errs, s.mapRemainingReposByMetadata(ctx, connector, installs, clients, installedRepoKeys, wantedRepos)) if len(wantedRepos) == 0 { if errs != nil { ctx.Logger().Error(errs, "some installations could not be fully enumerated while mapping repos") } return nil } } if len(wantedRepos) > 0 { errs = errors.Join(errs, s.mapRemainingAccessibleRepos(ctx, connector, wantedRepos)) if len(wantedRepos) == 0 { // Every requested repo was mapped, so returning errs would fail // repos that succeeded; log partial enumeration errors instead. if errs != nil { ctx.Logger().Error(errs, "some installations could not be fully enumerated while mapping repos") } return nil } } if len(wantedRepos) > 0 { unmatched := make([]string, 0, len(wantedRepos)) for _, requested := range wantedRepos { unmatched = append(unmatched, requested.original) } sort.Strings(unmatched) errs = errors.Join(errs, fmt.Errorf("configured repos were not found in any GitHub App installation: %s", strings.Join(unmatched, ", "))) } return errs } func (s *Source) recordInstallationRepoMatches(connector *appConnector, installationID int64, repos []*github.Repository, wantedRepos map[string]repoMappingRequest) { for _, repo := range repos { cloneURL := repo.GetCloneURL() key, err := repoHostOwnerNameKey(cloneURL) if err != nil { continue } requested, ok := wantedRepos[key] if !ok { continue } deleteRepoMappingRequest(wantedRepos, requested) s.recordRepoInstallation(connector, installationID, repo, requested) } } func deleteRepoMappingRequest(wantedRepos map[string]repoMappingRequest, requested repoMappingRequest) { if len(requested.lookupKeys) > 0 { delete(wantedRepos, requested.lookupKeys[0]) } } func (s *Source) mapRemainingReposByMetadata( ctx context.Context, connector *appConnector, installs []*github.Installation, clients map[int64]*github.Client, installedRepoKeys map[int64]map[string]struct{}, wantedRepos map[string]repoMappingRequest, ) error { var errs error for _, install := range installs { if len(wantedRepos) == 0 { return errs } installID := install.GetID() client, ok := clients[installID] if !ok { continue } for _, requested := range wantedRepos { for i, lookupURL := range requested.lookupURLs { _, parts, err := getRepoURLParts(lookupURL) if err != nil { errs = errors.Join(errs, fmt.Errorf("could not parse configured repo %q: %w", requested.original, err)) break } var repo *github.Repository for { repo, _, err = client.Repositories.Get(ctx, parts[1], parts[2]) if s.handleRateLimit(ctx, err) { continue } break } if err != nil { if isGitHub404Error(err) { continue } errs = errors.Join(errs, fmt.Errorf("could not fetch repo %q for installation %d: %w", requested.original, installID, err)) break } canonicalKey, err := repoHostOwnerNameKey(repo.GetCloneURL()) if err != nil { errs = errors.Join(errs, fmt.Errorf("could not parse fetched repo %q for installation %d: %w", repo.GetCloneURL(), installID, err)) continue } if _, ok := installedRepoKeys[installID][canonicalKey]; !ok { continue } if !sameRepoHost(requested.normalized, repo.GetCloneURL()) { if i == 0 { break } continue } deleteRepoMappingRequest(wantedRepos, requested) s.recordRepoInstallation(connector, installID, repo, requested) if len(wantedRepos) == 0 { return errs } break } } } return errs } // mapRemainingAccessibleRepos handles repos that no installation owns, such // as org members' personal repos enumerated via scan_users. Installations // only ever contain repos owned by the installing account, but the default // installation token can still read repos that are visible to it (public // repos in particular), so a repo that the API confirms accessible is mapped // to the default installation instead of failing the scan. func (s *Source) mapRemainingAccessibleRepos( ctx context.Context, connector *appConnector, wantedRepos map[string]repoMappingRequest, ) error { if !connector.HasDefaultInstallation() { return fmt.Errorf("cannot resolve %d configured repo(s) outside installation listings: githubApp.installationId is required as a fallback installation together with scanAllInstallations", len(wantedRepos)) } var errs error client := connector.APIClient() for _, requested := range wantedRepos { for i, lookupURL := range requested.lookupURLs { _, parts, err := getRepoURLParts(lookupURL) if err != nil { errs = errors.Join(errs, fmt.Errorf("could not parse configured repo %q: %w", requested.original, err)) break } var repo *github.Repository for { repo, _, err = client.Repositories.Get(ctx, parts[1], parts[2]) if s.handleRateLimit(ctx, err) { continue } break } if err != nil { if isGitHub404Error(err) { continue } errs = errors.Join(errs, fmt.Errorf("could not fetch repo %q with default installation: %w", requested.original, err)) break } if !sameRepoHost(requested.normalized, repo.GetCloneURL()) { if i == 0 { break } continue } ctx.Logger().Info("repo is not owned by any app installation; scanning with the default installation token", "repo", requested.original) deleteRepoMappingRequest(wantedRepos, requested) s.recordRepoInstallation(connector, connector.installationID, repo, requested) break } } return errs } func (s *Source) recordRepoInstallation(connector *appConnector, installationID int64, repo *github.Repository, requested repoMappingRequest) { cloneURL := repo.GetCloneURL() connector.setRepoInstallationForRepoName(cloneURL, repo.GetName(), installationID) if requested.normalized != cloneURL && sameRepoHost(requested.normalized, cloneURL) { connector.setRepoInstallation(requested.normalized, installationID) } if requested.original != requested.normalized && requested.original != cloneURL && sameRepoHost(requested.original, cloneURL) { connector.setRepoInstallation(requested.original, installationID) } s.cacheRepoInfo(repo) if info, ok := s.repoInfoCache.get(cloneURL); ok { if requested.normalized != cloneURL && sameRepoHost(requested.normalized, cloneURL) { s.repoInfoCache.put(requested.normalized, info) } if requested.original != requested.normalized && requested.original != cloneURL && sameRepoHost(requested.original, cloneURL) { s.repoInfoCache.put(requested.original, info) } } } func repoHostOwnerNameKey(repoURL string) (string, error) { _, parts, err := getRepoURLParts(repoURL) if err != nil { return "", err } if len(parts) != 3 { return "", fmt.Errorf("expected repository URL, got %q", repoURL) } return strings.ToLower(parts[0] + "/" + parts[1] + "/" + parts[2]), nil } func sameRepoHost(repoURL, otherRepoURL string) bool { _, parts, err := getRepoURLParts(repoURL) if err != nil { return false } _, otherParts, err := getRepoURLParts(otherRepoURL) if err != nil { return false } return strings.EqualFold(parts[0], otherParts[0]) } func (s *Source) repoURLFromTargetMetadata(meta *source_metadatapb.Github) (string, error) { linkRepo, err := s.repoURLFromTargetLink(meta.GetLink()) if err != nil { return "", err } if repo := meta.GetRepository(); repo != "" { // Target metadata may only include a bare repo name. In that case the // owner and host from the link are the authoritative repository identity. if !strings.Contains(repo, "/") && !strings.Contains(repo, ":") { return linkRepo, nil } normalized, err := s.normalizeRepo(repo) if err != nil { return "", fmt.Errorf("could not normalize target repository %q: %w", repo, err) } linkKey, err := repoHostOwnerNameKey(linkRepo) if err != nil { return "", err } repoKey, err := repoHostOwnerNameKey(normalized) if err != nil { return "", fmt.Errorf("could not parse target repository %q: %w", repo, err) } if repoKey != linkKey && isWikiLink(meta.GetLink()) { if parentRepoURL, isWiki := repoCloneURLForWikiCloneURL(normalized); isWiki { repoKey, err = repoHostOwnerNameKey(parentRepoURL) if err != nil { return "", fmt.Errorf("could not parse target repository %q: %w", repo, err) } } } if repoKey != linkKey { return "", fmt.Errorf("target repository %q does not match link repository %q", normalized, linkRepo) } } return linkRepo, nil } func (s *Source) repoURLFromTargetLink(link string) (string, error) { u, err := url.Parse(link) if err != nil { return "", fmt.Errorf("unable to parse GitHub URL: %w", err) } segments := strings.Split(strings.Trim(u.Path, "/"), "/") if len(segments) < 2 { return "", fmt.Errorf("invalid GitHub URL") } repoURL := (&url.URL{ Scheme: u.Scheme, Host: u.Host, }).JoinPath(segments[0], segments[1]+".git").String() return s.normalizeRepo(repoURL) } func (s *Source) scan(ctx context.Context, reporter sources.ChunkReporter) error { var scannedCount uint64 = 1 ctx.Logger().V(2).Info("Found repos to scan", "count", len(s.repos)) // 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) s.repos = reposToScan for i, repoURL := range s.repos { s.jobPool.Go(func() error { if common.IsDone(ctx) { return nil } ctx := context.WithValue(ctx, "repo", repoURL) // TODO: set progress complete is being called concurrently with i s.setProgressCompleteWithRepo(i, progressIndexOffset, repoURL) // Ensure the repo is removed from the resume info after being scanned. defer func(s *Source, repoURL string) { s.resumeInfoMutex.Lock() defer s.resumeInfoMutex.Unlock() s.resumeInfoSlice = sources.RemoveRepoFromResumeInfo(s.resumeInfoSlice, repoURL) }(s, repoURL) if err := s.scanRepo(ctx, repoURL, reporter); err != nil { ctx.Logger().Error(err, "error scanning repo") return nil } atomic.AddUint64(&scannedCount, 1) return nil }) } _ = s.jobPool.Wait() s.SetProgressComplete(len(s.repos), len(s.repos), "Completed GitHub scan", "") return nil } // scanRepo attempts to scan the provided URL and any associated wiki and // comments if configured. An error is returned if we could not find necessary // repository metadata or clone the repo, otherwise all errors are reported to // the ChunkReporter. func (s *Source) scanRepo(ctx context.Context, repoURL string, reporter sources.ChunkReporter) error { if !strings.HasSuffix(repoURL, ".git") { return fmt.Errorf("repo does not end in .git") } // Scan the repository repoInfo, ok := s.repoInfoCache.get(repoURL) if !ok { // This should never happen. return fmt.Errorf("no repoInfo for URL: %s", repoURL) } duration, err := s.cloneAndScanRepo(ctx, repoURL, repoInfo, reporter) if err != nil { return err } // Scan the wiki, if enabled, and the repo has one. if s.conn.IncludeWikis && repoInfo.hasWiki && s.wikiIsReachable(ctx, repoURL) { wikiURL, ok := wikiCloneURLForRepoInfo(repoURL, repoInfo) if ok { wikiCtx := context.WithValue(ctx, "repo", wikiURL) _, err := s.cloneAndScanRepo(wikiCtx, wikiURL, repoInfo, reporter) if err != nil { // Ignore "Repository not found" errors. // It's common for GitHub's API to say a repo has a wiki when it doesn't. if !strings.Contains(err.Error(), "not found") { if err := reporter.ChunkErr(ctx, fmt.Errorf("error scanning wiki: %w", err)); err != nil { return err } } // Don't return, it still might be possible to scan comments. } } } // Scan comments, if enabled. if s.includeGistComments || s.includeIssueComments || s.includePRComments { if err := s.scanComments(ctx, repoURL, repoInfo, reporter); err != nil { err := fmt.Errorf("error scanning comments: %w", err) if err := reporter.ChunkErr(ctx, err); err != nil { return err } } } ctx.Logger().V(2).Info("finished scanning repo", "duration_seconds", duration) githubReposScanned.WithLabelValues(s.name).Inc() return nil } func (s *Source) cloneAndScanRepo(ctx context.Context, repoURL string, repoInfo repoInfo, reporter sources.ChunkReporter) (time.Duration, error) { var duration time.Duration ctx.Logger().V(2).Info("attempting to clone repo", "repo_name", repoInfo.name) path, repo, err := s.cloneRepo(ctx, repoURL) if err != nil { return duration, 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.conn.GetPrintLegacyJson() { if strings.HasPrefix(path, filepath.Join(os.TempDir(), "trufflehog")) || (!s.conn.NoCleanup && s.conn.GetClonePath() != "") { defer func() { _ = os.RemoveAll(path) }() } } // TODO: Can this be set once or does it need to be set on every iteration? Is |s.scanOptions| set every clone? s.setScanOptions(s.conn.Base, s.conn.Head) start := time.Now() if err = s.git.ScanRepo(ctx, repo, path, s.scanOptions, reporter); err != nil { return duration, fmt.Errorf("error scanning repo %s: %w", repoURL, err) } duration = time.Since(start) return duration, nil } var ( rateLimitMu sync.RWMutex rateLimitResumeTime time.Time ) // errorReporter is an interface that captures just the error reporting functionality type errorReporter interface { Err(ctx context.Context, err error) error } // wrapper to adapt UnitReporter to errorReporter type unitErrorReporter struct { reporter sources.UnitReporter } func (u unitErrorReporter) Err(ctx context.Context, err error) error { return u.reporter.UnitErr(ctx, err) } // wrapper to adapt ChunkReporter to errorReporter type chunkErrorReporter struct { reporter sources.ChunkReporter } func (c chunkErrorReporter) Err(ctx context.Context, err error) error { return c.reporter.ChunkErr(ctx, err) } // handleRateLimit handles GitHub API rate limiting with an optional error reporter. // Returns true if a rate limit was handled. // // Unauthenticated users have a rate limit of 60 requests per hour. // Authenticated users have a rate limit of 5,000 requests per hour, // however, certain actions are subject to a stricter "secondary" limit. // https://docs.github.com/en/rest/overview/rate-limits-for-the-rest-api func (s *Source) handleRateLimit(ctx context.Context, errIn error, reporters ...errorReporter) bool { if errIn == nil { return false } rateLimitMu.RLock() resumeTime := rateLimitResumeTime rateLimitMu.RUnlock() var retryAfter time.Duration if resumeTime.IsZero() || time.Now().After(resumeTime) { rateLimitMu.Lock() var ( now = time.Now() // GitHub has both primary (RateLimit) and secondary (AbuseRateLimit) errors. limitType string rateLimit *github.RateLimitError abuseLimit *github.AbuseRateLimitError ) if errors.As(errIn, &rateLimit) { limitType = "primary" rate := rateLimit.Rate if rate.Remaining == 0 { // TODO: Will we ever receive a |RateLimitError| when remaining > 0? retryAfter = rate.Reset.Sub(now) } } else if errors.As(errIn, &abuseLimit) { limitType = "secondary" retryAfter = abuseLimit.GetRetryAfter() } else { rateLimitMu.Unlock() return false } jitter := time.Duration(rand.IntN(10)+1) * time.Second if retryAfter > 0 { retryAfter = retryAfter + jitter rateLimitResumeTime = now.Add(retryAfter) ctx.Logger().Info(fmt.Sprintf("exceeded %s rate limit", limitType), "retry_after", retryAfter.String(), "resume_time", rateLimitResumeTime.Format(time.RFC3339)) // Only report the error if a reporter was provided for _, reporter := range reporters { _ = reporter.Err(ctx, fmt.Errorf("exceeded %s rate limit", limitType)) } } else { retryAfter = (5 * time.Minute) + jitter rateLimitResumeTime = now.Add(retryAfter) // TODO: Use exponential backoff instead of static retry time. ctx.Logger().Error(errIn, "unexpected rate limit error", "retry_after", retryAfter.String(), "resume_time", rateLimitResumeTime.Format(time.RFC3339)) } rateLimitMu.Unlock() } else { retryAfter = time.Until(resumeTime) } githubNumRateLimitEncountered.WithLabelValues(s.name).Inc() slept, canceled := sleepWithContext(ctx, retryAfter) githubSecondsSpentRateLimited.WithLabelValues(s.name).Add(slept.Seconds()) return !canceled } func sleepWithContext(ctx context.Context, duration time.Duration) (time.Duration, bool) { start := time.Now() if duration <= 0 { return time.Since(start), false } timer := time.NewTimer(duration) defer timer.Stop() select { case <-timer.C: return time.Since(start), false case <-ctx.Done(): return time.Since(start), true } } // handleRateLimitWithUnitReporter is a wrapper around handleRateLimit that includes unit reporting func (s *Source) handleRateLimitWithUnitReporter(ctx context.Context, reporter sources.UnitReporter, errIn error) bool { return s.handleRateLimit(ctx, errIn, &unitErrorReporter{reporter: reporter}) } // handleRateLimitWithChunkReporter is a wrapper around handleRateLimit that includes chunk reporting func (s *Source) handleRateLimitWithChunkReporter(ctx context.Context, reporter sources.ChunkReporter, errIn error) bool { return s.handleRateLimit(ctx, errIn, &chunkErrorReporter{reporter: reporter}) } func (s *Source) addReposForMembers(ctx context.Context, reporter sources.UnitReporter) { ctx.Logger().Info("Fetching repos from members", "members", len(s.memberCache)) for member := range s.memberCache { if err := s.addUserGistsToCache(ctx, member, reporter); err != nil { ctx.Logger().Info("Unable to fetch gists by user", "user", member, "error", err) } if err := s.getReposByUser(ctx, member, false, reporter); err != nil { ctx.Logger().Info("Unable to fetch repos by user", "user", member, "error", err) } } } // addUserGistsToCache collects all the gist urls for a given user, // and adds them to the filteredRepoCache. func (s *Source) addUserGistsToCache(ctx context.Context, user string, reporter sources.UnitReporter) error { if s.ignoreGists { return nil } gistOpts := &github.GistListOptions{} logger := ctx.Logger().WithValues("user", user) for { gists, res, err := s.connector.APIClient().Gists.List(ctx, user, gistOpts) if s.handleRateLimitWithUnitReporter(ctx, reporter, err) { continue } if err != nil { return fmt.Errorf("could not list gists for user %s: %w", user, err) } for _, gist := range gists { s.filteredRepoCache.Set(gist.GetID(), gist.GetGitPullURL()) s.cacheGistInfo(gist) if err := reporter.UnitOk(ctx, GistUnit{Name: gist.GetID(), URL: gist.GetGitPullURL()}); err != nil { return err } } if res == nil || res.NextPage == 0 { break } logger.V(2).Info("Listed gists", "page", gistOpts.Page, "last_page", res.LastPage) gistOpts.Page = res.NextPage } return nil } func (s *Source) addMembersByApp(ctx context.Context, connector *appConnector, reporter sources.UnitReporter) error { // TODO: Check rate limit for this call. installs, err := s.getAppInstallations(ctx, connector) if err != nil { return fmt.Errorf("could not enumerate installed orgs: %w", err) } for _, install := range installs { if install.GetAccount().GetType() != "Organization" { continue } org := install.GetAccount().GetLogin() installCtx := context.WithValues(ctx, "org", org, "installation_id", install.GetID()) // Create a per-installation API client so that the token is scoped to // this org. GitHub App installation tokens only bypass IP allowlists for // their own org; using a cross-org token causes 403s. client, err := connector.APIClientForInstallation(install.GetID()) if err != nil { installCtx.Logger().Error(err, "could not create API client for installation") if err := reporter.UnitErr(installCtx, fmt.Errorf("could not create API client for installation %d: %w", install.GetID(), err)); err != nil { return err } continue } if err := s.addMembersByOrgWithClient(installCtx, client, org, reporter); err != nil { installCtx.Logger().Error(err, "Unable to add members for org") if err := reporter.UnitErr(installCtx, fmt.Errorf("could not add members for org %q (installation %d): %w", org, install.GetID(), err)); err != nil { return err } continue } } return nil } func (s *Source) addAllVisibleOrgs(ctx context.Context, reporter sources.UnitReporter) { ctx.Logger().V(2).Info("enumerating all visible organizations on GHE") // Enumeration on this endpoint does not use pages it uses a since ID. // The endpoint will return organizations with an ID greater than the given since ID. // Empty org response is our cue to break the enumeration loop. orgOpts := &github.OrganizationsListOptions{ Since: 0, ListOptions: github.ListOptions{ PerPage: defaultPagination, }, } for { orgs, _, err := s.connector.APIClient().Organizations.ListAll(ctx, orgOpts) if s.handleRateLimitWithUnitReporter(ctx, reporter, err) { continue } if err != nil { ctx.Logger().Error(err, "could not list all organizations") return } if len(orgs) == 0 { break } lastOrgID := *orgs[len(orgs)-1].ID ctx.Logger().V(2).Info(fmt.Sprintf("listed organization IDs %d through %d", orgOpts.Since, lastOrgID)) orgOpts.Since = lastOrgID for _, org := range orgs { var name string switch { case org.Name != nil: name = *org.Name case org.Login != nil: name = *org.Login default: continue } s.orgsCache.Set(name, name) ctx.Logger().V(2).Info("adding organization for repository enumeration", "id", org.ID, "name", name) } } } func (s *Source) addOrgsByUser(ctx context.Context, user string, reporter sources.UnitReporter) { orgOpts := &github.ListOptions{ PerPage: defaultPagination, } logger := ctx.Logger().WithValues("user", user) for { orgs, resp, err := s.connector.APIClient().Organizations.List(ctx, "", orgOpts) if s.handleRateLimitWithUnitReporter(ctx, reporter, err) { continue } if err != nil { logger.Error(err, "Could not list organizations") return } logger.V(2).Info("Listed orgs", "page", orgOpts.Page, "last_page", resp.LastPage) for _, org := range orgs { if org.Login == nil { continue } s.orgsCache.Set(*org.Login, *org.Login) } if resp.NextPage == 0 { break } orgOpts.Page = resp.NextPage } } func (s *Source) addMembersByOrg(ctx context.Context, org string, reporter sources.UnitReporter) error { return s.addMembersByOrgWithClient(ctx, s.connector.APIClient(), org, reporter) } func (s *Source) addMembersByOrgWithClient(ctx context.Context, client *github.Client, org string, reporter sources.UnitReporter) error { opts := &github.ListMembersOptions{ PublicOnly: false, ListOptions: github.ListOptions{ PerPage: membersAppPagination, }, } logger := ctx.Logger().WithValues("org", org) for { members, res, err := client.Organizations.ListMembers(ctx, org, opts) if s.handleRateLimitWithUnitReporter(ctx, reporter, err) { continue } if err != nil { return fmt.Errorf("could not list organization (%q) members: account may not have access to list organization members: %w", org, err) } if len(members) == 0 { return fmt.Errorf("organization (%q) had 0 members: account may not have access to list organization members", org) } logger.V(2).Info("Listed members", "page", opts.Page, "last_page", res.LastPage) for _, m := range members { usr := m.Login if usr == nil || *usr == "" { continue } if _, ok := s.memberCache[*usr]; !ok { s.memberCache[*usr] = struct{}{} } } if res.NextPage == 0 { break } opts.Page = res.NextPage } 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) sort.Strings(s.resumeInfoSlice) // Make the resume info string from the slice. encodedResumeInfo := sources.EncodeResumeInfo(s.resumeInfoSlice) s.SetProgressComplete(index+offset, len(s.repos)+offset, fmt.Sprintf("Repo: %s", repoURL), encodedResumeInfo) } func (s *Source) scanComments(ctx context.Context, repoPath string, repoInfo repoInfo, reporter sources.ChunkReporter) error { urlString, urlParts, err := getRepoURLParts(repoPath) if err != nil { return err } var cutoffTime *time.Time if s.commentsTimeframeDays > 0 { daysToFilter := int(s.commentsTimeframeDays) t := time.Now().AddDate(0, 0, -daysToFilter) cutoffTime = &t } apiClient, err := s.connector.APIClientForRepo(repoPath) if err != nil { return err } if s.includeGistComments && isGistUrl(urlParts) && !s.ignoreGists { return s.processGistComments(ctx, apiClient, urlString, urlParts, repoInfo, reporter, cutoffTime) } else if s.includeIssueComments || s.includePRComments { // if we need to use graphql api for repo issues, prs and comments if feature.UseGithubGraphQLAPI.Load() { graphqlClient, err := s.connector.GraphQLClientForRepo(ctx, repoPath) if err != nil { return err } return s.processRepoIssueandPRsWithCommentsGraphql(ctx, graphqlClient, repoInfo, reporter, cutoffTime) } return s.processIssueandPRsWithCommentsREST(ctx, apiClient, repoInfo, reporter, cutoffTime) } return nil } // trimURLAndSplit removes extraneous information from the |url| and splits it into segments. // This is typically 3 segments: host, owner, and name/ID; however, Gists have some edge cases. // // Examples: // - "https://github.com/trufflesecurity/trufflehog" => ["github.com", "trufflesecurity", "trufflehog"] // - "https://gist.github.com/nat/5fdbb7f945d121f197fb074578e53948" => ["gist.github.com", "nat", "5fdbb7f945d121f197fb074578e53948"] // - "https://gist.github.com/ff0e5e8dc8ec22f7a25ddfc3492d3451.git" => ["gist.github.com", "ff0e5e8dc8ec22f7a25ddfc3492d3451"] // - "https://github.company.org/gist/nat/5fdbb7f945d121f197fb074578e53948.git" => ["github.company.org", "gist", "nat", "5fdbb7f945d121f197fb074578e53948"] func getRepoURLParts(repoURLString string) (string, []string, error) { // Support ssh and https URLs. repoURL, err := git.GitURLParse(repoURLString) if err != nil { return "", nil, err } // Remove the user information. // e.g., `git@github.com` -> `github.com` if repoURL.User != nil { repoURL.User = nil } // Use Host and Path directly instead of reconstructing via String(). // This preserves special characters like trailing hyphens in repo names // that might be lost during URL reconstruction. // See: https://github.com/trufflesecurity/trufflehog/issues/4679 host := repoURL.Host path := strings.TrimPrefix(repoURL.Path, "/") path = strings.TrimSuffix(path, ".git") urlString := repoURL.String() urlParts := append([]string{host}, strings.Split(path, "/")...) // Validate switch len(urlParts) { case 2: // gist.github.com/ if !strings.EqualFold(urlParts[0], "gist.github.com") { err = fmt.Errorf("failed to parse repository or gist URL (%s): 2 path segments are only expected if the host is 'gist.github.com' ('gist.github.com', '')", urlString) } case 3: // github.com//repo> // gist.github.com// // github.company.org//repo> // github.company.org/gist/ case 4: // github.company.org/gist/ if !strings.EqualFold(urlParts[1], "gist") || (strings.EqualFold(urlParts[0], "github.com") && strings.EqualFold(urlParts[1], "gist")) { err = fmt.Errorf("failed to parse repository or gist URL (%s): 4 path segments are only expected if the host isn't 'github.com' and the path starts with 'gist' ('github.example.com', 'gist', '', '')", urlString) } default: err = fmt.Errorf("invalid repository or gist URL (%s): length of URL segments should be between 2 and 4, not %d (%v)", urlString, len(urlParts), urlParts) } if err != nil { return "", nil, err } return urlString, urlParts, nil } const initialPage = 1 // page to start listing from func (s *Source) processGistComments(ctx context.Context, apiClient *github.Client, gistURL string, urlParts []string, repoInfo repoInfo, reporter sources.ChunkReporter, cutoffTime *time.Time) error { ctx.Logger().V(2).Info("Scanning GitHub Gist comments") // GitHub Gist URL. gistID := extractGistID(urlParts) options := &github.ListOptions{ PerPage: defaultPagination, Page: initialPage, } for { comments, _, err := apiClient.Gists.ListComments(ctx, gistID, options) if s.handleRateLimitWithChunkReporter(ctx, reporter, err) { continue } if err != nil { return err } if err = s.chunkGistComments(ctx, gistURL, repoInfo, comments, reporter, cutoffTime); err != nil { return err } options.Page++ if len(comments) < options.PerPage { break } } return nil } func extractGistID(urlParts []string) string { return urlParts[len(urlParts)-1] } // isGistUrl returns true if the URL path is of a gist func isGistUrl(urlParts []string) bool { if len(urlParts) == 0 { return false } // standard github gists: gist.github.com/user/abc123 if strings.EqualFold(urlParts[0], "gist.github.com") { return true } // github enterprise: any 3 or 4 parts url with 'gist' if (len(urlParts) == 3 || len(urlParts) == 4) && slices.Contains(urlParts, "gist") { // enterprise.company.com/gist/gist-id // gist.company.com/gist/gist-id // gist.company.com/path/gist/gist-id return true } return false } func (s *Source) chunkGistComments(ctx context.Context, gistURL string, gistInfo repoInfo, comments []*github.GistComment, reporter sources.ChunkReporter, cutoffTime *time.Time) error { for _, comment := range comments { // Stop processing comments as soon as one created before the cutoff time is detected, as these are sorted if cutoffTime != nil && comment.GetCreatedAt().Before(*cutoffTime) { break } // Create chunk and send it to the channel. chunk := sources.Chunk{ SourceName: s.name, SourceID: s.SourceID(), SourceType: s.Type(), JobID: s.JobID(), SourceMetadata: &source_metadatapb.MetaData{ Data: &source_metadatapb.MetaData_Github{ Github: &source_metadatapb.Github{ Link: sanitizer.UTF8(comment.GetURL()), Username: sanitizer.UTF8(comment.GetUser().GetLogin()), Email: sanitizer.UTF8(comment.GetUser().GetEmail()), Repository: sanitizer.UTF8(gistURL), Timestamp: sanitizer.UTF8(comment.GetCreatedAt().String()), Visibility: gistInfo.visibility, }, }, }, Data: []byte(sanitizer.UTF8(comment.GetBody())), SourceVerify: s.verify, } if err := reporter.ChunkOk(ctx, chunk); err != nil { return err } } return nil } // Note: these can't be consts because the address is needed when using with the GitHub library. var ( // sortType defines the criteria for sorting comments. // By setting this to "updated" we can use this to reliably manage the comment timeframe filtering below sortType = "updated" // directionType defines the direction of sorting. // "desc" means comments will be sorted in descending order, showing the latest comments first, which is critical for managing the comment timeframe filtering directionType = "desc" // allComments is a placeholder for specifying the comment ID to start listing from. // A value of 0 means that all comments will be listed. allComments = 0 // state of "all" for the ListByRepo captures both open and closed issues. state = "all" ) func (s *Source) processIssueandPRsWithCommentsREST( ctx context.Context, apiClient *github.Client, repoInfo repoInfo, reporter sources.ChunkReporter, cutoffTime *time.Time, ) error { if s.includeIssueComments { ctx.Logger().V(2).Info("Scanning issues") if err := s.processIssues(ctx, apiClient, repoInfo, reporter); err != nil { return err } if err := s.processIssueComments(ctx, apiClient, repoInfo, reporter, cutoffTime); err != nil { return err } } if s.includePRComments { ctx.Logger().V(2).Info("Scanning pull requests") if err := s.processPRs(ctx, apiClient, repoInfo, reporter); err != nil { return err } if err := s.processPRComments(ctx, apiClient, repoInfo, reporter, cutoffTime); err != nil { return err } } return nil } func (s *Source) processRepoIssueandPRsWithCommentsGraphql( ctx context.Context, graphqlClient *githubv4.Client, repoInfo repoInfo, reporter sources.ChunkReporter, cutoffTime *time.Time, ) error { if s.includeIssueComments { ctx.Logger().V(2).Info("Scanning issues") if err := s.processIssuesWithComments(ctx, graphqlClient, repoInfo, reporter, cutoffTime); err != nil { return err } } if s.includePRComments { ctx.Logger().V(2).Info("Scanning pull requests") if err := s.processPRWithComments(ctx, graphqlClient, repoInfo, reporter, cutoffTime); err != nil { return err } if err := s.processReviewThreads(ctx, graphqlClient, repoInfo, reporter, cutoffTime); err != nil { return err } } return nil } func (s *Source) processIssues(ctx context.Context, apiClient *github.Client, repoInfo repoInfo, reporter sources.ChunkReporter) error { bodyTextsOpts := &github.IssueListByRepoOptions{ Sort: sortType, Direction: directionType, State: state, ListOptions: github.ListOptions{ PerPage: defaultPagination, Page: initialPage, }, } for { issues, _, err := apiClient.Issues.ListByRepo(ctx, repoInfo.owner, repoInfo.name, bodyTextsOpts) if s.handleRateLimitWithChunkReporter(ctx, reporter, err) { continue } if err != nil { return err } if err = s.chunkIssues(ctx, repoInfo, issues, reporter); err != nil { return err } bodyTextsOpts.Page++ if len(issues) < defaultPagination { break } } return nil } func (s *Source) chunkIssues(ctx context.Context, repoInfo repoInfo, issues []*github.Issue, reporter sources.ChunkReporter) error { for _, issue := range issues { // Skip pull requests since covered by processPRs. if issue.IsPullRequest() { continue } // Create chunk and send it to the channel. chunk := sources.Chunk{ SourceName: s.name, SourceID: s.SourceID(), JobID: s.JobID(), SourceType: s.Type(), SourceMetadata: &source_metadatapb.MetaData{ Data: &source_metadatapb.MetaData_Github{ Github: &source_metadatapb.Github{ Link: sanitizer.UTF8(issue.GetHTMLURL()), Username: sanitizer.UTF8(issue.GetUser().GetLogin()), Email: sanitizer.UTF8(issue.GetUser().GetEmail()), Repository: sanitizer.UTF8(repoInfo.fullName), Timestamp: sanitizer.UTF8(issue.GetCreatedAt().String()), Visibility: repoInfo.visibility, }, }, }, Data: []byte(sanitizer.UTF8(issue.GetTitle() + "\n" + issue.GetBody())), SourceVerify: s.verify, } if err := reporter.ChunkOk(ctx, chunk); err != nil { return err } } return nil } func (s *Source) processIssueComments(ctx context.Context, apiClient *github.Client, repoInfo repoInfo, reporter sources.ChunkReporter, cutoffTime *time.Time) error { issueOpts := &github.IssueListCommentsOptions{ Sort: &sortType, Direction: &directionType, ListOptions: github.ListOptions{ PerPage: defaultPagination, Page: initialPage, }, } for { issueComments, _, err := apiClient.Issues.ListComments(ctx, repoInfo.owner, repoInfo.name, allComments, issueOpts) if s.handleRateLimitWithChunkReporter(ctx, reporter, err) { continue } if err != nil { return err } if err = s.chunkIssueComments(ctx, repoInfo, issueComments, reporter, cutoffTime); err != nil { return err } issueOpts.Page++ if len(issueComments) < defaultPagination { break } } return nil } func (s *Source) chunkIssueComments(ctx context.Context, repoInfo repoInfo, comments []*github.IssueComment, reporter sources.ChunkReporter, cutoffTime *time.Time) error { for _, comment := range comments { // Stop processing comments as soon as one created before the cutoff time is detected, as these are sorted if cutoffTime != nil && comment.GetUpdatedAt().Before(*cutoffTime) { continue } // Create chunk and send it to the channel. chunk := sources.Chunk{ SourceName: s.name, SourceID: s.SourceID(), JobID: s.JobID(), SourceType: s.Type(), SourceMetadata: &source_metadatapb.MetaData{ Data: &source_metadatapb.MetaData_Github{ Github: &source_metadatapb.Github{ Link: sanitizer.UTF8(comment.GetHTMLURL()), Username: sanitizer.UTF8(comment.GetUser().GetLogin()), Email: sanitizer.UTF8(comment.GetUser().GetEmail()), Repository: sanitizer.UTF8(repoInfo.fullName), Timestamp: sanitizer.UTF8(comment.GetCreatedAt().String()), Visibility: repoInfo.visibility, }, }, }, Data: []byte(sanitizer.UTF8(comment.GetBody())), SourceVerify: s.verify, } if err := reporter.ChunkOk(ctx, chunk); err != nil { return err } } return nil } func (s *Source) processPRs(ctx context.Context, apiClient *github.Client, repoInfo repoInfo, reporter sources.ChunkReporter) error { prOpts := &github.PullRequestListOptions{ Sort: sortType, Direction: directionType, State: state, ListOptions: github.ListOptions{ PerPage: defaultPagination, Page: initialPage, }, } for { prs, _, err := apiClient.PullRequests.List(ctx, repoInfo.owner, repoInfo.name, prOpts) if s.handleRateLimitWithChunkReporter(ctx, reporter, err) { continue } if err != nil { return err } if err = s.chunkPullRequests(ctx, repoInfo, prs, reporter); err != nil { return err } prOpts.Page++ if len(prs) < defaultPagination { break } } return nil } func (s *Source) processPRComments(ctx context.Context, apiClient *github.Client, repoInfo repoInfo, reporter sources.ChunkReporter, cutoffTime *time.Time) error { prOpts := &github.PullRequestListCommentsOptions{ Sort: sortType, Direction: directionType, ListOptions: github.ListOptions{ PerPage: defaultPagination, Page: initialPage, }, } for { prComments, _, err := apiClient.PullRequests.ListComments(ctx, repoInfo.owner, repoInfo.name, allComments, prOpts) if s.handleRateLimitWithChunkReporter(ctx, reporter, err) { continue } if err != nil { return err } if err = s.chunkPullRequestComments(ctx, repoInfo, prComments, reporter, cutoffTime); err != nil { return err } prOpts.Page++ if len(prComments) < defaultPagination { break } } return nil } func (s *Source) chunkPullRequests(ctx context.Context, repoInfo repoInfo, prs []*github.PullRequest, reporter sources.ChunkReporter) error { for _, pr := range prs { // Create chunk and send it to the channel. chunk := sources.Chunk{ SourceName: s.name, SourceID: s.SourceID(), SourceType: s.Type(), JobID: s.JobID(), SourceMetadata: &source_metadatapb.MetaData{ Data: &source_metadatapb.MetaData_Github{ Github: &source_metadatapb.Github{ Link: sanitizer.UTF8(pr.GetHTMLURL()), Username: sanitizer.UTF8(pr.GetUser().GetLogin()), Email: sanitizer.UTF8(pr.GetUser().GetEmail()), Repository: sanitizer.UTF8(repoInfo.fullName), Timestamp: sanitizer.UTF8(pr.GetCreatedAt().String()), Visibility: repoInfo.visibility, }, }, }, Data: []byte(sanitizer.UTF8(pr.GetTitle() + "\n" + pr.GetBody())), SourceVerify: s.verify, } if err := reporter.ChunkOk(ctx, chunk); err != nil { return err } } return nil } func (s *Source) chunkPullRequestComments(ctx context.Context, repoInfo repoInfo, comments []*github.PullRequestComment, reporter sources.ChunkReporter, cutoffTime *time.Time) error { for _, comment := range comments { // Stop processing comments as soon as one created before the cutoff time is detected, as these are sorted if cutoffTime != nil && comment.GetUpdatedAt().Before(*cutoffTime) { continue } // Create chunk and send it to the channel. chunk := sources.Chunk{ SourceName: s.name, SourceID: s.SourceID(), JobID: s.JobID(), SourceType: s.Type(), SourceMetadata: &source_metadatapb.MetaData{ Data: &source_metadatapb.MetaData_Github{ Github: &source_metadatapb.Github{ Link: sanitizer.UTF8(comment.GetHTMLURL()), Username: sanitizer.UTF8(comment.GetUser().GetLogin()), Email: sanitizer.UTF8(comment.GetUser().GetEmail()), Repository: sanitizer.UTF8(repoInfo.fullName), Timestamp: sanitizer.UTF8(comment.GetCreatedAt().String()), Visibility: repoInfo.visibility, }, }, }, Data: []byte(sanitizer.UTF8(comment.GetBody())), SourceVerify: s.verify, } if err := reporter.ChunkOk(ctx, chunk); err != nil { return err } } return nil } func (s *Source) scanTargets(ctx context.Context, targets []sources.ChunkingTarget, reporter sources.ChunkReporter) []error { var errs []error for _, tgt := range targets { if err := s.scanTarget(ctx, tgt, reporter); err != nil { ctx.Logger().Error(err, "error scanning target") errs = append(errs, &sources.TargetedScanError{Err: err, SecretID: tgt.SecretID}) } } return errs } func (s *Source) scanTarget(ctx context.Context, target sources.ChunkingTarget, reporter sources.ChunkReporter) error { metaType, ok := target.QueryCriteria.GetData().(*source_metadatapb.MetaData_Github) if !ok { return fmt.Errorf("unable to cast metadata type for targeted scan") } meta := metaType.Github chunkSkel := sources.Chunk{ SourceType: s.Type(), SourceName: s.name, SourceID: s.SourceID(), JobID: s.JobID(), SecretID: target.SecretID, SourceMetadata: &source_metadatapb.MetaData{ Data: &source_metadatapb.MetaData_Github{Github: meta}, }, SourceVerify: s.verify, } u, err := url.Parse(meta.GetLink()) if err != nil { return fmt.Errorf("unable to parse GitHub URL: %w", err) } // The owner is the second segment and the repo is the third segment of the path. // Ex: https://github.com/owner/repo/..... segments := strings.Split(u.Path, "/") if len(segments) < 3 { return fmt.Errorf("invalid GitHub URL") } repoURL, err := s.repoURLFromTargetMetadata(meta) if err != nil { return err } targetIsWiki := isWikiLink(meta.GetLink()) if targetIsWiki { repoURL, err = repoCloneURLForTargetEndpoint(s.conn.GetEndpoint(), repoURL) if err != nil { return err } } if connector, ok := s.connector.(*appConnector); ok && s.conn.ScanAllInstallations { if err := s.mapReposToInstallations(ctx, connector, []string{repoURL}); err != nil { return err } } if targetIsWiki { wikiURL, ok := wikiCloneURLForRepo(repoURL) if !ok { return fmt.Errorf("could not derive wiki clone URL from target repository %q", repoURL) } return s.scanWikiTarget(ctx, wikiURL, meta, &chunkSkel, reporter) } apiClient, err := s.connector.APIClientForRepo(repoURL) if err != nil { return err } if meta.GetFile() == "" && meta.GetCommit() != "" { ctx := context.WithValues(ctx, "commit_hash", meta.GetCommit()) ctx.Logger().V(2).Info("secret metadata has no file; scanning commit metadata instead") return s.scanCommitMetadata(ctx, apiClient, segments[1], segments[2], meta, &chunkSkel, reporter) } // else try downloading the file content to scan readCloser, resp, err := apiClient.Repositories.DownloadContents( ctx, segments[1], segments[2], meta.GetFile(), &github.RepositoryContentGetOptions{Ref: meta.GetCommit()}) // As of this writing, if the returned readCloser is not nil, it's just the Body of the returned github.Response, so // there's no need to independently close it. if resp != nil && resp.Body != nil { defer func() { _ = resp.Body.Close() }() } if err != nil { // DownloadContents locates the file by listing its parent directory, // and the contents API caps listings at 1000 entries, so it can fail // for files that exist. Retry with an exact-path lookup, which also // serves as the existence probe when it fails. retryCloser, retryResp, retryErr := s.downloadContentsByPath( ctx, apiClient, segments[1], segments[2], meta.GetFile(), meta.GetCommit()) if retryResp != nil && retryResp.Response != nil && retryResp.Body != nil { defer func() { _ = retryResp.Body.Close() }() } if retryErr != nil { wrapped := fmt.Errorf("could not download file for scan: %w", retryErr) // The exact-path lookup 404ing is authoritative for the path, but // GitHub also returns 404 for existing resources the credentials // cannot see, so only classify the target as gone when the // repository itself is still reachable with the same client. if retryResp != nil && retryResp.Response != nil && retryResp.StatusCode == http.StatusNotFound && s.repoReachable(ctx, apiClient, segments[1], segments[2]) { return &sources.TargetNotFoundError{Err: wrapped} } return wrapped } fileCtx := context.WithValues(ctx, "path", meta.GetFile()) return handlers.HandleFile(fileCtx, retryCloser, &chunkSkel, reporter) } if resp.StatusCode != http.StatusOK { return fmt.Errorf("unexpected HTTP response status when trying to download file for scan: %v", resp.Status) } fileCtx := context.WithValues(ctx, "path", meta.GetFile()) return handlers.HandleFile(fileCtx, readCloser, &chunkSkel, reporter) } // downloadContentsByPath fetches a file the same way DownloadContents does, // but resolves it with an exact-path contents lookup instead of searching a // directory listing, so it is immune to the 1000-entry listing cap. On // failure the returned response is always the exact-path lookup's, whose 404 // is an authoritative statement about the path at that ref; a failure of the // download itself must not be mistaken for the target being gone, because // the lookup just proved it exists. func (s *Source) downloadContentsByPath(ctx context.Context, apiClient *github.Client, owner, repo, filePath, ref string) (io.ReadCloser, *github.Response, error) { fileContent, _, resp, err := apiClient.Repositories.GetContents( ctx, owner, repo, filePath, &github.RepositoryContentGetOptions{Ref: ref}) if err != nil { return nil, resp, err } if fileContent == nil || fileContent.GetDownloadURL() == "" { return nil, resp, fmt.Errorf("no download link found for %s", filePath) } dlReq, err := http.NewRequestWithContext(ctx, http.MethodGet, fileContent.GetDownloadURL(), nil) if err != nil { return nil, resp, err } dlResp, err := apiClient.Client().Do(dlReq) if err != nil { return nil, resp, err } if dlResp.StatusCode != http.StatusOK { _ = dlResp.Body.Close() return nil, resp, fmt.Errorf("unexpected HTTP response status when trying to download file for scan: %v", dlResp.Status) } return dlResp.Body, &github.Response{Response: dlResp}, nil } // repoReachable reports whether the repository is still visible with the // same client. func (s *Source) repoReachable(ctx context.Context, apiClient *github.Client, owner, repo string) bool { _, resp, err := apiClient.Repositories.Get(ctx, owner, repo) return err == nil && resp != nil && resp.StatusCode == http.StatusOK } func (s *Source) scanWikiTarget(ctx context.Context, wikiURL string, meta *source_metadatapb.Github, chunkSkel *sources.Chunk, reporter sources.ChunkReporter) error { if meta.GetCommit() == "" { return fmt.Errorf("wiki target has no commit") } path, repo, err := s.cloneRepo(ctx, wikiURL) if err != nil { return fmt.Errorf("could not clone wiki for targeted scan: %w", err) } if !s.conn.GetPrintLegacyJson() { if strings.HasPrefix(path, filepath.Join(os.TempDir(), "trufflehog")) || (!s.conn.NoCleanup && s.conn.GetClonePath() != "") { defer func() { _ = os.RemoveAll(path) }() } } commit, err := repo.CommitObject(plumbing.NewHash(meta.GetCommit())) if err != nil { return fmt.Errorf("could not fetch wiki commit for targeted scan: %w", err) } if meta.GetFile() == "" { var sb strings.Builder sb.WriteString(commit.Author.Email) sb.WriteString("\n") sb.WriteString(commit.Committer.Email) sb.WriteString("\n") sb.WriteString(commit.Message) return handlers.HandleFile(ctx, io.NopCloser(strings.NewReader(sb.String())), chunkSkel, reporter) } file, err := commit.File(meta.GetFile()) if err != nil { return fmt.Errorf("could not find wiki file for targeted scan: %w", err) } readCloser, err := file.Reader() if err != nil { return fmt.Errorf("could not read wiki file for targeted scan: %w", err) } defer func() { _ = readCloser.Close() }() fileCtx := context.WithValues(ctx, "path", meta.GetFile()) return handlers.HandleFile(fileCtx, readCloser, chunkSkel, reporter) } func (s *Source) scanCommitMetadata(ctx context.Context, apiClient *github.Client, owner, repo string, meta *source_metadatapb.Github, chunkSkel *sources.Chunk, reporter sources.ChunkReporter) error { // fetch the commit commit, resp, err := apiClient.Repositories.GetCommit(ctx, owner, repo, meta.GetCommit(), nil) if resp != nil && resp.Body != nil { defer func() { _ = resp.Body.Close() }() } if err != nil { return fmt.Errorf("could not fetch commit for metadata scan: %w", err) } if resp.StatusCode != http.StatusOK { return fmt.Errorf("unexpected HTTP response status when fetching commit: %v", resp.Status) } // create the string with the exact format we use in Git.ScanCommits() // author email + "\n" + committer + "\n" + commit message var sb strings.Builder sb.WriteString(commit.GetCommit().Author.GetEmail()) sb.WriteString("\n") sb.WriteString(commit.GetCommitter().GetEmail()) sb.WriteString("\n") sb.WriteString(commit.GetCommit().GetMessage()) content := strings.NewReader(sb.String()) return handlers.HandleFile(ctx, io.NopCloser(content), chunkSkel, reporter) } func (s *Source) ChunkUnit(ctx context.Context, unit sources.SourceUnit, reporter sources.ChunkReporter) error { repoURL, kind := unit.SourceUnitID() ctx = context.WithValue(ctx, "repo", repoURL) // If the unit has an installation ID, use it. Fetching it is slow, // specifically it stalls large multi-org scans on rate limits (INT-790). // [CG] This is pretty ugly; if you can clean it up please do (I failed). if ac, ok := s.connector.(*appConnector); ok && s.conn.ScanAllInstallations && kind == "repo" { if ru, ok := unit.(RepoUnit); ok && ru.InstallationID > 0 { ac.setRepoInstallationForRepoName(repoURL, ru.Name, ru.InstallationID) } else if err := s.mapReposToInstallations(ctx, ac, []string{repoURL}); err != nil { return err } } // ChunkUnit is not guaranteed to be called from Enumerate, so we must // check and fetch the repoInfoCache for this repo. repoURL, err := s.ensureRepoInfoCache(ctx, repoURL, &chunkErrorReporter{reporter: reporter}) if err != nil { return err } return s.scanRepo(ctx, repoURL, reporter) }