mirror of
https://github.com/BishopFox/cloudfox.git
synced 2026-11-07 09:56:01 -10:00
removed attackpath and orgcache flags and enable by default
This commit is contained in:
23 files changed
+384
-471
No files matched your search
@@ -301,21 +301,22 @@ For detailed documentation on each GCP command, see the [GCP Commands Wiki](http
|
||||
|
||||
CloudFox GCP uses a **unified attack path analysis** system that combines privilege escalation, lateral movement, and data exfiltration analysis. The three attack path modules share a common backend (`attackpathService`) that analyzes IAM policies across all 4 hierarchy levels: Organization → Folder → Project → Resource.
|
||||
|
||||
### `--attack-paths` Global Flag
|
||||
### Attack Path Column
|
||||
|
||||
When running compute/service modules, add `--attack-paths` to see attack path capabilities for service accounts:
|
||||
CloudFox automatically loads FoxMapper graph data when available and shows attack path capabilities in module output. Run foxmapper first to populate the Attack Paths column:
|
||||
|
||||
```bash
|
||||
# Run instances module with attack path analysis
|
||||
cloudfox gcp instances -p my-project --attack-paths
|
||||
# First, generate the attack path graph
|
||||
foxmapper gcp graph create -p my-project
|
||||
|
||||
# Run service accounts with attack paths
|
||||
cloudfox gcp serviceaccounts --all-projects --attack-paths
|
||||
# Then run cloudfox modules - Attack Paths column will be populated automatically
|
||||
cloudfox gcp instances -p my-project
|
||||
cloudfox gcp serviceaccounts --all-projects
|
||||
```
|
||||
|
||||
This adds an **"Attack Paths"** column showing: `Yes (P:3 E:2 L:1)` where P=Privesc, E=Exfil, L=Lateral counts.
|
||||
The **"Attack Paths"** column shows: `Yes (P:3 E:2 L:1)` where P=Privesc, E=Exfil, L=Lateral counts. If foxmapper hasn't been run, the column shows "run foxmapper".
|
||||
|
||||
**Modules supporting `--attack-paths`**: instances, serviceaccounts, functions, cloudrun, gke, composer, dataproc, dataflow, notebooks, cloudbuild, scheduler, appengine
|
||||
**Modules with Attack Paths column**: instances, serviceaccounts, functions, cloudrun, gke, composer, dataproc, dataflow, notebooks, cloudbuild, scheduler, appengine, service-agents
|
||||
|
||||
| Provider | Command Name | Description |
|
||||
| - | - | - |
|
||||
|
||||
+15
-24
@@ -32,12 +32,6 @@ var (
|
||||
GCPWrapTable bool
|
||||
GCPFlatOutput bool
|
||||
|
||||
// Attack path analysis flag
|
||||
GCPAttackPaths bool
|
||||
|
||||
// Organization cache flag - enumerates all orgs/folders/projects for cross-project analysis
|
||||
GCPOrgCache bool
|
||||
|
||||
// Refresh cache flag - force re-enumeration even if cache exists
|
||||
GCPRefreshCache bool
|
||||
|
||||
@@ -125,11 +119,9 @@ var (
|
||||
// Get account for cache operations
|
||||
account, _ := ctx.Value("account").(string)
|
||||
|
||||
// If --attack-paths flag is set, try to load FoxMapper data
|
||||
// Always try to load FoxMapper data for attack path analysis
|
||||
// This allows individual modules to show the Attack Paths column
|
||||
if GCPAttackPaths && len(GCPProjectIDs) > 0 {
|
||||
GCPLogger.InfoM("Looking for FoxMapper graph data...", "gcp")
|
||||
|
||||
if len(GCPProjectIDs) > 0 {
|
||||
// Get org ID from hierarchy if available (GCPOrganization flag may be empty)
|
||||
orgID := GCPOrganization
|
||||
if orgID == "" {
|
||||
@@ -144,21 +136,22 @@ var (
|
||||
if foxMapperCache != nil && foxMapperCache.IsPopulated() {
|
||||
ctx = gcpinternal.SetFoxMapperCacheInContext(ctx, foxMapperCache)
|
||||
totalNodes, adminNodes, nodesWithPrivesc := foxMapperCache.GetStats()
|
||||
GCPLogger.SuccessM(fmt.Sprintf("FoxMapper data loaded: %d principals, %d admins, %d with privesc - modules will show Attack Paths column",
|
||||
totalNodes, adminNodes, nodesWithPrivesc), "gcp")
|
||||
} else {
|
||||
GCPLogger.InfoM("No FoxMapper data found. Run 'foxmapper gcp graph create' to generate graph data for attack path analysis.", "gcp")
|
||||
ageDays := foxMapperCache.GetDataAgeDays()
|
||||
|
||||
if ageDays >= 7 {
|
||||
GCPLogger.WarnM(fmt.Sprintf("FoxMapper data is %d days old - consider running 'foxmapper gcp graph create' to refresh",
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// If --org-cache flag is set, load or enumerate all orgs/folders/projects
|
||||
// This is useful for cross-project analysis modules
|
||||
if GCPOrgCache {
|
||||
GCPLogger.InfoM("Loading/enumerating organization data...", "gcp")
|
||||
orgCache := loadOrPopulateOrgCache(account, GCPRefreshCache)
|
||||
if orgCache != nil && orgCache.IsPopulated() {
|
||||
ctx = gcpinternal.SetOrgCacheInContext(ctx, orgCache)
|
||||
}
|
||||
// Always try to load org cache for cross-project analysis
|
||||
// Cache auto-refreshes after 24 hours
|
||||
orgCache := loadOrPopulateOrgCache(account, GCPRefreshCache)
|
||||
if orgCache != nil && orgCache.IsPopulated() {
|
||||
ctx = gcpinternal.SetOrgCacheInContext(ctx, orgCache)
|
||||
}
|
||||
|
||||
cmd.SetContext(ctx)
|
||||
@@ -487,8 +480,6 @@ func init() {
|
||||
// GCPCommands.PersistentFlags().IntVarP(&Goroutines, "max-goroutines", "g", 30, "Maximum number of concurrent goroutines")
|
||||
GCPCommands.PersistentFlags().BoolVarP(&GCPWrapTable, "wrap", "w", false, "Wrap table to fit in terminal (complicates grepping)")
|
||||
GCPCommands.PersistentFlags().BoolVar(&GCPFlatOutput, "flat-output", false, "Use legacy flat output structure instead of hierarchical per-project directories")
|
||||
GCPCommands.PersistentFlags().BoolVar(&GCPAttackPaths, "attack-paths", false, "Run attack path analysis (privesc/exfil/lateral) and add Attack Paths column to module output")
|
||||
GCPCommands.PersistentFlags().BoolVar(&GCPOrgCache, "org-cache", false, "Enumerate all accessible orgs/folders/projects and cache for cross-project analysis")
|
||||
GCPCommands.PersistentFlags().BoolVar(&GCPRefreshCache, "refresh-cache", false, "Force re-enumeration of cached data (cache auto-expires after 24 hours)")
|
||||
|
||||
// Available commands
|
||||
|
||||
@@ -534,7 +534,7 @@ func (m *AppEngineModule) buildTablesForProject(projectID string, apps []AppEngi
|
||||
}
|
||||
|
||||
// Check attack paths (privesc/exfil/lateral) for the service account
|
||||
attackPaths := "run --attack-paths"
|
||||
attackPaths := "run foxmapper"
|
||||
if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
if ver.ServiceAccount != "" {
|
||||
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, ver.ServiceAccount)
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"google.golang.org/api/compute/v1"
|
||||
"google.golang.org/api/sqladmin/v1beta4"
|
||||
sqladmin "google.golang.org/api/sqladmin/v1beta4"
|
||||
)
|
||||
|
||||
// Module name constant
|
||||
@@ -62,18 +62,20 @@ type IAMBinding struct {
|
||||
}
|
||||
|
||||
type ComputeSnapshot struct {
|
||||
ProjectID string
|
||||
Name string
|
||||
SourceDisk string
|
||||
Status string
|
||||
DiskSizeGB int64
|
||||
StorageBytes int64
|
||||
CreationTime string
|
||||
StorageLocats []string
|
||||
AutoCreated bool
|
||||
SnapshotType string
|
||||
IAMBindings []IAMBinding
|
||||
PublicAccess bool
|
||||
ProjectID string
|
||||
Name string
|
||||
SourceDisk string
|
||||
Status string
|
||||
DiskSizeGB int64
|
||||
StorageBytes int64
|
||||
CreationTime string
|
||||
StorageLocats []string
|
||||
AutoCreated bool
|
||||
SnapshotType string
|
||||
IAMBindings []IAMBinding
|
||||
PublicAccess bool
|
||||
EncryptionType string
|
||||
KMSKeyName string
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
@@ -82,8 +84,8 @@ type ComputeSnapshot struct {
|
||||
type BackupInventoryModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
|
||||
ProjectResources map[string][]BackupResource // projectID -> resources
|
||||
ProjectSnapshots map[string][]ComputeSnapshot // projectID -> snapshots
|
||||
ProjectResources map[string][]BackupResource // projectID -> resources
|
||||
ProjectSnapshots map[string][]ComputeSnapshot // projectID -> snapshots
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
mu sync.Mutex
|
||||
|
||||
@@ -291,17 +293,31 @@ func (m *BackupInventoryModule) enumerateSnapshots(ctx context.Context, projectI
|
||||
req := computeService.Snapshots.List(projectID)
|
||||
err := req.Pages(ctx, func(page *compute.SnapshotList) error {
|
||||
for _, snapshot := range page.Items {
|
||||
// Determine encryption type and KMS key name
|
||||
encryptionType := "Google-managed"
|
||||
kmsKeyName := ""
|
||||
if snapshot.SnapshotEncryptionKey != nil {
|
||||
if snapshot.SnapshotEncryptionKey.KmsKeyName != "" {
|
||||
encryptionType = "CMEK"
|
||||
kmsKeyName = snapshot.SnapshotEncryptionKey.KmsKeyName
|
||||
} else if snapshot.SnapshotEncryptionKey.RawKey != "" || snapshot.SnapshotEncryptionKey.Sha256 != "" {
|
||||
encryptionType = "CSEK"
|
||||
}
|
||||
}
|
||||
|
||||
snap := ComputeSnapshot{
|
||||
ProjectID: projectID,
|
||||
Name: snapshot.Name,
|
||||
SourceDisk: snapshot.SourceDisk,
|
||||
Status: snapshot.Status,
|
||||
DiskSizeGB: snapshot.DiskSizeGb,
|
||||
StorageBytes: snapshot.StorageBytes,
|
||||
CreationTime: snapshot.CreationTimestamp,
|
||||
StorageLocats: snapshot.StorageLocations,
|
||||
AutoCreated: snapshot.AutoCreated,
|
||||
SnapshotType: snapshot.SnapshotType,
|
||||
ProjectID: projectID,
|
||||
Name: snapshot.Name,
|
||||
SourceDisk: snapshot.SourceDisk,
|
||||
Status: snapshot.Status,
|
||||
DiskSizeGB: snapshot.DiskSizeGb,
|
||||
StorageBytes: snapshot.StorageBytes,
|
||||
CreationTime: snapshot.CreationTimestamp,
|
||||
StorageLocats: snapshot.StorageLocations,
|
||||
AutoCreated: snapshot.AutoCreated,
|
||||
SnapshotType: snapshot.SnapshotType,
|
||||
EncryptionType: encryptionType,
|
||||
KMSKeyName: kmsKeyName,
|
||||
}
|
||||
|
||||
// Get IAM policy for this snapshot
|
||||
@@ -324,6 +340,34 @@ func (m *BackupInventoryModule) enumerateSnapshots(ctx context.Context, projectI
|
||||
m.mu.Lock()
|
||||
m.ProjectSnapshots[projectID] = append(m.ProjectSnapshots[projectID], snap)
|
||||
m.disksWithBackups[snapshot.SourceDisk] = true
|
||||
|
||||
// Add post-exploit commands for snapshots
|
||||
if m.LootMap[projectID] != nil {
|
||||
if lootFile := m.LootMap[projectID]["backup-inventory-commands"]; lootFile != nil {
|
||||
// Determine a zone from storage locations or use a default
|
||||
zone := "us-central1-a"
|
||||
if len(snapshot.StorageLocations) > 0 {
|
||||
zone = snapshot.StorageLocations[0] + "-a"
|
||||
}
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"### Snapshot: %s (Source: %s, Size: %dGB)\n"+
|
||||
"# Create a disk from this snapshot\n"+
|
||||
"gcloud compute disks create disk-from-%s \\\n"+
|
||||
" --project=%s \\\n"+
|
||||
" --zone=%s \\\n"+
|
||||
" --source-snapshot=%s\n\n"+
|
||||
"# Create an instance using a disk from this snapshot\n"+
|
||||
"gcloud compute instances create instance-from-%s \\\n"+
|
||||
" --project=%s \\\n"+
|
||||
" --zone=%s \\\n"+
|
||||
" --disk=name=disk-from-%s,boot=yes\n\n",
|
||||
snapshot.Name, m.extractDiskName(snapshot.SourceDisk), snapshot.DiskSizeGb,
|
||||
snapshot.Name, projectID, zone, snapshot.Name,
|
||||
snapshot.Name, projectID, zone, snapshot.Name,
|
||||
)
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
return nil
|
||||
@@ -504,18 +548,7 @@ func (m *BackupInventoryModule) identifyUnprotectedResources() {
|
||||
Contents: "# Backup Inventory Commands\n# Generated by CloudFox\n\n",
|
||||
}
|
||||
}
|
||||
if lootFile := m.LootMap[info.ProjectID]["backup-inventory-commands"]; lootFile != nil {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Unprotected disk: %s (%s) - %dGB\n"+
|
||||
"gcloud compute resource-policies create snapshot-schedule %s-backup \\\n"+
|
||||
" --project=%s \\\n"+
|
||||
" --region=%s \\\n"+
|
||||
" --max-retention-days=30 \\\n"+
|
||||
" --daily-schedule\n\n",
|
||||
info.Name, info.ProjectID, info.SizeGB,
|
||||
info.Name, info.ProjectID, m.extractRegionFromZone(info.Zone),
|
||||
)
|
||||
}
|
||||
// No loot commands for unprotected disks - these are informational only
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,15 +574,7 @@ func (m *BackupInventoryModule) identifyUnprotectedResources() {
|
||||
Contents: "# Backup Inventory Commands\n# Generated by CloudFox\n\n",
|
||||
}
|
||||
}
|
||||
if lootFile := m.LootMap[info.ProjectID]["backup-inventory-commands"]; lootFile != nil {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Unprotected SQL instance: %s\n"+
|
||||
"gcloud sql instances patch %s \\\n"+
|
||||
" --backup-start-time=02:00 \\\n"+
|
||||
" --enable-bin-log\n\n",
|
||||
instanceName, instanceName,
|
||||
)
|
||||
}
|
||||
// No loot commands for unprotected SQL instances - these are informational only
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -589,7 +614,6 @@ func (m *BackupInventoryModule) extractRegionFromZone(zone string) string {
|
||||
return zone
|
||||
}
|
||||
|
||||
|
||||
// ------------------------------
|
||||
// Output Generation
|
||||
// ------------------------------
|
||||
@@ -631,6 +655,7 @@ func (m *BackupInventoryModule) getSnapshotsHeader() []string {
|
||||
"Type",
|
||||
"Auto Created",
|
||||
"Locations",
|
||||
"Encryption",
|
||||
"IAM Binding Role",
|
||||
"IAM Binding Principal",
|
||||
"Public",
|
||||
@@ -698,6 +723,17 @@ func (m *BackupInventoryModule) snapshotsToTableBody(snapshots []ComputeSnapshot
|
||||
publicAccess = "Yes"
|
||||
}
|
||||
|
||||
// Format encryption - show KMS key name if CMEK
|
||||
encryptionDisplay := s.EncryptionType
|
||||
if s.EncryptionType == "CMEK" && s.KMSKeyName != "" {
|
||||
// Extract just the key name from the full path for display
|
||||
// Format: projects/PROJECT/locations/LOCATION/keyRings/RING/cryptoKeys/KEY
|
||||
keyParts := strings.Split(s.KMSKeyName, "/")
|
||||
if len(keyParts) >= 2 {
|
||||
encryptionDisplay = fmt.Sprintf("CMEK (%s)", keyParts[len(keyParts)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// If no IAM bindings, still show the snapshot
|
||||
if len(s.IAMBindings) == 0 {
|
||||
body = append(body, []string{
|
||||
@@ -710,6 +746,7 @@ func (m *BackupInventoryModule) snapshotsToTableBody(snapshots []ComputeSnapshot
|
||||
s.SnapshotType,
|
||||
autoCreatedStr,
|
||||
strings.Join(s.StorageLocats, ","),
|
||||
encryptionDisplay,
|
||||
"-",
|
||||
"-",
|
||||
publicAccess,
|
||||
@@ -728,6 +765,7 @@ func (m *BackupInventoryModule) snapshotsToTableBody(snapshots []ComputeSnapshot
|
||||
s.SnapshotType,
|
||||
autoCreatedStr,
|
||||
strings.Join(s.StorageLocats, ","),
|
||||
encryptionDisplay,
|
||||
binding.Role,
|
||||
member,
|
||||
publicAccess,
|
||||
|
||||
+18
-14
@@ -14,10 +14,10 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
bucketEnumMaxObjects int
|
||||
bucketEnumAllObjects bool
|
||||
bucketEnumNoLimit bool
|
||||
maxObjectsWasSet bool // tracks if --max-objects was explicitly set
|
||||
bucketEnumMaxObjects int
|
||||
bucketEnumAllObjects bool
|
||||
bucketEnumNoLimit bool
|
||||
maxObjectsWasSet bool // tracks if --max-objects was explicitly set
|
||||
)
|
||||
|
||||
var GCPBucketEnumCommand = &cobra.Command{
|
||||
@@ -42,16 +42,20 @@ File categories detected:
|
||||
- Source: Git repositories
|
||||
- Cloud: Cloud Functions source, build artifacts
|
||||
|
||||
Use --all-objects to enumerate ALL bucket contents (not just sensitive files).
|
||||
WARNING: Full enumeration may take a long time for buckets with many objects.
|
||||
Use --max-objects to limit the scan, or --no-limit for unlimited.`,
|
||||
Flags:
|
||||
--all-objects Report ALL bucket objects (not just sensitive files)
|
||||
--no-limit Remove the 1000 object-per-bucket scan limit
|
||||
--max-objects Set a custom object-per-bucket scan limit
|
||||
|
||||
By default, only sensitive files are reported with a 1000 object scan limit.
|
||||
WARNING: --all-objects and --no-limit may take a long time for large buckets.`,
|
||||
Run: runGCPBucketEnumCommand,
|
||||
}
|
||||
|
||||
func init() {
|
||||
GCPBucketEnumCommand.Flags().IntVar(&bucketEnumMaxObjects, "max-objects", 1000, "Maximum objects to scan per bucket (default 1000)")
|
||||
GCPBucketEnumCommand.Flags().BoolVar(&bucketEnumAllObjects, "all-objects", false, "Enumerate ALL bucket contents, not just sensitive files (implies --no-limit unless --max-objects is set)")
|
||||
GCPBucketEnumCommand.Flags().BoolVar(&bucketEnumNoLimit, "no-limit", false, "Remove the object limit (enumerate all objects in each bucket)")
|
||||
GCPBucketEnumCommand.Flags().IntVar(&bucketEnumMaxObjects, "max-objects", 1000, "Maximum objects to scan per bucket")
|
||||
GCPBucketEnumCommand.Flags().BoolVar(&bucketEnumAllObjects, "all-objects", false, "Report ALL objects, not just sensitive files (implies --no-limit unless --max-objects is set)")
|
||||
GCPBucketEnumCommand.Flags().BoolVar(&bucketEnumNoLimit, "no-limit", false, "Remove the 1000 object-per-bucket scan limit (still only reports sensitive files)")
|
||||
}
|
||||
|
||||
type BucketEnumModule struct {
|
||||
@@ -290,14 +294,14 @@ func (m *BucketEnumModule) addFileToLoot(projectID string, file bucketenumservic
|
||||
localDir := fmt.Sprintf("bucket/%s/%s", file.BucketName, getObjectDir(file.ObjectName))
|
||||
localCpCmd := fmt.Sprintf("gsutil cp gs://%s/%s %s", file.BucketName, file.ObjectName, localDir)
|
||||
|
||||
// All files go to the general commands file
|
||||
// All files go to the general commands file (without risk ranking)
|
||||
if lootFile := m.LootMap[projectID]["bucket-enum-commands"]; lootFile != nil {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# [%s] %s - gs://%s/%s\n"+
|
||||
"# Category: %s, Size: %d bytes\n"+
|
||||
"# %s - gs://%s/%s\n"+
|
||||
"# %s, Size: %d bytes\n"+
|
||||
"mkdir -p %s\n"+
|
||||
"%s\n\n",
|
||||
file.RiskLevel, file.Category,
|
||||
file.Category,
|
||||
file.BucketName, file.ObjectName,
|
||||
file.Description, file.Size,
|
||||
localDir,
|
||||
|
||||
+2
-118
@@ -49,7 +49,6 @@ type CertManagerModule struct {
|
||||
ProjectCertificates map[string][]certmanagerservice.Certificate // projectID -> certificates
|
||||
ProjectSSLCertificates map[string][]certmanagerservice.SSLCertificate // projectID -> SSL certs
|
||||
ProjectCertMaps map[string][]certmanagerservice.CertificateMap // projectID -> cert maps
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
@@ -78,7 +77,6 @@ func runGCPCertManagerCommand(cmd *cobra.Command, args []string) {
|
||||
ProjectCertificates: make(map[string][]certmanagerservice.Certificate),
|
||||
ProjectSSLCertificates: make(map[string][]certmanagerservice.SSLCertificate),
|
||||
ProjectCertMaps: make(map[string][]certmanagerservice.CertificateMap),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
@@ -166,17 +164,6 @@ func (m *CertManagerModule) processProject(ctx context.Context, projectID string
|
||||
|
||||
svc := certmanagerservice.New()
|
||||
|
||||
m.mu.Lock()
|
||||
// Initialize loot for this project
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["certmanager-details"] = &internal.LootFile{
|
||||
Name: "certmanager-details",
|
||||
Contents: "# Certificate Manager Details\n# Generated by CloudFox\n\n",
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// Get Certificate Manager certs
|
||||
certs, err := svc.GetCertificates(projectID)
|
||||
if err != nil {
|
||||
@@ -205,93 +192,9 @@ func (m *CertManagerModule) processProject(ctx context.Context, projectID string
|
||||
m.ProjectCertificates[projectID] = certs
|
||||
m.ProjectSSLCertificates[projectID] = sslCerts
|
||||
m.ProjectCertMaps[projectID] = certMaps
|
||||
|
||||
for _, cert := range certs {
|
||||
m.addCertToLoot(projectID, cert)
|
||||
}
|
||||
for _, cert := range sslCerts {
|
||||
m.addSSLCertToLoot(projectID, cert)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Loot File Management
|
||||
// ------------------------------
|
||||
func (m *CertManagerModule) addCertToLoot(projectID string, cert certmanagerservice.Certificate) {
|
||||
lootFile := m.LootMap[projectID]["certmanager-details"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
// Build flags for special attributes
|
||||
var flags []string
|
||||
if cert.Wildcard {
|
||||
flags = append(flags, "WILDCARD")
|
||||
}
|
||||
if cert.Expired {
|
||||
flags = append(flags, "EXPIRED")
|
||||
} else if cert.DaysUntilExpiry <= 30 {
|
||||
flags = append(flags, "EXPIRING")
|
||||
}
|
||||
if cert.SelfManaged {
|
||||
flags = append(flags, "SELF-MANAGED")
|
||||
}
|
||||
|
||||
flagStr := ""
|
||||
if len(flags) > 0 {
|
||||
flagStr = " [" + strings.Join(flags, "] [") + "]"
|
||||
}
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# %s%s\n"+
|
||||
"Project: %s | Location: %s\n"+
|
||||
"Type: %s | State: %s\n"+
|
||||
"Domains: %s\n"+
|
||||
"Expires: %s (%d days)\n\n",
|
||||
cert.Name, flagStr,
|
||||
cert.ProjectID, cert.Location,
|
||||
cert.Type, cert.State,
|
||||
strings.Join(cert.Domains, ", "),
|
||||
cert.ExpireTime, cert.DaysUntilExpiry,
|
||||
)
|
||||
}
|
||||
|
||||
func (m *CertManagerModule) addSSLCertToLoot(projectID string, cert certmanagerservice.SSLCertificate) {
|
||||
lootFile := m.LootMap[projectID]["certmanager-details"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
// Build flags for special attributes
|
||||
var flags []string
|
||||
if cert.Wildcard {
|
||||
flags = append(flags, "WILDCARD")
|
||||
}
|
||||
if cert.Expired {
|
||||
flags = append(flags, "EXPIRED")
|
||||
} else if cert.DaysUntilExpiry <= 30 {
|
||||
flags = append(flags, "EXPIRING")
|
||||
}
|
||||
if cert.SelfManaged {
|
||||
flags = append(flags, "SELF-MANAGED")
|
||||
}
|
||||
|
||||
flagStr := ""
|
||||
if len(flags) > 0 {
|
||||
flagStr = " [" + strings.Join(flags, "] [") + "]"
|
||||
}
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# %s (SSL Certificate)%s\n"+
|
||||
"Project: %s | Type: %s\n"+
|
||||
"Domains: %s\n"+
|
||||
"Expires: %s (%d days)\n\n",
|
||||
cert.Name, flagStr,
|
||||
cert.ProjectID, cert.Type,
|
||||
strings.Join(cert.Domains, ", "),
|
||||
cert.ExpireTime, cert.DaysUntilExpiry,
|
||||
)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Generation
|
||||
// ------------------------------
|
||||
@@ -429,17 +332,7 @@ func (m *CertManagerModule) writeHierarchicalOutput(ctx context.Context, logger
|
||||
|
||||
for projectID := range projectIDs {
|
||||
tableFiles := 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, "# Generated by CloudFox\n\n") {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = CertManagerOutput{Table: tableFiles, Loot: lootFiles}
|
||||
outputData.ProjectLevelData[projectID] = CertManagerOutput{Table: tableFiles}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
@@ -473,16 +366,7 @@ func (m *CertManagerModule) writeFlatOutput(ctx context.Context, logger internal
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := CertManagerOutput{Table: tables, Loot: lootFiles}
|
||||
output := CertManagerOutput{Table: tables}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, projectID := range m.ProjectIDs {
|
||||
|
||||
+80
-134
@@ -43,10 +43,9 @@ What this module finds:
|
||||
type CloudArmorModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
|
||||
ProjectPolicies map[string][]cloudarmorservice.SecurityPolicy // projectID -> policies
|
||||
UnprotectedLBs map[string][]string // projectID -> LB names
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
mu sync.Mutex
|
||||
ProjectPolicies map[string][]cloudarmorservice.SecurityPolicy // projectID -> policies
|
||||
UnprotectedLBs map[string][]string // projectID -> LB names
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
@@ -73,7 +72,6 @@ func runGCPCloudArmorCommand(cmd *cobra.Command, args []string) {
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectPolicies: make(map[string][]cloudarmorservice.SecurityPolicy),
|
||||
UnprotectedLBs: make(map[string][]string),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
@@ -130,17 +128,6 @@ func (m *CloudArmorModule) processProject(ctx context.Context, projectID string,
|
||||
logger.InfoM(fmt.Sprintf("Checking Cloud Armor in project: %s", projectID), globals.GCP_CLOUDARMOR_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
// Initialize loot for this project
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["cloudarmor-details"] = &internal.LootFile{
|
||||
Name: "cloudarmor-details",
|
||||
Contents: "# Cloud Armor Details\n# Generated by CloudFox\n\n",
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
svc := cloudarmorservice.New()
|
||||
|
||||
// Get security policies
|
||||
@@ -164,103 +151,9 @@ func (m *CloudArmorModule) processProject(ctx context.Context, projectID string,
|
||||
if len(unprotectedLBs) > 0 {
|
||||
m.UnprotectedLBs[projectID] = unprotectedLBs
|
||||
}
|
||||
|
||||
for _, policy := range policies {
|
||||
m.addPolicyToLoot(projectID, policy)
|
||||
}
|
||||
for _, lb := range unprotectedLBs {
|
||||
m.addUnprotectedLBToLoot(projectID, lb)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Loot File Management
|
||||
// ------------------------------
|
||||
func (m *CloudArmorModule) addPolicyToLoot(projectID string, policy cloudarmorservice.SecurityPolicy) {
|
||||
lootFile := m.LootMap[projectID]["cloudarmor-details"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Build flags for special attributes
|
||||
var flags []string
|
||||
if len(policy.Weaknesses) > 0 {
|
||||
flags = append(flags, "HAS WEAKNESSES")
|
||||
}
|
||||
|
||||
flagStr := ""
|
||||
if len(flags) > 0 {
|
||||
flagStr = " [" + strings.Join(flags, "] [") + "]"
|
||||
}
|
||||
|
||||
adaptive := "No"
|
||||
if policy.AdaptiveProtection {
|
||||
adaptive = "Yes"
|
||||
}
|
||||
|
||||
resources := "None"
|
||||
if len(policy.AttachedResources) > 0 {
|
||||
resources = strings.Join(policy.AttachedResources, ", ")
|
||||
}
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# %s%s\n"+
|
||||
"Project: %s | Type: %s\n"+
|
||||
"Rules: %d | Adaptive Protection: %s\n"+
|
||||
"Attached Resources: %s\n",
|
||||
policy.Name, flagStr,
|
||||
policy.ProjectID, policy.Type,
|
||||
policy.RuleCount, adaptive,
|
||||
resources,
|
||||
)
|
||||
|
||||
// Add weaknesses if any
|
||||
if len(policy.Weaknesses) > 0 {
|
||||
lootFile.Contents += "Weaknesses:\n"
|
||||
for _, weakness := range policy.Weaknesses {
|
||||
lootFile.Contents += fmt.Sprintf(" - %s\n", weakness)
|
||||
}
|
||||
}
|
||||
|
||||
// Add rules
|
||||
if len(policy.Rules) > 0 {
|
||||
lootFile.Contents += "Rules:\n"
|
||||
for _, rule := range policy.Rules {
|
||||
preview := ""
|
||||
if rule.Preview {
|
||||
preview = " [PREVIEW]"
|
||||
}
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
" - Priority %d: %s%s\n"+
|
||||
" Match: %s\n",
|
||||
rule.Priority, rule.Action, preview,
|
||||
rule.Match,
|
||||
)
|
||||
if rule.RateLimitConfig != nil {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
" Rate Limit: %d requests per %d seconds\n",
|
||||
rule.RateLimitConfig.ThresholdCount,
|
||||
rule.RateLimitConfig.IntervalSec,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lootFile.Contents += "\n"
|
||||
}
|
||||
|
||||
func (m *CloudArmorModule) addUnprotectedLBToLoot(projectID, lbName string) {
|
||||
if lootFile := m.LootMap[projectID]["cloudarmor-details"]; lootFile != nil {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# %s [UNPROTECTED]\n"+
|
||||
"Project: %s\n"+
|
||||
"No Cloud Armor policy attached\n\n",
|
||||
lbName, projectID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Generation
|
||||
// ------------------------------
|
||||
@@ -273,11 +166,15 @@ func (m *CloudArmorModule) writeOutput(ctx context.Context, logger internal.Logg
|
||||
}
|
||||
|
||||
func (m *CloudArmorModule) getPoliciesHeader() []string {
|
||||
return []string{"Project", "Name", "Type", "Rules", "Attached Resources", "Adaptive Protection"}
|
||||
return []string{"Project", "Policy", "Type", "Rules", "Adaptive", "DDoS", "Attached To", "Weaknesses"}
|
||||
}
|
||||
|
||||
func (m *CloudArmorModule) getRulesHeader() []string {
|
||||
return []string{"Project", "Policy", "Priority", "Action", "Preview", "Match", "Rate Limit"}
|
||||
}
|
||||
|
||||
func (m *CloudArmorModule) getUnprotectedLBsHeader() []string {
|
||||
return []string{"Project", "Backend Service"}
|
||||
return []string{"Project", "Backend Service", "Status"}
|
||||
}
|
||||
|
||||
func (m *CloudArmorModule) policiesToTableBody(policies []cloudarmorservice.SecurityPolicy) [][]string {
|
||||
@@ -288,29 +185,75 @@ func (m *CloudArmorModule) policiesToTableBody(policies []cloudarmorservice.Secu
|
||||
adaptive = "Yes"
|
||||
}
|
||||
|
||||
ddos := "-"
|
||||
if policy.DDOSProtection != "" {
|
||||
ddos = policy.DDOSProtection
|
||||
}
|
||||
|
||||
resources := "-"
|
||||
if len(policy.AttachedResources) > 0 {
|
||||
resources = strings.Join(policy.AttachedResources, ", ")
|
||||
}
|
||||
|
||||
weaknesses := "-"
|
||||
if len(policy.Weaknesses) > 0 {
|
||||
weaknesses = strings.Join(policy.Weaknesses, "; ")
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(policy.ProjectID),
|
||||
policy.Name,
|
||||
policy.Type,
|
||||
fmt.Sprintf("%d", policy.RuleCount),
|
||||
resources,
|
||||
adaptive,
|
||||
ddos,
|
||||
resources,
|
||||
weaknesses,
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *CloudArmorModule) rulesToTableBody(policies []cloudarmorservice.SecurityPolicy) [][]string {
|
||||
var body [][]string
|
||||
for _, policy := range policies {
|
||||
for _, rule := range policy.Rules {
|
||||
preview := "No"
|
||||
if rule.Preview {
|
||||
preview = "Yes"
|
||||
}
|
||||
|
||||
rateLimit := "-"
|
||||
if rule.RateLimitConfig != nil {
|
||||
rateLimit = fmt.Sprintf("%d/%ds", rule.RateLimitConfig.ThresholdCount, rule.RateLimitConfig.IntervalSec)
|
||||
}
|
||||
|
||||
match := rule.Match
|
||||
if len(match) > 80 {
|
||||
match = match[:77] + "..."
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(policy.ProjectID),
|
||||
policy.Name,
|
||||
fmt.Sprintf("%d", rule.Priority),
|
||||
rule.Action,
|
||||
preview,
|
||||
match,
|
||||
rateLimit,
|
||||
})
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *CloudArmorModule) unprotectedLBsToTableBody(projectID string, lbs []string) [][]string {
|
||||
var body [][]string
|
||||
for _, lb := range lbs {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(projectID),
|
||||
lb,
|
||||
"UNPROTECTED",
|
||||
})
|
||||
}
|
||||
return body
|
||||
@@ -325,6 +268,16 @@ func (m *CloudArmorModule) buildTablesForProject(projectID string) []internal.Ta
|
||||
Header: m.getPoliciesHeader(),
|
||||
Body: m.policiesToTableBody(policies),
|
||||
})
|
||||
|
||||
// Add rules table if there are rules
|
||||
rulesBody := m.rulesToTableBody(policies)
|
||||
if len(rulesBody) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "security-policy-rules",
|
||||
Header: m.getRulesHeader(),
|
||||
Body: rulesBody,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if lbs, ok := m.UnprotectedLBs[projectID]; ok && len(lbs) > 0 {
|
||||
@@ -355,17 +308,7 @@ func (m *CloudArmorModule) writeHierarchicalOutput(ctx context.Context, logger i
|
||||
|
||||
for projectID := range projectIDs {
|
||||
tableFiles := 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, "# Generated by CloudFox\n\n") {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = CloudArmorOutput{Table: tableFiles, Loot: lootFiles}
|
||||
outputData.ProjectLevelData[projectID] = CloudArmorOutput{Table: tableFiles}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
@@ -388,6 +331,18 @@ func (m *CloudArmorModule) writeFlatOutput(ctx context.Context, logger internal.
|
||||
})
|
||||
}
|
||||
|
||||
// Add rules table if there are rules
|
||||
if len(allPolicies) > 0 {
|
||||
rulesBody := m.rulesToTableBody(allPolicies)
|
||||
if len(rulesBody) > 0 {
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "security-policy-rules",
|
||||
Header: m.getRulesHeader(),
|
||||
Body: rulesBody,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Build unprotected LBs table from all projects
|
||||
var allUnprotectedBody [][]string
|
||||
for projectID, lbs := range m.UnprotectedLBs {
|
||||
@@ -401,16 +356,7 @@ func (m *CloudArmorModule) writeFlatOutput(ctx context.Context, logger internal.
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := CloudArmorOutput{Table: tables, Loot: lootFiles}
|
||||
output := CloudArmorOutput{Table: tables}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
|
||||
@@ -347,7 +347,7 @@ func (m *CloudBuildModule) triggersToTableBody(triggers []cloudbuildservice.Trig
|
||||
}
|
||||
|
||||
// Check attack paths (privesc/exfil/lateral) for the service account
|
||||
attackPaths := "run --attack-paths"
|
||||
attackPaths := "run foxmapper"
|
||||
if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
if sa != "(default)" && sa != "" {
|
||||
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, sa)
|
||||
|
||||
@@ -31,7 +31,7 @@ Security Columns:
|
||||
- Ingress: INGRESS_TRAFFIC_ALL (public), INTERNAL_ONLY, or INTERNAL_LOAD_BALANCER
|
||||
- Public: Whether allUsers or allAuthenticatedUsers can invoke the service
|
||||
- Service Account: The identity the service runs as
|
||||
- SA Attack Paths: Privesc/exfil/lateral movement potential (requires --attack-paths)
|
||||
- SA Attack Paths: Privesc/exfil/lateral movement potential (run foxmapper first)
|
||||
- VPC Access: Network connectivity to VPC resources
|
||||
- Env Vars: Count of plain environment variables
|
||||
- Secret Mgr: Count of env vars referencing Secret Manager (secure storage)
|
||||
@@ -44,10 +44,7 @@ Attack Surface:
|
||||
- Container images may contain vulnerabilities or secrets
|
||||
- Hardcoded secrets in env vars are a critical security risk
|
||||
|
||||
TIP: To see service account attack paths (privesc, exfil, lateral movement),
|
||||
use the global --attack-paths flag:
|
||||
|
||||
cloudfox gcp cloudrun -p PROJECT_ID --attack-paths`,
|
||||
TIP: Run foxmapper first to populate the SA Attack Paths column.`,
|
||||
Run: runGCPCloudRunCommand,
|
||||
}
|
||||
|
||||
@@ -469,7 +466,7 @@ func (m *CloudRunModule) buildTablesForProject(projectID string, services []Clou
|
||||
}
|
||||
|
||||
// Check attack paths (privesc/exfil/lateral) for the service account
|
||||
attackPaths := "run --attack-paths"
|
||||
attackPaths := "run foxmapper"
|
||||
if svc.ServiceAccount != "" {
|
||||
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, svc.ServiceAccount)
|
||||
} else if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
@@ -537,7 +534,7 @@ func (m *CloudRunModule) buildTablesForProject(projectID string, services []Clou
|
||||
}
|
||||
|
||||
// Check attack paths (privesc/exfil/lateral) for the service account
|
||||
jobAttackPaths := "run --attack-paths"
|
||||
jobAttackPaths := "run foxmapper"
|
||||
if job.ServiceAccount != "" {
|
||||
jobAttackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, job.ServiceAccount)
|
||||
} else if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
|
||||
@@ -201,7 +201,7 @@ func (m *ComposerModule) environmentsToTableBody(environments []composerservice.
|
||||
}
|
||||
|
||||
// Check attack paths (privesc/exfil/lateral) for the service account
|
||||
attackPaths := "run --attack-paths"
|
||||
attackPaths := "run foxmapper"
|
||||
if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
if sa != "(default)" && sa != "" {
|
||||
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, sa)
|
||||
|
||||
@@ -33,27 +33,27 @@ Features:
|
||||
- Discovers Pub/Sub subscriptions exporting to other projects (BQ, GCS, push)
|
||||
- Generates exploitation commands for lateral movement
|
||||
- Highlights service accounts spanning trust boundaries
|
||||
- Shows impersonation targets when --attack-paths flag is used
|
||||
- Shows impersonation targets (run foxmapper first for attack path analysis)
|
||||
|
||||
RECOMMENDED: For comprehensive cross-project analysis, use with -A and --org-cache
|
||||
to automatically discover all accessible projects in your organization:
|
||||
RECOMMENDED: For comprehensive cross-project analysis, use -A to analyze all accessible projects:
|
||||
|
||||
cloudfox gcp crossproject -A --org-cache --attack-paths
|
||||
cloudfox gcp crossproject -A
|
||||
|
||||
This will:
|
||||
- Discover all projects you have access to (--org-cache)
|
||||
- Analyze cross-project patterns across all of them (-A)
|
||||
- Include impersonation targets and attack paths (--attack-paths)
|
||||
- Use cached org/folder/project data (auto-populated, refreshes every 24h)
|
||||
- Analyze cross-project patterns across all accessible projects
|
||||
- Show "Trust Boundary" column indicating if target is Internal, External, or Unknown
|
||||
|
||||
TRUST BOUNDARY COLUMN (requires --org-cache):
|
||||
TRUST BOUNDARY COLUMN:
|
||||
- "Internal" - Target project is within your organization
|
||||
- "External" - Target project is outside your organization (trust boundary crossing!)
|
||||
- "Unknown" - Org cache not available, cannot determine boundary
|
||||
- "Unknown" - Cannot determine boundary
|
||||
|
||||
ALTERNATIVE: Specify projects manually with -l for a project list file:
|
||||
|
||||
cloudfox gcp crossproject -l projects.txt --attack-paths
|
||||
cloudfox gcp crossproject -l projects.txt
|
||||
|
||||
TIP: Run foxmapper first to populate the Attack Paths column.
|
||||
|
||||
WARNING: Requires multiple projects to be specified for effective analysis.
|
||||
Single project analysis (-p) will have limited results.`,
|
||||
@@ -124,7 +124,7 @@ func (m *CrossProjectModule) Execute(ctx context.Context, logger internal.Logger
|
||||
logger.InfoM("Using FoxMapper graph data for attack path analysis", globals.GCP_CROSSPROJECT_MODULE_NAME)
|
||||
}
|
||||
|
||||
// Get org cache from context (populated by --org-cache flag or all-checks)
|
||||
// Get org cache from context (auto-loaded at startup)
|
||||
m.OrgCache = gcpinternal.GetOrgCacheFromContext(ctx)
|
||||
|
||||
// If no context cache, try loading from disk cache
|
||||
@@ -234,11 +234,13 @@ func (m *CrossProjectModule) initializeLootFiles() {
|
||||
}
|
||||
|
||||
func (m *CrossProjectModule) addBindingToLoot(binding crossprojectservice.CrossProjectBinding) {
|
||||
// Add exploitation commands
|
||||
// Only add if there are exploitation commands
|
||||
if len(binding.ExploitCommands) > 0 {
|
||||
m.LootMap["crossproject-commands"].Contents += fmt.Sprintf(
|
||||
"# IAM Binding: %s -> %s\n# Principal: %s\n# Role: %s\n",
|
||||
binding.SourceProject, binding.TargetProject, binding.Principal, binding.Role,
|
||||
"### %s -> %s (%s)\n",
|
||||
m.GetProjectName(binding.SourceProject),
|
||||
m.GetProjectName(binding.TargetProject),
|
||||
cleanRole(binding.Role),
|
||||
)
|
||||
for _, cmd := range binding.ExploitCommands {
|
||||
m.LootMap["crossproject-commands"].Contents += cmd + "\n"
|
||||
@@ -248,66 +250,40 @@ func (m *CrossProjectModule) addBindingToLoot(binding crossprojectservice.CrossP
|
||||
}
|
||||
|
||||
func (m *CrossProjectModule) addServiceAccountToLoot(sa crossprojectservice.CrossProjectServiceAccount) {
|
||||
// Add impersonation commands for cross-project SAs
|
||||
m.LootMap["crossproject-commands"].Contents += fmt.Sprintf(
|
||||
"# Cross-project SA: %s (Home: %s)\n"+
|
||||
"gcloud auth print-access-token --impersonate-service-account=%s\n\n",
|
||||
sa.Email, sa.ProjectID, sa.Email,
|
||||
)
|
||||
// Skip - service account cross-project access is covered by bindings and lateral movement paths
|
||||
// Adding separate impersonation commands would be redundant
|
||||
}
|
||||
|
||||
func (m *CrossProjectModule) addLateralMovementToLoot(path crossprojectservice.LateralMovementPath) {
|
||||
// Add lateral movement exploitation commands
|
||||
m.LootMap["crossproject-commands"].Contents += fmt.Sprintf(
|
||||
"# Lateral Movement: %s -> %s\n"+
|
||||
"# Principal: %s\n"+
|
||||
"# Method: %s\n"+
|
||||
"# Target Roles: %s\n",
|
||||
path.SourceProject, path.TargetProject,
|
||||
path.SourcePrincipal,
|
||||
path.AccessMethod,
|
||||
strings.Join(path.TargetRoles, ", "),
|
||||
)
|
||||
|
||||
// Only add if there are exploitation commands
|
||||
if len(path.ExploitCommands) > 0 {
|
||||
// Clean up role names for display
|
||||
var cleanedRoles []string
|
||||
for _, r := range path.TargetRoles {
|
||||
cleanedRoles = append(cleanedRoles, cleanRole(r))
|
||||
}
|
||||
|
||||
m.LootMap["crossproject-commands"].Contents += fmt.Sprintf(
|
||||
"### %s -> %s (%s)\n",
|
||||
m.GetProjectName(path.SourceProject),
|
||||
m.GetProjectName(path.TargetProject),
|
||||
strings.Join(cleanedRoles, ", "),
|
||||
)
|
||||
for _, cmd := range path.ExploitCommands {
|
||||
m.LootMap["crossproject-commands"].Contents += cmd + "\n"
|
||||
}
|
||||
m.LootMap["crossproject-commands"].Contents += "\n"
|
||||
}
|
||||
m.LootMap["crossproject-commands"].Contents += "\n"
|
||||
}
|
||||
|
||||
func (m *CrossProjectModule) addLoggingSinkToLoot(sink crossprojectservice.CrossProjectLoggingSink) {
|
||||
m.LootMap["crossproject-commands"].Contents += fmt.Sprintf(
|
||||
"# Cross-Project Logging Sink: %s\n"+
|
||||
"# Source Project: %s -> Target Project: %s\n"+
|
||||
"# Destination: %s (%s)\n",
|
||||
sink.SinkName,
|
||||
sink.SourceProject, sink.TargetProject,
|
||||
sink.Destination, sink.DestinationType,
|
||||
)
|
||||
m.LootMap["crossproject-commands"].Contents += fmt.Sprintf(
|
||||
"gcloud logging sinks describe %s --project=%s\n\n",
|
||||
sink.SinkName, sink.SourceProject,
|
||||
)
|
||||
// Logging sinks are data exports, not direct exploitation paths
|
||||
// Skip adding to loot - the table output is sufficient
|
||||
}
|
||||
|
||||
func (m *CrossProjectModule) addPubSubExportToLoot(export crossprojectservice.CrossProjectPubSubExport) {
|
||||
m.LootMap["crossproject-commands"].Contents += fmt.Sprintf(
|
||||
"# Cross-Project Pub/Sub Export: %s\n"+
|
||||
"# Subscription: %s (Source: %s)\n"+
|
||||
"# Topic: %s (Project: %s)\n"+
|
||||
"# Export Type: %s -> Destination: %s\n",
|
||||
export.SubscriptionName,
|
||||
export.SubscriptionName, export.SourceProject,
|
||||
export.TopicName, export.TopicProject,
|
||||
export.ExportType,
|
||||
export.ExportDest,
|
||||
)
|
||||
m.LootMap["crossproject-commands"].Contents += fmt.Sprintf(
|
||||
"gcloud pubsub subscriptions describe %s --project=%s\n\n",
|
||||
export.SubscriptionName, export.SourceProject,
|
||||
)
|
||||
// Pub/Sub exports are data exports, not direct exploitation paths
|
||||
// Skip adding to loot - the table output is sufficient
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
@@ -634,6 +610,7 @@ func (m *CrossProjectModule) writeFlatOutput(ctx context.Context, logger interna
|
||||
lootFiles := m.collectLootFiles()
|
||||
|
||||
// Write output for each target project separately
|
||||
isFirstProject := true
|
||||
for targetProject, body := range bodyByProject {
|
||||
if len(body) == 0 {
|
||||
continue
|
||||
@@ -647,9 +624,16 @@ func (m *CrossProjectModule) writeFlatOutput(ctx context.Context, logger interna
|
||||
},
|
||||
}
|
||||
|
||||
// Only include loot files on the first project to avoid duplicate writes
|
||||
var projectLoot []internal.LootFile
|
||||
if isFirstProject {
|
||||
projectLoot = lootFiles
|
||||
isFirstProject = false
|
||||
}
|
||||
|
||||
output := CrossProjectOutput{
|
||||
Table: tables,
|
||||
Loot: lootFiles,
|
||||
Loot: projectLoot,
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart(
|
||||
|
||||
@@ -191,7 +191,7 @@ func (m *DataflowModule) jobsToTableBody(jobs []dataflowservice.JobInfo) [][]str
|
||||
}
|
||||
|
||||
// Check attack paths (privesc/exfil/lateral) for the service account
|
||||
attackPaths := "run --attack-paths"
|
||||
attackPaths := "run foxmapper"
|
||||
if job.ServiceAccount != "" && m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, job.ServiceAccount)
|
||||
} else {
|
||||
|
||||
@@ -201,7 +201,7 @@ func (m *DataprocModule) clustersToTableBody(clusters []dataprocservice.ClusterI
|
||||
}
|
||||
|
||||
// Check attack paths (privesc/exfil/lateral) for the service account
|
||||
attackPaths := "run --attack-paths"
|
||||
attackPaths := "run foxmapper"
|
||||
if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
if sa != "(default)" && sa != "" {
|
||||
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, sa)
|
||||
|
||||
@@ -45,10 +45,7 @@ Attack Surface:
|
||||
- Functions with VPC connectors can access internal resources
|
||||
- Event triggers reveal integration points (Pub/Sub, Storage, etc.)
|
||||
|
||||
TIP: To see service account attack paths (privesc, exfil, lateral movement),
|
||||
use the global --attack-paths flag:
|
||||
|
||||
cloudfox gcp functions -p PROJECT_ID --attack-paths`,
|
||||
TIP: Run foxmapper first to populate the Attack Paths column with privesc/exfil/lateral movement analysis.`,
|
||||
Run: runGCPFunctionsCommand,
|
||||
}
|
||||
|
||||
@@ -551,7 +548,7 @@ func (m *FunctionsModule) functionsToTableBody(functions []FunctionsService.Func
|
||||
}
|
||||
|
||||
// Check attack paths (privesc/exfil/lateral) for the service account
|
||||
attackPaths := "run --attack-paths"
|
||||
attackPaths := "run foxmapper"
|
||||
if serviceAccount != "-" {
|
||||
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, serviceAccount)
|
||||
} else if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
|
||||
+1
-1
@@ -391,7 +391,7 @@ func (m *GKEModule) buildTablesForProject(clusters []GKEService.ClusterInfo, nod
|
||||
}
|
||||
|
||||
// Check attack paths (privesc/exfil/lateral) for the service account
|
||||
attackPaths := "run --attack-paths"
|
||||
attackPaths := "run foxmapper"
|
||||
if saDisplay != "-" {
|
||||
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, saDisplay)
|
||||
} else if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
|
||||
@@ -864,7 +864,7 @@ func (m *InstancesModule) instancesToTableBody(instances []ComputeEngineService.
|
||||
|
||||
// Check attack paths (privesc/exfil/lateral) for the service account
|
||||
// FoxMapper takes priority if available (graph-based analysis)
|
||||
attackPaths := "run --attack-paths"
|
||||
attackPaths := "run foxmapper"
|
||||
if saEmail != "-" {
|
||||
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, saEmail)
|
||||
} else if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
|
||||
@@ -237,7 +237,7 @@ func (m *NotebooksModule) instancesToTableBody(instances []notebooksservice.Note
|
||||
}
|
||||
|
||||
// Check attack paths (privesc/exfil/lateral) for the service account
|
||||
attackPaths := "run --attack-paths"
|
||||
attackPaths := "run foxmapper"
|
||||
if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
if sa != "(default)" && sa != "" {
|
||||
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, sa)
|
||||
@@ -292,7 +292,7 @@ func (m *NotebooksModule) runtimesToTableBody(runtimes []notebooksservice.Runtim
|
||||
}
|
||||
|
||||
// Check attack paths (privesc/exfil/lateral) for the service account
|
||||
attackPaths := "run --attack-paths"
|
||||
attackPaths := "run foxmapper"
|
||||
if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
if sa != "-" && sa != "" {
|
||||
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, sa)
|
||||
|
||||
@@ -85,7 +85,7 @@ func runGCPOrganizationsCommand(cmd *cobra.Command, args []string) {
|
||||
func (m *OrganizationsModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
orgsSvc := orgsservice.New()
|
||||
|
||||
// Check if org cache is available (from all-checks or --org-cache flag)
|
||||
// Check if org cache is available (auto-loaded at startup)
|
||||
if orgCache := gcpinternal.GetOrgCacheFromContext(ctx); orgCache != nil && orgCache.IsPopulated() {
|
||||
logger.InfoM("Using cached organization data", globals.GCP_ORGANIZATIONS_MODULE_NAME)
|
||||
|
||||
|
||||
@@ -265,7 +265,7 @@ func (m *SchedulerModule) jobsToTableBody(jobs []SchedulerService.JobInfo) [][]s
|
||||
}
|
||||
|
||||
// Check attack paths (privesc/exfil/lateral) for the service account
|
||||
attackPaths := "run --attack-paths"
|
||||
attackPaths := "run foxmapper"
|
||||
if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
if sa != "-" {
|
||||
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, sa)
|
||||
|
||||
@@ -39,8 +39,7 @@ Security Considerations:
|
||||
- Cloud Build SA is a common privilege escalation vector
|
||||
- Default compute SA often has Editor role
|
||||
|
||||
TIP: Use the --attack-paths flag to analyze privesc/exfil/lateral movement potential:
|
||||
cloudfox gcp service-agents -p PROJECT_ID --attack-paths`,
|
||||
TIP: Run foxmapper first to populate the Attack Paths column with privesc/exfil/lateral movement analysis.`,
|
||||
Run: runGCPServiceAgentsCommand,
|
||||
}
|
||||
|
||||
@@ -457,7 +456,7 @@ func (m *ServiceAgentsModule) agentsToTableBody(agents []serviceagentsservice.Se
|
||||
}
|
||||
|
||||
// Check attack paths for this service agent
|
||||
attackPaths := "run --attack-paths"
|
||||
attackPaths := "run foxmapper"
|
||||
if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, agent.Email)
|
||||
}
|
||||
|
||||
@@ -356,36 +356,46 @@ func (s *CrossProjectService) analyzeBindingRisk(role, member string, isFromKnow
|
||||
func (s *CrossProjectService) generateExploitCommands(binding CrossProjectBinding) []string {
|
||||
var commands []string
|
||||
|
||||
// Build impersonation flag if service account
|
||||
impersonateFlag := ""
|
||||
if binding.PrincipalType == "serviceAccount" {
|
||||
email := strings.TrimPrefix(binding.Principal, "serviceAccount:")
|
||||
|
||||
commands = append(commands,
|
||||
fmt.Sprintf("# Impersonate SA from %s to access %s:", binding.SourceProject, binding.TargetProject),
|
||||
fmt.Sprintf("gcloud auth print-access-token --impersonate-service-account=%s", email),
|
||||
fmt.Sprintf("# Then use token to access target project:"),
|
||||
fmt.Sprintf("gcloud projects describe %s --impersonate-service-account=%s", binding.TargetProject, email),
|
||||
)
|
||||
impersonateFlag = fmt.Sprintf(" --impersonate-service-account=%s", email)
|
||||
}
|
||||
|
||||
// Role-specific exploitation
|
||||
if strings.Contains(binding.Role, "storage") {
|
||||
roleLower := strings.ToLower(binding.Role)
|
||||
|
||||
// Role-specific exploitation commands
|
||||
if strings.Contains(roleLower, "owner") || strings.Contains(roleLower, "editor") {
|
||||
commands = append(commands,
|
||||
fmt.Sprintf("# List buckets in target project:"),
|
||||
fmt.Sprintf("gcloud compute instances list --project=%s%s", binding.TargetProject, impersonateFlag),
|
||||
fmt.Sprintf("gcloud secrets list --project=%s%s", binding.TargetProject, impersonateFlag),
|
||||
fmt.Sprintf("gsutil ls -p %s", binding.TargetProject),
|
||||
)
|
||||
}
|
||||
|
||||
if strings.Contains(binding.Role, "compute") {
|
||||
} else if strings.Contains(roleLower, "storage") {
|
||||
commands = append(commands,
|
||||
fmt.Sprintf("# List instances in target project:"),
|
||||
fmt.Sprintf("gcloud compute instances list --project=%s", binding.TargetProject),
|
||||
fmt.Sprintf("gsutil ls -p %s", binding.TargetProject),
|
||||
)
|
||||
}
|
||||
|
||||
if strings.Contains(binding.Role, "secretmanager") {
|
||||
} else if strings.Contains(roleLower, "compute") {
|
||||
commands = append(commands,
|
||||
fmt.Sprintf("# List secrets in target project:"),
|
||||
fmt.Sprintf("gcloud secrets list --project=%s", binding.TargetProject),
|
||||
fmt.Sprintf("gcloud compute instances list --project=%s%s", binding.TargetProject, impersonateFlag),
|
||||
)
|
||||
} else if strings.Contains(roleLower, "secretmanager") {
|
||||
commands = append(commands,
|
||||
fmt.Sprintf("gcloud secrets list --project=%s%s", binding.TargetProject, impersonateFlag),
|
||||
)
|
||||
} else if strings.Contains(roleLower, "bigquery") {
|
||||
commands = append(commands,
|
||||
fmt.Sprintf("bq ls --project_id=%s", binding.TargetProject),
|
||||
fmt.Sprintf("bq query --project_id=%s 'SELECT * FROM INFORMATION_SCHEMA.TABLES'", binding.TargetProject),
|
||||
)
|
||||
} else if strings.Contains(roleLower, "cloudsql") {
|
||||
commands = append(commands,
|
||||
fmt.Sprintf("gcloud sql instances list --project=%s%s", binding.TargetProject, impersonateFlag),
|
||||
)
|
||||
} else if strings.Contains(roleLower, "serviceaccounttokencreator") || strings.Contains(roleLower, "serviceaccountkeyadmin") {
|
||||
commands = append(commands,
|
||||
fmt.Sprintf("gcloud iam service-accounts list --project=%s%s", binding.TargetProject, impersonateFlag),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -396,24 +406,39 @@ func (s *CrossProjectService) generateExploitCommands(binding CrossProjectBindin
|
||||
func (s *CrossProjectService) generateLateralMovementCommands(path LateralMovementPath) []string {
|
||||
var commands []string
|
||||
|
||||
// Build impersonation flag if service account
|
||||
impersonateFlag := ""
|
||||
if strings.HasPrefix(path.SourcePrincipal, "serviceAccount:") {
|
||||
email := strings.TrimPrefix(path.SourcePrincipal, "serviceAccount:")
|
||||
impersonateFlag = fmt.Sprintf(" --impersonate-service-account=%s", email)
|
||||
}
|
||||
|
||||
commands = append(commands,
|
||||
fmt.Sprintf("# Lateral movement from %s to %s via SA impersonation:", path.SourceProject, path.TargetProject),
|
||||
fmt.Sprintf("# 1. Get access token for the cross-project SA:"),
|
||||
fmt.Sprintf("gcloud auth print-access-token --impersonate-service-account=%s", email),
|
||||
fmt.Sprintf("# 2. Use the SA to access target project:"),
|
||||
)
|
||||
|
||||
// Add role-specific commands
|
||||
for _, role := range path.TargetRoles {
|
||||
if strings.Contains(role, "owner") || strings.Contains(role, "editor") {
|
||||
commands = append(commands,
|
||||
fmt.Sprintf("# Full project access with %s:", role),
|
||||
fmt.Sprintf("gcloud projects describe %s --impersonate-service-account=%s", path.TargetProject, email),
|
||||
)
|
||||
}
|
||||
// Add role-specific commands based on the most powerful role
|
||||
for _, role := range path.TargetRoles {
|
||||
roleLower := strings.ToLower(role)
|
||||
if strings.Contains(roleLower, "owner") || strings.Contains(roleLower, "editor") {
|
||||
commands = append(commands,
|
||||
fmt.Sprintf("gcloud compute instances list --project=%s%s", path.TargetProject, impersonateFlag),
|
||||
fmt.Sprintf("gcloud secrets list --project=%s%s", path.TargetProject, impersonateFlag),
|
||||
fmt.Sprintf("gsutil ls -p %s", path.TargetProject),
|
||||
)
|
||||
break // owner/editor covers everything, no need for more specific commands
|
||||
} else if strings.Contains(roleLower, "storage") {
|
||||
commands = append(commands,
|
||||
fmt.Sprintf("gsutil ls -p %s", path.TargetProject),
|
||||
)
|
||||
} else if strings.Contains(roleLower, "compute") {
|
||||
commands = append(commands,
|
||||
fmt.Sprintf("gcloud compute instances list --project=%s%s", path.TargetProject, impersonateFlag),
|
||||
)
|
||||
} else if strings.Contains(roleLower, "secretmanager") {
|
||||
commands = append(commands,
|
||||
fmt.Sprintf("gcloud secrets list --project=%s%s", path.TargetProject, impersonateFlag),
|
||||
)
|
||||
} else if strings.Contains(roleLower, "bigquery") {
|
||||
commands = append(commands,
|
||||
fmt.Sprintf("bq ls --project_id=%s", path.TargetProject),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ package gcpinternal
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
foxmapperservice "github.com/BishopFox/cloudfox/gcp/services/foxmapperService"
|
||||
)
|
||||
@@ -14,6 +17,8 @@ type FoxMapperCache struct {
|
||||
service *foxmapperservice.FoxMapperService
|
||||
populated bool
|
||||
identifier string
|
||||
loadedPath string
|
||||
dataAge time.Duration
|
||||
}
|
||||
|
||||
// NewFoxMapperCache creates a new FoxMapper cache
|
||||
@@ -132,6 +137,26 @@ func (c *FoxMapperCache) GetIdentifier() string {
|
||||
return c.identifier
|
||||
}
|
||||
|
||||
// GetDataAge returns how old the FoxMapper data is
|
||||
func (c *FoxMapperCache) GetDataAge() time.Duration {
|
||||
return c.dataAge
|
||||
}
|
||||
|
||||
// GetDataAgeDays returns the age of FoxMapper data in days
|
||||
func (c *FoxMapperCache) GetDataAgeDays() int {
|
||||
return int(c.dataAge.Hours() / 24)
|
||||
}
|
||||
|
||||
// SetLoadedPath sets the path and calculates data age from file modification time
|
||||
func (c *FoxMapperCache) SetLoadedPath(path string) {
|
||||
c.loadedPath = path
|
||||
// Try to get the modification time of the nodes.json file
|
||||
nodesPath := filepath.Join(path, "graph", "nodes.json")
|
||||
if info, err := os.Stat(nodesPath); err == nil {
|
||||
c.dataAge = time.Since(info.ModTime())
|
||||
}
|
||||
}
|
||||
|
||||
// HasPrivesc checks if a service account has privilege escalation potential
|
||||
func (c *FoxMapperCache) HasPrivesc(serviceAccount string) (bool, string) {
|
||||
if !c.populated {
|
||||
@@ -182,18 +207,25 @@ func TryLoadFoxMapper(orgID string, projectIDs []string) *FoxMapperCache {
|
||||
|
||||
// Try org first - if it exists, it should contain all projects
|
||||
if orgID != "" {
|
||||
if err := cache.LoadFromOrg(orgID); err == nil {
|
||||
return cache
|
||||
if path, err := foxmapperservice.FindFoxMapperData(orgID, true); err == nil {
|
||||
if err := cache.LoadFromOrg(orgID); err == nil {
|
||||
cache.SetLoadedPath(path)
|
||||
return cache
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No org-level graph - try to load and merge all project graphs
|
||||
loadedCount := 0
|
||||
var firstPath string
|
||||
for _, projectID := range projectIDs {
|
||||
if loadedCount == 0 {
|
||||
// First project - load normally
|
||||
if err := cache.LoadFromProject(projectID); err == nil {
|
||||
loadedCount++
|
||||
if path, err := foxmapperservice.FindFoxMapperData(projectID, false); err == nil {
|
||||
if err := cache.LoadFromProject(projectID); err == nil {
|
||||
firstPath = path
|
||||
loadedCount++
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Subsequent projects - merge into existing graph
|
||||
@@ -213,6 +245,9 @@ func TryLoadFoxMapper(orgID string, projectIDs []string) *FoxMapperCache {
|
||||
}
|
||||
|
||||
if loadedCount > 0 {
|
||||
if firstPath != "" {
|
||||
cache.SetLoadedPath(firstPath)
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,18 @@ func (l *Logger) SuccessM(text string, module string) {
|
||||
fmt.Printf(clearln+"[%s][%s] %s\n", green(emoji.Sprintf(":fox:cloudfox %s :fox:", l.version)), green(module), text)
|
||||
}
|
||||
|
||||
func (l *Logger) Warn(text string) {
|
||||
l.WarnM(text, "config")
|
||||
}
|
||||
|
||||
func (l *Logger) WarnM(text string, module string) {
|
||||
var yellow = color.New(color.FgYellow).SprintFunc()
|
||||
fmt.Printf(clearln+"[%s][%s] ⚠️ %s\n", yellow(emoji.Sprintf(":fox:cloudfox %s :fox:", l.version)), yellow(module), text)
|
||||
if l.txtLog != nil {
|
||||
l.txtLog.Printf("[%s] WARNING: %s", module, text)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) Error(text string) {
|
||||
l.ErrorM(text, "config")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user