diff --git a/pkg/analyzer/analyzers/postman/expected_output.json b/pkg/analyzer/analyzers/postman/expected_output.json new file mode 100644 index 000000000..307950248 --- /dev/null +++ b/pkg/analyzer/analyzers/postman/expected_output.json @@ -0,0 +1,76 @@ +{ + "AnalyzerType": 13, + "Bindings": [ + { + "Resource": { + "Name": "rendy", + "FullyQualifiedName": "rendyplayground@gmail.com", + "Type": "user", + "Metadata": { + "email": "rendyplayground@gmail.com", + "role": "user", + "team_domain": "", + "team_name": "", + "username": "rendyplayground" + }, + "Parent": null + }, + "Permission": { + "Value": "usage_data:view", + "Parent": null + } + }, + { + "Resource": { + "Name": "rendy", + "FullyQualifiedName": "rendyplayground@gmail.com", + "Type": "user", + "Metadata": { + "email": "rendyplayground@gmail.com", + "role": "user", + "team_domain": "", + "team_name": "", + "username": "rendyplayground" + }, + "Parent": null + }, + "Permission": { + "Value": "team_workspaces:create", + "Parent": null + } + }, + { + "Resource": { + "Name": "rendy", + "FullyQualifiedName": "rendyplayground@gmail.com", + "Type": "user", + "Metadata": { + "email": "rendyplayground@gmail.com", + "role": "user", + "team_domain": "", + "team_name": "", + "username": "rendyplayground" + }, + "Parent": null + }, + "Permission": { + "Value": "team_workspaces:view", + "Parent": null + } + } + ], + "UnboundedResources": [ + { + "Name": "My Workspace", + "FullyQualifiedName": "4d06fc0c-6402-4a26-857d-80787b10eabf", + "Type": "workspace", + "Metadata": { + "id": "4d06fc0c-6402-4a26-857d-80787b10eabf", + "type": "personal", + "visibility": "personal" + }, + "Parent": null + } + ], + "Metadata": null + } \ No newline at end of file diff --git a/pkg/analyzer/analyzers/postman/permissions.go b/pkg/analyzer/analyzers/postman/permissions.go new file mode 100644 index 000000000..2beb8b3cc --- /dev/null +++ b/pkg/analyzer/analyzers/postman/permissions.go @@ -0,0 +1,181 @@ +// Code generated by go generate; DO NOT EDIT. +package postman + +import "errors" + +type Permission int + +const ( + NoAccess Permission = iota + UserAdd Permission = iota + UserRemove Permission = iota + TeamAdminManage Permission = iota + TeamDevelopersManage Permission = iota + SsoManage Permission = iota + CustomDomainAdd Permission = iota + CustomDomainEdit Permission = iota + CustomDomainRemove Permission = iota + AuditLogsView Permission = iota + UsageDataView Permission = iota + BillingMembersManage Permission = iota + PaymentManage Permission = iota + PlanUpdate Permission = iota + TeamWorkspacesView Permission = iota + TeamWorkspacesCreate Permission = iota + TeamPublicProfileEnable Permission = iota + TeamPrivateApiNetworkManage Permission = iota + ParternerWorkspaceView Permission = iota + ParternerWorkspaceManage Permission = iota + ParternerWorkspaceVisibilityManage Permission = iota + PartnersManage Permission = iota + FlowAdd Permission = iota + FlowEdit Permission = iota + FlowRun Permission = iota + FlowPublish Permission = iota +) + +var ( + PermissionStrings = map[Permission]string{ + UserAdd: "user:add", + UserRemove: "user:remove", + TeamAdminManage: "team_admin:manage", + TeamDevelopersManage: "team_developers:manage", + SsoManage: "sso:manage", + CustomDomainAdd: "custom_domain:add", + CustomDomainEdit: "custom_domain:edit", + CustomDomainRemove: "custom_domain:remove", + AuditLogsView: "audit_logs:view", + UsageDataView: "usage_data:view", + BillingMembersManage: "billing_members:manage", + PaymentManage: "payment:manage", + PlanUpdate: "plan:update", + TeamWorkspacesView: "team_workspaces:view", + TeamWorkspacesCreate: "team_workspaces:create", + TeamPublicProfileEnable: "team_public_profile:enable", + TeamPrivateApiNetworkManage: "team_private_api_network:manage", + ParternerWorkspaceView: "parterner_workspace:view", + ParternerWorkspaceManage: "parterner_workspace:manage", + ParternerWorkspaceVisibilityManage: "parterner_workspace_visibility:manage", + PartnersManage: "partners:manage", + FlowAdd: "flow:add", + FlowEdit: "flow:edit", + FlowRun: "flow:run", + FlowPublish: "flow:publish", + } + + StringToPermission = map[string]Permission{ + "user:add": UserAdd, + "user:remove": UserRemove, + "team_admin:manage": TeamAdminManage, + "team_developers:manage": TeamDevelopersManage, + "sso:manage": SsoManage, + "custom_domain:add": CustomDomainAdd, + "custom_domain:edit": CustomDomainEdit, + "custom_domain:remove": CustomDomainRemove, + "audit_logs:view": AuditLogsView, + "usage_data:view": UsageDataView, + "billing_members:manage": BillingMembersManage, + "payment:manage": PaymentManage, + "plan:update": PlanUpdate, + "team_workspaces:view": TeamWorkspacesView, + "team_workspaces:create": TeamWorkspacesCreate, + "team_public_profile:enable": TeamPublicProfileEnable, + "team_private_api_network:manage": TeamPrivateApiNetworkManage, + "parterner_workspace:view": ParternerWorkspaceView, + "parterner_workspace:manage": ParternerWorkspaceManage, + "parterner_workspace_visibility:manage": ParternerWorkspaceVisibilityManage, + "partners:manage": PartnersManage, + "flow:add": FlowAdd, + "flow:edit": FlowEdit, + "flow:run": FlowRun, + "flow:publish": FlowPublish, + } + + PermissionIDs = map[Permission]int{ + UserAdd: 0, + UserRemove: 1, + TeamAdminManage: 2, + TeamDevelopersManage: 3, + SsoManage: 4, + CustomDomainAdd: 5, + CustomDomainEdit: 6, + CustomDomainRemove: 7, + AuditLogsView: 8, + UsageDataView: 9, + BillingMembersManage: 10, + PaymentManage: 11, + PlanUpdate: 12, + TeamWorkspacesView: 13, + TeamWorkspacesCreate: 14, + TeamPublicProfileEnable: 15, + TeamPrivateApiNetworkManage: 16, + ParternerWorkspaceView: 17, + ParternerWorkspaceManage: 18, + ParternerWorkspaceVisibilityManage: 19, + PartnersManage: 20, + FlowAdd: 21, + FlowEdit: 22, + FlowRun: 23, + FlowPublish: 24, + } + + IdToPermission = map[int]Permission{ + 0: UserAdd, + 1: UserRemove, + 2: TeamAdminManage, + 3: TeamDevelopersManage, + 4: SsoManage, + 5: CustomDomainAdd, + 6: CustomDomainEdit, + 7: CustomDomainRemove, + 8: AuditLogsView, + 9: UsageDataView, + 10: BillingMembersManage, + 11: PaymentManage, + 12: PlanUpdate, + 13: TeamWorkspacesView, + 14: TeamWorkspacesCreate, + 15: TeamPublicProfileEnable, + 16: TeamPrivateApiNetworkManage, + 17: ParternerWorkspaceView, + 18: ParternerWorkspaceManage, + 19: ParternerWorkspaceVisibilityManage, + 20: PartnersManage, + 21: FlowAdd, + 22: FlowEdit, + 23: FlowRun, + 24: FlowPublish, + } +) + +// 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/postman/permissions.yaml b/pkg/analyzer/analyzers/postman/permissions.yaml new file mode 100644 index 000000000..fc87c2413 --- /dev/null +++ b/pkg/analyzer/analyzers/postman/permissions.yaml @@ -0,0 +1,27 @@ +permissions: +- user:add +- user:remove +- team_admin:manage +- team_developers:manage +- sso:manage +- custom_domain:add +- custom_domain:edit +- custom_domain:remove +- audit_logs:view +- usage_data:view +- billing_members:manage +- payment:manage +- plan:update +- team_workspaces:view +- team_workspaces:create +- team_public_profile:enable +- team_private_api_network:manage +- parterner_workspace:view +- parterner_workspace:manage +- parterner_workspace_visibility:manage +- partners:manage +- flow:add +- flow:edit +- flow:run +- flow:publish + diff --git a/pkg/analyzer/analyzers/postman/postman.go b/pkg/analyzer/analyzers/postman/postman.go index 41fe141e0..98d088e0f 100644 --- a/pkg/analyzer/analyzers/postman/postman.go +++ b/pkg/analyzer/analyzers/postman/postman.go @@ -1,3 +1,4 @@ +//go:generate generate_permissions permissions.yaml permissions.go postman package postman import ( @@ -5,13 +6,109 @@ import ( "fmt" "net/http" "os" + "strings" "github.com/fatih/color" "github.com/jedib0t/go-pretty/table" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/analyzers" "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/config" + "github.com/trufflesecurity/trufflehog/v3/pkg/analyzer/pb/analyzerpb" + "github.com/trufflesecurity/trufflehog/v3/pkg/context" ) +var _ analyzers.Analyzer = (*Analyzer)(nil) + +type Analyzer struct { + Cfg *config.Config +} + +func (Analyzer) Type() analyzerpb.AnalyzerType { return analyzerpb.AnalyzerType_Postman } + +func (a Analyzer) Analyze(_ context.Context, credInfo map[string]string) (*analyzers.AnalyzerResult, error) { + key, ok := credInfo["key"] + if !ok { + return nil, fmt.Errorf("missing key in credInfo") + } + info, err := AnalyzePermissions(a.Cfg, 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: analyzerpb.AnalyzerType_Postman, + Metadata: nil, + UnboundedResources: []analyzers.Resource{}, + } + + resource := analyzers.Resource{ + Name: info.User.User.FullName, + FullyQualifiedName: info.User.User.Email, + Type: "user", + Metadata: map[string]any{ + "role": strings.Join(info.User.User.Roles, ","), + "username": info.User.User.Username, + "email": info.User.User.Email, + "team_name": info.User.User.TeamName, + "team_domain": info.User.User.TeamDomain, + }, + } + + permissions := bakePermissions(info.User.User.Roles) + + // bind all permissions with resources + result.Bindings = analyzers.BindAllPermissions(resource, permissions...) + + for _, workspace := range info.Workspace.Workspaces { + result.UnboundedResources = append(result.UnboundedResources, analyzers.Resource{ + Name: workspace.Name, + FullyQualifiedName: workspace.ID, + Type: "workspace", + Metadata: map[string]any{ + "id": workspace.ID, + "type": workspace.Type, + "visibility": workspace.Visibility, + }, + }) + } + + return &result +} + +func bakePermissions(roles []string) []analyzers.Permission { + permissionMap := map[Permission]struct{}{} + + for _, role := range roles { + permissions, ok := rolePermission[role] + if !ok { + continue + } + for _, permission := range permissions { + permissionMap[permission] = struct{}{} + } + } + + permissions := make([]analyzers.Permission, 0, len(permissionMap)) + for perm := range permissionMap { + permStr, err := perm.ToString() + if err != nil { + continue + } + permissions = append(permissions, analyzers.Permission{ + Value: permStr, + Parent: nil, + }) + } + + return permissions +} + type UserInfoJSON struct { User struct { Username string `json:"username"` diff --git a/pkg/analyzer/analyzers/postman/postman_test.go b/pkg/analyzer/analyzers/postman/postman_test.go new file mode 100644 index 000000000..863826bd2 --- /dev/null +++ b/pkg/analyzer/analyzers/postman/postman_test.go @@ -0,0 +1,100 @@ +package postman + +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", "detectors3") + if err != nil { + t.Fatalf("could not get test secrets from GCP: %s", err) + } + + tests := []struct { + name string + key string + want string // JSON string + wantErr bool + }{ + { + name: "valid Postman key", + key: testSecrets.MustGetField("POSTMAN_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{"key": tt.key}) + 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/postman/scopes.go b/pkg/analyzer/analyzers/postman/scopes.go index 496ba8360..46e8ca4f8 100644 --- a/pkg/analyzer/analyzers/postman/scopes.go +++ b/pkg/analyzer/analyzers/postman/scopes.go @@ -11,3 +11,93 @@ var roleDescriptions = map[string]string{ "guest": "Views collections and sends requests in collections that have been shared with them. This role can't be directly assigned to a user.", "flow-editor": "(Basic and Professional plans only) - Can create, edit, run, and publish Postman Flows.", } + +var rolePermission = map[string][]Permission{ + "super-admin": { + UserAdd, + UserRemove, + TeamAdminManage, + TeamDevelopersManage, + SsoManage, + CustomDomainAdd, + CustomDomainEdit, + CustomDomainRemove, + AuditLogsView, + UsageDataView, + BillingMembersManage, + PaymentManage, + PlanUpdate, + TeamWorkspacesView, + TeamWorkspacesCreate, + TeamPublicProfileEnable, + TeamPrivateApiNetworkManage, + PartnersManage, + ParternerWorkspaceManage, + ParternerWorkspaceView, + ParternerWorkspaceVisibilityManage, + FlowAdd, + FlowEdit, + FlowRun, + FlowPublish, + }, + "admin": { + UserAdd, + UserRemove, + TeamAdminManage, + TeamDevelopersManage, + SsoManage, + CustomDomainAdd, + CustomDomainEdit, + CustomDomainRemove, + AuditLogsView, + UsageDataView, + BillingMembersManage, + TeamPublicProfileEnable, + PartnersManage, + ParternerWorkspaceManage, + ParternerWorkspaceView, + ParternerWorkspaceVisibilityManage, + FlowAdd, + FlowEdit, + FlowRun, + FlowPublish, + }, + "billing": { + UsageDataView, + BillingMembersManage, + PaymentManage, + PlanUpdate, + }, + "user": { + UsageDataView, + TeamWorkspacesCreate, + TeamWorkspacesView, + }, + "community-manager": { + CustomDomainAdd, + CustomDomainEdit, + AuditLogsView, + UsageDataView, + TeamWorkspacesView, + TeamWorkspacesCreate, + TeamPublicProfileEnable, + }, + "partner-manager": { + PartnersManage, + ParternerWorkspaceManage, + ParternerWorkspaceView, + ParternerWorkspaceVisibilityManage, + }, + "partner": { + ParternerWorkspaceView, + }, + "guest": { + TeamWorkspacesView, + }, + "flow-editor": { + FlowAdd, + FlowEdit, + FlowRun, + FlowPublish, + }, +} diff --git a/pkg/detectors/postman/postman.go b/pkg/detectors/postman/postman.go index bc25323f4..25b1ce119 100644 --- a/pkg/detectors/postman/postman.go +++ b/pkg/detectors/postman/postman.go @@ -3,10 +3,11 @@ package postman import ( "context" "fmt" - regexp "github.com/wasilibs/go-re2" "net/http" "strings" + 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" @@ -56,6 +57,9 @@ func (s Scanner) FromData(ctx context.Context, verify bool, data []byte) (result isVerified, verificationErr := verifyPostman(ctx, client, resMatch) s1.Verified = isVerified s1.SetVerificationError(verificationErr, resMatch) + s1.AnalysisInfo = map[string]string{ + "key": resMatch, + } } results = append(results, s1) diff --git a/pkg/detectors/postman/postman_test.go b/pkg/detectors/postman/postman_test.go index 355b81cd8..65ac16c97 100644 --- a/pkg/detectors/postman/postman_test.go +++ b/pkg/detectors/postman/postman_test.go @@ -133,7 +133,7 @@ func TestPostman_FromChunk(t *testing.T) { t.Errorf("Postman.FromData() error = %v, wantErr %v", got[i].VerificationError(), tt.wantVerificationErr) } } - ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "verificationError") + ignoreOpts := cmpopts.IgnoreFields(detectors.Result{}, "Raw", "verificationError", "AnalysisInfo") if diff := cmp.Diff(got, tt.want, ignoreOpts); diff != "" { t.Errorf("Postman.FromData() %s diff: (-got +want)\n%s", tt.name, diff) }