Files
trufflehog/pkg/analyzer/analyzers/github/github.go
Kashif Khan a7082b69f5 fix(github): fallback resource Name to avoid empty-string proto validation failure (#5182)
Gist descriptions are optional; empty description produced empty
Resource.Name, failing CreateAnalysisResultsRequest validation and
dropping the whole payload. Fall back to gist ID / repo FullName.
2026-08-07 20:21:05 +05:00

245 lines
7.7 KiB
Go

package github
import (
"fmt"
"strings"
"time"
"github.com/fatih/color"
gh "github.com/google/go-github/v67/github"
"golang.org/x/time/rate"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/github/classic"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/github/common"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/github/finegrained"
"github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config"
"github.com/trufflesecurity/trufflehog/v3/pkg/context"
)
// According to GitHub's rate limiting documentation, the default rate limit for
// authenticated requests (PAT) is 5000 requests per hour. This equates to roughly 1.39
// requests per second. To provide some buffer, we set the rate limit to 1.25
// requests per second with a burst of 10.
// https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2022-11-28
var rateLimiter = rate.NewLimiter(rate.Limit(1.25), 10)
var _ analyzers.Analyzer = (*Analyzer)(nil)
type Analyzer struct {
Cfg *config.Config
}
func (Analyzer) Type() analyzers.AnalyzerType { return analyzers.AnalyzerTypeGitHub }
func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) {
info, err := AnalyzePermissions(a.Cfg, credInfo["key"])
if err != nil {
return nil, analyzers.NewAnalysisError(a.Type().String(), analyzers.OperationAnalyzePermissions, analyzers.ServiceAPI, "", err)
}
if info == nil {
return nil, analyzers.NewAnalysisError(a.Type().String(), analyzers.OperationAnalyzePermissions, analyzers.ServiceAPI, "", fmt.Errorf("GitHub analyzer returned no data for token"))
}
return secretInfoToAnalyzerResult(info), nil
}
func secretInfoToAnalyzerResult(info *common.SecretInfo) *analyzers.AnalyzerResult {
if info == nil {
return nil
}
result := &analyzers.AnalyzerResult{
AnalyzerType: analyzers.AnalyzerTypeGitHub,
Metadata: map[string]any{
"owner": info.Metadata.User.Login,
"type": info.Metadata.Type,
"expiration": info.Metadata.Expiration,
},
}
result.Bindings = append(result.Bindings, secretInfoToUserBindings(info)...)
result.Bindings = append(result.Bindings, secretInfoToRepoBindings(info)...)
result.Bindings = append(result.Bindings, secretInfoToGistBindings(info)...)
for _, repo := range append(info.Repos, info.AccessibleRepos...) {
if repo.Owner.GetType() != "Organization" {
continue
}
name := repo.Owner.GetName()
if name == "" {
continue
}
result.UnboundedResources = append(result.UnboundedResources, analyzers.Resource{
Name: name,
FullyQualifiedName: fmt.Sprintf("github.com/%s", name),
Type: "organization",
})
}
// TODO: Unbound resources
// - Repo owners
// - Gist owners
return result
}
func secretInfoToUserBindings(info *common.SecretInfo) []analyzers.Binding {
return analyzers.BindAllPermissions(*userToResource(info.Metadata.User), info.Metadata.OauthScopes...)
}
func userToResource(user *gh.User) *analyzers.Resource {
name := user.GetLogin()
return &analyzers.Resource{
Name: name,
FullyQualifiedName: fmt.Sprintf("github.com/%s", name),
Type: strings.ToLower(user.GetType()), // "user" or "organization"
}
}
func secretInfoToRepoBindings(info *common.SecretInfo) []analyzers.Binding {
var perms []analyzers.Permission
switch info.Metadata.Type {
case common.TokenTypeClassicPAT:
perms = info.Metadata.OauthScopes
case common.TokenTypeFineGrainedPAT:
fineGrainedPermissions := info.RepoAccessMap.([]finegrained.Permission)
for _, perm := range fineGrainedPermissions {
permName, _ := perm.ToString()
perms = append(perms, analyzers.Permission{Value: permName})
}
default:
if len(info.Metadata.OauthScopes) > 0 {
perms = info.Metadata.OauthScopes
}
}
repos := info.Repos
if len(info.AccessibleRepos) > 0 {
repos = info.AccessibleRepos
}
var bindings []analyzers.Binding
for _, repo := range repos {
// A repo has identity independent of its owner (name/full_name); if the
// owner is absent we still emit the repo but leave Parent unset.
var parent *analyzers.Resource
if owner := repo.GetOwner(); owner != nil {
parent = userToResource(owner)
}
// repo.GetName() could theoretically be empty for a deleted/ghost repo
// object; fall back to FullName so Name is never empty.
name := repo.GetName()
if name == "" {
name = repo.GetFullName()
}
if name == "" {
continue
}
resource := analyzers.Resource{
Name: name,
FullyQualifiedName: fmt.Sprintf("github.com/%s", repo.GetFullName()),
Type: "repository",
Parent: parent,
}
bindings = append(bindings, analyzers.BindAllPermissions(resource, perms...)...)
}
return bindings
}
func secretInfoToGistBindings(info *common.SecretInfo) []analyzers.Binding {
var bindings []analyzers.Binding
for _, gist := range info.Gists {
// A gist without an owner cannot be attributed to a user, so we cannot
// build a meaningful resource or FQN for it. Skip it rather than
// producing silently corrupt output (e.g. "gist.github.com//id").
if gist.GetOwner() == nil {
continue
}
// Gist descriptions are optional; fall back to the gist ID so Name is
// never empty (proto validation requires len >= 1).
name := gist.GetDescription()
if name == "" {
name = gist.GetID()
}
resource := analyzers.Resource{
Name: name,
FullyQualifiedName: fmt.Sprintf("gist.github.com/%s/%s", gist.GetOwner().GetLogin(), gist.GetID()),
Type: "gist",
Parent: userToResource(gist.GetOwner()),
}
bindings = append(bindings, analyzers.BindAllPermissions(resource, info.Metadata.OauthScopes...)...)
}
return bindings
}
func AnalyzePermissions(cfg *config.Config, key string) (*common.SecretInfo, error) {
if cfg == nil {
cfg = &config.Config{}
}
client := gh.NewClient(analyzers.NewAnalyzeClient(cfg, analyzers.WithRateLimiter(rateLimiter))).WithAuthToken(key)
md, err := common.GetTokenMetadata(key, client)
if err != nil {
return nil, err
}
if md.FineGrained {
return finegrained.AnalyzeFineGrainedToken(client, md, cfg.Shallow)
} else {
return classic.AnalyzeClassicToken(client, md)
}
}
func AnalyzeAndPrintPermissions(cfg *config.Config, key string) {
info, err := AnalyzePermissions(cfg, key)
if err != nil {
color.Red("[x] %s", err.Error())
return
}
color.Yellow("[i] Token User: %v", *info.Metadata.User.Login)
if expiry := info.Metadata.Expiration; expiry.IsZero() {
color.Red("[i] Token Expiration: does not expire")
} else {
timeRemaining := time.Until(expiry)
color.Yellow("[i] Token Expiration: %v (%s remaining)", expiry, roughHumanReadableDuration(timeRemaining))
}
color.Yellow("[i] Token Type: %s\n\n", info.Metadata.Type)
if info.Metadata.FineGrained {
finegrained.PrintFineGrainedToken(cfg, info)
return
}
classic.PrintClassicToken(cfg, info)
}
// roughHumanReadableDuration converts a duration into a rough estimate for
// human consumption. The larger the duration, the larger granularity is
// returned.
func roughHumanReadableDuration(d time.Duration) string {
var gran time.Duration
var unit string
switch {
case d < 1*time.Minute:
gran = time.Second
unit = "second"
case d < 1*time.Hour:
gran = time.Minute
unit = "minute"
case d < 24*time.Hour:
gran = time.Hour
unit = "hour"
case d < 4*7*24*time.Hour:
gran = 24 * time.Hour
unit = "day"
case d < 3*4*7*24*time.Hour:
gran = 7 * 24 * time.Hour
unit = "week"
case d < 5*365*24*time.Hour:
gran = 365 * 24 * time.Hour
unit = "month"
default:
gran = 365 * 24 * time.Hour
unit = "year"
}
num := d.Round(gran) / gran
if num != 1 {
unit += "s"
}
return fmt.Sprintf("%d %s", num, unit)
}