mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-24 12:24:17 +00:00
feat(googleworkspace): explain findings gated by the Workspace edition (#13113)
This commit is contained in:
1 parent
13202b8a70
commit
8c5357b087
23 files changed
+1103
-13
No files matched your search
@@ -25,6 +25,14 @@ Prowler requests the following read-only OAuth 2.0 scopes:
|
||||
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>
|
||||
|
||||
### Optional Scope for Edition Context
|
||||
|
||||
Prowler can read which Google Workspace editions the tenant holds, Cloud Identity Premium included, and state in a finding when the audited feature is not sold on them (for example, Data Loss Prevention (DLP) for Drive or Google session control). This needs the Enterprise License Manager API enabled in the GCP project (Step 2) and the scope `https://www.googleapis.com/auth/apps.licensing` added to the Domain-Wide Delegation list (Step 5). Without the scope, every check runs the same and findings carry no edition context.
|
||||
|
||||
<Warning>
|
||||
The `apps.licensing` scope is Google's "view and manage licenses" scope and has no read-only variant. Prowler only lists license assignments, but anyone holding the Service Account key could assign or revoke licenses with it on behalf of the super administrator. Grant it only if you want the edition context, and protect the key accordingly.
|
||||
</Warning>
|
||||
|
||||
## Setup Steps
|
||||
|
||||
### Step 1: Create a Google Cloud Platform (GCP) Project (if Needed)
|
||||
@@ -41,6 +49,7 @@ In the [Google Cloud Console](https://console.cloud.google.com), select the targ
|
||||
|-----|--------------|--------------|
|
||||
| **[Admin SDK API](https://console.cloud.google.com/apis/library/admin.googleapis.com)** | `admin.googleapis.com` | Directory service checks (users, roles, domains) |
|
||||
| **[Cloud Identity API](https://console.cloud.google.com/apis/library/cloudidentity.googleapis.com)** | `cloudidentity.googleapis.com` | All service checks except Directory (domain-level application policies) |
|
||||
| **[Enterprise License Manager API](https://console.cloud.google.com/apis/library/licensing.googleapis.com)** | `licensing.googleapis.com` | Optional: edition context in findings (see Optional Scope for Edition Context) |
|
||||
|
||||
For each API:
|
||||
|
||||
@@ -49,7 +58,7 @@ For each API:
|
||||
3. Click **Enable**
|
||||
|
||||
<Note>
|
||||
Both APIs must be enabled in the same GCP project that hosts the Service Account. All service checks except Directory will return no findings if the Cloud Identity API is not enabled. The Identity and Access Management (IAM) API (`iam.googleapis.com`) is a different API: enabling it does not replace the Cloud Identity API.
|
||||
The Admin SDK API and the Cloud Identity API must be enabled in the same GCP project that hosts the Service Account. All service checks except Directory will return no findings if the Cloud Identity API is not enabled. The Identity and Access Management (IAM) API (`iam.googleapis.com`) is a different API: enabling it does not replace the Cloud Identity API.
|
||||
</Note>
|
||||
|
||||
### Step 3: Create a Service Account
|
||||
@@ -114,6 +123,8 @@ The constraint only blocks new key creation. Keys that already exist keep workin
|
||||
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,https://www.googleapis.com/auth/admin.directory.orgunit.readonly,https://www.googleapis.com/auth/cloud-identity.policies.readonly,https://www.googleapis.com/auth/admin.directory.rolemanagement.readonly
|
||||
```
|
||||
|
||||
To enable the optional edition context, append `,https://www.googleapis.com/auth/apps.licensing` to the list.
|
||||
|
||||
7. Click **Authorize**
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
`drive_desktop_access_disabled` remediation steps for Google Workspace now point to the Google Drive for desktop card in the Admin console instead of Features and Applications
|
||||
@@ -0,0 +1 @@
|
||||
Google Workspace findings state when a feature is not sold on the tenant edition: Drive DLP rules, Google session control and the shared drive sharing settings on Business Starter; reading editions needs the optional `apps.licensing` scope
|
||||
@@ -26,6 +26,10 @@ from prowler.providers.googleworkspace.exceptions.exceptions import (
|
||||
GoogleWorkspaceNoCredentialsError,
|
||||
GoogleWorkspaceSetUpIdentityError,
|
||||
)
|
||||
from prowler.providers.googleworkspace.lib.licensing import (
|
||||
LICENSING_SCOPE,
|
||||
fetch_editions,
|
||||
)
|
||||
from prowler.providers.googleworkspace.lib.mutelist.mutelist import (
|
||||
GoogleWorkspaceMutelist,
|
||||
)
|
||||
@@ -116,6 +120,8 @@ class GoogleworkspaceProvider(Provider):
|
||||
resolved_delegated_user,
|
||||
)
|
||||
self._domain_resource = GoogleWorkspaceResource.from_identity(self._identity)
|
||||
# Licensed editions, read on first use: see the `editions` property.
|
||||
self._editions = None
|
||||
|
||||
# Audit Config
|
||||
if config_content:
|
||||
@@ -152,6 +158,38 @@ class GoogleworkspaceProvider(Provider):
|
||||
"""Returns the identity information for the Google Workspace provider."""
|
||||
return self._identity
|
||||
|
||||
@property
|
||||
def editions(self) -> list[str]:
|
||||
"""Google Workspace editions licensed to the tenant, read once, best effort.
|
||||
|
||||
Google sells some audited features only on certain editions, and a
|
||||
finding can say so when every licensed edition lacks the feature. The
|
||||
lookup needs the Enterprise License Manager API and the
|
||||
`apps.licensing` scope, requested as a separate token so a scope the
|
||||
admin has not granted never breaks the session. Without them the list
|
||||
stays empty and findings carry no edition context.
|
||||
"""
|
||||
if self._editions is None:
|
||||
self._editions = self._fetch_editions()
|
||||
return self._editions
|
||||
|
||||
def _fetch_editions(self) -> list[str]:
|
||||
"""Build a Licensing API client with its own scope and read the editions."""
|
||||
try:
|
||||
service = build(
|
||||
"licensing",
|
||||
"v1",
|
||||
credentials=self._session.credentials.with_scopes([LICENSING_SCOPE]),
|
||||
cache_discovery=False,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"Google Workspace editions could not be read: the Licensing "
|
||||
f"client could not be built ({error.__class__.__name__}: {error})."
|
||||
)
|
||||
return []
|
||||
return fetch_editions(service, self._identity.customer_id)
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
"""Returns the type of the Google Workspace provider."""
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Google Workspace editions, read from the Enterprise License Manager API.
|
||||
|
||||
Google sells some audited features only on certain editions. Knowing which
|
||||
editions a tenant holds lets a finding say when a missing feature is a matter
|
||||
of the subscription rather than of configuration, the way the Vercel checks
|
||||
explain plan-gated features.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
|
||||
# The Licensing API has no read-only scope; Prowler only lists assignments.
|
||||
LICENSING_SCOPE = "https://www.googleapis.com/auth/apps.licensing"
|
||||
WORKSPACE_PRODUCT_ID = "Google-Apps"
|
||||
# Cloud Identity Premium is a product of its own that a user can hold next to
|
||||
# a Workspace license. Google grants it some gated features (DLP for Drive,
|
||||
# Google session control), so a tenant that holds it never gets an excuse.
|
||||
CLOUD_IDENTITY_PREMIUM_PRODUCT_ID = "101005"
|
||||
CLOUD_IDENTITY_PREMIUM = "Cloud Identity Premium"
|
||||
|
||||
# SKU ids as listed in the Licensing API products table:
|
||||
# https://developers.google.com/workspace/admin/licensing/v1/how-tos/products
|
||||
# Education SKUs live under other products (Google-Apps-For-Education, 101031,
|
||||
# 101037) and are not read: an Education tenant reports no editions and its
|
||||
# findings carry no edition context.
|
||||
SKU_EDITIONS = {
|
||||
"1010020027": "Business Starter",
|
||||
"1010020028": "Business Standard",
|
||||
"1010020025": "Business Plus",
|
||||
"1010020029": "Enterprise Starter",
|
||||
"1010020026": "Enterprise Standard",
|
||||
"1010020020": "Enterprise Plus",
|
||||
"1010060001": "Essentials",
|
||||
"1010060003": "Enterprise Essentials",
|
||||
"1010060005": "Enterprise Essentials Plus",
|
||||
"1010020030": "Frontline Starter",
|
||||
"1010020031": "Frontline Standard",
|
||||
"1010020034": "Frontline Plus",
|
||||
"1010020035": "Business Continuity",
|
||||
"1010020036": "Business Continuity Plus",
|
||||
"Google-Apps-For-Business": "G Suite Basic",
|
||||
"Google-Apps-Unlimited": "G Suite Business",
|
||||
"Google-Apps-Lite": "G Suite Lite",
|
||||
}
|
||||
|
||||
# Google, "About DLP for Drive", lists the supported editions: Frontline
|
||||
# Standard and Plus, Enterprise Standard and Plus, Education Fundamentals,
|
||||
# Standard and Plus, and Enterprise Essentials Plus. The set names the editions
|
||||
# Google sells today without it. The legacy G Suite Business and the Business
|
||||
# Continuity SKUs are left out on purpose: Google documents no entitlement for
|
||||
# them either way, so they get no excuse.
|
||||
EDITIONS_WITHOUT_DRIVE_DLP = {
|
||||
"Business Starter",
|
||||
"Business Standard",
|
||||
"Business Plus",
|
||||
"Enterprise Starter",
|
||||
"Enterprise Essentials",
|
||||
"Essentials",
|
||||
"Frontline Starter",
|
||||
"G Suite Basic",
|
||||
"G Suite Lite",
|
||||
}
|
||||
DRIVE_DLP_EXPLANATION = (
|
||||
"Drive data protection rules are not available on the Google Workspace "
|
||||
"{editions}."
|
||||
)
|
||||
|
||||
# Google, "Set session length for Google services", lists the supported
|
||||
# editions: Frontline Standard and Plus, Business Plus, Enterprise Standard and
|
||||
# Plus, Education Fundamentals, Standard and Plus, Enterprise Essentials Plus,
|
||||
# G Suite Business and Cloud Identity Premium. The Admin console has no Google
|
||||
# session control page on the other editions. The set names the editions
|
||||
# Google sells today without it; the Business Continuity SKUs are left out
|
||||
# because Google lists them neither way.
|
||||
EDITIONS_WITHOUT_SESSION_CONTROL = {
|
||||
"Business Starter",
|
||||
"Business Standard",
|
||||
"Enterprise Starter",
|
||||
"Enterprise Essentials",
|
||||
"Essentials",
|
||||
"Frontline Starter",
|
||||
"G Suite Basic",
|
||||
"G Suite Lite",
|
||||
}
|
||||
SESSION_CONTROL_EXPLANATION = (
|
||||
"Google session control is not available on the Google Workspace {editions}."
|
||||
)
|
||||
|
||||
# Google, "Manage shared drives as an admin": the default sharing settings are
|
||||
# "Not supported for Business Starter. All shared drive sharing settings are
|
||||
# set to allow." The Admin console greys the controls out on that edition and
|
||||
# new shared drives allow every sharing option; a tenant downgraded from
|
||||
# another edition may still display its earlier values. Only Business Starter
|
||||
# is named because Google documents the behaviour for it alone. Editions
|
||||
# missing from the supported list (Frontline Starter and Standard, Enterprise
|
||||
# Starter, G Suite Basic and Lite) get no excuse.
|
||||
EDITIONS_WITHOUT_SHARED_DRIVE_SETTINGS = {"Business Starter"}
|
||||
SHARED_DRIVE_SETTINGS_EXPLANATION = (
|
||||
"shared drive settings cannot be changed on the Google Workspace {editions}, "
|
||||
"where new shared drives allow every sharing option."
|
||||
)
|
||||
|
||||
|
||||
def fetch_editions(service, customer_id: str) -> list[str]:
|
||||
"""Return the Google Workspace editions licensed to the customer.
|
||||
|
||||
Reads every license assignment for the Google Workspace product and names
|
||||
each distinct SKU. An unknown SKU keeps the name the API gives, or its id.
|
||||
Cloud Identity Premium is read as a presence test on its own product and
|
||||
listed as an edition when any user holds it.
|
||||
Returns an empty list when the API cannot be read, which callers treat as
|
||||
"edition unknown": the Licensing API needs its own scope and enablement,
|
||||
and nothing else depends on it.
|
||||
"""
|
||||
if service is None:
|
||||
return []
|
||||
|
||||
try:
|
||||
editions = _workspace_editions(service, customer_id)
|
||||
if editions is None:
|
||||
# A client that is not wired for licensing (a bare mock in the
|
||||
# check tests) answers with something that is not a page.
|
||||
return []
|
||||
if _has_cloud_identity_premium(service, customer_id):
|
||||
editions.add(CLOUD_IDENTITY_PREMIUM)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
f"Google Workspace editions could not be read "
|
||||
f"({error.__class__.__name__}: {error}). Findings will not say when a "
|
||||
f"feature is not sold on the tenant's edition. Enable the Enterprise "
|
||||
f"License Manager API and authorize the {LICENSING_SCOPE} scope to "
|
||||
f"add that context."
|
||||
)
|
||||
return []
|
||||
|
||||
editions.discard("")
|
||||
return sorted(editions)
|
||||
|
||||
|
||||
def _workspace_editions(service, customer_id: str) -> Optional[set[str]]:
|
||||
"""Name every Google Workspace SKU assigned to a user, across all pages."""
|
||||
editions: set[str] = set()
|
||||
page_token: Optional[str] = None
|
||||
while True:
|
||||
response = (
|
||||
service.licenseAssignments()
|
||||
.listForProduct(
|
||||
productId=WORKSPACE_PRODUCT_ID,
|
||||
customerId=customer_id,
|
||||
maxResults=1000,
|
||||
pageToken=page_token,
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
if not isinstance(response, dict):
|
||||
return None
|
||||
for item in response.get("items", []):
|
||||
sku_id = item.get("skuId", "")
|
||||
editions.add(SKU_EDITIONS.get(sku_id) or item.get("skuName") or sku_id)
|
||||
page_token = response.get("nextPageToken")
|
||||
if not page_token:
|
||||
return editions
|
||||
|
||||
|
||||
def _has_cloud_identity_premium(service, customer_id: str) -> bool:
|
||||
"""Tell whether any user holds a Cloud Identity Premium license.
|
||||
|
||||
One assignment is enough, so a single entry is requested. Google does not
|
||||
document how the API answers for a product the customer never subscribed
|
||||
to: an empty page and a 400 or 404 both mean "not held" here, since the
|
||||
Google Workspace listing just succeeded with the same credentials and
|
||||
customer id. Any other failure propagates and leaves the editions unknown,
|
||||
so no finding claims the feature is missing.
|
||||
"""
|
||||
try:
|
||||
response = (
|
||||
service.licenseAssignments()
|
||||
.listForProduct(
|
||||
productId=CLOUD_IDENTITY_PREMIUM_PRODUCT_ID,
|
||||
customerId=customer_id,
|
||||
maxResults=1,
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
except HttpError as error:
|
||||
if error.resp.status in (400, 404):
|
||||
return False
|
||||
raise
|
||||
return isinstance(response, dict) and bool(response.get("items"))
|
||||
|
||||
|
||||
def edition_reason_suffix(
|
||||
editions, unsupported_editions: set[str], explanation: str
|
||||
) -> str:
|
||||
"""Return an edition-based explanation suffix only when the editions prove it.
|
||||
|
||||
The suffix is added when every licensed edition lacks the feature; a tenant
|
||||
that also holds a supported edition gets no excuse, and unknown editions
|
||||
give none either. ``explanation`` may name the editions with ``{editions}``,
|
||||
which expands to "Business Starter edition" or "Business Standard and
|
||||
Business Starter editions".
|
||||
"""
|
||||
# The check tests run with a bare mock provider whose editions attribute
|
||||
# is a mock as well; anything that is not a real sequence counts as unknown.
|
||||
if not isinstance(editions, (list, tuple, set)) or not editions:
|
||||
return ""
|
||||
if not all(edition in unsupported_editions for edition in editions):
|
||||
return ""
|
||||
return (
|
||||
" This may be expected because "
|
||||
f"{explanation.format(editions=_edition_phrase(editions))}"
|
||||
)
|
||||
|
||||
|
||||
def _edition_phrase(editions) -> str:
|
||||
names = list(editions)
|
||||
if len(names) == 1:
|
||||
return f"{names[0]} edition"
|
||||
return f"{', '.join(names[:-1])} and {names[-1]} editions"
|
||||
+1
-1
@@ -20,7 +20,7 @@
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Drive and Docs**\n3. Click **Features and Applications** > **Google Drive for desktop**\n4. **Uncheck** *Allow Google Drive for desktop in your organization*\n5. Click **Save**",
|
||||
"Other": "1. Sign in to the Google **Admin console** at https://admin.google.com\n2. Navigate to **Apps** > **Google Workspace** > **Drive and Docs**\n3. Open the **Google Drive for desktop** card (its own section on the Drive and Docs settings page, below Features and Applications)\n4. **Uncheck** *Allow Google Drive for desktop in your organization*\n5. Click **Save**",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
|
||||
+1
-1
@@ -35,5 +35,5 @@
|
||||
"drive_shared_drive_managers_cannot_override",
|
||||
"drive_shared_drive_members_only_access"
|
||||
],
|
||||
"Notes": ""
|
||||
"Notes": "Not configurable on Business Starter: new shared drives on that edition allow every sharing option and the Admin console does not let you change the defaults; a tenant downgraded from another edition may still display its earlier values. When the Licensing API scope is authorized and every edition the tenant holds is Business Starter, the finding says so."
|
||||
}
|
||||
+9
-1
@@ -1,6 +1,11 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
|
||||
from prowler.providers.googleworkspace.lib.licensing import (
|
||||
EDITIONS_WITHOUT_SHARED_DRIVE_SETTINGS,
|
||||
SHARED_DRIVE_SETTINGS_EXPLANATION,
|
||||
edition_reason_suffix,
|
||||
)
|
||||
from prowler.providers.googleworkspace.services.drive.drive_client import drive_client
|
||||
|
||||
|
||||
@@ -10,7 +15,9 @@ class drive_shared_drive_disable_download_print_copy(Check):
|
||||
This check verifies that the domain-level Drive and Docs policy prevents
|
||||
viewers and commenters of shared drive files from downloading, printing,
|
||||
or copying their contents — limiting them to read and comment actions
|
||||
only and reducing the risk of bulk data exfiltration.
|
||||
only and reducing the risk of bulk data exfiltration. The setting is
|
||||
edition-gated: when every edition the tenant holds lacks it, the finding
|
||||
says so.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportGoogleWorkspace]:
|
||||
@@ -48,6 +55,7 @@ class drive_shared_drive_disable_download_print_copy(Check):
|
||||
f"Download, print, and copy in shared drives in domain "
|
||||
f"{drive_client.provider.identity.domain} is set to {allowed}. "
|
||||
f"These actions should be restricted to editors or managers only."
|
||||
f"{edition_reason_suffix(drive_client.provider.editions, EDITIONS_WITHOUT_SHARED_DRIVE_SETTINGS, SHARED_DRIVE_SETTINGS_EXPLANATION)}"
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
+1
-1
@@ -35,5 +35,5 @@
|
||||
"drive_shared_drive_members_only_access",
|
||||
"drive_shared_drive_disable_download_print_copy"
|
||||
],
|
||||
"Notes": ""
|
||||
"Notes": "Not configurable on Business Starter: new shared drives on that edition allow every sharing option and the Admin console does not let you change the defaults; a tenant downgraded from another edition may still display its earlier values. When the Licensing API scope is authorized and every edition the tenant holds is Business Starter, the finding says so."
|
||||
}
|
||||
+14
-1
@@ -1,6 +1,11 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
|
||||
from prowler.providers.googleworkspace.lib.licensing import (
|
||||
EDITIONS_WITHOUT_SHARED_DRIVE_SETTINGS,
|
||||
SHARED_DRIVE_SETTINGS_EXPLANATION,
|
||||
edition_reason_suffix,
|
||||
)
|
||||
from prowler.providers.googleworkspace.services.drive.drive_client import drive_client
|
||||
|
||||
|
||||
@@ -10,7 +15,8 @@ class drive_shared_drive_managers_cannot_override(Check):
|
||||
This check verifies that the domain-level Drive and Docs policy prevents
|
||||
members with manager access from overriding the shared drive settings
|
||||
configured by administrators, ensuring that security controls cannot be
|
||||
relaxed at the shared drive level.
|
||||
relaxed at the shared drive level. The setting is edition-gated: when
|
||||
every edition the tenant holds lacks it, the finding says so.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportGoogleWorkspace]:
|
||||
@@ -36,12 +42,18 @@ class drive_shared_drive_managers_cannot_override(Check):
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
edition_reason = edition_reason_suffix(
|
||||
drive_client.provider.editions,
|
||||
EDITIONS_WITHOUT_SHARED_DRIVE_SETTINGS,
|
||||
SHARED_DRIVE_SETTINGS_EXPLANATION,
|
||||
)
|
||||
if allow_override is None:
|
||||
report.status_extended = (
|
||||
f"Manager override of shared drive settings is not "
|
||||
f"explicitly configured in domain "
|
||||
f"{drive_client.provider.identity.domain}. "
|
||||
f"Managers should not be allowed to override shared drive settings."
|
||||
f"{edition_reason}"
|
||||
)
|
||||
else:
|
||||
report.status_extended = (
|
||||
@@ -49,6 +61,7 @@ class drive_shared_drive_managers_cannot_override(Check):
|
||||
f"{drive_client.provider.identity.domain} are allowed to "
|
||||
f"override shared drive settings. "
|
||||
f"Managers should not be allowed to override shared drive settings."
|
||||
f"{edition_reason}"
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
+1
-1
@@ -35,5 +35,5 @@
|
||||
"drive_shared_drive_managers_cannot_override",
|
||||
"drive_shared_drive_disable_download_print_copy"
|
||||
],
|
||||
"Notes": ""
|
||||
"Notes": "Not configurable on Business Starter: new shared drives on that edition allow every sharing option and the Admin console does not let you change the defaults; a tenant downgraded from another edition may still display its earlier values. When the Licensing API scope is authorized and every edition the tenant holds is Business Starter, the finding says so."
|
||||
}
|
||||
+14
-1
@@ -1,6 +1,11 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
|
||||
from prowler.providers.googleworkspace.lib.licensing import (
|
||||
EDITIONS_WITHOUT_SHARED_DRIVE_SETTINGS,
|
||||
SHARED_DRIVE_SETTINGS_EXPLANATION,
|
||||
edition_reason_suffix,
|
||||
)
|
||||
from prowler.providers.googleworkspace.services.drive.drive_client import drive_client
|
||||
|
||||
|
||||
@@ -10,7 +15,8 @@ class drive_shared_drive_members_only_access(Check):
|
||||
This check verifies that the domain-level Drive and Docs policy prevents
|
||||
people who are not shared drive members from being added to files within
|
||||
a shared drive, restricting file access to that drive's explicit
|
||||
membership.
|
||||
membership. The setting is edition-gated: when every edition the tenant
|
||||
holds lacks it, the finding says so.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportGoogleWorkspace]:
|
||||
@@ -36,11 +42,17 @@ class drive_shared_drive_members_only_access(Check):
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
edition_reason = edition_reason_suffix(
|
||||
drive_client.provider.editions,
|
||||
EDITIONS_WITHOUT_SHARED_DRIVE_SETTINGS,
|
||||
SHARED_DRIVE_SETTINGS_EXPLANATION,
|
||||
)
|
||||
if allow_non_member is None:
|
||||
report.status_extended = (
|
||||
f"Shared drive non-member access is not explicitly "
|
||||
f"configured in domain {drive_client.provider.identity.domain}. "
|
||||
f"Shared drive file access should be restricted to members only."
|
||||
f"{edition_reason}"
|
||||
)
|
||||
else:
|
||||
report.status_extended = (
|
||||
@@ -48,6 +60,7 @@ class drive_shared_drive_members_only_access(Check):
|
||||
f"{drive_client.provider.identity.domain} allows non-members "
|
||||
f"to be added to files. "
|
||||
f"Shared drive file access should be restricted to members only."
|
||||
f"{edition_reason}"
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
+2
-1
@@ -14,6 +14,7 @@
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://knowledge.workspace.google.com/admin/security/about-dlp",
|
||||
"https://knowledge.workspace.google.com/admin/security/about-dlp-for-drive",
|
||||
"https://docs.cloud.google.com/identity/docs/concepts/supported-policy-api-settings"
|
||||
],
|
||||
"Remediation": {
|
||||
@@ -33,5 +34,5 @@
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
"Notes": "Required edition: Frontline Standard or Plus, Enterprise Standard or Plus, Education Fundamentals, Standard or Plus, or Enterprise Essentials Plus; Cloud Identity Premium users also get DLP for Drive when their Google Workspace license includes Drive log events. When the Licensing API scope is authorized and every edition the tenant holds lacks DLP for Drive, the finding says so; a Cloud Identity Premium subscription suppresses that explanation."
|
||||
}
|
||||
+9
-1
@@ -1,6 +1,11 @@
|
||||
from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
|
||||
from prowler.providers.googleworkspace.lib.licensing import (
|
||||
DRIVE_DLP_EXPLANATION,
|
||||
EDITIONS_WITHOUT_DRIVE_DLP,
|
||||
edition_reason_suffix,
|
||||
)
|
||||
from prowler.providers.googleworkspace.services.security.security_client import (
|
||||
security_client,
|
||||
)
|
||||
@@ -11,7 +16,9 @@ class security_dlp_drive_rules_configured(Check):
|
||||
|
||||
This check verifies that at least one active Data Loss Prevention (DLP)
|
||||
rule targeting Google Drive file sharing exists, helping to prevent
|
||||
unintended exposure of sensitive information.
|
||||
unintended exposure of sensitive information. DLP for Drive is
|
||||
edition-gated: when every edition the tenant holds lacks it, the finding
|
||||
says so. A Cloud Identity Premium subscription counts as holding it.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportGoogleWorkspace]:
|
||||
@@ -43,6 +50,7 @@ class security_dlp_drive_rules_configured(Check):
|
||||
f"in domain {domain}. DLP rules should be configured "
|
||||
f"to detect and prevent sharing of sensitive information "
|
||||
f"through Drive."
|
||||
f"{edition_reason_suffix(security_client.provider.editions, EDITIONS_WITHOUT_DRIVE_DLP, DRIVE_DLP_EXPLANATION)}"
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
+1
-1
@@ -33,5 +33,5 @@
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
"Notes": "Required edition: Frontline Standard or Plus, Business Plus, Enterprise Standard or Plus, Education Fundamentals, Standard or Plus, Enterprise Essentials Plus, G Suite Business, or Cloud Identity Premium; the Admin console has no Google session control page on the other editions and the web session stays at Google's 14-day default. When the Licensing API scope is authorized and every edition the tenant holds lacks it, the finding says so."
|
||||
}
|
||||
+23
-1
@@ -2,6 +2,11 @@ from typing import List
|
||||
|
||||
from prowler.lib.check.models import Check, CheckReportGoogleWorkspace
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.googleworkspace.lib.licensing import (
|
||||
EDITIONS_WITHOUT_SESSION_CONTROL,
|
||||
SESSION_CONTROL_EXPLANATION,
|
||||
edition_reason_suffix,
|
||||
)
|
||||
from prowler.providers.googleworkspace.services.security.security_client import (
|
||||
security_client,
|
||||
)
|
||||
@@ -9,12 +14,27 @@ from prowler.providers.googleworkspace.services.security.security_client import
|
||||
MAX_SESSION_DURATION_SECONDS = 43200 # 12 hours
|
||||
|
||||
|
||||
def _edition_suffix() -> str:
|
||||
"""Read the editions only when a finding is about to carry the suffix.
|
||||
|
||||
The provider reads the editions on first use, so a passing tenant never
|
||||
pays the Licensing API calls nor sees its authorization warning.
|
||||
"""
|
||||
return edition_reason_suffix(
|
||||
security_client.provider.editions,
|
||||
EDITIONS_WITHOUT_SESSION_CONTROL,
|
||||
SESSION_CONTROL_EXPLANATION,
|
||||
)
|
||||
|
||||
|
||||
class security_session_duration_limited(Check):
|
||||
"""Check that Google session control is configured to 12 hours or less.
|
||||
|
||||
This check verifies that the domain-level web session duration is set
|
||||
to 12 hours or less, requiring users to re-authenticate more frequently
|
||||
than the default 14-day session length.
|
||||
than the default 14-day session length. Google session control is
|
||||
edition-gated: when every edition the tenant holds lacks it, the finding
|
||||
says so. A Cloud Identity Premium subscription counts as holding it.
|
||||
"""
|
||||
|
||||
def execute(self) -> List[CheckReportGoogleWorkspace]:
|
||||
@@ -38,6 +58,7 @@ class security_session_duration_limited(Check):
|
||||
f"Google session control is not explicitly configured "
|
||||
f"in domain {domain}. The default is 14 days. "
|
||||
f"Web session duration should be 12 hours or less."
|
||||
f"{_edition_suffix()}"
|
||||
)
|
||||
findings.append(report)
|
||||
return findings
|
||||
@@ -68,6 +89,7 @@ class security_session_duration_limited(Check):
|
||||
f"Google session control is set to {duration_hours:.0f} hours "
|
||||
f"in domain {domain}. "
|
||||
f"Web session duration should be 12 hours or less."
|
||||
f"{_edition_suffix()}"
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
@@ -73,6 +73,130 @@ class TestGoogleWorkspaceProvider:
|
||||
assert provider.domain_resource.name == DOMAIN
|
||||
assert provider._audit_config == {}
|
||||
|
||||
def test_editions_are_read_once_from_the_licensing_api(self):
|
||||
"""The licensed editions let checks say when a feature is not sold on the tenant's edition"""
|
||||
mock_credentials = MagicMock(spec=Credentials)
|
||||
licensing = MagicMock()
|
||||
pages = {"Google-Apps": {"items": [{"skuId": "1010020027"}]}, "101005": {}}
|
||||
|
||||
def list_for_product(productId, **_kwargs):
|
||||
request = MagicMock()
|
||||
request.execute.return_value = pages[productId]
|
||||
return request
|
||||
|
||||
licensing.licenseAssignments().listForProduct.side_effect = list_for_product
|
||||
|
||||
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.build",
|
||||
return_value=licensing,
|
||||
) as build,
|
||||
):
|
||||
provider = GoogleworkspaceProvider(
|
||||
credentials_file="/path/to/credentials.json",
|
||||
delegated_user=DELEGATED_USER,
|
||||
)
|
||||
|
||||
assert provider.editions == ["Business Starter"]
|
||||
assert provider.editions == ["Business Starter"]
|
||||
|
||||
mock_credentials.with_scopes.assert_called_once_with(
|
||||
["https://www.googleapis.com/auth/apps.licensing"]
|
||||
)
|
||||
build.assert_called_once_with(
|
||||
"licensing",
|
||||
"v1",
|
||||
credentials=mock_credentials.with_scopes.return_value,
|
||||
cache_discovery=False,
|
||||
)
|
||||
# One pass over both products, nothing on the second access
|
||||
assert licensing.licenseAssignments().listForProduct.call_count == 2
|
||||
|
||||
def test_editions_stay_unknown_when_the_licensing_api_is_unavailable(self):
|
||||
"""A missing scope or disabled API must not cost anything else"""
|
||||
mock_credentials = MagicMock(spec=Credentials)
|
||||
licensing = MagicMock()
|
||||
licensing.licenseAssignments().listForProduct.return_value.execute.side_effect = Exception(
|
||||
"unauthorized_client"
|
||||
)
|
||||
|
||||
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.build",
|
||||
return_value=licensing,
|
||||
),
|
||||
):
|
||||
provider = GoogleworkspaceProvider(
|
||||
credentials_file="/path/to/credentials.json",
|
||||
delegated_user=DELEGATED_USER,
|
||||
)
|
||||
|
||||
assert provider.editions == []
|
||||
|
||||
def test_editions_stay_unknown_when_the_licensing_client_cannot_be_built(self):
|
||||
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.build",
|
||||
side_effect=Exception("discovery document unavailable"),
|
||||
),
|
||||
):
|
||||
provider = GoogleworkspaceProvider(
|
||||
credentials_file="/path/to/credentials.json",
|
||||
delegated_user=DELEGATED_USER,
|
||||
)
|
||||
|
||||
assert provider.editions == []
|
||||
|
||||
def test_googleworkspace_provider_with_credentials_content(self):
|
||||
"""Test provider initialization with credentials content"""
|
||||
import json
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from googleapiclient.errors import HttpError
|
||||
from httplib2 import Response as HttpResponse
|
||||
|
||||
from prowler.providers.googleworkspace.lib.licensing import (
|
||||
CLOUD_IDENTITY_PREMIUM_PRODUCT_ID,
|
||||
WORKSPACE_PRODUCT_ID,
|
||||
edition_reason_suffix,
|
||||
fetch_editions,
|
||||
)
|
||||
|
||||
CUSTOMER_ID = "C04cw8iil"
|
||||
NO_PREMIUM = {"kind": "licensing#licenseAssignmentList"}
|
||||
PREMIUM = {"items": [{"skuId": "1010050001", "skuName": "Cloud Identity Premium"}]}
|
||||
|
||||
|
||||
def _licensing_service(workspace_pages, premium_pages=(NO_PREMIUM,)):
|
||||
"""Build a Licensing API client mock that answers per product, pages in order.
|
||||
|
||||
A page may be an exception; execute() raises it in its turn.
|
||||
"""
|
||||
pages = {
|
||||
WORKSPACE_PRODUCT_ID: iter(workspace_pages),
|
||||
CLOUD_IDENTITY_PREMIUM_PRODUCT_ID: iter(premium_pages),
|
||||
}
|
||||
|
||||
def list_for_product(productId, **_kwargs):
|
||||
page = next(pages[productId])
|
||||
request = MagicMock()
|
||||
if isinstance(page, Exception):
|
||||
request.execute.side_effect = page
|
||||
else:
|
||||
request.execute.return_value = page
|
||||
return request
|
||||
|
||||
service = MagicMock()
|
||||
service.licenseAssignments.return_value.listForProduct.side_effect = (
|
||||
list_for_product
|
||||
)
|
||||
return service
|
||||
|
||||
|
||||
def _http_error(status):
|
||||
return HttpError(HttpResponse({"status": str(status)}), b"")
|
||||
|
||||
|
||||
class TestFetchEditions:
|
||||
def test_reads_every_page_and_names_each_sku_once(self):
|
||||
service = _licensing_service(
|
||||
[
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"skuId": "1010020027",
|
||||
"skuName": "Google Workspace Business Starter",
|
||||
},
|
||||
{
|
||||
"skuId": "1010020027",
|
||||
"skuName": "Google Workspace Business Starter",
|
||||
},
|
||||
],
|
||||
"nextPageToken": "page-2",
|
||||
},
|
||||
{"items": [{"skuId": "1010020026"}]},
|
||||
]
|
||||
)
|
||||
|
||||
editions = fetch_editions(service, CUSTOMER_ID)
|
||||
|
||||
assert editions == ["Business Starter", "Enterprise Standard"]
|
||||
calls = service.licenseAssignments().listForProduct.call_args_list
|
||||
assert calls[0].kwargs == {
|
||||
"productId": WORKSPACE_PRODUCT_ID,
|
||||
"customerId": CUSTOMER_ID,
|
||||
"maxResults": 1000,
|
||||
"pageToken": None,
|
||||
}
|
||||
assert calls[1].kwargs["pageToken"] == "page-2"
|
||||
assert calls[2].kwargs == {
|
||||
"productId": CLOUD_IDENTITY_PREMIUM_PRODUCT_ID,
|
||||
"customerId": CUSTOMER_ID,
|
||||
"maxResults": 1,
|
||||
}
|
||||
|
||||
def test_an_unknown_sku_keeps_the_name_the_api_gives(self):
|
||||
service = _licensing_service(
|
||||
[{"items": [{"skuId": "9999999999", "skuName": "Google Workspace Future"}]}]
|
||||
)
|
||||
|
||||
assert fetch_editions(service, CUSTOMER_ID) == ["Google Workspace Future"]
|
||||
|
||||
def test_an_unknown_sku_without_a_name_keeps_its_id(self):
|
||||
service = _licensing_service([{"items": [{"skuId": "9999999999"}]}])
|
||||
|
||||
assert fetch_editions(service, CUSTOMER_ID) == ["9999999999"]
|
||||
|
||||
def test_no_licenses(self):
|
||||
assert fetch_editions(_licensing_service([{}]), CUSTOMER_ID) == []
|
||||
|
||||
def test_cloud_identity_premium_counts_as_an_edition(self):
|
||||
"""A Workspace license next to Cloud Identity Premium gets no edition excuse"""
|
||||
service = _licensing_service(
|
||||
[{"items": [{"skuId": "1010020027"}]}], premium_pages=[PREMIUM]
|
||||
)
|
||||
|
||||
assert fetch_editions(service, CUSTOMER_ID) == [
|
||||
"Business Starter",
|
||||
"Cloud Identity Premium",
|
||||
]
|
||||
|
||||
def test_a_product_the_customer_never_subscribed_to_is_not_held(self):
|
||||
"""Google does not document the answer for an unheld product: an error about the product itself means not held"""
|
||||
for status in (400, 404):
|
||||
service = _licensing_service(
|
||||
[{"items": [{"skuId": "1010020027"}]}],
|
||||
premium_pages=[_http_error(status)],
|
||||
)
|
||||
|
||||
assert fetch_editions(service, CUSTOMER_ID) == ["Business Starter"]
|
||||
|
||||
def test_a_failed_cloud_identity_premium_lookup_means_the_editions_are_unknown(
|
||||
self,
|
||||
):
|
||||
"""Nothing may claim a feature is missing while the entitlement is undecided"""
|
||||
for error in (_http_error(500), Exception("backend error")):
|
||||
service = _licensing_service(
|
||||
[{"items": [{"skuId": "1010020027"}]}], premium_pages=[error]
|
||||
)
|
||||
|
||||
assert fetch_editions(service, CUSTOMER_ID) == []
|
||||
|
||||
def test_an_api_error_means_the_editions_are_unknown(self):
|
||||
service = _licensing_service([Exception("insufficient scopes")])
|
||||
|
||||
assert fetch_editions(service, CUSTOMER_ID) == []
|
||||
|
||||
def test_an_unexpected_response_means_the_editions_are_unknown(self):
|
||||
"""A client that is not wired for licensing answers with mocks, never with a page"""
|
||||
assert fetch_editions(MagicMock(), CUSTOMER_ID) == []
|
||||
|
||||
def test_no_service(self):
|
||||
assert fetch_editions(None, CUSTOMER_ID) == []
|
||||
|
||||
|
||||
class TestEditionReasonSuffix:
|
||||
def test_names_the_edition_when_every_licensed_edition_lacks_the_feature(self):
|
||||
suffix = edition_reason_suffix(
|
||||
["Business Starter"],
|
||||
{"Business Starter", "Business Standard"},
|
||||
"Drive data protection rules are not available on the Google Workspace {editions}.",
|
||||
)
|
||||
|
||||
assert suffix == (
|
||||
" This may be expected because Drive data protection rules are not "
|
||||
"available on the Google Workspace Business Starter edition."
|
||||
)
|
||||
|
||||
def test_lists_every_licensed_edition(self):
|
||||
suffix = edition_reason_suffix(
|
||||
["Business Standard", "Business Starter"],
|
||||
{"Business Starter", "Business Standard"},
|
||||
"the feature is not available on the {editions}.",
|
||||
)
|
||||
|
||||
assert suffix == (
|
||||
" This may be expected because the feature is not available on the "
|
||||
"Business Standard and Business Starter editions."
|
||||
)
|
||||
|
||||
def test_separates_three_editions_with_commas(self):
|
||||
suffix = edition_reason_suffix(
|
||||
["Business Plus", "Business Standard", "Business Starter"],
|
||||
{"Business Starter", "Business Standard", "Business Plus"},
|
||||
"the feature is not available on the {editions}.",
|
||||
)
|
||||
|
||||
assert suffix == (
|
||||
" This may be expected because the feature is not available on the "
|
||||
"Business Plus, Business Standard and Business Starter editions."
|
||||
)
|
||||
|
||||
def test_a_supported_edition_in_the_mix_gives_no_excuse(self):
|
||||
suffix = edition_reason_suffix(
|
||||
["Business Starter", "Enterprise Standard"],
|
||||
{"Business Starter"},
|
||||
"unused {editions}",
|
||||
)
|
||||
|
||||
assert suffix == ""
|
||||
|
||||
def test_cloud_identity_premium_gives_no_excuse(self):
|
||||
suffix = edition_reason_suffix(
|
||||
["Business Starter", "Cloud Identity Premium"],
|
||||
{"Business Starter"},
|
||||
"unused {editions}",
|
||||
)
|
||||
|
||||
assert suffix == ""
|
||||
|
||||
def test_unknown_editions_give_no_excuse(self):
|
||||
assert edition_reason_suffix([], {"Business Starter"}, "unused") == ""
|
||||
assert edition_reason_suffix(None, {"Business Starter"}, "unused") == ""
|
||||
assert edition_reason_suffix(MagicMock(), {"Business Starter"}, "unused") == ""
|
||||
+91
@@ -3,6 +3,7 @@ from unittest.mock import patch
|
||||
from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies
|
||||
from tests.providers.googleworkspace.googleworkspace_fixtures import (
|
||||
CUSTOMER_ID,
|
||||
DOMAIN,
|
||||
set_mocked_googleworkspace_provider,
|
||||
)
|
||||
|
||||
@@ -159,6 +160,96 @@ class TestDriveSharedDriveDisableDownloadPrintCopy:
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "turned off" not in findings[0].status_extended
|
||||
|
||||
def test_fail_on_business_starter_says_the_setting_cannot_be_changed(self):
|
||||
"""Google keeps every shared drive setting allowed on Business Starter; the finding names the edition"""
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
mock_provider.editions = ["Business Starter"]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.drive.drive_shared_drive_disable_download_print_copy.drive_shared_drive_disable_download_print_copy.drive_client"
|
||||
) as mock_drive_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.drive.drive_shared_drive_disable_download_print_copy.drive_shared_drive_disable_download_print_copy import (
|
||||
drive_shared_drive_disable_download_print_copy,
|
||||
)
|
||||
|
||||
mock_drive_client.provider = mock_provider
|
||||
mock_drive_client.policies_fetched = True
|
||||
mock_drive_client.policies = DrivePolicies(
|
||||
allowed_parties_for_download_print_copy="ALL"
|
||||
)
|
||||
|
||||
findings = drive_shared_drive_disable_download_print_copy().execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert findings[0].status_extended == (
|
||||
f"Download, print, and copy in shared drives in domain {DOMAIN} is "
|
||||
f"set to ALL. These actions should be restricted to editors or "
|
||||
f"managers only. This may be expected because shared drive settings "
|
||||
f"cannot be changed on the Google Workspace Business Starter edition, "
|
||||
f"where new shared drives allow every sharing option."
|
||||
)
|
||||
|
||||
def test_fail_on_a_supported_edition_gives_no_excuse(self):
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
mock_provider.editions = ["Business Starter", "Business Standard"]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.drive.drive_shared_drive_disable_download_print_copy.drive_shared_drive_disable_download_print_copy.drive_client"
|
||||
) as mock_drive_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.drive.drive_shared_drive_disable_download_print_copy.drive_shared_drive_disable_download_print_copy import (
|
||||
drive_shared_drive_disable_download_print_copy,
|
||||
)
|
||||
|
||||
mock_drive_client.provider = mock_provider
|
||||
mock_drive_client.policies_fetched = True
|
||||
mock_drive_client.policies = DrivePolicies(
|
||||
allowed_parties_for_download_print_copy="ALL"
|
||||
)
|
||||
|
||||
findings = drive_shared_drive_disable_download_print_copy().execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "This may be expected" not in findings[0].status_extended
|
||||
|
||||
def test_no_resources(self):
|
||||
"""No policies could be read, so there is nothing to report"""
|
||||
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.drive.drive_shared_drive_disable_download_print_copy.drive_shared_drive_disable_download_print_copy.drive_client"
|
||||
) as mock_drive_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.drive.drive_shared_drive_disable_download_print_copy.drive_shared_drive_disable_download_print_copy import (
|
||||
drive_shared_drive_disable_download_print_copy,
|
||||
)
|
||||
|
||||
mock_drive_client.provider = mock_provider
|
||||
mock_drive_client.policies_fetched = False
|
||||
mock_drive_client.policies = DrivePolicies()
|
||||
|
||||
findings = drive_shared_drive_disable_download_print_copy().execute()
|
||||
|
||||
assert len(findings) == 0
|
||||
|
||||
def test_no_findings_when_fetch_failed(self):
|
||||
"""Test no findings returned when the API fetch failed"""
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
+62
@@ -3,6 +3,7 @@ from unittest.mock import patch
|
||||
from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies
|
||||
from tests.providers.googleworkspace.googleworkspace_fixtures import (
|
||||
CUSTOMER_ID,
|
||||
DOMAIN,
|
||||
set_mocked_googleworkspace_provider,
|
||||
)
|
||||
|
||||
@@ -129,6 +130,67 @@ class TestDriveSharedDriveManagersCannotOverride:
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "turned off" not in findings[0].status_extended
|
||||
|
||||
def test_fail_on_business_starter_says_the_setting_cannot_be_changed(self):
|
||||
"""Google keeps every shared drive setting allowed on Business Starter; the finding names the edition"""
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
mock_provider.editions = ["Business Starter"]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.drive.drive_shared_drive_managers_cannot_override.drive_shared_drive_managers_cannot_override.drive_client"
|
||||
) as mock_drive_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.drive.drive_shared_drive_managers_cannot_override.drive_shared_drive_managers_cannot_override import (
|
||||
drive_shared_drive_managers_cannot_override,
|
||||
)
|
||||
|
||||
mock_drive_client.provider = mock_provider
|
||||
mock_drive_client.policies_fetched = True
|
||||
mock_drive_client.policies = DrivePolicies(
|
||||
allow_managers_to_override_settings=True
|
||||
)
|
||||
|
||||
findings = drive_shared_drive_managers_cannot_override().execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert findings[0].status_extended == (
|
||||
f"Shared drive managers in domain {DOMAIN} are allowed to override "
|
||||
f"shared drive settings. Managers should not be allowed to override "
|
||||
f"shared drive settings. This may be expected because shared drive "
|
||||
f"settings cannot be changed on the Google Workspace Business Starter "
|
||||
f"edition, where new shared drives allow every sharing option."
|
||||
)
|
||||
|
||||
def test_no_resources(self):
|
||||
"""No policies could be read, so there is nothing to report"""
|
||||
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.drive.drive_shared_drive_managers_cannot_override.drive_shared_drive_managers_cannot_override.drive_client"
|
||||
) as mock_drive_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.drive.drive_shared_drive_managers_cannot_override.drive_shared_drive_managers_cannot_override import (
|
||||
drive_shared_drive_managers_cannot_override,
|
||||
)
|
||||
|
||||
mock_drive_client.provider = mock_provider
|
||||
mock_drive_client.policies_fetched = False
|
||||
mock_drive_client.policies = DrivePolicies()
|
||||
|
||||
findings = drive_shared_drive_managers_cannot_override().execute()
|
||||
|
||||
assert len(findings) == 0
|
||||
|
||||
def test_no_findings_when_fetch_failed(self):
|
||||
"""Test no findings returned when the API fetch failed"""
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
+60
@@ -3,6 +3,7 @@ from unittest.mock import patch
|
||||
from prowler.providers.googleworkspace.services.drive.drive_service import DrivePolicies
|
||||
from tests.providers.googleworkspace.googleworkspace_fixtures import (
|
||||
CUSTOMER_ID,
|
||||
DOMAIN,
|
||||
set_mocked_googleworkspace_provider,
|
||||
)
|
||||
|
||||
@@ -123,6 +124,65 @@ class TestDriveSharedDriveMembersOnlyAccess:
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "turned off" not in findings[0].status_extended
|
||||
|
||||
def test_fail_on_business_starter_says_the_setting_cannot_be_changed(self):
|
||||
"""Google keeps every shared drive setting allowed on Business Starter; the finding names the edition"""
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
mock_provider.editions = ["Business Starter"]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.drive.drive_shared_drive_members_only_access.drive_shared_drive_members_only_access.drive_client"
|
||||
) as mock_drive_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.drive.drive_shared_drive_members_only_access.drive_shared_drive_members_only_access import (
|
||||
drive_shared_drive_members_only_access,
|
||||
)
|
||||
|
||||
mock_drive_client.provider = mock_provider
|
||||
mock_drive_client.policies_fetched = True
|
||||
mock_drive_client.policies = DrivePolicies(allow_non_member_access=True)
|
||||
|
||||
findings = drive_shared_drive_members_only_access().execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert findings[0].status_extended == (
|
||||
f"Shared drive file access in domain {DOMAIN} allows non-members to "
|
||||
f"be added to files. Shared drive file access should be restricted "
|
||||
f"to members only. This may be expected because shared drive "
|
||||
f"settings cannot be changed on the Google Workspace Business Starter "
|
||||
f"edition, where new shared drives allow every sharing option."
|
||||
)
|
||||
|
||||
def test_no_resources(self):
|
||||
"""No policies could be read, so there is nothing to report"""
|
||||
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.drive.drive_shared_drive_members_only_access.drive_shared_drive_members_only_access.drive_client"
|
||||
) as mock_drive_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.drive.drive_shared_drive_members_only_access.drive_shared_drive_members_only_access import (
|
||||
drive_shared_drive_members_only_access,
|
||||
)
|
||||
|
||||
mock_drive_client.provider = mock_provider
|
||||
mock_drive_client.policies_fetched = False
|
||||
mock_drive_client.policies = DrivePolicies()
|
||||
|
||||
findings = drive_shared_drive_members_only_access().execute()
|
||||
|
||||
assert len(findings) == 0
|
||||
|
||||
def test_no_findings_when_fetch_failed(self):
|
||||
"""Test no findings returned when the API fetch failed"""
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
+90
@@ -5,6 +5,7 @@ from prowler.providers.googleworkspace.services.security.security_service import
|
||||
)
|
||||
from tests.providers.googleworkspace.googleworkspace_fixtures import (
|
||||
CUSTOMER_ID,
|
||||
DOMAIN,
|
||||
set_mocked_googleworkspace_provider,
|
||||
)
|
||||
|
||||
@@ -97,6 +98,95 @@ class TestSecurityDlpDriveRulesConfigured:
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "No active" in findings[0].status_extended
|
||||
|
||||
def test_fail_on_an_edition_without_drive_dlp_says_so(self):
|
||||
"""Google only sells Drive data protection on some editions; the finding names the tenant's edition"""
|
||||
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.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured.security_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured import (
|
||||
security_dlp_drive_rules_configured,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = SecurityPolicies(dlp_drive_rules_exist=None)
|
||||
mock_provider.editions = ["Business Starter"]
|
||||
|
||||
findings = security_dlp_drive_rules_configured().execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert findings[0].status_extended == (
|
||||
f"No active DLP policies for Google Drive are configured in domain "
|
||||
f"{DOMAIN}. DLP rules should be configured to detect and prevent "
|
||||
f"sharing of sensitive information through Drive. This may be "
|
||||
f"expected because Drive data protection rules are not available "
|
||||
f"on the Google Workspace Business Starter edition."
|
||||
)
|
||||
|
||||
def test_fail_with_cloud_identity_premium_gives_no_excuse(self):
|
||||
"""Cloud Identity Premium brings DLP for Drive to a Workspace license that lacks it"""
|
||||
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.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured.security_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured import (
|
||||
security_dlp_drive_rules_configured,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = SecurityPolicies(dlp_drive_rules_exist=None)
|
||||
mock_provider.editions = ["Business Starter", "Cloud Identity Premium"]
|
||||
|
||||
findings = security_dlp_drive_rules_configured().execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "This may be expected" not in findings[0].status_extended
|
||||
|
||||
def test_fail_on_an_edition_with_drive_dlp_gives_no_excuse(self):
|
||||
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.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured.security_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.security.security_dlp_drive_rules_configured.security_dlp_drive_rules_configured import (
|
||||
security_dlp_drive_rules_configured,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = SecurityPolicies(dlp_drive_rules_exist=None)
|
||||
mock_provider.editions = ["Business Starter", "Enterprise Standard"]
|
||||
|
||||
findings = security_dlp_drive_rules_configured().execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "This may be expected" not in findings[0].status_extended
|
||||
|
||||
def test_no_findings_when_fetch_failed(self):
|
||||
"""Test no findings returned when the API fetch failed"""
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
+122
-1
@@ -1,4 +1,4 @@
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import PropertyMock, patch
|
||||
|
||||
from prowler.providers.googleworkspace.services.security.security_service import (
|
||||
SecurityPolicies,
|
||||
@@ -41,6 +41,35 @@ class TestSecuritySessionDurationLimited:
|
||||
assert findings[0].resource_id == "securityPolicies"
|
||||
assert findings[0].customer_id == CUSTOMER_ID
|
||||
|
||||
def test_pass_does_not_read_the_editions(self):
|
||||
"""A passing tenant must not trigger the Licensing API lookup"""
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
type(mock_provider).editions = PropertyMock(
|
||||
side_effect=AssertionError("editions read on a passing finding")
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited.security_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited import (
|
||||
security_session_duration_limited,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = SecurityPolicies(web_session_duration="43200s")
|
||||
|
||||
findings = security_session_duration_limited().execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "PASS"
|
||||
|
||||
def test_fail_long_session_duration(self):
|
||||
"""Test FAIL when session duration is too long (336 hours)"""
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
@@ -97,6 +126,98 @@ class TestSecuritySessionDurationLimited:
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "not explicitly configured" in findings[0].status_extended
|
||||
|
||||
def test_fail_on_an_edition_without_session_control_says_so(self):
|
||||
"""Google sells session control from Business Plus up; the default-duration finding names the edition"""
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
mock_provider.editions = ["Business Starter"]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited.security_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited import (
|
||||
security_session_duration_limited,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = SecurityPolicies(web_session_duration=None)
|
||||
|
||||
findings = security_session_duration_limited().execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert findings[0].status_extended.endswith(
|
||||
"Web session duration should be 12 hours or less. This may be "
|
||||
"expected because Google session control is not available on the "
|
||||
"Google Workspace Business Starter edition."
|
||||
)
|
||||
|
||||
def test_fail_long_session_on_an_edition_without_session_control_says_so(self):
|
||||
"""The explicit-duration finding carries the same edition explanation"""
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
mock_provider.editions = ["Business Standard"]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited.security_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited import (
|
||||
security_session_duration_limited,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = SecurityPolicies(web_session_duration="1209600s")
|
||||
|
||||
findings = security_session_duration_limited().execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "336 hours" in findings[0].status_extended
|
||||
assert findings[0].status_extended.endswith(
|
||||
"This may be expected because Google session control is not "
|
||||
"available on the Google Workspace Business Standard edition."
|
||||
)
|
||||
|
||||
def test_fail_with_cloud_identity_premium_gives_no_excuse(self):
|
||||
"""Cloud Identity Premium brings session control to any Workspace edition"""
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
mock_provider.editions = ["Business Starter", "Cloud Identity Premium"]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"prowler.providers.common.provider.Provider.get_global_provider",
|
||||
return_value=mock_provider,
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited.security_client"
|
||||
) as mock_client,
|
||||
):
|
||||
from prowler.providers.googleworkspace.services.security.security_session_duration_limited.security_session_duration_limited import (
|
||||
security_session_duration_limited,
|
||||
)
|
||||
|
||||
mock_client.provider = mock_provider
|
||||
mock_client.policies_fetched = True
|
||||
mock_client.policies = SecurityPolicies(web_session_duration=None)
|
||||
|
||||
findings = security_session_duration_limited().execute()
|
||||
|
||||
assert len(findings) == 1
|
||||
assert findings[0].status == "FAIL"
|
||||
assert "This may be expected" not in findings[0].status_extended
|
||||
|
||||
def test_fail_unparseable_duration(self):
|
||||
"""Test FAIL when session duration has an unexpected format instead of crashing"""
|
||||
mock_provider = set_mocked_googleworkspace_provider()
|
||||
|
||||
Reference in new issue
Block a user