[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.
This commit is contained in:
Abdul Basit
2025-03-03 15:19:21 -06:00
committed by GitHub
parent 8765cc6bcb
commit 590ba66f6b
11 changed files with 1176 additions and 2 deletions
+2
View File
@@ -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
}
File diff suppressed because one or more lines are too long
@@ -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")
}
@@ -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
@@ -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()
}
@@ -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
})
}
@@ -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"
}
}
}
]
}
+3
View File
@@ -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"])
}
}
+6 -1
View File
@@ -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
@@ -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)
}
@@ -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",