Compare commits

..
25 Commits
Author SHA1 Message Date
jbarciabf 0801228b72 minor fixes 2026-05-19 11:08:57 -04:00
jbarciabf b47d4b0b45 Merge branch 'main' into jb/gcp-dev 2026-04-20 10:31:41 -04:00
jbarciabf 5dbb1a9c32 CVE-2026-33186 fix 2026-03-24 11:29:57 -04:00
jbarciabf d573ca6fb6 added OS Login Policy check at org level 2026-03-10 09:44:51 -04:00
jbarciabf 79aa3f48ef Merge remote-tracking branch 'upstream/main' into jb/gcp-dev 2026-03-09 11:51:26 -04:00
jbarciabf adc9a27232 added role enumeration for resource level bindings 2026-03-06 11:24:02 -05:00
jbarciabf 3d58404721 bug fix for when _withcond_<hash> role names when policy version 3 wasn't requested 2026-03-05 22:51:52 -05:00
jbarciabf 531a2ed572 added full wif path 2026-03-04 21:26:21 -05:00
jbarciabf 5d5c021c20 fixed token and access key identity enumeration 2026-03-04 15:03:24 -05:00
jbarciabf 182e26c7bc updated readme 2026-03-04 13:26:29 -05:00
jbarciabf 1cf3d52855 improved queries 2026-03-04 12:26:27 -05:00
jbarciabf dc040c3f94 added project bindings so user can run a single module when targeting a resource instead of multiple modules 2026-03-04 12:05:16 -05:00
jbarciabf 454ca1f81e Merge remote-tracking branch 'upstream/main' into jb/gcp-dev 2026-03-04 10:00:04 -05:00
jbarciabf 521422f5d6 fixed 'admin' check for custom roles 2026-03-04 09:52:42 -05:00
jbarciabf 45197cf89d added back known admin roles 2026-03-04 09:08:09 -05:00
jbarciabf b8bb8473e2 fixed quota detection for impersonation 2026-03-04 08:55:18 -05:00
jbarciabf 29c9a2e7a5 added key auth 2026-03-03 21:48:16 -05:00
jbarciabf 462dba9c10 fixed output misses in serviceaccounts 2026-03-03 21:36:48 -05:00
jbarciabf 2275e5872c fixed gap in resource bindings not populating 2026-03-03 21:12:58 -05:00
jbarciabf 9d5c7b79f2 added --query to filter on keywords 2026-03-03 14:48:44 -05:00
jbarciabf 5a04bef828 centralized session management and implemented service account impersonation (gcloud --impersonate-service-account) 2026-03-03 10:05:29 -05:00
jbarciabf 3887531d12 added quota project detection and override and fixed silent "not found" errors 2026-03-02 13:43:35 -05:00
jbarciabf 332a5df61c added @bishopfaure access-tokens logic and output 2026-03-02 10:46:54 -05:00
jbarciabf 2c4a83017c added @bishopfaure access-tokens logic and output 2026-03-02 10:37:28 -05:00
jbarciabf bde29aa2d6 added @bishopfaure access-tokens logic and output 2026-03-02 10:36:21 -05:00
167 changed files with 4020 additions and 95065 deletions

No files matched your search

-2
View File
@@ -68,8 +68,6 @@ output/*
*cloudfox-output*
cloudfox-*
cloudfox
cloudfox-macos
cloudfox-linux
*.log
*.bak
*.txt
+46 -40
View File
@@ -55,7 +55,7 @@ For the full documentation please refer to our [wiki](https://github.com/BishopF
| AWS | 34 |
| Azure | 4 |
| GCP | 60 |
| Kubernetes | 23 |
| Kubernetes | Support Planned |
# Install
@@ -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
@@ -154,9 +154,6 @@ For thorough security assessments across an entire organization:
For detailed setup instructions, see the [GCP Setup Guide](https://github.com/BishopFox/cloudfox/wiki/GCP-Setup-Guide).
### Kubernetes
* `Get` and `List` on all (`*`) resources
# AWS Commands
| Provider | Command Name | Description
| - | - | - |
@@ -207,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).
@@ -279,41 +320,6 @@ For detailed documentation on each GCP command, see the [GCP Commands Wiki](http
| GCP | [spanner-enum](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#spanner-enum) | Scan Spanner database schemas for sensitive table and column names |
# Kubernetes Commands
`cloudfox kubernetes --help`
or
`cloudfox k8s --help`
| Provider | Command Name | Description
| - | - | - |
| k8s | [whoami](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#whoami) | Display the identity the kubeconfig, ServiceAccount token, or in-cluster credentials are authenticated as |
| k8s | [all-checks](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#all-checks) | Executes all available Kubernetes commands to collect and display information from the cluster |
| k8s | [configmaps](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#configmaps) | List all cluster ConfigMaps and detailed information |
| k8s | [cronjobs](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#cronjobs) | List all cluster CronJobs |
| k8s | [daemonsets](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#daemonsets) | List all cluster DaemonSets |
| k8s | [deployments](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#deployments) | List all cluster Deployments |
| k8s | [endpoints](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#endpoints) | List all cluster Endpoints |
| k8s | [hidden-admins](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#hidden-admins) | Enumerate roles, users, groups, and service accounts with dangerous or escalatory permissions |
| k8s | [jobs](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#jobs) | List all cluster Jobs |
| k8s | [namespaces](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#namespaces) | List all cluster Namespaces
| k8s | [network-exposure](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#network-exposure) | Enumerate exposed network hosts and ports
| k8s | [network-policy](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#network-policy) | List all cluster Network Policies
| k8s | [nodes](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#nodes) | List all cluster Nodes
| k8s | [permissions](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#permissions) | List all cluster entities and their respective permissions
| k8s | [pod-security](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#pod-security) | List all cluster Pod Security Policies or Admission settings
| k8s | [pods](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#pods) | List all cluster Pods
| k8s | [replicasets](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#replicasets) | List all cluster ReplicaSets
| k8s | [secrets](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#secrets) | List all cluster Secrets
| k8s | [services](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#services) | List all cluster Services
| k8s | [statefulsets](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#statefulsets) | List all cluster StatefulSets
| k8s | [taints-tolerations](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#taints-tolerations) | Displays for each pod which node taints it tolerates and lists taints on nodes that are not tolerated by any pods
| k8s | [taints](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#taints) | List all cluster node taints and outputs an example YAML file with node and tolerations to create a pod
| k8s | [tolerations](https://github.com/BishopFox/cloudfox/wiki/k8s-Commands#tolerations) | List all cluster pod tolerations and outputs an example YAML file with tolerations to create a pod
# Authors
* [Carlos Vendramini](https://github.com/carlosvendramini-bf)
+3
View File
@@ -544,6 +544,9 @@ type OrgAccounts struct {
}
func awsPreRun(cmd *cobra.Command, args []string) {
// Set up Ctrl+C handler (double-press to confirm shutdown)
internal.SetupSignalHandler()
gob.Register(&types.Organization{})
// if multiple profiles were used, ensure the management account is first
+253 -25
View File
@@ -2,7 +2,9 @@ package cli
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"time"
@@ -12,6 +14,8 @@ import (
"github.com/BishopFox/cloudfox/internal"
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
"github.com/spf13/cobra"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
var (
@@ -22,6 +26,15 @@ var (
GCPProjectIDs []string
GCPAllProjects bool
// Service account impersonation
GCPImpersonateSA string
// Service account key file authentication
GCPKeyFile string
// Raw access token authentication
GCPAccessToken string
// Project name mapping (ProjectID -> DisplayName)
GCPProjectNames map[string]string
@@ -31,6 +44,8 @@ var (
GCPVerbosity int
GCPWrapTable bool
GCPFlatOutput bool
GCPQuery string
GCPSQLQuery string
// Refresh cache flag - force re-enumeration even if cache exists
GCPRefreshCache bool
@@ -52,22 +67,111 @@ var (
GCPProjectIDs = nil
GCPProjectNames = make(map[string]string)
// Create session based on auth flags.
// Allowed combinations:
// --access-token (raw token)
// --key-file (SA key file)
// --impersonate-sa (impersonate over ADC)
// --access-token + --impersonate-sa (impersonate using token as base)
// --key-file + --impersonate-sa (impersonate using key as base)
// Disallowed: --access-token + --key-file (both are base credentials)
var gcpSession *gcpinternal.SafeSession
if GCPAccessToken != "" && GCPKeyFile != "" {
GCPLogger.FatalM("--access-token and --key-file cannot be used together (both are base credentials)", "gcp")
}
// Step 1: Create base session from access-token or key-file (if provided)
var baseSession *gcpinternal.SafeSession
if GCPAccessToken != "" {
os.Unsetenv("GOOGLE_CLOUD_QUOTA_PROJECT")
GCPLogger.InfoM("Authenticating with provided access token...", "gcp")
var err error
baseSession, err = gcpinternal.NewSafeSessionFromAccessToken(context.Background(), GCPAccessToken)
if err != nil {
GCPLogger.FatalM(fmt.Sprintf("Failed to authenticate with access token: %v", err), "gcp")
}
// If tokeninfo/userinfo endpoints didn't return an email (common with
// SA tokens that only have the cloud-platform scope), try a project-scoped
// probe using the CRM API to extract the caller from the error response.
if baseSession.GetEmail() == "" && GCPProjectID != "" {
gcpinternal.ProbeIdentityWithProject(context.Background(), baseSession, GCPProjectID)
}
email := baseSession.GetEmail()
if email != "" {
GCPLogger.SuccessM(fmt.Sprintf("Authenticated as %s (from access token)", email), "gcp")
} else {
GCPLogger.WarnM("Authenticated with access token (could not resolve identity)", "gcp")
}
} else if GCPKeyFile != "" {
os.Unsetenv("GOOGLE_CLOUD_QUOTA_PROJECT")
GCPLogger.InfoM(fmt.Sprintf("Authenticating with key file: %s", GCPKeyFile), "gcp")
var err error
baseSession, err = gcpinternal.NewSafeSessionFromKeyFile(context.Background(), GCPKeyFile)
if err != nil {
GCPLogger.FatalM(fmt.Sprintf("Failed to authenticate with key file %s: %v", GCPKeyFile, err), "gcp")
}
GCPLogger.SuccessM(fmt.Sprintf("Authenticated as %s (from key file)", baseSession.GetEmail()), "gcp")
}
// Step 2: Layer impersonation on top (if requested)
if GCPImpersonateSA != "" {
os.Unsetenv("GOOGLE_CLOUD_QUOTA_PROJECT")
var baseTS oauth2.TokenSource
if baseSession != nil {
baseTS = baseSession.GetTokenSource()
GCPLogger.InfoM(fmt.Sprintf("Impersonating %s using %s as base credential...", GCPImpersonateSA, baseSession.GetEmail()), "gcp")
} else {
GCPLogger.InfoM(fmt.Sprintf("Impersonating service account: %s", GCPImpersonateSA), "gcp")
}
var err error
gcpSession, err = gcpinternal.NewSafeSessionWithImpersonation(context.Background(), GCPImpersonateSA, baseTS)
if err != nil {
GCPLogger.FatalM(fmt.Sprintf("Failed to impersonate %s: %v", GCPImpersonateSA, err), "gcp")
}
GCPLogger.SuccessM(fmt.Sprintf("Successfully impersonating %s", GCPImpersonateSA), "gcp")
} else if baseSession != nil {
// No impersonation, use the base session directly
gcpSession = baseSession
} else {
// Set quota project for GCP API calls. ADC may reference a deleted
// or inaccessible quota project, causing all API calls to fail with
// 403/404. Override it with the target project when known.
if GCPProjectID != "" {
os.Setenv("GOOGLE_CLOUD_QUOTA_PROJECT", GCPProjectID)
} else if GCPProjectIDsFilePath != "" {
lines := internal.LoadFileLinesIntoArray(GCPProjectIDsFilePath)
if len(lines) > 0 {
os.Setenv("GOOGLE_CLOUD_QUOTA_PROJECT", lines[0])
}
} else if GCPAllProjects {
// No target project yet. Validate the ADC quota project
// and clear it if the project is inaccessible.
validateADCQuotaProject()
}
}
// Handle project discovery based on flags
// Priority: -p (single project) > -l (project list) > -A (all projects)
if GCPProjectID != "" {
// Single project specified with -p/--project
GCPProjectIDs = append(GCPProjectIDs, GCPProjectID)
resolveProjectNames(GCPProjectIDs)
resolveProjectNames(GCPProjectIDs, gcpSession)
} else if GCPProjectIDsFilePath != "" {
// Project list specified with -l/--project-list
rawProjectIDs := internal.LoadFileLinesIntoArray(GCPProjectIDsFilePath)
GCPProjectIDs = deduplicateProjectIDs(rawProjectIDs)
resolveProjectNames(GCPProjectIDs)
resolveProjectNames(GCPProjectIDs, gcpSession)
} else if GCPAllProjects {
// Discover all accessible projects with -A/--all-projects
GCPLogger.InfoM("Discovering all accessible projects...", "gcp")
orgsSvc := orgsservice.New()
projects, err := orgsSvc.SearchProjects("")
orgsSvc := newOrgsService(gcpSession)
var projects []orgsservice.ProjectInfo
err := gcpinternal.RetryWithBackoff(GCPLogger, "gcp", 3, func() error {
var searchErr error
projects, searchErr = orgsSvc.SearchProjects("")
return searchErr
})
if err != nil {
GCPLogger.FatalM(fmt.Sprintf("Failed to discover projects: %v. Try using -p or -l flags instead.", err), "gcp")
}
@@ -85,22 +189,53 @@ var (
GCPLogger.InfoM("No project scope specified. Use -p, -l, or -A flag.", "gcp")
}
// Create a context with project IDs and names
ctx := context.WithValue(context.Background(), "projectIDs", GCPProjectIDs)
// Create a context with project IDs and names, using signal-aware base context
sigCtx := internal.SetupSignalHandler()
ctx := context.WithValue(sigCtx, "projectIDs", GCPProjectIDs)
ctx = context.WithValue(ctx, "projectNames", GCPProjectNames)
// Authenticate and get account info
os := oauthservice.NewOAuthService()
principal, err := os.WhoAmI()
if err != nil {
GCPLogger.FatalM(fmt.Sprintf("could not determine default user credential with error %s.\n\nPlease use default application default credentials: https://cloud.google.com/docs/authentication/application-default-credentials\n\nTry: gcloud auth application-default login", err.Error()), "gcp")
// Set default session so all services pick up impersonation
if gcpSession != nil {
gcpinternal.SetDefaultSession(gcpSession)
}
// Set global query filter if --query or --sqlquery flag is provided
if GCPQuery != "" && GCPSQLQuery != "" {
GCPLogger.FatalM("--query and --sqlquery cannot be used together", "gcp")
}
if GCPQuery != "" {
internal.SetQueryFilter(GCPQuery)
}
if GCPSQLQuery != "" {
internal.SetSQLQueryFilter(GCPSQLQuery)
}
// Authenticate and get account info.
// Check impersonation first since it overrides the effective identity
// even when combined with --access-token or --key-file.
if GCPImpersonateSA != "" {
ctx = context.WithValue(ctx, "account", GCPImpersonateSA)
} else if GCPAccessToken != "" {
email := gcpSession.GetEmail()
if email == "" {
email = "access-token-user"
}
ctx = context.WithValue(ctx, "account", email)
} else if GCPKeyFile != "" {
ctx = context.WithValue(ctx, "account", gcpSession.GetEmail())
} else {
os := oauthservice.NewOAuthService()
principal, err := os.WhoAmI()
if err != nil {
GCPLogger.FatalM(fmt.Sprintf("could not determine default user credential with error %s.\n\nPlease use default application default credentials: https://cloud.google.com/docs/authentication/application-default-credentials\n\nTry: gcloud auth application-default login", err.Error()), "gcp")
}
ctx = context.WithValue(ctx, "account", principal.Email)
}
ctx = context.WithValue(ctx, "account", principal.Email)
// Build scope hierarchy for hierarchical output (unless --flat-output is set)
if !GCPFlatOutput && len(GCPProjectIDs) > 0 {
GCPLogger.InfoM("Building scope hierarchy for hierarchical output...", "gcp")
orgsSvc := orgsservice.New()
orgsSvc := newOrgsService(gcpSession)
provider := orgsservice.NewHierarchyProvider(orgsSvc)
hierarchy, err := gcpinternal.BuildScopeHierarchy(GCPProjectIDs, provider)
if err != nil {
@@ -153,7 +288,7 @@ var (
// Cache auto-refreshes after 24 hours
// Force refresh when running the organizations command to ensure fresh data
refreshCache := GCPRefreshCache || cmd.Name() == "organizations"
orgCache := loadOrPopulateOrgCache(account, refreshCache)
orgCache := loadOrPopulateOrgCache(account, refreshCache, gcpSession)
if orgCache != nil && orgCache.IsPopulated() {
ctx = gcpinternal.SetOrgCacheInContext(ctx, orgCache)
}
@@ -163,6 +298,14 @@ var (
}
)
// newOrgsService creates an organizations service, using the impersonated session if provided
func newOrgsService(session *gcpinternal.SafeSession) *orgsservice.OrganizationsService {
if session != nil {
return orgsservice.NewWithSession(session)
}
return orgsservice.New()
}
// deduplicateProjectIDs removes duplicates, trims whitespace, and filters empty entries
func deduplicateProjectIDs(projectIDs []string) []string {
seen := make(map[string]bool)
@@ -195,15 +338,72 @@ func deduplicateProjectIDs(projectIDs []string) []string {
return result
}
// validateADCQuotaProject reads the ADC file and checks whether the configured
// quota project is accessible. The Go SDK sends x-goog-user-project on every
// request based on the ADC's quota_project_id. If that project is deleted or
// inaccessible, ALL API calls will fail with 403/404. This function detects
// that condition and clears the quota project so the SDK doesn't send the
// broken header. The caller is responsible for setting a valid quota project
// once a working project is discovered.
func validateADCQuotaProject() {
creds, err := google.FindDefaultCredentials(context.Background())
if err != nil || creds.JSON == nil {
return
}
var adcFile struct {
QuotaProject string `json:"quota_project_id"`
}
if err := json.Unmarshal(creds.JSON, &adcFile); err != nil || adcFile.QuotaProject == "" {
return
}
// Proactively clear the quota project via env var override. If the ADC
// quota project is valid, the SearchProjects call below will succeed and
// we'll restore it. If it's broken, we've already fixed the problem.
os.Setenv("GOOGLE_CLOUD_QUOTA_PROJECT", "")
orgsSvc := orgsservice.New()
projects, err := orgsSvc.SearchProjects("")
if err != nil {
// Can't validate. Leave it cleared (better than a known-broken value).
GCPLogger.WarnM(fmt.Sprintf("Could not validate ADC quota project '%s': %v", adcFile.QuotaProject, err), "gcp")
return
}
// Check if the original quota project is among accessible active projects
for _, p := range projects {
if p.ProjectID == adcFile.QuotaProject && p.State == "ACTIVE" {
// Original quota project is valid, restore it
os.Setenv("GOOGLE_CLOUD_QUOTA_PROJECT", adcFile.QuotaProject)
return
}
}
// Original quota project is not accessible. Pick the first active project.
GCPLogger.WarnM(fmt.Sprintf("ADC quota project '%s' is not accessible or has been deleted. Overriding with first available project.", adcFile.QuotaProject), "gcp")
for _, p := range projects {
if p.State == "ACTIVE" {
os.Setenv("GOOGLE_CLOUD_QUOTA_PROJECT", p.ProjectID)
return
}
}
}
// resolveProjectNames fetches display names for given project IDs
func resolveProjectNames(projectIDs []string) {
func resolveProjectNames(projectIDs []string, session *gcpinternal.SafeSession) {
if len(projectIDs) == 0 {
return
}
orgsSvc := orgsservice.New()
orgsSvc := newOrgsService(session)
// Fetch all accessible projects and build lookup map
projects, err := orgsSvc.SearchProjects("")
var projects []orgsservice.ProjectInfo
err := gcpinternal.RetryWithBackoff(GCPLogger, "gcp", 3, func() error {
var searchErr error
projects, searchErr = orgsSvc.SearchProjects("")
return searchErr
})
if err != nil {
// Non-fatal: we can continue without display names
GCPLogger.InfoM("Could not resolve project names, using project IDs only", "gcp")
@@ -245,11 +445,17 @@ var GCPAllChecksCommand = &cobra.Command{
ctx = gcpinternal.SetAllChecksMode(ctx, true)
cmd.SetContext(ctx)
// Get session from context for impersonation support
var allChecksSession *gcpinternal.SafeSession
if s, ok := ctx.Value("session").(*gcpinternal.SafeSession); ok {
allChecksSession = s
}
// Load or populate org cache for cross-project modules
existingOrgCache := gcpinternal.GetOrgCacheFromContext(ctx)
if existingOrgCache == nil || !existingOrgCache.IsPopulated() {
GCPLogger.InfoM("Loading/enumerating organization data for cross-project analysis...", "all-checks")
orgCache := loadOrPopulateOrgCache(account, GCPRefreshCache)
orgCache := loadOrPopulateOrgCache(account, GCPRefreshCache, allChecksSession)
if orgCache != nil && orgCache.IsPopulated() {
ctx = gcpinternal.SetOrgCacheInContext(ctx, orgCache)
cmd.SetContext(ctx)
@@ -310,6 +516,7 @@ var GCPAllChecksCommand = &cobra.Command{
"bigquery-enum": true,
"bigtable-enum": true,
"spanner-enum": true,
"access-tokens": true, // Local-only, no API calls
}
// Count total modules to execute (excluding self, hidden, and excluded modules)
@@ -344,7 +551,7 @@ var GCPAllChecksCommand = &cobra.Command{
}
// loadOrPopulateOrgCache loads org cache from disk if available, or enumerates and saves it
func loadOrPopulateOrgCache(account string, forceRefresh bool) *gcpinternal.OrgCache {
func loadOrPopulateOrgCache(account string, forceRefresh bool, session *gcpinternal.SafeSession) *gcpinternal.OrgCache {
// Check if cache exists and we're not forcing refresh
if !forceRefresh && gcpinternal.OrgCacheExists(GCPOutputDirectory, account) {
// Check if cache is stale (older than 24 hours)
@@ -368,18 +575,23 @@ func loadOrPopulateOrgCache(account string, forceRefresh bool) *gcpinternal.OrgC
}
// Enumerate and create cache
cache := enumerateAndCacheOrgs(account)
cache := enumerateAndCacheOrgs(account, session)
return cache
}
// enumerateAndCacheOrgs enumerates all orgs/folders/projects and saves to disk
func enumerateAndCacheOrgs(account string) *gcpinternal.OrgCache {
func enumerateAndCacheOrgs(account string, session *gcpinternal.SafeSession) *gcpinternal.OrgCache {
cache := gcpinternal.NewOrgCache()
orgsSvc := orgsservice.New()
orgsSvc := newOrgsService(session)
// Get all organizations
orgs, err := orgsSvc.SearchOrganizations()
var orgs []orgsservice.OrganizationInfo
err := gcpinternal.RetryWithBackoff(GCPLogger, "gcp", 3, func() error {
var searchErr error
orgs, searchErr = orgsSvc.SearchOrganizations()
return searchErr
})
if err == nil {
for _, org := range orgs {
cache.AddOrganization(gcpinternal.CachedOrganization{
@@ -393,7 +605,12 @@ func enumerateAndCacheOrgs(account string) *gcpinternal.OrgCache {
}
// Get all folders
folders, err := orgsSvc.SearchAllFolders()
var folders []orgsservice.FolderInfo
err = gcpinternal.RetryWithBackoff(GCPLogger, "gcp", 3, func() error {
var searchErr error
folders, searchErr = orgsSvc.SearchAllFolders()
return searchErr
})
if err == nil {
for _, folder := range folders {
cache.AddFolder(gcpinternal.CachedFolder{
@@ -407,7 +624,12 @@ func enumerateAndCacheOrgs(account string) *gcpinternal.OrgCache {
}
// Get all projects
projects, err := orgsSvc.SearchProjects("")
var projects []orgsservice.ProjectInfo
err = gcpinternal.RetryWithBackoff(GCPLogger, "gcp", 3, func() error {
var searchErr error
projects, searchErr = orgsSvc.SearchProjects("")
return searchErr
})
if err == nil {
for _, project := range projects {
// Extract project number from Name (format: "projects/123456789")
@@ -504,6 +726,11 @@ func init() {
GCPCommands.PersistentFlags().BoolVarP(&GCPWrapTable, "wrap", "w", false, "Wrap table to fit in terminal (complicates grepping)")
GCPCommands.PersistentFlags().BoolVar(&GCPFlatOutput, "flat-output", false, "Use legacy flat output structure instead of hierarchical per-project directories")
GCPCommands.PersistentFlags().BoolVar(&GCPRefreshCache, "refresh-cache", false, "Force re-enumeration of cached data (cache auto-expires after 24 hours)")
GCPCommands.PersistentFlags().StringVarP(&GCPImpersonateSA, "impersonate-sa", "i", "", "Service account email to impersonate (requires roles/iam.serviceAccountTokenCreator)")
GCPCommands.PersistentFlags().StringVarP(&GCPKeyFile, "key-file", "k", "", "Path to a service account JSON key file for authentication")
GCPCommands.PersistentFlags().StringVar(&GCPAccessToken, "access-token", "", "Raw GCP access token for authentication (non-refreshable)")
GCPCommands.PersistentFlags().StringVarP(&GCPQuery, "query", "q", "", "Filter output rows by substring match (e.g. \"Role:owner,Principal:privesc02\" or plain \"privesc02\")")
GCPCommands.PersistentFlags().StringVar(&GCPSQLQuery, "sqlquery", "", "Filter output with SQL-like syntax (e.g. \"Role CONTAINS owner AND Principal CONTAINS privesc02\")")
// Available commands
GCPCommands.AddCommand(
@@ -572,6 +799,7 @@ func init() {
commands.GCPAccessLevelsCommand,
// Pentest/Exploitation commands
commands.GCPAccessTokensCommand,
commands.GCPPrivescCommand,
commands.GCPOrgPoliciesCommand,
commands.GCPStorageEnumCommand,
-365
View File
@@ -1,365 +0,0 @@
package cli
import (
"fmt"
"strings"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
k8sinternal "github.com/BishopFox/cloudfox/internal/kubernetes"
"github.com/BishopFox/cloudfox/kubernetes/commands"
"github.com/BishopFox/cloudfox/kubernetes/config"
"github.com/BishopFox/cloudfox/kubernetes/sdk"
"github.com/BishopFox/cloudfox/kubernetes/shared"
"github.com/spf13/cobra"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
var (
// K8s auth options
K8sToken string
K8sContext string
K8sKubeConfig string
K8sAPIServer string
K8sClusterName string
// Namespace filtering options
K8sNamespaceFlag string // Comma-delimited list of namespaces
K8sNamespaceListFile string
K8sAllNamespaces bool
// Output formatting options
K8sOutputFormat string
K8sOutputDirectory string
K8sVerbosity int
K8sWrapTable bool
K8sTimeout int // API timeout in seconds
// K8sMergedTable bool
// Cloud provider configuration (for network-exposure correlation)
K8sCloudProvider string // Comma-separated list: aws,gcp,azure
K8sAWSProfile string
K8sAzureSubscription string
K8sGCPProject string
// Admission controller filtering for lazy CRD discovery
K8sAdmissionControllers string // Comma-separated list or "all"
// Detailed output flag
K8sDetailed bool
// logger
logger = internal.NewLogger()
K8sCommands = &cobra.Command{
Use: "kubernetes",
Aliases: []string{"k8s"},
Long: `See "Available Commands" for Kubernetes Modules below`,
Short: "See \"Available Commands\" for Kubernetes Modules below",
PersistentPreRun: func(cmd *cobra.Command, args []string) {
// Only init config if the command isn't just asking for help
if !cmd.HasSubCommands() || cmd.CalledAs() == "all-checks" {
config.InitConfig(K8sKubeConfig, K8sContext, K8sToken, K8sAPIServer)
clientset := config.GetClientOrExit()
// If user passed --cluster-name, use that. Otherwise, detect it.
if K8sClusterName != "" {
globals.ClusterName = K8sClusterName
} else {
// Suppress stderr during cluster name detection to hide noisy auth plugin output
restoreStderr := sdk.SuppressStderr()
globals.ClusterName = k8sinternal.GetClusterName(clientset)
restoreStderr()
}
// Handle namespace filtering
ctx, cancel := shared.ContextWithTimeout()
defer cancel()
if K8sNamespaceFlag != "" {
// Comma-delimited namespace list specified
rawNamespaces := strings.Split(K8sNamespaceFlag, ",")
for _, ns := range rawNamespaces {
ns = strings.TrimSpace(ns)
if ns != "" {
globals.K8sNamespaces = append(globals.K8sNamespaces, ns)
}
}
if len(globals.K8sNamespaces) == 1 {
globals.K8sNamespace = globals.K8sNamespaces[0]
}
globals.K8sAllNamespaces = false
logger.InfoM(fmt.Sprintf("Targeting %d namespace(s): %s", len(globals.K8sNamespaces), strings.Join(globals.K8sNamespaces, ", ")), "kubernetes")
} else if K8sNamespaceListFile != "" {
// Load namespaces from file
rawNamespaces := internal.LoadFileLinesIntoArray(K8sNamespaceListFile)
globals.K8sNamespaces = deduplicateNamespaces(rawNamespaces)
globals.K8sAllNamespaces = false
logger.InfoM(fmt.Sprintf("Targeting %d namespace(s) from file", len(globals.K8sNamespaces)), "kubernetes")
} else if K8sAllNamespaces {
// Discover all namespaces
namespaces, err := clientset.CoreV1().Namespaces().List(ctx, metav1.ListOptions{})
if err != nil {
logger.ErrorM(fmt.Sprintf("Failed to list namespaces: %v", err), "kubernetes")
} else {
for _, ns := range namespaces.Items {
globals.K8sNamespaces = append(globals.K8sNamespaces, ns.Name)
}
}
globals.K8sAllNamespaces = true
logger.InfoM(fmt.Sprintf("Targeting all %d namespace(s)", len(globals.K8sNamespaces)), "kubernetes")
} else {
// Default: all namespaces
namespaces, err := clientset.CoreV1().Namespaces().List(ctx, metav1.ListOptions{})
if err != nil {
logger.ErrorM(fmt.Sprintf("Failed to list namespaces: %v", err), "kubernetes")
} else {
for _, ns := range namespaces.Items {
globals.K8sNamespaces = append(globals.K8sNamespaces, ns.Name)
}
}
globals.K8sAllNamespaces = true
}
// Store cloud provider configuration in globals for network-exposure command
// Parse --cloud-provider flag (comma-separated: aws,gcp,azure)
if K8sCloudProvider != "" {
providers := strings.Split(K8sCloudProvider, ",")
for _, p := range providers {
p = strings.TrimSpace(strings.ToLower(p))
if p == "aws" || p == "gcp" || p == "azure" || p == "az" {
// Normalize "az" to "azure"
if p == "az" {
p = "azure"
}
globals.K8sCloudProviders = append(globals.K8sCloudProviders, p)
}
}
}
globals.K8sAWSProfile = K8sAWSProfile
// Parse --azure-subscription (comma-separated)
if K8sAzureSubscription != "" {
for _, sub := range strings.Split(K8sAzureSubscription, ",") {
sub = strings.TrimSpace(sub)
if sub != "" {
globals.K8sAzureSubscriptions = append(globals.K8sAzureSubscriptions, sub)
}
}
}
// Parse --gcp-project (comma-separated)
if K8sGCPProject != "" {
for _, proj := range strings.Split(K8sGCPProject, ",") {
proj = strings.TrimSpace(proj)
if proj != "" {
globals.K8sGCPProjects = append(globals.K8sGCPProjects, proj)
}
}
}
// Set timeout
globals.K8sTimeout = K8sTimeout
// Set detailed output flag
globals.K8sDetailed = K8sDetailed
// Parse --admission-controllers flag
if K8sAdmissionControllers != "" {
if strings.ToLower(K8sAdmissionControllers) == "all" {
// "all" means check everything (empty slice = no filter)
globals.K8sAdmissionControllers = nil
} else {
for _, ctrl := range strings.Split(K8sAdmissionControllers, ",") {
ctrl = strings.TrimSpace(strings.ToLower(ctrl))
if ctrl != "" {
globals.K8sAdmissionControllers = append(globals.K8sAdmissionControllers, ctrl)
}
}
if len(globals.K8sAdmissionControllers) > 0 {
logger.InfoM(fmt.Sprintf("Filtering admission checks to: %s", strings.Join(globals.K8sAdmissionControllers, ", ")), "kubernetes")
}
}
}
}
},
Run: func(cmd *cobra.Command, args []string) {
cmd.Help()
},
}
)
// deduplicateNamespaces removes duplicates, trims whitespace, and filters empty entries
func deduplicateNamespaces(namespaces []string) []string {
seen := make(map[string]bool)
var result []string
duplicateCount := 0
for _, ns := range namespaces {
// Trim whitespace
ns = strings.TrimSpace(ns)
// Skip empty lines
if ns == "" {
continue
}
// Skip duplicates
if seen[ns] {
duplicateCount++
continue
}
seen[ns] = true
result = append(result, ns)
}
if duplicateCount > 0 {
logger.InfoM(fmt.Sprintf("Removed %d duplicate namespace(s) from list", duplicateCount), "kubernetes")
}
return result
}
var K8sAllChecksCommand = &cobra.Command{
Use: "all-checks",
Short: "Runs all available Kubernetes commands",
Long: `Executes all available Kubernetes commands to collect and display information from the cluster.`,
Run: func(cmd *cobra.Command, args []string) {
// Warm the cache before running all modules to avoid redundant API calls
ctx, cancel := shared.ContextWithTimeout()
defer cancel()
clientset := config.GetClientOrExit()
logger.InfoM("Pre-fetching Kubernetes resources to cache...", "all-checks")
if err := sdk.WarmCache(ctx, clientset); err != nil {
logger.ErrorM(fmt.Sprintf("Cache warming failed (continuing anyway): %v", err), "all-checks")
}
for _, childCmd := range K8sCommands.Commands() {
if childCmd == cmd { // Skip the run-all command itself to avoid infinite recursion
continue
}
logger.InfoM(fmt.Sprintf("Running command: %s", childCmd.Use), "all-checks")
childCmd.Run(cmd, args)
}
// Clear cache after all-checks completes
sdk.Flush()
},
}
func init() {
// Register global flags for Kubernetes module
K8sCommands.PersistentFlags().StringVarP(&K8sToken, "token", "t", "", "Service Account Token")
K8sCommands.PersistentFlags().StringVarP(&K8sContext, "context", "c", "", "Kube-config context (overrides default kube-config)")
K8sCommands.PersistentFlags().StringVarP(&K8sKubeConfig, "kube-config", "k", "", "Path to a kube config file")
K8sCommands.PersistentFlags().StringVarP(&K8sAPIServer, "api-server", "s", "", "Kubernetes API server URL (used with --token)")
K8sCommands.PersistentFlags().StringVar(&K8sClusterName, "cluster-name", "", "Kubernetes Cluster Name (overrides default cluster name)")
// Namespace filtering flags
K8sCommands.PersistentFlags().StringVarP(&K8sNamespaceFlag, "namespace", "n", "", "Target specific namespace(s) - comma-delimited (e.g., default,kube-system)")
K8sCommands.PersistentFlags().StringVarP(&K8sNamespaceListFile, "namespace-list", "N", "", "Path to a file containing a list of namespaces")
K8sCommands.PersistentFlags().BoolVarP(&K8sAllNamespaces, "all-namespaces", "A", true, "Target all namespaces (default)")
//K8sCommands.PersistentFlags().StringVarP(&K8sOutputFormat, "output", "o", "all", "[\"table\" | \"csv\" | \"all\"]")
K8sCommands.PersistentFlags().StringVar(&K8sOutputDirectory, "outdir", defaultOutputDir, "Output Directory ")
K8sCommands.PersistentFlags().IntVarP(&Verbosity, "verbosity", "v", 2, "1 = Print control messages only\n2 = Print control messages, module output\n3 = Print control messages, module output, and loot file output\n")
K8sCommands.PersistentFlags().BoolVarP(&K8sWrapTable, "wrap", "w", false, "Wrap table to fit in terminal (complicates grepping)")
K8sCommands.PersistentFlags().IntVar(&K8sTimeout, "timeout", 300, "API timeout in seconds (default: 300s/5min)")
// K8sCommands.PersistentFlags().BoolVarP(&K8sMergedTable, "merged-table", "m", false, "Write one table for all namespaces (default: per namespace)")
// Cloud provider configuration flags (for network-exposure correlation)
K8sCommands.PersistentFlags().StringVar(&K8sCloudProvider, "cloud-provider", "", "Enable cloud correlation for specified providers - comma-separated (aws,gcp,azure)")
K8sCommands.PersistentFlags().StringVar(&K8sAWSProfile, "aws-profile", "", "AWS profile name (optional, uses default credentials if not specified)")
K8sCommands.PersistentFlags().StringVar(&K8sAzureSubscription, "azure-subscription", "", "Azure subscription ID(s) - comma-separated (optional, discovers all if not specified)")
K8sCommands.PersistentFlags().StringVar(&K8sGCPProject, "gcp-project", "", "GCP project ID(s) - comma-separated (optional, discovers all if not specified)")
// Admission controller filtering flag for lazy CRD discovery
K8sCommands.PersistentFlags().StringVar(&K8sAdmissionControllers, "admission-controllers", "",
"Filter admission modules to only check for specified controllers - comma-separated\n"+
"Use 'all' to check all (default behavior). Known controllers:\n"+
" Pod/Security: gatekeeper, kyverno, pss, vap\n"+
" Runtime: falco, tetragon, kubearmor, tracee, sysdig, prisma, aqua, stackrox, neuvector, crowdstrike\n"+
" Image: trivy, harbor, notary, cosign, prisma, aqua, stackrox, anchore\n"+
" Network: calico, cilium, istio, linkerd\n"+
" Secrets: vault, external-secrets, sealed-secrets\n"+
" Certs: cert-manager, venafi\n"+
" Other: coredns, opa")
// Detailed output flag
K8sCommands.PersistentFlags().BoolVarP(&K8sDetailed, "detailed", "d", false, "Show detailed output for modules that support it (admission modules, crds)")
// Add subcommands
K8sCommands.AddCommand(
// Identity & Access
commands.WhoamiCmd,
commands.PermissionsCmd,
commands.RoleBindingsCmd,
commands.ServiceAccountsCmd,
commands.HiddenAdminsCmd,
commands.CloudIAMCmd,
// Attack Path Analysis
commands.PrivescCmd,
commands.LateralMovementCmd,
commands.DataExfiltrationCmd,
// Workloads
commands.PodsCmd,
commands.DeploymentsCmd,
commands.DaemonSetsCmd,
commands.StatefulSetsCmd,
commands.ReplicaSetsCmd,
commands.JobsCmd,
commands.CronJobsCmd,
// Security
commands.SecretsCmd,
commands.ConfigMapsCmd,
commands.PodAdmissionCmd,
commands.ImageAdmissionCmd,
commands.SecretAdmissionCmd,
commands.RuntimeAdmissionCmd,
commands.AuditAdmissionCmd,
commands.MeshAdmissionCmd,
commands.CertAdmissionCmd,
commands.MultitenancyAdmissionCmd,
commands.DNSAdmissionCmd,
commands.WebhooksCmd,
commands.CRDsCmd,
// Networking
commands.ServicesCmd,
commands.EndpointsCmd,
commands.IngressCmd,
commands.NetworkAdmissionCmd,
commands.NetworkExposureCmd,
// Storage
commands.PersistentVolumesCmd,
commands.StorageClassesCmd,
// Cluster Resources
commands.NodesCmd,
commands.NamespacesCmd,
commands.TaintsCmd,
commands.TolerationsCmd,
commands.TaintsTolerationsCmd,
commands.PriorityClassesCmd,
commands.ResourceQuotasCmd,
// Availability & Scaling
commands.PodDisruptionBudgetsCmd,
commands.HPAsCmd,
// Observability
commands.EventsCmd,
// Meta
K8sAllChecksCommand,
)
}
+288
View File
@@ -0,0 +1,288 @@
package commands
import (
"fmt"
"sort"
"strings"
accesstokensservice "github.com/BishopFox/cloudfox/gcp/services/accessTokensService"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
"github.com/spf13/cobra"
)
// GCPAccessTokensCommand reads locally cached gcloud credentials.
// It overrides PersistentPreRun to bypass the parent's auth/project requirements
// since it only reads local files — no GCP API calls needed.
var GCPAccessTokensCommand = &cobra.Command{
Use: globals.GCP_ACCESS_TOKENS_MODULE_NAME,
Aliases: []string{"tokens", "creds"},
Short: "Enumerate locally cached gcloud access tokens, refresh tokens, and ADC",
Long: `Reads locally cached gcloud credentials for post-exploitation credential discovery.
Sources checked:
- access_tokens.db Cached OAuth2 access tokens
- credentials.db Cached refresh tokens
- application_default_credentials.json (ADC)
- configurations/config_* All gcloud profiles
- active_config Currently active profile
- GOOGLE_APPLICATION_CREDENTIALS env var (SA key files)
- CLOUDSDK_CONFIG_DIR env var (custom config paths)
This command reads LOCAL FILES ONLY — no GCP API calls are made.
No project flag (-p) or authentication is required.`,
// Override PersistentPreRun to bypass the parent's auth/project resolution.
// Cobra behavior: a child's PersistentPreRun fully replaces the parent's.
PersistentPreRun: func(cmd *cobra.Command, args []string) {
// Intentionally empty — no project flags or OAuth needed
},
Run: runGCPAccessTokensCommand,
}
// AccessTokensModule holds the credential discovery results
type AccessTokensModule struct {
Credentials []accesstokensservice.CredentialSummary
Verbosity int
WrapTable bool
OutputDir string
Format string
}
// AccessTokensOutput implements internal.CloudfoxOutput
type AccessTokensOutput struct {
Table []internal.TableFile
Loot []internal.LootFile
}
func (o AccessTokensOutput) TableFiles() []internal.TableFile { return o.Table }
func (o AccessTokensOutput) LootFiles() []internal.LootFile { return o.Loot }
func runGCPAccessTokensCommand(cmd *cobra.Command, args []string) {
logger := internal.NewLogger()
// Read output flags from the parent command (gcp)
parentCmd := cmd.Parent()
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
outputDir, _ := parentCmd.PersistentFlags().GetString("outdir")
format, _ := parentCmd.PersistentFlags().GetString("output")
if format == "" {
format = "all"
}
module := &AccessTokensModule{
Verbosity: verbosity,
WrapTable: wrap,
OutputDir: outputDir,
Format: format,
}
module.Execute(logger)
}
func (m *AccessTokensModule) Execute(logger internal.Logger) {
logger.InfoM("Searching for locally cached gcloud credentials...", globals.GCP_ACCESS_TOKENS_MODULE_NAME)
svc := accesstokensservice.New()
creds, err := svc.GetAllCredentials()
if err != nil {
logger.ErrorM(fmt.Sprintf("Error reading credentials: %v", err), globals.GCP_ACCESS_TOKENS_MODULE_NAME)
return
}
if len(creds) == 0 {
logger.InfoM("No cached credentials found", globals.GCP_ACCESS_TOKENS_MODULE_NAME)
return
}
m.Credentials = creds
// Sort: active tokens with refresh tokens first, then active-only, then expired with refresh, then rest
sort.Slice(m.Credentials, func(i, j int) bool {
return credSortScore(m.Credentials[i]) > credSortScore(m.Credentials[j])
})
logger.SuccessM(fmt.Sprintf("Found %d credential(s)", len(m.Credentials)), globals.GCP_ACCESS_TOKENS_MODULE_NAME)
m.writeOutput(logger)
}
// credSortScore returns a numeric score for sorting (higher = more interesting to a pentester)
func credSortScore(c accesstokensservice.CredentialSummary) int {
score := 0
if c.Validity == "ACTIVE" {
score += 4
}
if c.HasRefresh {
score += 2
}
if c.IsActive {
score += 1
}
return score
}
func (m *AccessTokensModule) getTableHeader() []string {
return []string{
"Account",
"Type",
"Validity",
"Has Refresh Token",
"RAPT Status",
"Profile",
"Active Profile",
"Project",
"Access Token",
}
}
func (m *AccessTokensModule) credentialsToTableBody() [][]string {
var body [][]string
for _, c := range m.Credentials {
hasRefresh := "No"
if c.HasRefresh {
hasRefresh = "Yes"
}
active := "No"
if c.IsActive {
active = "Yes"
}
profile := c.Profile
if profile == "" {
profile = "-"
}
project := c.Project
if project == "" {
project = "-"
}
accessToken := c.AccessToken
if accessToken == "" {
accessToken = "-"
}
body = append(body, []string{
c.Account,
c.Type,
c.Validity,
hasRefresh,
c.RAPTStatus,
profile,
active,
project,
accessToken,
})
}
return body
}
func (m *AccessTokensModule) writeOutput(logger internal.Logger) {
body := m.credentialsToTableBody()
tables := []internal.TableFile{{
Name: "access-tokens",
Header: m.getTableHeader(),
Body: body,
}}
// Build loot files
var lootFiles []internal.LootFile
// Loot 1: Exploitation commands (curl with active access tokens)
var exploitCmds strings.Builder
exploitCmds.WriteString("# Active Access Token Exploitation Commands\n")
exploitCmds.WriteString("# Generated by CloudFox\n\n")
hasExploitCmds := false
for _, c := range m.Credentials {
if c.Type == "access_token" && c.Validity == "ACTIVE" && c.AccessToken != "" {
exploitCmds.WriteString(fmt.Sprintf("# Account: %s (Profile: %s, Project: %s)\n", c.Account, c.Profile, c.Project))
exploitCmds.WriteString(fmt.Sprintf("curl -s -H 'Authorization: Bearer %s' https://www.googleapis.com/oauth2/v1/tokeninfo\n", c.AccessToken))
exploitCmds.WriteString(fmt.Sprintf("curl -s -H 'Authorization: Bearer %s' https://cloudresourcemanager.googleapis.com/v1/projects\n\n", c.AccessToken))
hasExploitCmds = true
}
if c.Type == "sa_key" {
exploitCmds.WriteString(fmt.Sprintf("# Service Account Key: %s\n", c.Account))
exploitCmds.WriteString(fmt.Sprintf("# Source: %s\n", c.Source))
exploitCmds.WriteString(fmt.Sprintf("gcloud auth activate-service-account --key-file=%s\n\n", c.Source))
hasExploitCmds = true
}
}
if hasExploitCmds {
lootFiles = append(lootFiles, internal.LootFile{
Name: "access-tokens-exploitation-commands",
Contents: exploitCmds.String(),
})
}
// Loot 2: Refresh token commands
var refreshCmds strings.Builder
refreshCmds.WriteString("# Refresh Token Commands\n")
refreshCmds.WriteString("# Generated by CloudFox\n")
refreshCmds.WriteString("# Refresh tokens can be used to obtain new access tokens\n\n")
hasRefreshCmds := false
for _, c := range m.Credentials {
if c.HasRefresh && (c.Type == "access_token" || c.Type == "refresh_token") {
refreshCmds.WriteString(fmt.Sprintf("# Account: %s\n", c.Account))
refreshCmds.WriteString("# To extract the refresh token:\n")
refreshCmds.WriteString(fmt.Sprintf("# sqlite3 ~/.config/gcloud/credentials.db \"SELECT value FROM credentials WHERE account_id='%s'\"\n", c.Account))
refreshCmds.WriteString("# Then use it to get a new access token:\n")
refreshCmds.WriteString("# curl -s -X POST https://oauth2.googleapis.com/token \\\n")
refreshCmds.WriteString("# -d 'client_id=<CLIENT_ID>' \\\n")
refreshCmds.WriteString("# -d 'client_secret=<CLIENT_SECRET>' \\\n")
refreshCmds.WriteString("# -d 'refresh_token=<REFRESH_TOKEN>' \\\n")
refreshCmds.WriteString("# -d 'grant_type=refresh_token'\n\n")
hasRefreshCmds = true
}
}
if hasRefreshCmds {
lootFiles = append(lootFiles, internal.LootFile{
Name: "access-tokens-refresh-commands",
Contents: refreshCmds.String(),
})
}
// Loot 3: ADC commands
var adcCmds strings.Builder
adcCmds.WriteString("# Application Default Credentials (ADC) Commands\n")
adcCmds.WriteString("# Generated by CloudFox\n\n")
hasADCCmds := false
for _, c := range m.Credentials {
if c.Type == "adc" {
adcCmds.WriteString(fmt.Sprintf("# ADC Account: %s\n", c.Account))
adcCmds.WriteString(fmt.Sprintf("# Source: %s\n", c.Source))
if c.Project != "" {
adcCmds.WriteString(fmt.Sprintf("# Quota Project: %s\n", c.Project))
}
adcCmds.WriteString("# ADC is used by client libraries and tools like terraform\n")
adcCmds.WriteString("# To use these credentials:\n")
adcCmds.WriteString("# gcloud auth application-default print-access-token\n")
adcCmds.WriteString("# Or copy ~/.config/gcloud/application_default_credentials.json to your attack machine\n\n")
hasADCCmds = true
}
}
if hasADCCmds {
lootFiles = append(lootFiles, internal.LootFile{
Name: "access-tokens-adc-commands",
Contents: adcCmds.String(),
})
}
output := AccessTokensOutput{Table: tables, Loot: lootFiles}
// Use HandleOutputSmart with a local-only scope
err := internal.HandleOutputSmart(
"gcp",
m.Format,
m.OutputDir,
m.Verbosity,
m.WrapTable,
"local",
[]string{"localhost"},
[]string{"localhost"},
"local",
output,
)
if err != nil {
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_ACCESS_TOKENS_MODULE_NAME)
}
}
+7 -2
View File
@@ -9,6 +9,7 @@ import (
artifactregistry "cloud.google.com/go/artifactregistry/apiv1"
ArtifactRegistryService "github.com/BishopFox/cloudfox/gcp/services/artifactRegistryService"
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
"google.golang.org/api/option"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
"github.com/spf13/cobra"
@@ -73,8 +74,12 @@ func runGCPArtifactRegistryCommand(cmd *cobra.Command, args []string) {
return // Error already logged
}
// Create Artifact Registry client
client, err := artifactregistry.NewClient(cmdCtx.Ctx)
// Create Artifact Registry client (uses impersonated credentials if --impersonate-sa is set)
var arOpts []option.ClientOption
if s := gcpinternal.GetDefaultSession(); s != nil {
arOpts = append(arOpts, s.GetClientOption())
}
client, err := artifactregistry.NewClient(cmdCtx.Ctx, arOpts...)
if err != nil {
cmdCtx.Logger.ErrorM(fmt.Sprintf("Failed to create Artifact Registry client: %v", err), globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME)
return
+42
View File
@@ -7,6 +7,8 @@ import (
"sync"
BigQueryService "github.com/BishopFox/cloudfox/gcp/services/bigqueryService"
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
"github.com/BishopFox/cloudfox/gcp/shared"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
@@ -135,6 +137,40 @@ func (m *BigQueryModule) processProject(ctx context.Context, projectID string, l
return
}
// Inject inherited project-level IAM bindings
iamSvc := IAMService.New()
projectPolicies, projErr := iamSvc.Policies(projectID, "project")
if projErr == nil {
// Inject into datasets (AccessEntries)
for i := range result.Datasets {
for _, pb := range projectPolicies {
if !shared.IsInheritedRole("dataset", pb.Role) {
continue
}
for _, member := range pb.Members {
result.Datasets[i].AccessEntries = append(result.Datasets[i].AccessEntries, BigQueryService.AccessEntry{
Role: pb.Role,
Entity: member,
Source: "Project",
})
}
}
}
// Inject into tables
for i := range result.Tables {
for _, pb := range projectPolicies {
if !shared.IsInheritedRole("table", pb.Role) {
continue
}
result.Tables[i].IAMBindings = append(result.Tables[i].IAMBindings, BigQueryService.TableIAMBinding{
Role: pb.Role,
Members: pb.Members,
Source: "Project",
})
}
}
}
// Thread-safe store per-project
m.mu.Lock()
m.ProjectDatasets[projectID] = result.Datasets
@@ -261,6 +297,7 @@ func (m *BigQueryModule) getDatasetHeader() []string {
"IAM Binding Role",
"Principal Type",
"IAM Binding Principal",
"Binding Source",
}
}
@@ -276,6 +313,7 @@ func (m *BigQueryModule) getTableHeader() []string {
"Public",
"IAM Binding Role",
"IAM Binding Principal",
"Binding Source",
}
}
@@ -306,6 +344,7 @@ func (m *BigQueryModule) datasetsToTableBody(datasets []BigQueryService.Bigquery
role,
memberType,
entry.Entity,
shared.BindingSource(entry.Source),
})
}
} else {
@@ -318,6 +357,7 @@ func (m *BigQueryModule) datasetsToTableBody(datasets []BigQueryService.Bigquery
"-",
"-",
"-",
"-",
})
}
}
@@ -344,6 +384,7 @@ func (m *BigQueryModule) tablesToTableBody(tables []BigQueryService.BigqueryTabl
publicStatus,
"-",
"-",
"-",
})
} else {
for _, binding := range table.IAMBindings {
@@ -358,6 +399,7 @@ func (m *BigQueryModule) tablesToTableBody(tables []BigQueryService.BigqueryTabl
publicStatus,
binding.Role,
member,
shared.BindingSource(binding.Source),
})
}
}
+22
View File
@@ -7,6 +7,7 @@ import (
"sync"
CloudStorageService "github.com/BishopFox/cloudfox/gcp/services/cloudStorageService"
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
"github.com/BishopFox/cloudfox/gcp/shared"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
@@ -161,6 +162,24 @@ func (m *BucketsModule) processProject(ctx context.Context, projectID string, lo
return
}
// Inject inherited project-level IAM bindings into each bucket
iamSvc := IAMService.New()
projectPolicies, projErr := iamSvc.Policies(projectID, "project")
if projErr == nil {
for i := range buckets {
for _, pb := range projectPolicies {
if !shared.IsInheritedRole("bucket", pb.Role) {
continue
}
buckets[i].IAMBindings = append(buckets[i].IAMBindings, CloudStorageService.IAMBinding{
Role: pb.Role,
Members: pb.Members,
Source: "Project",
})
}
}
}
// Thread-safe store per-project
m.mu.Lock()
m.ProjectBuckets[projectID] = buckets
@@ -377,6 +396,7 @@ func (m *BucketsModule) getTableHeader() []string {
"IAM Binding Role",
"Principal Type",
"IAM Binding Principal",
"Binding Source",
"Principal Attack Paths",
}
}
@@ -454,6 +474,7 @@ func (m *BucketsModule) bucketsToTableBody(buckets []CloudStorageService.BucketI
binding.Role,
memberType,
member,
shared.BindingSource(binding.Source),
attackPaths,
})
}
@@ -475,6 +496,7 @@ func (m *BucketsModule) bucketsToTableBody(buckets []CloudStorageService.BucketI
"-",
"-",
"-",
"-",
})
}
}
+46 -4
View File
@@ -7,6 +7,8 @@ import (
"sync"
CloudRunService "github.com/BishopFox/cloudfox/gcp/services/cloudrunService"
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
"github.com/BishopFox/cloudfox/gcp/shared"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
@@ -173,6 +175,10 @@ func (m *CloudRunModule) processProject(ctx context.Context, projectID string, l
}
m.mu.Unlock()
// Fetch project-level IAM for inherited binding injection
iamSvc := IAMService.New()
projectPolicies, _ := iamSvc.Policies(projectID, "project")
// Get services
services, err := cs.Services(projectID)
if err != nil {
@@ -180,6 +186,23 @@ func (m *CloudRunModule) processProject(ctx context.Context, projectID string, l
gcpinternal.HandleGCPError(err, logger, globals.GCP_CLOUDRUN_MODULE_NAME,
fmt.Sprintf("Could not enumerate Cloud Run services in project %s", projectID))
} else {
// Inject inherited project-level IAM bindings into services
if projectPolicies != nil {
for i := range services {
for _, pb := range projectPolicies {
if !shared.IsInheritedRole("cloudrun", pb.Role) {
continue
}
for _, member := range pb.Members {
services[i].IAMBindings = append(services[i].IAMBindings, CloudRunService.IAMBinding{
Role: pb.Role,
Member: member,
Source: "Project",
})
}
}
}
}
m.mu.Lock()
m.ProjectServices[projectID] = services
for _, svc := range services {
@@ -195,6 +218,23 @@ func (m *CloudRunModule) processProject(ctx context.Context, projectID string, l
gcpinternal.HandleGCPError(err, logger, globals.GCP_CLOUDRUN_MODULE_NAME,
fmt.Sprintf("Could not enumerate Cloud Run jobs in project %s", projectID))
} else {
// Inject inherited project-level IAM bindings into jobs
if projectPolicies != nil {
for i := range jobs {
for _, pb := range projectPolicies {
if !shared.IsInheritedRole("cloudrun", pb.Role) {
continue
}
for _, member := range pb.Members {
jobs[i].IAMBindings = append(jobs[i].IAMBindings, CloudRunService.IAMBinding{
Role: pb.Role,
Member: member,
Source: "Project",
})
}
}
}
}
m.mu.Lock()
m.ProjectJobs[projectID] = jobs
for _, job := range jobs {
@@ -454,7 +494,7 @@ func (m *CloudRunModule) buildTablesForProject(projectID string, services []Clou
servicesHeader := []string{
"Project", "Type", "Name", "Region", "Status", "URL", "Ingress", "Public",
"Service Account", "SA Attack Paths", "Default SA", "Image", "VPC Access",
"Min/Max", "IAM Binding Role", "IAM Binding Principal",
"Min/Max", "IAM Binding Role", "IAM Binding Principal", "Binding Source",
}
var servicesBody [][]string
@@ -497,6 +537,7 @@ func (m *CloudRunModule) buildTablesForProject(projectID string, services []Clou
formatIngress(svc.IngressSettings), publicStatus, svc.ServiceAccount,
attackPaths, defaultSA, svc.ContainerImage, vpcAccess, scaling,
binding.Role, binding.Member,
shared.BindingSource(binding.Source),
})
}
} else {
@@ -505,7 +546,7 @@ func (m *CloudRunModule) buildTablesForProject(projectID string, services []Clou
m.GetProjectName(svc.ProjectID), "Service", svc.Name, svc.Region, status, svc.URL,
formatIngress(svc.IngressSettings), publicStatus, svc.ServiceAccount,
attackPaths, defaultSA, svc.ContainerImage, vpcAccess, scaling,
"-", "-",
"-", "-", "-",
})
}
}
@@ -522,7 +563,7 @@ func (m *CloudRunModule) buildTablesForProject(projectID string, services []Clou
jobsHeader := []string{
"Project", "Type", "Name", "Region", "Status", "Service Account", "SA Attack Paths", "Default SA",
"Image", "VPC Access", "Tasks", "Parallelism", "Last Execution",
"IAM Binding Role", "IAM Binding Principal",
"IAM Binding Role", "IAM Binding Principal", "Binding Source",
}
var jobsBody [][]string
@@ -565,6 +606,7 @@ func (m *CloudRunModule) buildTablesForProject(projectID string, services []Clou
job.ServiceAccount, jobAttackPaths, defaultSA, job.ContainerImage, vpcAccess,
fmt.Sprintf("%d", job.TaskCount), fmt.Sprintf("%d", job.Parallelism),
lastExec, binding.Role, binding.Member,
shared.BindingSource(binding.Source),
})
}
} else {
@@ -573,7 +615,7 @@ func (m *CloudRunModule) buildTablesForProject(projectID string, services []Clou
m.GetProjectName(job.ProjectID), "Job", job.Name, job.Region, status,
job.ServiceAccount, jobAttackPaths, defaultSA, job.ContainerImage, vpcAccess,
fmt.Sprintf("%d", job.TaskCount), fmt.Sprintf("%d", job.Parallelism),
lastExec, "-", "-",
lastExec, "-", "-", "-",
})
}
}
+24
View File
@@ -9,6 +9,7 @@ import (
"github.com/BishopFox/cloudfox/gcp/shared"
FunctionsService "github.com/BishopFox/cloudfox/gcp/services/functionsService"
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
@@ -154,6 +155,26 @@ func (m *FunctionsModule) processProject(ctx context.Context, projectID string,
return
}
// Inject inherited project-level IAM bindings into each function
iamSvc := IAMService.New()
projectPolicies, projErr := iamSvc.Policies(projectID, "project")
if projErr == nil {
for i := range functions {
for _, pb := range projectPolicies {
if !shared.IsInheritedRole("function", pb.Role) {
continue
}
for _, member := range pb.Members {
functions[i].IAMBindings = append(functions[i].IAMBindings, FunctionsService.IAMBinding{
Role: pb.Role,
Member: member,
Source: "Project",
})
}
}
}
}
// Thread-safe store per-project
m.mu.Lock()
m.ProjectFunctions[projectID] = functions
@@ -495,6 +516,7 @@ func (m *FunctionsModule) getTableHeader() []string {
"VPC Access",
"IAM Binding Role",
"IAM Binding Principal",
"Binding Source",
}
}
@@ -572,6 +594,7 @@ func (m *FunctionsModule) functionsToTableBody(functions []FunctionsService.Func
vpcAccess,
binding.Role,
binding.Member,
shared.BindingSource(binding.Source),
})
}
} else {
@@ -592,6 +615,7 @@ func (m *FunctionsModule) functionsToTableBody(functions []FunctionsService.Func
vpcAccess,
"-",
"-",
"-",
})
}
}
+5 -1
View File
@@ -290,6 +290,7 @@ func (m *HiddenAdminsModule) analyzeOrganizationIAM(ctx context.Context, logger
policy, err := orgsClient.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{
Resource: org.Name,
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
})
if err != nil {
continue
@@ -332,6 +333,7 @@ func (m *HiddenAdminsModule) analyzeFolderIAM(ctx context.Context, logger intern
policy, err := foldersClient.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{
Resource: folder.Name,
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
})
if err != nil {
continue
@@ -352,7 +354,9 @@ func (m *HiddenAdminsModule) analyzeProjectIAM(ctx context.Context, logger inter
return
}
policy, err := crmService.Projects.GetIamPolicy(projectID, &crmv1.GetIamPolicyRequest{}).Do()
policy, err := crmService.Projects.GetIamPolicy(projectID, &crmv1.GetIamPolicyRequest{
Options: &crmv1.GetPolicyOptions{RequestedPolicyVersion: 3},
}).Do()
if err != nil {
return
}
+252 -16
View File
@@ -7,6 +7,7 @@ import (
"sync"
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
resourceiamservice "github.com/BishopFox/cloudfox/gcp/services/resourceIAMService"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
@@ -78,6 +79,28 @@ var highPrivilegeRoles = map[string]bool{
"roles/orgpolicy.policyAdmin": true,
}
// adminRoles are roles that grant broad administrative control. Used as a
// fallback for the Admin column when FoxMapper data is not available.
var adminRoles = map[string]bool{
"roles/owner": true,
"roles/editor": true,
"roles/resourcemanager.projectIamAdmin": true,
"roles/resourcemanager.folderAdmin": true,
"roles/resourcemanager.folderIamAdmin": true,
"roles/resourcemanager.organizationAdmin": true,
"roles/iam.securityAdmin": true,
"roles/iam.roleAdmin": true,
}
// isAdminRole returns "Yes" if the role is a known admin role, "" otherwise.
// Used as a fallback when FoxMapper is not available.
func isAdminRole(role string) string {
if adminRoles[role] {
return "Yes"
}
return ""
}
// ------------------------------
// Module Struct with embedded BaseGCPModule
// ------------------------------
@@ -85,11 +108,12 @@ type IAMModule struct {
gcpinternal.BaseGCPModule
// Module-specific fields - using enhanced data
ScopeBindings []IAMService.ScopeBinding
ServiceAccounts []IAMService.ServiceAccountInfo
CustomRoles []IAMService.CustomRole
Groups []IAMService.GroupInfo
MFAStatus map[string]*IAMService.MFAStatus
ScopeBindings []IAMService.ScopeBinding
ServiceAccounts []IAMService.ServiceAccountInfo
CustomRoles []IAMService.CustomRole
Groups []IAMService.GroupInfo
MFAStatus map[string]*IAMService.MFAStatus
ResourceBindings []resourceiamservice.ResourceIAMBinding
// Per-scope loot for inheritance-aware output
OrgLoot map[string]*internal.LootFile // orgID -> loot commands
@@ -132,11 +156,12 @@ func runGCPIAMCommand(cmd *cobra.Command, args []string) {
// Create module instance
module := &IAMModule{
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
ScopeBindings: []IAMService.ScopeBinding{},
ServiceAccounts: []IAMService.ServiceAccountInfo{},
CustomRoles: []IAMService.CustomRole{},
Groups: []IAMService.GroupInfo{},
MFAStatus: make(map[string]*IAMService.MFAStatus),
ScopeBindings: []IAMService.ScopeBinding{},
ServiceAccounts: []IAMService.ServiceAccountInfo{},
CustomRoles: []IAMService.CustomRole{},
Groups: []IAMService.GroupInfo{},
MFAStatus: make(map[string]*IAMService.MFAStatus),
ResourceBindings: []resourceiamservice.ResourceIAMBinding{},
OrgLoot: make(map[string]*internal.LootFile),
FolderLoot: make(map[string]*internal.LootFile),
ProjectLoot: make(map[string]*internal.LootFile),
@@ -182,6 +207,16 @@ func (m *IAMModule) Execute(ctx context.Context, logger internal.Logger) {
m.Groups = iamData.Groups
m.MFAStatus = iamData.MFAStatus
// Enumerate resource-level IAM bindings (SA, buckets, secrets, functions, etc.)
resourceIAMSvc := resourceiamservice.New()
for _, projectID := range m.ProjectIDs {
resourceBindings, err := resourceIAMSvc.GetAllResourceIAM(ctx, projectID)
if err != nil {
continue
}
m.ResourceBindings = append(m.ResourceBindings, resourceBindings...)
}
// Try to enumerate group memberships to build reverse lookup
enrichedGroups := iamService.GetGroupMemberships(ctx, m.Groups)
m.Groups = enrichedGroups
@@ -220,8 +255,9 @@ func (m *IAMModule) Execute(ctx context.Context, logger internal.Logger) {
}
}
logger.SuccessM(fmt.Sprintf("Found %d binding(s) across %d org(s), %d folder(s), %d project(s); %d SA(s), %d custom role(s), %d group(s)",
logger.SuccessM(fmt.Sprintf("Found %d binding(s) across %d org(s), %d folder(s), %d project(s); %d resource-level binding(s); %d SA(s), %d custom role(s), %d group(s)",
len(m.ScopeBindings), orgCount, folderCount, projectCount,
len(m.ResourceBindings),
len(m.ServiceAccounts), len(m.CustomRoles), len(m.Groups)), globals.GCP_IAM_MODULE_NAME)
// Write output
@@ -631,9 +667,11 @@ func (m *IAMModule) buildTables() []internal.TableFile {
// Add scope bindings (one row per binding)
for _, sb := range m.ScopeBindings {
// Check admin status from FoxMapper only - shows Org/Folder/Project or No
// This is different from "high privilege roles" - Admin means broad IAM control
// Check admin status: FoxMapper (granular) > role-based fallback
adminStatus := gcpinternal.GetAdminStatusFromCache(m.FoxMapperCache, sb.MemberEmail)
if adminStatus == "" {
adminStatus = isAdminRole(sb.Role)
}
if adminStatus == "" {
adminStatus = "No"
}
@@ -698,6 +736,91 @@ func (m *IAMModule) buildTables() []internal.TableFile {
})
}
// Add resource-level IAM bindings (one row per binding)
for _, rb := range m.ResourceBindings {
memberType := resourceiamservice.DetermineMemberType(rb.Member)
memberEmail := rb.MemberEmail
// Check admin status: FoxMapper (granular) > role-based fallback
adminStatus := gcpinternal.GetAdminStatusFromCache(m.FoxMapperCache, memberEmail)
if adminStatus == "" {
adminStatus = isAdminRole(rb.Role)
}
if adminStatus == "" {
adminStatus = "No"
}
isCustom := "No"
if strings.HasPrefix(rb.Role, "projects/") || strings.HasPrefix(rb.Role, "organizations/") {
isCustom = "Yes"
}
condition := "No"
if rb.HasCondition && rb.ConditionTitle != "" {
condition = rb.ConditionTitle
} else if rb.HasCondition {
condition = "Yes"
}
// Get MFA status
mfa := "-"
if memberType == "User" {
if status, ok := m.MFAStatus[memberEmail]; ok {
if status.Error != "" {
mfa = "Unknown"
} else if status.HasMFA {
mfa = "Yes"
} else {
mfa = "No"
}
}
} else if memberType == "ServiceAccount" {
mfa = "N/A"
}
// Get groups this member belongs to
groups := "-"
if memberGroups, ok := m.MemberToGroups[memberEmail]; ok && len(memberGroups) > 0 {
groups = strings.Join(memberGroups, ", ")
}
// Check for federated identity
federated := formatFederatedInfo(parseFederatedIdentity(memberEmail))
// Check attack paths for service account principals
attackPaths := "-"
if memberType == "ServiceAccount" {
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, memberEmail)
}
body = append(body, []string{
rb.ResourceType,
rb.ResourceID,
rb.ResourceName,
memberType,
memberEmail,
rb.Role,
adminStatus,
isCustom,
"-",
condition,
mfa,
groups,
federated,
attackPaths,
})
}
// Build SA email -> roles lookup from scope bindings for admin fallback.
// ServiceAccountsBasic() doesn't populate the Roles field, so we
// extract roles from the already-enumerated bindings.
saRolesMap := make(map[string][]string)
for _, sb := range m.ScopeBindings {
if sb.MemberType == "ServiceAccount" {
saRolesMap[sb.MemberEmail] = append(saRolesMap[sb.MemberEmail], sb.Role)
}
}
// Add service accounts
for _, sa := range m.ServiceAccounts {
hasKeys := "No"
@@ -716,8 +839,16 @@ func (m *IAMModule) buildTables() []internal.TableFile {
groups = strings.Join(memberGroups, ", ")
}
// Check admin status from FoxMapper
// Check admin status: FoxMapper (granular) > role-based fallback
adminStatus := gcpinternal.GetAdminStatusFromCache(m.FoxMapperCache, sa.Email)
if adminStatus == "" {
for _, role := range saRolesMap[sa.Email] {
if adminRoles[role] {
adminStatus = "Yes"
break
}
}
}
if adminStatus == "" {
adminStatus = "No"
}
@@ -998,6 +1129,12 @@ func (m *IAMModule) writeHierarchicalOutputTee(ctx context.Context, logger inter
}
}
// Build reverse lookup: for each resource ID, which project it belongs to
resourceToProject := make(map[string]string)
for _, rb := range m.ResourceBindings {
resourceToProject[rb.ResourceID] = rb.ProjectID
}
// Create a row router that routes based on scope type and OrgCache
rowRouter := func(row []string) []string {
// Row format: [ScopeType, ScopeID, ScopeName, ...]
@@ -1021,6 +1158,11 @@ func (m *IAMModule) writeHierarchicalOutputTee(ctx context.Context, logger inter
}
return m.ProjectIDs
default:
// Resource-level binding (serviceAccount, bucket, secret, etc.)
// Route to the project that owns this resource
if projectID, ok := resourceToProject[scopeID]; ok {
return []string{projectID}
}
return nil
}
}
@@ -1099,8 +1241,11 @@ func (m *IAMModule) buildTablesForProject(projectID string) []internal.TableFile
continue
}
// Check admin status from FoxMapper only - shows Org/Folder/Project or No
// Check admin status: FoxMapper (granular) > role-based fallback
adminStatus := gcpinternal.GetAdminStatusFromCache(m.FoxMapperCache, sb.MemberEmail)
if adminStatus == "" {
adminStatus = isAdminRole(sb.Role)
}
if adminStatus == "" {
adminStatus = "No"
}
@@ -1161,6 +1306,89 @@ func (m *IAMModule) buildTablesForProject(projectID string) []internal.TableFile
})
}
// Add resource-level IAM bindings for this project
for _, rb := range m.ResourceBindings {
if rb.ProjectID != projectID {
continue
}
memberType := resourceiamservice.DetermineMemberType(rb.Member)
memberEmail := rb.MemberEmail
// Check admin status: FoxMapper (granular) > role-based fallback
adminStatus := gcpinternal.GetAdminStatusFromCache(m.FoxMapperCache, memberEmail)
if adminStatus == "" {
adminStatus = isAdminRole(rb.Role)
}
if adminStatus == "" {
adminStatus = "No"
}
isCustom := "No"
if strings.HasPrefix(rb.Role, "projects/") || strings.HasPrefix(rb.Role, "organizations/") {
isCustom = "Yes"
}
condition := "No"
if rb.HasCondition && rb.ConditionTitle != "" {
condition = rb.ConditionTitle
} else if rb.HasCondition {
condition = "Yes"
}
mfa := "-"
if memberType == "User" {
if status, ok := m.MFAStatus[memberEmail]; ok {
if status.Error != "" {
mfa = "Unknown"
} else if status.HasMFA {
mfa = "Yes"
} else {
mfa = "No"
}
}
} else if memberType == "ServiceAccount" {
mfa = "N/A"
}
groups := "-"
if memberGroups, ok := m.MemberToGroups[memberEmail]; ok && len(memberGroups) > 0 {
groups = strings.Join(memberGroups, ", ")
}
federated := formatFederatedInfo(parseFederatedIdentity(memberEmail))
attackPaths := "-"
if memberType == "ServiceAccount" {
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, memberEmail)
}
body = append(body, []string{
rb.ResourceType,
rb.ResourceID,
rb.ResourceName,
memberType,
memberEmail,
rb.Role,
adminStatus,
isCustom,
"-",
condition,
mfa,
groups,
federated,
attackPaths,
})
}
// Build SA email -> roles lookup from scope bindings for admin fallback
saRolesMap := make(map[string][]string)
for _, sb := range m.ScopeBindings {
if sb.MemberType == "ServiceAccount" {
saRolesMap[sb.MemberEmail] = append(saRolesMap[sb.MemberEmail], sb.Role)
}
}
// Add service accounts for this project only
for _, sa := range m.ServiceAccounts {
if sa.ProjectID != projectID {
@@ -1182,8 +1410,16 @@ func (m *IAMModule) buildTablesForProject(projectID string) []internal.TableFile
groups = strings.Join(memberGroups, ", ")
}
// Check admin status from FoxMapper
// Check admin status: FoxMapper (granular) > role-based fallback
adminStatus := gcpinternal.GetAdminStatusFromCache(m.FoxMapperCache, sa.Email)
if adminStatus == "" {
for _, role := range saRolesMap[sa.Email] {
if adminRoles[role] {
adminStatus = "Yes"
break
}
}
}
if adminStatus == "" {
adminStatus = "No"
}
+4
View File
@@ -448,6 +448,7 @@ func (m *IdentityFederationModule) buildTables(
"Display Name",
"State",
"Disabled",
"Full Resource Path",
}
var poolsBody [][]string
@@ -462,6 +463,7 @@ func (m *IdentityFederationModule) buildTables(
pool.DisplayName,
pool.State,
disabled,
pool.Name,
})
}
@@ -482,6 +484,7 @@ func (m *IdentityFederationModule) buildTables(
"OIDC Issuer / AWS Account",
"Trust Scope",
"Access Condition",
"Full Resource Path",
}
var providersBody [][]string
@@ -508,6 +511,7 @@ func (m *IdentityFederationModule) buildTables(
issuerOrAccount,
trustScope,
attrCond,
p.Name,
})
}
+59 -12
View File
@@ -9,6 +9,8 @@ import (
"sync"
ComputeEngineService "github.com/BishopFox/cloudfox/gcp/services/computeEngineService"
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
orgpolicyservice "github.com/BishopFox/cloudfox/gcp/services/orgpolicyService"
"github.com/BishopFox/cloudfox/gcp/shared"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
@@ -39,6 +41,7 @@ Security Columns:
- BroadScopes: Has cloud-platform or other broad OAuth scopes
- OSLogin: OS Login enabled (recommended for access control)
- OSLogin2FA: OS Login with 2FA required
- OSLoginPolicy: Whether compute.requireOsLogin org policy is enforced (inherited)
- BlockProjKeys: Instance blocks project-wide SSH keys
- SerialPort: Serial port access enabled (security risk if exposed)
- CanIPForward: Can forward packets (potential for lateral movement)
@@ -55,11 +58,12 @@ type InstancesModule struct {
gcpinternal.BaseGCPModule
// Module-specific fields - per-project for hierarchical output
ProjectInstances map[string][]ComputeEngineService.ComputeEngineInfo // projectID -> instances
ProjectMetadata map[string]*ComputeEngineService.ProjectMetadataInfo // projectID -> metadata
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
FoxMapperCache *gcpinternal.FoxMapperCache // FoxMapper graph data (preferred)
mu sync.Mutex
ProjectInstances map[string][]ComputeEngineService.ComputeEngineInfo // projectID -> instances
ProjectMetadata map[string]*ComputeEngineService.ProjectMetadataInfo // projectID -> metadata
ProjectOSLoginPolicy map[string]string // projectID -> "Enforced", "Not Enforced", "Unknown"
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
FoxMapperCache *gcpinternal.FoxMapperCache // FoxMapper graph data (preferred)
mu sync.Mutex
}
// ------------------------------
@@ -85,10 +89,11 @@ func runGCPInstancesCommand(cmd *cobra.Command, args []string) {
// Create module instance
module := &InstancesModule{
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
ProjectInstances: make(map[string][]ComputeEngineService.ComputeEngineInfo),
ProjectMetadata: make(map[string]*ComputeEngineService.ProjectMetadataInfo),
LootMap: make(map[string]map[string]*internal.LootFile),
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
ProjectInstances: make(map[string][]ComputeEngineService.ComputeEngineInfo),
ProjectMetadata: make(map[string]*ComputeEngineService.ProjectMetadataInfo),
ProjectOSLoginPolicy: make(map[string]string),
LootMap: make(map[string]map[string]*internal.LootFile),
}
// Execute enumeration
@@ -148,10 +153,41 @@ func (m *InstancesModule) processProject(ctx context.Context, projectID string,
return
}
// Inject inherited project-level IAM bindings into each instance
iamSvc := IAMService.New()
projectPolicies, projErr := iamSvc.Policies(projectID, "project")
if projErr == nil {
for i := range instances {
for _, pb := range projectPolicies {
if !shared.IsInheritedRole("instance", pb.Role) {
continue
}
for _, member := range pb.Members {
instances[i].IAMBindings = append(instances[i].IAMBindings, ComputeEngineService.IAMBinding{
Role: pb.Role,
Member: member,
Source: "Project",
})
}
}
}
}
// Check effective compute.requireOsLogin org policy for this project
orgPolicySvc := orgpolicyservice.New()
effectivePolicy, policyErr := orgPolicySvc.GetEffectivePolicy("project", projectID, "compute.requireOsLogin")
osLoginPolicy := "Unknown"
if policyErr == nil && effectivePolicy != nil && effectivePolicy.Enforced {
osLoginPolicy = "Enforced"
} else if policyErr == nil {
osLoginPolicy = "Not Enforced"
}
// Thread-safe store per-project
m.mu.Lock()
m.ProjectInstances[projectID] = instances
m.ProjectMetadata[projectID] = projectMeta
m.ProjectOSLoginPolicy[projectID] = osLoginPolicy
// Initialize loot for this project
if m.LootMap[projectID] == nil {
@@ -672,7 +708,7 @@ func (m *InstancesModule) writeFlatOutput(ctx context.Context, logger internal.L
// - Identity: Project, Name, Type, Zone, State, Machine Type
// - Network: External IP, Internal IP, IP Forward
// - Service Account: Service Account, SA Attack Paths, Scopes, Default SA, Broad Scopes
// - Access Control: OS Login, OS Login 2FA, Block Proj Keys, Serial Port
// - Access Control: OS Login, OS Login 2FA, OS Login Policy, Block Proj Keys, Serial Port
// - Protection: Delete Protect, Last Snapshot
// - Hardware Security: Shielded VM, Secure Boot, vTPM, Integrity, Confidential
// - Disk Encryption: Encryption, KMS Key
@@ -699,6 +735,7 @@ func (m *InstancesModule) getInstancesTableHeader() []string {
// Access Control
"OS Login",
"OS Login 2FA",
"OS Login Policy",
"Block Proj Keys",
"Serial Port",
// Protection
@@ -716,6 +753,7 @@ func (m *InstancesModule) getInstancesTableHeader() []string {
// IAM
"IAM Binding Role",
"IAM Binding Principal",
"Binding Source",
}
}
@@ -885,6 +923,12 @@ func (m *InstancesModule) instancesToTableBody(instances []ComputeEngineService.
kmsKey = "-"
}
// OS Login Policy display
osLoginPolicy := "Unknown"
if val, ok := m.ProjectOSLoginPolicy[instance.ProjectID]; ok {
osLoginPolicy = val
}
// Instance type for contextual display
instType := instance.InstanceType
if instType == "" {
@@ -916,6 +960,7 @@ func (m *InstancesModule) instancesToTableBody(instances []ComputeEngineService.
// Access Control
shared.BoolToYesNo(instance.OSLoginEnabled),
shared.BoolToYesNo(instance.OSLogin2FAEnabled),
osLoginPolicy,
shared.BoolToYesNo(instance.BlockProjectSSHKeys),
shared.BoolToYesNo(instance.SerialPortEnabled),
// Protection - Delete protection is NOT expected for managed instances (they're ephemeral)
@@ -936,18 +981,20 @@ func (m *InstancesModule) instancesToTableBody(instances []ComputeEngineService.
// If instance has IAM bindings, create one row per binding
if len(instance.IAMBindings) > 0 {
for _, binding := range instance.IAMBindings {
row := make([]string, len(baseRow)+2)
row := make([]string, len(baseRow)+3)
copy(row, baseRow)
row[len(baseRow)] = binding.Role
row[len(baseRow)+1] = binding.Member
row[len(baseRow)+2] = shared.BindingSource(binding.Source)
body = append(body, row)
}
} else {
// No IAM bindings - single row
row := make([]string, len(baseRow)+2)
row := make([]string, len(baseRow)+3)
copy(row, baseRow)
row[len(baseRow)] = "-"
row[len(baseRow)+1] = "-"
row[len(baseRow)+2] = "-"
body = append(body, row)
}
}
+24
View File
@@ -7,6 +7,7 @@ import (
"sync"
KMSService "github.com/BishopFox/cloudfox/gcp/services/kmsService"
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
"github.com/BishopFox/cloudfox/gcp/shared"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
@@ -184,6 +185,26 @@ func (m *KMSModule) processProject(ctx context.Context, projectID string, logger
fmt.Sprintf("Could not enumerate KMS keys in project %s", projectID))
}
// Inject inherited project-level IAM bindings into each key
iamSvc := IAMService.New()
projectPolicies, projErr := iamSvc.Policies(projectID, "project")
if projErr == nil {
for i := range keys {
for _, pb := range projectPolicies {
if !shared.IsInheritedRole("cryptoKey", pb.Role) {
continue
}
for _, member := range pb.Members {
keys[i].IAMBindings = append(keys[i].IAMBindings, KMSService.IAMBinding{
Role: pb.Role,
Member: member,
Source: "Project",
})
}
}
}
}
// Thread-safe store per-project
m.mu.Lock()
m.ProjectKeyRings[projectID] = keyRings
@@ -295,6 +316,7 @@ func (m *KMSModule) getKeysHeader() []string {
"Public Decrypt",
"IAM Binding Role",
"IAM Binding Principal",
"Binding Source",
}
}
@@ -341,6 +363,7 @@ func (m *KMSModule) keysToTableBody(keys []KMSService.CryptoKeyInfo) [][]string
shared.BoolToYesNo(key.IsPublicDecrypt),
binding.Role,
binding.Member,
shared.BindingSource(binding.Source),
})
}
} else {
@@ -359,6 +382,7 @@ func (m *KMSModule) keysToTableBody(keys []KMSService.CryptoKeyInfo) [][]string
shared.BoolToYesNo(key.IsPublicDecrypt),
"-",
"-",
"-",
})
}
}
+216 -46
View File
@@ -1,13 +1,14 @@
package commands
import (
"github.com/BishopFox/cloudfox/gcp/shared"
"context"
"fmt"
"strings"
"sync"
organizationsservice "github.com/BishopFox/cloudfox/gcp/services/organizationsService"
orgpolicyservice "github.com/BishopFox/cloudfox/gcp/services/orgpolicyService"
"github.com/BishopFox/cloudfox/gcp/shared"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
@@ -23,6 +24,9 @@ var GCPOrgPoliciesCommand = &cobra.Command{
Organization policies control security constraints across GCP resources. This module
identifies policies that may be misconfigured or weakened, creating security risks.
Enumerates policies at organization, folder, and project levels. Org and folder level
enumeration requires orgpolicy.policies.list at those scopes and is best-effort.
Security-Relevant Policies Analyzed:
- Domain restrictions (iam.allowedPolicyMemberDomains)
- Service account key controls (iam.disableServiceAccountKeyCreation)
@@ -44,7 +48,9 @@ Risk Indicators:
type OrgPoliciesModule struct {
gcpinternal.BaseGCPModule
ProjectPolicies map[string][]orgpolicyservice.OrgPolicyInfo // projectID -> policies
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
OrgPolicies map[string][]orgpolicyservice.OrgPolicyInfo // orgID -> policies
FolderPolicies map[string][]orgpolicyservice.OrgPolicyInfo // folderID -> policies
LootMap map[string]map[string]*internal.LootFile // scopeID -> loot files
mu sync.Mutex
}
@@ -65,12 +71,18 @@ func runGCPOrgPoliciesCommand(cmd *cobra.Command, args []string) {
module := &OrgPoliciesModule{
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
ProjectPolicies: make(map[string][]orgpolicyservice.OrgPolicyInfo),
OrgPolicies: make(map[string][]orgpolicyservice.OrgPolicyInfo),
FolderPolicies: make(map[string][]orgpolicyservice.OrgPolicyInfo),
LootMap: make(map[string]map[string]*internal.LootFile),
}
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
}
func (m *OrgPoliciesModule) Execute(ctx context.Context, logger internal.Logger) {
// Enumerate org-level and folder-level policies (best-effort)
m.enumerateOrgAndFolderPolicies(logger)
// Enumerate project-level policies
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_ORGPOLICIES_MODULE_NAME, m.processProject)
allPolicies := m.getAllPolicies()
@@ -83,8 +95,91 @@ func (m *OrgPoliciesModule) Execute(ctx context.Context, logger internal.Logger)
m.writeOutput(ctx, logger)
}
func (m *OrgPoliciesModule) enumerateOrgAndFolderPolicies(logger internal.Logger) {
orgSvc := organizationsservice.New()
policySvc := orgpolicyservice.New()
// Discover organizations
orgs, err := orgSvc.SearchOrganizations()
if err != nil {
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
logger.InfoM("Could not enumerate organizations (may lack permissions)", globals.GCP_ORGPOLICIES_MODULE_NAME)
}
} else {
for _, org := range orgs {
orgID := strings.TrimPrefix(org.Name, "organizations/")
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
logger.InfoM(fmt.Sprintf("Enumerating org policies for organization: %s (%s)", org.DisplayName, orgID), globals.GCP_ORGPOLICIES_MODULE_NAME)
}
policies, err := policySvc.ListOrganizationPolicies(orgID, org.DisplayName)
if err != nil {
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
gcpinternal.HandleGCPError(err, logger, globals.GCP_ORGPOLICIES_MODULE_NAME,
fmt.Sprintf("Could not enumerate org policies for organization %s", orgID))
}
continue
}
m.mu.Lock()
m.OrgPolicies[orgID] = policies
m.initLootForScope(orgID)
for _, policy := range policies {
m.addPolicyToLoot(orgID, policy)
}
m.mu.Unlock()
if len(policies) > 0 {
logger.SuccessM(fmt.Sprintf("Found %d org-level policy(ies) for organization %s", len(policies), org.DisplayName), globals.GCP_ORGPOLICIES_MODULE_NAME)
}
}
}
// Discover folders
folders, err := orgSvc.SearchAllFolders()
if err != nil {
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
logger.InfoM("Could not enumerate folders (may lack permissions)", globals.GCP_ORGPOLICIES_MODULE_NAME)
}
} else {
for _, folder := range folders {
folderID := strings.TrimPrefix(folder.Name, "folders/")
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
logger.InfoM(fmt.Sprintf("Enumerating org policies for folder: %s (%s)", folder.DisplayName, folderID), globals.GCP_ORGPOLICIES_MODULE_NAME)
}
policies, err := policySvc.ListFolderPolicies(folderID, folder.DisplayName)
if err != nil {
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
gcpinternal.HandleGCPError(err, logger, globals.GCP_ORGPOLICIES_MODULE_NAME,
fmt.Sprintf("Could not enumerate org policies for folder %s", folderID))
}
continue
}
m.mu.Lock()
m.FolderPolicies[folderID] = policies
m.initLootForScope(folderID)
for _, policy := range policies {
m.addPolicyToLoot(folderID, policy)
}
m.mu.Unlock()
if len(policies) > 0 {
logger.SuccessM(fmt.Sprintf("Found %d folder-level policy(ies) for folder %s", len(policies), folder.DisplayName), globals.GCP_ORGPOLICIES_MODULE_NAME)
}
}
}
}
func (m *OrgPoliciesModule) getAllPolicies() []orgpolicyservice.OrgPolicyInfo {
var all []orgpolicyservice.OrgPolicyInfo
for _, policies := range m.OrgPolicies {
all = append(all, policies...)
}
for _, policies := range m.FolderPolicies {
all = append(all, policies...)
}
for _, policies := range m.ProjectPolicies {
all = append(all, policies...)
}
@@ -97,13 +192,7 @@ func (m *OrgPoliciesModule) processProject(ctx context.Context, projectID string
}
m.mu.Lock()
if m.LootMap[projectID] == nil {
m.LootMap[projectID] = make(map[string]*internal.LootFile)
m.LootMap[projectID]["orgpolicies-commands"] = &internal.LootFile{
Name: "orgpolicies-commands",
Contents: "# Organization Policy Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
}
}
m.initLootForScope(projectID)
m.mu.Unlock()
svc := orgpolicyservice.New()
@@ -115,6 +204,11 @@ func (m *OrgPoliciesModule) processProject(ctx context.Context, projectID string
return
}
// Set ScopeName from project name map
for i := range policies {
policies[i].ScopeName = m.GetProjectName(projectID)
}
m.mu.Lock()
m.ProjectPolicies[projectID] = policies
for _, policy := range policies {
@@ -123,8 +217,18 @@ func (m *OrgPoliciesModule) processProject(ctx context.Context, projectID string
m.mu.Unlock()
}
func (m *OrgPoliciesModule) addPolicyToLoot(projectID string, policy orgpolicyservice.OrgPolicyInfo) {
lootFile := m.LootMap[projectID]["orgpolicies-commands"]
func (m *OrgPoliciesModule) initLootForScope(scopeID string) {
if m.LootMap[scopeID] == nil {
m.LootMap[scopeID] = make(map[string]*internal.LootFile)
m.LootMap[scopeID]["orgpolicies-commands"] = &internal.LootFile{
Name: "orgpolicies-commands",
Contents: "# Organization Policy Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
}
}
}
func (m *OrgPoliciesModule) addPolicyToLoot(scopeID string, policy orgpolicyservice.OrgPolicyInfo) {
lootFile := m.LootMap[scopeID]["orgpolicies-commands"]
if lootFile == nil {
return
}
@@ -134,13 +238,19 @@ func (m *OrgPoliciesModule) addPolicyToLoot(projectID string, policy orgpolicyse
constraintName = strings.TrimPrefix(constraintName, "constraints/")
}
// Scope label for loot comments
scopeLabel := fmt.Sprintf("%s: %s", policy.ScopeType, policy.ScopeID)
if policy.ScopeName != "" {
scopeLabel = fmt.Sprintf("%s: %s (%s)", policy.ScopeType, policy.ScopeName, policy.ScopeID)
}
lootFile.Contents += fmt.Sprintf(
"# =============================================================================\n"+
"# CONSTRAINT: %s\n"+
"# =============================================================================\n"+
"# Project: %s\n",
"# Scope: %s\n",
policy.Constraint,
policy.ProjectID,
scopeLabel,
)
if policy.Description != "" {
@@ -162,22 +272,56 @@ func (m *OrgPoliciesModule) addPolicyToLoot(projectID string, policy orgpolicyse
lootFile.Contents += fmt.Sprintf("# Denied Values: %s\n", strings.Join(policy.DeniedValues, ", "))
}
lootFile.Contents += fmt.Sprintf(
"\n# === ENUMERATION COMMANDS ===\n\n"+
// Build enumeration commands based on scope type
lootFile.Contents += "\n# === ENUMERATION COMMANDS ===\n\n"
switch policy.ScopeType {
case "organization":
lootFile.Contents += fmt.Sprintf(
"# Describe this policy:\n"+
"gcloud org-policies describe %s --project=%s\n\n"+
"# Get effective policy (includes inheritance):\n"+
"gcloud org-policies describe %s --project=%s --effective\n\n"+
"# List all constraints for this project:\n"+
"gcloud org-policies list --project=%s\n\n",
constraintName, policy.ProjectID,
constraintName, policy.ProjectID,
policy.ProjectID,
)
"gcloud org-policies describe %s --organization=%s\n\n"+
"# Get effective policy (includes inheritance):\n"+
"gcloud org-policies describe %s --organization=%s --effective\n\n"+
"# List all constraints for this organization:\n"+
"gcloud org-policies list --organization=%s\n\n",
constraintName, policy.ScopeID,
constraintName, policy.ScopeID,
policy.ScopeID,
)
case "folder":
lootFile.Contents += fmt.Sprintf(
"# Describe this policy:\n"+
"gcloud org-policies describe %s --folder=%s\n\n"+
"# Get effective policy (includes inheritance):\n"+
"gcloud org-policies describe %s --folder=%s --effective\n\n"+
"# List all constraints for this folder:\n"+
"gcloud org-policies list --folder=%s\n\n",
constraintName, policy.ScopeID,
constraintName, policy.ScopeID,
policy.ScopeID,
)
default: // project
lootFile.Contents += fmt.Sprintf(
"# Describe this policy:\n"+
"gcloud org-policies describe %s --project=%s\n\n"+
"# Get effective policy (includes inheritance):\n"+
"gcloud org-policies describe %s --project=%s --effective\n\n"+
"# List all constraints for this project:\n"+
"gcloud org-policies list --project=%s\n\n",
constraintName, policy.ScopeID,
constraintName, policy.ScopeID,
policy.ScopeID,
)
}
// Exploit/bypass commands based on specific constraint types
lootFile.Contents += "# === EXPLOIT / BYPASS COMMANDS ===\n\n"
// For exploit commands, use project ID if available, otherwise use scope ID
targetProject := policy.ProjectID
if targetProject == "" {
targetProject = "<PROJECT_ID>"
}
switch constraintName {
case "iam.allowedPolicyMemberDomains":
if policy.AllowAll {
@@ -185,7 +329,7 @@ func (m *OrgPoliciesModule) addPolicyToLoot(projectID string, policy orgpolicyse
lootFile.Contents += fmt.Sprintf(
"# Grant access to external identity:\n"+
"gcloud projects add-iam-policy-binding %s --member=user:[email protected] --role=roles/viewer\n\n",
policy.ProjectID,
targetProject,
)
} else if !policy.Enforced {
lootFile.Contents += "# [FINDING] Domain restriction is NOT ENFORCED\n\n"
@@ -196,7 +340,7 @@ func (m *OrgPoliciesModule) addPolicyToLoot(projectID string, policy orgpolicyse
lootFile.Contents += "# [FINDING] SA key creation is NOT restricted - create keys for persistence:\n"
lootFile.Contents += fmt.Sprintf(
"gcloud iam service-accounts keys create /tmp/sa-key.json --iam-account=SA_EMAIL@%s.iam.gserviceaccount.com\n\n",
policy.ProjectID,
targetProject,
)
} else {
lootFile.Contents += "# SA key creation is restricted - try alternative persistence methods:\n" +
@@ -209,7 +353,7 @@ func (m *OrgPoliciesModule) addPolicyToLoot(projectID string, policy orgpolicyse
lootFile.Contents += "# [FINDING] SA creation is NOT restricted - create backdoor service accounts:\n"
lootFile.Contents += fmt.Sprintf(
"gcloud iam service-accounts create cloudfox-backdoor --display-name='System Service' --project=%s\n\n",
policy.ProjectID,
targetProject,
)
}
@@ -225,7 +369,7 @@ func (m *OrgPoliciesModule) addPolicyToLoot(projectID string, policy orgpolicyse
lootFile.Contents += fmt.Sprintf(
"# Add SSH key to project metadata:\n"+
"gcloud compute project-info add-metadata --metadata=ssh-keys=\"attacker:ssh-rsa AAAA...\" --project=%s\n\n",
policy.ProjectID,
targetProject,
)
}
@@ -283,8 +427,9 @@ func (m *OrgPoliciesModule) writeOutput(ctx context.Context, logger internal.Log
func (m *OrgPoliciesModule) getHeader() []string {
return []string{
"Project Name",
"Project ID",
"Scope Type",
"Scope ID",
"Scope Name",
"Constraint",
"Description",
"Enforced",
@@ -314,9 +459,15 @@ func (m *OrgPoliciesModule) policiesToTableBody(policies []orgpolicyservice.OrgP
deniedValues = strings.Join(policy.DeniedValues, ", ")
}
scopeName := policy.ScopeName
if scopeName == "" {
scopeName = "-"
}
body = append(body, []string{
m.GetProjectName(policy.ProjectID),
policy.ProjectID,
policy.ScopeType,
policy.ScopeID,
scopeName,
policy.Constraint,
description,
shared.BoolToYesNo(policy.Enforced),
@@ -330,9 +481,9 @@ func (m *OrgPoliciesModule) policiesToTableBody(policies []orgpolicyservice.OrgP
return body
}
func (m *OrgPoliciesModule) buildTablesForProject(projectID string) []internal.TableFile {
func (m *OrgPoliciesModule) buildTablesForScope(scopeID string, policies []orgpolicyservice.OrgPolicyInfo) []internal.TableFile {
var tableFiles []internal.TableFile
if policies, ok := m.ProjectPolicies[projectID]; ok && len(policies) > 0 {
if len(policies) > 0 {
tableFiles = append(tableFiles, internal.TableFile{
Name: "orgpolicies",
Header: m.getHeader(),
@@ -342,24 +493,43 @@ func (m *OrgPoliciesModule) buildTablesForProject(projectID string) []internal.T
return tableFiles
}
func (m *OrgPoliciesModule) collectLootForScope(scopeID string) []internal.LootFile {
var lootFiles []internal.LootFile
if scopeLoot, ok := m.LootMap[scopeID]; ok {
for _, loot := range scopeLoot {
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# WARNING: Only use with proper authorization\n\n") {
lootFiles = append(lootFiles, *loot)
}
}
}
return lootFiles
}
func (m *OrgPoliciesModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
outputData := internal.HierarchicalOutputData{
OrgLevelData: make(map[string]internal.CloudfoxOutput),
FolderLevelData: make(map[string]internal.CloudfoxOutput),
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
}
for projectID := range m.ProjectPolicies {
tableFiles := m.buildTablesForProject(projectID)
// Org-level data
for orgID, policies := range m.OrgPolicies {
tableFiles := m.buildTablesForScope(orgID, policies)
lootFiles := m.collectLootForScope(orgID)
outputData.OrgLevelData[orgID] = OrgPoliciesOutput{Table: tableFiles, Loot: lootFiles}
}
var lootFiles []internal.LootFile
if projectLoot, ok := m.LootMap[projectID]; ok {
for _, loot := range projectLoot {
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# WARNING: Only use with proper authorization\n\n") {
lootFiles = append(lootFiles, *loot)
}
}
}
// Folder-level data
for folderID, policies := range m.FolderPolicies {
tableFiles := m.buildTablesForScope(folderID, policies)
lootFiles := m.collectLootForScope(folderID)
outputData.FolderLevelData[folderID] = OrgPoliciesOutput{Table: tableFiles, Loot: lootFiles}
}
// Project-level data
for projectID, policies := range m.ProjectPolicies {
tableFiles := m.buildTablesForScope(projectID, policies)
lootFiles := m.collectLootForScope(projectID)
outputData.ProjectLevelData[projectID] = OrgPoliciesOutput{Table: tableFiles, Loot: lootFiles}
}
@@ -384,8 +554,8 @@ func (m *OrgPoliciesModule) writeFlatOutput(ctx context.Context, logger internal
}
var lootFiles []internal.LootFile
for _, projectLoot := range m.LootMap {
for _, loot := range projectLoot {
for _, scopeLoot := range m.LootMap {
for _, loot := range scopeLoot {
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# WARNING: Only use with proper authorization\n\n") {
lootFiles = append(lootFiles, *loot)
}
+145
View File
@@ -9,6 +9,7 @@ import (
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
orgsservice "github.com/BishopFox/cloudfox/gcp/services/organizationsService"
resourceiamservice "github.com/BishopFox/cloudfox/gcp/services/resourceIAMService"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
@@ -386,6 +387,138 @@ func (m *PermissionsModule) processProject(ctx context.Context, projectID string
}
}
// Enumerate SA-level IAM bindings (resource-level permissions on service accounts)
saList, saErr := iamService.ServiceAccountsBasic(projectID)
if saErr == nil {
saCtx := context.Background()
saBindingCount := 0
for _, sa := range saList {
saBindings, err := iamService.GetServiceAccountIAMBindings(saCtx, sa.Email, projectID)
if err != nil {
continue
}
for _, binding := range saBindings {
// Get permissions for this role
permissions, err := iamService.GetRolePermissions(saCtx, binding.Role)
if err != nil {
continue
}
for _, member := range binding.Members {
memberType := IAMService.GetMemberType(member)
memberEmail := extractMemberEmail(member)
for _, perm := range permissions {
isHighPriv := isHighPrivilegePermission(perm)
exploded := ExplodedPermission{
Entity: member,
EntityType: memberType,
EntityEmail: memberEmail,
Permission: perm,
Role: binding.Role,
RoleType: IAMService.GetRoleType(binding.Role),
ResourceScope: fmt.Sprintf("serviceAccount/%s", sa.Email),
ResourceScopeType: "serviceAccount",
ResourceScopeID: sa.Email,
ResourceScopeName: sa.Email,
IsInherited: false,
HasCondition: binding.HasCondition,
EffectiveProject: projectID,
ProjectName: m.GetProjectName(projectID),
IsHighPrivilege: isHighPriv,
}
if binding.ConditionInfo != nil {
exploded.ConditionTitle = binding.ConditionInfo.Title
exploded.Condition = binding.ConditionInfo.Title
}
// Detect cross-project access
if memberType == "ServiceAccount" {
saProject := extractProjectFromPrincipal(memberEmail, m.OrgCache)
if saProject != "" && saProject != projectID {
exploded.IsCrossProject = true
exploded.SourceProject = saProject
}
}
projectPerms = append(projectPerms, exploded)
saBindingCount++
}
}
}
}
if saBindingCount > 0 && globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
logger.InfoM(fmt.Sprintf("Enumerated %d SA-level permission entries across %d service account(s)", saBindingCount, len(saList)), globals.GCP_PERMISSIONS_MODULE_NAME)
}
}
// Enumerate resource-level IAM bindings (buckets, datasets, secrets, KMS, functions, Cloud Run, instances)
resourceIAMSvc := resourceiamservice.New()
resourceBindings, resourceErr := resourceIAMSvc.GetAllResourceIAM(ctx, projectID)
if resourceErr == nil && len(resourceBindings) > 0 {
resCtx := context.Background()
resourcePermCount := 0
for _, rb := range resourceBindings {
// Skip serviceAccount type here since we already handled it above
if rb.ResourceType == "serviceAccount" {
continue
}
// Get permissions for this role
permissions, err := iamService.GetRolePermissions(resCtx, rb.Role)
if err != nil {
continue
}
memberType := IAMService.GetMemberType(rb.Member)
memberEmail := extractMemberEmail(rb.Member)
for _, perm := range permissions {
isHighPriv := isHighPrivilegePermission(perm)
exploded := ExplodedPermission{
Entity: rb.Member,
EntityType: memberType,
EntityEmail: memberEmail,
Permission: perm,
Role: rb.Role,
RoleType: IAMService.GetRoleType(rb.Role),
ResourceScope: fmt.Sprintf("%s/%s", rb.ResourceType, rb.ResourceID),
ResourceScopeType: rb.ResourceType,
ResourceScopeID: rb.ResourceID,
ResourceScopeName: rb.ResourceName,
IsInherited: false,
HasCondition: rb.HasCondition,
EffectiveProject: projectID,
ProjectName: m.GetProjectName(projectID),
IsHighPrivilege: isHighPriv,
}
if rb.HasCondition && rb.ConditionTitle != "" {
exploded.ConditionTitle = rb.ConditionTitle
exploded.Condition = rb.ConditionTitle
}
// Detect cross-project access
if memberType == "ServiceAccount" {
saProject := extractProjectFromPrincipal(memberEmail, m.OrgCache)
if saProject != "" && saProject != projectID {
exploded.IsCrossProject = true
exploded.SourceProject = saProject
}
}
projectPerms = append(projectPerms, exploded)
resourcePermCount++
}
}
if resourcePermCount > 0 && globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
logger.InfoM(fmt.Sprintf("Enumerated %d resource-level permission entries in project %s", resourcePermCount, projectID), globals.GCP_PERMISSIONS_MODULE_NAME)
}
}
m.mu.Lock()
// Store per-project permissions
m.ProjectPerms[projectID] = append(m.ProjectPerms[projectID], projectPerms...)
@@ -435,11 +568,23 @@ func (m *PermissionsModule) getScopeName(scopeType, scopeID string) string {
return scopeID
case "folder":
return scopeID // Could be enhanced to lookup folder names
case "serviceAccount", "bucket", "dataset", "topic", "subscription",
"secret", "cryptoKey", "function", "cloudrun", "instance":
return scopeID // Resource ID is the scope name
default:
return scopeID
}
}
// extractMemberEmail strips the type prefix from a member string (e.g., "user:[email protected]" -> "[email protected]")
func extractMemberEmail(member string) string {
parts := strings.SplitN(member, ":", 2)
if len(parts) == 2 {
return parts[1]
}
return member
}
func parseConditionTitle(condition string) string {
// Try to extract title from condition if it looks like a struct
if strings.Contains(condition, "title:") {
+41
View File
@@ -6,6 +6,7 @@ import (
"strings"
"sync"
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
PubSubService "github.com/BishopFox/cloudfox/gcp/services/pubsubService"
"github.com/BishopFox/cloudfox/gcp/shared"
"github.com/BishopFox/cloudfox/globals"
@@ -187,6 +188,40 @@ func (m *PubSubModule) processProject(ctx context.Context, projectID string, log
subs = subsResult
}
// Inject inherited project-level IAM bindings
iamSvc := IAMService.New()
projectPolicies, projErr := iamSvc.Policies(projectID, "project")
if projErr == nil {
for i := range topics {
for _, pb := range projectPolicies {
if !shared.IsInheritedRole("topic", pb.Role) {
continue
}
for _, member := range pb.Members {
topics[i].IAMBindings = append(topics[i].IAMBindings, PubSubService.IAMBinding{
Role: pb.Role,
Member: member,
Source: "Project",
})
}
}
}
for i := range subs {
for _, pb := range projectPolicies {
if !shared.IsInheritedRole("subscription", pb.Role) {
continue
}
for _, member := range pb.Members {
subs[i].IAMBindings = append(subs[i].IAMBindings, PubSubService.IAMBinding{
Role: pb.Role,
Member: member,
Source: "Project",
})
}
}
}
}
// Thread-safe store per-project
m.mu.Lock()
m.ProjectTopics[projectID] = topics
@@ -615,6 +650,7 @@ func (m *PubSubModule) getTopicsHeader() []string {
"Public Publish",
"IAM Binding Role",
"IAM Binding Principal",
"Binding Source",
}
}
@@ -633,6 +669,7 @@ func (m *PubSubModule) getSubsHeader() []string {
"Public Subscribe",
"IAM Binding Role",
"IAM Binding Principal",
"Binding Source",
}
}
@@ -688,6 +725,7 @@ func (m *PubSubModule) topicsToTableBody(topics []PubSubService.TopicInfo) [][]s
publicPublish,
binding.Role,
binding.Member,
shared.BindingSource(binding.Source),
})
}
} else {
@@ -701,6 +739,7 @@ func (m *PubSubModule) topicsToTableBody(topics []PubSubService.TopicInfo) [][]s
publicPublish,
"-",
"-",
"-",
})
}
}
@@ -782,6 +821,7 @@ func (m *PubSubModule) subsToTableBody(subs []PubSubService.SubscriptionInfo) []
publicSubscribe,
binding.Role,
binding.Member,
shared.BindingSource(binding.Source),
})
}
} else {
@@ -799,6 +839,7 @@ func (m *PubSubModule) subsToTableBody(subs []PubSubService.SubscriptionInfo) []
publicSubscribe,
"-",
"-",
"-",
})
}
}
+47
View File
@@ -30,6 +30,8 @@ Supported Resource Types:
- Cloud KMS keys
- Cloud Functions
- Cloud Run services
- Service Accounts
- Compute Instances
Key Findings:
- Public access (allUsers/allAuthenticatedUsers)
@@ -275,6 +277,28 @@ func (m *ResourceIAMModule) addResourceToLoot(b resourceiamservice.ResourceIAMBi
b.ResourceID, b.ProjectID,
b.ResourceID, b.ProjectID,
)
case "serviceAccount":
lootFile.Contents += fmt.Sprintf(
"# Service Account: %s (Member: %s, Role: %s)\n"+
"gcloud iam service-accounts describe %s --project=%s\n"+
"gcloud iam service-accounts get-iam-policy %s --project=%s\n"+
"gcloud iam service-accounts keys list --iam-account=%s --project=%s\n\n",
b.ResourceID, b.Member, b.Role,
b.ResourceID, b.ProjectID,
b.ResourceID, b.ProjectID,
b.ResourceID, b.ProjectID,
)
case "instance":
// Extract zone from ResourceName (projects/{proj}/zones/{zone}/instances/{name})
zone := extractZoneFromResourceName(b.ResourceName)
lootFile.Contents += fmt.Sprintf(
"# Compute Instance: %s (Member: %s, Role: %s)\n"+
"gcloud compute instances describe %s --zone=%s --project=%s\n"+
"gcloud compute instances get-iam-policy %s --zone=%s --project=%s\n\n",
b.ResourceID, b.Member, b.Role,
b.ResourceID, zone, b.ProjectID,
b.ResourceID, zone, b.ProjectID,
)
}
}
@@ -306,9 +330,32 @@ func (m *ResourceIAMModule) addPublicResourceToLoot(b resourceiamservice.Resourc
"# Cloud Run service may be publicly accessible\ngcloud run services describe %s --project=%s\n\n",
b.ResourceID, b.ProjectID,
)
case "serviceAccount":
lootFile.Contents += fmt.Sprintf(
"# Service account has public IAM binding\ngcloud iam service-accounts describe %s --project=%s\n\n",
b.ResourceID, b.ProjectID,
)
case "instance":
zone := extractZoneFromResourceName(b.ResourceName)
lootFile.Contents += fmt.Sprintf(
"# Compute instance has public IAM binding\ngcloud compute instances describe %s --zone=%s --project=%s\n\n",
b.ResourceID, zone, b.ProjectID,
)
}
}
// extractZoneFromResourceName extracts the zone from a resource name like
// "projects/{proj}/zones/{zone}/instances/{name}"
func extractZoneFromResourceName(resourceName string) string {
parts := strings.Split(resourceName, "/")
for i, part := range parts {
if part == "zones" && i+1 < len(parts) {
return parts[i+1]
}
}
return ""
}
// resourceKey creates a unique key for a resource to group bindings
func resourceKey(b resourceiamservice.ResourceIAMBinding) string {
return fmt.Sprintf("%s|%s|%s", b.ProjectID, b.ResourceType, b.ResourceName)
+29 -2
View File
@@ -8,6 +8,8 @@ import (
secretmanager "cloud.google.com/go/secretmanager/apiv1"
SecretsService "github.com/BishopFox/cloudfox/gcp/services/secretsService"
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
"google.golang.org/api/option"
"github.com/BishopFox/cloudfox/gcp/shared"
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
"github.com/BishopFox/cloudfox/globals"
@@ -78,8 +80,12 @@ func runGCPSecretsCommand(cmd *cobra.Command, args []string) {
return // Error already logged
}
// Create Secret Manager client
client, err := secretmanager.NewClient(cmdCtx.Ctx)
// Create Secret Manager client (uses impersonated credentials if --impersonate-sa is set)
var smOpts []option.ClientOption
if s := gcpinternal.GetDefaultSession(); s != nil {
smOpts = append(smOpts, s.GetClientOption())
}
client, err := secretmanager.NewClient(cmdCtx.Ctx, smOpts...)
if err != nil {
cmdCtx.Logger.ErrorM(fmt.Sprintf("Failed to create Secret Manager client: %v", err), globals.GCP_SECRETS_MODULE_NAME)
return
@@ -151,6 +157,24 @@ func (m *SecretsModule) processProject(ctx context.Context, projectID string, lo
return
}
// Inject inherited project-level IAM bindings into each secret
iamSvc := IAMService.New()
projectPolicies, projErr := iamSvc.Policies(projectID, "project")
if projErr == nil {
for i := range secrets {
for _, pb := range projectPolicies {
if !shared.IsInheritedRole("secret", pb.Role) {
continue
}
secrets[i].IAMBindings = append(secrets[i].IAMBindings, SecretsService.IAMBinding{
Role: pb.Role,
Members: pb.Members,
Source: "Project",
})
}
}
}
// Thread-safe store per-project
m.mu.Lock()
m.ProjectSecrets[projectID] = secrets
@@ -399,6 +423,7 @@ func (m *SecretsModule) getTableHeader() []string {
"IAM Binding Role",
"Principal Type",
"IAM Binding Principal",
"Binding Source",
"Principal Attack Paths",
}
}
@@ -472,6 +497,7 @@ func (m *SecretsModule) secretsToTableBody(secrets []SecretsService.SecretInfo)
binding.Role,
memberType,
member,
shared.BindingSource(binding.Source),
attackPaths,
})
}
@@ -494,6 +520,7 @@ func (m *SecretsModule) secretsToTableBody(secrets []SecretsService.SecretInfo)
"-",
"-",
"-",
"-",
})
}
}
+117 -72
View File
@@ -36,6 +36,41 @@ Column Descriptions:
Run: runGCPServiceAccountsCommand,
}
// SAIAMBinding represents a raw IAM binding on a service account (role + member)
type SAIAMBinding struct {
Role string
Member string
Source string // "SA" (direct on SA resource) or "Project" (inherited from project IAM)
}
// saAccessRoles are project-level roles that implicitly grant access to all
// service accounts in the project. When a principal holds one of these at the
// project level, they can interact with every SA in that project.
var saAccessRoles = map[string]bool{
"roles/owner": true,
"roles/editor": true,
"roles/iam.serviceAccountAdmin": true,
"roles/iam.serviceAccountKeyAdmin": true,
"roles/iam.serviceAccountTokenCreator": true,
"roles/iam.serviceAccountUser": true,
"roles/iam.workloadIdentityUser": true,
"roles/iam.serviceAccountOpenIdTokenCreator": true,
}
// saRelevantPermissions are individual permissions that grant meaningful access
// to service accounts. Custom roles containing any of these should be treated
// as SA-access roles for inherited binding purposes.
var saRelevantPermissions = map[string]bool{
"iam.serviceAccounts.getAccessToken": true,
"iam.serviceAccountKeys.create": true,
"iam.serviceAccounts.signBlob": true,
"iam.serviceAccounts.signJwt": true,
"iam.serviceAccounts.implicitDelegation": true,
"iam.serviceAccounts.actAs": true,
"iam.serviceAccounts.setIamPolicy": true,
"iam.serviceAccounts.getOpenIdToken": true,
}
// ServiceAccountAnalysis extends ServiceAccountInfo with security analysis
type ServiceAccountAnalysis struct {
IAMService.ServiceAccountInfo
@@ -46,6 +81,8 @@ type ServiceAccountAnalysis struct {
HasOldKeys bool // Keys older than 90 days
// Pentest: Impersonation analysis
ImpersonationInfo *IAMService.SAImpersonationInfo
// Raw IAM bindings on this service account (actual roles, not friendly names)
RawIAMBindings []SAIAMBinding
}
// ------------------------------
@@ -190,6 +227,70 @@ func (m *ServiceAccountsModule) processProject(ctx context.Context, projectID st
// Silently skip if we can't get roles - user may not have IAM permissions
}
// Get raw SA-level IAM bindings for each service account (direct bindings)
saIAMBindingsMap := make(map[string][]SAIAMBinding)
bindingCtx := context.Background()
for _, sa := range serviceAccounts {
rawBindings, err := iamService.GetServiceAccountIAMBindings(bindingCtx, sa.Email, projectID)
if err == nil {
for _, pb := range rawBindings {
for _, member := range pb.Members {
saIAMBindingsMap[sa.Email] = append(saIAMBindingsMap[sa.Email], SAIAMBinding{
Role: pb.Role,
Member: member,
Source: "SA",
})
}
}
}
}
// Get project-level IAM bindings that grant implicit access to all SAs.
// These are inherited, not set directly on the SA resource.
projectBindings, err := iamService.Policies(projectID, "project")
if err == nil {
// Cache for custom role resolution so we only resolve each role once
resolvedRoles := make(map[string]bool) // role -> hasSAPermissions
roleCtx := context.Background()
for _, pb := range projectBindings {
include := saAccessRoles[pb.Role]
// For roles not in the hardcoded list, resolve permissions to check
// for SA-relevant permissions (catches custom roles like privesc_02_createSAKey)
if !include {
if cached, ok := resolvedRoles[pb.Role]; ok {
include = cached
} else {
perms, permErr := iamService.GetRolePermissions(roleCtx, pb.Role)
if permErr == nil {
for _, p := range perms {
if saRelevantPermissions[p] {
include = true
break
}
}
}
resolvedRoles[pb.Role] = include
}
}
if !include {
continue
}
for _, member := range pb.Members {
// Add this inherited binding to every SA in the project
for _, sa := range serviceAccounts {
saIAMBindingsMap[sa.Email] = append(saIAMBindingsMap[sa.Email], SAIAMBinding{
Role: pb.Role,
Member: member,
Source: "Project",
})
}
}
}
}
// Analyze each service account
var analyzedSAs []ServiceAccountAnalysis
for _, sa := range serviceAccounts {
@@ -202,6 +303,10 @@ func (m *ServiceAccountsModule) processProject(ctx context.Context, projectID st
if roles, ok := saRoles[sa.Email]; ok {
analyzed.Roles = roles
}
// Attach raw IAM bindings
if bindings, ok := saIAMBindingsMap[sa.Email]; ok {
analyzed.RawIAMBindings = bindings
}
analyzedSAs = append(analyzedSAs, analyzed)
}
@@ -481,6 +586,7 @@ func (m *ServiceAccountsModule) getTableHeader() []string {
// Impersonation
"IAM Binding Role",
"IAM Binding Principal",
"Binding Source",
}
}
@@ -541,85 +647,24 @@ func (m *ServiceAccountsModule) serviceAccountsToTableBody(serviceAccounts []Ser
}
}
// Format roles for display
rolesDisplay := IAMService.FormatRolesShort(sa.Roles)
// Format roles for display (full role names)
rolesDisplay := "-"
if len(sa.Roles) > 0 {
rolesDisplay = strings.Join(sa.Roles, ", ")
}
// Build IAM bindings from impersonation info
// Row order: Identity (Project, Email, Display Name, Disabled, Default SA),
// Keys (User Managed Keys, Google Managed Keys, Oldest Key Age),
// Permissions (DWD, Roles, SA Attack Paths),
// Impersonation (IAM Binding Role, IAM Binding Principal)
// Build IAM binding rows from raw bindings (actual roles, not friendly names)
hasBindings := false
if sa.ImpersonationInfo != nil {
for _, member := range sa.ImpersonationInfo.TokenCreators {
email := extractEmailFromMember(member)
if len(sa.RawIAMBindings) > 0 {
for _, binding := range sa.RawIAMBindings {
email := extractEmailFromMember(binding.Member)
if email != sa.Email {
hasBindings = true
body = append(body, []string{
m.GetProjectName(sa.ProjectID), sa.Email, sa.DisplayName, disabled, defaultSA,
userKeys, googleKeys, oldestKeyAge,
dwd, rolesDisplay, attackPaths,
"TokenCreator", member,
})
}
}
for _, member := range sa.ImpersonationInfo.KeyCreators {
email := extractEmailFromMember(member)
if email != sa.Email {
hasBindings = true
body = append(body, []string{
m.GetProjectName(sa.ProjectID), sa.Email, sa.DisplayName, disabled, defaultSA,
userKeys, googleKeys, oldestKeyAge,
dwd, rolesDisplay, attackPaths,
"KeyAdmin", member,
})
}
}
for _, member := range sa.ImpersonationInfo.ActAsUsers {
email := extractEmailFromMember(member)
if email != sa.Email {
hasBindings = true
body = append(body, []string{
m.GetProjectName(sa.ProjectID), sa.Email, sa.DisplayName, disabled, defaultSA,
userKeys, googleKeys, oldestKeyAge,
dwd, rolesDisplay, attackPaths,
"ActAs", member,
})
}
}
for _, member := range sa.ImpersonationInfo.SAAdmins {
email := extractEmailFromMember(member)
if email != sa.Email {
hasBindings = true
body = append(body, []string{
m.GetProjectName(sa.ProjectID), sa.Email, sa.DisplayName, disabled, defaultSA,
userKeys, googleKeys, oldestKeyAge,
dwd, rolesDisplay, attackPaths,
"SAAdmin", member,
})
}
}
for _, member := range sa.ImpersonationInfo.SignBlobUsers {
email := extractEmailFromMember(member)
if email != sa.Email {
hasBindings = true
body = append(body, []string{
m.GetProjectName(sa.ProjectID), sa.Email, sa.DisplayName, disabled, defaultSA,
userKeys, googleKeys, oldestKeyAge,
dwd, rolesDisplay, attackPaths,
"SignBlob", member,
})
}
}
for _, member := range sa.ImpersonationInfo.SignJwtUsers {
email := extractEmailFromMember(member)
if email != sa.Email {
hasBindings = true
body = append(body, []string{
m.GetProjectName(sa.ProjectID), sa.Email, sa.DisplayName, disabled, defaultSA,
userKeys, googleKeys, oldestKeyAge,
dwd, rolesDisplay, attackPaths,
"SignJwt", member,
binding.Role, binding.Member, binding.Source,
})
}
}
@@ -630,7 +675,7 @@ func (m *ServiceAccountsModule) serviceAccountsToTableBody(serviceAccounts []Ser
m.GetProjectName(sa.ProjectID), sa.Email, sa.DisplayName, disabled, defaultSA,
userKeys, googleKeys, oldestKeyAge,
dwd, rolesDisplay, attackPaths,
"-", "-",
"-", "-", "-",
})
}
}
+35 -6
View File
@@ -237,13 +237,42 @@ func (m *WhoAmIModule) Execute(ctx context.Context, logger internal.Logger) {
}
// Step 1: Get current identity
// When a non-ADC session exists (--access-token, --key-file, --impersonate-sa),
// resolve identity from the session's token. Never fall back to ADC when the
// user provided explicit credentials, as that would report the wrong identity.
oauthService := OAuthService.NewOAuthService()
principal, err := oauthService.WhoAmI()
if err != nil {
parsedErr := gcpinternal.ParseGCPError(err, "oauth2.googleapis.com")
gcpinternal.HandleGCPError(parsedErr, logger, globals.GCP_WHOAMI_MODULE_NAME,
"Could not retrieve token info")
return
var principal *OAuthService.Principal
var err error
if session := gcpinternal.GetDefaultSession(); session != nil {
// Try 1: Use the session's already-resolved email (populated during session creation
// from key file client_email, tokeninfo, or impersonation target).
if session.GetEmail() != "" {
principal = &OAuthService.Principal{Email: session.GetEmail()}
} else {
// Try 2: Query tokeninfo with the session's access token.
token, tokenErr := session.GetToken(ctx)
if tokenErr == nil && token != "" {
principal, err = oauthService.WhoAmIWithToken(token)
}
}
// If we still have no identity, error out. Do NOT fall back to ADC.
if principal == nil || principal.Email == "" {
logger.ErrorM("Could not determine identity for the provided credentials. "+
"The access token may not include the email claim. "+
"Try using --key-file or --impersonate-sa instead.", globals.GCP_WHOAMI_MODULE_NAME)
return
}
} else {
// No explicit session: use Application Default Credentials
principal, err = oauthService.WhoAmI()
if err != nil {
parsedErr := gcpinternal.ParseGCPError(err, "oauth2.googleapis.com")
gcpinternal.HandleGCPError(parsedErr, logger, globals.GCP_WHOAMI_MODULE_NAME,
"Could not retrieve token info")
return
}
}
m.Identity = IdentityContext{
@@ -15,7 +15,7 @@ type AccessPolicyService struct {
}
func New() *AccessPolicyService {
return &AccessPolicyService{}
return &AccessPolicyService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *AccessPolicyService {
@@ -0,0 +1,502 @@
package accesstokensservice
import (
"bufio"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/BishopFox/cloudfox/globals"
_ "modernc.org/sqlite"
)
// AccessToken represents a cached access token from access_tokens.db
type AccessToken struct {
AccountID string
AccessToken string
TokenExpiry time.Time
RAPTToken string
IDToken string
}
// RefreshToken represents a cached refresh token from credentials.db
type RefreshToken struct {
AccountID string
RefreshToken string
TokenURI string
ClientID string
ClientSecret string
RAPTToken string
Type string
}
// ADCCredential represents application default credentials
type ADCCredential struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
RefreshToken string `json:"refresh_token"`
Type string `json:"type"`
QuotaProject string `json:"quota_project_id"`
}
// GcloudProfile represents a gcloud configuration profile
type GcloudProfile struct {
Name string
Account string
Project string
}
// CredentialSummary is the unified view of a discovered credential
type CredentialSummary struct {
Account string
Type string // "access_token", "refresh_token", "adc", "sa_key"
Source string // file path or db name
Validity string // "ACTIVE", "EXPIRED", "UNKNOWN"
HasRefresh bool
RAPTStatus string // "ENROLLED", "NOT_ENROLLED", "UNKNOWN"
Profile string
IsActive bool
Project string
AccessToken string
}
// AccessTokensService reads locally cached gcloud credentials
type AccessTokensService struct {
configDir string
}
// New creates an AccessTokensService using the default or env-overridden config dir
func New() *AccessTokensService {
configDir := getConfigDir()
return &AccessTokensService{configDir: configDir}
}
// getConfigDir returns the gcloud config directory, respecting CLOUDSDK_CONFIG_DIR
func getConfigDir() string {
if dir := os.Getenv("CLOUDSDK_CONFIG_DIR"); dir != "" {
return dir
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, ".config", "gcloud")
}
// GetAllCredentials discovers all locally cached gcloud credentials
func (s *AccessTokensService) GetAllCredentials() ([]CredentialSummary, error) {
if s.configDir == "" {
return nil, fmt.Errorf("could not determine gcloud config directory")
}
var summaries []CredentialSummary
// 1. Read access tokens from access_tokens.db
accessTokens, err := s.readAccessTokensDB()
if err != nil && globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
fmt.Fprintf(os.Stderr, "[access-tokens] Could not read access_tokens.db: %v\n", err)
}
// 2. Read refresh tokens from credentials.db
refreshTokens, err := s.readCredentialsDB()
if err != nil && globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
fmt.Fprintf(os.Stderr, "[access-tokens] Could not read credentials.db: %v\n", err)
}
// 3. Read ADC
adc, err := s.readADC()
if err != nil && globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
fmt.Fprintf(os.Stderr, "[access-tokens] Could not read application_default_credentials.json: %v\n", err)
}
// 4. Read profiles
activeConfig := s.readActiveConfig()
profiles := s.readAllProfiles()
// 5. Check GOOGLE_APPLICATION_CREDENTIALS
saKeyPath := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")
// Build refresh token lookup by account_id
refreshMap := make(map[string]RefreshToken)
for _, rt := range refreshTokens {
refreshMap[rt.AccountID] = rt
}
// Build profile lookup by account
profileMap := make(map[string]GcloudProfile)
for _, p := range profiles {
profileMap[p.Account] = p
}
// Process access tokens
for _, at := range accessTokens {
summary := CredentialSummary{
Account: at.AccountID,
Type: "access_token",
Source: "access_tokens.db",
RAPTStatus: "UNKNOWN",
}
// Check token validity
if at.TokenExpiry.IsZero() {
summary.Validity = "UNKNOWN"
} else if time.Now().Before(at.TokenExpiry) {
summary.Validity = "ACTIVE"
} else {
summary.Validity = "EXPIRED"
}
// Check for matching refresh token
if rt, ok := refreshMap[at.AccountID]; ok {
summary.HasRefresh = true
if rt.RAPTToken != "" {
summary.RAPTStatus = "ENROLLED"
} else {
summary.RAPTStatus = "NOT_ENROLLED"
}
}
// Check RAPT from access token itself
if at.RAPTToken != "" {
summary.RAPTStatus = "ENROLLED"
}
// Match profile
if p, ok := profileMap[at.AccountID]; ok {
summary.Profile = p.Name
summary.Project = p.Project
summary.IsActive = (p.Name == activeConfig)
}
// Full access token
summary.AccessToken = at.AccessToken
summaries = append(summaries, summary)
}
// Process refresh tokens that don't have a matching access token
accessTokenAccounts := make(map[string]bool)
for _, at := range accessTokens {
accessTokenAccounts[at.AccountID] = true
}
for _, rt := range refreshTokens {
if accessTokenAccounts[rt.AccountID] {
continue // Already covered by access token entry
}
summary := CredentialSummary{
Account: rt.AccountID,
Type: "refresh_token",
Source: "credentials.db",
Validity: "N/A",
HasRefresh: true,
RAPTStatus: "UNKNOWN",
}
if rt.RAPTToken != "" {
summary.RAPTStatus = "ENROLLED"
} else {
summary.RAPTStatus = "NOT_ENROLLED"
}
if p, ok := profileMap[rt.AccountID]; ok {
summary.Profile = p.Name
summary.Project = p.Project
summary.IsActive = (p.Name == activeConfig)
}
summaries = append(summaries, summary)
}
// Process ADC
if adc != nil {
adcAccount := "application_default"
// Try to correlate ADC with an account via refresh token hash
if adc.RefreshToken != "" {
hash := sha256Hash(adc.RefreshToken)
for _, rt := range refreshTokens {
if rt.RefreshToken != "" && sha256Hash(rt.RefreshToken) == hash {
adcAccount = rt.AccountID
break
}
}
}
summary := CredentialSummary{
Account: adcAccount,
Type: "adc",
Source: "application_default_credentials.json",
Validity: "N/A",
HasRefresh: adc.RefreshToken != "",
RAPTStatus: "UNKNOWN",
Project: adc.QuotaProject,
}
if p, ok := profileMap[adcAccount]; ok {
summary.Profile = p.Name
summary.IsActive = (p.Name == activeConfig)
}
summaries = append(summaries, summary)
}
// Process SA key file from GOOGLE_APPLICATION_CREDENTIALS
if saKeyPath != "" {
if _, err := os.Stat(saKeyPath); err == nil {
summary := CredentialSummary{
Account: "GOOGLE_APPLICATION_CREDENTIALS",
Type: "sa_key",
Source: saKeyPath,
Validity: "ACTIVE",
HasRefresh: false,
RAPTStatus: "N/A",
}
// Try to read the SA key file for the email
saEmail := readSAKeyEmail(saKeyPath)
if saEmail != "" {
summary.Account = saEmail
}
summaries = append(summaries, summary)
}
}
return summaries, nil
}
// readAccessTokensDB reads access_tokens.db SQLite database
func (s *AccessTokensService) readAccessTokensDB() ([]AccessToken, error) {
dbPath := filepath.Join(s.configDir, "access_tokens.db")
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return nil, fmt.Errorf("file not found: %s", dbPath)
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("could not open %s: %w", dbPath, err)
}
defer db.Close()
rows, err := db.Query("SELECT account_id, access_token, token_expiry, rapt_token, id_token FROM access_tokens")
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
defer rows.Close()
var tokens []AccessToken
for rows.Next() {
var t AccessToken
var expiryStr, raptToken, idToken sql.NullString
var accessToken sql.NullString
err := rows.Scan(&t.AccountID, &accessToken, &expiryStr, &raptToken, &idToken)
if err != nil {
continue
}
if accessToken.Valid {
t.AccessToken = accessToken.String
}
if expiryStr.Valid && expiryStr.String != "" {
// Try multiple time formats
for _, layout := range []string{
time.RFC3339,
"2006-01-02T15:04:05Z",
"2006-01-02 15:04:05",
time.RFC3339Nano,
} {
if parsed, err := time.Parse(layout, expiryStr.String); err == nil {
t.TokenExpiry = parsed
break
}
}
}
if raptToken.Valid {
t.RAPTToken = raptToken.String
}
if idToken.Valid {
t.IDToken = idToken.String
}
tokens = append(tokens, t)
}
return tokens, nil
}
// readCredentialsDB reads credentials.db SQLite database
func (s *AccessTokensService) readCredentialsDB() ([]RefreshToken, error) {
dbPath := filepath.Join(s.configDir, "credentials.db")
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return nil, fmt.Errorf("file not found: %s", dbPath)
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return nil, fmt.Errorf("could not open %s: %w", dbPath, err)
}
defer db.Close()
rows, err := db.Query("SELECT account_id, value FROM credentials")
if err != nil {
return nil, fmt.Errorf("query failed: %w", err)
}
defer rows.Close()
var tokens []RefreshToken
for rows.Next() {
var accountID string
var valueJSON sql.NullString
err := rows.Scan(&accountID, &valueJSON)
if err != nil {
continue
}
rt := RefreshToken{
AccountID: accountID,
}
if valueJSON.Valid && valueJSON.String != "" {
var cred struct {
RefreshToken string `json:"refresh_token"`
TokenURI string `json:"token_uri"`
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
RAPTToken string `json:"rapt_token"`
Type string `json:"type"`
}
if err := json.Unmarshal([]byte(valueJSON.String), &cred); err == nil {
rt.RefreshToken = cred.RefreshToken
rt.TokenURI = cred.TokenURI
rt.ClientID = cred.ClientID
rt.ClientSecret = cred.ClientSecret
rt.RAPTToken = cred.RAPTToken
rt.Type = cred.Type
}
}
tokens = append(tokens, rt)
}
return tokens, nil
}
// readADC reads application_default_credentials.json
func (s *AccessTokensService) readADC() (*ADCCredential, error) {
// Check well-known location
adcPath := filepath.Join(s.configDir, "application_default_credentials.json")
if _, err := os.Stat(adcPath); os.IsNotExist(err) {
return nil, fmt.Errorf("file not found: %s", adcPath)
}
data, err := os.ReadFile(adcPath)
if err != nil {
return nil, fmt.Errorf("could not read %s: %w", adcPath, err)
}
var adc ADCCredential
if err := json.Unmarshal(data, &adc); err != nil {
return nil, fmt.Errorf("could not parse %s: %w", adcPath, err)
}
return &adc, nil
}
// readActiveConfig reads the active_config file to determine the active profile name
func (s *AccessTokensService) readActiveConfig() string {
path := filepath.Join(s.configDir, "active_config")
data, err := os.ReadFile(path)
if err != nil {
return "default"
}
name := strings.TrimSpace(string(data))
if name == "" {
return "default"
}
return name
}
// readAllProfiles scans all config_* files in the configurations directory
func (s *AccessTokensService) readAllProfiles() []GcloudProfile {
configsDir := filepath.Join(s.configDir, "configurations")
entries, err := os.ReadDir(configsDir)
if err != nil {
return nil
}
var profiles []GcloudProfile
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasPrefix(name, "config_") {
continue
}
profileName := strings.TrimPrefix(name, "config_")
path := filepath.Join(configsDir, name)
profile := parseConfigFile(path, profileName)
profiles = append(profiles, profile)
}
return profiles
}
// parseConfigFile reads a gcloud INI-style configuration file
func parseConfigFile(path string, profileName string) GcloudProfile {
profile := GcloudProfile{Name: profileName}
file, err := os.Open(path)
if err != nil {
return profile
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
// Skip section headers
if strings.HasPrefix(line, "[") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
switch key {
case "account":
profile.Account = value
case "project":
profile.Project = value
}
}
return profile
}
// readSAKeyEmail reads a service account key JSON file to extract the client_email
func readSAKeyEmail(path string) string {
data, err := os.ReadFile(path)
if err != nil {
return ""
}
var key struct {
ClientEmail string `json:"client_email"`
}
if err := json.Unmarshal(data, &key); err != nil {
return ""
}
return key.ClientEmail
}
// sha256Hash computes SHA-256 hash of a string
func sha256Hash(s string) string {
h := sha256.Sum256([]byte(s))
return hex.EncodeToString(h[:])
}
@@ -17,7 +17,7 @@ type APIKeysService struct {
// New creates a new APIKeysService
func New() *APIKeysService {
return &APIKeysService{}
return &APIKeysService{session: gcpinternal.GetDefaultSession()}
}
// NewWithSession creates an APIKeysService with a SafeSession for managed authentication
@@ -206,6 +206,7 @@ func (ars *ArtifactRegistryService) getRepositoryIAMPolicy(ctx context.Context,
// Get IAM policy
req := &iampb.GetIamPolicyRequest{
Resource: repoName,
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
}
policy, err := client.GetIamPolicy(ctx, req)
+1 -1
View File
@@ -16,7 +16,7 @@ type AssetService struct {
}
func New() *AssetService {
return &AssetService{}
return &AssetService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *AssetService {
@@ -15,7 +15,7 @@ type BeyondCorpService struct {
}
func New() *BeyondCorpService {
return &BeyondCorpService{}
return &BeyondCorpService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *BeyondCorpService {
@@ -15,7 +15,7 @@ type BigQueryEnumService struct {
}
func New() *BigQueryEnumService {
return &BigQueryEnumService{}
return &BigQueryEnumService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *BigQueryEnumService {
@@ -18,6 +18,7 @@ type AccessEntry struct {
Role string `json:"role"` // OWNER, WRITER, READER
EntityType string `json:"entityType"` // User, Group, Domain, ServiceAccount, etc.
Entity string `json:"entity"` // The actual entity identifier
Source string `json:"source"` // "Resource" (direct) or "Project" (inherited)
}
// BigqueryDataset represents a dataset in BigQuery with security-relevant fields
@@ -98,6 +99,7 @@ type BigqueryTable struct {
type TableIAMBinding struct {
Role string `json:"role"`
Members []string `json:"members"`
Source string `json:"source"` // "Resource" (direct) or "Project" (inherited)
}
// CombinedBigqueryData represents both datasets and tables within a project
@@ -112,7 +114,7 @@ type BigQueryService struct {
// New creates a new instance of BigQueryService (legacy - uses ADC directly)
func New() *BigQueryService {
return &BigQueryService{}
return &BigQueryService{session: gcpinternal.GetDefaultSession()}
}
// NewWithSession creates a BigQueryService with a SafeSession for managed authentication
@@ -15,7 +15,7 @@ type BigtableEnumService struct {
}
func New() *BigtableEnumService {
return &BigtableEnumService{}
return &BigtableEnumService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *BigtableEnumService {
@@ -15,7 +15,7 @@ type BigtableService struct {
}
func New() *BigtableService {
return &BigtableService{}
return &BigtableService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *BigtableService {
@@ -17,7 +17,7 @@ type BucketEnumService struct {
}
func New() *BucketEnumService {
return &BucketEnumService{}
return &BucketEnumService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *BucketEnumService {
@@ -17,7 +17,7 @@ type CertManagerService struct {
}
func New() *CertManagerService {
return &CertManagerService{}
return &CertManagerService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *CertManagerService {
@@ -15,7 +15,7 @@ type CloudArmorService struct{
}
func New() *CloudArmorService {
return &CloudArmorService{}
return &CloudArmorService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *CloudArmorService {
@@ -20,7 +20,7 @@ type CloudStorageService struct {
// New creates a new CloudStorageService (requires session for SDK caching)
func New() *CloudStorageService {
return &CloudStorageService{}
return &CloudStorageService{session: gcpinternal.GetDefaultSession()}
}
// NewWithSession creates a CloudStorageService with a SafeSession for managed authentication
@@ -32,6 +32,7 @@ func NewWithSession(session *gcpinternal.SafeSession) *CloudStorageService {
type IAMBinding struct {
Role string `json:"role"`
Members []string `json:"members"`
Source string `json:"source"` // "Resource" (direct) or "Project" (inherited)
}
// LifecycleRule represents a single lifecycle rule on a bucket
@@ -16,7 +16,7 @@ type CloudBuildService struct {
// New creates a new CloudBuildService
func New() *CloudBuildService {
return &CloudBuildService{}
return &CloudBuildService{session: gcpinternal.GetDefaultSession()}
}
// NewWithSession creates a CloudBuildService with a SafeSession for managed authentication
@@ -17,7 +17,7 @@ type CloudRunService struct{
}
func New() *CloudRunService {
return &CloudRunService{}
return &CloudRunService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *CloudRunService {
@@ -116,6 +116,7 @@ type SecretRefInfo struct {
type IAMBinding struct {
Role string
Member string
Source string // "Resource" (direct) or "Project" (inherited)
}
// JobInfo holds Cloud Run job details
@@ -15,7 +15,7 @@ type CloudSQLService struct{
}
func New() *CloudSQLService {
return &CloudSQLService{}
return &CloudSQLService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *CloudSQLService {
@@ -17,7 +17,7 @@ type ComposerService struct {
}
func New() *ComposerService {
return &ComposerService{}
return &ComposerService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *ComposerService {
@@ -16,7 +16,7 @@ type ComputeEngineService struct {
// New creates a new ComputeEngineService (legacy - uses ADC directly)
func New() *ComputeEngineService {
return &ComputeEngineService{}
return &ComputeEngineService{session: gcpinternal.GetDefaultSession()}
}
// NewWithSession creates a ComputeEngineService with a SafeSession for managed authentication
@@ -34,6 +34,7 @@ type ServiceAccountInfo struct {
type IAMBinding struct {
Role string `json:"role"`
Member string `json:"member"`
Source string `json:"source"` // "Resource" (direct) or "Project" (inherited)
}
// InstanceType represents the type/manager of an instance
@@ -18,7 +18,7 @@ type CrossProjectService struct {
}
func New() *CrossProjectService {
return &CrossProjectService{}
return &CrossProjectService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *CrossProjectService {
@@ -14,7 +14,7 @@ type DataflowService struct {
}
func New() *DataflowService {
return &DataflowService{}
return &DataflowService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *DataflowService {
@@ -16,7 +16,7 @@ type DataprocService struct {
}
func New() *DataprocService {
return &DataprocService{}
return &DataprocService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *DataprocService {
+1 -1
View File
@@ -15,7 +15,7 @@ type DNSService struct{
}
func New() *DNSService {
return &DNSService{}
return &DNSService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *DNSService {
@@ -15,7 +15,7 @@ type DomainWideDelegationService struct{
}
func New() *DomainWideDelegationService {
return &DomainWideDelegationService{}
return &DomainWideDelegationService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *DomainWideDelegationService {
@@ -15,7 +15,7 @@ type FilestoreService struct {
}
func New() *FilestoreService {
return &FilestoreService{}
return &FilestoreService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *FilestoreService {
@@ -15,7 +15,7 @@ type FunctionsService struct{
}
func New() *FunctionsService {
return &FunctionsService{}
return &FunctionsService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *FunctionsService {
@@ -94,6 +94,7 @@ type EnvVarInfo struct {
type IAMBinding struct {
Role string
Member string
Source string // "Resource" (direct) or "Project" (inherited)
}
// getService returns a Cloud Functions v2 service instance, using cached wrapper if session is available
+1 -1
View File
@@ -16,7 +16,7 @@ type GKEService struct {
// New creates a new GKEService (legacy - uses ADC directly)
func New() *GKEService {
return &GKEService{}
return &GKEService{session: gcpinternal.GetDefaultSession()}
}
// NewWithSession creates a GKEService with a SafeSession for managed authentication
+1 -1
View File
@@ -15,7 +15,7 @@ type HMACService struct {
}
func New() *HMACService {
return &HMACService{}
return &HMACService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *HMACService {
+59 -5
View File
@@ -26,7 +26,7 @@ type IAMService struct {
// New creates a new IAMService (legacy - uses ADC directly)
func New() *IAMService {
return &IAMService{}
return &IAMService{session: gcpinternal.GetDefaultSession()}
}
// NewWithSession creates an IAMService with a SafeSession for managed authentication
@@ -277,6 +277,7 @@ func (s *IAMService) Policies(resourceID string, resourceType string) ([]PolicyB
req := &iampb.GetIamPolicyRequest{
Resource: resourceName,
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
}
// Fetch the IAM policy for the resource
@@ -623,7 +624,10 @@ func (s *IAMService) getPoliciesForResource(ctx context.Context, resourceID stri
defer client.Close()
resourceName = "projects/" + resourceID
policy, err := client.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{Resource: resourceName})
policy, err := client.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{
Resource: resourceName,
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
})
if err != nil {
return nil, gcpinternal.ParseGCPError(err, "cloudresourcemanager.googleapis.com")
}
@@ -646,7 +650,10 @@ func (s *IAMService) getPoliciesForResource(ctx context.Context, resourceID stri
defer client.Close()
resourceName = "folders/" + resourceID
policy, err := client.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{Resource: resourceName})
policy, err := client.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{
Resource: resourceName,
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
})
if err != nil {
policyFailureCache[cacheKey] = true
return nil, gcpinternal.ParseGCPError(err, "cloudresourcemanager.googleapis.com")
@@ -670,7 +677,10 @@ func (s *IAMService) getPoliciesForResource(ctx context.Context, resourceID stri
defer client.Close()
resourceName = "organizations/" + resourceID
policy, err := client.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{Resource: resourceName})
policy, err := client.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{
Resource: resourceName,
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
})
if err != nil {
policyFailureCache[cacheKey] = true
return nil, gcpinternal.ParseGCPError(err, "cloudresourcemanager.googleapis.com")
@@ -956,6 +966,7 @@ func (s *IAMService) GetRolePermissions(ctx context.Context, roleName string) ([
// Project-level custom role
role, err := iamService.Projects.Roles.Get(roleName).Context(ctx).Do()
if err != nil {
rolePermissionsFailureCache[roleName] = true
return nil, gcpinternal.ParseGCPError(err, "iam.googleapis.com")
}
permissions = role.IncludedPermissions
@@ -1424,7 +1435,7 @@ func (s *IAMService) GetServiceAccountIAMPolicy(ctx context.Context, saEmail str
saResource := fmt.Sprintf("projects/%s/serviceAccounts/%s", projectID, saEmail)
policy, err := iamService.Projects.ServiceAccounts.GetIamPolicy(saResource).Context(ctx).Do()
policy, err := iamService.Projects.ServiceAccounts.GetIamPolicy(saResource).OptionsRequestedPolicyVersion(3).Context(ctx).Do()
if err != nil {
return nil, gcpinternal.ParseGCPError(err, "iam.googleapis.com")
}
@@ -1465,6 +1476,47 @@ func (s *IAMService) GetServiceAccountIAMPolicy(ctx context.Context, saEmail str
return info, nil
}
// GetServiceAccountIAMBindings returns raw PolicyBinding objects for a service account's IAM policy.
// This is used by the permissions module to explode SA-level bindings into individual permissions.
func (s *IAMService) GetServiceAccountIAMBindings(ctx context.Context, saEmail string, projectID string) ([]PolicyBinding, error) {
iamService, err := s.getIAMService(ctx)
if err != nil {
return nil, gcpinternal.ParseGCPError(err, "iam.googleapis.com")
}
saResource := fmt.Sprintf("projects/%s/serviceAccounts/%s", projectID, saEmail)
policy, err := iamService.Projects.ServiceAccounts.GetIamPolicy(saResource).OptionsRequestedPolicyVersion(3).Context(ctx).Do()
if err != nil {
return nil, gcpinternal.ParseGCPError(err, "iam.googleapis.com")
}
var bindings []PolicyBinding
for _, binding := range policy.Bindings {
pb := PolicyBinding{
Role: binding.Role,
Members: binding.Members,
ResourceID: saEmail,
ResourceType: "serviceAccount",
PolicyName: saResource + "_policyBindings",
}
if binding.Condition != nil {
pb.Condition = fmt.Sprintf("title:%s expression:%s", binding.Condition.Title, binding.Condition.Expression)
pb.HasCondition = true
pb.ConditionInfo = &IAMCondition{
Title: binding.Condition.Title,
Description: binding.Condition.Description,
Expression: binding.Condition.Expression,
}
}
bindings = append(bindings, pb)
}
return bindings, nil
}
// GetAllServiceAccountImpersonation analyzes impersonation risks for all SAs in a project
func (s *IAMService) GetAllServiceAccountImpersonation(projectID string) ([]SAImpersonationInfo, error) {
ctx := context.Background()
@@ -1672,6 +1724,7 @@ func (s *IAMService) GetOrganizationIAM(ctx context.Context) ([]ScopeBinding, ma
// Get IAM policy for this organization
policy, err := orgsClient.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{
Resource: org.Name,
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
})
if err != nil {
continue
@@ -1744,6 +1797,7 @@ func (s *IAMService) GetFolderIAM(ctx context.Context) ([]ScopeBinding, map[stri
// Get IAM policy for this folder
policy, err := foldersClient.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{
Resource: folder.Name,
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
})
if err != nil {
continue
+1 -1
View File
@@ -16,7 +16,7 @@ type IAPService struct {
}
func New() *IAPService {
return &IAPService{}
return &IAPService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *IAPService {
+2 -1
View File
@@ -15,7 +15,7 @@ type KMSService struct{
}
func New() *KMSService {
return &KMSService{}
return &KMSService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *KMSService {
@@ -47,6 +47,7 @@ type KeyRingInfo struct {
type IAMBinding struct {
Role string
Member string
Source string // "Resource" (direct) or "Project" (inherited)
}
// CryptoKeyInfo holds KMS crypto key details with security-relevant information
@@ -14,7 +14,7 @@ type LoadBalancerService struct {
}
func New() *LoadBalancerService {
return &LoadBalancerService{}
return &LoadBalancerService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *LoadBalancerService {
@@ -16,7 +16,7 @@ type LogEnumService struct {
}
func New() *LogEnumService {
return &LogEnumService{}
return &LogEnumService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *LogEnumService {
@@ -19,7 +19,7 @@ type LoggingService struct{
}
func New() *LoggingService {
return &LoggingService{}
return &LoggingService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *LoggingService {
@@ -15,7 +15,7 @@ type MemorystoreService struct {
}
func New() *MemorystoreService {
return &MemorystoreService{}
return &MemorystoreService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *MemorystoreService {
@@ -16,7 +16,7 @@ type NetworkEndpointsService struct {
}
func New() *NetworkEndpointsService {
return &NetworkEndpointsService{}
return &NetworkEndpointsService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *NetworkEndpointsService {
@@ -64,7 +64,7 @@ type NetwworkService struct {
// New creates a new NetworkService (legacy - uses ADC directly)
func New() *NetwworkService {
return &NetwworkService{}
return &NetwworkService{session: gcpinternal.GetDefaultSession()}
}
// NewWithSession creates a NetworkService with a SafeSession for managed authentication
@@ -15,7 +15,7 @@ type NotebooksService struct {
}
func New() *NotebooksService {
return &NotebooksService{}
return &NotebooksService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *NotebooksService {
+15
View File
@@ -59,6 +59,21 @@ func (s *OAuthService) WhoAmI() (*Principal, error) {
}, nil
}
// WhoAmIWithToken resolves identity from an explicit access token instead of ADC.
// This is used when --access-token or --key-file flags provide a session.
func (s *OAuthService) WhoAmIWithToken(accessToken string) (*Principal, error) {
tokenInfo, err := queryTokenInfo(accessToken)
if err != nil {
return nil, fmt.Errorf("failed to retrieve metadata of the token with error: %w", err)
}
scopes := strings.Split(tokenInfo.Scope, " ")
return &Principal{
Email: tokenInfo.Email,
Scope: scopes,
}, nil
}
// queryTokenInfo function modified to return (*Principal, error).
func queryTokenInfo(accessToken string) (*tokenInfoResponse, error) {
url := fmt.Sprintf("https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=%s", accessToken)
@@ -17,7 +17,7 @@ type OrganizationsService struct {
// New creates a new OrganizationsService
func New() *OrganizationsService {
return &OrganizationsService{}
return &OrganizationsService{session: gcpinternal.GetDefaultSession()}
}
// NewWithSession creates an OrganizationsService with a SafeSession for managed authentication
@@ -15,7 +15,7 @@ type OrgPolicyService struct {
}
func New() *OrgPolicyService {
return &OrgPolicyService{}
return &OrgPolicyService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *OrgPolicyService {
@@ -35,6 +35,9 @@ type OrgPolicyInfo struct {
Name string `json:"name"`
Constraint string `json:"constraint"`
ProjectID string `json:"projectId"`
ScopeType string `json:"scopeType"` // "organization", "folder", or "project"
ScopeID string `json:"scopeId"` // org/folder/project ID
ScopeName string `json:"scopeName"` // display name
Enforced bool `json:"enforced"`
AllowAll bool `json:"allowAll"`
DenyAll bool `json:"denyAll"`
@@ -171,7 +174,7 @@ func (s *OrgPolicyService) ListProjectPolicies(projectID string) ([]OrgPolicyInf
err = service.Projects.Policies.List(parent).Pages(ctx, func(resp *orgpolicy.GoogleCloudOrgpolicyV2ListPoliciesResponse) error {
for _, policy := range resp.Policies {
info := s.parsePolicyInfo(policy, projectID)
info := s.parsePolicyInfo(policy, "project", projectID, "")
policies = append(policies, info)
}
return nil
@@ -183,10 +186,107 @@ func (s *OrgPolicyService) ListProjectPolicies(projectID string) ([]OrgPolicyInf
return policies, nil
}
func (s *OrgPolicyService) parsePolicyInfo(policy *orgpolicy.GoogleCloudOrgpolicyV2Policy, projectID string) OrgPolicyInfo {
// ListOrganizationPolicies lists all org policies for an organization
func (s *OrgPolicyService) ListOrganizationPolicies(orgID, orgDisplayName string) ([]OrgPolicyInfo, error) {
ctx := context.Background()
service, err := s.getService(ctx)
if err != nil {
return nil, gcpinternal.ParseGCPError(err, "orgpolicy.googleapis.com")
}
var policies []OrgPolicyInfo
parent := fmt.Sprintf("organizations/%s", orgID)
err = service.Organizations.Policies.List(parent).Pages(ctx, func(resp *orgpolicy.GoogleCloudOrgpolicyV2ListPoliciesResponse) error {
for _, policy := range resp.Policies {
info := s.parsePolicyInfo(policy, "organization", orgID, orgDisplayName)
policies = append(policies, info)
}
return nil
})
if err != nil {
return nil, gcpinternal.ParseGCPError(err, "orgpolicy.googleapis.com")
}
return policies, nil
}
// ListFolderPolicies lists all org policies for a folder
func (s *OrgPolicyService) ListFolderPolicies(folderID, folderDisplayName string) ([]OrgPolicyInfo, error) {
ctx := context.Background()
service, err := s.getService(ctx)
if err != nil {
return nil, gcpinternal.ParseGCPError(err, "orgpolicy.googleapis.com")
}
var policies []OrgPolicyInfo
parent := fmt.Sprintf("folders/%s", folderID)
err = service.Folders.Policies.List(parent).Pages(ctx, func(resp *orgpolicy.GoogleCloudOrgpolicyV2ListPoliciesResponse) error {
for _, policy := range resp.Policies {
info := s.parsePolicyInfo(policy, "folder", folderID, folderDisplayName)
policies = append(policies, info)
}
return nil
})
if err != nil {
return nil, gcpinternal.ParseGCPError(err, "orgpolicy.googleapis.com")
}
return policies, nil
}
// GetEffectivePolicy gets the effective (inherited + local) policy for a specific constraint at a given scope.
// scopeType must be "project", "folder", or "organization".
// constraint should be the short name (e.g., "compute.requireOsLogin").
func (s *OrgPolicyService) GetEffectivePolicy(scopeType, scopeID, constraint string) (*OrgPolicyInfo, error) {
ctx := context.Background()
service, err := s.getService(ctx)
if err != nil {
return nil, gcpinternal.ParseGCPError(err, "orgpolicy.googleapis.com")
}
// Strip "constraints/" prefix if present
constraint = strings.TrimPrefix(constraint, "constraints/")
var policy *orgpolicy.GoogleCloudOrgpolicyV2Policy
switch scopeType {
case "project":
name := fmt.Sprintf("projects/%s/policies/%s", scopeID, constraint)
policy, err = service.Projects.Policies.GetEffectivePolicy(name).Context(ctx).Do()
case "folder":
name := fmt.Sprintf("folders/%s/policies/%s", scopeID, constraint)
policy, err = service.Folders.Policies.GetEffectivePolicy(name).Context(ctx).Do()
case "organization":
name := fmt.Sprintf("organizations/%s/policies/%s", scopeID, constraint)
policy, err = service.Organizations.Policies.GetEffectivePolicy(name).Context(ctx).Do()
default:
return nil, fmt.Errorf("unsupported scope type: %s", scopeType)
}
if err != nil {
return nil, gcpinternal.ParseGCPError(err, "orgpolicy.googleapis.com")
}
info := s.parsePolicyInfo(policy, scopeType, scopeID, "")
return &info, nil
}
func (s *OrgPolicyService) parsePolicyInfo(policy *orgpolicy.GoogleCloudOrgpolicyV2Policy, scopeType, scopeID, scopeName string) OrgPolicyInfo {
info := OrgPolicyInfo{
Name: policy.Name,
ProjectID: projectID,
ScopeType: scopeType,
ScopeID: scopeID,
ScopeName: scopeName,
}
// Set ProjectID for backward compatibility when scope is project
if scopeType == "project" {
info.ProjectID = scopeID
}
// Extract constraint name from policy name
+2 -1
View File
@@ -15,7 +15,7 @@ type PubSubService struct {
}
func New() *PubSubService {
return &PubSubService{}
return &PubSubService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *PubSubService {
@@ -35,6 +35,7 @@ func (ps *PubSubService) getService(ctx context.Context) (*pubsub.Service, error
type IAMBinding struct {
Role string `json:"role"`
Member string `json:"member"`
Source string `json:"source"` // "Resource" (direct) or "Project" (inherited)
}
// TopicInfo holds Pub/Sub topic details with security-relevant information
@@ -13,11 +13,13 @@ import (
regionservice "github.com/BishopFox/cloudfox/gcp/services/regionService"
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
"github.com/BishopFox/cloudfox/internal/gcp/sdk"
run "google.golang.org/api/run/v1"
secretmanager "google.golang.org/api/secretmanager/v1"
cloudfunctions "google.golang.org/api/cloudfunctions/v1"
compute "google.golang.org/api/compute/v1"
iam "google.golang.org/api/iam/v1"
"google.golang.org/api/iterator"
"google.golang.org/api/option"
cloudfunctions "google.golang.org/api/cloudfunctions/v1"
run "google.golang.org/api/run/v1"
secretmanager "google.golang.org/api/secretmanager/v1"
)
// ResourceIAMService handles enumeration of resource-level IAM policies
@@ -27,7 +29,7 @@ type ResourceIAMService struct {
// New creates a new ResourceIAMService
func New() *ResourceIAMService {
return &ResourceIAMService{}
return &ResourceIAMService{session: gcpinternal.GetDefaultSession()}
}
// NewWithSession creates a ResourceIAMService with a SafeSession
@@ -67,6 +69,22 @@ func (s *ResourceIAMService) getCloudRunService(ctx context.Context) (*run.APISe
return run.NewService(ctx)
}
// getIAMService returns a cached IAM service
func (s *ResourceIAMService) getIAMService(ctx context.Context) (*iam.Service, error) {
if s.session != nil {
return sdk.CachedGetIAMService(ctx, s.session)
}
return iam.NewService(ctx)
}
// getComputeService returns a cached Compute Engine service
func (s *ResourceIAMService) getComputeService(ctx context.Context) (*compute.Service, error) {
if s.session != nil {
return sdk.CachedGetComputeService(ctx, s.session)
}
return compute.NewService(ctx)
}
// ResourceIAMBinding represents an IAM binding on a specific resource
type ResourceIAMBinding struct {
ResourceType string `json:"resourceType"` // bucket, dataset, topic, secret, etc.
@@ -129,6 +147,18 @@ func (s *ResourceIAMService) GetAllResourceIAM(ctx context.Context, projectID st
allBindings = append(allBindings, runBindings...)
}
// Get Service Account IAM
saBindings, err := s.GetServiceAccountIAM(ctx, projectID)
if err == nil {
allBindings = append(allBindings, saBindings...)
}
// Get Compute Instance IAM
instanceBindings, err := s.GetComputeInstanceIAM(ctx, projectID)
if err == nil {
allBindings = append(allBindings, instanceBindings...)
}
return allBindings, nil
}
@@ -176,8 +206,8 @@ func (s *ResourceIAMService) GetBucketIAM(ctx context.Context, projectID string)
ProjectID: projectID,
Role: string(role),
Member: member,
MemberType: determineMemberType(member),
MemberEmail: extractEmail(member),
MemberType: DetermineMemberType(member),
MemberEmail: ExtractEmail(member),
IsPublic: isPublicMember(member),
}
bindings = append(bindings, binding)
@@ -250,7 +280,7 @@ func (s *ResourceIAMService) GetBigQueryDatasetIAM(ctx context.Context, projectI
memberType = "SpecialGroup"
}
case bigquery.IAMMemberEntity:
memberType = determineMemberType(access.Entity)
memberType = DetermineMemberType(access.Entity)
isPublic = isPublicMember(access.Entity)
}
@@ -266,7 +296,7 @@ func (s *ResourceIAMService) GetBigQueryDatasetIAM(ctx context.Context, projectI
Role: string(access.Role),
Member: member,
MemberType: memberType,
MemberEmail: extractEmail(member),
MemberEmail: ExtractEmail(member),
IsPublic: isPublic,
}
bindings = append(bindings, binding)
@@ -319,8 +349,8 @@ func (s *ResourceIAMService) GetPubSubIAM(ctx context.Context, projectID string)
ProjectID: projectID,
Role: string(role),
Member: member,
MemberType: determineMemberType(member),
MemberEmail: extractEmail(member),
MemberType: DetermineMemberType(member),
MemberEmail: ExtractEmail(member),
IsPublic: isPublicMember(member),
}
bindings = append(bindings, binding)
@@ -355,8 +385,8 @@ func (s *ResourceIAMService) GetPubSubIAM(ctx context.Context, projectID string)
ProjectID: projectID,
Role: string(role),
Member: member,
MemberType: determineMemberType(member),
MemberEmail: extractEmail(member),
MemberType: DetermineMemberType(member),
MemberEmail: ExtractEmail(member),
IsPublic: isPublicMember(member),
}
bindings = append(bindings, binding)
@@ -398,7 +428,7 @@ func (s *ResourceIAMService) GetSecretManagerIAM(ctx context.Context, projectID
for _, secret := range allSecrets {
// Get IAM policy for this secret
policy, err := smService.Projects.Secrets.GetIamPolicy(secret.Name).Context(ctx).Do()
policy, err := smService.Projects.Secrets.GetIamPolicy(secret.Name).OptionsRequestedPolicyVersion(3).Context(ctx).Do()
if err != nil {
continue
}
@@ -413,8 +443,8 @@ func (s *ResourceIAMService) GetSecretManagerIAM(ctx context.Context, projectID
ProjectID: projectID,
Role: binding.Role,
Member: member,
MemberType: determineMemberType(member),
MemberEmail: extractEmail(member),
MemberType: DetermineMemberType(member),
MemberEmail: ExtractEmail(member),
IsPublic: isPublicMember(member),
}
if binding.Condition != nil {
@@ -491,8 +521,8 @@ func (s *ResourceIAMService) GetKMSIAM(ctx context.Context, projectID string) ([
ProjectID: projectID,
Role: string(role),
Member: member,
MemberType: determineMemberType(member),
MemberEmail: extractEmail(member),
MemberType: DetermineMemberType(member),
MemberEmail: ExtractEmail(member),
IsPublic: isPublicMember(member),
}
bindings = append(bindings, binding)
@@ -523,7 +553,7 @@ func (s *ResourceIAMService) GetCloudFunctionsIAM(ctx context.Context, projectID
for _, fn := range resp.Functions {
// Get IAM policy for this function
policy, err := cfService.Projects.Locations.Functions.GetIamPolicy(fn.Name).Context(ctx).Do()
policy, err := cfService.Projects.Locations.Functions.GetIamPolicy(fn.Name).OptionsRequestedPolicyVersion(3).Context(ctx).Do()
if err != nil {
continue
}
@@ -538,8 +568,8 @@ func (s *ResourceIAMService) GetCloudFunctionsIAM(ctx context.Context, projectID
ProjectID: projectID,
Role: binding.Role,
Member: member,
MemberType: determineMemberType(member),
MemberEmail: extractEmail(member),
MemberType: DetermineMemberType(member),
MemberEmail: ExtractEmail(member),
IsPublic: isPublicMember(member),
}
if binding.Condition != nil {
@@ -573,7 +603,7 @@ func (s *ResourceIAMService) GetCloudRunIAM(ctx context.Context, projectID strin
for _, svc := range resp.Items {
// Get IAM policy for this service
policy, err := runService.Projects.Locations.Services.GetIamPolicy(svc.Metadata.Name).Context(ctx).Do()
policy, err := runService.Projects.Locations.Services.GetIamPolicy(svc.Metadata.Name).OptionsRequestedPolicyVersion(3).Context(ctx).Do()
if err != nil {
continue
}
@@ -588,8 +618,8 @@ func (s *ResourceIAMService) GetCloudRunIAM(ctx context.Context, projectID strin
ProjectID: projectID,
Role: binding.Role,
Member: member,
MemberType: determineMemberType(member),
MemberEmail: extractEmail(member),
MemberType: DetermineMemberType(member),
MemberEmail: ExtractEmail(member),
IsPublic: isPublicMember(member),
}
if binding.Condition != nil {
@@ -605,9 +635,140 @@ func (s *ResourceIAMService) GetCloudRunIAM(ctx context.Context, projectID strin
return bindings, nil
}
// GetServiceAccountIAM enumerates IAM policies on service accounts
func (s *ResourceIAMService) GetServiceAccountIAM(ctx context.Context, projectID string) ([]ResourceIAMBinding, error) {
var bindings []ResourceIAMBinding
iamSvc, err := s.getIAMService(ctx)
if err != nil {
return nil, gcpinternal.ParseGCPError(err, "iam.googleapis.com")
}
// List all service accounts in the project
req := iamSvc.Projects.ServiceAccounts.List("projects/" + projectID)
err = req.Pages(ctx, func(page *iam.ListServiceAccountsResponse) error {
for _, sa := range page.Accounts {
// Get IAM policy for this service account
saResource := fmt.Sprintf("projects/%s/serviceAccounts/%s", projectID, sa.Email)
policy, err := iamSvc.Projects.ServiceAccounts.GetIamPolicy(saResource).OptionsRequestedPolicyVersion(3).Context(ctx).Do()
if err != nil {
continue
}
// Skip service accounts with no bindings
if len(policy.Bindings) == 0 {
continue
}
for _, binding := range policy.Bindings {
for _, member := range binding.Members {
b := ResourceIAMBinding{
ResourceType: "serviceAccount",
ResourceName: saResource,
ResourceID: sa.Email,
ProjectID: projectID,
Role: binding.Role,
Member: member,
MemberType: DetermineMemberType(member),
MemberEmail: ExtractEmail(member),
IsPublic: isPublicMember(member),
}
if binding.Condition != nil {
b.HasCondition = true
b.ConditionTitle = binding.Condition.Title
b.ConditionExpression = binding.Condition.Expression
}
bindings = append(bindings, b)
}
}
}
return nil
})
if err != nil {
return bindings, gcpinternal.ParseGCPError(err, "iam.googleapis.com")
}
return bindings, nil
}
// GetComputeInstanceIAM enumerates IAM policies on Compute Engine instances
func (s *ResourceIAMService) GetComputeInstanceIAM(ctx context.Context, projectID string) ([]ResourceIAMBinding, error) {
var bindings []ResourceIAMBinding
computeSvc, err := s.getComputeService(ctx)
if err != nil {
return nil, gcpinternal.ParseGCPError(err, "compute.googleapis.com")
}
// List all instances across all zones using AggregatedList
req := computeSvc.Instances.AggregatedList(projectID)
err = req.Pages(ctx, func(page *compute.InstanceAggregatedList) error {
for _, scopedList := range page.Items {
for _, instance := range scopedList.Instances {
// Extract zone from the instance's zone URL
zone := extractZoneFromURL(instance.Zone)
if zone == "" {
continue
}
// Get IAM policy for this instance
policy, err := computeSvc.Instances.GetIamPolicy(projectID, zone, instance.Name).OptionsRequestedPolicyVersion(3).Context(ctx).Do()
if err != nil {
continue
}
// Skip instances with no bindings
if len(policy.Bindings) == 0 {
continue
}
resourceName := fmt.Sprintf("projects/%s/zones/%s/instances/%s", projectID, zone, instance.Name)
for _, binding := range policy.Bindings {
for _, member := range binding.Members {
b := ResourceIAMBinding{
ResourceType: "instance",
ResourceName: resourceName,
ResourceID: instance.Name,
ProjectID: projectID,
Role: binding.Role,
Member: member,
MemberType: DetermineMemberType(member),
MemberEmail: ExtractEmail(member),
IsPublic: isPublicMember(member),
}
if binding.Condition != nil {
b.HasCondition = true
b.ConditionTitle = binding.Condition.Title
b.ConditionExpression = binding.Condition.Expression
}
bindings = append(bindings, b)
}
}
}
}
return nil
})
if err != nil {
return bindings, gcpinternal.ParseGCPError(err, "compute.googleapis.com")
}
return bindings, nil
}
// Helper functions
func determineMemberType(member string) string {
// extractZoneFromURL extracts zone name from a full zone URL
// e.g., "https://www.googleapis.com/compute/v1/projects/my-project/zones/us-central1-a" -> "us-central1-a"
func extractZoneFromURL(zoneURL string) string {
parts := strings.Split(zoneURL, "/")
if len(parts) > 0 {
return parts[len(parts)-1]
}
return ""
}
// DetermineMemberType classifies an IAM member string into its type.
func DetermineMemberType(member string) string {
switch {
case member == "allUsers":
return "allUsers"
@@ -630,7 +791,8 @@ func determineMemberType(member string) string {
}
}
func extractEmail(member string) string {
// ExtractEmail strips the member type prefix from an IAM member string.
func ExtractEmail(member string) string {
if strings.Contains(member, ":") {
parts := strings.SplitN(member, ":", 2)
if len(parts) == 2 {
@@ -17,7 +17,7 @@ type SchedulerService struct{
}
func New() *SchedulerService {
return &SchedulerService{}
return &SchedulerService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *SchedulerService {
@@ -91,6 +91,7 @@ func NewWithSession(session *gcpinternal.SafeSession) (SecretsService, error) {
type IAMBinding struct {
Role string `json:"role"`
Members []string `json:"members"`
Source string `json:"source"` // "Resource" (direct) or "Project" (inherited)
}
// SecretInfo contains secret metadata and security-relevant configuration
@@ -15,7 +15,7 @@ type ServiceAgentsService struct{
}
func New() *ServiceAgentsService {
return &ServiceAgentsService{}
return &ServiceAgentsService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *ServiceAgentsService {
@@ -15,7 +15,7 @@ type SourceReposService struct{
}
func New() *SourceReposService {
return &SourceReposService{}
return &SourceReposService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *SourceReposService {
@@ -16,7 +16,7 @@ type SpannerEnumService struct {
}
func New() *SpannerEnumService {
return &SpannerEnumService{}
return &SpannerEnumService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *SpannerEnumService {
@@ -15,7 +15,7 @@ type SpannerService struct {
}
func New() *SpannerService {
return &SpannerService{}
return &SpannerService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *SpannerService {
+1 -1
View File
@@ -15,7 +15,7 @@ type VPCService struct {
}
func New() *VPCService {
return &VPCService{}
return &VPCService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *VPCService {
+1 -1
View File
@@ -15,7 +15,7 @@ type VPCSCService struct {
}
func New() *VPCSCService {
return &VPCSCService{}
return &VPCSCService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *VPCSCService {
@@ -15,7 +15,7 @@ type WorkloadIdentityService struct{
}
func New() *WorkloadIdentityService {
return &WorkloadIdentityService{}
return &WorkloadIdentityService{session: gcpinternal.GetDefaultSession()}
}
func NewWithSession(session *gcpinternal.SafeSession) *WorkloadIdentityService {
+79
View File
@@ -0,0 +1,79 @@
package shared
// InheritedAccessRoles maps resource types to the project-level roles that
// grant implicit access to ALL resources of that type. Principals with these
// roles at the project level can access every resource even without a direct
// resource-level IAM binding.
var InheritedAccessRoles = map[string][]string{
"bucket": {
"roles/owner", "roles/editor",
"roles/storage.admin", "roles/storage.objectAdmin",
"roles/storage.objectViewer", "roles/storage.objectCreator",
},
"dataset": {
"roles/owner", "roles/editor",
"roles/bigquery.admin", "roles/bigquery.dataEditor",
"roles/bigquery.dataViewer", "roles/bigquery.dataOwner",
},
"table": {
"roles/owner", "roles/editor",
"roles/bigquery.admin", "roles/bigquery.dataEditor",
"roles/bigquery.dataViewer", "roles/bigquery.dataOwner",
},
"topic": {
"roles/owner", "roles/editor",
"roles/pubsub.admin", "roles/pubsub.editor", "roles/pubsub.publisher",
},
"subscription": {
"roles/owner", "roles/editor",
"roles/pubsub.admin", "roles/pubsub.editor", "roles/pubsub.subscriber",
},
"secret": {
"roles/owner", "roles/editor",
"roles/secretmanager.admin", "roles/secretmanager.secretAccessor",
"roles/secretmanager.secretVersionAdder",
},
"cryptoKey": {
"roles/owner",
"roles/cloudkms.admin", "roles/cloudkms.cryptoKeyEncrypterDecrypter",
"roles/cloudkms.cryptoKeyDecrypter",
},
"function": {
"roles/owner", "roles/editor",
"roles/cloudfunctions.admin", "roles/cloudfunctions.developer",
"roles/cloudfunctions.invoker",
},
"cloudrun": {
"roles/owner", "roles/editor",
"roles/run.admin", "roles/run.developer", "roles/run.invoker",
},
"instance": {
"roles/owner", "roles/editor",
"roles/compute.admin", "roles/compute.instanceAdmin",
"roles/compute.instanceAdmin.v1",
},
}
// IsInheritedRole checks whether the given role grants inherited access
// for the specified resource type.
func IsInheritedRole(resourceType, role string) bool {
roles, ok := InheritedAccessRoles[resourceType]
if !ok {
return false
}
for _, r := range roles {
if r == role {
return true
}
}
return false
}
// BindingSource returns a display-ready binding source string.
// If the source is empty, defaults to "Resource" (direct binding).
func BindingSource(source string) string {
if source == "" {
return "Resource"
}
return source
}
+11 -4
View File
@@ -86,7 +86,14 @@ var GCP_VERBOSITY int = 0
const GCP_VERBOSE_ERRORS = 9
// const GCP_INVENTORY_MODULE_NAME string = "inventory"
// const GCP_GCLOUD_REFRESH_TOKENS_DB_PATH = ".config/gcloud/credentials.db"
// const GCP_GCLOUD_ACCESS_TOKENS_DB_PATH = ".config/gcloud/access_tokens.db"
// const GCP_GCLOUD_DEFAULT_CONFIG_PATH = ".config/gcloud/configurations/config_default"
// const GCP_GCLOUD_APPLICATION_DEFAULT_PATH = ".config/gcloud/application_default_credentials.json"
// Access tokens module
const GCP_ACCESS_TOKENS_MODULE_NAME string = "access-tokens"
// Gcloud local file paths (relative to home or CLOUDSDK_CONFIG_DIR)
const GCP_GCLOUD_REFRESH_TOKENS_DB_PATH = ".config/gcloud/credentials.db"
const GCP_GCLOUD_ACCESS_TOKENS_DB_PATH = ".config/gcloud/access_tokens.db"
const GCP_GCLOUD_DEFAULT_CONFIG_PATH = ".config/gcloud/configurations/config_default"
const GCP_GCLOUD_APPLICATION_DEFAULT_PATH = ".config/gcloud/application_default_credentials.json"
const GCP_GCLOUD_ACTIVE_CONFIG_PATH = ".config/gcloud/active_config"
const GCP_GCLOUD_CONFIGURATIONS_DIR = ".config/gcloud/configurations"
-78
View File
@@ -1,78 +0,0 @@
package globals
// Module names
const (
K8S_CONFIGMAPS_MODULE_NAME string = "configmaps"
K8S_CRDS_MODULE_NAME string = "crds"
K8S_CRONJOBS_MODULE_NAME string = "cronjobs"
K8S_DAEMONSETS_MODULE_NAME string = "daemonsets"
K8S_DEPLOYMENTS_MODULE_NAME string = "deployments"
K8S_ENDPOINTS_MODULE_NAME string = "endpoints"
K8S_EVENTS_MODULE_NAME string = "events"
K8S_HIDDEN_ADMINS_MODULE_NAME string = "hidden_admins"
K8S_HPAS_MODULE_NAME string = "hpas"
K8S_INGRESS_MODULE_NAME string = "ingress"
K8S_JOBS_MODULE_NAME string = "jobs"
K8S_PERSISTENT_VOLUMES_MODULE_NAME string = "persistent_volumes"
K8S_NAMESPACES_MODULE_NAME string = "namespaces"
K8S_NETWORK_POLICY_MODULE_NAME string = "network_policy" // Alias for backwards compatibility
K8S_NETWORK_ADMISSION_MODULE_NAME string = "network_admission"
K8S_NETWORK_PORTS_MODULE_NAME string = "network_ports"
K8S_NODES_MODULE_NAME string = "nodes"
K8S_PERMISSIONS_MODULE_NAME string = "permissions"
K8S_POD_ADMISSION_MODULE_NAME string = "pod_admission"
K8S_POD_SECURITY_MODULE_NAME string = "pod_admission" // Alias for backwards compatibility
K8S_PODDISRUPTIONBUDGETS_MODULE_NAME string = "poddisruptionbudgets"
K8S_PODS_MODULE_NAME string = "pods"
K8S_PRIORITYCLASSES_MODULE_NAME string = "priorityclasses"
K8S_REPLICASETS_MODULE_NAME string = "replicasets"
K8S_RESOURCEQUOTAS_MODULE_NAME string = "resourcequotas"
K8S_SECRETS_MODULE_NAME string = "secrets"
K8S_SERVICEACCOUNTS_MODULE_NAME string = "serviceaccounts"
K8S_SERVICES_MODULE_NAME string = "services"
K8S_STATEFULSETS_MODULE_NAME string = "statefulsets"
K8S_STORAGECLASSES_MODULE_NAME string = "storageclasses"
K8S_TAINTS_TOLERATIONS_MODULE_NAME string = "taints_tolerations"
K8S_TAINTS_MODULE_NAME string = "taints"
K8S_TOLERATIONS_MODULE_NAME string = "tolerations"
K8S_WEBHOOKS_MODULE_NAME string = "webhooks"
K8S_WHOAMI_MODULE_NAME string = "whoami"
K8S_AUTH_MODULE_NAME string = "auth"
K8S_PRIVESC_MODULE_NAME string = "privesc"
K8S_LATERAL_MOVEMENT_MODULE_NAME string = "lateral_movement"
K8S_DATA_EXFIL_MODULE_NAME string = "data_exfiltration"
K8S_ROLEBINDINGS_MODULE_NAME string = "rolebindings"
)
var (
KubeConfigPath string = ""
KubeContext string = ""
KubeToken string = ""
KubeAPIServer string = ""
RawKubeconfig []byte
ClusterName string = ""
// Namespace filtering
K8sNamespace string // Single namespace filter
K8sNamespaces []string // List of namespaces to target
K8sAllNamespaces bool // Target all namespaces (default)
// API timeout
K8sTimeout int // API timeout in seconds
// Cloud provider configuration for network exposure correlation
K8sCloudProviders []string // List of cloud providers to use (aws, gcp, azure)
K8sAWSProfile string // AWS profile name for cloud correlation
K8sAzureSubscriptions []string // Azure subscription IDs for cloud correlation (CSV supported)
K8sGCPProjects []string // GCP project IDs for cloud correlation (CSV supported)
// Admission controller filtering for lazy CRD discovery
// Supports: "all", or comma-separated list of known controllers
// Known controllers: gatekeeper, kyverno, pss, vap, falco, tetragon, prisma, aqua, stackrox,
// neuvector, crowdstrike, trivy, harbor, notary, cosign, istio, linkerd,
// cert-manager, vault, external-secrets, calico, cilium, coredns, etc.
K8sAdmissionControllers []string // List of admission controllers to check for (empty = all)
// Detailed output flag
K8sDetailed bool // Show detailed output for modules that support it
)
+18 -51
View File
@@ -16,7 +16,7 @@ require (
github.com/Azure/go-autorest/autorest v0.11.30
github.com/Azure/go-autorest/autorest/azure/auth v0.5.13
github.com/aquasecurity/table v1.11.0
github.com/aws/aws-sdk-go-v2 v1.41.6
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/config v1.32.5
github.com/aws/aws-sdk-go-v2/credentials v1.19.5
github.com/aws/aws-sdk-go-v2/service/apigateway v1.38.3
@@ -68,7 +68,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/sqs v1.42.20
github.com/aws/aws-sdk-go-v2/service/ssm v1.67.7
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5
github.com/aws/smithy-go v1.25.0
github.com/aws/smithy-go v1.24.2
github.com/bishopfox/awsservicemap v1.1.0
github.com/bishopfox/knownawsaccountslookup v0.0.0-20231228165844-c37ef8df33cb
github.com/dominikbraun/graph v0.23.0
@@ -81,7 +81,7 @@ require (
github.com/sirupsen/logrus v1.9.3
github.com/spf13/afero v1.15.0
github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.47.0
golang.org/x/crypto v0.46.0
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9
)
@@ -108,36 +108,25 @@ 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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // 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/x448/float16 v0.8.4 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
@@ -149,20 +138,12 @@ require (
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/telemetry v0.0.0-20251208220230-2638a1023523 // indirect
gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect
sigs.k8s.io/randfill v1.0.0 // indirect
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
require (
@@ -171,33 +152,19 @@ require (
cloud.google.com/go/monitoring v1.24.3
cloud.google.com/go/pubsub v1.50.1
cloud.google.com/go/securitycenter v1.38.1
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dnsresolver/armdnsresolver v1.3.0
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.1.0
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/policyinsights/armpolicyinsights v0.9.0
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armpolicy v1.0.0
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/subscription/armsubscription v1.2.0
github.com/aws/aws-sdk-go-v2/service/acm v1.38.2
github.com/aws/aws-sdk-go-v2/service/kms v1.49.4
github.com/aws/aws-sdk-go-v2/service/route53resolver v1.42.6
github.com/aws/aws-sdk-go-v2/service/signer v1.32.6
golang.org/x/oauth2 v0.34.0
google.golang.org/api v0.257.0
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217
google.golang.org/protobuf v1.36.11
k8s.io/api v0.35.4
k8s.io/apiextensions-apiserver v0.35.4
k8s.io/apimachinery v0.35.4
k8s.io/client-go v0.35.4
sigs.k8s.io/yaml v1.6.0
modernc.org/sqlite v1.46.1
)
require (
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/longrunning v0.7.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
github.com/Azure/go-autorest/autorest/adal v0.9.24 // indirect
@@ -209,9 +176,9 @@ require (
github.com/Azure/go-autorest/tracing v0.6.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
@@ -249,10 +216,10 @@ require (
github.com/zeebo/xxh3 v1.0.2 // indirect
go.mongodb.org/mongo-driver v1.17.6 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.40.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
+58 -120
View File
@@ -49,40 +49,22 @@ cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU=
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dnsresolver/armdnsresolver v1.3.0 h1:oFR4sUr/00//HiGZNO4Y0b/yZn4GxMmBPng4GdErJiY=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dnsresolver/armdnsresolver v1.3.0/go.mod h1:Rwggm+UIAtUyKV+chyaPaUeiVeP7Y+XtXH7/BSI2J8A=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal v1.0.0 h1:lMW1lD/17LUA5z1XTURo7LcVG2ICBPlyMHjIUrcFZNQ=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal v1.0.0/go.mod h1:ceIuwmxDWptoW3eCqSXlnPsZFKh4X+R38dWPv7GS9Vs=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0/go.mod h1:LRr2FzBTQlONPPa5HREE5+RjSCTXl7BwOvYOaWTqCaI=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsIIvxVT+uE6yrNldntJKlLRgxGbZ85kgtz5SNBhMw=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 h1:nnQ9vXH039UrEFxi08pPuZBE7VfqSJt343uJLw0rhWI=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0/go.mod h1:4YIVtzMFVsPwBvitCDX7J9sqthSj43QD1sP6fYc1egc=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0 h1:pPvTJ1dY0sA35JOeFq6TsY2xj6Z85Yo23Pj4wCCvu4o=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0/go.mod h1:mLfWfj8v3jfWKsL9G4eoBoXVcsqcIUTapmdKy7uGOp0=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.1.0 h1:QM6sE5k2ZT/vI5BEe0r7mqjsUSnhVBFbOsVkEuaEfiA=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork v1.1.0/go.mod h1:243D9iHbcQXoFUtgHJwL7gl2zx1aDuDMjvBZVGr2uW0=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/policyinsights/armpolicyinsights v0.9.0 h1:VK9yyk+hLSM+9UHsemlsON7sqQqbCu9O349e0RG8kBg=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/policyinsights/armpolicyinsights v0.9.0/go.mod h1:FFXnZdOg5Gt9/Pfljm1IzcezNfmbm2ScWYT5KH7KiNc=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 h1:yzrctSl9GMIQ5lHu7jc8olOsGjWDCsBpJhWqfGa/YIM=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0/go.mod h1:GE4m0rnnfwLGX0Y9A9A25Zx5N/90jneT5ABevqzhuFQ=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armpolicy v1.0.0 h1:FKYUd0TZfjjWtdk7LR+6rI64yNY1gMe9rNV40Y2Fh4o=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armpolicy v1.0.0/go.mod h1:w987ZDGBg74HZu5sAqaBEhDTlvlJlxPggIhBiDviYhE=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/subscription/armsubscription v1.2.0 h1:UrGzkHueDwAWDdjQxC+QaXHd4tVCkISYE9j7fSSXF8k=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/subscription/armsubscription v1.2.0/go.mod h1:qskvSQeW+cxEE2bcKYyKimB1/KiQ9xpJ99bcHY0BX6c=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 h1:ZJJNFaQ86GVKQ9ehwqyAFE6pIfyicpuJ8IkVaPBc6/4=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3/go.mod h1:URuDvhmATVKqHBH9/0nOiNKk0+YcwfQ3WkK5PqHKxc8=
github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs=
@@ -128,14 +110,12 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.54.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 h1:s0WlVbf9qpvkh1c/uDAPElam0WrL7fHRIidgZJ7UqZI=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/apache/arrow/go/v15 v15.0.2 h1:60IliRbiyTWCWjERBCkO1W4Qun9svcYoZrSLcyOsMLE=
github.com/apache/arrow/go/v15 v15.0.2/go.mod h1:DGXsR3ajT524njufqf95822i+KTh+yea1jass9YXgjA=
github.com/aquasecurity/table v1.11.0 h1:SzgCAv7dZcv/gyAyzxorS6OgEk7w/WU5iT2pStIkpl4=
github.com/aquasecurity/table v1.11.0/go.mod h1:eqOmvjjB7AhXFgFqpJUEE/ietg7RrMSJZXyTN8E/wZw=
github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg=
github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/config v1.32.5 h1:pz3duhAfUgnxbtVhIK39PGF/AHYyrzGEyRD9Og0QrE8=
@@ -144,16 +124,14 @@ github.com/aws/aws-sdk-go-v2/credentials v1.19.5 h1:xMo63RlqP3ZZydpJDMBsH9uJ10hg
github.com/aws/aws-sdk-go-v2/credentials v1.19.5/go.mod h1:hhbH6oRcou+LpXfA/0vPElh/e0M3aFeOblE1sssAAEk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
github.com/aws/aws-sdk-go-v2/service/acm v1.38.2 h1:ozcwethaFOi2ST9h6MKGq1GAIHP68tjiDqgkWVPwfR8=
github.com/aws/aws-sdk-go-v2/service/acm v1.38.2/go.mod h1:HNtDOv4XmqExPxNIBp171KKc5ZoUJwHH9ZhlCcZmdt0=
github.com/aws/aws-sdk-go-v2/service/apigateway v1.38.3 h1:nnhGwOSJAnWSwcOINuRUql8/C/l0pCGedsNgv6FSZHs=
github.com/aws/aws-sdk-go-v2/service/apigateway v1.38.3/go.mod h1:U3xTNpFRAV7yduECTfDBDJVFmY5FLrL5HsTSigwOeHs=
github.com/aws/aws-sdk-go-v2/service/apigatewayv2 v1.33.4 h1:FcarAOOdK+8gIYD8/90x7JTOAno+U6IrzMdowePmyBA=
@@ -248,8 +226,6 @@ github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.31.5 h1:0jwTqyy
github.com/aws/aws-sdk-go-v2/service/resourcegroupstaggingapi v1.31.5/go.mod h1:ydy76wx7I+HsqhlEo0vhVTl785TDNbpgtEXhd3i4ZTc=
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.0 h1:80pDB3Tpmb2RCSZORrK9/3iQxsd+w6vSzVqpT1FGiwE=
github.com/aws/aws-sdk-go-v2/service/route53 v1.62.0/go.mod h1:6EZUGGNLPLh5Unt30uEoA+KQcByERfXIkax9qrc80nA=
github.com/aws/aws-sdk-go-v2/service/route53resolver v1.42.6 h1:jS1uZQ2G4DumllnT342TwUjtzz44kiyeQ+QM2PZGc9o=
github.com/aws/aws-sdk-go-v2/service/route53resolver v1.42.6/go.mod h1:27vQn1Pqnz6I/J1vjp45O9Ig/c815ovG00355C9Y+00=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
github.com/aws/aws-sdk-go-v2/service/sagemaker v1.228.2 h1:96uJoMTjZ6WdXD0+bCjQib+U42++cYrf4fXbiu7VpEY=
@@ -258,8 +234,6 @@ github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.0 h1:vL6rQXcGtFv9q/9eR
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.0/go.mod h1:QwEDLD+7EukuEUnbWtiNE8LhgvvmhjZoi4XAppYPtyc=
github.com/aws/aws-sdk-go-v2/service/sfn v1.40.5 h1:nhPlRp9oCZOh1M/4zVn4pqguzEJ3Q3emnyS9k8sW8u8=
github.com/aws/aws-sdk-go-v2/service/sfn v1.40.5/go.mod h1:dfVRuB5XudlLMY6PVMu4T2lmfXYMARapmdc2/cUN2Mw=
github.com/aws/aws-sdk-go-v2/service/signer v1.32.6 h1:ccmmr4gLKThEHgZmcHQzIf9NR/wisat7M/JIA4M04Uw=
github.com/aws/aws-sdk-go-v2/service/signer v1.32.6/go.mod h1:Um9nzST5C8IF1I96c1R16FAYCSFhZWSxKIl+jngpH3k=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
github.com/aws/aws-sdk-go-v2/service/sns v1.39.10 h1:wqErrLzV3iERQ7dbZbKQS0gOM6ngxZtmPwKyRGn+Krc=
@@ -274,8 +248,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlV
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U=
github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
@@ -314,7 +288,6 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -323,8 +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/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
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=
@@ -343,8 +316,6 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -354,20 +325,10 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/errors v0.22.5 h1:Yfv4O/PRYpNF3BNmVkEizcHb3uLVVsrDt3LNdgAKRY4=
github.com/go-openapi/errors v0.22.5/go.mod h1:z9S8ASTUqx7+CP1Q8dD8ewGH/1JWFFLX/2PmAYNQLgk=
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
github.com/go-openapi/strfmt v0.25.0 h1:7R0RX7mbKLa9EYCTHRcCuIPcaqlyQiWNPTXwClK0saQ=
github.com/go-openapi/strfmt v0.25.0/go.mod h1:nNXct7OzbwrMY9+5tLX4I21pzcmE6ccMGXl3jFdPfn8=
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
@@ -377,8 +338,8 @@ github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzw
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
@@ -397,8 +358,6 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/flatbuffers v25.9.23+incompatible h1:rGZKv+wOb6QPzIdkM2KxhBZCDrA0DeN6DNmRDrqIsQU=
github.com/google/flatbuffers v25.9.23+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -407,11 +366,10 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
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-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
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=
@@ -421,25 +379,20 @@ 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=
github.com/jedib0t/go-pretty v4.3.0+incompatible/go.mod h1:XemHduiw8R651AF9Pt4FwCTKeG3oo7hrHJAoznj9nag=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
@@ -448,8 +401,6 @@ github.com/kyokomi/emoji v2.2.4+incompatible h1:np0woGKwx9LiHAQmwZx79Oc0rHpNw3o+
github.com/kyokomi/emoji v2.2.4+incompatible/go.mod h1:mZ6aGCD7yk8j6QY6KICwnZ2pxoszVseX1DNoGtU2tBA=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -460,28 +411,18 @@ github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byF
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
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=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
@@ -494,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=
@@ -513,8 +456,6 @@ github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xI
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -523,8 +464,6 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
@@ -558,17 +497,14 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9 h1:MDfG8Cvcqlt9XXrmEiD4epKn7VJHZO84hejP9Jmp0MM=
golang.org/x/exp v0.0.0-20251209150349-8475f28825e9/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
@@ -591,8 +527,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
@@ -628,8 +564,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -637,8 +573,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -691,10 +627,6 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo=
gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -702,25 +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=
k8s.io/api v0.35.4 h1:P7nFYKl5vo9AGUp1Z+Pmd3p2tA7bX2wbFWCvDeRv988=
k8s.io/api v0.35.4/go.mod h1:yl4lqySWOgYJJf9RERXKUwE9g2y+CkuwG+xmcOK8wXU=
k8s.io/apiextensions-apiserver v0.35.4 h1:HeP+Upp7ItdvnyGmub0yoix+2z5+ev4M5cE5TCgtOUU=
k8s.io/apiextensions-apiserver v0.35.4/go.mod h1:ogQlk+stIE8mnoRthSYCwlOS12fVqgWFiErMwPaXA7c=
k8s.io/apimachinery v0.35.4 h1:xtdom9RG7e+yDp71uoXoJDWEE2eOiHgeO4GdBzwWpds=
k8s.io/apimachinery v0.35.4/go.mod h1:NNi1taPOpep0jOj+oRha3mBJPqvi0hGdaV8TCqGQ+cc=
k8s.io/client-go v0.35.4 h1:DN6fyaGuzK64UvnKO5fOA6ymSjvfGAnCAHAR0C66kD8=
k8s.io/client-go v0.35.4/go.mod h1:2Pg9WpsS4NeOpoYTfHHfMxBG8zFMSAUi4O/qoiJC3nY=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE=
k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+47 -5
View File
@@ -7,6 +7,7 @@ import (
"os"
"strings"
"sync"
"time"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
@@ -242,6 +243,42 @@ func ParseGCPError(err error, apiName string) error {
return err
}
// IsRetryableError checks if a GCP API error is transient and worth retrying.
// Rate limits, server errors, and unavailable responses are retryable.
// Permission denied, not found, and auth failures are not.
func IsRetryableError(err error) bool {
if err == nil {
return false
}
errStr := err.Error()
return strings.Contains(errStr, "rate limited") ||
strings.Contains(errStr, "GCP service error") ||
strings.Contains(errStr, "Unavailable") ||
strings.Contains(errStr, "Internal")
}
// RetryWithBackoff calls fn up to maxAttempts times with exponential backoff
// for transient GCP API errors. Non-retryable errors (permission denied, etc.)
// are returned immediately without further attempts.
func RetryWithBackoff(logger internal.Logger, module string, maxAttempts int, fn func() error) error {
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
lastErr = fn()
if lastErr == nil {
return nil
}
if !IsRetryableError(lastErr) {
return lastErr
}
if attempt < maxAttempts {
delay := time.Duration(attempt) * 2 * time.Second // 2s, 4s, 6s
logger.InfoM(fmt.Sprintf("Transient error: %v (retry %d/%d in %s)", lastErr, attempt, maxAttempts-1, delay), module)
time.Sleep(delay)
}
}
return lastErr
}
// IsPermissionDenied checks if an error is a permission denied error
func IsPermissionDenied(err error) bool {
return errors.Is(err, ErrPermissionDenied)
@@ -281,12 +318,13 @@ func HandleGCPError(err error, logger internal.Logger, moduleName string, resour
return true // Can continue with other resources
case errors.Is(parsedErr, ErrNotFound):
// Not found is often expected, don't log as error
// Log not-found errors so users can see invalid project IDs or missing resources
logger.ErrorM(fmt.Sprintf("%s - not found", resourceDesc), moduleName)
return true
default:
// For unknown errors, log a concise message without the full error details
logger.ErrorM(fmt.Sprintf("%s - error occurred", resourceDesc), moduleName)
// Include the actual error so it's visible in logs and (when not overwritten by spinner) on screen
logger.ErrorM(fmt.Sprintf("%s - %v", resourceDesc, parsedErr), moduleName)
return true // Continue with other resources
}
}
@@ -439,8 +477,12 @@ func (b *BaseGCPModule) RunProjectEnumeration(
wg.Done()
}()
// Acquire semaphore
semaphore <- struct{}{}
// Check for cancellation before starting work
select {
case <-ctx.Done():
return
case semaphore <- struct{}{}:
}
defer func() { <-semaphore }()
b.CommandCounter.Pending--
+58 -44
View File
@@ -15,6 +15,7 @@ import (
// REST API services (NewService pattern)
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
"google.golang.org/api/option"
accesscontextmanager "google.golang.org/api/accesscontextmanager/v1"
apikeys "google.golang.org/api/apikeys/v2"
artifactregistryapi "google.golang.org/api/artifactregistry/v1"
@@ -54,13 +55,26 @@ import (
storageapi "google.golang.org/api/storage/v1"
)
// resolveOpts returns the client options for a session. If session is nil,
// it checks the package-level default session (set by --impersonate-sa).
// If no session is available, returns nil so the GCP SDK uses ADC.
func resolveOpts(session *gcpinternal.SafeSession) []option.ClientOption {
if session == nil {
session = gcpinternal.GetDefaultSession()
}
if session != nil {
return []option.ClientOption{session.GetClientOption()}
}
return nil
}
// =============================================================================
// GO SDK CLIENTS (NewClient pattern) - These return *Client types
// =============================================================================
// GetStorageClient returns a Cloud Storage client (Go SDK)
func GetStorageClient(ctx context.Context, session *gcpinternal.SafeSession) (*storage.Client, error) {
client, err := storage.NewClient(ctx, session.GetClientOption())
client, err := storage.NewClient(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create storage client: %w", err)
}
@@ -69,7 +83,7 @@ func GetStorageClient(ctx context.Context, session *gcpinternal.SafeSession) (*s
// GetSecretManagerClient returns a Secret Manager client (Go SDK)
func GetSecretManagerClient(ctx context.Context, session *gcpinternal.SafeSession) (*secretmanagerclient.Client, error) {
client, err := secretmanagerclient.NewClient(ctx, session.GetClientOption())
client, err := secretmanagerclient.NewClient(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create secret manager client: %w", err)
}
@@ -78,7 +92,7 @@ func GetSecretManagerClient(ctx context.Context, session *gcpinternal.SafeSessio
// GetBigQueryClient returns a BigQuery client (Go SDK)
func GetBigQueryClient(ctx context.Context, session *gcpinternal.SafeSession, projectID string) (*bigquery.Client, error) {
client, err := bigquery.NewClient(ctx, projectID, session.GetClientOption())
client, err := bigquery.NewClient(ctx, projectID, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create BigQuery client: %w", err)
}
@@ -87,7 +101,7 @@ func GetBigQueryClient(ctx context.Context, session *gcpinternal.SafeSession, pr
// GetPubSubClient returns a Pub/Sub client (Go SDK)
func GetPubSubClient(ctx context.Context, session *gcpinternal.SafeSession, projectID string) (*pubsub.Client, error) {
client, err := pubsub.NewClient(ctx, projectID, session.GetClientOption())
client, err := pubsub.NewClient(ctx, projectID, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Pub/Sub client: %w", err)
}
@@ -96,7 +110,7 @@ func GetPubSubClient(ctx context.Context, session *gcpinternal.SafeSession, proj
// GetAssetClient returns a Cloud Asset client (Go SDK)
func GetAssetClient(ctx context.Context, session *gcpinternal.SafeSession) (*asset.Client, error) {
client, err := asset.NewClient(ctx, session.GetClientOption())
client, err := asset.NewClient(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create asset client: %w", err)
}
@@ -105,7 +119,7 @@ func GetAssetClient(ctx context.Context, session *gcpinternal.SafeSession) (*ass
// GetArtifactRegistryClient returns an Artifact Registry client (Go SDK)
func GetArtifactRegistryClient(ctx context.Context, session *gcpinternal.SafeSession) (*artifactregistry.Client, error) {
client, err := artifactregistry.NewClient(ctx, session.GetClientOption())
client, err := artifactregistry.NewClient(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create artifact registry client: %w", err)
}
@@ -114,7 +128,7 @@ func GetArtifactRegistryClient(ctx context.Context, session *gcpinternal.SafeSes
// GetOrganizationsClient returns a Resource Manager Organizations client (Go SDK)
func GetOrganizationsClient(ctx context.Context, session *gcpinternal.SafeSession) (*resourcemanager.OrganizationsClient, error) {
client, err := resourcemanager.NewOrganizationsClient(ctx, session.GetClientOption())
client, err := resourcemanager.NewOrganizationsClient(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create organizations client: %w", err)
}
@@ -127,7 +141,7 @@ func GetOrganizationsClient(ctx context.Context, session *gcpinternal.SafeSessio
// GetComputeService returns a Compute Engine service
func GetComputeService(ctx context.Context, session *gcpinternal.SafeSession) (*compute.Service, error) {
service, err := compute.NewService(ctx, session.GetClientOption())
service, err := compute.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create compute service: %w", err)
}
@@ -136,7 +150,7 @@ func GetComputeService(ctx context.Context, session *gcpinternal.SafeSession) (*
// GetIAMService returns an IAM Admin service
func GetIAMService(ctx context.Context, session *gcpinternal.SafeSession) (*iam.Service, error) {
service, err := iam.NewService(ctx, session.GetClientOption())
service, err := iam.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create IAM service: %w", err)
}
@@ -145,7 +159,7 @@ func GetIAMService(ctx context.Context, session *gcpinternal.SafeSession) (*iam.
// GetResourceManagerService returns a Cloud Resource Manager service (v1)
func GetResourceManagerService(ctx context.Context, session *gcpinternal.SafeSession) (*cloudresourcemanager.Service, error) {
service, err := cloudresourcemanager.NewService(ctx, session.GetClientOption())
service, err := cloudresourcemanager.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create resource manager service: %w", err)
}
@@ -154,7 +168,7 @@ func GetResourceManagerService(ctx context.Context, session *gcpinternal.SafeSes
// GetSecretManagerService returns a Secret Manager service (REST API)
func GetSecretManagerService(ctx context.Context, session *gcpinternal.SafeSession) (*secretmanagerapi.Service, error) {
service, err := secretmanagerapi.NewService(ctx, session.GetClientOption())
service, err := secretmanagerapi.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create secret manager service: %w", err)
}
@@ -163,7 +177,7 @@ func GetSecretManagerService(ctx context.Context, session *gcpinternal.SafeSessi
// GetBigQueryService returns a BigQuery service (REST API v2)
func GetBigQueryService(ctx context.Context, session *gcpinternal.SafeSession) (*bigqueryapi.Service, error) {
service, err := bigqueryapi.NewService(ctx, session.GetClientOption())
service, err := bigqueryapi.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create BigQuery service: %w", err)
}
@@ -172,7 +186,7 @@ func GetBigQueryService(ctx context.Context, session *gcpinternal.SafeSession) (
// GetStorageService returns a Cloud Storage service (REST API)
func GetStorageService(ctx context.Context, session *gcpinternal.SafeSession) (*storageapi.Service, error) {
service, err := storageapi.NewService(ctx, session.GetClientOption())
service, err := storageapi.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create storage service: %w", err)
}
@@ -181,7 +195,7 @@ func GetStorageService(ctx context.Context, session *gcpinternal.SafeSession) (*
// GetArtifactRegistryService returns an Artifact Registry service (REST API)
func GetArtifactRegistryService(ctx context.Context, session *gcpinternal.SafeSession) (*artifactregistryapi.Service, error) {
service, err := artifactregistryapi.NewService(ctx, session.GetClientOption())
service, err := artifactregistryapi.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Artifact Registry service: %w", err)
}
@@ -190,7 +204,7 @@ func GetArtifactRegistryService(ctx context.Context, session *gcpinternal.SafeSe
// GetContainerService returns a GKE Container service
func GetContainerService(ctx context.Context, session *gcpinternal.SafeSession) (*container.Service, error) {
service, err := container.NewService(ctx, session.GetClientOption())
service, err := container.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create container service: %w", err)
}
@@ -199,7 +213,7 @@ func GetContainerService(ctx context.Context, session *gcpinternal.SafeSession)
// GetCloudRunService returns a Cloud Run service (v1)
func GetCloudRunService(ctx context.Context, session *gcpinternal.SafeSession) (*run.APIService, error) {
service, err := run.NewService(ctx, session.GetClientOption())
service, err := run.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Cloud Run service: %w", err)
}
@@ -208,7 +222,7 @@ func GetCloudRunService(ctx context.Context, session *gcpinternal.SafeSession) (
// GetCloudRunServiceV2 returns a Cloud Run service (v2)
func GetCloudRunServiceV2(ctx context.Context, session *gcpinternal.SafeSession) (*runv2.Service, error) {
service, err := runv2.NewService(ctx, session.GetClientOption())
service, err := runv2.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Cloud Run v2 service: %w", err)
}
@@ -217,7 +231,7 @@ func GetCloudRunServiceV2(ctx context.Context, session *gcpinternal.SafeSession)
// GetCloudFunctionsService returns a Cloud Functions service (v1)
func GetCloudFunctionsService(ctx context.Context, session *gcpinternal.SafeSession) (*cloudfunctions.Service, error) {
service, err := cloudfunctions.NewService(ctx, session.GetClientOption())
service, err := cloudfunctions.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Cloud Functions service: %w", err)
}
@@ -226,7 +240,7 @@ func GetCloudFunctionsService(ctx context.Context, session *gcpinternal.SafeSess
// GetCloudFunctionsServiceV2 returns a Cloud Functions v2 service
func GetCloudFunctionsServiceV2(ctx context.Context, session *gcpinternal.SafeSession) (*cloudfunctionsv2.Service, error) {
service, err := cloudfunctionsv2.NewService(ctx, session.GetClientOption())
service, err := cloudfunctionsv2.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Cloud Functions v2 service: %w", err)
}
@@ -235,7 +249,7 @@ func GetCloudFunctionsServiceV2(ctx context.Context, session *gcpinternal.SafeSe
// GetCloudIdentityService returns a Cloud Identity service
func GetCloudIdentityService(ctx context.Context, session *gcpinternal.SafeSession) (*cloudidentity.Service, error) {
service, err := cloudidentity.NewService(ctx, session.GetClientOption())
service, err := cloudidentity.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Cloud Identity service: %w", err)
}
@@ -244,7 +258,7 @@ func GetCloudIdentityService(ctx context.Context, session *gcpinternal.SafeSessi
// GetAccessContextManagerService returns an Access Context Manager service
func GetAccessContextManagerService(ctx context.Context, session *gcpinternal.SafeSession) (*accesscontextmanager.Service, error) {
service, err := accesscontextmanager.NewService(ctx, session.GetClientOption())
service, err := accesscontextmanager.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Access Context Manager service: %w", err)
}
@@ -253,7 +267,7 @@ func GetAccessContextManagerService(ctx context.Context, session *gcpinternal.Sa
// GetRedisService returns a Memorystore Redis service
func GetRedisService(ctx context.Context, session *gcpinternal.SafeSession) (*redis.Service, error) {
service, err := redis.NewService(ctx, session.GetClientOption())
service, err := redis.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Redis service: %w", err)
}
@@ -262,7 +276,7 @@ func GetRedisService(ctx context.Context, session *gcpinternal.SafeSession) (*re
// GetServiceNetworkingService returns a Service Networking service
func GetServiceNetworkingService(ctx context.Context, session *gcpinternal.SafeSession) (*servicenetworking.APIService, error) {
service, err := servicenetworking.NewService(ctx, session.GetClientOption())
service, err := servicenetworking.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Service Networking service: %w", err)
}
@@ -271,7 +285,7 @@ func GetServiceNetworkingService(ctx context.Context, session *gcpinternal.SafeS
// GetComposerService returns a Cloud Composer service
func GetComposerService(ctx context.Context, session *gcpinternal.SafeSession) (*composer.Service, error) {
service, err := composer.NewService(ctx, session.GetClientOption())
service, err := composer.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Composer service: %w", err)
}
@@ -280,7 +294,7 @@ func GetComposerService(ctx context.Context, session *gcpinternal.SafeSession) (
// GetDataflowService returns a Dataflow service
func GetDataflowService(ctx context.Context, session *gcpinternal.SafeSession) (*dataflow.Service, error) {
service, err := dataflow.NewService(ctx, session.GetClientOption())
service, err := dataflow.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Dataflow service: %w", err)
}
@@ -289,7 +303,7 @@ func GetDataflowService(ctx context.Context, session *gcpinternal.SafeSession) (
// GetDataprocService returns a Dataproc service
func GetDataprocService(ctx context.Context, session *gcpinternal.SafeSession) (*dataproc.Service, error) {
service, err := dataproc.NewService(ctx, session.GetClientOption())
service, err := dataproc.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Dataproc service: %w", err)
}
@@ -298,7 +312,7 @@ func GetDataprocService(ctx context.Context, session *gcpinternal.SafeSession) (
// GetNotebooksService returns a Notebooks service
func GetNotebooksService(ctx context.Context, session *gcpinternal.SafeSession) (*notebooks.Service, error) {
service, err := notebooks.NewService(ctx, session.GetClientOption())
service, err := notebooks.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Notebooks service: %w", err)
}
@@ -307,7 +321,7 @@ func GetNotebooksService(ctx context.Context, session *gcpinternal.SafeSession)
// GetBeyondCorpService returns a BeyondCorp service
func GetBeyondCorpService(ctx context.Context, session *gcpinternal.SafeSession) (*beyondcorp.Service, error) {
service, err := beyondcorp.NewService(ctx, session.GetClientOption())
service, err := beyondcorp.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create BeyondCorp service: %w", err)
}
@@ -316,7 +330,7 @@ func GetBeyondCorpService(ctx context.Context, session *gcpinternal.SafeSession)
// GetIAPService returns an IAP service
func GetIAPService(ctx context.Context, session *gcpinternal.SafeSession) (*iap.Service, error) {
service, err := iap.NewService(ctx, session.GetClientOption())
service, err := iap.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create IAP service: %w", err)
}
@@ -325,7 +339,7 @@ func GetIAPService(ctx context.Context, session *gcpinternal.SafeSession) (*iap.
// GetKMSService returns a Cloud KMS service
func GetKMSService(ctx context.Context, session *gcpinternal.SafeSession) (*cloudkms.Service, error) {
service, err := cloudkms.NewService(ctx, session.GetClientOption())
service, err := cloudkms.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create KMS service: %w", err)
}
@@ -334,7 +348,7 @@ func GetKMSService(ctx context.Context, session *gcpinternal.SafeSession) (*clou
// GetSQLAdminService returns a Cloud SQL Admin service (v1)
func GetSQLAdminService(ctx context.Context, session *gcpinternal.SafeSession) (*sqladmin.Service, error) {
service, err := sqladmin.NewService(ctx, session.GetClientOption())
service, err := sqladmin.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create SQL Admin service: %w", err)
}
@@ -343,7 +357,7 @@ func GetSQLAdminService(ctx context.Context, session *gcpinternal.SafeSession) (
// GetSQLAdminServiceBeta returns a Cloud SQL Admin service (v1beta4)
func GetSQLAdminServiceBeta(ctx context.Context, session *gcpinternal.SafeSession) (*sqladminbeta.Service, error) {
service, err := sqladminbeta.NewService(ctx, session.GetClientOption())
service, err := sqladminbeta.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create SQL Admin beta service: %w", err)
}
@@ -352,7 +366,7 @@ func GetSQLAdminServiceBeta(ctx context.Context, session *gcpinternal.SafeSessio
// GetDNSService returns a Cloud DNS service
func GetDNSService(ctx context.Context, session *gcpinternal.SafeSession) (*dns.Service, error) {
service, err := dns.NewService(ctx, session.GetClientOption())
service, err := dns.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create DNS service: %w", err)
}
@@ -361,7 +375,7 @@ func GetDNSService(ctx context.Context, session *gcpinternal.SafeSession) (*dns.
// GetPubSubService returns a Pub/Sub service (REST API)
func GetPubSubService(ctx context.Context, session *gcpinternal.SafeSession) (*pubsubapi.Service, error) {
service, err := pubsubapi.NewService(ctx, session.GetClientOption())
service, err := pubsubapi.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Pub/Sub service: %w", err)
}
@@ -370,7 +384,7 @@ func GetPubSubService(ctx context.Context, session *gcpinternal.SafeSession) (*p
// GetLoggingService returns a Cloud Logging service
func GetLoggingService(ctx context.Context, session *gcpinternal.SafeSession) (*logging.Service, error) {
service, err := logging.NewService(ctx, session.GetClientOption())
service, err := logging.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Logging service: %w", err)
}
@@ -379,7 +393,7 @@ func GetLoggingService(ctx context.Context, session *gcpinternal.SafeSession) (*
// GetSpannerService returns a Cloud Spanner service
func GetSpannerService(ctx context.Context, session *gcpinternal.SafeSession) (*spanner.Service, error) {
service, err := spanner.NewService(ctx, session.GetClientOption())
service, err := spanner.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Spanner service: %w", err)
}
@@ -388,7 +402,7 @@ func GetSpannerService(ctx context.Context, session *gcpinternal.SafeSession) (*
// GetBigtableAdminService returns a Bigtable Admin service
func GetBigtableAdminService(ctx context.Context, session *gcpinternal.SafeSession) (*bigtableadmin.Service, error) {
service, err := bigtableadmin.NewService(ctx, session.GetClientOption())
service, err := bigtableadmin.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Bigtable Admin service: %w", err)
}
@@ -397,7 +411,7 @@ func GetBigtableAdminService(ctx context.Context, session *gcpinternal.SafeSessi
// GetFilestoreService returns a Filestore service
func GetFilestoreService(ctx context.Context, session *gcpinternal.SafeSession) (*file.Service, error) {
service, err := file.NewService(ctx, session.GetClientOption())
service, err := file.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Filestore service: %w", err)
}
@@ -406,7 +420,7 @@ func GetFilestoreService(ctx context.Context, session *gcpinternal.SafeSession)
// GetSourceRepoService returns a Source Repositories service
func GetSourceRepoService(ctx context.Context, session *gcpinternal.SafeSession) (*sourcerepo.Service, error) {
service, err := sourcerepo.NewService(ctx, session.GetClientOption())
service, err := sourcerepo.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Source Repositories service: %w", err)
}
@@ -415,7 +429,7 @@ func GetSourceRepoService(ctx context.Context, session *gcpinternal.SafeSession)
// GetCloudBuildService returns a Cloud Build service
func GetCloudBuildService(ctx context.Context, session *gcpinternal.SafeSession) (*cloudbuild.Service, error) {
service, err := cloudbuild.NewService(ctx, session.GetClientOption())
service, err := cloudbuild.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Cloud Build service: %w", err)
}
@@ -424,7 +438,7 @@ func GetCloudBuildService(ctx context.Context, session *gcpinternal.SafeSession)
// GetOrgPolicyService returns an Organization Policy service
func GetOrgPolicyService(ctx context.Context, session *gcpinternal.SafeSession) (*orgpolicy.Service, error) {
service, err := orgpolicy.NewService(ctx, session.GetClientOption())
service, err := orgpolicy.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Org Policy service: %w", err)
}
@@ -433,7 +447,7 @@ func GetOrgPolicyService(ctx context.Context, session *gcpinternal.SafeSession)
// GetSchedulerService returns a Cloud Scheduler service
func GetSchedulerService(ctx context.Context, session *gcpinternal.SafeSession) (*cloudscheduler.Service, error) {
service, err := cloudscheduler.NewService(ctx, session.GetClientOption())
service, err := cloudscheduler.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Scheduler service: %w", err)
}
@@ -442,7 +456,7 @@ func GetSchedulerService(ctx context.Context, session *gcpinternal.SafeSession)
// GetAPIKeysService returns an API Keys service
func GetAPIKeysService(ctx context.Context, session *gcpinternal.SafeSession) (*apikeys.Service, error) {
service, err := apikeys.NewService(ctx, session.GetClientOption())
service, err := apikeys.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create API Keys service: %w", err)
}
@@ -451,7 +465,7 @@ func GetAPIKeysService(ctx context.Context, session *gcpinternal.SafeSession) (*
// GetCertificateManagerService returns a Certificate Manager service
func GetCertificateManagerService(ctx context.Context, session *gcpinternal.SafeSession) (*certificatemanager.Service, error) {
service, err := certificatemanager.NewService(ctx, session.GetClientOption())
service, err := certificatemanager.NewService(ctx, resolveOpts(session)...)
if err != nil {
return nil, fmt.Errorf("failed to create Certificate Manager service: %w", err)
}
+727 -25
View File
@@ -4,6 +4,9 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"strings"
"sync"
@@ -11,6 +14,7 @@ import (
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/impersonate"
"google.golang.org/api/option"
)
@@ -37,6 +41,7 @@ type SafeSession struct {
email string
projectID string
accountType string // "user" or "serviceAccount"
uniqueID string // Numeric unique ID from tokeninfo (issued_to/azp)
}
// GCPCredentialInfo holds information about the current credential
@@ -100,6 +105,675 @@ func NewSafeSession(ctx context.Context) (*SafeSession, error) {
return ss, nil
}
// NewSafeSessionWithImpersonation creates a SafeSession that generates short-lived
// tokens as the target service account. When baseTS is nil the caller's ADC is
// used as the base credential. When baseTS is provided (e.g. from a key file or
// access token session) it is used instead, enabling auth chaining like
// --key-file + --impersonate-sa or --access-token + --impersonate-sa.
func NewSafeSessionWithImpersonation(ctx context.Context, targetSA string, baseTS oauth2.TokenSource) (*SafeSession, error) {
var opts []option.ClientOption
if baseTS != nil {
opts = append(opts, option.WithTokenSource(baseTS))
}
// Override the ADC quota project with the target SA's project. The ADC
// file may contain a stale or deleted quota_project_id which causes the
// IAM Credentials API call to fail with USER_PROJECT_DENIED.
if projectID := projectFromSAEmail(targetSA); projectID != "" {
opts = append(opts, option.WithQuotaProject(projectID))
}
ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{
TargetPrincipal: targetSA,
Scopes: CommonScopes,
}, opts...)
if err != nil {
return nil, fmt.Errorf("failed to create impersonated token source for %s: %w", targetSA, err)
}
ss := &SafeSession{
tokenSource: ts,
tokens: make(map[string]*oauth2.Token),
refreshBuffer: 5 * time.Minute,
stopMonitor: make(chan struct{}),
email: targetSA,
accountType: "serviceAccount",
}
// Get initial token to validate impersonation works
token, err := ts.Token()
if err != nil {
return nil, fmt.Errorf("impersonation failed for %s (do you have roles/iam.serviceAccountTokenCreator?): %w", targetSA, err)
}
ss.currentToken = token
ss.sessionExpiry = token.Expiry
// Cache the token for the default scope
ss.tokens["https://www.googleapis.com/auth/cloud-platform"] = token
return ss, nil
}
// NewSafeSessionFromKeyFile creates a SafeSession from a service account JSON key file.
// This bypasses gcloud CLI entirely and authenticates directly using the key file.
func NewSafeSessionFromKeyFile(ctx context.Context, keyFilePath string) (*SafeSession, error) {
keyJSON, err := os.ReadFile(keyFilePath)
if err != nil {
return nil, fmt.Errorf("failed to read key file %s: %w", keyFilePath, err)
}
// Parse the JSON to extract the service account email and project
var keyFileData struct {
ClientEmail string `json:"client_email"`
ProjectID string `json:"project_id"`
Type string `json:"type"`
}
if err := json.Unmarshal(keyJSON, &keyFileData); err != nil {
return nil, fmt.Errorf("failed to parse key file %s: %w", keyFilePath, err)
}
if keyFileData.Type != "service_account" {
return nil, fmt.Errorf("key file %s is not a service account key (type: %s)", keyFilePath, keyFileData.Type)
}
if keyFileData.ClientEmail == "" {
return nil, fmt.Errorf("key file %s does not contain a client_email field", keyFilePath)
}
// Create credentials from the key file
creds, err := google.CredentialsFromJSON(ctx, keyJSON, CommonScopes...)
if err != nil {
return nil, fmt.Errorf("failed to create credentials from key file %s: %w", keyFilePath, err)
}
ss := &SafeSession{
tokenSource: creds.TokenSource,
tokens: make(map[string]*oauth2.Token),
refreshBuffer: 5 * time.Minute,
stopMonitor: make(chan struct{}),
email: keyFileData.ClientEmail,
accountType: "serviceAccount",
projectID: keyFileData.ProjectID,
}
// Get initial token to validate the key works
token, err := creds.TokenSource.Token()
if err != nil {
return nil, fmt.Errorf("failed to get token from key file %s (key may be expired or revoked): %w", keyFilePath, err)
}
ss.currentToken = token
ss.sessionExpiry = token.Expiry
// Cache the token for the default scope
ss.tokens["https://www.googleapis.com/auth/cloud-platform"] = token
return ss, nil
}
// NewSafeSessionFromAccessToken creates a SafeSession from a raw GCP access token.
// The token cannot be refreshed, so the session will expire when the token does.
// Identity is resolved via the Google tokeninfo endpoint.
func NewSafeSessionFromAccessToken(ctx context.Context, accessToken string) (*SafeSession, error) {
if strings.TrimSpace(accessToken) == "" {
return nil, fmt.Errorf("access token is empty")
}
token := &oauth2.Token{
AccessToken: strings.TrimSpace(accessToken),
TokenType: "Bearer",
}
ts := &StaticTokenSource{StaticToken: token}
ss := &SafeSession{
tokenSource: ts,
currentToken: token,
tokens: make(map[string]*oauth2.Token),
refreshBuffer: 5 * time.Minute,
stopMonitor: make(chan struct{}),
}
// Cache the token for the default scope
ss.tokens["https://www.googleapis.com/auth/cloud-platform"] = token
// Resolve identity via tokeninfo endpoint
ss.resolveTokenIdentity(ctx, accessToken)
return ss, nil
}
// resolveTokenIdentity tries multiple Google endpoints to determine the email
// and account type for a raw access token.
//
// Strategy:
// 1. tokeninfo v1 endpoint: returns email (if scope includes email) AND the
// numeric unique ID (issued_to/azp) which is always present.
// 2. If email was found, done. Otherwise use the unique ID with
// serviceAccounts.get to resolve the SA email (IAM API accepts numeric IDs
// in the resource path: projects/-/serviceAccounts/{uniqueId}).
// 3. Fallback: userinfo endpoint, then GCP API error parsing.
// 4. If still unresolved and --project is set, ProbeIdentityWithProject lists
// SAs in the project and matches by unique ID (called from CLI layer).
func (s *SafeSession) resolveTokenIdentity(ctx context.Context, accessToken string) {
debug := os.Getenv("CLOUDFOX_DEBUG") != ""
// Step 1: Call tokeninfo to get email (if available) and unique ID (always available)
email, acctType, uniqueID, expiry := queryTokenInfoFull(ctx, accessToken)
if debug {
fmt.Fprintf(os.Stderr, "[debug] resolveTokenIdentity tokeninfo: email=%q acctType=%q uniqueID=%q\n", email, acctType, uniqueID)
}
// Store expiry and unique ID regardless of email resolution
if !expiry.IsZero() {
s.sessionExpiry = expiry
s.currentToken.Expiry = expiry
}
if uniqueID != "" {
s.uniqueID = uniqueID
}
if email != "" {
s.email = email
s.accountType = acctType
return
}
// Step 2: Use unique ID with serviceAccounts.get to resolve SA email.
// The IAM API accepts numeric unique IDs in place of the SA email in the
// resource path: projects/-/serviceAccounts/{uniqueId}
// This returns the full SA object including the email field.
if uniqueID != "" {
email, acctType = getSAByUniqueID(ctx, accessToken, uniqueID)
if debug {
fmt.Fprintf(os.Stderr, "[debug] resolveTokenIdentity getSAByUniqueID: email=%q acctType=%q\n", email, acctType)
}
if email != "" {
s.email = email
s.accountType = acctType
return
}
}
// Step 3: Fallback - userinfo endpoint (works with openid/email scopes)
email, acctType = queryUserInfoHTTP(ctx, accessToken)
if debug {
fmt.Fprintf(os.Stderr, "[debug] resolveTokenIdentity userinfo: email=%q acctType=%q\n", email, acctType)
}
if email != "" {
s.email = email
s.accountType = acctType
return
}
// Step 4: Fallback - GCP API error parsing
email, acctType = probeGCPIdentity(ctx, accessToken)
if debug {
fmt.Fprintf(os.Stderr, "[debug] resolveTokenIdentity iam-probe: email=%q acctType=%q\n", email, acctType)
}
if email != "" {
s.email = email
s.accountType = acctType
}
}
// getSAByUniqueID calls the IAM serviceAccounts.get endpoint using the SA's
// numeric unique ID. The IAM API accepts unique IDs in place of email in the
// resource path: projects/-/serviceAccounts/{uniqueId}
// If the caller has iam.serviceAccounts.get permission, this returns the full
// SA object including the email. This works even without knowing the project.
func getSAByUniqueID(ctx context.Context, accessToken, uniqueID string) (email, acctType string) {
saURL := fmt.Sprintf("https://iam.googleapis.com/v1/projects/-/serviceAccounts/%s", uniqueID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, saURL, nil)
if err != nil {
return
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return
}
if os.Getenv("CLOUDFOX_DEBUG") != "" {
fmt.Fprintf(os.Stderr, "[debug] getSAByUniqueID response (status %d): %s\n", resp.StatusCode, string(body))
}
if resp.StatusCode == http.StatusOK {
var sa struct {
Email string `json:"email"`
UniqueID string `json:"uniqueId"`
Name string `json:"name"`
}
if err := json.Unmarshal(body, &sa); err == nil && sa.Email != "" {
return sa.Email, "serviceAccount"
}
}
// On 403, the error message might contain the resolved email
resolvedEmail, resolvedType := extractCallerFromGCPResponse(body)
if resolvedEmail != "" {
return resolvedEmail, resolvedType
}
return extractEmailFromGCPError(string(body)), "serviceAccount"
}
// queryTokenInfoFull calls the Google tokeninfo v1 endpoint and extracts email,
// account type, the numeric unique ID (issued_to/azp), and expiry.
// SA tokens from generateAccessToken always return the unique ID even when
// the email field is empty (because the token lacks the email scope).
func queryTokenInfoFull(ctx context.Context, accessToken string) (email, acctType, uniqueID string, expiry time.Time) {
url := fmt.Sprintf("https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=%s", accessToken)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return
}
if os.Getenv("CLOUDFOX_DEBUG") != "" {
fmt.Fprintf(os.Stderr, "[debug] queryTokenInfoFull (status %d): %s\n", resp.StatusCode, string(body))
}
if resp.StatusCode != http.StatusOK {
return
}
var info struct {
Email string `json:"email"`
IssuedTo string `json:"issued_to"` // numeric unique ID of the SA/user
Audience string `json:"audience"`
Scope string `json:"scope"`
Exp string `json:"exp"`
// v2/v3 field names (if we switch endpoints later)
AZP string `json:"azp"`
}
if err := json.Unmarshal(body, &info); err != nil {
return
}
if info.Email != "" {
email = info.Email
if strings.Contains(info.Email, ".iam.gserviceaccount.com") {
acctType = "serviceAccount"
} else {
acctType = "user"
}
}
// Extract unique ID: v1 uses "issued_to", v2/v3 use "azp"
uniqueID = info.IssuedTo
if uniqueID == "" {
uniqueID = info.AZP
}
if info.Exp != "" {
var expUnix int64
if _, scanErr := fmt.Sscanf(info.Exp, "%d", &expUnix); scanErr == nil {
expiry = time.Unix(expUnix, 0)
}
}
return
}
// queryUserInfoHTTP calls the Google userinfo endpoint as a fallback for
// identity resolution. This works for tokens that have openid or email scopes.
func queryUserInfoHTTP(ctx context.Context, accessToken string) (email, acctType string) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://www.googleapis.com/oauth2/v3/userinfo", nil)
if err != nil {
return
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != http.StatusOK {
return
}
var info struct {
Email string `json:"email"`
}
if err := json.Unmarshal(body, &info); err != nil {
return
}
if info.Email != "" {
email = info.Email
if strings.Contains(info.Email, ".iam.gserviceaccount.com") {
acctType = "serviceAccount"
} else {
acctType = "user"
}
}
return
}
// probeGCPIdentity is a last-resort identity resolver that makes GCP API calls
// and attempts to extract the caller email from error responses. Most identity
// resolution is handled earlier by getSAByUniqueID (step 2) or
// matchSAByUniqueID (in ProbeIdentityWithProject). This function exists as a
// final fallback for edge cases where those approaches fail.
func probeGCPIdentity(ctx context.Context, accessToken string) (email, acctType string) {
// Try a lightweight GCP API call and parse the error for caller identity.
// Use compute.regions.list which often includes the caller in 403 errors.
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://compute.googleapis.com/compute/v1/projects/_/regions?maxResults=1", nil)
if err != nil {
return
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return
}
if os.Getenv("CLOUDFOX_DEBUG") != "" {
fmt.Fprintf(os.Stderr, "[debug] probeGCPIdentity compute response (status %d): %s\n", resp.StatusCode, string(body))
}
return extractCallerFromGCPResponse(body)
}
// extractEmailFromGCPError extracts an email address from a GCP API error message.
// GCP error messages often contain the caller identity in patterns like:
// - "... Caller: [email protected] ..."
// - "... caller does not have permission ... [email protected] ..."
func extractEmailFromGCPError(msg string) string {
// Look for email-like patterns in the error message
// Simple approach: find strings matching [email protected] pattern
parts := strings.Fields(msg)
for _, part := range parts {
// Clean common trailing punctuation
part = strings.TrimRight(part, ".,;:\"'")
if isLikelyEmail(part) {
return part
}
}
return ""
}
// isLikelyEmail checks if a string looks like an email address
func isLikelyEmail(s string) bool {
at := strings.Index(s, "@")
if at < 1 || at >= len(s)-1 {
return false
}
dot := strings.LastIndex(s[at:], ".")
return dot > 1
}
// ProbeIdentityWithProject attempts to resolve the session's email by making
// project-scoped GCP API calls. This is used as a fallback when tokeninfo
// endpoints return a numeric unique ID but no email (common with SA tokens
// generated via generateAccessToken with only cloud-platform scope).
//
// Strategies (in order):
// 1. List service accounts in the project and match by unique ID
// 2. Cloud Resource Manager projects.get (extracts caller from error details)
// 3. Cloud Resource Manager projects.testIamPermissions
func ProbeIdentityWithProject(ctx context.Context, session *SafeSession, projectID string) {
if session.GetEmail() != "" {
return // already resolved
}
token, err := session.GetToken(ctx)
if err != nil || token == "" {
return
}
debug := os.Getenv("CLOUDFOX_DEBUG") != ""
// Strategy 1: List SAs in the project and match by unique ID.
// The tokeninfo endpoint always returns the SA's numeric unique ID
// (issued_to/azp), even when email is missing. Each SA in the project
// has a uniqueId field. We match to find the email.
session.mu.Lock()
uniqueID := session.uniqueID
session.mu.Unlock()
if uniqueID != "" {
email, acctType := matchSAByUniqueID(ctx, token, projectID, uniqueID)
if debug {
fmt.Fprintf(os.Stderr, "[debug] matchSAByUniqueID: email=%q acctType=%q\n", email, acctType)
}
if email != "" {
session.mu.Lock()
session.email = email
session.accountType = acctType
session.mu.Unlock()
return
}
}
// Strategy 2: projects.get - extracts caller from 403 error details
email, acctType := probeWithCRM(ctx, token, projectID)
if debug {
fmt.Fprintf(os.Stderr, "[debug] probeWithCRM: email=%q acctType=%q\n", email, acctType)
}
if email != "" {
session.mu.Lock()
session.email = email
session.accountType = acctType
session.mu.Unlock()
return
}
// Strategy 3: testIamPermissions - extracts caller from error details
email, acctType = probeWithTestPermissions(ctx, token, projectID)
if debug {
fmt.Fprintf(os.Stderr, "[debug] probeWithTestPermissions: email=%q acctType=%q\n", email, acctType)
}
if email != "" {
session.mu.Lock()
session.email = email
session.accountType = acctType
session.mu.Unlock()
}
}
// matchSAByUniqueID lists service accounts in a project and matches by numeric
// unique ID. The IAM API returns each SA's uniqueId field which corresponds to
// the issued_to/azp value from the tokeninfo endpoint.
func matchSAByUniqueID(ctx context.Context, accessToken, projectID, targetUniqueID string) (email, acctType string) {
debug := os.Getenv("CLOUDFOX_DEBUG") != ""
pageToken := ""
for {
url := fmt.Sprintf("https://iam.googleapis.com/v1/projects/%s/serviceAccounts?pageSize=100", projectID)
if pageToken != "" {
url += "&pageToken=" + pageToken
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return
}
if debug {
fmt.Fprintf(os.Stderr, "[debug] matchSAByUniqueID page (status %d, project %s)\n", resp.StatusCode, projectID)
}
if resp.StatusCode != http.StatusOK {
if debug {
fmt.Fprintf(os.Stderr, "[debug] matchSAByUniqueID error: %s\n", string(body))
}
return
}
var listResp struct {
Accounts []struct {
Email string `json:"email"`
UniqueID string `json:"uniqueId"`
Name string `json:"name"`
} `json:"accounts"`
NextPageToken string `json:"nextPageToken"`
}
if err := json.Unmarshal(body, &listResp); err != nil {
return
}
for _, sa := range listResp.Accounts {
if sa.UniqueID == targetUniqueID {
if debug {
fmt.Fprintf(os.Stderr, "[debug] matchSAByUniqueID MATCH: uniqueID=%s -> email=%s\n", targetUniqueID, sa.Email)
}
return sa.Email, "serviceAccount"
}
}
if listResp.NextPageToken == "" {
break
}
pageToken = listResp.NextPageToken
}
if debug {
fmt.Fprintf(os.Stderr, "[debug] matchSAByUniqueID: no match for uniqueID=%s in project %s\n", targetUniqueID, projectID)
}
return
}
// probeWithCRM calls cloudresourcemanager projects.get and extracts caller email
// from the response or error details.
func probeWithCRM(ctx context.Context, accessToken, projectID string) (email, acctType string) {
url := fmt.Sprintf("https://cloudresourcemanager.googleapis.com/v1/projects/%s", projectID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return
}
if os.Getenv("CLOUDFOX_DEBUG") != "" {
fmt.Fprintf(os.Stderr, "[debug] probeWithCRM response (status %d): %s\n", resp.StatusCode, string(body))
}
return extractCallerFromGCPResponse(body)
}
// probeWithTestPermissions calls testIamPermissions on the project. This endpoint
// is commonly accessible and error responses include the caller identity.
func probeWithTestPermissions(ctx context.Context, accessToken, projectID string) (email, acctType string) {
url := fmt.Sprintf("https://cloudresourcemanager.googleapis.com/v1/projects/%s:testIamPermissions", projectID)
payload := `{"permissions":["resourcemanager.projects.get"]}`
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(payload))
if err != nil {
return
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return
}
return extractCallerFromGCPResponse(body)
}
// extractCallerFromGCPResponse extracts a caller email from a GCP API response body.
// Works with both error responses (403/404) and success responses that may include
// the caller in audit metadata or error details.
func extractCallerFromGCPResponse(body []byte) (email, acctType string) {
var apiResp struct {
Error struct {
Message string `json:"message"`
Status string `json:"status"`
Details []struct {
Type string `json:"@type"`
Reason string `json:"reason"`
Domain string `json:"domain"`
Metadata map[string]string `json:"metadata"`
} `json:"details"`
} `json:"error"`
}
if err := json.Unmarshal(body, &apiResp); err != nil {
return
}
// Check error details metadata for caller identity
for _, detail := range apiResp.Error.Details {
if detail.Type == "type.googleapis.com/google.rpc.ErrorInfo" {
// Check common metadata keys for caller identity
for _, key := range []string{"caller", "email", "principal", "service_account"} {
if val, ok := detail.Metadata[key]; ok && isLikelyEmail(val) {
email = val
break
}
}
if email != "" {
break
}
}
}
// Fallback: scan the error message for email-like strings
if email == "" {
email = extractEmailFromGCPError(apiResp.Error.Message)
}
if email != "" {
if strings.Contains(email, ".iam.gserviceaccount.com") {
acctType = "serviceAccount"
} else {
acctType = "user"
}
}
return
}
// NewSmartSession creates a session with automatic monitoring and refresh
func NewSmartSession(ctx context.Context) (*SafeSession, error) {
ss, err := NewSafeSession(ctx)
@@ -141,7 +815,10 @@ func (s *SafeSession) getTokenUnlocked(ctx context.Context) (string, error) {
return token.AccessToken, nil
}
// GetTokenForScope returns a token for a specific OAuth scope
// GetTokenForScope returns a token for a specific OAuth scope.
// When using an impersonated session, this returns the impersonated token
// (which already has cloud-platform scope) rather than creating a new ADC
// token source that would bypass impersonation.
func (s *SafeSession) GetTokenForScope(ctx context.Context, scope string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -151,13 +828,10 @@ func (s *SafeSession) GetTokenForScope(ctx context.Context, scope string) (strin
return tok.AccessToken, nil
}
// Get a new token source for this scope
ts, err := google.DefaultTokenSource(ctx, scope)
if err != nil {
return "", fmt.Errorf("failed to create token source for scope %s: %w", scope, err)
}
token, err := ts.Token()
// Use the existing token source to preserve identity (ADC or impersonated).
// The token source was created with CommonScopes which includes cloud-platform,
// so the returned token is valid for all GCP APIs regardless of the requested scope.
token, err := s.tokenSource.Token()
if err != nil {
return "", fmt.Errorf("failed to get token for scope %s: %w", scope, err)
}
@@ -225,33 +899,25 @@ func (s *SafeSession) IsSessionExpired() bool {
return time.Now().Add(s.refreshBuffer).After(s.sessionExpiry)
}
// RefreshSession refreshes the token and clears the cache
// RefreshSession refreshes the token and clears the cache.
// This uses the existing token source (which may be ADC or an impersonated
// token source) so it preserves the current identity.
func (s *SafeSession) RefreshSession(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
// Check if gcloud session is still valid
if !IsSessionValid() {
return fmt.Errorf("GCP session expired; please run 'gcloud auth login' or 'gcloud auth application-default login'")
}
// Create new token source
ts, err := google.DefaultTokenSource(ctx, CommonScopes...)
// Get a fresh token from the existing token source. Both ADC and
// impersonated token sources handle refresh internally, so we just
// need to invalidate the cached token and request a new one.
token, err := s.tokenSource.Token()
if err != nil {
return fmt.Errorf("failed to create token source: %w", err)
}
s.tokenSource = ts
// Get fresh token
token, err := ts.Token()
if err != nil {
return fmt.Errorf("failed to get fresh token: %w", err)
return fmt.Errorf("failed to refresh token: %w", err)
}
s.currentToken = token
s.sessionExpiry = token.Expiry
// Clear token cache
// Clear per-scope token cache
s.tokens = make(map[string]*oauth2.Token)
s.tokens["https://www.googleapis.com/auth/cloud-platform"] = token
@@ -397,6 +1063,27 @@ func IsSessionValid() bool {
return token != "" && !strings.Contains(token, "ERROR")
}
// defaultSession holds the global session set by --impersonate-sa.
// Services check this when their struct-level session is nil.
var (
defaultSessionMu sync.RWMutex
defaultSession *SafeSession
)
// SetDefaultSession sets the package-level default session (called from CLI init).
func SetDefaultSession(s *SafeSession) {
defaultSessionMu.Lock()
defaultSession = s
defaultSessionMu.Unlock()
}
// GetDefaultSession returns the package-level default session, or nil.
func GetDefaultSession() *SafeSession {
defaultSessionMu.RLock()
defer defaultSessionMu.RUnlock()
return defaultSession
}
// IsADCConfigured checks if Application Default Credentials are configured
func IsADCConfigured() bool {
ctx := context.Background()
@@ -422,6 +1109,21 @@ func GetDefaultAccount() string {
return strings.TrimSpace(string(out))
}
// projectFromSAEmail extracts the project ID from a service account email.
// e.g. "[email protected]" -> "my-project"
func projectFromSAEmail(email string) string {
// SA emails have the form: <name>@<project>.iam.gserviceaccount.com
parts := strings.SplitN(email, "@", 2)
if len(parts) != 2 {
return ""
}
domain := parts[1]
if !strings.HasSuffix(domain, ".iam.gserviceaccount.com") {
return ""
}
return strings.TrimSuffix(domain, ".iam.gserviceaccount.com")
}
// GetAccessToken returns a fresh access token from gcloud CLI
// This is useful for REST API calls that need a bearer token
func GetAccessToken() (string, error) {
-162
View File
@@ -1,162 +0,0 @@
package kubernetes
import (
"context"
"fmt"
"strings"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
)
// CloudRoleDetectionResult holds a unified detection
type CloudRoleDetectionResult struct {
Provider string
Role string
Source string // e.g., "pod env", "pod annotation", "serviceaccount annotation", "configmap", "secret"
}
// DetectCloudRole inspects PodSpec + annotations + ServiceAccount for cloud roles.
func DetectCloudRole(ctx context.Context, clientset *kubernetes.Clientset, namespace, serviceAccountName string, podSpec *corev1.PodSpec, annotations map[string]string) []CloudRoleDetectionResult {
results := []CloudRoleDetectionResult{}
// --- 1. Pod annotations ---
for k, v := range annotations {
if provider, role, ok := ClassifyCloudRole(v); ok {
results = append(results, CloudRoleDetectionResult{
Provider: provider,
Role: role,
Source: fmt.Sprintf("pod annotation %s", k),
})
}
}
// --- 2. ServiceAccount annotations ---
if podSpec.ServiceAccountName != "" {
sa, err := clientset.CoreV1().ServiceAccounts(namespace).Get(ctx, podSpec.ServiceAccountName, metav1.GetOptions{})
if err == nil {
for k, v := range sa.Annotations {
if provider, role, ok := ClassifyCloudRole(v); ok {
results = append(results, CloudRoleDetectionResult{
Provider: provider,
Role: role,
Source: fmt.Sprintf("serviceaccount %s annotation %s", podSpec.ServiceAccountName, k),
})
}
}
}
}
// --- 3. Environment variables (direct values) ---
for _, c := range podSpec.Containers {
for _, e := range c.Env {
if e.Value != "" {
if provider, role, ok := ClassifyCloudRole(e.Value); ok {
results = append(results, CloudRoleDetectionResult{
Provider: provider,
Role: role,
Source: fmt.Sprintf("pod env %s", e.Name),
})
}
}
}
}
// --- 4. Env sourced from ConfigMaps/Secrets ---
for _, c := range podSpec.Containers {
for _, e := range c.Env {
if e.ValueFrom != nil && e.ValueFrom.ConfigMapKeyRef != nil {
cmName := e.ValueFrom.ConfigMapKeyRef.Name
key := e.ValueFrom.ConfigMapKeyRef.Key
cm, err := clientset.CoreV1().ConfigMaps(namespace).Get(ctx, cmName, metav1.GetOptions{})
if err == nil {
if val := SafeGet(cm.Data, key); val != "" {
if provider, role, ok := ClassifyCloudRole(val); ok {
results = append(results, CloudRoleDetectionResult{
Provider: provider,
Role: role,
Source: fmt.Sprintf("configmap %s/%s key %s", namespace, cmName, key),
})
}
}
}
}
if e.ValueFrom != nil && e.ValueFrom.SecretKeyRef != nil {
secName := e.ValueFrom.SecretKeyRef.Name
key := e.ValueFrom.SecretKeyRef.Key
sec, err := clientset.CoreV1().Secrets(namespace).Get(ctx, secName, metav1.GetOptions{})
if err == nil {
if val := SafeGetString(sec.Data, key); val != "" {
if provider, role, ok := ClassifyCloudRole(val); ok {
results = append(results, CloudRoleDetectionResult{
Provider: provider,
Role: role,
Source: fmt.Sprintf("secret %s/%s key %s", namespace, secName, key),
})
}
}
}
}
}
}
// --- Deduplicate ---
return DeduplicateDetections(results)
}
// Deduplicate results by Provider+Role
func DeduplicateDetections(in []CloudRoleDetectionResult) []CloudRoleDetectionResult {
seen := map[string]bool{}
out := []CloudRoleDetectionResult{}
for _, r := range in {
key := r.Provider + "|" + r.Role
if !seen[key] {
seen[key] = true
out = append(out, r)
}
}
return out
}
// SafeGetString fetches from Secret.Data safely
func SafeGetString(m map[string][]byte, key string) string {
if m == nil {
return ""
}
val, ok := m[key]
if !ok {
return ""
}
return strings.TrimSpace(string(val))
}
// DetectCloudProviderFromNode detects the cloud provider from Node.Spec.ProviderID
func DetectCloudProviderFromNode(providerID string) string {
if providerID == "" {
return ""
}
switch {
case strings.HasPrefix(providerID, "aws://"):
return "AWS"
case strings.HasPrefix(providerID, "gce://"):
return "GCP"
case strings.HasPrefix(providerID, "azure://"):
return "Azure"
default:
return "Unknown"
}
}
// DetectCloudRoleFromNodeLabels returns a role (string) based on node labels (optional)
func DetectCloudRoleFromNodeLabels(labels map[string]string) string {
if labels == nil {
return ""
}
// Example: AWS node IAM role label
if role, ok := labels["iam.amazonaws.com/role"]; ok {
return role
}
// Add other label-based detection if needed
return ""
}
-53
View File
@@ -1,53 +0,0 @@
package kubernetes
import (
"regexp"
"strings"
)
// --- Regex matchers for role formats ---
// AWS IAM role ARN
var awsRoleRegex = regexp.MustCompile(`^arn:aws:iam::\d{12}:role\/[A-Za-z0-9+=,.@\-_/]+$`)
// GCP Service Account email
var gcpSARegex = regexp.MustCompile(`^[a-zA-Z0-9\-]+@[a-z0-9\-]+\.iam\.gserviceaccount\.com$`)
// Azure Managed Identity clientID or resourceID
var azureMIRegex = regexp.MustCompile(`^[0-9a-fA-F\-]{36}$`)
// --- Helpers ---
func IsAWSRoleArn(s string) bool {
return awsRoleRegex.MatchString(strings.TrimSpace(s))
}
func IsGCPServiceAccount(s string) bool {
return gcpSARegex.MatchString(strings.TrimSpace(s))
}
func IsAzureManagedIdentity(s string) bool {
return azureMIRegex.MatchString(strings.TrimSpace(s))
}
// Classify string into provider/role
func ClassifyCloudRole(s string) (provider, role string, ok bool) {
if IsAWSRoleArn(s) {
return "AWS", s, true
}
if IsGCPServiceAccount(s) {
return "GCP", s, true
}
if IsAzureManagedIdentity(s) {
return "Azure", s, true
}
return "", "", false
}
// SafeGet fetches a map key safely
func SafeGet(m map[string]string, key string) string {
if m == nil {
return ""
}
return m[key]
}
-376
View File
@@ -1,376 +0,0 @@
package kubernetes
import (
"context"
"math/rand"
"net/url"
"os"
"regexp"
"strings"
"time"
"github.com/BishopFox/cloudfox/globals"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
)
// randomSuffix generates a random 6-character alphanumeric string
func randomSuffix() string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
seededRand := rand.New(rand.NewSource(time.Now().UnixNano()))
b := make([]byte, 6)
for i := range b {
b[i] = charset[seededRand.Intn(len(charset))]
}
return string(b)
}
// GetClusterName retrieves the cluster name using multiple detection methods.
// Priority: kubeconfig context (user-configured, most authoritative) > node labels > API server URL > error parsing.
func GetClusterName(clientset *kubernetes.Clientset) string {
ctx := context.Background()
// Attempt 1: Parse kubeconfig context/cluster name (most authoritative — user-configured)
if name := getClusterNameFromKubeconfig(); name != "" {
return name
}
// Attempt 2: Check node labels for cluster name (cloud provider metadata)
if name := getClusterNameFromNodes(ctx, clientset); name != "" {
return name
}
// Attempt 3: Extract from API server URL (cloud provider specific)
if name := getClusterNameFromAPIServer(clientset); name != "" {
return name
}
// Attempt 4: Force an error and parse the cluster info from it
if name := getClusterNameFromError(ctx, clientset); name != "" {
return name
}
return "unknown-cluster-" + randomSuffix()
}
// getClusterNameFromNodes checks node labels for cluster name
func getClusterNameFromNodes(ctx context.Context, clientset *kubernetes.Clientset) string {
nodes, err := clientset.CoreV1().Nodes().List(ctx, metav1.ListOptions{Limit: 1})
if err != nil {
return ""
}
for _, node := range nodes.Items {
labels := node.GetLabels()
// Check known cloud provider labels first
knownKeys := []string{
"eks.amazonaws.com/cluster-name", // AWS EKS
"kubernetes.azure.com/cluster", // Azure AKS
"cloud.google.com/gke-nodepool", // GKE (nodepool contains cluster info)
"cluster.x-k8s.io/cluster-name", // Cluster API
"alpha.eksctl.io/cluster-name", // eksctl
"kops.k8s.io/instancegroup", // kops
}
for _, key := range knownKeys {
if val, ok := labels[key]; ok && val != "" {
// For GKE nodepool, try to extract cluster name from node name
if key == "cloud.google.com/gke-nodepool" {
if gkeName := extractGKEClusterFromNodeName(node.Name); gkeName != "" {
return gkeName
}
}
return val
}
}
// Check provider ID for cloud-specific parsing
if providerID := node.Spec.ProviderID; providerID != "" {
if name := extractClusterFromProviderID(providerID); name != "" {
return name
}
}
// Fallback: any label containing "cluster"
for k, v := range labels {
if strings.Contains(strings.ToLower(k), "cluster") && v != "" && !strings.Contains(v, "/") {
return v
}
}
}
return ""
}
// getClusterNameFromKubeconfig extracts cluster name from kubeconfig context.
// This is the most authoritative source — it's what the user configured.
func getClusterNameFromKubeconfig() string {
// Try to resolve kubeconfig path if not explicitly set
kubeconfigPath := globals.KubeConfigPath
if kubeconfigPath == "" {
if env := os.Getenv("KUBECONFIG"); env != "" {
kubeconfigPath = env
} else if home, err := os.UserHomeDir(); err == nil {
candidate := home + "/.kube/config"
if _, err := os.Stat(candidate); err == nil {
kubeconfigPath = candidate
}
}
}
if kubeconfigPath == "" {
return ""
}
kubeconfig, err := clientcmd.LoadFromFile(kubeconfigPath)
if err != nil {
return ""
}
// Determine which context to use
contextName := globals.KubeContext
if contextName == "" {
contextName = kubeconfig.CurrentContext
}
if contextName == "" {
return ""
}
ctx, ok := kubeconfig.Contexts[contextName]
if !ok {
return ""
}
// Get cluster name from context
clusterName := ctx.Cluster
if clusterName == "" {
return ""
}
// For EKS: cluster entry is an ARN like "arn:aws:eks:us-east-1:123456789012:cluster/my-cluster"
if strings.HasPrefix(clusterName, "arn:aws:eks:") {
if name := extractEKSClusterName(clusterName); name != "" {
return name
}
}
// For GKE: cluster entry looks like "gke_project-id_zone_cluster-name"
if strings.HasPrefix(clusterName, "gke_") {
if name := extractGKEClusterName(clusterName); name != "" {
return name
}
}
// For generic kubeconfigs (including AKS which is typically just "my-aks-cluster"),
// return the cluster name as-is. Don't split on underscores — that's a valid character
// in cluster names and splitting breaks non-cloud names.
if len(clusterName) < 200 {
return clusterName
}
return ""
}
// getClusterNameFromAPIServer extracts cluster name from API server URL
func getClusterNameFromAPIServer(clientset *kubernetes.Clientset) string {
if clientset.RESTClient() == nil {
return ""
}
apiURL := clientset.RESTClient().Get().URL()
if apiURL == nil {
return ""
}
host := apiURL.Host
// EKS: https://<hash>.gr7.us-east-1.eks.amazonaws.com
if strings.Contains(host, ".eks.amazonaws.com") {
// Can't get cluster name from URL, but we know it's EKS
return ""
}
// AKS: https://<cluster>-<resource-group>-<random>.<region>.azmk8s.io:443
if strings.Contains(host, ".azmk8s.io") {
return extractAKSClusterFromURL(host)
}
// GKE: https://<ip> or https://<hash>.gke.goog
if strings.Contains(host, ".gke.goog") {
return "" // Can't extract name from GKE URL
}
// Generic: try to extract from hostname
if name := extractClusterFromHostname(host); name != "" {
return name
}
return ""
}
// getClusterNameFromError forces an error and parses cluster info from it
func getClusterNameFromError(ctx context.Context, clientset *kubernetes.Clientset) string {
// Try to access a non-existent resource to get an error with cluster info
_, err := clientset.CoreV1().Namespaces().Get(ctx, "cloudfox-nonexistent-namespace-probe", metav1.GetOptions{})
if err != nil {
errStr := err.Error()
// Some clusters include cluster name in error messages
// Example: "namespaces \"cloudfox-nonexistent\" not found" with cluster context
// Check for server URL in error
if strings.Contains(errStr, "server") {
re := regexp.MustCompile(`server[:\s]+["']?([^"'\s]+)["']?`)
if match := re.FindStringSubmatch(errStr); len(match) > 1 {
if parsed, parseErr := url.Parse(match[1]); parseErr == nil {
if name := extractClusterFromHostname(parsed.Host); name != "" {
return name
}
}
}
}
}
return ""
}
// extractEKSClusterName extracts cluster name from EKS ARN
// Format: arn:aws:eks:region:account:cluster/cluster-name
func extractEKSClusterName(arn string) string {
re := regexp.MustCompile(`arn:aws:eks:[^:]+:[^:]+:cluster/(.+)$`)
if match := re.FindStringSubmatch(arn); len(match) > 1 {
return match[1]
}
return ""
}
// extractGKEClusterName extracts cluster name from GKE context
// Format: gke_project-id_zone_cluster-name
func extractGKEClusterName(context string) string {
parts := strings.Split(context, "_")
if len(parts) >= 4 && parts[0] == "gke" {
return parts[len(parts)-1]
}
return ""
}
// extractGKEClusterFromNodeName extracts cluster name from GKE node name
// Format: gke-cluster-name-nodepool-name-random
func extractGKEClusterFromNodeName(nodeName string) string {
if !strings.HasPrefix(nodeName, "gke-") {
return ""
}
parts := strings.Split(nodeName, "-")
if len(parts) < 3 {
return ""
}
// Find where nodepool name starts (usually has "pool" or "default-pool")
for i := 1; i < len(parts)-1; i++ {
if parts[i] == "pool" || parts[i] == "default" {
return strings.Join(parts[1:i], "-")
}
}
// Fallback: take parts[1] as cluster name
return parts[1]
}
// extractAKSClusterFromURL extracts cluster name from AKS API server URL
// Format: cluster-resourcegroup-random.region.azmk8s.io
func extractAKSClusterFromURL(host string) string {
// Remove port if present
if idx := strings.Index(host, ":"); idx != -1 {
host = host[:idx]
}
parts := strings.Split(host, ".")
if len(parts) < 3 {
return ""
}
// First part is: cluster-resourcegroup-random
nameParts := strings.Split(parts[0], "-")
if len(nameParts) >= 2 {
// Return first part as cluster name (rough heuristic)
return nameParts[0]
}
return ""
}
// extractClusterFromProviderID extracts cluster hints from provider ID
func extractClusterFromProviderID(providerID string) string {
// AWS: aws:///us-east-1a/i-xxxxx
// GCE: gce://project/zone/instance-name
// Azure: azure:///subscriptions/xxx/resourceGroups/xxx/providers/Microsoft.Compute/virtualMachineScaleSets/xxx/virtualMachines/xxx
// For Azure, try to extract resource group (often named after cluster)
if strings.HasPrefix(providerID, "azure://") {
re := regexp.MustCompile(`resourceGroups/([^/]+)`)
if match := re.FindStringSubmatch(providerID); len(match) > 1 {
// Resource group often contains cluster name
rg := match[1]
// AKS resource groups are often: MC_<resource-group>_<cluster-name>_<region>
if strings.HasPrefix(rg, "MC_") {
parts := strings.Split(rg, "_")
if len(parts) >= 3 {
return parts[2] // cluster name
}
}
return rg
}
}
return ""
}
// extractClusterFromHostname attempts to extract cluster name from generic hostname
func extractClusterFromHostname(host string) string {
// Remove port if present
if idx := strings.Index(host, ":"); idx != -1 {
host = host[:idx]
}
// Skip IP addresses
if isIPAddress(host) {
return ""
}
// For hostnames like "kubernetes.default.svc.cluster.local" or similar
if strings.Contains(host, "kubernetes") || strings.Contains(host, "cluster.local") {
return ""
}
// For simple hostnames, return the first segment
parts := strings.Split(host, ".")
if len(parts) > 0 && len(parts[0]) > 0 && len(parts[0]) < 64 {
return parts[0]
}
return ""
}
// isIPAddress checks if a string is an IP address
func isIPAddress(s string) bool {
// Simple check for IPv4
parts := strings.Split(s, ".")
if len(parts) == 4 {
for _, part := range parts {
if len(part) > 3 {
return false
}
for _, c := range part {
if c < '0' || c > '9' {
return false
}
}
}
return true
}
// Check for IPv6 (contains colons)
if strings.Contains(s, ":") && !strings.Contains(s, ".") {
return true
}
return false
}
-12
View File
@@ -1,12 +0,0 @@
package kubernetes
import "strings"
// ImageTagType returns the tag part of a container image, or "latest" if missing.
func ImageTagType(image string) string {
parts := strings.Split(image, ":")
if len(parts) > 1 {
return parts[len(parts)-1]
}
return "latest"
}
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -87,7 +87,7 @@ func (l *Logger) ErrorM(text string, module string) {
var red = color.New(color.FgRed).SprintFunc()
fmt.Printf(clearln+"[%s][%s] %s\n", red(emoji.Sprintf(":fox:cloudfox %s :fox:", l.version)), red(module), text)
if l.txtLog != nil {
l.txtLog.Printf("[%s] %s", module, text)
l.txtLog.Errorf("[%s] %s", module, text)
}
}
+393 -2
View File
@@ -160,7 +160,9 @@ func HandleStreamingOutput(
}
// ---- STREAM ROWS TO TEMP FILES ----
for _, t := range dataToOutput.TableFiles() {
streamTables := dataToOutput.TableFiles()
applyQueryFilter(streamTables)
for _, t := range streamTables {
if verbosity > 0 {
tmpClient := TableClient{Wrap: wrap}
tmpClient.printTablesToScreen([]TableFile{t})
@@ -540,6 +542,372 @@ func AppendLootFile(outputDirectory, lootFileName, entry string) error {
return nil
}
// ============================================================================
// QUERY FILTER - Global row-level filtering for --query / -q flag
// ============================================================================
var globalQueryFilter string
var globalSQLQueryFilter string
// SetQueryFilter sets the global query filter string. Called once from PersistentPreRun.
func SetQueryFilter(query string) {
globalQueryFilter = query
}
// SetSQLQueryFilter sets the global SQL-like query filter string.
func SetSQLQueryFilter(query string) {
globalSQLQueryFilter = query
}
// buildQueryPatterns generates search patterns from a query string.
// All patterns are lowercased for case-insensitive matching.
func buildQueryPatterns(query string) []string {
return []string{strings.ToLower(query)}
}
// rowMatchesQuery returns true if any column value (after stripping ANSI codes
// and lowercasing) contains any of the given patterns as a substring.
func rowMatchesQuery(row []string, patterns []string) bool {
for _, col := range row {
colLower := strings.ToLower(removeColorCodes(col))
for _, pattern := range patterns {
if strings.Contains(colLower, pattern) {
return true
}
}
}
return false
}
// ============================================================================
// Column-aware query types and parsing
// ============================================================================
type queryOperator int
const (
opContains queryOperator = iota // case-insensitive substring
opEquals // case-insensitive exact match
opNotContains // negated substring
opNotEquals // negated exact match
)
type queryCondition struct {
columnPattern string // fuzzy match against header names (lowercase)
value string // match value (lowercase)
operator queryOperator
}
// parseKeyValueQuery parses the enhanced --query key:value syntax.
// Returns (conditions, isStructured). If isStructured is false, the query has
// no column prefixes and should use legacy global substring matching.
//
// Syntax:
//
// "Role:owner,Principal:privesc02" -> column substring matches, ANDed
// "!Principal:compute" -> negated substring
// "Role=roles/owner" -> exact match
// "!Source=Project" -> negated exact match
// "privesc02" -> legacy global substring (no colon/equals)
func parseKeyValueQuery(query string) ([]queryCondition, bool) {
parts := strings.Split(query, ",")
// Check if any part contains a structured separator (: or =)
hasStructured := false
for _, part := range parts {
trimmed := strings.TrimSpace(part)
// Strip leading ! for detection
if strings.HasPrefix(trimmed, "!") {
trimmed = trimmed[1:]
}
if strings.ContainsAny(trimmed, ":=") {
hasStructured = true
break
}
}
if !hasStructured {
return nil, false
}
var conditions []queryCondition
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
continue
}
// Check for negation prefix
negated := false
if strings.HasPrefix(trimmed, "!") {
negated = true
trimmed = trimmed[1:]
}
// Determine operator by finding first = or :
eqIdx := strings.Index(trimmed, "=")
colonIdx := strings.Index(trimmed, ":")
var colPattern, value string
var isExact bool
switch {
case eqIdx >= 0 && (colonIdx < 0 || eqIdx < colonIdx):
// = found before : (or no : at all) -> exact match
colPattern = trimmed[:eqIdx]
value = trimmed[eqIdx+1:]
isExact = true
case colonIdx >= 0:
// : found -> substring match
colPattern = trimmed[:colonIdx]
value = trimmed[colonIdx+1:]
isExact = false
default:
// No separator found in this part, skip it
continue
}
op := opContains
if isExact && negated {
op = opNotEquals
} else if isExact {
op = opEquals
} else if negated {
op = opNotContains
}
conditions = append(conditions, queryCondition{
columnPattern: strings.ToLower(strings.TrimSpace(colPattern)),
value: strings.ToLower(strings.TrimSpace(value)),
operator: op,
})
}
return conditions, true
}
// parseSQLQuery parses the --sqlquery SQL-like WHERE clause syntax.
//
// Syntax:
//
// "Role CONTAINS owner AND Principal CONTAINS privesc02"
// "Role = roles/owner AND Principal NOT CONTAINS compute"
// "Source != Project"
//
// Operators: CONTAINS, =, NOT CONTAINS, !=
// Conjunction: AND only (all conditions must match)
// All operators and keywords are case-insensitive.
func parseSQLQuery(query string) []queryCondition {
// Split on AND (case-insensitive) with word boundaries
// Use a simple approach: find " AND " case-insensitively
parts := splitOnAND(query)
var conditions []queryCondition
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
continue
}
cond, ok := parseSQLCondition(trimmed)
if ok {
conditions = append(conditions, cond)
}
}
return conditions
}
// splitOnAND splits a string on " AND " (case-insensitive)
func splitOnAND(s string) []string {
lower := strings.ToLower(s)
var parts []string
for {
idx := strings.Index(lower, " and ")
if idx < 0 {
parts = append(parts, s)
break
}
parts = append(parts, s[:idx])
s = s[idx+5:] // len(" and ") == 5
lower = lower[idx+5:]
}
return parts
}
// parseSQLCondition parses a single SQL condition like "Role CONTAINS owner"
func parseSQLCondition(s string) (queryCondition, bool) {
lower := strings.ToLower(s)
// Try "NOT CONTAINS" first (must come before "CONTAINS" check)
if idx := strings.Index(lower, " not contains "); idx >= 0 {
col := strings.TrimSpace(s[:idx])
val := strings.TrimSpace(s[idx+14:]) // len(" not contains ") == 14
return queryCondition{
columnPattern: strings.ToLower(col),
value: strings.ToLower(val),
operator: opNotContains,
}, true
}
// Try "CONTAINS"
if idx := strings.Index(lower, " contains "); idx >= 0 {
col := strings.TrimSpace(s[:idx])
val := strings.TrimSpace(s[idx+10:]) // len(" contains ") == 10
return queryCondition{
columnPattern: strings.ToLower(col),
value: strings.ToLower(val),
operator: opContains,
}, true
}
// Try "!=" (must come before "=" check)
if idx := strings.Index(s, "!="); idx >= 0 {
col := strings.TrimSpace(s[:idx])
val := strings.TrimSpace(s[idx+2:])
return queryCondition{
columnPattern: strings.ToLower(col),
value: strings.ToLower(val),
operator: opNotEquals,
}, true
}
// Try "="
if idx := strings.Index(s, "="); idx >= 0 {
col := strings.TrimSpace(s[:idx])
val := strings.TrimSpace(s[idx+1:])
return queryCondition{
columnPattern: strings.ToLower(col),
value: strings.ToLower(val),
operator: opEquals,
}, true
}
return queryCondition{}, false
}
// resolveColumns returns indices of all headers that contain the pattern
// as a case-insensitive substring.
func resolveColumns(columnPattern string, headers []string) []int {
var indices []int
for i, h := range headers {
if strings.Contains(strings.ToLower(h), columnPattern) {
indices = append(indices, i)
}
}
return indices
}
// rowMatchesConditions checks if a row satisfies all query conditions.
// All conditions are ANDed. Column resolution uses fuzzy substring matching
// against header names.
func rowMatchesConditions(row []string, conditions []queryCondition, headers []string) bool {
for _, cond := range conditions {
colIndices := resolveColumns(cond.columnPattern, headers)
if len(colIndices) == 0 {
// No columns matched the pattern, condition fails
return false
}
switch cond.operator {
case opContains:
// At least one resolved column must contain the value
found := false
for _, idx := range colIndices {
if idx < len(row) {
colVal := strings.ToLower(removeColorCodes(row[idx]))
if strings.Contains(colVal, cond.value) {
found = true
break
}
}
}
if !found {
return false
}
case opEquals:
// At least one resolved column must exactly equal the value
found := false
for _, idx := range colIndices {
if idx < len(row) {
colVal := strings.ToLower(removeColorCodes(row[idx]))
if colVal == cond.value {
found = true
break
}
}
}
if !found {
return false
}
case opNotContains:
// None of the resolved columns may contain the value
for _, idx := range colIndices {
if idx < len(row) {
colVal := strings.ToLower(removeColorCodes(row[idx]))
if strings.Contains(colVal, cond.value) {
return false
}
}
}
case opNotEquals:
// None of the resolved columns may exactly equal the value
for _, idx := range colIndices {
if idx < len(row) {
colVal := strings.ToLower(removeColorCodes(row[idx]))
if colVal == cond.value {
return false
}
}
}
}
}
return true
}
// applyQueryFilter filters all table bodies in-place using the global query.
// Supports both legacy global substring matching and column-aware filtering.
// No-op when both globalQueryFilter and globalSQLQueryFilter are empty.
func applyQueryFilter(tables []TableFile) {
if globalQueryFilter == "" && globalSQLQueryFilter == "" {
return
}
var conditions []queryCondition
var useLegacy bool
if globalSQLQueryFilter != "" {
conditions = parseSQLQuery(globalSQLQueryFilter)
} else {
var isStructured bool
conditions, isStructured = parseKeyValueQuery(globalQueryFilter)
if !isStructured {
// Fall back to legacy global substring matching
useLegacy = true
}
}
for i := range tables {
var filtered [][]string
for _, row := range tables[i].Body {
if useLegacy {
if rowMatchesQuery(row, buildQueryPatterns(globalQueryFilter)) {
filtered = append(filtered, row)
}
} else {
if rowMatchesConditions(row, conditions, tables[i].Header) {
filtered = append(filtered, row)
}
}
}
tables[i].Body = filtered
}
}
func removeColorCodes(input string) string {
// Regular expression to match ANSI color codes
ansiRegExp := regexp.MustCompile(`\x1b\[[0-9;]*m`)
@@ -982,6 +1350,7 @@ func HandleOutputV2(
)
tables := dataToOutput.TableFiles()
applyQueryFilter(tables)
lootFiles := dataToOutput.LootFiles()
// Determine base module name from first table file (for backwards compatibility)
@@ -1031,6 +1400,14 @@ func HandleOutputSmart(
) error {
logger := NewLogger()
// Log query filter if active
if globalQueryFilter != "" {
logger.InfoM(fmt.Sprintf("Filtering output with query: %q", globalQueryFilter), "output")
}
if globalSQLQueryFilter != "" {
logger.InfoM(fmt.Sprintf("Filtering output with sqlquery: %q", globalSQLQueryFilter), "output")
}
// Count total rows across all table files
totalRows := 0
for _, tableFile := range dataToOutput.TableFiles() {
@@ -1276,6 +1653,7 @@ func HandleHierarchicalOutput(
// writeOutputToPath writes CloudfoxOutput data to a specific path
func writeOutputToPath(outPath string, format string, verbosity int, wrap bool, data CloudfoxOutput, logger Logger) error {
tables := data.TableFiles()
applyQueryFilter(tables)
lootFiles := data.LootFiles()
// Determine base module name from first table file (for logging)
@@ -1357,7 +1735,9 @@ func streamOutputToPath(outPath string, format string, verbosity int, wrap bool,
}
// Stream table files
for _, t := range data.TableFiles() {
streamTables := data.TableFiles()
applyQueryFilter(streamTables)
for _, t := range streamTables {
if verbosity > 0 {
tmpClient := TableClient{Wrap: wrap}
tmpClient.printTablesToScreen([]TableFile{t})
@@ -1466,6 +1846,14 @@ func HandleHierarchicalOutputSmart(
) error {
logger := NewLogger()
// Log query filter if active
if globalQueryFilter != "" {
logger.InfoM(fmt.Sprintf("Filtering output with query: %q", globalQueryFilter), "output")
}
if globalSQLQueryFilter != "" {
logger.InfoM(fmt.Sprintf("Filtering output with sqlquery: %q", globalSQLQueryFilter), "output")
}
// Count total rows across all data
totalRows := 0
for _, orgData := range outputData.OrgLevelData {
@@ -1611,6 +1999,9 @@ func HandleHierarchicalOutputTee(config TeeStreamingConfig) error {
// Track which projects received data (for loot file generation)
projectsWithData := make(map[string]bool)
// Apply query filter to all tables before processing
applyQueryFilter(config.Tables)
// Process each table
for _, t := range config.Tables {
if config.Verbosity > 0 {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-828
View File
@@ -1,828 +0,0 @@
package commands
import (
"context"
"fmt"
"sort"
"strings"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
k8sinternal "github.com/BishopFox/cloudfox/internal/kubernetes"
"github.com/BishopFox/cloudfox/kubernetes/config"
"github.com/BishopFox/cloudfox/kubernetes/shared"
"github.com/spf13/cobra"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
)
const K8S_CLOUD_IAM_MODULE_NAME = "cloud-iam"
var CloudIAMCmd = &cobra.Command{
Use: "cloud-iam",
Aliases: []string{"cloud-identity", "cloud-roles"},
Short: "Enumerate all cloud IAM identities mapped to Kubernetes resources",
Long: `
Enumerate all cloud IAM identities (AWS IAM Roles, GCP Service Accounts, Azure Managed Identities)
that are mapped to Kubernetes resources across the cluster.
This provides a centralized view of cloud identity mappings including:
- Nodes with attached IAM roles/service accounts
- ServiceAccounts with workload identity annotations
- Pods/Deployments/DaemonSets/etc. with cloud role references
- AWS Pod Identity Associations
- Azure AAD Pod Identity bindings
- GCP Workload Identity mappings
Examples:
cloudfox kubernetes cloud-iam
cloudfox kubernetes cloud-iam -A
cloudfox kubernetes cloud-iam -n production`,
Run: CloudIAM,
}
// CloudIAMFinding represents a cloud identity mapping to a K8s resource
type CloudIAMFinding struct {
// Scope
Namespace string // Empty for cluster-scoped resources like nodes
Scope string // "Cluster" or "Namespace"
// Kubernetes Resource
ResourceType string // Node, ServiceAccount, Pod, Deployment, DaemonSet, StatefulSet, ReplicaSet, Job, CronJob
ResourceName string
// Cloud Identity
CloudProvider string // AWS, GCP, Azure
CloudIdentity string // ARN, email, client ID
IdentityType string // IAM Role, Service Account, Managed Identity, Pod Identity, Workload Identity
// Detection Source
Source string // How the identity was detected (annotation, env var, label, CRD, etc.)
// Additional context
ControllerType string // For pods: what controller owns them
ControllerName string
ServiceAccount string // SA name if applicable
}
type CloudIAMOutput struct {
Table []internal.TableFile
Loot []internal.LootFile
}
func (c CloudIAMOutput) TableFiles() []internal.TableFile {
return c.Table
}
func (c CloudIAMOutput) LootFiles() []internal.LootFile {
return c.Loot
}
func CloudIAM(cmd *cobra.Command, args []string) {
ctx, cancel := shared.ContextWithTimeout()
defer cancel()
logger := internal.NewLogger()
parentCmd := cmd.Parent()
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
outputDirectory, _ := parentCmd.PersistentFlags().GetString("outdir")
format, _ := parentCmd.PersistentFlags().GetString("output")
clientset := config.GetClientOrExit()
dynamicClient := config.GetDynamicClientOrExit()
logger.InfoM(fmt.Sprintf("Enumerating cloud IAM identities for %s", globals.ClusterName), K8S_CLOUD_IAM_MODULE_NAME)
findings := collectCloudIAMFindings(ctx, clientset, dynamicClient, &logger)
if len(findings) == 0 {
logger.InfoM("No cloud IAM identities found in cluster", K8S_CLOUD_IAM_MODULE_NAME)
return
}
// Sort findings for consistent output
sort.Slice(findings, func(i, j int) bool {
if findings[i].CloudProvider != findings[j].CloudProvider {
return findings[i].CloudProvider < findings[j].CloudProvider
}
if findings[i].ResourceType != findings[j].ResourceType {
return findings[i].ResourceType < findings[j].ResourceType
}
if findings[i].Namespace != findings[j].Namespace {
return findings[i].Namespace < findings[j].Namespace
}
return findings[i].ResourceName < findings[j].ResourceName
})
// Generate output
output := generateCloudIAMOutput(findings, &logger)
err := internal.HandleOutput(
"Kubernetes",
format,
outputDirectory,
verbosity,
wrap,
"cloud-iam",
globals.ClusterName,
"results",
output,
)
if err != nil {
logger.ErrorM(fmt.Sprintf("Error handling output: %v", err), K8S_CLOUD_IAM_MODULE_NAME)
}
// Print summary
printCloudIAMSummary(findings, &logger)
}
func collectCloudIAMFindings(ctx context.Context, clientset *kubernetes.Clientset, dynamicClient dynamic.Interface, logger *internal.Logger) []CloudIAMFinding {
var findings []CloudIAMFinding
// Collect from nodes
nodeFindings := collectNodeCloudIAM(ctx, clientset, logger)
findings = append(findings, nodeFindings...)
// Collect from service accounts
saFindings := collectServiceAccountCloudIAM(ctx, clientset, dynamicClient, logger)
findings = append(findings, saFindings...)
// Collect from pods (also detects from env vars, configmaps, secrets)
podFindings := collectPodCloudIAM(ctx, clientset, logger)
findings = append(findings, podFindings...)
// Collect from AWS Pod Identity Associations CRD
awsPodIdentityFindings := collectAWSPodIdentityAssociations(ctx, dynamicClient, logger)
findings = append(findings, awsPodIdentityFindings...)
// Collect from Azure AAD Pod Identity bindings
azureIdentityFindings := collectAzureIdentityBindings(ctx, dynamicClient, logger)
findings = append(findings, azureIdentityFindings...)
// Deduplicate findings
findings = deduplicateCloudIAMFindings(findings)
return findings
}
func collectNodeCloudIAM(ctx context.Context, clientset *kubernetes.Clientset, logger *internal.Logger) []CloudIAMFinding {
var findings []CloudIAMFinding
nodes, err := clientset.CoreV1().Nodes().List(ctx, metav1.ListOptions{})
if err != nil {
logger.ErrorM(fmt.Sprintf("Failed to list nodes: %v", err), K8S_CLOUD_IAM_MODULE_NAME)
return findings
}
for _, node := range nodes.Items {
cloudProvider := k8sinternal.DetectCloudProviderFromNode(node.Spec.ProviderID)
if cloudProvider == "" || cloudProvider == "Unknown" {
continue
}
// Check for IAM role from node labels
cloudRole := k8sinternal.DetectCloudRoleFromNodeLabels(node.Labels)
// For AWS nodes, extract role from provider ID if available
if cloudProvider == "AWS" && cloudRole == "" {
// AWS nodes typically have IAM roles attached via instance profiles
// The role is detectable via IMDS, but we note that IAM access is available
cloudRole = extractAWSNodeRole(node.Spec.ProviderID, node.Labels, node.Annotations)
}
// For GCP nodes, check for service account
if cloudProvider == "GCP" && cloudRole == "" {
cloudRole = extractGCPNodeServiceAccount(node.Labels, node.Annotations)
}
// For Azure nodes, check for managed identity
if cloudProvider == "Azure" && cloudRole == "" {
cloudRole = extractAzureNodeIdentity(node.Labels, node.Annotations)
}
// Even without explicit role, nodes in cloud have implicit IAM access
if cloudRole == "" {
cloudRole = "(instance profile/metadata available)"
}
findings = append(findings, CloudIAMFinding{
Namespace: "",
Scope: "Cluster",
ResourceType: "Node",
ResourceName: node.Name,
CloudProvider: cloudProvider,
CloudIdentity: cloudRole,
IdentityType: getNodeIdentityType(cloudProvider),
Source: "node provider ID / labels",
})
}
return findings
}
func collectServiceAccountCloudIAM(ctx context.Context, clientset *kubernetes.Clientset, dynamicClient dynamic.Interface, logger *internal.Logger) []CloudIAMFinding {
var findings []CloudIAMFinding
for _, ns := range globals.K8sNamespaces {
sas, err := clientset.CoreV1().ServiceAccounts(ns).List(ctx, metav1.ListOptions{})
if err != nil {
continue
}
for _, sa := range sas.Items {
// Check AWS EKS IRSA annotation
if roleARN, ok := sa.Annotations["eks.amazonaws.com/role-arn"]; ok && roleARN != "" {
findings = append(findings, CloudIAMFinding{
Namespace: ns,
Scope: "Namespace",
ResourceType: "ServiceAccount",
ResourceName: sa.Name,
CloudProvider: "AWS",
CloudIdentity: roleARN,
IdentityType: "IAM Role (IRSA)",
Source: "annotation: eks.amazonaws.com/role-arn",
})
}
// Check GCP Workload Identity annotation
if gcpSA, ok := sa.Annotations["iam.gke.io/gcp-service-account"]; ok && gcpSA != "" {
findings = append(findings, CloudIAMFinding{
Namespace: ns,
Scope: "Namespace",
ResourceType: "ServiceAccount",
ResourceName: sa.Name,
CloudProvider: "GCP",
CloudIdentity: gcpSA,
IdentityType: "Service Account (Workload Identity)",
Source: "annotation: iam.gke.io/gcp-service-account",
})
}
// Check Azure Workload Identity annotation
if azureClientID, ok := sa.Annotations["azure.workload.identity/client-id"]; ok && azureClientID != "" {
identityStr := azureClientID
if tenantID, ok := sa.Annotations["azure.workload.identity/tenant-id"]; ok {
identityStr = fmt.Sprintf("%s (tenant: %s)", azureClientID, tenantID)
}
findings = append(findings, CloudIAMFinding{
Namespace: ns,
Scope: "Namespace",
ResourceType: "ServiceAccount",
ResourceName: sa.Name,
CloudProvider: "Azure",
CloudIdentity: identityStr,
IdentityType: "Managed Identity (Workload Identity)",
Source: "annotation: azure.workload.identity/client-id",
})
}
// Check for AWS Pod Identity Associations (via CRD lookup)
if dynamicClient != nil {
awsInfo := checkAWSPodIdentityForSA(ctx, dynamicClient, ns, sa.Name)
if awsInfo.RoleARN != "" {
findings = append(findings, CloudIAMFinding{
Namespace: ns,
Scope: "Namespace",
ResourceType: "ServiceAccount",
ResourceName: sa.Name,
CloudProvider: "AWS",
CloudIdentity: awsInfo.RoleARN,
IdentityType: "IAM Role (Pod Identity)",
Source: fmt.Sprintf("CRD: PodIdentityAssociation/%s", awsInfo.Name),
})
}
}
}
}
return findings
}
func collectPodCloudIAM(ctx context.Context, clientset *kubernetes.Clientset, logger *internal.Logger) []CloudIAMFinding {
var findings []CloudIAMFinding
for _, ns := range globals.K8sNamespaces {
pods, err := clientset.CoreV1().Pods(ns).List(ctx, metav1.ListOptions{})
if err != nil {
continue
}
for _, pod := range pods.Items {
// Use the existing DetectCloudRole function
roleResults := k8sinternal.DetectCloudRole(ctx, clientset, pod.Namespace, pod.Spec.ServiceAccountName, &pod.Spec, pod.Annotations)
for _, result := range roleResults {
// Skip if this is from ServiceAccount (already captured above)
if strings.Contains(result.Source, "serviceaccount") {
continue
}
controllerType, controllerName := getControllerInfo(&pod)
findings = append(findings, CloudIAMFinding{
Namespace: ns,
Scope: "Namespace",
ResourceType: "Pod",
ResourceName: pod.Name,
CloudProvider: result.Provider,
CloudIdentity: result.Role,
IdentityType: getIdentityTypeFromProvider(result.Provider),
Source: result.Source,
ControllerType: controllerType,
ControllerName: controllerName,
ServiceAccount: pod.Spec.ServiceAccountName,
})
}
}
}
return findings
}
func collectAWSPodIdentityAssociations(ctx context.Context, dynamicClient dynamic.Interface, logger *internal.Logger) []CloudIAMFinding {
var findings []CloudIAMFinding
if dynamicClient == nil {
return findings
}
gvr := schema.GroupVersionResource{
Group: "eks.amazonaws.com",
Version: "v1alpha1",
Resource: "podidentityassociations",
}
// Try cluster-scoped first
list, err := dynamicClient.Resource(gvr).List(ctx, metav1.ListOptions{})
if err != nil {
// CRD not installed or no permissions
return findings
}
for _, item := range list.Items {
spec, found, _ := getNestedMapCloudIAM(item.Object, "spec")
if !found {
continue
}
namespace := getNestedStringCloudIAM(spec, "namespace")
if namespace == "" {
namespace = item.GetNamespace()
}
saName := getNestedStringCloudIAM(spec, "serviceAccountName")
roleARN := getNestedStringCloudIAM(spec, "roleArn")
if roleARN != "" && saName != "" {
findings = append(findings, CloudIAMFinding{
Namespace: namespace,
Scope: "Namespace",
ResourceType: "PodIdentityAssociation",
ResourceName: item.GetName(),
CloudProvider: "AWS",
CloudIdentity: roleARN,
IdentityType: "IAM Role (Pod Identity)",
Source: "CRD: eks.amazonaws.com/v1alpha1/PodIdentityAssociation",
ServiceAccount: saName,
})
}
}
return findings
}
func collectAzureIdentityBindings(ctx context.Context, dynamicClient dynamic.Interface, logger *internal.Logger) []CloudIAMFinding {
var findings []CloudIAMFinding
if dynamicClient == nil {
return findings
}
// Check for AzureIdentityBinding (AAD Pod Identity v1)
gvr := schema.GroupVersionResource{
Group: "aadpodidentity.k8s.io",
Version: "v1",
Resource: "azureidentitybindings",
}
list, err := dynamicClient.Resource(gvr).List(ctx, metav1.ListOptions{})
if err != nil {
// CRD not installed
return findings
}
for _, item := range list.Items {
spec, found, _ := getNestedMapCloudIAM(item.Object, "spec")
if !found {
continue
}
azureIdentityRef := getNestedStringCloudIAM(spec, "azureIdentity")
selector := getNestedStringCloudIAM(spec, "selector")
if azureIdentityRef != "" {
// Lookup the AzureIdentity to get the client ID
clientID := lookupAzureIdentityClientID(ctx, dynamicClient, item.GetNamespace(), azureIdentityRef)
findings = append(findings, CloudIAMFinding{
Namespace: item.GetNamespace(),
Scope: "Namespace",
ResourceType: "AzureIdentityBinding",
ResourceName: item.GetName(),
CloudProvider: "Azure",
CloudIdentity: clientID,
IdentityType: "Managed Identity (AAD Pod Identity)",
Source: fmt.Sprintf("CRD: AzureIdentityBinding (selector: %s)", selector),
})
}
}
return findings
}
func lookupAzureIdentityClientID(ctx context.Context, dynamicClient dynamic.Interface, namespace, name string) string {
gvr := schema.GroupVersionResource{
Group: "aadpodidentity.k8s.io",
Version: "v1",
Resource: "azureidentities",
}
// Try namespace-scoped first
identity, err := dynamicClient.Resource(gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
// Try cluster-scoped
identity, err = dynamicClient.Resource(gvr).Get(ctx, name, metav1.GetOptions{})
if err != nil {
return name // Return the reference name if lookup fails
}
}
spec, found, _ := getNestedMapCloudIAM(identity.Object, "spec")
if !found {
return name
}
clientID := getNestedStringCloudIAM(spec, "clientID")
if clientID != "" {
return clientID
}
resourceID := getNestedStringCloudIAM(spec, "resourceID")
if resourceID != "" {
return resourceID
}
return name
}
// Helper functions
func extractAWSNodeRole(providerID string, labels, annotations map[string]string) string {
// Check common AWS node labels/annotations for IAM role info
roleLabels := []string{
"iam.amazonaws.com/role",
"eks.amazonaws.com/nodegroup",
"alpha.eksctl.io/iamidentitymapping",
}
for _, label := range roleLabels {
if role, ok := labels[label]; ok && role != "" {
return role
}
if role, ok := annotations[label]; ok && role != "" {
return role
}
}
return ""
}
func extractGCPNodeServiceAccount(labels, annotations map[string]string) string {
// Check for GCP service account in labels/annotations
saLabels := []string{
"iam.gke.io/gke-metadata-server-enabled",
"cloud.google.com/gke-nodepool",
}
for _, label := range saLabels {
if sa, ok := labels[label]; ok && sa != "" {
return sa
}
}
return ""
}
func extractAzureNodeIdentity(labels, annotations map[string]string) string {
// Check for Azure managed identity in labels/annotations
miLabels := []string{
"kubernetes.azure.com/managed-identity-client-id",
"aadpodidentity.k8s.io/assigned-identity",
}
for _, label := range miLabels {
if mi, ok := labels[label]; ok && mi != "" {
return mi
}
if mi, ok := annotations[label]; ok && mi != "" {
return mi
}
}
return ""
}
func getNodeIdentityType(provider string) string {
switch provider {
case "AWS":
return "Instance Profile / IAM Role"
case "GCP":
return "Node Service Account"
case "Azure":
return "Node Managed Identity"
default:
return "Unknown"
}
}
func getIdentityTypeFromProvider(provider string) string {
switch provider {
case "AWS":
return "IAM Role"
case "GCP":
return "Service Account"
case "Azure":
return "Managed Identity"
default:
return "Unknown"
}
}
func getControllerInfo(pod *corev1.Pod) (string, string) {
for _, ref := range pod.OwnerReferences {
if ref.Controller != nil && *ref.Controller {
return ref.Kind, ref.Name
}
}
return "", ""
}
func checkAWSPodIdentityForSA(ctx context.Context, dynamicClient dynamic.Interface, namespace, saName string) struct{ Name, RoleARN string } {
result := struct{ Name, RoleARN string }{}
gvr := schema.GroupVersionResource{
Group: "eks.amazonaws.com",
Version: "v1alpha1",
Resource: "podidentityassociations",
}
list, err := dynamicClient.Resource(gvr).List(ctx, metav1.ListOptions{})
if err != nil {
return result
}
for _, item := range list.Items {
spec, found, _ := getNestedMapCloudIAM(item.Object, "spec")
if !found {
continue
}
itemNS := getNestedStringCloudIAM(spec, "namespace")
if itemNS == "" {
itemNS = item.GetNamespace()
}
itemSA := getNestedStringCloudIAM(spec, "serviceAccountName")
roleARN := getNestedStringCloudIAM(spec, "roleArn")
if itemNS == namespace && itemSA == saName && roleARN != "" {
result.Name = item.GetName()
result.RoleARN = roleARN
return result
}
}
return result
}
func getNestedMapCloudIAM(obj map[string]interface{}, key string) (map[string]interface{}, bool, error) {
val, ok := obj[key]
if !ok {
return nil, false, nil
}
m, ok := val.(map[string]interface{})
return m, ok, nil
}
func getNestedStringCloudIAM(obj map[string]interface{}, key string) string {
val, ok := obj[key]
if !ok {
return ""
}
s, ok := val.(string)
if !ok {
return ""
}
return s
}
func deduplicateCloudIAMFindings(findings []CloudIAMFinding) []CloudIAMFinding {
seen := make(map[string]bool)
var result []CloudIAMFinding
for _, f := range findings {
// Create a unique key based on resource + cloud identity
key := fmt.Sprintf("%s|%s|%s|%s|%s", f.Namespace, f.ResourceType, f.ResourceName, f.CloudProvider, f.CloudIdentity)
if !seen[key] {
seen[key] = true
result = append(result, f)
}
}
return result
}
func generateCloudIAMOutput(findings []CloudIAMFinding, logger *internal.Logger) CloudIAMOutput {
var output CloudIAMOutput
// Table headers
headers := []string{
"Scope",
"Namespace",
"Resource Type",
"Resource Name",
"Cloud Provider",
"Identity Type",
"Cloud Identity",
"Source",
}
var rows [][]string
for _, f := range findings {
namespace := f.Namespace
if namespace == "" {
namespace = "-"
}
rows = append(rows, []string{
f.Scope,
namespace,
f.ResourceType,
f.ResourceName,
f.CloudProvider,
f.IdentityType,
f.CloudIdentity,
f.Source,
})
}
output.Table = append(output.Table, internal.TableFile{
Name: "cloud-iam",
Header: headers,
Body: rows,
})
// Generate loot files
output.Loot = generateCloudIAMLoot(findings)
return output
}
func generateCloudIAMLoot(findings []CloudIAMFinding) []internal.LootFile {
var lootFiles []internal.LootFile
var lootContent []string
lootContent = append(lootContent, "# Cloud IAM Identity Mappings")
lootContent = append(lootContent, "# Generated by cloudfox kubernetes cloud-iam")
lootContent = append(lootContent, "")
// Group by provider
awsFindings := filterByProvider(findings, "AWS")
gcpFindings := filterByProvider(findings, "GCP")
azureFindings := filterByProvider(findings, "Azure")
if len(awsFindings) > 0 {
lootContent = append(lootContent, "## AWS IAM Roles")
lootContent = append(lootContent, "")
for _, f := range awsFindings {
lootContent = append(lootContent, fmt.Sprintf("### %s: %s/%s", f.ResourceType, f.Namespace, f.ResourceName))
lootContent = append(lootContent, fmt.Sprintf("Role ARN: %s", f.CloudIdentity))
lootContent = append(lootContent, fmt.Sprintf("Identity Type: %s", f.IdentityType))
lootContent = append(lootContent, fmt.Sprintf("Source: %s", f.Source))
if f.ServiceAccount != "" {
lootContent = append(lootContent, fmt.Sprintf("Service Account: %s", f.ServiceAccount))
}
lootContent = append(lootContent, "")
// Add AWS CLI commands to enumerate role
if strings.HasPrefix(f.CloudIdentity, "arn:aws:iam::") {
roleName := extractRoleNameFromARN(f.CloudIdentity)
if roleName != "" {
lootContent = append(lootContent, "# Enumerate AWS IAM role:")
lootContent = append(lootContent, fmt.Sprintf("aws iam get-role --role-name %s", roleName))
lootContent = append(lootContent, fmt.Sprintf("aws iam list-attached-role-policies --role-name %s", roleName))
lootContent = append(lootContent, fmt.Sprintf("aws iam list-role-policies --role-name %s", roleName))
lootContent = append(lootContent, "")
}
}
}
}
if len(gcpFindings) > 0 {
lootContent = append(lootContent, "## GCP Service Accounts")
lootContent = append(lootContent, "")
for _, f := range gcpFindings {
lootContent = append(lootContent, fmt.Sprintf("### %s: %s/%s", f.ResourceType, f.Namespace, f.ResourceName))
lootContent = append(lootContent, fmt.Sprintf("Service Account: %s", f.CloudIdentity))
lootContent = append(lootContent, fmt.Sprintf("Identity Type: %s", f.IdentityType))
lootContent = append(lootContent, fmt.Sprintf("Source: %s", f.Source))
if f.ServiceAccount != "" {
lootContent = append(lootContent, fmt.Sprintf("K8s Service Account: %s", f.ServiceAccount))
}
lootContent = append(lootContent, "")
// Add gcloud commands
if strings.Contains(f.CloudIdentity, "@") && strings.Contains(f.CloudIdentity, ".iam.gserviceaccount.com") {
lootContent = append(lootContent, "# Enumerate GCP service account:")
lootContent = append(lootContent, fmt.Sprintf("gcloud iam service-accounts describe %s", f.CloudIdentity))
lootContent = append(lootContent, fmt.Sprintf("gcloud iam service-accounts get-iam-policy %s", f.CloudIdentity))
lootContent = append(lootContent, "")
}
}
}
if len(azureFindings) > 0 {
lootContent = append(lootContent, "## Azure Managed Identities")
lootContent = append(lootContent, "")
for _, f := range azureFindings {
lootContent = append(lootContent, fmt.Sprintf("### %s: %s/%s", f.ResourceType, f.Namespace, f.ResourceName))
lootContent = append(lootContent, fmt.Sprintf("Client ID: %s", f.CloudIdentity))
lootContent = append(lootContent, fmt.Sprintf("Identity Type: %s", f.IdentityType))
lootContent = append(lootContent, fmt.Sprintf("Source: %s", f.Source))
if f.ServiceAccount != "" {
lootContent = append(lootContent, fmt.Sprintf("K8s Service Account: %s", f.ServiceAccount))
}
lootContent = append(lootContent, "")
// Add Azure CLI commands
lootContent = append(lootContent, "# Enumerate Azure managed identity:")
lootContent = append(lootContent, "az identity list --query \"[?clientId=='"+f.CloudIdentity+"']\"")
lootContent = append(lootContent, "")
}
}
lootFiles = append(lootFiles, internal.LootFile{
Name: "cloud-iam-mappings.md",
Contents: strings.Join(lootContent, "\n"),
})
return lootFiles
}
func filterByProvider(findings []CloudIAMFinding, provider string) []CloudIAMFinding {
var result []CloudIAMFinding
for _, f := range findings {
if f.CloudProvider == provider {
result = append(result, f)
}
}
return result
}
func extractRoleNameFromARN(arn string) string {
// arn:aws:iam::123456789012:role/my-role-name
parts := strings.Split(arn, "/")
if len(parts) >= 2 {
return parts[len(parts)-1]
}
// Try extracting after :role/
if strings.Contains(arn, ":role/") {
parts := strings.Split(arn, ":role/")
if len(parts) == 2 {
return parts[1]
}
}
return ""
}
func printCloudIAMSummary(findings []CloudIAMFinding, logger *internal.Logger) {
// Count by provider
providerCounts := make(map[string]int)
resourceTypeCounts := make(map[string]int)
for _, f := range findings {
providerCounts[f.CloudProvider]++
resourceTypeCounts[f.ResourceType]++
}
logger.InfoM(fmt.Sprintf("Found %d cloud IAM identity mappings", len(findings)), K8S_CLOUD_IAM_MODULE_NAME)
// Provider breakdown
var providerSummary []string
for provider, count := range providerCounts {
providerSummary = append(providerSummary, fmt.Sprintf("%s: %d", provider, count))
}
sort.Strings(providerSummary)
logger.InfoM(fmt.Sprintf("By provider: %s", strings.Join(providerSummary, ", ")), K8S_CLOUD_IAM_MODULE_NAME)
// Resource type breakdown
var resourceSummary []string
for resourceType, count := range resourceTypeCounts {
resourceSummary = append(resourceSummary, fmt.Sprintf("%s: %d", resourceType, count))
}
sort.Strings(resourceSummary)
logger.InfoM(fmt.Sprintf("By resource: %s", strings.Join(resourceSummary, ", ")), K8S_CLOUD_IAM_MODULE_NAME)
}
-957
View File
@@ -1,957 +0,0 @@
package commands
import (
"encoding/base64"
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
"github.com/BishopFox/cloudfox/kubernetes/config"
"github.com/BishopFox/cloudfox/kubernetes/sdk"
"github.com/BishopFox/cloudfox/kubernetes/shared"
"github.com/spf13/cobra"
corev1 "k8s.io/api/core/v1"
)
var ConfigMapsCmd = &cobra.Command{
Use: "configmaps",
Aliases: []string{"cm"},
Short: "List all cluster ConfigMaps",
Long: `
List all cluster ConfigMaps and detailed information:
cloudfox kubernetes configmaps`,
Run: ListConfigMaps,
}
type ConfigMapsOutput struct {
Table []internal.TableFile
Loot []internal.LootFile
}
func (t ConfigMapsOutput) TableFiles() []internal.TableFile {
return t.Table
}
func (t ConfigMapsOutput) LootFiles() []internal.LootFile {
return t.Loot
}
type ConfigMapFinding struct {
Namespace string
Name string
SensitiveKeys []string
DangerousPatterns []string
AWSCredentials []string
GCPCredentials []string
GitHubTokens []string
PrivateKeys []string
Base64Secrets []string
ConnectionStrings []string
DockerCredentials []string
KubeconfigFound bool
MountedByPods []string
UsageCount int
DataSize int
DataKeys []string
CreationTimestamp string
IsImmutable bool
Annotations map[string]string
CredentialFindings []CredentialFinding
}
type CredentialFinding struct {
Type string // "AWS", "GCP", "GitHub", "PrivateKey", etc.
Key string // ConfigMap key where found
Value string // Actual credential (truncated for display)
FullValue string // Full credential value
Description string // What this credential is
}
func ListConfigMaps(cmd *cobra.Command, args []string) {
ctx, cancel := shared.ContextWithCancel()
defer cancel()
logger := internal.NewLogger()
// Extract global flags
parentCmd := cmd.Parent()
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
outputDirectory, _ := parentCmd.PersistentFlags().GetString("outdir")
format, _ := parentCmd.PersistentFlags().GetString("output")
logger.InfoM(fmt.Sprintf("Enumerating configmaps for %s", globals.ClusterName), globals.K8S_CONFIGMAPS_MODULE_NAME)
clientset := config.GetClientOrExit()
// Get all pods using cache for mounting analysis
allPods, err := sdk.GetPods(ctx, clientset)
if err != nil {
shared.LogListError(&logger, "pods", "", err, globals.K8S_CONFIGMAPS_MODULE_NAME, false)
allPods = []corev1.Pod{}
}
// Get all ConfigMaps using cache
allConfigMaps, err := sdk.GetConfigMaps(ctx, clientset)
if err != nil {
shared.LogListError(&logger, "configmaps", "", err, globals.K8S_CONFIGMAPS_MODULE_NAME, true)
return
}
// Table 1: ConfigMaps Summary
summaryHeaders := []string{
"Namespace", "Name", "Keys Count", "Data Size", "Pods Mounted", "Immutable", "Has Sensitive Data", "Created",
}
// Table 2: ConfigMap-Keys Detail (one row per configmap/key/pod combination)
keysHeaders := []string{
"Namespace", "ConfigMap", "Key Name", "Mounted By Pod", "Value Size", "Detected Pattern", "Detected Value",
}
var summaryRows [][]string
var keysRows [][]string
var findings []ConfigMapFinding
for _, cm := range allConfigMaps {
// Perform comprehensive security analysis
finding := analyzeConfigMapSecurity(&cm)
// Find pods mounting this ConfigMap using pre-fetched pods
finding.MountedByPods = findPodsMountingConfigMapCached(allPods, cm.Namespace, cm.Name)
finding.UsageCount = len(finding.MountedByPods)
findings = append(findings, finding)
// Build summary table row
immutableStr := "No"
if finding.IsImmutable {
immutableStr = "Yes"
}
hasSensitiveData := "No"
if len(finding.DangerousPatterns) > 0 || len(finding.SensitiveKeys) > 0 {
hasSensitiveData = "Yes"
}
summaryRow := []string{
finding.Namespace,
finding.Name,
fmt.Sprintf("%d", len(finding.DataKeys)),
fmt.Sprintf("%d bytes", finding.DataSize),
fmt.Sprintf("%d", finding.UsageCount),
immutableStr,
hasSensitiveData,
finding.CreationTimestamp,
}
summaryRows = append(summaryRows, summaryRow)
// Build keys table rows - one row per (configmap, key, pod) combination
keyPatterns := buildKeyPatternMap(&cm, finding)
// Build key -> detected value map from credential findings
keyValues := make(map[string]string)
for _, cf := range finding.CredentialFindings {
if _, exists := keyValues[cf.Key]; !exists {
keyValues[cf.Key] = cf.Value
}
}
if len(finding.MountedByPods) == 0 {
for _, keyName := range finding.DataKeys {
valueSize := len(cm.Data[keyName])
detectedPattern := keyPatterns[keyName]
if detectedPattern == "" {
detectedPattern = "-"
}
detectedValue := keyValues[keyName]
if detectedValue == "" {
if detectedPattern == "Sensitive Keyword" {
detectedValue = "Check Config"
} else {
detectedValue = "-"
}
}
keysRows = append(keysRows, []string{
finding.Namespace, finding.Name, keyName, "-",
fmt.Sprintf("%d bytes", valueSize),
detectedPattern, detectedValue,
})
}
} else {
for _, keyName := range finding.DataKeys {
valueSize := len(cm.Data[keyName])
detectedPattern := keyPatterns[keyName]
if detectedPattern == "" {
detectedPattern = "-"
}
detectedValue := keyValues[keyName]
if detectedValue == "" {
if detectedPattern == "Sensitive Keyword" {
detectedValue = "Check Config"
} else {
detectedValue = "-"
}
}
for _, podName := range finding.MountedByPods {
keysRows = append(keysRows, []string{
finding.Namespace, finding.Name, keyName, podName,
fmt.Sprintf("%d bytes", valueSize),
detectedPattern, detectedValue,
})
}
}
}
}
summaryTable := internal.TableFile{
Name: "ConfigMaps",
Header: summaryHeaders,
Body: summaryRows,
}
keysTable := internal.TableFile{
Name: "ConfigMap-Keys",
Header: keysHeaders,
Body: keysRows,
}
// Generate loot files
lootFiles := generateConfigMapLoot(findings, allConfigMaps, outputDirectory)
err = internal.HandleOutput(
"Kubernetes",
format,
outputDirectory,
verbosity,
wrap,
"ConfigMaps",
globals.ClusterName,
"results",
ConfigMapsOutput{
Table: []internal.TableFile{summaryTable, keysTable},
Loot: lootFiles,
},
)
if err != nil {
logger.ErrorM(fmt.Sprintf("Error handling output: %v", err), globals.K8S_CONFIGMAPS_MODULE_NAME)
return
}
if len(summaryRows) > 0 {
logger.InfoM(fmt.Sprintf("%d configmaps found, %d key entries", len(summaryRows), len(keysRows)), globals.K8S_CONFIGMAPS_MODULE_NAME)
} else {
logger.InfoM("No configmaps found, skipping output file creation", globals.K8S_CONFIGMAPS_MODULE_NAME)
}
logger.InfoM(fmt.Sprintf("For context and next steps: https://github.com/BishopFox/cloudfox/wiki/Kubernetes-Commands#%s", globals.K8S_CONFIGMAPS_MODULE_NAME), globals.K8S_CONFIGMAPS_MODULE_NAME)
}
// detectAWSCredentials checks for AWS access keys and secret keys
func detectAWSCredentials(data map[string]string) ([]string, []CredentialFinding) {
var findings []string
var creds []CredentialFinding
// AWS Access Key ID pattern: AKIA[0-9A-Z]{16}
accessKeyPattern := regexp.MustCompile(`AKIA[0-9A-Z]{16}`)
// AWS Secret Access Key pattern: 40 characters base64-like
secretKeyPattern := regexp.MustCompile(`[A-Za-z0-9/+=]{40}`)
for key, value := range data {
if match := accessKeyPattern.FindString(value); match != "" {
findings = append(findings, fmt.Sprintf("AWS Access Key in '%s'", key))
creds = append(creds, CredentialFinding{Type: "AWS", Key: key, Value: match, FullValue: match, Description: "AWS Access Key ID"})
}
lowerKey := strings.ToLower(key)
if (strings.Contains(lowerKey, "aws") && strings.Contains(lowerKey, "secret")) ||
strings.Contains(lowerKey, "aws_secret_access_key") {
if match := secretKeyPattern.FindString(value); match != "" {
findings = append(findings, fmt.Sprintf("AWS Secret Key in '%s'", key))
creds = append(creds, CredentialFinding{Type: "AWS", Key: key, Value: match, FullValue: match, Description: "AWS Secret Access Key"})
}
}
}
return findings, creds
}
// detectGCPCredentials checks for GCP service account JSON keys
func detectGCPCredentials(data map[string]string) ([]string, []CredentialFinding) {
var findings []string
var creds []CredentialFinding
for key, value := range data {
var jsonData map[string]interface{}
if err := json.Unmarshal([]byte(value), &jsonData); err == nil {
if _, hasPrivateKeyID := jsonData["private_key_id"]; hasPrivateKeyID {
if _, hasPrivateKey := jsonData["private_key"]; hasPrivateKey {
if clientEmail, hasClientEmail := jsonData["client_email"]; hasClientEmail {
findings = append(findings, fmt.Sprintf("GCP Service Account Key in '%s'", key))
email, _ := clientEmail.(string)
creds = append(creds, CredentialFinding{Type: "GCP", Key: key, Value: email, FullValue: value, Description: "GCP Service Account Key"})
}
}
}
}
}
return findings, creds
}
// detectGitHubTokens checks for GitHub personal access tokens
func detectGitHubTokens(data map[string]string) ([]string, []CredentialFinding) {
var findings []string
var creds []CredentialFinding
patterns := []struct {
regex *regexp.Regexp
desc string
}{
{regexp.MustCompile(`ghp_[a-zA-Z0-9]{36}`), "GitHub Personal Access Token"},
{regexp.MustCompile(`gho_[a-zA-Z0-9]{36}`), "GitHub OAuth Token"},
{regexp.MustCompile(`ghs_[a-zA-Z0-9]{36}`), "GitHub Server Token"},
{regexp.MustCompile(`github_pat_[a-zA-Z0-9_]{82}`), "GitHub Fine-grained PAT"},
{regexp.MustCompile(`ghr_[a-zA-Z0-9]{36}`), "GitHub Refresh Token"},
}
for key, value := range data {
for _, pattern := range patterns {
if match := pattern.regex.FindString(value); match != "" {
findings = append(findings, fmt.Sprintf("%s in '%s'", pattern.desc, key))
creds = append(creds, CredentialFinding{Type: "GitHub", Key: key, Value: match, FullValue: match, Description: pattern.desc})
}
}
}
return findings, creds
}
// detectPrivateKeys checks for PEM-encoded private keys
func detectPrivateKeys(data map[string]string) ([]string, []CredentialFinding) {
var findings []string
var creds []CredentialFinding
privateKeyPatterns := []string{
"-----BEGIN RSA PRIVATE KEY-----",
"-----BEGIN EC PRIVATE KEY-----",
"-----BEGIN PRIVATE KEY-----",
"-----BEGIN OPENSSH PRIVATE KEY-----",
"-----BEGIN DSA PRIVATE KEY-----",
"-----BEGIN ENCRYPTED PRIVATE KEY-----",
}
for key, value := range data {
for _, pattern := range privateKeyPatterns {
if strings.Contains(value, pattern) {
findings = append(findings, fmt.Sprintf("Private Key in '%s'", key))
creds = append(creds, CredentialFinding{Type: "PrivateKey", Key: key, Value: pattern, FullValue: value, Description: pattern})
break
}
}
}
return findings, creds
}
// detectConnectionStrings checks for database and service URLs with credentials
func detectConnectionStrings(data map[string]string) ([]string, []CredentialFinding) {
var findings []string
var creds []CredentialFinding
patterns := []struct {
regex *regexp.Regexp
desc string
}{
{regexp.MustCompile(`postgres://[^:]+:[^@]+@[^\s]+`), "PostgreSQL"},
{regexp.MustCompile(`mysql://[^:]+:[^@]+@[^\s]+`), "MySQL"},
{regexp.MustCompile(`mongodb://[^:]+:[^@]+@[^\s]+`), "MongoDB"},
{regexp.MustCompile(`redis://[^:]+:[^@]+@[^\s]+`), "Redis"},
{regexp.MustCompile(`amqp://[^:]+:[^@]+@[^\s]+`), "AMQP/RabbitMQ"},
{regexp.MustCompile(`Server=[^;]+;.*Password=[^;]+`), "SQL Server"},
}
for key, value := range data {
for _, pattern := range patterns {
if match := pattern.regex.FindString(value); match != "" {
findings = append(findings, fmt.Sprintf("%s Connection String in '%s'", pattern.desc, key))
creds = append(creds, CredentialFinding{Type: "ConnString", Key: key, Value: match, FullValue: match, Description: pattern.desc + " Connection String"})
}
}
}
return findings, creds
}
// detectBase64Secrets attempts to decode base64 values and analyze content
func detectBase64Secrets(data map[string]string) ([]string, []CredentialFinding) {
var findings []string
var creds []CredentialFinding
base64Pattern := regexp.MustCompile(`^[A-Za-z0-9+/]{20,}={0,2}$`)
for key, value := range data {
if strings.Contains(value, "-----BEGIN") {
continue
}
if base64Pattern.MatchString(value) && len(value) > 20 {
decoded, err := base64.StdEncoding.DecodeString(value)
if err == nil {
decodedStr := string(decoded)
lowerDecoded := strings.ToLower(decodedStr)
if strings.Contains(lowerDecoded, "password") ||
strings.Contains(lowerDecoded, "secret") ||
strings.Contains(lowerDecoded, "token") ||
strings.Contains(lowerDecoded, "api") ||
strings.Contains(decodedStr, "-----BEGIN") {
findings = append(findings, fmt.Sprintf("Base64-encoded Secret in '%s'", key))
creds = append(creds, CredentialFinding{Type: "Base64", Key: key, Value: decodedStr, FullValue: decodedStr, Description: "Base64-encoded Secret"})
}
}
}
}
return findings, creds
}
// detectDockerCredentials checks for Docker registry credentials
func detectDockerCredentials(data map[string]string) ([]string, []CredentialFinding) {
var findings []string
var creds []CredentialFinding
for key, value := range data {
if strings.Contains(key, "docker") && strings.Contains(key, "config") {
var dockerConfig map[string]interface{}
if err := json.Unmarshal([]byte(value), &dockerConfig); err == nil {
if auths, ok := dockerConfig["auths"].(map[string]interface{}); ok && len(auths) > 0 {
var registries []string
for reg := range auths {
registries = append(registries, reg)
}
findings = append(findings, fmt.Sprintf("Docker Registry Credentials in '%s'", key))
creds = append(creds, CredentialFinding{Type: "Docker", Key: key, Value: strings.Join(registries, ","), FullValue: value, Description: "Docker Registry Credentials"})
}
}
}
lowerKey := strings.ToLower(key)
if strings.Contains(lowerKey, "docker") && (strings.Contains(lowerKey, "auth") || strings.Contains(lowerKey, "password")) {
findings = append(findings, fmt.Sprintf("Docker Credentials in '%s'", key))
creds = append(creds, CredentialFinding{Type: "Docker", Key: key, Value: value, FullValue: value, Description: "Docker Credentials"})
}
}
return findings, creds
}
// detectKubeconfig checks if data contains kubeconfig content
func detectKubeconfig(data map[string]string) bool {
for _, value := range data {
if strings.Contains(value, "apiVersion:") &&
strings.Contains(value, "kind: Config") &&
(strings.Contains(value, "clusters:") || strings.Contains(value, "users:")) {
return true
}
}
return false
}
// detectSensitiveKeywords checks for sensitive keywords in key names only (not values).
// alreadyDetected contains keys already flagged by specific detectors to avoid duplicates.
func detectSensitiveKeywords(data map[string]string, alreadyDetected map[string]bool) ([]string, []CredentialFinding) {
suspects := []string{"password", "passwd", "secret_key", "secret-key", "secretkey", "token", "credentials", "api_key", "api-key", "apikey", "private_key", "private-key", "access_key", "access-key"}
var hits []string
var creds []CredentialFinding
for k, v := range data {
if alreadyDetected[k] {
continue
}
lk := strings.ToLower(k)
for _, s := range suspects {
if strings.Contains(lk, s) {
hits = append(hits, k)
creds = append(creds, CredentialFinding{Type: "SensitiveKeyword", Key: k, Value: v, FullValue: v, Description: fmt.Sprintf("keyword: %s", s)})
break
}
}
}
return hits, creds
}
// analyzeConfigMapSecurity performs comprehensive security analysis
func analyzeConfigMapSecurity(cm *corev1.ConfigMap) ConfigMapFinding {
finding := ConfigMapFinding{
Namespace: cm.Namespace,
Name: cm.Name,
CreationTimestamp: cm.CreationTimestamp.String(),
DataKeys: []string{},
Annotations: cm.Annotations,
}
// Check immutability
if cm.Immutable != nil && *cm.Immutable {
finding.IsImmutable = true
}
// Collect data keys
for key := range cm.Data {
finding.DataKeys = append(finding.DataKeys, key)
}
sort.Strings(finding.DataKeys)
// Calculate data size
for _, value := range cm.Data {
finding.DataSize += len(value)
}
// Detect various credential types
var credFindings []CredentialFinding
var creds []CredentialFinding
finding.AWSCredentials, creds = detectAWSCredentials(cm.Data)
credFindings = append(credFindings, creds...)
finding.GCPCredentials, creds = detectGCPCredentials(cm.Data)
credFindings = append(credFindings, creds...)
finding.GitHubTokens, creds = detectGitHubTokens(cm.Data)
credFindings = append(credFindings, creds...)
finding.PrivateKeys, creds = detectPrivateKeys(cm.Data)
credFindings = append(credFindings, creds...)
finding.ConnectionStrings, creds = detectConnectionStrings(cm.Data)
credFindings = append(credFindings, creds...)
finding.Base64Secrets, creds = detectBase64Secrets(cm.Data)
credFindings = append(credFindings, creds...)
finding.DockerCredentials, creds = detectDockerCredentials(cm.Data)
credFindings = append(credFindings, creds...)
finding.KubeconfigFound = detectKubeconfig(cm.Data)
if finding.KubeconfigFound {
for key, value := range cm.Data {
if strings.Contains(value, "apiVersion:") && strings.Contains(value, "kind: Config") {
credFindings = append(credFindings, CredentialFinding{Type: "Kubeconfig", Key: key, Value: value, FullValue: value, Description: "Kubeconfig"})
}
}
}
// Sensitive keywords don't produce credential findings (no detected value shown)
alreadyDetected := make(map[string]bool)
for _, cf := range credFindings {
alreadyDetected[cf.Key] = true
}
finding.SensitiveKeys, _ = detectSensitiveKeywords(cm.Data, alreadyDetected)
finding.CredentialFindings = credFindings
// Aggregate dangerous patterns
finding.DangerousPatterns = append(finding.DangerousPatterns, finding.AWSCredentials...)
finding.DangerousPatterns = append(finding.DangerousPatterns, finding.GCPCredentials...)
finding.DangerousPatterns = append(finding.DangerousPatterns, finding.GitHubTokens...)
finding.DangerousPatterns = append(finding.DangerousPatterns, finding.PrivateKeys...)
finding.DangerousPatterns = append(finding.DangerousPatterns, finding.ConnectionStrings...)
finding.DangerousPatterns = append(finding.DangerousPatterns, finding.Base64Secrets...)
finding.DangerousPatterns = append(finding.DangerousPatterns, finding.DockerCredentials...)
if finding.KubeconfigFound {
finding.DangerousPatterns = append(finding.DangerousPatterns, "Kubeconfig found")
}
return finding
}
// buildKeyPatternMap creates a map of key names to their detected sensitive patterns
func buildKeyPatternMap(cm *corev1.ConfigMap, finding ConfigMapFinding) map[string]string {
keyPatterns := make(map[string]string)
// Parse the pattern strings to extract key names and build the map
// Pattern format is like "AWS Access Key in 'keyname'" or "Private Key in 'keyname'"
parsePatterns := func(patterns []string, patternType string) {
for _, pattern := range patterns {
// Extract key name from pattern like "Something in 'keyname'"
if idx := strings.Index(pattern, " in '"); idx != -1 {
keyStart := idx + 5
keyEnd := strings.LastIndex(pattern, "'")
if keyEnd > keyStart {
keyName := pattern[keyStart:keyEnd]
if existing, ok := keyPatterns[keyName]; ok {
keyPatterns[keyName] = existing + ", " + patternType
} else {
keyPatterns[keyName] = patternType
}
}
}
}
}
parsePatterns(finding.AWSCredentials, "AWS Credential")
parsePatterns(finding.GCPCredentials, "GCP Credential")
parsePatterns(finding.GitHubTokens, "GitHub Token")
parsePatterns(finding.PrivateKeys, "Private Key")
parsePatterns(finding.ConnectionStrings, "Connection String")
parsePatterns(finding.Base64Secrets, "Base64 Secret")
parsePatterns(finding.DockerCredentials, "Docker Credential")
// Check for kubeconfig in all keys
if finding.KubeconfigFound {
for key, value := range cm.Data {
if strings.Contains(value, "apiVersion:") &&
strings.Contains(value, "kind: Config") {
if existing, ok := keyPatterns[key]; ok {
keyPatterns[key] = existing + ", Kubeconfig"
} else {
keyPatterns[key] = "Kubeconfig"
}
}
}
}
// Add sensitive keywords detection
for _, keyName := range finding.SensitiveKeys {
if _, ok := keyPatterns[keyName]; !ok {
keyPatterns[keyName] = "Sensitive Keyword"
}
}
return keyPatterns
}
// findPodsMountingConfigMapCached finds all pods that mount a specific ConfigMap using pre-fetched pods
func findPodsMountingConfigMapCached(allPods []corev1.Pod, namespace, configMapName string) []string {
var mountingPods []string
for _, pod := range allPods {
// Only check pods in the same namespace
if pod.Namespace != namespace {
continue
}
for _, volume := range pod.Spec.Volumes {
if volume.ConfigMap != nil && volume.ConfigMap.Name == configMapName {
mountingPods = append(mountingPods, pod.Name)
break
}
}
}
return mountingPods
}
// hasSensitiveData returns true if the configmap contains credentials or sensitive patterns
func hasSensitiveData(finding ConfigMapFinding) bool {
return len(finding.AWSCredentials) > 0 ||
len(finding.GCPCredentials) > 0 ||
len(finding.GitHubTokens) > 0 ||
len(finding.PrivateKeys) > 0 ||
len(finding.ConnectionStrings) > 0 ||
len(finding.DockerCredentials) > 0 ||
len(finding.Base64Secrets) > 0 ||
finding.KubeconfigFound
}
// generateConfigMapLoot creates a single consolidated commands loot file
func generateConfigMapLoot(findings []ConfigMapFinding, allConfigMaps []corev1.ConfigMap, outputDirectory string) []internal.LootFile {
loot := shared.NewLootBuilder()
// Filter findings by sensitivity
var sensitiveFindings []ConfigMapFinding
for _, f := range findings {
if hasSensitiveData(f) {
sensitiveFindings = append(sensitiveFindings, f)
}
}
// Single consolidated commands file
cmds := loot.Section("ConfigMap-Commands")
cmds.Add("═══════════════════════════════════════════════════════════════")
cmds.Add(" CONFIGMAP ENUMERATION AND EXPLOITATION COMMANDS")
cmds.Add("═══════════════════════════════════════════════════════════════")
cmds.Add("")
// Section 1: Enumeration
cmds.Add("##############################################")
cmds.Add("## 1. ENUMERATION")
cmds.Add("##############################################")
cmds.Add("")
cmds.Add("# Check permissions")
cmds.Add("kubectl auth can-i get configmaps --all-namespaces")
cmds.Add("kubectl auth can-i list configmaps --all-namespaces")
cmds.Add("kubectl auth can-i update configmaps --all-namespaces")
cmds.Add("")
cmds.Add("# List all ConfigMaps")
cmds.Add("kubectl get configmaps -A")
cmds.Add("kubectl get configmaps -A -o wide")
cmds.Add("")
cmds.Add("# Get ConfigMap details")
cmds.Add("kubectl get configmap <name> -n <namespace> -o yaml")
cmds.Add("kubectl describe configmap <name> -n <namespace>")
cmds.Add("")
cmds.Add("# Extract all ConfigMap data as JSON")
cmds.Add("kubectl get configmaps -A -o json | jq -r '.items[] | \"\\(.metadata.namespace)/\\(.metadata.name): \\(.data | keys)\"'")
cmds.Add("")
cmds.Add("# Find ConfigMaps with specific keys")
cmds.Add("kubectl get configmaps -A -o json | jq -r '.items[] | select(.data | keys | any(test(\"password|secret|key|token\"; \"i\"))) | \"\\(.metadata.namespace)/\\(.metadata.name)\"'")
cmds.Add("")
// Section 2: Sensitive ConfigMap Extraction
cmds.Add("##############################################")
cmds.Add("## 2. EXTRACTION - Sensitive ConfigMaps")
cmds.Add("##############################################")
cmds.Add("")
if len(sensitiveFindings) > 0 {
cmds.Add("# Bulk extraction script")
cmds.Add("mkdir -p configmap-dump && cd configmap-dump")
cmds.Add("")
for _, f := range sensitiveFindings {
cmds.Addf("# %s/%s - %s", f.Namespace, f.Name, strings.Join(f.DangerousPatterns, ", "))
cmds.Addf("kubectl get configmap -n %s %s -o yaml > %s-%s.yaml", f.Namespace, f.Name, f.Namespace, f.Name)
cmds.Add("")
}
} else {
cmds.Add("# No ConfigMaps with detected credentials found")
cmds.Add("# Use generic extraction:")
cmds.Add("kubectl get configmap <name> -n <namespace> -o yaml > configmap.yaml")
cmds.Add("")
}
// Section 3: Decode Base64 secrets
hasBase64 := false
for _, f := range sensitiveFindings {
if len(f.Base64Secrets) > 0 {
hasBase64 = true
break
}
}
if hasBase64 {
cmds.Add("##############################################")
cmds.Add("## 3. DECODE BASE64 SECRETS")
cmds.Add("##############################################")
cmds.Add("")
for _, f := range sensitiveFindings {
if len(f.Base64Secrets) > 0 {
cmds.Addf("# %s/%s", f.Namespace, f.Name)
for _, key := range f.DataKeys {
cmds.Addf("kubectl get configmap -n %s %s -o jsonpath='{.data.%s}' | base64 -d",
f.Namespace, f.Name, key)
}
cmds.Add("")
}
}
}
// Section 4: Credential-specific exploitation
cmds.Add("##############################################")
cmds.Add("## 4. CREDENTIAL EXPLOITATION")
cmds.Add("##############################################")
cmds.Add("")
hasAWS := false
hasGCP := false
hasGitHub := false
hasDB := false
hasPrivateKey := false
hasKubeconfig := false
for _, f := range sensitiveFindings {
if len(f.AWSCredentials) > 0 {
hasAWS = true
}
if len(f.GCPCredentials) > 0 {
hasGCP = true
}
if len(f.GitHubTokens) > 0 {
hasGitHub = true
}
if len(f.ConnectionStrings) > 0 {
hasDB = true
}
if len(f.PrivateKeys) > 0 {
hasPrivateKey = true
}
if f.KubeconfigFound {
hasKubeconfig = true
}
}
if hasAWS {
cmds.Add("### AWS Credentials ###")
for _, f := range sensitiveFindings {
if len(f.AWSCredentials) > 0 {
cmds.Addf("# Extract from %s/%s", f.Namespace, f.Name)
cmds.Addf("AWS_ACCESS_KEY_ID=$(kubectl get configmap -n %s %s -o jsonpath='{.data.AWS_ACCESS_KEY_ID}')", f.Namespace, f.Name)
cmds.Addf("AWS_SECRET_ACCESS_KEY=$(kubectl get configmap -n %s %s -o jsonpath='{.data.AWS_SECRET_ACCESS_KEY}')", f.Namespace, f.Name)
cmds.Add("export AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY")
cmds.Add("aws sts get-caller-identity")
cmds.Add("aws s3 ls")
cmds.Add("")
}
}
}
if hasGCP {
cmds.Add("### GCP Service Account Keys ###")
for _, f := range sensitiveFindings {
if len(f.GCPCredentials) > 0 {
cmds.Addf("# Extract from %s/%s", f.Namespace, f.Name)
cmds.Addf("kubectl get configmap -n %s %s -o jsonpath='{.data.GOOGLE_APPLICATION_CREDENTIALS}' > gcp-key.json", f.Namespace, f.Name)
cmds.Add("gcloud auth activate-service-account --key-file=gcp-key.json")
cmds.Add("gcloud projects list")
cmds.Add("")
}
}
}
if hasGitHub {
cmds.Add("### GitHub Tokens ###")
for _, f := range sensitiveFindings {
if len(f.GitHubTokens) > 0 {
cmds.Addf("# Extract from %s/%s", f.Namespace, f.Name)
cmds.Addf("GH_TOKEN=$(kubectl get configmap -n %s %s -o jsonpath='{.data.GITHUB_TOKEN}')", f.Namespace, f.Name)
cmds.Add("curl -H \"Authorization: token $GH_TOKEN\" https://api.github.com/user")
cmds.Add("")
}
}
}
if hasDB {
cmds.Add("### Database Connection Strings ###")
for _, f := range sensitiveFindings {
if len(f.ConnectionStrings) > 0 {
cmds.Addf("# Extract from %s/%s", f.Namespace, f.Name)
cmds.Addf("DB_URL=$(kubectl get configmap -n %s %s -o jsonpath='{.data.DATABASE_URL}')", f.Namespace, f.Name)
cmds.Add("psql \"$DB_URL\" # or mysql, mongo, etc.")
cmds.Add("")
}
}
}
if hasPrivateKey {
cmds.Add("### Private Keys ###")
for _, f := range sensitiveFindings {
if len(f.PrivateKeys) > 0 {
cmds.Addf("# Extract from %s/%s", f.Namespace, f.Name)
for _, key := range f.DataKeys {
cmds.Addf("kubectl get configmap -n %s %s -o jsonpath='{.data.%s}' > extracted-key.pem", f.Namespace, f.Name, key)
}
cmds.Add("chmod 600 extracted-key.pem")
cmds.Add("ssh -i extracted-key.pem user@host")
cmds.Add("")
}
}
}
if hasKubeconfig {
cmds.Add("### Kubeconfig Files ###")
for _, f := range sensitiveFindings {
if f.KubeconfigFound {
cmds.Addf("# Extract from %s/%s", f.Namespace, f.Name)
for _, key := range f.DataKeys {
cmds.Addf("kubectl get configmap -n %s %s -o jsonpath='{.data.%s}' > extracted-kubeconfig", f.Namespace, f.Name, key)
}
cmds.Add("export KUBECONFIG=./extracted-kubeconfig")
cmds.Add("kubectl get pods -A")
cmds.Add("")
}
}
}
if !hasAWS && !hasGCP && !hasGitHub && !hasDB && !hasPrivateKey && !hasKubeconfig {
cmds.Add("# No specific credentials found in ConfigMaps")
cmds.Add("# Review ConfigMaps manually for sensitive data")
cmds.Add("")
}
// Section 5: Pod injection technique
cmds.Add("##############################################")
cmds.Add("## 5. POD INJECTION - Mount ConfigMaps")
cmds.Add("##############################################")
cmds.Add("")
cmds.Add("# If you can create pods, mount sensitive ConfigMaps:")
cmds.Add("cat <<EOF | kubectl apply -f -")
cmds.Add("apiVersion: v1")
cmds.Add("kind: Pod")
cmds.Add("metadata:")
cmds.Add(" name: configmap-reader")
cmds.Add(" namespace: <TARGET_NAMESPACE>")
cmds.Add("spec:")
cmds.Add(" containers:")
cmds.Add(" - name: reader")
cmds.Add(" image: alpine")
cmds.Add(" command: [\"sleep\", \"3600\"]")
cmds.Add(" volumeMounts:")
cmds.Add(" - name: config")
cmds.Add(" mountPath: /config")
cmds.Add(" volumes:")
cmds.Add(" - name: config")
cmds.Add(" configMap:")
cmds.Add(" name: <TARGET_CONFIGMAP>")
cmds.Add("EOF")
cmds.Add("")
cmds.Add("# Extract data from pod")
cmds.Add("kubectl exec -it configmap-reader -n <TARGET_NAMESPACE> -- cat /config/*")
cmds.Add("")
// Section 6: Modify ConfigMaps
cmds.Add("##############################################")
cmds.Add("## 6. MODIFICATION - Inject Malicious Config")
cmds.Add("##############################################")
cmds.Add("")
cmds.Add("# Check if you can update ConfigMaps")
cmds.Add("kubectl auth can-i update configmaps -n <namespace>")
cmds.Add("")
cmds.Add("# Edit ConfigMap directly")
cmds.Add("kubectl edit configmap <name> -n <namespace>")
cmds.Add("")
cmds.Add("# Patch specific key")
cmds.Add("kubectl patch configmap <name> -n <namespace> --type=merge -p '{\"data\":{\"key\":\"malicious-value\"}}'")
cmds.Add("")
// Section 7: Find pods mounting ConfigMaps
cmds.Add("##############################################")
cmds.Add("## 7. FIND PODS MOUNTING CONFIGMAPS")
cmds.Add("##############################################")
cmds.Add("")
cmds.Add("# Find all pods mounting a specific ConfigMap")
cmds.Add("kubectl get pods -A -o json | jq -r '.items[] | select(.spec.volumes[]?.configMap.name == \"<CONFIGMAP_NAME>\") | \"\\(.metadata.namespace)/\\(.metadata.name)\"'")
cmds.Add("")
cmds.Add("# Find pods using ConfigMaps as environment variables")
cmds.Add("kubectl get pods -A -o json | jq -r '.items[] | select(.spec.containers[].envFrom[]?.configMapRef != null) | \"\\(.metadata.namespace)/\\(.metadata.name)\"'")
cmds.Add("")
// Full configmap data dump
dump := loot.Section("ConfigMap-Data")
dump.Add("═══════════════════════════════════════════════════════════════")
dump.Add(" CONFIGMAP FULL DATA DUMP")
dump.Add("═══════════════════════════════════════════════════════════════")
dump.Add("")
for _, cm := range allConfigMaps {
if len(cm.Data) == 0 && len(cm.BinaryData) == 0 {
continue
}
dump.Add(fmt.Sprintf("##############################################"))
dump.Add(fmt.Sprintf("## %s/%s", cm.Namespace, cm.Name))
dump.Add(fmt.Sprintf("## Keys: %d | Created: %s", len(cm.Data)+len(cm.BinaryData), cm.CreationTimestamp.Format("2006-01-02 15:04:05")))
if cm.Immutable != nil && *cm.Immutable {
dump.Add("## Immutable: Yes")
}
dump.Add(fmt.Sprintf("##############################################"))
dump.Add("")
for key, value := range cm.Data {
dump.Add(fmt.Sprintf("--- [%s] ---", key))
dump.Add(value)
dump.Add("")
}
for key, value := range cm.BinaryData {
encoded := base64.StdEncoding.EncodeToString(value)
dump.Add(fmt.Sprintf("--- [%s] (binary, base64-encoded) ---", key))
dump.Add(encoded)
dump.Add("")
}
}
return loot.Build()
}
-875
View File
@@ -1,875 +0,0 @@
package commands
import (
"context"
"fmt"
"sort"
"strings"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
"github.com/BishopFox/cloudfox/kubernetes/config"
"github.com/BishopFox/cloudfox/kubernetes/sdk"
"github.com/BishopFox/cloudfox/kubernetes/shared"
"github.com/spf13/cobra"
admissionregv1 "k8s.io/api/admissionregistration/v1"
rbacv1 "k8s.io/api/rbac/v1"
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
)
var CRDsCmd = &cobra.Command{
Use: "crds",
Aliases: []string{"crd", "customresources"},
Short: "Analyze Custom Resource Definitions for security risks",
Long: `
Analyze Custom Resource Definitions with comprehensive security analysis including:
- CRD inventory with resource counts
- Interesting/security-relevant CRDs (certs, secrets, policy, RBAC)
- RBAC analysis: who can CRUD each CRD's resources
- Webhook analysis: validating/mutating webhooks covering CRDs
- Missing validation schemas (injection risk)
- Conversion webhooks (admission bypass potential)
- Cluster-scoped CRDs (broad access implications)
cloudfox kubernetes crds`,
Run: ListCRDs,
}
// init() removed - detailed flag is now a global persistent flag in cli/kubernetes.go
type CRDsOutput struct {
Table []internal.TableFile
Loot []internal.LootFile
}
func (t CRDsOutput) TableFiles() []internal.TableFile {
return t.Table
}
func (t CRDsOutput) LootFiles() []internal.LootFile {
return t.Loot
}
type CRDAnalysis struct {
Name string
Group string
Kind string
Plural string
Scope string
Versions []string
StoredVersions []string
LatestVersion string
HasValidation bool
HasConversionWebhook bool
ConversionStrategy string
HasStatus bool
HasScale bool
Namespaced bool
Categories []string
ShortNames []string
DeprecatedVersions []string
ResourceCount int
}
type CRDResourceInfo struct {
CRDName string
Group string
Kind string
Resource string
Namespace string
ResourceName string
Labels string
CreatedAt string
}
type CRDRBACEntry struct {
CRDGroup string
Resource string
SubjectKind string
SubjectName string
Namespace string
Verbs string
Scope string
RoleName string
}
type CRDWebhookEntry struct {
CRDGroup string
WebhookName string
Type string
FailurePolicy string
Operations string
Service string
}
type InterestingCRD struct {
Name string
Group string
Kind string
Category string
Why string
Resources int
}
// interestingGroupPatterns maps category to group substrings
var interestingGroupPatterns = map[string][]string{
"Secrets/Certs": {"cert-manager", "secrets-store", "sealed-secrets", "vault", "external-secrets", "certmanager", "spiffe"},
"Policy": {"policy", "kyverno", "gatekeeper", "opa", "constraints", "kubearmor", "falco", "neuvector", "stackrox", "crowdstrike", "security-profiles-operator", "sysdig", "aquasec", "prismacloud"},
"RBAC/Auth": {"rbac", "auth", "identity", "oidc", "dex", "keycloak"},
"Workload": {"argoproj", "tekton", "flux", "keda", "knative", "crossplane"},
"Network": {"networking.istio", "cilium", "calico", "linkerd", "gateway.networking"},
"Storage": {"snapshot.storage", "rook", "longhorn", "velero"},
}
func ListCRDs(cmd *cobra.Command, args []string) {
ctx, cancel := shared.ContextWithCancel()
defer cancel()
logger := internal.NewLogger()
parentCmd := cmd.Parent()
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
outputDirectory, _ := parentCmd.PersistentFlags().GetString("outdir")
format, _ := parentCmd.PersistentFlags().GetString("output")
detailed := globals.K8sDetailed
logger.InfoM(fmt.Sprintf("Analyzing CRDs for %s", globals.ClusterName), globals.K8S_CRDS_MODULE_NAME)
// Standard clientset for RBAC and webhooks
clientset := config.GetClientOrExit()
// Dynamic client for resource counting
dynClient := config.GetDynamicClientOrExit()
// Fetch CRDs (via SDK cache)
crdItems, err := sdk.GetCRDs(ctx)
if err != nil {
logger.ErrorM(fmt.Sprintf("Error fetching CRDs: %v", err), globals.K8S_CRDS_MODULE_NAME)
return
}
// Build set of CRD groups for RBAC/webhook matching
crdGroups := make(map[string]bool)
var crdAnalyses []CRDAnalysis
for _, crd := range crdItems {
analysis := analyzeCRD(&crd)
crdGroups[analysis.Group] = true
crdAnalyses = append(crdAnalyses, analysis)
}
// Enumerate CRD resources
logger.InfoM("Enumerating CRD resources...", globals.K8S_CRDS_MODULE_NAME)
resourceInfos := enumerateCRDResources(ctx, dynClient, crdAnalyses)
// Build resource count map
resourceCountMap := make(map[string]int)
for _, ri := range resourceInfos {
resourceCountMap[ri.CRDName]++
}
for i := range crdAnalyses {
crdAnalyses[i].ResourceCount = resourceCountMap[crdAnalyses[i].Name]
}
// Identify interesting CRDs
interestingCRDs := identifyInterestingCRDs(crdAnalyses)
// RBAC analysis
logger.InfoM("Analyzing RBAC for CRD groups...", globals.K8S_CRDS_MODULE_NAME)
rbacEntries := analyzeCRDRBAC(ctx, clientset, crdGroups)
// Webhook analysis
logger.InfoM("Analyzing webhooks for CRD groups...", globals.K8S_CRDS_MODULE_NAME)
webhookEntries := analyzeCRDWebhooks(ctx, clientset, crdGroups)
// Build loot — only CRD-Commands (RBAC and Interesting are covered by tables)
loot := shared.NewLootBuilder()
loot.Section("CRD-Commands").Add(generateCRDCommands(crdAnalyses))
lootFiles := loot.Build()
// Build tables
var tables []internal.TableFile
// Always show: main CRDs table and Interesting CRDs
tables = append(tables, generateCRDTable(crdAnalyses))
if len(interestingCRDs) > 0 {
tables = append(tables, generateInterestingCRDTable(interestingCRDs))
}
// Detailed: RBAC, webhooks, per-namespace resources
if detailed {
if len(rbacEntries) > 0 {
tables = append(tables, generateCRDRBACTable(rbacEntries))
}
if len(webhookEntries) > 0 {
tables = append(tables, generateCRDWebhookTable(webhookEntries))
}
if len(resourceInfos) > 0 {
tables = append(tables, generateCRDResourceTable(resourceInfos))
}
}
err = internal.HandleOutput(
"Kubernetes",
format,
outputDirectory,
verbosity,
wrap,
"CRDs",
globals.ClusterName,
"results",
CRDsOutput{
Table: tables,
Loot: lootFiles,
},
)
if err != nil {
logger.ErrorM(fmt.Sprintf("Error handling output: %v", err), globals.K8S_CRDS_MODULE_NAME)
return
}
// Summary logging
if len(crdItems) > 0 {
noValidationCount := 0
clusterScopedCount := 0
webhooksCount := 0
for _, analysis := range crdAnalyses {
if !analysis.HasValidation {
noValidationCount++
}
if !analysis.Namespaced {
clusterScopedCount++
}
if analysis.HasConversionWebhook {
webhooksCount++
}
}
logger.InfoM(fmt.Sprintf("%d CRDs analyzed | Interesting: %d | No Validation: %d | Cluster-Scoped: %d | Conversion Webhooks: %d | RBAC Entries: %d | Admission Webhooks: %d",
len(crdItems), len(interestingCRDs), noValidationCount, clusterScopedCount, webhooksCount, len(rbacEntries), len(webhookEntries)),
globals.K8S_CRDS_MODULE_NAME)
} else {
logger.InfoM("No CRDs found", globals.K8S_CRDS_MODULE_NAME)
}
}
func analyzeCRD(crd *apiextensionsv1.CustomResourceDefinition) CRDAnalysis {
analysis := CRDAnalysis{
Name: crd.Name,
Group: crd.Spec.Group,
Kind: crd.Spec.Names.Kind,
Plural: crd.Spec.Names.Plural,
Scope: string(crd.Spec.Scope),
Categories: crd.Spec.Names.Categories,
ShortNames: crd.Spec.Names.ShortNames,
Namespaced: crd.Spec.Scope == apiextensionsv1.NamespaceScoped,
}
for _, ver := range crd.Spec.Versions {
analysis.Versions = append(analysis.Versions, ver.Name)
if ver.Served && ver.Storage {
analysis.LatestVersion = ver.Name
}
if ver.Schema != nil && ver.Schema.OpenAPIV3Schema != nil {
analysis.HasValidation = true
}
if ver.Deprecated {
analysis.DeprecatedVersions = append(analysis.DeprecatedVersions, ver.Name)
}
if ver.Subresources != nil {
if ver.Subresources.Status != nil {
analysis.HasStatus = true
}
if ver.Subresources.Scale != nil {
analysis.HasScale = true
}
}
}
analysis.StoredVersions = crd.Status.StoredVersions
if crd.Spec.Conversion != nil {
analysis.ConversionStrategy = string(crd.Spec.Conversion.Strategy)
if crd.Spec.Conversion.Strategy == apiextensionsv1.WebhookConverter {
analysis.HasConversionWebhook = true
}
}
return analysis
}
// enumerateCRDResources fetches actual instances of each CRD
func enumerateCRDResources(ctx context.Context, dynClient dynamic.Interface, analyses []CRDAnalysis) []CRDResourceInfo {
var results []CRDResourceInfo
for _, crd := range analyses {
if crd.LatestVersion == "" || crd.Plural == "" {
continue
}
gvr := schema.GroupVersionResource{
Group: crd.Group,
Version: crd.LatestVersion,
Resource: crd.Plural,
}
list, err := dynClient.Resource(gvr).Namespace("").List(ctx, metav1.ListOptions{})
if err != nil || len(list.Items) == 0 {
continue
}
for _, item := range list.Items {
ns := item.GetNamespace()
if ns == "" {
ns = "(cluster-scoped)"
}
// Format labels
labels := item.GetLabels()
var labelParts []string
for k, v := range labels {
labelParts = append(labelParts, fmt.Sprintf("%s=%s", k, v))
}
labelsStr := "-"
if len(labelParts) > 0 {
sort.Strings(labelParts)
labelsStr = strings.Join(labelParts, ", ")
}
createdAt := "-"
if t := item.GetCreationTimestamp(); !t.IsZero() {
createdAt = t.Format("2006-01-02 15:04")
}
results = append(results, CRDResourceInfo{
CRDName: crd.Name,
Group: crd.Group,
Kind: crd.Kind,
Resource: crd.Plural,
Namespace: ns,
ResourceName: item.GetName(),
Labels: labelsStr,
CreatedAt: createdAt,
})
}
}
return results
}
// identifyInterestingCRDs categorizes security-relevant CRDs
func identifyInterestingCRDs(analyses []CRDAnalysis) []InterestingCRD {
var results []InterestingCRD
for _, crd := range analyses {
groupLower := strings.ToLower(crd.Group)
for category, patterns := range interestingGroupPatterns {
for _, pattern := range patterns {
if strings.Contains(groupLower, pattern) {
why := fmt.Sprintf("Group matches: %s", pattern)
if !crd.HasValidation {
why += ", no validation"
}
if !crd.Namespaced {
why += ", cluster-scoped"
}
results = append(results, InterestingCRD{
Name: crd.Name,
Group: crd.Group,
Kind: crd.Kind,
Category: category,
Why: why,
Resources: crd.ResourceCount,
})
break // one category per CRD
}
}
}
}
sort.Slice(results, func(i, j int) bool {
if results[i].Category != results[j].Category {
return results[i].Category < results[j].Category
}
return results[i].Name < results[j].Name
})
return results
}
// analyzeCRDRBAC finds RBAC entries that grant access to CRD API groups
func analyzeCRDRBAC(ctx context.Context, clientset *kubernetes.Clientset, crdGroups map[string]bool) []CRDRBACEntry {
var results []CRDRBACEntry
clusterRoles, err := sdk.GetClusterRoles(ctx, clientset)
if err != nil {
return results
}
clusterRoleBindings, err := sdk.GetClusterRoleBindings(ctx, clientset)
if err != nil {
return results
}
roles, err := sdk.GetRoles(ctx, clientset)
if err != nil {
return results
}
roleBindings, err := sdk.GetRoleBindings(ctx, clientset)
if err != nil {
return results
}
// Build role lookup maps
crMap := make(map[string]*rbacv1.ClusterRole)
for i := range clusterRoles {
crMap[clusterRoles[i].Name] = &clusterRoles[i]
}
rMap := make(map[string]*rbacv1.Role)
for i := range roles {
key := roles[i].Namespace + "/" + roles[i].Name
rMap[key] = &roles[i]
}
// Check ClusterRoleBindings
for _, crb := range clusterRoleBindings {
cr, ok := crMap[crb.RoleRef.Name]
if !ok {
continue
}
entries := extractCRDRBACFromRules(cr.Rules, crdGroups, crb.Subjects, crb.RoleRef.Name, "Cluster", "")
results = append(results, entries...)
}
// Check RoleBindings
for _, rb := range roleBindings {
var rules []rbacv1.PolicyRule
if rb.RoleRef.Kind == "ClusterRole" {
if cr, ok := crMap[rb.RoleRef.Name]; ok {
rules = cr.Rules
}
} else {
key := rb.Namespace + "/" + rb.RoleRef.Name
if r, ok := rMap[key]; ok {
rules = r.Rules
}
}
if len(rules) == 0 {
continue
}
entries := extractCRDRBACFromRules(rules, crdGroups, rb.Subjects, rb.RoleRef.Name, "Namespace", rb.Namespace)
results = append(results, entries...)
}
sort.Slice(results, func(i, j int) bool {
if results[i].CRDGroup != results[j].CRDGroup {
return results[i].CRDGroup < results[j].CRDGroup
}
return results[i].SubjectName < results[j].SubjectName
})
return results
}
func extractCRDRBACFromRules(rules []rbacv1.PolicyRule, crdGroups map[string]bool, subjects []rbacv1.Subject, roleName, scope, namespace string) []CRDRBACEntry {
var results []CRDRBACEntry
for _, rule := range rules {
for _, apiGroup := range rule.APIGroups {
if !crdGroups[apiGroup] && apiGroup != "*" {
continue
}
// This rule covers a CRD group (or wildcard)
matchedGroup := apiGroup
if apiGroup == "*" {
matchedGroup = "(all groups)"
}
resources := strings.Join(rule.Resources, ",")
if len(rule.Resources) == 0 {
resources = "*"
}
verbs := strings.Join(rule.Verbs, ",")
for _, subj := range subjects {
results = append(results, CRDRBACEntry{
CRDGroup: matchedGroup,
Resource: resources,
SubjectKind: subj.Kind,
SubjectName: subj.Name,
Namespace: namespace,
Verbs: verbs,
Scope: scope,
RoleName: roleName,
})
}
}
}
return results
}
// analyzeCRDWebhooks finds webhooks that cover CRD API groups
func analyzeCRDWebhooks(ctx context.Context, clientset *kubernetes.Clientset, crdGroups map[string]bool) []CRDWebhookEntry {
var results []CRDWebhookEntry
validating, err := clientset.AdmissionregistrationV1().ValidatingWebhookConfigurations().List(ctx, metav1.ListOptions{})
if err == nil {
for _, vwc := range validating.Items {
for _, wh := range vwc.Webhooks {
fp := "Unknown"
if wh.FailurePolicy != nil {
fp = string(*wh.FailurePolicy)
}
svc := webhookClientConfigStr(wh.ClientConfig)
for _, rule := range wh.Rules {
entries := extractWebhookCRDEntries(rule.APIGroups, rule.Operations, crdGroups, vwc.Name, "Validating", fp, svc)
results = append(results, entries...)
}
}
}
}
mutating, err := clientset.AdmissionregistrationV1().MutatingWebhookConfigurations().List(ctx, metav1.ListOptions{})
if err == nil {
for _, mwc := range mutating.Items {
for _, wh := range mwc.Webhooks {
fp := "Unknown"
if wh.FailurePolicy != nil {
fp = string(*wh.FailurePolicy)
}
svc := webhookClientConfigStr(wh.ClientConfig)
for _, rule := range wh.Rules {
entries := extractWebhookCRDEntries(rule.APIGroups, rule.Operations, crdGroups, mwc.Name, "Mutating", fp, svc)
results = append(results, entries...)
}
}
}
}
return results
}
func extractWebhookCRDEntries(apiGroups []string, operations []admissionregv1.OperationType, crdGroups map[string]bool, webhookName, whType, failurePolicy, service string) []CRDWebhookEntry {
var results []CRDWebhookEntry
for _, apiGroup := range apiGroups {
if !crdGroups[apiGroup] && apiGroup != "*" {
continue
}
matchedGroup := apiGroup
if apiGroup == "*" {
matchedGroup = "(all groups)"
}
ops := make([]string, len(operations))
for i, op := range operations {
ops[i] = string(op)
}
results = append(results, CRDWebhookEntry{
CRDGroup: matchedGroup,
WebhookName: webhookName,
Type: whType,
FailurePolicy: failurePolicy,
Operations: strings.Join(ops, ","),
Service: service,
})
}
return results
}
func webhookClientConfigStr(cc admissionregv1.WebhookClientConfig) string {
if cc.Service != nil {
return fmt.Sprintf("%s/%s", cc.Service.Namespace, cc.Service.Name)
}
if cc.URL != nil {
return *cc.URL
}
return "-"
}
// Table generators
func generateCRDTable(analyses []CRDAnalysis) internal.TableFile {
header := []string{"Name", "Group", "Kind", "Scope", "Versions", "Validation", "Conv. Webhook", "Resources"}
sort.Slice(analyses, func(i, j int) bool {
return analyses[i].Name < analyses[j].Name
})
var rows [][]string
for _, crd := range analyses {
versionsStr := strings.Join(crd.Versions, ",")
if len(versionsStr) > 20 {
versionsStr = versionsStr[:17] + "..."
}
rows = append(rows, []string{
truncateStr(crd.Name, 50),
crd.Group,
crd.Kind,
crd.Scope,
versionsStr,
shared.FormatBool(crd.HasValidation),
shared.FormatBool(crd.HasConversionWebhook),
fmt.Sprintf("%d", crd.ResourceCount),
})
}
return internal.TableFile{
Name: "CRDs",
Header: header,
Body: rows,
}
}
func generateInterestingCRDTable(crds []InterestingCRD) internal.TableFile {
header := []string{"Name", "Group", "Kind", "Category", "Why", "Resources"}
var rows [][]string
for _, crd := range crds {
rows = append(rows, []string{
truncateStr(crd.Name, 50),
crd.Group,
crd.Kind,
crd.Category,
crd.Why,
fmt.Sprintf("%d", crd.Resources),
})
}
return internal.TableFile{
Name: "Interesting-CRDs",
Header: header,
Body: rows,
}
}
func generateCRDRBACTable(entries []CRDRBACEntry) internal.TableFile {
header := []string{"Namespace", "CRD Group", "Resource", "Verbs", "Subject", "Subject Name", "Role", "Scope"}
var rows [][]string
for _, e := range entries {
ns := e.Namespace
if ns == "" {
ns = "(cluster)"
}
rows = append(rows, []string{
ns,
e.CRDGroup,
e.Resource,
e.Verbs,
e.SubjectKind,
e.SubjectName,
e.RoleName,
e.Scope,
})
}
return internal.TableFile{
Name: "CRD-RBAC",
Header: header,
Body: rows,
}
}
func generateCRDWebhookTable(entries []CRDWebhookEntry) internal.TableFile {
header := []string{"CRD Group", "Webhook Name", "Type", "Service", "Operations", "Failure Policy"}
var rows [][]string
for _, e := range entries {
rows = append(rows, []string{
e.CRDGroup,
e.WebhookName,
e.Type,
e.Service,
e.Operations,
e.FailurePolicy,
})
}
return internal.TableFile{
Name: "CRD-Webhooks",
Header: header,
Body: rows,
}
}
func generateCRDResourceTable(infos []CRDResourceInfo) internal.TableFile {
header := []string{"Namespace", "Kind", "Name", "Created", "Labels"}
var rows [][]string
sort.Slice(infos, func(i, j int) bool {
if infos[i].Namespace != infos[j].Namespace {
return infos[i].Namespace < infos[j].Namespace
}
if infos[i].Kind != infos[j].Kind {
return infos[i].Kind < infos[j].Kind
}
return infos[i].ResourceName < infos[j].ResourceName
})
for _, ri := range infos {
rows = append(rows, []string{
ri.Namespace,
ri.Kind,
ri.ResourceName,
ri.CreatedAt,
ri.Labels,
})
}
return internal.TableFile{
Name: "CRD-Resources",
Header: header,
Body: rows,
}
}
// Loot generators
// generateCRDCommands creates enumeration and exploitation commands for CRDs
func generateCRDCommands(analyses []CRDAnalysis) string {
var lines []string
lines = append(lines, "═══════════════════════════════════════════════════════════════")
lines = append(lines, " CRD ENUMERATION AND EXPLOITATION COMMANDS")
lines = append(lines, "═══════════════════════════════════════════════════════════════")
lines = append(lines, "")
// Basic enumeration
lines = append(lines, "##############################################")
lines = append(lines, "## 1. ENUMERATION - List All CRDs")
lines = append(lines, "##############################################")
lines = append(lines, "")
lines = append(lines, "kubectl get crds")
lines = append(lines, "kubectl get crds -o wide")
lines = append(lines, "kubectl get crd <crd-name> -o yaml")
lines = append(lines, "kubectl describe crd <crd-name>")
lines = append(lines, "")
// Find vulnerable CRDs
lines = append(lines, "##############################################")
lines = append(lines, "## 2. IDENTIFY VULNERABLE CRDs")
lines = append(lines, "##############################################")
lines = append(lines, "")
lines = append(lines, "# CRDs without validation schema")
lines = append(lines, "kubectl get crds -o json | jq -r '.items[] | select(.spec.versions[].schema == null or .spec.versions[].schema.openAPIV3Schema == null) | .metadata.name'")
lines = append(lines, "")
lines = append(lines, "# Cluster-scoped CRDs")
lines = append(lines, "kubectl get crds -o json | jq -r '.items[] | select(.spec.scope == \"Cluster\") | .metadata.name'")
lines = append(lines, "")
lines = append(lines, "# CRDs with conversion webhooks")
lines = append(lines, "kubectl get crds -o json | jq -r '.items[] | select(.spec.conversion.strategy == \"Webhook\") | {name: .metadata.name, webhook: .spec.conversion.webhook}'")
lines = append(lines, "")
// Enumerate custom resources
lines = append(lines, "##############################################")
lines = append(lines, "## 3. ENUMERATE CUSTOM RESOURCES")
lines = append(lines, "##############################################")
lines = append(lines, "")
if len(analyses) > 0 {
for _, crd := range analyses {
plural := crd.Plural
if plural == "" {
plural = strings.ToLower(crd.Kind) + "s"
}
if len(crd.ShortNames) > 0 {
lines = append(lines, fmt.Sprintf("kubectl get %s -A # %s (shortname: %s)", plural, crd.Name, strings.Join(crd.ShortNames, ",")))
} else {
lines = append(lines, fmt.Sprintf("kubectl get %s -A # %s", plural, crd.Name))
}
}
lines = append(lines, "")
}
// RBAC analysis commands
lines = append(lines, "##############################################")
lines = append(lines, "## 4. RBAC ANALYSIS FOR CRDs")
lines = append(lines, "##############################################")
lines = append(lines, "")
lines = append(lines, "kubectl auth can-i list customresourcedefinitions")
lines = append(lines, "kubectl auth can-i create customresourcedefinitions")
lines = append(lines, "kubectl auth can-i delete customresourcedefinitions")
lines = append(lines, "")
lines = append(lines, "# Find roles granting CRD access")
lines = append(lines, "kubectl get clusterroles -o json | jq -r '.items[] | select(.rules[]?.resources[]? | contains(\"customresourcedefinitions\")) | .metadata.name'")
lines = append(lines, "")
// No-validation exploitation
noValidationCRDs := []CRDAnalysis{}
for _, crd := range analyses {
if !crd.HasValidation {
noValidationCRDs = append(noValidationCRDs, crd)
}
}
if len(noValidationCRDs) > 0 {
lines = append(lines, "##############################################")
lines = append(lines, "## 5. NO-VALIDATION CRDs (INJECTION TARGETS)")
lines = append(lines, "##############################################")
lines = append(lines, "")
for _, crd := range noValidationCRDs {
lines = append(lines, fmt.Sprintf("# %s (Group: %s) - no validation", crd.Name, crd.Group))
plural := crd.Plural
if plural == "" {
plural = strings.ToLower(crd.Kind) + "s"
}
lines = append(lines, fmt.Sprintf("kubectl get %s -A -o yaml", plural))
}
lines = append(lines, "")
}
// Webhook bypass
lines = append(lines, "##############################################")
lines = append(lines, "## 6. WEBHOOK BYPASS")
lines = append(lines, "##############################################")
lines = append(lines, "")
lines = append(lines, "# Check conversion webhook config")
lines = append(lines, "kubectl get crd <crd-name> -o jsonpath='{.spec.conversion}'")
lines = append(lines, "")
lines = append(lines, "# Find webhook service")
lines = append(lines, "kubectl get crd <crd-name> -o json | jq '.spec.conversion.webhook.clientConfig'")
lines = append(lines, "")
// Privilege escalation
lines = append(lines, "##############################################")
lines = append(lines, "## 7. PRIVILEGE ESCALATION VIA CRDs")
lines = append(lines, "##############################################")
lines = append(lines, "")
lines = append(lines, "kubectl auth can-i create customresourcedefinitions")
lines = append(lines, "")
lines = append(lines, "# If you can create CRDs, create one without validation:")
lines = append(lines, "cat <<EOF | kubectl apply -f -")
lines = append(lines, "apiVersion: apiextensions.k8s.io/v1")
lines = append(lines, "kind: CustomResourceDefinition")
lines = append(lines, "metadata:")
lines = append(lines, " name: exploits.attacker.example.com")
lines = append(lines, "spec:")
lines = append(lines, " group: attacker.example.com")
lines = append(lines, " names:")
lines = append(lines, " kind: Exploit")
lines = append(lines, " plural: exploits")
lines = append(lines, " scope: Namespaced")
lines = append(lines, " versions:")
lines = append(lines, " - name: v1")
lines = append(lines, " served: true")
lines = append(lines, " storage: true")
lines = append(lines, " schema:")
lines = append(lines, " openAPIV3Schema:")
lines = append(lines, " type: object")
lines = append(lines, " x-kubernetes-preserve-unknown-fields: true")
lines = append(lines, "EOF")
lines = append(lines, "")
return strings.Join(lines, "\n")
}
func truncateStr(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen-3] + "..."
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
-735
View File
@@ -1,735 +0,0 @@
package commands
import (
"fmt"
"sort"
"strings"
"github.com/BishopFox/cloudfox/globals"
"github.com/BishopFox/cloudfox/internal"
"github.com/BishopFox/cloudfox/kubernetes/sdk"
attackpathservice "github.com/BishopFox/cloudfox/kubernetes/services/attackpathService"
"github.com/BishopFox/cloudfox/kubernetes/shared"
"github.com/spf13/cobra"
)
var DataExfiltrationCmd = &cobra.Command{
Use: "data-exfiltration",
Aliases: []string{"exfil", "de", "data-exfil"},
Short: "Identify data exfiltration paths in Kubernetes",
Long: `Analyze Kubernetes RBAC and resources to identify data exfiltration opportunities.
This module examines permissions that allow reading sensitive data from the cluster
that could be exfiltrated to external destinations.
Detected data exfiltration vectors include:
Secrets:
- Read secrets (credentials, API keys, TLS certificates)
- List secrets cluster-wide or namespace-scoped
- Full wildcard access to secrets
ConfigMaps:
- Read configmaps (often contain sensitive configuration)
- List configmaps for sensitive data discovery
Logs:
- Pod logs access (may contain sensitive data, credentials in error messages)
Data Extraction:
- Pod exec (extract files and data from containers)
- PersistentVolumeClaim access (access data volumes)
Token Exfiltration:
- ServiceAccount token generation (for external use)
Custom Resources:
- Read custom resources (may contain sensitive application data)
Usage:
cloudfox kubernetes data-exfiltration`,
Run: runDataExfiltrationCommand,
}
type DataExfiltrationOutput struct {
Table []internal.TableFile
Loot []internal.LootFile
}
func (o DataExfiltrationOutput) TableFiles() []internal.TableFile { return o.Table }
func (o DataExfiltrationOutput) LootFiles() []internal.LootFile { return o.Loot }
type DataExfiltrationModule struct {
// All paths from combined analysis
AllPaths []attackpathservice.AttackPath
ClusterPaths []attackpathservice.AttackPath
NamespacePaths map[string][]attackpathservice.AttackPath
Namespaces []string
}
func runDataExfiltrationCommand(cmd *cobra.Command, args []string) {
ctx, cancel := shared.ContextWithCancel()
defer cancel()
logger := internal.NewLogger()
parentCmd := cmd.Parent()
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
outputDirectory, _ := parentCmd.PersistentFlags().GetString("outdir")
format, _ := parentCmd.PersistentFlags().GetString("output")
// Validate authentication first
if err := sdk.ValidateAuth(ctx); err != nil {
logger.ErrorM(fmt.Sprintf("Authentication failed:\n%v", err), globals.K8S_DATA_EXFIL_MODULE_NAME)
return
}
logger.InfoM(fmt.Sprintf("Analyzing data exfiltration paths for %s", globals.ClusterName), globals.K8S_DATA_EXFIL_MODULE_NAME)
// Initialize attack path service
svc, err := attackpathservice.New()
if err != nil {
logger.ErrorM(fmt.Sprintf("Failed to initialize attack path service: %v", err), globals.K8S_DATA_EXFIL_MODULE_NAME)
return
}
svc.SetModuleName(globals.K8S_DATA_EXFIL_MODULE_NAME)
// Run analysis
result, err := svc.CombinedAnalysis(ctx, "exfil")
if err != nil {
logger.ErrorM(fmt.Sprintf("Failed to analyze data exfiltration: %v", err), globals.K8S_DATA_EXFIL_MODULE_NAME)
return
}
module := &DataExfiltrationModule{
AllPaths: result.AllPaths,
ClusterPaths: result.ClusterPaths,
NamespacePaths: result.NamespacePaths,
Namespaces: result.Namespaces,
}
if len(module.AllPaths) == 0 {
logger.InfoM("No data exfiltration paths found", globals.K8S_DATA_EXFIL_MODULE_NAME)
return
}
// Count by scope and risk
clusterCount := len(module.ClusterPaths)
namespaceCount := len(module.AllPaths) - clusterCount
riskCounts := countExfilRiskLevels(module.AllPaths)
logger.SuccessM(fmt.Sprintf("Found %d data exfiltration path(s): %d cluster-level, %d namespace-level",
len(module.AllPaths), clusterCount, namespaceCount), globals.K8S_DATA_EXFIL_MODULE_NAME)
logger.InfoM(fmt.Sprintf("Risk Summary: CRITICAL=%d, HIGH=%d, MEDIUM=%d, LOW=%d",
riskCounts.Critical, riskCounts.High, riskCounts.Medium, riskCounts.Low), globals.K8S_DATA_EXFIL_MODULE_NAME)
// Generate output
tables := module.buildExfilTables()
loot := module.generateExfilLoot()
output := DataExfiltrationOutput{
Table: tables,
Loot: loot,
}
err = internal.HandleOutput(
"Kubernetes",
format,
outputDirectory,
verbosity,
wrap,
"data-exfiltration",
globals.ClusterName,
"results",
output,
)
if err != nil {
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.K8S_DATA_EXFIL_MODULE_NAME)
return
}
logger.InfoM(fmt.Sprintf("For context and next steps: https://github.com/BishopFox/cloudfox/wiki/Kubernetes-Commands#%s", globals.K8S_DATA_EXFIL_MODULE_NAME), globals.K8S_DATA_EXFIL_MODULE_NAME)
}
func (m *DataExfiltrationModule) buildExfilTables() []internal.TableFile {
headers := []string{
"Risk Level",
"Source",
"Scope",
"Principal",
"Principal Type",
"Method",
"Target Resource",
"Permissions",
"Role",
"Binding",
"Description",
}
var body [][]string
// Sort by risk level (CRITICAL first)
sortedPaths := make([]attackpathservice.AttackPath, len(m.AllPaths))
copy(sortedPaths, m.AllPaths)
sort.Slice(sortedPaths, func(i, j int) bool {
return shared.RiskLevelValue(sortedPaths[i].RiskLevel) > shared.RiskLevelValue(sortedPaths[j].RiskLevel)
})
for _, path := range sortedPaths {
scope := path.ScopeName
if path.ScopeType == "cluster" {
scope = "cluster-wide"
}
source := path.SourceType
if source == "" {
source = "core"
}
body = append(body, []string{
path.RiskLevel,
source,
scope,
path.Principal,
path.PrincipalType,
path.Method,
path.TargetResource,
strings.Join(path.Permissions, ", "),
path.RoleName,
path.BindingName,
path.Description,
})
}
return []internal.TableFile{
{
Name: "DataExfiltration",
Header: headers,
Body: body,
},
}
}
func (m *DataExfiltrationModule) generateExfilLoot() []internal.LootFile {
var lootContent []string
lootContent = append(lootContent, "#####################################")
lootContent = append(lootContent, "##### Kubernetes Data Exfiltration Commands")
lootContent = append(lootContent, "##### Generated by CloudFox")
lootContent = append(lootContent, "#####################################")
lootContent = append(lootContent, "")
riskCounts := countExfilRiskLevels(m.AllPaths)
lootContent = append(lootContent, fmt.Sprintf("# Risk Summary: CRITICAL=%d, HIGH=%d, MEDIUM=%d, LOW=%d",
riskCounts.Critical, riskCounts.High, riskCounts.Medium, riskCounts.Low))
lootContent = append(lootContent, "")
// Section: Secrets
secretPaths := filterExfilByCategory(m.AllPaths, "Secrets")
if len(secretPaths) > 0 {
lootContent = append(lootContent, "")
lootContent = append(lootContent, "### SECRETS - Extract credentials, API keys, certificates")
lootContent = append(lootContent, "")
for _, path := range secretPaths {
lootContent = append(lootContent, fmt.Sprintf("# [%s] %s (%s) - %s", path.RiskLevel, path.Principal, path.PrincipalType, path.ScopeName))
lootContent = append(lootContent, fmt.Sprintf("# Role: %s via %s", path.RoleName, path.BindingName))
lootContent = append(lootContent, path.ExploitCommand)
lootContent = append(lootContent, "")
}
// Add additional secret extraction commands
lootContent = append(lootContent, "# Additional secret extraction commands:")
lootContent = append(lootContent, "# Decode all secrets in a namespace:")
lootContent = append(lootContent, "kubectl get secrets -n <namespace> -o json | jq '.items[].data | map_values(@base64d)'")
lootContent = append(lootContent, "")
lootContent = append(lootContent, "# Find TLS certificates:")
lootContent = append(lootContent, "kubectl get secrets -A --field-selector type=kubernetes.io/tls -o wide")
lootContent = append(lootContent, "")
lootContent = append(lootContent, "# Find docker registry credentials:")
lootContent = append(lootContent, "kubectl get secrets -A --field-selector type=kubernetes.io/dockerconfigjson -o json | jq '.items[].data[\".dockerconfigjson\"]' -r | base64 -d")
lootContent = append(lootContent, "")
}
// Section: ConfigMaps
configmapPaths := filterExfilByCategory(m.AllPaths, "ConfigMaps")
if len(configmapPaths) > 0 {
lootContent = append(lootContent, "")
lootContent = append(lootContent, "### CONFIGMAPS - Extract configuration data")
lootContent = append(lootContent, "")
for _, path := range configmapPaths {
lootContent = append(lootContent, fmt.Sprintf("# [%s] %s (%s) - %s", path.RiskLevel, path.Principal, path.PrincipalType, path.ScopeName))
lootContent = append(lootContent, path.ExploitCommand)
lootContent = append(lootContent, "")
}
}
// Section: Logs
logPaths := filterExfilByCategory(m.AllPaths, "Logs")
if len(logPaths) > 0 {
lootContent = append(lootContent, "")
lootContent = append(lootContent, "### LOGS - Extract pod logs (may contain sensitive data)")
lootContent = append(lootContent, "")
for _, path := range logPaths {
lootContent = append(lootContent, fmt.Sprintf("# [%s] %s (%s) - %s", path.RiskLevel, path.Principal, path.PrincipalType, path.ScopeName))
lootContent = append(lootContent, path.ExploitCommand)
lootContent = append(lootContent, "")
}
// Add additional log extraction commands
lootContent = append(lootContent, "# Search logs for sensitive patterns:")
lootContent = append(lootContent, "kubectl logs <pod> | grep -iE '(password|secret|token|key|credential)'")
lootContent = append(lootContent, "")
}
// Section: Data Extraction via Exec
execPaths := filterExfilByCategory(m.AllPaths, "Data Extraction")
if len(execPaths) > 0 {
lootContent = append(lootContent, "")
lootContent = append(lootContent, "### DATA EXTRACTION - Extract files from containers")
lootContent = append(lootContent, "")
for _, path := range execPaths {
lootContent = append(lootContent, fmt.Sprintf("# [%s] %s (%s) - %s", path.RiskLevel, path.Principal, path.PrincipalType, path.ScopeName))
lootContent = append(lootContent, path.ExploitCommand)
lootContent = append(lootContent, "")
}
// Add additional data extraction commands
lootContent = append(lootContent, "# Extract files via kubectl cp:")
lootContent = append(lootContent, "kubectl cp <namespace>/<pod>:/path/to/file ./extracted-file")
lootContent = append(lootContent, "")
lootContent = append(lootContent, "# Extract environment variables:")
lootContent = append(lootContent, "kubectl exec <pod> -n <namespace> -- env | grep -iE '(password|secret|token|key|credential|database|api)'")
lootContent = append(lootContent, "")
lootContent = append(lootContent, "# Extract SA token from running pod:")
lootContent = append(lootContent, "kubectl exec <pod> -n <namespace> -- cat /var/run/secrets/kubernetes.io/serviceaccount/token")
lootContent = append(lootContent, "")
}
// Section: Token Exfiltration
tokenPaths := filterExfilByCategory(m.AllPaths, "Token Exfil")
if len(tokenPaths) > 0 {
lootContent = append(lootContent, "")
lootContent = append(lootContent, "### TOKEN EXFILTRATION - Generate SA tokens for external use")
lootContent = append(lootContent, "")
for _, path := range tokenPaths {
lootContent = append(lootContent, fmt.Sprintf("# [%s] %s (%s) - %s", path.RiskLevel, path.Principal, path.PrincipalType, path.ScopeName))
lootContent = append(lootContent, path.ExploitCommand)
lootContent = append(lootContent, "")
}
}
// Section: Storage
storagePaths := filterExfilByCategory(m.AllPaths, "Storage")
if len(storagePaths) > 0 {
lootContent = append(lootContent, "")
lootContent = append(lootContent, "### STORAGE - Access persistent volume data")
lootContent = append(lootContent, "")
for _, path := range storagePaths {
lootContent = append(lootContent, fmt.Sprintf("# [%s] %s (%s) - %s", path.RiskLevel, path.Principal, path.PrincipalType, path.ScopeName))
lootContent = append(lootContent, path.ExploitCommand)
lootContent = append(lootContent, "")
}
}
// Group by namespace for targeted exfiltration
lootContent = append(lootContent, "")
lootContent = append(lootContent, "### GROUPED BY NAMESPACE")
lootContent = append(lootContent, "")
for namespace, paths := range m.NamespacePaths {
if len(paths) > 0 {
lootContent = append(lootContent, fmt.Sprintf("## Namespace: %s (%d paths)", namespace, len(paths)))
// Group by category within namespace
secretsInNs := filterExfilByCategory(paths, "Secrets")
if len(secretsInNs) > 0 {
lootContent = append(lootContent, fmt.Sprintf("# Secrets access: %d principals", len(secretsInNs)))
lootContent = append(lootContent, fmt.Sprintf("kubectl get secrets -n %s -o yaml", namespace))
}
configmapsInNs := filterExfilByCategory(paths, "ConfigMaps")
if len(configmapsInNs) > 0 {
lootContent = append(lootContent, fmt.Sprintf("# ConfigMaps access: %d principals", len(configmapsInNs)))
lootContent = append(lootContent, fmt.Sprintf("kubectl get configmaps -n %s -o yaml", namespace))
}
lootContent = append(lootContent, "")
}
}
// Summary of high-value targets
lootContent = append(lootContent, "")
lootContent = append(lootContent, "### HIGH-VALUE TARGETS")
lootContent = append(lootContent, "")
lootContent = append(lootContent, "# Cluster-wide secret access is most dangerous:")
clusterSecretPaths := filterExfilByCategory(m.ClusterPaths, "Secrets")
if len(clusterSecretPaths) > 0 {
lootContent = append(lootContent, fmt.Sprintf("# Found %d principals with cluster-wide secret access", len(clusterSecretPaths)))
for _, path := range clusterSecretPaths {
lootContent = append(lootContent, fmt.Sprintf("# - %s (%s) via %s", path.Principal, path.PrincipalType, path.RoleName))
}
} else {
lootContent = append(lootContent, "# No cluster-wide secret access found")
}
lootContent = append(lootContent, "")
// Build playbook content using centralized attackpathService function
clusterHeader := fmt.Sprintf("Cluster: %s", globals.ClusterName)
playbookContent := attackpathservice.GenerateExfilPlaybook(m.AllPaths, clusterHeader)
// Fallback to legacy playbook if centralized returns empty
if playbookContent == "" {
methodGroups := groupExfilByMethod(m.AllPaths)
playbookContent = m.generateExfilPlaybook(methodGroups)
}
return []internal.LootFile{
{
Name: "DataExfiltration-Commands",
Contents: strings.Join(lootContent, "\n"),
},
{
Name: "DataExfiltration-Playbook",
Contents: playbookContent,
},
}
}
// generateExfilPlaybook creates a reference-style guide for data exfiltration organized by data type
func (m *DataExfiltrationModule) generateExfilPlaybook(methodGroups map[string][]attackpathservice.AttackPath) string {
var content []string
content = append(content, "#####################################")
content = append(content, "##### Data Exfiltration Playbook")
content = append(content, "##### Reference Guide by Data Type")
content = append(content, "#####################################")
content = append(content, "")
// Secrets
content = append(content, `
##############################################
## SECRETS
##############################################
# Extract credentials, API keys, TLS certificates, SA tokens`)
if entities := getExfilEntitiesForMethods(methodGroups, "Secrets"); len(entities) > 0 {
content = append(content, "# Entities with this permission:")
for _, entity := range entities {
content = append(content, fmt.Sprintf("# - %s", entity))
}
} else {
content = append(content, "# No entities found with secret access")
}
content = append(content, `
# List all secrets cluster-wide:
kubectl get secrets -A -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,TYPE:.type'
# Extract and decode ALL secrets:
kubectl get secrets -A -o json | jq -r '.items[] | "\(.metadata.namespace)/\(.metadata.name):\n\(.data | to_entries | map(" \(.key): \(.value | @base64d)") | join("\n"))"'
# Find ServiceAccount tokens:
kubectl get secrets -A -o json | jq -r '.items[] | select(.type=="kubernetes.io/service-account-token") | "\(.metadata.namespace)/\(.metadata.name): \(.data.token | @base64d)"'
# Find TLS certificates and keys:
kubectl get secrets -A --field-selector type=kubernetes.io/tls -o json | jq -r '.items[] | "\(.metadata.namespace)/\(.metadata.name):\n cert: \(.data["tls.crt"] | @base64d | split("\n")[0])...\n key: \(.data["tls.key"] | @base64d | split("\n")[0])..."'
# Find Docker registry credentials:
kubectl get secrets -A --field-selector type=kubernetes.io/dockerconfigjson -o json | jq -r '.items[] | "\(.metadata.namespace)/\(.metadata.name): \(.data[".dockerconfigjson"] | @base64d)"'
# Find Opaque secrets (often contain passwords):
kubectl get secrets -A --field-selector type=Opaque -o json | jq -r '.items[] | "\(.metadata.namespace)/\(.metadata.name): \(.data | map_values(@base64d))"'
# Search secrets for specific patterns:
kubectl get secrets -A -o json | jq -r '.items[].data | to_entries[] | "\(.key): \(.value | @base64d)"' | grep -iE '(password|token|key|secret|credential)'`)
// ConfigMaps
content = append(content, `
##############################################
## CONFIGMAPS
##############################################
# Extract configuration files, connection strings, environment variables`)
if entities := getExfilEntitiesForMethods(methodGroups, "ConfigMaps", "Config Access"); len(entities) > 0 {
content = append(content, "# Entities with this permission:")
for _, entity := range entities {
content = append(content, fmt.Sprintf("# - %s", entity))
}
} else {
content = append(content, "# No entities found with configmap access")
}
content = append(content, `
# List all configmaps:
kubectl get configmaps -A -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name'
# Extract all configmap data:
kubectl get configmaps -A -o json | jq -r '.items[] | "\(.metadata.namespace)/\(.metadata.name):\n\(.data | to_entries | map(" \(.key): \(.value[:100])...") | join("\n"))"'
# Search configmaps for sensitive data:
kubectl get configmaps -A -o json | jq -r '.items[].data | to_entries[] | "\(.key): \(.value)"' | grep -iE '(password|secret|token|key|database|connection|jdbc|redis|mongo)'
# Find kubeconfig files in configmaps:
kubectl get configmaps -A -o json | jq -r '.items[] | select(.data | to_entries[] | .value | contains("apiVersion: v1") and contains("clusters:")) | "\(.metadata.namespace)/\(.metadata.name)"'`)
// Pod Logs
content = append(content, `
##############################################
## POD LOGS
##############################################
# Extract sensitive data from application logs`)
if entities := getExfilEntitiesForMethods(methodGroups, "Logs"); len(entities) > 0 {
content = append(content, "# Entities with this permission:")
for _, entity := range entities {
content = append(content, fmt.Sprintf("# - %s", entity))
}
} else {
content = append(content, "# No entities found with log access")
}
content = append(content, `
# Get logs from all pods in a namespace:
for pod in $(kubectl get pods -n NAMESPACE -o name); do echo "=== $pod ==="; kubectl logs $pod -n NAMESPACE --all-containers 2>/dev/null | head -100; done
# Search logs for sensitive patterns:
kubectl logs POD_NAME -n NAMESPACE --all-containers | grep -iE '(password|secret|token|key|credential|error|exception|failed)'
# Get previous container logs (after crash):
kubectl logs POD_NAME -n NAMESPACE --previous
# Stream logs with timestamps:
kubectl logs -f POD_NAME -n NAMESPACE --timestamps
# Get logs from all containers in all pods:
kubectl get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace}{" "}{.metadata.name}{"\n"}{end}' | while read ns pod; do echo "=== $ns/$pod ==="; kubectl logs $pod -n $ns --all-containers 2>/dev/null | grep -iE '(password|secret|token|api.key)' | head -10; done`)
// Pod Exec / Data Extraction
content = append(content, `
##############################################
## POD EXEC / DATA EXTRACTION
##############################################
# Execute commands in containers to extract files and data`)
if entities := getExfilEntitiesForMethods(methodGroups, "Data Extraction", "Etcd Access"); len(entities) > 0 {
content = append(content, "# Entities with this permission:")
for _, entity := range entities {
content = append(content, fmt.Sprintf("# - %s", entity))
}
} else {
content = append(content, "# No entities found with exec permissions")
}
content = append(content, `
# Extract environment variables (often contain secrets):
kubectl exec POD_NAME -n NAMESPACE -- env | sort
# Extract SA token from inside container:
kubectl exec POD_NAME -n NAMESPACE -- cat /var/run/secrets/kubernetes.io/serviceaccount/token
# Extract CA cert and namespace:
kubectl exec POD_NAME -n NAMESPACE -- cat /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
kubectl exec POD_NAME -n NAMESPACE -- cat /var/run/secrets/kubernetes.io/serviceaccount/namespace
# Copy files from container:
kubectl cp NAMESPACE/POD_NAME:/path/to/file ./local-file
# Find and extract sensitive files:
kubectl exec POD_NAME -n NAMESPACE -- find / -name "*.key" -o -name "*.pem" -o -name "*.env" -o -name "*config*" 2>/dev/null
kubectl exec POD_NAME -n NAMESPACE -- cat /app/.env 2>/dev/null
# Extract database connection from common locations:
kubectl exec POD_NAME -n NAMESPACE -- cat /app/config/database.yml 2>/dev/null
kubectl exec POD_NAME -n NAMESPACE -- cat /etc/mysql/my.cnf 2>/dev/null
# Dump process memory for secrets:
kubectl exec POD_NAME -n NAMESPACE -- cat /proc/1/environ | tr '\0' '\n'`)
// Storage / PVC
content = append(content, `
##############################################
## STORAGE / PERSISTENT VOLUMES
##############################################
# Access data stored in persistent volumes`)
if entities := getExfilEntitiesForMethods(methodGroups, "Storage"); len(entities) > 0 {
content = append(content, "# Entities with this permission:")
for _, entity := range entities {
content = append(content, fmt.Sprintf("# - %s", entity))
}
} else {
content = append(content, "# No entities found with storage access")
}
content = append(content, `
# List all PVCs:
kubectl get pvc -A -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,VOLUME:.spec.volumeName,STORAGECLASS:.spec.storageClassName'
# Find PVCs bound to interesting PVs:
kubectl get pv -o json | jq -r '.items[] | select(.spec.hostPath != null) | "\(.metadata.name): hostPath=\(.spec.hostPath.path)"'
# Create a pod to mount and read PVC data:
cat <<'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: pvc-reader
namespace: TARGET_NAMESPACE
spec:
containers:
- name: reader
image: alpine
command: ["sleep", "3600"]
volumeMounts:
- name: data
mountPath: /data
volumes:
- name: data
persistentVolumeClaim:
claimName: TARGET_PVC_NAME
EOF
# Then exec in and browse:
kubectl exec -it pvc-reader -n TARGET_NAMESPACE -- ls -la /data
kubectl exec -it pvc-reader -n TARGET_NAMESPACE -- find /data -type f -name "*.sql" -o -name "*.dump" -o -name "*.bak"`)
// Token Generation
content = append(content, `
##############################################
## TOKEN GENERATION
##############################################
# Generate SA tokens for use outside the cluster`)
if entities := getExfilEntitiesForMethods(methodGroups, "Token Exfil"); len(entities) > 0 {
content = append(content, "# Entities with this permission:")
for _, entity := range entities {
content = append(content, fmt.Sprintf("# - %s", entity))
}
} else {
content = append(content, "# No entities found with token generation permissions")
}
content = append(content, `
# Generate token for a service account:
kubectl create token SA_NAME -n NAMESPACE
# Generate long-lived token (1 year):
kubectl create token SA_NAME -n NAMESPACE --duration=8760h
# Generate token with specific audience:
kubectl create token SA_NAME -n NAMESPACE --audience=https://my-service.example.com
# Use generated token externally:
TOKEN=$(kubectl create token SA_NAME -n NAMESPACE)
curl -sk -H "Authorization: Bearer $TOKEN" https://KUBERNETES_API/api/v1/namespaces`)
// CRD Secrets (cert-manager, external-secrets, vault)
content = append(content, `
##############################################
## CRD-BASED SECRETS
##############################################
# Extract secrets from cert-manager, external-secrets, vault, etc.`)
if entities := getExfilEntitiesForMethods(methodGroups, "CRD Secrets (Certs)", "CRD Secrets (ExtSecrets)", "CRD Secrets (CSI)", "CRD Secrets (Vault)"); len(entities) > 0 {
content = append(content, "# Entities with this permission:")
for _, entity := range entities {
content = append(content, fmt.Sprintf("# - %s", entity))
}
} else {
content = append(content, "# No entities found with CRD secrets access")
}
content = append(content, `
# cert-manager - List certificates:
kubectl get certificates -A -o wide
kubectl get certificates -A -o json | jq -r '.items[] | "\(.metadata.namespace)/\(.metadata.name): secret=\(.spec.secretName)"'
# cert-manager - Extract certificate and key from secret:
kubectl get secret CERT_SECRET -n NAMESPACE -o json | jq -r '.data["tls.crt"]' | base64 -d
kubectl get secret CERT_SECRET -n NAMESPACE -o json | jq -r '.data["tls.key"]' | base64 -d
# external-secrets - List ExternalSecrets:
kubectl get externalsecrets -A -o wide
kubectl get externalsecrets -A -o json | jq -r '.items[] | "\(.metadata.namespace)/\(.metadata.name): target=\(.spec.target.name)"'
# external-secrets - View SecretStore configs (may reveal cloud credentials):
kubectl get secretstores -A -o yaml
kubectl get clustersecretstores -o yaml
# Vault - List VaultAuth configs:
kubectl get vaultauths -A -o yaml
kubectl get vaultconnections -A -o yaml
# secrets-store-csi - List SecretProviderClasses:
kubectl get secretproviderclasses -A -o yaml`)
// Custom Resources
content = append(content, `
##############################################
## CUSTOM RESOURCES
##############################################
# Extract sensitive data from application-specific CRDs`)
if entities := getExfilEntitiesForMethods(methodGroups, "Custom Resources"); len(entities) > 0 {
content = append(content, "# Entities with this permission:")
for _, entity := range entities {
content = append(content, fmt.Sprintf("# - %s", entity))
}
} else {
content = append(content, "# No entities found with custom resource access")
}
content = append(content, `
# List all CRDs in the cluster:
kubectl get crds -o custom-columns='NAME:.metadata.name,GROUP:.spec.group'
# Find CRDs that might contain sensitive data:
kubectl get crds -o name | xargs -I{} kubectl get {} -A -o yaml 2>/dev/null | grep -iE '(password|secret|token|key|credential)' | head -50
# Common sensitive CRDs to check:
kubectl get sealedsecrets -A -o yaml 2>/dev/null # Bitnami Sealed Secrets
kubectl get externalsecrets -A -o yaml 2>/dev/null # External Secrets Operator
kubectl get secretclaims -A -o yaml 2>/dev/null # Crossplane
kubectl get databaseclusters -A -o yaml 2>/dev/null # Database operators`)
return strings.Join(content, "\n")
}
// groupExfilByMethod groups paths by their Method field
func groupExfilByMethod(paths []attackpathservice.AttackPath) map[string][]attackpathservice.AttackPath {
groups := make(map[string][]attackpathservice.AttackPath)
for _, path := range paths {
groups[path.Method] = append(groups[path.Method], path)
}
return groups
}
// getExfilEntitiesForMethods returns unique entities that have any of the specified methods
func getExfilEntitiesForMethods(methodGroups map[string][]attackpathservice.AttackPath, methods ...string) []string {
seen := make(map[string]bool)
var entities []string
for _, method := range methods {
if paths, ok := methodGroups[method]; ok {
for _, path := range paths {
key := fmt.Sprintf("%s:%s", path.PrincipalType, path.Principal)
if !seen[key] {
seen[key] = true
entities = append(entities, fmt.Sprintf("%s (%s) - %s", path.Principal, path.PrincipalType, path.ScopeName))
}
}
}
}
return entities
}
// Helper functions
func countExfilRiskLevels(paths []attackpathservice.AttackPath) *shared.RiskCounts {
counts := shared.NewRiskCounts()
for _, path := range paths {
counts.Add(path.RiskLevel)
}
return counts
}
func filterExfilByCategory(paths []attackpathservice.AttackPath, category string) []attackpathservice.AttackPath {
var filtered []attackpathservice.AttackPath
for _, path := range paths {
if path.Category == category {
filtered = append(filtered, path)
}
}
return filtered
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff. Show more