trufflehog/pkg/analyzer/analyzers/github/finegrained/finegrained.go
2024-10-10 06:16:40 -07:00

1372 lines
44 KiB
Go

//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/v66/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")
}