From 590ba66f6bc77c099616fb7e8181db7e36084711 Mon Sep 17 00:00:00 2001 From: Abdul Basit Date: Tue, 4 Mar 2025 02:19:21 +0500 Subject: [PATCH] [Feat] Planetscale Analyzer (#3928) * implemented planetscale anlayzer * linked detector with analyzer for planetscale. override input form for TUI. refactor some variable name for clarrification. * clean up scopes for planetscale. * add database and other scopes * add remaining scopes and refactoring * updated the analyzer type value in expected output of notion test. * updated the expected output for analyzers updated the go-pretty library in planetscale. added extra information from results to be ignore like AnalysisInfo, ExtraData etc. * updated expected output of test. --- pkg/analyzer/analyzers/analyzers.go | 2 + .../planetscale/expected_output.json | 1 + .../analyzers/planetscale/permissions.go | 216 +++++++ .../analyzers/planetscale/permissions.yaml | 33 + .../analyzers/planetscale/planetscale.go | 598 ++++++++++++++++++ .../analyzers/planetscale/planetscale_test.go | 102 +++ .../analyzers/planetscale/scopes.json | 203 ++++++ pkg/analyzer/cli.go | 3 + pkg/detectors/planetscale/planetscale.go | 7 +- .../planetscale_integration_test.go | 2 +- pkg/tui/pages/analyze_form/analyze_form.go | 11 + 11 files changed, 1176 insertions(+), 2 deletions(-) create mode 100644 pkg/analyzer/analyzers/planetscale/expected_output.json create mode 100644 pkg/analyzer/analyzers/planetscale/permissions.go create mode 100644 pkg/analyzer/analyzers/planetscale/permissions.yaml create mode 100644 pkg/analyzer/analyzers/planetscale/planetscale.go create mode 100644 pkg/analyzer/analyzers/planetscale/planetscale_test.go create mode 100644 pkg/analyzer/analyzers/planetscale/scopes.json diff --git a/pkg/analyzer/analyzers/analyzers.go b/pkg/analyzer/analyzers/analyzers.go index 809058a2c..7c8c4fbc2 100644 --- a/pkg/analyzer/analyzers/analyzers.go +++ b/pkg/analyzer/analyzers/analyzers.go @@ -87,6 +87,7 @@ const ( AnalyzerTypeNotion AnalyzerTypeAirtable AnalyzerTypeDigitalOcean + AnalyzerTypePlanetScale // Add new items here with AnalyzerType prefix ) @@ -120,6 +121,7 @@ var analyzerTypeStrings = map[AnalyzerType]string{ AnalyzerTypeTwilio: "Twilio", AnalyzerTypePrivateKey: "PrivateKey", AnalyzerTypeNotion: "Notion", + AnalyzerTypePlanetScale: "PlanetScale", // Add new mappings here } diff --git a/pkg/analyzer/analyzers/planetscale/expected_output.json b/pkg/analyzer/analyzers/planetscale/expected_output.json new file mode 100644 index 000000000..afece58a2 --- /dev/null +++ b/pkg/analyzer/analyzers/planetscale/expected_output.json @@ -0,0 +1 @@ +{"AnalyzerType":28,"Bindings":[{"Resource":{"Name":"detector-db","FullyQualifiedName":"planetscale.com/database/9p2lzxigxod0","Type":"Database","Metadata":null,"Parent":{"Name":"detectors","FullyQualifiedName":"planetscale.com/organization/hn31ztkm9u15","Type":"Organization","Metadata":null,"Parent":null}},"Permission":{"Value":"connect_branch","Parent":null}},{"Resource":{"Name":"detector-db","FullyQualifiedName":"planetscale.com/database/9p2lzxigxod0","Type":"Database","Metadata":null,"Parent":{"Name":"detectors","FullyQualifiedName":"planetscale.com/organization/hn31ztkm9u15","Type":"Organization","Metadata":null,"Parent":null}},"Permission":{"Value":"connect_production_branch","Parent":null}},{"Resource":{"Name":"detector-db","FullyQualifiedName":"planetscale.com/database/9p2lzxigxod0","Type":"Database","Metadata":null,"Parent":{"Name":"detectors","FullyQualifiedName":"planetscale.com/organization/hn31ztkm9u15","Type":"Organization","Metadata":null,"Parent":null}},"Permission":{"Value":"create_branch","Parent":null}},{"Resource":{"Name":"detector-db","FullyQualifiedName":"planetscale.com/database/9p2lzxigxod0","Type":"Database","Metadata":null,"Parent":{"Name":"detectors","FullyQualifiedName":"planetscale.com/organization/hn31ztkm9u15","Type":"Organization","Metadata":null,"Parent":null}},"Permission":{"Value":"create_deploy_request","Parent":null}},{"Resource":{"Name":"detector-db","FullyQualifiedName":"planetscale.com/database/9p2lzxigxod0","Type":"Database","Metadata":null,"Parent":{"Name":"detectors","FullyQualifiedName":"planetscale.com/organization/hn31ztkm9u15","Type":"Organization","Metadata":null,"Parent":null}},"Permission":{"Value":"read_backups","Parent":null}},{"Resource":{"Name":"detector-db","FullyQualifiedName":"planetscale.com/database/9p2lzxigxod0","Type":"Database","Metadata":null,"Parent":{"Name":"detectors","FullyQualifiedName":"planetscale.com/organization/hn31ztkm9u15","Type":"Organization","Metadata":null,"Parent":null}},"Permission":{"Value":"read_branch","Parent":null}},{"Resource":{"Name":"detector-db","FullyQualifiedName":"planetscale.com/database/9p2lzxigxod0","Type":"Database","Metadata":null,"Parent":{"Name":"detectors","FullyQualifiedName":"planetscale.com/organization/hn31ztkm9u15","Type":"Organization","Metadata":null,"Parent":null}},"Permission":{"Value":"read_database","Parent":null}},{"Resource":{"Name":"detector-db","FullyQualifiedName":"planetscale.com/database/9p2lzxigxod0","Type":"Database","Metadata":null,"Parent":{"Name":"detectors","FullyQualifiedName":"planetscale.com/organization/hn31ztkm9u15","Type":"Organization","Metadata":null,"Parent":null}},"Permission":{"Value":"read_deploy_request","Parent":null}},{"Resource":{"Name":"detector-db","FullyQualifiedName":"planetscale.com/database/9p2lzxigxod0","Type":"Database","Metadata":null,"Parent":{"Name":"detectors","FullyQualifiedName":"planetscale.com/organization/hn31ztkm9u15","Type":"Organization","Metadata":null,"Parent":null}},"Permission":{"Value":"restore_backup","Parent":null}},{"Resource":{"Name":"detector-db","FullyQualifiedName":"planetscale.com/database/9p2lzxigxod0","Type":"Database","Metadata":null,"Parent":{"Name":"detectors","FullyQualifiedName":"planetscale.com/organization/hn31ztkm9u15","Type":"Organization","Metadata":null,"Parent":null}},"Permission":{"Value":"restore_production_branch_backup","Parent":null}},{"Resource":{"Name":"detector-db","FullyQualifiedName":"planetscale.com/database/9p2lzxigxod0","Type":"Database","Metadata":null,"Parent":{"Name":"detectors","FullyQualifiedName":"planetscale.com/organization/hn31ztkm9u15","Type":"Organization","Metadata":null,"Parent":null}},"Permission":{"Value":"write_database","Parent":null}},{"Resource":{"Name":"detectors","FullyQualifiedName":"planetscale.com/organization/hn31ztkm9u15","Type":"Organization","Metadata":null,"Parent":null},"Permission":{"Value":"create_databases","Parent":null}},{"Resource":{"Name":"detectors","FullyQualifiedName":"planetscale.com/organization/hn31ztkm9u15","Type":"Organization","Metadata":null,"Parent":null},"Permission":{"Value":"read_audit_logs","Parent":null}},{"Resource":{"Name":"detectors","FullyQualifiedName":"planetscale.com/organization/hn31ztkm9u15","Type":"Organization","Metadata":null,"Parent":null},"Permission":{"Value":"read_databases","Parent":null}},{"Resource":{"Name":"detectors","FullyQualifiedName":"planetscale.com/organization/hn31ztkm9u15","Type":"Organization","Metadata":null,"Parent":null},"Permission":{"Value":"read_invoices","Parent":null}},{"Resource":{"Name":"detectors","FullyQualifiedName":"planetscale.com/organization/hn31ztkm9u15","Type":"Organization","Metadata":null,"Parent":null},"Permission":{"Value":"read_oauth_applications","Parent":null}},{"Resource":{"Name":"detectors","FullyQualifiedName":"planetscale.com/organization/hn31ztkm9u15","Type":"Organization","Metadata":null,"Parent":null},"Permission":{"Value":"read_oauth_tokens","Parent":null}},{"Resource":{"Name":"detectors","FullyQualifiedName":"planetscale.com/organization/hn31ztkm9u15","Type":"Organization","Metadata":null,"Parent":null},"Permission":{"Value":"read_organization","Parent":null}},{"Resource":{"Name":"detectors","FullyQualifiedName":"planetscale.com/organization/hn31ztkm9u15","Type":"Organization","Metadata":null,"Parent":null},"Permission":{"Value":"write_oauth_tokens","Parent":null}}],"UnboundedResources":null,"Metadata":null} \ No newline at end of file diff --git a/pkg/analyzer/analyzers/planetscale/permissions.go b/pkg/analyzer/analyzers/planetscale/permissions.go new file mode 100644 index 000000000..f071af287 --- /dev/null +++ b/pkg/analyzer/analyzers/planetscale/permissions.go @@ -0,0 +1,216 @@ +// Code generated by go generate; DO NOT EDIT. +package planetscale + +import "errors" + +type Permission int + +const ( + Invalid Permission = iota + ReadOrganization Permission = iota + ReadInvoices Permission = iota + ReadDatabases Permission = iota + ReadAuditLogs Permission = iota + CreateDatabases Permission = iota + DeleteDatabases Permission = iota + ReadOauthApplications Permission = iota + WriteOauthTokens Permission = iota + ReadOauthTokens Permission = iota + DeleteOauthTokens Permission = iota + ReadDatabase Permission = iota + WriteDatabase Permission = iota + DeleteDatabase Permission = iota + ReadBranch Permission = iota + CreateBranch Permission = iota + DeleteBranch Permission = iota + DeleteBranchPassword Permission = iota + DeleteProductionBranch Permission = iota + DeleteProductionBranchPassword Permission = iota + ReadDeployRequest Permission = iota + CreateDeployRequest Permission = iota + ApproveDeployRequest Permission = iota + ConnectBranch Permission = iota + ConnectProductionBranch Permission = iota + ReadComment Permission = iota + CreateComment Permission = iota + RestoreBackup Permission = iota + WriteBackups Permission = iota + ReadBackups Permission = iota + DeleteBackups Permission = iota + RestoreProductionBranchBackup Permission = iota + DeleteProductionBranchBackups Permission = iota +) + +var ( + PermissionStrings = map[Permission]string{ + ReadOrganization: "read_organization", + ReadInvoices: "read_invoices", + ReadDatabases: "read_databases", + ReadAuditLogs: "read_audit_logs", + CreateDatabases: "create_databases", + DeleteDatabases: "delete_databases", + ReadOauthApplications: "read_oauth_applications", + WriteOauthTokens: "write_oauth_tokens", + ReadOauthTokens: "read_oauth_tokens", + DeleteOauthTokens: "delete_oauth_tokens", + ReadDatabase: "read_database", + WriteDatabase: "write_database", + DeleteDatabase: "delete_database", + ReadBranch: "read_branch", + CreateBranch: "create_branch", + DeleteBranch: "delete_branch", + DeleteBranchPassword: "delete_branch_password", + DeleteProductionBranch: "delete_production_branch", + DeleteProductionBranchPassword: "delete_production_branch_password", + ReadDeployRequest: "read_deploy_request", + CreateDeployRequest: "create_deploy_request", + ApproveDeployRequest: "approve_deploy_request", + ConnectBranch: "connect_branch", + ConnectProductionBranch: "connect_production_branch", + ReadComment: "read_comment", + CreateComment: "create_comment", + RestoreBackup: "restore_backup", + WriteBackups: "write_backups", + ReadBackups: "read_backups", + DeleteBackups: "delete_backups", + RestoreProductionBranchBackup: "restore_production_branch_backup", + DeleteProductionBranchBackups: "delete_production_branch_backups", + } + + StringToPermission = map[string]Permission{ + "read_organization": ReadOrganization, + "read_invoices": ReadInvoices, + "read_databases": ReadDatabases, + "read_audit_logs": ReadAuditLogs, + "create_databases": CreateDatabases, + "delete_databases": DeleteDatabases, + "read_oauth_applications": ReadOauthApplications, + "write_oauth_tokens": WriteOauthTokens, + "read_oauth_tokens": ReadOauthTokens, + "delete_oauth_tokens": DeleteOauthTokens, + "read_database": ReadDatabase, + "write_database": WriteDatabase, + "delete_database": DeleteDatabase, + "read_branch": ReadBranch, + "create_branch": CreateBranch, + "delete_branch": DeleteBranch, + "delete_branch_password": DeleteBranchPassword, + "delete_production_branch": DeleteProductionBranch, + "delete_production_branch_password": DeleteProductionBranchPassword, + "read_deploy_request": ReadDeployRequest, + "create_deploy_request": CreateDeployRequest, + "approve_deploy_request": ApproveDeployRequest, + "connect_branch": ConnectBranch, + "connect_production_branch": ConnectProductionBranch, + "read_comment": ReadComment, + "create_comment": CreateComment, + "restore_backup": RestoreBackup, + "write_backups": WriteBackups, + "read_backups": ReadBackups, + "delete_backups": DeleteBackups, + "restore_production_branch_backup": RestoreProductionBranchBackup, + "delete_production_branch_backups": DeleteProductionBranchBackups, + } + + PermissionIDs = map[Permission]int{ + ReadOrganization: 1, + ReadInvoices: 2, + ReadDatabases: 3, + ReadAuditLogs: 4, + CreateDatabases: 5, + DeleteDatabases: 6, + ReadOauthApplications: 7, + WriteOauthTokens: 8, + ReadOauthTokens: 9, + DeleteOauthTokens: 10, + ReadDatabase: 11, + WriteDatabase: 12, + DeleteDatabase: 13, + ReadBranch: 14, + CreateBranch: 15, + DeleteBranch: 16, + DeleteBranchPassword: 17, + DeleteProductionBranch: 18, + DeleteProductionBranchPassword: 19, + ReadDeployRequest: 20, + CreateDeployRequest: 21, + ApproveDeployRequest: 22, + ConnectBranch: 23, + ConnectProductionBranch: 24, + ReadComment: 25, + CreateComment: 26, + RestoreBackup: 27, + WriteBackups: 28, + ReadBackups: 29, + DeleteBackups: 30, + RestoreProductionBranchBackup: 31, + DeleteProductionBranchBackups: 32, + } + + IdToPermission = map[int]Permission{ + 1: ReadOrganization, + 2: ReadInvoices, + 3: ReadDatabases, + 4: ReadAuditLogs, + 5: CreateDatabases, + 6: DeleteDatabases, + 7: ReadOauthApplications, + 8: WriteOauthTokens, + 9: ReadOauthTokens, + 10: DeleteOauthTokens, + 11: ReadDatabase, + 12: WriteDatabase, + 13: DeleteDatabase, + 14: ReadBranch, + 15: CreateBranch, + 16: DeleteBranch, + 17: DeleteBranchPassword, + 18: DeleteProductionBranch, + 19: DeleteProductionBranchPassword, + 20: ReadDeployRequest, + 21: CreateDeployRequest, + 22: ApproveDeployRequest, + 23: ConnectBranch, + 24: ConnectProductionBranch, + 25: ReadComment, + 26: CreateComment, + 27: RestoreBackup, + 28: WriteBackups, + 29: ReadBackups, + 30: DeleteBackups, + 31: RestoreProductionBranchBackup, + 32: DeleteProductionBranchBackups, + } +) + +// ToString converts a Permission enum to its string representation +func (p Permission) ToString() (string, error) { + if str, ok := PermissionStrings[p]; ok { + return str, nil + } + return "", errors.New("invalid permission") +} + +// ToID converts a Permission enum to its ID +func (p Permission) ToID() (int, error) { + if id, ok := PermissionIDs[p]; ok { + return id, nil + } + return 0, errors.New("invalid permission") +} + +// PermissionFromString converts a string representation to its Permission enum +func PermissionFromString(s string) (Permission, error) { + if p, ok := StringToPermission[s]; ok { + return p, nil + } + return 0, errors.New("invalid permission string") +} + +// PermissionFromID converts an ID to its Permission enum +func PermissionFromID(id int) (Permission, error) { + if p, ok := IdToPermission[id]; ok { + return p, nil + } + return 0, errors.New("invalid permission ID") +} diff --git a/pkg/analyzer/analyzers/planetscale/permissions.yaml b/pkg/analyzer/analyzers/planetscale/permissions.yaml new file mode 100644 index 000000000..45a0b1fc2 --- /dev/null +++ b/pkg/analyzer/analyzers/planetscale/permissions.yaml @@ -0,0 +1,33 @@ +permissions: + - read_organization + - read_invoices + - read_databases + - read_audit_logs + - create_databases + - delete_databases + - read_oauth_applications + - write_oauth_tokens + - read_oauth_tokens + - delete_oauth_tokens + - read_database + - write_database + - delete_database + - read_branch + - create_branch + - delete_branch + - delete_branch_password + - delete_production_branch + - delete_production_branch_password + - read_deploy_request + - create_deploy_request + - approve_deploy_request + - connect_branch + - connect_production_branch + - read_comment + - create_comment + - restore_backup + - write_backups + - read_backups + - delete_backups + - restore_production_branch_backup + - delete_production_branch_backups \ No newline at end of file diff --git a/pkg/analyzer/analyzers/planetscale/planetscale.go b/pkg/analyzer/analyzers/planetscale/planetscale.go new file mode 100644 index 000000000..412fc753a --- /dev/null +++ b/pkg/analyzer/analyzers/planetscale/planetscale.go @@ -0,0 +1,598 @@ +//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, errors.New("missing id in credInfo") + } + key, ok := credInfo["token"] + if !ok { + return nil, errors.New("missing key in credInfo") + } + info, err := AnalyzePermissions(a.Cfg, id, key) + if err != nil { + return nil, 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 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 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() +} diff --git a/pkg/analyzer/analyzers/planetscale/planetscale_test.go b/pkg/analyzer/analyzers/planetscale/planetscale_test.go new file mode 100644 index 000000000..22da2ce64 --- /dev/null +++ b/pkg/analyzer/analyzers/planetscale/planetscale_test.go @@ -0,0 +1,102 @@ +package planetscale + +import ( + _ "embed" + "encoding/json" + "sort" + "testing" + "time" + + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" + "github.com/trufflesecurity/trufflehog/v3/pkg/common" + "github.com/trufflesecurity/trufflehog/v3/pkg/context" +) + +//go:embed expected_output.json +var expectedOutput []byte + +func TestAnalyzer_Analyze(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) + defer cancel() + testSecrets, err := common.GetSecret(ctx, "trufflehog-testing", "detectors5") + if err != nil { + t.Fatalf("could not get test secrets from GCP: %s", err) + } + + tests := []struct { + name string + id string + token string + want string // JSON string + wantErr bool + }{ + { + name: "valid planetscale id and key", + id: testSecrets.MustGetField("PLANET_SCALE_ID"), + token: testSecrets.MustGetField("PLANET_SCALE_TOKEN"), + want: string(expectedOutput), + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := Analyzer{Cfg: &config.Config{}} + got, err := a.Analyze(ctx, map[string]string{"id": tt.id, "token": tt.token}) + if (err != nil) != tt.wantErr { + t.Errorf("Analyzer.Analyze() error = %v, wantErr %v", err, tt.wantErr) + return + } + + // bindings need to be in the same order to be comparable + sortBindings(got.Bindings) + + // Marshal the actual result to JSON + gotJSON, err := json.Marshal(got) + if err != nil { + t.Fatalf("could not marshal got to JSON: %s", err) + } + + // Parse the expected JSON string + var wantObj analyzers.AnalyzerResult + if err := json.Unmarshal([]byte(tt.want), &wantObj); err != nil { + t.Fatalf("could not unmarshal want JSON string: %s", err) + } + + // bindings need to be in the same order to be comparable + sortBindings(wantObj.Bindings) + + // Marshal the expected result to JSON (to normalize) + wantJSON, err := json.Marshal(wantObj) + if err != nil { + t.Fatalf("could not marshal want to JSON: %s", err) + } + + // Compare the JSON strings + if string(gotJSON) != string(wantJSON) { + // Pretty-print both JSON strings for easier comparison + var gotIndented, wantIndented []byte + gotIndented, err = json.MarshalIndent(got, "", " ") + if err != nil { + t.Fatalf("could not marshal got to indented JSON: %s", err) + } + wantIndented, err = json.MarshalIndent(wantObj, "", " ") + if err != nil { + t.Fatalf("could not marshal want to indented JSON: %s", err) + } + t.Errorf("Analyzer.Analyze() = %s, want %s", gotIndented, wantIndented) + } + }) + } +} + +// Helper function to sort bindings +func sortBindings(bindings []analyzers.Binding) { + sort.SliceStable(bindings, func(i, j int) bool { + if bindings[i].Resource.Name == bindings[j].Resource.Name { + return bindings[i].Permission.Value < bindings[j].Permission.Value + } + return bindings[i].Resource.Name < bindings[j].Resource.Name + }) +} diff --git a/pkg/analyzer/analyzers/planetscale/scopes.json b/pkg/analyzer/analyzers/planetscale/scopes.json new file mode 100644 index 000000000..c17255a8b --- /dev/null +++ b/pkg/analyzer/analyzers/planetscale/scopes.json @@ -0,0 +1,203 @@ +{ + "organization_scopes": [ + { + "name": "read_organization", + "test": { + "endpoint": "https://api.planetscale.com/v1/organizations/%s", + "method": "GET", + "valid_status_code": [200], + "invalid_status_code": [403] + } + }, + { + "name": "read_invoices", + "test": { + "endpoint": "https://api.planetscale.com/v1/organizations/%s/invoices", + "method": "GET", + "valid_status_code": [200], + "invalid_status_code": [403] + } + }, + { + "name": "read_databases", + "test": { + "endpoint": "https://api.planetscale.com/v1/organizations/%s/databases", + "method": "GET", + "valid_status_code": [200], + "invalid_status_code": [403] + } + }, + { + "name": "read_audit_logs", + "test": { + "endpoint": "https://api.planetscale.com/v1/organizations/%s/audit-log", + "method": "GET", + "valid_status_code": [200], + "invalid_status_code": [403] + } + }, + { + "name": "create_databases", + "test": { + "endpoint": "https://api.planetscale.com/v1/organizations/%s/databases", + "method": "POST", + "valid_status_code": [422], + "invalid_status_code": [403] + } + }, + { + "name": "read_oauth_applications", + "test": { + "endpoint": "https://api.planetscale.com/v1/organizations/%s/oauth-applications", + "method": "GET", + "valid_status_code": [200], + "invalid_status_code": [403] + } + } + ], + "oauth_application_scopes": [ + { + "name": "write_oauth_tokens", + "test": { + "endpoint": "https://api.planetscale.com/v1/organizations/%s/oauth-applications/%s/token", + "method": "POST", + "valid_status_code": [422], + "invalid_status_code": [403] + } + }, + { + "name": "read_oauth_tokens", + "test": { + "endpoint": "https://api.planetscale.com/v1/organizations/%s/oauth-applications/%s/tokens", + "method": "GET", + "valid_status_code": [200], + "invalid_status_code": [403] + } + } + ], + "database_scopes": [ + { + "name": "read_database", + "test": { + "endpoint": "https://api.planetscale.com/v1/organizations/%s/databases/%s", + "method": "GET", + "valid_status_code": [200], + "invalid_status_code": [403] + } + }, + { + "name": "write_database", + "test": { + "endpoint": "https://api.planetscale.com/v1/organizations/%s/databases/%s", + "method": "PATCH", + "valid_status_code": [400], + "invalid_status_code": [403], + "payload": { + "default_branch": "`nowaythisbranchcanexist" + } + } + }, + { + "name": "read_branch", + "test": { + "endpoint": "https://api.planetscale.com/v1/organizations/%s/databases/%s/branches", + "method": "GET", + "valid_status_code": [200], + "invalid_status_code": [403] + } + }, + { + "name": "create_branch", + "test": { + "endpoint": "https://api.planetscale.com/v1/organizations/%s/databases/%s/branches", + "method": "POST", + "valid_status_code": [422], + "invalid_status_code": [403] + } + }, + { + "name": "read_deploy_request", + "test": { + "endpoint": "https://api.planetscale.com/v1/organizations/%s/databases/%s/deploy-requests", + "method": "GET", + "valid_status_code": [200], + "invalid_status_code": [403] + } + }, + { + "name": "create_deploy_request", + "test": { + "endpoint": "https://api.planetscale.com/v1/organizations/%s/databases/%s/deploy-requests", + "method": "POST", + "valid_status_code": [422], + "invalid_status_code": [403] + } + } + ], + "branch_scopes": [ + { + "name": "connect_branch", + "production": false, + "test": { + "endpoint": "https://api.planetscale.com/v1/organizations/%s/databases/%s/branches/%s/passwords", + "method": "POST", + "valid_status_code": [422], + "invalid_status_code": [403], + "payload": { + "role": "`nowaythisrolecanexist" + } + } + }, + { + "name": "connect_production_branch", + "production": true, + "test": { + "endpoint": "https://api.planetscale.com/v1/organizations/%s/databases/%s/branches/%s/passwords", + "method": "POST", + "valid_status_code": [422], + "invalid_status_code": [403], + "payload": { + "role": "`nowaythisrolecanexist" + } + } + }, + { + "name": "read_backups", + "production": true, + "test": { + "endpoint": "https://api.planetscale.com/v1/organizations/%s/databases/%s/branches/%s/backups", + "method": "GET", + "valid_status_code": [200], + "invalid_status_code": [403] + } + } + ], + "backup_scopes": [ + { + "name": "restore_backup", + "production": false, + "test": { + "endpoint": "https://api.planetscale.com/v1/organizations/%s/databases/%s/branches", + "method": "POST", + "valid_status_code": [422], + "invalid_status_code": [403], + "payload": { + "backup_id": "%s" + } + } + }, + { + "name": "restore_production_branch_backup", + "production": true, + "test": { + "endpoint": "https://api.planetscale.com/v1/organizations/%s/databases/%s/branches", + "method": "POST", + "valid_status_code": [422], + "invalid_status_code": [403], + "payload": { + "backup_id": "%s" + } + } + } + ] +} \ No newline at end of file diff --git a/pkg/analyzer/cli.go b/pkg/analyzer/cli.go index da388a2f4..073f8dd08 100644 --- a/pkg/analyzer/cli.go +++ b/pkg/analyzer/cli.go @@ -22,6 +22,7 @@ import ( "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/notion" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/openai" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/opsgenie" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/planetscale" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/postgres" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/postman" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers/privatekey" @@ -103,5 +104,7 @@ func Run(keyType string, secretInfo SecretInfo) { digitalocean.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) case "elevenlabs": elevenlabs.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["key"]) + case "planetscale": + planetscale.AnalyzeAndPrintPermissions(secretInfo.Cfg, secretInfo.Parts["id"], secretInfo.Parts["token"]) } } diff --git a/pkg/detectors/planetscale/planetscale.go b/pkg/detectors/planetscale/planetscale.go index 1e42fb9e1..3226ec37c 100644 --- a/pkg/detectors/planetscale/planetscale.go +++ b/pkg/detectors/planetscale/planetscale.go @@ -3,9 +3,10 @@ package planetscale import ( "context" "fmt" - regexp "github.com/wasilibs/go-re2" "net/http" + regexp "github.com/wasilibs/go-re2" + "github.com/trufflesecurity/trufflehog/v3/pkg/common" "github.com/trufflesecurity/trufflehog/v3/pkg/detectors" "github.com/trufflesecurity/trufflehog/v3/pkg/pb/detectorspb" @@ -66,6 +67,10 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result defer res.Body.Close() if res.StatusCode >= 200 && res.StatusCode < 300 { s1.Verified = true + s1.AnalysisInfo = map[string]string{ + "id": username, + "token": password, + } } else if res.StatusCode == 401 { // The secret is determinately not verified s1.Verified = false diff --git a/pkg/detectors/planetscale/planetscale_integration_test.go b/pkg/detectors/planetscale/planetscale_integration_test.go index 315f8f97b..5742a568c 100644 --- a/pkg/detectors/planetscale/planetscale_integration_test.go +++ b/pkg/detectors/planetscale/planetscale_integration_test.go @@ -139,7 +139,7 @@ func TestPlanetscale_FromChunk(t *testing.T) { t.Fatalf("wantVerificationError = %v, verification error = %v", tt.wantVerificationErr, got[i].VerificationError()) } } - ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "verificationError") + ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "RawV2", "verificationError", "AnalysisInfo", "ExtraData") if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" { t.Errorf("Planetscale.FromData() %s diff: (-got +want)\n%s", tt.name, diff) } diff --git a/pkg/tui/pages/analyze_form/analyze_form.go b/pkg/tui/pages/analyze_form/analyze_form.go index a21a0876a..7e82b4f27 100644 --- a/pkg/tui/pages/analyze_form/analyze_form.go +++ b/pkg/tui/pages/analyze_form/analyze_form.go @@ -68,6 +68,17 @@ func New(c common.Common, keyType string) *AnalyzeForm { Required: true, RedactInput: true, }} + case "planetscale": + inputs = []textinputs.InputConfig{{ + Label: "Service Id", + Key: "id", + Required: true, + }, { + Label: "Service Token", + Key: "token", + Required: true, + RedactInput: true, + }} default: inputs = []textinputs.InputConfig{{ Label: "Secret",