From 9d089c21882c72811700334bce1e92160f6dc1b0 Mon Sep 17 00:00:00 2001 From: Miccah Date: Fri, 26 Jul 2024 14:47:03 -0700 Subject: [PATCH] [analyze] Implement Analyzer interface for github (#3110) * [analyze] Implement Analyzer interface for github * Make github repo and user enumeration configurable * Add AnalysisInfo to github detector * Use AnalyzeAndPrintPermissions from the CLI --- pkg/analyzer/analyzers/analyzers.go | 13 + pkg/analyzer/analyzers/github/classictoken.go | 154 +++++++----- pkg/analyzer/analyzers/github/finegrained.go | 94 +++---- pkg/analyzer/analyzers/github/github.go | 236 +++++++++++++----- pkg/analyzer/cli.go | 2 +- pkg/analyzer/config/config.go | 2 + pkg/detectors/github/v1/github_old.go | 1 + pkg/detectors/github/v2/github.go | 7 +- 8 files changed, 338 insertions(+), 171 deletions(-) diff --git a/pkg/analyzer/analyzers/analyzers.go b/pkg/analyzer/analyzers/analyzers.go index 003dc668e..35aff76f0 100644 --- a/pkg/analyzer/analyzers/analyzers.go +++ b/pkg/analyzer/analyzers/analyzers.go @@ -217,3 +217,16 @@ func (r LoggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error return resp, nil } + +// BindAllPermissions creates a Binding for each permission to the given +// resource. +func BindAllPermissions(r Resource, perms ...Permission) []Binding { + bindings := make([]Binding, len(perms)) + for i, perm := range perms { + bindings[i] = Binding{ + Resource: r, + Permission: perm, + } + } + return bindings +} diff --git a/pkg/analyzer/analyzers/github/classictoken.go b/pkg/analyzer/analyzers/github/classictoken.go index 87dae0e6a..9728981e9 100644 --- a/pkg/analyzer/analyzers/github/classictoken.go +++ b/pkg/analyzer/analyzers/github/classictoken.go @@ -1,20 +1,39 @@ package github import ( - "context" "fmt" "os" - "slices" "strings" "github.com/fatih/color" gh "github.com/google/go-github/v59/github" "github.com/jedib0t/go-pretty/v6/table" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" ) -// var SCOPE_ORDER = []string{"repo", "repo:status", "repo_deployment", "public_repo", "repo:invite", "security_events", "--", "workflow", "--", "write:packages", "read:packages", "--", "delete:packages", "--", "admin:org", "write:org", "read:org", "manage_runners:org", "--", "admin:public_key", "write:public_key", "read:public_key", "--", "admin:repo_hook", "write:repo_hook", "read:repo_hook", "--", "admin:org_hook", "--", "gist", "--", "notifications", "--", "user", "read:user", "user:email", "user:follow", "--", "delete_repo", "--", "write:discussion", "read:discussion", "--", "admin:enterprise", "manage_runners:enterprise", "manage_billing:enterprise", "read:enterprise", "--", "audit_log", "read:audit_log", "--", "codespace", "codespace:secrets", "--", "copilot", "manage_billing:copilot", "--", "project", "read:project", "--", "admin:gpg_key", "write:gpg_key", "read:gpg_key", "--", "admin:ssh_signing_key", "write:ssh_signing_key", "read:ssh_signing_key"} - -var SCOPE_ORDER = [][]string{{"repo", "repo:status", "repo_deployment", "public_repo", "repo:invite", "security_events"}, {"workflow"}, {"write:packages", "read:packages"}, {"delete:packages"}, {"admin:org", "write:org", "read:org", "manage_runners:org"}, {"admin:public_key", "write:public_key", "read:public_key"}, {"admin:repo_hook", "write:repo_hook", "read:repo_hook"}, {"admin:org_hook"}, {"gist"}, {"notifications"}, {"user", "read:user", "user:email", "user:follow"}, {"delete_repo"}, {"write:discussion", "read:discussion"}, {"admin:enterprise", "manage_runners:enterprise", "manage_billing:enterprise", "read:enterprise"}, {"audit_log", "read:audit_log"}, {"codespace", "codespace:secrets"}, {"copilot", "manage_billing:copilot"}, {"project", "read:project"}, {"admin:gpg_key", "write:gpg_key", "read:gpg_key"}, {"admin:ssh_signing_key", "write:ssh_signing_key", "read:ssh_signing_key"}} +var SCOPE_ORDER = [][]string{ + {"repo", "repo:status", "repo_deployment", "public_repo", "repo:invite", "security_events"}, + {"workflow"}, + {"write:packages", "read:packages"}, + {"delete:packages"}, + {"admin:org", "write:org", "read:org", "manage_runners:org"}, + {"admin:public_key", "write:public_key", "read:public_key"}, + {"admin:repo_hook", "write:repo_hook", "read:repo_hook"}, + {"admin:org_hook"}, + {"gist"}, + {"notifications"}, + {"user", "read:user", "user:email", "user:follow"}, + {"delete_repo"}, + {"write:discussion", "read:discussion"}, + {"admin:enterprise", "manage_runners:enterprise", "manage_billing:enterprise", "read:enterprise"}, + {"audit_log", "read:audit_log"}, + {"codespace", "codespace:secrets"}, + {"copilot", "manage_billing:copilot"}, + {"project", "read:project"}, + {"admin:gpg_key", "write:gpg_key", "read:gpg_key"}, + {"admin:ssh_signing_key", "write:ssh_signing_key", "read:ssh_signing_key"}, +} var SCOPE_TO_SUB_SCOPE = map[string][]string{ "repo": {"repo:status", "repo_deployment", "public_repo", "repo:invite", "security_events"}, @@ -39,21 +58,15 @@ var SCOPE_TO_SUB_SCOPE = map[string][]string{ "write:ssh_signing_key": {"read:ssh_signing_key"}, } -func checkPrivateRepoAccess(scopes map[string]bool) []string { - var currPrivateScopes []string - privateScopes := []string{"repo", "repo:status", "repo_deployment", "repo:invite", "security_events", "admin:repo_hook", "write:repo_hook", "read:repo_hook"} - for _, scope := range privateScopes { - if scopes[scope] { - currPrivateScopes = append(currPrivateScopes, scope) - } - } - return currPrivateScopes +func hasPrivateRepoAccess(scopes map[string]bool) bool { + // privateScopes := []string{"repo", "repo:status", "repo_deployment", "repo:invite", "security_events", "admin:repo_hook", "write:repo_hook", "read:repo_hook"} + return scopes["repo"] } -func processScopes(headerScopesSlice []string) map[string]bool { +func processScopes(headerScopesSlice []analyzers.Permission) map[string]bool { allScopes := make(map[string]bool) for _, scope := range headerScopesSlice { - allScopes[scope] = true + allScopes[scope.Value] = true } for scope := range allScopes { if subScopes, ok := SCOPE_TO_SUB_SCOPE[scope]; ok { @@ -66,50 +79,65 @@ func processScopes(headerScopesSlice []string) map[string]bool { } // The `gists` scope is required to update private gists. Anyone can access a private gist with the link. -// These tokens can seem to list out the private repos, but access will depend on scopes. +// These tokens can seem to list out the private repos, but access will depend on scopes. +func analyzeClassicToken(client *gh.Client, meta *TokenMetadata) (*SecretInfo, error) { + scopes := processScopes(meta.OauthScopes) -func analyzeClassicToken(client *gh.Client, _ string, show_all bool) { - - // Issue GET request to /user - user, resp, err := client.Users.Get(context.Background(), "") - if err != nil { - color.Red("[x] Invalid GitHub Token.") - return - } - - // If resp.Header "X-OAuth-Scopes", parse the scopes into a map[string]bool - headerScopes := resp.Header.Get("X-OAuth-Scopes") - - var scopes = make(map[string]bool) - if headerScopes == "" { - color.Red("[x] Classic Token has no scopes.") - } else { - // Split string into slice of strings - headerScopesSlice := strings.Split(headerScopes, ", ") - scopes = processScopes(headerScopesSlice) - } - - printClassicGHPermissions(scopes, show_all) - - // Check if private repo access - privateScopes := checkPrivateRepoAccess(scopes) - - if len(privateScopes) > 0 && slices.Contains(privateScopes, "repo") { - color.Green("[!] Token has scope(s) for both public and private repositories. Here's a list of all accessible repositories:") - repos, _ := getAllReposForUser(client) - printGitHubRepos(repos) - } else if len(privateScopes) > 0 { - color.Yellow("[!] Token has scope(s) useful for accessing both public and private repositories.\n However, without the `repo` scope, we cannot enumerate or access code from private repos.\n Review the permissions associated with the following scopes for more details: %v", strings.Join(privateScopes, ", ")) - } else if scopes["public_repo"] { - color.Yellow("[i] Token is scoped to only public repositories. See https://github.com/%v?tab=repositories", *user.Login) - } else { - color.Red("[x] Token does not appear scoped to any specific repositories.") + var repos []*gh.Repository + if hasPrivateRepoAccess(scopes) { + var err error + repos, err = getAllReposForUser(client) + if err != nil { + return nil, err + } } // Get all private gists - gists, _ := getAllGistsForUser(client) - printGists(gists, show_all) + gists, err := getAllGistsForUser(client) + if err != nil { + return nil, err + } + return &SecretInfo{ + Metadata: meta, + Repos: repos, + Gists: gists, + }, nil +} + +func filterPrivateRepoScopes(scopes map[string]bool) []string { + var intersection []string + privateScopes := []string{"repo", "repo:status", "repo_deployment", "repo:invite", "security_events", "admin:repo_hook", "write:repo_hook", "read:repo_hook"} + + for _, privScope := range privateScopes { + if scopes[privScope] { + intersection = append(intersection, privScope) + } + } + return intersection +} + +func printClassicToken(cfg *config.Config, info *SecretInfo) { + scopes := processScopes(info.Metadata.OauthScopes) + if len(scopes) == 0 { + color.Red("[x] Classic Token has no scopes") + } else { + printClassicGHPermissions(scopes, cfg.ShowAll) + } + + // Check if private repo access + privateScopes := filterPrivateRepoScopes(scopes) + if hasPrivateRepoAccess(scopes) { + color.Green("[!] Token has scope(s) for both public and private repositories. Here's a list of all accessible repositories:") + printGitHubRepos(info.Repos) + } else if len(privateScopes) > 0 { + color.Yellow("[!] Token has scope(s) useful for accessing both public and private repositories.\n However, without the `repo` scope, we cannot enumerate or access code from private repos.\n Review the permissions associated with the following scopes for more details: %v", strings.Join(privateScopes, ", ")) + } else if scopes["public_repo"] { + color.Yellow("[i] Token is scoped to only public repositories. See https://github.com/%v?tab=repositories", *info.Metadata.User.Login) + } else { + color.Red("[x] Token does not appear scoped to any specific repositories.") + } + printGists(info.Gists, cfg.ShowAll) } // Question: can you access private repo with those other permissions? or can we just not list them? @@ -125,7 +153,7 @@ func scopeFormatter(scope string, checked bool, indentation int) (string, string } } -func printClassicGHPermissions(scopes map[string]bool, show_all bool) { +func printClassicGHPermissions(scopes map[string]bool, showAll bool) { scopeCount := 0 t := table.NewWriter() t.SetOutputMirror(os.Stdout) @@ -145,7 +173,7 @@ func printClassicGHPermissions(scopes map[string]bool, show_all bool) { var formattedScope, status string var indentation int - if !show_all { + if !showAll { for _, scopeSlice := range filteredScopes { for ind, scope := range scopeSlice { if ind == 0 { @@ -153,16 +181,16 @@ func printClassicGHPermissions(scopes map[string]bool, show_all bool) { if scopes[scope] { scopeCount++ formattedScope, status = scopeFormatter(scope, true, indentation) - t.AppendRow([]interface{}{formattedScope, status}) + t.AppendRow([]any{formattedScope, status}) } else { - t.AppendRow([]interface{}{scope, "----"}) + t.AppendRow([]any{scope, "----"}) } } else { indentation = 2 if scopes[scope] { scopeCount++ formattedScope, status = scopeFormatter(scope, true, indentation) - t.AppendRow([]interface{}{formattedScope, status}) + t.AppendRow([]any{formattedScope, status}) } } } @@ -179,17 +207,17 @@ func printClassicGHPermissions(scopes map[string]bool, show_all bool) { if scopes[scope] { scopeCount++ formattedScope, status = scopeFormatter(scope, true, indentation) - t.AppendRow([]interface{}{formattedScope, status}) + t.AppendRow([]any{formattedScope, status}) } else { formattedScope, status = scopeFormatter(scope, false, indentation) - t.AppendRow([]interface{}{formattedScope, status}) + t.AppendRow([]any{formattedScope, status}) } } t.AppendSeparator() } } - if scopeCount == 0 && !show_all { + if scopeCount == 0 && !showAll { color.Red("No Scopes Found for the GitHub Token above\n\n") return } else if scopeCount == 0 { diff --git a/pkg/analyzer/analyzers/github/finegrained.go b/pkg/analyzer/analyzers/github/finegrained.go index 4d21d20d9..a92e292eb 100644 --- a/pkg/analyzer/analyzers/github/finegrained.go +++ b/pkg/analyzer/analyzers/github/finegrained.go @@ -12,6 +12,7 @@ import ( "github.com/fatih/color" gh "github.com/google/go-github/v59/github" "github.com/jedib0t/go-pretty/v6/table" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" ) const ( @@ -119,7 +120,7 @@ var acctPermFuncMap = map[string]func(client *gh.Client, user *gh.User) (string, } // Define your custom formatter function -func permissionFormatter(key interface{}, val interface{}) (string, string) { +func permissionFormatter(key, val any) (string, string) { if strVal, ok := val.(string); ok { switch strVal { case NO_ACCESS: @@ -970,12 +971,8 @@ func getWebhooksPermission(client *gh.Client, repo *gh.Repository, currentAccess // Ex: "Code scanning alerts" must be enabled to tell if we have that permission. func analyzeRepositoryPermissions(client *gh.Client, repos []*gh.Repository, permissionType string) string { access := "" - var err error for _, repo := range repos { - access, err = repoPermFuncMap[permissionType](client, repo, access) - if err != nil { - log.Fatal(err) - } + access, _ = repoPermFuncMap[permissionType](client, repo, access) if access != UNKNOWN && access != ERROR { return access } @@ -1310,61 +1307,66 @@ func analyzeUserPermissions(client *gh.Client, user *gh.User, permissionType str return access } -func analyzeFineGrainedToken(client *gh.Client, _ string, show_all bool) { - // Get all private repos +func analyzeFineGrainedToken(client *gh.Client, meta *TokenMetadata, shallowCheck bool) (*SecretInfo, error) { allRepos, err := getAllReposForUser(client) if err != nil { - color.Red("Error getting repos.") - return + return nil, err } - filteredRepos := make([]*gh.Repository, 0) + allGists, err := getAllGistsForUser(client) + if err != nil { + return nil, err + } + accessibleRepos := make([]*gh.Repository, 0) for _, repo := range allRepos { if analyzeRepositoryPermissions(client, []*gh.Repository{repo}, METADATA) != NO_ACCESS { - filteredRepos = append(filteredRepos, repo) + accessibleRepos = append(accessibleRepos, repo) } } - if len(filteredRepos) == 0 { + repoAccessMap := make(map[string]string) + userAccessMap := make(map[string]string) + + if !shallowCheck { + // Check our access + for key := range repoPermFuncMap { + repoAccessMap[key] = analyzeRepositoryPermissions(client, accessibleRepos, key) + } + + // Analyze Account's Permissions + for key := range acctPermFuncMap { + userAccessMap[key] = analyzeUserPermissions(client, meta.User, key) + } + } + + return &SecretInfo{ + Metadata: meta, + Repos: allRepos, + Gists: allGists, + AccessibleRepos: accessibleRepos, + RepoAccessMap: repoAccessMap, + UserAccessMap: userAccessMap, + }, nil +} + +func printFineGrainedToken(cfg *config.Config, info *SecretInfo) { + if len(info.AccessibleRepos) == 0 { // If no repos are accessible, then we only have read access to public repos color.Red("[!] Repository Access: Public Repositories (read-only)\n") } else { // Print out the repos the token can access - color.Green(fmt.Sprintf("Found %v", len(filteredRepos)) + " Accessible Repositor(ies) \n") - printGitHubRepos(filteredRepos) - - // Check our access - repoAccessMap := make(map[string]string) - for key := range repoPermFuncMap { - repoAccessMap[key] = analyzeRepositoryPermissions(client, filteredRepos, key) - } + color.Green(fmt.Sprintf("Found %v", len(info.AccessibleRepos)) + " Accessible Repositor(ies) \n") + printGitHubRepos(info.AccessibleRepos) // Print out the access map - printFineGrainedPermissions(repoAccessMap, show_all, true) + printFineGrainedPermissions(info.RepoAccessMap, cfg.ShowAll, true) } - // Get this token's user - user, _, err := client.Users.Get(context.Background(), "") - if err != nil { - color.Red("Error getting user.") - return - } - - // Analyze Account's Permissions - userAccessMap := make(map[string]string) - for key := range acctPermFuncMap { - userAccessMap[key] = analyzeUserPermissions(client, user, key) - } - - printFineGrainedPermissions(userAccessMap, show_all, false) - - // Get all private gists - gists, _ := getAllGistsForUser(client) - printGists(gists, show_all) - + printFineGrainedPermissions(info.UserAccessMap, cfg.ShowAll, false) + printGists(info.Gists, cfg.ShowAll) } -func printFineGrainedPermissions(accessMap map[string]string, show_all bool, repo_permissions bool) { +func printFineGrainedPermissions(accessMap map[string]string, showAll bool, repoPermissions bool) { permissionCount := 0 t := table.NewWriter() t.SetOutputMirror(os.Stdout) @@ -1385,20 +1387,20 @@ func printFineGrainedPermissions(accessMap map[string]string, show_all bool, rep } else { permissionCount++ } - if !show_all && (value == NO_ACCESS || value == UNKNOWN || value == NOT_IMPLEMENTED) { + if !showAll && (value == NO_ACCESS || value == UNKNOWN || value == NOT_IMPLEMENTED) { continue } else { k, v := permissionFormatter(key, value) - t.AppendRow([]interface{}{k, v}) + t.AppendRow([]any{k, v}) } } var permissionType string - if repo_permissions { + if repoPermissions { permissionType = "Repositor(ies)" } else { permissionType = "User Account" } - if permissionCount == 0 && !show_all { + if permissionCount == 0 && !showAll { color.Red("No Permissions Found for the %v above\n\n", permissionType) return } else if permissionCount == 0 { diff --git a/pkg/analyzer/analyzers/github/github.go b/pkg/analyzer/analyzers/github/github.go index 3c8dbc1a3..4fb4f94a5 100644 --- a/pkg/analyzer/analyzers/github/github.go +++ b/pkg/analyzer/analyzers/github/github.go @@ -1,7 +1,6 @@ package github import ( - "context" "fmt" "os" "strings" @@ -10,9 +9,115 @@ import ( "github.com/fatih/color" gh "github.com/google/go-github/v59/github" "github.com/jedib0t/go-pretty/v6/table" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/pb/analyzerpb" + "github.com/trufflesecurity/trufflehog/v3/pkg/context" ) +var _ analyzers.Analyzer = (*Analyzer)(nil) + +type Analyzer struct { + Cfg *config.Config +} + +func (Analyzer) Type() analyzerpb.AnalyzerType { return analyzerpb.AnalyzerType_GitHub } + +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, err + } + return secretInfoToAnalyzerResult(info), nil +} + +func secretInfoToAnalyzerResult(info *SecretInfo) *analyzers.AnalyzerResult { + if info == nil { + return nil + } + // Metadata *TokenMetadata + // Type string + // FineGrained bool + // User *gh.User + // Expiration time.Time + // OauthScopes []analyzers.Permission + // Repos []*gh.Repository + // Gists []*gh.Gist + // AccessibleRepos []*gh.Repository + // RepoAccessMap map[string]string + // UserAccessMap map[string]string + result := &analyzers.AnalyzerResult{ + Metadata: map[string]any{ + "type": info.Metadata.Type, + "fine_grained": info.Metadata.FineGrained, + "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.Type != "Organization" { + continue + } + name := *repo.Owner.Name + 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 *SecretInfo) []analyzers.Binding { + return analyzers.BindAllPermissions(*userToResource(info.Metadata.User), info.Metadata.OauthScopes...) +} + +func userToResource(user *gh.User) *analyzers.Resource { + name := *user.Login + return &analyzers.Resource{ + Name: name, + FullyQualifiedName: fmt.Sprintf("github.com/%s", name), + Type: strings.ToLower(*user.Type), // "user" or "organization" + } +} + +func secretInfoToRepoBindings(info *SecretInfo) []analyzers.Binding { + repos := info.Repos + if len(info.AccessibleRepos) > 0 { + repos = info.AccessibleRepos + } + var bindings []analyzers.Binding + for _, repo := range repos { + resource := analyzers.Resource{ + Name: *repo.Name, + FullyQualifiedName: fmt.Sprintf("github.com/%s", *repo.FullName), + Type: "repository", + Parent: userToResource(repo.Owner), + } + bindings = append(bindings, analyzers.BindAllPermissions(resource, info.Metadata.OauthScopes...)...) + } + return bindings +} + +func secretInfoToGistBindings(info *SecretInfo) []analyzers.Binding { + var bindings []analyzers.Binding + for _, gist := range info.Gists { + resource := analyzers.Resource{ + Name: *gist.Description, + FullyQualifiedName: fmt.Sprintf("gist.github.com/%s/%s", *gist.Owner.Login, *gist.ID), + Type: "gist", + Parent: userToResource(gist.Owner), + } + bindings = append(bindings, analyzers.BindAllPermissions(resource, info.Metadata.OauthScopes...)...) + } + return bindings +} + func getAllGistsForUser(client *gh.Client) ([]*gh.Gist, error) { opt := &gh.GistListOptions{ListOptions: gh.ListOptions{PerPage: 100}} var allGists []*gh.Gist @@ -76,14 +181,14 @@ func printGitHubRepos(repos []*gh.Repository) { fmt.Print("\n\n") } -func printGists(gists []*gh.Gist, show_all bool) { +func printGists(gists []*gh.Gist, showAll bool) { privateCount := 0 t := table.NewWriter() t.SetOutputMirror(os.Stdout) t.AppendHeader(table.Row{"Gist ID", "Gist Link", "Description", "Private"}) for _, gist := range gists { - if show_all && *gist.Public { + if showAll && *gist.Public { t.AppendRow([]interface{}{*gist.ID, *gist.HTMLURL, *gist.Description, "false"}) } else if !*gist.Public { privateCount++ @@ -91,9 +196,9 @@ func printGists(gists []*gh.Gist, show_all bool) { t.AppendRow([]interface{}{green(*gist.ID), green(*gist.HTMLURL), green(*gist.Description), green("true")}) } } - if show_all && len(gists) == 0 { + if showAll && len(gists) == 0 { color.Red("[i] No Gist(s) Found\n") - } else if show_all { + } else if showAll { color.Yellow("[i] Found %v Total Gist(s) (%v private)\n", len(gists), privateCount) t.Render() } else if privateCount == 0 { @@ -105,100 +210,115 @@ func printGists(gists []*gh.Gist, show_all bool) { fmt.Print("\n\n") } -func getRemainingTime(t string) string { - targetTime, err := time.Parse("2006-01-02 15:04:05 MST", t) - if err != nil { - return "" - } - - // Get the current time - currentTime := time.Now() - - // Calculate the duration until the target time - durationUntilTarget := targetTime.Sub(currentTime) - durationUntilTarget = durationUntilTarget.Truncate(time.Minute) - - // Print the duration - return fmt.Sprintf("%v", durationUntilTarget) +type TokenMetadata struct { + Type string + FineGrained bool + User *gh.User + Expiration time.Time + OauthScopes []analyzers.Permission } // getTokenMetadata gets the username, expiration date, and x-oauth-scopes headers for a given token // by sending a GET request to the /user endpoint // Returns a response object for usage in the checkFineGrained function -func getTokenMetadata(token string, client *gh.Client) (resp *gh.Response, err error) { +func getTokenMetadata(token string, client *gh.Client) (*TokenMetadata, error) { user, resp, err := client.Users.Get(context.Background(), "") if err != nil { return nil, err } - color.Yellow("[i] Token User: %v", *user.Login) + expiration, _ := time.Parse("2006-01-02 15:04:05 MST", resp.Header.Get("github-authentication-token-expiration")) - expiry := resp.Header.Get("github-authentication-token-expiration") - timeRemaining := getRemainingTime(expiry) - if timeRemaining == "" { - color.Red("[i] Token Expiration: does not expire") - } else { - color.Yellow("[i] Token Expiration: %v (%v remaining)", expiry, timeRemaining) + var oauthScopes []analyzers.Permission + for _, scope := range resp.Header.Values("X-OAuth-Scopes") { + for _, scope := range strings.Split(scope, ", ") { + oauthScopes = append(oauthScopes, analyzers.Permission{Value: scope}) + } } - return resp, nil + tokenType, fineGrained := checkFineGrained(token, oauthScopes) + return &TokenMetadata{ + Type: tokenType, + FineGrained: fineGrained, + User: user, + Expiration: expiration, + OauthScopes: oauthScopes, + }, nil } -func checkFineGrained(resp *gh.Response, token string) (bool, error) { +func checkFineGrained(token string, oauthScopes []analyzers.Permission) (string, bool) { // For details on token prefixes, see: // https://github.blog/2021-04-05-behind-githubs-new-authentication-token-formats/ // Special case for ghu_ prefix tokens (ex: in a codespace) that don't have the X-OAuth-Scopes header if strings.HasPrefix(token, "ghu_") { - color.Yellow("[i] Token Type: GitHub User-to-Server Token") - return true, nil + return "GitHub User-to-Server Token", true } // Handle github_pat_ tokens if strings.HasPrefix(token, "github_pat") { - color.Yellow("[i] Token Type: Fine-Grained GitHub Personal Access Token") - return true, nil + return "Fine-Grained GitHub Personal Access Token", true } // Handle classic PATs if strings.HasPrefix(token, "ghp_") { - color.Yellow("[i] Token Type: Classic GitHub Personal Access Token") - return false, nil + return "Classic GitHub Personal Access Token", false } // Catch-all for any other types // If resp.Header "X-OAuth-Scopes" doesn't exist, then we have fine-grained permissions - color.Yellow("[i] Token Type: GitHub Token") - if len(resp.Header.Values("X-Oauth-Scopes")) > 0 { - return false, nil + if len(oauthScopes) > 0 { + return "GitHub Token", false } - return true, nil + return "GitHub Token", true } -func AnalyzePermissions(cfg *config.Config, key string) { +type SecretInfo struct { + Metadata *TokenMetadata + Repos []*gh.Repository + Gists []*gh.Gist + // AccessibleRepos, RepoAccessMap, and UserAccessMap are only set if + // the token has fine-grained access. + AccessibleRepos []*gh.Repository + RepoAccessMap map[string]string + UserAccessMap map[string]string +} - // ToDo: Add logging for GitHub when rewrite to not use GH client. - if cfg.LoggingEnabled { - color.Red("[x] Logging not supported for GitHub Token Analysis.") - return +func AnalyzePermissions(cfg *config.Config, key string) (*SecretInfo, error) { + if cfg == nil { + cfg = &config.Config{} } + client := gh.NewClient(analyzers.NewAnalyzeClient(cfg)).WithAuthToken(key) - client := gh.NewClient(nil).WithAuthToken(key) - - resp, err := getTokenMetadata(key, client) + md, err := getTokenMetadata(key, client) if err != nil { - color.Red("[x] Invalid GitHub Token.") + return nil, err + } + + if md.FineGrained { + return analyzeFineGrainedToken(client, md, cfg.Shallow) + } + return 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 } - // Check if the token is fine-grained or classic - if fineGrained, err := checkFineGrained(resp, key); err != nil { - color.Red("[x] Invalid GitHub Token.") - return - } else if !fineGrained { - fmt.Print("\n\n") - analyzeClassicToken(client, key, cfg.ShowAll) + 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 { - fmt.Print("\n\n") - analyzeFineGrainedToken(client, key, cfg.ShowAll) + timeRemaining := time.Until(expiry) + color.Yellow("[i] Token Expiration: %v (%v remaining)", expiry, timeRemaining) } + color.Yellow("[i] Token Type: %s\n\n", info.Metadata.Type) + + if info.Metadata.FineGrained { + printFineGrainedToken(cfg, info) + return + } + printClassicToken(cfg, info) } diff --git a/pkg/analyzer/cli.go b/pkg/analyzer/cli.go index 3ab09a709..9cd81c916 100644 --- a/pkg/analyzer/cli.go +++ b/pkg/analyzer/cli.go @@ -188,7 +188,7 @@ func Run(cmd string) { panic("todo") case githubScan.FullCommand(): cfg.LogFile = analyzers.CreateLogFileName("github") - github.AnalyzePermissions(cfg, *githubKey) + github.AnalyzeAndPrintPermissions(cfg, *githubKey) case sendgridScan.FullCommand(): cfg.LogFile = analyzers.CreateLogFileName("sendgrid") sendgrid.AnalyzePermissions(cfg, *sendgridKey) diff --git a/pkg/analyzer/config/config.go b/pkg/analyzer/config/config.go index 3189eabec..4efd88cbc 100644 --- a/pkg/analyzer/config/config.go +++ b/pkg/analyzer/config/config.go @@ -5,4 +5,6 @@ type Config struct { LoggingEnabled bool LogFile string ShowAll bool + // Limit API calls when enumerating permissions. + Shallow bool } diff --git a/pkg/detectors/github/v1/github_old.go b/pkg/detectors/github/v1/github_old.go index b5eb37e9a..0a78a8671 100644 --- a/pkg/detectors/github/v1/github_old.go +++ b/pkg/detectors/github/v1/github_old.go @@ -85,6 +85,7 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result "rotation_guide": "https://howtorotate.com/docs/tutorials/github/", "version": fmt.Sprintf("%d", s.Version()), }, + AnalysisInfo: map[string]string{"key": token}, } if verify { diff --git a/pkg/detectors/github/v2/github.go b/pkg/detectors/github/v2/github.go index 2c21a3ce4..08ec56758 100644 --- a/pkg/detectors/github/v2/github.go +++ b/pkg/detectors/github/v2/github.go @@ -18,9 +18,9 @@ type Scanner struct { } // Ensure the Scanner satisfies the interfaces at compile time. -var _ detectors.Detector = (*v1.Scanner)(nil) -var _ detectors.Versioner = (*v1.Scanner)(nil) -var _ detectors.EndpointCustomizer = (*v1.Scanner)(nil) +var _ detectors.Detector = (*Scanner)(nil) +var _ detectors.Versioner = (*Scanner)(nil) +var _ detectors.EndpointCustomizer = (*Scanner)(nil) func (s Scanner) Version() int { return 2 @@ -66,6 +66,7 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result "rotation_guide": "https://howtorotate.com/docs/tutorials/github/", "version": fmt.Sprintf("%d", s.Version()), }, + AnalysisInfo: map[string]string{"key": token}, } if verify {