feat(googleworkspace): add Google Workspace provider with directory service and super admin check (#10022)

This commit is contained in:
lydiavilchez
2026-02-25 12:17:13 +01:00
committed by GitHub
parent 6935c4eb1b
commit 9ee8072572
46 changed files with 2466 additions and 3 deletions

8
.github/labeler.yml vendored
View File

@@ -62,6 +62,11 @@ provider/openstack:
- any-glob-to-any-file: "prowler/providers/openstack/**" - any-glob-to-any-file: "prowler/providers/openstack/**"
- any-glob-to-any-file: "tests/providers/openstack/**" - any-glob-to-any-file: "tests/providers/openstack/**"
provider/googleworkspace:
- changed-files:
- any-glob-to-any-file: "prowler/providers/googleworkspace/**"
- any-glob-to-any-file: "tests/providers/googleworkspace/**"
github_actions: github_actions:
- changed-files: - changed-files:
- any-glob-to-any-file: ".github/workflows/*" - any-glob-to-any-file: ".github/workflows/*"
@@ -83,6 +88,7 @@ mutelist:
- any-glob-to-any-file: "prowler/providers/alibabacloud/lib/mutelist/**" - any-glob-to-any-file: "prowler/providers/alibabacloud/lib/mutelist/**"
- any-glob-to-any-file: "prowler/providers/cloudflare/lib/mutelist/**" - any-glob-to-any-file: "prowler/providers/cloudflare/lib/mutelist/**"
- any-glob-to-any-file: "prowler/providers/openstack/lib/mutelist/**" - any-glob-to-any-file: "prowler/providers/openstack/lib/mutelist/**"
- any-glob-to-any-file: "prowler/providers/googleworkspace/lib/mutelist/**"
- any-glob-to-any-file: "tests/lib/mutelist/**" - any-glob-to-any-file: "tests/lib/mutelist/**"
- any-glob-to-any-file: "tests/providers/aws/lib/mutelist/**" - any-glob-to-any-file: "tests/providers/aws/lib/mutelist/**"
- any-glob-to-any-file: "tests/providers/azure/lib/mutelist/**" - any-glob-to-any-file: "tests/providers/azure/lib/mutelist/**"
@@ -94,6 +100,8 @@ mutelist:
- any-glob-to-any-file: "tests/providers/alibabacloud/lib/mutelist/**" - any-glob-to-any-file: "tests/providers/alibabacloud/lib/mutelist/**"
- any-glob-to-any-file: "tests/providers/cloudflare/lib/mutelist/**" - any-glob-to-any-file: "tests/providers/cloudflare/lib/mutelist/**"
- any-glob-to-any-file: "tests/providers/openstack/lib/mutelist/**" - any-glob-to-any-file: "tests/providers/openstack/lib/mutelist/**"
- any-glob-to-any-file: "prowler/providers/googleworkspace/lib/mutelist/**"
- any-glob-to-any-file: "tests/providers/googleworkspace/lib/mutelist/**"
integration/s3: integration/s3:
- changed-files: - changed-files:

View File

@@ -438,6 +438,30 @@ jobs:
flags: prowler-py${{ matrix.python-version }}-openstack flags: prowler-py${{ matrix.python-version }}-openstack
files: ./openstack_coverage.xml files: ./openstack_coverage.xml
# Google Workspace Provider
- name: Check if Google Workspace files changed
if: steps.check-changes.outputs.any_changed == 'true'
id: changed-googleworkspace
uses: tj-actions/changed-files@e0021407031f5be11a464abee9a0776171c79891 # v47.0.1
with:
files: |
./prowler/**/googleworkspace/**
./tests/**/googleworkspace/**
./poetry.lock
- name: Run Google Workspace tests
if: steps.changed-googleworkspace.outputs.any_changed == 'true'
run: poetry run pytest -n auto --cov=./prowler/providers/googleworkspace --cov-report=xml:googleworkspace_coverage.xml tests/providers/googleworkspace
- name: Upload Google Workspace coverage to Codecov
if: steps.changed-googleworkspace.outputs.any_changed == 'true'
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:
flags: prowler-py${{ matrix.python-version }}-googleworkspace
files: ./googleworkspace_coverage.xml
# Lib # Lib
- name: Check if Lib files changed - name: Check if Lib files changed
if: steps.check-changes.outputs.any_changed == 'true' if: steps.check-changes.outputs.any_changed == 'true'

View File

@@ -228,6 +228,13 @@
"user-guide/providers/microsoft365/use-of-powershell" "user-guide/providers/microsoft365/use-of-powershell"
] ]
}, },
{
"group": "Google Workspace",
"pages": [
"user-guide/providers/googleworkspace/getting-started-googleworkspace",
"user-guide/providers/googleworkspace/authentication"
]
},
{ {
"group": "GitHub", "group": "GitHub",
"pages": [ "pages": [

View File

@@ -0,0 +1,156 @@
---
title: 'Google Workspace Authentication in Prowler'
---
Prowler for Google Workspace uses a **Service Account with Domain-Wide Delegation** to authenticate to the Google Workspace Admin SDK. This allows Prowler to read directory data on behalf of a super administrator without requiring an interactive login.
## Required OAuth Scopes
Prowler requests the following read-only OAuth 2.0 scopes from the Google Workspace Admin SDK:
| Scope | Description |
|-------|-------------|
| `https://www.googleapis.com/auth/admin.directory.user.readonly` | Read access to user accounts and their admin status |
| `https://www.googleapis.com/auth/admin.directory.domain.readonly` | Read access to domain information |
| `https://www.googleapis.com/auth/admin.directory.customer.readonly` | Read access to customer information (Customer ID) |
<Warning>
The delegated user must be a **super administrator** in your Google Workspace organization. Using a non-admin account will result in permission errors when accessing the Admin SDK.
</Warning>
## Setup Steps
### Step 1: Create a GCP Project (if needed)
If you don't have a GCP project, create one at [https://console.cloud.google.com](https://console.cloud.google.com).
The project is only used to host the Service Account — it does not need to have any Google Workspace data in it.
### Step 2: Enable the Admin SDK API
1. Go to the [Google Cloud Console](https://console.cloud.google.com)
2. Select your project
3. Navigate to **APIs & Services → Library**
4. Search for **Admin SDK API**
5. Click **Enable**
### Step 3: Create a Service Account
1. In the Google Cloud Console, navigate to **IAM & Admin → Service Accounts**
2. Click **Create Service Account**
3. Give it a descriptive name (e.g., `prowler-googleworkspace-reader`)
4. Click **Create and Continue**
5. Skip the optional role and user access steps — click **Done**
<Note>
The Service Account does not need any GCP IAM roles. Its access to Google Workspace is granted entirely through Domain-Wide Delegation in the next steps.
</Note>
### Step 4: Generate a JSON Key
1. Click on the Service Account you just created
2. Go to the **Keys** tab
3. Click **Add Key → Create new key**
4. Select **JSON** format
5. Click **Create** — the key file will download automatically
6. Store it securely (e.g., `~/.config/prowler/googleworkspace-sa.json`)
<Warning>
This JSON key grants access to your Google Workspace organization. Never commit it to version control, share it in plain text, or store it in an insecure location.
</Warning>
### Step 5: Configure Domain-Wide Delegation in Google Workspace
1. Go to the [Google Workspace Admin Console](https://admin.google.com)
2. Navigate to **Security → Access and data control → API controls**
3. Click **Manage Domain Wide Delegation**
4. Click **Add new**
5. Enter the **Client ID** of the Service Account (found in the JSON key as `client_id`, or on the Service Account details page)
6. In the **OAuth scopes** field, enter the following scopes as a comma-separated list:
```
https://www.googleapis.com/auth/admin.directory.user.readonly,https://www.googleapis.com/auth/admin.directory.domain.readonly,https://www.googleapis.com/auth/admin.directory.customer.readonly
```
7. Click **Authorize**
<Note>
Domain-Wide Delegation must be configured by a Google Workspace **super administrator**. It may take a few minutes to propagate after saving.
</Note>
### Step 6: Store Credentials Securely
Set your credentials as environment variables:
```bash
export GOOGLEWORKSPACE_CREDENTIALS_FILE="/path/to/googleworkspace-sa.json"
export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com"
```
Alternatively, if you need to pass credentials as a string (e.g., in CI/CD pipelines):
```bash
export GOOGLEWORKSPACE_CREDENTIALS_CONTENT=$(cat /path/to/googleworkspace-sa.json)
export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com"
```
## Credential Lookup Order
Prowler resolves credentials in the following order:
1. `GOOGLEWORKSPACE_CREDENTIALS_FILE` environment variable
2. `GOOGLEWORKSPACE_CREDENTIALS_CONTENT` environment variable
The delegated user must be provided via the `GOOGLEWORKSPACE_DELEGATED_USER` environment variable.
## Best Practices
- **Use environment variables** — Never hardcode credentials in scripts or commands
- **Use a dedicated Service Account** — Create one specifically for Prowler, separate from other integrations
- **Use read-only scopes** — Prowler only requires the three read-only scopes listed above
- **Restrict key access** — Set file permissions to `600` on the JSON key file
- **Rotate keys regularly** — Delete and regenerate the JSON key periodically
- **Use a least-privilege super admin** — Consider using a dedicated super admin account for Prowler's delegated user rather than a personal admin account
```bash
# Secure the key file
chmod 600 /path/to/googleworkspace-sa.json
```
## Troubleshooting
### `GoogleWorkspaceMissingDelegatedUserError`
The delegated user email was not provided. Set it via environment variable:
```bash
export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com"
```
### `GoogleWorkspaceNoCredentialsError`
No credentials were found. Ensure either `GOOGLEWORKSPACE_CREDENTIALS_FILE` or `GOOGLEWORKSPACE_CREDENTIALS_CONTENT` is set.
### `GoogleWorkspaceInvalidCredentialsError`
The JSON key file is malformed or cannot be parsed. Verify the file was downloaded correctly and is valid JSON:
```bash
python3 -c "import json; json.load(open('/path/to/key.json'))" && echo "Valid JSON"
```
### `GoogleWorkspaceImpersonationError`
The Service Account cannot impersonate the delegated user. This usually means Domain-Wide Delegation has not been configured, or the OAuth scopes are incorrect. Verify:
- The Service Account Client ID is correctly entered in the Admin Console
- All three required OAuth scopes are included
- The delegated user is a super administrator
### Permission Denied on Admin SDK calls
If Prowler connects but returns empty results or permission errors for specific API calls:
- Confirm Domain-Wide Delegation is fully propagated (wait a few minutes after setup)
- Verify all three scopes are authorized in the Admin Console
- Ensure the delegated user is an active super administrator

View File

@@ -0,0 +1,100 @@
---
title: 'Getting Started with Google Workspace'
---
import { VersionBadge } from "/snippets/version-badge.mdx";
<VersionBadge version="5.19.0" />
Prowler for Google Workspace allows you to audit your organization's Google Workspace environment for security misconfigurations, including super administrator account hygiene, domain settings, and more.
## Prerequisites
Before running Prowler with the Google Workspace provider, ensure you have:
1. A Google Workspace account with super administrator privileges
2. A Google Cloud Platform (GCP) project to host the Service Account
3. Authentication configured (see [Authentication](/user-guide/providers/googleworkspace/authentication)):
- A **Service Account JSON key** from a GCP project with Domain-Wide Delegation enabled
## Quick Start
### Step 1: Set Up Authentication
Set your Service Account credentials file path and delegated user email as environment variables:
```bash
export GOOGLEWORKSPACE_CREDENTIALS_FILE="/path/to/service-account-key.json"
export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com"
```
### Step 2: Run Prowler
```bash
prowler googleworkspace
```
Prowler will authenticate as the delegated user and run all available security checks against your Google Workspace organization.
## Authentication
Prowler uses a **Service Account with Domain-Wide Delegation** to authenticate to Google Workspace. This requires:
- A Service Account created in a GCP project
- The Admin SDK API enabled in that project
- Domain-Wide Delegation configured in the Google Workspace Admin Console
- A super admin user email to impersonate
### Using Environment Variables (Recommended)
```bash
export GOOGLEWORKSPACE_CREDENTIALS_FILE="/path/to/service-account-key.json"
export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com"
prowler googleworkspace
```
Alternatively, pass the credentials content directly as a JSON string:
```bash
export GOOGLEWORKSPACE_CREDENTIALS_CONTENT='{"type": "service_account", ...}'
export GOOGLEWORKSPACE_DELEGATED_USER="admin@yourdomain.com"
prowler googleworkspace
```
<Note>
The delegated user must be a super admin email in your Google Workspace organization. The service account credentials must be provided via environment variables (`GOOGLEWORKSPACE_CREDENTIALS_FILE` or `GOOGLEWORKSPACE_CREDENTIALS_CONTENT`).
</Note>
## Understanding the Output
When Prowler runs successfully, it will display the credentials being used:
```
Using the Google Workspace credentials below:
┌─────────────────────────────────────────────────────────┐
│ Google Workspace Domain: yourdomain.com │
│ Customer ID: C0xxxxxxx │
│ Delegated User: admin@yourdomain.com │
│ Authentication Method: Service Account with Domain-Wide │
│ Delegation │
└─────────────────────────────────────────────────────────┘
```
Findings are reported per check. For example, the `directory_super_admin_count` check verifies the number of super administrators is within a recommended range (24):
- **PASS** — 2 to 4 super administrators found
- **FAIL** — 0 or 1 (single point of failure) or 5+ (excessive privilege exposure)
Output files are saved in the configured output directory (default: `output/`) in CSV, JSON-OCSF, and HTML formats.
## Configuration
Prowler uses a configuration file to customize provider behavior. To use a custom configuration:
```bash
prowler googleworkspace --config-file /path/to/config.yaml
```
## Next Steps
- [Authentication](/user-guide/providers/googleworkspace/authentication) — Detailed guide on setting up a Service Account and Domain-Wide Delegation

View File

@@ -6,6 +6,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 🚀 Added ### 🚀 Added
- `Google Workspace` provider support with Directory service including 1 security check [(#10022)](https://github.com/prowler-cloud/prowler/pull/10022)
- `entra_app_enforced_restrictions` check for M365 provider [(#10058)](https://github.com/prowler-cloud/prowler/pull/10058) - `entra_app_enforced_restrictions` check for M365 provider [(#10058)](https://github.com/prowler-cloud/prowler/pull/10058)
- `entra_app_registration_no_unused_privileged_permissions` check for m365 provider [(#10080)](https://github.com/prowler-cloud/prowler/pull/10080) - `entra_app_registration_no_unused_privileged_permissions` check for m365 provider [(#10080)](https://github.com/prowler-cloud/prowler/pull/10080)
- `defenderidentity_health_issues_no_open` check for M365 provider [(#10087)](https://github.com/prowler-cloud/prowler/pull/10087) - `defenderidentity_health_issues_no_open` check for M365 provider [(#10087)](https://github.com/prowler-cloud/prowler/pull/10087)

View File

@@ -128,6 +128,7 @@ from prowler.providers.common.provider import Provider
from prowler.providers.common.quick_inventory import run_provider_quick_inventory from prowler.providers.common.quick_inventory import run_provider_quick_inventory
from prowler.providers.gcp.models import GCPOutputOptions from prowler.providers.gcp.models import GCPOutputOptions
from prowler.providers.github.models import GithubOutputOptions from prowler.providers.github.models import GithubOutputOptions
from prowler.providers.googleworkspace.models import GoogleWorkspaceOutputOptions
from prowler.providers.iac.models import IACOutputOptions from prowler.providers.iac.models import IACOutputOptions
from prowler.providers.image.exceptions.exceptions import ImageBaseException from prowler.providers.image.exceptions.exceptions import ImageBaseException
from prowler.providers.image.models import ImageOutputOptions from prowler.providers.image.models import ImageOutputOptions
@@ -354,6 +355,10 @@ def prowler():
output_options = M365OutputOptions( output_options = M365OutputOptions(
args, bulk_checks_metadata, global_provider.identity args, bulk_checks_metadata, global_provider.identity
) )
elif provider == "googleworkspace":
output_options = GoogleWorkspaceOutputOptions(
args, bulk_checks_metadata, global_provider.identity
)
elif provider == "mongodbatlas": elif provider == "mongodbatlas":
output_options = MongoDBAtlasOutputOptions( output_options = MongoDBAtlasOutputOptions(
args, bulk_checks_metadata, global_provider.identity args, bulk_checks_metadata, global_provider.identity

View File

@@ -57,6 +57,7 @@ class Provider(str, Enum):
KUBERNETES = "kubernetes" KUBERNETES = "kubernetes"
M365 = "m365" M365 = "m365"
GITHUB = "github" GITHUB = "github"
GOOGLEWORKSPACE = "googleworkspace"
IAC = "iac" IAC = "iac"
NHN = "nhn" NHN = "nhn"
MONGODBATLAS = "mongodbatlas" MONGODBATLAS = "mongodbatlas"

View File

@@ -0,0 +1,32 @@
### Account, Check and/or Region can be * to apply for all the cases.
### Account == Google Workspace Customer ID and Region == * (Google Workspace is a global service)
### Resources and tags are lists that can have either Regex or Keywords.
### Tags is an optional list that matches on tuples of 'key=value' and are "ANDed" together.
### Use an alternation Regex to match one of multiple tags with "ORed" logic.
### For each check you can except Accounts, Regions, Resources and/or Tags.
########################### MUTELIST EXAMPLE ###########################
Mutelist:
Accounts:
"C1234567":
Checks:
"directory_super_admin_count":
Regions:
- "*"
Resources:
- "example.com" # Will ignore example.com domain in check directory_super_admin_count
Description: "Super admin count check muted for example.com during planned admin account restructuring"
"directory_*":
Regions:
- "*"
Resources:
- "*" # Will ignore every Directory check for Customer ID C1234567
"*":
Checks:
"*":
Regions:
- "*"
Resources:
- "test"
Tags:
- "test=test" # Will ignore every resource containing the string "test" and the tag 'test=test' in every account

View File

@@ -720,6 +720,45 @@ class CheckReportGithub(Check_Report):
) )
@dataclass
class CheckReportGoogleWorkspace(Check_Report):
"""Contains the Google Workspace Check's finding information."""
resource_name: str
resource_id: str
customer_id: str
location: str
def __init__(
self,
metadata: Dict,
resource: Any,
resource_name: str = None,
resource_id: str = None,
customer_id: str = None,
location: str = "global",
) -> None:
"""Initialize the Google Workspace Check's finding information.
Args:
metadata: The metadata of the check.
resource: Basic information about the resource. Defaults to None.
resource_name: The name of the resource related with the finding.
resource_id: The id of the resource related with the finding.
customer_id: The Google Workspace customer ID.
location: The location of the resource (default: "global").
"""
super().__init__(metadata, resource)
self.resource_name = (
resource_name
or getattr(resource, "email", "")
or getattr(resource, "name", "")
)
self.resource_id = resource_id or getattr(resource, "id", "")
self.customer_id = customer_id or getattr(resource, "customer_id", "")
self.location = location
@dataclass @dataclass
class CheckReportCloudflare(Check_Report): class CheckReportCloudflare(Check_Report):
"""Contains the Cloudflare Check's finding information. """Contains the Cloudflare Check's finding information.

View File

@@ -27,16 +27,17 @@ class ProwlerArgumentParser:
self.parser = argparse.ArgumentParser( self.parser = argparse.ArgumentParser(
prog="prowler", prog="prowler",
formatter_class=RawTextHelpFormatter, formatter_class=RawTextHelpFormatter,
usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,dashboard,iac,image} ...", usage="prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,dashboard,iac,image} ...",
epilog=""" epilog="""
Available Cloud Providers: Available Cloud Providers:
{aws,azure,gcp,kubernetes,m365,github,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack} {aws,azure,gcp,kubernetes,m365,github,googleworkspace,iac,llm,image,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack}
aws AWS Provider aws AWS Provider
azure Azure Provider azure Azure Provider
gcp GCP Provider gcp GCP Provider
kubernetes Kubernetes Provider kubernetes Kubernetes Provider
m365 Microsoft 365 Provider m365 Microsoft 365 Provider
github GitHub Provider github GitHub Provider
googleworkspace Google Workspace Provider
cloudflare Cloudflare Provider cloudflare Cloudflare Provider
oraclecloud Oracle Cloud Infrastructure Provider oraclecloud Oracle Cloud Infrastructure Provider
openstack OpenStack Provider openstack OpenStack Provider

View File

@@ -278,6 +278,20 @@ class Finding(BaseModel):
output_data["resource_uid"] = check_output.resource_id output_data["resource_uid"] = check_output.resource_id
output_data["region"] = check_output.location output_data["region"] = check_output.location
elif provider.type == "googleworkspace":
output_data["auth_method"] = (
f"service_account: {provider.identity.delegated_user}"
)
output_data["account_uid"] = get_nested_attribute(
provider, "identity.customer_id"
)
output_data["account_name"] = get_nested_attribute(
provider, "identity.domain"
)
output_data["resource_name"] = check_output.resource_name
output_data["resource_uid"] = check_output.resource_id
output_data["region"] = check_output.location
elif provider.type == "mongodbatlas": elif provider.type == "mongodbatlas":
output_data["auth_method"] = "api_key" output_data["auth_method"] = "api_key"
output_data["account_uid"] = get_nested_attribute( output_data["account_uid"] = get_nested_attribute(

View File

@@ -1282,6 +1282,55 @@ class HTML(Output):
) )
return "" return ""
@staticmethod
def get_googleworkspace_assessment_summary(provider: Provider) -> str:
"""
get_googleworkspace_assessment_summary gets the HTML assessment summary for the Google Workspace provider
Args:
provider (Provider): the Google Workspace provider object
Returns:
str: HTML assessment summary for the Google Workspace provider
"""
try:
return f"""
<div class="col-md-2">
<div class="card">
<div class="card-header">
Google Workspace Assessment Summary
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>Domain:</b> {provider.identity.domain}
</li>
<li class="list-group-item">
<b>Customer ID:</b> {provider.identity.customer_id}
</li>
</ul>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
Google Workspace Credentials
</div>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<b>Delegated User:</b> {provider.identity.delegated_user}
</li>
<li class="list-group-item">
<b>Authentication Method:</b> Service Account with Domain-Wide Delegation
</li>
</ul>
</div>
</div>"""
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
)
return ""
@staticmethod @staticmethod
def get_assessment_summary(provider: Provider) -> str: def get_assessment_summary(provider: Provider) -> str:
""" """

View File

@@ -36,6 +36,8 @@ def stdout_report(finding, color, verbose, status, fix):
details = finding.region details = finding.region
if finding.check_metadata.Provider == "cloudflare": if finding.check_metadata.Provider == "cloudflare":
details = finding.zone_name details = finding.zone_name
if finding.check_metadata.Provider == "googleworkspace":
details = finding.location
if (verbose or fix) and (not status or finding.status in status): if (verbose or fix) and (not status or finding.status in status):
if finding.muted: if finding.muted:

View File

@@ -51,6 +51,9 @@ def display_summary_table(
elif provider.type == "m365": elif provider.type == "m365":
entity_type = "Tenant Domain" entity_type = "Tenant Domain"
audited_entities = provider.identity.tenant_domain audited_entities = provider.identity.tenant_domain
elif provider.type == "googleworkspace":
entity_type = "Domain"
audited_entities = provider.identity.domain
elif provider.type == "mongodbatlas": elif provider.type == "mongodbatlas":
entity_type = "Organization" entity_type = "Organization"
audited_entities = provider.identity.organization_name audited_entities = provider.identity.organization_name

View File

@@ -248,6 +248,12 @@ class Provider(ABC):
repositories=arguments.repository, repositories=arguments.repository,
organizations=arguments.organization, organizations=arguments.organization,
) )
elif "googleworkspace" in provider_class_name.lower():
provider_class(
config_path=arguments.config_file,
mutelist_path=arguments.mutelist_file,
fixer_config=fixer_config,
)
elif "cloudflare" in provider_class_name.lower(): elif "cloudflare" in provider_class_name.lower():
provider_class( provider_class(
filter_zones=arguments.region, filter_zones=arguments.region,

View File

@@ -0,0 +1,119 @@
from prowler.exceptions.exceptions import ProwlerException
# Exceptions codes from 12000 to 12999 are reserved for Google Workspace exceptions
class GoogleWorkspaceBaseException(ProwlerException):
"""Base class for Google Workspace Errors."""
GOOGLEWORKSPACE_ERROR_CODES = {
(12000, "GoogleWorkspaceEnvironmentVariableError"): {
"message": "Google Workspace environment variable error",
"remediation": "Check the Google Workspace environment variables and ensure they are properly set.",
},
(12001, "GoogleWorkspaceNoCredentialsError"): {
"message": "Google Workspace credentials are required to authenticate",
"remediation": "Set the GOOGLEWORKSPACE_CREDENTIALS_FILE or GOOGLEWORKSPACE_CREDENTIALS_CONTENT environment variable with a valid Service Account JSON.",
},
(12002, "GoogleWorkspaceInvalidCredentialsError"): {
"message": "Google Workspace credentials provided are not valid",
"remediation": "Check the Service Account credentials and ensure they are valid.",
},
(12003, "GoogleWorkspaceSetUpSessionError"): {
"message": "Error setting up Google Workspace session",
"remediation": "Check the session setup and ensure credentials are properly configured.",
},
(12004, "GoogleWorkspaceSetUpIdentityError"): {
"message": "Google Workspace identity setup error due to bad credentials or API access",
"remediation": "Check credentials and ensure the Service Account has proper API access and Domain-Wide Delegation configured.",
},
(12005, "GoogleWorkspaceImpersonationError"): {
"message": "Error impersonating user with Domain-Wide Delegation",
"remediation": "Ensure the Service Account has Domain-Wide Delegation enabled and the delegated user email is correct.",
},
(12006, "GoogleWorkspaceMissingDelegatedUserError"): {
"message": "Delegated user email is required for Domain-Wide Delegation",
"remediation": "Set the GOOGLEWORKSPACE_DELEGATED_USER environment variable with a valid super admin email from your domain.",
},
(12007, "GoogleWorkspaceInsufficientScopesError"): {
"message": "Service Account does not have required OAuth scopes",
"remediation": "Ensure the Service Account has the required scopes configured in Domain-Wide Delegation settings.",
},
}
def __init__(self, code, file=None, original_exception=None, message=None):
provider = "GoogleWorkspace"
error_info = self.GOOGLEWORKSPACE_ERROR_CODES.get(
(code, self.__class__.__name__)
)
if message:
error_info["message"] = message
super().__init__(
code=code,
source=provider,
file=file,
original_exception=original_exception,
error_info=error_info,
)
class GoogleWorkspaceCredentialsError(GoogleWorkspaceBaseException):
"""Base class for Google Workspace credentials errors."""
def __init__(self, code, file=None, original_exception=None, message=None):
super().__init__(code, file, original_exception, message)
class GoogleWorkspaceEnvironmentVariableError(GoogleWorkspaceCredentialsError):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
12000, file=file, original_exception=original_exception, message=message
)
class GoogleWorkspaceNoCredentialsError(GoogleWorkspaceCredentialsError):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
12001, file=file, original_exception=original_exception, message=message
)
class GoogleWorkspaceInvalidCredentialsError(GoogleWorkspaceCredentialsError):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
12002, file=file, original_exception=original_exception, message=message
)
class GoogleWorkspaceSetUpSessionError(GoogleWorkspaceCredentialsError):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
12003, file=file, original_exception=original_exception, message=message
)
class GoogleWorkspaceSetUpIdentityError(GoogleWorkspaceCredentialsError):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
12004, file=file, original_exception=original_exception, message=message
)
class GoogleWorkspaceImpersonationError(GoogleWorkspaceCredentialsError):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
12005, file=file, original_exception=original_exception, message=message
)
class GoogleWorkspaceMissingDelegatedUserError(GoogleWorkspaceCredentialsError):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
12006, file=file, original_exception=original_exception, message=message
)
class GoogleWorkspaceInsufficientScopesError(GoogleWorkspaceCredentialsError):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
12007, file=file, original_exception=original_exception, message=message
)

View File

@@ -0,0 +1,527 @@
import json
import logging
import os
import re
from os import environ
from colorama import Fore, Style
from google.oauth2 import service_account
from googleapiclient.discovery import build
from prowler.config.config import (
default_config_file_path,
get_default_mute_file_path,
load_and_validate_config_file,
)
from prowler.lib.logger import logger
from prowler.lib.utils.utils import print_boxes
from prowler.providers.common.models import Audit_Metadata, Connection
from prowler.providers.common.provider import Provider
from prowler.providers.googleworkspace.exceptions.exceptions import (
GoogleWorkspaceImpersonationError,
GoogleWorkspaceInsufficientScopesError,
GoogleWorkspaceInvalidCredentialsError,
GoogleWorkspaceMissingDelegatedUserError,
GoogleWorkspaceNoCredentialsError,
GoogleWorkspaceSetUpIdentityError,
)
from prowler.providers.googleworkspace.lib.mutelist.mutelist import (
GoogleWorkspaceMutelist,
)
from prowler.providers.googleworkspace.models import (
GoogleWorkspaceIdentityInfo,
GoogleWorkspaceSession,
)
class GoogleworkspaceProvider(Provider):
"""
Google Workspace Provider class
This class is responsible for setting up the Google Workspace provider, including the session,
identity, audit configuration, fixer configuration, and mutelist.
Attributes:
_type (str): The type of the provider.
_session (GoogleWorkspaceSession): The session object for the provider.
_identity (GoogleWorkspaceIdentityInfo): The identity information for the provider.
_audit_config (dict): The audit configuration for the provider.
_fixer_config (dict): The fixer configuration for the provider.
_mutelist (GoogleWorkspaceMutelist): The mutelist for the provider.
audit_metadata (Audit_Metadata): The audit metadata for the provider.
"""
_type: str = "googleworkspace"
_session: GoogleWorkspaceSession
_identity: GoogleWorkspaceIdentityInfo
_audit_config: dict
_mutelist: GoogleWorkspaceMutelist
audit_metadata: Audit_Metadata
# Google Workspace Admin SDK OAuth2 scopes
DIRECTORY_SCOPES = [
"https://www.googleapis.com/auth/admin.directory.user.readonly",
"https://www.googleapis.com/auth/admin.directory.domain.readonly",
"https://www.googleapis.com/auth/admin.directory.customer.readonly",
]
def __init__(
self,
# Authentication credentials
credentials_file: str = None,
credentials_content: str = None,
delegated_user: str = None,
# Provider configuration
config_path: str = None,
config_content: dict = None,
fixer_config: dict = None,
mutelist_path: str = None,
mutelist_content: dict = None,
):
"""
Google Workspace Provider constructor
Args:
credentials_file (str): Path to Service Account JSON credentials file.
credentials_content (str): Service Account JSON credentials as a string.
delegated_user (str): Email of the user to impersonate via Domain-Wide Delegation.
config_path (str): Path to the audit configuration file.
config_content (dict): Audit configuration content.
fixer_config (dict): Fixer configuration content.
mutelist_path (str): Path to the mutelist file.
mutelist_content (dict): Mutelist content.
"""
logger.info("Instantiating Google Workspace Provider...")
# Mute Google API library logs to reduce noise
logging.getLogger("googleapiclient.discovery").setLevel(logging.ERROR)
logging.getLogger("googleapiclient.discovery_cache").setLevel(logging.ERROR)
self._session, resolved_delegated_user = GoogleworkspaceProvider.setup_session(
credentials_file,
credentials_content,
delegated_user,
)
self._identity = GoogleworkspaceProvider.setup_identity(
self._session,
resolved_delegated_user,
)
# Audit Config
if config_content:
self._audit_config = config_content
else:
if not config_path:
config_path = default_config_file_path
self._audit_config = load_and_validate_config_file(self._type, config_path)
# Fixer Config
self._fixer_config = fixer_config or {}
# Mutelist
if mutelist_content:
self._mutelist = GoogleWorkspaceMutelist(
mutelist_content=mutelist_content,
)
else:
if not mutelist_path:
mutelist_path = get_default_mute_file_path(self.type)
self._mutelist = GoogleWorkspaceMutelist(
mutelist_path=mutelist_path,
)
Provider.set_global_provider(self)
@property
def session(self):
"""Returns the session object for the Google Workspace provider."""
return self._session
@property
def identity(self):
"""Returns the identity information for the Google Workspace provider."""
return self._identity
@property
def type(self):
"""Returns the type of the Google Workspace provider."""
return self._type
@property
def audit_config(self):
return self._audit_config
@property
def fixer_config(self):
return self._fixer_config
@property
def mutelist(self) -> GoogleWorkspaceMutelist:
"""
mutelist method returns the provider's mutelist.
"""
return self._mutelist
@staticmethod
def setup_session(
credentials_file: str = None,
credentials_content: str = None,
delegated_user: str = None,
) -> tuple[GoogleWorkspaceSession, str]:
"""
Sets up the Google Workspace session with Service Account and Domain-Wide Delegation.
Args:
credentials_file (str): Path to Service Account JSON credentials file.
credentials_content (str): Service Account JSON credentials as a string.
delegated_user (str): Email of the user to impersonate via Domain-Wide Delegation.
Returns:
tuple[GoogleWorkspaceSession, str]: Tuple containing the authenticated session and resolved delegated user email.
Raises:
GoogleWorkspaceNoCredentialsError: If no credentials are provided.
GoogleWorkspaceMissingDelegatedUserError: If delegated_user is not provided.
GoogleWorkspaceInvalidCredentialsError: If credentials are invalid.
GoogleWorkspaceImpersonationError: If impersonation fails.
GoogleWorkspaceSetUpSessionError: If session setup fails.
"""
# Check if delegated_user is provided (required for Domain-Wide Delegation)
if not delegated_user:
# Try environment variable
delegated_user = environ.get("GOOGLEWORKSPACE_DELEGATED_USER", "")
if not delegated_user:
raise GoogleWorkspaceMissingDelegatedUserError(
file=os.path.basename(__file__),
message="Delegated user email is required for Domain-Wide Delegation authentication",
)
# Validate email format with regex
email_pattern = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
if not email_pattern.match(delegated_user):
raise GoogleWorkspaceInvalidCredentialsError(
file=os.path.basename(__file__),
message=f"Invalid delegated user email format: {delegated_user}. Must be a valid email address.",
)
# Determine credentials source
if credentials_file:
logger.info(
f"Using Service Account credentials from file: {credentials_file}"
)
try:
credentials = service_account.Credentials.from_service_account_file(
credentials_file,
scopes=GoogleworkspaceProvider.DIRECTORY_SCOPES,
)
except FileNotFoundError as error:
raise GoogleWorkspaceInvalidCredentialsError(
file=os.path.basename(__file__),
original_exception=error,
message=f"Credentials file not found: {credentials_file}",
)
except ValueError as error:
raise GoogleWorkspaceInvalidCredentialsError(
file=os.path.basename(__file__),
original_exception=error,
message=f"Invalid service account credentials file: {credentials_file}",
)
elif credentials_content:
logger.info("Using Service Account credentials from content")
try:
credentials_data = json.loads(credentials_content)
except json.JSONDecodeError as error:
raise GoogleWorkspaceInvalidCredentialsError(
file=os.path.basename(__file__),
original_exception=error,
message="Invalid JSON in credentials content",
)
try:
credentials = service_account.Credentials.from_service_account_info(
credentials_data,
scopes=GoogleworkspaceProvider.DIRECTORY_SCOPES,
)
except ValueError as error:
raise GoogleWorkspaceInvalidCredentialsError(
file=os.path.basename(__file__),
original_exception=error,
message="Invalid service account credentials in content",
)
else:
# Try environment variables
logger.info(
"Looking for GOOGLEWORKSPACE_CREDENTIALS_FILE or GOOGLEWORKSPACE_CREDENTIALS_CONTENT environment variables..."
)
env_file = environ.get("GOOGLEWORKSPACE_CREDENTIALS_FILE", "")
env_content = environ.get("GOOGLEWORKSPACE_CREDENTIALS_CONTENT", "")
if env_file:
logger.info(
f"Using Service Account credentials from environment variable file: {env_file}"
)
try:
credentials = service_account.Credentials.from_service_account_file(
env_file,
scopes=GoogleworkspaceProvider.DIRECTORY_SCOPES,
)
except FileNotFoundError as error:
raise GoogleWorkspaceInvalidCredentialsError(
file=os.path.basename(__file__),
original_exception=error,
message=f"Credentials file not found: {env_file}",
)
except ValueError as error:
raise GoogleWorkspaceInvalidCredentialsError(
file=os.path.basename(__file__),
original_exception=error,
message=f"Invalid service account credentials file: {env_file}",
)
elif env_content:
logger.info(
"Using Service Account credentials from environment variable content"
)
try:
credentials_data = json.loads(env_content)
except json.JSONDecodeError as error:
raise GoogleWorkspaceInvalidCredentialsError(
file=os.path.basename(__file__),
original_exception=error,
message="Invalid JSON in GOOGLEWORKSPACE_CREDENTIALS_CONTENT",
)
try:
credentials = service_account.Credentials.from_service_account_info(
credentials_data,
scopes=GoogleworkspaceProvider.DIRECTORY_SCOPES,
)
except ValueError as error:
raise GoogleWorkspaceInvalidCredentialsError(
file=os.path.basename(__file__),
original_exception=error,
message="Invalid service account credentials in GOOGLEWORKSPACE_CREDENTIALS_CONTENT",
)
else:
raise GoogleWorkspaceNoCredentialsError(
file=os.path.basename(__file__),
message="No credentials provided. Set the GOOGLEWORKSPACE_CREDENTIALS_FILE or GOOGLEWORKSPACE_CREDENTIALS_CONTENT environment variable.",
)
# Perform Domain-Wide Delegation impersonation
logger.info(f"Impersonating user: {delegated_user}")
# Note: with_subject() never fails - it just creates an object
# We need to verify the delegation actually works by making an API call
delegated_credentials = credentials.with_subject(delegated_user)
# Test the delegation by making an actual API call to verify it works
try:
test_service = build(
"admin",
"directory_v1",
credentials=delegated_credentials,
cache_discovery=False,
)
# Try to get the delegated user's info to verify delegation works
test_service.users().get(userKey=delegated_user).execute()
logger.info(f"Domain-Wide Delegation verified for user: {delegated_user}")
except Exception as error:
# Check if it's a permission/delegation error
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
)
error_message = str(error).lower()
if (
"403" in str(error)
or "forbidden" in error_message
or "insufficient" in error_message
or "unauthorized" in error_message
):
raise GoogleWorkspaceInsufficientScopesError(
file=os.path.basename(__file__),
original_exception=error,
message=f"Domain-Wide Delegation is not configured or user {delegated_user} lacks required permissions. Ensure the Service Account Client ID is authorized in Google Workspace Admin Console with the required OAuth scopes.",
)
else:
raise GoogleWorkspaceImpersonationError(
file=os.path.basename(__file__),
original_exception=error,
message=f"Failed to verify delegation for user {delegated_user}: {error}",
)
session = GoogleWorkspaceSession(credentials=delegated_credentials)
return session, delegated_user
@staticmethod
def setup_identity(
session: GoogleWorkspaceSession,
delegated_user: str,
) -> GoogleWorkspaceIdentityInfo:
"""
Retrieves Google Workspace identity information using the Admin SDK.
Args:
session (GoogleWorkspaceSession): The authenticated session.
delegated_user (str): The delegated user email.
Returns:
GoogleWorkspaceIdentityInfo: Identity information including domain and customer ID.
Raises:
GoogleWorkspaceSetUpIdentityError: If identity setup fails.
"""
# Build the Admin SDK Directory service
try:
service = build(
"admin",
"directory_v1",
credentials=session.credentials,
cache_discovery=False,
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
)
raise GoogleWorkspaceSetUpIdentityError(
file=os.path.basename(__file__),
original_exception=error,
message=f"Failed to build Admin SDK service. Ensure the Admin SDK API is enabled: {error}",
)
# Extract domain from delegated user email for validation
# (email format already validated in setup_session)
user_domain = delegated_user.split("@")[-1]
# Fetch customer information using the Directory API
# This validates that the delegated user belongs to a Google Workspace domain
try:
customer_info = service.customers().get(customerKey="my_customer").execute()
customer_id = customer_info.get("id", "")
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
)
raise GoogleWorkspaceSetUpIdentityError(
file=os.path.basename(__file__),
original_exception=error,
message=f"Failed to fetch customer information from Google Workspace API: {error}",
)
# Validate customer ID was retrieved successfully
if not customer_id:
raise GoogleWorkspaceSetUpIdentityError(
file=os.path.basename(__file__),
message="Failed to retrieve customer ID from Google Workspace API. Ensure the delegated user has proper access.",
)
# Fetch all domains (primary + aliases) to support domain aliases
# The scope admin.directory.domain.readonly is already in DIRECTORY_SCOPES
try:
domains_response = service.domains().list(customer="my_customer").execute()
valid_domains = [
domain.get("domainName", "").lower()
for domain in domains_response.get("domains", [])
if domain.get("domainName")
]
except Exception as error:
# No fallback - fail if we cannot fetch domains
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
)
raise GoogleWorkspaceSetUpIdentityError(
file=os.path.basename(__file__),
original_exception=error,
message=f"Failed to fetch domain list from Google Workspace API: {error}",
)
# Validate that the delegated user's domain is in the workspace (primary or alias)
if not valid_domains:
raise GoogleWorkspaceSetUpIdentityError(
file=os.path.basename(__file__),
message="No domains found in Google Workspace. Ensure the delegated user has proper access.",
)
if user_domain.lower() not in valid_domains:
raise GoogleWorkspaceInvalidCredentialsError(
file=os.path.basename(__file__),
message=f"Delegated user domain {user_domain} is not configured in this Google Workspace. Valid domains: {', '.join(valid_domains)}. Ensure the delegated user belongs to the correct workspace or domain alias.",
)
identity = GoogleWorkspaceIdentityInfo(
domain=user_domain,
customer_id=customer_id,
delegated_user=delegated_user,
profile="default",
)
logger.info(
f"Google Workspace identity set up for domain: {user_domain}, customer: {customer_id}"
)
return identity
def print_credentials(self):
"""
Prints the Google Workspace credentials.
Usage:
>>> self.print_credentials()
"""
report_lines = [
f"Google Workspace Domain: {Fore.YELLOW}{self.identity.domain}{Style.RESET_ALL}",
f"Customer ID: {Fore.YELLOW}{self.identity.customer_id}{Style.RESET_ALL}",
f"Delegated User: {Fore.YELLOW}{self.identity.delegated_user}{Style.RESET_ALL}",
f"Authentication Method: {Fore.YELLOW}Service Account with Domain-Wide Delegation{Style.RESET_ALL}",
]
report_title = f"{Style.BRIGHT}Using the Google Workspace credentials below:{Style.RESET_ALL}"
print_boxes(report_lines, report_title)
@staticmethod
def test_connection(
credentials_file: str = None,
credentials_content: str = None,
delegated_user: str = None,
raise_on_exception: bool = True,
) -> Connection:
"""Test connection to Google Workspace.
Test the connection to Google Workspace using the provided credentials.
Args:
credentials_file (str): Path to Service Account JSON credentials file.
credentials_content (str): Service Account JSON credentials as a string.
delegated_user (str): Email of the user to impersonate via Domain-Wide Delegation.
raise_on_exception (bool): Flag indicating whether to raise an exception if the connection fails.
Returns:
Connection: Connection object with success status or error information.
Raises:
GoogleWorkspaceNoCredentialsError: If no credentials are provided.
GoogleWorkspaceMissingDelegatedUserError: If delegated_user is not provided.
GoogleWorkspaceSetUpSessionError: If there is an error setting up the session.
GoogleWorkspaceSetUpIdentityError: If there is an error setting up the identity.
Examples:
>>> GoogleworkspaceProvider.test_connection(
... credentials_file="sa.json",
... delegated_user="prowler-reader@company.com"
... )
Connection(is_connected=True)
"""
try:
# Set up the Google Workspace session
session, resolved_delegated_user = GoogleworkspaceProvider.setup_session(
credentials_file=credentials_file,
credentials_content=credentials_content,
delegated_user=delegated_user,
)
# Set up the identity to test the connection
GoogleworkspaceProvider.setup_identity(session, resolved_delegated_user)
return Connection(is_connected=True)
except Exception as error:
logger.critical(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
if raise_on_exception:
raise error
return Connection(error=error)

View File

@@ -0,0 +1,7 @@
def init_parser(self):
"""Init the Google Workspace Provider CLI parser"""
self.subparsers.add_parser(
"googleworkspace",
parents=[self.common_providers_parser],
help="Google Workspace Provider",
)

View File

@@ -0,0 +1,17 @@
from prowler.lib.check.models import CheckReportGoogleWorkspace
from prowler.lib.mutelist.mutelist import Mutelist
from prowler.lib.outputs.utils import unroll_dict, unroll_tags
class GoogleWorkspaceMutelist(Mutelist):
def is_finding_muted(
self,
finding: CheckReportGoogleWorkspace,
) -> bool:
return self.is_muted(
finding.customer_id,
finding.check_metadata.CheckID,
finding.location, # Google Workspace resources are typically "global"
finding.resource_name,
unroll_dict(unroll_tags(finding.resource_tags)),
)

View File

@@ -0,0 +1,77 @@
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from prowler.lib.logger import logger
from prowler.providers.googleworkspace.googleworkspace_provider import (
GoogleworkspaceProvider,
)
class GoogleWorkspaceService:
def __init__(
self,
provider: GoogleworkspaceProvider,
):
self.provider = provider
self.audit_config = provider.audit_config
self.fixer_config = provider.fixer_config
self.credentials = provider.session.credentials
def _build_service(self, api_name: str, api_version: str):
"""
Build and return a Google API service client.
Args:
api_name: The name of the API (e.g., 'admin')
api_version: The API version (e.g., 'directory_v1')
Returns:
A Google API service client
"""
try:
return build(
api_name,
api_version,
credentials=self.credentials,
cache_discovery=False,
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return None
def _handle_api_error(self, error, context: str, resource_name: str = ""):
"""
Centralized Google Workspace API error handling.
Args:
error: The exception that was raised
context: Description of what operation was being performed
resource_name: Name of the resource being accessed (optional)
"""
resource_info = resource_name if resource_name else ""
if isinstance(error, HttpError):
if error.resp.status == 403:
logger.error(
f"Access denied while {context} {resource_info}: Insufficient permissions or API not enabled"
)
elif error.resp.status == 404:
logger.error(f"{resource_info} not found while {context}")
elif error.resp.status == 429:
logger.error(
f"Rate limit exceeded while {context} {resource_info}: {error}"
)
elif error.resp.status == 401:
logger.error(
f"Authentication error while {context} {resource_info}: Check credentials and delegation"
)
else:
logger.error(
f"Google API error ({error.resp.status}) while {context} {resource_info}: {error}"
)
else:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] while {context} {resource_info}: {error}"
)

View File

@@ -0,0 +1,43 @@
from typing import Optional
from google.oauth2.service_account import Credentials
from pydantic.v1 import BaseModel
from prowler.config.config import output_file_timestamp
from prowler.providers.common.models import ProviderOutputOptions
class GoogleWorkspaceSession(BaseModel):
"""Google Workspace session containing credentials"""
credentials: Credentials
class Config:
arbitrary_types_allowed = True
class GoogleWorkspaceIdentityInfo(BaseModel):
"""Google Workspace identity information"""
domain: str
customer_id: str
delegated_user: str
profile: Optional[str] = "default"
class GoogleWorkspaceOutputOptions(ProviderOutputOptions):
"""Google Workspace specific output options"""
def __init__(self, arguments, bulk_checks_metadata, identity):
# First call ProviderOutputOptions init
super().__init__(arguments, bulk_checks_metadata)
# Check if custom output filename was input, if not, set the default
if (
not hasattr(arguments, "output_filename")
or arguments.output_filename is None
):
self.output_filename = (
f"prowler-output-{identity.domain}-{output_file_timestamp}"
)
else:
self.output_filename = arguments.output_filename

View File

@@ -0,0 +1,6 @@
from prowler.providers.common.provider import Provider
from prowler.providers.googleworkspace.services.directory.directory_service import (
Directory,
)
directory_client = Directory(Provider.get_global_provider())

View File

@@ -0,0 +1,70 @@
from pydantic import BaseModel
from prowler.lib.logger import logger
from prowler.providers.googleworkspace.lib.service.service import GoogleWorkspaceService
class Directory(GoogleWorkspaceService):
def __init__(self, provider):
super().__init__(provider)
self.users = self._list_users()
def _list_users(self):
logger.info("Directory - Listing Users...")
users = {}
try:
# Build the Admin SDK Directory service
service = self._build_service("admin", "directory_v1")
if not service:
logger.error("Failed to build Directory service")
return users
# Fetch users using the Directory API
# Reference: https://developers.google.com/admin-sdk/directory/reference/rest/v1/users/list
request = service.users().list(
customer=self.provider.identity.customer_id,
maxResults=500, # Max allowed by API
orderBy="email",
)
while request is not None:
try:
response = request.execute()
for user_data in response.get("users", []):
user = User(
id=user_data.get("id"),
email=user_data.get("primaryEmail"),
is_admin=user_data.get("isAdmin", False),
)
users[user.id] = user
logger.debug(
f"Processed user: {user.email} (Admin: {user.is_admin})"
)
request = service.users().list_next(request, response)
except Exception as error:
self._handle_api_error(
error, "listing users", self.provider.identity.customer_id
)
break
logger.info(f"Found {len(users)} users in the domain")
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return users
class User(BaseModel):
id: str
email: str
is_admin: bool = False

View File

@@ -0,0 +1,37 @@
{
"Provider": "googleworkspace",
"CheckID": "directory_super_admin_count",
"CheckTitle": "Domain has 2-4 super administrators",
"CheckType": [],
"ServiceName": "directory",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "high",
"ResourceType": "NotDefined",
"ResourceGroup": "IAM",
"Description": "Google Workspace domain has between **2 and 4 super administrators**.\n\nHaving too few super admins creates a **single point of failure** and administrative access issues if the only admin is unavailable. Having too many super admins increases the **attack surface** and the risk of unauthorized access to critical administrative functions.",
"Risk": "Having fewer than 2 super administrators creates a **single point of failure** and may prevent administrative access in emergencies.\n\nHaving more than 4 super administrators increases the security risk by expanding the **attack surface** for compromised accounts with full **administrative privileges**.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://knowledge.workspace.google.com/admin/users/prebuilt-administrator-roles",
"https://support.google.com/a/answer/9011373"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Directory** > **Users**\n3. Click on a user to view their details\n4. Click **Admin roles and privileges**\n5. To add super admin: Check **Super Admin** role and click **Save**\n6. To remove super admin: Uncheck **Super Admin** role and click **Save**\n\nEnsure your domain has **2-4 super administrators** for operational resilience and security. For users requiring limited administrative access, assign specific delegated admin roles instead of super admin privileges.",
"Terraform": ""
},
"Recommendation": {
"Text": "Review the list of super administrators in your Google Workspace Admin console. Add more super admins if you have fewer than 2, or remove unnecessary super admin privileges if you have more than 4. Consider using delegated admin roles for users who need limited administrative capabilities.",
"Url": "https://hub.prowler.com/check/directory_super_admin_count"
}
},
"Categories": [
"identity-access"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}

View File

@@ -0,0 +1,53 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
from prowler.providers.googleworkspace.services.directory.directory_client import (
directory_client,
)
class directory_super_admin_count(Check):
"""Check that the number of super admins is between 2 and 4
This check verifies that the Google Workspace domain has between 2 and 4 super administrators.
Having too few admins creates a single point of failure, while too many increases security risk.
"""
def execute(self) -> List[CheckReportGoogleWorkspace]:
findings = []
super_admins = [
user for user in directory_client.users.values() if user.is_admin
]
admin_count = len(super_admins)
report = CheckReportGoogleWorkspace(
metadata=self.metadata(),
resource=directory_client.provider.identity,
resource_name=directory_client.provider.identity.domain,
resource_id=directory_client.provider.identity.customer_id,
customer_id=directory_client.provider.identity.customer_id,
location="global",
)
if 2 <= admin_count <= 4:
report.status = "PASS"
report.status_extended = (
f"Domain {directory_client.provider.identity.domain} has {admin_count} super administrator(s), "
f"which is within the recommended range of 2-4."
)
else:
report.status = "FAIL"
if admin_count < 2:
report.status_extended = (
f"Domain {directory_client.provider.identity.domain} has only {admin_count} super administrator(s). "
f"It is recommended to have between 2 and 4 super admins to avoid single point of failure."
)
else:
report.status_extended = (
f"Domain {directory_client.provider.identity.domain} has {admin_count} super administrator(s). "
f"It is recommended to have between 2 and 4 super admins to minimize security risk."
)
findings.append(report)
return findings

View File

@@ -17,7 +17,7 @@ prowler_command = "prowler"
# capsys # capsys
# https://docs.pytest.org/en/7.1.x/how-to/capture-stdout-stderr.html # https://docs.pytest.org/en/7.1.x/how-to/capture-stdout-stderr.html
prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,dashboard,iac,image} ..." prowler_default_usage_error = "usage: prowler [-h] [--version] {aws,azure,gcp,kubernetes,m365,github,googleworkspace,nhn,mongodbatlas,oraclecloud,alibabacloud,cloudflare,openstack,dashboard,iac,image} ..."
def mock_get_available_providers(): def mock_get_available_providers():
@@ -28,6 +28,7 @@ def mock_get_available_providers():
"kubernetes", "kubernetes",
"m365", "m365",
"github", "github",
"googleworkspace",
"iac", "iac",
"image", "image",
"nhn", "nhn",

View File

@@ -428,6 +428,40 @@ class TestFinding:
assert finding_output.metadata.Notes == "mock_notes" assert finding_output.metadata.Notes == "mock_notes"
assert finding_output.metadata.Compliance == [] assert finding_output.metadata.Compliance == []
def test_generate_output_googleworkspace(self):
provider = MagicMock()
provider.type = "googleworkspace"
provider.identity.delegated_user = "admin@test-company.com"
provider.identity.customer_id = "C1234567"
provider.identity.domain = "test-company.com"
check_output = MagicMock()
check_output.resource_id = "test_resource_id"
check_output.resource_name = "test_resource_name"
check_output.resource_details = ""
check_output.location = "global"
check_output.status = Status.PASS
check_output.status_extended = "mock_status_extended"
check_output.muted = False
check_output.check_metadata = mock_check_metadata(provider="googleworkspace")
check_output.resource = {}
check_output.compliance = {}
output_options = MagicMock()
output_options.unix_timestamp = True
finding_output = Finding.generate_output(provider, check_output, output_options)
assert isinstance(finding_output, Finding)
assert finding_output.auth_method == "service_account: admin@test-company.com"
assert finding_output.account_uid == "C1234567"
assert finding_output.account_name == "test-company.com"
assert finding_output.resource_name == "test_resource_name"
assert finding_output.resource_uid == "test_resource_id"
assert finding_output.region == "global"
assert finding_output.status == Status.PASS
assert finding_output.muted is False
def test_generate_output_kubernetes(self): def test_generate_output_kubernetes(self):
# Mock provider # Mock provider
provider = MagicMock() provider = MagicMock()

View File

@@ -12,6 +12,9 @@ from tests.providers.aws.utils import AWS_REGION_EU_WEST_1, set_mocked_aws_provi
from tests.providers.azure.azure_fixtures import set_mocked_azure_provider from tests.providers.azure.azure_fixtures import set_mocked_azure_provider
from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID, set_mocked_gcp_provider from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID, set_mocked_gcp_provider
from tests.providers.github.github_fixtures import APP_ID, set_mocked_github_provider from tests.providers.github.github_fixtures import APP_ID, set_mocked_github_provider
from tests.providers.googleworkspace.googleworkspace_fixtures import (
set_mocked_googleworkspace_provider,
)
from tests.providers.kubernetes.kubernetes_fixtures import ( from tests.providers.kubernetes.kubernetes_fixtures import (
set_mocked_kubernetes_provider, set_mocked_kubernetes_provider,
) )
@@ -910,6 +913,24 @@ class TestHTML:
assert summary == mongodbatlas_html_assessment_summary assert summary == mongodbatlas_html_assessment_summary
def test_googleworkspace_get_assessment_summary(self):
"""Test Google Workspace HTML assessment summary generation."""
findings = [generate_finding_output()]
output = HTML(findings)
provider = set_mocked_googleworkspace_provider()
summary = output.get_assessment_summary(provider)
assert "Google Workspace Assessment Summary" in summary
assert "Google Workspace Credentials" in summary
assert "<b>Domain:</b> test-company.com" in summary
assert "<b>Customer ID:</b> C1234567" in summary
assert "<b>Delegated User:</b> prowler-reader@test-company.com" in summary
assert (
"<b>Authentication Method:</b> Service Account with Domain-Wide Delegation"
in summary
)
def test_image_get_assessment_summary_with_registry(self): def test_image_get_assessment_summary_with_registry(self):
"""Test Image HTML assessment summary with registry URL.""" """Test Image HTML assessment summary with registry URL."""
findings = [generate_finding_output()] findings = [generate_finding_output()]

View File

@@ -1196,6 +1196,46 @@ class TestReport:
) )
mocked_print.assert_called() # Verifying that print was called mocked_print.assert_called() # Verifying that print was called
def test_report_with_googleworkspace_provider_pass(self):
finding = MagicMock()
finding.status = "PASS"
finding.muted = False
finding.location = "global"
finding.check_metadata.Provider = "googleworkspace"
finding.status_extended = "Domain has 2 super administrators"
output_options = MagicMock()
output_options.verbose = True
output_options.status = ["PASS", "FAIL"]
output_options.fixer = False
provider = MagicMock()
provider.type = "googleworkspace"
with mock.patch("builtins.print") as mocked_print:
report([finding], provider, output_options)
mocked_print.assert_called()
def test_report_with_googleworkspace_provider_fail(self):
finding = MagicMock()
finding.status = "FAIL"
finding.muted = False
finding.location = "global"
finding.check_metadata.Provider = "googleworkspace"
finding.status_extended = "Domain has only 1 super administrator"
output_options = MagicMock()
output_options.verbose = True
output_options.status = ["PASS", "FAIL"]
output_options.fixer = False
provider = MagicMock()
provider.type = "googleworkspace"
with mock.patch("builtins.print") as mocked_print:
report([finding], provider, output_options)
mocked_print.assert_called()
def test_report_with_no_findings(self): def test_report_with_no_findings(self):
# Mocking check_findings and provider # Mocking check_findings and provider
check_findings = [] check_findings = []

View File

@@ -0,0 +1,57 @@
"""Test fixtures for Google Workspace provider tests"""
from unittest.mock import MagicMock
from prowler.providers.googleworkspace.models import GoogleWorkspaceIdentityInfo
# Google Workspace test constants
DOMAIN = "test-company.com"
CUSTOMER_ID = "C1234567"
DELEGATED_USER = "prowler-reader@test-company.com"
# Service Account credentials (mock)
SERVICE_ACCOUNT_CREDENTIALS = {
"type": "service_account",
"project_id": "test-project-12345",
"private_key_id": "test-key-id-12345",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC\n-----END PRIVATE KEY-----\n",
"client_email": "test-sa@test-project-12345.iam.gserviceaccount.com",
"client_id": "123456789012345678901",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/test-sa%40test-project-12345.iam.gserviceaccount.com",
}
# Mock user data
USER_1 = {
"id": "user1-id",
"primaryEmail": "admin@test-company.com",
"isAdmin": True,
}
USER_2 = {
"id": "user2-id",
"primaryEmail": "admin2@test-company.com",
"isAdmin": True,
}
USER_3 = {
"id": "user3-id",
"primaryEmail": "user@test-company.com",
"isAdmin": False,
}
def set_mocked_googleworkspace_provider(
identity: GoogleWorkspaceIdentityInfo = GoogleWorkspaceIdentityInfo(
domain=DOMAIN,
customer_id=CUSTOMER_ID,
delegated_user=DELEGATED_USER,
profile="default",
),
):
provider = MagicMock()
provider.type = "googleworkspace"
provider.identity = identity
return provider

View File

@@ -0,0 +1,363 @@
from unittest.mock import MagicMock, patch
import pytest
from google.oauth2.service_account import Credentials
from googleapiclient.errors import HttpError
from prowler.providers.googleworkspace.exceptions.exceptions import (
GoogleWorkspaceImpersonationError,
GoogleWorkspaceInsufficientScopesError,
GoogleWorkspaceInvalidCredentialsError,
GoogleWorkspaceMissingDelegatedUserError,
GoogleWorkspaceNoCredentialsError,
GoogleWorkspaceSetUpIdentityError,
GoogleWorkspaceSetUpSessionError,
)
from prowler.providers.googleworkspace.googleworkspace_provider import (
GoogleworkspaceProvider,
)
from prowler.providers.googleworkspace.models import (
GoogleWorkspaceIdentityInfo,
GoogleWorkspaceSession,
)
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
DELEGATED_USER,
DOMAIN,
SERVICE_ACCOUNT_CREDENTIALS,
)
class TestGoogleWorkspaceProvider:
def test_googleworkspace_provider_with_credentials_file(self):
"""Test provider initialization with credentials file"""
credentials_file = "/path/to/credentials.json"
delegated_user = DELEGATED_USER
# Mock credentials object
mock_credentials = MagicMock(spec=Credentials)
with (
patch(
"prowler.providers.googleworkspace.googleworkspace_provider.GoogleworkspaceProvider.setup_session",
return_value=(
GoogleWorkspaceSession(credentials=mock_credentials),
DELEGATED_USER,
),
),
patch(
"prowler.providers.googleworkspace.googleworkspace_provider.GoogleworkspaceProvider.setup_identity",
return_value=GoogleWorkspaceIdentityInfo(
domain=DOMAIN,
customer_id=CUSTOMER_ID,
delegated_user=DELEGATED_USER,
profile="default",
),
),
):
provider = GoogleworkspaceProvider(
credentials_file=credentials_file,
delegated_user=delegated_user,
)
assert provider._type == "googleworkspace"
assert provider.session.credentials == mock_credentials
assert provider.identity == GoogleWorkspaceIdentityInfo(
domain=DOMAIN,
customer_id=CUSTOMER_ID,
delegated_user=DELEGATED_USER,
profile="default",
)
assert provider._audit_config == {}
def test_googleworkspace_provider_with_credentials_content(self):
"""Test provider initialization with credentials content"""
import json
credentials_content = json.dumps(SERVICE_ACCOUNT_CREDENTIALS)
delegated_user = DELEGATED_USER
# Mock credentials object
mock_credentials = MagicMock(spec=Credentials)
with (
patch(
"prowler.providers.googleworkspace.googleworkspace_provider.GoogleworkspaceProvider.setup_session",
return_value=(
GoogleWorkspaceSession(credentials=mock_credentials),
DELEGATED_USER,
),
),
patch(
"prowler.providers.googleworkspace.googleworkspace_provider.GoogleworkspaceProvider.setup_identity",
return_value=GoogleWorkspaceIdentityInfo(
domain=DOMAIN,
customer_id=CUSTOMER_ID,
delegated_user=DELEGATED_USER,
profile="default",
),
),
):
provider = GoogleworkspaceProvider(
credentials_content=credentials_content,
delegated_user=delegated_user,
)
assert provider._type == "googleworkspace"
assert provider.identity.domain == DOMAIN
assert provider.identity.customer_id == CUSTOMER_ID
assert provider.identity.delegated_user == DELEGATED_USER
def test_googleworkspace_provider_missing_delegated_user(self):
"""Test that missing delegated_user raises exception"""
credentials_file = "/path/to/credentials.json"
with pytest.raises(GoogleWorkspaceMissingDelegatedUserError):
GoogleworkspaceProvider.setup_session(
credentials_file=credentials_file,
delegated_user=None,
)
def test_googleworkspace_provider_no_credentials(self):
"""Test that missing credentials raises exception"""
delegated_user = DELEGATED_USER
with pytest.raises(GoogleWorkspaceNoCredentialsError):
GoogleworkspaceProvider.setup_session(
credentials_file=None,
credentials_content=None,
delegated_user=delegated_user,
)
def test_googleworkspace_provider_test_connection_success(self):
"""Test successful connection test"""
credentials_file = "/path/to/credentials.json"
delegated_user = DELEGATED_USER
mock_credentials = MagicMock(spec=Credentials)
with (
patch(
"prowler.providers.googleworkspace.googleworkspace_provider.GoogleworkspaceProvider.setup_session",
return_value=(
GoogleWorkspaceSession(credentials=mock_credentials),
DELEGATED_USER,
),
),
patch(
"prowler.providers.googleworkspace.googleworkspace_provider.GoogleworkspaceProvider.setup_identity",
return_value=GoogleWorkspaceIdentityInfo(
domain=DOMAIN,
customer_id=CUSTOMER_ID,
delegated_user=DELEGATED_USER,
profile="default",
),
),
):
connection = GoogleworkspaceProvider.test_connection(
credentials_file=credentials_file,
delegated_user=delegated_user,
)
assert connection.is_connected is True
assert connection.error is None
def test_googleworkspace_provider_test_connection_failure(self):
"""Test failed connection test"""
credentials_file = "/path/to/credentials.json"
delegated_user = DELEGATED_USER
with patch(
"prowler.providers.googleworkspace.googleworkspace_provider.GoogleworkspaceProvider.setup_session",
side_effect=GoogleWorkspaceSetUpSessionError(),
):
connection = GoogleworkspaceProvider.test_connection(
credentials_file=credentials_file,
delegated_user=delegated_user,
raise_on_exception=False,
)
assert connection.is_connected is False
assert connection.error is not None
def test_googleworkspace_provider_print_credentials(self):
"""Test print_credentials method"""
mock_credentials = MagicMock(spec=Credentials)
with (
patch(
"prowler.providers.googleworkspace.googleworkspace_provider.GoogleworkspaceProvider.setup_session",
return_value=(
GoogleWorkspaceSession(credentials=mock_credentials),
DELEGATED_USER,
),
),
patch(
"prowler.providers.googleworkspace.googleworkspace_provider.GoogleworkspaceProvider.setup_identity",
return_value=GoogleWorkspaceIdentityInfo(
domain=DOMAIN,
customer_id=CUSTOMER_ID,
delegated_user=DELEGATED_USER,
profile="default",
),
),
patch(
"prowler.providers.googleworkspace.googleworkspace_provider.print_boxes"
) as mock_print_boxes,
):
provider = GoogleworkspaceProvider(
credentials_file="/path/to/credentials.json",
delegated_user=DELEGATED_USER,
)
provider.print_credentials()
# Verify print_boxes was called
assert mock_print_boxes.called
def test_setup_session_credentials_file_invalid_json(self):
"""Test ValueError when credentials file has invalid format"""
with patch(
"prowler.providers.googleworkspace.googleworkspace_provider.service_account.Credentials.from_service_account_file",
side_effect=ValueError("Invalid credentials format"),
):
with pytest.raises(GoogleWorkspaceInvalidCredentialsError) as exc_info:
GoogleworkspaceProvider.setup_session(
credentials_file="/path/to/invalid.json",
delegated_user=DELEGATED_USER,
)
assert "Invalid service account credentials file" in str(exc_info.value)
def test_setup_session_credentials_content_invalid_json(self):
"""Test JSONDecodeError when credentials content is invalid JSON"""
with pytest.raises(GoogleWorkspaceInvalidCredentialsError) as exc_info:
GoogleworkspaceProvider.setup_session(
credentials_content="{ invalid json }",
delegated_user=DELEGATED_USER,
)
assert "Invalid JSON in credentials content" in str(exc_info.value)
def test_setup_session_invalid_delegated_user_email(self):
"""Test invalid delegated user email format"""
with pytest.raises(GoogleWorkspaceInvalidCredentialsError) as exc_info:
GoogleworkspaceProvider.setup_session(
credentials_file="/path/to/credentials.json",
delegated_user="not-an-email",
)
assert "Must be a valid email address" in str(exc_info.value)
def test_setup_session_insufficient_scopes_403(self):
"""Test GoogleWorkspaceInsufficientScopesError for 403 errors"""
mock_credentials = MagicMock(spec=Credentials)
mock_delegated_creds = MagicMock()
mock_credentials.with_subject.return_value = mock_delegated_creds
# Mock HttpError with 403 status
http_error = HttpError(
resp=MagicMock(status=403), content=b"Forbidden", uri="test"
)
with (
patch(
"prowler.providers.googleworkspace.googleworkspace_provider.service_account.Credentials.from_service_account_file",
return_value=mock_credentials,
),
patch(
"prowler.providers.googleworkspace.googleworkspace_provider.build"
) as mock_build,
):
mock_service = MagicMock()
mock_build.return_value = mock_service
mock_service.users().get().execute.side_effect = http_error
with pytest.raises(GoogleWorkspaceInsufficientScopesError) as exc_info:
GoogleworkspaceProvider.setup_session(
credentials_file="/path/to/creds.json",
delegated_user=DELEGATED_USER,
)
assert "Domain-Wide Delegation is not configured" in str(exc_info.value)
def test_setup_session_impersonation_generic_error(self):
"""Test GoogleWorkspaceImpersonationError for other delegation errors"""
mock_credentials = MagicMock(spec=Credentials)
mock_delegated_creds = MagicMock()
mock_credentials.with_subject.return_value = mock_delegated_creds
with (
patch(
"prowler.providers.googleworkspace.googleworkspace_provider.service_account.Credentials.from_service_account_file",
return_value=mock_credentials,
),
patch(
"prowler.providers.googleworkspace.googleworkspace_provider.build"
) as mock_build,
):
mock_service = MagicMock()
mock_build.return_value = mock_service
mock_service.users().get().execute.side_effect = Exception(
"Connection error"
)
with pytest.raises(GoogleWorkspaceImpersonationError) as exc_info:
GoogleworkspaceProvider.setup_session(
credentials_file="/path/to/creds.json",
delegated_user=DELEGATED_USER,
)
assert "Failed to verify delegation" in str(exc_info.value)
def test_setup_identity_customer_fetch_failure(self):
"""Test error when fetching customer information fails"""
mock_session = GoogleWorkspaceSession(credentials=MagicMock(spec=Credentials))
with patch(
"prowler.providers.googleworkspace.googleworkspace_provider.build"
) as mock_build:
mock_service = MagicMock()
mock_build.return_value = mock_service
mock_service.customers().get().execute.side_effect = Exception("API error")
with pytest.raises(GoogleWorkspaceSetUpIdentityError) as exc_info:
GoogleworkspaceProvider.setup_identity(
session=mock_session,
delegated_user=DELEGATED_USER,
)
assert "Failed to fetch customer information" in str(exc_info.value)
def test_setup_identity_domain_mismatch(self):
"""Test error when user domain is not in workspace"""
mock_session = GoogleWorkspaceSession(credentials=MagicMock(spec=Credentials))
with patch(
"prowler.providers.googleworkspace.googleworkspace_provider.build"
) as mock_build:
mock_service = MagicMock()
mock_build.return_value = mock_service
mock_service.customers().get().execute.return_value = {"id": CUSTOMER_ID}
mock_service.domains().list().execute.return_value = {
"domains": [{"domainName": "different-company.com"}]
}
with pytest.raises(GoogleWorkspaceInvalidCredentialsError) as exc_info:
GoogleworkspaceProvider.setup_identity(
session=mock_session,
delegated_user=DELEGATED_USER,
)
assert "is not configured in this Google Workspace" in str(exc_info.value)
def test_test_connection_raises_exception_when_flag_true(self):
"""Test that test_connection raises exception when raise_on_exception=True"""
credentials_file = "/path/to/credentials.json"
delegated_user = DELEGATED_USER
with patch(
"prowler.providers.googleworkspace.googleworkspace_provider.GoogleworkspaceProvider.setup_session",
side_effect=GoogleWorkspaceSetUpSessionError(
file="test", message="Test error"
),
):
with pytest.raises(GoogleWorkspaceSetUpSessionError):
GoogleworkspaceProvider.test_connection(
credentials_file=credentials_file,
delegated_user=delegated_user,
raise_on_exception=True,
)

View File

@@ -0,0 +1,28 @@
from unittest.mock import MagicMock
from prowler.providers.googleworkspace.lib.arguments import arguments
class TestGoogleWorkspaceArguments:
def setup_method(self):
"""Setup mock ArgumentParser for testing"""
self.mock_parser = MagicMock()
self.mock_subparsers = MagicMock()
self.mock_googleworkspace_parser = MagicMock()
self.mock_parser.add_subparsers.return_value = self.mock_subparsers
self.mock_subparsers.add_parser.return_value = self.mock_googleworkspace_parser
def test_init_parser_creates_subparser(self):
"""Test that init_parser creates the googleworkspace subparser correctly"""
mock_args = MagicMock()
mock_args.subparsers = self.mock_subparsers
mock_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_args)
self.mock_subparsers.add_parser.assert_called_once_with(
"googleworkspace",
parents=[mock_args.common_providers_parser],
help="Google Workspace Provider",
)

View File

@@ -0,0 +1,15 @@
### Account, Check and/or Region can be * to apply for all the cases.
### Resources and tags are lists that can have either Regex or Keywords.
### Tags is an optional list that matches on tuples of 'key=value' and are "ANDed" together.
### Use an alternation Regex to match one of multiple tags with "ORed" logic.
### For each check you can except Accounts, Regions, Resources and/or Tags.
########################### MUTELIST EXAMPLE ###########################
Mutelist:
Accounts:
"C1234567":
Checks:
"directory_super_admin_count":
Regions:
- "*"
Resources:
- "test-company.com"

View File

@@ -0,0 +1,128 @@
import yaml
from mock import MagicMock
from prowler.providers.googleworkspace.lib.mutelist.mutelist import (
GoogleWorkspaceMutelist,
)
from tests.lib.outputs.fixtures.fixtures import generate_finding_output
MUTELIST_FIXTURE_PATH = "tests/providers/googleworkspace/lib/mutelist/fixtures/googleworkspace_mutelist.yaml"
class TestGoogleWorkspaceMutelist:
def test_get_mutelist_file_from_local_file(self):
mutelist = GoogleWorkspaceMutelist(mutelist_path=MUTELIST_FIXTURE_PATH)
with open(MUTELIST_FIXTURE_PATH) as f:
mutelist_fixture = yaml.safe_load(f)["Mutelist"]
assert mutelist.mutelist == mutelist_fixture
assert mutelist.mutelist_file_path == MUTELIST_FIXTURE_PATH
def test_get_mutelist_file_from_local_file_non_existent(self):
mutelist_path = "tests/lib/mutelist/fixtures/not_present"
mutelist = GoogleWorkspaceMutelist(mutelist_path=mutelist_path)
assert mutelist.mutelist == {}
assert mutelist.mutelist_file_path == mutelist_path
def test_validate_mutelist_not_valid_key(self):
mutelist_path = MUTELIST_FIXTURE_PATH
with open(mutelist_path) as f:
mutelist_fixture = yaml.safe_load(f)["Mutelist"]
mutelist_fixture["Accounts1"] = mutelist_fixture["Accounts"]
del mutelist_fixture["Accounts"]
mutelist = GoogleWorkspaceMutelist(mutelist_content=mutelist_fixture)
assert len(mutelist.validate_mutelist(mutelist_fixture)) == 0
assert mutelist.mutelist == {}
assert mutelist.mutelist_file_path is None
def test_is_finding_muted(self):
mutelist_content = {
"Accounts": {
"C1234567": {
"Checks": {
"directory_super_admin_count": {
"Regions": ["*"],
"Resources": ["test-company.com"],
}
}
}
}
}
mutelist = GoogleWorkspaceMutelist(mutelist_content=mutelist_content)
finding = MagicMock
finding.check_metadata = MagicMock
finding.check_metadata.CheckID = "directory_super_admin_count"
finding.status = "FAIL"
finding.customer_id = "C1234567"
finding.location = "global"
finding.resource_name = "test-company.com"
finding.resource_tags = []
assert mutelist.is_finding_muted(finding)
def test_is_finding_not_muted(self):
mutelist_content = {
"Accounts": {
"C1234567": {
"Checks": {
"directory_super_admin_count": {
"Regions": ["*"],
"Resources": ["test-company.com"],
}
}
}
}
}
mutelist = GoogleWorkspaceMutelist(mutelist_content=mutelist_content)
finding = MagicMock
finding.check_metadata = MagicMock
finding.check_metadata.CheckID = "directory_super_admin_count"
finding.status = "FAIL"
finding.customer_id = "C9999999"
finding.location = "global"
finding.resource_name = "test-company.com"
finding.resource_tags = []
assert not mutelist.is_finding_muted(finding)
def test_mute_finding(self):
mutelist_content = {
"Accounts": {
"C1234567": {
"Checks": {
"directory_super_admin_count": {
"Regions": ["*"],
"Resources": ["test-company.com"],
}
}
}
}
}
mutelist = GoogleWorkspaceMutelist(mutelist_content=mutelist_content)
finding_1 = generate_finding_output(
check_id="directory_super_admin_count",
service_name="directory",
status="FAIL",
account_uid="C1234567",
region="global",
resource_uid="test-company.com",
resource_tags={},
muted=False,
)
muted_finding = mutelist.mute_finding(finding=finding_1)
assert muted_finding.status == "MUTED"
assert muted_finding.muted
assert muted_finding.raw["status"] == "FAIL"

View File

@@ -0,0 +1,132 @@
from unittest.mock import MagicMock, patch
from tests.providers.googleworkspace.googleworkspace_fixtures import (
USER_1,
USER_2,
USER_3,
set_mocked_googleworkspace_provider,
)
class TestDirectoryService:
def test_directory_list_users(self):
"""Test listing users from Directory API"""
mock_provider = set_mocked_googleworkspace_provider()
mock_provider.audit_config = {}
mock_provider.fixer_config = {}
mock_credentials = MagicMock()
mock_session = MagicMock()
mock_session.credentials = mock_credentials
mock_provider.session = mock_session
mock_service = MagicMock()
mock_users_list = MagicMock()
mock_users_list.execute.return_value = {"users": [USER_1, USER_2, USER_3]}
mock_service.users().list.return_value = mock_users_list
mock_service.users().list_next.return_value = None
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.directory.directory_service.GoogleWorkspaceService._build_service",
return_value=mock_service,
),
):
from prowler.providers.googleworkspace.services.directory.directory_service import (
Directory,
)
directory = Directory(mock_provider)
assert len(directory.users) == 3
assert "user1-id" in directory.users
assert "user2-id" in directory.users
assert "user3-id" in directory.users
admin_users = [user for user in directory.users.values() if user.is_admin]
assert len(admin_users) == 2
assert directory.users["user1-id"].email == "admin@test-company.com"
assert directory.users["user1-id"].is_admin is True
assert directory.users["user3-id"].is_admin is False
def test_directory_empty_users_list(self):
"""Test handling empty users list"""
mock_provider = set_mocked_googleworkspace_provider()
mock_provider.audit_config = {}
mock_provider.fixer_config = {}
mock_session = MagicMock()
mock_session.credentials = MagicMock()
mock_provider.session = mock_session
mock_service = MagicMock()
mock_users_list = MagicMock()
mock_users_list.execute.return_value = {"users": []}
mock_service.users().list.return_value = mock_users_list
mock_service.users().list_next.return_value = None
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.directory.directory_service.GoogleWorkspaceService._build_service",
return_value=mock_service,
),
):
from prowler.providers.googleworkspace.services.directory.directory_service import (
Directory,
)
directory = Directory(mock_provider)
assert len(directory.users) == 0
def test_directory_api_error_handling(self):
"""Test handling of API errors"""
mock_provider = set_mocked_googleworkspace_provider()
mock_provider.audit_config = {}
mock_provider.fixer_config = {}
mock_session = MagicMock()
mock_session.credentials = MagicMock()
mock_provider.session = mock_session
mock_service = MagicMock()
mock_service.users().list.side_effect = Exception("API Error")
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.directory.directory_service.GoogleWorkspaceService._build_service",
return_value=mock_service,
),
):
from prowler.providers.googleworkspace.services.directory.directory_service import (
Directory,
)
directory = Directory(mock_provider)
assert len(directory.users) == 0
def test_user_model(self):
"""Test User Pydantic model"""
from prowler.providers.googleworkspace.services.directory.directory_service import (
User,
)
user = User(
id="test-id",
email="test@test-company.com",
is_admin=True,
)
assert user.id == "test-id"
assert user.email == "test@test-company.com"
assert user.is_admin is True

View File

@@ -0,0 +1,240 @@
from unittest.mock import patch
from prowler.providers.googleworkspace.services.directory.directory_service import User
from tests.providers.googleworkspace.googleworkspace_fixtures import (
CUSTOMER_ID,
DOMAIN,
set_mocked_googleworkspace_provider,
)
class TestDirectorySuperAdminCount:
def test_directory_super_admin_count_pass_2_admins(self):
"""Test PASS when there are 2 super admins (within range)"""
users = {
"user1-id": User(
id="user1-id",
email="admin1@test-company.com",
is_admin=True,
),
"user2-id": User(
id="user2-id",
email="admin2@test-company.com",
is_admin=True,
),
"user3-id": User(
id="user3-id",
email="user@test-company.com",
is_admin=False,
),
}
mock_provider = set_mocked_googleworkspace_provider()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.directory.directory_super_admin_count.directory_super_admin_count.directory_client"
) as mock_directory_client,
):
from prowler.providers.googleworkspace.services.directory.directory_super_admin_count.directory_super_admin_count import (
directory_super_admin_count,
)
mock_directory_client.users = users
mock_directory_client.provider = mock_provider
check = directory_super_admin_count()
findings = check.execute()
assert len(findings) == 1
assert findings[0].status == "PASS"
assert "2 super administrator(s)" in findings[0].status_extended
assert "within the recommended range" in findings[0].status_extended
assert findings[0].resource_name == DOMAIN
assert findings[0].customer_id == CUSTOMER_ID
def test_directory_super_admin_count_pass_4_admins(self):
"""Test PASS when there are 4 super admins (within range)"""
users = {
f"admin{i}-id": User(
id=f"admin{i}-id",
email=f"admin{i}@test-company.com",
is_admin=True,
)
for i in range(1, 5)
}
mock_provider = set_mocked_googleworkspace_provider()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.directory.directory_super_admin_count.directory_super_admin_count.directory_client"
) as mock_directory_client,
):
from prowler.providers.googleworkspace.services.directory.directory_super_admin_count.directory_super_admin_count import (
directory_super_admin_count,
)
mock_directory_client.users = users
mock_directory_client.provider = mock_provider
check = directory_super_admin_count()
findings = check.execute()
assert len(findings) == 1
assert findings[0].status == "PASS"
assert "4 super administrator(s)" in findings[0].status_extended
assert "within the recommended range" in findings[0].status_extended
def test_directory_super_admin_count_fail_0_admins(self):
"""Test FAIL when there are 0 super admins"""
users = {
"user1-id": User(
id="user1-id",
email="user1@test-company.com",
is_admin=False,
),
}
mock_provider = set_mocked_googleworkspace_provider()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.directory.directory_super_admin_count.directory_super_admin_count.directory_client"
) as mock_directory_client,
):
from prowler.providers.googleworkspace.services.directory.directory_super_admin_count.directory_super_admin_count import (
directory_super_admin_count,
)
mock_directory_client.users = users
mock_directory_client.provider = mock_provider
check = directory_super_admin_count()
findings = check.execute()
assert len(findings) == 1
assert findings[0].status == "FAIL"
assert "only 0 super administrator(s)" in findings[0].status_extended
assert "single point of failure" in findings[0].status_extended
def test_directory_super_admin_count_fail_1_admin(self):
"""Test FAIL when there is only 1 super admin"""
users = {
"admin1-id": User(
id="admin1-id",
email="admin@test-company.com",
is_admin=True,
),
}
mock_provider = set_mocked_googleworkspace_provider()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.directory.directory_super_admin_count.directory_super_admin_count.directory_client"
) as mock_directory_client,
):
from prowler.providers.googleworkspace.services.directory.directory_super_admin_count.directory_super_admin_count import (
directory_super_admin_count,
)
mock_directory_client.users = users
mock_directory_client.provider = mock_provider
check = directory_super_admin_count()
findings = check.execute()
assert len(findings) == 1
assert findings[0].status == "FAIL"
assert "only 1 super administrator(s)" in findings[0].status_extended
assert "single point of failure" in findings[0].status_extended
def test_directory_super_admin_count_fail_5_admins(self):
"""Test FAIL when there are 5 super admins (too many)"""
users = {
f"admin{i}-id": User(
id=f"admin{i}-id",
email=f"admin{i}@test-company.com",
is_admin=True,
)
for i in range(1, 6)
}
mock_provider = set_mocked_googleworkspace_provider()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.directory.directory_super_admin_count.directory_super_admin_count.directory_client"
) as mock_directory_client,
):
from prowler.providers.googleworkspace.services.directory.directory_super_admin_count.directory_super_admin_count import (
directory_super_admin_count,
)
mock_directory_client.users = users
mock_directory_client.provider = mock_provider
check = directory_super_admin_count()
findings = check.execute()
assert len(findings) == 1
assert findings[0].status == "FAIL"
assert "5 super administrator(s)" in findings[0].status_extended
assert "minimize security risk" in findings[0].status_extended
def test_directory_super_admin_count_fail_10_admins(self):
"""Test FAIL when there are 10 super admins (way too many)"""
users = {
f"admin{i}-id": User(
id=f"admin{i}-id",
email=f"admin{i}@test-company.com",
is_admin=True,
)
for i in range(1, 11)
}
mock_provider = set_mocked_googleworkspace_provider()
with (
patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=mock_provider,
),
patch(
"prowler.providers.googleworkspace.services.directory.directory_super_admin_count.directory_super_admin_count.directory_client"
) as mock_directory_client,
):
from prowler.providers.googleworkspace.services.directory.directory_super_admin_count.directory_super_admin_count import (
directory_super_admin_count,
)
mock_directory_client.users = users
mock_directory_client.provider = mock_provider
check = directory_super_admin_count()
findings = check.execute()
assert len(findings) == 1
assert findings[0].status == "FAIL"
assert "10 super administrator(s)" in findings[0].status_extended