mirror of
https://github.com/trufflesecurity/trufflehog.git
synced 2026-06-15 13:24:37 +00:00
396 lines
14 KiB
Go
396 lines
14 KiB
Go
package github
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
|
|
gogit "github.com/go-git/go-git/v5"
|
|
"github.com/google/go-github/v67/github"
|
|
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/giturl"
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/source_metadatapb"
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/sources"
|
|
)
|
|
|
|
// repoInfoCache is a thread-safe cache to store information about repositories.
|
|
type repoInfoCache struct {
|
|
mu sync.RWMutex
|
|
cache map[string]repoInfo // the actual cache storing the repository information by URL.
|
|
}
|
|
|
|
// newRepoInfoCache creates a new instance of repoInfoCache with an empty cache.
|
|
func newRepoInfoCache() repoInfoCache {
|
|
return repoInfoCache{
|
|
cache: make(map[string]repoInfo),
|
|
}
|
|
}
|
|
|
|
// put adds repository information to the cache, locking for thread safety.
|
|
func (r *repoInfoCache) put(repoURL string, info repoInfo) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.cache[repoURL] = info
|
|
}
|
|
|
|
// get retrieves repository information from the cache, locking for thread safety.
|
|
// it returns the info and a boolean indicating whether it was found.
|
|
func (r *repoInfoCache) get(repoURL string) (repoInfo, bool) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
|
|
info, ok := r.cache[repoURL]
|
|
return info, ok
|
|
}
|
|
|
|
// repoInfo holds basic metadata about a repository.
|
|
type repoInfo struct {
|
|
owner string // repository owner (user|organization).
|
|
name string // repository name.
|
|
fullName string // full repository name (owner/repo).
|
|
hasWiki bool // whether the repository is likely to have a wiki.
|
|
size int // size of the repository in kilobytes.
|
|
visibility source_metadatapb.Visibility // visibility of the repository (public/private).
|
|
}
|
|
|
|
// cloneRepo clones a repository given its URL, returns the path and the repository object.
|
|
func (s *Source) cloneRepo(ctx context.Context, repoURL string) (string, *gogit.Repository, error) {
|
|
return s.connector.Clone(ctx, repoURL)
|
|
}
|
|
|
|
// repoListOptions is an interface for types that provide options for listing repositories.
|
|
type repoListOptions interface {
|
|
getListOptions() *github.ListOptions
|
|
}
|
|
|
|
// repoLister is a function signature for listing repositories based on certain options.
|
|
type repoLister func(ctx context.Context, target string, opts repoListOptions) ([]*github.Repository, *github.Response, error)
|
|
|
|
// === GitHub App Repositories ===
|
|
|
|
// appListOptions represents options for listing repositories by GitHub Apps.
|
|
type appListOptions struct {
|
|
github.ListOptions
|
|
}
|
|
|
|
// getListOptions returns the ListOptions for appListOptions.
|
|
func (a *appListOptions) getListOptions() *github.ListOptions {
|
|
return &a.ListOptions
|
|
}
|
|
|
|
// appListReposWrapper lists repositories for a GitHub App, using the provided options.
|
|
func (s *Source) appListReposWrapper(ctx context.Context, _ string, opts repoListOptions) ([]*github.Repository, *github.Response, error) {
|
|
someRepos, res, err := s.connector.APIClient().Apps.ListRepos(ctx, opts.getListOptions())
|
|
if someRepos != nil {
|
|
return someRepos.Repositories, res, err
|
|
}
|
|
return nil, res, err
|
|
}
|
|
|
|
// getReposByApp retrieves repositories by a GitHub App.
|
|
func (s *Source) getReposByApp(ctx context.Context, reporter sources.UnitReporter) error {
|
|
return s.processRepos(ctx, "", reporter, s.appListReposWrapper, &appListOptions{
|
|
ListOptions: github.ListOptions{
|
|
PerPage: defaultPagination, // Default pagination setting for API requests.
|
|
},
|
|
})
|
|
}
|
|
|
|
// === GitHub User Repositories ===
|
|
|
|
// userListOptions represents options for listing repositories by user.
|
|
type userListOptions struct {
|
|
github.RepositoryListByUserOptions // embedded options for listing repositories by user.
|
|
}
|
|
|
|
// getListOptions returns the ListOptions for userListOptions.
|
|
func (u *userListOptions) getListOptions() *github.ListOptions {
|
|
return &u.ListOptions
|
|
}
|
|
|
|
// authenticatedUserListOption represents options for listing repositories by authenticated user.
|
|
type authenticatedUserListOption struct {
|
|
github.RepositoryListByAuthenticatedUserOptions // embedded options for listing repositories by authenticated user.
|
|
}
|
|
|
|
// getListOptions returns the ListOptions for authenticatedUserListOption.
|
|
func (a *authenticatedUserListOption) getListOptions() *github.ListOptions {
|
|
return &a.ListOptions
|
|
}
|
|
|
|
// userListReposWrapper lists repositories for a user, using the provided options.
|
|
func (s *Source) userListReposWrapper(ctx context.Context, user string, opts repoListOptions) ([]*github.Repository, *github.Response, error) {
|
|
return s.connector.APIClient().Repositories.ListByUser(ctx, user, &opts.(*userListOptions).RepositoryListByUserOptions)
|
|
}
|
|
|
|
// authenticatedUserListReposWrapper lists repositories for an authenticated user, using the provided options.
|
|
func (s *Source) authenticatedUserListReposWrapper(ctx context.Context, user string, opts repoListOptions) ([]*github.Repository, *github.Response, error) {
|
|
return s.connector.APIClient().Repositories.ListByAuthenticatedUser(ctx, &opts.(*authenticatedUserListOption).RepositoryListByAuthenticatedUserOptions)
|
|
}
|
|
|
|
// getReposByUser retrieves repositories for a given user.
|
|
func (s *Source) getReposByUser(ctx context.Context, user string, authenticated bool, reporter sources.UnitReporter) error {
|
|
if authenticated {
|
|
return s.processRepos(ctx, user, reporter, s.authenticatedUserListReposWrapper, &authenticatedUserListOption{
|
|
RepositoryListByAuthenticatedUserOptions: github.RepositoryListByAuthenticatedUserOptions{
|
|
ListOptions: github.ListOptions{
|
|
PerPage: defaultPagination,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
return s.processRepos(ctx, user, reporter, s.userListReposWrapper, &userListOptions{
|
|
RepositoryListByUserOptions: github.RepositoryListByUserOptions{
|
|
ListOptions: github.ListOptions{
|
|
PerPage: defaultPagination,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
// === GitHub Organization Repositories ===
|
|
|
|
// orgListOptions represents options for listing repositories by organization.
|
|
type orgListOptions struct {
|
|
github.RepositoryListByOrgOptions // Embedded options for listing repositories by organization.
|
|
}
|
|
|
|
// getListOptions returns the ListOptions for orgListOptions.
|
|
func (o *orgListOptions) getListOptions() *github.ListOptions {
|
|
return &o.ListOptions
|
|
}
|
|
|
|
// orgListReposWrapper lists repositories for an organization, using the provided options.
|
|
func (s *Source) orgListReposWrapper(ctx context.Context, org string, opts repoListOptions) ([]*github.Repository, *github.Response, error) {
|
|
// TODO: It's possible to exclude forks when making the API request rather than doing post-request filtering.
|
|
return s.connector.APIClient().Repositories.ListByOrg(ctx, org, &opts.(*orgListOptions).RepositoryListByOrgOptions)
|
|
}
|
|
|
|
// getReposByOrg retrieves repositories for a given organization.
|
|
func (s *Source) getReposByOrg(ctx context.Context, org string, reporter sources.UnitReporter) error {
|
|
return s.processRepos(ctx, org, reporter, s.orgListReposWrapper, &orgListOptions{
|
|
RepositoryListByOrgOptions: github.RepositoryListByOrgOptions{
|
|
ListOptions: github.ListOptions{
|
|
PerPage: defaultPagination,
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
// userType indicates whether an account belongs to a person or organization.
|
|
//
|
|
// See:
|
|
// - https://docs.github.com/en/get-started/learning-about-github/types-of-github-accounts
|
|
// - https://docs.github.com/en/rest/users/users?apiVersion=2022-11-28#get-a-user
|
|
type userType int
|
|
|
|
// Constants for userType.
|
|
const (
|
|
unknown userType = iota // default invalid state.
|
|
user // the account is a person (https://docs.github.com/en/rest/users/users).
|
|
organization // the account is an organization (https://docs.github.com/en/rest/orgs/orgs).
|
|
)
|
|
|
|
// getReposByOrgOrUser retrieves repositories for an organization or user.
|
|
func (s *Source) getReposByOrgOrUser(ctx context.Context, name string, authenticated bool, reporter sources.UnitReporter) (userType, error) {
|
|
var err error
|
|
|
|
// try to get repositories for the organization first.
|
|
err = s.getReposByOrg(ctx, name, reporter)
|
|
if err == nil {
|
|
return organization, nil
|
|
} else if !isGitHub404Error(err) { // if the error is not a "not found" error, report it.
|
|
if err := reporter.UnitErr(ctx, fmt.Errorf("error getting repos by org: %w", err)); err != nil {
|
|
return unknown, err
|
|
}
|
|
|
|
return unknown, err
|
|
}
|
|
|
|
// if organization repos aren't found, try user repos.
|
|
err = s.getReposByUser(ctx, name, authenticated, reporter)
|
|
if err == nil {
|
|
if err := s.addUserGistsToCache(ctx, name, reporter); err != nil {
|
|
ctx.Logger().Error(err, "Unable to add user to cache")
|
|
}
|
|
return user, nil
|
|
} else if !isGitHub404Error(err) { // if the error is not a "not found" error, report it.
|
|
return unknown, err
|
|
}
|
|
|
|
// if neither organization nor user repos are found, return an error.
|
|
return unknown, fmt.Errorf("account '%s' not found", name)
|
|
}
|
|
|
|
// isGitHub404Error checks if the error is a GitHub API error with a 404 status.
|
|
func isGitHub404Error(err error) bool {
|
|
var ghErr *github.ErrorResponse
|
|
if !errors.As(err, &ghErr) {
|
|
return false
|
|
}
|
|
|
|
return ghErr.Response.StatusCode == http.StatusNotFound
|
|
}
|
|
|
|
// processRepos processes repositories from a source, handling pagination and rate limits.
|
|
func (s *Source) processRepos(ctx context.Context, target string, reporter sources.UnitReporter, listRepos repoLister, listOpts repoListOptions) error {
|
|
logger := ctx.Logger().WithValues("target", target)
|
|
opts := listOpts.getListOptions()
|
|
|
|
var (
|
|
numRepos, numForks int
|
|
uniqueOrgs = map[string]struct{}{}
|
|
)
|
|
|
|
// loop to handle pagination.
|
|
for {
|
|
someRepos, res, err := listRepos(ctx, target, listOpts)
|
|
if s.handleRateLimitWithUnitReporter(ctx, reporter, err) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx.Logger().V(2).Info("Listed repos", "page", opts.Page, "last_page", res.LastPage)
|
|
for _, r := range someRepos {
|
|
if r.GetFork() {
|
|
if !s.conn.IncludeForks {
|
|
continue
|
|
}
|
|
numForks++
|
|
}
|
|
numRepos++
|
|
|
|
// track unique organizations.
|
|
if r.GetOwner().GetType() == "Organization" {
|
|
uniqueOrgs[r.GetOwner().GetLogin()] = struct{}{}
|
|
}
|
|
|
|
repoName, repoURL := r.GetFullName(), r.GetCloneURL()
|
|
|
|
// Check if we should process this repository based on the filter
|
|
if !s.filteredRepoCache.wantRepo(repoName) {
|
|
continue
|
|
}
|
|
|
|
s.totalRepoSize += r.GetSize()
|
|
s.filteredRepoCache.Set(repoName, repoURL)
|
|
s.cacheRepoInfo(r)
|
|
if err := reporter.UnitOk(ctx, RepoUnit{Name: repoName, URL: repoURL}); err != nil {
|
|
return err
|
|
}
|
|
|
|
logger.V(3).Info("repo attributes", "name", repoName, "kb_size", r.GetSize(), "repo_url", repoURL)
|
|
}
|
|
|
|
if res.NextPage == 0 {
|
|
break
|
|
}
|
|
opts.Page = res.NextPage
|
|
}
|
|
|
|
// final logging of repository stats.
|
|
logger.V(2).Info("found repos", "total", numRepos, "num_forks", numForks, "num_orgs", len(uniqueOrgs))
|
|
githubOrgsEnumerated.WithLabelValues(s.name).Add(float64(len(uniqueOrgs)))
|
|
|
|
return nil
|
|
}
|
|
|
|
// cacheRepoInfo caches basic information about a repository for later use.
|
|
func (s *Source) cacheRepoInfo(r *github.Repository) {
|
|
info := repoInfo{
|
|
owner: r.GetOwner().GetLogin(),
|
|
name: r.GetName(),
|
|
fullName: r.GetFullName(),
|
|
hasWiki: r.GetHasWiki(),
|
|
size: r.GetSize(),
|
|
}
|
|
if r.GetPrivate() {
|
|
info.visibility = source_metadatapb.Visibility_private
|
|
} else {
|
|
info.visibility = source_metadatapb.Visibility_public
|
|
}
|
|
s.repoInfoCache.put(r.GetCloneURL(), info)
|
|
}
|
|
|
|
// cacheGistInfo caches information about a Gist.
|
|
func (s *Source) cacheGistInfo(g *github.Gist) {
|
|
info := repoInfo{
|
|
owner: g.GetOwner().GetLogin(),
|
|
}
|
|
if g.GetPublic() {
|
|
info.visibility = source_metadatapb.Visibility_public
|
|
} else {
|
|
info.visibility = source_metadatapb.Visibility_private
|
|
}
|
|
s.repoInfoCache.put(g.GetGitPullURL(), info)
|
|
}
|
|
|
|
// wikiIsReachable checks if the wiki for a repository is reachable by sending a HEAD request.
|
|
// Unfortunately, this isn't 100% accurate. Some repositories have `has_wiki: true` and don't redirect their wiki page,
|
|
// but still don't have a cloneable wiki.
|
|
func (s *Source) wikiIsReachable(ctx context.Context, repoURL string) bool {
|
|
wikiURL := strings.TrimSuffix(repoURL, ".git") + "/wiki"
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodHead, wikiURL, nil)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
res, err := s.connector.APIClient().Client().Do(req)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
_, _ = io.Copy(io.Discard, res.Body)
|
|
_ = res.Body.Close()
|
|
|
|
// if the wiki is disabled or unreachable, the request will be redirected.
|
|
return wikiURL == res.Request.URL.String()
|
|
}
|
|
|
|
// normalizeRepo normalizes a GitHub repository URL or name to its canonical form.
|
|
func (s *Source) normalizeRepo(repo string) (string, error) {
|
|
|
|
// If it's a full URL (has protocol), normalize it
|
|
if regexp.MustCompile(`^[a-z]+://`).MatchString(repo) {
|
|
|
|
return giturl.NormalizeGithubRepo(repo)
|
|
}
|
|
// If it's a repository name (contains / but not http), convert to full URL first
|
|
if strings.Contains(repo, "/") && !regexp.MustCompile(`^[a-z]+://`).MatchString(repo) {
|
|
fullURL := "https://github.com/" + repo
|
|
// If using GitHub Enterprise, adjust the URL accordingly
|
|
if s.conn != nil && s.conn.Endpoint != "" && !endsWithGithub.MatchString(s.conn.Endpoint) {
|
|
u, err := url.Parse(s.conn.Endpoint)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid enterprise endpoint: %w", err)
|
|
}
|
|
// we want to remove any path components from the endpoint and just use the host
|
|
u.Path = "/" + repo
|
|
fullURL = u.String()
|
|
}
|
|
return giturl.NormalizeGithubRepo(fullURL)
|
|
}
|
|
|
|
return "", fmt.Errorf("no repositories found for %s", repo)
|
|
}
|
|
|
|
// extractRepoNameFromUrl extracts the "owner/repo" name from a GitHub repository URL.
|
|
// Example: http://github.com/owner/repo.git -> owner/repo
|
|
// If an invalid URL is provided, it returns the original string.
|
|
func extractRepoNameFromUrl(repoURL string) string {
|
|
u, err := url.Parse(repoURL)
|
|
if err != nil {
|
|
return repoURL
|
|
}
|
|
cleanedPath := strings.Trim(u.Path, "/")
|
|
return strings.TrimSuffix(cleanedPath, ".git")
|
|
}
|