577 lines
15 KiB
Go
577 lines
15 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math/rand"
|
|
"net/http"
|
|
"os"
|
|
"regexp"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/bradleyfalzon/ghinstallation"
|
|
"github.com/go-errors/errors"
|
|
gogit "github.com/go-git/go-git/v5"
|
|
"github.com/google/go-github/v42/github"
|
|
"github.com/sirupsen/logrus"
|
|
log "github.com/sirupsen/logrus"
|
|
"golang.org/x/oauth2"
|
|
"golang.org/x/sync/semaphore"
|
|
"google.golang.org/protobuf/proto"
|
|
"google.golang.org/protobuf/types/known/anypb"
|
|
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/source_metadatapb"
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/sourcespb"
|
|
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/common"
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/giturl"
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/sanitizer"
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/sources"
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/sources/git"
|
|
)
|
|
|
|
type Source struct {
|
|
name string
|
|
sourceId int64
|
|
jobId int64
|
|
verify bool
|
|
repos []string
|
|
orgs []string
|
|
members []string
|
|
git *git.Git
|
|
httpClient *http.Client
|
|
aCtx context.Context
|
|
sources.Progress
|
|
log *log.Entry
|
|
token string
|
|
conn *sourcespb.GitHub
|
|
jobSem *semaphore.Weighted
|
|
}
|
|
|
|
// Ensure the Source satisfies the interface at compile time
|
|
var _ sources.Source = (*Source)(nil)
|
|
var endsWithGithub = regexp.MustCompile(`github.com/?$`)
|
|
|
|
// 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 sourcespb.SourceType_SOURCE_TYPE_GITHUB
|
|
}
|
|
|
|
func (s *Source) SourceID() int64 {
|
|
return s.sourceId
|
|
}
|
|
|
|
func (s *Source) JobID() int64 {
|
|
return s.jobId
|
|
}
|
|
|
|
func (s *Source) Token(ctx context.Context, installationClient *github.Client) (string, error) {
|
|
switch cred := s.conn.GetCredential().(type) {
|
|
case *sourcespb.GitHub_Unauthenticated:
|
|
// do nothing
|
|
case *sourcespb.GitHub_GithubApp:
|
|
id, err := strconv.ParseInt(cred.GithubApp.InstallationId, 10, 64)
|
|
if err != nil {
|
|
return "", errors.New(err)
|
|
}
|
|
token, _, err := installationClient.Apps.CreateInstallationToken(
|
|
ctx, id, &github.InstallationTokenOptions{})
|
|
if err != nil {
|
|
return "", errors.WrapPrefix(err, "unable to create installation token", 0)
|
|
}
|
|
return token.GetToken(), nil // TODO: multiple workers request this, track the TTL
|
|
case *sourcespb.GitHub_Token:
|
|
return cred.Token, nil
|
|
}
|
|
|
|
return "", errors.New("unhandled credential type for token fetch")
|
|
}
|
|
|
|
// Init returns an initialized GitHub source.
|
|
func (s *Source) Init(aCtx context.Context, name string, jobId, sourceId int64, verify bool, connection *anypb.Any, concurrency int) error {
|
|
s.log = log.WithField("source", s.Type()).WithField("name", name)
|
|
|
|
s.aCtx = aCtx
|
|
s.name = name
|
|
s.sourceId = sourceId
|
|
s.jobId = jobId
|
|
s.verify = verify
|
|
s.jobSem = semaphore.NewWeighted(int64(concurrency))
|
|
|
|
s.httpClient = common.SaneHttpClient()
|
|
|
|
var conn sourcespb.GitHub
|
|
err := anypb.UnmarshalTo(connection, &conn, proto.UnmarshalOptions{})
|
|
if err != nil {
|
|
errors.WrapPrefix(err, "error unmarshalling connection", 0)
|
|
}
|
|
s.conn = &conn
|
|
|
|
s.git = git.NewGit(s.Type(), s.JobID(), s.SourceID(), s.name, s.verify, runtime.NumCPU(),
|
|
func(file, email, commit, repository string) *source_metadatapb.MetaData {
|
|
return &source_metadatapb.MetaData{
|
|
Data: &source_metadatapb.MetaData_Github{
|
|
Github: &source_metadatapb.Github{
|
|
Commit: sanitizer.UTF8(commit),
|
|
File: sanitizer.UTF8(file),
|
|
Email: sanitizer.UTF8(email),
|
|
Repository: sanitizer.UTF8(repository),
|
|
Link: git.GenerateLink(repository, commit, file),
|
|
},
|
|
},
|
|
}
|
|
})
|
|
|
|
return nil
|
|
}
|
|
|
|
// Chunks emits chunks of bytes over a channel.
|
|
func (s *Source) Chunks(ctx context.Context, chunksChan chan *sources.Chunk) error {
|
|
apiEndpoint := s.conn.Endpoint
|
|
if len(s.conn.Endpoint) == 0 || endsWithGithub.MatchString(apiEndpoint) {
|
|
apiEndpoint = "https://api.github.com"
|
|
}
|
|
|
|
var installationClient *github.Client
|
|
s.repos = s.conn.Repositories
|
|
s.orgs = s.conn.Organizations
|
|
|
|
var apiClient *github.Client
|
|
switch cred := s.conn.GetCredential().(type) {
|
|
case *sourcespb.GitHub_Unauthenticated:
|
|
apiClient = github.NewClient(s.httpClient)
|
|
if len(s.orgs) > 30 {
|
|
log.Warn("You may experience rate limiting when using the unauthenticated GitHub api. Consider using an authenticated scan instead.")
|
|
}
|
|
|
|
if len(s.orgs) > 0 {
|
|
for _, org := range s.orgs {
|
|
s.addReposByOrg(ctx, apiClient, org)
|
|
s.addReposByUser(ctx, apiClient, org)
|
|
}
|
|
}
|
|
case *sourcespb.GitHub_Token:
|
|
// needed for clones
|
|
s.token = cred.Token
|
|
|
|
// needed to list repos
|
|
ts := oauth2.StaticTokenSource(
|
|
&oauth2.Token{AccessToken: cred.Token},
|
|
)
|
|
tc := oauth2.NewClient(context.TODO(), ts)
|
|
|
|
var err error
|
|
// If we're using public github, make a regular client.
|
|
// Otherwise make an enterprise client
|
|
if apiEndpoint == "https://api.github.com" {
|
|
apiClient = github.NewClient(tc)
|
|
} else {
|
|
apiClient, err = github.NewEnterpriseClient(apiEndpoint, apiEndpoint, tc)
|
|
if err != nil {
|
|
return errors.New(err)
|
|
}
|
|
}
|
|
|
|
// TODO: this should support scanning users too
|
|
|
|
specificScope := false
|
|
|
|
if len(s.repos) > 0 {
|
|
specificScope = true
|
|
}
|
|
|
|
if len(s.orgs) > 0 {
|
|
specificScope = true
|
|
for _, org := range s.orgs {
|
|
if !strings.HasSuffix(org, ".git") {
|
|
s.addReposByOrg(ctx, apiClient, org)
|
|
s.addReposByUser(ctx, apiClient, org)
|
|
}
|
|
}
|
|
}
|
|
|
|
user, _, err := apiClient.Users.Get(context.TODO(), "")
|
|
if err != nil {
|
|
return errors.New(err)
|
|
}
|
|
|
|
if !specificScope {
|
|
s.addReposByUser(ctx, apiClient, user.GetLogin())
|
|
// Scan for orgs is default with a token. GitHub App enumerates the repositories
|
|
// that were assigned to it in GitHub App settings.
|
|
s.addOrgsByUser(ctx, apiClient, user.GetLogin())
|
|
for _, org := range s.orgs {
|
|
s.addReposByOrg(ctx, apiClient, org)
|
|
}
|
|
}
|
|
|
|
s.addGistsByUser(ctx, apiClient, user.GetLogin())
|
|
for _, org := range s.orgs {
|
|
// TODO: Test it actually works to list org gists like this.
|
|
s.addGistsByUser(ctx, apiClient, org)
|
|
}
|
|
case *sourcespb.GitHub_GithubApp:
|
|
installationID, err := strconv.ParseInt(cred.GithubApp.InstallationId, 10, 64)
|
|
if err != nil {
|
|
return errors.New(err)
|
|
}
|
|
|
|
appID, err := strconv.ParseInt(cred.GithubApp.AppId, 10, 64)
|
|
if err != nil {
|
|
return errors.New(err)
|
|
}
|
|
|
|
// This client is used for most APIs
|
|
itr, err := ghinstallation.New(
|
|
common.SaneHttpClient().Transport,
|
|
appID,
|
|
installationID,
|
|
[]byte(cred.GithubApp.PrivateKey))
|
|
if err != nil {
|
|
return errors.New(err)
|
|
}
|
|
itr.BaseURL = apiEndpoint
|
|
apiClient, err = github.NewEnterpriseClient(apiEndpoint, apiEndpoint, &http.Client{Transport: itr})
|
|
if err != nil {
|
|
return errors.New(err)
|
|
}
|
|
|
|
// This client is required to create installation tokens for cloning.. Otherwise the required JWT is not in the
|
|
// request for the token :/
|
|
appItr, err := ghinstallation.NewAppsTransport(
|
|
common.SaneHttpClient().Transport,
|
|
appID,
|
|
[]byte(cred.GithubApp.PrivateKey))
|
|
if err != nil {
|
|
return errors.New(err)
|
|
}
|
|
appItr.BaseURL = apiEndpoint
|
|
installationClient, err = github.NewEnterpriseClient(apiEndpoint, apiEndpoint, &http.Client{Transport: appItr})
|
|
if err != nil {
|
|
return errors.New(err)
|
|
}
|
|
|
|
err = s.addReposByApp(ctx, apiClient)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
//check if we need to find user repos
|
|
if s.conn.ScanUsers {
|
|
err := s.addMembersByApp(ctx, installationClient, apiClient)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
log.Infof("Scanning repos from %v organization members.", len(s.members))
|
|
for _, member := range s.members {
|
|
s.addGistsByUser(ctx, apiClient, member)
|
|
s.addReposByUser(ctx, apiClient, member)
|
|
}
|
|
|
|
}
|
|
default:
|
|
return errors.Errorf("Invalid configuration given for source. Name: %s, Type: %s", s.name, s.Type())
|
|
}
|
|
|
|
s.normalizeRepos(ctx, apiClient)
|
|
|
|
if _, ok := os.LookupEnv("DO_NOT_RANDOMIZE"); !ok {
|
|
//Randomize channel scan order on each scan
|
|
rand.Seed(time.Now().UnixNano())
|
|
rand.Shuffle(len(s.repos), func(i, j int) { s.repos[i], s.repos[j] = s.repos[j], s.repos[i] })
|
|
}
|
|
|
|
scanned := 0
|
|
|
|
log.Debugf("Found %v total repos to scan", len(s.repos))
|
|
wg := sync.WaitGroup{}
|
|
errChan := make(chan error)
|
|
for i, repoURL := range s.repos {
|
|
s.jobSem.Acquire(ctx, 1)
|
|
wg.Add(1)
|
|
go func(ctx context.Context, errCh chan error, repoURL string, i int) {
|
|
defer s.jobSem.Release(1)
|
|
defer wg.Done()
|
|
|
|
s.SetProgressComplete(i, len(s.repos), fmt.Sprintf("Repo: %s", repoURL))
|
|
|
|
if !strings.HasSuffix(repoURL, ".git") {
|
|
return
|
|
}
|
|
|
|
s.log.WithField("repo", repoURL).Debugf("attempting to clone repo %d/%d", i+1, len(s.repos))
|
|
var path string
|
|
var repo *gogit.Repository
|
|
var err error
|
|
|
|
switch s.conn.GetCredential().(type) {
|
|
case *sourcespb.GitHub_Unauthenticated:
|
|
path, repo, err = git.CloneRepoUsingUnauthenticated(repoURL)
|
|
default:
|
|
var token string
|
|
token, err = s.Token(ctx, installationClient)
|
|
if err != nil {
|
|
errCh <- err
|
|
return
|
|
}
|
|
path, repo, err = git.CloneRepoUsingToken(token, repoURL, "clone")
|
|
}
|
|
|
|
defer os.RemoveAll(path)
|
|
if err != nil {
|
|
log.WithError(err).Errorf("unable to clone repo (%s), continuing", repoURL)
|
|
return
|
|
}
|
|
err = s.git.ScanRepo(ctx, repo, git.NewScanOptions(), chunksChan)
|
|
if err != nil {
|
|
log.WithError(err).Errorf("unable to scan repo, continuing")
|
|
}
|
|
scanned += 1
|
|
logrus.Debugf("scanned %d/%d repos", scanned, len(s.repos))
|
|
}(ctx, errChan, repoURL, i)
|
|
}
|
|
|
|
wg.Wait()
|
|
close(errChan)
|
|
// This only returns first error which is what we did prior to concurrency
|
|
for err := range errChan {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// handleRateLimit returns true if a rate limit was handled
|
|
//unauthed github has a rate limit of 60 requests per hour. This will likely only be exhausted if many users/orgs are scanned without auth
|
|
func handleRateLimit(err error) bool {
|
|
limit, ok := err.(*github.RateLimitError)
|
|
if !ok {
|
|
return false
|
|
}
|
|
log.WithField("retry-after", limit.Message).Debug("handling rate limit (5 minutes retry)")
|
|
time.Sleep(time.Minute * 5)
|
|
return true
|
|
}
|
|
|
|
func (s *Source) addReposByOrg(ctx context.Context, apiClient *github.Client, org string) {
|
|
opts := &github.RepositoryListByOrgOptions{
|
|
ListOptions: github.ListOptions{
|
|
PerPage: 100,
|
|
},
|
|
}
|
|
for {
|
|
someRepos, res, err := apiClient.Repositories.ListByOrg(ctx, org, opts)
|
|
if err == nil {
|
|
defer res.Body.Close()
|
|
}
|
|
if handled := handleRateLimit(err); handled {
|
|
continue
|
|
}
|
|
if len(someRepos) == 0 || err != nil {
|
|
break
|
|
}
|
|
for _, r := range someRepos {
|
|
if r.GetFork() && !s.conn.IncludeForks {
|
|
continue
|
|
}
|
|
common.AddStringSliceItem(r.GetCloneURL(), &s.repos)
|
|
}
|
|
if res.NextPage == 0 {
|
|
break
|
|
}
|
|
opts.Page = res.NextPage
|
|
}
|
|
}
|
|
|
|
func (s *Source) addReposByUser(ctx context.Context, apiClient *github.Client, user string) {
|
|
opts := &github.RepositoryListOptions{
|
|
ListOptions: github.ListOptions{
|
|
PerPage: 50,
|
|
},
|
|
}
|
|
for {
|
|
someRepos, res, err := apiClient.Repositories.List(ctx, user, opts)
|
|
if err == nil {
|
|
defer res.Body.Close()
|
|
}
|
|
if handled := handleRateLimit(err); handled {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
break
|
|
}
|
|
for _, r := range someRepos {
|
|
if r.GetFork() && !s.conn.IncludeForks {
|
|
continue
|
|
}
|
|
common.AddStringSliceItem(r.GetCloneURL(), &s.repos)
|
|
}
|
|
if res.NextPage == 0 {
|
|
break
|
|
}
|
|
opts.Page = res.NextPage
|
|
}
|
|
}
|
|
|
|
func (s *Source) addGistsByUser(ctx context.Context, apiClient *github.Client, user string) {
|
|
gistOpts := &github.GistListOptions{}
|
|
for {
|
|
gists, resp, err := apiClient.Gists.List(ctx, user, gistOpts)
|
|
if err == nil {
|
|
defer resp.Body.Close()
|
|
}
|
|
if handled := handleRateLimit(err); handled {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
log.WithError(err).Warnf("Could not get gists for user %s", user)
|
|
}
|
|
for _, gist := range gists {
|
|
common.AddStringSliceItem(gist.GetGitPullURL(), &s.repos)
|
|
}
|
|
if resp == nil || resp.NextPage == 0 {
|
|
break
|
|
}
|
|
gistOpts.Page = resp.NextPage
|
|
}
|
|
return
|
|
}
|
|
|
|
func (s *Source) addMembersByApp(ctx context.Context, installationClient *github.Client, apiClient *github.Client) error {
|
|
|
|
opts := &github.ListOptions{
|
|
PerPage: 500,
|
|
}
|
|
optsOrg := &github.ListMembersOptions{
|
|
PublicOnly: false,
|
|
ListOptions: *opts,
|
|
}
|
|
|
|
installs, _, err := installationClient.Apps.ListInstallations(ctx, opts)
|
|
if err != nil {
|
|
log.WithError(err).Warn("Could not enumerate organizations using user")
|
|
return err
|
|
}
|
|
for _, org := range installs {
|
|
for {
|
|
members, res, err := apiClient.Organizations.ListMembers(ctx, *org.Account.Login, optsOrg)
|
|
if err == nil {
|
|
defer res.Body.Close()
|
|
}
|
|
if handled := handleRateLimit(err); handled {
|
|
continue
|
|
}
|
|
if err != nil || len(members) == 0 {
|
|
errText := "Could not list organization members: Please install on an organization. Otherwise, this is an older version of the Github app, please delete and re-add this source!"
|
|
log.WithError(err).Warnf(errText)
|
|
return errors.New(errText)
|
|
}
|
|
for _, m := range members {
|
|
usr := m.Login
|
|
if usr == nil || *usr == "" {
|
|
continue
|
|
}
|
|
common.AddStringSliceItem(*usr, &s.members)
|
|
}
|
|
if res.NextPage == 0 {
|
|
break
|
|
}
|
|
opts.Page = res.NextPage
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Source) addReposByApp(ctx context.Context, apiClient *github.Client) error {
|
|
// Authenticated enumeration of repos
|
|
opts := &github.ListOptions{
|
|
PerPage: 100,
|
|
}
|
|
for {
|
|
someRepos, res, err := apiClient.Apps.ListRepos(ctx, opts)
|
|
if err == nil {
|
|
defer res.Body.Close()
|
|
}
|
|
if handled := handleRateLimit(err); handled {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return errors.WrapPrefix(err, "unable to list repositories", 0)
|
|
}
|
|
for _, r := range someRepos.Repositories {
|
|
if r.GetFork() && !s.conn.IncludeForks {
|
|
continue
|
|
}
|
|
common.AddStringSliceItem(r.GetCloneURL(), &s.repos)
|
|
}
|
|
if res.NextPage == 0 {
|
|
break
|
|
}
|
|
opts.Page = res.NextPage
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Source) addOrgsByUser(ctx context.Context, apiClient *github.Client, user string) {
|
|
orgOpts := &github.ListOptions{
|
|
PerPage: 100,
|
|
}
|
|
for {
|
|
orgs, resp, err := apiClient.Organizations.List(ctx, "", orgOpts)
|
|
if err == nil {
|
|
defer resp.Body.Close()
|
|
}
|
|
if handled := handleRateLimit(err); handled {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
log.WithError(err).Errorf("Could not list organizations for %s", user)
|
|
return
|
|
}
|
|
for _, org := range orgs {
|
|
var name string
|
|
if org.Name != nil {
|
|
name = *org.Name
|
|
} else if org.Login != nil {
|
|
name = *org.Login
|
|
} else {
|
|
continue
|
|
}
|
|
common.AddStringSliceItem(name, &s.orgs)
|
|
}
|
|
if resp.NextPage == 0 {
|
|
break
|
|
}
|
|
orgOpts.Page = resp.NextPage
|
|
}
|
|
}
|
|
|
|
func (s *Source) normalizeRepos(ctx context.Context, apiClient *github.Client) {
|
|
// TODO: Add check/fix for repos that are missing scheme
|
|
var newRepoList []string
|
|
for _, repo := range s.repos {
|
|
if parts := strings.Split(repo, "/"); len(parts) == 1 {
|
|
origSources := len(s.repos)
|
|
s.addGistsByUser(ctx, apiClient, repo)
|
|
s.addReposByUser(ctx, apiClient, repo)
|
|
if origSources != len(s.repos) {
|
|
common.RemoveStringSliceItem(repo, &s.repos)
|
|
continue
|
|
}
|
|
}
|
|
repoNormalized, err := giturl.NormalizeGithubRepo(repo)
|
|
if err != nil {
|
|
log.WithError(err).Warnf("Repo not in expected format: %s", repo)
|
|
}
|
|
newRepoList = append(newRepoList, repoNormalized)
|
|
}
|
|
}
|