mirror of
https://github.com/BishopFox/cloudfox.git
synced 2026-11-10 09:41:25 -10:00
880 lines
30 KiB
Go
880 lines
30 KiB
Go
package crossprojectservice
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
|
|
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
|
"github.com/BishopFox/cloudfox/internal/gcp/sdk"
|
|
cloudresourcemanager "google.golang.org/api/cloudresourcemanager/v1"
|
|
iam "google.golang.org/api/iam/v1"
|
|
logging "google.golang.org/api/logging/v2"
|
|
pubsub "google.golang.org/api/pubsub/v1"
|
|
)
|
|
|
|
type CrossProjectService struct {
|
|
session *gcpinternal.SafeSession
|
|
}
|
|
|
|
func New() *CrossProjectService {
|
|
return &CrossProjectService{}
|
|
}
|
|
|
|
func NewWithSession(session *gcpinternal.SafeSession) *CrossProjectService {
|
|
return &CrossProjectService{
|
|
session: session,
|
|
}
|
|
}
|
|
|
|
// getResourceManagerService returns a Resource Manager service using cached session if available
|
|
func (s *CrossProjectService) getResourceManagerService(ctx context.Context) (*cloudresourcemanager.Service, error) {
|
|
if s.session != nil {
|
|
return sdk.CachedGetResourceManagerService(ctx, s.session)
|
|
}
|
|
return cloudresourcemanager.NewService(ctx)
|
|
}
|
|
|
|
// getIAMService returns an IAM service using cached session if available
|
|
func (s *CrossProjectService) getIAMService(ctx context.Context) (*iam.Service, error) {
|
|
if s.session != nil {
|
|
return sdk.CachedGetIAMService(ctx, s.session)
|
|
}
|
|
return iam.NewService(ctx)
|
|
}
|
|
|
|
// getLoggingService returns a Logging service using cached session if available
|
|
func (s *CrossProjectService) getLoggingService(ctx context.Context) (*logging.Service, error) {
|
|
if s.session != nil {
|
|
return sdk.CachedGetLoggingService(ctx, s.session)
|
|
}
|
|
return logging.NewService(ctx)
|
|
}
|
|
|
|
// getPubSubService returns a PubSub service using cached session if available
|
|
func (s *CrossProjectService) getPubSubService(ctx context.Context) (*pubsub.Service, error) {
|
|
if s.session != nil {
|
|
return sdk.CachedGetPubSubService(ctx, s.session)
|
|
}
|
|
return pubsub.NewService(ctx)
|
|
}
|
|
|
|
// CrossProjectBinding represents a cross-project IAM binding
|
|
type CrossProjectBinding struct {
|
|
SourceProject string `json:"sourceProject"` // Where the principal is from
|
|
TargetProject string `json:"targetProject"` // Where access is granted
|
|
Principal string `json:"principal"` // The service account or user
|
|
PrincipalType string `json:"principalType"` // serviceAccount, user, group
|
|
Role string `json:"role"` // The IAM role granted
|
|
RiskLevel string `json:"riskLevel"` // CRITICAL, HIGH, MEDIUM, LOW
|
|
RiskReasons []string `json:"riskReasons"` // Why it's risky
|
|
ExploitCommands []string `json:"exploitCommands"` // Commands for exploitation
|
|
}
|
|
|
|
// CrossProjectServiceAccount represents a service account that may have cross-project access
|
|
type CrossProjectServiceAccount struct {
|
|
Email string `json:"email"`
|
|
ProjectID string `json:"projectId"`
|
|
DisplayName string `json:"displayName"`
|
|
UniqueID string `json:"uniqueId"`
|
|
TargetAccess []string `json:"targetAccess"` // Other projects this SA can access
|
|
}
|
|
|
|
// LateralMovementPath represents a potential lateral movement path
|
|
type LateralMovementPath struct {
|
|
SourceProject string `json:"sourceProject"`
|
|
SourcePrincipal string `json:"sourcePrincipal"`
|
|
TargetProject string `json:"targetProject"`
|
|
AccessMethod string `json:"accessMethod"` // e.g., "impersonation", "direct role"
|
|
TargetRoles []string `json:"targetRoles"`
|
|
PrivilegeLevel string `json:"privilegeLevel"` // ADMIN, WRITE, READ
|
|
ExploitCommands []string `json:"exploitCommands"`
|
|
}
|
|
|
|
// CrossProjectLoggingSink represents a logging sink exporting to another project
|
|
type CrossProjectLoggingSink struct {
|
|
SourceProject string `json:"sourceProject"` // Project where sink is configured
|
|
SinkName string `json:"sinkName"` // Name of the logging sink
|
|
Destination string `json:"destination"` // Full destination (bucket, BQ, pubsub, etc)
|
|
DestinationType string `json:"destinationType"` // storage, bigquery, pubsub, logging
|
|
TargetProject string `json:"targetProject"` // Project where data is sent
|
|
Filter string `json:"filter"` // Log filter
|
|
RiskLevel string `json:"riskLevel"` // CRITICAL, HIGH, MEDIUM, LOW
|
|
RiskReasons []string `json:"riskReasons"`
|
|
}
|
|
|
|
// CrossProjectPubSubExport represents a Pub/Sub subscription exporting to another project
|
|
type CrossProjectPubSubExport struct {
|
|
SourceProject string `json:"sourceProject"` // Project where subscription is
|
|
TopicProject string `json:"topicProject"` // Project where topic is
|
|
TopicName string `json:"topicName"` // Topic name
|
|
SubscriptionName string `json:"subscriptionName"` // Subscription name
|
|
ExportType string `json:"exportType"` // push, bigquery, cloudstorage
|
|
ExportDest string `json:"exportDest"` // Destination details
|
|
TargetProject string `json:"targetProject"` // Project where data is exported to
|
|
RiskLevel string `json:"riskLevel"`
|
|
RiskReasons []string `json:"riskReasons"`
|
|
}
|
|
|
|
// AnalyzeCrossProjectAccess analyzes cross-project IAM bindings for a set of projects.
|
|
// If orgCache is provided, it resolves project numbers to IDs for accurate detection.
|
|
func (s *CrossProjectService) AnalyzeCrossProjectAccess(projectIDs []string, orgCache *gcpinternal.OrgCache) ([]CrossProjectBinding, error) {
|
|
ctx := context.Background()
|
|
|
|
crmService, err := s.getResourceManagerService(ctx)
|
|
if err != nil {
|
|
return nil, gcpinternal.ParseGCPError(err, "cloudresourcemanager.googleapis.com")
|
|
}
|
|
|
|
var crossProjectBindings []CrossProjectBinding
|
|
|
|
// Build a map of project IDs for quick lookup
|
|
projectMap := make(map[string]bool)
|
|
for _, pid := range projectIDs {
|
|
projectMap[pid] = true
|
|
}
|
|
|
|
// Analyze IAM policy of each project
|
|
for _, targetProject := range projectIDs {
|
|
policy, err := crmService.Projects.GetIamPolicy(targetProject, &cloudresourcemanager.GetIamPolicyRequest{}).Context(ctx).Do()
|
|
if err != nil {
|
|
continue // Skip projects we can't access
|
|
}
|
|
|
|
for _, binding := range policy.Bindings {
|
|
for _, member := range binding.Members {
|
|
sourceProject := extractProjectFromMember(member, orgCache)
|
|
|
|
// Check if this is cross-project access
|
|
if sourceProject != "" && sourceProject != targetProject {
|
|
// Check if source project is in our analysis scope
|
|
isFromKnownProject := projectMap[sourceProject]
|
|
|
|
cpBinding := CrossProjectBinding{
|
|
SourceProject: sourceProject,
|
|
TargetProject: targetProject,
|
|
Principal: member,
|
|
PrincipalType: extractPrincipalType(member),
|
|
Role: binding.Role,
|
|
RiskReasons: []string{},
|
|
}
|
|
|
|
// Analyze risk level
|
|
cpBinding.RiskLevel, cpBinding.RiskReasons = s.analyzeBindingRisk(binding.Role, member, isFromKnownProject)
|
|
cpBinding.ExploitCommands = s.generateExploitCommands(cpBinding)
|
|
|
|
crossProjectBindings = append(crossProjectBindings, cpBinding)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return crossProjectBindings, nil
|
|
}
|
|
|
|
// GetCrossProjectServiceAccounts finds service accounts with cross-project access
|
|
func (s *CrossProjectService) GetCrossProjectServiceAccounts(projectIDs []string) ([]CrossProjectServiceAccount, error) {
|
|
ctx := context.Background()
|
|
|
|
iamService, err := s.getIAMService(ctx)
|
|
if err != nil {
|
|
return nil, gcpinternal.ParseGCPError(err, "iam.googleapis.com")
|
|
}
|
|
|
|
crmService, err := s.getResourceManagerService(ctx)
|
|
if err != nil {
|
|
return nil, gcpinternal.ParseGCPError(err, "cloudresourcemanager.googleapis.com")
|
|
}
|
|
|
|
var crossProjectSAs []CrossProjectServiceAccount
|
|
|
|
// Build a map of all service accounts by email -> project
|
|
saProjectMap := make(map[string]string)
|
|
allSAs := make(map[string]*CrossProjectServiceAccount)
|
|
|
|
// List all service accounts in each project
|
|
for _, projectID := range projectIDs {
|
|
req := iamService.Projects.ServiceAccounts.List(fmt.Sprintf("projects/%s", projectID))
|
|
err := req.Pages(ctx, func(page *iam.ListServiceAccountsResponse) error {
|
|
for _, sa := range page.Accounts {
|
|
saProjectMap[sa.Email] = projectID
|
|
allSAs[sa.Email] = &CrossProjectServiceAccount{
|
|
Email: sa.Email,
|
|
ProjectID: projectID,
|
|
DisplayName: sa.DisplayName,
|
|
UniqueID: sa.UniqueId,
|
|
TargetAccess: []string{},
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
continue // Skip on error
|
|
}
|
|
}
|
|
|
|
// Now check each project's IAM policy for service accounts from other projects
|
|
for _, targetProject := range projectIDs {
|
|
policy, err := crmService.Projects.GetIamPolicy(targetProject, &cloudresourcemanager.GetIamPolicyRequest{}).Context(ctx).Do()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
for _, binding := range policy.Bindings {
|
|
for _, member := range binding.Members {
|
|
if strings.HasPrefix(member, "serviceAccount:") {
|
|
email := strings.TrimPrefix(member, "serviceAccount:")
|
|
sourceProject := saProjectMap[email]
|
|
|
|
// Cross-project access
|
|
if sourceProject != "" && sourceProject != targetProject {
|
|
if sa, exists := allSAs[email]; exists {
|
|
accessDesc := fmt.Sprintf("%s: %s", targetProject, binding.Role)
|
|
sa.TargetAccess = append(sa.TargetAccess, accessDesc)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Collect SAs with cross-project access
|
|
for _, sa := range allSAs {
|
|
if len(sa.TargetAccess) > 0 {
|
|
crossProjectSAs = append(crossProjectSAs, *sa)
|
|
}
|
|
}
|
|
|
|
return crossProjectSAs, nil
|
|
}
|
|
|
|
// FindLateralMovementPaths identifies lateral movement paths between projects.
|
|
// If orgCache is provided, it resolves project numbers to IDs for accurate detection.
|
|
func (s *CrossProjectService) FindLateralMovementPaths(projectIDs []string, orgCache *gcpinternal.OrgCache) ([]LateralMovementPath, error) {
|
|
ctx := context.Background()
|
|
|
|
crmService, err := s.getResourceManagerService(ctx)
|
|
if err != nil {
|
|
return nil, gcpinternal.ParseGCPError(err, "cloudresourcemanager.googleapis.com")
|
|
}
|
|
|
|
var paths []LateralMovementPath
|
|
|
|
// Analyze each project pair
|
|
for _, sourceProject := range projectIDs {
|
|
for _, targetProject := range projectIDs {
|
|
if sourceProject == targetProject {
|
|
continue
|
|
}
|
|
|
|
// Get target project IAM policy
|
|
policy, err := crmService.Projects.GetIamPolicy(targetProject, &cloudresourcemanager.GetIamPolicyRequest{}).Context(ctx).Do()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
// Find principals from source project that have access to target
|
|
for _, binding := range policy.Bindings {
|
|
for _, member := range binding.Members {
|
|
memberProject := extractProjectFromMember(member, orgCache)
|
|
if memberProject == sourceProject {
|
|
path := LateralMovementPath{
|
|
SourceProject: sourceProject,
|
|
SourcePrincipal: member,
|
|
TargetProject: targetProject,
|
|
AccessMethod: "Direct IAM Role",
|
|
TargetRoles: []string{binding.Role},
|
|
PrivilegeLevel: categorizePrivilegeLevel(binding.Role),
|
|
}
|
|
path.ExploitCommands = s.generateLateralMovementCommands(path)
|
|
paths = append(paths, path)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return paths, nil
|
|
}
|
|
|
|
// analyzeBindingRisk determines the risk level of a cross-project binding
|
|
func (s *CrossProjectService) analyzeBindingRisk(role, member string, isFromKnownProject bool) (string, []string) {
|
|
var reasons []string
|
|
score := 0
|
|
|
|
// High-privilege roles
|
|
highPrivRoles := map[string]bool{
|
|
"roles/owner": true,
|
|
"roles/editor": true,
|
|
"roles/iam.serviceAccountTokenCreator": true,
|
|
"roles/iam.serviceAccountKeyAdmin": true,
|
|
"roles/iam.securityAdmin": true,
|
|
"roles/compute.admin": true,
|
|
"roles/storage.admin": true,
|
|
"roles/secretmanager.admin": true,
|
|
}
|
|
|
|
if highPrivRoles[role] {
|
|
reasons = append(reasons, fmt.Sprintf("High-privilege role: %s", role))
|
|
score += 3
|
|
}
|
|
|
|
// Admin/editor roles are always concerning
|
|
if strings.Contains(role, "admin") || strings.Contains(role, "Admin") {
|
|
reasons = append(reasons, "Role contains 'admin' permissions")
|
|
score += 2
|
|
}
|
|
|
|
if strings.Contains(role, "editor") || strings.Contains(role, "Editor") {
|
|
reasons = append(reasons, "Role contains 'editor' permissions")
|
|
score += 2
|
|
}
|
|
|
|
// Service account cross-project is higher risk than user
|
|
if strings.HasPrefix(member, "serviceAccount:") {
|
|
reasons = append(reasons, "Service account has cross-project access (can be automated)")
|
|
score += 1
|
|
}
|
|
|
|
// Unknown source project is concerning
|
|
if !isFromKnownProject {
|
|
reasons = append(reasons, "Access from project outside analyzed scope")
|
|
score += 1
|
|
}
|
|
|
|
if score >= 4 {
|
|
return "CRITICAL", reasons
|
|
} else if score >= 3 {
|
|
return "HIGH", reasons
|
|
} else if score >= 2 {
|
|
return "MEDIUM", reasons
|
|
} else if score >= 1 {
|
|
return "LOW", reasons
|
|
}
|
|
return "INFO", reasons
|
|
}
|
|
|
|
// generateExploitCommands generates exploitation commands for a cross-project binding
|
|
func (s *CrossProjectService) generateExploitCommands(binding CrossProjectBinding) []string {
|
|
var commands []string
|
|
|
|
// Build impersonation flag if service account
|
|
impersonateFlag := ""
|
|
if binding.PrincipalType == "serviceAccount" {
|
|
email := strings.TrimPrefix(binding.Principal, "serviceAccount:")
|
|
impersonateFlag = fmt.Sprintf(" --impersonate-service-account=%s", email)
|
|
}
|
|
|
|
roleLower := strings.ToLower(binding.Role)
|
|
|
|
// Role-specific exploitation commands
|
|
if strings.Contains(roleLower, "owner") || strings.Contains(roleLower, "editor") {
|
|
commands = append(commands,
|
|
fmt.Sprintf("gcloud compute instances list --project=%s%s", binding.TargetProject, impersonateFlag),
|
|
fmt.Sprintf("gcloud secrets list --project=%s%s", binding.TargetProject, impersonateFlag),
|
|
fmt.Sprintf("gsutil ls -p %s", binding.TargetProject),
|
|
)
|
|
} else if strings.Contains(roleLower, "storage") {
|
|
commands = append(commands,
|
|
fmt.Sprintf("gsutil ls -p %s", binding.TargetProject),
|
|
)
|
|
} else if strings.Contains(roleLower, "compute") {
|
|
commands = append(commands,
|
|
fmt.Sprintf("gcloud compute instances list --project=%s%s", binding.TargetProject, impersonateFlag),
|
|
)
|
|
} else if strings.Contains(roleLower, "secretmanager") {
|
|
commands = append(commands,
|
|
fmt.Sprintf("gcloud secrets list --project=%s%s", binding.TargetProject, impersonateFlag),
|
|
)
|
|
} else if strings.Contains(roleLower, "bigquery") {
|
|
commands = append(commands,
|
|
fmt.Sprintf("bq ls --project_id=%s", binding.TargetProject),
|
|
fmt.Sprintf("bq query --project_id=%s 'SELECT * FROM INFORMATION_SCHEMA.TABLES'", binding.TargetProject),
|
|
)
|
|
} else if strings.Contains(roleLower, "cloudsql") {
|
|
commands = append(commands,
|
|
fmt.Sprintf("gcloud sql instances list --project=%s%s", binding.TargetProject, impersonateFlag),
|
|
)
|
|
} else if strings.Contains(roleLower, "serviceaccounttokencreator") || strings.Contains(roleLower, "serviceaccountkeyadmin") {
|
|
commands = append(commands,
|
|
fmt.Sprintf("gcloud iam service-accounts list --project=%s%s", binding.TargetProject, impersonateFlag),
|
|
)
|
|
}
|
|
|
|
return commands
|
|
}
|
|
|
|
// generateLateralMovementCommands generates commands for lateral movement
|
|
func (s *CrossProjectService) generateLateralMovementCommands(path LateralMovementPath) []string {
|
|
var commands []string
|
|
|
|
// Build impersonation flag if service account
|
|
impersonateFlag := ""
|
|
if strings.HasPrefix(path.SourcePrincipal, "serviceAccount:") {
|
|
email := strings.TrimPrefix(path.SourcePrincipal, "serviceAccount:")
|
|
impersonateFlag = fmt.Sprintf(" --impersonate-service-account=%s", email)
|
|
}
|
|
|
|
// Add role-specific commands based on the most powerful role
|
|
for _, role := range path.TargetRoles {
|
|
roleLower := strings.ToLower(role)
|
|
if strings.Contains(roleLower, "owner") || strings.Contains(roleLower, "editor") {
|
|
commands = append(commands,
|
|
fmt.Sprintf("gcloud compute instances list --project=%s%s", path.TargetProject, impersonateFlag),
|
|
fmt.Sprintf("gcloud secrets list --project=%s%s", path.TargetProject, impersonateFlag),
|
|
fmt.Sprintf("gsutil ls -p %s", path.TargetProject),
|
|
)
|
|
break // owner/editor covers everything, no need for more specific commands
|
|
} else if strings.Contains(roleLower, "storage") {
|
|
commands = append(commands,
|
|
fmt.Sprintf("gsutil ls -p %s", path.TargetProject),
|
|
)
|
|
} else if strings.Contains(roleLower, "compute") {
|
|
commands = append(commands,
|
|
fmt.Sprintf("gcloud compute instances list --project=%s%s", path.TargetProject, impersonateFlag),
|
|
)
|
|
} else if strings.Contains(roleLower, "secretmanager") {
|
|
commands = append(commands,
|
|
fmt.Sprintf("gcloud secrets list --project=%s%s", path.TargetProject, impersonateFlag),
|
|
)
|
|
} else if strings.Contains(roleLower, "bigquery") {
|
|
commands = append(commands,
|
|
fmt.Sprintf("bq ls --project_id=%s", path.TargetProject),
|
|
)
|
|
}
|
|
}
|
|
|
|
return commands
|
|
}
|
|
|
|
// extractProjectFromMember extracts the project ID from a member string.
|
|
// If orgCache is provided, it resolves project numbers to IDs.
|
|
func extractProjectFromMember(member string, orgCache *gcpinternal.OrgCache) string {
|
|
if !strings.HasPrefix(member, "serviceAccount:") {
|
|
return ""
|
|
}
|
|
|
|
email := strings.TrimPrefix(member, "serviceAccount:")
|
|
parts := strings.Split(email, "@")
|
|
if len(parts) != 2 {
|
|
return ""
|
|
}
|
|
|
|
prefix := parts[0]
|
|
domain := parts[1]
|
|
|
|
// Helper to resolve a project number to ID via OrgCache
|
|
resolveNumber := func(number string) string {
|
|
if orgCache != nil && orgCache.IsPopulated() {
|
|
if resolved := orgCache.GetProjectIDByNumber(number); resolved != "" {
|
|
return resolved
|
|
}
|
|
}
|
|
return "" // Can't resolve without cache
|
|
}
|
|
|
|
// Pattern: [email protected] (regular SAs)
|
|
// But NOT gcp-sa-* domains (those are Google service agents with project numbers)
|
|
if strings.HasSuffix(domain, ".iam.gserviceaccount.com") && !strings.HasPrefix(domain, "gcp-sa-") {
|
|
projectPart := strings.TrimSuffix(domain, ".iam.gserviceaccount.com")
|
|
return projectPart
|
|
}
|
|
|
|
// Pattern: service-PROJECT_NUMBER@gcp-sa-*.iam.gserviceaccount.com
|
|
if strings.HasPrefix(domain, "gcp-sa-") && strings.HasSuffix(domain, ".iam.gserviceaccount.com") {
|
|
number := prefix
|
|
if strings.HasPrefix(prefix, "service-") {
|
|
number = strings.TrimPrefix(prefix, "service-")
|
|
}
|
|
if resolved := resolveNumber(number); resolved != "" {
|
|
return resolved
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Pattern: [email protected]
|
|
if domain == "appspot.gserviceaccount.com" {
|
|
return prefix // This is already a project ID
|
|
}
|
|
|
|
// Pattern: [email protected]
|
|
if strings.HasSuffix(domain, "developer.gserviceaccount.com") {
|
|
if idx := strings.Index(prefix, "-compute"); idx > 0 {
|
|
number := prefix[:idx]
|
|
if resolved := resolveNumber(number); resolved != "" {
|
|
return resolved
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Pattern: [email protected]
|
|
if domain == "cloudservices.gserviceaccount.com" {
|
|
if resolved := resolveNumber(prefix); resolved != "" {
|
|
return resolved
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Pattern: [email protected]
|
|
if domain == "cloudbuild.gserviceaccount.com" {
|
|
if resolved := resolveNumber(prefix); resolved != "" {
|
|
return resolved
|
|
}
|
|
return ""
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// extractPrincipalType extracts the type of principal from a member string
|
|
func extractPrincipalType(member string) string {
|
|
if strings.HasPrefix(member, "serviceAccount:") {
|
|
return "serviceAccount"
|
|
} else if strings.HasPrefix(member, "user:") {
|
|
return "user"
|
|
} else if strings.HasPrefix(member, "group:") {
|
|
return "group"
|
|
} else if strings.HasPrefix(member, "domain:") {
|
|
return "domain"
|
|
}
|
|
return "unknown"
|
|
}
|
|
|
|
// categorizePrivilegeLevel categorizes the privilege level of a role
|
|
func categorizePrivilegeLevel(role string) string {
|
|
if strings.Contains(role, "owner") || strings.Contains(role, "Owner") {
|
|
return "ADMIN"
|
|
}
|
|
if strings.Contains(role, "admin") || strings.Contains(role, "Admin") {
|
|
return "ADMIN"
|
|
}
|
|
if strings.Contains(role, "editor") || strings.Contains(role, "Editor") {
|
|
return "WRITE"
|
|
}
|
|
if strings.Contains(role, "writer") || strings.Contains(role, "Writer") {
|
|
return "WRITE"
|
|
}
|
|
if strings.Contains(role, "creator") || strings.Contains(role, "Creator") {
|
|
return "WRITE"
|
|
}
|
|
if strings.Contains(role, "viewer") || strings.Contains(role, "Viewer") {
|
|
return "READ"
|
|
}
|
|
if strings.Contains(role, "reader") || strings.Contains(role, "Reader") {
|
|
return "READ"
|
|
}
|
|
return "READ" // Default to READ for unknown
|
|
}
|
|
|
|
// FindCrossProjectLoggingSinks discovers logging sinks that export to other projects
|
|
func (s *CrossProjectService) FindCrossProjectLoggingSinks(projectIDs []string) ([]CrossProjectLoggingSink, error) {
|
|
ctx := context.Background()
|
|
|
|
loggingService, err := s.getLoggingService(ctx)
|
|
if err != nil {
|
|
return nil, gcpinternal.ParseGCPError(err, "logging.googleapis.com")
|
|
}
|
|
|
|
// Build project lookup map
|
|
projectMap := make(map[string]bool)
|
|
for _, p := range projectIDs {
|
|
projectMap[p] = true
|
|
}
|
|
|
|
var crossProjectSinks []CrossProjectLoggingSink
|
|
|
|
for _, sourceProject := range projectIDs {
|
|
parent := fmt.Sprintf("projects/%s", sourceProject)
|
|
req := loggingService.Projects.Sinks.List(parent)
|
|
err := req.Pages(ctx, func(page *logging.ListSinksResponse) error {
|
|
for _, sink := range page.Sinks {
|
|
// Parse destination to extract target project
|
|
destType, targetProject := parseLoggingDestination(sink.Destination)
|
|
|
|
// Check if this is a cross-project sink
|
|
if targetProject != "" && targetProject != sourceProject {
|
|
riskLevel, riskReasons := analyzeLoggingSinkRisk(sink, targetProject, projectMap)
|
|
|
|
crossSink := CrossProjectLoggingSink{
|
|
SourceProject: sourceProject,
|
|
SinkName: sink.Name,
|
|
Destination: sink.Destination,
|
|
DestinationType: destType,
|
|
TargetProject: targetProject,
|
|
Filter: sink.Filter,
|
|
RiskLevel: riskLevel,
|
|
RiskReasons: riskReasons,
|
|
}
|
|
crossProjectSinks = append(crossProjectSinks, crossSink)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
// Continue with other projects
|
|
continue
|
|
}
|
|
}
|
|
|
|
return crossProjectSinks, nil
|
|
}
|
|
|
|
// parseLoggingDestination parses a logging sink destination to extract type and project
|
|
func parseLoggingDestination(destination string) (destType, projectID string) {
|
|
// Destination formats:
|
|
// storage.googleapis.com/BUCKET_NAME
|
|
// bigquery.googleapis.com/projects/PROJECT_ID/datasets/DATASET_ID
|
|
// pubsub.googleapis.com/projects/PROJECT_ID/topics/TOPIC_ID
|
|
// logging.googleapis.com/projects/PROJECT_ID/locations/LOCATION/buckets/BUCKET_ID
|
|
|
|
if strings.HasPrefix(destination, "storage.googleapis.com/") {
|
|
// GCS bucket - need to look up bucket to get project (not easily extractable)
|
|
return "storage", ""
|
|
}
|
|
|
|
if strings.HasPrefix(destination, "bigquery.googleapis.com/") {
|
|
destType = "bigquery"
|
|
// Format: bigquery.googleapis.com/projects/PROJECT_ID/datasets/DATASET_ID
|
|
parts := strings.Split(destination, "/")
|
|
for i, part := range parts {
|
|
if part == "projects" && i+1 < len(parts) {
|
|
return destType, parts[i+1]
|
|
}
|
|
}
|
|
}
|
|
|
|
if strings.HasPrefix(destination, "pubsub.googleapis.com/") {
|
|
destType = "pubsub"
|
|
// Format: pubsub.googleapis.com/projects/PROJECT_ID/topics/TOPIC_ID
|
|
parts := strings.Split(destination, "/")
|
|
for i, part := range parts {
|
|
if part == "projects" && i+1 < len(parts) {
|
|
return destType, parts[i+1]
|
|
}
|
|
}
|
|
}
|
|
|
|
if strings.HasPrefix(destination, "logging.googleapis.com/") {
|
|
destType = "logging"
|
|
// Format: logging.googleapis.com/projects/PROJECT_ID/locations/LOCATION/buckets/BUCKET_ID
|
|
parts := strings.Split(destination, "/")
|
|
for i, part := range parts {
|
|
if part == "projects" && i+1 < len(parts) {
|
|
return destType, parts[i+1]
|
|
}
|
|
}
|
|
}
|
|
|
|
return "unknown", ""
|
|
}
|
|
|
|
// analyzeLoggingSinkRisk analyzes the risk level of a cross-project logging sink
|
|
func analyzeLoggingSinkRisk(sink *logging.LogSink, targetProject string, knownProjects map[string]bool) (string, []string) {
|
|
var reasons []string
|
|
score := 0
|
|
|
|
// External project is higher risk
|
|
if !knownProjects[targetProject] {
|
|
reasons = append(reasons, "Logs exported to project outside analyzed scope")
|
|
score += 2
|
|
}
|
|
|
|
// Check if filter is broad (empty = all logs)
|
|
if sink.Filter == "" {
|
|
reasons = append(reasons, "No filter - ALL logs exported")
|
|
score += 2
|
|
}
|
|
|
|
// Check for sensitive log types in filter
|
|
sensitiveLogTypes := []string{"data_access", "admin_activity", "cloudaudit"}
|
|
for _, lt := range sensitiveLogTypes {
|
|
if strings.Contains(sink.Filter, lt) {
|
|
reasons = append(reasons, fmt.Sprintf("Exports sensitive logs: %s", lt))
|
|
score += 1
|
|
}
|
|
}
|
|
|
|
// Check if sink has service account (writerIdentity)
|
|
if sink.WriterIdentity != "" {
|
|
reasons = append(reasons, fmt.Sprintf("Service account: %s", sink.WriterIdentity))
|
|
}
|
|
|
|
if score >= 3 {
|
|
return "HIGH", reasons
|
|
} else if score >= 2 {
|
|
return "MEDIUM", reasons
|
|
}
|
|
return "LOW", reasons
|
|
}
|
|
|
|
// FindCrossProjectPubSubExports discovers Pub/Sub subscriptions that export to other projects
|
|
func (s *CrossProjectService) FindCrossProjectPubSubExports(projectIDs []string) ([]CrossProjectPubSubExport, error) {
|
|
ctx := context.Background()
|
|
|
|
pubsubService, err := s.getPubSubService(ctx)
|
|
if err != nil {
|
|
return nil, gcpinternal.ParseGCPError(err, "pubsub.googleapis.com")
|
|
}
|
|
|
|
// Build project lookup map
|
|
projectMap := make(map[string]bool)
|
|
for _, p := range projectIDs {
|
|
projectMap[p] = true
|
|
}
|
|
|
|
var crossProjectExports []CrossProjectPubSubExport
|
|
|
|
for _, sourceProject := range projectIDs {
|
|
// List all subscriptions in project
|
|
parent := fmt.Sprintf("projects/%s", sourceProject)
|
|
req := pubsubService.Projects.Subscriptions.List(parent)
|
|
err := req.Pages(ctx, func(page *pubsub.ListSubscriptionsResponse) error {
|
|
for _, sub := range page.Subscriptions {
|
|
// Extract subscription name and topic project
|
|
subName := extractResourceNameFromPath(sub.Name)
|
|
topicProject := extractProjectFromPath(sub.Topic)
|
|
|
|
var exportType, exportDest, targetProject string
|
|
|
|
// Check for BigQuery export
|
|
if sub.BigqueryConfig != nil && sub.BigqueryConfig.Table != "" {
|
|
exportType = "bigquery"
|
|
exportDest = sub.BigqueryConfig.Table
|
|
// Extract project from table: PROJECT:DATASET.TABLE
|
|
if parts := strings.Split(sub.BigqueryConfig.Table, ":"); len(parts) > 0 {
|
|
targetProject = parts[0]
|
|
}
|
|
}
|
|
|
|
// Check for Cloud Storage export
|
|
if sub.CloudStorageConfig != nil && sub.CloudStorageConfig.Bucket != "" {
|
|
exportType = "cloudstorage"
|
|
exportDest = sub.CloudStorageConfig.Bucket
|
|
// Bucket project not easily extractable without additional API call
|
|
targetProject = ""
|
|
}
|
|
|
|
// Check for push endpoint
|
|
if sub.PushConfig != nil && sub.PushConfig.PushEndpoint != "" {
|
|
exportType = "push"
|
|
exportDest = sub.PushConfig.PushEndpoint
|
|
// External push endpoints can't be mapped to a project
|
|
targetProject = "external"
|
|
}
|
|
|
|
// Check if subscription is to a topic in another project
|
|
if topicProject != "" && topicProject != sourceProject {
|
|
// This is a cross-project topic subscription
|
|
riskLevel, riskReasons := analyzePubSubExportRisk(sub, targetProject, projectMap, topicProject, sourceProject)
|
|
export := CrossProjectPubSubExport{
|
|
SourceProject: sourceProject,
|
|
TopicProject: topicProject,
|
|
TopicName: extractResourceNameFromPath(sub.Topic),
|
|
SubscriptionName: subName,
|
|
ExportType: "cross-project-topic",
|
|
ExportDest: sub.Topic,
|
|
TargetProject: topicProject,
|
|
RiskLevel: riskLevel,
|
|
RiskReasons: riskReasons,
|
|
}
|
|
crossProjectExports = append(crossProjectExports, export)
|
|
}
|
|
|
|
// If exporting to another project via BQ/GCS
|
|
if targetProject != "" && targetProject != sourceProject && targetProject != "external" {
|
|
riskLevel, riskReasons := analyzePubSubExportRisk(sub, targetProject, projectMap, topicProject, sourceProject)
|
|
export := CrossProjectPubSubExport{
|
|
SourceProject: sourceProject,
|
|
TopicProject: topicProject,
|
|
TopicName: extractResourceNameFromPath(sub.Topic),
|
|
SubscriptionName: subName,
|
|
ExportType: exportType,
|
|
ExportDest: exportDest,
|
|
TargetProject: targetProject,
|
|
RiskLevel: riskLevel,
|
|
RiskReasons: riskReasons,
|
|
}
|
|
crossProjectExports = append(crossProjectExports, export)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
// Continue with other projects
|
|
continue
|
|
}
|
|
}
|
|
|
|
return crossProjectExports, nil
|
|
}
|
|
|
|
// extractResourceNameFromPath extracts the resource name from a full path
|
|
func extractResourceNameFromPath(path string) string {
|
|
parts := strings.Split(path, "/")
|
|
if len(parts) > 0 {
|
|
return parts[len(parts)-1]
|
|
}
|
|
return path
|
|
}
|
|
|
|
// extractProjectFromPath extracts the project ID from a resource path
|
|
func extractProjectFromPath(path string) string {
|
|
// Format: projects/PROJECT_ID/...
|
|
parts := strings.Split(path, "/")
|
|
for i, part := range parts {
|
|
if part == "projects" && i+1 < len(parts) {
|
|
return parts[i+1]
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// analyzePubSubExportRisk analyzes the risk level of a cross-project Pub/Sub export
|
|
func analyzePubSubExportRisk(sub *pubsub.Subscription, targetProject string, knownProjects map[string]bool, topicProject, sourceProject string) (string, []string) {
|
|
var reasons []string
|
|
score := 0
|
|
|
|
// External target project is higher risk
|
|
if targetProject != "" && !knownProjects[targetProject] {
|
|
reasons = append(reasons, "Data exported to project outside analyzed scope")
|
|
score += 2
|
|
}
|
|
|
|
// Cross-project topic subscription
|
|
if topicProject != "" && topicProject != sourceProject {
|
|
reasons = append(reasons, fmt.Sprintf("Subscription to topic in project %s", topicProject))
|
|
score += 1
|
|
}
|
|
|
|
// Push to external endpoint
|
|
if sub.PushConfig != nil && sub.PushConfig.PushEndpoint != "" {
|
|
endpoint := sub.PushConfig.PushEndpoint
|
|
reasons = append(reasons, fmt.Sprintf("Push endpoint: %s", endpoint))
|
|
// External endpoints are high risk
|
|
if !strings.Contains(endpoint, ".run.app") && !strings.Contains(endpoint, ".cloudfunctions.net") {
|
|
reasons = append(reasons, "Push to external (non-GCP) endpoint")
|
|
score += 2
|
|
}
|
|
}
|
|
|
|
// BigQuery export
|
|
if sub.BigqueryConfig != nil {
|
|
reasons = append(reasons, fmt.Sprintf("BigQuery export: %s", sub.BigqueryConfig.Table))
|
|
score += 1
|
|
}
|
|
|
|
// Cloud Storage export
|
|
if sub.CloudStorageConfig != nil {
|
|
reasons = append(reasons, fmt.Sprintf("Cloud Storage export: %s", sub.CloudStorageConfig.Bucket))
|
|
score += 1
|
|
}
|
|
|
|
if score >= 3 {
|
|
return "HIGH", reasons
|
|
} else if score >= 2 {
|
|
return "MEDIUM", reasons
|
|
}
|
|
return "LOW", reasons
|
|
}
|