From 25b01019b338add0f4e01a425a091b4c9912ad75 Mon Sep 17 00:00:00 2001 From: Dustin Decker Date: Wed, 31 Jul 2024 13:01:29 -0700 Subject: [PATCH] Add permissions lookup tables (#3125) * OpenAI LUT * github LUT * cleanup * add test * update * update * update openai * update * Add Analyze interface to Twilio (#3128) * Add Analyze interface to Twilio * add readme --- .github/workflows/secrets.yml | 21 +- pkg/analyzer/README.md | 15 + pkg/analyzer/analyzers/analyzers.go | 5 +- .../analyzers/github/classic/classic.yaml | 49 ++ .../github/classic/classic_permissions.go | 296 +++++++ .../analyzers/github/classic/classictoken.go | 246 ++++++ pkg/analyzer/analyzers/github/classictoken.go | 230 ----- .../analyzers/github/common/github.go | 182 ++++ .../github/{ => finegrained}/finegrained.go | 831 +++++++++--------- .../github/finegrained/finegrained.yaml | 80 ++ .../finegrained/finegrained_permissions.go | 446 ++++++++++ pkg/analyzer/analyzers/github/github.go | 190 +--- pkg/analyzer/analyzers/github/github_test.go | 121 +++ pkg/analyzer/analyzers/openai/openai.go | 19 +- pkg/analyzer/analyzers/openai/openai_test.go | 125 +++ pkg/analyzer/analyzers/openai/permissions.go | 106 +++ .../analyzers/openai/permissions.yaml | 11 + pkg/analyzer/analyzers/openai/scopes.go | 70 +- pkg/analyzer/analyzers/twilio/permissions.go | 136 +++ .../analyzers/twilio/permissions.yaml | 17 + pkg/analyzer/analyzers/twilio/twilio.go | 80 ++ .../generate_permissions.go | 144 +++ 22 files changed, 2544 insertions(+), 876 deletions(-) create mode 100644 pkg/analyzer/README.md create mode 100644 pkg/analyzer/analyzers/github/classic/classic.yaml create mode 100644 pkg/analyzer/analyzers/github/classic/classic_permissions.go create mode 100644 pkg/analyzer/analyzers/github/classic/classictoken.go delete mode 100644 pkg/analyzer/analyzers/github/classictoken.go create mode 100644 pkg/analyzer/analyzers/github/common/github.go rename pkg/analyzer/analyzers/github/{ => finegrained}/finegrained.go (68%) create mode 100644 pkg/analyzer/analyzers/github/finegrained/finegrained.yaml create mode 100644 pkg/analyzer/analyzers/github/finegrained/finegrained_permissions.go create mode 100644 pkg/analyzer/analyzers/github/github_test.go create mode 100644 pkg/analyzer/analyzers/openai/openai_test.go create mode 100644 pkg/analyzer/analyzers/openai/permissions.go create mode 100644 pkg/analyzer/analyzers/openai/permissions.yaml create mode 100644 pkg/analyzer/analyzers/twilio/permissions.go create mode 100644 pkg/analyzer/analyzers/twilio/permissions.yaml create mode 100644 pkg/analyzer/generate_permissions/generate_permissions.go diff --git a/.github/workflows/secrets.yml b/.github/workflows/secrets.yml index bd564fccd..eb2b60408 100644 --- a/.github/workflows/secrets.yml +++ b/.github/workflows/secrets.yml @@ -1,4 +1,3 @@ - name: Scan for secrets on: @@ -15,13 +14,13 @@ jobs: if: github.repository == 'trufflesecurity/trufflehog' runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ github.head_ref }} - - name: Dogfood - uses: ./ - id: dogfood - with: - extra_args: --results=verified,unknown + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.head_ref }} + - name: Dogfood + uses: ./ + id: dogfood + with: + extra_args: --results=verified diff --git a/pkg/analyzer/README.md b/pkg/analyzer/README.md new file mode 100644 index 000000000..3c9a4c9b7 --- /dev/null +++ b/pkg/analyzer/README.md @@ -0,0 +1,15 @@ +# Implementing Analyzers + +## Defining the Permissions + +Permissions are defined in lower snake case as `permission_name:access_level`. + +The Permissions are initially defined as a [yaml file](analyzers/twilio/permissions.yaml). + +At the top of the [analyzer implementation](analyzers/twilio/twilio.go) you specify the go generate command. + +You can install the generator with `go install github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/generate_permissions`. + +Then you can run `go generate ./...` to generate the Permission types for the analyzer. + +The generated Permission types are to be used in the `AnalyzerResult` struct when defining the `Permissions` and in your code. diff --git a/pkg/analyzer/analyzers/analyzers.go b/pkg/analyzer/analyzers/analyzers.go index 35aff76f0..971f64b2f 100644 --- a/pkg/analyzer/analyzers/analyzers.go +++ b/pkg/analyzer/analyzers/analyzers.go @@ -38,9 +38,8 @@ type ( } Permission struct { - Value string - AccessLevel string - Parent *Permission + Value string + Parent *Permission } Binding struct { diff --git a/pkg/analyzer/analyzers/github/classic/classic.yaml b/pkg/analyzer/analyzers/github/classic/classic.yaml new file mode 100644 index 000000000..8383c50c2 --- /dev/null +++ b/pkg/analyzer/analyzers/github/classic/classic.yaml @@ -0,0 +1,49 @@ +permissions: + - 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 diff --git a/pkg/analyzer/analyzers/github/classic/classic_permissions.go b/pkg/analyzer/analyzers/github/classic/classic_permissions.go new file mode 100644 index 000000000..a957bb918 --- /dev/null +++ b/pkg/analyzer/analyzers/github/classic/classic_permissions.go @@ -0,0 +1,296 @@ +// Code generated by go generate; DO NOT EDIT. +package classic + +import "errors" + +type Permission int + +const ( + NoAccess Permission = iota + Repo Permission = iota + RepoStatus Permission = iota + RepoDeployment Permission = iota + PublicRepo Permission = iota + RepoInvite Permission = iota + SecurityEvents Permission = iota + Workflow Permission = iota + WritePackages Permission = iota + ReadPackages Permission = iota + DeletePackages Permission = iota + AdminOrg Permission = iota + WriteOrg Permission = iota + ReadOrg Permission = iota + ManageRunnersOrg Permission = iota + AdminPublicKey Permission = iota + WritePublicKey Permission = iota + ReadPublicKey Permission = iota + AdminRepoHook Permission = iota + WriteRepoHook Permission = iota + ReadRepoHook Permission = iota + AdminOrgHook Permission = iota + Gist Permission = iota + Notifications Permission = iota + User Permission = iota + ReadUser Permission = iota + UserEmail Permission = iota + UserFollow Permission = iota + DeleteRepo Permission = iota + WriteDiscussion Permission = iota + ReadDiscussion Permission = iota + AdminEnterprise Permission = iota + ManageRunnersEnterprise Permission = iota + ManageBillingEnterprise Permission = iota + ReadEnterprise Permission = iota + AuditLog Permission = iota + ReadAuditLog Permission = iota + Codespace Permission = iota + CodespaceSecrets Permission = iota + Copilot Permission = iota + ManageBillingCopilot Permission = iota + Project Permission = iota + ReadProject Permission = iota + AdminGpgKey Permission = iota + WriteGpgKey Permission = iota + ReadGpgKey Permission = iota + AdminSshSigningKey Permission = iota + WriteSshSigningKey Permission = iota + ReadSshSigningKey Permission = iota +) + +var ( + permissionStrings = map[Permission]string{ + Repo: "repo", + RepoStatus: "repo:status", + RepoDeployment: "repo_deployment", + PublicRepo: "public_repo", + RepoInvite: "repo:invite", + SecurityEvents: "security_events", + Workflow: "workflow", + WritePackages: "write:packages", + ReadPackages: "read:packages", + DeletePackages: "delete:packages", + AdminOrg: "admin:org", + WriteOrg: "write:org", + ReadOrg: "read:org", + ManageRunnersOrg: "manage_runners:org", + AdminPublicKey: "admin:public_key", + WritePublicKey: "write:public_key", + ReadPublicKey: "read:public_key", + AdminRepoHook: "admin:repo_hook", + WriteRepoHook: "write:repo_hook", + ReadRepoHook: "read:repo_hook", + AdminOrgHook: "admin:org_hook", + Gist: "gist", + Notifications: "notifications", + User: "user", + ReadUser: "read:user", + UserEmail: "user:email", + UserFollow: "user:follow", + DeleteRepo: "delete_repo", + WriteDiscussion: "write:discussion", + ReadDiscussion: "read:discussion", + AdminEnterprise: "admin:enterprise", + ManageRunnersEnterprise: "manage_runners:enterprise", + ManageBillingEnterprise: "manage_billing:enterprise", + ReadEnterprise: "read:enterprise", + AuditLog: "audit_log", + ReadAuditLog: "read:audit_log", + Codespace: "codespace", + CodespaceSecrets: "codespace:secrets", + Copilot: "copilot", + ManageBillingCopilot: "manage_billing:copilot", + Project: "project", + ReadProject: "read:project", + AdminGpgKey: "admin:gpg_key", + WriteGpgKey: "write:gpg_key", + ReadGpgKey: "read:gpg_key", + AdminSshSigningKey: "admin:ssh_signing_key", + WriteSshSigningKey: "write:ssh_signing_key", + ReadSshSigningKey: "read:ssh_signing_key", + } + + stringToPermission = map[string]Permission{ + "repo": Repo, + "repo:status": RepoStatus, + "repo_deployment": RepoDeployment, + "public_repo": PublicRepo, + "repo:invite": RepoInvite, + "security_events": SecurityEvents, + "workflow": Workflow, + "write:packages": WritePackages, + "read:packages": ReadPackages, + "delete:packages": DeletePackages, + "admin:org": AdminOrg, + "write:org": WriteOrg, + "read:org": ReadOrg, + "manage_runners:org": ManageRunnersOrg, + "admin:public_key": AdminPublicKey, + "write:public_key": WritePublicKey, + "read:public_key": ReadPublicKey, + "admin:repo_hook": AdminRepoHook, + "write:repo_hook": WriteRepoHook, + "read:repo_hook": ReadRepoHook, + "admin:org_hook": AdminOrgHook, + "gist": Gist, + "notifications": Notifications, + "user": User, + "read:user": ReadUser, + "user:email": UserEmail, + "user:follow": UserFollow, + "delete_repo": DeleteRepo, + "write:discussion": WriteDiscussion, + "read:discussion": ReadDiscussion, + "admin:enterprise": AdminEnterprise, + "manage_runners:enterprise": ManageRunnersEnterprise, + "manage_billing:enterprise": ManageBillingEnterprise, + "read:enterprise": ReadEnterprise, + "audit_log": AuditLog, + "read:audit_log": ReadAuditLog, + "codespace": Codespace, + "codespace:secrets": CodespaceSecrets, + "copilot": Copilot, + "manage_billing:copilot": ManageBillingCopilot, + "project": Project, + "read:project": ReadProject, + "admin:gpg_key": AdminGpgKey, + "write:gpg_key": WriteGpgKey, + "read:gpg_key": ReadGpgKey, + "admin:ssh_signing_key": AdminSshSigningKey, + "write:ssh_signing_key": WriteSshSigningKey, + "read:ssh_signing_key": ReadSshSigningKey, + } + + permissionIDs = map[Permission]int{ + Repo: 0, + RepoStatus: 1, + RepoDeployment: 2, + PublicRepo: 3, + RepoInvite: 4, + SecurityEvents: 5, + Workflow: 6, + WritePackages: 7, + ReadPackages: 8, + DeletePackages: 9, + AdminOrg: 10, + WriteOrg: 11, + ReadOrg: 12, + ManageRunnersOrg: 13, + AdminPublicKey: 14, + WritePublicKey: 15, + ReadPublicKey: 16, + AdminRepoHook: 17, + WriteRepoHook: 18, + ReadRepoHook: 19, + AdminOrgHook: 20, + Gist: 21, + Notifications: 22, + User: 23, + ReadUser: 24, + UserEmail: 25, + UserFollow: 26, + DeleteRepo: 27, + WriteDiscussion: 28, + ReadDiscussion: 29, + AdminEnterprise: 30, + ManageRunnersEnterprise: 31, + ManageBillingEnterprise: 32, + ReadEnterprise: 33, + AuditLog: 34, + ReadAuditLog: 35, + Codespace: 36, + CodespaceSecrets: 37, + Copilot: 38, + ManageBillingCopilot: 39, + Project: 40, + ReadProject: 41, + AdminGpgKey: 42, + WriteGpgKey: 43, + ReadGpgKey: 44, + AdminSshSigningKey: 45, + WriteSshSigningKey: 46, + ReadSshSigningKey: 47, + } + + idToPermission = map[int]Permission{ + 0: Repo, + 1: RepoStatus, + 2: RepoDeployment, + 3: PublicRepo, + 4: RepoInvite, + 5: SecurityEvents, + 6: Workflow, + 7: WritePackages, + 8: ReadPackages, + 9: DeletePackages, + 10: AdminOrg, + 11: WriteOrg, + 12: ReadOrg, + 13: ManageRunnersOrg, + 14: AdminPublicKey, + 15: WritePublicKey, + 16: ReadPublicKey, + 17: AdminRepoHook, + 18: WriteRepoHook, + 19: ReadRepoHook, + 20: AdminOrgHook, + 21: Gist, + 22: Notifications, + 23: User, + 24: ReadUser, + 25: UserEmail, + 26: UserFollow, + 27: DeleteRepo, + 28: WriteDiscussion, + 29: ReadDiscussion, + 30: AdminEnterprise, + 31: ManageRunnersEnterprise, + 32: ManageBillingEnterprise, + 33: ReadEnterprise, + 34: AuditLog, + 35: ReadAuditLog, + 36: Codespace, + 37: CodespaceSecrets, + 38: Copilot, + 39: ManageBillingCopilot, + 40: Project, + 41: ReadProject, + 42: AdminGpgKey, + 43: WriteGpgKey, + 44: ReadGpgKey, + 45: AdminSshSigningKey, + 46: WriteSshSigningKey, + 47: ReadSshSigningKey, + } +) + +// ToString converts a Permission enum to its string representation +func (p Permission) ToString() (string, error) { + if str, ok := permissionStrings[p]; ok { + return str, nil + } + return "", errors.New("invalid permission") +} + +// ToID converts a Permission enum to its ID +func (p Permission) ToID() (int, error) { + if id, ok := permissionIDs[p]; ok { + return id, nil + } + return 0, errors.New("invalid permission") +} + +// PermissionFromString converts a string representation to its Permission enum +func PermissionFromString(s string) (Permission, error) { + if p, ok := stringToPermission[s]; ok { + return p, nil + } + return 0, errors.New("invalid permission string") +} + +// PermissionFromID converts an ID to its Permission enum +func PermissionFromID(id int) (Permission, error) { + if p, ok := idToPermission[id]; ok { + return p, nil + } + return 0, errors.New("invalid permission ID") +} diff --git a/pkg/analyzer/analyzers/github/classic/classictoken.go b/pkg/analyzer/analyzers/github/classic/classictoken.go new file mode 100644 index 000000000..b453bb4a4 --- /dev/null +++ b/pkg/analyzer/analyzers/github/classic/classictoken.go @@ -0,0 +1,246 @@ +//go:generate generate_permissions classic.yaml classic_permissions.go classic + +package classic + +import ( + "fmt" + "os" + "strings" + + "github.com/fatih/color" + gh "github.com/google/go-github/v63/github" + "github.com/jedib0t/go-pretty/v6/table" + + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/github/common" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" +) + +var SCOPE_ORDER = [][]Permission{ + {Repo, RepoStatus, RepoDeployment, PublicRepo, RepoInvite, SecurityEvents}, + {Workflow}, + {WritePackages, ReadPackages}, + {DeletePackages}, + {AdminOrg, WriteOrg, ReadOrg, ManageRunnersOrg}, + {AdminPublicKey, WritePublicKey, ReadPublicKey}, + {AdminRepoHook, WriteRepoHook, ReadRepoHook}, + {AdminOrgHook}, + {Gist}, + {Notifications}, + {User, ReadUser, UserEmail, UserFollow}, + {DeleteRepo}, + {WriteDiscussion, ReadDiscussion}, + {AdminEnterprise, ManageRunnersEnterprise, ManageBillingEnterprise, ReadEnterprise}, + {AuditLog, ReadAuditLog}, + {Codespace, CodespaceSecrets}, + {Copilot, ManageBillingCopilot}, + {Project, ReadProject}, + {AdminGpgKey, WriteGpgKey, ReadGpgKey}, + {AdminSshSigningKey, WriteSshSigningKey, ReadSshSigningKey}, +} + +var SCOPE_TO_SUB_SCOPE = map[Permission][]Permission{ + Repo: {RepoStatus, RepoDeployment, PublicRepo, RepoInvite, SecurityEvents}, + WritePackages: {ReadPackages}, + AdminOrg: {WriteOrg, ReadOrg, ManageRunnersOrg}, + WriteOrg: {ReadOrg}, + AdminPublicKey: {WritePublicKey, ReadPublicKey}, + WritePublicKey: {ReadPublicKey}, + AdminRepoHook: {WriteRepoHook, ReadRepoHook}, + WriteRepoHook: {ReadRepoHook}, + User: {ReadUser, UserEmail, UserFollow}, + WriteDiscussion: {ReadDiscussion}, + AdminEnterprise: {ManageRunnersEnterprise, ManageBillingEnterprise, ReadEnterprise}, + ManageBillingEnterprise: {ReadEnterprise}, + AuditLog: {ReadAuditLog}, + Codespace: {CodespaceSecrets}, + Copilot: {ManageBillingCopilot}, + Project: {ReadProject}, + AdminGpgKey: {WriteGpgKey, ReadGpgKey}, + WriteGpgKey: {ReadGpgKey}, + AdminSshSigningKey: {WriteSshSigningKey, ReadSshSigningKey}, + WriteSshSigningKey: {ReadSshSigningKey}, +} + +func hasPrivateRepoAccess(scopes map[Permission]bool) bool { + return scopes[Repo] +} + +func processScopes(headerScopesSlice []analyzers.Permission) map[Permission]bool { + allScopes := make(map[Permission]bool) + for _, scope := range headerScopesSlice { + allScopes[stringToPermission[scope.Value]] = true + } + for scope := range allScopes { + if subScopes, ok := SCOPE_TO_SUB_SCOPE[scope]; ok { + for _, subScope := range subScopes { + allScopes[subScope] = true + } + } + } + return allScopes +} + +func AnalyzeClassicToken(client *gh.Client, meta *common.TokenMetadata) (*common.SecretInfo, error) { + scopes := processScopes(meta.OauthScopes) + + var repos []*gh.Repository + if hasPrivateRepoAccess(scopes) { + var err error + repos, err = common.GetAllReposForUser(client) + if err != nil { + return nil, err + } + } + + gists, err := common.GetAllGistsForUser(client) + if err != nil { + return nil, err + } + + return &common.SecretInfo{ + Metadata: meta, + Repos: repos, + Gists: gists, + }, nil +} + +func filterPrivateRepoScopes(scopes map[Permission]bool) []Permission { + var intersection []Permission + privateScopes := []Permission{Repo, RepoStatus, RepoDeployment, RepoInvite, SecurityEvents, AdminRepoHook, WriteRepoHook, ReadRepoHook} + + for _, privScope := range privateScopes { + if scopes[privScope] { + intersection = append(intersection, privScope) + } + } + return intersection +} + +func PrintClassicToken(cfg *config.Config, info *common.SecretInfo) { + scopes := processScopes(info.Metadata.OauthScopes) + if len(scopes) == 0 { + color.Red("[x] Classic Token has no scopes") + } else { + printClassicGHPermissions(scopes, cfg.ShowAll) + } + + 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:") + common.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", joinPermissions(privateScopes)) + } else if scopes[PublicRepo] { + 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.") + } + common.PrintGists(info.Gists, cfg.ShowAll) +} + +func joinPermissions(perms []Permission) string { + var permStrings []string + for _, perm := range perms { + permStr, err := perm.ToString() + if err != nil { + panic(err) + } + permStrings = append(permStrings, permStr) + } + return strings.Join(permStrings, ", ") +} + +func scopeFormatter(scope Permission, checked bool, indentation int) (string, string) { + scopeStr, err := scope.ToString() + if err != nil { + panic(err) + } + if indentation != 0 { + scopeStr = strings.Repeat(" ", indentation) + scopeStr + } + if checked { + return color.GreenString(scopeStr), color.GreenString("true") + } + return scopeStr, "false" +} + +func printClassicGHPermissions(scopes map[Permission]bool, showAll bool) { + scopeCount := 0 + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Scope", "In-Scope"}) + + filteredScopes := make([][]Permission, 0) + for _, scopeSlice := range SCOPE_ORDER { + for _, scope := range scopeSlice { + if scopes[scope] { + filteredScopes = append(filteredScopes, scopeSlice) + break + } + } + } + + var formattedScope, status string + var indentation int + + 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([]any{formattedScope, status}) + } else { + + scopeStr, err := scope.ToString() + if err != nil { + panic(err) + } + t.AppendRow([]any{scopeStr, "----"}) + } + } else { + indentation = 2 + if scopes[scope] { + scopeCount++ + formattedScope, status = scopeFormatter(scope, true, indentation) + t.AppendRow([]any{formattedScope, status}) + } + } + } + t.AppendSeparator() + } + } else { + for _, scopeSlice := range SCOPE_ORDER { + for ind, scope := range scopeSlice { + if ind == 0 { + indentation = 0 + } else { + indentation = 2 + } + if scopes[scope] { + scopeCount++ + formattedScope, status = scopeFormatter(scope, true, indentation) + t.AppendRow([]any{formattedScope, status}) + } else { + formattedScope, status = scopeFormatter(scope, false, indentation) + t.AppendRow([]any{formattedScope, status}) + } + } + t.AppendSeparator() + } + } + + if scopeCount == 0 && !showAll { + color.Red("No Scopes Found for the GitHub Token above\n\n") + return + } else if scopeCount == 0 { + color.Red("Found No Scopes for the GitHub Token above\n") + } else { + color.Green(fmt.Sprintf("[!] Found %v Scope(s) for the GitHub Token above\n", scopeCount)) + } + t.Render() + fmt.Print("\n\n") +} diff --git a/pkg/analyzer/analyzers/github/classictoken.go b/pkg/analyzer/analyzers/github/classictoken.go deleted file mode 100644 index 6ea64ac9f..000000000 --- a/pkg/analyzer/analyzers/github/classictoken.go +++ /dev/null @@ -1,230 +0,0 @@ -package github - -import ( - "fmt" - "os" - "strings" - - "github.com/fatih/color" - gh "github.com/google/go-github/v63/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_TO_SUB_SCOPE = map[string][]string{ - "repo": {"repo:status", "repo_deployment", "public_repo", "repo:invite", "security_events"}, - "write:pakages": {"read:packages"}, - "admin:org": {"write:org", "read:org", "manage_runners:org"}, - "write:org": {"read:org"}, - "admin:public_key": {"write:public_key", "read:public_key"}, - "write:public_key": {"read:public_key"}, - "admin:repo_hook": {"write:repo_hook", "read:repo_hook"}, - "write:repo_hook": {"read:repo_hook"}, - "user": {"read:user", "user:email", "user:follow"}, - "write:discussion": {"read:discussion"}, - "admin:enterprise": {"manage_runners:enterprise", "manage_billing:enterprise", "read: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"}, - "write:gpg_key": {"read:gpg_key"}, - "admin:ssh_signing_key": {"write:ssh_signing_key", "read:ssh_signing_key"}, - "write:ssh_signing_key": {"read:ssh_signing_key"}, -} - -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 []analyzers.Permission) map[string]bool { - allScopes := make(map[string]bool) - for _, scope := range headerScopesSlice { - allScopes[scope.Value] = true - } - for scope := range allScopes { - if subScopes, ok := SCOPE_TO_SUB_SCOPE[scope]; ok { - for _, subScope := range subScopes { - allScopes[subScope] = true - } - } - } - return allScopes -} - -// 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, 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 - } - } - - // Get all private gists - 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? - -func scopeFormatter(scope string, checked bool, indentation int) (string, string) { - if indentation != 0 { - scope = strings.Repeat(" ", indentation) + scope - } - if checked { - return color.GreenString(scope), color.GreenString("true") - } else { - return scope, "false" - } -} - -func printClassicGHPermissions(scopes map[string]bool, showAll bool) { - scopeCount := 0 - t := table.NewWriter() - t.SetOutputMirror(os.Stdout) - t.AppendHeader(table.Row{"Scope", "In-Scope" /* Add more column headers if needed */}) - - filteredScopes := make([][]string, 0) - for _, scopeSlice := range SCOPE_ORDER { - for _, scope := range scopeSlice { - if scopes[scope] { - filteredScopes = append(filteredScopes, scopeSlice) - break - } - } - } - - // For ease of reading, divide the scopes into sections, just like the GH UI - var formattedScope, status string - var indentation int - - 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([]any{formattedScope, status}) - } else { - t.AppendRow([]any{scope, "----"}) - } - } else { - indentation = 2 - if scopes[scope] { - scopeCount++ - formattedScope, status = scopeFormatter(scope, true, indentation) - t.AppendRow([]any{formattedScope, status}) - } - } - } - t.AppendSeparator() - } - } else { - for _, scopeSlice := range SCOPE_ORDER { - for ind, scope := range scopeSlice { - if ind == 0 { - indentation = 0 - } else { - indentation = 2 - } - if scopes[scope] { - scopeCount++ - formattedScope, status = scopeFormatter(scope, true, indentation) - t.AppendRow([]any{formattedScope, status}) - } else { - formattedScope, status = scopeFormatter(scope, false, indentation) - t.AppendRow([]any{formattedScope, status}) - } - } - t.AppendSeparator() - } - } - - if scopeCount == 0 && !showAll { - color.Red("No Scopes Found for the GitHub Token above\n\n") - return - } else if scopeCount == 0 { - color.Red("Found No Scopes for the GitHub Token above\n") - } else { - color.Green(fmt.Sprintf("[!] Found %v Scope(s) for the GitHub Token above\n", scopeCount)) - } - t.Render() - fmt.Print("\n\n") -} diff --git a/pkg/analyzer/analyzers/github/common/github.go b/pkg/analyzer/analyzers/github/common/github.go new file mode 100644 index 000000000..77727a229 --- /dev/null +++ b/pkg/analyzer/analyzers/github/common/github.go @@ -0,0 +1,182 @@ +package common + +import ( + "context" + "fmt" + "os" + "strings" + "time" + + "github.com/fatih/color" + gh "github.com/google/go-github/v63/github" + "github.com/jedib0t/go-pretty/table" + + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" +) + +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_") { + return "GitHub User-to-Server Token", true + } + + // Handle github_pat_ tokens + if strings.HasPrefix(token, "github_pat") { + return "Fine-Grained GitHub Personal Access Token", true + } + + // Handle classic PATs + if strings.HasPrefix(token, "ghp_") { + 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 + if len(oauthScopes) > 0 { + return "GitHub Token", false + } + return "GitHub Token", true +} + +type Permission int + +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 any + UserAccessMap any +} + +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) (*TokenMetadata, error) { + user, resp, err := client.Users.Get(context.Background(), "") + if err != nil { + return nil, err + } + + expiration, _ := time.Parse("2006-01-02 15:04:05 MST", resp.Header.Get("github-authentication-token-expiration")) + + 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}) + } + } + tokenType, fineGrained := checkFineGrained(token, oauthScopes) + return &TokenMetadata{ + Type: tokenType, + FineGrained: fineGrained, + User: user, + Expiration: expiration, + OauthScopes: oauthScopes, + }, nil +} + +func GetAllGistsForUser(client *gh.Client) ([]*gh.Gist, error) { + opt := &gh.GistListOptions{ListOptions: gh.ListOptions{PerPage: 100}} + var allGists []*gh.Gist + page := 1 + for { + opt.Page = page + gists, resp, err := client.Gists.List(context.Background(), "", opt) + if err != nil { + color.Red("Error getting gists.") + return nil, err + } + allGists = append(allGists, gists...) + + linkHeader := resp.Header.Get("link") + if linkHeader == "" || !strings.Contains(linkHeader, `rel="next"`) { + break + } + page++ + + } + + return allGists, nil +} + +func GetAllReposForUser(client *gh.Client) ([]*gh.Repository, error) { + opt := &gh.RepositoryListByAuthenticatedUserOptions{ListOptions: gh.ListOptions{PerPage: 100}} + var allRepos []*gh.Repository + page := 1 + for { + opt.Page = page + repos, resp, err := client.Repositories.ListByAuthenticatedUser(context.Background(), opt) + if err != nil { + color.Red("Error getting repos.") + return nil, err + } + allRepos = append(allRepos, repos...) + + linkHeader := resp.Header.Get("link") + if linkHeader == "" || !strings.Contains(linkHeader, `rel="next"`) { + break + } + page++ + + } + return allRepos, nil +} + +func PrintGitHubRepos(repos []*gh.Repository) { + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{"Repo Name", "Owner", "Repo Link", "Private"}) + for _, repo := range repos { + if *repo.Private { + green := color.New(color.FgGreen).SprintFunc() + t.AppendRow([]interface{}{green(*repo.Name), green(*repo.Owner.Login), green(*repo.HTMLURL), green("true")}) + } else { + t.AppendRow([]interface{}{*repo.Name, *repo.Owner.Login, *repo.HTMLURL, *repo.Private}) + } + } + t.Render() + fmt.Print("\n\n") +} + +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 showAll && *gist.Public { + t.AppendRow([]interface{}{*gist.ID, *gist.HTMLURL, *gist.Description, "false"}) + } else if !*gist.Public { + privateCount++ + green := color.New(color.FgGreen).SprintFunc() + t.AppendRow([]interface{}{green(*gist.ID), green(*gist.HTMLURL), green(*gist.Description), green("true")}) + } + } + if showAll && len(gists) == 0 { + color.Red("[i] No Gist(s) Found\n") + } else if showAll { + color.Yellow("[i] Found %v Total Gist(s) (%v private)\n", len(gists), privateCount) + t.Render() + } else if privateCount == 0 { + color.Red("[i] No Private Gist(s) Found\n") + } else { + color.Green(fmt.Sprintf("[!] Found %v Private Gist(s)\n", privateCount)) + t.Render() + } + fmt.Print("\n\n") +} diff --git a/pkg/analyzer/analyzers/github/finegrained.go b/pkg/analyzer/analyzers/github/finegrained/finegrained.go similarity index 68% rename from pkg/analyzer/analyzers/github/finegrained.go rename to pkg/analyzer/analyzers/github/finegrained/finegrained.go index 439ca2499..c5f93b25d 100644 --- a/pkg/analyzer/analyzers/github/finegrained.go +++ b/pkg/analyzer/analyzers/github/finegrained/finegrained.go @@ -1,7 +1,10 @@ -package github +//go:generate generate_permissions finegrained.yaml finegrained_permissions.go finegrained + +package finegrained import ( "context" + "errors" "fmt" "io" "log" @@ -12,6 +15,8 @@ import ( "github.com/fatih/color" gh "github.com/google/go-github/v63/github" "github.com/jedib0t/go-pretty/v6/table" + + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/github/common" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" ) @@ -21,161 +26,123 @@ const ( RANDOM_USERNAME = "d" + "ummy" + "acco" + "untgh" + "2024" RANDOM_REPO = "te" + "st" RANDOM_INTEGER = 4294967289 - - // Permissions - NO_ACCESS = "No access" - READ_ONLY = "Read-only" - READ_WRITE = "Read and write" - ERROR = "Error" - UNKNOWN = "Unknown" - NOT_IMPLEMENTED = "Not implemented" - - // Repo Permission Types - ACTIONS = "Actions" - ADMINISTRATION = "Administration" - CODE_SCANNING_ALERTS = "Code scanning alerts" - CODESPACES = "Codespaces" - CODESPACES_LIFECYCLE = "Codespaces lifecycle admin" - CODESPACES_METADATA = "Codespaces metadata" - CODESPACES_SECRETS = "Codespaces secrets" - COMMIT_STATUSES = "Commit statuses" - CONTENTS = "Contents" - CUSTOM_PROPERTIES = "Custom properties" - DEPENDABOT_ALERTS = "Dependabot alerts" - DEPENDABOT_SECRETS = "Dependabot secrets" - DEPLOYMENTS = "Deployments" - ENVIRONMENTS = "Environments" // Note: Addt'l permissions are not required (despite documentation). - ISSUES = "Issues" - MERGE_QUEUES = "Merge queues" - METADATA = "Metadata" - PAGES = "Pages" - PULL_REQUESTS = "Pull requests" - REPO_SECURITY = "Repository security advisories" - SECRET_SCANNING = "Secret scanning alerts" - SECRETS = "Secrets" - VARIABLES = "Variables" - WEBHOOKS = "Webhooks" - WORKFLOWS = "Workflows" - - // Account Permission Types - BLOCK_USER = "Block another user" - CODESPACE_USER_SECRETS = "Codespace user secrets" - EMAIL = "Email Addresses" - FOLLOWERS = "Followers" - GPG_KEYS = "GPG Keys" - GISTS = "Gists" - GIT_KEYS = "Git SSH keys" - LIMITS = "Interaction limits" - PLAN = "Plan" - PRIVATE_INVITES = "Private invitations" - PROFILE = "Profile" - SIGNING_KEYS = "SSH signing keys" - STARRING = "Starring" - WATCHING = "Watching" ) -var repoPermFuncMap = map[string]func(client *gh.Client, repo *gh.Repository, acess string) (string, error){ - ACTIONS: getActionsPermission, - ADMINISTRATION: getAdministrationPermission, - CODE_SCANNING_ALERTS: getCodeScanningAlertsPermission, - CODESPACES: getCodespacesPermission, - CODESPACES_LIFECYCLE: notImplemented, // ToDo: Implement. Docs make this look org-wide...not repo-based? - CODESPACES_METADATA: getCodespacesMetadataPermission, - CODESPACES_SECRETS: getCodespacesSecretsPermission, - COMMIT_STATUSES: getCommitStatusesPermission, - CONTENTS: getContentsPermission, - CUSTOM_PROPERTIES: notImplemented, // ToDo: Only supports orgs. Implement once have an org token. - DEPENDABOT_ALERTS: getDependabotAlertsPermission, - DEPENDABOT_SECRETS: getDependabotSecretsPermission, - DEPLOYMENTS: getDeploymentsPermission, - ENVIRONMENTS: getEnvironmentsPermission, - ISSUES: getIssuesPermission, - MERGE_QUEUES: notImplemented, // Skipped until API better documented - METADATA: getMetadataPermission, - PAGES: getPagesPermission, - PULL_REQUESTS: getPullRequestsPermission, - REPO_SECURITY: getRepoSecurityPermission, - SECRET_SCANNING: getSecretScanningPermission, - SECRETS: getSecretsPermission, - VARIABLES: getVariablesPermission, - WEBHOOKS: getWebhooksPermission, - WORKFLOWS: notImplemented, // ToDo: Skipped b/c would require us to create a release (High Risk function) +var ErrNoAccess = errors.New("no access") + +var repoPermFuncMap = []func(client *gh.Client, repo *gh.Repository, access string) (Permission, error){ + getActionsPermission, + getAdministrationPermission, + getCodeScanningAlertsPermission, + getCodespacesPermission, + notImplementedRepoPerm, // ToDo: Implement. Docs make this look org-wide...not repo-based? + getCodespacesMetadataPermission, + getCodespacesSecretsPermission, + getCommitStatusesPermission, + getContentsPermission, + notImplementedRepoPerm, // ToDo: Only supports orgs. Implement once have an org token. + getDependabotAlertsPermission, + getDependabotSecretsPermission, + getDeploymentsPermission, + getEnvironmentsPermission, + getIssuesPermission, + notImplementedRepoPerm, // Skipped until API better documented + getMetadataPermission, + getPagesPermission, + getPullRequestsPermission, + getRepoSecurityPermission, + getSecretScanningPermission, + getSecretsPermission, + getVariablesPermission, + getWebhooksPermission, + notImplementedRepoPerm, // ToDo: Skipped b/c would require us to create a release (High Risk function) } -var acctPermFuncMap = map[string]func(client *gh.Client, user *gh.User) (string, error){ - BLOCK_USER: getBlockUserPermission, - CODESPACE_USER_SECRETS: getCodespacesUserPermission, - EMAIL: getEmailPermission, - FOLLOWERS: getFollowersPermission, - GPG_KEYS: getGPGKeysPermission, - GISTS: getGistsPermission, - GIT_KEYS: getGitKeysPermission, - LIMITS: getLimitsPermission, - PLAN: getPlanPermission, - // PRIVATE_INVITES: getPrivateInvitesPermission, // Skipped until API better documented - PROFILE: getProfilePermission, - SIGNING_KEYS: getSigningKeysPermission, - STARRING: getStarringPermission, - WATCHING: getWatchingPermission, +var acctPermFuncMap = []func(client *gh.Client, user *gh.User) (Permission, error){ + getBlockUserPermission, + getCodespacesUserPermission, + getEmailPermission, + getFollowersPermission, + getGPGKeysPermission, + getGistsPermission, + getGitKeysPermission, + getLimitsPermission, + getPlanPermission, + notImplementedAcctPerm, // Skipped until API better documented + getProfilePermission, + getSigningKeysPermission, + getStarringPermission, + getWatchingPermission, } // Define your custom formatter function func permissionFormatter(key, val any) (string, string) { - if strVal, ok := val.(string); ok { - switch strVal { - case NO_ACCESS: - red := color.New(color.FgRed).SprintFunc() - return red(key), red(NO_ACCESS) - case READ_ONLY: - yellow := color.New(color.FgYellow).SprintFunc() - return yellow(key), yellow(READ_ONLY) - case READ_WRITE: - green := color.New(color.FgGreen).SprintFunc() - return green(key), green(READ_WRITE) - case UNKNOWN: - blue := color.New(color.FgBlue).SprintFunc() - return blue(key), blue(UNKNOWN) - case NOT_IMPLEMENTED: - blue := color.New(color.FgBlue).SprintFunc() - return blue(key), blue(NOT_IMPLEMENTED) + if perm, ok := val.(Permission); ok { + permStr, err := perm.ToString() + if err != nil { + log.Fatal(fmt.Errorf("Error converting permission to string: %v", err)) + } + var permissionStr string + switch { + case strings.Contains(permStr, "read"): + permissionStr = "READ_ONLY" + case strings.Contains(permStr, "write"): + permissionStr = "READ_WRITE" default: - red := color.New(color.FgRed).SprintFunc() - return red(key), red(ERROR) + permissionStr = "UNKNOWN" + } + + switch permissionStr { + case "READ_ONLY": + yellow := color.New(color.FgYellow).SprintFunc() + return yellow(key), yellow(permissionStr) + case "READ_WRITE": + red := color.New(color.FgGreen).SprintFunc() + return red(key), red(permissionStr) + case "UNKNOWN": + blue := color.New(color.FgBlue).SprintFunc() + return blue(key), blue(permissionStr) } } return fmt.Sprintf("%v", key), fmt.Sprintf("%v", val) } -func notImplemented(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { - return NOT_IMPLEMENTED, nil +func notImplementedRepoPerm(client *gh.Client, repo *gh.Repository, currentAccess string) (Permission, error) { + return NoAccess, ErrNoAccess } -func getMetadataPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { +// notImplementedAcctPerm is a placeholder function that returns a "NOT_IMPLEMENTED" status when a GitHub account permission is not yet implemented. +func notImplementedAcctPerm(client *gh.Client, user *gh.User) (Permission, error) { + return NoAccess, ErrNoAccess +} + +func getMetadataPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (Permission, error) { // Risk: Extremely Low // -> GET request to /repos/{owner}/{repo}/collaborators _, resp, err := client.Repositories.ListCollaborators(context.Background(), *repo.Owner.Login, *repo.Name, nil) if err != nil { if resp.StatusCode == 403 { - return NO_ACCESS, nil + return NoAccess, ErrNoAccess } - return ERROR, err + return NoAccess, err } // If no error, then we have read access - return READ_ONLY, nil + + return MetadataRead, nil } -func getActionsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { +func getActionsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (Permission, error) { if *repo.Private { // Risk: Extremely Low // -> GET request to /repos/{owner}/{repo}/actions/artifacts _, resp, err := client.Actions.ListArtifacts(context.Background(), *repo.Owner.Login, *repo.Name, nil) switch resp.StatusCode { case 403: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 200: break default: - return ERROR, err + return NoAccess, err } // Risk: Very, very low. @@ -184,19 +151,19 @@ func getActionsPermission(client *gh.Client, repo *gh.Repository, currentAccess resp, err = client.Actions.CreateWorkflowDispatchEventByFileName(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING, gh.CreateWorkflowDispatchEventRequest{}) switch resp.StatusCode { case 403: - return READ_ONLY, nil + return ActionsRead, nil case 404: - return READ_WRITE, nil + return ActionsWrite, nil case 200: log.Fatal("This shouldn't print. We are enabling a workflow based on a random string " + RANDOM_STRING + ", which most likely doesn't exist.") - return READ_WRITE, nil + return ActionsWrite, nil default: - return ERROR, err + return NoAccess, err } } else { // Will only land here if already tested one public repo and got a 403. - if currentAccess == UNKNOWN { - return UNKNOWN, nil + if currentAccess == "UNKNOWN" { + return NoAccess, ErrNoAccess } // Risk: Very, very low. // -> Unless the user has a workflow file named (see RANDOM_STRING above), this will always return 404 for users with READ_WRITE permissions. @@ -204,54 +171,56 @@ func getActionsPermission(client *gh.Client, repo *gh.Repository, currentAccess resp, err := client.Actions.CreateWorkflowDispatchEventByFileName(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING, gh.CreateWorkflowDispatchEventRequest{}) switch resp.StatusCode { case 403: - return UNKNOWN, nil + return NoAccess, ErrNoAccess case 404: - return READ_WRITE, nil + return ActionsWrite, nil case 200: log.Fatal("This shouldn't print. We are enabling a workflow based on a random string " + RANDOM_STRING + ", which most likely doesn't exist.") - return READ_WRITE, nil + return ActionsWrite, nil default: - return ERROR, err + return NoAccess, err } } } -func getAdministrationPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { +// Continue with the other functions using the same pattern... + +func getAdministrationPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (Permission, error) { // Risk: Extremely Low // -> GET request to /repos/{owner}/{repo}/actions/permissions _, resp, err := client.Repositories.GetActionsPermissions(context.Background(), *repo.Owner.Login, *repo.Name) switch resp.StatusCode { case 403, 404: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 200: break default: - return ERROR, err + return NoAccess, err } // Risk: Extremely Low // -> GET request to /repos/{owner}/{repo}/rulesets/rule-suites req, err := client.NewRequest("GET", "https://api.github.com/repos/"+*repo.Owner.Login+"/"+*repo.Name+"/rulesets/rule-suites", nil) if err != nil { - return ERROR, err + return NoAccess, err } resp, err = client.Do(context.Background(), req, nil) switch resp.StatusCode { case 403: - return READ_ONLY, nil + return AdministrationRead, nil case 200: - return READ_WRITE, nil + return AdministrationWrite, nil default: - return ERROR, err + return NoAccess, err } } -func getCodeScanningAlertsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { +func getCodeScanningAlertsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (Permission, error) { // Risk: Extremely Low // -> GET request to /repos/{owner}/{repo}/code-scanning/alerts _, resp, err := client.CodeScanning.ListAlertsForRepo(context.Background(), *repo.Owner.Login, *repo.Name, nil) if err != nil { - return "", err + return NoAccess, err } defer resp.Body.Close() @@ -262,18 +231,18 @@ func getCodeScanningAlertsPermission(client *gh.Client, repo *gh.Repository, cur body := string(bodyBytes) if strings.Contains(body, "Code scanning is not enabled for this repository") { - return UNKNOWN, nil + return NoAccess, errors.New("code scanning is not enabled") } switch { case resp.StatusCode == 403: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case resp.StatusCode == 404: break case resp.StatusCode >= 200 && resp.StatusCode <= 299: break default: - return ERROR, err + return NoAccess, err } // Risk: Very Low @@ -282,80 +251,80 @@ func getCodeScanningAlertsPermission(client *gh.Client, repo *gh.Repository, cur _, resp, err = client.CodeScanning.UpdateAlert(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_INTEGER, nil) switch resp.StatusCode { case 403: - return READ_ONLY, nil + return CodeScanningAlertsRead, nil case 422: - return READ_WRITE, nil + return CodeScanningAlertsWrite, nil case 200: log.Fatal("This should never happen. We are updating an alert with nil which should be an invalid request.") - return READ_WRITE, nil + return CodeScanningAlertsWrite, nil default: - return ERROR, err + return NoAccess, err } } -func getCodespacesPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { +func getCodespacesPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (Permission, error) { // Risk: Extremely Low // GET request to /repos/{owner}/{repo}/codespaces _, resp, err := client.Codespaces.ListInRepo(context.Background(), *repo.Owner.Login, *repo.Name, nil) switch resp.StatusCode { case 403, 404: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 200: break default: - return ERROR, err + return NoAccess, err } // Risk: Extremely Low // GET request to /repos/{owner}/{repo}/codespaces/permissions_check req, err := client.NewRequest("GET", "https://api.github.com/repos/"+*repo.Owner.Login+"/"+*repo.Name+"/codespaces/permissions_check", nil) if err != nil { - return ERROR, err + return NoAccess, err } resp, err = client.Do(context.Background(), req, nil) switch resp.StatusCode { case 403: - return READ_ONLY, nil + return CodespacesRead, nil case 422: - return READ_WRITE, nil + return CodespacesWrite, nil case 200: - return READ_WRITE, nil + return CodespacesWrite, nil default: - return ERROR, err + return NoAccess, err } } -func getCodespacesMetadataPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { +func getCodespacesMetadataPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (Permission, error) { // Risk: Extremely Low // GET request to /repos/{owner}/{repo}/codespaces/machines req, err := client.NewRequest("GET", "https://api.github.com/repos/"+*repo.Owner.Login+"/"+*repo.Name+"/codespaces/machines", nil) if err != nil { - return ERROR, err + return NoAccess, err } resp, err := client.Do(context.Background(), req, nil) switch resp.StatusCode { case 403: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 200: - return READ_ONLY, nil + return CodespacesMetadataRead, nil default: - return ERROR, err + return NoAccess, err } } -func getCodespacesSecretsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { +func getCodespacesSecretsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (Permission, error) { // Risk: Extremely Low // GET request to /repos/{owner}/{repo}/codespaces/secrets for non-existent secret _, resp, err := client.Codespaces.GetRepoSecret(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING) switch resp.StatusCode { case 403: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 404: - return READ_WRITE, nil + return CodespacesSecretsWrite, nil case 200: - return READ_WRITE, nil + return CodespacesSecretsWrite, nil default: - return ERROR, err + return NoAccess, err } } @@ -363,18 +332,18 @@ func getCodespacesSecretsPermission(client *gh.Client, repo *gh.Repository, curr // By default, we have read-only access to commit statuses for all public repos. If only public repos exist under // this key's permissions, then they best we can hope for us a READ_WRITE status or an UNKNOWN status. // If a private repo exists, then we can check for READ_ONLY, READ_WRITE and NO_ACCESS. -func getCommitStatusesPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { +func getCommitStatusesPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (Permission, error) { if *repo.Private { // Risk: Extremely Low // GET request to /repos/{owner}/{repo}/commits/{commit_sha}/statuses _, resp, err := client.Repositories.ListStatuses(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING, nil) switch resp.StatusCode { case 403: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 404: break default: - return ERROR, err + return NoAccess, err } // At this point we have read access @@ -384,16 +353,16 @@ func getCommitStatusesPermission(client *gh.Client, repo *gh.Repository, current _, resp, err = client.Repositories.CreateStatus(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING, &gh.RepoStatus{}) switch resp.StatusCode { case 403: - return READ_ONLY, nil + return CommitStatusesRead, nil case 422: - return READ_WRITE, nil + return CommitStatusesWrite, nil default: - return ERROR, err + return NoAccess, err } } else { // Will only land here if already tested one public repo and got a 403. - if currentAccess == UNKNOWN { - return UNKNOWN, nil + if currentAccess == "UNKNOWN" { + return NoAccess, ErrNoAccess } // Risk: Extremely Low // -> We're POSTing a commit status to a commit that cannot exist. This should always return 422 if valid access. @@ -402,11 +371,11 @@ func getCommitStatusesPermission(client *gh.Client, repo *gh.Repository, current switch resp.StatusCode { case 403: // All we know is we don't have READ_WRITE - return UNKNOWN, nil + return NoAccess, ErrNoAccess case 422: - return READ_WRITE, nil + return CommitStatusesWrite, nil default: - return ERROR, err + return NoAccess, err } } } @@ -415,20 +384,20 @@ func getCommitStatusesPermission(client *gh.Client, repo *gh.Repository, current // By default, we have read-only access to the contents of all public repos. If only public repos exist under // this key's permissions, then they best we can hope for us a READ_WRITE status or an UNKNOWN status. // If a private repo exists, then we can check for READ_ONLY, READ_WRITE and NO_ACCESS. -func getContentsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { +func getContentsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (Permission, error) { if *repo.Private { // Risk: Extremely Low // GET request to /repos/{owner}/{repo}/commits _, resp, err := client.Repositories.ListCommits(context.Background(), *repo.Owner.Login, *repo.Name, &gh.CommitsListOptions{}) switch resp.StatusCode { case 403: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 200: break case 409: break default: - return ERROR, err + return NoAccess, err } // At this point we have read access @@ -438,19 +407,19 @@ func getContentsPermission(client *gh.Client, repo *gh.Repository, currentAccess _, resp, err = client.Repositories.CreateFile(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING, &gh.RepositoryContentFileOptions{}) switch resp.StatusCode { case 403: - return READ_ONLY, nil + return ContentsRead, nil case 200: log.Fatal("This should never happen. We are creating a file with an invalid payload.") - return READ_WRITE, nil + return ContentsWrite, nil case 400, 422: - return READ_WRITE, nil + return ContentsWrite, nil default: - return ERROR, err + return NoAccess, err } } else { // Will only land here if already tested one public repo and got a 403. - if currentAccess == UNKNOWN { - return UNKNOWN, nil + if currentAccess == "UNKNOWN" { + return NoAccess, ErrNoAccess } // Risk: Low-Medium // -> We're creating a file with an invalid payload. Worst case is a file with a random string and no content is created. But this should never happen. @@ -458,27 +427,19 @@ func getContentsPermission(client *gh.Client, repo *gh.Repository, currentAccess _, resp, err := client.Repositories.CreateFile(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING, &gh.RepositoryContentFileOptions{}) switch resp.StatusCode { case 403: - return UNKNOWN, nil + return NoAccess, ErrNoAccess case 200: log.Fatal("This should never happen. We are creating a file with an invalid payload.") - return READ_WRITE, nil + return ContentsWrite, nil case 400, 422: - return READ_WRITE, nil + return ContentsWrite, nil default: - return ERROR, err + return NoAccess, err } } } -// func getCustomPropertiesPermission(client *gh.Client, owner, repo string, private bool) (string, error) { -// // Look for the phrase "Custom properties only supported for organizations" in the response body -// // If find, then we want to skip this repo. -// // If all repos have that phrase, then we have to put "Unknown". -// // If we find a repo without that (an organization-owned repo), then we just check for NO_ACCESS and READ_WRITE? -// // I'd add in READ_ONLY, but the docs only show one `write` endpoint for this block. -// } - -func getDependabotAlertsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { +func getDependabotAlertsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (Permission, error) { // Risk: Extremely Low // GET /repos/{owner}/{repo}/dependabot/alerts _, resp, err := client.Dependabot.ListRepoAlerts(context.Background(), *repo.Owner.Login, *repo.Name, &gh.ListAlertsOptions{}) @@ -493,41 +454,41 @@ func getDependabotAlertsPermission(client *gh.Client, repo *gh.Repository, curre body := string(bodyBytes) if strings.Contains(body, "Dependabot alerts are disabled for this repository.") { - return UNKNOWN, nil + return NoAccess, errors.New("dependabot alerts are disabled") } - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 200: break default: - return ERROR, err + return NoAccess, err } // PATCH /repos/{owner}/{repo}/dependabot/alerts/{alert_number} _, resp, err = client.Dependabot.UpdateAlert(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_INTEGER, nil) switch resp.StatusCode { case 403: - return READ_ONLY, nil + return DependabotAlertsRead, nil case 422, 404: - return READ_WRITE, nil + return DependabotAlertsWrite, nil case 200: log.Fatal("This should never happen. We are updating an alert with nil which should be an invalid request.") - return READ_WRITE, nil + return DependabotAlertsWrite, nil default: - return ERROR, err + return NoAccess, err } } -func getDependabotSecretsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { +func getDependabotSecretsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (Permission, error) { // Risk: Extremely Low // GET /repos/{owner}/{repo}/dependabot/secrets _, resp, err := client.Dependabot.ListRepoSecrets(context.Background(), *repo.Owner.Login, *repo.Name, &gh.ListOptions{}) switch resp.StatusCode { case 403: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 200: break default: - return ERROR, err + return NoAccess, err } // Risk: Very Low @@ -536,28 +497,28 @@ func getDependabotSecretsPermission(client *gh.Client, repo *gh.Repository, curr resp, err = client.Dependabot.CreateOrUpdateRepoSecret(context.Background(), *repo.Owner.Login, *repo.Name, &gh.DependabotEncryptedSecret{Name: RANDOM_STRING}) switch resp.StatusCode { case 403: - return READ_ONLY, nil + return DependabotSecretsRead, nil case 422: - return READ_WRITE, nil + return DependabotSecretsWrite, nil case 201, 204: log.Fatal("This should never happen. We are creating a secret with an invalid payload.") - return READ_WRITE, nil + return DependabotSecretsWrite, nil default: - return ERROR, err + return NoAccess, err } } -func getDeploymentsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { +func getDeploymentsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (Permission, error) { // Risk: Extremely Low // GET /repos/{owner}/{repo}/deployments _, resp, err := client.Repositories.ListDeployments(context.Background(), *repo.Owner.Login, *repo.Name, &gh.DeploymentsListOptions{}) switch resp.StatusCode { case 403: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 200: break default: - return ERROR, err + return NoAccess, err } // Risk: Very Low @@ -566,27 +527,27 @@ func getDeploymentsPermission(client *gh.Client, repo *gh.Repository, currentAcc _, resp, err = client.Repositories.CreateDeployment(context.Background(), *repo.Owner.Login, *repo.Name, &gh.DeploymentRequest{}) switch resp.StatusCode { case 403: - return READ_ONLY, nil + return DeploymentsRead, nil case 409, 422: - return READ_WRITE, nil + return DeploymentsWrite, nil case 201, 202: log.Fatal("This should never happen. We are creating a deployment with an invalid payload.") - return READ_WRITE, nil + return DeploymentsWrite, nil default: - return ERROR, err + return NoAccess, err } } -func getEnvironmentsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { +func getEnvironmentsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (Permission, error) { // Risk: Extremely Low // GET /repos/{owner}/{repo}/environments envResp, resp, _ := client.Repositories.ListEnvironments(context.Background(), *repo.Owner.Login, *repo.Name, &gh.EnvironmentListOptions{}) if resp.StatusCode != 200 { - return UNKNOWN, nil + return NoAccess, errors.New("unknown status code") } // If no environments exist, then we return UNKNOWN if len(envResp.Environments) == 0 { - return UNKNOWN, nil + return NoAccess, errors.New("no environments found") } // Risk: Extremely Low @@ -594,11 +555,11 @@ func getEnvironmentsPermission(client *gh.Client, repo *gh.Repository, currentAc _, resp, err := client.Actions.ListEnvVariables(context.Background(), *repo.Owner.Login, *repo.Name, *envResp.Environments[0].Name, &gh.ListOptions{}) switch resp.StatusCode { case 403: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 200: break default: - return ERROR, err + return NoAccess, err } // Risk: Very Low @@ -607,18 +568,18 @@ func getEnvironmentsPermission(client *gh.Client, repo *gh.Repository, currentAc resp, err = client.Actions.UpdateEnvVariable(context.Background(), *repo.Owner.Login, *repo.Name, *envResp.Environments[0].Name, &gh.ActionsVariable{Name: RANDOM_STRING}) switch resp.StatusCode { case 403: - return READ_ONLY, nil + return EnvironmentsRead, nil case 422: - return READ_WRITE, nil + return EnvironmentsWrite, nil case 200: log.Fatal("This should never happen. We are updating an environment variable with an invalid payload.") - return READ_WRITE, nil + return EnvironmentsWrite, nil default: - return ERROR, err + return NoAccess, err } } -func getIssuesPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { +func getIssuesPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (Permission, error) { if *repo.Private { @@ -627,11 +588,11 @@ func getIssuesPermission(client *gh.Client, repo *gh.Repository, currentAccess s _, resp, err := client.Issues.ListByRepo(context.Background(), *repo.Owner.Login, *repo.Name, &gh.IssueListByRepoOptions{}) switch resp.StatusCode { case 403: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 200, 301: break default: - return ERROR, err + return NoAccess, err } // Risk: Very Low @@ -640,19 +601,19 @@ func getIssuesPermission(client *gh.Client, repo *gh.Repository, currentAccess s _, resp, err = client.Issues.EditLabel(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING, &gh.Label{}) switch resp.StatusCode { case 403: - return READ_ONLY, nil + return IssuesRead, nil case 404: - return READ_WRITE, nil + return IssuesWrite, nil case 200: log.Fatal("This should never happen. We are editing a label with an invalid payload.") - return READ_WRITE, nil + return IssuesWrite, nil default: - return ERROR, err + return NoAccess, err } } else { // Will only land here if already tested one public repo and got a 403. - if currentAccess == UNKNOWN { - return UNKNOWN, nil + if currentAccess == "UNKNOWN" { + return NoAccess, ErrNoAccess } // Risk: Very Low // -> We're editing an issue label that does not exist. Even if we did, the name would be (see RANDOM_STRING above). @@ -660,30 +621,30 @@ func getIssuesPermission(client *gh.Client, repo *gh.Repository, currentAccess s _, resp, err := client.Issues.EditLabel(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING, &gh.Label{}) switch resp.StatusCode { case 403: - return UNKNOWN, nil + return NoAccess, ErrNoAccess case 404: - return READ_WRITE, nil + return IssuesWrite, nil case 200: log.Fatal("This should never happen. We are editing a label with an invalid payload.") - return READ_WRITE, nil + return IssuesWrite, nil default: - return ERROR, err + return NoAccess, err } } } -func getPagesPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { +func getPagesPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (Permission, error) { if *repo.Private { // Risk: Extremely Low // GET /repos/{owner}/{repo}/pages _, resp, err := client.Repositories.GetPagesInfo(context.Background(), *repo.Owner.Login, *repo.Name) switch resp.StatusCode { case 403: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 200, 404: break default: - return ERROR, err + return NoAccess, err } // Risk: Very Low @@ -691,59 +652,59 @@ func getPagesPermission(client *gh.Client, repo *gh.Repository, currentAccess st // POST /repos/{owner}/{repo}/pages/deployments/{deployment_id}/cancel req, err := client.NewRequest("POST", "https://api.github.com/repos/"+*repo.Owner.Login+"/"+*repo.Name+"/pages/deployments/"+RANDOM_STRING+"/cancel", nil) if err != nil { - return ERROR, err + return NoAccess, err } resp, err = client.Do(context.Background(), req, nil) switch resp.StatusCode { case 403: - return READ_ONLY, nil + return PagesRead, nil case 404: - return READ_WRITE, nil + return PagesWrite, nil case 200: log.Fatal("This should never happen. We are cancelling a deployment with an invalid ID.") - return READ_WRITE, nil + return PagesWrite, nil default: - return ERROR, err + return NoAccess, err } } else { // Will only land here if already tested one public repo and got a 403. - if currentAccess == UNKNOWN { - return UNKNOWN, nil + if currentAccess == "UNKNOWN" { + return NoAccess, ErrNoAccess } // Risk: Very Low // -> We're cancelling a GitHub Pages deployment that does not exist (see RANDOM_STRING above). // POST /repos/{owner}/{repo}/pages/deployments/{deployment_id}/cancel req, err := client.NewRequest("POST", "https://api.github.com/repos/"+*repo.Owner.Login+"/"+*repo.Name+"/pages/deployments/"+RANDOM_STRING+"/cancel", nil) if err != nil { - return ERROR, err + return NoAccess, err } resp, err := client.Do(context.Background(), req, nil) switch resp.StatusCode { case 403: - return UNKNOWN, nil + return NoAccess, ErrNoAccess case 404: - return READ_WRITE, nil + return PagesWrite, nil case 200: log.Fatal("This should never happen. We are cancelling a deployment with an invalid ID.") - return READ_WRITE, nil + return PagesWrite, nil default: - return ERROR, err + return NoAccess, err } } } -func getPullRequestsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { +func getPullRequestsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (Permission, error) { if *repo.Private { // Risk: Extremely Low // GET /repos/{owner}/{repo}/pulls _, resp, err := client.PullRequests.List(context.Background(), *repo.Owner.Login, *repo.Name, &gh.PullRequestListOptions{}) switch resp.StatusCode { case 403: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 200: break default: - return ERROR, err + return NoAccess, err } // Risk: Very Low @@ -752,19 +713,19 @@ func getPullRequestsPermission(client *gh.Client, repo *gh.Repository, currentAc _, resp, err = client.PullRequests.Create(context.Background(), *repo.Owner.Login, *repo.Name, &gh.NewPullRequest{}) switch resp.StatusCode { case 403: - return READ_ONLY, nil + return PullRequestsRead, nil case 422: - return READ_WRITE, nil + return PullRequestsWrite, nil case 200: log.Fatal("This should never happen. We are creating a pull request with an invalid payload.") - return READ_WRITE, nil + return PullRequestsWrite, nil default: - return ERROR, err + return NoAccess, err } } else { // Will only land here if already tested one public repo and got a 403. - if currentAccess == UNKNOWN { - return UNKNOWN, nil + if currentAccess == "UNKNOWN" { + return NoAccess, ErrNoAccess } // Risk: Very Low // -> We're creating a pull request with an invalid payload. @@ -772,19 +733,19 @@ func getPullRequestsPermission(client *gh.Client, repo *gh.Repository, currentAc _, resp, err := client.PullRequests.Create(context.Background(), *repo.Owner.Login, *repo.Name, &gh.NewPullRequest{}) switch resp.StatusCode { case 403: - return UNKNOWN, nil + return NoAccess, ErrNoAccess case 422: - return READ_WRITE, nil + return PullRequestsWrite, nil case 200: log.Fatal("This should never happen. We are creating a pull request with an invalid payload.") - return READ_WRITE, nil + return PullRequestsWrite, nil default: - return ERROR, err + return NoAccess, err } } } -func getRepoSecurityPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { +func getRepoSecurityPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (Permission, error) { if *repo.Private { // Risk: Extremely Low @@ -792,11 +753,11 @@ func getRepoSecurityPermission(client *gh.Client, repo *gh.Repository, currentAc _, resp, err := client.SecurityAdvisories.ListRepositorySecurityAdvisories(context.Background(), *repo.Owner.Login, *repo.Name, nil) switch resp.StatusCode { case 403, 404: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 200: break default: - return ERROR, err + return NoAccess, err } // Risk: Very Low @@ -804,58 +765,58 @@ func getRepoSecurityPermission(client *gh.Client, repo *gh.Repository, currentAc // POST /repos/{owner}/{repo}/security-advisories req, err := client.NewRequest("POST", "https://api.github.com/repos/"+*repo.Owner.Login+"/"+*repo.Name+"/security-advisories", nil) if err != nil { - return ERROR, err + return NoAccess, err } resp, err = client.Do(context.Background(), req, nil) switch resp.StatusCode { case 403: - return READ_ONLY, nil + return RepoSecurityRead, nil case 422: - return READ_WRITE, nil + return RepoSecurityWrite, nil case 200: log.Fatal("This should never happen. We are creating a security advisory with an invalid payload.") - return READ_WRITE, nil + return RepoSecurityWrite, nil default: - return ERROR, err + return NoAccess, err } } else { // Will only land here if already tested one public repo and got a 403. - if currentAccess == UNKNOWN { - return UNKNOWN, nil + if currentAccess == "UNKNOWN" { + return NoAccess, ErrNoAccess } // Risk: Very Low // -> We're creating a security advisory with an invalid payload. // POST /repos/{owner}/{repo}/security-advisories req, err := client.NewRequest("POST", "https://api.github.com/repos/"+*repo.Owner.Login+"/"+*repo.Name+"/security-advisories", nil) if err != nil { - return ERROR, err + return NoAccess, err } resp, err := client.Do(context.Background(), req, nil) switch resp.StatusCode { case 403: - return UNKNOWN, nil + return NoAccess, ErrNoAccess case 422: - return READ_WRITE, nil + return RepoSecurityWrite, nil case 200: log.Fatal("This should never happen. We are creating a security advisory with an invalid payload.") - return READ_WRITE, nil + return RepoSecurityWrite, nil default: - return ERROR, err + return NoAccess, err } } } -func getSecretScanningPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { +func getSecretScanningPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (Permission, error) { // Risk: Extremely Low // GET /repos/{owner}/{repo}/secret-scanning/alerts _, resp, err := client.SecretScanning.ListAlertsForRepo(context.Background(), *repo.Owner.Login, *repo.Name, nil) switch resp.StatusCode { case 403: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 200, 404: break default: - return ERROR, err + return NoAccess, err } // Risk: Very Low @@ -864,28 +825,28 @@ func getSecretScanningPermission(client *gh.Client, repo *gh.Repository, current _, resp, err = client.SecretScanning.UpdateAlert(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_INTEGER, &gh.SecretScanningAlertUpdateOptions{}) switch resp.StatusCode { case 403: - return READ_ONLY, nil + return SecretScanningRead, nil case 404, 422: - return READ_WRITE, nil + return SecretScanningWrite, nil case 200: log.Fatal("This should never happen. We are updating a secret scanning alert that doesn't exist.") - return READ_WRITE, nil + return SecretScanningWrite, nil default: - return ERROR, err + return NoAccess, err } } -func getSecretsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { +func getSecretsPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (Permission, error) { // Risk: Extremely Low // GET /repos/{owner}/{repo}/actions/secrets _, resp, err := client.Actions.ListRepoSecrets(context.Background(), *repo.Owner.Login, *repo.Name, &gh.ListOptions{}) switch resp.StatusCode { case 403: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 200: break default: - return ERROR, err + return NoAccess, err } // Risk: Very Low @@ -894,28 +855,28 @@ func getSecretsPermission(client *gh.Client, repo *gh.Repository, currentAccess resp, err = client.Actions.CreateOrUpdateRepoSecret(context.Background(), *repo.Owner.Login, *repo.Name, &gh.EncryptedSecret{Name: RANDOM_STRING}) switch resp.StatusCode { case 403: - return READ_ONLY, nil + return SecretsRead, nil case 422: - return READ_WRITE, nil + return SecretsWrite, nil case 201, 204: log.Fatal("This should never happen. We are creating a secret with an invalid payload.") - return READ_WRITE, nil + return SecretsWrite, nil default: - return ERROR, err + return NoAccess, err } } -func getVariablesPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { +func getVariablesPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (Permission, error) { // Risk: Extremely Low // GET /repos/{owner}/{repo}/actions/variables _, resp, err := client.Actions.ListRepoVariables(context.Background(), *repo.Owner.Login, *repo.Name, &gh.ListOptions{}) switch resp.StatusCode { case 403: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 200: break default: - return ERROR, err + return NoAccess, err } // Risk: Very Low @@ -924,28 +885,28 @@ func getVariablesPermission(client *gh.Client, repo *gh.Repository, currentAcces resp, err = client.Actions.UpdateRepoVariable(context.Background(), *repo.Owner.Login, *repo.Name, &gh.ActionsVariable{Name: RANDOM_STRING}) switch resp.StatusCode { case 403: - return READ_ONLY, nil + return VariablesRead, nil case 422: - return READ_WRITE, nil + return VariablesWrite, nil case 201, 204: log.Fatal("This should never happen. We are patching a variable with an invalid payload and no name.") - return READ_WRITE, nil + return VariablesWrite, nil default: - return ERROR, err + return NoAccess, err } } -func getWebhooksPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (string, error) { +func getWebhooksPermission(client *gh.Client, repo *gh.Repository, currentAccess string) (Permission, error) { // Risk: Extremely Low // GET /repos/{owner}/{repo}/hooks _, resp, err := client.Repositories.ListHooks(context.Background(), *repo.Owner.Login, *repo.Name, &gh.ListOptions{}) switch resp.StatusCode { case 403, 404: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 200: break default: - return ERROR, err + return NoAccess, err } // Risk: Very Low @@ -954,14 +915,14 @@ func getWebhooksPermission(client *gh.Client, repo *gh.Repository, currentAccess _, resp, err = client.Repositories.EditHook(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_INTEGER, &gh.Hook{}) switch resp.StatusCode { case 403: - return READ_ONLY, nil + return WebhooksRead, nil case 404: - return READ_WRITE, nil + return WebhooksWrite, nil case 200: log.Fatal("This should never happen. We are updating a webhook with an invalid payload.") - return READ_WRITE, nil + return WebhooksWrite, nil default: - return ERROR, err + return NoAccess, err } } @@ -969,28 +930,33 @@ func getWebhooksPermission(client *gh.Client, repo *gh.Repository, currentAccess // This function is needed b/c in some cases a token could have permissions that are only enabled on specific repos. // If we only checked one repo, we wouldn't be able to tell if the token has access to a specific permission type. // 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 := "" +func analyzeRepositoryPermissions(client *gh.Client, repos []*gh.Repository) ([]Permission, error) { + perms := []Permission{} for _, repo := range repos { - access, _ = repoPermFuncMap[permissionType](client, repo, access) - if access != UNKNOWN && access != ERROR { - return access + for _, permFunc := range repoPermFuncMap { + access, err := permFunc(client, repo, "") + if err != nil { + return nil, err + } + if access != NoAccess { + perms = append(perms, access) + } } } - return access + return perms, nil } -func getBlockUserPermission(client *gh.Client, user *gh.User) (string, error) { +func getBlockUserPermission(client *gh.Client, user *gh.User) (Permission, error) { // Risk: Extremely Low // -> GET request to /user/blocks _, resp, err := client.Users.ListBlockedUsers(context.Background(), nil) switch resp.StatusCode { case 403, 404: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 200: break default: - return ERROR, err + return NoAccess, err } // Risk: Extremely Low @@ -999,28 +965,28 @@ func getBlockUserPermission(client *gh.Client, user *gh.User) (string, error) { resp, err = client.Users.BlockUser(context.Background(), RANDOM_STRING) switch resp.StatusCode { case 403: - return READ_ONLY, nil + return BlockUserRead, nil case 404: - return READ_WRITE, nil + return BlockUserWrite, nil case 204: log.Fatal("This should never happen. We are blocking a user that doesn't exist.") - return READ_WRITE, nil + return BlockUserWrite, nil default: - return ERROR, err + return NoAccess, err } } -func getCodespacesUserPermission(client *gh.Client, user *gh.User) (string, error) { +func getCodespacesUserPermission(client *gh.Client, user *gh.User) (Permission, error) { // Risk: Extremely Low // GET request to /user/codespaces/secrets _, resp, err := client.Codespaces.ListUserSecrets(context.Background(), nil) switch resp.StatusCode { case 403, 404: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 200: break default: - return ERROR, err + return NoAccess, err } // Risk: Low @@ -1029,28 +995,28 @@ func getCodespacesUserPermission(client *gh.Client, user *gh.User) (string, erro resp, err = client.Codespaces.CreateOrUpdateUserSecret(context.Background(), &gh.EncryptedSecret{Name: RANDOM_STRING}) switch resp.StatusCode { case 403: - return READ_ONLY, nil + return CodespaceUserSecretsRead, nil case 422: - return READ_WRITE, nil + return CodespaceUserSecretsWrite, nil case 201, 204: log.Fatal("This should never happen. We are creating a user secret with an invalid payload.") - return READ_WRITE, nil + return CodespaceUserSecretsWrite, nil default: - return ERROR, err + return NoAccess, err } } -func getEmailPermission(client *gh.Client, user *gh.User) (string, error) { +func getEmailPermission(client *gh.Client, user *gh.User) (Permission, error) { // Risk: Extremely Low // GET request to /user/emails _, resp, err := client.Users.ListEmails(context.Background(), nil) switch resp.StatusCode { case 403, 404: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 200: break default: - return ERROR, err + return NoAccess, err } // Risk: Low @@ -1058,28 +1024,28 @@ func getEmailPermission(client *gh.Client, user *gh.User) (string, error) { _, resp, err = client.Users.SetEmailVisibility(context.Background(), RANDOM_STRING) switch resp.StatusCode { case 403, 404: - return READ_ONLY, nil + return EmailRead, nil case 422: - return READ_WRITE, nil + return EmailWrite, nil case 201: log.Fatal("This should never happen. We are setting email visibility with an invalid payload.") - return READ_WRITE, nil + return EmailWrite, nil default: - return ERROR, err + return NoAccess, err } } -func getFollowersPermission(client *gh.Client, user *gh.User) (string, error) { +func getFollowersPermission(client *gh.Client, user *gh.User) (Permission, error) { // Risk: Extremely Low // GET request to /user/followers _, resp, err := client.Users.ListFollowers(context.Background(), "", nil) switch resp.StatusCode { case 403: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 200: break default: - return ERROR, err + return NoAccess, err } // Risk: Low - Medium @@ -1090,25 +1056,25 @@ func getFollowersPermission(client *gh.Client, user *gh.User) (string, error) { resp, err = client.Users.Unfollow(context.Background(), RANDOM_USERNAME) switch resp.StatusCode { case 403, 404: - return READ_ONLY, nil + return FollowersRead, nil case 204: - return READ_WRITE, nil + return FollowersWrite, nil default: - return ERROR, err + return NoAccess, err } } -func getGPGKeysPermission(client *gh.Client, user *gh.User) (string, error) { +func getGPGKeysPermission(client *gh.Client, user *gh.User) (Permission, error) { // Risk: Extremely Low // GET request to /user/gpg_keys _, resp, err := client.Users.ListGPGKeys(context.Background(), "", nil) switch resp.StatusCode { case 403, 404: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 200: break default: - return ERROR, err + return NoAccess, err } // Risk: Low - Medium @@ -1117,46 +1083,46 @@ func getGPGKeysPermission(client *gh.Client, user *gh.User) (string, error) { _, resp, err = client.Users.CreateGPGKey(context.Background(), RANDOM_STRING) switch resp.StatusCode { case 403: - return READ_ONLY, nil + return GpgKeysRead, nil case 422: - return READ_WRITE, nil + return GpgKeysWrite, nil case 200, 201, 204: log.Fatal("This should never happen. We are creating a GPG key with an invalid payload.") - return READ_WRITE, nil + return GpgKeysWrite, nil default: - return ERROR, err + return NoAccess, err } } -func getGistsPermission(client *gh.Client, user *gh.User) (string, error) { +func getGistsPermission(client *gh.Client, user *gh.User) (Permission, error) { // Risk: Low - Medium // POST request to /gists // Payload is invalid, so it shouldn't actually post. _, resp, err := client.Gists.Create(context.Background(), &gh.Gist{}) switch resp.StatusCode { case 403, 404: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 422: - return READ_WRITE, nil + return GistsWrite, nil case 200, 201, 204: log.Fatal("This should never happen. We are creating a Gist with an invalid payload.") - return READ_WRITE, nil + return GistsWrite, nil default: - return ERROR, err + return NoAccess, err } } -func getGitKeysPermission(client *gh.Client, user *gh.User) (string, error) { +func getGitKeysPermission(client *gh.Client, user *gh.User) (Permission, error) { // Risk: Extremely Low // GET request to /user/keys _, resp, err := client.Users.ListKeys(context.Background(), "", nil) switch resp.StatusCode { case 403, 404: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 200: break default: - return ERROR, err + return NoAccess, err } // Risk: Low - Medium @@ -1165,32 +1131,32 @@ func getGitKeysPermission(client *gh.Client, user *gh.User) (string, error) { _, resp, err = client.Users.CreateKey(context.Background(), &gh.Key{}) switch resp.StatusCode { case 403: - return READ_ONLY, nil + return GitKeysRead, nil case 422: - return READ_WRITE, nil + return GitKeysWrite, nil case 200, 201, 204: log.Fatal("This should never happen. We are creating a key with an invalid payload.") - return READ_WRITE, nil + return GitKeysWrite, nil default: - return ERROR, err + return NoAccess, err } } -func getLimitsPermission(client *gh.Client, user *gh.User) (string, error) { +func getLimitsPermission(client *gh.Client, user *gh.User) (Permission, error) { // Risk: Extremely Low // GET request to /user/interaction-limits req, err := client.NewRequest("GET", "https://api.github.com/user/interaction-limits", nil) if err != nil { - return ERROR, err + return NoAccess, err } resp, err := client.Do(context.Background(), req, nil) switch resp.StatusCode { case 403: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 200, 204: break default: - return ERROR, err + return NoAccess, err } // Risk: Low @@ -1198,69 +1164,69 @@ func getLimitsPermission(client *gh.Client, user *gh.User) (string, error) { // Payload is invalid, so it shouldn't actually post. req, err = client.NewRequest("PUT", "https://api.github.com/user/interaction-limits", nil) if err != nil { - return ERROR, err + return NoAccess, err } resp, err = client.Do(context.Background(), req, nil) switch resp.StatusCode { case 403: - return READ_ONLY, nil + return LimitsRead, nil case 422: - return READ_WRITE, nil + return LimitsWrite, nil case 200, 204: log.Fatal("This should never happen. We are setting interaction limits with an invalid payload.") - return READ_WRITE, nil + return LimitsWrite, nil default: - return ERROR, err + return NoAccess, err } } -func getPlanPermission(client *gh.Client, user *gh.User) (string, error) { +func getPlanPermission(client *gh.Client, user *gh.User) (Permission, error) { // Risk: Extremely Low // GET request to /user/{username}/settings/billing/actions _, resp, err := client.Billing.GetActionsBillingUser(context.Background(), *user.Login) switch resp.StatusCode { case 403, 404: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 200: - return READ_ONLY, nil + return PlanRead, nil default: - return ERROR, err + return NoAccess, err } } -func getProfilePermission(client *gh.Client, user *gh.User) (string, error) { +func getProfilePermission(client *gh.Client, user *gh.User) (Permission, error) { // Risk: Low // POST request to /user/social_accounts // Payload is invalid, so it shouldn't actually patch. req, err := client.NewRequest("POST", "https://api.github.com/user/social_accounts", nil) if err != nil { - return ERROR, err + return NoAccess, err } resp, err := client.Do(context.Background(), req, nil) switch resp.StatusCode { case 403, 404: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 422: - return READ_WRITE, nil + return ProfileWrite, nil case 200, 201, 204: log.Fatal("This should never happen. We are creating a social account with an invalid payload.") - return READ_WRITE, nil + return ProfileWrite, nil default: - return ERROR, err + return NoAccess, err } } -func getSigningKeysPermission(client *gh.Client, user *gh.User) (string, error) { +func getSigningKeysPermission(client *gh.Client, user *gh.User) (Permission, error) { // Risk: Extremely Low // GET request to /user/ssh_signing_keys _, resp, err := client.Users.ListSSHSigningKeys(context.Background(), "", nil) switch resp.StatusCode { case 403, 404: - return NO_ACCESS, nil + return NoAccess, ErrNoAccess case 200: break default: - return ERROR, err + return NoAccess, err } // Risk: Low - Medium @@ -1269,77 +1235,83 @@ func getSigningKeysPermission(client *gh.Client, user *gh.User) (string, error) _, resp, err = client.Users.CreateSSHSigningKey(context.Background(), &gh.Key{}) switch resp.StatusCode { case 403: - return READ_ONLY, nil + return SigningKeysRead, nil case 422: - return READ_WRITE, nil + return SigningKeysWrite, nil case 200, 201, 204: log.Fatal("This should never happen. We are creating a SSH key with an invalid payload.") - return READ_WRITE, nil + return SigningKeysWrite, nil default: - return ERROR, err + return NoAccess, err } } -func getStarringPermission(client *gh.Client, user *gh.User) (string, error) { +func getStarringPermission(client *gh.Client, user *gh.User) (Permission, error) { // Note: We can't test READ_WRITE b/c Unstar() isn't working even with READ_WRITE permissions. // Note: GET /user/starred returns the same results regardless of permissions // but since all have the same access, we'll call it READ_ONLY for now. - return READ_ONLY, nil - + return StarringRead, nil } -func getWatchingPermission(client *gh.Client, user *gh.User) (string, error) { +func getWatchingPermission(client *gh.Client, user *gh.User) (Permission, error) { // Note: GET /user/subscriptions returns the same results regardless of permissions // but since all have the same access, we'll call it READ_ONLY for now. - return READ_ONLY, nil + return WatchingRead, nil } -func analyzeUserPermissions(client *gh.Client, user *gh.User, permissionType string) string { - access := "" - var err error - access, err = acctPermFuncMap[permissionType](client, user) - if err != nil { - log.Fatal(err) +func analyzeUserPermissions(client *gh.Client, user *gh.User) ([]Permission, error) { + perms := []Permission{} + for _, permFunc := range acctPermFuncMap { + access, err := permFunc(client, user) + if err != nil { + return nil, err + } + perms = append(perms, access) } - if access != UNKNOWN && access != ERROR { - return access - } - return access + + return perms, nil } -func analyzeFineGrainedToken(client *gh.Client, meta *TokenMetadata, shallowCheck bool) (*SecretInfo, error) { - allRepos, err := getAllReposForUser(client) +func AnalyzeFineGrainedToken(client *gh.Client, meta *common.TokenMetadata, shallowCheck bool) (*common.SecretInfo, error) { + allRepos, err := common.GetAllReposForUser(client) if err != nil { return nil, err } - allGists, err := getAllGistsForUser(client) + allGists, err := common.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 { + perm, err := getMetadataPermission(client, repo, "") + if err != nil { + return nil, err + } + if perm != NoAccess { accessibleRepos = append(accessibleRepos, repo) } } - repoAccessMap := make(map[string]string) - userAccessMap := make(map[string]string) + repoAccessMap := []Permission{} + userAccessMap := []Permission{} if !shallowCheck { // Check our access - for key := range repoPermFuncMap { - repoAccessMap[key] = analyzeRepositoryPermissions(client, accessibleRepos, key) + perms, err := analyzeRepositoryPermissions(client, accessibleRepos) + if err != nil { + return nil, err } + repoAccessMap = append(repoAccessMap, perms...) - // Analyze Account's Permissions - for key := range acctPermFuncMap { - userAccessMap[key] = analyzeUserPermissions(client, meta.User, key) + perms, err = analyzeUserPermissions(client, meta.User) + if err != nil { + return nil, err } + userAccessMap = append(userAccessMap, perms...) } - return &SecretInfo{ + return &common.SecretInfo{ Metadata: meta, Repos: allRepos, Gists: allGists, @@ -1349,24 +1321,33 @@ func analyzeFineGrainedToken(client *gh.Client, meta *TokenMetadata, shallowChec }, nil } -func printFineGrainedToken(cfg *config.Config, info *SecretInfo) { +func PrintFineGrainedToken(cfg *config.Config, info *common.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) + common.PrintGitHubRepos(info.AccessibleRepos) // Print out the access map - printFineGrainedPermissions(info.RepoAccessMap, cfg.ShowAll, true) + perms, ok := info.RepoAccessMap.(map[string]Permission) + if !ok { + panic("Repo Access Map is not of type Permission") + } + printFineGrainedPermissions(perms, cfg.ShowAll, true) } - printFineGrainedPermissions(info.UserAccessMap, cfg.ShowAll, false) - printGists(info.Gists, cfg.ShowAll) + perms, ok := info.UserAccessMap.(map[string]Permission) + if !ok { + panic("Repo Access Map is not of type Permission") + } + + printFineGrainedPermissions(perms, cfg.ShowAll, false) + common.PrintGists(info.Gists, cfg.ShowAll) } -func printFineGrainedPermissions(accessMap map[string]string, showAll bool, repoPermissions bool) { +func printFineGrainedPermissions(accessMap map[string]Permission, showAll bool, repoPermissions bool) { permissionCount := 0 t := table.NewWriter() t.SetOutputMirror(os.Stdout) @@ -1382,12 +1363,12 @@ func printFineGrainedPermissions(accessMap map[string]string, showAll bool, repo for _, key := range keys { value := accessMap[key] - if value == NO_ACCESS || value == UNKNOWN || value == ERROR || value == NOT_IMPLEMENTED { + if value == NoAccess { // don't change permissionCount } else { permissionCount++ } - if !showAll && (value == NO_ACCESS || value == UNKNOWN || value == NOT_IMPLEMENTED) { + if !showAll && value == NoAccess { continue } else { k, v := permissionFormatter(key, value) diff --git a/pkg/analyzer/analyzers/github/finegrained/finegrained.yaml b/pkg/analyzer/analyzers/github/finegrained/finegrained.yaml new file mode 100644 index 000000000..dd3de36f8 --- /dev/null +++ b/pkg/analyzer/analyzers/github/finegrained/finegrained.yaml @@ -0,0 +1,80 @@ +# Please generate a yaml list of all of the strings permission_name:access_level for all of the permissions and access levels that can be emitted from the test functions. The strings should be lower snake case with a colon joining the permission name and access level. The only access levels I want are "read" and "write" +permissions: + - actions:read + - actions:write + - administration:read + - administration:write + - code_scanning_alerts:read + - code_scanning_alerts:write + - codespaces:read + - codespaces:write + - codespaces_lifecycle:read + - codespaces_lifecycle:write + - codespaces_metadata:read + - codespaces_metadata:write + - codespaces_secrets:read + - codespaces_secrets:write + - commit_statuses:read + - commit_statuses:write + - contents:read + - contents:write + - custom_properties:read + - custom_properties:write + - dependabot_alerts:read + - dependabot_alerts:write + - dependabot_secrets:read + - dependabot_secrets:write + - deployments:read + - deployments:write + - environments:read + - environments:write + - issues:read + - issues:write + - merge_queues:read + - merge_queues:write + - metadata:read + - metadata:write + - pages:read + - pages:write + - pull_requests:read + - pull_requests:write + - repo_security:read + - repo_security:write + - secret_scanning:read + - secret_scanning:write + - secrets:read + - secrets:write + - variables:read + - variables:write + - webhooks:read + - webhooks:write + - workflows:read + - workflows:write + - block_user:read + - block_user:write + - codespace_user_secrets:read + - codespace_user_secrets:write + - email:read + - email:write + - followers:read + - followers:write + - gpg_keys:read + - gpg_keys:write + - gists:read + - gists:write + - git_keys:read + - git_keys:write + - limits:read + - limits:write + - plan:read + - plan:write + - private_invites:read + - private_invites:write + - profile:read + - profile:write + - signing_keys:read + - signing_keys:write + - starring:read + - starring:write + - watching:read + - watching:write diff --git a/pkg/analyzer/analyzers/github/finegrained/finegrained_permissions.go b/pkg/analyzer/analyzers/github/finegrained/finegrained_permissions.go new file mode 100644 index 000000000..dc0cbbafd --- /dev/null +++ b/pkg/analyzer/analyzers/github/finegrained/finegrained_permissions.go @@ -0,0 +1,446 @@ +// Code generated by go generate; DO NOT EDIT. +package finegrained + +import "errors" + +type Permission int + +const ( + NoAccess Permission = iota + ActionsRead Permission = iota + ActionsWrite Permission = iota + AdministrationRead Permission = iota + AdministrationWrite Permission = iota + CodeScanningAlertsRead Permission = iota + CodeScanningAlertsWrite Permission = iota + CodespacesRead Permission = iota + CodespacesWrite Permission = iota + CodespacesLifecycleRead Permission = iota + CodespacesLifecycleWrite Permission = iota + CodespacesMetadataRead Permission = iota + CodespacesMetadataWrite Permission = iota + CodespacesSecretsRead Permission = iota + CodespacesSecretsWrite Permission = iota + CommitStatusesRead Permission = iota + CommitStatusesWrite Permission = iota + ContentsRead Permission = iota + ContentsWrite Permission = iota + CustomPropertiesRead Permission = iota + CustomPropertiesWrite Permission = iota + DependabotAlertsRead Permission = iota + DependabotAlertsWrite Permission = iota + DependabotSecretsRead Permission = iota + DependabotSecretsWrite Permission = iota + DeploymentsRead Permission = iota + DeploymentsWrite Permission = iota + EnvironmentsRead Permission = iota + EnvironmentsWrite Permission = iota + IssuesRead Permission = iota + IssuesWrite Permission = iota + MergeQueuesRead Permission = iota + MergeQueuesWrite Permission = iota + MetadataRead Permission = iota + MetadataWrite Permission = iota + PagesRead Permission = iota + PagesWrite Permission = iota + PullRequestsRead Permission = iota + PullRequestsWrite Permission = iota + RepoSecurityRead Permission = iota + RepoSecurityWrite Permission = iota + SecretScanningRead Permission = iota + SecretScanningWrite Permission = iota + SecretsRead Permission = iota + SecretsWrite Permission = iota + VariablesRead Permission = iota + VariablesWrite Permission = iota + WebhooksRead Permission = iota + WebhooksWrite Permission = iota + WorkflowsRead Permission = iota + WorkflowsWrite Permission = iota + BlockUserRead Permission = iota + BlockUserWrite Permission = iota + CodespaceUserSecretsRead Permission = iota + CodespaceUserSecretsWrite Permission = iota + EmailRead Permission = iota + EmailWrite Permission = iota + FollowersRead Permission = iota + FollowersWrite Permission = iota + GpgKeysRead Permission = iota + GpgKeysWrite Permission = iota + GistsRead Permission = iota + GistsWrite Permission = iota + GitKeysRead Permission = iota + GitKeysWrite Permission = iota + LimitsRead Permission = iota + LimitsWrite Permission = iota + PlanRead Permission = iota + PlanWrite Permission = iota + PrivateInvitesRead Permission = iota + PrivateInvitesWrite Permission = iota + ProfileRead Permission = iota + ProfileWrite Permission = iota + SigningKeysRead Permission = iota + SigningKeysWrite Permission = iota + StarringRead Permission = iota + StarringWrite Permission = iota + WatchingRead Permission = iota + WatchingWrite Permission = iota +) + +var ( + permissionStrings = map[Permission]string{ + ActionsRead: "actions:read", + ActionsWrite: "actions:write", + AdministrationRead: "administration:read", + AdministrationWrite: "administration:write", + CodeScanningAlertsRead: "code_scanning_alerts:read", + CodeScanningAlertsWrite: "code_scanning_alerts:write", + CodespacesRead: "codespaces:read", + CodespacesWrite: "codespaces:write", + CodespacesLifecycleRead: "codespaces_lifecycle:read", + CodespacesLifecycleWrite: "codespaces_lifecycle:write", + CodespacesMetadataRead: "codespaces_metadata:read", + CodespacesMetadataWrite: "codespaces_metadata:write", + CodespacesSecretsRead: "codespaces_secrets:read", + CodespacesSecretsWrite: "codespaces_secrets:write", + CommitStatusesRead: "commit_statuses:read", + CommitStatusesWrite: "commit_statuses:write", + ContentsRead: "contents:read", + ContentsWrite: "contents:write", + CustomPropertiesRead: "custom_properties:read", + CustomPropertiesWrite: "custom_properties:write", + DependabotAlertsRead: "dependabot_alerts:read", + DependabotAlertsWrite: "dependabot_alerts:write", + DependabotSecretsRead: "dependabot_secrets:read", + DependabotSecretsWrite: "dependabot_secrets:write", + DeploymentsRead: "deployments:read", + DeploymentsWrite: "deployments:write", + EnvironmentsRead: "environments:read", + EnvironmentsWrite: "environments:write", + IssuesRead: "issues:read", + IssuesWrite: "issues:write", + MergeQueuesRead: "merge_queues:read", + MergeQueuesWrite: "merge_queues:write", + MetadataRead: "metadata:read", + MetadataWrite: "metadata:write", + PagesRead: "pages:read", + PagesWrite: "pages:write", + PullRequestsRead: "pull_requests:read", + PullRequestsWrite: "pull_requests:write", + RepoSecurityRead: "repo_security:read", + RepoSecurityWrite: "repo_security:write", + SecretScanningRead: "secret_scanning:read", + SecretScanningWrite: "secret_scanning:write", + SecretsRead: "secrets:read", + SecretsWrite: "secrets:write", + VariablesRead: "variables:read", + VariablesWrite: "variables:write", + WebhooksRead: "webhooks:read", + WebhooksWrite: "webhooks:write", + WorkflowsRead: "workflows:read", + WorkflowsWrite: "workflows:write", + BlockUserRead: "block_user:read", + BlockUserWrite: "block_user:write", + CodespaceUserSecretsRead: "codespace_user_secrets:read", + CodespaceUserSecretsWrite: "codespace_user_secrets:write", + EmailRead: "email:read", + EmailWrite: "email:write", + FollowersRead: "followers:read", + FollowersWrite: "followers:write", + GpgKeysRead: "gpg_keys:read", + GpgKeysWrite: "gpg_keys:write", + GistsRead: "gists:read", + GistsWrite: "gists:write", + GitKeysRead: "git_keys:read", + GitKeysWrite: "git_keys:write", + LimitsRead: "limits:read", + LimitsWrite: "limits:write", + PlanRead: "plan:read", + PlanWrite: "plan:write", + PrivateInvitesRead: "private_invites:read", + PrivateInvitesWrite: "private_invites:write", + ProfileRead: "profile:read", + ProfileWrite: "profile:write", + SigningKeysRead: "signing_keys:read", + SigningKeysWrite: "signing_keys:write", + StarringRead: "starring:read", + StarringWrite: "starring:write", + WatchingRead: "watching:read", + WatchingWrite: "watching:write", + } + + stringToPermission = map[string]Permission{ + "actions:read": ActionsRead, + "actions:write": ActionsWrite, + "administration:read": AdministrationRead, + "administration:write": AdministrationWrite, + "code_scanning_alerts:read": CodeScanningAlertsRead, + "code_scanning_alerts:write": CodeScanningAlertsWrite, + "codespaces:read": CodespacesRead, + "codespaces:write": CodespacesWrite, + "codespaces_lifecycle:read": CodespacesLifecycleRead, + "codespaces_lifecycle:write": CodespacesLifecycleWrite, + "codespaces_metadata:read": CodespacesMetadataRead, + "codespaces_metadata:write": CodespacesMetadataWrite, + "codespaces_secrets:read": CodespacesSecretsRead, + "codespaces_secrets:write": CodespacesSecretsWrite, + "commit_statuses:read": CommitStatusesRead, + "commit_statuses:write": CommitStatusesWrite, + "contents:read": ContentsRead, + "contents:write": ContentsWrite, + "custom_properties:read": CustomPropertiesRead, + "custom_properties:write": CustomPropertiesWrite, + "dependabot_alerts:read": DependabotAlertsRead, + "dependabot_alerts:write": DependabotAlertsWrite, + "dependabot_secrets:read": DependabotSecretsRead, + "dependabot_secrets:write": DependabotSecretsWrite, + "deployments:read": DeploymentsRead, + "deployments:write": DeploymentsWrite, + "environments:read": EnvironmentsRead, + "environments:write": EnvironmentsWrite, + "issues:read": IssuesRead, + "issues:write": IssuesWrite, + "merge_queues:read": MergeQueuesRead, + "merge_queues:write": MergeQueuesWrite, + "metadata:read": MetadataRead, + "metadata:write": MetadataWrite, + "pages:read": PagesRead, + "pages:write": PagesWrite, + "pull_requests:read": PullRequestsRead, + "pull_requests:write": PullRequestsWrite, + "repo_security:read": RepoSecurityRead, + "repo_security:write": RepoSecurityWrite, + "secret_scanning:read": SecretScanningRead, + "secret_scanning:write": SecretScanningWrite, + "secrets:read": SecretsRead, + "secrets:write": SecretsWrite, + "variables:read": VariablesRead, + "variables:write": VariablesWrite, + "webhooks:read": WebhooksRead, + "webhooks:write": WebhooksWrite, + "workflows:read": WorkflowsRead, + "workflows:write": WorkflowsWrite, + "block_user:read": BlockUserRead, + "block_user:write": BlockUserWrite, + "codespace_user_secrets:read": CodespaceUserSecretsRead, + "codespace_user_secrets:write": CodespaceUserSecretsWrite, + "email:read": EmailRead, + "email:write": EmailWrite, + "followers:read": FollowersRead, + "followers:write": FollowersWrite, + "gpg_keys:read": GpgKeysRead, + "gpg_keys:write": GpgKeysWrite, + "gists:read": GistsRead, + "gists:write": GistsWrite, + "git_keys:read": GitKeysRead, + "git_keys:write": GitKeysWrite, + "limits:read": LimitsRead, + "limits:write": LimitsWrite, + "plan:read": PlanRead, + "plan:write": PlanWrite, + "private_invites:read": PrivateInvitesRead, + "private_invites:write": PrivateInvitesWrite, + "profile:read": ProfileRead, + "profile:write": ProfileWrite, + "signing_keys:read": SigningKeysRead, + "signing_keys:write": SigningKeysWrite, + "starring:read": StarringRead, + "starring:write": StarringWrite, + "watching:read": WatchingRead, + "watching:write": WatchingWrite, + } + + permissionIDs = map[Permission]int{ + ActionsRead: 0, + ActionsWrite: 1, + AdministrationRead: 2, + AdministrationWrite: 3, + CodeScanningAlertsRead: 4, + CodeScanningAlertsWrite: 5, + CodespacesRead: 6, + CodespacesWrite: 7, + CodespacesLifecycleRead: 8, + CodespacesLifecycleWrite: 9, + CodespacesMetadataRead: 10, + CodespacesMetadataWrite: 11, + CodespacesSecretsRead: 12, + CodespacesSecretsWrite: 13, + CommitStatusesRead: 14, + CommitStatusesWrite: 15, + ContentsRead: 16, + ContentsWrite: 17, + CustomPropertiesRead: 18, + CustomPropertiesWrite: 19, + DependabotAlertsRead: 20, + DependabotAlertsWrite: 21, + DependabotSecretsRead: 22, + DependabotSecretsWrite: 23, + DeploymentsRead: 24, + DeploymentsWrite: 25, + EnvironmentsRead: 26, + EnvironmentsWrite: 27, + IssuesRead: 28, + IssuesWrite: 29, + MergeQueuesRead: 30, + MergeQueuesWrite: 31, + MetadataRead: 32, + MetadataWrite: 33, + PagesRead: 34, + PagesWrite: 35, + PullRequestsRead: 36, + PullRequestsWrite: 37, + RepoSecurityRead: 38, + RepoSecurityWrite: 39, + SecretScanningRead: 40, + SecretScanningWrite: 41, + SecretsRead: 42, + SecretsWrite: 43, + VariablesRead: 44, + VariablesWrite: 45, + WebhooksRead: 46, + WebhooksWrite: 47, + WorkflowsRead: 48, + WorkflowsWrite: 49, + BlockUserRead: 50, + BlockUserWrite: 51, + CodespaceUserSecretsRead: 52, + CodespaceUserSecretsWrite: 53, + EmailRead: 54, + EmailWrite: 55, + FollowersRead: 56, + FollowersWrite: 57, + GpgKeysRead: 58, + GpgKeysWrite: 59, + GistsRead: 60, + GistsWrite: 61, + GitKeysRead: 62, + GitKeysWrite: 63, + LimitsRead: 64, + LimitsWrite: 65, + PlanRead: 66, + PlanWrite: 67, + PrivateInvitesRead: 68, + PrivateInvitesWrite: 69, + ProfileRead: 70, + ProfileWrite: 71, + SigningKeysRead: 72, + SigningKeysWrite: 73, + StarringRead: 74, + StarringWrite: 75, + WatchingRead: 76, + WatchingWrite: 77, + } + + idToPermission = map[int]Permission{ + 0: ActionsRead, + 1: ActionsWrite, + 2: AdministrationRead, + 3: AdministrationWrite, + 4: CodeScanningAlertsRead, + 5: CodeScanningAlertsWrite, + 6: CodespacesRead, + 7: CodespacesWrite, + 8: CodespacesLifecycleRead, + 9: CodespacesLifecycleWrite, + 10: CodespacesMetadataRead, + 11: CodespacesMetadataWrite, + 12: CodespacesSecretsRead, + 13: CodespacesSecretsWrite, + 14: CommitStatusesRead, + 15: CommitStatusesWrite, + 16: ContentsRead, + 17: ContentsWrite, + 18: CustomPropertiesRead, + 19: CustomPropertiesWrite, + 20: DependabotAlertsRead, + 21: DependabotAlertsWrite, + 22: DependabotSecretsRead, + 23: DependabotSecretsWrite, + 24: DeploymentsRead, + 25: DeploymentsWrite, + 26: EnvironmentsRead, + 27: EnvironmentsWrite, + 28: IssuesRead, + 29: IssuesWrite, + 30: MergeQueuesRead, + 31: MergeQueuesWrite, + 32: MetadataRead, + 33: MetadataWrite, + 34: PagesRead, + 35: PagesWrite, + 36: PullRequestsRead, + 37: PullRequestsWrite, + 38: RepoSecurityRead, + 39: RepoSecurityWrite, + 40: SecretScanningRead, + 41: SecretScanningWrite, + 42: SecretsRead, + 43: SecretsWrite, + 44: VariablesRead, + 45: VariablesWrite, + 46: WebhooksRead, + 47: WebhooksWrite, + 48: WorkflowsRead, + 49: WorkflowsWrite, + 50: BlockUserRead, + 51: BlockUserWrite, + 52: CodespaceUserSecretsRead, + 53: CodespaceUserSecretsWrite, + 54: EmailRead, + 55: EmailWrite, + 56: FollowersRead, + 57: FollowersWrite, + 58: GpgKeysRead, + 59: GpgKeysWrite, + 60: GistsRead, + 61: GistsWrite, + 62: GitKeysRead, + 63: GitKeysWrite, + 64: LimitsRead, + 65: LimitsWrite, + 66: PlanRead, + 67: PlanWrite, + 68: PrivateInvitesRead, + 69: PrivateInvitesWrite, + 70: ProfileRead, + 71: ProfileWrite, + 72: SigningKeysRead, + 73: SigningKeysWrite, + 74: StarringRead, + 75: StarringWrite, + 76: WatchingRead, + 77: WatchingWrite, + } +) + +// ToString converts a Permission enum to its string representation +func (p Permission) ToString() (string, error) { + if str, ok := permissionStrings[p]; ok { + return str, nil + } + return "", errors.New("invalid permission") +} + +// ToID converts a Permission enum to its ID +func (p Permission) ToID() (int, error) { + if id, ok := permissionIDs[p]; ok { + return id, nil + } + return 0, errors.New("invalid permission") +} + +// PermissionFromString converts a string representation to its Permission enum +func PermissionFromString(s string) (Permission, error) { + if p, ok := stringToPermission[s]; ok { + return p, nil + } + return 0, errors.New("invalid permission string") +} + +// PermissionFromID converts an ID to its Permission enum +func PermissionFromID(id int) (Permission, error) { + if p, ok := idToPermission[id]; ok { + return p, nil + } + return 0, errors.New("invalid permission ID") +} diff --git a/pkg/analyzer/analyzers/github/github.go b/pkg/analyzer/analyzers/github/github.go index 2574c85e1..f2243cdad 100644 --- a/pkg/analyzer/analyzers/github/github.go +++ b/pkg/analyzer/analyzers/github/github.go @@ -2,15 +2,16 @@ package github import ( "fmt" - "os" "strings" "time" "github.com/fatih/color" gh "github.com/google/go-github/v63/github" - "github.com/jedib0t/go-pretty/v6/table" "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/analyzer/pb/analyzerpb" "github.com/trufflesecurity/trufflehog/v3/pkg/context" @@ -32,7 +33,7 @@ func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analy return secretInfoToAnalyzerResult(info), nil } -func secretInfoToAnalyzerResult(info *SecretInfo) *analyzers.AnalyzerResult { +func secretInfoToAnalyzerResult(info *common.SecretInfo) *analyzers.AnalyzerResult { if info == nil { return nil } @@ -74,7 +75,7 @@ func secretInfoToAnalyzerResult(info *SecretInfo) *analyzers.AnalyzerResult { return result } -func secretInfoToUserBindings(info *SecretInfo) []analyzers.Binding { +func secretInfoToUserBindings(info *common.SecretInfo) []analyzers.Binding { return analyzers.BindAllPermissions(*userToResource(info.Metadata.User), info.Metadata.OauthScopes...) } @@ -87,7 +88,7 @@ func userToResource(user *gh.User) *analyzers.Resource { } } -func secretInfoToRepoBindings(info *SecretInfo) []analyzers.Binding { +func secretInfoToRepoBindings(info *common.SecretInfo) []analyzers.Binding { repos := info.Repos if len(info.AccessibleRepos) > 0 { repos = info.AccessibleRepos @@ -105,7 +106,7 @@ func secretInfoToRepoBindings(info *SecretInfo) []analyzers.Binding { return bindings } -func secretInfoToGistBindings(info *SecretInfo) []analyzers.Binding { +func secretInfoToGistBindings(info *common.SecretInfo) []analyzers.Binding { var bindings []analyzers.Binding for _, gist := range info.Gists { resource := analyzers.Resource{ @@ -119,186 +120,21 @@ func secretInfoToGistBindings(info *SecretInfo) []analyzers.Binding { return bindings } -func getAllGistsForUser(client *gh.Client) ([]*gh.Gist, error) { - opt := &gh.GistListOptions{ListOptions: gh.ListOptions{PerPage: 100}} - var allGists []*gh.Gist - page := 1 - for { - opt.Page = page - gists, resp, err := client.Gists.List(context.Background(), "", opt) - if err != nil { - color.Red("Error getting gists.") - return nil, err - } - allGists = append(allGists, gists...) - - linkHeader := resp.Header.Get("link") - if linkHeader == "" || !strings.Contains(linkHeader, `rel="next"`) { - break - } - page++ - - } - - return allGists, nil -} - -func getAllReposForUser(client *gh.Client) ([]*gh.Repository, error) { - opt := &gh.RepositoryListByAuthenticatedUserOptions{ListOptions: gh.ListOptions{PerPage: 100}} - var allRepos []*gh.Repository - page := 1 - for { - opt.Page = page - repos, resp, err := client.Repositories.ListByAuthenticatedUser(context.Background(), opt) - if err != nil { - color.Red("Error getting repos.") - return nil, err - } - allRepos = append(allRepos, repos...) - - linkHeader := resp.Header.Get("link") - if linkHeader == "" || !strings.Contains(linkHeader, `rel="next"`) { - break - } - page++ - - } - return allRepos, nil -} - -func printGitHubRepos(repos []*gh.Repository) { - t := table.NewWriter() - t.SetOutputMirror(os.Stdout) - t.AppendHeader(table.Row{"Repo Name", "Owner", "Repo Link", "Private"}) - for _, repo := range repos { - if *repo.Private { - green := color.New(color.FgGreen).SprintFunc() - t.AppendRow([]interface{}{green(*repo.Name), green(*repo.Owner.Login), green(*repo.HTMLURL), green("true")}) - } else { - t.AppendRow([]interface{}{*repo.Name, *repo.Owner.Login, *repo.HTMLURL, *repo.Private}) - } - } - t.Render() - fmt.Print("\n\n") -} - -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 showAll && *gist.Public { - t.AppendRow([]interface{}{*gist.ID, *gist.HTMLURL, *gist.Description, "false"}) - } else if !*gist.Public { - privateCount++ - green := color.New(color.FgGreen).SprintFunc() - t.AppendRow([]interface{}{green(*gist.ID), green(*gist.HTMLURL), green(*gist.Description), green("true")}) - } - } - if showAll && len(gists) == 0 { - color.Red("[i] No Gist(s) Found\n") - } else if showAll { - color.Yellow("[i] Found %v Total Gist(s) (%v private)\n", len(gists), privateCount) - t.Render() - } else if privateCount == 0 { - color.Red("[i] No Private Gist(s) Found\n") - } else { - color.Green(fmt.Sprintf("[!] Found %v Private Gist(s)\n", privateCount)) - t.Render() - } - fmt.Print("\n\n") -} - -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) (*TokenMetadata, error) { - user, resp, err := client.Users.Get(context.Background(), "") - if err != nil { - return nil, err - } - - expiration, _ := time.Parse("2006-01-02 15:04:05 MST", resp.Header.Get("github-authentication-token-expiration")) - - 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}) - } - } - tokenType, fineGrained := checkFineGrained(token, oauthScopes) - return &TokenMetadata{ - Type: tokenType, - FineGrained: fineGrained, - User: user, - Expiration: expiration, - OauthScopes: oauthScopes, - }, nil -} - -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_") { - return "GitHub User-to-Server Token", true - } - - // Handle github_pat_ tokens - if strings.HasPrefix(token, "github_pat") { - return "Fine-Grained GitHub Personal Access Token", true - } - - // Handle classic PATs - if strings.HasPrefix(token, "ghp_") { - 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 - if len(oauthScopes) > 0 { - return "GitHub Token", false - } - return "GitHub Token", true -} - -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 -} - -func AnalyzePermissions(cfg *config.Config, key string) (*SecretInfo, error) { +func AnalyzePermissions(cfg *config.Config, key string) (*common.SecretInfo, error) { if cfg == nil { cfg = &config.Config{} } client := gh.NewClient(analyzers.NewAnalyzeClient(cfg)).WithAuthToken(key) - md, err := getTokenMetadata(key, client) + md, err := common.GetTokenMetadata(key, client) if err != nil { return nil, err } if md.FineGrained { - return analyzeFineGrainedToken(client, md, cfg.Shallow) + return finegrained.AnalyzeFineGrainedToken(client, md, cfg.Shallow) } - return analyzeClassicToken(client, md) + return classic.AnalyzeClassicToken(client, md) } func AnalyzeAndPrintPermissions(cfg *config.Config, key string) { @@ -318,8 +154,8 @@ func AnalyzeAndPrintPermissions(cfg *config.Config, key string) { color.Yellow("[i] Token Type: %s\n\n", info.Metadata.Type) if info.Metadata.FineGrained { - printFineGrainedToken(cfg, info) + finegrained.PrintFineGrainedToken(cfg, info) return } - printClassicToken(cfg, info) + classic.PrintClassicToken(cfg, info) } diff --git a/pkg/analyzer/analyzers/github/github_test.go b/pkg/analyzer/analyzers/github/github_test.go new file mode 100644 index 000000000..c4e812bd4 --- /dev/null +++ b/pkg/analyzer/analyzers/github/github_test.go @@ -0,0 +1,121 @@ +package github + +import ( + "encoding/json" + "testing" + "time" + + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/common" + "github.com/trufflesecurity/trufflehog/v3/pkg/context" +) + +func TestAnalyzer_Analyze(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors4") + if err != nil { + t.Fatalf("could not get test secrets from GCP: %s", err) + } + + tests := []struct { + name string + key string + want string // JSON string + wantErr bool + }{ + { + name: "v2 ghp", + key: testSecrets.MustGetField("GITHUB_VERIFIED_GHP"), + want: `{ + "AnalyzerType": 0, + "Bindings": [ + { + "Resource": { + "Name": "truffle-sandbox", + "FullyQualifiedName": "github.com/truffle-sandbox", + "Type": "user", + "Metadata": null, + "Parent": null + }, + "Permission": { + "Value": "notifications", + "AccessLevel": "", + "Parent": null + } + }, + { + "Resource": { + "Name": "public gist", + "FullyQualifiedName": "gist.github.com/truffle-sandbox/fecf272c606ddbc5f8486f9c44821312", + "Type": "gist", + "Metadata": null, + "Parent": { + "Name": "truffle-sandbox", + "FullyQualifiedName": "github.com/truffle-sandbox", + "Type": "user", + "Metadata": null, + "Parent": null + } + }, + "Permission": { + "Value": "notifications", + "AccessLevel": "", + "Parent": null + } + } + ], + "UnboundedResources": null, + "Metadata": { + "expiration": "0001-01-01T00:00:00Z", + "fine_grained": false, + "type": "Classic GitHub Personal Access Token" + } + }`, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := Analyzer{} + got, err := a.Analyze(ctx, map[string]string{"key": tt.key}) + if (err != nil) != tt.wantErr { + t.Errorf("Analyzer.Analyze() error = %v, wantErr %v", err, tt.wantErr) + return + } + + // Marshal the actual result to JSON + gotJSON, err := json.Marshal(got) + if err != nil { + t.Fatalf("could not marshal got to JSON: %s", err) + } + + // Parse the expected JSON string + var wantObj analyzers.AnalyzerResult + if err := json.Unmarshal([]byte(tt.want), &wantObj); err != nil { + t.Fatalf("could not unmarshal want JSON string: %s", err) + } + + // Marshal the expected result to JSON (to normalize) + wantJSON, err := json.Marshal(wantObj) + if err != nil { + t.Fatalf("could not marshal want to JSON: %s", err) + } + + // Compare the JSON strings + if string(gotJSON) != string(wantJSON) { + // Pretty-print both JSON strings for easier comparison + var gotIndented, wantIndented []byte + gotIndented, err = json.MarshalIndent(got, "", " ") + if err != nil { + t.Fatalf("could not marshal got to indented JSON: %s", err) + } + wantIndented, err = json.MarshalIndent(wantObj, "", " ") + if err != nil { + t.Fatalf("could not marshal want to indented JSON: %s", err) + } + t.Errorf("Analyzer.Analyze() = %s, want %s", gotIndented, wantIndented) + } + }) + } +} diff --git a/pkg/analyzer/analyzers/openai/openai.go b/pkg/analyzer/analyzers/openai/openai.go index af0be0084..34c902a2b 100644 --- a/pkg/analyzer/analyzers/openai/openai.go +++ b/pkg/analyzer/analyzers/openai/openai.go @@ -1,3 +1,5 @@ +//go:generate generate_permissions permissions.yaml permissions.go openai + package openai import ( @@ -39,7 +41,6 @@ func secretInfoToAnalyzerResult(info *AnalyzerJSON) *analyzers.AnalyzerResult { Metadata: map[string]any{ "user": info.me.Name, "email": info.me.Email, - "phone": info.me.Phone, "mfa": strconv.FormatBool(info.me.MfaEnabled), "is_admin": strconv.FormatBool(info.isAdmin), "is_restricted": strconv.FormatBool(info.isRestricted), @@ -259,14 +260,20 @@ func printUserData(meJSON MeJSON) { fmt.Print("\n\n") } -func stringifyPermissionStatus(tests []analyzers.HttpStatusTest) analyzers.PermissionType { +func stringifyPermissionStatus(readTests []analyzers.HttpStatusTest, writeTests []analyzers.HttpStatusTest) analyzers.PermissionType { readStatus := false writeStatus := false errors := false - for _, test := range tests { + for _, test := range readTests { if test.Type == analyzers.READ { readStatus = test.Status.Value - } else if test.Type == analyzers.WRITE { + } + if test.Status.IsError { + errors = true + } + } + for _, test := range writeTests { + if test.Type == analyzers.WRITE { writeStatus = test.Status.Value } if test.Status.IsError { @@ -291,9 +298,9 @@ func getPermissions() []permissionData { var perms []permissionData for _, scope := range SCOPES { - status := stringifyPermissionStatus(scope.Tests) + status := stringifyPermissionStatus(scope.ReadTests, scope.WriteTests) perms = append(perms, permissionData{ - name: scope.Name, + name: scope.Endpoints[0], // Using the first endpoint as the name for simplicity endpoints: scope.Endpoints, status: status, }) diff --git a/pkg/analyzer/analyzers/openai/openai_test.go b/pkg/analyzer/analyzers/openai/openai_test.go new file mode 100644 index 000000000..94365d9dc --- /dev/null +++ b/pkg/analyzer/analyzers/openai/openai_test.go @@ -0,0 +1,125 @@ +package openai + +import ( + "encoding/json" + "testing" + "time" + + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" + "github.com/trufflesecurity/trufflehog/v3/pkg/common" + "github.com/trufflesecurity/trufflehog/v3/pkg/context" +) + +func TestAnalyzer_Analyze(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + defer cancel() + testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors4") + if err != nil { + t.Fatalf("could not get test secrets from GCP: %s", err) + } + + tests := []struct { + name string + key string + want string // JSON string + wantErr bool + }{ + { + name: "valid OpenAI key", + key: testSecrets.MustGetField("OPENAI_VERIFIED"), + want: `{ + "AnalyzerType": 0, + "Bindings": [ + { + "Resource": { + "Name": "Truffle Security Co", + "FullyQualifiedName": "org-n56tuYdSewh06PEGJZC0xWHf", + "Type": "organization", + "Metadata": { + "description": "Personal org for dustin@trufflesec.com", + "user": "truffle-security-co" + }, + "Parent": null + }, + "Permission": { + "Value": "full_access", + "AccessLevel": "", + "Parent": null + } + }, + { + "Resource": { + "Name": "Personal", + "FullyQualifiedName": "org-S2T2qOGM1KofMLUxb9rt7eV0", + "Type": "organization", + "Metadata": { + "description": "Personal org for dustin@trufflesec.com", + "user": "user-ohfap0ky8lkatw97iskuhghv" + }, + "Parent": null + }, + "Permission": { + "Value": "full_access", + "AccessLevel": "", + "Parent": null + } + } + ], + "UnboundedResources": null, + "Metadata": { + "email": "dustin@trufflesec.com", + "is_admin": "true", + "is_restricted": "false", + "mfa": "true", + "user": "Dustin Decker" + } + }`, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := Analyzer{Cfg: &config.Config{}} + got, err := a.Analyze(ctx, map[string]string{"key": tt.key}) + if (err != nil) != tt.wantErr { + t.Errorf("Analyzer.Analyze() error = %v, wantErr %v", err, tt.wantErr) + return + } + + // Marshal the actual result to JSON + gotJSON, err := json.Marshal(got) + if err != nil { + t.Fatalf("could not marshal got to JSON: %s", err) + } + + // Parse the expected JSON string + var wantObj analyzers.AnalyzerResult + if err := json.Unmarshal([]byte(tt.want), &wantObj); err != nil { + t.Fatalf("could not unmarshal want JSON string: %s", err) + } + + // Marshal the expected result to JSON (to normalize) + wantJSON, err := json.Marshal(wantObj) + if err != nil { + t.Fatalf("could not marshal want to JSON: %s", err) + } + + // Compare the JSON strings + if string(gotJSON) != string(wantJSON) { + // Pretty-print both JSON strings for easier comparison + var gotIndented, wantIndented []byte + gotIndented, err = json.MarshalIndent(got, "", " ") + if err != nil { + t.Fatalf("could not marshal got to indented JSON: %s", err) + } + wantIndented, err = json.MarshalIndent(wantObj, "", " ") + if err != nil { + t.Fatalf("could not marshal want to indented JSON: %s", err) + } + t.Errorf("Analyzer.Analyze() = %s, want %s", gotIndented, wantIndented) + } + }) + } +} diff --git a/pkg/analyzer/analyzers/openai/permissions.go b/pkg/analyzer/analyzers/openai/permissions.go new file mode 100644 index 000000000..27feb3d09 --- /dev/null +++ b/pkg/analyzer/analyzers/openai/permissions.go @@ -0,0 +1,106 @@ +// Code generated by go generate; DO NOT EDIT. +package openai + +import "errors" + +type Permission int + +const ( + NoAccess Permission = iota + ModelsRead Permission = iota + ModelCapabilitiesWrite Permission = iota + AssistantsRead Permission = iota + AssistantsWrite Permission = iota + ThreadsRead Permission = iota + ThreadsWrite Permission = iota + FineTuningRead Permission = iota + FineTuningWrite Permission = iota + FilesRead Permission = iota + FilesWrite Permission = iota +) + +var ( + permissionStrings = map[Permission]string{ + ModelsRead: "models:read", + ModelCapabilitiesWrite: "model_capabilities:write", + AssistantsRead: "assistants:read", + AssistantsWrite: "assistants:write", + ThreadsRead: "threads:read", + ThreadsWrite: "threads:write", + FineTuningRead: "fine_tuning:read", + FineTuningWrite: "fine_tuning:write", + FilesRead: "files:read", + FilesWrite: "files:write", + } + + stringToPermission = map[string]Permission{ + "models:read": ModelsRead, + "model_capabilities:write": ModelCapabilitiesWrite, + "assistants:read": AssistantsRead, + "assistants:write": AssistantsWrite, + "threads:read": ThreadsRead, + "threads:write": ThreadsWrite, + "fine_tuning:read": FineTuningRead, + "fine_tuning:write": FineTuningWrite, + "files:read": FilesRead, + "files:write": FilesWrite, + } + + permissionIDs = map[Permission]int{ + ModelsRead: 0, + ModelCapabilitiesWrite: 1, + AssistantsRead: 2, + AssistantsWrite: 3, + ThreadsRead: 4, + ThreadsWrite: 5, + FineTuningRead: 6, + FineTuningWrite: 7, + FilesRead: 8, + FilesWrite: 9, + } + + idToPermission = map[int]Permission{ + 0: ModelsRead, + 1: ModelCapabilitiesWrite, + 2: AssistantsRead, + 3: AssistantsWrite, + 4: ThreadsRead, + 5: ThreadsWrite, + 6: FineTuningRead, + 7: FineTuningWrite, + 8: FilesRead, + 9: FilesWrite, + } +) + +// ToString converts a Permission enum to its string representation +func (p Permission) ToString() (string, error) { + if str, ok := permissionStrings[p]; ok { + return str, nil + } + return "", errors.New("invalid permission") +} + +// ToID converts a Permission enum to its ID +func (p Permission) ToID() (int, error) { + if id, ok := permissionIDs[p]; ok { + return id, nil + } + return 0, errors.New("invalid permission") +} + +// PermissionFromString converts a string representation to its Permission enum +func PermissionFromString(s string) (Permission, error) { + if p, ok := stringToPermission[s]; ok { + return p, nil + } + return 0, errors.New("invalid permission string") +} + +// PermissionFromID converts an ID to its Permission enum +func PermissionFromID(id int) (Permission, error) { + if p, ok := idToPermission[id]; ok { + return p, nil + } + return 0, errors.New("invalid permission ID") +} diff --git a/pkg/analyzer/analyzers/openai/permissions.yaml b/pkg/analyzer/analyzers/openai/permissions.yaml new file mode 100644 index 000000000..4efd163b5 --- /dev/null +++ b/pkg/analyzer/analyzers/openai/permissions.yaml @@ -0,0 +1,11 @@ +permissions: +- models:read +- model_capabilities:write +- assistants:read +- assistants:write +- threads:read +- threads:write +- fine_tuning:read +- fine_tuning:write +- files:read +- files:write diff --git a/pkg/analyzer/analyzers/openai/scopes.go b/pkg/analyzer/analyzers/openai/scopes.go index b76eaabc3..ca8d5a987 100644 --- a/pkg/analyzer/analyzers/openai/scopes.go +++ b/pkg/analyzer/analyzers/openai/scopes.go @@ -1,11 +1,15 @@ package openai -import "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" +import ( + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" +) type OpenAIScope struct { - Name string - Tests []analyzers.HttpStatusTest - Endpoints []string + ReadTests []analyzers.HttpStatusTest + WriteTests []analyzers.HttpStatusTest + Endpoints []string + ReadPermission Permission + WritePermission Permission } func (s *OpenAIScope) RunTests(key string) error { @@ -13,8 +17,14 @@ func (s *OpenAIScope) RunTests(key string) error { "Authorization": "Bearer " + key, "Content-Type": "application/json", } - for i := range s.Tests { - test := &s.Tests[i] + for i := range s.ReadTests { + test := &s.ReadTests[i] + if err := test.RunTest(headers); err != nil { + return err + } + } + for i := range s.WriteTests { + test := &s.WriteTests[i] if err := test.RunTest(headers); err != nil { return err } @@ -24,49 +34,61 @@ func (s *OpenAIScope) RunTests(key string) error { var SCOPES = []OpenAIScope{ { - Name: "Models", - Tests: []analyzers.HttpStatusTest{ + ReadTests: []analyzers.HttpStatusTest{ {URL: BASE_URL + "/v1/models", Method: "GET", Valid: []int{200}, Invalid: []int{403}, Type: analyzers.READ, Status: analyzers.PermissionStatus{}}, }, - Endpoints: []string{"/v1/models"}, + Endpoints: []string{"/v1/models"}, + ReadPermission: ModelsRead, }, { - Name: "Model capabilities", - Tests: []analyzers.HttpStatusTest{ + WriteTests: []analyzers.HttpStatusTest{ {URL: BASE_URL + "/v1/images/generations", Method: "POST", Payload: POST_PAYLOAD, Valid: []int{400}, Invalid: []int{401}, Type: analyzers.WRITE, Status: analyzers.PermissionStatus{}}, }, - Endpoints: []string{"/v1/audio", "/v1/chat/completions", "/v1/embeddings", "/v1/images", "/v1/moderations"}, + Endpoints: []string{"/v1/audio", "/v1/chat/completions", "/v1/embeddings", "/v1/images", "/v1/moderations"}, + WritePermission: ModelCapabilitiesWrite, }, { - Name: "Assistants", - Tests: []analyzers.HttpStatusTest{ + ReadTests: []analyzers.HttpStatusTest{ {URL: BASE_URL + "/v1/assistants", Method: "GET", Valid: []int{400}, Invalid: []int{401}, Type: analyzers.READ, Status: analyzers.PermissionStatus{}}, + }, + WriteTests: []analyzers.HttpStatusTest{ {URL: BASE_URL + "/v1/assistants", Method: "POST", Payload: POST_PAYLOAD, Valid: []int{400}, Invalid: []int{401}, Type: analyzers.WRITE, Status: analyzers.PermissionStatus{}}, }, - Endpoints: []string{"/v1/assistants"}, + Endpoints: []string{"/v1/assistants"}, + ReadPermission: AssistantsRead, + WritePermission: AssistantsWrite, }, { - Name: "Threads", - Tests: []analyzers.HttpStatusTest{ + ReadTests: []analyzers.HttpStatusTest{ {URL: BASE_URL + "/v1/threads/1", Method: "GET", Valid: []int{400}, Invalid: []int{401}, Type: analyzers.READ, Status: analyzers.PermissionStatus{}}, + }, + WriteTests: []analyzers.HttpStatusTest{ {URL: BASE_URL + "/v1/threads", Method: "POST", Payload: POST_PAYLOAD, Valid: []int{400}, Invalid: []int{401}, Type: analyzers.WRITE, Status: analyzers.PermissionStatus{}}, }, - Endpoints: []string{"/v1/threads"}, + Endpoints: []string{"/v1/threads"}, + ReadPermission: ThreadsRead, + WritePermission: ThreadsWrite, }, { - Name: "Fine-tuning", - Tests: []analyzers.HttpStatusTest{ + ReadTests: []analyzers.HttpStatusTest{ {URL: BASE_URL + "/v1/fine_tuning/jobs", Method: "GET", Valid: []int{200}, Invalid: []int{401}, Type: analyzers.READ, Status: analyzers.PermissionStatus{}}, + }, + WriteTests: []analyzers.HttpStatusTest{ {URL: BASE_URL + "/v1/fine_tuning/jobs", Method: "POST", Payload: POST_PAYLOAD, Valid: []int{400}, Invalid: []int{401}, Type: analyzers.WRITE, Status: analyzers.PermissionStatus{}}, }, - Endpoints: []string{"/v1/fine_tuning"}, + Endpoints: []string{"/v1/fine_tuning"}, + ReadPermission: FineTuningRead, + WritePermission: FineTuningWrite, }, { - Name: "Files", - Tests: []analyzers.HttpStatusTest{ + ReadTests: []analyzers.HttpStatusTest{ {URL: BASE_URL + "/v1/files", Method: "GET", Valid: []int{200}, Invalid: []int{401}, Type: analyzers.READ, Status: analyzers.PermissionStatus{}}, + }, + WriteTests: []analyzers.HttpStatusTest{ {URL: BASE_URL + "/v1/files", Method: "POST", Payload: POST_PAYLOAD, Valid: []int{415}, Invalid: []int{401}, Type: analyzers.WRITE, Status: analyzers.PermissionStatus{}}, }, - Endpoints: []string{"/v1/files"}, + Endpoints: []string{"/v1/files"}, + ReadPermission: FilesRead, + WritePermission: FilesWrite, }, } diff --git a/pkg/analyzer/analyzers/twilio/permissions.go b/pkg/analyzer/analyzers/twilio/permissions.go new file mode 100644 index 000000000..05d827f6a --- /dev/null +++ b/pkg/analyzer/analyzers/twilio/permissions.go @@ -0,0 +1,136 @@ +// Code generated by go generate; DO NOT EDIT. +package twilio + +import "errors" + +type Permission int + +const ( + NoAccess Permission = iota + AccountManagementRead Permission = iota + AccountManagementWrite Permission = iota + SubaccountConfigurationRead Permission = iota + SubaccountConfigurationWrite Permission = iota + KeyManagementRead Permission = iota + KeyManagementWrite Permission = iota + ServiceVerificationRead Permission = iota + ServiceVerificationWrite Permission = iota + SmsRead Permission = iota + SmsWrite Permission = iota + VoiceRead Permission = iota + VoiceWrite Permission = iota + MessagingRead Permission = iota + MessagingWrite Permission = iota + CallManagementRead Permission = iota + CallManagementWrite Permission = iota +) + +var ( + permissionStrings = map[Permission]string{ + AccountManagementRead: "account_management:read", + AccountManagementWrite: "account_management:write", + SubaccountConfigurationRead: "subaccount_configuration:read", + SubaccountConfigurationWrite: "subaccount_configuration:write", + KeyManagementRead: "key_management:read", + KeyManagementWrite: "key_management:write", + ServiceVerificationRead: "service_verification:read", + ServiceVerificationWrite: "service_verification:write", + SmsRead: "sms:read", + SmsWrite: "sms:write", + VoiceRead: "voice:read", + VoiceWrite: "voice:write", + MessagingRead: "messaging:read", + MessagingWrite: "messaging:write", + CallManagementRead: "call_management:read", + CallManagementWrite: "call_management:write", + } + + stringToPermission = map[string]Permission{ + "account_management:read": AccountManagementRead, + "account_management:write": AccountManagementWrite, + "subaccount_configuration:read": SubaccountConfigurationRead, + "subaccount_configuration:write": SubaccountConfigurationWrite, + "key_management:read": KeyManagementRead, + "key_management:write": KeyManagementWrite, + "service_verification:read": ServiceVerificationRead, + "service_verification:write": ServiceVerificationWrite, + "sms:read": SmsRead, + "sms:write": SmsWrite, + "voice:read": VoiceRead, + "voice:write": VoiceWrite, + "messaging:read": MessagingRead, + "messaging:write": MessagingWrite, + "call_management:read": CallManagementRead, + "call_management:write": CallManagementWrite, + } + + permissionIDs = map[Permission]int{ + AccountManagementRead: 0, + AccountManagementWrite: 1, + SubaccountConfigurationRead: 2, + SubaccountConfigurationWrite: 3, + KeyManagementRead: 4, + KeyManagementWrite: 5, + ServiceVerificationRead: 6, + ServiceVerificationWrite: 7, + SmsRead: 8, + SmsWrite: 9, + VoiceRead: 10, + VoiceWrite: 11, + MessagingRead: 12, + MessagingWrite: 13, + CallManagementRead: 14, + CallManagementWrite: 15, + } + + idToPermission = map[int]Permission{ + 0: AccountManagementRead, + 1: AccountManagementWrite, + 2: SubaccountConfigurationRead, + 3: SubaccountConfigurationWrite, + 4: KeyManagementRead, + 5: KeyManagementWrite, + 6: ServiceVerificationRead, + 7: ServiceVerificationWrite, + 8: SmsRead, + 9: SmsWrite, + 10: VoiceRead, + 11: VoiceWrite, + 12: MessagingRead, + 13: MessagingWrite, + 14: CallManagementRead, + 15: CallManagementWrite, + } +) + +// ToString converts a Permission enum to its string representation +func (p Permission) ToString() (string, error) { + if str, ok := permissionStrings[p]; ok { + return str, nil + } + return "", errors.New("invalid permission") +} + +// ToID converts a Permission enum to its ID +func (p Permission) ToID() (int, error) { + if id, ok := permissionIDs[p]; ok { + return id, nil + } + return 0, errors.New("invalid permission") +} + +// PermissionFromString converts a string representation to its Permission enum +func PermissionFromString(s string) (Permission, error) { + if p, ok := stringToPermission[s]; ok { + return p, nil + } + return 0, errors.New("invalid permission string") +} + +// PermissionFromID converts an ID to its Permission enum +func PermissionFromID(id int) (Permission, error) { + if p, ok := idToPermission[id]; ok { + return p, nil + } + return 0, errors.New("invalid permission ID") +} diff --git a/pkg/analyzer/analyzers/twilio/permissions.yaml b/pkg/analyzer/analyzers/twilio/permissions.yaml new file mode 100644 index 000000000..cc03fa5d2 --- /dev/null +++ b/pkg/analyzer/analyzers/twilio/permissions.yaml @@ -0,0 +1,17 @@ +permissions: + - account_management:read + - account_management:write + - subaccount_configuration:read + - subaccount_configuration:write + - key_management:read + - key_management:write + - service_verification:read + - service_verification:write + - sms:read + - sms:write + - voice:read + - voice:write + - messaging:read + - messaging:write + - call_management:read + - call_management:write diff --git a/pkg/analyzer/analyzers/twilio/twilio.go b/pkg/analyzer/analyzers/twilio/twilio.go index 4bcdc4ed6..0b855a246 100644 --- a/pkg/analyzer/analyzers/twilio/twilio.go +++ b/pkg/analyzer/analyzers/twilio/twilio.go @@ -1,3 +1,5 @@ +//go:generate generate_permissions permissions.yaml permissions.go twilio + package twilio import ( @@ -10,8 +12,86 @@ import ( "github.com/fatih/color" "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" ) +type Analyzer struct{} + +func (a *Analyzer) Type() analyzerpb.AnalyzerType { + return analyzerpb.AnalyzerType_Twilio +} + +func (a *Analyzer) Analyze(ctx context.Context, credentialInfo map[string]string) (*analyzers.AnalyzerResult, error) { + key, ok := credentialInfo["key"] + if !ok { + return nil, errors.New("key not found in credentialInfo") + } + + cfg := &config.Config{} // You might need to adjust this based on how you want to handle config + info, err := AnalyzePermissions(cfg, key) + if err != nil { + return nil, err + } + + var permissions []Permission + if info.AccountStatusCode == 200 { + permissions = []Permission{ + AccountManagementRead, + AccountManagementWrite, + SubaccountConfigurationRead, + SubaccountConfigurationWrite, + KeyManagementRead, + KeyManagementWrite, + ServiceVerificationRead, + ServiceVerificationWrite, + SmsRead, + SmsWrite, + VoiceRead, + VoiceWrite, + MessagingRead, + MessagingWrite, + CallManagementRead, + CallManagementWrite, + } + } else if info.AccountStatusCode == 401 { + permissions = []Permission{ + ServiceVerificationRead, + ServiceVerificationWrite, + SmsRead, + SmsWrite, + VoiceRead, + VoiceWrite, + MessagingRead, + MessagingWrite, + CallManagementRead, + CallManagementWrite, + } + } + + // Can we get org information? + resource := analyzers.Resource{ + Name: "Twilio API", + Type: "API", + } + + var bindings []analyzers.Binding + for _, perm := range permissions { + permStr, _ := perm.ToString() + bindings = append(bindings, analyzers.Binding{ + Resource: resource, + Permission: analyzers.Permission{ + Value: permStr, + }, + }) + } + + return &analyzers.AnalyzerResult{ + AnalyzerType: analyzerpb.AnalyzerType_Twilio, + Bindings: bindings, + }, nil +} + type VerifyJSON struct { Code int `json:"code"` } diff --git a/pkg/analyzer/generate_permissions/generate_permissions.go b/pkg/analyzer/generate_permissions/generate_permissions.go new file mode 100644 index 000000000..8efc3e5e1 --- /dev/null +++ b/pkg/analyzer/generate_permissions/generate_permissions.go @@ -0,0 +1,144 @@ +package main + +import ( + "fmt" + "log" + "os" + "strings" + "text/template" + + "golang.org/x/text/cases" + "golang.org/x/text/language" + "gopkg.in/yaml.v3" +) + +type PermissionsData struct { + Permissions []string `yaml:"permissions"` + PackageName string `yaml:"package_name"` +} + +const templateText = `// Code generated by go generate; DO NOT EDIT. +package {{ .PackageName }} + +import "errors" + +type Permission int + +const ( + NoAccess Permission = iota +{{- range $index, $permission := .Permissions }} + {{ ToCamelCase $permission }} Permission = iota +{{- end }} +) + +var ( + permissionStrings = map[Permission]string{ +{{- range $index, $permission := .Permissions }} + {{ ToCamelCase $permission }}: "{{ $permission }}", +{{- end }} + } + + stringToPermission = map[string]Permission{ +{{- range $index, $permission := .Permissions }} + "{{ $permission }}": {{ ToCamelCase $permission }}, +{{- end }} + } + + permissionIDs = map[Permission]int{ +{{- range $index, $permission := .Permissions }} + {{ ToCamelCase $permission }}: {{ $index }}, +{{- end }} + } + + idToPermission = map[int]Permission{ +{{- range $index, $permission := .Permissions }} + {{ $index }}: {{ ToCamelCase $permission }}, +{{- end }} + } +) + +// ToString converts a Permission enum to its string representation +func (p Permission) ToString() (string, error) { + if str, ok := permissionStrings[p]; ok { + return str, nil + } + return "", errors.New("invalid permission") +} + +// ToID converts a Permission enum to its ID +func (p Permission) ToID() (int, error) { + if id, ok := permissionIDs[p]; ok { + return id, nil + } + return 0, errors.New("invalid permission") +} + +// PermissionFromString converts a string representation to its Permission enum +func PermissionFromString(s string) (Permission, error) { + if p, ok := stringToPermission[s]; ok { + return p, nil + } + return 0, errors.New("invalid permission string") +} + +// PermissionFromID converts an ID to its Permission enum +func PermissionFromID(id int) (Permission, error) { + if p, ok := idToPermission[id]; ok { + return p, nil + } + return 0, errors.New("invalid permission ID") +} +` + +// ToCamelCase converts a string to CamelCase +func ToCamelCase(s string) string { + parts := strings.Split(s, ":") + caser := cases.Title(language.English) + for i := range parts { + subParts := strings.Split(parts[i], "_") + for j := range subParts { + subParts[j] = caser.String(subParts[j]) + } + parts[i] = strings.Join(subParts, "") + } + return strings.Join(parts, "") +} + +func main() { + // Read the YAML file from first argument + file, err := os.Open(os.Args[1]) + if err != nil { + log.Fatalf("Failed to open YAML file: %v", err) + } + defer file.Close() + + var data PermissionsData + decoder := yaml.NewDecoder(file) + err = decoder.Decode(&data) + if err != nil { + log.Fatalf("Failed to decode YAML file: %v", err) + } + data.PackageName = os.Args[3] + + // Parse the template + tmpl, err := template.New("permissions").Funcs(template.FuncMap{ + "ToCamelCase": ToCamelCase, + }).Parse(templateText) + if err != nil { + log.Fatalf("Failed to parse template: %v", err) + } + + // Generate the code + outputFile, err := os.Create(os.Args[2]) + if err != nil { + log.Fatalf("Failed to create output file: %v", err) + } + defer outputFile.Close() + + err = tmpl.Execute(outputFile, data) + if err != nil { + log.Fatalf("Failed to execute template: %v", err) + } + + fmt.Println("Permissions code generated successfully.") +}