* Support scanning all GitHub App installations Add scan_all_installations handling for GitHub App sources so multi-installation scans use the correct per-installation clients, token mapping, and fail-closed repo ownership checks. * Cache per-installation GitHub App API clients Avoid creating a new client and token negotiation on every repo operation by caching clients per installation ID, reusing the configured default client, and giving each cached client its own apps transport to avoid races on the shared transport's token-refresh path. * Cache per-installation GraphQL clients and hoist URL scheme regex Reuse GraphQL clients for non-default installations the same way REST clients are cached, and compile the URL scheme regex once at package level instead of on every call. * Log per-installation mapping errors on early return When all requested repos get mapped, failures from other installations no longer disappear silently; they're logged before the success return. * Report per-installation enumeration failures without aborting scan-all A transient failure in one installation no longer aborts the whole multi-org enumeration; it's reported via the unit reporter and remaining installations continue. Also avoid nested mutex acquisition in GraphQLClientForRepo with a double-check cache pattern. * Report member enumeration failures and use nil-safe installation getters Surface per-org member enumeration failures through the unit reporter instead of only logging them, and use GetAccount() getters for installation account access. * Simplify GitHub App installation setup * Address GitHub App connector review followups Unify per-installation client state, centralize GitHub URL helpers, and tighten wiki/endpoint handling so multi-installation scans keep auth routing explicit and maintainable. * Harden GitHub App multi-installation scans Surface partial installation enumeration failures, tighten wiki URL disambiguation, and make rate-limit waits cancellation-aware so scan-all behavior is safer and easier to diagnose. * Fix GitHub App ID parse error message Report the original app ID string when parsing fails so invalid configuration errors are actionable. * Avoid bogus wiki alias mappings Only derive double-wiki installation aliases when repository metadata confirms the repo itself is named with a .wiki suffix.
2317 lines
74 KiB
Go
2317 lines
74 KiB
Go
package github
|
|
|
|
import (
|
|
"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
|
|
sources.CommonSourceUnitUnmarshaller
|
|
|
|
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"`
|
|
}
|
|
|
|
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 }
|
|
|
|
// --------------------------------------------------------------------------------
|
|
|
|
// 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
|
|
*/
|
|
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
|
|
}
|
|
if err := dedupeReporter.UnitOk(ctx, RepoUnit{Name: name, URL: url}); 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) {
|
|
// 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))
|
|
// 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 {
|
|
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
|
|
}
|
|
|
|
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., `[email protected]` -> `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/<gist_id>
|
|
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', '<gist_id>')", urlString)
|
|
}
|
|
case 3:
|
|
// github.com/<user>/repo>
|
|
// gist.github.com/<user>/<gist_id>
|
|
// github.company.org/<user>/repo>
|
|
// github.company.org/gist/<gist_id>
|
|
case 4:
|
|
// github.company.org/gist/<user/<id>
|
|
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', '<owner>', '<gist_id>')", 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 {
|
|
return fmt.Errorf("could not download file for scan: %w", err)
|
|
}
|
|
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)
|
|
}
|
|
|
|
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 connector, ok := s.connector.(*appConnector); ok && s.conn.ScanAllInstallations && kind == "repo" {
|
|
if err := s.mapReposToInstallations(ctx, connector, []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)
|
|
}
|