mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-03-21 18:58:04 +00:00
feat(googleworkspace): add Google Workspace provider with directory service and super admin check (#10022)
This commit is contained in:
8
.github/labeler.yml
vendored
8
.github/labeler.yml
vendored
@@ -62,6 +62,11 @@ provider/openstack:
|
||||
- any-glob-to-any-file: "prowler/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:
|
||||
- changed-files:
|
||||
- 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/cloudflare/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/providers/aws/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/cloudflare/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:
|
||||
- changed-files:
|
||||
|
||||
24
.github/workflows/sdk-tests.yml
vendored
24
.github/workflows/sdk-tests.yml
vendored
@@ -438,6 +438,30 @@ jobs:
|
||||
flags: prowler-py${{ matrix.python-version }}-openstack
|
||||
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
|
||||
- name: Check if Lib files changed
|
||||
if: steps.check-changes.outputs.any_changed == 'true'
|
||||
|
||||
@@ -228,6 +228,13 @@
|
||||
"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",
|
||||
"pages": [
|
||||
|
||||
156
docs/user-guide/providers/googleworkspace/authentication.mdx
Normal file
156
docs/user-guide/providers/googleworkspace/authentication.mdx
Normal 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
|
||||
@@ -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 (2–4):
|
||||
|
||||
- **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
|
||||
@@ -6,6 +6,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
|
||||
### 🚀 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_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)
|
||||
|
||||
@@ -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.gcp.models import GCPOutputOptions
|
||||
from prowler.providers.github.models import GithubOutputOptions
|
||||
from prowler.providers.googleworkspace.models import GoogleWorkspaceOutputOptions
|
||||
from prowler.providers.iac.models import IACOutputOptions
|
||||
from prowler.providers.image.exceptions.exceptions import ImageBaseException
|
||||
from prowler.providers.image.models import ImageOutputOptions
|
||||
@@ -354,6 +355,10 @@ def prowler():
|
||||
output_options = M365OutputOptions(
|
||||
args, bulk_checks_metadata, global_provider.identity
|
||||
)
|
||||
elif provider == "googleworkspace":
|
||||
output_options = GoogleWorkspaceOutputOptions(
|
||||
args, bulk_checks_metadata, global_provider.identity
|
||||
)
|
||||
elif provider == "mongodbatlas":
|
||||
output_options = MongoDBAtlasOutputOptions(
|
||||
args, bulk_checks_metadata, global_provider.identity
|
||||
|
||||
@@ -57,6 +57,7 @@ class Provider(str, Enum):
|
||||
KUBERNETES = "kubernetes"
|
||||
M365 = "m365"
|
||||
GITHUB = "github"
|
||||
GOOGLEWORKSPACE = "googleworkspace"
|
||||
IAC = "iac"
|
||||
NHN = "nhn"
|
||||
MONGODBATLAS = "mongodbatlas"
|
||||
|
||||
32
prowler/config/googleworkspace_mutelist_example.yaml
Normal file
32
prowler/config/googleworkspace_mutelist_example.yaml
Normal 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
|
||||
@@ -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
|
||||
class CheckReportCloudflare(Check_Report):
|
||||
"""Contains the Cloudflare Check's finding information.
|
||||
|
||||
@@ -27,16 +27,17 @@ class ProwlerArgumentParser:
|
||||
self.parser = argparse.ArgumentParser(
|
||||
prog="prowler",
|
||||
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="""
|
||||
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
|
||||
azure Azure Provider
|
||||
gcp GCP Provider
|
||||
kubernetes Kubernetes Provider
|
||||
m365 Microsoft 365 Provider
|
||||
github GitHub Provider
|
||||
googleworkspace Google Workspace Provider
|
||||
cloudflare Cloudflare Provider
|
||||
oraclecloud Oracle Cloud Infrastructure Provider
|
||||
openstack OpenStack Provider
|
||||
|
||||
@@ -278,6 +278,20 @@ class Finding(BaseModel):
|
||||
output_data["resource_uid"] = check_output.resource_id
|
||||
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":
|
||||
output_data["auth_method"] = "api_key"
|
||||
output_data["account_uid"] = get_nested_attribute(
|
||||
|
||||
@@ -1282,6 +1282,55 @@ class HTML(Output):
|
||||
)
|
||||
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
|
||||
def get_assessment_summary(provider: Provider) -> str:
|
||||
"""
|
||||
|
||||
@@ -36,6 +36,8 @@ def stdout_report(finding, color, verbose, status, fix):
|
||||
details = finding.region
|
||||
if finding.check_metadata.Provider == "cloudflare":
|
||||
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 finding.muted:
|
||||
|
||||
@@ -51,6 +51,9 @@ def display_summary_table(
|
||||
elif provider.type == "m365":
|
||||
entity_type = "Tenant Domain"
|
||||
audited_entities = provider.identity.tenant_domain
|
||||
elif provider.type == "googleworkspace":
|
||||
entity_type = "Domain"
|
||||
audited_entities = provider.identity.domain
|
||||
elif provider.type == "mongodbatlas":
|
||||
entity_type = "Organization"
|
||||
audited_entities = provider.identity.organization_name
|
||||
|
||||
@@ -248,6 +248,12 @@ class Provider(ABC):
|
||||
repositories=arguments.repository,
|
||||
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():
|
||||
provider_class(
|
||||
filter_zones=arguments.region,
|
||||
|
||||
0
prowler/providers/googleworkspace/__init__.py
Normal file
0
prowler/providers/googleworkspace/__init__.py
Normal file
119
prowler/providers/googleworkspace/exceptions/exceptions.py
Normal file
119
prowler/providers/googleworkspace/exceptions/exceptions.py
Normal 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
|
||||
)
|
||||
527
prowler/providers/googleworkspace/googleworkspace_provider.py
Normal file
527
prowler/providers/googleworkspace/googleworkspace_provider.py
Normal 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)
|
||||
0
prowler/providers/googleworkspace/lib/__init__.py
Normal file
0
prowler/providers/googleworkspace/lib/__init__.py
Normal 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",
|
||||
)
|
||||
17
prowler/providers/googleworkspace/lib/mutelist/mutelist.py
Normal file
17
prowler/providers/googleworkspace/lib/mutelist/mutelist.py
Normal 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)),
|
||||
)
|
||||
77
prowler/providers/googleworkspace/lib/service/service.py
Normal file
77
prowler/providers/googleworkspace/lib/service/service.py
Normal 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}"
|
||||
)
|
||||
43
prowler/providers/googleworkspace/models.py
Normal file
43
prowler/providers/googleworkspace/models.py
Normal 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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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": ""
|
||||
}
|
||||
@@ -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
|
||||
@@ -17,7 +17,7 @@ prowler_command = "prowler"
|
||||
|
||||
# capsys
|
||||
# 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():
|
||||
@@ -28,6 +28,7 @@ def mock_get_available_providers():
|
||||
"kubernetes",
|
||||
"m365",
|
||||
"github",
|
||||
"googleworkspace",
|
||||
"iac",
|
||||
"image",
|
||||
"nhn",
|
||||
|
||||
@@ -428,6 +428,40 @@ class TestFinding:
|
||||
assert finding_output.metadata.Notes == "mock_notes"
|
||||
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):
|
||||
# Mock provider
|
||||
provider = MagicMock()
|
||||
|
||||
@@ -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.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.googleworkspace.googleworkspace_fixtures import (
|
||||
set_mocked_googleworkspace_provider,
|
||||
)
|
||||
from tests.providers.kubernetes.kubernetes_fixtures import (
|
||||
set_mocked_kubernetes_provider,
|
||||
)
|
||||
@@ -910,6 +913,24 @@ class TestHTML:
|
||||
|
||||
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):
|
||||
"""Test Image HTML assessment summary with registry URL."""
|
||||
findings = [generate_finding_output()]
|
||||
|
||||
@@ -1196,6 +1196,46 @@ class TestReport:
|
||||
)
|
||||
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):
|
||||
# Mocking check_findings and provider
|
||||
check_findings = []
|
||||
|
||||
57
tests/providers/googleworkspace/googleworkspace_fixtures.py
Normal file
57
tests/providers/googleworkspace/googleworkspace_fixtures.py
Normal 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
|
||||
363
tests/providers/googleworkspace/googleworkspace_provider_test.py
Normal file
363
tests/providers/googleworkspace/googleworkspace_provider_test.py
Normal 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,
|
||||
)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user