mirror of
https://github.com/BishopFox/cloudfox.git
synced 2026-11-07 17:05:53 -10:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0801228b72 | ||
|
|
b47d4b0b45 | ||
|
|
5dbb1a9c32 | ||
|
|
d573ca6fb6 | ||
|
|
79aa3f48ef | ||
|
|
adc9a27232 | ||
|
|
3d58404721 | ||
|
|
531a2ed572 | ||
|
|
5d5c021c20 | ||
|
|
182e26c7bc | ||
|
|
1cf3d52855 | ||
|
|
dc040c3f94 | ||
|
|
454ca1f81e | ||
|
|
521422f5d6 | ||
|
|
45197cf89d | ||
|
|
b8bb8473e2 | ||
|
|
29c9a2e7a5 | ||
|
|
462dba9c10 | ||
|
|
2275e5872c | ||
|
|
9d5c7b79f2 | ||
|
|
5a04bef828 | ||
|
|
3887531d12 | ||
|
|
332a5df61c | ||
|
|
2c4a83017c | ||
|
|
bde29aa2d6 |
No files matched your search
@@ -15,10 +15,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
|
||||
- name: Go 1.25
|
||||
uses: actions/setup-go@v5
|
||||
- name: Go 1.21
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: ^1.25
|
||||
go-version: ^1.20
|
||||
id: go
|
||||
|
||||
- id: install-secret-key
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
gpg --list-secret-keys --keyid-format LONG
|
||||
|
||||
- name: Check Out Code
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Git Fetch Tags
|
||||
run: git fetch --prune --unshallow --tags -f
|
||||
@@ -39,6 +39,9 @@ jobs:
|
||||
|
||||
|
||||
- name: Release binaries
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: "marvinpinto/action-automatic-releases@latest"
|
||||
with:
|
||||
files: ./cloudfox/*
|
||||
repo_token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
prerelease: false
|
||||
files: |
|
||||
./cloudfox/*
|
||||
@@ -11,6 +11,6 @@ jobs:
|
||||
codespell:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- run: pip install --user codespell
|
||||
- run: codespell --ignore-words-list="aks,referers,invokable" --skip="*.sum"
|
||||
@@ -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).
|
||||
|
||||
+4
-6
@@ -128,13 +128,9 @@ func (m *InstancesModule) Instances(filter string, outputDirectory string, verbo
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
//time.Sleep(time.Second * 2)
|
||||
|
||||
// Signal the receiver to stop and wait for it to finish draining the channel.
|
||||
// This must happen BEFORE role analysis to ensure all MappedInstances are collected.
|
||||
receiverDone <- true
|
||||
<-receiverDone
|
||||
|
||||
// Perform role analysis (all instances are now in m.MappedInstances)
|
||||
// Perform role analysis
|
||||
if m.pmapperError == nil {
|
||||
for i := range m.MappedInstances {
|
||||
m.MappedInstances[i].Admin, m.MappedInstances[i].CanPrivEsc = GetPmapperResults(m.SkipAdminCheck, m.pmapperMod, &m.MappedInstances[i].Role)
|
||||
@@ -148,6 +144,8 @@ func (m *InstancesModule) Instances(filter string, outputDirectory string, verbo
|
||||
// Send a message to the spinner goroutine to close the channel and stop
|
||||
spinnerDone <- true
|
||||
<-spinnerDone
|
||||
receiverDone <- true
|
||||
<-receiverDone
|
||||
|
||||
// This conditional block will either dump the userData attribute content or the general instances data, depending on what you select via command line.
|
||||
//fmt.Printf("\n[*] Preparing output...\n\n")
|
||||
|
||||
+9
-8
@@ -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
|
||||
@@ -2252,7 +2255,6 @@ func runAllChecksCommand(cmd *cobra.Command, args []string) {
|
||||
WrapTable: AWSWrapTable,
|
||||
AWSOutputType: AWSOutputType,
|
||||
AWSTableCols: AWSTableCols,
|
||||
PmapperDataBasePath: PmapperDataBasePath,
|
||||
}
|
||||
ec2UserData.Instances(InstancesFilter, AWSOutputDirectory, Verbosity)
|
||||
envsMod := aws.EnvsModule{
|
||||
@@ -2435,13 +2437,12 @@ func runAllChecksCommand(cmd *cobra.Command, args []string) {
|
||||
roleTrusts.PrintRoleTrusts(AWSOutputDirectory, Verbosity)
|
||||
|
||||
pmapperCommand := aws.PmapperModule{
|
||||
Caller: *caller,
|
||||
AWSProfile: profile,
|
||||
Goroutines: Goroutines,
|
||||
WrapTable: AWSWrapTable,
|
||||
AWSOutputType: AWSOutputType,
|
||||
AWSTableCols: AWSTableCols,
|
||||
PmapperDataBasePath: PmapperDataBasePath,
|
||||
Caller: *caller,
|
||||
AWSProfile: profile,
|
||||
Goroutines: Goroutines,
|
||||
WrapTable: AWSWrapTable,
|
||||
AWSOutputType: AWSOutputType,
|
||||
AWSTableCols: AWSTableCols,
|
||||
}
|
||||
pmapperCommand.PrintPmapperData(AWSOutputDirectory, Verbosity)
|
||||
|
||||
|
||||
+253
-25
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, "-", "-", "-",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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:") {
|
||||
|
||||
@@ -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,
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,7 +15,7 @@ type HMACService struct {
|
||||
}
|
||||
|
||||
func New() *HMACService {
|
||||
return &HMACService{}
|
||||
return &HMACService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *HMACService {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -16,7 +16,7 @@ type IAPService struct {
|
||||
}
|
||||
|
||||
func New() *IAPService {
|
||||
return &IAPService{}
|
||||
return &IAPService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *IAPService {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,7 +15,7 @@ type VPCService struct {
|
||||
}
|
||||
|
||||
func New() *VPCService {
|
||||
return &VPCService{}
|
||||
return &VPCService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *VPCService {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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.4"
|
||||
const CLOUDFOX_VERSION = "2.0.3"
|
||||
@@ -108,6 +108,7 @@ 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
|
||||
@@ -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
|
||||
@@ -138,6 +141,9 @@ require (
|
||||
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 (
|
||||
|
||||
@@ -296,6 +296,8 @@ 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=
|
||||
@@ -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=
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user