Skip to content

Commit

Permalink
[analyze] Implement Analyzer interface for github (#3110)
Browse files Browse the repository at this point in the history
* [analyze] Implement Analyzer interface for github

* Make github repo and user enumeration configurable

* Add AnalysisInfo to github detector

* Use AnalyzeAndPrintPermissions from the CLI
  • Loading branch information
mcastorina authored Jul 26, 2024
1 parent 6707361 commit 9d089c2
Show file tree
Hide file tree
Showing 8 changed files with 324 additions and 157 deletions.
13 changes: 13 additions & 0 deletions pkg/analyzer/analyzers/analyzers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
132 changes: 80 additions & 52 deletions pkg/analyzer/analyzers/github/classictoken.go
Original file line number Diff line number Diff line change
@@ -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"},
Expand All @@ -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 {
Expand All @@ -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.

func analyzeClassicToken(client *gh.Client, _ string, show_all bool) {
// 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)

var repos []*gh.Repository
if hasPrivateRepoAccess(scopes) {
var err error
repos, err = getAllReposForUser(client)
if err != nil {
return nil, err
}
}

// Issue GET request to /user
user, resp, err := client.Users.Get(context.Background(), "")
// Get all private gists
gists, err := getAllGistsForUser(client)
if err != nil {
color.Red("[x] Invalid GitHub Token.")
return
return nil, err
}

// If resp.Header "X-OAuth-Scopes", parse the scopes into a map[string]bool
headerScopes := resp.Header.Get("X-OAuth-Scopes")
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"}

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)
for _, privScope := range privateScopes {
if scopes[privScope] {
intersection = append(intersection, privScope)
}
}
return intersection
}

printClassicGHPermissions(scopes, show_all)
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 := checkPrivateRepoAccess(scopes)

if len(privateScopes) > 0 && slices.Contains(privateScopes, "repo") {
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:")
repos, _ := getAllReposForUser(client)
printGitHubRepos(repos)
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", *user.Login)
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.")
}

// Get all private gists
gists, _ := getAllGistsForUser(client)
printGists(gists, show_all)

printGists(info.Gists, cfg.ShowAll)
}

// Question: can you access private repo with those other permissions? or can we just not list them?
Expand All @@ -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)
Expand All @@ -145,24 +173,24 @@ 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 {
indentation = 0
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})
}
}
}
Expand All @@ -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 {
Expand Down
90 changes: 46 additions & 44 deletions pkg/analyzer/analyzers/github/finegrained.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
// 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)
repoAccessMap := make(map[string]string)
userAccessMap := make(map[string]string)

if !shallowCheck {
// Check our access
repoAccessMap := make(map[string]string)
for key := range repoPermFuncMap {
repoAccessMap[key] = analyzeRepositoryPermissions(client, filteredRepos, key)
repoAccessMap[key] = analyzeRepositoryPermissions(client, accessibleRepos, key)
}

// Print out the access map
printFineGrainedPermissions(repoAccessMap, show_all, 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
for key := range acctPermFuncMap {
userAccessMap[key] = analyzeUserPermissions(client, meta.User, key)
}
}

// Analyze Account's Permissions
userAccessMap := make(map[string]string)
for key := range acctPermFuncMap {
userAccessMap[key] = analyzeUserPermissions(client, user, key)
}
return &SecretInfo{
Metadata: meta,
Repos: allRepos,
Gists: allGists,
AccessibleRepos: accessibleRepos,
RepoAccessMap: repoAccessMap,
UserAccessMap: userAccessMap,
}, nil
}

printFineGrainedPermissions(userAccessMap, show_all, false)
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(info.AccessibleRepos)) + " Accessible Repositor(ies) \n")
printGitHubRepos(info.AccessibleRepos)

// Get all private gists
gists, _ := getAllGistsForUser(client)
printGists(gists, show_all)
// Print out the access map
printFineGrainedPermissions(info.RepoAccessMap, cfg.ShowAll, true)
}

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)
Expand All @@ -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 {
Expand Down
Loading

0 comments on commit 9d089c2

Please sign in to comment.