Compare commits

...
35 Commits
Author SHA1 Message Date
jbarciabf 0801228b72 minor fixes 2026-05-19 11:08:57 -04:00
jbarciabf b47d4b0b45 Merge branch 'main' into jb/gcp-dev 2026-04-20 10:31:41 -04:00
jbarciabf 142757b5c2 Merge pull request #125 from BishopFox/fix/go-stdlib-vulnerabilities
Upgrade Go to 1.25.9, fix 24 stdlib vulnerabilities
2026-04-13 09:06:34 -04:00
Joseph Barcia 146034d619 Update AWS SDK to fix EventStream decoder panic (GHSA-xmrv-pmrh-hhx2)
Updates aws-sdk-go-v2/aws/protocol/eventstream to v1.7.8,
service/s3 to v1.97.3, service/lambda to v1.88.5, and
service/kinesis to v1.43.5.
2026-04-13 12:59:37 +00:00
Joseph Barcia 9e056b29d7 Update opentelemetry-go to v1.43.0 and go-jose to v4.1.4
Fixes CVE-2026-39883 (PATH hijacking in otel SDK) and
CVE-2026-34986 (panic in JWE decryption in go-jose).
2026-04-13 12:55:22 +00:00
Joseph Barcia 871491738b Upgrade Go to 1.25.9 and bump version to 2.0.3
Fixes 24 standard library vulnerabilities in crypto/tls, crypto/x509,
net/url, net/http, html/template, encoding/asn1, encoding/pem,
net/mail, net/textproto, os, and os/exec.
2026-04-12 18:16:58 +00:00
jbarciabf 033c953d57 Merge pull request #124 from BishopFox/version-bump-2.0.2
Bump version to 2.0.2
2026-04-12 13:49:44 -04:00
Joseph Barcia a7e45b9ae5 Bump version to 2.0.2 2026-04-12 17:47:40 +00:00
jbarciabf ce513ba04d Merge pull request #123 from sethsec/fix/awsservicemap-v1.1.0-service-slugs
Fix service slug mismatches for awsservicemap v1.1.0
2026-04-12 13:46:14 -04:00
Joseph Barcia 83f9af249d Update google.golang.org/grpc to v1.79.3 to fix GO-2026-4762
Fixes authorization bypass via missing leading slash in :path header.
2026-04-12 17:40:35 +00:00
Seth Art 11652a9a6e Fix service slug mismatches for awsservicemap v1.1.0 across all commands
awsservicemap v1.1.0 switched to URL-based service slugs, breaking all
commands that used the old identifiers. Affected slugs and their fixes:

  secretsmanager  -> secrets-manager   (resource-trusts, inventory)
  ssm             -> systems-manager   (inventory)
  elb             -> elasticloadbalancing (endpoints, inventory, network-ports)
  es              -> opensearch-service   (endpoints, inventory, resource-trusts)
  mq              -> amazon-mq            (endpoints, inventory)
  stepfunctions   -> step-functions       (inventory)
  sagemaker       -> sagemaker-ai         (env-vars)
  clouddirectory  -> directoryservice     (directory-services)
  kinesis         -> streams              (inventory)
  lightsail       -> amazonlightsail.com  (endpoints, env-vars, network-ports)

Each mismatch caused IsServiceInRegion to return false for all regions,
silently skipping enumeration of that service entirely.
2026-04-11 11:40:48 -04:00
Seth Art 6ac7a0bf6b Fix secrets command skipping all regions due to stale service slug names
awsservicemap v1.1.0 changed to a URL-based service identification format.
The secrets module was passing old-style slugs ("secretsmanager", "ssm") to
IsServiceInRegion, which never matched the new URL-derived slugs
("secrets-manager", "systems-manager"). This caused 0 tasks to be queued
and no secrets to be returned despite secrets existing in the account.
2026-04-11 11:40:48 -04:00
jbarciabf 5dbb1a9c32 CVE-2026-33186 fix 2026-03-24 11:29:57 -04:00
jbarciabf d573ca6fb6 added OS Login Policy check at org level 2026-03-10 09:44:51 -04:00
jbarciabf 79aa3f48ef Merge remote-tracking branch 'upstream/main' into jb/gcp-dev 2026-03-09 11:51:26 -04:00
jbarciabf adc9a27232 added role enumeration for resource level bindings 2026-03-06 11:24:02 -05:00
jbarciabf 3d58404721 bug fix for when _withcond_<hash> role names when policy version 3 wasn't requested 2026-03-05 22:51:52 -05:00
jbarciabf 531a2ed572 added full wif path 2026-03-04 21:26:21 -05:00
jbarciabf 5d5c021c20 fixed token and access key identity enumeration 2026-03-04 15:03:24 -05:00
jbarciabf 182e26c7bc updated readme 2026-03-04 13:26:29 -05:00
jbarciabf 1cf3d52855 improved queries 2026-03-04 12:26:27 -05:00
jbarciabf dc040c3f94 added project bindings so user can run a single module when targeting a resource instead of multiple modules 2026-03-04 12:05:16 -05:00
jbarciabf 454ca1f81e Merge remote-tracking branch 'upstream/main' into jb/gcp-dev 2026-03-04 10:00:04 -05:00
jbarciabf 521422f5d6 fixed 'admin' check for custom roles 2026-03-04 09:52:42 -05:00
jbarciabf 45197cf89d added back known admin roles 2026-03-04 09:08:09 -05:00
jbarciabf b8bb8473e2 fixed quota detection for impersonation 2026-03-04 08:55:18 -05:00
jbarciabf 29c9a2e7a5 added key auth 2026-03-03 21:48:16 -05:00
jbarciabf 462dba9c10 fixed output misses in serviceaccounts 2026-03-03 21:36:48 -05:00
jbarciabf 2275e5872c fixed gap in resource bindings not populating 2026-03-03 21:12:58 -05:00
jbarciabf 9d5c7b79f2 added --query to filter on keywords 2026-03-03 14:48:44 -05:00
jbarciabf 5a04bef828 centralized session management and implemented service account impersonation (gcloud --impersonate-service-account) 2026-03-03 10:05:29 -05:00
jbarciabf 3887531d12 added quota project detection and override and fixed silent "not found" errors 2026-03-02 13:43:35 -05:00
jbarciabf 332a5df61c added @bishopfaure access-tokens logic and output 2026-03-02 10:46:54 -05:00
jbarciabf 2c4a83017c added @bishopfaure access-tokens logic and output 2026-03-02 10:37:28 -05:00
jbarciabf bde29aa2d6 added @bishopfaure access-tokens logic and output 2026-03-02 10:36:21 -05:00
91 changed files with 4136 additions and 435 deletions

No files matched your search

+45 -1
View File
@@ -110,7 +110,7 @@ Additional policy notes (as of 09/2022):
### GCP
* Google Cloud SDK installed and authenticated
* Application Default Credentials configured (`gcloud auth application-default login`)
* Application Default Credentials configured (`gcloud auth application-default login`), or use `--key-file` / `--access-token` / `--impersonate-sa` flags
* Recommended permissions at appropriate hierarchy levels (see below)
#### GCP Permissions: Minimal vs Comprehensive
@@ -204,6 +204,50 @@ For detailed setup instructions, see the [GCP Setup Guide](https://github.com/Bi
| Azure | [vms](https://github.com/BishopFox/cloudfox/wiki/Azure-Commands#vms) | Enumerates useful information for Compute instances in all available resource groups and subscriptions |
# GCP Global Flags
All GCP commands support these flags:
### Authentication
| Flag | Description |
| - | - |
| `--access-token` | Raw GCP access token for authentication (non-refreshable) |
| `-i, --impersonate-sa` | Service account email to impersonate (requires `roles/iam.serviceAccountTokenCreator`) |
| `-k, --key-file` | Path to a service account JSON key file for authentication |
If no authentication flags are provided, CloudFox uses Application Default Credentials (ADC). You can combine `--key-file` or `--access-token` with `--impersonate-sa` to impersonate using a specific base credential.
### Query Filtering
| Flag | Description |
| - | - |
| `-q, --query` | Filter output rows by substring match or column-aware key:value syntax |
| `--sqlquery` | Filter output with SQL-like WHERE clause syntax |
**`--query` examples:**
```bash
# Plain substring (matches any column)
cloudfox gcp -p my-project iam --query "privesc02"
# Column-specific: Role contains "owner" AND Principal contains "privesc02"
cloudfox gcp -p my-project serviceaccounts --query "Role:owner,Principal:privesc02"
# Negation: Role contains "owner" but Principal does NOT contain "compute"
cloudfox gcp -p my-project serviceaccounts --query "Role:owner,!Principal:compute"
# Exact match on column value
cloudfox gcp -p my-project iam --query "Role=roles/owner"
```
**`--sqlquery` examples:**
```bash
cloudfox gcp -p my-project serviceaccounts \
--sqlquery "Role CONTAINS owner AND Principal CONTAINS privesc02"
cloudfox gcp -p my-project iam --sqlquery "Source != Project"
```
For full query filtering documentation, see the [GCP Commands Wiki - Query Filtering](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#query-filtering).
# GCP Commands
For detailed documentation on each GCP command, see the [GCP Commands Wiki](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands).
+1 -1
View File
@@ -198,7 +198,7 @@ func (m *DirectoryModule) executeChecks(r string, wg *sync.WaitGroup, semaphore
JsonFileSource: "DOWNLOAD_FROM_AWS",
}
}
res, err := servicemap.IsServiceInRegion("clouddirectory", r)
res, err := servicemap.IsServiceInRegion("directoryservice", r)
if err != nil {
m.modLog.Error(err)
}
+4 -4
View File
@@ -293,7 +293,7 @@ func (m *EndpointsModule) executeChecks(r string, wg *sync.WaitGroup, semaphore
wg.Add(1)
go m.getEksClustersPerRegion(r, wg, semaphore, dataReceiver)
}
res, err = servicemap.IsServiceInRegion("mq", r)
res, err = servicemap.IsServiceInRegion("amazon-mq", r)
if err != nil {
m.modLog.Error(err)
}
@@ -302,7 +302,7 @@ func (m *EndpointsModule) executeChecks(r string, wg *sync.WaitGroup, semaphore
wg.Add(1)
go m.getMqBrokersPerRegion(r, wg, semaphore, dataReceiver)
}
res, err = servicemap.IsServiceInRegion("es", r)
res, err = servicemap.IsServiceInRegion("opensearch-service", r)
if err != nil {
m.modLog.Error(err)
}
@@ -320,7 +320,7 @@ func (m *EndpointsModule) executeChecks(r string, wg *sync.WaitGroup, semaphore
wg.Add(1)
m.getGrafanaEndPointsPerRegion(r, wg, semaphore, dataReceiver)
}
res, err = servicemap.IsServiceInRegion("elb", r)
res, err = servicemap.IsServiceInRegion("elasticloadbalancing", r)
if err != nil {
m.modLog.Error(err)
}
@@ -378,7 +378,7 @@ func (m *EndpointsModule) executeChecks(r string, wg *sync.WaitGroup, semaphore
wg.Add(1)
go m.getAppRunnerEndpointsPerRegion(r, wg, semaphore, dataReceiver)
res, err = servicemap.IsServiceInRegion("lightsail", r)
res, err = servicemap.IsServiceInRegion("amazonlightsail.com", r)
if err != nil {
m.modLog.Error(err)
}
+2 -2
View File
@@ -310,7 +310,7 @@ func (m *EnvsModule) executeChecks(r string, wg *sync.WaitGroup, semaphore chan
wg.Add(1)
go m.getAppRunnerEnvironmentVariablesPerRegion(r, wg, semaphore, dataReceiver)
res, err = servicemap.IsServiceInRegion("lightsail", r)
res, err = servicemap.IsServiceInRegion("amazonlightsail.com", r)
if err != nil {
m.modLog.Error(err)
}
@@ -320,7 +320,7 @@ func (m *EnvsModule) executeChecks(r string, wg *sync.WaitGroup, semaphore chan
go m.getLightsailEnvironmentVariablesPerRegion(r, wg, semaphore, dataReceiver)
}
res, err = servicemap.IsServiceInRegion("sagemaker", r)
res, err = servicemap.IsServiceInRegion("sagemaker-ai", r)
if err != nil {
m.modLog.Error(err)
}
+8 -8
View File
@@ -599,7 +599,7 @@ func (m *Inventory2Module) executeChecks(r string, wg *sync.WaitGroup, semaphore
go m.getEKSNodeGroupsPerRegion(r, wg, semaphore)
}
res, err = servicemap.IsServiceInRegion("elb", r)
res, err = servicemap.IsServiceInRegion("elasticloadbalancing", r)
if err != nil {
m.modLog.Error(err)
}
@@ -645,7 +645,7 @@ func (m *Inventory2Module) executeChecks(r string, wg *sync.WaitGroup, semaphore
go m.GetEMRInstancesPerRegion(r, wg, semaphore)
}
res, err = servicemap.IsServiceInRegion("es", r)
res, err = servicemap.IsServiceInRegion("opensearch-service", r)
if err != nil {
m.modLog.Error(err)
}
@@ -682,7 +682,7 @@ func (m *Inventory2Module) executeChecks(r string, wg *sync.WaitGroup, semaphore
}
res, err = servicemap.IsServiceInRegion("kinesis", r)
res, err = servicemap.IsServiceInRegion("streams", r)
if err != nil {
m.modLog.Error(err)
}
@@ -703,7 +703,7 @@ func (m *Inventory2Module) executeChecks(r string, wg *sync.WaitGroup, semaphore
go m.getLambdaFunctionsPerRegion(r, wg, semaphore)
}
res, err = servicemap.IsServiceInRegion("lightsail", r)
res, err = servicemap.IsServiceInRegion("amazonlightsail.com", r)
if err != nil {
m.modLog.Error(err)
}
@@ -713,7 +713,7 @@ func (m *Inventory2Module) executeChecks(r string, wg *sync.WaitGroup, semaphore
go m.getLightsailInstancesAndContainersPerRegion(r, wg, semaphore)
}
res, err = servicemap.IsServiceInRegion("mq", r)
res, err = servicemap.IsServiceInRegion("amazon-mq", r)
if err != nil {
m.modLog.Error(err)
}
@@ -743,7 +743,7 @@ func (m *Inventory2Module) executeChecks(r string, wg *sync.WaitGroup, semaphore
go m.getRedshiftClustersPerRegion(r, wg, semaphore)
}
res, err = servicemap.IsServiceInRegion("secretsmanager", r)
res, err = servicemap.IsServiceInRegion("secrets-manager", r)
if err != nil {
m.modLog.Error(err)
}
@@ -773,7 +773,7 @@ func (m *Inventory2Module) executeChecks(r string, wg *sync.WaitGroup, semaphore
go m.getSQSQueuesPerRegion(r, wg, semaphore)
}
res, err = servicemap.IsServiceInRegion("ssm", r)
res, err = servicemap.IsServiceInRegion("systems-manager", r)
if err != nil {
m.modLog.Error(err)
}
@@ -783,7 +783,7 @@ func (m *Inventory2Module) executeChecks(r string, wg *sync.WaitGroup, semaphore
go m.getSSMParametersPerRegion(r, wg, semaphore)
}
res, err = servicemap.IsServiceInRegion("stepfunctions", r)
res, err = servicemap.IsServiceInRegion("step-functions", r)
if err != nil {
m.modLog.Error(err)
}
+2 -2
View File
@@ -318,7 +318,7 @@ func (m *NetworkPortsModule) executeChecks(r string, wg *sync.WaitGroup, dataRec
m.getElastiCacheNetworkPortsPerRegion(r, dataReceiver)
}
res, err = servicemap.IsServiceInRegion("elb", r)
res, err = servicemap.IsServiceInRegion("elasticloadbalancing", r)
if err != nil {
m.modLog.Error(err)
}
@@ -327,7 +327,7 @@ func (m *NetworkPortsModule) executeChecks(r string, wg *sync.WaitGroup, dataRec
m.getLBNetworkPortsPerRegion(r, dataReceiver)
}
res, err = servicemap.IsServiceInRegion("lightsail", r)
res, err = servicemap.IsServiceInRegion("amazonlightsail.com", r)
if err != nil {
m.modLog.Error(err)
}
+2 -2
View File
@@ -272,7 +272,7 @@ func (m *ResourceTrustsModule) executeChecks(r string, wg *sync.WaitGroup, semap
m.getEFSfilesystemPoliciesPerRegion(r, wg, semaphore, dataReceiver)
}
res, err = servicemap.IsServiceInRegion("secretsmanager", r)
res, err = servicemap.IsServiceInRegion("secrets-manager", r)
if err != nil {
m.modLog.Error(err)
}
@@ -329,7 +329,7 @@ func (m *ResourceTrustsModule) executeChecks(r string, wg *sync.WaitGroup, semap
}
if m.OpenSearchClient != nil {
res, err = servicemap.IsServiceInRegion("es", r)
res, err = servicemap.IsServiceInRegion("opensearch-service", r)
if err != nil {
m.modLog.Error(err)
}
+2 -2
View File
@@ -200,7 +200,7 @@ func (m *SecretsModule) executeChecks(r string, wg *sync.WaitGroup, semaphore ch
JsonFileSource: "DOWNLOAD_FROM_AWS",
}
}
res, err := servicemap.IsServiceInRegion("secretsmanager", r)
res, err := servicemap.IsServiceInRegion("secrets-manager", r)
if err != nil {
m.modLog.Error(err)
}
@@ -209,7 +209,7 @@ func (m *SecretsModule) executeChecks(r string, wg *sync.WaitGroup, semaphore ch
wg.Add(1)
go m.getSecretsManagerSecretsPerRegion(r, wg, semaphore, dataReceiver)
}
res, err = servicemap.IsServiceInRegion("ssm", r)
res, err = servicemap.IsServiceInRegion("systems-manager", r)
if err != nil {
m.modLog.Error(err)
}
+3
View File
@@ -544,6 +544,9 @@ type OrgAccounts struct {
}
func awsPreRun(cmd *cobra.Command, args []string) {
// Set up Ctrl+C handler (double-press to confirm shutdown)
internal.SetupSignalHandler()
gob.Register(&types.Organization{})
// if multiple profiles were used, ensure the management account is first
+253 -25
View File
@@ -2,7 +2,9 @@ package cli
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
@@ -12,6 +14,8 @@ import (
"github.com/BishopFox/cloudfox/internal"
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
"github.com/spf13/cobra"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
var (
@@ -22,6 +26,15 @@ var (
GCPProjectIDs []string
GCPAllProjects bool
// Service account impersonation
GCPImpersonateSA string
// Service account key file authentication
GCPKeyFile string
// Raw access token authentication
GCPAccessToken string
// Project name mapping (ProjectID -> DisplayName)
GCPProjectNames map[string]string
@@ -31,6 +44,8 @@ var (
GCPVerbosity int
GCPWrapTable bool
GCPFlatOutput bool
GCPQuery string
GCPSQLQuery string
// Refresh cache flag - force re-enumeration even if cache exists
GCPRefreshCache bool
@@ -52,22 +67,111 @@ var (
GCPProjectIDs = nil
GCPProjectNames = make(map[string]string)
// Create session based on auth flags.
// Allowed combinations:
// --access-token (raw token)
// --key-file (SA key file)
// --impersonate-sa (impersonate over ADC)
// --access-token + --impersonate-sa (impersonate using token as base)
// --key-file + --impersonate-sa (impersonate using key as base)
// Disallowed: --access-token + --key-file (both are base credentials)
var gcpSession *gcpinternal.SafeSession
if GCPAccessToken != "" && GCPKeyFile != "" {
GCPLogger.FatalM("--access-token and --key-file cannot be used together (both are base credentials)", "gcp")
}
// Step 1: Create base session from access-token or key-file (if provided)
var baseSession *gcpinternal.SafeSession
if GCPAccessToken != "" {
os.Unsetenv("GOOGLE_CLOUD_QUOTA_PROJECT")
GCPLogger.InfoM("Authenticating with provided access token...", "gcp")
var err error
baseSession, err = gcpinternal.NewSafeSessionFromAccessToken(context.Background(), GCPAccessToken)
if err != nil {
GCPLogger.FatalM(fmt.Sprintf("Failed to authenticate with access token: %v", err), "gcp")
}
// If tokeninfo/userinfo endpoints didn't return an email (common with
// SA tokens that only have the cloud-platform scope), try a project-scoped
// probe using the CRM API to extract the caller from the error response.
if baseSession.GetEmail() == "" && GCPProjectID != "" {
gcpinternal.ProbeIdentityWithProject(context.Background(), baseSession, GCPProjectID)
}
email := baseSession.GetEmail()
if email != "" {
GCPLogger.SuccessM(fmt.Sprintf("Authenticated as %s (from access token)", email), "gcp")
} else {
GCPLogger.WarnM("Authenticated with access token (could not resolve identity)", "gcp")
}
} else if GCPKeyFile != "" {
os.Unsetenv("GOOGLE_CLOUD_QUOTA_PROJECT")
GCPLogger.InfoM(fmt.Sprintf("Authenticating with key file: %s", GCPKeyFile), "gcp")
var err error
baseSession, err = gcpinternal.NewSafeSessionFromKeyFile(context.Background(), GCPKeyFile)
if err != nil {
GCPLogger.FatalM(fmt.Sprintf("Failed to authenticate with key file %s: %v", GCPKeyFile, err), "gcp")
}
GCPLogger.SuccessM(fmt.Sprintf("Authenticated as %s (from key file)", baseSession.GetEmail()), "gcp")
}
// Step 2: Layer impersonation on top (if requested)
if GCPImpersonateSA != "" {
os.Unsetenv("GOOGLE_CLOUD_QUOTA_PROJECT")
var baseTS oauth2.TokenSource
if baseSession != nil {
baseTS = baseSession.GetTokenSource()
GCPLogger.InfoM(fmt.Sprintf("Impersonating %s using %s as base credential...", GCPImpersonateSA, baseSession.GetEmail()), "gcp")
} else {
GCPLogger.InfoM(fmt.Sprintf("Impersonating service account: %s", GCPImpersonateSA), "gcp")
}
var err error
gcpSession, err = gcpinternal.NewSafeSessionWithImpersonation(context.Background(), GCPImpersonateSA, baseTS)
if err != nil {
GCPLogger.FatalM(fmt.Sprintf("Failed to impersonate %s: %v", GCPImpersonateSA, err), "gcp")
}
GCPLogger.SuccessM(fmt.Sprintf("Successfully impersonating %s", GCPImpersonateSA), "gcp")
} else if baseSession != nil {
// No impersonation, use the base session directly
gcpSession = baseSession
} else {
// Set quota project for GCP API calls. ADC may reference a deleted
// or inaccessible quota project, causing all API calls to fail with
// 403/404. Override it with the target project when known.
if GCPProjectID != "" {
os.Setenv("GOOGLE_CLOUD_QUOTA_PROJECT", GCPProjectID)
} else if GCPProjectIDsFilePath != "" {
lines := internal.LoadFileLinesIntoArray(GCPProjectIDsFilePath)
if len(lines) > 0 {
os.Setenv("GOOGLE_CLOUD_QUOTA_PROJECT", lines[0])
}
} else if GCPAllProjects {
// No target project yet. Validate the ADC quota project
// and clear it if the project is inaccessible.
validateADCQuotaProject()
}
}
// Handle project discovery based on flags
// Priority: -p (single project) > -l (project list) > -A (all projects)
if GCPProjectID != "" {
// Single project specified with -p/--project
GCPProjectIDs = append(GCPProjectIDs, GCPProjectID)
resolveProjectNames(GCPProjectIDs)
resolveProjectNames(GCPProjectIDs, gcpSession)
} else if GCPProjectIDsFilePath != "" {
// Project list specified with -l/--project-list
rawProjectIDs := internal.LoadFileLinesIntoArray(GCPProjectIDsFilePath)
GCPProjectIDs = deduplicateProjectIDs(rawProjectIDs)
resolveProjectNames(GCPProjectIDs)
resolveProjectNames(GCPProjectIDs, gcpSession)
} else if GCPAllProjects {
// Discover all accessible projects with -A/--all-projects
GCPLogger.InfoM("Discovering all accessible projects...", "gcp")
orgsSvc := orgsservice.New()
projects, err := orgsSvc.SearchProjects("")
orgsSvc := newOrgsService(gcpSession)
var projects []orgsservice.ProjectInfo
err := gcpinternal.RetryWithBackoff(GCPLogger, "gcp", 3, func() error {
var searchErr error
projects, searchErr = orgsSvc.SearchProjects("")
return searchErr
})
if err != nil {
GCPLogger.FatalM(fmt.Sprintf("Failed to discover projects: %v. Try using -p or -l flags instead.", err), "gcp")
}
@@ -85,22 +189,53 @@ var (
GCPLogger.InfoM("No project scope specified. Use -p, -l, or -A flag.", "gcp")
}
// Create a context with project IDs and names
ctx := context.WithValue(context.Background(), "projectIDs", GCPProjectIDs)
// Create a context with project IDs and names, using signal-aware base context
sigCtx := internal.SetupSignalHandler()
ctx := context.WithValue(sigCtx, "projectIDs", GCPProjectIDs)
ctx = context.WithValue(ctx, "projectNames", GCPProjectNames)
// Authenticate and get account info
os := oauthservice.NewOAuthService()
principal, err := os.WhoAmI()
if err != nil {
GCPLogger.FatalM(fmt.Sprintf("could not determine default user credential with error %s.\n\nPlease use default application default credentials: https://cloud.google.com/docs/authentication/application-default-credentials\n\nTry: gcloud auth application-default login", err.Error()), "gcp")
// Set default session so all services pick up impersonation
if gcpSession != nil {
gcpinternal.SetDefaultSession(gcpSession)
}
// Set global query filter if --query or --sqlquery flag is provided
if GCPQuery != "" && GCPSQLQuery != "" {
GCPLogger.FatalM("--query and --sqlquery cannot be used together", "gcp")
}
if GCPQuery != "" {
internal.SetQueryFilter(GCPQuery)
}
if GCPSQLQuery != "" {
internal.SetSQLQueryFilter(GCPSQLQuery)
}
// Authenticate and get account info.
// Check impersonation first since it overrides the effective identity
// even when combined with --access-token or --key-file.
if GCPImpersonateSA != "" {
ctx = context.WithValue(ctx, "account", GCPImpersonateSA)
} else if GCPAccessToken != "" {
email := gcpSession.GetEmail()
if email == "" {
email = "access-token-user"
}
ctx = context.WithValue(ctx, "account", email)
} else if GCPKeyFile != "" {
ctx = context.WithValue(ctx, "account", gcpSession.GetEmail())
} else {
os := oauthservice.NewOAuthService()
principal, err := os.WhoAmI()
if err != nil {
GCPLogger.FatalM(fmt.Sprintf("could not determine default user credential with error %s.\n\nPlease use default application default credentials: https://cloud.google.com/docs/authentication/application-default-credentials\n\nTry: gcloud auth application-default login", err.Error()), "gcp")
}
ctx = context.WithValue(ctx, "account", principal.Email)
}
ctx = context.WithValue(ctx, "account", principal.Email)
// Build scope hierarchy for hierarchical output (unless --flat-output is set)
if !GCPFlatOutput && len(GCPProjectIDs) > 0 {
GCPLogger.InfoM("Building scope hierarchy for hierarchical output...", "gcp")
orgsSvc := orgsservice.New()
orgsSvc := newOrgsService(gcpSession)
provider := orgsservice.NewHierarchyProvider(orgsSvc)
hierarchy, err := gcpinternal.BuildScopeHierarchy(GCPProjectIDs, provider)
if err != nil {
@@ -153,7 +288,7 @@ var (
// Cache auto-refreshes after 24 hours
// Force refresh when running the organizations command to ensure fresh data
refreshCache := GCPRefreshCache || cmd.Name() == "organizations"
orgCache := loadOrPopulateOrgCache(account, refreshCache)
orgCache := loadOrPopulateOrgCache(account, refreshCache, gcpSession)
if orgCache != nil && orgCache.IsPopulated() {
ctx = gcpinternal.SetOrgCacheInContext(ctx, orgCache)
}
@@ -163,6 +298,14 @@ var (
}
)
// newOrgsService creates an organizations service, using the impersonated session if provided
func newOrgsService(session *gcpinternal.SafeSession) *orgsservice.OrganizationsService {
if session != nil {
return orgsservice.NewWithSession(session)
}
return orgsservice.New()
}
// deduplicateProjectIDs removes duplicates, trims whitespace, and filters empty entries
func deduplicateProjectIDs(projectIDs []string) []string {
seen := make(map[string]bool)
@@ -195,15 +338,72 @@ func deduplicateProjectIDs(projectIDs []string) []string {
return result
}
// validateADCQuotaProject reads the ADC file and checks whether the configured
// quota project is accessible. The Go SDK sends x-goog-user-project on every
// request based on the ADC's quota_project_id. If that project is deleted or
// inaccessible, ALL API calls will fail with 403/404. This function detects
// that condition and clears the quota project so the SDK doesn't send the
// broken header. The caller is responsible for setting a valid quota project
// once a working project is discovered.
func validateADCQuotaProject() {
creds, err := google.FindDefaultCredentials(context.Background())
if err != nil || creds.JSON == nil {
return
}
var adcFile struct {
QuotaProject string `json:"quota_project_id"`
}
if err := json.Unmarshal(creds.JSON, &adcFile); err != nil || adcFile.QuotaProject == "" {
return
}
// Proactively clear the quota project via env var override. If the ADC
// quota project is valid, the SearchProjects call below will succeed and
// we'll restore it. If it's broken, we've already fixed the problem.
os.Setenv("GOOGLE_CLOUD_QUOTA_PROJECT", "")
orgsSvc := orgsservice.New()
projects, err := orgsSvc.SearchProjects("")
if err != nil {
// Can't validate. Leave it cleared (better than a known-broken value).
GCPLogger.WarnM(fmt.Sprintf("Could not validate ADC quota project '%s': %v", adcFile.QuotaProject, err), "gcp")
return
}
// Check if the original quota project is among accessible active projects
for _, p := range projects {
if p.ProjectID == adcFile.QuotaProject && p.State == "ACTIVE" {
// Original quota project is valid, restore it
os.Setenv("GOOGLE_CLOUD_QUOTA_PROJECT", adcFile.QuotaProject)
return
}
}
// Original quota project is not accessible. Pick the first active project.
GCPLogger.WarnM(fmt.Sprintf("ADC quota project '%s' is not accessible or has been deleted. Overriding with first available project.", adcFile.QuotaProject), "gcp")
for _, p := range projects {
if p.State == "ACTIVE" {
os.Setenv("GOOGLE_CLOUD_QUOTA_PROJECT", p.ProjectID)
return
}
}
}
// resolveProjectNames fetches display names for given project IDs
func resolveProjectNames(projectIDs []string) {
func resolveProjectNames(projectIDs []string, session *gcpinternal.SafeSession) {
if len(projectIDs) == 0 {
return
}
orgsSvc := orgsservice.New()
orgsSvc := newOrgsService(session)
// Fetch all accessible projects and build lookup map
projects, err := orgsSvc.SearchProjects("")
var projects []orgsservice.ProjectInfo
err := gcpinternal.RetryWithBackoff(GCPLogger, "gcp", 3, func() error {
var searchErr error
projects, searchErr = orgsSvc.SearchProjects("")
return searchErr
})
if err != nil {
// Non-fatal: we can continue without display names
GCPLogger.InfoM("Could not resolve project names, using project IDs only", "gcp")
@@ -245,11 +445,17 @@ var GCPAllChecksCommand = &cobra.Command{
ctx = gcpinternal.SetAllChecksMode(ctx, true)
cmd.SetContext(ctx)
// Get session from context for impersonation support
var allChecksSession *gcpinternal.SafeSession
if s, ok := ctx.Value("session").(*gcpinternal.SafeSession); ok {
allChecksSession = s
}
// Load or populate org cache for cross-project modules
existingOrgCache := gcpinternal.GetOrgCacheFromContext(ctx)
if existingOrgCache == nil || !existingOrgCache.IsPopulated() {
GCPLogger.InfoM("Loading/enumerating organization data for cross-project analysis...", "all-checks")
orgCache := loadOrPopulateOrgCache(account, GCPRefreshCache)
orgCache := loadOrPopulateOrgCache(account, GCPRefreshCache, allChecksSession)
if orgCache != nil && orgCache.IsPopulated() {
ctx = gcpinternal.SetOrgCacheInContext(ctx, orgCache)
cmd.SetContext(ctx)
@@ -310,6 +516,7 @@ var GCPAllChecksCommand = &cobra.Command{
"bigquery-enum": true,
"bigtable-enum": true,
"spanner-enum": true,
"access-tokens": true, // Local-only, no API calls
}
// Count total modules to execute (excluding self, hidden, and excluded modules)
@@ -344,7 +551,7 @@ var GCPAllChecksCommand = &cobra.Command{
}
// loadOrPopulateOrgCache loads org cache from disk if available, or enumerates and saves it
func loadOrPopulateOrgCache(account string, forceRefresh bool) *gcpinternal.OrgCache {
func loadOrPopulateOrgCache(account string, forceRefresh bool, session *gcpinternal.SafeSession) *gcpinternal.OrgCache {
// Check if cache exists and we're not forcing refresh
if !forceRefresh && gcpinternal.OrgCacheExists(GCPOutputDirectory, account) {
// Check if cache is stale (older than 24 hours)
@@ -368,18 +575,23 @@ func loadOrPopulateOrgCache(account string, forceRefresh bool) *gcpinternal.OrgC
}
// Enumerate and create cache
cache := enumerateAndCacheOrgs(account)
cache := enumerateAndCacheOrgs(account, session)
return cache
}
// enumerateAndCacheOrgs enumerates all orgs/folders/projects and saves to disk
func enumerateAndCacheOrgs(account string) *gcpinternal.OrgCache {
func enumerateAndCacheOrgs(account string, session *gcpinternal.SafeSession) *gcpinternal.OrgCache {
cache := gcpinternal.NewOrgCache()
orgsSvc := orgsservice.New()
orgsSvc := newOrgsService(session)
// Get all organizations
orgs, err := orgsSvc.SearchOrganizations()
var orgs []orgsservice.OrganizationInfo
err := gcpinternal.RetryWithBackoff(GCPLogger, "gcp", 3, func() error {
var searchErr error
orgs, searchErr = orgsSvc.SearchOrganizations()
return searchErr
})
if err == nil {
for _, org := range orgs {
cache.AddOrganization(gcpinternal.CachedOrganization{
@@ -393,7 +605,12 @@ func enumerateAndCacheOrgs(account string) *gcpinternal.OrgCache {
}
// Get all folders
folders, err := orgsSvc.SearchAllFolders()
var folders []orgsservice.FolderInfo
err = gcpinternal.RetryWithBackoff(GCPLogger, "gcp", 3, func() error {
var searchErr error
folders, searchErr = orgsSvc.SearchAllFolders()
return searchErr
})
if err == nil {
for _, folder := range folders {
cache.AddFolder(gcpinternal.CachedFolder{
@@ -407,7 +624,12 @@ func enumerateAndCacheOrgs(account string) *gcpinternal.OrgCache {
}
// Get all projects
projects, err := orgsSvc.SearchProjects("")
var projects []orgsservice.ProjectInfo
err = gcpinternal.RetryWithBackoff(GCPLogger, "gcp", 3, func() error {
var searchErr error
projects, searchErr = orgsSvc.SearchProjects("")
return searchErr
})
if err == nil {
for _, project := range projects {
// Extract project number from Name (format: "projects/123456789")
@@ -504,6 +726,11 @@ func init() {
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(&GCPRefreshCache, "refresh-cache", false, "Force re-enumeration of cached data (cache auto-expires after 24 hours)")
GCPCommands.PersistentFlags().StringVarP(&GCPImpersonateSA, "impersonate-sa", "i", "", "Service account email to impersonate (requires roles/iam.serviceAccountTokenCreator)")
GCPCommands.PersistentFlags().StringVarP(&GCPKeyFile, "key-file", "k", "", "Path to a service account JSON key file for authentication")
GCPCommands.PersistentFlags().StringVar(&GCPAccessToken, "access-token", "", "Raw GCP access token for authentication (non-refreshable)")
GCPCommands.PersistentFlags().StringVarP(&GCPQuery, "query", "q", "", "Filter output rows by substring match (e.g. \"Role:owner,Principal:privesc02\" or plain \"privesc02\")")
GCPCommands.PersistentFlags().StringVar(&GCPSQLQuery, "sqlquery", "", "Filter output with SQL-like syntax (e.g. \"Role CONTAINS owner AND Principal CONTAINS privesc02\")")
// Available commands
GCPCommands.AddCommand(
@@ -572,6 +799,7 @@ func init() {
commands.GCPAccessLevelsCommand,
// Pentest/Exploitation commands
commands.GCPAccessTokensCommand,
commands.GCPPrivescCommand,
commands.GCPOrgPoliciesCommand,
commands.GCPStorageEnumCommand,
+288
View File
@@ -0,0 +1,288 @@
package commands
import (
"fmt"
"sort"
"strings"
accesstokensservice "github.com/BishopFox/cloudfox/gcp/services/accessTokensService"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
"github.com/spf13/cobra"
)
// GCPAccessTokensCommand reads locally cached gcloud credentials.
// It overrides PersistentPreRun to bypass the parent's auth/project requirements
// since it only reads local files — no GCP API calls needed.
var GCPAccessTokensCommand = &cobra.Command{
Use: globals.GCP_ACCESS_TOKENS_MODULE_NAME,
Aliases: []string{"tokens", "creds"},
Short: "Enumerate locally cached gcloud access tokens, refresh tokens, and ADC",
Long: `Reads locally cached gcloud credentials for post-exploitation credential discovery.
Sources checked:
- access_tokens.db Cached OAuth2 access tokens
- credentials.db Cached refresh tokens
- application_default_credentials.json (ADC)
- configurations/config_* All gcloud profiles
- active_config Currently active profile
- GOOGLE_APPLICATION_CREDENTIALS env var (SA key files)
- CLOUDSDK_CONFIG_DIR env var (custom config paths)
This command reads LOCAL FILES ONLY — no GCP API calls are made.
No project flag (-p) or authentication is required.`,
// Override PersistentPreRun to bypass the parent's auth/project resolution.
// Cobra behavior: a child's PersistentPreRun fully replaces the parent's.
PersistentPreRun: func(cmd *cobra.Command, args []string) {
// Intentionally empty — no project flags or OAuth needed
},
Run: runGCPAccessTokensCommand,
}
// AccessTokensModule holds the credential discovery results
type AccessTokensModule struct {
Credentials []accesstokensservice.CredentialSummary
Verbosity int
WrapTable bool
OutputDir string
Format string
}
// AccessTokensOutput implements internal.CloudfoxOutput
type AccessTokensOutput struct {
Table []internal.TableFile
Loot []internal.LootFile
}
func (o AccessTokensOutput) TableFiles() []internal.TableFile { return o.Table }
func (o AccessTokensOutput) LootFiles() []internal.LootFile { return o.Loot }
func runGCPAccessTokensCommand(cmd *cobra.Command, args []string) {
logger := internal.NewLogger()
// Read output flags from the parent command (gcp)
parentCmd := cmd.Parent()
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
outputDir, _ := parentCmd.PersistentFlags().GetString("outdir")
format, _ := parentCmd.PersistentFlags().GetString("output")
if format == "" {
format = "all"
}
module := &AccessTokensModule{
Verbosity: verbosity,
WrapTable: wrap,
OutputDir: outputDir,
Format: format,
}
module.Execute(logger)
}
func (m *AccessTokensModule) Execute(logger internal.Logger) {
logger.InfoM("Searching for locally cached gcloud credentials...", globals.GCP_ACCESS_TOKENS_MODULE_NAME)
svc := accesstokensservice.New()
creds, err := svc.GetAllCredentials()
if err != nil {
logger.ErrorM(fmt.Sprintf("Error reading credentials: %v", err), globals.GCP_ACCESS_TOKENS_MODULE_NAME)
return
}
if len(creds) == 0 {
logger.InfoM("No cached credentials found", globals.GCP_ACCESS_TOKENS_MODULE_NAME)
return
}
m.Credentials = creds
// Sort: active tokens with refresh tokens first, then active-only, then expired with refresh, then rest
sort.Slice(m.Credentials, func(i, j int) bool {
return credSortScore(m.Credentials[i]) > credSortScore(m.Credentials[j])
})
logger.SuccessM(fmt.Sprintf("Found %d credential(s)", len(m.Credentials)), globals.GCP_ACCESS_TOKENS_MODULE_NAME)
m.writeOutput(logger)
}
// credSortScore returns a numeric score for sorting (higher = more interesting to a pentester)
func credSortScore(c accesstokensservice.CredentialSummary) int {
score := 0
if c.Validity == "ACTIVE" {
score += 4
}
if c.HasRefresh {
score += 2
}
if c.IsActive {
score += 1
}
return score
}
func (m *AccessTokensModule) getTableHeader() []string {
return []string{
"Account",
"Type",
"Validity",
"Has Refresh Token",
"RAPT Status",
"Profile",
"Active Profile",
"Project",
"Access Token",
}
}
func (m *AccessTokensModule) credentialsToTableBody() [][]string {
var body [][]string
for _, c := range m.Credentials {
hasRefresh := "No"
if c.HasRefresh {
hasRefresh = "Yes"
}
active := "No"
if c.IsActive {
active = "Yes"
}
profile := c.Profile
if profile == "" {
profile = "-"
}
project := c.Project
if project == "" {
project = "-"
}
accessToken := c.AccessToken
if accessToken == "" {
accessToken = "-"
}
body = append(body, []string{
c.Account,
c.Type,
c.Validity,
hasRefresh,
c.RAPTStatus,
profile,
active,
project,
accessToken,
})
}
return body
}
func (m *AccessTokensModule) writeOutput(logger internal.Logger) {
body := m.credentialsToTableBody()
tables := []internal.TableFile{{
Name: "access-tokens",
Header: m.getTableHeader(),
Body: body,
}}
// Build loot files
var lootFiles []internal.LootFile
// Loot 1: Exploitation commands (curl with active access tokens)
var exploitCmds strings.Builder
exploitCmds.WriteString("# Active Access Token Exploitation Commands\n")
exploitCmds.WriteString("# Generated by CloudFox\n\n")
hasExploitCmds := false
for _, c := range m.Credentials {
if c.Type == "access_token" && c.Validity == "ACTIVE" && c.AccessToken != "" {
exploitCmds.WriteString(fmt.Sprintf("# Account: %s (Profile: %s, Project: %s)\n", c.Account, c.Profile, c.Project))
exploitCmds.WriteString(fmt.Sprintf("curl -s -H 'Authorization: Bearer %s' https://www.googleapis.com/oauth2/v1/tokeninfo\n", c.AccessToken))
exploitCmds.WriteString(fmt.Sprintf("curl -s -H 'Authorization: Bearer %s' https://cloudresourcemanager.googleapis.com/v1/projects\n\n", c.AccessToken))
hasExploitCmds = true
}
if c.Type == "sa_key" {
exploitCmds.WriteString(fmt.Sprintf("# Service Account Key: %s\n", c.Account))
exploitCmds.WriteString(fmt.Sprintf("# Source: %s\n", c.Source))
exploitCmds.WriteString(fmt.Sprintf("gcloud auth activate-service-account --key-file=%s\n\n", c.Source))
hasExploitCmds = true
}
}
if hasExploitCmds {
lootFiles = append(lootFiles, internal.LootFile{
Name: "access-tokens-exploitation-commands",
Contents: exploitCmds.String(),
})
}
// Loot 2: Refresh token commands
var refreshCmds strings.Builder
refreshCmds.WriteString("# Refresh Token Commands\n")
refreshCmds.WriteString("# Generated by CloudFox\n")
refreshCmds.WriteString("# Refresh tokens can be used to obtain new access tokens\n\n")
hasRefreshCmds := false
for _, c := range m.Credentials {
if c.HasRefresh && (c.Type == "access_token" || c.Type == "refresh_token") {
refreshCmds.WriteString(fmt.Sprintf("# Account: %s\n", c.Account))
refreshCmds.WriteString("# To extract the refresh token:\n")
refreshCmds.WriteString(fmt.Sprintf("# sqlite3 ~/.config/gcloud/credentials.db \"SELECT value FROM credentials WHERE account_id='%s'\"\n", c.Account))
refreshCmds.WriteString("# Then use it to get a new access token:\n")
refreshCmds.WriteString("# curl -s -X POST https://oauth2.googleapis.com/token \\\n")
refreshCmds.WriteString("# -d 'client_id=<CLIENT_ID>' \\\n")
refreshCmds.WriteString("# -d 'client_secret=<CLIENT_SECRET>' \\\n")
refreshCmds.WriteString("# -d 'refresh_token=<REFRESH_TOKEN>' \\\n")
refreshCmds.WriteString("# -d 'grant_type=refresh_token'\n\n")
hasRefreshCmds = true
}
}
if hasRefreshCmds {
lootFiles = append(lootFiles, internal.LootFile{
Name: "access-tokens-refresh-commands",
Contents: refreshCmds.String(),
})
}
// Loot 3: ADC commands
var adcCmds strings.Builder
adcCmds.WriteString("# Application Default Credentials (ADC) Commands\n")
adcCmds.WriteString("# Generated by CloudFox\n\n")
hasADCCmds := false
for _, c := range m.Credentials {
if c.Type == "adc" {
adcCmds.WriteString(fmt.Sprintf("# ADC Account: %s\n", c.Account))
adcCmds.WriteString(fmt.Sprintf("# Source: %s\n", c.Source))
if c.Project != "" {
adcCmds.WriteString(fmt.Sprintf("# Quota Project: %s\n", c.Project))
}
adcCmds.WriteString("# ADC is used by client libraries and tools like terraform\n")
adcCmds.WriteString("# To use these credentials:\n")
adcCmds.WriteString("# gcloud auth application-default print-access-token\n")
adcCmds.WriteString("# Or copy ~/.config/gcloud/application_default_credentials.json to your attack machine\n\n")
hasADCCmds = true
}
}
if hasADCCmds {
lootFiles = append(lootFiles, internal.LootFile{
Name: "access-tokens-adc-commands",
Contents: adcCmds.String(),
})
}
output := AccessTokensOutput{Table: tables, Loot: lootFiles}
// Use HandleOutputSmart with a local-only scope
err := internal.HandleOutputSmart(
"gcp",
m.Format,
m.OutputDir,
m.Verbosity,
m.WrapTable,
"local",
[]string{"localhost"},
[]string{"localhost"},
"local",
output,
)
if err != nil {
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_ACCESS_TOKENS_MODULE_NAME)
}
}
+7 -2
View File
@@ -9,6 +9,7 @@ import (
artifactregistry "cloud.google.com/go/artifactregistry/apiv1"
ArtifactRegistryService "github.com/BishopFox/cloudfox/gcp/services/artifactRegistryService"
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
"google.golang.org/api/option"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
"github.com/spf13/cobra"
@@ -73,8 +74,12 @@ func runGCPArtifactRegistryCommand(cmd *cobra.Command, args []string) {
return // Error already logged
}
// Create Artifact Registry client
client, err := artifactregistry.NewClient(cmdCtx.Ctx)
// Create Artifact Registry client (uses impersonated credentials if --impersonate-sa is set)
var arOpts []option.ClientOption
if s := gcpinternal.GetDefaultSession(); s != nil {
arOpts = append(arOpts, s.GetClientOption())
}
client, err := artifactregistry.NewClient(cmdCtx.Ctx, arOpts...)
if err != nil {
cmdCtx.Logger.ErrorM(fmt.Sprintf("Failed to create Artifact Registry client: %v", err), globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME)
return
+42
View File
@@ -7,6 +7,8 @@ import (
"sync"
BigQueryService "github.com/BishopFox/cloudfox/gcp/services/bigqueryService"
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
"github.com/BishopFox/cloudfox/gcp/shared"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
@@ -135,6 +137,40 @@ func (m *BigQueryModule) processProject(ctx context.Context, projectID string, l
return
}
// Inject inherited project-level IAM bindings
iamSvc := IAMService.New()
projectPolicies, projErr := iamSvc.Policies(projectID, "project")
if projErr == nil {
// Inject into datasets (AccessEntries)
for i := range result.Datasets {
for _, pb := range projectPolicies {
if !shared.IsInheritedRole("dataset", pb.Role) {
continue
}
for _, member := range pb.Members {
result.Datasets[i].AccessEntries = append(result.Datasets[i].AccessEntries, BigQueryService.AccessEntry{
Role: pb.Role,
Entity: member,
Source: "Project",
})
}
}
}
// Inject into tables
for i := range result.Tables {
for _, pb := range projectPolicies {
if !shared.IsInheritedRole("table", pb.Role) {
continue
}
result.Tables[i].IAMBindings = append(result.Tables[i].IAMBindings, BigQueryService.TableIAMBinding{
Role: pb.Role,
Members: pb.Members,
Source: "Project",
})
}
}
}
// Thread-safe store per-project
m.mu.Lock()
m.ProjectDatasets[projectID] = result.Datasets
@@ -261,6 +297,7 @@ func (m *BigQueryModule) getDatasetHeader() []string {
"IAM Binding Role",
"Principal Type",
"IAM Binding Principal",
"Binding Source",
}
}
@@ -276,6 +313,7 @@ func (m *BigQueryModule) getTableHeader() []string {
"Public",
"IAM Binding Role",
"IAM Binding Principal",
"Binding Source",
}
}
@@ -306,6 +344,7 @@ func (m *BigQueryModule) datasetsToTableBody(datasets []BigQueryService.Bigquery
role,
memberType,
entry.Entity,
shared.BindingSource(entry.Source),
})
}
} else {
@@ -318,6 +357,7 @@ func (m *BigQueryModule) datasetsToTableBody(datasets []BigQueryService.Bigquery
"-",
"-",
"-",
"-",
})
}
}
@@ -344,6 +384,7 @@ func (m *BigQueryModule) tablesToTableBody(tables []BigQueryService.BigqueryTabl
publicStatus,
"-",
"-",
"-",
})
} else {
for _, binding := range table.IAMBindings {
@@ -358,6 +399,7 @@ func (m *BigQueryModule) tablesToTableBody(tables []BigQueryService.BigqueryTabl
publicStatus,
binding.Role,
member,
shared.BindingSource(binding.Source),
})
}
}
+22
View File
@@ -7,6 +7,7 @@ import (
"sync"
CloudStorageService "github.com/BishopFox/cloudfox/gcp/services/cloudStorageService"
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
"github.com/BishopFox/cloudfox/gcp/shared"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
@@ -161,6 +162,24 @@ func (m *BucketsModule) processProject(ctx context.Context, projectID string, lo
return
}
// Inject inherited project-level IAM bindings into each bucket
iamSvc := IAMService.New()
projectPolicies, projErr := iamSvc.Policies(projectID, "project")
if projErr == nil {
for i := range buckets {
for _, pb := range projectPolicies {
if !shared.IsInheritedRole("bucket", pb.Role) {
continue
}
buckets[i].IAMBindings = append(buckets[i].IAMBindings, CloudStorageService.IAMBinding{
Role: pb.Role,
Members: pb.Members,
Source: "Project",
})
}
}
}
// Thread-safe store per-project
m.mu.Lock()
m.ProjectBuckets[projectID] = buckets
@@ -377,6 +396,7 @@ func (m *BucketsModule) getTableHeader() []string {
"IAM Binding Role",
"Principal Type",
"IAM Binding Principal",
"Binding Source",
"Principal Attack Paths",
}
}
@@ -454,6 +474,7 @@ func (m *BucketsModule) bucketsToTableBody(buckets []CloudStorageService.BucketI
binding.Role,
memberType,
member,
shared.BindingSource(binding.Source),
attackPaths,
})
}
@@ -475,6 +496,7 @@ func (m *BucketsModule) bucketsToTableBody(buckets []CloudStorageService.BucketI
"-",
"-",
"-",
"-",
})
}
}
+46 -4
View File
@@ -7,6 +7,8 @@ import (
"sync"
CloudRunService "github.com/BishopFox/cloudfox/gcp/services/cloudrunService"
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
"github.com/BishopFox/cloudfox/gcp/shared"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
@@ -173,6 +175,10 @@ func (m *CloudRunModule) processProject(ctx context.Context, projectID string, l
}
m.mu.Unlock()
// Fetch project-level IAM for inherited binding injection
iamSvc := IAMService.New()
projectPolicies, _ := iamSvc.Policies(projectID, "project")
// Get services
services, err := cs.Services(projectID)
if err != nil {
@@ -180,6 +186,23 @@ func (m *CloudRunModule) processProject(ctx context.Context, projectID string, l
gcpinternal.HandleGCPError(err, logger, globals.GCP_CLOUDRUN_MODULE_NAME,
fmt.Sprintf("Could not enumerate Cloud Run services in project %s", projectID))
} else {
// Inject inherited project-level IAM bindings into services
if projectPolicies != nil {
for i := range services {
for _, pb := range projectPolicies {
if !shared.IsInheritedRole("cloudrun", pb.Role) {
continue
}
for _, member := range pb.Members {
services[i].IAMBindings = append(services[i].IAMBindings, CloudRunService.IAMBinding{
Role: pb.Role,
Member: member,
Source: "Project",
})
}
}
}
}
m.mu.Lock()
m.ProjectServices[projectID] = services
for _, svc := range services {
@@ -195,6 +218,23 @@ func (m *CloudRunModule) processProject(ctx context.Context, projectID string, l
gcpinternal.HandleGCPError(err, logger, globals.GCP_CLOUDRUN_MODULE_NAME,
fmt.Sprintf("Could not enumerate Cloud Run jobs in project %s", projectID))
} else {
// Inject inherited project-level IAM bindings into jobs
if projectPolicies != nil {
for i := range jobs {
for _, pb := range projectPolicies {
if !shared.IsInheritedRole("cloudrun", pb.Role) {
continue
}
for _, member := range pb.Members {
jobs[i].IAMBindings = append(jobs[i].IAMBindings, CloudRunService.IAMBinding{
Role: pb.Role,
Member: member,
Source: "Project",
})
}
}
}
}
m.mu.Lock()
m.ProjectJobs[projectID] = jobs
for _, job := range jobs {
@@ -454,7 +494,7 @@ func (m *CloudRunModule) buildTablesForProject(projectID string, services []Clou
servicesHeader := []string{
"Project", "Type", "Name", "Region", "Status", "URL", "Ingress", "Public",
"Service Account", "SA Attack Paths", "Default SA", "Image", "VPC Access",
"Min/Max", "IAM Binding Role", "IAM Binding Principal",
"Min/Max", "IAM Binding Role", "IAM Binding Principal", "Binding Source",
}
var servicesBody [][]string
@@ -497,6 +537,7 @@ func (m *CloudRunModule) buildTablesForProject(projectID string, services []Clou
formatIngress(svc.IngressSettings), publicStatus, svc.ServiceAccount,
attackPaths, defaultSA, svc.ContainerImage, vpcAccess, scaling,
binding.Role, binding.Member,
shared.BindingSource(binding.Source),
})
}
} else {
@@ -505,7 +546,7 @@ func (m *CloudRunModule) buildTablesForProject(projectID string, services []Clou
m.GetProjectName(svc.ProjectID), "Service", svc.Name, svc.Region, status, svc.URL,
formatIngress(svc.IngressSettings), publicStatus, svc.ServiceAccount,
attackPaths, defaultSA, svc.ContainerImage, vpcAccess, scaling,
"-", "-",
"-", "-", "-",
})
}
}
@@ -522,7 +563,7 @@ func (m *CloudRunModule) buildTablesForProject(projectID string, services []Clou
jobsHeader := []string{
"Project", "Type", "Name", "Region", "Status", "Service Account", "SA Attack Paths", "Default SA",
"Image", "VPC Access", "Tasks", "Parallelism", "Last Execution",
"IAM Binding Role", "IAM Binding Principal",
"IAM Binding Role", "IAM Binding Principal", "Binding Source",
}
var jobsBody [][]string
@@ -565,6 +606,7 @@ func (m *CloudRunModule) buildTablesForProject(projectID string, services []Clou
job.ServiceAccount, jobAttackPaths, defaultSA, job.ContainerImage, vpcAccess,
fmt.Sprintf("%d", job.TaskCount), fmt.Sprintf("%d", job.Parallelism),
lastExec, binding.Role, binding.Member,
shared.BindingSource(binding.Source),
})
}
} else {
@@ -573,7 +615,7 @@ func (m *CloudRunModule) buildTablesForProject(projectID string, services []Clou
m.GetProjectName(job.ProjectID), "Job", job.Name, job.Region, status,
job.ServiceAccount, jobAttackPaths, defaultSA, job.ContainerImage, vpcAccess,
fmt.Sprintf("%d", job.TaskCount), fmt.Sprintf("%d", job.Parallelism),
lastExec, "-", "-",
lastExec, "-", "-", "-",
})
}
}
+24
View File
@@ -9,6 +9,7 @@ import (
"github.com/BishopFox/cloudfox/gcp/shared"
FunctionsService "github.com/BishopFox/cloudfox/gcp/services/functionsService"
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
@@ -154,6 +155,26 @@ func (m *FunctionsModule) processProject(ctx context.Context, projectID string,
return
}
// Inject inherited project-level IAM bindings into each function
iamSvc := IAMService.New()
projectPolicies, projErr := iamSvc.Policies(projectID, "project")
if projErr == nil {
for i := range functions {
for _, pb := range projectPolicies {
if !shared.IsInheritedRole("function", pb.Role) {
continue
}
for _, member := range pb.Members {
functions[i].IAMBindings = append(functions[i].IAMBindings, FunctionsService.IAMBinding{
Role: pb.Role,
Member: member,
Source: "Project",
})
}
}
}
}
// Thread-safe store per-project
m.mu.Lock()
m.ProjectFunctions[projectID] = functions
@@ -495,6 +516,7 @@ func (m *FunctionsModule) getTableHeader() []string {
"VPC Access",
"IAM Binding Role",
"IAM Binding Principal",
"Binding Source",
}
}
@@ -572,6 +594,7 @@ func (m *FunctionsModule) functionsToTableBody(functions []FunctionsService.Func
vpcAccess,
binding.Role,
binding.Member,
shared.BindingSource(binding.Source),
})
}
} else {
@@ -592,6 +615,7 @@ func (m *FunctionsModule) functionsToTableBody(functions []FunctionsService.Func
vpcAccess,
"-",
"-",
"-",
})
}
}
+5 -1
View File
@@ -290,6 +290,7 @@ func (m *HiddenAdminsModule) analyzeOrganizationIAM(ctx context.Context, logger
policy, err := orgsClient.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{
Resource: org.Name,
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
})
if err != nil {
continue
@@ -332,6 +333,7 @@ func (m *HiddenAdminsModule) analyzeFolderIAM(ctx context.Context, logger intern
policy, err := foldersClient.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{
Resource: folder.Name,
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
})
if err != nil {
continue
@@ -352,7 +354,9 @@ func (m *HiddenAdminsModule) analyzeProjectIAM(ctx context.Context, logger inter
return
}
policy, err := crmService.Projects.GetIamPolicy(projectID, &crmv1.GetIamPolicyRequest{}).Do()
policy, err := crmService.Projects.GetIamPolicy(projectID, &crmv1.GetIamPolicyRequest{
Options: &crmv1.GetPolicyOptions{RequestedPolicyVersion: 3},
}).Do()
if err != nil {
return
}
+252 -16
View File
@@ -7,6 +7,7 @@ import (
"sync"
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
resourceiamservice "github.com/BishopFox/cloudfox/gcp/services/resourceIAMService"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
@@ -78,6 +79,28 @@ var highPrivilegeRoles = map[string]bool{
"roles/orgpolicy.policyAdmin": true,
}
// adminRoles are roles that grant broad administrative control. Used as a
// fallback for the Admin column when FoxMapper data is not available.
var adminRoles = map[string]bool{
"roles/owner": true,
"roles/editor": true,
"roles/resourcemanager.projectIamAdmin": true,
"roles/resourcemanager.folderAdmin": true,
"roles/resourcemanager.folderIamAdmin": true,
"roles/resourcemanager.organizationAdmin": true,
"roles/iam.securityAdmin": true,
"roles/iam.roleAdmin": true,
}
// isAdminRole returns "Yes" if the role is a known admin role, "" otherwise.
// Used as a fallback when FoxMapper is not available.
func isAdminRole(role string) string {
if adminRoles[role] {
return "Yes"
}
return ""
}
// ------------------------------
// Module Struct with embedded BaseGCPModule
// ------------------------------
@@ -85,11 +108,12 @@ type IAMModule struct {
gcpinternal.BaseGCPModule
// Module-specific fields - using enhanced data
ScopeBindings []IAMService.ScopeBinding
ServiceAccounts []IAMService.ServiceAccountInfo
CustomRoles []IAMService.CustomRole
Groups []IAMService.GroupInfo
MFAStatus map[string]*IAMService.MFAStatus
ScopeBindings []IAMService.ScopeBinding
ServiceAccounts []IAMService.ServiceAccountInfo
CustomRoles []IAMService.CustomRole
Groups []IAMService.GroupInfo
MFAStatus map[string]*IAMService.MFAStatus
ResourceBindings []resourceiamservice.ResourceIAMBinding
// Per-scope loot for inheritance-aware output
OrgLoot map[string]*internal.LootFile // orgID -> loot commands
@@ -132,11 +156,12 @@ func runGCPIAMCommand(cmd *cobra.Command, args []string) {
// Create module instance
module := &IAMModule{
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
ScopeBindings: []IAMService.ScopeBinding{},
ServiceAccounts: []IAMService.ServiceAccountInfo{},
CustomRoles: []IAMService.CustomRole{},
Groups: []IAMService.GroupInfo{},
MFAStatus: make(map[string]*IAMService.MFAStatus),
ScopeBindings: []IAMService.ScopeBinding{},
ServiceAccounts: []IAMService.ServiceAccountInfo{},
CustomRoles: []IAMService.CustomRole{},
Groups: []IAMService.GroupInfo{},
MFAStatus: make(map[string]*IAMService.MFAStatus),
ResourceBindings: []resourceiamservice.ResourceIAMBinding{},
OrgLoot: make(map[string]*internal.LootFile),
FolderLoot: make(map[string]*internal.LootFile),
ProjectLoot: make(map[string]*internal.LootFile),
@@ -182,6 +207,16 @@ func (m *IAMModule) Execute(ctx context.Context, logger internal.Logger) {
m.Groups = iamData.Groups
m.MFAStatus = iamData.MFAStatus
// Enumerate resource-level IAM bindings (SA, buckets, secrets, functions, etc.)
resourceIAMSvc := resourceiamservice.New()
for _, projectID := range m.ProjectIDs {
resourceBindings, err := resourceIAMSvc.GetAllResourceIAM(ctx, projectID)
if err != nil {
continue
}
m.ResourceBindings = append(m.ResourceBindings, resourceBindings...)
}
// Try to enumerate group memberships to build reverse lookup
enrichedGroups := iamService.GetGroupMemberships(ctx, m.Groups)
m.Groups = enrichedGroups
@@ -220,8 +255,9 @@ func (m *IAMModule) Execute(ctx context.Context, logger internal.Logger) {
}
}
logger.SuccessM(fmt.Sprintf("Found %d binding(s) across %d org(s), %d folder(s), %d project(s); %d SA(s), %d custom role(s), %d group(s)",
logger.SuccessM(fmt.Sprintf("Found %d binding(s) across %d org(s), %d folder(s), %d project(s); %d resource-level binding(s); %d SA(s), %d custom role(s), %d group(s)",
len(m.ScopeBindings), orgCount, folderCount, projectCount,
len(m.ResourceBindings),
len(m.ServiceAccounts), len(m.CustomRoles), len(m.Groups)), globals.GCP_IAM_MODULE_NAME)
// Write output
@@ -631,9 +667,11 @@ func (m *IAMModule) buildTables() []internal.TableFile {
// Add scope bindings (one row per binding)
for _, sb := range m.ScopeBindings {
// Check admin status from FoxMapper only - shows Org/Folder/Project or No
// This is different from "high privilege roles" - Admin means broad IAM control
// Check admin status: FoxMapper (granular) > role-based fallback
adminStatus := gcpinternal.GetAdminStatusFromCache(m.FoxMapperCache, sb.MemberEmail)
if adminStatus == "" {
adminStatus = isAdminRole(sb.Role)
}
if adminStatus == "" {
adminStatus = "No"
}
@@ -698,6 +736,91 @@ func (m *IAMModule) buildTables() []internal.TableFile {
})
}
// Add resource-level IAM bindings (one row per binding)
for _, rb := range m.ResourceBindings {
memberType := resourceiamservice.DetermineMemberType(rb.Member)
memberEmail := rb.MemberEmail
// Check admin status: FoxMapper (granular) > role-based fallback
adminStatus := gcpinternal.GetAdminStatusFromCache(m.FoxMapperCache, memberEmail)
if adminStatus == "" {
adminStatus = isAdminRole(rb.Role)
}
if adminStatus == "" {
adminStatus = "No"
}
isCustom := "No"
if strings.HasPrefix(rb.Role, "projects/") || strings.HasPrefix(rb.Role, "organizations/") {
isCustom = "Yes"
}
condition := "No"
if rb.HasCondition && rb.ConditionTitle != "" {
condition = rb.ConditionTitle
} else if rb.HasCondition {
condition = "Yes"
}
// Get MFA status
mfa := "-"
if memberType == "User" {
if status, ok := m.MFAStatus[memberEmail]; ok {
if status.Error != "" {
mfa = "Unknown"
} else if status.HasMFA {
mfa = "Yes"
} else {
mfa = "No"
}
}
} else if memberType == "ServiceAccount" {
mfa = "N/A"
}
// Get groups this member belongs to
groups := "-"
if memberGroups, ok := m.MemberToGroups[memberEmail]; ok && len(memberGroups) > 0 {
groups = strings.Join(memberGroups, ", ")
}
// Check for federated identity
federated := formatFederatedInfo(parseFederatedIdentity(memberEmail))
// Check attack paths for service account principals
attackPaths := "-"
if memberType == "ServiceAccount" {
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, memberEmail)
}
body = append(body, []string{
rb.ResourceType,
rb.ResourceID,
rb.ResourceName,
memberType,
memberEmail,
rb.Role,
adminStatus,
isCustom,
"-",
condition,
mfa,
groups,
federated,
attackPaths,
})
}
// Build SA email -> roles lookup from scope bindings for admin fallback.
// ServiceAccountsBasic() doesn't populate the Roles field, so we
// extract roles from the already-enumerated bindings.
saRolesMap := make(map[string][]string)
for _, sb := range m.ScopeBindings {
if sb.MemberType == "ServiceAccount" {
saRolesMap[sb.MemberEmail] = append(saRolesMap[sb.MemberEmail], sb.Role)
}
}
// Add service accounts
for _, sa := range m.ServiceAccounts {
hasKeys := "No"
@@ -716,8 +839,16 @@ func (m *IAMModule) buildTables() []internal.TableFile {
groups = strings.Join(memberGroups, ", ")
}
// Check admin status from FoxMapper
// Check admin status: FoxMapper (granular) > role-based fallback
adminStatus := gcpinternal.GetAdminStatusFromCache(m.FoxMapperCache, sa.Email)
if adminStatus == "" {
for _, role := range saRolesMap[sa.Email] {
if adminRoles[role] {
adminStatus = "Yes"
break
}
}
}
if adminStatus == "" {
adminStatus = "No"
}
@@ -998,6 +1129,12 @@ func (m *IAMModule) writeHierarchicalOutputTee(ctx context.Context, logger inter
}
}
// Build reverse lookup: for each resource ID, which project it belongs to
resourceToProject := make(map[string]string)
for _, rb := range m.ResourceBindings {
resourceToProject[rb.ResourceID] = rb.ProjectID
}
// Create a row router that routes based on scope type and OrgCache
rowRouter := func(row []string) []string {
// Row format: [ScopeType, ScopeID, ScopeName, ...]
@@ -1021,6 +1158,11 @@ func (m *IAMModule) writeHierarchicalOutputTee(ctx context.Context, logger inter
}
return m.ProjectIDs
default:
// Resource-level binding (serviceAccount, bucket, secret, etc.)
// Route to the project that owns this resource
if projectID, ok := resourceToProject[scopeID]; ok {
return []string{projectID}
}
return nil
}
}
@@ -1099,8 +1241,11 @@ func (m *IAMModule) buildTablesForProject(projectID string) []internal.TableFile
continue
}
// Check admin status from FoxMapper only - shows Org/Folder/Project or No
// Check admin status: FoxMapper (granular) > role-based fallback
adminStatus := gcpinternal.GetAdminStatusFromCache(m.FoxMapperCache, sb.MemberEmail)
if adminStatus == "" {
adminStatus = isAdminRole(sb.Role)
}
if adminStatus == "" {
adminStatus = "No"
}
@@ -1161,6 +1306,89 @@ func (m *IAMModule) buildTablesForProject(projectID string) []internal.TableFile
})
}
// Add resource-level IAM bindings for this project
for _, rb := range m.ResourceBindings {
if rb.ProjectID != projectID {
continue
}
memberType := resourceiamservice.DetermineMemberType(rb.Member)
memberEmail := rb.MemberEmail
// Check admin status: FoxMapper (granular) > role-based fallback
adminStatus := gcpinternal.GetAdminStatusFromCache(m.FoxMapperCache, memberEmail)
if adminStatus == "" {
adminStatus = isAdminRole(rb.Role)
}
if adminStatus == "" {
adminStatus = "No"
}
isCustom := "No"
if strings.HasPrefix(rb.Role, "projects/") || strings.HasPrefix(rb.Role, "organizations/") {
isCustom = "Yes"
}
condition := "No"
if rb.HasCondition && rb.ConditionTitle != "" {
condition = rb.ConditionTitle
} else if rb.HasCondition {
condition = "Yes"
}
mfa := "-"
if memberType == "User" {
if status, ok := m.MFAStatus[memberEmail]; ok {
if status.Error != "" {
mfa = "Unknown"
} else if status.HasMFA {
mfa = "Yes"
} else {
mfa = "No"
}
}
} else if memberType == "ServiceAccount" {
mfa = "N/A"
}
groups := "-"
if memberGroups, ok := m.MemberToGroups[memberEmail]; ok && len(memberGroups) > 0 {
groups = strings.Join(memberGroups, ", ")
}
federated := formatFederatedInfo(parseFederatedIdentity(memberEmail))
attackPaths := "-"
if memberType == "ServiceAccount" {
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, memberEmail)
}
body = append(body, []string{
rb.ResourceType,
rb.ResourceID,
rb.ResourceName,
memberType,
memberEmail,
rb.Role,
adminStatus,
isCustom,
"-",
condition,
mfa,
groups,
federated,
attackPaths,
})
}
// Build SA email -> roles lookup from scope bindings for admin fallback
saRolesMap := make(map[string][]string)
for _, sb := range m.ScopeBindings {
if sb.MemberType == "ServiceAccount" {
saRolesMap[sb.MemberEmail] = append(saRolesMap[sb.MemberEmail], sb.Role)
}
}
// Add service accounts for this project only
for _, sa := range m.ServiceAccounts {
if sa.ProjectID != projectID {
@@ -1182,8 +1410,16 @@ func (m *IAMModule) buildTablesForProject(projectID string) []internal.TableFile
groups = strings.Join(memberGroups, ", ")
}
// Check admin status from FoxMapper
// Check admin status: FoxMapper (granular) > role-based fallback
adminStatus := gcpinternal.GetAdminStatusFromCache(m.FoxMapperCache, sa.Email)
if adminStatus == "" {
for _, role := range saRolesMap[sa.Email] {
if adminRoles[role] {
adminStatus = "Yes"
break
}
}
}
if adminStatus == "" {
adminStatus = "No"
}
+4
View File
@@ -448,6 +448,7 @@ func (m *IdentityFederationModule) buildTables(
"Display Name",
"State",
"Disabled",
"Full Resource Path",
}
var poolsBody [][]string
@@ -462,6 +463,7 @@ func (m *IdentityFederationModule) buildTables(
pool.DisplayName,
pool.State,
disabled,
pool.Name,
})
}
@@ -482,6 +484,7 @@ func (m *IdentityFederationModule) buildTables(
"OIDC Issuer / AWS Account",
"Trust Scope",
"Access Condition",
"Full Resource Path",
}
var providersBody [][]string
@@ -508,6 +511,7 @@ func (m *IdentityFederationModule) buildTables(
issuerOrAccount,
trustScope,
attrCond,
p.Name,
})
}
+59 -12
View File
@@ -9,6 +9,8 @@ import (
"sync"
ComputeEngineService "github.com/BishopFox/cloudfox/gcp/services/computeEngineService"
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
orgpolicyservice "github.com/BishopFox/cloudfox/gcp/services/orgpolicyService"
"github.com/BishopFox/cloudfox/gcp/shared"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
@@ -39,6 +41,7 @@ Security Columns:
- BroadScopes: Has cloud-platform or other broad OAuth scopes
- OSLogin: OS Login enabled (recommended for access control)
- OSLogin2FA: OS Login with 2FA required
- OSLoginPolicy: Whether compute.requireOsLogin org policy is enforced (inherited)
- BlockProjKeys: Instance blocks project-wide SSH keys
- SerialPort: Serial port access enabled (security risk if exposed)
- CanIPForward: Can forward packets (potential for lateral movement)
@@ -55,11 +58,12 @@ type InstancesModule struct {
gcpinternal.BaseGCPModule
// Module-specific fields - per-project for hierarchical output
ProjectInstances map[string][]ComputeEngineService.ComputeEngineInfo // projectID -> instances
ProjectMetadata map[string]*ComputeEngineService.ProjectMetadataInfo // projectID -> metadata
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
FoxMapperCache *gcpinternal.FoxMapperCache // FoxMapper graph data (preferred)
mu sync.Mutex
ProjectInstances map[string][]ComputeEngineService.ComputeEngineInfo // projectID -> instances
ProjectMetadata map[string]*ComputeEngineService.ProjectMetadataInfo // projectID -> metadata
ProjectOSLoginPolicy map[string]string // projectID -> "Enforced", "Not Enforced", "Unknown"
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
FoxMapperCache *gcpinternal.FoxMapperCache // FoxMapper graph data (preferred)
mu sync.Mutex
}
// ------------------------------
@@ -85,10 +89,11 @@ func runGCPInstancesCommand(cmd *cobra.Command, args []string) {
// Create module instance
module := &InstancesModule{
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
ProjectInstances: make(map[string][]ComputeEngineService.ComputeEngineInfo),
ProjectMetadata: make(map[string]*ComputeEngineService.ProjectMetadataInfo),
LootMap: make(map[string]map[string]*internal.LootFile),
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
ProjectInstances: make(map[string][]ComputeEngineService.ComputeEngineInfo),
ProjectMetadata: make(map[string]*ComputeEngineService.ProjectMetadataInfo),
ProjectOSLoginPolicy: make(map[string]string),
LootMap: make(map[string]map[string]*internal.LootFile),
}
// Execute enumeration
@@ -148,10 +153,41 @@ func (m *InstancesModule) processProject(ctx context.Context, projectID string,
return
}
// Inject inherited project-level IAM bindings into each instance
iamSvc := IAMService.New()
projectPolicies, projErr := iamSvc.Policies(projectID, "project")
if projErr == nil {
for i := range instances {
for _, pb := range projectPolicies {
if !shared.IsInheritedRole("instance", pb.Role) {
continue
}
for _, member := range pb.Members {
instances[i].IAMBindings = append(instances[i].IAMBindings, ComputeEngineService.IAMBinding{
Role: pb.Role,
Member: member,
Source: "Project",
})
}
}
}
}
// Check effective compute.requireOsLogin org policy for this project
orgPolicySvc := orgpolicyservice.New()
effectivePolicy, policyErr := orgPolicySvc.GetEffectivePolicy("project", projectID, "compute.requireOsLogin")
osLoginPolicy := "Unknown"
if policyErr == nil && effectivePolicy != nil && effectivePolicy.Enforced {
osLoginPolicy = "Enforced"
} else if policyErr == nil {
osLoginPolicy = "Not Enforced"
}
// Thread-safe store per-project
m.mu.Lock()
m.ProjectInstances[projectID] = instances
m.ProjectMetadata[projectID] = projectMeta
m.ProjectOSLoginPolicy[projectID] = osLoginPolicy
// Initialize loot for this project
if m.LootMap[projectID] == nil {
@@ -672,7 +708,7 @@ func (m *InstancesModule) writeFlatOutput(ctx context.Context, logger internal.L
// - Identity: Project, Name, Type, Zone, State, Machine Type
// - Network: External IP, Internal IP, IP Forward
// - Service Account: Service Account, SA Attack Paths, Scopes, Default SA, Broad Scopes
// - Access Control: OS Login, OS Login 2FA, Block Proj Keys, Serial Port
// - Access Control: OS Login, OS Login 2FA, OS Login Policy, Block Proj Keys, Serial Port
// - Protection: Delete Protect, Last Snapshot
// - Hardware Security: Shielded VM, Secure Boot, vTPM, Integrity, Confidential
// - Disk Encryption: Encryption, KMS Key
@@ -699,6 +735,7 @@ func (m *InstancesModule) getInstancesTableHeader() []string {
// Access Control
"OS Login",
"OS Login 2FA",
"OS Login Policy",
"Block Proj Keys",
"Serial Port",
// Protection
@@ -716,6 +753,7 @@ func (m *InstancesModule) getInstancesTableHeader() []string {
// IAM
"IAM Binding Role",
"IAM Binding Principal",
"Binding Source",
}
}
@@ -885,6 +923,12 @@ func (m *InstancesModule) instancesToTableBody(instances []ComputeEngineService.
kmsKey = "-"
}
// OS Login Policy display
osLoginPolicy := "Unknown"
if val, ok := m.ProjectOSLoginPolicy[instance.ProjectID]; ok {
osLoginPolicy = val
}
// Instance type for contextual display
instType := instance.InstanceType
if instType == "" {
@@ -916,6 +960,7 @@ func (m *InstancesModule) instancesToTableBody(instances []ComputeEngineService.
// Access Control
shared.BoolToYesNo(instance.OSLoginEnabled),
shared.BoolToYesNo(instance.OSLogin2FAEnabled),
osLoginPolicy,
shared.BoolToYesNo(instance.BlockProjectSSHKeys),
shared.BoolToYesNo(instance.SerialPortEnabled),
// Protection - Delete protection is NOT expected for managed instances (they're ephemeral)
@@ -936,18 +981,20 @@ func (m *InstancesModule) instancesToTableBody(instances []ComputeEngineService.
// If instance has IAM bindings, create one row per binding
if len(instance.IAMBindings) > 0 {
for _, binding := range instance.IAMBindings {
row := make([]string, len(baseRow)+2)
row := make([]string, len(baseRow)+3)
copy(row, baseRow)
row[len(baseRow)] = binding.Role
row[len(baseRow)+1] = binding.Member
row[len(baseRow)+2] = shared.BindingSource(binding.Source)
body = append(body, row)
}
} else {
// No IAM bindings - single row
row := make([]string, len(baseRow)+2)
row := make([]string, len(baseRow)+3)
copy(row, baseRow)
row[len(baseRow)] = "-"
row[len(baseRow)+1] = "-"
row[len(baseRow)+2] = "-"
body = append(body, row)
}
}
+24
View File
@@ -7,6 +7,7 @@ import (
"sync"
KMSService "github.com/BishopFox/cloudfox/gcp/services/kmsService"
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
"github.com/BishopFox/cloudfox/gcp/shared"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
@@ -184,6 +185,26 @@ func (m *KMSModule) processProject(ctx context.Context, projectID string, logger
fmt.Sprintf("Could not enumerate KMS keys in project %s", projectID))
}
// Inject inherited project-level IAM bindings into each key
iamSvc := IAMService.New()
projectPolicies, projErr := iamSvc.Policies(projectID, "project")
if projErr == nil {
for i := range keys {
for _, pb := range projectPolicies {
if !shared.IsInheritedRole("cryptoKey", pb.Role) {
continue
}
for _, member := range pb.Members {
keys[i].IAMBindings = append(keys[i].IAMBindings, KMSService.IAMBinding{
Role: pb.Role,
Member: member,
Source: "Project",
})
}
}
}
}
// Thread-safe store per-project
m.mu.Lock()
m.ProjectKeyRings[projectID] = keyRings
@@ -295,6 +316,7 @@ func (m *KMSModule) getKeysHeader() []string {
"Public Decrypt",
"IAM Binding Role",
"IAM Binding Principal",
"Binding Source",
}
}
@@ -341,6 +363,7 @@ func (m *KMSModule) keysToTableBody(keys []KMSService.CryptoKeyInfo) [][]string
shared.BoolToYesNo(key.IsPublicDecrypt),
binding.Role,
binding.Member,
shared.BindingSource(binding.Source),
})
}
} else {
@@ -359,6 +382,7 @@ func (m *KMSModule) keysToTableBody(keys []KMSService.CryptoKeyInfo) [][]string
shared.BoolToYesNo(key.IsPublicDecrypt),
"-",
"-",
"-",
})
}
}
+216 -46
View File
@@ -1,13 +1,14 @@
package commands
import (
"github.com/BishopFox/cloudfox/gcp/shared"
"context"
"fmt"
"strings"
"sync"
organizationsservice "github.com/BishopFox/cloudfox/gcp/services/organizationsService"
orgpolicyservice "github.com/BishopFox/cloudfox/gcp/services/orgpolicyService"
"github.com/BishopFox/cloudfox/gcp/shared"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
@@ -23,6 +24,9 @@ var GCPOrgPoliciesCommand = &cobra.Command{
Organization policies control security constraints across GCP resources. This module
identifies policies that may be misconfigured or weakened, creating security risks.
Enumerates policies at organization, folder, and project levels. Org and folder level
enumeration requires orgpolicy.policies.list at those scopes and is best-effort.
Security-Relevant Policies Analyzed:
- Domain restrictions (iam.allowedPolicyMemberDomains)
- Service account key controls (iam.disableServiceAccountKeyCreation)
@@ -44,7 +48,9 @@ Risk Indicators:
type OrgPoliciesModule struct {
gcpinternal.BaseGCPModule
ProjectPolicies map[string][]orgpolicyservice.OrgPolicyInfo // projectID -> policies
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
OrgPolicies map[string][]orgpolicyservice.OrgPolicyInfo // orgID -> policies
FolderPolicies map[string][]orgpolicyservice.OrgPolicyInfo // folderID -> policies
LootMap map[string]map[string]*internal.LootFile // scopeID -> loot files
mu sync.Mutex
}
@@ -65,12 +71,18 @@ func runGCPOrgPoliciesCommand(cmd *cobra.Command, args []string) {
module := &OrgPoliciesModule{
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
ProjectPolicies: make(map[string][]orgpolicyservice.OrgPolicyInfo),
OrgPolicies: make(map[string][]orgpolicyservice.OrgPolicyInfo),
FolderPolicies: make(map[string][]orgpolicyservice.OrgPolicyInfo),
LootMap: make(map[string]map[string]*internal.LootFile),
}
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
}
func (m *OrgPoliciesModule) Execute(ctx context.Context, logger internal.Logger) {
// Enumerate org-level and folder-level policies (best-effort)
m.enumerateOrgAndFolderPolicies(logger)
// Enumerate project-level policies
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_ORGPOLICIES_MODULE_NAME, m.processProject)
allPolicies := m.getAllPolicies()
@@ -83,8 +95,91 @@ func (m *OrgPoliciesModule) Execute(ctx context.Context, logger internal.Logger)
m.writeOutput(ctx, logger)
}
func (m *OrgPoliciesModule) enumerateOrgAndFolderPolicies(logger internal.Logger) {
orgSvc := organizationsservice.New()
policySvc := orgpolicyservice.New()
// Discover organizations
orgs, err := orgSvc.SearchOrganizations()
if err != nil {
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
logger.InfoM("Could not enumerate organizations (may lack permissions)", globals.GCP_ORGPOLICIES_MODULE_NAME)
}
} else {
for _, org := range orgs {
orgID := strings.TrimPrefix(org.Name, "organizations/")
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
logger.InfoM(fmt.Sprintf("Enumerating org policies for organization: %s (%s)", org.DisplayName, orgID), globals.GCP_ORGPOLICIES_MODULE_NAME)
}
policies, err := policySvc.ListOrganizationPolicies(orgID, org.DisplayName)
if err != nil {
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
gcpinternal.HandleGCPError(err, logger, globals.GCP_ORGPOLICIES_MODULE_NAME,
fmt.Sprintf("Could not enumerate org policies for organization %s", orgID))
}
continue
}
m.mu.Lock()
m.OrgPolicies[orgID] = policies
m.initLootForScope(orgID)
for _, policy := range policies {
m.addPolicyToLoot(orgID, policy)
}
m.mu.Unlock()
if len(policies) > 0 {
logger.SuccessM(fmt.Sprintf("Found %d org-level policy(ies) for organization %s", len(policies), org.DisplayName), globals.GCP_ORGPOLICIES_MODULE_NAME)
}
}
}
// Discover folders
folders, err := orgSvc.SearchAllFolders()
if err != nil {
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
logger.InfoM("Could not enumerate folders (may lack permissions)", globals.GCP_ORGPOLICIES_MODULE_NAME)
}
} else {
for _, folder := range folders {
folderID := strings.TrimPrefix(folder.Name, "folders/")
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
logger.InfoM(fmt.Sprintf("Enumerating org policies for folder: %s (%s)", folder.DisplayName, folderID), globals.GCP_ORGPOLICIES_MODULE_NAME)
}
policies, err := policySvc.ListFolderPolicies(folderID, folder.DisplayName)
if err != nil {
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
gcpinternal.HandleGCPError(err, logger, globals.GCP_ORGPOLICIES_MODULE_NAME,
fmt.Sprintf("Could not enumerate org policies for folder %s", folderID))
}
continue
}
m.mu.Lock()
m.FolderPolicies[folderID] = policies
m.initLootForScope(folderID)
for _, policy := range policies {
m.addPolicyToLoot(folderID, policy)
}
m.mu.Unlock()
if len(policies) > 0 {
logger.SuccessM(fmt.Sprintf("Found %d folder-level policy(ies) for folder %s", len(policies), folder.DisplayName), globals.GCP_ORGPOLICIES_MODULE_NAME)
}
}
}
}
func (m *OrgPoliciesModule) getAllPolicies() []orgpolicyservice.OrgPolicyInfo {
var all []orgpolicyservice.OrgPolicyInfo
for _, policies := range m.OrgPolicies {
all = append(all, policies...)
}
for _, policies := range m.FolderPolicies {
all = append(all, policies...)
}
for _, policies := range m.ProjectPolicies {
all = append(all, policies...)
}
@@ -97,13 +192,7 @@ func (m *OrgPoliciesModule) processProject(ctx context.Context, projectID string
}
m.mu.Lock()
if m.LootMap[projectID] == nil {
m.LootMap[projectID] = make(map[string]*internal.LootFile)
m.LootMap[projectID]["orgpolicies-commands"] = &internal.LootFile{
Name: "orgpolicies-commands",
Contents: "# Organization Policy Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
}
}
m.initLootForScope(projectID)
m.mu.Unlock()
svc := orgpolicyservice.New()
@@ -115,6 +204,11 @@ func (m *OrgPoliciesModule) processProject(ctx context.Context, projectID string
return
}
// Set ScopeName from project name map
for i := range policies {
policies[i].ScopeName = m.GetProjectName(projectID)
}
m.mu.Lock()
m.ProjectPolicies[projectID] = policies
for _, policy := range policies {
@@ -123,8 +217,18 @@ func (m *OrgPoliciesModule) processProject(ctx context.Context, projectID string
m.mu.Unlock()
}
func (m *OrgPoliciesModule) addPolicyToLoot(projectID string, policy orgpolicyservice.OrgPolicyInfo) {
lootFile := m.LootMap[projectID]["orgpolicies-commands"]
func (m *OrgPoliciesModule) initLootForScope(scopeID string) {
if m.LootMap[scopeID] == nil {
m.LootMap[scopeID] = make(map[string]*internal.LootFile)
m.LootMap[scopeID]["orgpolicies-commands"] = &internal.LootFile{
Name: "orgpolicies-commands",
Contents: "# Organization Policy Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
}
}
}
func (m *OrgPoliciesModule) addPolicyToLoot(scopeID string, policy orgpolicyservice.OrgPolicyInfo) {
lootFile := m.LootMap[scopeID]["orgpolicies-commands"]
if lootFile == nil {
return
}
@@ -134,13 +238,19 @@ func (m *OrgPoliciesModule) addPolicyToLoot(projectID string, policy orgpolicyse
constraintName = strings.TrimPrefix(constraintName, "constraints/")
}
// Scope label for loot comments
scopeLabel := fmt.Sprintf("%s: %s", policy.ScopeType, policy.ScopeID)
if policy.ScopeName != "" {
scopeLabel = fmt.Sprintf("%s: %s (%s)", policy.ScopeType, policy.ScopeName, policy.ScopeID)
}
lootFile.Contents += fmt.Sprintf(
"# =============================================================================\n"+
"# CONSTRAINT: %s\n"+
"# =============================================================================\n"+
"# Project: %s\n",
"# Scope: %s\n",
policy.Constraint,
policy.ProjectID,
scopeLabel,
)
if policy.Description != "" {
@@ -162,22 +272,56 @@ func (m *OrgPoliciesModule) addPolicyToLoot(projectID string, policy orgpolicyse
lootFile.Contents += fmt.Sprintf("# Denied Values: %s\n", strings.Join(policy.DeniedValues, ", "))
}
lootFile.Contents += fmt.Sprintf(
"\n# === ENUMERATION COMMANDS ===\n\n"+
// Build enumeration commands based on scope type
lootFile.Contents += "\n# === ENUMERATION COMMANDS ===\n\n"
switch policy.ScopeType {
case "organization":
lootFile.Contents += fmt.Sprintf(
"# Describe this policy:\n"+
"gcloud org-policies describe %s --project=%s\n\n"+
"# Get effective policy (includes inheritance):\n"+
"gcloud org-policies describe %s --project=%s --effective\n\n"+
"# List all constraints for this project:\n"+
"gcloud org-policies list --project=%s\n\n",
constraintName, policy.ProjectID,
constraintName, policy.ProjectID,
policy.ProjectID,
)
"gcloud org-policies describe %s --organization=%s\n\n"+
"# Get effective policy (includes inheritance):\n"+
"gcloud org-policies describe %s --organization=%s --effective\n\n"+
"# List all constraints for this organization:\n"+
"gcloud org-policies list --organization=%s\n\n",
constraintName, policy.ScopeID,
constraintName, policy.ScopeID,
policy.ScopeID,
)
case "folder":
lootFile.Contents += fmt.Sprintf(
"# Describe this policy:\n"+
"gcloud org-policies describe %s --folder=%s\n\n"+
"# Get effective policy (includes inheritance):\n"+
"gcloud org-policies describe %s --folder=%s --effective\n\n"+
"# List all constraints for this folder:\n"+
"gcloud org-policies list --folder=%s\n\n",
constraintName, policy.ScopeID,
constraintName, policy.ScopeID,
policy.ScopeID,
)
default: // project
lootFile.Contents += fmt.Sprintf(
"# Describe this policy:\n"+
"gcloud org-policies describe %s --project=%s\n\n"+
"# Get effective policy (includes inheritance):\n"+
"gcloud org-policies describe %s --project=%s --effective\n\n"+
"# List all constraints for this project:\n"+
"gcloud org-policies list --project=%s\n\n",
constraintName, policy.ScopeID,
constraintName, policy.ScopeID,
policy.ScopeID,
)
}
// Exploit/bypass commands based on specific constraint types
lootFile.Contents += "# === EXPLOIT / BYPASS COMMANDS ===\n\n"
// For exploit commands, use project ID if available, otherwise use scope ID
targetProject := policy.ProjectID
if targetProject == "" {
targetProject = "<PROJECT_ID>"
}
switch constraintName {
case "iam.allowedPolicyMemberDomains":
if policy.AllowAll {
@@ -185,7 +329,7 @@ func (m *OrgPoliciesModule) addPolicyToLoot(projectID string, policy orgpolicyse
lootFile.Contents += fmt.Sprintf(
"# Grant access to external identity:\n"+
"gcloud projects add-iam-policy-binding %s --member=user:[email protected] --role=roles/viewer\n\n",
policy.ProjectID,
targetProject,
)
} else if !policy.Enforced {
lootFile.Contents += "# [FINDING] Domain restriction is NOT ENFORCED\n\n"
@@ -196,7 +340,7 @@ func (m *OrgPoliciesModule) addPolicyToLoot(projectID string, policy orgpolicyse
lootFile.Contents += "# [FINDING] SA key creation is NOT restricted - create keys for persistence:\n"
lootFile.Contents += fmt.Sprintf(
"gcloud iam service-accounts keys create /tmp/sa-key.json --iam-account=SA_EMAIL@%s.iam.gserviceaccount.com\n\n",
policy.ProjectID,
targetProject,
)
} else {
lootFile.Contents += "# SA key creation is restricted - try alternative persistence methods:\n" +
@@ -209,7 +353,7 @@ func (m *OrgPoliciesModule) addPolicyToLoot(projectID string, policy orgpolicyse
lootFile.Contents += "# [FINDING] SA creation is NOT restricted - create backdoor service accounts:\n"
lootFile.Contents += fmt.Sprintf(
"gcloud iam service-accounts create cloudfox-backdoor --display-name='System Service' --project=%s\n\n",
policy.ProjectID,
targetProject,
)
}
@@ -225,7 +369,7 @@ func (m *OrgPoliciesModule) addPolicyToLoot(projectID string, policy orgpolicyse
lootFile.Contents += fmt.Sprintf(
"# Add SSH key to project metadata:\n"+
"gcloud compute project-info add-metadata --metadata=ssh-keys=\"attacker:ssh-rsa AAAA...\" --project=%s\n\n",
policy.ProjectID,
targetProject,
)
}
@@ -283,8 +427,9 @@ func (m *OrgPoliciesModule) writeOutput(ctx context.Context, logger internal.Log
func (m *OrgPoliciesModule) getHeader() []string {
return []string{
"Project Name",
"Project ID",
"Scope Type",
"Scope ID",
"Scope Name",
"Constraint",
"Description",
"Enforced",
@@ -314,9 +459,15 @@ func (m *OrgPoliciesModule) policiesToTableBody(policies []orgpolicyservice.OrgP
deniedValues = strings.Join(policy.DeniedValues, ", ")
}
scopeName := policy.ScopeName
if scopeName == "" {
scopeName = "-"
}
body = append(body, []string{
m.GetProjectName(policy.ProjectID),
policy.ProjectID,
policy.ScopeType,
policy.ScopeID,
scopeName,
policy.Constraint,
description,
shared.BoolToYesNo(policy.Enforced),
@@ -330,9 +481,9 @@ func (m *OrgPoliciesModule) policiesToTableBody(policies []orgpolicyservice.OrgP
return body
}
func (m *OrgPoliciesModule) buildTablesForProject(projectID string) []internal.TableFile {
func (m *OrgPoliciesModule) buildTablesForScope(scopeID string, policies []orgpolicyservice.OrgPolicyInfo) []internal.TableFile {
var tableFiles []internal.TableFile
if policies, ok := m.ProjectPolicies[projectID]; ok && len(policies) > 0 {
if len(policies) > 0 {
tableFiles = append(tableFiles, internal.TableFile{
Name: "orgpolicies",
Header: m.getHeader(),
@@ -342,24 +493,43 @@ func (m *OrgPoliciesModule) buildTablesForProject(projectID string) []internal.T
return tableFiles
}
func (m *OrgPoliciesModule) collectLootForScope(scopeID string) []internal.LootFile {
var lootFiles []internal.LootFile
if scopeLoot, ok := m.LootMap[scopeID]; ok {
for _, loot := range scopeLoot {
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# WARNING: Only use with proper authorization\n\n") {
lootFiles = append(lootFiles, *loot)
}
}
}
return lootFiles
}
func (m *OrgPoliciesModule) 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),
}
for projectID := range m.ProjectPolicies {
tableFiles := m.buildTablesForProject(projectID)
// Org-level data
for orgID, policies := range m.OrgPolicies {
tableFiles := m.buildTablesForScope(orgID, policies)
lootFiles := m.collectLootForScope(orgID)
outputData.OrgLevelData[orgID] = OrgPoliciesOutput{Table: tableFiles, Loot: lootFiles}
}
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)
}
}
}
// Folder-level data
for folderID, policies := range m.FolderPolicies {
tableFiles := m.buildTablesForScope(folderID, policies)
lootFiles := m.collectLootForScope(folderID)
outputData.FolderLevelData[folderID] = OrgPoliciesOutput{Table: tableFiles, Loot: lootFiles}
}
// Project-level data
for projectID, policies := range m.ProjectPolicies {
tableFiles := m.buildTablesForScope(projectID, policies)
lootFiles := m.collectLootForScope(projectID)
outputData.ProjectLevelData[projectID] = OrgPoliciesOutput{Table: tableFiles, Loot: lootFiles}
}
@@ -384,8 +554,8 @@ func (m *OrgPoliciesModule) writeFlatOutput(ctx context.Context, logger internal
}
var lootFiles []internal.LootFile
for _, projectLoot := range m.LootMap {
for _, loot := range projectLoot {
for _, scopeLoot := range m.LootMap {
for _, loot := range scopeLoot {
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# WARNING: Only use with proper authorization\n\n") {
lootFiles = append(lootFiles, *loot)
}
+145
View File
@@ -9,6 +9,7 @@ import (
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
orgsservice "github.com/BishopFox/cloudfox/gcp/services/organizationsService"
resourceiamservice "github.com/BishopFox/cloudfox/gcp/services/resourceIAMService"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
@@ -386,6 +387,138 @@ func (m *PermissionsModule) processProject(ctx context.Context, projectID string
}
}
// Enumerate SA-level IAM bindings (resource-level permissions on service accounts)
saList, saErr := iamService.ServiceAccountsBasic(projectID)
if saErr == nil {
saCtx := context.Background()
saBindingCount := 0
for _, sa := range saList {
saBindings, err := iamService.GetServiceAccountIAMBindings(saCtx, sa.Email, projectID)
if err != nil {
continue
}
for _, binding := range saBindings {
// Get permissions for this role
permissions, err := iamService.GetRolePermissions(saCtx, binding.Role)
if err != nil {
continue
}
for _, member := range binding.Members {
memberType := IAMService.GetMemberType(member)
memberEmail := extractMemberEmail(member)
for _, perm := range permissions {
isHighPriv := isHighPrivilegePermission(perm)
exploded := ExplodedPermission{
Entity: member,
EntityType: memberType,
EntityEmail: memberEmail,
Permission: perm,
Role: binding.Role,
RoleType: IAMService.GetRoleType(binding.Role),
ResourceScope: fmt.Sprintf("serviceAccount/%s", sa.Email),
ResourceScopeType: "serviceAccount",
ResourceScopeID: sa.Email,
ResourceScopeName: sa.Email,
IsInherited: false,
HasCondition: binding.HasCondition,
EffectiveProject: projectID,
ProjectName: m.GetProjectName(projectID),
IsHighPrivilege: isHighPriv,
}
if binding.ConditionInfo != nil {
exploded.ConditionTitle = binding.ConditionInfo.Title
exploded.Condition = binding.ConditionInfo.Title
}
// Detect cross-project access
if memberType == "ServiceAccount" {
saProject := extractProjectFromPrincipal(memberEmail, m.OrgCache)
if saProject != "" && saProject != projectID {
exploded.IsCrossProject = true
exploded.SourceProject = saProject
}
}
projectPerms = append(projectPerms, exploded)
saBindingCount++
}
}
}
}
if saBindingCount > 0 && globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
logger.InfoM(fmt.Sprintf("Enumerated %d SA-level permission entries across %d service account(s)", saBindingCount, len(saList)), globals.GCP_PERMISSIONS_MODULE_NAME)
}
}
// Enumerate resource-level IAM bindings (buckets, datasets, secrets, KMS, functions, Cloud Run, instances)
resourceIAMSvc := resourceiamservice.New()
resourceBindings, resourceErr := resourceIAMSvc.GetAllResourceIAM(ctx, projectID)
if resourceErr == nil && len(resourceBindings) > 0 {
resCtx := context.Background()
resourcePermCount := 0
for _, rb := range resourceBindings {
// Skip serviceAccount type here since we already handled it above
if rb.ResourceType == "serviceAccount" {
continue
}
// Get permissions for this role
permissions, err := iamService.GetRolePermissions(resCtx, rb.Role)
if err != nil {
continue
}
memberType := IAMService.GetMemberType(rb.Member)
memberEmail := extractMemberEmail(rb.Member)
for _, perm := range permissions {
isHighPriv := isHighPrivilegePermission(perm)
exploded := ExplodedPermission{
Entity: rb.Member,
EntityType: memberType,
EntityEmail: memberEmail,
Permission: perm,
Role: rb.Role,
RoleType: IAMService.GetRoleType(rb.Role),
ResourceScope: fmt.Sprintf("%s/%s", rb.ResourceType, rb.ResourceID),
ResourceScopeType: rb.ResourceType,
ResourceScopeID: rb.ResourceID,
ResourceScopeName: rb.ResourceName,
IsInherited: false,
HasCondition: rb.HasCondition,
EffectiveProject: projectID,
ProjectName: m.GetProjectName(projectID),
IsHighPrivilege: isHighPriv,
}
if rb.HasCondition && rb.ConditionTitle != "" {
exploded.ConditionTitle = rb.ConditionTitle
exploded.Condition = rb.ConditionTitle
}
// Detect cross-project access
if memberType == "ServiceAccount" {
saProject := extractProjectFromPrincipal(memberEmail, m.OrgCache)
if saProject != "" && saProject != projectID {
exploded.IsCrossProject = true
exploded.SourceProject = saProject
}
}
projectPerms = append(projectPerms, exploded)
resourcePermCount++
}
}
if resourcePermCount > 0 && globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
logger.InfoM(fmt.Sprintf("Enumerated %d resource-level permission entries in project %s", resourcePermCount, projectID), globals.GCP_PERMISSIONS_MODULE_NAME)
}
}
m.mu.Lock()
// Store per-project permissions
m.ProjectPerms[projectID] = append(m.ProjectPerms[projectID], projectPerms...)
@@ -435,11 +568,23 @@ func (m *PermissionsModule) getScopeName(scopeType, scopeID string) string {
return scopeID
case "folder":
return scopeID // Could be enhanced to lookup folder names
case "serviceAccount", "bucket", "dataset", "topic", "subscription",
"secret", "cryptoKey", "function", "cloudrun", "instance":
return scopeID // Resource ID is the scope name
default:
return scopeID
}
}
// extractMemberEmail strips the type prefix from a member string (e.g., "user:[email protected]" -> "[email protected]")
func extractMemberEmail(member string) string {
parts := strings.SplitN(member, ":", 2)
if len(parts) == 2 {
return parts[1]
}
return member
}
func parseConditionTitle(condition string) string {
// Try to extract title from condition if it looks like a struct
if strings.Contains(condition, "title:") {
+41
View File
@@ -6,6 +6,7 @@ import (
"strings"
"sync"
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
PubSubService "github.com/BishopFox/cloudfox/gcp/services/pubsubService"
"github.com/BishopFox/cloudfox/gcp/shared"
"github.com/BishopFox/cloudfox/globals"
@@ -187,6 +188,40 @@ func (m *PubSubModule) processProject(ctx context.Context, projectID string, log
subs = subsResult
}
// Inject inherited project-level IAM bindings
iamSvc := IAMService.New()
projectPolicies, projErr := iamSvc.Policies(projectID, "project")
if projErr == nil {
for i := range topics {
for _, pb := range projectPolicies {
if !shared.IsInheritedRole("topic", pb.Role) {
continue
}
for _, member := range pb.Members {
topics[i].IAMBindings = append(topics[i].IAMBindings, PubSubService.IAMBinding{
Role: pb.Role,
Member: member,
Source: "Project",
})
}
}
}
for i := range subs {
for _, pb := range projectPolicies {
if !shared.IsInheritedRole("subscription", pb.Role) {
continue
}
for _, member := range pb.Members {
subs[i].IAMBindings = append(subs[i].IAMBindings, PubSubService.IAMBinding{
Role: pb.Role,
Member: member,
Source: "Project",
})
}
}
}
}
// Thread-safe store per-project
m.mu.Lock()
m.ProjectTopics[projectID] = topics
@@ -615,6 +650,7 @@ func (m *PubSubModule) getTopicsHeader() []string {
"Public Publish",
"IAM Binding Role",
"IAM Binding Principal",
"Binding Source",
}
}
@@ -633,6 +669,7 @@ func (m *PubSubModule) getSubsHeader() []string {
"Public Subscribe",
"IAM Binding Role",
"IAM Binding Principal",
"Binding Source",
}
}
@@ -688,6 +725,7 @@ func (m *PubSubModule) topicsToTableBody(topics []PubSubService.TopicInfo) [][]s
publicPublish,
binding.Role,
binding.Member,
shared.BindingSource(binding.Source),
})
}
} else {
@@ -701,6 +739,7 @@ func (m *PubSubModule) topicsToTableBody(topics []PubSubService.TopicInfo) [][]s
publicPublish,
"-",
"-",
"-",
})
}
}
@@ -782,6 +821,7 @@ func (m *PubSubModule) subsToTableBody(subs []PubSubService.SubscriptionInfo) []
publicSubscribe,
binding.Role,
binding.Member,
shared.BindingSource(binding.Source),
})
}
} else {
@@ -799,6 +839,7 @@ func (m *PubSubModule) subsToTableBody(subs []PubSubService.SubscriptionInfo) []
publicSubscribe,
"-",
"-",
"-",
})
}
}
+47
View File
@@ -30,6 +30,8 @@ Supported Resource Types:
- Cloud KMS keys
- Cloud Functions
- Cloud Run services
- Service Accounts
- Compute Instances
Key Findings:
- Public access (allUsers/allAuthenticatedUsers)
@@ -275,6 +277,28 @@ func (m *ResourceIAMModule) addResourceToLoot(b resourceiamservice.ResourceIAMBi
b.ResourceID, b.ProjectID,
b.ResourceID, b.ProjectID,
)
case "serviceAccount":
lootFile.Contents += fmt.Sprintf(
"# Service Account: %s (Member: %s, Role: %s)\n"+
"gcloud iam service-accounts describe %s --project=%s\n"+
"gcloud iam service-accounts get-iam-policy %s --project=%s\n"+
"gcloud iam service-accounts keys list --iam-account=%s --project=%s\n\n",
b.ResourceID, b.Member, b.Role,
b.ResourceID, b.ProjectID,
b.ResourceID, b.ProjectID,
b.ResourceID, b.ProjectID,
)
case "instance":
// Extract zone from ResourceName (projects/{proj}/zones/{zone}/instances/{name})
zone := extractZoneFromResourceName(b.ResourceName)
lootFile.Contents += fmt.Sprintf(
"# Compute Instance: %s (Member: %s, Role: %s)\n"+
"gcloud compute instances describe %s --zone=%s --project=%s\n"+
"gcloud compute instances get-iam-policy %s --zone=%s --project=%s\n\n",
b.ResourceID, b.Member, b.Role,
b.ResourceID, zone, b.ProjectID,
b.ResourceID, zone, b.ProjectID,
)
}
}
@@ -306,9 +330,32 @@ func (m *ResourceIAMModule) addPublicResourceToLoot(b resourceiamservice.Resourc
"# Cloud Run service may be publicly accessible\ngcloud run services describe %s --project=%s\n\n",
b.ResourceID, b.ProjectID,
)
case "serviceAccount":
lootFile.Contents += fmt.Sprintf(
"# Service account has public IAM binding\ngcloud iam service-accounts describe %s --project=%s\n\n",
b.ResourceID, b.ProjectID,
)
case "instance":
zone := extractZoneFromResourceName(b.ResourceName)
lootFile.Contents += fmt.Sprintf(
"# Compute instance has public IAM binding\ngcloud compute instances describe %s --zone=%s --project=%s\n\n",
b.ResourceID, zone, b.ProjectID,
)
}
}
// extractZoneFromResourceName extracts the zone from a resource name like
// "projects/{proj}/zones/{zone}/instances/{name}"
func extractZoneFromResourceName(resourceName string) string {
parts := strings.Split(resourceName, "/")
for i, part := range parts {
if part == "zones" && i+1 < len(parts) {
return parts[i+1]
}
}
return ""
}
// resourceKey creates a unique key for a resource to group bindings
func resourceKey(b resourceiamservice.ResourceIAMBinding) string {
return fmt.Sprintf("%s|%s|%s", b.ProjectID, b.ResourceType, b.ResourceName)
+29 -2
View File
@@ -8,6 +8,8 @@ import (
secretmanager "cloud.google.com/go/secretmanager/apiv1"
SecretsService "github.com/BishopFox/cloudfox/gcp/services/secretsService"
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
"google.golang.org/api/option"
"github.com/BishopFox/cloudfox/gcp/shared"
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
"github.com/BishopFox/cloudfox/globals"
@@ -78,8 +80,12 @@ func runGCPSecretsCommand(cmd *cobra.Command, args []string) {
return // Error already logged
}
// Create Secret Manager client
client, err := secretmanager.NewClient(cmdCtx.Ctx)
// Create Secret Manager client (uses impersonated credentials if --impersonate-sa is set)
var smOpts []option.ClientOption
if s := gcpinternal.GetDefaultSession(); s != nil {
smOpts = append(smOpts, s.GetClientOption())
}
client, err := secretmanager.NewClient(cmdCtx.Ctx, smOpts...)
if err != nil {
cmdCtx.Logger.ErrorM(fmt.Sprintf("Failed to create Secret Manager client: %v", err), globals.GCP_SECRETS_MODULE_NAME)
return
@@ -151,6 +157,24 @@ func (m *SecretsModule) processProject(ctx context.Context, projectID string, lo
return
}
// Inject inherited project-level IAM bindings into each secret
iamSvc := IAMService.New()
projectPolicies, projErr := iamSvc.Policies(projectID, "project")
if projErr == nil {
for i := range secrets {
for _, pb := range projectPolicies {
if !shared.IsInheritedRole("secret", pb.Role) {
continue
}
secrets[i].IAMBindings = append(secrets[i].IAMBindings, SecretsService.IAMBinding{
Role: pb.Role,
Members: pb.Members,
Source: "Project",
})
}
}
}
// Thread-safe store per-project
m.mu.Lock()
m.ProjectSecrets[projectID] = secrets
@@ -399,6 +423,7 @@ func (m *SecretsModule) getTableHeader() []string {
"IAM Binding Role",
"Principal Type",
"IAM Binding Principal",
"Binding Source",
"Principal Attack Paths",
}
}
@@ -472,6 +497,7 @@ func (m *SecretsModule) secretsToTableBody(secrets []SecretsService.SecretInfo)
binding.Role,
memberType,
member,
shared.BindingSource(binding.Source),
attackPaths,
})
}
@@ -494,6 +520,7 @@ func (m *SecretsModule) secretsToTableBody(secrets []SecretsService.SecretInfo)
"-",
"-",
"-",
"-",
})
}
}
+117 -72
View File
@@ -36,6 +36,41 @@ Column Descriptions:
Run: runGCPServiceAccountsCommand,
}
// SAIAMBinding represents a raw IAM binding on a service account (role + member)
type SAIAMBinding struct {
Role string
Member string
Source string // "SA" (direct on SA resource) or "Project" (inherited from project IAM)
}
// saAccessRoles are project-level roles that implicitly grant access to all
// service accounts in the project. When a principal holds one of these at the
// project level, they can interact with every SA in that project.
var saAccessRoles = 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/iam.serviceAccountOpenIdTokenCreator": true,
}
// saRelevantPermissions are individual permissions that grant meaningful access
// to service accounts. Custom roles containing any of these should be treated
// as SA-access roles for inherited binding purposes.
var saRelevantPermissions = map[string]bool{
"iam.serviceAccounts.getAccessToken": true,
"iam.serviceAccountKeys.create": true,
"iam.serviceAccounts.signBlob": true,
"iam.serviceAccounts.signJwt": true,
"iam.serviceAccounts.implicitDelegation": true,
"iam.serviceAccounts.actAs": true,
"iam.serviceAccounts.setIamPolicy": true,
"iam.serviceAccounts.getOpenIdToken": true,
}
// ServiceAccountAnalysis extends ServiceAccountInfo with security analysis
type ServiceAccountAnalysis struct {
IAMService.ServiceAccountInfo
@@ -46,6 +81,8 @@ type ServiceAccountAnalysis struct {
HasOldKeys bool // Keys older than 90 days
// Pentest: Impersonation analysis
ImpersonationInfo *IAMService.SAImpersonationInfo
// Raw IAM bindings on this service account (actual roles, not friendly names)
RawIAMBindings []SAIAMBinding
}
// ------------------------------
@@ -190,6 +227,70 @@ func (m *ServiceAccountsModule) processProject(ctx context.Context, projectID st
// Silently skip if we can't get roles - user may not have IAM permissions
}
// Get raw SA-level IAM bindings for each service account (direct bindings)
saIAMBindingsMap := make(map[string][]SAIAMBinding)
bindingCtx := context.Background()
for _, sa := range serviceAccounts {
rawBindings, err := iamService.GetServiceAccountIAMBindings(bindingCtx, sa.Email, projectID)
if err == nil {
for _, pb := range rawBindings {
for _, member := range pb.Members {
saIAMBindingsMap[sa.Email] = append(saIAMBindingsMap[sa.Email], SAIAMBinding{
Role: pb.Role,
Member: member,
Source: "SA",
})
}
}
}
}
// Get project-level IAM bindings that grant implicit access to all SAs.
// These are inherited, not set directly on the SA resource.
projectBindings, err := iamService.Policies(projectID, "project")
if err == nil {
// Cache for custom role resolution so we only resolve each role once
resolvedRoles := make(map[string]bool) // role -> hasSAPermissions
roleCtx := context.Background()
for _, pb := range projectBindings {
include := saAccessRoles[pb.Role]
// For roles not in the hardcoded list, resolve permissions to check
// for SA-relevant permissions (catches custom roles like privesc_02_createSAKey)
if !include {
if cached, ok := resolvedRoles[pb.Role]; ok {
include = cached
} else {
perms, permErr := iamService.GetRolePermissions(roleCtx, pb.Role)
if permErr == nil {
for _, p := range perms {
if saRelevantPermissions[p] {
include = true
break
}
}
}
resolvedRoles[pb.Role] = include
}
}
if !include {
continue
}
for _, member := range pb.Members {
// Add this inherited binding to every SA in the project
for _, sa := range serviceAccounts {
saIAMBindingsMap[sa.Email] = append(saIAMBindingsMap[sa.Email], SAIAMBinding{
Role: pb.Role,
Member: member,
Source: "Project",
})
}
}
}
}
// Analyze each service account
var analyzedSAs []ServiceAccountAnalysis
for _, sa := range serviceAccounts {
@@ -202,6 +303,10 @@ func (m *ServiceAccountsModule) processProject(ctx context.Context, projectID st
if roles, ok := saRoles[sa.Email]; ok {
analyzed.Roles = roles
}
// Attach raw IAM bindings
if bindings, ok := saIAMBindingsMap[sa.Email]; ok {
analyzed.RawIAMBindings = bindings
}
analyzedSAs = append(analyzedSAs, analyzed)
}
@@ -481,6 +586,7 @@ func (m *ServiceAccountsModule) getTableHeader() []string {
// Impersonation
"IAM Binding Role",
"IAM Binding Principal",
"Binding Source",
}
}
@@ -541,85 +647,24 @@ func (m *ServiceAccountsModule) serviceAccountsToTableBody(serviceAccounts []Ser
}
}
// Format roles for display
rolesDisplay := IAMService.FormatRolesShort(sa.Roles)
// Format roles for display (full role names)
rolesDisplay := "-"
if len(sa.Roles) > 0 {
rolesDisplay = strings.Join(sa.Roles, ", ")
}
// Build IAM bindings from impersonation info
// Row order: Identity (Project, Email, Display Name, Disabled, Default SA),
// Keys (User Managed Keys, Google Managed Keys, Oldest Key Age),
// Permissions (DWD, Roles, SA Attack Paths),
// Impersonation (IAM Binding Role, IAM Binding Principal)
// Build IAM binding rows from raw bindings (actual roles, not friendly names)
hasBindings := false
if sa.ImpersonationInfo != nil {
for _, member := range sa.ImpersonationInfo.TokenCreators {
email := extractEmailFromMember(member)
if len(sa.RawIAMBindings) > 0 {
for _, binding := range sa.RawIAMBindings {
email := extractEmailFromMember(binding.Member)
if email != sa.Email {
hasBindings = true
body = append(body, []string{
m.GetProjectName(sa.ProjectID), sa.Email, sa.DisplayName, disabled, defaultSA,
userKeys, googleKeys, oldestKeyAge,
dwd, rolesDisplay, attackPaths,
"TokenCreator", member,
})
}
}
for _, member := range sa.ImpersonationInfo.KeyCreators {
email := extractEmailFromMember(member)
if email != sa.Email {
hasBindings = true
body = append(body, []string{
m.GetProjectName(sa.ProjectID), sa.Email, sa.DisplayName, disabled, defaultSA,
userKeys, googleKeys, oldestKeyAge,
dwd, rolesDisplay, attackPaths,
"KeyAdmin", member,
})
}
}
for _, member := range sa.ImpersonationInfo.ActAsUsers {
email := extractEmailFromMember(member)
if email != sa.Email {
hasBindings = true
body = append(body, []string{
m.GetProjectName(sa.ProjectID), sa.Email, sa.DisplayName, disabled, defaultSA,
userKeys, googleKeys, oldestKeyAge,
dwd, rolesDisplay, attackPaths,
"ActAs", member,
})
}
}
for _, member := range sa.ImpersonationInfo.SAAdmins {
email := extractEmailFromMember(member)
if email != sa.Email {
hasBindings = true
body = append(body, []string{
m.GetProjectName(sa.ProjectID), sa.Email, sa.DisplayName, disabled, defaultSA,
userKeys, googleKeys, oldestKeyAge,
dwd, rolesDisplay, attackPaths,
"SAAdmin", member,
})
}
}
for _, member := range sa.ImpersonationInfo.SignBlobUsers {
email := extractEmailFromMember(member)
if email != sa.Email {
hasBindings = true
body = append(body, []string{
m.GetProjectName(sa.ProjectID), sa.Email, sa.DisplayName, disabled, defaultSA,
userKeys, googleKeys, oldestKeyAge,
dwd, rolesDisplay, attackPaths,
"SignBlob", member,
})
}
}
for _, member := range sa.ImpersonationInfo.SignJwtUsers {
email := extractEmailFromMember(member)
if email != sa.Email {
hasBindings = true
body = append(body, []string{
m.GetProjectName(sa.ProjectID), sa.Email, sa.DisplayName, disabled, defaultSA,
userKeys, googleKeys, oldestKeyAge,
dwd, rolesDisplay, attackPaths,
"SignJwt", member,
binding.Role, binding.Member, binding.Source,
})
}
}
@@ -630,7 +675,7 @@ func (m *ServiceAccountsModule) serviceAccountsToTableBody(serviceAccounts []Ser
m.GetProjectName(sa.ProjectID), sa.Email, sa.DisplayName, disabled, defaultSA,
userKeys, googleKeys, oldestKeyAge,
dwd, rolesDisplay, attackPaths,
"-", "-",
"-", "-", "-",
})
}
}
+35 -6
View File
@@ -237,13 +237,42 @@ func (m *WhoAmIModule) Execute(ctx context.Context, logger internal.Logger) {
}
// Step 1: Get current identity
// When a non-ADC session exists (--access-token, --key-file, --impersonate-sa),
// resolve identity from the session's token. Never fall back to ADC when the
// user provided explicit credentials, as that would report the wrong identity.
oauthService := OAuthService.NewOAuthService()
principal, err := oauthService.WhoAmI()
if err != nil {
parsedErr := gcpinternal.ParseGCPError(err, "oauth2.googleapis.com")
gcpinternal.HandleGCPError(parsedErr, logger, globals.GCP_WHOAMI_MODULE_NAME,
"Could not retrieve token info")
return
var principal *OAuthService.Principal
var err error
if session := gcpinternal.GetDefaultSession(); session != nil {
// Try 1: Use the session's already-resolved email (populated during session creation
// from key file client_email, tokeninfo, or impersonation target).
if session.GetEmail() != "" {
principal = &OAuthService.Principal{Email: session.GetEmail()}
} else {
// Try 2: Query tokeninfo with the session's access token.
token, tokenErr := session.GetToken(ctx)
if tokenErr == nil && token != "" {
principal, err = oauthService.WhoAmIWithToken(token)
}
}
// If we still have no identity, error out. Do NOT fall back to ADC.
if principal == nil || principal.Email == "" {
logger.ErrorM("Could not determine identity for the provided credentials. "+
"The access token may not include the email claim. "+
"Try using --key-file or --impersonate-sa instead.", globals.GCP_WHOAMI_MODULE_NAME)
return
}
} else {
// No explicit session: use Application Default Credentials
principal, err = oauthService.WhoAmI()
if err != nil {
parsedErr := gcpinternal.ParseGCPError(err, "oauth2.googleapis.com")
gcpinternal.HandleGCPError(parsedErr, logger, globals.GCP_WHOAMI_MODULE_NAME,
"Could not retrieve token info")
return
}
}
m.Identity = IdentityContext{
@@ -15,7 +15,7 @@ type AccessPolicyService struct {
}
func New() *AccessPolicyService {
return &AccessPolicyService{}
return &AccessPolicyService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *AccessPolicyService {
@@ -0,0 +1,502 @@
package accesstokensservice
import (
"bufio"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/BishopFox/cloudfox/globals"
_ "modernc.org/sqlite"
)
// AccessToken represents a cached access token from access_tokens.db
type AccessToken struct {
AccountID string
AccessToken string
TokenExpiry time.Time
RAPTToken string
IDToken string
}
// RefreshToken represents a cached refresh token from credentials.db
type RefreshToken struct {
AccountID string
RefreshToken string
TokenURI string
ClientID string
ClientSecret string
RAPTToken string
Type string
}
// ADCCredential represents application default credentials
type ADCCredential struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
RefreshToken string `json:"refresh_token"`
Type string `json:"type"`
QuotaProject string `json:"quota_project_id"`
}
// GcloudProfile represents a gcloud configuration profile
type GcloudProfile struct {
Name string
Account string
Project string
}
// CredentialSummary is the unified view of a discovered credential
type CredentialSummary struct {
Account string
Type string // "access_token", "refresh_token", "adc", "sa_key"
Source string // file path or db name
Validity string // "ACTIVE", "EXPIRED", "UNKNOWN"
HasRefresh bool
RAPTStatus string // "ENROLLED", "NOT_ENROLLED", "UNKNOWN"
Profile string
IsActive bool
Project string
AccessToken string
}
// AccessTokensService reads locally cached gcloud credentials
type AccessTokensService struct {
configDir string
}
// New creates an AccessTokensService using the default or env-overridden config dir
func New() *AccessTokensService {
configDir := getConfigDir()
return &AccessTokensService{configDir: configDir}
}
// getConfigDir returns the gcloud config directory, respecting CLOUDSDK_CONFIG_DIR
func getConfigDir() string {
if dir := os.Getenv("CLOUDSDK_CONFIG_DIR"); dir != "" {
return dir
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".config", "gcloud")
}
// GetAllCredentials discovers all locally cached gcloud credentials
func (s *AccessTokensService) GetAllCredentials() ([]CredentialSummary, error) {
if s.configDir == "" {
return nil, fmt.Errorf("could not determine gcloud config directory")
}
var summaries []CredentialSummary
// 1. Read access tokens from access_tokens.db
accessTokens, err := s.readAccessTokensDB()
if err != nil && globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
fmt.Fprintf(os.Stderr, "[access-tokens] Could not read access_tokens.db: %v\n", err)
}
// 2. Read refresh tokens from credentials.db
refreshTokens, err := s.readCredentialsDB()
if err != nil && globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
fmt.Fprintf(os.Stderr, "[access-tokens] Could not read credentials.db: %v\n", err)
}
// 3. Read ADC
adc, err := s.readADC()
if err != nil && globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
fmt.Fprintf(os.Stderr, "[access-tokens] Could not read application_default_credentials.json: %v\n", err)
}
// 4. Read profiles
activeConfig := s.readActiveConfig()
profiles := s.readAllProfiles()
// 5. Check GOOGLE_APPLICATION_CREDENTIALS
saKeyPath := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")
// Build refresh token lookup by account_id
refreshMap := make(map[string]RefreshToken)
for _, rt := range refreshTokens {
refreshMap[rt.AccountID] = rt
}
// Build profile lookup by account
profileMap := make(map[string]GcloudProfile)
for _, p := range profiles {
profileMap[p.Account] = p
}
// Process access tokens
for _, at := range accessTokens {
summary := CredentialSummary{
Account: at.AccountID,
Type: "access_token",
Source: "access_tokens.db",
RAPTStatus: "UNKNOWN",
}
// Check token validity
if at.TokenExpiry.IsZero() {
summary.Validity = "UNKNOWN"
} else if time.Now().Before(at.TokenExpiry) {
summary.Validity = "ACTIVE"
} else {
summary.Validity = "EXPIRED"
}
// Check for matching refresh token
if rt, ok := refreshMap[at.AccountID]; ok {
summary.HasRefresh = true
if rt.RAPTToken != "" {
summary.RAPTStatus = "ENROLLED"
} else {
summary.RAPTStatus = "NOT_ENROLLED"
}
}
// Check RAPT from access token itself
if at.RAPTToken != "" {
summary.RAPTStatus = "ENROLLED"
}
// Match profile
if p, ok := profileMap[at.AccountID]; ok {
summary.Profile = p.Name
summary.Project = p.Project
summary.IsActive = (p.Name == activeConfig)
}
// Full access token
summary.AccessToken = at.AccessToken
summaries = append(summaries, summary)
}
// Process refresh tokens that don't have a matching access token
accessTokenAccounts := make(map[string]bool)
for _, at := range accessTokens {
accessTokenAccounts[at.AccountID] = true
}
for _, rt := range refreshTokens {
if accessTokenAccounts[rt.AccountID] {
continue // Already covered by access token entry
}
summary := CredentialSummary{
Account: rt.AccountID,
Type: "refresh_token",
Source: "credentials.db",
Validity: "N/A",
HasRefresh: true,
RAPTStatus: "UNKNOWN",
}
if rt.RAPTToken != "" {
summary.RAPTStatus = "ENROLLED"
} else {
summary.RAPTStatus = "NOT_ENROLLED"
}
if p, ok := profileMap[rt.AccountID]; ok {
summary.Profile = p.Name
summary.Project = p.Project
summary.IsActive = (p.Name == activeConfig)
}
summaries = append(summaries, summary)
}
// Process ADC
if adc != nil {
adcAccount := "application_default"
// Try to correlate ADC with an account via refresh token hash
if adc.RefreshToken != "" {
hash := sha256Hash(adc.RefreshToken)
for _, rt := range refreshTokens {
if rt.RefreshToken != "" && sha256Hash(rt.RefreshToken) == hash {
adcAccount = rt.AccountID
break
}
}
}
summary := CredentialSummary{
Account: adcAccount,
Type: "adc",
Source: "application_default_credentials.json",
Validity: "N/A",
HasRefresh: adc.RefreshToken != "",
RAPTStatus: "UNKNOWN",
Project: adc.QuotaProject,
}
if p, ok := profileMap[adcAccount]; ok {
summary.Profile = p.Name
summary.IsActive = (p.Name == activeConfig)
}
summaries = append(summaries, summary)
}
// Process SA key file from GOOGLE_APPLICATION_CREDENTIALS
if saKeyPath != "" {
if _, err := os.Stat(saKeyPath); err == nil {
summary := CredentialSummary{
Account: "GOOGLE_APPLICATION_CREDENTIALS",
Type: "sa_key",
Source: saKeyPath,
Validity: "ACTIVE",
HasRefresh: false,
RAPTStatus: "N/A",
}
// Try to read the SA key file for the email
saEmail := readSAKeyEmail(saKeyPath)
if saEmail != "" {
summary.Account = saEmail
}
summaries = append(summaries, summary)
}
}
return summaries, nil
}
// readAccessTokensDB reads access_tokens.db SQLite database
func (s *AccessTokensService) readAccessTokensDB() ([]AccessToken, error) {
dbPath := filepath.Join(s.configDir, "access_tokens.db")
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return nil, fmt.Errorf("file not found: %s", dbPath)
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("could not open %s: %w", dbPath, err)
}
defer db.Close()
rows, err := db.Query("SELECT account_id, access_token, token_expiry, rapt_token, id_token FROM access_tokens")
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
defer rows.Close()
var tokens []AccessToken
for rows.Next() {
var t AccessToken
var expiryStr, raptToken, idToken sql.NullString
var accessToken sql.NullString
err := rows.Scan(&t.AccountID, &accessToken, &expiryStr, &raptToken, &idToken)
if err != nil {
continue
}
if accessToken.Valid {
t.AccessToken = accessToken.String
}
if expiryStr.Valid && expiryStr.String != "" {
// Try multiple time formats
for _, layout := range []string{
time.RFC3339,
"2006-01-02T15:04:05Z",
"2006-01-02 15:04:05",
time.RFC3339Nano,
} {
if parsed, err := time.Parse(layout, expiryStr.String); err == nil {
t.TokenExpiry = parsed
break
}
}
}
if raptToken.Valid {
t.RAPTToken = raptToken.String
}
if idToken.Valid {
t.IDToken = idToken.String
}
tokens = append(tokens, t)
}
return tokens, nil
}
// readCredentialsDB reads credentials.db SQLite database
func (s *AccessTokensService) readCredentialsDB() ([]RefreshToken, error) {
dbPath := filepath.Join(s.configDir, "credentials.db")
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return nil, fmt.Errorf("file not found: %s", dbPath)
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("could not open %s: %w", dbPath, err)
}
defer db.Close()
rows, err := db.Query("SELECT account_id, value FROM credentials")
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
defer rows.Close()
var tokens []RefreshToken
for rows.Next() {
var accountID string
var valueJSON sql.NullString
err := rows.Scan(&accountID, &valueJSON)
if err != nil {
continue
}
rt := RefreshToken{
AccountID: accountID,
}
if valueJSON.Valid && valueJSON.String != "" {
var cred struct {
RefreshToken string `json:"refresh_token"`
TokenURI string `json:"token_uri"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
RAPTToken string `json:"rapt_token"`
Type string `json:"type"`
}
if err := json.Unmarshal([]byte(valueJSON.String), &cred); err == nil {
rt.RefreshToken = cred.RefreshToken
rt.TokenURI = cred.TokenURI
rt.ClientID = cred.ClientID
rt.ClientSecret = cred.ClientSecret
rt.RAPTToken = cred.RAPTToken
rt.Type = cred.Type
}
}
tokens = append(tokens, rt)
}
return tokens, nil
}
// readADC reads application_default_credentials.json
func (s *AccessTokensService) readADC() (*ADCCredential, error) {
// Check well-known location
adcPath := filepath.Join(s.configDir, "application_default_credentials.json")
if _, err := os.Stat(adcPath); os.IsNotExist(err) {
return nil, fmt.Errorf("file not found: %s", adcPath)
}
data, err := os.ReadFile(adcPath)
if err != nil {
return nil, fmt.Errorf("could not read %s: %w", adcPath, err)
}
var adc ADCCredential
if err := json.Unmarshal(data, &adc); err != nil {
return nil, fmt.Errorf("could not parse %s: %w", adcPath, err)
}
return &adc, nil
}
// readActiveConfig reads the active_config file to determine the active profile name
func (s *AccessTokensService) readActiveConfig() string {
path := filepath.Join(s.configDir, "active_config")
data, err := os.ReadFile(path)
if err != nil {
return "default"
}
name := strings.TrimSpace(string(data))
if name == "" {
return "default"
}
return name
}
// readAllProfiles scans all config_* files in the configurations directory
func (s *AccessTokensService) readAllProfiles() []GcloudProfile {
configsDir := filepath.Join(s.configDir, "configurations")
entries, err := os.ReadDir(configsDir)
if err != nil {
return nil
}
var profiles []GcloudProfile
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasPrefix(name, "config_") {
continue
}
profileName := strings.TrimPrefix(name, "config_")
path := filepath.Join(configsDir, name)
profile := parseConfigFile(path, profileName)
profiles = append(profiles, profile)
}
return profiles
}
// parseConfigFile reads a gcloud INI-style configuration file
func parseConfigFile(path string, profileName string) GcloudProfile {
profile := GcloudProfile{Name: profileName}
file, err := os.Open(path)
if err != nil {
return profile
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
// Skip section headers
if strings.HasPrefix(line, "[") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
switch key {
case "account":
profile.Account = value
case "project":
profile.Project = value
}
}
return profile
}
// readSAKeyEmail reads a service account key JSON file to extract the client_email
func readSAKeyEmail(path string) string {
data, err := os.ReadFile(path)
if err != nil {
return ""
}
var key struct {
ClientEmail string `json:"client_email"`
}
if err := json.Unmarshal(data, &key); err != nil {
return ""
}
return key.ClientEmail
}
// sha256Hash computes SHA-256 hash of a string
func sha256Hash(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:])
}
@@ -17,7 +17,7 @@ type APIKeysService struct {
// New creates a new APIKeysService
func New() *APIKeysService {
return &APIKeysService{}
return &APIKeysService{session: gcpinternal.GetDefaultSession()}
}
// NewWithSession creates an APIKeysService with a SafeSession for managed authentication
@@ -206,6 +206,7 @@ func (ars *ArtifactRegistryService) getRepositoryIAMPolicy(ctx context.Context,
// Get IAM policy
req := &iampb.GetIamPolicyRequest{
Resource: repoName,
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
}
policy, err := client.GetIamPolicy(ctx, req)
+1 -1
View File
@@ -16,7 +16,7 @@ type AssetService struct {
}
func New() *AssetService {
return &AssetService{}
return &AssetService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *AssetService {
@@ -15,7 +15,7 @@ type BeyondCorpService struct {
}
func New() *BeyondCorpService {
return &BeyondCorpService{}
return &BeyondCorpService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *BeyondCorpService {
@@ -15,7 +15,7 @@ type BigQueryEnumService struct {
}
func New() *BigQueryEnumService {
return &BigQueryEnumService{}
return &BigQueryEnumService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *BigQueryEnumService {
@@ -18,6 +18,7 @@ type AccessEntry struct {
Role string `json:"role"` // OWNER, WRITER, READER
EntityType string `json:"entityType"` // User, Group, Domain, ServiceAccount, etc.
Entity string `json:"entity"` // The actual entity identifier
Source string `json:"source"` // "Resource" (direct) or "Project" (inherited)
}
// BigqueryDataset represents a dataset in BigQuery with security-relevant fields
@@ -98,6 +99,7 @@ type BigqueryTable struct {
type TableIAMBinding struct {
Role string `json:"role"`
Members []string `json:"members"`
Source string `json:"source"` // "Resource" (direct) or "Project" (inherited)
}
// CombinedBigqueryData represents both datasets and tables within a project
@@ -112,7 +114,7 @@ type BigQueryService struct {
// New creates a new instance of BigQueryService (legacy - uses ADC directly)
func New() *BigQueryService {
return &BigQueryService{}
return &BigQueryService{session: gcpinternal.GetDefaultSession()}
}
// NewWithSession creates a BigQueryService with a SafeSession for managed authentication
@@ -15,7 +15,7 @@ type BigtableEnumService struct {
}
func New() *BigtableEnumService {
return &BigtableEnumService{}
return &BigtableEnumService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *BigtableEnumService {
@@ -15,7 +15,7 @@ type BigtableService struct {
}
func New() *BigtableService {
return &BigtableService{}
return &BigtableService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *BigtableService {
@@ -17,7 +17,7 @@ type BucketEnumService struct {
}
func New() *BucketEnumService {
return &BucketEnumService{}
return &BucketEnumService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *BucketEnumService {
@@ -17,7 +17,7 @@ type CertManagerService struct {
}
func New() *CertManagerService {
return &CertManagerService{}
return &CertManagerService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *CertManagerService {
@@ -15,7 +15,7 @@ type CloudArmorService struct{
}
func New() *CloudArmorService {
return &CloudArmorService{}
return &CloudArmorService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *CloudArmorService {
@@ -20,7 +20,7 @@ type CloudStorageService struct {
// New creates a new CloudStorageService (requires session for SDK caching)
func New() *CloudStorageService {
return &CloudStorageService{}
return &CloudStorageService{session: gcpinternal.GetDefaultSession()}
}
// NewWithSession creates a CloudStorageService with a SafeSession for managed authentication
@@ -32,6 +32,7 @@ func NewWithSession(session *gcpinternal.SafeSession) *CloudStorageService {
type IAMBinding struct {
Role string `json:"role"`
Members []string `json:"members"`
Source string `json:"source"` // "Resource" (direct) or "Project" (inherited)
}
// LifecycleRule represents a single lifecycle rule on a bucket
@@ -16,7 +16,7 @@ type CloudBuildService struct {
// New creates a new CloudBuildService
func New() *CloudBuildService {
return &CloudBuildService{}
return &CloudBuildService{session: gcpinternal.GetDefaultSession()}
}
// NewWithSession creates a CloudBuildService with a SafeSession for managed authentication
@@ -17,7 +17,7 @@ type CloudRunService struct{
}
func New() *CloudRunService {
return &CloudRunService{}
return &CloudRunService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *CloudRunService {
@@ -116,6 +116,7 @@ type SecretRefInfo struct {
type IAMBinding struct {
Role string
Member string
Source string // "Resource" (direct) or "Project" (inherited)
}
// JobInfo holds Cloud Run job details
@@ -15,7 +15,7 @@ type CloudSQLService struct{
}
func New() *CloudSQLService {
return &CloudSQLService{}
return &CloudSQLService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *CloudSQLService {
@@ -17,7 +17,7 @@ type ComposerService struct {
}
func New() *ComposerService {
return &ComposerService{}
return &ComposerService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *ComposerService {
@@ -16,7 +16,7 @@ type ComputeEngineService struct {
// New creates a new ComputeEngineService (legacy - uses ADC directly)
func New() *ComputeEngineService {
return &ComputeEngineService{}
return &ComputeEngineService{session: gcpinternal.GetDefaultSession()}
}
// NewWithSession creates a ComputeEngineService with a SafeSession for managed authentication
@@ -34,6 +34,7 @@ type ServiceAccountInfo struct {
type IAMBinding struct {
Role string `json:"role"`
Member string `json:"member"`
Source string `json:"source"` // "Resource" (direct) or "Project" (inherited)
}
// InstanceType represents the type/manager of an instance
@@ -18,7 +18,7 @@ type CrossProjectService struct {
}
func New() *CrossProjectService {
return &CrossProjectService{}
return &CrossProjectService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *CrossProjectService {
@@ -14,7 +14,7 @@ type DataflowService struct {
}
func New() *DataflowService {
return &DataflowService{}
return &DataflowService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *DataflowService {
@@ -16,7 +16,7 @@ type DataprocService struct {
}
func New() *DataprocService {
return &DataprocService{}
return &DataprocService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *DataprocService {
+1 -1
View File
@@ -15,7 +15,7 @@ type DNSService struct{
}
func New() *DNSService {
return &DNSService{}
return &DNSService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *DNSService {
@@ -15,7 +15,7 @@ type DomainWideDelegationService struct{
}
func New() *DomainWideDelegationService {
return &DomainWideDelegationService{}
return &DomainWideDelegationService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *DomainWideDelegationService {
@@ -15,7 +15,7 @@ type FilestoreService struct {
}
func New() *FilestoreService {
return &FilestoreService{}
return &FilestoreService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *FilestoreService {
@@ -15,7 +15,7 @@ type FunctionsService struct{
}
func New() *FunctionsService {
return &FunctionsService{}
return &FunctionsService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *FunctionsService {
@@ -94,6 +94,7 @@ type EnvVarInfo struct {
type IAMBinding struct {
Role string
Member string
Source string // "Resource" (direct) or "Project" (inherited)
}
// getService returns a Cloud Functions v2 service instance, using cached wrapper if session is available
+1 -1
View File
@@ -16,7 +16,7 @@ type GKEService struct {
// New creates a new GKEService (legacy - uses ADC directly)
func New() *GKEService {
return &GKEService{}
return &GKEService{session: gcpinternal.GetDefaultSession()}
}
// NewWithSession creates a GKEService with a SafeSession for managed authentication
+1 -1
View File
@@ -15,7 +15,7 @@ type HMACService struct {
}
func New() *HMACService {
return &HMACService{}
return &HMACService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *HMACService {
+59 -5
View File
@@ -26,7 +26,7 @@ type IAMService struct {
// New creates a new IAMService (legacy - uses ADC directly)
func New() *IAMService {
return &IAMService{}
return &IAMService{session: gcpinternal.GetDefaultSession()}
}
// NewWithSession creates an IAMService with a SafeSession for managed authentication
@@ -277,6 +277,7 @@ func (s *IAMService) Policies(resourceID string, resourceType string) ([]PolicyB
req := &iampb.GetIamPolicyRequest{
Resource: resourceName,
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
}
// Fetch the IAM policy for the resource
@@ -623,7 +624,10 @@ func (s *IAMService) getPoliciesForResource(ctx context.Context, resourceID stri
defer client.Close()
resourceName = "projects/" + resourceID
policy, err := client.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{Resource: resourceName})
policy, err := client.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{
Resource: resourceName,
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
})
if err != nil {
return nil, gcpinternal.ParseGCPError(err, "cloudresourcemanager.googleapis.com")
}
@@ -646,7 +650,10 @@ func (s *IAMService) getPoliciesForResource(ctx context.Context, resourceID stri
defer client.Close()
resourceName = "folders/" + resourceID
policy, err := client.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{Resource: resourceName})
policy, err := client.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{
Resource: resourceName,
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
})
if err != nil {
policyFailureCache[cacheKey] = true
return nil, gcpinternal.ParseGCPError(err, "cloudresourcemanager.googleapis.com")
@@ -670,7 +677,10 @@ func (s *IAMService) getPoliciesForResource(ctx context.Context, resourceID stri
defer client.Close()
resourceName = "organizations/" + resourceID
policy, err := client.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{Resource: resourceName})
policy, err := client.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{
Resource: resourceName,
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
})
if err != nil {
policyFailureCache[cacheKey] = true
return nil, gcpinternal.ParseGCPError(err, "cloudresourcemanager.googleapis.com")
@@ -956,6 +966,7 @@ func (s *IAMService) GetRolePermissions(ctx context.Context, roleName string) ([
// Project-level custom role
role, err := iamService.Projects.Roles.Get(roleName).Context(ctx).Do()
if err != nil {
rolePermissionsFailureCache[roleName] = true
return nil, gcpinternal.ParseGCPError(err, "iam.googleapis.com")
}
permissions = role.IncludedPermissions
@@ -1424,7 +1435,7 @@ func (s *IAMService) GetServiceAccountIAMPolicy(ctx context.Context, saEmail str
saResource := fmt.Sprintf("projects/%s/serviceAccounts/%s", projectID, saEmail)
policy, err := iamService.Projects.ServiceAccounts.GetIamPolicy(saResource).Context(ctx).Do()
policy, err := iamService.Projects.ServiceAccounts.GetIamPolicy(saResource).OptionsRequestedPolicyVersion(3).Context(ctx).Do()
if err != nil {
return nil, gcpinternal.ParseGCPError(err, "iam.googleapis.com")
}
@@ -1465,6 +1476,47 @@ func (s *IAMService) GetServiceAccountIAMPolicy(ctx context.Context, saEmail str
return info, nil
}
// GetServiceAccountIAMBindings returns raw PolicyBinding objects for a service account's IAM policy.
// This is used by the permissions module to explode SA-level bindings into individual permissions.
func (s *IAMService) GetServiceAccountIAMBindings(ctx context.Context, saEmail string, projectID string) ([]PolicyBinding, error) {
iamService, err := s.getIAMService(ctx)
if err != nil {
return nil, gcpinternal.ParseGCPError(err, "iam.googleapis.com")
}
saResource := fmt.Sprintf("projects/%s/serviceAccounts/%s", projectID, saEmail)
policy, err := iamService.Projects.ServiceAccounts.GetIamPolicy(saResource).OptionsRequestedPolicyVersion(3).Context(ctx).Do()
if err != nil {
return nil, gcpinternal.ParseGCPError(err, "iam.googleapis.com")
}
var bindings []PolicyBinding
for _, binding := range policy.Bindings {
pb := PolicyBinding{
Role: binding.Role,
Members: binding.Members,
ResourceID: saEmail,
ResourceType: "serviceAccount",
PolicyName: saResource + "_policyBindings",
}
if binding.Condition != nil {
pb.Condition = fmt.Sprintf("title:%s expression:%s", binding.Condition.Title, binding.Condition.Expression)
pb.HasCondition = true
pb.ConditionInfo = &IAMCondition{
Title: binding.Condition.Title,
Description: binding.Condition.Description,
Expression: binding.Condition.Expression,
}
}
bindings = append(bindings, pb)
}
return bindings, nil
}
// GetAllServiceAccountImpersonation analyzes impersonation risks for all SAs in a project
func (s *IAMService) GetAllServiceAccountImpersonation(projectID string) ([]SAImpersonationInfo, error) {
ctx := context.Background()
@@ -1672,6 +1724,7 @@ func (s *IAMService) GetOrganizationIAM(ctx context.Context) ([]ScopeBinding, ma
// Get IAM policy for this organization
policy, err := orgsClient.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{
Resource: org.Name,
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
})
if err != nil {
continue
@@ -1744,6 +1797,7 @@ func (s *IAMService) GetFolderIAM(ctx context.Context) ([]ScopeBinding, map[stri
// Get IAM policy for this folder
policy, err := foldersClient.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{
Resource: folder.Name,
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
})
if err != nil {
continue
+1 -1
View File
@@ -16,7 +16,7 @@ type IAPService struct {
}
func New() *IAPService {
return &IAPService{}
return &IAPService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *IAPService {
+2 -1
View File
@@ -15,7 +15,7 @@ type KMSService struct{
}
func New() *KMSService {
return &KMSService{}
return &KMSService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *KMSService {
@@ -47,6 +47,7 @@ type KeyRingInfo struct {
type IAMBinding struct {
Role string
Member string
Source string // "Resource" (direct) or "Project" (inherited)
}
// CryptoKeyInfo holds KMS crypto key details with security-relevant information
@@ -14,7 +14,7 @@ type LoadBalancerService struct {
}
func New() *LoadBalancerService {
return &LoadBalancerService{}
return &LoadBalancerService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *LoadBalancerService {
@@ -16,7 +16,7 @@ type LogEnumService struct {
}
func New() *LogEnumService {
return &LogEnumService{}
return &LogEnumService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *LogEnumService {
@@ -19,7 +19,7 @@ type LoggingService struct{
}
func New() *LoggingService {
return &LoggingService{}
return &LoggingService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *LoggingService {
@@ -15,7 +15,7 @@ type MemorystoreService struct {
}
func New() *MemorystoreService {
return &MemorystoreService{}
return &MemorystoreService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *MemorystoreService {
@@ -16,7 +16,7 @@ type NetworkEndpointsService struct {
}
func New() *NetworkEndpointsService {
return &NetworkEndpointsService{}
return &NetworkEndpointsService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *NetworkEndpointsService {
@@ -64,7 +64,7 @@ type NetwworkService struct {
// New creates a new NetworkService (legacy - uses ADC directly)
func New() *NetwworkService {
return &NetwworkService{}
return &NetwworkService{session: gcpinternal.GetDefaultSession()}
}
// NewWithSession creates a NetworkService with a SafeSession for managed authentication
@@ -15,7 +15,7 @@ type NotebooksService struct {
}
func New() *NotebooksService {
return &NotebooksService{}
return &NotebooksService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *NotebooksService {
+15
View File
@@ -59,6 +59,21 @@ func (s *OAuthService) WhoAmI() (*Principal, error) {
}, nil
}
// WhoAmIWithToken resolves identity from an explicit access token instead of ADC.
// This is used when --access-token or --key-file flags provide a session.
func (s *OAuthService) WhoAmIWithToken(accessToken string) (*Principal, error) {
tokenInfo, err := queryTokenInfo(accessToken)
if err != nil {
return nil, fmt.Errorf("failed to retrieve metadata of the token with error: %w", err)
}
scopes := strings.Split(tokenInfo.Scope, " ")
return &Principal{
Email: tokenInfo.Email,
Scope: scopes,
}, nil
}
// queryTokenInfo function modified to return (*Principal, error).
func queryTokenInfo(accessToken string) (*tokenInfoResponse, error) {
url := fmt.Sprintf("https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=%s", accessToken)
@@ -17,7 +17,7 @@ type OrganizationsService struct {
// New creates a new OrganizationsService
func New() *OrganizationsService {
return &OrganizationsService{}
return &OrganizationsService{session: gcpinternal.GetDefaultSession()}
}
// NewWithSession creates an OrganizationsService with a SafeSession for managed authentication
@@ -15,7 +15,7 @@ type OrgPolicyService struct {
}
func New() *OrgPolicyService {
return &OrgPolicyService{}
return &OrgPolicyService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *OrgPolicyService {
@@ -35,6 +35,9 @@ type OrgPolicyInfo struct {
Name string `json:"name"`
Constraint string `json:"constraint"`
ProjectID string `json:"projectId"`
ScopeType string `json:"scopeType"` // "organization", "folder", or "project"
ScopeID string `json:"scopeId"` // org/folder/project ID
ScopeName string `json:"scopeName"` // display name
Enforced bool `json:"enforced"`
AllowAll bool `json:"allowAll"`
DenyAll bool `json:"denyAll"`
@@ -171,7 +174,7 @@ func (s *OrgPolicyService) ListProjectPolicies(projectID string) ([]OrgPolicyInf
err = service.Projects.Policies.List(parent).Pages(ctx, func(resp *orgpolicy.GoogleCloudOrgpolicyV2ListPoliciesResponse) error {
for _, policy := range resp.Policies {
info := s.parsePolicyInfo(policy, projectID)
info := s.parsePolicyInfo(policy, "project", projectID, "")
policies = append(policies, info)
}
return nil
@@ -183,10 +186,107 @@ func (s *OrgPolicyService) ListProjectPolicies(projectID string) ([]OrgPolicyInf
return policies, nil
}
func (s *OrgPolicyService) parsePolicyInfo(policy *orgpolicy.GoogleCloudOrgpolicyV2Policy, projectID string) OrgPolicyInfo {
// ListOrganizationPolicies lists all org policies for an organization
func (s *OrgPolicyService) ListOrganizationPolicies(orgID, orgDisplayName string) ([]OrgPolicyInfo, error) {
ctx := context.Background()
service, err := s.getService(ctx)
if err != nil {
return nil, gcpinternal.ParseGCPError(err, "orgpolicy.googleapis.com")
}
var policies []OrgPolicyInfo
parent := fmt.Sprintf("organizations/%s", orgID)
err = service.Organizations.Policies.List(parent).Pages(ctx, func(resp *orgpolicy.GoogleCloudOrgpolicyV2ListPoliciesResponse) error {
for _, policy := range resp.Policies {
info := s.parsePolicyInfo(policy, "organization", orgID, orgDisplayName)
policies = append(policies, info)
}
return nil
})
if err != nil {
return nil, gcpinternal.ParseGCPError(err, "orgpolicy.googleapis.com")
}
return policies, nil
}
// ListFolderPolicies lists all org policies for a folder
func (s *OrgPolicyService) ListFolderPolicies(folderID, folderDisplayName string) ([]OrgPolicyInfo, error) {
ctx := context.Background()
service, err := s.getService(ctx)
if err != nil {
return nil, gcpinternal.ParseGCPError(err, "orgpolicy.googleapis.com")
}
var policies []OrgPolicyInfo
parent := fmt.Sprintf("folders/%s", folderID)
err = service.Folders.Policies.List(parent).Pages(ctx, func(resp *orgpolicy.GoogleCloudOrgpolicyV2ListPoliciesResponse) error {
for _, policy := range resp.Policies {
info := s.parsePolicyInfo(policy, "folder", folderID, folderDisplayName)
policies = append(policies, info)
}
return nil
})
if err != nil {
return nil, gcpinternal.ParseGCPError(err, "orgpolicy.googleapis.com")
}
return policies, nil
}
// GetEffectivePolicy gets the effective (inherited + local) policy for a specific constraint at a given scope.
// scopeType must be "project", "folder", or "organization".
// constraint should be the short name (e.g., "compute.requireOsLogin").
func (s *OrgPolicyService) GetEffectivePolicy(scopeType, scopeID, constraint string) (*OrgPolicyInfo, error) {
ctx := context.Background()
service, err := s.getService(ctx)
if err != nil {
return nil, gcpinternal.ParseGCPError(err, "orgpolicy.googleapis.com")
}
// Strip "constraints/" prefix if present
constraint = strings.TrimPrefix(constraint, "constraints/")
var policy *orgpolicy.GoogleCloudOrgpolicyV2Policy
switch scopeType {
case "project":
name := fmt.Sprintf("projects/%s/policies/%s", scopeID, constraint)
policy, err = service.Projects.Policies.GetEffectivePolicy(name).Context(ctx).Do()
case "folder":
name := fmt.Sprintf("folders/%s/policies/%s", scopeID, constraint)
policy, err = service.Folders.Policies.GetEffectivePolicy(name).Context(ctx).Do()
case "organization":
name := fmt.Sprintf("organizations/%s/policies/%s", scopeID, constraint)
policy, err = service.Organizations.Policies.GetEffectivePolicy(name).Context(ctx).Do()
default:
return nil, fmt.Errorf("unsupported scope type: %s", scopeType)
}
if err != nil {
return nil, gcpinternal.ParseGCPError(err, "orgpolicy.googleapis.com")
}
info := s.parsePolicyInfo(policy, scopeType, scopeID, "")
return &info, nil
}
func (s *OrgPolicyService) parsePolicyInfo(policy *orgpolicy.GoogleCloudOrgpolicyV2Policy, scopeType, scopeID, scopeName string) OrgPolicyInfo {
info := OrgPolicyInfo{
Name: policy.Name,
ProjectID: projectID,
ScopeType: scopeType,
ScopeID: scopeID,
ScopeName: scopeName,
}
// Set ProjectID for backward compatibility when scope is project
if scopeType == "project" {
info.ProjectID = scopeID
}
// Extract constraint name from policy name
+2 -1
View File
@@ -15,7 +15,7 @@ type PubSubService struct {
}
func New() *PubSubService {
return &PubSubService{}
return &PubSubService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *PubSubService {
@@ -35,6 +35,7 @@ func (ps *PubSubService) getService(ctx context.Context) (*pubsub.Service, error
type IAMBinding struct {
Role string `json:"role"`
Member string `json:"member"`
Source string `json:"source"` // "Resource" (direct) or "Project" (inherited)
}
// TopicInfo holds Pub/Sub topic details with security-relevant information
@@ -13,11 +13,13 @@ import (
regionservice "github.com/BishopFox/cloudfox/gcp/services/regionService"
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
"github.com/BishopFox/cloudfox/internal/gcp/sdk"
run "google.golang.org/api/run/v1"
secretmanager "google.golang.org/api/secretmanager/v1"
cloudfunctions "google.golang.org/api/cloudfunctions/v1"
compute "google.golang.org/api/compute/v1"
iam "google.golang.org/api/iam/v1"
"google.golang.org/api/iterator"
"google.golang.org/api/option"
cloudfunctions "google.golang.org/api/cloudfunctions/v1"
run "google.golang.org/api/run/v1"
secretmanager "google.golang.org/api/secretmanager/v1"
)
// ResourceIAMService handles enumeration of resource-level IAM policies
@@ -27,7 +29,7 @@ type ResourceIAMService struct {
// New creates a new ResourceIAMService
func New() *ResourceIAMService {
return &ResourceIAMService{}
return &ResourceIAMService{session: gcpinternal.GetDefaultSession()}
}
// NewWithSession creates a ResourceIAMService with a SafeSession
@@ -67,6 +69,22 @@ func (s *ResourceIAMService) getCloudRunService(ctx context.Context) (*run.APISe
return run.NewService(ctx)
}
// getIAMService returns a cached IAM service
func (s *ResourceIAMService) getIAMService(ctx context.Context) (*iam.Service, error) {
if s.session != nil {
return sdk.CachedGetIAMService(ctx, s.session)
}
return iam.NewService(ctx)
}
// getComputeService returns a cached Compute Engine service
func (s *ResourceIAMService) getComputeService(ctx context.Context) (*compute.Service, error) {
if s.session != nil {
return sdk.CachedGetComputeService(ctx, s.session)
}
return compute.NewService(ctx)
}
// ResourceIAMBinding represents an IAM binding on a specific resource
type ResourceIAMBinding struct {
ResourceType string `json:"resourceType"` // bucket, dataset, topic, secret, etc.
@@ -129,6 +147,18 @@ func (s *ResourceIAMService) GetAllResourceIAM(ctx context.Context, projectID st
allBindings = append(allBindings, runBindings...)
}
// Get Service Account IAM
saBindings, err := s.GetServiceAccountIAM(ctx, projectID)
if err == nil {
allBindings = append(allBindings, saBindings...)
}
// Get Compute Instance IAM
instanceBindings, err := s.GetComputeInstanceIAM(ctx, projectID)
if err == nil {
allBindings = append(allBindings, instanceBindings...)
}
return allBindings, nil
}
@@ -176,8 +206,8 @@ func (s *ResourceIAMService) GetBucketIAM(ctx context.Context, projectID string)
ProjectID: projectID,
Role: string(role),
Member: member,
MemberType: determineMemberType(member),
MemberEmail: extractEmail(member),
MemberType: DetermineMemberType(member),
MemberEmail: ExtractEmail(member),
IsPublic: isPublicMember(member),
}
bindings = append(bindings, binding)
@@ -250,7 +280,7 @@ func (s *ResourceIAMService) GetBigQueryDatasetIAM(ctx context.Context, projectI
memberType = "SpecialGroup"
}
case bigquery.IAMMemberEntity:
memberType = determineMemberType(access.Entity)
memberType = DetermineMemberType(access.Entity)
isPublic = isPublicMember(access.Entity)
}
@@ -266,7 +296,7 @@ func (s *ResourceIAMService) GetBigQueryDatasetIAM(ctx context.Context, projectI
Role: string(access.Role),
Member: member,
MemberType: memberType,
MemberEmail: extractEmail(member),
MemberEmail: ExtractEmail(member),
IsPublic: isPublic,
}
bindings = append(bindings, binding)
@@ -319,8 +349,8 @@ func (s *ResourceIAMService) GetPubSubIAM(ctx context.Context, projectID string)
ProjectID: projectID,
Role: string(role),
Member: member,
MemberType: determineMemberType(member),
MemberEmail: extractEmail(member),
MemberType: DetermineMemberType(member),
MemberEmail: ExtractEmail(member),
IsPublic: isPublicMember(member),
}
bindings = append(bindings, binding)
@@ -355,8 +385,8 @@ func (s *ResourceIAMService) GetPubSubIAM(ctx context.Context, projectID string)
ProjectID: projectID,
Role: string(role),
Member: member,
MemberType: determineMemberType(member),
MemberEmail: extractEmail(member),
MemberType: DetermineMemberType(member),
MemberEmail: ExtractEmail(member),
IsPublic: isPublicMember(member),
}
bindings = append(bindings, binding)
@@ -398,7 +428,7 @@ func (s *ResourceIAMService) GetSecretManagerIAM(ctx context.Context, projectID
for _, secret := range allSecrets {
// Get IAM policy for this secret
policy, err := smService.Projects.Secrets.GetIamPolicy(secret.Name).Context(ctx).Do()
policy, err := smService.Projects.Secrets.GetIamPolicy(secret.Name).OptionsRequestedPolicyVersion(3).Context(ctx).Do()
if err != nil {
continue
}
@@ -413,8 +443,8 @@ func (s *ResourceIAMService) GetSecretManagerIAM(ctx context.Context, projectID
ProjectID: projectID,
Role: binding.Role,
Member: member,
MemberType: determineMemberType(member),
MemberEmail: extractEmail(member),
MemberType: DetermineMemberType(member),
MemberEmail: ExtractEmail(member),
IsPublic: isPublicMember(member),
}
if binding.Condition != nil {
@@ -491,8 +521,8 @@ func (s *ResourceIAMService) GetKMSIAM(ctx context.Context, projectID string) ([
ProjectID: projectID,
Role: string(role),
Member: member,
MemberType: determineMemberType(member),
MemberEmail: extractEmail(member),
MemberType: DetermineMemberType(member),
MemberEmail: ExtractEmail(member),
IsPublic: isPublicMember(member),
}
bindings = append(bindings, binding)
@@ -523,7 +553,7 @@ func (s *ResourceIAMService) GetCloudFunctionsIAM(ctx context.Context, projectID
for _, fn := range resp.Functions {
// Get IAM policy for this function
policy, err := cfService.Projects.Locations.Functions.GetIamPolicy(fn.Name).Context(ctx).Do()
policy, err := cfService.Projects.Locations.Functions.GetIamPolicy(fn.Name).OptionsRequestedPolicyVersion(3).Context(ctx).Do()
if err != nil {
continue
}
@@ -538,8 +568,8 @@ func (s *ResourceIAMService) GetCloudFunctionsIAM(ctx context.Context, projectID
ProjectID: projectID,
Role: binding.Role,
Member: member,
MemberType: determineMemberType(member),
MemberEmail: extractEmail(member),
MemberType: DetermineMemberType(member),
MemberEmail: ExtractEmail(member),
IsPublic: isPublicMember(member),
}
if binding.Condition != nil {
@@ -573,7 +603,7 @@ func (s *ResourceIAMService) GetCloudRunIAM(ctx context.Context, projectID strin
for _, svc := range resp.Items {
// Get IAM policy for this service
policy, err := runService.Projects.Locations.Services.GetIamPolicy(svc.Metadata.Name).Context(ctx).Do()
policy, err := runService.Projects.Locations.Services.GetIamPolicy(svc.Metadata.Name).OptionsRequestedPolicyVersion(3).Context(ctx).Do()
if err != nil {
continue
}
@@ -588,8 +618,8 @@ func (s *ResourceIAMService) GetCloudRunIAM(ctx context.Context, projectID strin
ProjectID: projectID,
Role: binding.Role,
Member: member,
MemberType: determineMemberType(member),
MemberEmail: extractEmail(member),
MemberType: DetermineMemberType(member),
MemberEmail: ExtractEmail(member),
IsPublic: isPublicMember(member),
}
if binding.Condition != nil {
@@ -605,9 +635,140 @@ func (s *ResourceIAMService) GetCloudRunIAM(ctx context.Context, projectID strin
return bindings, nil
}
// GetServiceAccountIAM enumerates IAM policies on service accounts
func (s *ResourceIAMService) GetServiceAccountIAM(ctx context.Context, projectID string) ([]ResourceIAMBinding, error) {
var bindings []ResourceIAMBinding
iamSvc, err := s.getIAMService(ctx)
if err != nil {
return nil, gcpinternal.ParseGCPError(err, "iam.googleapis.com")
}
// List all service accounts in the project
req := iamSvc.Projects.ServiceAccounts.List("projects/" + projectID)
err = req.Pages(ctx, func(page *iam.ListServiceAccountsResponse) error {
for _, sa := range page.Accounts {
// Get IAM policy for this service account
saResource := fmt.Sprintf("projects/%s/serviceAccounts/%s", projectID, sa.Email)
policy, err := iamSvc.Projects.ServiceAccounts.GetIamPolicy(saResource).OptionsRequestedPolicyVersion(3).Context(ctx).Do()
if err != nil {
continue
}
// Skip service accounts with no bindings
if len(policy.Bindings) == 0 {
continue
}
for _, binding := range policy.Bindings {
for _, member := range binding.Members {
b := ResourceIAMBinding{
ResourceType: "serviceAccount",
ResourceName: saResource,
ResourceID: sa.Email,
ProjectID: projectID,
Role: binding.Role,
Member: member,
MemberType: DetermineMemberType(member),
MemberEmail: ExtractEmail(member),
IsPublic: isPublicMember(member),
}
if binding.Condition != nil {
b.HasCondition = true
b.ConditionTitle = binding.Condition.Title
b.ConditionExpression = binding.Condition.Expression
}
bindings = append(bindings, b)
}
}
}
return nil
})
if err != nil {
return bindings, gcpinternal.ParseGCPError(err, "iam.googleapis.com")
}
return bindings, nil
}
// GetComputeInstanceIAM enumerates IAM policies on Compute Engine instances
func (s *ResourceIAMService) GetComputeInstanceIAM(ctx context.Context, projectID string) ([]ResourceIAMBinding, error) {
var bindings []ResourceIAMBinding
computeSvc, err := s.getComputeService(ctx)
if err != nil {
return nil, gcpinternal.ParseGCPError(err, "compute.googleapis.com")
}
// List all instances across all zones using AggregatedList
req := computeSvc.Instances.AggregatedList(projectID)
err = req.Pages(ctx, func(page *compute.InstanceAggregatedList) error {
for _, scopedList := range page.Items {
for _, instance := range scopedList.Instances {
// Extract zone from the instance's zone URL
zone := extractZoneFromURL(instance.Zone)
if zone == "" {
continue
}
// Get IAM policy for this instance
policy, err := computeSvc.Instances.GetIamPolicy(projectID, zone, instance.Name).OptionsRequestedPolicyVersion(3).Context(ctx).Do()
if err != nil {
continue
}
// Skip instances with no bindings
if len(policy.Bindings) == 0 {
continue
}
resourceName := fmt.Sprintf("projects/%s/zones/%s/instances/%s", projectID, zone, instance.Name)
for _, binding := range policy.Bindings {
for _, member := range binding.Members {
b := ResourceIAMBinding{
ResourceType: "instance",
ResourceName: resourceName,
ResourceID: instance.Name,
ProjectID: projectID,
Role: binding.Role,
Member: member,
MemberType: DetermineMemberType(member),
MemberEmail: ExtractEmail(member),
IsPublic: isPublicMember(member),
}
if binding.Condition != nil {
b.HasCondition = true
b.ConditionTitle = binding.Condition.Title
b.ConditionExpression = binding.Condition.Expression
}
bindings = append(bindings, b)
}
}
}
}
return nil
})
if err != nil {
return bindings, gcpinternal.ParseGCPError(err, "compute.googleapis.com")
}
return bindings, nil
}
// Helper functions
func determineMemberType(member string) string {
// extractZoneFromURL extracts zone name from a full zone URL
// e.g., "https://www.googleapis.com/compute/v1/projects/my-project/zones/us-central1-a" -> "us-central1-a"
func extractZoneFromURL(zoneURL string) string {
parts := strings.Split(zoneURL, "/")
if len(parts) > 0 {
return parts[len(parts)-1]
}
return ""
}
// DetermineMemberType classifies an IAM member string into its type.
func DetermineMemberType(member string) string {
switch {
case member == "allUsers":
return "allUsers"
@@ -630,7 +791,8 @@ func determineMemberType(member string) string {
}
}
func extractEmail(member string) string {
// ExtractEmail strips the member type prefix from an IAM member string.
func ExtractEmail(member string) string {
if strings.Contains(member, ":") {
parts := strings.SplitN(member, ":", 2)
if len(parts) == 2 {
@@ -17,7 +17,7 @@ type SchedulerService struct{
}
func New() *SchedulerService {
return &SchedulerService{}
return &SchedulerService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *SchedulerService {
@@ -91,6 +91,7 @@ func NewWithSession(session *gcpinternal.SafeSession) (SecretsService, error) {
type IAMBinding struct {
Role string `json:"role"`
Members []string `json:"members"`
Source string `json:"source"` // "Resource" (direct) or "Project" (inherited)
}
// SecretInfo contains secret metadata and security-relevant configuration
@@ -15,7 +15,7 @@ type ServiceAgentsService struct{
}
func New() *ServiceAgentsService {
return &ServiceAgentsService{}
return &ServiceAgentsService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *ServiceAgentsService {
@@ -15,7 +15,7 @@ type SourceReposService struct{
}
func New() *SourceReposService {
return &SourceReposService{}
return &SourceReposService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *SourceReposService {
@@ -16,7 +16,7 @@ type SpannerEnumService struct {
}
func New() *SpannerEnumService {
return &SpannerEnumService{}
return &SpannerEnumService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *SpannerEnumService {
@@ -15,7 +15,7 @@ type SpannerService struct {
}
func New() *SpannerService {
return &SpannerService{}
return &SpannerService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *SpannerService {
+1 -1
View File
@@ -15,7 +15,7 @@ type VPCService struct {
}
func New() *VPCService {
return &VPCService{}
return &VPCService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *VPCService {
+1 -1
View File
@@ -15,7 +15,7 @@ type VPCSCService struct {
}
func New() *VPCSCService {
return &VPCSCService{}
return &VPCSCService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *VPCSCService {
@@ -15,7 +15,7 @@ type WorkloadIdentityService struct{
}
func New() *WorkloadIdentityService {
return &WorkloadIdentityService{}
return &WorkloadIdentityService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *WorkloadIdentityService {
+79
View File
@@ -0,0 +1,79 @@
package shared
// InheritedAccessRoles maps resource types to the project-level roles that
// grant implicit access to ALL resources of that type. Principals with these
// roles at the project level can access every resource even without a direct
// resource-level IAM binding.
var InheritedAccessRoles = map[string][]string{
"bucket": {
"roles/owner", "roles/editor",
"roles/storage.admin", "roles/storage.objectAdmin",
"roles/storage.objectViewer", "roles/storage.objectCreator",
},
"dataset": {
"roles/owner", "roles/editor",
"roles/bigquery.admin", "roles/bigquery.dataEditor",
"roles/bigquery.dataViewer", "roles/bigquery.dataOwner",
},
"table": {
"roles/owner", "roles/editor",
"roles/bigquery.admin", "roles/bigquery.dataEditor",
"roles/bigquery.dataViewer", "roles/bigquery.dataOwner",
},
"topic": {
"roles/owner", "roles/editor",
"roles/pubsub.admin", "roles/pubsub.editor", "roles/pubsub.publisher",
},
"subscription": {
"roles/owner", "roles/editor",
"roles/pubsub.admin", "roles/pubsub.editor", "roles/pubsub.subscriber",
},
"secret": {
"roles/owner", "roles/editor",
"roles/secretmanager.admin", "roles/secretmanager.secretAccessor",
"roles/secretmanager.secretVersionAdder",
},
"cryptoKey": {
"roles/owner",
"roles/cloudkms.admin", "roles/cloudkms.cryptoKeyEncrypterDecrypter",
"roles/cloudkms.cryptoKeyDecrypter",
},
"function": {
"roles/owner", "roles/editor",
"roles/cloudfunctions.admin", "roles/cloudfunctions.developer",
"roles/cloudfunctions.invoker",
},
"cloudrun": {
"roles/owner", "roles/editor",
"roles/run.admin", "roles/run.developer", "roles/run.invoker",
},
"instance": {
"roles/owner", "roles/editor",
"roles/compute.admin", "roles/compute.instanceAdmin",
"roles/compute.instanceAdmin.v1",
},
}
// IsInheritedRole checks whether the given role grants inherited access
// for the specified resource type.
func IsInheritedRole(resourceType, role string) bool {
roles, ok := InheritedAccessRoles[resourceType]
if !ok {
return false
}
for _, r := range roles {
if r == role {
return true
}
}
return false
}
// BindingSource returns a display-ready binding source string.
// If the source is empty, defaults to "Resource" (direct binding).
func BindingSource(source string) string {
if source == "" {
return "Resource"
}
return source
}
+11 -4
View File
@@ -86,7 +86,14 @@ var GCP_VERBOSITY int = 0
const GCP_VERBOSE_ERRORS = 9
// const GCP_INVENTORY_MODULE_NAME string = "inventory"
// const GCP_GCLOUD_REFRESH_TOKENS_DB_PATH = ".config/gcloud/credentials.db"
// const GCP_GCLOUD_ACCESS_TOKENS_DB_PATH = ".config/gcloud/access_tokens.db"
// const GCP_GCLOUD_DEFAULT_CONFIG_PATH = ".config/gcloud/configurations/config_default"
// const GCP_GCLOUD_APPLICATION_DEFAULT_PATH = ".config/gcloud/application_default_credentials.json"
// Access tokens module
const GCP_ACCESS_TOKENS_MODULE_NAME string = "access-tokens"
// Gcloud local file paths (relative to home or CLOUDSDK_CONFIG_DIR)
const GCP_GCLOUD_REFRESH_TOKENS_DB_PATH = ".config/gcloud/credentials.db"
const GCP_GCLOUD_ACCESS_TOKENS_DB_PATH = ".config/gcloud/access_tokens.db"
const GCP_GCLOUD_DEFAULT_CONFIG_PATH = ".config/gcloud/configurations/config_default"
const GCP_GCLOUD_APPLICATION_DEFAULT_PATH = ".config/gcloud/application_default_credentials.json"
const GCP_GCLOUD_ACTIVE_CONFIG_PATH = ".config/gcloud/active_config"
const GCP_GCLOUD_CONFIGURATIONS_DIR = ".config/gcloud/configurations"
+1 -1
View File
@@ -4,4 +4,4 @@ const CLOUDFOX_USER_AGENT = "cloudfox"
const CLOUDFOX_LOG_FILE_DIR_NAME = ".cloudfox"
const CLOUDFOX_BASE_DIRECTORY = "cloudfox-output"
const LOOT_DIRECTORY_NAME = "loot"
const CLOUDFOX_VERSION = "2.0.1"
const CLOUDFOX_VERSION = "2.0.3"
+29 -22
View File
@@ -1,6 +1,6 @@
module github.com/BishopFox/cloudfox
go 1.24.2
go 1.25.9
require (
cloud.google.com/go/artifactregistry v1.18.0
@@ -16,7 +16,7 @@ require (
github.com/Azure/go-autorest/autorest v0.11.30
github.com/Azure/go-autorest/autorest/azure/auth v0.5.13
github.com/aquasecurity/table v1.11.0
github.com/aws/aws-sdk-go-v2 v1.41.0
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/config v1.32.5
github.com/aws/aws-sdk-go-v2/credentials v1.19.5
github.com/aws/aws-sdk-go-v2/service/apigateway v1.38.3
@@ -49,8 +49,8 @@ require (
github.com/aws/aws-sdk-go-v2/service/glue v1.135.3
github.com/aws/aws-sdk-go-v2/service/grafana v1.32.9
github.com/aws/aws-sdk-go-v2/service/iam v1.53.1
github.com/aws/aws-sdk-go-v2/service/kinesis v1.42.9
github.com/aws/aws-sdk-go-v2/service/lambda v1.87.0
github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.5
github.com/aws/aws-sdk-go-v2/service/lambda v1.88.5
github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.10
github.com/aws/aws-sdk-go-v2/service/mq v1.34.14
github.com/aws/aws-sdk-go-v2/service/opensearch v1.56.0
@@ -60,7 +60,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/redshift v1.61.4
github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.31.5
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.0
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3
github.com/aws/aws-sdk-go-v2/service/sagemaker v1.228.2
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.0
github.com/aws/aws-sdk-go-v2/service/sfn v1.40.5
@@ -68,7 +68,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.20
github.com/aws/aws-sdk-go-v2/service/ssm v1.67.7
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5
github.com/aws/smithy-go v1.24.0
github.com/aws/smithy-go v1.24.2
github.com/bishopfox/awsservicemap v1.1.0
github.com/bishopfox/knownawsaccountslookup v0.0.0-20231228165844-c37ef8df33cb
github.com/dominikbraun/graph v0.23.0
@@ -108,11 +108,12 @@ require (
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
@@ -122,7 +123,9 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.opencensus.io v0.24.0 // indirect
@@ -130,14 +133,17 @@ require (
go.opentelemetry.io/contrib/detectors/gcp v1.39.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/telemetry v0.0.0-20251208220230-2638a1023523 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
require (
@@ -151,6 +157,7 @@ require (
google.golang.org/api v0.257.0
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217
google.golang.org/protobuf v1.36.11
modernc.org/sqlite v1.46.1
)
require (
@@ -168,17 +175,17 @@ require (
github.com/Azure/go-autorest/logger v0.2.2 // indirect
github.com/Azure/go-autorest/tracing v0.6.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
github.com/charmbracelet/bubbles v0.21.0
@@ -210,7 +217,7 @@ require (
go.mongodb.org/mongo-driver v1.17.6 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.14.0 // indirect
@@ -218,5 +225,5 @@ require (
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.77.0
google.golang.org/grpc v1.79.3
)
+82 -44
View File
@@ -114,24 +114,24 @@ github.com/apache/arrow/go/v15 v15.0.2 h1:60IliRbiyTWCWjERBCkO1W4Qun9svcYoZrSLcy
github.com/apache/arrow/go/v15 v15.0.2/go.mod h1:DGXsR3ajT524njufqf95822i+KTh+yea1jass9YXgjA=
github.com/aquasecurity/table v1.11.0 h1:SzgCAv7dZcv/gyAyzxorS6OgEk7w/WU5iT2pStIkpl4=
github.com/aquasecurity/table v1.11.0/go.mod h1:eqOmvjjB7AhXFgFqpJUEE/ietg7RrMSJZXyTN8E/wZw=
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/config v1.32.5 h1:pz3duhAfUgnxbtVhIK39PGF/AHYyrzGEyRD9Og0QrE8=
github.com/aws/aws-sdk-go-v2/config v1.32.5/go.mod h1:xmDjzSUs/d0BB7ClzYPAZMmgQdrodNjPPhd6bGASwoE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.5 h1:xMo63RlqP3ZZydpJDMBsH9uJ10hgHYfQFIk1cHDXrR4=
github.com/aws/aws-sdk-go-v2/credentials v1.19.5/go.mod h1:hhbH6oRcou+LpXfA/0vPElh/e0M3aFeOblE1sssAAEk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
github.com/aws/aws-sdk-go-v2/service/apigateway v1.38.3 h1:nnhGwOSJAnWSwcOINuRUql8/C/l0pCGedsNgv6FSZHs=
github.com/aws/aws-sdk-go-v2/service/apigateway v1.38.3/go.mod h1:U3xTNpFRAV7yduECTfDBDJVFmY5FLrL5HsTSigwOeHs=
github.com/aws/aws-sdk-go-v2/service/apigatewayv2 v1.33.4 h1:FcarAOOdK+8gIYD8/90x7JTOAno+U6IrzMdowePmyBA=
@@ -192,22 +192,22 @@ github.com/aws/aws-sdk-go-v2/service/grafana v1.32.9 h1:5UI/ZQ+cW+sPsWe6BHn3K+hW
github.com/aws/aws-sdk-go-v2/service/grafana v1.32.9/go.mod h1:QVVGT98da/skEaF+G12IB3AAosDTS57x0bL4bJ9FCuE=
github.com/aws/aws-sdk-go-v2/service/iam v1.53.1 h1:xNCUk9XN6Pa9PyzbEfzgRpvEIVlqtth402yjaWvNMu4=
github.com/aws/aws-sdk-go-v2/service/iam v1.53.1/go.mod h1:GNQZL4JRSGH6L0/SNGOtffaB1vmlToYp3KtcUIB0NhI=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.16 h1:8g4OLy3zfNzLV20wXmZgx+QumI9WhWHnd4GCdvETxs4=
github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.16/go.mod h1:5a78jwLMs7BaesU0UIhLfVy2ZmOEgOy6ewYQXKTD37Q=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
github.com/aws/aws-sdk-go-v2/service/kinesis v1.42.9 h1:9Dme/lCNr7GT+n3+AsJV95g5akEhSYeJKoQOcrL8xZ4=
github.com/aws/aws-sdk-go-v2/service/kinesis v1.42.9/go.mod h1:77+d3nX1hnx0CMC+FG3N34e86SOaEKGpSP+8bQYkX90=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.5 h1:LxgRVyuY+5DEPSX7kmin/V7toE8MWZ9U8n2dqRtX+RE=
github.com/aws/aws-sdk-go-v2/service/kinesis v1.43.5/go.mod h1:eUebEBEqVfOwEyDDDbGauH4PNqDCuepRvTaNbJeWr5w=
github.com/aws/aws-sdk-go-v2/service/kms v1.49.4 h1:2gom8MohxN0SnhHZBYAC4S8jHG+ENEnXjyJ5xKe3vLc=
github.com/aws/aws-sdk-go-v2/service/kms v1.49.4/go.mod h1:HO31s0qt0lso/ADvZQyzKs8js/ku0fMHsfyXW8OPVYc=
github.com/aws/aws-sdk-go-v2/service/lambda v1.87.0 h1:E5UXxF3vK3JuViwKCHfTJBIiFjvE4aytSucZjI2UAlQ=
github.com/aws/aws-sdk-go-v2/service/lambda v1.87.0/go.mod h1:6f64Y1BEf6e1uCI+LtGbcZSKDK1GvgJ+iI4vP/bbE8s=
github.com/aws/aws-sdk-go-v2/service/lambda v1.88.5 h1:HWN7xwaV7Zwrn3Jlauio4u4aTMFgRzG2fblHWQeir/k=
github.com/aws/aws-sdk-go-v2/service/lambda v1.88.5/go.mod h1:6HBXRyFFqOw+ALkJ6YGHfrr20/YXYv6X9pcZErXRvCA=
github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.10 h1:MQuZZ6Tq1qQabPlkVxrCMdyVl70Ogl4AERZKo+y9Wzo=
github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.10/go.mod h1:U5C3JME1ibKESmpzBAqlRpTYZfVbTqrb5ICJm+sVVd8=
github.com/aws/aws-sdk-go-v2/service/mq v1.34.14 h1:mqy/sNV+hiOfa5/GVyAD3Ru25M9ZRmnJrupwTbnbjLA=
@@ -226,8 +226,8 @@ github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.31.5 h1:0jwTqyy
github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.31.5/go.mod h1:ydy76wx7I+HsqhlEo0vhVTl785TDNbpgtEXhd3i4ZTc=
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.0 h1:80pDB3Tpmb2RCSZORrK9/3iQxsd+w6vSzVqpT1FGiwE=
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.0/go.mod h1:6EZUGGNLPLh5Unt30uEoA+KQcByERfXIkax9qrc80nA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2 h1:U3ygWUhCpiSPYSHOrRhb3gOl9T5Y3kB8k5Vjs//57bE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
github.com/aws/aws-sdk-go-v2/service/sagemaker v1.228.2 h1:96uJoMTjZ6WdXD0+bCjQib+U42++cYrf4fXbiu7VpEY=
github.com/aws/aws-sdk-go-v2/service/sagemaker v1.228.2/go.mod h1:6TLogKvr0gKvi3GDJd6rZQ9uVl/fkXgCkWUuVD4EdLI=
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.0 h1:vL6rQXcGtFv9q/9eRPdI+lL+dvTm7xKGZYSHEvmrpDk=
@@ -248,8 +248,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlV
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
@@ -296,11 +296,13 @@ github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi
github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE=
github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo=
github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329 h1:K+fnvUM0VZ7ZFJf0n4L/BRlnsb9pL/GuDG6FqaH+PwM=
github.com/envoyproxy/go-control-plane v0.13.5-0.20251024222203-75eaa193e329/go.mod h1:Alz8LEClvR7xKsrq3qzoc4N0guvVNSS8KmSChGYr9hs=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
@@ -314,8 +316,8 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -366,6 +368,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -375,6 +379,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAV
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jedib0t/go-pretty v4.3.0+incompatible h1:CGs8AVhEKg/n9YbUenWmNStRW2PHJzaeDodcfvRAbIo=
@@ -411,6 +417,8 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/neo4j/neo4j-go-driver/v5 v5.28.4 h1:7toxehVcYkZbyxV4W3Ib9VcnyRBQPucF+VwNNmtSXi4=
github.com/neo4j/neo4j-go-driver/v5 v5.28.4/go.mod h1:Vff8OwT7QpLm7L2yYr85XNWe9Rbqlbeb9asNXJTHO4k=
github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
@@ -427,6 +435,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -475,18 +485,18 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.6
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0/go.mod h1:habDz3tEWiFANTo6oUE99EmaFUrCNYAAg3wiVmusm70=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
@@ -545,8 +555,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20251208220230-2638a1023523 h1:H52Mhyrc44wBgLTGzq6+0cmuVuF3LURCSXsLMOqfFos=
golang.org/x/telemetry v0.0.0-20251208220230-2638a1023523/go.mod h1:ArQvPJS723nJQietgilmZA+shuB3CZxH1n2iXq9VSfs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -601,8 +611,8 @@ google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -624,3 +634,31 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+47 -5
View File
@@ -7,6 +7,7 @@ import (
"os"
"strings"
"sync"
"time"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
@@ -242,6 +243,42 @@ func ParseGCPError(err error, apiName string) error {
return err
}
// IsRetryableError checks if a GCP API error is transient and worth retrying.
// Rate limits, server errors, and unavailable responses are retryable.
// Permission denied, not found, and auth failures are not.
func IsRetryableError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
return strings.Contains(errStr, "rate limited") ||
strings.Contains(errStr, "GCP service error") ||
strings.Contains(errStr, "Unavailable") ||
strings.Contains(errStr, "Internal")
}
// RetryWithBackoff calls fn up to maxAttempts times with exponential backoff
// for transient GCP API errors. Non-retryable errors (permission denied, etc.)
// are returned immediately without further attempts.
func RetryWithBackoff(logger internal.Logger, module string, maxAttempts int, fn func() error) error {
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
lastErr = fn()
if lastErr == nil {
return nil
}
if !IsRetryableError(lastErr) {
return lastErr
}
if attempt < maxAttempts {
delay := time.Duration(attempt) * 2 * time.Second // 2s, 4s, 6s
logger.InfoM(fmt.Sprintf("Transient error: %v (retry %d/%d in %s)", lastErr, attempt, maxAttempts-1, delay), module)
time.Sleep(delay)
}
}
return lastErr
}
// IsPermissionDenied checks if an error is a permission denied error
func IsPermissionDenied(err error) bool {
return errors.Is(err, ErrPermissionDenied)
@@ -281,12 +318,13 @@ func HandleGCPError(err error, logger internal.Logger, moduleName string, resour
return true // Can continue with other resources
case errors.Is(parsedErr, ErrNotFound):
// Not found is often expected, don't log as error
// Log not-found errors so users can see invalid project IDs or missing resources
logger.ErrorM(fmt.Sprintf("%s - not found", resourceDesc), moduleName)
return true
default:
// For unknown errors, log a concise message without the full error details
logger.ErrorM(fmt.Sprintf("%s - error occurred", resourceDesc), moduleName)
// Include the actual error so it's visible in logs and (when not overwritten by spinner) on screen
logger.ErrorM(fmt.Sprintf("%s - %v", resourceDesc, parsedErr), moduleName)
return true // Continue with other resources
}
}
@@ -439,8 +477,12 @@ func (b *BaseGCPModule) RunProjectEnumeration(
wg.Done()
}()
// Acquire semaphore
semaphore <- struct{}{}
// Check for cancellation before starting work
select {
case <-ctx.Done():
return
case semaphore <- struct{}{}:
}
defer func() { <-semaphore }()
b.CommandCounter.Pending--
+58 -44
View File
@@ -15,6 +15,7 @@ import (
// REST API services (NewService pattern)
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
"google.golang.org/api/option"
accesscontextmanager "google.golang.org/api/accesscontextmanager/v1"
apikeys "google.golang.org/api/apikeys/v2"
artifactregistryapi "google.golang.org/api/artifactregistry/v1"
@@ -54,13 +55,26 @@ import (
storageapi "google.golang.org/api/storage/v1"
)
// resolveOpts returns the client options for a session. If session is nil,
// it checks the package-level default session (set by --impersonate-sa).
// If no session is available, returns nil so the GCP SDK uses ADC.
func resolveOpts(session *gcpinternal.SafeSession) []option.ClientOption {
if session == nil {
session = gcpinternal.GetDefaultSession()
}
if session != nil {
return []option.ClientOption{session.GetClientOption()}
}
return nil
}
// =============================================================================
// GO SDK CLIENTS (NewClient pattern) - These return *Client types
// =============================================================================
// GetStorageClient returns a Cloud Storage client (Go SDK)
func GetStorageClient(ctx context.Context, session *gcpinternal.SafeSession) (*storage.Client, error) {
client, err := storage.NewClient(ctx, session.GetClientOption())
client, err := storage.NewClient(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create storage client: %w", err)
}
@@ -69,7 +83,7 @@ func GetStorageClient(ctx context.Context, session *gcpinternal.SafeSession) (*s
// GetSecretManagerClient returns a Secret Manager client (Go SDK)
func GetSecretManagerClient(ctx context.Context, session *gcpinternal.SafeSession) (*secretmanagerclient.Client, error) {
client, err := secretmanagerclient.NewClient(ctx, session.GetClientOption())
client, err := secretmanagerclient.NewClient(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create secret manager client: %w", err)
}
@@ -78,7 +92,7 @@ func GetSecretManagerClient(ctx context.Context, session *gcpinternal.SafeSessio
// GetBigQueryClient returns a BigQuery client (Go SDK)
func GetBigQueryClient(ctx context.Context, session *gcpinternal.SafeSession, projectID string) (*bigquery.Client, error) {
client, err := bigquery.NewClient(ctx, projectID, session.GetClientOption())
client, err := bigquery.NewClient(ctx, projectID, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create BigQuery client: %w", err)
}
@@ -87,7 +101,7 @@ func GetBigQueryClient(ctx context.Context, session *gcpinternal.SafeSession, pr
// GetPubSubClient returns a Pub/Sub client (Go SDK)
func GetPubSubClient(ctx context.Context, session *gcpinternal.SafeSession, projectID string) (*pubsub.Client, error) {
client, err := pubsub.NewClient(ctx, projectID, session.GetClientOption())
client, err := pubsub.NewClient(ctx, projectID, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Pub/Sub client: %w", err)
}
@@ -96,7 +110,7 @@ func GetPubSubClient(ctx context.Context, session *gcpinternal.SafeSession, proj
// GetAssetClient returns a Cloud Asset client (Go SDK)
func GetAssetClient(ctx context.Context, session *gcpinternal.SafeSession) (*asset.Client, error) {
client, err := asset.NewClient(ctx, session.GetClientOption())
client, err := asset.NewClient(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create asset client: %w", err)
}
@@ -105,7 +119,7 @@ func GetAssetClient(ctx context.Context, session *gcpinternal.SafeSession) (*ass
// GetArtifactRegistryClient returns an Artifact Registry client (Go SDK)
func GetArtifactRegistryClient(ctx context.Context, session *gcpinternal.SafeSession) (*artifactregistry.Client, error) {
client, err := artifactregistry.NewClient(ctx, session.GetClientOption())
client, err := artifactregistry.NewClient(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create artifact registry client: %w", err)
}
@@ -114,7 +128,7 @@ func GetArtifactRegistryClient(ctx context.Context, session *gcpinternal.SafeSes
// GetOrganizationsClient returns a Resource Manager Organizations client (Go SDK)
func GetOrganizationsClient(ctx context.Context, session *gcpinternal.SafeSession) (*resourcemanager.OrganizationsClient, error) {
client, err := resourcemanager.NewOrganizationsClient(ctx, session.GetClientOption())
client, err := resourcemanager.NewOrganizationsClient(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create organizations client: %w", err)
}
@@ -127,7 +141,7 @@ func GetOrganizationsClient(ctx context.Context, session *gcpinternal.SafeSessio
// GetComputeService returns a Compute Engine service
func GetComputeService(ctx context.Context, session *gcpinternal.SafeSession) (*compute.Service, error) {
service, err := compute.NewService(ctx, session.GetClientOption())
service, err := compute.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create compute service: %w", err)
}
@@ -136,7 +150,7 @@ func GetComputeService(ctx context.Context, session *gcpinternal.SafeSession) (*
// GetIAMService returns an IAM Admin service
func GetIAMService(ctx context.Context, session *gcpinternal.SafeSession) (*iam.Service, error) {
service, err := iam.NewService(ctx, session.GetClientOption())
service, err := iam.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create IAM service: %w", err)
}
@@ -145,7 +159,7 @@ func GetIAMService(ctx context.Context, session *gcpinternal.SafeSession) (*iam.
// GetResourceManagerService returns a Cloud Resource Manager service (v1)
func GetResourceManagerService(ctx context.Context, session *gcpinternal.SafeSession) (*cloudresourcemanager.Service, error) {
service, err := cloudresourcemanager.NewService(ctx, session.GetClientOption())
service, err := cloudresourcemanager.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create resource manager service: %w", err)
}
@@ -154,7 +168,7 @@ func GetResourceManagerService(ctx context.Context, session *gcpinternal.SafeSes
// GetSecretManagerService returns a Secret Manager service (REST API)
func GetSecretManagerService(ctx context.Context, session *gcpinternal.SafeSession) (*secretmanagerapi.Service, error) {
service, err := secretmanagerapi.NewService(ctx, session.GetClientOption())
service, err := secretmanagerapi.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create secret manager service: %w", err)
}
@@ -163,7 +177,7 @@ func GetSecretManagerService(ctx context.Context, session *gcpinternal.SafeSessi
// GetBigQueryService returns a BigQuery service (REST API v2)
func GetBigQueryService(ctx context.Context, session *gcpinternal.SafeSession) (*bigqueryapi.Service, error) {
service, err := bigqueryapi.NewService(ctx, session.GetClientOption())
service, err := bigqueryapi.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create BigQuery service: %w", err)
}
@@ -172,7 +186,7 @@ func GetBigQueryService(ctx context.Context, session *gcpinternal.SafeSession) (
// GetStorageService returns a Cloud Storage service (REST API)
func GetStorageService(ctx context.Context, session *gcpinternal.SafeSession) (*storageapi.Service, error) {
service, err := storageapi.NewService(ctx, session.GetClientOption())
service, err := storageapi.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create storage service: %w", err)
}
@@ -181,7 +195,7 @@ func GetStorageService(ctx context.Context, session *gcpinternal.SafeSession) (*
// GetArtifactRegistryService returns an Artifact Registry service (REST API)
func GetArtifactRegistryService(ctx context.Context, session *gcpinternal.SafeSession) (*artifactregistryapi.Service, error) {
service, err := artifactregistryapi.NewService(ctx, session.GetClientOption())
service, err := artifactregistryapi.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Artifact Registry service: %w", err)
}
@@ -190,7 +204,7 @@ func GetArtifactRegistryService(ctx context.Context, session *gcpinternal.SafeSe
// GetContainerService returns a GKE Container service
func GetContainerService(ctx context.Context, session *gcpinternal.SafeSession) (*container.Service, error) {
service, err := container.NewService(ctx, session.GetClientOption())
service, err := container.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create container service: %w", err)
}
@@ -199,7 +213,7 @@ func GetContainerService(ctx context.Context, session *gcpinternal.SafeSession)
// GetCloudRunService returns a Cloud Run service (v1)
func GetCloudRunService(ctx context.Context, session *gcpinternal.SafeSession) (*run.APIService, error) {
service, err := run.NewService(ctx, session.GetClientOption())
service, err := run.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Cloud Run service: %w", err)
}
@@ -208,7 +222,7 @@ func GetCloudRunService(ctx context.Context, session *gcpinternal.SafeSession) (
// GetCloudRunServiceV2 returns a Cloud Run service (v2)
func GetCloudRunServiceV2(ctx context.Context, session *gcpinternal.SafeSession) (*runv2.Service, error) {
service, err := runv2.NewService(ctx, session.GetClientOption())
service, err := runv2.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Cloud Run v2 service: %w", err)
}
@@ -217,7 +231,7 @@ func GetCloudRunServiceV2(ctx context.Context, session *gcpinternal.SafeSession)
// GetCloudFunctionsService returns a Cloud Functions service (v1)
func GetCloudFunctionsService(ctx context.Context, session *gcpinternal.SafeSession) (*cloudfunctions.Service, error) {
service, err := cloudfunctions.NewService(ctx, session.GetClientOption())
service, err := cloudfunctions.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Cloud Functions service: %w", err)
}
@@ -226,7 +240,7 @@ func GetCloudFunctionsService(ctx context.Context, session *gcpinternal.SafeSess
// GetCloudFunctionsServiceV2 returns a Cloud Functions v2 service
func GetCloudFunctionsServiceV2(ctx context.Context, session *gcpinternal.SafeSession) (*cloudfunctionsv2.Service, error) {
service, err := cloudfunctionsv2.NewService(ctx, session.GetClientOption())
service, err := cloudfunctionsv2.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Cloud Functions v2 service: %w", err)
}
@@ -235,7 +249,7 @@ func GetCloudFunctionsServiceV2(ctx context.Context, session *gcpinternal.SafeSe
// GetCloudIdentityService returns a Cloud Identity service
func GetCloudIdentityService(ctx context.Context, session *gcpinternal.SafeSession) (*cloudidentity.Service, error) {
service, err := cloudidentity.NewService(ctx, session.GetClientOption())
service, err := cloudidentity.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Cloud Identity service: %w", err)
}
@@ -244,7 +258,7 @@ func GetCloudIdentityService(ctx context.Context, session *gcpinternal.SafeSessi
// GetAccessContextManagerService returns an Access Context Manager service
func GetAccessContextManagerService(ctx context.Context, session *gcpinternal.SafeSession) (*accesscontextmanager.Service, error) {
service, err := accesscontextmanager.NewService(ctx, session.GetClientOption())
service, err := accesscontextmanager.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Access Context Manager service: %w", err)
}
@@ -253,7 +267,7 @@ func GetAccessContextManagerService(ctx context.Context, session *gcpinternal.Sa
// GetRedisService returns a Memorystore Redis service
func GetRedisService(ctx context.Context, session *gcpinternal.SafeSession) (*redis.Service, error) {
service, err := redis.NewService(ctx, session.GetClientOption())
service, err := redis.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Redis service: %w", err)
}
@@ -262,7 +276,7 @@ func GetRedisService(ctx context.Context, session *gcpinternal.SafeSession) (*re
// GetServiceNetworkingService returns a Service Networking service
func GetServiceNetworkingService(ctx context.Context, session *gcpinternal.SafeSession) (*servicenetworking.APIService, error) {
service, err := servicenetworking.NewService(ctx, session.GetClientOption())
service, err := servicenetworking.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Service Networking service: %w", err)
}
@@ -271,7 +285,7 @@ func GetServiceNetworkingService(ctx context.Context, session *gcpinternal.SafeS
// GetComposerService returns a Cloud Composer service
func GetComposerService(ctx context.Context, session *gcpinternal.SafeSession) (*composer.Service, error) {
service, err := composer.NewService(ctx, session.GetClientOption())
service, err := composer.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Composer service: %w", err)
}
@@ -280,7 +294,7 @@ func GetComposerService(ctx context.Context, session *gcpinternal.SafeSession) (
// GetDataflowService returns a Dataflow service
func GetDataflowService(ctx context.Context, session *gcpinternal.SafeSession) (*dataflow.Service, error) {
service, err := dataflow.NewService(ctx, session.GetClientOption())
service, err := dataflow.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Dataflow service: %w", err)
}
@@ -289,7 +303,7 @@ func GetDataflowService(ctx context.Context, session *gcpinternal.SafeSession) (
// GetDataprocService returns a Dataproc service
func GetDataprocService(ctx context.Context, session *gcpinternal.SafeSession) (*dataproc.Service, error) {
service, err := dataproc.NewService(ctx, session.GetClientOption())
service, err := dataproc.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Dataproc service: %w", err)
}
@@ -298,7 +312,7 @@ func GetDataprocService(ctx context.Context, session *gcpinternal.SafeSession) (
// GetNotebooksService returns a Notebooks service
func GetNotebooksService(ctx context.Context, session *gcpinternal.SafeSession) (*notebooks.Service, error) {
service, err := notebooks.NewService(ctx, session.GetClientOption())
service, err := notebooks.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Notebooks service: %w", err)
}
@@ -307,7 +321,7 @@ func GetNotebooksService(ctx context.Context, session *gcpinternal.SafeSession)
// GetBeyondCorpService returns a BeyondCorp service
func GetBeyondCorpService(ctx context.Context, session *gcpinternal.SafeSession) (*beyondcorp.Service, error) {
service, err := beyondcorp.NewService(ctx, session.GetClientOption())
service, err := beyondcorp.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create BeyondCorp service: %w", err)
}
@@ -316,7 +330,7 @@ func GetBeyondCorpService(ctx context.Context, session *gcpinternal.SafeSession)
// GetIAPService returns an IAP service
func GetIAPService(ctx context.Context, session *gcpinternal.SafeSession) (*iap.Service, error) {
service, err := iap.NewService(ctx, session.GetClientOption())
service, err := iap.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create IAP service: %w", err)
}
@@ -325,7 +339,7 @@ func GetIAPService(ctx context.Context, session *gcpinternal.SafeSession) (*iap.
// GetKMSService returns a Cloud KMS service
func GetKMSService(ctx context.Context, session *gcpinternal.SafeSession) (*cloudkms.Service, error) {
service, err := cloudkms.NewService(ctx, session.GetClientOption())
service, err := cloudkms.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create KMS service: %w", err)
}
@@ -334,7 +348,7 @@ func GetKMSService(ctx context.Context, session *gcpinternal.SafeSession) (*clou
// GetSQLAdminService returns a Cloud SQL Admin service (v1)
func GetSQLAdminService(ctx context.Context, session *gcpinternal.SafeSession) (*sqladmin.Service, error) {
service, err := sqladmin.NewService(ctx, session.GetClientOption())
service, err := sqladmin.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create SQL Admin service: %w", err)
}
@@ -343,7 +357,7 @@ func GetSQLAdminService(ctx context.Context, session *gcpinternal.SafeSession) (
// GetSQLAdminServiceBeta returns a Cloud SQL Admin service (v1beta4)
func GetSQLAdminServiceBeta(ctx context.Context, session *gcpinternal.SafeSession) (*sqladminbeta.Service, error) {
service, err := sqladminbeta.NewService(ctx, session.GetClientOption())
service, err := sqladminbeta.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create SQL Admin beta service: %w", err)
}
@@ -352,7 +366,7 @@ func GetSQLAdminServiceBeta(ctx context.Context, session *gcpinternal.SafeSessio
// GetDNSService returns a Cloud DNS service
func GetDNSService(ctx context.Context, session *gcpinternal.SafeSession) (*dns.Service, error) {
service, err := dns.NewService(ctx, session.GetClientOption())
service, err := dns.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create DNS service: %w", err)
}
@@ -361,7 +375,7 @@ func GetDNSService(ctx context.Context, session *gcpinternal.SafeSession) (*dns.
// GetPubSubService returns a Pub/Sub service (REST API)
func GetPubSubService(ctx context.Context, session *gcpinternal.SafeSession) (*pubsubapi.Service, error) {
service, err := pubsubapi.NewService(ctx, session.GetClientOption())
service, err := pubsubapi.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Pub/Sub service: %w", err)
}
@@ -370,7 +384,7 @@ func GetPubSubService(ctx context.Context, session *gcpinternal.SafeSession) (*p
// GetLoggingService returns a Cloud Logging service
func GetLoggingService(ctx context.Context, session *gcpinternal.SafeSession) (*logging.Service, error) {
service, err := logging.NewService(ctx, session.GetClientOption())
service, err := logging.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Logging service: %w", err)
}
@@ -379,7 +393,7 @@ func GetLoggingService(ctx context.Context, session *gcpinternal.SafeSession) (*
// GetSpannerService returns a Cloud Spanner service
func GetSpannerService(ctx context.Context, session *gcpinternal.SafeSession) (*spanner.Service, error) {
service, err := spanner.NewService(ctx, session.GetClientOption())
service, err := spanner.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Spanner service: %w", err)
}
@@ -388,7 +402,7 @@ func GetSpannerService(ctx context.Context, session *gcpinternal.SafeSession) (*
// GetBigtableAdminService returns a Bigtable Admin service
func GetBigtableAdminService(ctx context.Context, session *gcpinternal.SafeSession) (*bigtableadmin.Service, error) {
service, err := bigtableadmin.NewService(ctx, session.GetClientOption())
service, err := bigtableadmin.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Bigtable Admin service: %w", err)
}
@@ -397,7 +411,7 @@ func GetBigtableAdminService(ctx context.Context, session *gcpinternal.SafeSessi
// GetFilestoreService returns a Filestore service
func GetFilestoreService(ctx context.Context, session *gcpinternal.SafeSession) (*file.Service, error) {
service, err := file.NewService(ctx, session.GetClientOption())
service, err := file.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Filestore service: %w", err)
}
@@ -406,7 +420,7 @@ func GetFilestoreService(ctx context.Context, session *gcpinternal.SafeSession)
// GetSourceRepoService returns a Source Repositories service
func GetSourceRepoService(ctx context.Context, session *gcpinternal.SafeSession) (*sourcerepo.Service, error) {
service, err := sourcerepo.NewService(ctx, session.GetClientOption())
service, err := sourcerepo.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Source Repositories service: %w", err)
}
@@ -415,7 +429,7 @@ func GetSourceRepoService(ctx context.Context, session *gcpinternal.SafeSession)
// GetCloudBuildService returns a Cloud Build service
func GetCloudBuildService(ctx context.Context, session *gcpinternal.SafeSession) (*cloudbuild.Service, error) {
service, err := cloudbuild.NewService(ctx, session.GetClientOption())
service, err := cloudbuild.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Cloud Build service: %w", err)
}
@@ -424,7 +438,7 @@ func GetCloudBuildService(ctx context.Context, session *gcpinternal.SafeSession)
// GetOrgPolicyService returns an Organization Policy service
func GetOrgPolicyService(ctx context.Context, session *gcpinternal.SafeSession) (*orgpolicy.Service, error) {
service, err := orgpolicy.NewService(ctx, session.GetClientOption())
service, err := orgpolicy.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Org Policy service: %w", err)
}
@@ -433,7 +447,7 @@ func GetOrgPolicyService(ctx context.Context, session *gcpinternal.SafeSession)
// GetSchedulerService returns a Cloud Scheduler service
func GetSchedulerService(ctx context.Context, session *gcpinternal.SafeSession) (*cloudscheduler.Service, error) {
service, err := cloudscheduler.NewService(ctx, session.GetClientOption())
service, err := cloudscheduler.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Scheduler service: %w", err)
}
@@ -442,7 +456,7 @@ func GetSchedulerService(ctx context.Context, session *gcpinternal.SafeSession)
// GetAPIKeysService returns an API Keys service
func GetAPIKeysService(ctx context.Context, session *gcpinternal.SafeSession) (*apikeys.Service, error) {
service, err := apikeys.NewService(ctx, session.GetClientOption())
service, err := apikeys.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create API Keys service: %w", err)
}
@@ -451,7 +465,7 @@ func GetAPIKeysService(ctx context.Context, session *gcpinternal.SafeSession) (*
// GetCertificateManagerService returns a Certificate Manager service
func GetCertificateManagerService(ctx context.Context, session *gcpinternal.SafeSession) (*certificatemanager.Service, error) {
service, err := certificatemanager.NewService(ctx, session.GetClientOption())
service, err := certificatemanager.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Certificate Manager service: %w", err)
}
+727 -25
View File
@@ -4,6 +4,9 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"strings"
"sync"
@@ -11,6 +14,7 @@ import (
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/impersonate"
"google.golang.org/api/option"
)
@@ -37,6 +41,7 @@ type SafeSession struct {
email string
projectID string
accountType string // "user" or "serviceAccount"
uniqueID string // Numeric unique ID from tokeninfo (issued_to/azp)
}
// GCPCredentialInfo holds information about the current credential
@@ -100,6 +105,675 @@ func NewSafeSession(ctx context.Context) (*SafeSession, error) {
return ss, nil
}
// NewSafeSessionWithImpersonation creates a SafeSession that generates short-lived
// tokens as the target service account. When baseTS is nil the caller's ADC is
// used as the base credential. When baseTS is provided (e.g. from a key file or
// access token session) it is used instead, enabling auth chaining like
// --key-file + --impersonate-sa or --access-token + --impersonate-sa.
func NewSafeSessionWithImpersonation(ctx context.Context, targetSA string, baseTS oauth2.TokenSource) (*SafeSession, error) {
var opts []option.ClientOption
if baseTS != nil {
opts = append(opts, option.WithTokenSource(baseTS))
}
// Override the ADC quota project with the target SA's project. The ADC
// file may contain a stale or deleted quota_project_id which causes the
// IAM Credentials API call to fail with USER_PROJECT_DENIED.
if projectID := projectFromSAEmail(targetSA); projectID != "" {
opts = append(opts, option.WithQuotaProject(projectID))
}
ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{
TargetPrincipal: targetSA,
Scopes: CommonScopes,
}, opts...)
if err != nil {
return nil, fmt.Errorf("failed to create impersonated token source for %s: %w", targetSA, err)
}
ss := &SafeSession{
tokenSource: ts,
tokens: make(map[string]*oauth2.Token),
refreshBuffer: 5 * time.Minute,
stopMonitor: make(chan struct{}),
email: targetSA,
accountType: "serviceAccount",
}
// Get initial token to validate impersonation works
token, err := ts.Token()
if err != nil {
return nil, fmt.Errorf("impersonation failed for %s (do you have roles/iam.serviceAccountTokenCreator?): %w", targetSA, err)
}
ss.currentToken = token
ss.sessionExpiry = token.Expiry
// Cache the token for the default scope
ss.tokens["https://www.googleapis.com/auth/cloud-platform"] = token
return ss, nil
}
// NewSafeSessionFromKeyFile creates a SafeSession from a service account JSON key file.
// This bypasses gcloud CLI entirely and authenticates directly using the key file.
func NewSafeSessionFromKeyFile(ctx context.Context, keyFilePath string) (*SafeSession, error) {
keyJSON, err := os.ReadFile(keyFilePath)
if err != nil {
return nil, fmt.Errorf("failed to read key file %s: %w", keyFilePath, err)
}
// Parse the JSON to extract the service account email and project
var keyFileData struct {
ClientEmail string `json:"client_email"`
ProjectID string `json:"project_id"`
Type string `json:"type"`
}
if err := json.Unmarshal(keyJSON, &keyFileData); err != nil {
return nil, fmt.Errorf("failed to parse key file %s: %w", keyFilePath, err)
}
if keyFileData.Type != "service_account" {
return nil, fmt.Errorf("key file %s is not a service account key (type: %s)", keyFilePath, keyFileData.Type)
}
if keyFileData.ClientEmail == "" {
return nil, fmt.Errorf("key file %s does not contain a client_email field", keyFilePath)
}
// Create credentials from the key file
creds, err := google.CredentialsFromJSON(ctx, keyJSON, CommonScopes...)
if err != nil {
return nil, fmt.Errorf("failed to create credentials from key file %s: %w", keyFilePath, err)
}
ss := &SafeSession{
tokenSource: creds.TokenSource,
tokens: make(map[string]*oauth2.Token),
refreshBuffer: 5 * time.Minute,
stopMonitor: make(chan struct{}),
email: keyFileData.ClientEmail,
accountType: "serviceAccount",
projectID: keyFileData.ProjectID,
}
// Get initial token to validate the key works
token, err := creds.TokenSource.Token()
if err != nil {
return nil, fmt.Errorf("failed to get token from key file %s (key may be expired or revoked): %w", keyFilePath, err)
}
ss.currentToken = token
ss.sessionExpiry = token.Expiry
// Cache the token for the default scope
ss.tokens["https://www.googleapis.com/auth/cloud-platform"] = token
return ss, nil
}
// NewSafeSessionFromAccessToken creates a SafeSession from a raw GCP access token.
// The token cannot be refreshed, so the session will expire when the token does.
// Identity is resolved via the Google tokeninfo endpoint.
func NewSafeSessionFromAccessToken(ctx context.Context, accessToken string) (*SafeSession, error) {
if strings.TrimSpace(accessToken) == "" {
return nil, fmt.Errorf("access token is empty")
}
token := &oauth2.Token{
AccessToken: strings.TrimSpace(accessToken),
TokenType: "Bearer",
}
ts := &StaticTokenSource{StaticToken: token}
ss := &SafeSession{
tokenSource: ts,
currentToken: token,
tokens: make(map[string]*oauth2.Token),
refreshBuffer: 5 * time.Minute,
stopMonitor: make(chan struct{}),
}
// Cache the token for the default scope
ss.tokens["https://www.googleapis.com/auth/cloud-platform"] = token
// Resolve identity via tokeninfo endpoint
ss.resolveTokenIdentity(ctx, accessToken)
return ss, nil
}
// resolveTokenIdentity tries multiple Google endpoints to determine the email
// and account type for a raw access token.
//
// Strategy:
// 1. tokeninfo v1 endpoint: returns email (if scope includes email) AND the
// numeric unique ID (issued_to/azp) which is always present.
// 2. If email was found, done. Otherwise use the unique ID with
// serviceAccounts.get to resolve the SA email (IAM API accepts numeric IDs
// in the resource path: projects/-/serviceAccounts/{uniqueId}).
// 3. Fallback: userinfo endpoint, then GCP API error parsing.
// 4. If still unresolved and --project is set, ProbeIdentityWithProject lists
// SAs in the project and matches by unique ID (called from CLI layer).
func (s *SafeSession) resolveTokenIdentity(ctx context.Context, accessToken string) {
debug := os.Getenv("CLOUDFOX_DEBUG") != ""
// Step 1: Call tokeninfo to get email (if available) and unique ID (always available)
email, acctType, uniqueID, expiry := queryTokenInfoFull(ctx, accessToken)
if debug {
fmt.Fprintf(os.Stderr, "[debug] resolveTokenIdentity tokeninfo: email=%q acctType=%q uniqueID=%q\n", email, acctType, uniqueID)
}
// Store expiry and unique ID regardless of email resolution
if !expiry.IsZero() {
s.sessionExpiry = expiry
s.currentToken.Expiry = expiry
}
if uniqueID != "" {
s.uniqueID = uniqueID
}
if email != "" {
s.email = email
s.accountType = acctType
return
}
// Step 2: Use unique ID with serviceAccounts.get to resolve SA email.
// The IAM API accepts numeric unique IDs in place of the SA email in the
// resource path: projects/-/serviceAccounts/{uniqueId}
// This returns the full SA object including the email field.
if uniqueID != "" {
email, acctType = getSAByUniqueID(ctx, accessToken, uniqueID)
if debug {
fmt.Fprintf(os.Stderr, "[debug] resolveTokenIdentity getSAByUniqueID: email=%q acctType=%q\n", email, acctType)
}
if email != "" {
s.email = email
s.accountType = acctType
return
}
}
// Step 3: Fallback - userinfo endpoint (works with openid/email scopes)
email, acctType = queryUserInfoHTTP(ctx, accessToken)
if debug {
fmt.Fprintf(os.Stderr, "[debug] resolveTokenIdentity userinfo: email=%q acctType=%q\n", email, acctType)
}
if email != "" {
s.email = email
s.accountType = acctType
return
}
// Step 4: Fallback - GCP API error parsing
email, acctType = probeGCPIdentity(ctx, accessToken)
if debug {
fmt.Fprintf(os.Stderr, "[debug] resolveTokenIdentity iam-probe: email=%q acctType=%q\n", email, acctType)
}
if email != "" {
s.email = email
s.accountType = acctType
}
}
// getSAByUniqueID calls the IAM serviceAccounts.get endpoint using the SA's
// numeric unique ID. The IAM API accepts unique IDs in place of email in the
// resource path: projects/-/serviceAccounts/{uniqueId}
// If the caller has iam.serviceAccounts.get permission, this returns the full
// SA object including the email. This works even without knowing the project.
func getSAByUniqueID(ctx context.Context, accessToken, uniqueID string) (email, acctType string) {
saURL := fmt.Sprintf("https://iam.googleapis.com/v1/projects/-/serviceAccounts/%s", uniqueID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, saURL, nil)
if err != nil {
return
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return
}
if os.Getenv("CLOUDFOX_DEBUG") != "" {
fmt.Fprintf(os.Stderr, "[debug] getSAByUniqueID response (status %d): %s\n", resp.StatusCode, string(body))
}
if resp.StatusCode == http.StatusOK {
var sa struct {
Email string `json:"email"`
UniqueID string `json:"uniqueId"`
Name string `json:"name"`
}
if err := json.Unmarshal(body, &sa); err == nil && sa.Email != "" {
return sa.Email, "serviceAccount"
}
}
// On 403, the error message might contain the resolved email
resolvedEmail, resolvedType := extractCallerFromGCPResponse(body)
if resolvedEmail != "" {
return resolvedEmail, resolvedType
}
return extractEmailFromGCPError(string(body)), "serviceAccount"
}
// queryTokenInfoFull calls the Google tokeninfo v1 endpoint and extracts email,
// account type, the numeric unique ID (issued_to/azp), and expiry.
// SA tokens from generateAccessToken always return the unique ID even when
// the email field is empty (because the token lacks the email scope).
func queryTokenInfoFull(ctx context.Context, accessToken string) (email, acctType, uniqueID string, expiry time.Time) {
url := fmt.Sprintf("https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=%s", accessToken)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return
}
if os.Getenv("CLOUDFOX_DEBUG") != "" {
fmt.Fprintf(os.Stderr, "[debug] queryTokenInfoFull (status %d): %s\n", resp.StatusCode, string(body))
}
if resp.StatusCode != http.StatusOK {
return
}
var info struct {
Email string `json:"email"`
IssuedTo string `json:"issued_to"` // numeric unique ID of the SA/user
Audience string `json:"audience"`
Scope string `json:"scope"`
Exp string `json:"exp"`
// v2/v3 field names (if we switch endpoints later)
AZP string `json:"azp"`
}
if err := json.Unmarshal(body, &info); err != nil {
return
}
if info.Email != "" {
email = info.Email
if strings.Contains(info.Email, ".iam.gserviceaccount.com") {
acctType = "serviceAccount"
} else {
acctType = "user"
}
}
// Extract unique ID: v1 uses "issued_to", v2/v3 use "azp"
uniqueID = info.IssuedTo
if uniqueID == "" {
uniqueID = info.AZP
}
if info.Exp != "" {
var expUnix int64
if _, scanErr := fmt.Sscanf(info.Exp, "%d", &expUnix); scanErr == nil {
expiry = time.Unix(expUnix, 0)
}
}
return
}
// queryUserInfoHTTP calls the Google userinfo endpoint as a fallback for
// identity resolution. This works for tokens that have openid or email scopes.
func queryUserInfoHTTP(ctx context.Context, accessToken string) (email, acctType string) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://www.googleapis.com/oauth2/v3/userinfo", nil)
if err != nil {
return
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != http.StatusOK {
return
}
var info struct {
Email string `json:"email"`
}
if err := json.Unmarshal(body, &info); err != nil {
return
}
if info.Email != "" {
email = info.Email
if strings.Contains(info.Email, ".iam.gserviceaccount.com") {
acctType = "serviceAccount"
} else {
acctType = "user"
}
}
return
}
// probeGCPIdentity is a last-resort identity resolver that makes GCP API calls
// and attempts to extract the caller email from error responses. Most identity
// resolution is handled earlier by getSAByUniqueID (step 2) or
// matchSAByUniqueID (in ProbeIdentityWithProject). This function exists as a
// final fallback for edge cases where those approaches fail.
func probeGCPIdentity(ctx context.Context, accessToken string) (email, acctType string) {
// Try a lightweight GCP API call and parse the error for caller identity.
// Use compute.regions.list which often includes the caller in 403 errors.
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://compute.googleapis.com/compute/v1/projects/_/regions?maxResults=1", nil)
if err != nil {
return
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return
}
if os.Getenv("CLOUDFOX_DEBUG") != "" {
fmt.Fprintf(os.Stderr, "[debug] probeGCPIdentity compute response (status %d): %s\n", resp.StatusCode, string(body))
}
return extractCallerFromGCPResponse(body)
}
// extractEmailFromGCPError extracts an email address from a GCP API error message.
// GCP error messages often contain the caller identity in patterns like:
// - "... Caller: [email protected] ..."
// - "... caller does not have permission ... [email protected] ..."
func extractEmailFromGCPError(msg string) string {
// Look for email-like patterns in the error message
// Simple approach: find strings matching [email protected] pattern
parts := strings.Fields(msg)
for _, part := range parts {
// Clean common trailing punctuation
part = strings.TrimRight(part, ".,;:\"'")
if isLikelyEmail(part) {
return part
}
}
return ""
}
// isLikelyEmail checks if a string looks like an email address
func isLikelyEmail(s string) bool {
at := strings.Index(s, "@")
if at < 1 || at >= len(s)-1 {
return false
}
dot := strings.LastIndex(s[at:], ".")
return dot > 1
}
// ProbeIdentityWithProject attempts to resolve the session's email by making
// project-scoped GCP API calls. This is used as a fallback when tokeninfo
// endpoints return a numeric unique ID but no email (common with SA tokens
// generated via generateAccessToken with only cloud-platform scope).
//
// Strategies (in order):
// 1. List service accounts in the project and match by unique ID
// 2. Cloud Resource Manager projects.get (extracts caller from error details)
// 3. Cloud Resource Manager projects.testIamPermissions
func ProbeIdentityWithProject(ctx context.Context, session *SafeSession, projectID string) {
if session.GetEmail() != "" {
return // already resolved
}
token, err := session.GetToken(ctx)
if err != nil || token == "" {
return
}
debug := os.Getenv("CLOUDFOX_DEBUG") != ""
// Strategy 1: List SAs in the project and match by unique ID.
// The tokeninfo endpoint always returns the SA's numeric unique ID
// (issued_to/azp), even when email is missing. Each SA in the project
// has a uniqueId field. We match to find the email.
session.mu.Lock()
uniqueID := session.uniqueID
session.mu.Unlock()
if uniqueID != "" {
email, acctType := matchSAByUniqueID(ctx, token, projectID, uniqueID)
if debug {
fmt.Fprintf(os.Stderr, "[debug] matchSAByUniqueID: email=%q acctType=%q\n", email, acctType)
}
if email != "" {
session.mu.Lock()
session.email = email
session.accountType = acctType
session.mu.Unlock()
return
}
}
// Strategy 2: projects.get - extracts caller from 403 error details
email, acctType := probeWithCRM(ctx, token, projectID)
if debug {
fmt.Fprintf(os.Stderr, "[debug] probeWithCRM: email=%q acctType=%q\n", email, acctType)
}
if email != "" {
session.mu.Lock()
session.email = email
session.accountType = acctType
session.mu.Unlock()
return
}
// Strategy 3: testIamPermissions - extracts caller from error details
email, acctType = probeWithTestPermissions(ctx, token, projectID)
if debug {
fmt.Fprintf(os.Stderr, "[debug] probeWithTestPermissions: email=%q acctType=%q\n", email, acctType)
}
if email != "" {
session.mu.Lock()
session.email = email
session.accountType = acctType
session.mu.Unlock()
}
}
// matchSAByUniqueID lists service accounts in a project and matches by numeric
// unique ID. The IAM API returns each SA's uniqueId field which corresponds to
// the issued_to/azp value from the tokeninfo endpoint.
func matchSAByUniqueID(ctx context.Context, accessToken, projectID, targetUniqueID string) (email, acctType string) {
debug := os.Getenv("CLOUDFOX_DEBUG") != ""
pageToken := ""
for {
url := fmt.Sprintf("https://iam.googleapis.com/v1/projects/%s/serviceAccounts?pageSize=100", projectID)
if pageToken != "" {
url += "&pageToken=" + pageToken
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return
}
if debug {
fmt.Fprintf(os.Stderr, "[debug] matchSAByUniqueID page (status %d, project %s)\n", resp.StatusCode, projectID)
}
if resp.StatusCode != http.StatusOK {
if debug {
fmt.Fprintf(os.Stderr, "[debug] matchSAByUniqueID error: %s\n", string(body))
}
return
}
var listResp struct {
Accounts []struct {
Email string `json:"email"`
UniqueID string `json:"uniqueId"`
Name string `json:"name"`
} `json:"accounts"`
NextPageToken string `json:"nextPageToken"`
}
if err := json.Unmarshal(body, &listResp); err != nil {
return
}
for _, sa := range listResp.Accounts {
if sa.UniqueID == targetUniqueID {
if debug {
fmt.Fprintf(os.Stderr, "[debug] matchSAByUniqueID MATCH: uniqueID=%s -> email=%s\n", targetUniqueID, sa.Email)
}
return sa.Email, "serviceAccount"
}
}
if listResp.NextPageToken == "" {
break
}
pageToken = listResp.NextPageToken
}
if debug {
fmt.Fprintf(os.Stderr, "[debug] matchSAByUniqueID: no match for uniqueID=%s in project %s\n", targetUniqueID, projectID)
}
return
}
// probeWithCRM calls cloudresourcemanager projects.get and extracts caller email
// from the response or error details.
func probeWithCRM(ctx context.Context, accessToken, projectID string) (email, acctType string) {
url := fmt.Sprintf("https://cloudresourcemanager.googleapis.com/v1/projects/%s", projectID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return
}
if os.Getenv("CLOUDFOX_DEBUG") != "" {
fmt.Fprintf(os.Stderr, "[debug] probeWithCRM response (status %d): %s\n", resp.StatusCode, string(body))
}
return extractCallerFromGCPResponse(body)
}
// probeWithTestPermissions calls testIamPermissions on the project. This endpoint
// is commonly accessible and error responses include the caller identity.
func probeWithTestPermissions(ctx context.Context, accessToken, projectID string) (email, acctType string) {
url := fmt.Sprintf("https://cloudresourcemanager.googleapis.com/v1/projects/%s:testIamPermissions", projectID)
payload := `{"permissions":["resourcemanager.projects.get"]}`
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(payload))
if err != nil {
return
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return
}
return extractCallerFromGCPResponse(body)
}
// extractCallerFromGCPResponse extracts a caller email from a GCP API response body.
// Works with both error responses (403/404) and success responses that may include
// the caller in audit metadata or error details.
func extractCallerFromGCPResponse(body []byte) (email, acctType string) {
var apiResp struct {
Error struct {
Message string `json:"message"`
Status string `json:"status"`
Details []struct {
Type string `json:"@type"`
Reason string `json:"reason"`
Domain string `json:"domain"`
Metadata map[string]string `json:"metadata"`
} `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(body, &apiResp); err != nil {
return
}
// Check error details metadata for caller identity
for _, detail := range apiResp.Error.Details {
if detail.Type == "type.googleapis.com/google.rpc.ErrorInfo" {
// Check common metadata keys for caller identity
for _, key := range []string{"caller", "email", "principal", "service_account"} {
if val, ok := detail.Metadata[key]; ok && isLikelyEmail(val) {
email = val
break
}
}
if email != "" {
break
}
}
}
// Fallback: scan the error message for email-like strings
if email == "" {
email = extractEmailFromGCPError(apiResp.Error.Message)
}
if email != "" {
if strings.Contains(email, ".iam.gserviceaccount.com") {
acctType = "serviceAccount"
} else {
acctType = "user"
}
}
return
}
// NewSmartSession creates a session with automatic monitoring and refresh
func NewSmartSession(ctx context.Context) (*SafeSession, error) {
ss, err := NewSafeSession(ctx)
@@ -141,7 +815,10 @@ func (s *SafeSession) getTokenUnlocked(ctx context.Context) (string, error) {
return token.AccessToken, nil
}
// GetTokenForScope returns a token for a specific OAuth scope
// GetTokenForScope returns a token for a specific OAuth scope.
// When using an impersonated session, this returns the impersonated token
// (which already has cloud-platform scope) rather than creating a new ADC
// token source that would bypass impersonation.
func (s *SafeSession) GetTokenForScope(ctx context.Context, scope string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -151,13 +828,10 @@ func (s *SafeSession) GetTokenForScope(ctx context.Context, scope string) (strin
return tok.AccessToken, nil
}
// Get a new token source for this scope
ts, err := google.DefaultTokenSource(ctx, scope)
if err != nil {
return "", fmt.Errorf("failed to create token source for scope %s: %w", scope, err)
}
token, err := ts.Token()
// Use the existing token source to preserve identity (ADC or impersonated).
// The token source was created with CommonScopes which includes cloud-platform,
// so the returned token is valid for all GCP APIs regardless of the requested scope.
token, err := s.tokenSource.Token()
if err != nil {
return "", fmt.Errorf("failed to get token for scope %s: %w", scope, err)
}
@@ -225,33 +899,25 @@ func (s *SafeSession) IsSessionExpired() bool {
return time.Now().Add(s.refreshBuffer).After(s.sessionExpiry)
}
// RefreshSession refreshes the token and clears the cache
// RefreshSession refreshes the token and clears the cache.
// This uses the existing token source (which may be ADC or an impersonated
// token source) so it preserves the current identity.
func (s *SafeSession) RefreshSession(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
// Check if gcloud session is still valid
if !IsSessionValid() {
return fmt.Errorf("GCP session expired; please run 'gcloud auth login' or 'gcloud auth application-default login'")
}
// Create new token source
ts, err := google.DefaultTokenSource(ctx, CommonScopes...)
// Get a fresh token from the existing token source. Both ADC and
// impersonated token sources handle refresh internally, so we just
// need to invalidate the cached token and request a new one.
token, err := s.tokenSource.Token()
if err != nil {
return fmt.Errorf("failed to create token source: %w", err)
}
s.tokenSource = ts
// Get fresh token
token, err := ts.Token()
if err != nil {
return fmt.Errorf("failed to get fresh token: %w", err)
return fmt.Errorf("failed to refresh token: %w", err)
}
s.currentToken = token
s.sessionExpiry = token.Expiry
// Clear token cache
// Clear per-scope token cache
s.tokens = make(map[string]*oauth2.Token)
s.tokens["https://www.googleapis.com/auth/cloud-platform"] = token
@@ -397,6 +1063,27 @@ func IsSessionValid() bool {
return token != "" && !strings.Contains(token, "ERROR")
}
// defaultSession holds the global session set by --impersonate-sa.
// Services check this when their struct-level session is nil.
var (
defaultSessionMu sync.RWMutex
defaultSession *SafeSession
)
// SetDefaultSession sets the package-level default session (called from CLI init).
func SetDefaultSession(s *SafeSession) {
defaultSessionMu.Lock()
defaultSession = s
defaultSessionMu.Unlock()
}
// GetDefaultSession returns the package-level default session, or nil.
func GetDefaultSession() *SafeSession {
defaultSessionMu.RLock()
defer defaultSessionMu.RUnlock()
return defaultSession
}
// IsADCConfigured checks if Application Default Credentials are configured
func IsADCConfigured() bool {
ctx := context.Background()
@@ -422,6 +1109,21 @@ func GetDefaultAccount() string {
return strings.TrimSpace(string(out))
}
// projectFromSAEmail extracts the project ID from a service account email.
// e.g. "[email protected]" -> "my-project"
func projectFromSAEmail(email string) string {
// SA emails have the form: <name>@<project>.iam.gserviceaccount.com
parts := strings.SplitN(email, "@", 2)
if len(parts) != 2 {
return ""
}
domain := parts[1]
if !strings.HasSuffix(domain, ".iam.gserviceaccount.com") {
return ""
}
return strings.TrimSuffix(domain, ".iam.gserviceaccount.com")
}
// GetAccessToken returns a fresh access token from gcloud CLI
// This is useful for REST API calls that need a bearer token
func GetAccessToken() (string, error) {
+1 -1
View File
@@ -87,7 +87,7 @@ func (l *Logger) ErrorM(text string, module string) {
var red = color.New(color.FgRed).SprintFunc()
fmt.Printf(clearln+"[%s][%s] %s\n", red(emoji.Sprintf(":fox:cloudfox %s :fox:", l.version)), red(module), text)
if l.txtLog != nil {
l.txtLog.Printf("[%s] %s", module, text)
l.txtLog.Errorf("[%s] %s", module, text)
}
}
+393 -2
View File
@@ -160,7 +160,9 @@ func HandleStreamingOutput(
}
// ---- STREAM ROWS TO TEMP FILES ----
for _, t := range dataToOutput.TableFiles() {
streamTables := dataToOutput.TableFiles()
applyQueryFilter(streamTables)
for _, t := range streamTables {
if verbosity > 0 {
tmpClient := TableClient{Wrap: wrap}
tmpClient.printTablesToScreen([]TableFile{t})
@@ -540,6 +542,372 @@ func AppendLootFile(outputDirectory, lootFileName, entry string) error {
return nil
}
// ============================================================================
// QUERY FILTER - Global row-level filtering for --query / -q flag
// ============================================================================
var globalQueryFilter string
var globalSQLQueryFilter string
// SetQueryFilter sets the global query filter string. Called once from PersistentPreRun.
func SetQueryFilter(query string) {
globalQueryFilter = query
}
// SetSQLQueryFilter sets the global SQL-like query filter string.
func SetSQLQueryFilter(query string) {
globalSQLQueryFilter = query
}
// buildQueryPatterns generates search patterns from a query string.
// All patterns are lowercased for case-insensitive matching.
func buildQueryPatterns(query string) []string {
return []string{strings.ToLower(query)}
}
// rowMatchesQuery returns true if any column value (after stripping ANSI codes
// and lowercasing) contains any of the given patterns as a substring.
func rowMatchesQuery(row []string, patterns []string) bool {
for _, col := range row {
colLower := strings.ToLower(removeColorCodes(col))
for _, pattern := range patterns {
if strings.Contains(colLower, pattern) {
return true
}
}
}
return false
}
// ============================================================================
// Column-aware query types and parsing
// ============================================================================
type queryOperator int
const (
opContains queryOperator = iota // case-insensitive substring
opEquals // case-insensitive exact match
opNotContains // negated substring
opNotEquals // negated exact match
)
type queryCondition struct {
columnPattern string // fuzzy match against header names (lowercase)
value string // match value (lowercase)
operator queryOperator
}
// parseKeyValueQuery parses the enhanced --query key:value syntax.
// Returns (conditions, isStructured). If isStructured is false, the query has
// no column prefixes and should use legacy global substring matching.
//
// Syntax:
//
// "Role:owner,Principal:privesc02" -> column substring matches, ANDed
// "!Principal:compute" -> negated substring
// "Role=roles/owner" -> exact match
// "!Source=Project" -> negated exact match
// "privesc02" -> legacy global substring (no colon/equals)
func parseKeyValueQuery(query string) ([]queryCondition, bool) {
parts := strings.Split(query, ",")
// Check if any part contains a structured separator (: or =)
hasStructured := false
for _, part := range parts {
trimmed := strings.TrimSpace(part)
// Strip leading ! for detection
if strings.HasPrefix(trimmed, "!") {
trimmed = trimmed[1:]
}
if strings.ContainsAny(trimmed, ":=") {
hasStructured = true
break
}
}
if !hasStructured {
return nil, false
}
var conditions []queryCondition
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
continue
}
// Check for negation prefix
negated := false
if strings.HasPrefix(trimmed, "!") {
negated = true
trimmed = trimmed[1:]
}
// Determine operator by finding first = or :
eqIdx := strings.Index(trimmed, "=")
colonIdx := strings.Index(trimmed, ":")
var colPattern, value string
var isExact bool
switch {
case eqIdx >= 0 && (colonIdx < 0 || eqIdx < colonIdx):
// = found before : (or no : at all) -> exact match
colPattern = trimmed[:eqIdx]
value = trimmed[eqIdx+1:]
isExact = true
case colonIdx >= 0:
// : found -> substring match
colPattern = trimmed[:colonIdx]
value = trimmed[colonIdx+1:]
isExact = false
default:
// No separator found in this part, skip it
continue
}
op := opContains
if isExact && negated {
op = opNotEquals
} else if isExact {
op = opEquals
} else if negated {
op = opNotContains
}
conditions = append(conditions, queryCondition{
columnPattern: strings.ToLower(strings.TrimSpace(colPattern)),
value: strings.ToLower(strings.TrimSpace(value)),
operator: op,
})
}
return conditions, true
}
// parseSQLQuery parses the --sqlquery SQL-like WHERE clause syntax.
//
// Syntax:
//
// "Role CONTAINS owner AND Principal CONTAINS privesc02"
// "Role = roles/owner AND Principal NOT CONTAINS compute"
// "Source != Project"
//
// Operators: CONTAINS, =, NOT CONTAINS, !=
// Conjunction: AND only (all conditions must match)
// All operators and keywords are case-insensitive.
func parseSQLQuery(query string) []queryCondition {
// Split on AND (case-insensitive) with word boundaries
// Use a simple approach: find " AND " case-insensitively
parts := splitOnAND(query)
var conditions []queryCondition
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
continue
}
cond, ok := parseSQLCondition(trimmed)
if ok {
conditions = append(conditions, cond)
}
}
return conditions
}
// splitOnAND splits a string on " AND " (case-insensitive)
func splitOnAND(s string) []string {
lower := strings.ToLower(s)
var parts []string
for {
idx := strings.Index(lower, " and ")
if idx < 0 {
parts = append(parts, s)
break
}
parts = append(parts, s[:idx])
s = s[idx+5:] // len(" and ") == 5
lower = lower[idx+5:]
}
return parts
}
// parseSQLCondition parses a single SQL condition like "Role CONTAINS owner"
func parseSQLCondition(s string) (queryCondition, bool) {
lower := strings.ToLower(s)
// Try "NOT CONTAINS" first (must come before "CONTAINS" check)
if idx := strings.Index(lower, " not contains "); idx >= 0 {
col := strings.TrimSpace(s[:idx])
val := strings.TrimSpace(s[idx+14:]) // len(" not contains ") == 14
return queryCondition{
columnPattern: strings.ToLower(col),
value: strings.ToLower(val),
operator: opNotContains,
}, true
}
// Try "CONTAINS"
if idx := strings.Index(lower, " contains "); idx >= 0 {
col := strings.TrimSpace(s[:idx])
val := strings.TrimSpace(s[idx+10:]) // len(" contains ") == 10
return queryCondition{
columnPattern: strings.ToLower(col),
value: strings.ToLower(val),
operator: opContains,
}, true
}
// Try "!=" (must come before "=" check)
if idx := strings.Index(s, "!="); idx >= 0 {
col := strings.TrimSpace(s[:idx])
val := strings.TrimSpace(s[idx+2:])
return queryCondition{
columnPattern: strings.ToLower(col),
value: strings.ToLower(val),
operator: opNotEquals,
}, true
}
// Try "="
if idx := strings.Index(s, "="); idx >= 0 {
col := strings.TrimSpace(s[:idx])
val := strings.TrimSpace(s[idx+1:])
return queryCondition{
columnPattern: strings.ToLower(col),
value: strings.ToLower(val),
operator: opEquals,
}, true
}
return queryCondition{}, false
}
// resolveColumns returns indices of all headers that contain the pattern
// as a case-insensitive substring.
func resolveColumns(columnPattern string, headers []string) []int {
var indices []int
for i, h := range headers {
if strings.Contains(strings.ToLower(h), columnPattern) {
indices = append(indices, i)
}
}
return indices
}
// rowMatchesConditions checks if a row satisfies all query conditions.
// All conditions are ANDed. Column resolution uses fuzzy substring matching
// against header names.
func rowMatchesConditions(row []string, conditions []queryCondition, headers []string) bool {
for _, cond := range conditions {
colIndices := resolveColumns(cond.columnPattern, headers)
if len(colIndices) == 0 {
// No columns matched the pattern, condition fails
return false
}
switch cond.operator {
case opContains:
// At least one resolved column must contain the value
found := false
for _, idx := range colIndices {
if idx < len(row) {
colVal := strings.ToLower(removeColorCodes(row[idx]))
if strings.Contains(colVal, cond.value) {
found = true
break
}
}
}
if !found {
return false
}
case opEquals:
// At least one resolved column must exactly equal the value
found := false
for _, idx := range colIndices {
if idx < len(row) {
colVal := strings.ToLower(removeColorCodes(row[idx]))
if colVal == cond.value {
found = true
break
}
}
}
if !found {
return false
}
case opNotContains:
// None of the resolved columns may contain the value
for _, idx := range colIndices {
if idx < len(row) {
colVal := strings.ToLower(removeColorCodes(row[idx]))
if strings.Contains(colVal, cond.value) {
return false
}
}
}
case opNotEquals:
// None of the resolved columns may exactly equal the value
for _, idx := range colIndices {
if idx < len(row) {
colVal := strings.ToLower(removeColorCodes(row[idx]))
if colVal == cond.value {
return false
}
}
}
}
}
return true
}
// applyQueryFilter filters all table bodies in-place using the global query.
// Supports both legacy global substring matching and column-aware filtering.
// No-op when both globalQueryFilter and globalSQLQueryFilter are empty.
func applyQueryFilter(tables []TableFile) {
if globalQueryFilter == "" && globalSQLQueryFilter == "" {
return
}
var conditions []queryCondition
var useLegacy bool
if globalSQLQueryFilter != "" {
conditions = parseSQLQuery(globalSQLQueryFilter)
} else {
var isStructured bool
conditions, isStructured = parseKeyValueQuery(globalQueryFilter)
if !isStructured {
// Fall back to legacy global substring matching
useLegacy = true
}
}
for i := range tables {
var filtered [][]string
for _, row := range tables[i].Body {
if useLegacy {
if rowMatchesQuery(row, buildQueryPatterns(globalQueryFilter)) {
filtered = append(filtered, row)
}
} else {
if rowMatchesConditions(row, conditions, tables[i].Header) {
filtered = append(filtered, row)
}
}
}
tables[i].Body = filtered
}
}
func removeColorCodes(input string) string {
// Regular expression to match ANSI color codes
ansiRegExp := regexp.MustCompile(`\x1b\[[0-9;]*m`)
@@ -982,6 +1350,7 @@ func HandleOutputV2(
)
tables := dataToOutput.TableFiles()
applyQueryFilter(tables)
lootFiles := dataToOutput.LootFiles()
// Determine base module name from first table file (for backwards compatibility)
@@ -1031,6 +1400,14 @@ func HandleOutputSmart(
) error {
logger := NewLogger()
// Log query filter if active
if globalQueryFilter != "" {
logger.InfoM(fmt.Sprintf("Filtering output with query: %q", globalQueryFilter), "output")
}
if globalSQLQueryFilter != "" {
logger.InfoM(fmt.Sprintf("Filtering output with sqlquery: %q", globalSQLQueryFilter), "output")
}
// Count total rows across all table files
totalRows := 0
for _, tableFile := range dataToOutput.TableFiles() {
@@ -1276,6 +1653,7 @@ func HandleHierarchicalOutput(
// writeOutputToPath writes CloudfoxOutput data to a specific path
func writeOutputToPath(outPath string, format string, verbosity int, wrap bool, data CloudfoxOutput, logger Logger) error {
tables := data.TableFiles()
applyQueryFilter(tables)
lootFiles := data.LootFiles()
// Determine base module name from first table file (for logging)
@@ -1357,7 +1735,9 @@ func streamOutputToPath(outPath string, format string, verbosity int, wrap bool,
}
// Stream table files
for _, t := range data.TableFiles() {
streamTables := data.TableFiles()
applyQueryFilter(streamTables)
for _, t := range streamTables {
if verbosity > 0 {
tmpClient := TableClient{Wrap: wrap}
tmpClient.printTablesToScreen([]TableFile{t})
@@ -1466,6 +1846,14 @@ func HandleHierarchicalOutputSmart(
) error {
logger := NewLogger()
// Log query filter if active
if globalQueryFilter != "" {
logger.InfoM(fmt.Sprintf("Filtering output with query: %q", globalQueryFilter), "output")
}
if globalSQLQueryFilter != "" {
logger.InfoM(fmt.Sprintf("Filtering output with sqlquery: %q", globalSQLQueryFilter), "output")
}
// Count total rows across all data
totalRows := 0
for _, orgData := range outputData.OrgLevelData {
@@ -1611,6 +1999,9 @@ func HandleHierarchicalOutputTee(config TeeStreamingConfig) error {
// Track which projects received data (for loot file generation)
projectsWithData := make(map[string]bool)
// Apply query filter to all tables before processing
applyQueryFilter(config.Tables)
// Process each table
for _, t := range config.Tables {
if config.Verbosity > 0 {
+61
View File
@@ -0,0 +1,61 @@
package internal
import (
"context"
"fmt"
"os"
"os/signal"
"sync"
"time"
)
// signalOnce ensures we only register the SIGINT handler once per process,
// even if multiple cloud providers call SetupSignalHandler.
var signalOnce sync.Once
// signalCancel is the cancel function for the process-wide signal context.
var signalCancel context.CancelFunc
// signalCtx is the process-wide cancellable context.
var signalCtx context.Context
// SetupSignalHandler sets up a process-wide Ctrl+C handler with double-press
// confirmation to prevent accidental shutdowns of long-running commands.
//
// First Ctrl+C: prints a warning and starts a 5-second confirmation window.
// Second Ctrl+C within 5s: cancels the returned context (graceful shutdown).
// Third Ctrl+C: calls os.Exit(1) (force exit).
// If the 5s window expires without a second press, resets to initial state.
//
// Returns a context that is cancelled on confirmed graceful shutdown.
// Safe to call multiple times; only the first call registers the handler.
func SetupSignalHandler() context.Context {
signalOnce.Do(func() {
signalCtx, signalCancel = context.WithCancel(context.Background())
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt)
go func() {
for {
<-sigCh
fmt.Printf("\n[cloudfox] Press Ctrl+C again within 5 seconds to gracefully shut down.\n")
select {
case <-sigCh:
// Second Ctrl+C within window: graceful shutdown
fmt.Printf("[cloudfox] Shutting down gracefully, finishing in-flight requests and writing partial results...\n")
fmt.Printf("[cloudfox] Press Ctrl+C again to force exit.\n")
signalCancel()
<-sigCh
fmt.Printf("\n[cloudfox] Force exit.\n")
os.Exit(1)
case <-time.After(5 * time.Second):
// Window expired, reset
fmt.Printf("[cloudfox] Shutdown cancelled. Resuming...\n")
continue
}
}
}()
})
return signalCtx
}