//go:generate generate_permissions permissions.yaml permissions.go posthog package posthog import ( "bytes" _ "embed" "encoding/json" "errors" "fmt" "io" "net/http" "os" "strconv" "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) const ( USDomain = "https://us.posthog.com" EUDomain = "https://eu.posthog.com" ) type Analyzer struct { Cfg *config.Config } func (Analyzer) Type() analyzers.AnalyzerType { return analyzers.AnalyzerTypePosthog } func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) { key, ok := credInfo["key"] if !ok { return nil, analyzers.NewAnalysisError(a.Type().String(), analyzers.OperationValidateCredentials, analyzers.ServiceConfig, "", errors.New("missing key in credInfo")) } info, err := AnalyzePermissions(a.Cfg, 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.AnalyzerTypePosthog, Metadata: nil, Bindings: make([]analyzers.Binding, 0), } if info.orgPermissions == nil { // no permissions to check return &result } if info.user != nil { // for user resource userResource := analyzers.Resource{ Name: info.user.FirstName + " " + info.user.LastName, FullyQualifiedName: info.user.UUID, Type: "user", } analyzerPermission := analyzers.Permission{ Value: PermissionStrings[UserRead], } result.Bindings = append(result.Bindings, analyzers.Binding{ Resource: userResource, Permission: analyzerPermission, }) } // for organization permissions, we need to bind the permissions to the organization resource organizationResource := analyzers.Resource{ Name: info.organization.Name, FullyQualifiedName: info.organization.ID, Type: "organization", } for _, permission := range info.orgPermissions { if value, ok := PermissionStrings[permission]; ok { analyzerPermission := analyzers.Permission{ Value: value, } result.Bindings = append(result.Bindings, analyzers.Binding{ Resource: organizationResource, Permission: analyzerPermission, }) } } // for project permissions, we need to bind the permissions to the project resource and organization as the parent resource for _, projectPermission := range info.projectPermissions { projectResource := analyzers.Resource{ Name: projectPermission.Project.Name, FullyQualifiedName: strconv.FormatInt(projectPermission.Project.ID, 10), Type: "project", Parent: &organizationResource, } for _, permission := range projectPermission.Permissions { permissionStr, _ := permission.ToString() analyzerPermission := analyzers.Permission{ Value: permissionStr, } result.Bindings = append(result.Bindings, analyzers.Binding{ Resource: projectResource, Permission: analyzerPermission, }) } } return &result } //go:embed scopes.json var scopesConfigBytes []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, client *http.Client, domain string, 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) } req, err := http.NewRequest(h.Method, fmt.Sprintf(domain+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) } // 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: fmt.Println(h.Method, h.Endpoint) return false, errors.New("error checking response status code") } } type ScopesConfig struct { GeneralScopes []Scope `json:"general_scopes"` OrganizationScopes []Scope `json:"organization_scopes"` ProjectScopes []Scope `json:"project_scopes"` } type Scope struct { Name string `json:"name"` Test ScopeTest `json:"test"` } type ScopeTest struct { Read *HttpStatusTest `json:"read"` Write *HttpStatusTest `json:"write"` } func readInScopesConfig() (*ScopesConfig, error) { var scopesConfig ScopesConfig if err := json.Unmarshal(scopesConfigBytes, &scopesConfig); err != nil { return nil, err } return &scopesConfig, nil } func checkPermissions(cfg *config.Config, client *http.Client, domain string, key string, scopes []Scope, args ...any) ([]Permission, error) { permissions := make([]Permission, 0) headers := map[string]string{"Authorization": "Bearer " + key} for _, scope := range scopes { var status bool var err error if scope.Test.Write != nil { status, err = scope.Test.Write.RunTest(cfg, client, domain, headers, args...) if err != nil { return nil, fmt.Errorf("running test: %w", err) } } if status { if permission, ok := StringToPermission[scope.Name+":write"]; ok { permissions = append(permissions, permission) } // if write exists, read also exists if permission, ok := StringToPermission[scope.Name+":read"]; ok { permissions = append(permissions, permission) } } else { status, err = scope.Test.Read.RunTest(cfg, client, domain, headers, args...) if err != nil { return nil, fmt.Errorf("running test: %w", err) } if status { if permission, ok := StringToPermission[scope.Name+":read"]; ok { permissions = append(permissions, permission) } } } } return permissions, nil } type ProjectPermissions struct { Project *Project Permissions []Permission } type SecretInfo struct { user *User organization *Organization orgPermissions []Permission projectPermissions []ProjectPermissions // generalPermissions []Permission unverifiedPermissions map[Permission]struct{} } func AnalyzeAndPrintPermissions(cfg *config.Config, key string) { info, err := AnalyzePermissions(cfg, key) if err != nil { color.Red("[x] Error : %s", err.Error()) return } color.Green("[!] Valid Posthog API key") color.Yellow("[i] Expires: Never") if info.user != nil { printUser(*info.user) } if info.organization == nil { color.Yellow("\n[i] No permissions were verified for this key because the key does not have one of the necessary permissions (user:read or organization:read) required to verify other permissions.") } if info.orgPermissions != nil { printOrganizationPermissions(*info.organization, info.orgPermissions) } if len(info.projectPermissions) > 0 { printProjectPermissions(info.projectPermissions) } printUnverifiedPermissions(info.unverifiedPermissions) } func AnalyzePermissions(cfg *config.Config, key string) (*SecretInfo, error) { var info = &SecretInfo{} // These are permissions that cannot be verified due to no endpoint available info.unverifiedPermissions = map[Permission]struct{}{ ErrorTrackingRead: {}, ErrorTrackingWrite: {}, SharingConfigurationRead: {}, SharingConfigurationWrite: {}, WebhookRead: {}, WebhookWrite: {}, } client := analyzers.NewAnalyzeClient(cfg) // we need to determine if the key is for US or EU domain domain, user, err := resolveDomainAndUser(cfg, client, key) if err != nil { return nil, fmt.Errorf("invalid API key: %w", err) } info.user = user // Most posthog API scopes are bound to projects and organization, so to determine the scopes we need to first get the organization and projects. // If the key has user:read scope, we will get the user above which contains the organizations and projects. // If the key does not have user:read scope, we can call the /organizations/@current endpoint to get the // organization and projects. If the key does not have organization:read scope as well, we cannot determine any scope. var org *Organization if user == nil { org, err = getOrganization(cfg, client, domain, key) if err != nil { return nil, err } if org == nil { // can't determine any scopes for permission := range PermissionStrings { info.unverifiedPermissions[permission] = struct{}{} } return info, nil } } else { org = &user.Organization } // set the organization in the info struct info.organization = org // read in scopes scopesConfig, err := readInScopesConfig() if err != nil { return nil, err } // check organization permissions organizationPermissions, err := checkOrganizationPermissions(cfg, client, domain, key, scopesConfig, org) if err != nil { return nil, err } // check general permissions generalOrganizationPermissions, err := checkGeneralPermissions(cfg, client, domain, key, scopesConfig) if err != nil { return nil, err } // merge general permissions with organization permissions info.orgPermissions = organizationPermissions info.orgPermissions = append(info.orgPermissions, generalOrganizationPermissions...) // check project permissions projectPermissions, err := checkProjectPermissions(cfg, client, domain, key, scopesConfig, org) if err != nil { return nil, err } info.projectPermissions = projectPermissions return info, nil } func checkGeneralPermissions(cfg *config.Config, client *http.Client, domain, key string, scopesConfig *ScopesConfig) ([]Permission, error) { return checkPermissions(cfg, client, domain, key, scopesConfig.GeneralScopes) } func checkOrganizationPermissions( cfg *config.Config, client *http.Client, domain, key string, scopesConfig *ScopesConfig, org *Organization, ) ([]Permission, error) { return checkPermissions(cfg, client, domain, key, scopesConfig.OrganizationScopes, org.ID) } func checkProjectPermissions( cfg *config.Config, client *http.Client, domain, key string, scopesConfig *ScopesConfig, org *Organization, ) ([]ProjectPermissions, error) { projectPermissions := make([]ProjectPermissions, 0) for _, project := range org.Projects { projectPermission := ProjectPermissions{ Project: &project, } permissions, err := checkPermissions(cfg, client, domain, key, scopesConfig.ProjectScopes, project.ID) if err != nil { return nil, err } projectPermission.Permissions = permissions projectPermissions = append(projectPermissions, projectPermission) } return projectPermissions, nil } type User struct { UUID string `json:"uuid"` FirstName string `json:"first_name"` LastName string `json:"last_name"` Email string `json:"email"` Organization Organization `json:"organization"` } type Organization struct { ID string `json:"id"` Name string `json:"name"` Projects []Project `json:"projects"` } type Project struct { ID int64 `json:"id"` Name string `json:"name"` } // resolves the domain and user (if permission exists) by calling the /users/@me method for both US and EU domains // if the response is 200 OK, it means the domain is valid and user:read permission is also there // if the response is 403 Forbidden, it means the domain is valid but user:read permission is not there // if the response is 401 Unauthorized, it means the domain is invalid func resolveDomainAndUser(cfg *config.Config, client *http.Client, key string) (string, *User, error) { domains := []string{USDomain, EUDomain} for _, domain := range domains { req, err := http.NewRequest(http.MethodGet, domain+"/api/users/@me/", nil) if err != nil { return "", nil, err } req.Header.Set("Authorization", "Bearer "+key) // Execute HTTP Request resp, err := client.Do(req) if err != nil { return "", nil, err } defer func() { _ = resp.Body.Close() }() switch resp.StatusCode { case http.StatusOK: // domain is valid and user permission also exists var userInfo User if err := json.NewDecoder(resp.Body).Decode(&userInfo); err != nil { return "", nil, err } return domain, &userInfo, nil case http.StatusForbidden: // domain is valid but user permission does not exist return domain, nil, nil case http.StatusUnauthorized: // Key might not be valid of this domain // Try the other domain continue default: // unexpected status code return "", nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } } return "", nil, fmt.Errorf("invalid Posthog API key") } func getOrganization(cfg *config.Config, client *http.Client, domain string, key string) (*Organization, error) { req, err := http.NewRequest(http.MethodGet, domain+"/api/organizations/@current/", nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+key) // Execute HTTP Request resp, err := client.Do(req) if err != nil { return nil, err } defer func() { _ = resp.Body.Close() }() switch resp.StatusCode { case http.StatusOK: var org Organization if err := json.NewDecoder(resp.Body).Decode(&org); err != nil { return nil, err } return &org, nil case http.StatusForbidden: return nil, nil default: return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) } } func printUser(user User) { color.Yellow("\n[i] User Info:") color.Green("[i] Name: %s %s", user.FirstName, user.LastName) color.Green("[i] Email: %s", user.Email) color.Green("[i] ID: %s", user.UUID) } func printOrganizationPermissions(organization Organization, permissions []Permission) { color.Yellow("\n[i] Organization Permissions:") t := table.NewWriter() t.SetOutputMirror(os.Stdout) t.AppendHeader(table.Row{"Organization", "Permission"}) permissionsString := make([]string, len(permissions)) for i, permission := range permissions { permissionsString[i], _ = permission.ToString() } t.AppendRow(table.Row{ color.GreenString(organization.Name), color.GreenString(strings.Join(permissionsString, "\n")), }) t.Render() } func printProjectPermissions(projectPermissions []ProjectPermissions) { color.Yellow("\n[i] Project Permissions:") t := table.NewWriter() t.SetOutputMirror(os.Stdout) t.AppendHeader(table.Row{"Project", "Permission"}) for _, projectPermission := range projectPermissions { permissionsString := make([]string, len(projectPermission.Permissions)) for i, permission := range projectPermission.Permissions { permissionsString[i], _ = permission.ToString() } t.AppendRow(table.Row{ color.GreenString(projectPermission.Project.Name), color.GreenString(strings.Join(permissionsString, "\n")), }) } t.Render() } func printUnverifiedPermissions(permissions map[Permission]struct{}) { color.Yellow("\n[i] Unverified Permissions:") t := table.NewWriter() t.SetOutputMirror(os.Stdout) t.AppendHeader(table.Row{"Permission"}) for permission := range permissions { permissionStr, _ := permission.ToString() t.AppendRow(table.Row{color.YellowString(permissionStr)}) } t.Render() }