//go:generate generate_permissions permissions.yaml permissions.go planetscale package planetscale import ( "bytes" _ "embed" "encoding/json" "errors" "fmt" "io" "net/http" "os" "strings" "github.com/fatih/color" "github.com/jedib0t/go-pretty/v6/table" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" "github.com/trufflesecurity/trufflehog/v3/pkg/context" ) var _ analyzers.Analyzer = (*Analyzer)(nil) type Analyzer struct { Cfg *config.Config } func (Analyzer) Type() analyzers.AnalyzerType { return analyzers.AnalyzerTypePlanetScale } func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) { id, ok := credInfo["id"] if !ok { return nil, analyzers.NewAnalysisError(a.Type().String(), analyzers.OperationValidateCredentials, analyzers.ServiceConfig, "", errors.New("missing id in credInfo")) } key, ok := credInfo["token"] if !ok { return nil, analyzers.NewAnalysisError(a.Type().String(), analyzers.OperationValidateCredentials, analyzers.ServiceConfig, "", errors.New("missing key in credInfo")) } info, err := AnalyzePermissions(a.Cfg, id, key) if err != nil { return nil, analyzers.NewAnalysisError(a.Type().String(), analyzers.OperationAnalyzePermissions, analyzers.ServiceAPI, "", err) } return secretInfoToAnalyzerResult(info), nil } func secretInfoToAnalyzerResult(info *SecretInfo) *analyzers.AnalyzerResult { if info == nil { return nil } result := analyzers.AnalyzerResult{ AnalyzerType: analyzers.AnalyzerTypePlanetScale, Metadata: nil, Bindings: make([]analyzers.Binding, 0), } resource := analyzers.Resource{ Name: info.Organization.Name, FullyQualifiedName: "planetscale.com/organization/" + info.Organization.Id, Type: "Organization", } for _, permission := range info.OrgPermissions { result.Bindings = append(result.Bindings, analyzers.Binding{ Resource: resource, Permission: analyzers.Permission{ Value: permission, }, }) } for db, permissions := range info.DBPermissions { dbResource := analyzers.Resource{ Name: db.Name, FullyQualifiedName: "planetscale.com/database/" + db.Id, Type: "Database", Parent: &resource, } for _, permission := range permissions { result.Bindings = append(result.Bindings, analyzers.Binding{ Resource: dbResource, Permission: analyzers.Permission{ Value: permission, }, }) } } return &result } //go:embed scopes.json var scopesConfig []byte type HttpStatusTest struct { Endpoint string `json:"endpoint"` Method string `json:"method"` Payload interface{} `json:"payload"` ValidStatuses []int `json:"valid_status_code"` InvalidStatuses []int `json:"invalid_status_code"` } func StatusContains(status int, vals []int) bool { for _, v := range vals { if status == v { return true } } return false } func (h *HttpStatusTest) RunTest(cfg *config.Config, headers map[string]string, args ...any) (bool, error) { // If body data, marshal to JSON var data io.Reader if h.Payload != nil { jsonData, err := json.Marshal(h.Payload) if err != nil { return false, err } data = bytes.NewBuffer(jsonData) } // Create new HTTP request client := analyzers.NewAnalyzeClient(cfg) req, err := http.NewRequest(h.Method, fmt.Sprintf(h.Endpoint, args...), data) if err != nil { return false, err } // Add custom headers if provided for key, value := range headers { req.Header.Set(key, value) } req.Header.Add("Content-Type", "application/json") // Execute HTTP Request resp, err := client.Do(req) if err != nil { return false, err } defer func() { _ = resp.Body.Close() }() // Check response status code switch { case StatusContains(resp.StatusCode, h.ValidStatuses): return true, nil case StatusContains(resp.StatusCode, h.InvalidStatuses): return false, nil default: return false, errors.New("error checking response status code") } } type Scopes struct { OrganizationScopes []Scope `json:"organization_scopes"` OAuthApplicationScopes []Scope `json:"oauth_application_scopes"` DatabaseScopes []Scope `json:"database_scopes"` DeployRequestScopes []Scope `json:"deploy_request_scopes"` BranchScopes []BranchScope `json:"branch_scopes"` BackupScopes []BranchScope `json:"backup_scopes"` } type Scope struct { Name string `json:"name"` HttpTest HttpStatusTest `json:"test"` } type BranchScope struct { Scope Production bool `json:"production"` } func readInScopes() (*Scopes, error) { var scopes Scopes if err := json.Unmarshal(scopesConfig, &scopes); err != nil { return nil, err } return &scopes, nil } func checkPermissions(cfg *config.Config, scopes []Scope, id, key string, args ...any) ([]string, error) { permissions := make([]string, 0) for _, scope := range scopes { status, err := scope.HttpTest.RunTest(cfg, map[string]string{"Authorization": fmt.Sprintf("%s:%s", id, key)}, args...) if err != nil { return nil, fmt.Errorf("running test: %w", err) } if status { permissions = append(permissions, scope.Name) } } return permissions, nil } func checkBranchPermissions(cfg *config.Config, scopes []BranchScope, id, key, organization, db, branch string, production bool) ([]string, error) { permissions := make([]string, 0) for _, scope := range scopes { // check if scope is for production or non production branch if production != scope.Production { continue } status, err := scope.HttpTest.RunTest(cfg, map[string]string{"Authorization": fmt.Sprintf("%s:%s", id, key)}, organization, db, branch) if err != nil { return nil, fmt.Errorf("running test: %w", err) } if status { permissions = append(permissions, scope.Name) } } return permissions, nil } func checkBackupPermissions(cfg *config.Config, scopes []BranchScope, id, key, organization, db, backupId string, production bool) ([]string, error) { permissions := make([]string, 0) for _, scope := range scopes { // check if scope is for production or non production branch if production != scope.Production { continue } scope.HttpTest.Payload = map[string]string{"backup_id": backupId} status, err := scope.HttpTest.RunTest(cfg, map[string]string{"Authorization": fmt.Sprintf("%s:%s", id, key)}, organization, db) if err != nil { return nil, fmt.Errorf("running test: %w", err) } if status { permissions = append(permissions, scope.Name) } } return permissions, nil } type SecretInfo struct { Organization organization OrgPermissions []string DBPermissions map[Database][]string UnverifiedPermissions []string } func AnalyzeAndPrintPermissions(cfg *config.Config, id, token string) { info, err := AnalyzePermissions(cfg, id, token) if err != nil { color.Red("[x] Error : %s", err.Error()) return } color.Green("[!] Valid PlanetScale credentials\n\n") color.Green("[i] Organization: %s\n\n", info.Organization.Name) printOrganizationPermissions(info.OrgPermissions) if len(info.DBPermissions) > 0 { printDatabasePermissions(info.DBPermissions) } printUnverifiedPermissions(info.UnverifiedPermissions) } func AnalyzePermissions(cfg *config.Config, id, token string) (*SecretInfo, error) { var info = &SecretInfo{} org, err := getOrganization(cfg, id, token) if err != nil { return nil, err } info.Organization = *org scopes, err := readInScopes() if err != nil { return nil, fmt.Errorf("reading in scopes: %w", err) } // organization permissions orgPermissions, err := getOrganizationPermissions(cfg, scopes, id, token, org.Name) if err != nil { return nil, err } info.OrgPermissions = orgPermissions // database permissions dbPermissions, err := getDatabasePermissions(cfg, scopes, id, token, org.Name) if err != nil { return nil, err } info.DBPermissions = dbPermissions // These are permissions that can not be verified, // either due to no endpoint available that specifically requires the permission // or there does not exist a way to verify these permissions without changing the state of the system (mostly DELETE permissions) info.UnverifiedPermissions = []string{ PermissionStrings[ReadComment], PermissionStrings[CreateComment], PermissionStrings[ApproveDeployRequest], PermissionStrings[DeleteDatabases], PermissionStrings[DeleteDatabase], PermissionStrings[DeleteOauthTokens], PermissionStrings[DeleteBranch], PermissionStrings[DeleteBranchPassword], PermissionStrings[DeleteProductionBranch], PermissionStrings[DeleteProductionBranchPassword], PermissionStrings[DeleteBackups], PermissionStrings[DeleteProductionBranchBackups], PermissionStrings[WriteBackups], } return info, nil } type organization struct { Id string `json:"id"` Name string `json:"name"` } type organizationJSON struct { Data []organization `json:"data"` } func getOrganization(cfg *config.Config, id, key string) (*organization, error) { url := "https://api.planetscale.com/v1/organizations" var organizationJSON organizationJSON err := sendGetRequest(cfg, id, key, url, &organizationJSON) if err != nil { return nil, err } if len(organizationJSON.Data) == 0 { return nil, errors.New("invalid api credentials") } return &organizationJSON.Data[0], nil } func getOrganizationPermissions(cfg *config.Config, scopes *Scopes, id, token, orgName string) ([]string, error) { organizationPermissions, err := checkPermissions(cfg, scopes.OrganizationScopes, id, token, orgName) if err != nil { return nil, err } oauthPermissions, err := getOAuthApplicationPermissions(cfg, scopes.OAuthApplicationScopes, id, token, orgName) if err != nil { return nil, err } organizationPermissions = append(organizationPermissions, oauthPermissions...) return organizationPermissions, nil } func getOAuthApplicationPermissions(cfg *config.Config, scopes []Scope, id, key, organization string) ([]string, error) { oauthApplicationId, err := getOAuthApplicationId(cfg, id, key, organization) if err != nil { return nil, err } if oauthApplicationId != "" { oauthPermissions, err := checkPermissions(cfg, scopes, id, key, organization, oauthApplicationId) if err != nil { return nil, err } return oauthPermissions, nil } return nil, nil } type oauthApplicationJSON struct { Data []struct { Id string `json:"id"` } } func getOAuthApplicationId(cfg *config.Config, id, key, organization string) (string, error) { url := fmt.Sprintf("https://api.planetscale.com/v1/organizations/%s/oauth-applications", organization) var oauthApplicationJSON oauthApplicationJSON err := sendGetRequest(cfg, id, key, url, &oauthApplicationJSON) if err != nil { return "", err } if len(oauthApplicationJSON.Data) > 0 { return oauthApplicationJSON.Data[0].Id, nil } return "", nil // no oauth application found } func getDatabasePermissions(cfg *config.Config, scopes *Scopes, id, token, orgName string) (map[Database][]string, error) { databases, err := getDatabases(cfg, id, token, orgName) if err != nil { return nil, err } dbPermissionsMap := make(map[Database][]string) for _, database := range databases { dbPermissions, err := checkPermissions(cfg, scopes.DatabaseScopes, id, token, orgName, database.Name) if err != nil { return nil, err } dbPermissionsMap[database] = dbPermissions branchPermissions, err := getBranchPermissions(cfg, scopes, id, token, orgName, database.Name) if err != nil { return nil, err } dbPermissionsMap[database] = append(dbPermissionsMap[database], branchPermissions...) } return dbPermissionsMap, nil } func getBranchPermissions(cfg *config.Config, scopes *Scopes, id, token, orgName, dbName string) ([]string, error) { branches, err := getDbBranches(cfg, id, token, orgName, dbName) if err != nil { return nil, err } // get permissions for prod and non prod branches prodDone, nonProdDone := false, false allBranchPermissions := make([]string, 0) for _, branch := range branches { // check if we have already checked permissions for prod or non prod branches if (prodDone && branch.Production) || (nonProdDone && !branch.Production) { continue } if branch.Production { prodDone = true } else { nonProdDone = true } branchPermissions, err := checkBranchPermissions(cfg, scopes.BranchScopes, id, token, orgName, dbName, branch.Name, branch.Production) if err != nil { return nil, err } allBranchPermissions = append(allBranchPermissions, branchPermissions...) backupId, err := getBackupId(cfg, id, token, orgName, dbName, branch.Name) if err != nil { return nil, err } if backupId != "" { backupPermissions, err := checkBackupPermissions(cfg, scopes.BackupScopes, id, token, orgName, dbName, backupId, branch.Production) if err != nil { return nil, err } allBranchPermissions = append(allBranchPermissions, backupPermissions...) } if prodDone && nonProdDone { break } } return allBranchPermissions, err } type Database struct { Id string `json:"id"` Name string `json:"name"` } type databasesJSON struct { Data []Database `json:"data"` NextPageUrl string `json:"next_page_url"` } func getDatabases(cfg *config.Config, id, key, organization string) ([]Database, error) { url := fmt.Sprintf("https://api.planetscale.com/v1/organizations/%s/databases", organization) databases := make([]Database, 0) // loop for pagination for url != "" { var databasesResponse databasesJSON err := sendGetRequest(cfg, id, key, url, &databasesResponse) if err != nil { return nil, err } databases = append(databases, databasesResponse.Data...) url = databasesResponse.NextPageUrl } return databases, nil } type Branch struct { Id string `json:"id"` Name string `json:"name"` Production bool `json:"production"` } type branchesJSON struct { Data []Branch `json:"data"` } func getDbBranches(cfg *config.Config, id, key, organization, db string) ([]Branch, error) { url := fmt.Sprintf("https://api.planetscale.com/v1/organizations/%s/databases/%s/branches", organization, db) var branchesResponse branchesJSON err := sendGetRequest(cfg, id, key, url, &branchesResponse) if err != nil { return nil, err } return branchesResponse.Data, nil } type backupsJson struct { Data []struct { Id string `json:"id"` } } func getBackupId(cfg *config.Config, id, key, organization, db, branch string) (string, error) { url := fmt.Sprintf("https://api.planetscale.com/v1/organizations/%s/databases/%s/branches/%s/backups", organization, db, branch) var backupsResponse backupsJson err := sendGetRequest(cfg, id, key, url, &backupsResponse) if err != nil { return "", err } if len(backupsResponse.Data) > 0 { return backupsResponse.Data[0].Id, nil } return "", nil // no backups found } func sendGetRequest(cfg *config.Config, id, key, url string, responseObj interface{}) error { client := analyzers.NewAnalyzeClient(cfg) req, err := http.NewRequest("GET", url, nil) if err != nil { return err } req.Header.Set("Authorization", fmt.Sprintf("%s:%s", id, key)) // Execute HTTP Request resp, err := client.Do(req) if err != nil { return err } defer func() { _ = resp.Body.Close() }() // Check response status code switch resp.StatusCode { case http.StatusOK: // Decode response body err = json.NewDecoder(resp.Body).Decode(&responseObj) if err != nil { return err } return nil // response successfully decoded case http.StatusForbidden: return nil // no permission default: return fmt.Errorf("unexpected status code %d", resp.StatusCode) } } func printOrganizationPermissions(permissions []string) { color.Yellow("[i] Organization Permissions:") if len(permissions) == 0 { color.Yellow("No permissions found") } else { t := table.NewWriter() t.SetOutputMirror(os.Stdout) t.AppendHeader(table.Row{"Permission"}) for _, permission := range permissions { t.AppendRow(table.Row{color.GreenString(permission)}) } t.Render() } } func printDatabasePermissions(permissions map[Database][]string) { color.Yellow("[i] Database Permissions:") t := table.NewWriter() t.SetOutputMirror(os.Stdout) t.AppendHeader(table.Row{"Database", "Permission"}) for database, dbPermissions := range permissions { t.AppendRow(table.Row{database.Name, color.GreenString(strings.Join(dbPermissions, ", "))}) } t.Render() } func printUnverifiedPermissions(permissions []string) { color.Yellow("[i] Unverified Permissions:") t := table.NewWriter() t.SetOutputMirror(os.Stdout) t.AppendHeader(table.Row{"Permission"}) for _, permission := range permissions { t.AppendRow(table.Row{color.YellowString(permission)}) } t.Render() }