//go:generate generate_permissions finegrained.yaml finegrained_permissions.go finegrained package finegrained import ( "context" "errors" "fmt" "log" "os" "strings" "github.com/fatih/color" gh "github.com/google/go-github/v67/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" ) const ( // Random values for testing RANDOM_STRING = "FQ2pR.4voZg-gJfsqYKx_eLDNF_6BYhw8RL__" RANDOM_USERNAME = "d" + "ummy" + "acco" + "untgh" + "2024" RANDOM_REPO = "te" + "st" RANDOM_INTEGER = 4294967289 ) var ErrInvalid = errors.New("invalid") var repoPermFuncMap = []func(client *gh.Client, repo *gh.Repository, currentAccess Permission) (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 = []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 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: 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 notImplementedRepoPerm(client *gh.Client, repo *gh.Repository, currentAccess Permission) (Permission, error) { return NoAccess, nil } // 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, nil } func getMetadataPermission(client *gh.Client, repo *gh.Repository, currentAccess Permission) (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 NoAccess, nil } return Invalid, err } // If no error, then we have read access return MetadataRead, nil } func getActionsPermission(client *gh.Client, repo *gh.Repository, currentAccess Permission) (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 NoAccess, nil case 200: break default: return Invalid, err } // 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. // -> POST request to /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches resp, err = client.Actions.CreateWorkflowDispatchEventByFileName(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING, gh.CreateWorkflowDispatchEventRequest{}) switch resp.StatusCode { case 403: return ActionsRead, nil case 404: 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 ActionsWrite, nil default: return Invalid, err } } else { // Will only land here if already tested one public repo and got a 403. if currentAccess == NoAccess { return NoAccess, nil } // 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. // -> POST request to /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches resp, err := client.Actions.CreateWorkflowDispatchEventByFileName(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING, gh.CreateWorkflowDispatchEventRequest{}) switch resp.StatusCode { case 403: return NoAccess, nil case 404: 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 ActionsWrite, nil default: return Invalid, err } } } // Continue with the other functions using the same pattern... func getAdministrationPermission(client *gh.Client, repo *gh.Repository, currentAccess Permission) (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 NoAccess, nil case 200: break default: return Invalid, 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 Invalid, err } resp, err = client.Do(context.Background(), req, nil) switch resp.StatusCode { case 403: return AdministrationRead, nil case 200: return AdministrationWrite, nil default: return Invalid, err } } func getCodeScanningAlertsPermission(client *gh.Client, repo *gh.Repository, currentAccess Permission) (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) defer resp.Body.Close() switch { case resp.StatusCode == 403: return NoAccess, nil case resp.StatusCode == 404: break case resp.StatusCode >= 200 && resp.StatusCode <= 299: break default: return Invalid, err } // Risk: Very Low // -> Even if user had an alert with the number (see RANDOM_INTEGER above), this should error 422 due to the nil value passed in. // -> PATCH request to /repos/{owner}/{repo}/code-scanning/alerts/{alert_number} _, resp, err = client.CodeScanning.UpdateAlert(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_INTEGER, nil) switch resp.StatusCode { case 403: return CodeScanningAlertsRead, nil case 422: 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 CodeScanningAlertsWrite, nil default: return Invalid, err } } func getCodespacesPermission(client *gh.Client, repo *gh.Repository, currentAccess Permission) (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 NoAccess, nil case 200: break default: return Invalid, 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 Invalid, err } resp, err = client.Do(context.Background(), req, nil) switch resp.StatusCode { case 403: return CodespacesRead, nil case 422: return CodespacesWrite, nil case 200: return CodespacesWrite, nil default: return Invalid, err } } func getCodespacesMetadataPermission(client *gh.Client, repo *gh.Repository, currentAccess Permission) (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 Invalid, err } resp, err := client.Do(context.Background(), req, nil) switch resp.StatusCode { case 403: return NoAccess, nil case 200: return CodespacesMetadataRead, nil default: return Invalid, err } } func getCodespacesSecretsPermission(client *gh.Client, repo *gh.Repository, currentAccess Permission) (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 NoAccess, nil case 404: return CodespacesSecretsWrite, nil case 200: return CodespacesSecretsWrite, nil default: return Invalid, err } } // getCommitStatusesPermission will check if we have access to commit statuses for a given repo. // 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 Permission) (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 NoAccess, nil case 404: break default: return Invalid, err } // At this point we have read access // Risk: Extremely Low // -> We're POSTing a commit status to a commit that cannot exist. This should always return 422 if valid access. // POST request to /repos/{owner}/{repo}/statuses/{commit_sha} _, resp, err = client.Repositories.CreateStatus(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING, &gh.RepoStatus{}) switch resp.StatusCode { case 403: return CommitStatusesRead, nil case 422: return CommitStatusesWrite, nil default: return Invalid, err } } else { // Will only land here if already tested one public repo and got a 403. if currentAccess == NoAccess { return NoAccess, nil } // Risk: Extremely Low // -> We're POSTing a commit status to a commit that cannot exist. This should always return 422 if valid access. // POST request to /repos/{owner}/{repo}/statuses/{commit_sha} _, resp, err := client.Repositories.CreateStatus(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING, &gh.RepoStatus{}) switch resp.StatusCode { case 403: // All we know is we don't have READ_WRITE return NoAccess, nil case 422: return CommitStatusesWrite, nil default: return Invalid, err } } } // getContentsPermission will check if we have access to the contents of a given repo. // 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 Permission) (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 NoAccess, nil case 200: break case 409: break default: return Invalid, err } // At this point we have read access // 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. // PUT /repos/{owner}/{repo}/contents/{path} _, resp, err = client.Repositories.CreateFile(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING, &gh.RepositoryContentFileOptions{}) switch resp.StatusCode { case 403: return ContentsRead, nil case 200: log.Fatal("This should never happen. We are creating a file with an invalid payload.") return ContentsWrite, nil case 400, 422: return ContentsWrite, nil default: return Invalid, err } } else { // Will only land here if already tested one public repo and got a 403. if currentAccess == NoAccess { return NoAccess, nil } // 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. // PUT /repos/{owner}/{repo}/contents/{path} _, resp, err := client.Repositories.CreateFile(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING, &gh.RepositoryContentFileOptions{}) switch resp.StatusCode { case 403: return NoAccess, nil case 200: panic("This should never happen. We are creating a file with an invalid payload.") case 400, 422: return ContentsWrite, nil default: return Invalid, err } } } func getDependabotAlertsPermission(client *gh.Client, repo *gh.Repository, currentAccess Permission) (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{}) defer resp.Body.Close() switch resp.StatusCode { case 403: return NoAccess, nil case 200: break default: return Invalid, 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 DependabotAlertsRead, nil case 422, 404: 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 DependabotAlertsWrite, nil default: return Invalid, err } } func getDependabotSecretsPermission(client *gh.Client, repo *gh.Repository, currentAccess Permission) (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 NoAccess, nil case 200: break default: return Invalid, err } // Risk: Very Low // -> We're "creating" a secret with an invalid payload. Even if we did, the name would be (see RANDOM_STRING above) and the value would be nil. // PUT /repos/{owner}/{repo}/dependabot/secrets/{secret_name} resp, err = client.Dependabot.CreateOrUpdateRepoSecret(context.Background(), *repo.Owner.Login, *repo.Name, &gh.DependabotEncryptedSecret{Name: RANDOM_STRING}) switch resp.StatusCode { case 403: return DependabotSecretsRead, nil case 422: return DependabotSecretsWrite, nil case 201, 204: log.Fatal("This should never happen. We are creating a secret with an invalid payload.") return DependabotSecretsWrite, nil default: return Invalid, err } } func getDeploymentsPermission(client *gh.Client, repo *gh.Repository, currentAccess Permission) (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 NoAccess, nil case 200: break default: return Invalid, err } // Risk: Very Low // -> We're creating a deployment with an invalid payload. Even if we did, the name would be (see RANDOM_STRING above) and the value would be nil. // POST /repos/{owner}/{repo}/deployments/{deployment_id}/statuses _, resp, err = client.Repositories.CreateDeployment(context.Background(), *repo.Owner.Login, *repo.Name, &gh.DeploymentRequest{}) switch resp.StatusCode { case 403: return DeploymentsRead, nil case 409, 422: return DeploymentsWrite, nil case 201, 202: log.Fatal("This should never happen. We are creating a deployment with an invalid payload.") return DeploymentsWrite, nil default: return Invalid, err } } func getEnvironmentsPermission(client *gh.Client, repo *gh.Repository, currentAccess Permission) (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 NoAccess, nil } // If no environments exist, then we return UNKNOWN if len(envResp.Environments) == 0 { return NoAccess, nil } // Risk: Extremely Low // GET /repositories/{repository_id}/environments/{environment_name}/variables _, resp, _ = client.Actions.ListEnvVariables(context.Background(), *repo.Owner.Login, *repo.Name, *envResp.Environments[0].Name, &gh.ListOptions{}) switch resp.StatusCode { case 403: return NoAccess, nil case 200: break default: return Invalid, nil } // Risk: Very Low // -> We're updating an environment variable with an invalid payload. Even if we did, the name would be (see RANDOM_STRING above) and the value would be nil. // PATCH /repositories/{repository_id}/environments/{environment_name}/variables/{variable_name} 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 EnvironmentsRead, nil case 422: return EnvironmentsWrite, nil case 200: log.Fatal("This should never happen. We are updating an environment variable with an invalid payload.") return EnvironmentsWrite, nil default: return Invalid, err } } func getIssuesPermission(client *gh.Client, repo *gh.Repository, currentAccess Permission) (Permission, error) { if *repo.Private { // Risk: Extremely Low // GET /repos/{owner}/{repo}/issues _, resp, err := client.Issues.ListByRepo(context.Background(), *repo.Owner.Login, *repo.Name, &gh.IssueListByRepoOptions{}) switch resp.StatusCode { case 403: return NoAccess, nil case 200, 301: break default: return Invalid, err } // 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). // PATCH /repos/{owner}/{repo}/labels/{name} _, resp, err = client.Issues.EditLabel(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING, &gh.Label{}) switch resp.StatusCode { case 403: return IssuesRead, nil case 404: return IssuesWrite, nil case 200: log.Fatal("This should never happen. We are editing a label with an invalid payload.") return IssuesWrite, nil default: return Invalid, err } } else { // Will only land here if already tested one public repo and got a 403. if currentAccess == NoAccess { return NoAccess, nil } // 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). // PATCH /repos/{owner}/{repo}/labels/{name} _, resp, err := client.Issues.EditLabel(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_STRING, &gh.Label{}) switch resp.StatusCode { case 403: return NoAccess, nil case 404: return IssuesWrite, nil case 200: log.Fatal("This should never happen. We are editing a label with an invalid payload.") return IssuesWrite, nil default: return Invalid, err } } } func getPagesPermission(client *gh.Client, repo *gh.Repository, currentAccess Permission) (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 NoAccess, nil case 200, 404: break default: return Invalid, err } // 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 Invalid, err } resp, err = client.Do(context.Background(), req, nil) switch resp.StatusCode { case 403: return PagesRead, nil case 404: return PagesWrite, nil case 200: log.Fatal("This should never happen. We are cancelling a deployment with an invalid ID.") return PagesWrite, nil default: return Invalid, err } } else { // Will only land here if already tested one public repo and got a 403. if currentAccess == NoAccess { return NoAccess, nil } // 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 Invalid, err } resp, err := client.Do(context.Background(), req, nil) switch resp.StatusCode { case 403: return NoAccess, nil case 404: return PagesWrite, nil case 200: log.Fatal("This should never happen. We are cancelling a deployment with an invalid ID.") return PagesWrite, nil default: return Invalid, err } } } func getPullRequestsPermission(client *gh.Client, repo *gh.Repository, currentAccess Permission) (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 NoAccess, nil case 200: break default: return Invalid, err } // Risk: Very Low // -> We're creating a pull request with an invalid payload. // POST /repos/{owner}/{repo}/pulls _, resp, err = client.PullRequests.Create(context.Background(), *repo.Owner.Login, *repo.Name, &gh.NewPullRequest{}) switch resp.StatusCode { case 403: return PullRequestsRead, nil case 422: return PullRequestsWrite, nil case 200: log.Fatal("This should never happen. We are creating a pull request with an invalid payload.") return PullRequestsWrite, nil default: return Invalid, err } } else { // Will only land here if already tested one public repo and got a 403. if currentAccess == NoAccess { return NoAccess, nil } // Risk: Very Low // -> We're creating a pull request with an invalid payload. // POST /repos/{owner}/{repo}/pulls _, resp, err := client.PullRequests.Create(context.Background(), *repo.Owner.Login, *repo.Name, &gh.NewPullRequest{}) switch resp.StatusCode { case 403: return NoAccess, nil case 422: return PullRequestsWrite, nil case 200: log.Fatal("This should never happen. We are creating a pull request with an invalid payload.") return PullRequestsWrite, nil default: return Invalid, err } } } func getRepoSecurityPermission(client *gh.Client, repo *gh.Repository, currentAccess Permission) (Permission, error) { if *repo.Private { // Risk: Extremely Low // GET /repos/{owner}/{repo}/security-advisories _, resp, err := client.SecurityAdvisories.ListRepositorySecurityAdvisories(context.Background(), *repo.Owner.Login, *repo.Name, nil) switch resp.StatusCode { case 403, 404: return NoAccess, nil case 200: break default: return Invalid, err } // 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 Invalid, err } resp, err = client.Do(context.Background(), req, nil) switch resp.StatusCode { case 403: return RepoSecurityRead, nil case 422: return RepoSecurityWrite, nil case 200: log.Fatal("This should never happen. We are creating a security advisory with an invalid payload.") return RepoSecurityWrite, nil default: return Invalid, err } } else { // Will only land here if already tested one public repo and got a 403. if currentAccess == NoAccess { return NoAccess, nil } // 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 Invalid, err } resp, err := client.Do(context.Background(), req, nil) switch resp.StatusCode { case 403: return NoAccess, nil case 422: return RepoSecurityWrite, nil case 200: log.Fatal("This should never happen. We are creating a security advisory with an invalid payload.") return RepoSecurityWrite, nil default: return Invalid, err } } } func getSecretScanningPermission(client *gh.Client, repo *gh.Repository, currentAccess Permission) (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 NoAccess, nil case 200, 404: break default: return Invalid, err } // Risk: Very Low // -> We're updating a secret scanning alert for an alert that doesn't exist. // POST /repos/{owner}/{repo}/secret-scanning/alerts _, resp, err = client.SecretScanning.UpdateAlert(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_INTEGER, &gh.SecretScanningAlertUpdateOptions{}) switch resp.StatusCode { case 403: return SecretScanningRead, nil case 404, 422: return SecretScanningWrite, nil case 200: log.Fatal("This should never happen. We are updating a secret scanning alert that doesn't exist.") return SecretScanningWrite, nil default: return Invalid, err } } func getSecretsPermission(client *gh.Client, repo *gh.Repository, currentAccess Permission) (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 NoAccess, nil case 200: break default: return Invalid, err } // Risk: Very Low // -> We're creating a secret with an invalid payload. // PUT /repos/{owner}/{repo}/actions/secrets/{secret_name} resp, err = client.Actions.CreateOrUpdateRepoSecret(context.Background(), *repo.Owner.Login, *repo.Name, &gh.EncryptedSecret{Name: RANDOM_STRING}) switch resp.StatusCode { case 403: return SecretsRead, nil case 422: return SecretsWrite, nil case 201, 204: log.Fatal("This should never happen. We are creating a secret with an invalid payload.") return SecretsWrite, nil default: return Invalid, err } } func getVariablesPermission(client *gh.Client, repo *gh.Repository, currentAccess Permission) (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 NoAccess, nil case 200: break default: return Invalid, err } // Risk: Very Low // -> We're updating a variable that doesn't exist with an invalid payload. // PATCH /repos/{owner}/{repo}/actions/variables/{name} resp, err = client.Actions.UpdateRepoVariable(context.Background(), *repo.Owner.Login, *repo.Name, &gh.ActionsVariable{Name: RANDOM_STRING}) switch resp.StatusCode { case 403: return VariablesRead, nil case 422: 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 VariablesWrite, nil default: return Invalid, err } } func getWebhooksPermission(client *gh.Client, repo *gh.Repository, currentAccess Permission) (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 NoAccess, nil case 200: break default: return Invalid, err } // Risk: Very Low // -> We're updating a webhook that doesn't exist with an invalid payload. // PATCH /repos/{owner}/{repo}/hooks/{hook_id} _, resp, err = client.Repositories.EditHook(context.Background(), *repo.Owner.Login, *repo.Name, RANDOM_INTEGER, &gh.Hook{}) switch resp.StatusCode { case 403: return WebhooksRead, nil case 404: return WebhooksWrite, nil case 200: log.Fatal("This should never happen. We are updating a webhook with an invalid payload.") return WebhooksWrite, nil default: return Invalid, err } } // analyzeRepositoryPermissions will analyze the fine-grained permissions of a given permission type and return the access level. // 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) ([]Permission, error) { perms := make([]Permission, len(repoPermFuncMap)) for _, repo := range repos { for i, permFunc := range repoPermFuncMap { access, err := permFunc(client, repo, perms[i]) if err != nil || access == Invalid { // TODO: Log error. continue } if perms[i] == Invalid || perms[i] == NoAccess { perms[i] = access } } } return perms, nil } 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 NoAccess, nil case 200: break default: return Invalid, err } // Risk: Extremely Low // -> PUT request to /user/blocks/{username} // -> We're blocking a user that doesn't exist. See RANDOM_STRING above. resp, err = client.Users.BlockUser(context.Background(), RANDOM_STRING) switch resp.StatusCode { case 403: return BlockUserRead, nil case 404: return BlockUserWrite, nil case 204: log.Fatal("This should never happen. We are blocking a user that doesn't exist.") return BlockUserWrite, nil default: return Invalid, err } } 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 NoAccess, nil case 200: break default: return Invalid, err } // Risk: Low // PUT request to /user/codespaces/secrets/{secret_name} // Payload is invalid, so it shouldn't actually post. resp, err = client.Codespaces.CreateOrUpdateUserSecret(context.Background(), &gh.EncryptedSecret{Name: RANDOM_STRING}) switch resp.StatusCode { case 403: return CodespaceUserSecretsRead, nil case 422: return CodespaceUserSecretsWrite, nil case 201, 204: log.Fatal("This should never happen. We are creating a user secret with an invalid payload.") return CodespaceUserSecretsWrite, nil default: return Invalid, err } } 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 NoAccess, nil case 200: break default: return Invalid, err } // Risk: Low // POST request to /user/emails/visibility _, resp, err = client.Users.SetEmailVisibility(context.Background(), RANDOM_STRING) switch resp.StatusCode { case 403, 404: return EmailRead, nil case 422: return EmailWrite, nil case 201: log.Fatal("This should never happen. We are setting email visibility with an invalid payload.") return EmailWrite, nil default: return Invalid, err } } 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 NoAccess, nil case 200: break default: return Invalid, err } // Risk: Low - Medium // DELETE request to /user/followers/{username} // For the username value, we need to use a real username. So there is a super small chance that someone following // an account for RANDOM_USERNAME value will then no longer follow that account. // But we're using an account created specifically for this purpose with no activity. resp, err = client.Users.Unfollow(context.Background(), RANDOM_USERNAME) switch resp.StatusCode { case 403, 404: return FollowersRead, nil case 204: return FollowersWrite, nil default: return Invalid, err } } 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 NoAccess, nil case 200: break default: return Invalid, err } // Risk: Low - Medium // POST request to /user/gpg_keys // Payload is invalid, so it shouldn't actually post. _, resp, err = client.Users.CreateGPGKey(context.Background(), RANDOM_STRING) switch resp.StatusCode { case 403: return GpgKeysRead, nil case 422: return GpgKeysWrite, nil case 200, 201, 204: log.Fatal("This should never happen. We are creating a GPG key with an invalid payload.") return GpgKeysWrite, nil default: return Invalid, err } } 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 NoAccess, nil case 422: return GistsWrite, nil case 200, 201, 204: log.Fatal("This should never happen. We are creating a Gist with an invalid payload.") return GistsWrite, nil default: return Invalid, err } } 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 NoAccess, nil case 200: break default: return Invalid, err } // Risk: Low - Medium // POST request to /user/keys // Payload is invalid, so it shouldn't actually post. _, resp, err = client.Users.CreateKey(context.Background(), &gh.Key{}) switch resp.StatusCode { case 403: return GitKeysRead, nil case 422: return GitKeysWrite, nil case 200, 201, 204: log.Fatal("This should never happen. We are creating a key with an invalid payload.") return GitKeysWrite, nil default: return Invalid, err } } 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 Invalid, err } resp, err := client.Do(context.Background(), req, nil) switch resp.StatusCode { case 403: return NoAccess, nil case 200, 204: break default: return Invalid, err } // Risk: Low // PUT request to /user/interaction-limits // 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 Invalid, err } resp, err = client.Do(context.Background(), req, nil) switch resp.StatusCode { case 403: return LimitsRead, nil case 422: return LimitsWrite, nil case 200, 204: log.Fatal("This should never happen. We are setting interaction limits with an invalid payload.") return LimitsWrite, nil default: return Invalid, err } } 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 NoAccess, nil case 200: return PlanRead, nil default: return Invalid, err } } 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 Invalid, err } resp, err := client.Do(context.Background(), req, nil) switch resp.StatusCode { case 403, 404: return NoAccess, nil case 422: return ProfileWrite, nil case 200, 201, 204: log.Fatal("This should never happen. We are creating a social account with an invalid payload.") return ProfileWrite, nil default: return Invalid, err } } 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 NoAccess, nil case 200: break default: return Invalid, err } // Risk: Low - Medium // POST request to /user/ssh_signing_keys // Payload is invalid, so it shouldn't actually post. _, resp, err = client.Users.CreateSSHSigningKey(context.Background(), &gh.Key{}) switch resp.StatusCode { case 403: return SigningKeysRead, nil case 422: return SigningKeysWrite, nil case 200, 201, 204: log.Fatal("This should never happen. We are creating a SSH key with an invalid payload.") return SigningKeysWrite, nil default: return Invalid, err } } 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 StarringRead, nil } 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 WatchingRead, nil } func analyzeUserPermissions(client *gh.Client, user *gh.User) ([]Permission, error) { perms := []Permission{} for _, permFunc := range acctPermFuncMap { access, err := permFunc(client, user) if err != nil { // TODO: Log error. continue } perms = append(perms, access) } return perms, nil } 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 := common.GetAllGistsForUser(client) if err != nil { return nil, err } accessibleRepos := make([]*gh.Repository, 0) for _, repo := range allRepos { perm, err := getMetadataPermission(client, repo, Invalid) if err != nil { // TODO: Log error. continue } if perm != Invalid { accessibleRepos = append(accessibleRepos, repo) } } repoAccessMap := []Permission{} userAccessMap := []Permission{} if !shallowCheck { // Check our access perms, err := analyzeRepositoryPermissions(client, accessibleRepos) if err != nil { return nil, err } for _, perm := range perms { if perm != Invalid && perm != NoAccess { repoAccessMap = append(repoAccessMap, perm) } } perms, err = analyzeUserPermissions(client, meta.User) if err != nil { return nil, err } for _, perm := range perms { if perm != Invalid && perm != NoAccess { userAccessMap = append(userAccessMap, perm) } } } return &common.SecretInfo{ Metadata: meta, Repos: allRepos, Gists: allGists, AccessibleRepos: accessibleRepos, RepoAccessMap: repoAccessMap, UserAccessMap: userAccessMap, }, nil } 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") common.PrintGitHubRepos(info.AccessibleRepos) // Print out the access map perms, ok := info.RepoAccessMap.([]Permission) if !ok { panic("Repo Access Map is not of type Permission") } printFineGrainedPermissions(perms, cfg.ShowAll, true) } perms, ok := info.UserAccessMap.([]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 []Permission, showAll bool, repoPermissions bool) { permissionCount := 0 t := table.NewWriter() t.SetOutputMirror(os.Stdout) t.AppendHeader(table.Row{"Permission Type", "Permission" /* Add more column headers if needed */}) for _, perm := range accessMap { permStr, _ := perm.ToString() if perm == Invalid { // don't change permissionCount } else { permissionCount++ } if !showAll && perm == Invalid { continue } else { k, v := permissionFormatter(permStr, perm) t.AppendRow([]any{k, v}) } } var permissionType string if repoPermissions { permissionType = "Repositor(ies)" } else { permissionType = "User Account" } if permissionCount == 0 && !showAll { color.Red("No Permissions Found for the %v above\n\n", permissionType) return } else if permissionCount == 0 { color.Red("Found No Permissions for the %v above\n", permissionType) } else { color.Green(fmt.Sprintf("Found %v Permission(s) for the %v above\n", permissionCount, permissionType)) } t.Render() fmt.Print("\n\n") }