mirror of
https://github.com/trufflesecurity/trufflehog.git
synced 2026-06-15 13:24:37 +00:00
Chunk.Verify is an odd field - it originally conveys whether a source is going to run with verification, but then, at a certain point in the scanning pipeline, is mutated such that it instead indicates whether the chunk should be scanned with verification - which is not solely dependent on the source's verify flag. This is unnecessarily difficult to understand and maintain. This commit separates those two pieces of information into two flags: - Chunk.Verify has been renamed to Chunk.SourceVerify - It is no longer mutated; instead "should this chunk's secrets be verified?" is now captured by a new field on detectableChunk
496 lines
16 KiB
Go
496 lines
16 KiB
Go
package github
|
|
|
|
import (
|
|
"cmp"
|
|
"fmt"
|
|
"math/rand/v2"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/shurcooL/githubv4"
|
|
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/pb/source_metadatapb"
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/sanitizer"
|
|
"github.com/trufflesecurity/trufflehog/v3/pkg/sources"
|
|
)
|
|
|
|
// processIssuesWithComments process github repo issues with comments using graphql API
|
|
func (s *Source) processIssuesWithComments(
|
|
ctx context.Context, repoInfo repoInfo,
|
|
reporter sources.ChunkReporter, cutoffTime *time.Time,
|
|
) error {
|
|
vars := map[string]any{
|
|
owner: githubv4.String(repoInfo.owner),
|
|
repository: githubv4.String(repoInfo.name),
|
|
issuesPerPage: githubv4.Int(defaultPagination),
|
|
issuesPagination: (*githubv4.String)(nil),
|
|
commentsPerPage: githubv4.Int(defaultPagination),
|
|
commentsPagination: (*githubv4.String)(nil),
|
|
}
|
|
|
|
var totalIssues int
|
|
|
|
// loop will continue as long as there are issues in the repository
|
|
for {
|
|
var query issuesWithComments
|
|
err := s.connector.GraphQLClient().Query(ctx, &query, vars)
|
|
if s.handleGraphqlRateLimitWithChunkReporter(ctx, reporter, &query.RateLimit, err) {
|
|
continue
|
|
}
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("error fetching issues: %w", err)
|
|
}
|
|
|
|
totalIssues += len(query.GetIssues())
|
|
|
|
ctx.Logger().V(5).Info("Scanning Issues",
|
|
"total_issues", len(query.GetIssues()))
|
|
|
|
if err := s.chunkGraphqlIssues(ctx, repoInfo, query.Repository.Issues.Nodes, reporter); err != nil {
|
|
return err
|
|
}
|
|
|
|
// process each issue comments
|
|
for _, issue := range query.GetIssues() {
|
|
ctx.Logger().V(5).Info("Scanning Issue Comments",
|
|
"issue_id", issue.Number,
|
|
"total_comments", len(issue.GetIssueComments()),
|
|
)
|
|
|
|
if err := s.chunkComments(ctx, repoInfo, issue.GetIssueComments(), reporter, cutoffTime); err != nil {
|
|
return err
|
|
}
|
|
|
|
// if issue has more than 100 comments, we need to send another request for that specific issue to pull more comments
|
|
for issue.Comments.PageInfo.HasNextPage {
|
|
commentVars := map[string]any{
|
|
owner: githubv4.String(repoInfo.owner),
|
|
repository: githubv4.String(repoInfo.name),
|
|
issueNumber: githubv4.Int(issue.Number),
|
|
commentsPerPage: githubv4.Int(defaultPagination),
|
|
commentsPagination: issue.Comments.PageInfo.EndCursor,
|
|
}
|
|
|
|
// request this issue more comments
|
|
var commentsQuery singleIssueComments
|
|
err := s.connector.GraphQLClient().Query(ctx, &commentsQuery, commentVars)
|
|
if s.handleGraphqlRateLimitWithChunkReporter(ctx, reporter, &commentsQuery.RateLimit, err) {
|
|
continue
|
|
}
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("error fetching issue: %w", err)
|
|
}
|
|
|
|
ctx.Logger().V(5).Info("Scanning additional issue comments",
|
|
"issue_id", issue.Number,
|
|
"total_comments", len(commentsQuery.GetIssueComments()))
|
|
|
|
if err := s.chunkComments(ctx, repoInfo, commentsQuery.GetIssueComments(), reporter, cutoffTime); err != nil {
|
|
return err
|
|
}
|
|
|
|
// update page info for loop
|
|
issue.Comments.PageInfo = commentsQuery.Repository.Issue.Comments.PageInfo
|
|
}
|
|
}
|
|
|
|
// paginate issues
|
|
if !query.Repository.Issues.PageInfo.HasNextPage {
|
|
ctx.Logger().V(4).Info("Scanned all repository issues with comments", "total_issues_scanned", totalIssues)
|
|
break
|
|
}
|
|
|
|
// update issues pagination to go to next page
|
|
vars[issuesPagination] = githubv4.NewString(query.Repository.Issues.PageInfo.EndCursor)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// processPRWithComments process github repo pull requests with inline comments
|
|
func (s *Source) processPRWithComments(ctx context.Context, repoInfo repoInfo, reporter sources.ChunkReporter, cutoffTime *time.Time) error {
|
|
vars := map[string]any{
|
|
owner: githubv4.String(repoInfo.owner),
|
|
repository: githubv4.String(repoInfo.name),
|
|
pullRequestPerPage: githubv4.Int(defaultPagination),
|
|
pullRequestPagination: (*githubv4.String)(nil),
|
|
commentsPerPage: githubv4.Int(defaultPagination),
|
|
commentsPagination: (*githubv4.String)(nil),
|
|
}
|
|
|
|
var totalPRs int
|
|
|
|
// continue loop as long as there are pull requests remaining
|
|
for {
|
|
var query pullRequestWithComments
|
|
err := s.connector.GraphQLClient().Query(ctx, &query, vars)
|
|
if s.handleGraphqlRateLimitWithChunkReporter(ctx, reporter, &query.RateLimit, err) {
|
|
continue
|
|
}
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("error fetching pull requests with comments: %w", err)
|
|
}
|
|
|
|
totalPRs += len(query.GetPullRequests())
|
|
|
|
ctx.Logger().V(5).Info("Scanning pull requests",
|
|
"total_pull_requests", len(query.GetPullRequests()))
|
|
|
|
if err := s.chunkGraphqlPullRequests(ctx, repoInfo, query.GetPullRequests(), reporter); err != nil {
|
|
return err
|
|
}
|
|
|
|
// process each pr comments
|
|
for _, pr := range query.GetPullRequests() {
|
|
ctx.Logger().V(5).Info("Scanning pull request comments",
|
|
"pull_request_no", pr.Number,
|
|
"total_comments", len(pr.Comments.Nodes))
|
|
|
|
if err := s.chunkComments(ctx, repoInfo, pr.GetPRComments(), reporter, cutoffTime); err != nil {
|
|
return err
|
|
}
|
|
|
|
// if a pull request has more than 100 comments - some interns pull request might endup here :)
|
|
for pr.Comments.PageInfo.HasNextPage {
|
|
// request this pull request more comments
|
|
var commentQuery singlePRComments
|
|
singlePRVars := map[string]any{
|
|
owner: githubv4.String(repoInfo.owner),
|
|
repository: githubv4.String(repoInfo.name),
|
|
pullRequestNumber: pr.Number,
|
|
commentsPerPage: githubv4.Int(defaultPagination),
|
|
commentsPagination: pr.Comments.PageInfo.EndCursor,
|
|
}
|
|
|
|
err := s.connector.GraphQLClient().Query(ctx, &commentQuery, singlePRVars)
|
|
if s.handleGraphqlRateLimitWithChunkReporter(ctx, reporter, &commentQuery.RateLimit, err) {
|
|
continue
|
|
}
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("error fetching pull request with comments: %w", err)
|
|
}
|
|
|
|
ctx.Logger().V(5).Info("Scanning additional comments",
|
|
"pull_request_no", pr.Number,
|
|
"total_comments", len(commentQuery.GetPRComments()))
|
|
|
|
if err := s.chunkComments(ctx, repoInfo, commentQuery.GetPRComments(), reporter, cutoffTime); err != nil {
|
|
return err
|
|
}
|
|
|
|
// update pr.Comments.PageInfo so loop condition reflects new state
|
|
pr.Comments.PageInfo = commentQuery.Repository.PullRequest.Comments.PageInfo
|
|
}
|
|
}
|
|
|
|
// move to next page of PRs
|
|
if !query.Repository.PullRequests.PageInfo.HasNextPage {
|
|
ctx.Logger().V(4).Info("Scanned all repository pull requests with comments", "total_pullrequests_scanned", totalPRs)
|
|
break
|
|
}
|
|
|
|
// update pull request pagination to go to next page
|
|
vars[pullRequestPagination] = githubv4.NewString(query.Repository.PullRequests.PageInfo.EndCursor)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// processReviewThreads process github repo pull request review threads
|
|
func (s *Source) processReviewThreads(ctx context.Context, repoInfo repoInfo, reporter sources.ChunkReporter, cutoffTime *time.Time) error {
|
|
vars := map[string]any{
|
|
owner: githubv4.String(repoInfo.owner),
|
|
repository: githubv4.String(repoInfo.name),
|
|
pullRequestPerPage: githubv4.Int(defaultPagination),
|
|
pullRequestPagination: (*githubv4.String)(nil),
|
|
threadPerPage: githubv4.Int(defaultPagination),
|
|
threadPagination: (*githubv4.String)(nil),
|
|
}
|
|
|
|
var threadIDs = make([]string, 0)
|
|
|
|
// continue as long as pull requests have threads
|
|
for {
|
|
var query prWithReviewThreadIDs
|
|
err := s.connector.GraphQLClient().Query(ctx, &query, vars)
|
|
if s.handleGraphqlRateLimitWithChunkReporter(ctx, reporter, &query.RateLimit, err) {
|
|
continue
|
|
}
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("error fetching pr thread reviews: %w", err)
|
|
}
|
|
|
|
// collect thread ids
|
|
for _, pr := range query.GetMinimalPullRequests() {
|
|
prThreadIDs := pr.ReviewThreads.GetThreadIDs()
|
|
threadIDs = append(threadIDs, prThreadIDs...)
|
|
}
|
|
|
|
if !query.Repository.PullRequests.PageInfo.HasNextPage {
|
|
ctx.Logger().V(4).Info("Pulled all repository PR's threads IDs")
|
|
break
|
|
}
|
|
|
|
// update pull request pagination to go to next page
|
|
vars[pullRequestPagination] = githubv4.NewString(query.Repository.PullRequests.PageInfo.EndCursor)
|
|
}
|
|
|
|
ctx.Logger().V(4).Info("Pulled all thread IDs", "total_threads", len(threadIDs))
|
|
|
|
// if we got more than 0 threads unfortunately :( than we need to pull their comments in batches
|
|
if len(threadIDs) > 0 {
|
|
// process in batches of max 100
|
|
for _, batch := range chunkIDs(threadIDs, 100) {
|
|
ctx.Logger().V(5).Info("Processing Thread comments in Batches", "batch_length", len(batch))
|
|
// fetch comments for the batch of threads
|
|
if err := s.fetchThreadComments(ctx, batch, repoInfo, reporter, cutoffTime); err != nil {
|
|
return fmt.Errorf("error fetching thread review comments: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// fetchThreadComments process github repo pull request threads and their comments
|
|
func (s *Source) fetchThreadComments(ctx context.Context, threadIDs []string, repoInfo repoInfo, reporter sources.ChunkReporter, cutoffTime *time.Time) error {
|
|
// Process in batches of 100
|
|
var query multiReviewThreadComments
|
|
vars := map[string]any{
|
|
"ids": threadIDs,
|
|
commentsPerPage: githubv4.Int(defaultPagination),
|
|
commentsPagination: (*githubv4.String)(nil),
|
|
}
|
|
|
|
if err := s.connector.GraphQLClient().Query(ctx, &query, vars); err != nil {
|
|
return fmt.Errorf("multi-thread query failed: %w", err)
|
|
}
|
|
|
|
// process each thread in batch
|
|
for _, thread := range query.GetThreads() {
|
|
if err := s.chunkComments(ctx, repoInfo, thread.GetThreadComments(), reporter, cutoffTime); err != nil {
|
|
return err
|
|
}
|
|
|
|
// if a thread has more than 100 comments :)
|
|
for thread.Comments.PageInfo.HasNextPage {
|
|
// request this thread more comments
|
|
var query singleReviewThreadComments
|
|
reviewThreadVars := map[string]any{
|
|
threadID: thread.ID,
|
|
commentsPerPage: githubv4.Int(defaultPagination),
|
|
commentsPagination: (*githubv4.String)(nil),
|
|
}
|
|
|
|
err := s.connector.GraphQLClient().Query(ctx, &query, reviewThreadVars)
|
|
if s.handleGraphqlRateLimitWithChunkReporter(ctx, reporter, &query.RateLimit, err) {
|
|
continue
|
|
}
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("single-thread query failed: %w", err)
|
|
}
|
|
|
|
node := query.Node
|
|
if err := s.chunkComments(ctx, repoInfo, node.Comments.Nodes, reporter, cutoffTime); err != nil {
|
|
return err
|
|
}
|
|
|
|
if !node.Comments.PageInfo.HasNextPage {
|
|
break
|
|
}
|
|
|
|
// update thread comments pagination
|
|
reviewThreadVars[commentsPagination] = &node.Comments.PageInfo.EndCursor
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// chunkIDs splits a slice of IDs into multiple chunks of at most `size` elements.
|
|
// For example, if you have 250 IDs and size=100, this will return
|
|
//
|
|
// [][]string{
|
|
// {id0 ... id99}, // first 100
|
|
// {id100 ... id199},// next 100
|
|
// {id200 ... id249} // last 50
|
|
// }
|
|
func chunkIDs(ids []string, size int) [][]string {
|
|
var chunks [][]string
|
|
for size < len(ids) {
|
|
ids, chunks = ids[size:], append(chunks, ids[0:size:size])
|
|
}
|
|
return append(chunks, ids)
|
|
}
|
|
|
|
// handleRateLimitWithChunkReporter is a wrapper around handleRateLimit that includes chunk reporting
|
|
func (s *Source) handleGraphqlRateLimitWithChunkReporter(
|
|
ctx context.Context, reporter sources.ChunkReporter,
|
|
rl *rateLimit, errIn error,
|
|
) bool {
|
|
return s.handleGraphQLRateLimit(ctx, rl, errIn, &chunkErrorReporter{reporter: reporter})
|
|
}
|
|
|
|
// handleGraphQLRateLimit inspects the rateLimit info returned in GraphQL queries.
|
|
func (s *Source) handleGraphQLRateLimit(ctx context.Context, rl *rateLimit, errIn error, reporters ...errorReporter) bool {
|
|
// check global resume time first (in case another request already set it)
|
|
rateLimitMu.RLock()
|
|
resumeTime := rateLimitResumeTime
|
|
rateLimitMu.RUnlock()
|
|
|
|
// if resume time is not empty and is after current time, than put the request to sleep till that.
|
|
if !resumeTime.IsZero() && time.Now().Before(resumeTime) {
|
|
retryAfter := time.Until(resumeTime)
|
|
time.Sleep(retryAfter)
|
|
return true
|
|
}
|
|
|
|
var retryAfter time.Duration
|
|
if errIn != nil && strings.Contains(errIn.Error(), "rate limit exceeded") {
|
|
now := time.Now()
|
|
|
|
rateLimitMu.Lock()
|
|
rateLimitResumeTime = now.Add(1 * time.Minute)
|
|
retryAfter = time.Until(rateLimitResumeTime)
|
|
ctx.Logger().Info("GraphQL RATE_LIMITED error (fallback)",
|
|
"retry_after", retryAfter.String())
|
|
rateLimitMu.Unlock()
|
|
} else if rl != nil {
|
|
// if rate limit remaining is more than 3 continue using graphql api
|
|
if rl.Remaining > 3 {
|
|
return false
|
|
}
|
|
|
|
// === only reach here if error is nil and rate limit remaining is less than 3 (safety)
|
|
now := time.Now()
|
|
retryAfter = time.Until(rl.ResetAt)
|
|
// never negative and enforce a sane minimum backoff (avoid thrashing with 1s/2s retries)
|
|
if cmp.Less(retryAfter, 5*time.Second) {
|
|
retryAfter = 5 * time.Second
|
|
}
|
|
|
|
jitter := time.Duration(rand.IntN(10)+1) * time.Second
|
|
retryAfter += jitter
|
|
|
|
// update global resume time
|
|
rateLimitMu.Lock()
|
|
rateLimitResumeTime = now.Add(retryAfter)
|
|
ctx.Logger().Info("exceeded GraphQL rate limit",
|
|
"retry_after", retryAfter.String(),
|
|
"resume_time", rateLimitResumeTime.Format(time.RFC3339))
|
|
rateLimitMu.Unlock()
|
|
|
|
for _, reporter := range reporters {
|
|
_ = reporter.Err(ctx, fmt.Errorf("exceeded GraphQL rate limit"))
|
|
}
|
|
}
|
|
|
|
githubNumRateLimitEncountered.WithLabelValues(s.name).Inc()
|
|
time.Sleep(retryAfter)
|
|
githubSecondsSpentRateLimited.WithLabelValues(s.name).Add(retryAfter.Seconds())
|
|
|
|
return true
|
|
}
|
|
|
|
func (s *Source) chunkGraphqlIssues(ctx context.Context, repoInfo repoInfo, issues []issue, reporter sources.ChunkReporter) error {
|
|
for _, issue := range issues {
|
|
// 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.URL),
|
|
Username: sanitizer.UTF8(issue.Author.Login),
|
|
Repository: sanitizer.UTF8(repoInfo.fullName),
|
|
Timestamp: sanitizer.UTF8(issue.CreatedAt.String()),
|
|
Visibility: repoInfo.visibility,
|
|
},
|
|
},
|
|
},
|
|
Data: []byte(sanitizer.UTF8(issue.Title + "\n" + issue.Body)),
|
|
SourceVerify: s.verify,
|
|
}
|
|
|
|
if err := reporter.ChunkOk(ctx, chunk); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Source) chunkComments(ctx context.Context, repoInfo repoInfo, comments []comment, 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.UpdatedAt.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.URL),
|
|
Username: sanitizer.UTF8(comment.Author.Login),
|
|
Repository: sanitizer.UTF8(repoInfo.fullName),
|
|
Timestamp: sanitizer.UTF8(comment.CreatedAt.String()),
|
|
Visibility: repoInfo.visibility,
|
|
},
|
|
},
|
|
},
|
|
Data: []byte(sanitizer.UTF8(comment.Body)),
|
|
SourceVerify: s.verify,
|
|
}
|
|
|
|
if err := reporter.ChunkOk(ctx, chunk); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Source) chunkGraphqlPullRequests(ctx context.Context, repoInfo repoInfo, prs []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.URL),
|
|
Username: sanitizer.UTF8(pr.Author.Login),
|
|
Repository: sanitizer.UTF8(repoInfo.fullName),
|
|
Timestamp: sanitizer.UTF8(pr.CreatedAt.String()),
|
|
Visibility: repoInfo.visibility,
|
|
},
|
|
},
|
|
},
|
|
Data: []byte(sanitizer.UTF8(pr.Title + "\n" + pr.Body)),
|
|
SourceVerify: s.verify,
|
|
}
|
|
|
|
if err := reporter.ChunkOk(ctx, chunk); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|