Files
trufflehog/pkg/sources/github_experimental/object_discovery.go
joeleonjr fd007c74cc add ability to run github-experimental against private repos (#4508)
The existing implementation of github-experimental requires a github token for the --object-discovery subcommand (that's the only subcommand atm); however, it didn't properly use that token to clone private repos. I used the existing logic in the github/connector.go file to add enable cloning of private repositories.
2025-10-17 09:21:22 -04:00

640 lines
18 KiB
Go

package github_experimental
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"strings"
"time"
"github.com/google/go-github/v67/github"
"github.com/k0kubun/go-ansi"
"github.com/schollz/progressbar/v3"
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
"github.com/trufflesecurity/trufflehog/v3/pkg/sources"
)
// Assumption: sleeping for 60 seconds is enough to reset the secondary rate limit
// see https://docs.github.com/en/graphql/overview/rate-limits-and-node-limits-for-the-graphql-api#secondary-rate-limits
const secondaryRateLimitSleep = 60
// Assumption: on average, a fork contributes 0.1% additional commits
const forkCommitMultiplier = 0.001
// Threshold for estimated Short SHA-1 hash collisions (default to 1...so basically none)
// as calculated using the Birthday Paradox
// Adjust this to a higher value if you're willing to accept more collisions (and shorter runtime).
var collisionThreshold float64
// Starting character length (4 is the minimum required by git)
const startingCharLen = 4
// Max character length (6 is the default maximum)
// 6 chars == 16M possibilities --> which will take 18k-55k queries.
// that's really the max that's tolerable since it will take a long time to run.
// If you increase this to accommodate a MASSIVE repository, it will take a long time to run.
const maxCharLen = 6
// Starting GraphQL query chunk size.
// Max that worked was 900.
// 350 is a safe starting point.
const maxChunkSize = 900
const initialChunkSize = 350
// Max number of commits to fetch from the repository in one command
// ex: git fetch origin <commit1> <commit2> ... <commit1000>
const gitFetchMax = 1000
// Constants for commit types
const (
invalidCommit = "invalid"
validHiddenCommit = "valid_hidden"
)
type backoff struct {
value float64
decreasePercentage float64
increasePercentage float64
successThreshold int
successCount int
}
func newBackoff(initialValue, decreasePercentage, increasePercentage float64, successThreshold int) *backoff {
return &backoff{
value: initialValue,
decreasePercentage: decreasePercentage,
increasePercentage: increasePercentage,
successThreshold: successThreshold,
}
}
func (b *backoff) errorOccurred() float64 {
b.value -= b.value * (b.decreasePercentage / 100)
b.successCount = 0 // Reset success count on error
if b.value < 100 {
b.value = 100
}
return b.value
}
func (b *backoff) successOccurred() float64 {
b.successCount++
if b.successCount >= b.successThreshold {
b.value += b.value * (b.increasePercentage / 100)
b.successCount = 0 // Reset success count after increasing the value
}
if b.value > maxChunkSize {
b.value = maxChunkSize
}
return b.value
}
func (b *backoff) getValue() int {
return int(b.value)
}
func getForksCount(ctx context.Context, client *github.Client, owner, repoName string) (int, error) {
repo, _, err := client.Repositories.Get(ctx, owner, repoName)
if err != nil {
return 0, err
}
return repo.GetForksCount(), nil
}
// runGitCommand runs a git command
func runGitCommand(args []string) ([]byte, error) {
cmd := exec.Command("git", args...)
out, err := cmd.CombinedOutput()
return out, err
}
func getExistingHashes(path string) ([]string, error) {
var hashes []string
gitArgs := []string{
"-C",
path,
"--work-tree",
path,
"cat-file",
"--batch-check",
"--batch-all-objects",
}
outputBytes, err := runGitCommand(gitArgs)
if err != nil {
return hashes, err
}
output := string(outputBytes)
lines := strings.Split(output, "\n")
for _, line := range lines {
if len(line) > 0 {
parts := strings.Fields(line)
if len(parts) > 0 {
hashes = append(hashes, parts[0])
}
}
}
return hashes, nil
}
// calculateUsedKeySet Estimates the total used key set -
// meaning how many used hashes are in the repository.
func calculateUsedKeySet(commitCount, forksCount int) int {
// Calculate total known key set
commits := float64(commitCount)
forks := float64(forksCount)
knownKeySet := (commits + (commits * forkCommitMultiplier * forks))
return int(knownKeySet)
}
// Estimate the number of collisions using the Birthday Paradox
func estimateCollisions(keySpace, knownKeySet int) float64 {
keySpaceF := float64(keySpace)
knownKeySetF := float64(knownKeySet)
return (knownKeySetF * (knownKeySetF - 1)) / (2 * keySpaceF)
}
func getShortShaLen(knownKeySet int) int {
// Calculate the length of the short SHA-1 hash
// This is the minimum length required to avoid collisions
// in the estimated known key set
shortShaLen := startingCharLen
keySpace := 1 << (shortShaLen * 4)
collisions := estimateCollisions(keySpace, knownKeySet)
for collisions > collisionThreshold {
if shortShaLen >= maxCharLen {
break
}
shortShaLen++
keySpace = 1 << (shortShaLen * 4)
collisions = estimateCollisions(keySpace, knownKeySet)
}
return shortShaLen
}
// Generate all possible min commit hashes
func generateShortSHAStrings(charLen int) []string {
hexDigits := "0123456789abcdef"
var hexStrings []string
var generateCombinations func(prefix string, length int)
generateCombinations = func(prefix string, length int) {
if length == 0 {
hexStrings = append(hexStrings, prefix)
return
}
for _, digit := range hexDigits {
generateCombinations(prefix+string(digit), length-1)
}
}
generateCombinations("", charLen)
return hexStrings
}
// Write commits to disk
func writeCommitsToDisk(commits []string, commitsType, folder string) error {
filename := fmt.Sprintf("%s/%s.txt", folder, commitsType)
// Open file in append mode, create if it doesn't exist
file, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer file.Close()
for _, commit := range commits {
if _, err := file.WriteString(commit + "\n"); err != nil {
return err
}
}
return nil
}
// Read commits from disk
func readCommitsFromDisk(commitsType, folder string) ([]string, error) {
filename := fmt.Sprintf("%s/%s.txt", folder, commitsType)
if _, err := os.Stat(filename); os.IsNotExist(err) {
return nil, nil
}
data, err := os.ReadFile(filename)
if err != nil {
return nil, err
}
lines := strings.Split(string(data), "\n")
var commits []string
for _, line := range lines {
if line != "" {
commits = append(commits, strings.TrimSpace(line))
}
}
return removeNewlineAndUnique(commits), nil
}
// Remove newlines from commits and make them unique
func removeNewlineAndUnique(commits []string) []string {
commitMap := make(map[string]struct{})
for _, commit := range commits {
cleanCommit := strings.TrimSpace(commit)
commitMap[cleanCommit] = struct{}{}
}
var uniqueCommits []string
for commit := range commitMap {
uniqueCommits = append(uniqueCommits, commit)
}
return uniqueCommits
}
// Remove commits that are already in the existing_commits list
func removeByShortSHA(existingCommits, newCommits []string) []string {
existingSet := make(map[string]struct{})
for _, commit := range existingCommits {
existingSet[commit] = struct{}{}
}
var filteredCommits []string
for _, commit := range newCommits {
if _, exists := existingSet[commit]; !exists {
filteredCommits = append(filteredCommits, commit)
}
}
return filteredCommits
}
// Remove commits that are already in the existing_commits list (by char_len)
func removeBySHA(existingCommits, newCommits []string, charLen int) []string {
existingSet := make(map[string]struct{})
for _, commit := range existingCommits {
shortSHA := commit
if len(commit) > charLen {
shortSHA = commit[:charLen]
}
existingSet[shortSHA] = struct{}{}
}
var filteredCommits []string
for _, commit := range newCommits {
shortSHA := commit
if len(commit) > charLen {
shortSHA = commit[:charLen]
}
if _, exists := existingSet[shortSHA]; !exists {
filteredCommits = append(filteredCommits, commit)
}
}
return filteredCommits
}
func processCommits(ctx context.Context, apiClient *github.Client, needsProcessing []string, owner, repo, path string) {
repoCtx := context.WithValue(ctx, "repo", repo)
startingSize := float64(len(needsProcessing))
queryChunkSize := newBackoff(initialChunkSize, 10, 10, 1)
// Initialize the progress bar for commit processing
bar := progressbar.NewOptions(int(startingSize),
progressbar.OptionSetDescription("[green]Processing commits[reset]"),
progressbar.OptionSetWriter(ansi.NewAnsiStderr()),
progressbar.OptionEnableColorCodes(true),
)
for len(needsProcessing) > 0 {
if len(needsProcessing) < queryChunkSize.getValue() {
queryChunkSize.value = float64(len(needsProcessing))
}
chunkSize := queryChunkSize.getValue()
chunk := needsProcessing[:chunkSize]
needsProcessing = needsProcessing[chunkSize:]
commitData, err := checkHashes(repoCtx, apiClient, owner, repo, chunk)
if err != nil {
repoCtx.Logger().V(2).Info("Temporary error occurred in guessing commits", "error", err)
// Prepend the failed chunk to the FRONT of the queue for immediate retry
// This ensures we retry the same hashes instead of moving to the next batch
needsProcessing = append(chunk, needsProcessing...)
queryChunkSize.errorOccurred()
if strings.Contains(err.Error(), "You have exceeded a secondary rate limit") {
repoCtx.Logger().V(2).Info("Reached secondary GitHub Rate Limit. Sleeping for 60 seconds.")
time.Sleep(secondaryRateLimitSleep * time.Second)
}
continue
}
percentCompleted := (1 - (float64(len(needsProcessing)) / startingSize)) * 100
repoCtx.Logger().V(2).Info("Progress", "percent_completed", percentCompleted, "needs_processing", len(needsProcessing))
queryChunkSize.successOccurred()
err = writeCommitsToDisk(commitData[validHiddenCommit], validHiddenCommit, path)
if err != nil {
repoCtx.Logger().V(2).Info("Failed to write valid hidden commits to disk", "error", err)
}
err = writeCommitsToDisk(commitData[invalidCommit], invalidCommit, path)
if err != nil {
repoCtx.Logger().V(2).Info("Failed to write invalid commits to disk", "error", err)
}
// Update the progress bar
_ = bar.Add(chunkSize)
}
// Finish the progress bar
_ = bar.Finish()
}
type commitData struct {
OID string `json:"oid"`
}
type responseData struct {
Data struct {
Repository map[string]commitData `json:"repository"`
} `json:"data"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
Message string `json:"message"`
}
func checkHashes(ctx context.Context, client *github.Client, owner, repo string, hashes []string) (map[string][]string, error) {
testCases := ""
for _, h := range hashes {
testCase := fmt.Sprintf(`
commit%s: object(expression: "%s") {
... on Commit {
oid
}
}
`, h, h)
testCases += testCase
}
query := fmt.Sprintf(`
query {
repository(owner: "%s", name: "%s") {
%s
}
}
`, owner, repo, testCases)
headers := map[string]string{
"Content-Type": "application/json",
"Github-Verified-Fetch": "true",
"X-Requested-With": "XMLHttpRequest",
"Accept-Language": "en-US,en;q=0.9",
"Priority": "u=1, i",
}
requestBody, err := json.Marshal(map[string]string{"query": query})
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.github.com/graphql", bytes.NewBuffer(requestBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
for key, value := range headers {
req.Header.Set(key, value)
}
// Use the authenticated HTTP client from the GitHub API client
// This client already has the Bearer token configured via OAuth2 transport
resp, err := client.Client().Do(req)
if err != nil {
return nil, fmt.Errorf("python request error: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
var data responseData
if err := json.Unmarshal(body, &data); err != nil {
return nil, fmt.Errorf("failed to unmarshal response: %w", err)
}
if len(data.Errors) > 0 {
return nil, fmt.Errorf("%s (GitHub Request Error)", strings.Split(data.Errors[0].Message, ".")[0])
}
if data.Message != "" {
return nil, fmt.Errorf("%s (GitHub Request Error)", strings.Split(data.Message, ".")[0])
}
commits := data.Data.Repository
valid_cfor := []string{}
invalid := []string{}
for commit, value := range commits {
commit = strings.Replace(commit, "commit", "", 1)
if value.OID == "{}" || value.OID == "" {
invalid = append(invalid, commit)
} else {
valid_cfor = append(valid_cfor, value.OID)
}
}
res := map[string][]string{
validHiddenCommit: valid_cfor,
invalidCommit: invalid,
}
return res, nil
}
// createBatches divides a slice into batches of a specified size
func createBatches(items []string, batchSize int) <-chan []string {
out := make(chan []string)
go func() {
defer close(out)
itemsCopy := append([]string(nil), items...)
for len(itemsCopy) > 0 {
end := batchSize
if len(itemsCopy) < batchSize {
end = len(itemsCopy)
}
batch := itemsCopy[:end]
itemsCopy = itemsCopy[end:]
out <- batch
}
}()
return out
}
// downloadPatches fetches and checks out cfor commits
func downloadPatches(valid_cfor []string, path string) error {
// Download all patches
for batch := range createBatches(valid_cfor, gitFetchMax) {
gitArgs := []string{
"-C",
path,
"--work-tree",
path,
"fetch",
"--quiet",
"origin",
}
gitArgs = append(gitArgs, batch...)
_, err := runGitCommand(gitArgs)
if err != nil {
return err
}
}
// Checkout each commit
// Note: path and worktree are needed or else git will do something funny with the actual cwd
for _, commit := range valid_cfor {
branchName := fmt.Sprintf("_%s", commit)
gitArgs := []string{
"-C",
path,
"--work-tree",
path,
"checkout",
"--quiet",
"-b",
branchName,
commit,
}
_, err := runGitCommand(gitArgs)
if err != nil {
return fmt.Errorf("failed to checkout commit %s: %v", commit, err)
}
}
return nil
}
// scanHiddenData scans hidden data (and non-hidden data) for secrets in a GitHub repository
func (s *Source) EnumerateAndScanAllObjects(ctx context.Context, chunksChan chan *sources.Chunk) error {
// set collision threshold to user input
collisionThreshold = float64(s.conn.CollisionThreshold)
// parse the repo URL
repoURL, urlParts, err := getRepoURLParts(s.conn.Repository)
if err != nil {
return fmt.Errorf("failed to get repo URL parts: %w", err)
}
// read in the owner and repo name
owner := urlParts[1]
repoName := urlParts[2]
// get repo metadata and store in cacheRepoInfo
repoCtx := context.WithValue(ctx, "repo", owner+"/"+repoName)
ghRepo, _, err := s.connector.APIClient().Repositories.Get(repoCtx, owner, repoName)
if err != nil {
return fmt.Errorf("failed to fetch repository: %w", err)
}
s.cacheRepoInfo(ghRepo)
// Create a folder housing the repo and commit data
userHomeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
}
folderPath := userHomeDir + "/.trufflehog/" + owner + "/" + repoName
err = os.MkdirAll(folderPath, 0755)
if err != nil {
return fmt.Errorf("failed to create .trufflehog folder in user's home directory: %w", err)
}
// get the number of forks
forksCount, err := getForksCount(repoCtx, s.connector.APIClient(), owner, repoName)
if err != nil {
return fmt.Errorf("failed to get forks count: %w", err)
}
// download the repo using the authenticated connector
path, repo, err := s.connector.Clone(ctx, repoURL)
if err != nil {
return fmt.Errorf("failed to clone the repository: %w", err)
}
defer os.RemoveAll(path)
// count total valid hashes
validHashes, err := getExistingHashes(path)
if err != nil {
return fmt.Errorf("failed to enumerate existing commit object hashes: %w", err)
}
// Calculate estimated used key set
estimatedUsedKeySet := calculateUsedKeySet(len(validHashes), forksCount)
// Calculate Short SHA-1 Length for Unambiguous Commit Identifiers
shortShaLen := getShortShaLen(estimatedUsedKeySet)
// Log stats
repoCtx.Logger().V(2).Info("Estimated used keys", "count", estimatedUsedKeySet)
repoCtx.Logger().V(2).Info("Target Short SHA-1 length", "length", shortShaLen)
repoCtx.Logger().V(2).Info("Estimated collisions", "count", estimateCollisions(1<<(shortShaLen*4), estimatedUsedKeySet))
// Read in existing commits (if any)
validHiddenCommits, err := readCommitsFromDisk(validHiddenCommit, folderPath)
if err != nil {
return fmt.Errorf("failed to read valid hidden commits from disk: %w", err)
}
invalidCommits, err := readCommitsFromDisk(invalidCommit, folderPath)
if err != nil {
return fmt.Errorf("failed to read invalid commits from disk: %w", err)
}
// Generate all possible commit hashes using the short SHA-1 length
possibleCommits := generateShortSHAStrings(shortShaLen)
// Remove commits that are already used by the repo or previously calculated (on restart)
possibleCommits = removeBySHA(validHashes, possibleCommits, shortShaLen)
possibleCommits = removeBySHA(validHiddenCommits, possibleCommits, shortShaLen)
possibleCommits = removeByShortSHA(invalidCommits, possibleCommits)
// Guess all possible commit hashes
processCommits(ctx, s.connector.APIClient(), possibleCommits, owner, repoName, folderPath)
// Read in the new commits
validHiddenCommits, err = readCommitsFromDisk(validHiddenCommit, folderPath)
if err != nil {
return fmt.Errorf("failed to read valid hidden commits from disk: %w", err)
}
// Download commit hashes and checkout into branches (only way scanner will pick them up)
err = downloadPatches(validHiddenCommits, path)
if err != nil {
return fmt.Errorf("failed to download patches: %w", err)
}
// Scan git for secrets
repoCtx.Logger().V(2).Info("scanning for secrets in repo", "repo_url", repoURL)
start := time.Now()
err = s.git.ScanRepo(ctx, repo, path, s.scanOptions, sources.ChanReporter{Ch: chunksChan})
if err != nil {
return fmt.Errorf("failed to scan repo: %w", err)
}
duration := time.Since(start)
repoCtx.Logger().V(2).Info("scanned 1 repo for hidden data", "duration_seconds", duration)
// Remove the folder if user requests
if s.conn.DeleteCachedData {
err = os.RemoveAll(folderPath)
if err != nil {
return fmt.Errorf("failed to delete cached data: %w", err)
}
}
return nil
}