mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-31 15:54:24 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa73ef6ab2 | ||
|
|
669df91ac9 | ||
|
|
35d25a7218 | ||
|
|
819f5fd7a0 | ||
|
|
7d71220a25 | ||
|
|
69b80f6a3f | ||
|
|
f26b9776d4 | ||
|
|
54f2400aee | ||
|
|
8c5357b087 | ||
|
|
13202b8a70 | ||
|
|
680acf8b31 | ||
|
|
1468596b61 | ||
|
|
02c8881378 | ||
|
|
eae17a5e17 |
No files matched your search
+10
-9
@@ -80,15 +80,16 @@ ignore:
|
||||
package:
|
||||
name: python
|
||||
|
||||
# zlib in the Alpine base images of the UI (node:24.18.1-alpine) and MCP
|
||||
# (python:3.13.14-alpine3.23) containers. CVE-2026-85091 is a heap overflow in
|
||||
# gz_vacate() reached only through non-blocking gzwrite() followed by gzprintf() after
|
||||
# a write stall. Node links its own bundled zlib and the MCP server never writes gzip
|
||||
# files, so neither image calls that path. TEMPORARY: the 1.3.2-r1 fix is in the
|
||||
# Alpine index and could be taken with `apk add --upgrade`, but we wait for the base
|
||||
# images to ship it instead (the newest node:24-alpine and python:3.13-alpine3.23 still
|
||||
# carry 1.3.2-r0). Pinned to that version so the rule stops matching on its own once a
|
||||
# base image moves forward. Remove when the base digests are bumped, by 2026-11-07.
|
||||
# zlib in the Alpine base image of the MCP (python:3.13.16-alpine3.23) container.
|
||||
# CVE-2026-85091 is a heap overflow in gz_vacate() reached only through non-blocking
|
||||
# gzwrite() followed by gzprintf() after a write stall; the MCP server never writes
|
||||
# gzip files, so the image never calls that path. The UI image no longer needs this:
|
||||
# node:24.22.1-alpine is built on Alpine 3.24 and ships 1.3.2-r1. TEMPORARY: the fix
|
||||
# landed in the 3.23 index on 2026-10-06, two days after the pinned python image was
|
||||
# built, and could be taken with `apk add --upgrade`, but we wait for the base image
|
||||
# to ship it instead (python:3.13.17-alpine3.23, built 2026-10-22, already does).
|
||||
# Pinned to that version so the rule stops matching on its own once the base image
|
||||
# moves forward. Remove when the MCP digest is bumped, by 2026-11-07.
|
||||
# https://security.alpinelinux.org/vuln/CVE-2026-85091
|
||||
- vulnerability: CVE-2026-85091
|
||||
package:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
SAML SSO configuration endpoints require the `manage_account` permission instead of `manage_integrations`, since the configured IdP controls who signs in and which role they receive
|
||||
+1
-1
@@ -52,7 +52,7 @@ dependencies = [
|
||||
"uuid6==2024.7.10",
|
||||
"openai (==1.109.1)",
|
||||
"xmlsec==1.3.17",
|
||||
"h2 (==4.3.0)",
|
||||
"h2 (==4.4.1)",
|
||||
"markdown (==3.10.2)",
|
||||
"drf-simple-apikey (==2.2.1)",
|
||||
"matplotlib (==3.10.8)",
|
||||
|
||||
@@ -12,6 +12,7 @@ from api.models import (
|
||||
ProviderSecret,
|
||||
Role,
|
||||
RoleProviderGroupRelationship,
|
||||
SAMLConfiguration,
|
||||
Scan,
|
||||
User,
|
||||
UserRoleRelationship,
|
||||
@@ -1896,6 +1897,90 @@ class TestUserRoleLinkPermissions:
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
SAML_METADATA_XML = """<?xml version='1.0' encoding='UTF-8'?>
|
||||
<md:EntityDescriptor entityID='TEST' xmlns:md='urn:oasis:names:tc:SAML:2.0:metadata'>
|
||||
<md:IDPSSODescriptor WantAuthnRequestsSigned='false' protocolSupportEnumeration='urn:oasis:names:tc:SAML:2.0:protocol'>
|
||||
<md:KeyDescriptor use='signing'>
|
||||
<ds:KeyInfo xmlns:ds='http://www.w3.org/2000/09/xmldsig#'>
|
||||
<ds:X509Data>
|
||||
<ds:X509Certificate>TEST</ds:X509Certificate>
|
||||
</ds:X509Data>
|
||||
</ds:KeyInfo>
|
||||
</md:KeyDescriptor>
|
||||
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
||||
<md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST' Location='https://TEST/sso/saml'/>
|
||||
<md:SingleSignOnService Binding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' Location='https://TEST/sso/saml'/>
|
||||
</md:IDPSSODescriptor>
|
||||
</md:EntityDescriptor>
|
||||
"""
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestSAMLConfigurationPermissions:
|
||||
def _create(self, client, email_domain):
|
||||
return client.post(
|
||||
reverse("saml-config-list"),
|
||||
data={"email_domain": email_domain, "metadata_xml": SAML_METADATA_XML},
|
||||
format="json",
|
||||
)
|
||||
|
||||
def test_manage_integrations_only_cannot_create(
|
||||
self, authenticated_client_rbac_manage_integrations_only
|
||||
):
|
||||
response = self._create(
|
||||
authenticated_client_rbac_manage_integrations_only,
|
||||
"integrations-only-create.com",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert not SAMLConfiguration.objects.filter(
|
||||
email_domain="integrations-only-create.com"
|
||||
).exists()
|
||||
|
||||
def test_manage_integrations_only_cannot_read_or_replace(
|
||||
self, authenticated_client_rbac_manage_integrations_only
|
||||
):
|
||||
client = authenticated_client_rbac_manage_integrations_only
|
||||
tenant = Membership.objects.filter(user=client.user).first().tenant
|
||||
config = SAMLConfiguration.objects.create(
|
||||
tenant_id=str(tenant.id),
|
||||
email_domain="integrations-only-replace.com",
|
||||
metadata_xml=SAML_METADATA_XML,
|
||||
)
|
||||
|
||||
list_response = client.get(reverse("saml-config-list"))
|
||||
patch_response = client.patch(
|
||||
reverse("saml-config-detail", kwargs={"pk": config.id}),
|
||||
data={"metadata_xml": SAML_METADATA_XML},
|
||||
format="json",
|
||||
)
|
||||
delete_response = client.delete(
|
||||
reverse("saml-config-detail", kwargs={"pk": config.id})
|
||||
)
|
||||
|
||||
assert list_response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert patch_response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert delete_response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert SAMLConfiguration.objects.filter(id=config.id).exists()
|
||||
|
||||
def test_manage_account_only_can_create_and_delete(
|
||||
self, authenticated_client_rbac_manage_account
|
||||
):
|
||||
client = authenticated_client_rbac_manage_account
|
||||
|
||||
create_response = self._create(client, "account-only.com")
|
||||
|
||||
assert create_response.status_code == status.HTTP_201_CREATED
|
||||
config = SAMLConfiguration.objects.get(email_domain="account-only.com")
|
||||
|
||||
delete_response = client.delete(
|
||||
reverse("saml-config-detail", kwargs={"pk": config.id})
|
||||
)
|
||||
|
||||
assert delete_response.status_code == status.HTTP_204_NO_CONTENT
|
||||
assert not SAMLConfiguration.objects.filter(id=config.id).exists()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestCrossTenantRoleLeak:
|
||||
"""Regression tests for get_role() cross-tenant privilege leak.
|
||||
|
||||
@@ -808,7 +808,9 @@ class SAMLConfigurationViewSet(BaseRLSViewSet):
|
||||
"""
|
||||
|
||||
serializer_class = SAMLConfigurationSerializer
|
||||
required_permissions = [Permissions.MANAGE_INTEGRATIONS]
|
||||
# The IdP behind this configuration decides who signs in and which role
|
||||
# they get, so writing it is account administration, not an integration.
|
||||
required_permissions = [Permissions.MANAGE_ACCOUNT]
|
||||
queryset = SAMLConfiguration.objects.all()
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
@@ -443,6 +443,47 @@ def authenticated_client_rbac_manage_users_only(
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def create_test_user_rbac_manage_integrations_only(django_db_setup, django_db_blocker):
|
||||
"""User with only manage_integrations permission (no manage_account)."""
|
||||
with django_db_blocker.unblock():
|
||||
user = User.objects.create_user(
|
||||
name="testing_manage_integrations_only",
|
||||
email="rbac_manage_integrations_only@rbac.com",
|
||||
password=TEST_PASSWORD,
|
||||
)
|
||||
tenant = Tenant.objects.create(name="Tenant Test Manage Integrations Only")
|
||||
Membership.objects.create(
|
||||
user=user,
|
||||
tenant=tenant,
|
||||
role=Membership.RoleChoices.OWNER,
|
||||
)
|
||||
role = Role.objects.create(
|
||||
name="manage_integrations_only",
|
||||
tenant_id=tenant.id,
|
||||
manage_users=False,
|
||||
manage_account=False,
|
||||
manage_billing=False,
|
||||
manage_providers=False,
|
||||
manage_integrations=True,
|
||||
manage_scans=False,
|
||||
unlimited_visibility=False,
|
||||
)
|
||||
UserRoleRelationship.objects.create(user=user, role=role, tenant_id=tenant.id)
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_client_rbac_manage_integrations_only(
|
||||
create_test_user_rbac_manage_integrations_only, client
|
||||
):
|
||||
return authenticate_client_for_tenant(
|
||||
client,
|
||||
create_test_user_rbac_manage_integrations_only,
|
||||
first_membership_tenant(create_test_user_rbac_manage_integrations_only),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def authenticated_client_rbac(create_test_user_rbac, tenants_fixture, client):
|
||||
return authenticate_client_for_tenant(
|
||||
|
||||
@@ -164,6 +164,28 @@ mainConfig:
|
||||
# aws.appstream_fleet_maximum_session_duration
|
||||
max_session_duration_seconds: 36000 # 10 Hours
|
||||
|
||||
# AWS Elastic Beanstalk Configuration
|
||||
# aws.elasticbeanstalk_environment_php_platform_updated
|
||||
# Oldest PHP branch still receiving security fixes
|
||||
elasticbeanstalk_php_min_branch: "8.2"
|
||||
# Oldest Amazon Linux 2023 PHP platform version bundling the fixed PHP release
|
||||
elasticbeanstalk_php_min_platform_version: "4.13.13"
|
||||
|
||||
# AWS Lightsail Configuration
|
||||
# aws.lightsail_instance_php_blueprint_updated
|
||||
# Blueprint id prefixes that ship PHP and require manual runtime verification
|
||||
lightsail_php_blueprints:
|
||||
[
|
||||
"wordpress",
|
||||
"lamp",
|
||||
"nginx",
|
||||
"drupal",
|
||||
"joomla",
|
||||
"magento",
|
||||
"prestashop",
|
||||
"plesk",
|
||||
"cpanel",
|
||||
]
|
||||
# AWS Lambda Configuration
|
||||
# aws.awslambda_function_using_supported_runtimes
|
||||
obsolete_lambda_runtimes:
|
||||
@@ -501,8 +523,9 @@ mainConfig:
|
||||
shodan_api_key: null
|
||||
|
||||
# Azure App Service
|
||||
# Minimum runtime versions; any newer version passes
|
||||
# azure.app_ensure_php_version_is_latest
|
||||
php_latest_version: "8.2"
|
||||
php_latest_version: "8.5"
|
||||
# azure.app_ensure_python_version_is_latest
|
||||
python_latest_version: "3.12"
|
||||
# azure.app_ensure_java_version_is_latest
|
||||
|
||||
@@ -39,6 +39,9 @@ The following list includes all the AWS checks with configurable variables that
|
||||
| `awslambda_function_no_secrets_in_variables` | `secrets_ignore_patterns` | List of Strings | `[]` |
|
||||
| `awslambda_function_using_supported_runtimes` | `obsolete_lambda_runtimes` | List of Strings | See `config.yaml` |
|
||||
| `awslambda_function_vpc_multi_az` | `lambda_min_azs` | Integer | `2` |
|
||||
| `elasticbeanstalk_environment_php_platform_updated` | `elasticbeanstalk_php_min_branch` | String | `"8.2"` |
|
||||
| `elasticbeanstalk_environment_php_platform_updated` | `elasticbeanstalk_php_min_platform_version` | String | `"4.13.13"` |
|
||||
| `lightsail_instance_php_blueprint_updated` | `lightsail_php_blueprints` | List of Strings | See `config.yaml` |
|
||||
| `cloudformation_stack_cdktoolkit_bootstrap_version` | `recommended_cdk_bootstrap_version` | Integer | `21` |
|
||||
| `cloudformation_stack_outputs_find_secrets` | `secrets_ignore_patterns` | List of Strings | `[]` |
|
||||
| `cloudfront_distributions_pqc_tls_enabled` | `cloudfront_pqc_min_protocol_versions` | List of Strings | `["TLSv1.3_2025"]` |
|
||||
@@ -214,7 +217,7 @@ The following list includes all the Azure checks with configurable variables tha
|
||||
| `apim_threat_detection_llm_jacking` | `apim_threat_detection_llm_jacking_minutes` | Integer | `1440` |
|
||||
| `apim_threat_detection_llm_jacking` | `apim_threat_detection_llm_jacking_threshold` | Float | `0.1` |
|
||||
| `app_ensure_java_version_is_latest` | `java_latest_version` | String | `"17"` |
|
||||
| `app_ensure_php_version_is_latest` | `php_latest_version` | String | `"8.2"` |
|
||||
| `app_ensure_php_version_is_latest` | `php_latest_version` | String | `"8.5"` |
|
||||
| `app_ensure_python_version_is_latest` | `python_latest_version` | String | `"3.12"` |
|
||||
| `defender_attack_path_notifications_properly_configured` | `defender_attack_path_minimal_risk_level` | String | `"High"` |
|
||||
| `network_public_ip_shodan` | `shodan_api_key` | String | `null` |
|
||||
@@ -231,6 +234,7 @@ The following list includes all the GCP checks with configurable variables that
|
||||
|
||||
| Check Name | Value | Type | Default |
|
||||
|---------------------------------------------------|-----------------------------------|---------|---------|
|
||||
| `appengine_version_php_runtime_supported` | `appengine_php_min_version` | String | `"8.2"` |
|
||||
| `cloudstorage_bucket_sufficient_retention_period` | `storage_min_retention_days` | Integer | `90` |
|
||||
| `compute_instance_group_multiple_zones` | `mig_min_zones` | Integer | `2` |
|
||||
| `compute_public_address_shodan` | `shodan_api_key` | String | `null` |
|
||||
@@ -448,6 +452,28 @@ aws:
|
||||
# aws.appstream_fleet_maximum_session_duration
|
||||
max_session_duration_seconds: 36000 # 10 Hours
|
||||
|
||||
# AWS Elastic Beanstalk Configuration
|
||||
# aws.elasticbeanstalk_environment_php_platform_updated
|
||||
# Oldest PHP branch still receiving security fixes
|
||||
elasticbeanstalk_php_min_branch: "8.2"
|
||||
# Oldest Amazon Linux 2023 PHP platform version bundling the fixed PHP release
|
||||
elasticbeanstalk_php_min_platform_version: "4.13.13"
|
||||
|
||||
# AWS Lightsail Configuration
|
||||
# aws.lightsail_instance_php_blueprint_updated
|
||||
# Blueprint id prefixes that ship PHP and require manual runtime verification
|
||||
lightsail_php_blueprints:
|
||||
[
|
||||
"wordpress",
|
||||
"lamp",
|
||||
"nginx",
|
||||
"drupal",
|
||||
"joomla",
|
||||
"magento",
|
||||
"prestashop",
|
||||
"plesk",
|
||||
"cpanel",
|
||||
]
|
||||
# AWS Lambda Configuration
|
||||
# aws.awslambda_function_using_supported_runtimes
|
||||
obsolete_lambda_runtimes:
|
||||
@@ -718,8 +744,9 @@ azure:
|
||||
shodan_api_key: null
|
||||
|
||||
# Azure App Service
|
||||
# Minimum runtime versions; any newer version passes
|
||||
# azure.app_ensure_php_version_is_latest
|
||||
php_latest_version: "8.2"
|
||||
php_latest_version: "8.5"
|
||||
# azure.app_ensure_python_version_is_latest
|
||||
python_latest_version: "3.12"
|
||||
# azure.app_ensure_java_version_is_latest
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -249,7 +249,7 @@ Assign administrative permissions by selecting from the following options:
|
||||
| Permission | Scope | Description |
|
||||
|------------|-------|-------------|
|
||||
| Invite and Manage Users | All | Invite new users and manage existing ones. |
|
||||
| Manage Account | All | Adjust account settings, delete users and read/manage users permissions. |
|
||||
| Manage Account | All | Adjust account settings, delete users, read/manage users permissions and configure SAML SSO. |
|
||||
| Manage Scans | All | Run and review scans, and manage [Scan Configuration](/user-guide/tutorials/prowler-app-scan-configuration) settings. |
|
||||
| Manage Providers | All | Add or modify connected providers, and attach or detach providers from a [Scan Configuration](/user-guide/tutorials/prowler-app-scan-configuration) (in addition to Manage Scans). |
|
||||
| Manage Integrations | All | Add or modify the Prowler Integrations. |
|
||||
|
||||
@@ -41,7 +41,7 @@ If the SAML configuration is removed, users who previously authenticated via SAM
|
||||
</Warning>
|
||||
### Prerequisites
|
||||
|
||||
- Administrator access to the Prowler organization is required.
|
||||
- A role with the **Manage Account** permission in the Prowler organization is required.
|
||||
- Administrative access to the SAML 2.0 compliant Identity Provider (e.g., Okta, Azure AD, Google Workspace) is necessary.
|
||||
|
||||
### Configuration Steps
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
`elasticbeanstalk_environment_php_platform_updated` check to flag Elastic Beanstalk PHP environments on branches without security fixes or on platform versions older than the one bundling the fixed PHP release
|
||||
@@ -0,0 +1 @@
|
||||
`lightsail_instance_php_blueprint_updated` check to flag Lightsail instances created from PHP blueprints for manual verification of the PHP release
|
||||
@@ -0,0 +1 @@
|
||||
`wafv2_webacl_managed_rule_groups_enabled` check to verify WAFv2 Web ACLs reference at least one managed rule group
|
||||
@@ -0,0 +1 @@
|
||||
Azure App Service runtime checks (`app_ensure_php_version_is_latest`, `app_ensure_python_version_is_latest`, `app_ensure_java_version_is_latest`) now pass any runtime at or above the configured minimum instead of failing every version newer than the configured one
|
||||
@@ -0,0 +1 @@
|
||||
Default `php_latest_version` for `app_ensure_php_version_is_latest` raised from `8.2` to `8.5`, the newest PHP runtime on Azure App Service for Linux, following the PHP2Shell (CVE-2026-109618, CVE-2026-109619) security release
|
||||
@@ -0,0 +1 @@
|
||||
`bedrock_api_key_no_long_term_credentials` now reports a Bedrock long-term API key that never expires; IAM only returns `ExpirationDate` for keys created with an expiration period, so keys created without one were skipped and produced no finding at all
|
||||
@@ -0,0 +1 @@
|
||||
`bedrock_guardrail_sensitive_information_filter_enforced` check for AWS provider, flagging Bedrock guardrails whose PII entities or custom regexes take no action on the output path, so a detected match is reported in the trace and still delivered to the caller
|
||||
@@ -0,0 +1 @@
|
||||
`bedrock_model_invocation_logging_text_data_enabled` check for AWS provider, verifying that model invocation logging delivers prompt and response text rather than naming a destination while excluding request and response bodies
|
||||
@@ -0,0 +1 @@
|
||||
`cognito_user_pool_account_recovery_sms_not_preferred` check verifying Cognito user pools do not rank SMS first for account recovery
|
||||
@@ -0,0 +1 @@
|
||||
`appengine` GCP service and `appengine_version_php_runtime_supported` check to flag App Engine serving versions on PHP runtimes without security fixes
|
||||
@@ -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 `drive_publishing_files_disabled` passes when sharing outside the organization is limited to allowlisted domains or off, since publishing to the web only exists with sharing on
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
Gmail spoofing checks read each protection action from its own Policy API field; the inbound domain spoofing check could never pass and the similar domain names check reported the inbound action
|
||||
@@ -0,0 +1 @@
|
||||
Google Workspace checks now read the policy Google applies (greatest sortOrder) instead of the last policy the Cloud Identity Policy API lists, which reported default values over admin-configured ones
|
||||
@@ -0,0 +1 @@
|
||||
Google Workspace checks for Chat, Drive, Gmail, Calendar and Groups pass with the reason stated when the app is turned off for everyone and no organizational unit or group turns it back on. The rule covers settings that only act while the app is on: Drive sharing settings and the settings of existing groups keep being evaluated because existing shares and groups stay reachable, and only group creation is exempt when Groups for Business is off
|
||||
@@ -0,0 +1 @@
|
||||
Resource metadata attached to findings no longer carries the values that secrets checks scan (CloudFormation outputs, Lambda environment variables, EC2 user data and similar fields); they are replaced with `REDACTED` before reaching the Evidence tab, the API and the output files
|
||||
@@ -123,7 +123,8 @@
|
||||
"inspector2_active_findings_exist",
|
||||
"awslambda_function_using_supported_runtimes",
|
||||
"rds_instance_deprecated_engine_version",
|
||||
"rds_instance_extended_support"
|
||||
"rds_instance_extended_support",
|
||||
"elasticbeanstalk_environment_php_platform_updated"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
@@ -160,7 +161,8 @@
|
||||
"redshift_cluster_automatic_upgrades",
|
||||
"kafka_cluster_uses_latest_version",
|
||||
"opensearch_service_domains_updated_to_the_latest_service_software_version",
|
||||
"ecs_service_fargate_latest_platform_version"
|
||||
"ecs_service_fargate_latest_platform_version",
|
||||
"elasticbeanstalk_environment_php_platform_updated"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
@@ -215,7 +217,8 @@
|
||||
"eks_cluster_uses_a_supported_version",
|
||||
"ecs_service_fargate_latest_platform_version",
|
||||
"kafka_cluster_uses_latest_version",
|
||||
"opensearch_service_domains_updated_to_the_latest_service_software_version"
|
||||
"opensearch_service_domains_updated_to_the_latest_service_software_version",
|
||||
"elasticbeanstalk_environment_php_platform_updated"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
|
||||
@@ -60,7 +60,8 @@
|
||||
"wafv2_webacl_logging_enabled",
|
||||
"wafv2_webacl_rule_logging_enabled",
|
||||
"apigateway_restapi_waf_acl_attached",
|
||||
"cognito_user_pool_waf_acl_attached"
|
||||
"cognito_user_pool_waf_acl_attached",
|
||||
"wafv2_webacl_managed_rule_groups_enabled"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -552,7 +553,8 @@
|
||||
}
|
||||
],
|
||||
"Checks": [
|
||||
"bedrock_guardrail_sensitive_information_filter_enabled"
|
||||
"bedrock_guardrail_sensitive_information_filter_enabled",
|
||||
"bedrock_guardrail_sensitive_information_filter_enforced"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -585,6 +587,7 @@
|
||||
],
|
||||
"Checks": [
|
||||
"bedrock_model_invocation_logging_enabled",
|
||||
"bedrock_model_invocation_logging_text_data_enabled",
|
||||
"bedrock_model_invocation_logs_encryption_enabled"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -5142,7 +5142,8 @@
|
||||
"ssm_managed_compliant_patching",
|
||||
"elasticache_redis_cluster_auto_minor_version_upgrades",
|
||||
"codeartifact_packages_external_public_publishing_disabled",
|
||||
"elasticbeanstalk_environment_managed_updates_enabled"
|
||||
"elasticbeanstalk_environment_managed_updates_enabled",
|
||||
"elasticbeanstalk_environment_php_platform_updated"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2856,7 +2856,8 @@
|
||||
"waf_regional_webacl_with_rules",
|
||||
"wafv2_webacl_logging_enabled",
|
||||
"wafv2_webacl_rule_logging_enabled",
|
||||
"wafv2_webacl_with_rules"
|
||||
"wafv2_webacl_with_rules",
|
||||
"wafv2_webacl_managed_rule_groups_enabled"
|
||||
],
|
||||
"ConfigRequirements": [
|
||||
{
|
||||
@@ -3619,7 +3620,8 @@
|
||||
"rds_instance_deprecated_engine_version",
|
||||
"rds_instance_minor_version_upgrade_enabled",
|
||||
"redshift_cluster_automatic_upgrades",
|
||||
"ssm_managed_compliant_patching"
|
||||
"ssm_managed_compliant_patching",
|
||||
"elasticbeanstalk_environment_php_platform_updated"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
|
||||
@@ -2859,7 +2859,8 @@
|
||||
"waf_regional_webacl_with_rules",
|
||||
"wafv2_webacl_logging_enabled",
|
||||
"wafv2_webacl_rule_logging_enabled",
|
||||
"wafv2_webacl_with_rules"
|
||||
"wafv2_webacl_with_rules",
|
||||
"wafv2_webacl_managed_rule_groups_enabled"
|
||||
],
|
||||
"ConfigRequirements": [
|
||||
{
|
||||
@@ -3622,7 +3623,8 @@
|
||||
"rds_instance_deprecated_engine_version",
|
||||
"rds_instance_minor_version_upgrade_enabled",
|
||||
"redshift_cluster_automatic_upgrades",
|
||||
"ssm_managed_compliant_patching"
|
||||
"ssm_managed_compliant_patching",
|
||||
"elasticbeanstalk_environment_php_platform_updated"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
|
||||
@@ -1128,7 +1128,8 @@
|
||||
"Description": "requirements regarding security updates throughout the entire lifetime of the ICT services or ICT products, or replacement after the end of the support period;",
|
||||
"Checks": [
|
||||
"elasticbeanstalk_environment_managed_updates_enabled",
|
||||
"opensearch_service_domains_updated_to_the_latest_service_software_version"
|
||||
"opensearch_service_domains_updated_to_the_latest_service_software_version",
|
||||
"elasticbeanstalk_environment_php_platform_updated"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
@@ -1253,7 +1254,8 @@
|
||||
"memorydb_cluster_auto_minor_version_upgrades",
|
||||
"elasticbeanstalk_environment_managed_updates_enabled",
|
||||
"elasticache_redis_cluster_auto_minor_version_upgrades",
|
||||
"glue_development_endpoints_job_bookmark_encryption_enabled"
|
||||
"glue_development_endpoints_job_bookmark_encryption_enabled",
|
||||
"elasticbeanstalk_environment_php_platform_updated"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
|
||||
@@ -280,7 +280,11 @@
|
||||
"ec2_instance_with_outdated_ami",
|
||||
"eks_cluster_uses_a_supported_version",
|
||||
"kafka_cluster_uses_latest_version",
|
||||
"rds_instance_deprecated_engine_version"
|
||||
"rds_instance_deprecated_engine_version",
|
||||
"elasticbeanstalk_environment_php_platform_updated"
|
||||
],
|
||||
"gcp": [
|
||||
"appengine_version_php_runtime_supported"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -3717,7 +3721,8 @@
|
||||
"waf_regional_rule_with_conditions",
|
||||
"waf_regional_rulegroup_not_empty",
|
||||
"waf_regional_webacl_with_rules",
|
||||
"wafv2_webacl_with_rules"
|
||||
"wafv2_webacl_with_rules",
|
||||
"wafv2_webacl_managed_rule_groups_enabled"
|
||||
],
|
||||
"cloudflare": [
|
||||
"dns_record_proxied",
|
||||
|
||||
@@ -6363,7 +6363,8 @@
|
||||
"cloudfront_distributions_using_waf",
|
||||
"guardduty_is_enabled",
|
||||
"shield_advanced_protection_in_cloudfront_distributions",
|
||||
"shield_advanced_protection_in_internet_facing_load_balancers"
|
||||
"shield_advanced_protection_in_internet_facing_load_balancers",
|
||||
"wafv2_webacl_managed_rule_groups_enabled"
|
||||
],
|
||||
"azure": [
|
||||
"defender_ensure_defender_for_arm_is_on",
|
||||
@@ -8481,7 +8482,8 @@
|
||||
"memorydb_cluster_auto_minor_version_upgrades",
|
||||
"mq_broker_auto_minor_version_upgrades",
|
||||
"opensearch_service_domains_updated_to_the_latest_service_software_version",
|
||||
"kafka_cluster_uses_latest_version"
|
||||
"kafka_cluster_uses_latest_version",
|
||||
"elasticbeanstalk_environment_php_platform_updated"
|
||||
],
|
||||
"azure": [
|
||||
"app_ensure_java_version_is_latest",
|
||||
@@ -8492,6 +8494,9 @@
|
||||
],
|
||||
"alibabacloud": [
|
||||
"ecs_instance_latest_os_patches_applied"
|
||||
],
|
||||
"gcp": [
|
||||
"appengine_version_php_runtime_supported"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -354,7 +354,8 @@
|
||||
"kafka_cluster_uses_latest_version",
|
||||
"opensearch_service_domains_updated_to_the_latest_service_software_version",
|
||||
"rds_instance_deprecated_engine_version",
|
||||
"wellarchitected_workload_no_high_or_medium_risks"
|
||||
"wellarchitected_workload_no_high_or_medium_risks",
|
||||
"elasticbeanstalk_environment_php_platform_updated"
|
||||
],
|
||||
"azure": [],
|
||||
"gcp": [],
|
||||
@@ -718,7 +719,8 @@
|
||||
"waf_regional_rule_with_conditions",
|
||||
"waf_regional_rulegroup_not_empty",
|
||||
"waf_regional_webacl_with_rules",
|
||||
"wafv2_webacl_with_rules"
|
||||
"wafv2_webacl_with_rules",
|
||||
"wafv2_webacl_managed_rule_groups_enabled"
|
||||
],
|
||||
"azure": [
|
||||
"network_vnet_ddos_protection_enabled",
|
||||
@@ -1756,7 +1758,8 @@
|
||||
"ecr_registry_enhanced_scanning_enabled",
|
||||
"inspector2_active_findings_exist",
|
||||
"inspector2_is_enabled",
|
||||
"ssm_managed_compliant_patching"
|
||||
"ssm_managed_compliant_patching",
|
||||
"elasticbeanstalk_environment_php_platform_updated"
|
||||
],
|
||||
"azure": [
|
||||
"app_ensure_java_version_is_latest",
|
||||
@@ -1765,7 +1768,9 @@
|
||||
"app_function_latest_runtime_version",
|
||||
"defender_ensure_system_updates_are_applied"
|
||||
],
|
||||
"gcp": [],
|
||||
"gcp": [
|
||||
"appengine_version_php_runtime_supported"
|
||||
],
|
||||
"kubernetes": [],
|
||||
"m365": []
|
||||
},
|
||||
|
||||
@@ -4427,7 +4427,9 @@
|
||||
"ComplementaryCriteria": ""
|
||||
}
|
||||
],
|
||||
"Checks": []
|
||||
"Checks": [
|
||||
"appengine_version_php_runtime_supported"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Id": "OPS-33.01AS",
|
||||
|
||||
@@ -656,7 +656,8 @@
|
||||
"apikeys_api_restrictions_configured",
|
||||
"apikeys_key_rotated_in_90_days",
|
||||
"artifacts_container_analysis_enabled",
|
||||
"bigquery_dataset_cmk_encryption"
|
||||
"bigquery_dataset_cmk_encryption",
|
||||
"appengine_version_php_runtime_supported"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
@@ -733,7 +734,8 @@
|
||||
"Checks": [
|
||||
"compute_instance_shielded_vm_enabled",
|
||||
"gcr_container_scanning_enabled",
|
||||
"artifacts_container_analysis_enabled"
|
||||
"artifacts_container_analysis_enabled",
|
||||
"appengine_version_php_runtime_supported"
|
||||
],
|
||||
"Attributes": [
|
||||
{
|
||||
|
||||
@@ -81,7 +81,8 @@
|
||||
"compute_instance_shielded_vm_enabled",
|
||||
"compute_project_os_login_enabled",
|
||||
"artifacts_container_analysis_enabled",
|
||||
"gcr_container_scanning_enabled"
|
||||
"gcr_container_scanning_enabled",
|
||||
"appengine_version_php_runtime_supported"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -139,6 +139,28 @@ aws:
|
||||
# aws.appstream_fleet_maximum_session_duration
|
||||
max_session_duration_seconds: 36000 # 10 Hours
|
||||
|
||||
# AWS Elastic Beanstalk Configuration
|
||||
# aws.elasticbeanstalk_environment_php_platform_updated
|
||||
# Oldest PHP branch still receiving security fixes
|
||||
elasticbeanstalk_php_min_branch: "8.2"
|
||||
# Oldest Amazon Linux 2023 PHP platform version bundling the fixed PHP release
|
||||
elasticbeanstalk_php_min_platform_version: "4.13.13"
|
||||
|
||||
# AWS Lightsail Configuration
|
||||
# aws.lightsail_instance_php_blueprint_updated
|
||||
# Blueprint id prefixes that ship PHP and require manual runtime verification
|
||||
lightsail_php_blueprints:
|
||||
[
|
||||
"wordpress",
|
||||
"lamp",
|
||||
"nginx",
|
||||
"drupal",
|
||||
"joomla",
|
||||
"magento",
|
||||
"prestashop",
|
||||
"plesk",
|
||||
"cpanel",
|
||||
]
|
||||
# AWS Lambda Configuration
|
||||
# aws.awslambda_function_using_supported_runtimes
|
||||
obsolete_lambda_runtimes:
|
||||
@@ -550,8 +572,9 @@ azure:
|
||||
defender_attack_path_minimal_risk_level: "High"
|
||||
|
||||
# Azure App Service
|
||||
# Minimum runtime versions; any newer version passes
|
||||
# azure.app_ensure_php_version_is_latest
|
||||
php_latest_version: "8.2"
|
||||
php_latest_version: "8.5"
|
||||
# azure.app_ensure_python_version_is_latest
|
||||
python_latest_version: "3.12"
|
||||
# azure.app_ensure_java_version_is_latest
|
||||
@@ -650,6 +673,10 @@ gcp:
|
||||
# GCP Secret Manager Rotation Period
|
||||
# gcp.secretmanager_secret_rotation_enabled
|
||||
secretmanager_max_rotation_days: 90
|
||||
# GCP App Engine PHP runtime
|
||||
# gcp.appengine_version_php_runtime_supported
|
||||
# Oldest PHP branch still receiving security fixes
|
||||
appengine_php_min_version: "8.2"
|
||||
|
||||
# Kubernetes Configuration
|
||||
kubernetes:
|
||||
|
||||
@@ -88,6 +88,8 @@ _validate_trusted_ips = validate_ip_networks
|
||||
_validate_semver = make_dotted_version_validator(3, 3)
|
||||
# "1.28" style strings (EKS minor versions).
|
||||
_validate_eks_minor = make_dotted_version_validator(2, 2)
|
||||
_validate_php_branch = make_dotted_version_validator(2, 2)
|
||||
_validate_platform_version = make_dotted_version_validator(3, 3)
|
||||
|
||||
|
||||
def _validate_account_ids(v: Optional[list[str]]) -> Optional[list[str]]:
|
||||
@@ -309,6 +311,20 @@ class AWSProviderConfig(ProviderConfigBase):
|
||||
),
|
||||
)
|
||||
|
||||
# --- Elastic Beanstalk ------------------------------------------------
|
||||
elasticbeanstalk_php_min_branch: Annotated[
|
||||
Optional[str], AfterValidator(_validate_php_branch)
|
||||
] = Field(
|
||||
default=None,
|
||||
description='Oldest PHP branch still receiving security fixes, e.g. "8.2".',
|
||||
)
|
||||
elasticbeanstalk_php_min_platform_version: Annotated[
|
||||
Optional[str], AfterValidator(_validate_platform_version)
|
||||
] = Field(
|
||||
default=None,
|
||||
description='Oldest Amazon Linux 2023 PHP platform version bundling the fixed PHP release, e.g. "4.13.13".',
|
||||
)
|
||||
|
||||
# --- Lambda -----------------------------------------------------------
|
||||
obsolete_lambda_runtimes: Optional[list[str]] = None
|
||||
lambda_min_azs: Optional[int] = Field(
|
||||
@@ -318,6 +334,12 @@ class AWSProviderConfig(ProviderConfigBase):
|
||||
description="Min number of AZs a VPC-bound Lambda must span. Range: 1..6.",
|
||||
)
|
||||
|
||||
# --- Lightsail --------------------------------------------------------
|
||||
lightsail_php_blueprints: Optional[list[str]] = Field(
|
||||
default=None,
|
||||
description="Blueprint id prefixes that ship PHP and require manual runtime verification.",
|
||||
)
|
||||
|
||||
# --- Organizations ----------------------------------------------------
|
||||
organizations_enabled_regions: Optional[list[str]] = None
|
||||
organizations_trusted_delegated_administrators: Annotated[
|
||||
|
||||
@@ -36,7 +36,7 @@ class AzureProviderConfig(ProviderConfigBase):
|
||||
# --- App Service ----------------------------------------------------
|
||||
php_latest_version: Annotated[
|
||||
Optional[str], AfterValidator(_validate_dotted_version)
|
||||
] = Field(default=None, description='PHP minimum acceptable version, e.g. "8.2".')
|
||||
] = Field(default=None, description='PHP minimum acceptable version, e.g. "8.5".')
|
||||
python_latest_version: Annotated[
|
||||
Optional[str], AfterValidator(_validate_dotted_version)
|
||||
] = Field(
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
"""GCP provider config schema with safety bounds."""
|
||||
|
||||
from typing import Optional
|
||||
from typing import Annotated, Optional
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic import AfterValidator, Field
|
||||
|
||||
from prowler.config.schema.base import ProviderConfigBase
|
||||
from prowler.config.schema.validators import make_dotted_version_validator
|
||||
|
||||
_validate_php_branch = make_dotted_version_validator(2, 2)
|
||||
|
||||
|
||||
class GCPProviderConfig(ProviderConfigBase):
|
||||
@@ -43,3 +46,11 @@ class GCPProviderConfig(ProviderConfigBase):
|
||||
le=3650,
|
||||
description="Min retention period on Cloud Storage buckets. Range: 1..3650.",
|
||||
)
|
||||
|
||||
# --- App Engine -----------------------------------------------------
|
||||
appengine_php_min_version: Annotated[
|
||||
Optional[str], AfterValidator(_validate_php_branch)
|
||||
] = Field(
|
||||
default=None,
|
||||
description='Oldest PHP branch still receiving security fixes, e.g. "8.2".',
|
||||
)
|
||||
@@ -12,6 +12,7 @@ from pydantic.v1 import BaseModel, Field, ValidationError, validator
|
||||
from pydantic.v1.error_wrappers import ErrorWrapper
|
||||
|
||||
from prowler.lib.check.compliance_models import Compliance
|
||||
from prowler.lib.check.redact import redact_sensitive_fields
|
||||
from prowler.lib.check.utils import recover_checks_from_provider
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.common.provider import Provider as ProviderABC
|
||||
@@ -747,6 +748,7 @@ class Check_Report:
|
||||
f"Resource metadata {type(resource)} in {self.check_metadata.CheckID} could not be converted to dict"
|
||||
)
|
||||
self.resource = {}
|
||||
self.resource = redact_sensitive_fields(resource, self.resource)
|
||||
self.status_extended = ""
|
||||
self.resource_details = ""
|
||||
self.resource_tags = getattr(resource, "tags", []) if resource else []
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from typing import Any
|
||||
|
||||
REDACTED_VALUE = "REDACTED"
|
||||
|
||||
|
||||
def _mask(value: Any) -> Any:
|
||||
"""Replace every leaf of value with REDACTED_VALUE, keeping dict keys and list lengths."""
|
||||
if value is None or value == "" or value == [] or value == {}:
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
return {key: _mask(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_mask(item) for item in value]
|
||||
return REDACTED_VALUE
|
||||
|
||||
|
||||
def redact_sensitive_fields(resource: Any, data: Any) -> Any:
|
||||
"""Return data, the serialised form of resource, with the fields each model lists in sensitive_fields masked."""
|
||||
if isinstance(data, dict):
|
||||
if isinstance(resource, dict):
|
||||
return {
|
||||
key: redact_sensitive_fields(resource.get(key), item)
|
||||
for key, item in data.items()
|
||||
}
|
||||
sensitive = getattr(type(resource), "sensitive_fields", ())
|
||||
return {
|
||||
key: (
|
||||
_mask(item)
|
||||
if key in sensitive
|
||||
else redact_sensitive_fields(getattr(resource, key, None), item)
|
||||
)
|
||||
for key, item in data.items()
|
||||
}
|
||||
if (
|
||||
isinstance(data, list)
|
||||
and isinstance(resource, (list, tuple))
|
||||
and len(data) == len(resource)
|
||||
):
|
||||
return [
|
||||
redact_sensitive_fields(item_resource, item)
|
||||
for item_resource, item in zip(resource, data)
|
||||
]
|
||||
return data
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import ClassVar
|
||||
|
||||
from botocore.exceptions import ClientError
|
||||
from pydantic.v1 import BaseModel, Field
|
||||
|
||||
@@ -11,6 +13,7 @@ class Branch(BaseModel):
|
||||
|
||||
name: str
|
||||
arn: str
|
||||
sensitive_fields: ClassVar[tuple[str, ...]] = ("environment_variables",)
|
||||
environment_variables: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -21,6 +24,10 @@ class App(BaseModel):
|
||||
name: str
|
||||
arn: str
|
||||
region: str
|
||||
sensitive_fields: ClassVar[tuple[str, ...]] = (
|
||||
"environment_variables",
|
||||
"build_spec",
|
||||
)
|
||||
environment_variables: dict = Field(default_factory=dict)
|
||||
build_spec: str = ""
|
||||
branches: list[Branch] = Field(default_factory=list)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Any, Optional
|
||||
from typing import Any, ClassVar, Optional
|
||||
|
||||
from botocore.exceptions import ClientError
|
||||
from pydantic.v1 import BaseModel
|
||||
@@ -266,6 +266,7 @@ class Stage(BaseModel):
|
||||
tracing_enabled: Optional[bool] = None
|
||||
cache_enabled: Optional[bool] = None
|
||||
cache_data_encrypted: Optional[bool] = None
|
||||
sensitive_fields: ClassVar[tuple[str, ...]] = ("variables",)
|
||||
variables: Optional[dict] = {}
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import ClassVar
|
||||
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
@@ -155,6 +157,7 @@ class ApplicationAutoScaling(AWSService):
|
||||
class LaunchConfiguration(BaseModel):
|
||||
arn: str
|
||||
name: str
|
||||
sensitive_fields: ClassVar[tuple[str, ...]] = ("user_data",)
|
||||
user_data: str
|
||||
image_id: str
|
||||
region: str
|
||||
|
||||
@@ -3,7 +3,7 @@ import json
|
||||
import zipfile
|
||||
from concurrent.futures import as_completed
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
from typing import Any, ClassVar, Optional
|
||||
|
||||
import requests
|
||||
from botocore.client import ClientError
|
||||
@@ -404,6 +404,7 @@ class Function(BaseModel):
|
||||
security_groups: list
|
||||
last_modified: Optional[str] = None
|
||||
runtime: Optional[str] = None
|
||||
sensitive_fields: ClassVar[tuple[str, ...]] = ("environment",)
|
||||
environment: Optional[dict] = None
|
||||
region: str
|
||||
policy: dict = {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from itertools import zip_longest
|
||||
from typing import Optional
|
||||
from typing import ClassVar, Optional
|
||||
|
||||
from botocore.exceptions import ClientError
|
||||
from pydantic.v1 import BaseModel
|
||||
@@ -14,6 +14,7 @@ class ContainerEnvVariable(BaseModel):
|
||||
"""An environment variable name-value pair."""
|
||||
|
||||
name: str
|
||||
sensitive_fields: ClassVar[tuple[str, ...]] = ("value",)
|
||||
value: str
|
||||
|
||||
|
||||
@@ -21,6 +22,7 @@ class BatchContainerProperties(BaseModel):
|
||||
"""Container properties for an AWS Batch job definition."""
|
||||
|
||||
image: Optional[str]
|
||||
sensitive_fields: ClassVar[tuple[str, ...]] = ("command",)
|
||||
command: list[str] = []
|
||||
environment: list[ContainerEnvVariable] = []
|
||||
|
||||
|
||||
+19
-4
@@ -19,18 +19,33 @@ class bedrock_api_key_no_long_term_credentials(Check):
|
||||
FAIL (high) for any other active long-term key.
|
||||
"""
|
||||
|
||||
def execute(self):
|
||||
def execute(self) -> list[Check_Report_AWS]:
|
||||
"""Execute the check logic.
|
||||
|
||||
Returns:
|
||||
A list of reports containing the result of the check.
|
||||
"""
|
||||
findings = []
|
||||
for api_key in iam_client.service_specific_credentials:
|
||||
if api_key.service_name != "bedrock.amazonaws.com":
|
||||
continue
|
||||
if not api_key.expiration_date:
|
||||
continue
|
||||
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=api_key)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if api_key.expiration_date <= now:
|
||||
if api_key.expiration_date is None:
|
||||
# ExpirationDate "is only present for Bedrock API keys ... that were created with
|
||||
# an expiration period" (IAM ServiceSpecificCredentialMetadata), so its absence is
|
||||
# the never-expiring key itself, not a key outside this check's scope.
|
||||
report.status = "FAIL"
|
||||
report.check_metadata.Severity = Severity.critical
|
||||
report.status_extended = (
|
||||
f"Bedrock long-term API key {api_key.id} in user "
|
||||
f"{api_key.user.name} has no expiration date, so it never expires. "
|
||||
f"Use short-term Bedrock API keys (session-scoped, valid up to "
|
||||
f"12 hours) for non-exploratory workloads instead."
|
||||
)
|
||||
elif api_key.expiration_date <= now:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Bedrock long-term API key {api_key.id} in user "
|
||||
|
||||
Whitespace-only changes.
+46
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "bedrock_guardrail_sensitive_information_filter_enforced",
|
||||
"CheckTitle": "Amazon Bedrock guardrail sensitive information filter acts on the output path",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Software and Configuration Checks/AWS Security Best Practices/Runtime Behavior Analysis",
|
||||
"Effects/Data Exposure",
|
||||
"Sensitive Data Identifications/PII"
|
||||
],
|
||||
"ServiceName": "bedrock",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "high",
|
||||
"ResourceType": "Other",
|
||||
"ResourceGroup": "ai_ml",
|
||||
"Description": "A **Bedrock guardrail** sensitive information entry whose action is `NONE` reports the detected PII or regex match in the trace and still delivers it to the caller. The evaluation looks for guardrails whose configured PII entities and custom regexes are set to `BLOCK` or `ANONYMIZE` on the output path.",
|
||||
"Risk": "A filter that detects without acting reads as configured while **PII**, credentials and secrets keep flowing through model output.\n- Reviews and dashboards record the filter as present, so the gap goes unnoticed\n- Leaked tokens and customer records enable unauthorized access, fraud and identity theft",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails-sensitive-filters.html",
|
||||
"https://docs.aws.amazon.com/bedrock/latest/APIReference/API_GuardrailPiiEntity.html",
|
||||
"https://docs.aws.amazon.com/bedrock/latest/APIReference/API_GuardrailRegex.html",
|
||||
"https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails-components.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Sign in to the AWS Management Console and open Amazon Bedrock\n2. Go to Guardrails and select <example_resource_name>\n3. Click Edit (or Open draft) and open Sensitive information filters\n4. For every PII type and regular expression listed, change the action from None to Mask or Block, and leave the output evaluation enabled\n5. Click Save, then publish a new guardrail version and point your agents and model invocations at it\nNote for API and CLI users: UpdateGuardrail replaces the whole guardrail, so it must be a read-modify-write. Call GetGuardrail first, edit only the actions inside the returned sensitiveInformationPolicyConfig, and send every other policy block back unchanged. AWS states \"Include both fields that you want to update as well as fields that you want to keep the same\" -- omitting a block deletes it. Changes land on the working draft only, so publish a new version afterwards.",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Set every PII entity and custom regular expression in the guardrail sensitive information policy to `BLOCK` or `ANONYMIZE` rather than `NONE`, and keep output evaluation enabled so matches are stopped on the response path. Use `BLOCK` for values that must never appear, `ANONYMIZE` where the surrounding answer is still useful. Confirm the result with an `ApplyGuardrail` call using `source=OUTPUT` and check that the assessment reports the entity as blocked or anonymized.",
|
||||
"Url": "https://hub.prowler.com/check/bedrock_guardrail_sensitive_information_filter_enforced"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"gen-ai"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"bedrock_guardrail_sensitive_information_filter_enabled"
|
||||
],
|
||||
"Notes": "Scoped to guardrails that already carry a sensitive information policy; a guardrail with no such policy is reported by bedrock_guardrail_sensitive_information_filter_enabled instead. GetGuardrail reports `action` for every entry and omits the per-path `outputAction` and `outputEnabled` unless they were set explicitly, in which case `action` governs the output path — verified against a live guardrail whose entries carried only `action: ANONYMIZE` and which masked PII on an ApplyGuardrail call with source=OUTPUT. An entry whose action the API does not report is MANUAL, never PASS. Like the other guardrail checks, this reads the working draft, because the collector calls GetGuardrail without guardrailVersion."
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.bedrock.bedrock_client import bedrock_client
|
||||
|
||||
ENFORCING_ACTIONS = {"BLOCK", "ANONYMIZE"}
|
||||
|
||||
|
||||
class bedrock_guardrail_sensitive_information_filter_enforced(Check):
|
||||
"""Ensure guardrail sensitive-information entries block or mask matches on the output path.
|
||||
|
||||
A guardrail can carry a full sensitive-information policy whose every PII entity and regex is
|
||||
set to NONE, which detects the match and returns it in the trace while still delivering the
|
||||
value to the caller. Presence of the policy therefore does not establish that leakage is
|
||||
stopped, which is all bedrock_guardrail_sensitive_information_filter_enabled asserts.
|
||||
|
||||
Scope: guardrails that already carry a sensitive-information policy. A guardrail with no such
|
||||
policy is the subject of bedrock_guardrail_sensitive_information_filter_enabled and is not
|
||||
reported here.
|
||||
|
||||
PASS when every entry blocks or masks its matches on the output path.
|
||||
FAIL when any entry takes no action on the output path, or has output evaluation switched off.
|
||||
MANUAL when the guardrail configuration could not be read, or an entry's action was not
|
||||
reported AND no other entry was proven unenforced. A proven leak outranks an incomplete
|
||||
inventory: this is a universal claim, so one entry known to take no action settles it.
|
||||
Unreported entries are still named in the FAIL text, so an incomplete inventory is disclosed
|
||||
rather than dropped.
|
||||
|
||||
ENFORCING_ACTIONS holds only BLOCK and ANONYMIZE. NONE reports the detection in the trace and
|
||||
lets the content through, so an entry set to NONE detects a leak and permits it.
|
||||
|
||||
Both name lists are rendered into status_extended, and they are built in the order GetGuardrail
|
||||
returned piiEntities and regexes. Neither ListGuardrails nor GetGuardrail documents an ordering,
|
||||
so each render point sorts independently: unsorted output makes one unchanged guardrail produce
|
||||
two different status_extended strings across scans, which reads as a changed finding.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[Check_Report_AWS]:
|
||||
"""Execute the check logic.
|
||||
|
||||
Returns:
|
||||
A list of reports containing the result of the check.
|
||||
"""
|
||||
findings = []
|
||||
for guardrail in bedrock_client.guardrails.values():
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=guardrail)
|
||||
|
||||
if not guardrail.detail_retrieved:
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"Bedrock Guardrail {guardrail.name} configuration could not be retrieved "
|
||||
f"({guardrail.detail_error or 'unknown error'}); verify manually that its "
|
||||
"sensitive information filters block or mask matches on the output path."
|
||||
)
|
||||
findings.append(report)
|
||||
continue
|
||||
|
||||
if not guardrail.sensitive_information_entries:
|
||||
continue
|
||||
|
||||
unreported = []
|
||||
unenforced = []
|
||||
for entry in guardrail.sensitive_information_entries:
|
||||
# GetGuardrail omits outputAction unless it was configured explicitly; the
|
||||
# required `action` governs the output path in that case.
|
||||
action = entry.output_action or entry.action
|
||||
if action is None:
|
||||
unreported.append(entry.name)
|
||||
elif entry.output_enabled is False or action not in ENFORCING_ACTIONS:
|
||||
unenforced.append(entry.name)
|
||||
|
||||
if unenforced:
|
||||
unenforced.sort()
|
||||
unreported.sort()
|
||||
report.status = "FAIL"
|
||||
unreported_note = (
|
||||
f" The action for {', '.join(unreported)} was not reported, so there may be "
|
||||
"more."
|
||||
if unreported
|
||||
else ""
|
||||
)
|
||||
report.status_extended = (
|
||||
f"Bedrock Guardrail {guardrail.name} detects but does not block or mask "
|
||||
f"{', '.join(unenforced)} on the output path, so matched sensitive "
|
||||
f"information still reaches the caller.{unreported_note}"
|
||||
)
|
||||
elif unreported:
|
||||
# Sorted here too: a second render point needs its own sort, not an inherited one.
|
||||
unreported.sort()
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"Bedrock Guardrail {guardrail.name} did not report an action for "
|
||||
f"{', '.join(unreported)}; verify manually that matches are blocked or "
|
||||
"masked on the output path."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
count = len(guardrail.sensitive_information_entries)
|
||||
report.status_extended = (
|
||||
f"Bedrock Guardrail {guardrail.name} blocks or masks "
|
||||
f"{'its 1 configured sensitive information entry' if count == 1 else f'all {count} configured sensitive information entries'}"
|
||||
" on the output path."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
Whitespace-only changes.
+43
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "bedrock_model_invocation_logging_text_data_enabled",
|
||||
"CheckTitle": "Bedrock model invocation logging delivers prompt and response text.",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Software and Configuration Checks/Industry and Regulatory Standards/AWS Foundational Security Best Practices"
|
||||
],
|
||||
"ServiceName": "bedrock",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "Other",
|
||||
"ResourceGroup": "ai_ml",
|
||||
"Description": "Bedrock model invocation logging is checked for text data delivery. A logging configuration can name a destination while excluding request and response bodies, so the destination alone does not establish that prompts and completions are captured.",
|
||||
"Risk": "Invocation logging that excludes **text data** records that a call happened, but not what was sent or returned.\n\nThat leaves no evidence for the investigations which depend on content itself: **prompt injection**, data exfiltration, and abuse of the model by an authorized caller.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/bedrock/latest/userguide/model-invocation-logging.html#model-invocation-logging-console",
|
||||
"https://www.trendmicro.com/trendaivisiononecloudriskmanagement/knowledge-base/aws/Bedrock/enable-model-invocation-logging.html",
|
||||
"https://docs.aws.amazon.com/bedrock/latest/userguide/model-invocation-logging.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws bedrock put-model-invocation-logging-configuration --logging-config '{\"textDataDeliveryEnabled\":true,\"cloudWatchConfig\":{\"logGroupName\":\"<log_group>\",\"roleArn\":\"<role_arn>\"}}'",
|
||||
"NativeIaC": "",
|
||||
"Other": "",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Set textDataDeliveryEnabled to true on the model invocation logging configuration so prompt and response bodies are delivered to the configured destination.",
|
||||
"Url": "https://hub.prowler.com/check/bedrock_model_invocation_logging_text_data_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"logging",
|
||||
"forensics-ready",
|
||||
"gen-ai"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.bedrock.bedrock_client import bedrock_client
|
||||
|
||||
|
||||
class bedrock_model_invocation_logging_text_data_enabled(Check):
|
||||
"""Ensure model invocation logging delivers prompt and response text.
|
||||
|
||||
Model invocation logging can name a CloudWatch log group or S3 bucket while excluding the
|
||||
request and response bodies, so a configured destination does not establish that prompts and
|
||||
completions are captured. This check is scoped to Regions where invocation logging is already
|
||||
enabled: a Region with logging switched off is the subject of
|
||||
bedrock_model_invocation_logging_enabled and is not reported here.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[Check_Report_AWS]:
|
||||
"""Execute the check logic.
|
||||
|
||||
Returns:
|
||||
A list of reports containing the result of the check.
|
||||
"""
|
||||
findings = []
|
||||
for region, logging in bedrock_client.logging_configurations.items():
|
||||
# Skip-if-absent: only Regions that already deliver logs are in scope.
|
||||
if not logging.enabled:
|
||||
continue
|
||||
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=logging)
|
||||
report.region = region
|
||||
# "model-invocation-logging", as both siblings on this configuration use. The account
|
||||
# id identified no resource -- every Region reported the same id, and the ARN beside it
|
||||
# already carries the account -- so a reader could not tell two Regions' findings apart
|
||||
# by resource_id alone.
|
||||
report.resource_id = "model-invocation-logging"
|
||||
report.resource_arn = (
|
||||
bedrock_client._get_model_invocation_logging_arn_template(region)
|
||||
)
|
||||
|
||||
if logging.text_data_delivery_enabled is None:
|
||||
# The API did not report the flag. Absent is not False, and reporting it as
|
||||
# compliant would assert content capture that was never confirmed.
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = (
|
||||
f"Bedrock Model Invocation Logging is enabled in region {region} but the "
|
||||
"text data delivery setting was not returned; verify manually that prompt "
|
||||
"and response text is delivered."
|
||||
)
|
||||
elif logging.text_data_delivery_enabled:
|
||||
report.status = "PASS"
|
||||
report.status_extended = (
|
||||
f"Bedrock Model Invocation Logging in region {region} delivers prompt and "
|
||||
"response text."
|
||||
)
|
||||
else:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"Bedrock Model Invocation Logging in region {region} excludes prompt and "
|
||||
"response text, so invocation content is not captured."
|
||||
)
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -33,6 +33,13 @@ class Bedrock(AWSService):
|
||||
)
|
||||
|
||||
def _get_model_invocation_logging_configuration(self, regional_client):
|
||||
"""Read the model invocation logging configuration for a region.
|
||||
|
||||
An empty `loggingConfig` is how the API reports logging switched off, so it maps to
|
||||
`enabled=False` rather than to an absent entry. The four per-data-type delivery flags are
|
||||
stored as reported, including None: the API omits a flag it was never given, and None
|
||||
cannot be collapsed to False without asserting an exclusion that was never configured.
|
||||
"""
|
||||
logger.info("Bedrock - Getting Model Invocation Logging Configuration...")
|
||||
try:
|
||||
logging_config = (
|
||||
@@ -48,6 +55,18 @@ class Bedrock(AWSService):
|
||||
).get("logGroupName"),
|
||||
s3_bucket=logging_config.get("s3Config", {}).get("bucketName"),
|
||||
enabled=True,
|
||||
text_data_delivery_enabled=logging_config.get(
|
||||
"textDataDeliveryEnabled"
|
||||
),
|
||||
image_data_delivery_enabled=logging_config.get(
|
||||
"imageDataDeliveryEnabled"
|
||||
),
|
||||
embedding_data_delivery_enabled=logging_config.get(
|
||||
"embeddingDataDeliveryEnabled"
|
||||
),
|
||||
video_data_delivery_enabled=logging_config.get(
|
||||
"videoDataDeliveryEnabled"
|
||||
),
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -94,6 +113,17 @@ class Bedrock(AWSService):
|
||||
)
|
||||
|
||||
def _get_guardrail(self, guardrail):
|
||||
"""Read one guardrail's policies and flatten its sensitive-information entries.
|
||||
|
||||
`piiEntities` and `regexes` are flattened into a single list because both carry the same
|
||||
action/outputAction/outputEnabled triple and both leak the same way; the entry name keeps
|
||||
the kind visible in findings. Each field is stored as reported, so an action the API did
|
||||
not return stays None instead of becoming a verdict.
|
||||
|
||||
`detail_retrieved` is set only on the success path. A guardrail whose configuration could
|
||||
not be read therefore carries no filter claims at all, and `detail_error` names the API
|
||||
error code so a check can report the cause rather than a compliant-looking default.
|
||||
"""
|
||||
logger.info("Bedrock - Getting Guardrail...")
|
||||
try:
|
||||
guardrail_info = self.regional_clients[guardrail.region].get_guardrail(
|
||||
@@ -119,8 +149,37 @@ class Bedrock(AWSService):
|
||||
"filters", []
|
||||
)
|
||||
]
|
||||
sensitive_information_policy = guardrail_info.get(
|
||||
"sensitiveInformationPolicy", {}
|
||||
)
|
||||
for entity in sensitive_information_policy.get("piiEntities", []):
|
||||
guardrail.sensitive_information_entries.append(
|
||||
SensitiveInformationEntry(
|
||||
name=f"PII entity {entity.get('type', 'unknown')}",
|
||||
action=entity.get("action"),
|
||||
output_action=entity.get("outputAction"),
|
||||
output_enabled=entity.get("outputEnabled"),
|
||||
)
|
||||
)
|
||||
for regex in sensitive_information_policy.get("regexes", []):
|
||||
guardrail.sensitive_information_entries.append(
|
||||
SensitiveInformationEntry(
|
||||
name=f"regex {regex.get('name', 'unknown')}",
|
||||
action=regex.get("action"),
|
||||
output_action=regex.get("outputAction"),
|
||||
output_enabled=regex.get("outputEnabled"),
|
||||
)
|
||||
)
|
||||
guardrail.detail_retrieved = True
|
||||
except ClientError as error:
|
||||
guardrail.detail_error = error.response["Error"].get(
|
||||
"Code", error.__class__.__name__
|
||||
)
|
||||
logger.error(
|
||||
f"{guardrail.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
except Exception as error:
|
||||
guardrail.detail_error = error.__class__.__name__
|
||||
logger.error(
|
||||
f"{guardrail.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
@@ -201,6 +260,29 @@ class LoggingConfiguration(BaseModel):
|
||||
enabled: bool = False
|
||||
cloudwatch_log_group: Optional[str] = None
|
||||
s3_bucket: Optional[str] = None
|
||||
# GetModelInvocationLoggingConfiguration reports which data types are actually delivered.
|
||||
# A configuration can name a destination while excluding the prompt and response bodies, so
|
||||
# the destination alone does not establish that invocation content is captured. None means
|
||||
# the API did not report the flag, which is not the same as False.
|
||||
text_data_delivery_enabled: Optional[bool] = None
|
||||
image_data_delivery_enabled: Optional[bool] = None
|
||||
embedding_data_delivery_enabled: Optional[bool] = None
|
||||
video_data_delivery_enabled: Optional[bool] = None
|
||||
|
||||
|
||||
class SensitiveInformationEntry(BaseModel):
|
||||
"""One entry of a guardrail sensitive-information policy: a PII entity or a custom regex.
|
||||
|
||||
GetGuardrail always reports `action` (the API marks it required) and omits the per-path
|
||||
`outputAction` / `outputEnabled` unless they were configured explicitly, in which case
|
||||
`action` governs both the input and the output path. None therefore means the API did not
|
||||
report the per-path setting, which is not the same as False or NONE.
|
||||
"""
|
||||
|
||||
name: str
|
||||
action: Optional[str] = None
|
||||
output_action: Optional[str] = None
|
||||
output_enabled: Optional[bool] = None
|
||||
|
||||
|
||||
class ContextualGroundingFilter(BaseModel):
|
||||
@@ -227,6 +309,8 @@ class Guardrail(BaseModel):
|
||||
contextual_grounding_filters: list[ContextualGroundingFilter] = []
|
||||
# False when GetGuardrail failed: absent policy is unknown, not unset.
|
||||
detail_retrieved: bool = False
|
||||
detail_error: Optional[str] = None
|
||||
sensitive_information_entries: list[SensitiveInformationEntry] = []
|
||||
|
||||
|
||||
class CustomModel(BaseModel):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Optional
|
||||
from typing import ClassVar, Optional
|
||||
|
||||
from botocore.client import ClientError
|
||||
from pydantic.v1 import BaseModel
|
||||
@@ -82,6 +82,7 @@ class Stack(BaseModel):
|
||||
"""In the CloudFormation API the "Stacks[].StackId" is the ARN"""
|
||||
name: str
|
||||
"""Stacks[].StackName"""
|
||||
sensitive_fields: ClassVar[tuple[str, ...]] = ("outputs",)
|
||||
outputs: list[str]
|
||||
"""Stacks[].Outputs"""
|
||||
enable_termination_protection: bool = False
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from typing import ClassVar, Optional
|
||||
|
||||
from botocore.exceptions import ClientError
|
||||
from pydantic.v1 import BaseModel
|
||||
@@ -381,6 +381,7 @@ class LogGroup(BaseModel):
|
||||
# Properties inherited from account-level settings, e.g. ACCOUNT_DATA_PROTECTION.
|
||||
inherited_properties: list[str] = []
|
||||
region: str
|
||||
sensitive_fields: ClassVar[tuple[str, ...]] = ("log_streams",)
|
||||
log_streams: dict[str, list[str]] = (
|
||||
{}
|
||||
) # Log stream name as the key, array of events as the value
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import datetime
|
||||
from concurrent.futures import as_completed
|
||||
from typing import List, Optional
|
||||
from typing import ClassVar, List, Optional
|
||||
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
@@ -286,6 +286,7 @@ class Source(BaseModel):
|
||||
|
||||
class EnvironmentVariable(BaseModel):
|
||||
name: str
|
||||
sensitive_fields: ClassVar[tuple[str, ...]] = ("value",)
|
||||
value: str
|
||||
type: str
|
||||
|
||||
|
||||
Whitespace-only changes.
+41
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "cognito_user_pool_account_recovery_sms_not_preferred",
|
||||
"CheckTitle": "Cognito user pool does not prefer SMS for account recovery",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"TTPs/Initial Access/Unauthorized Access"
|
||||
],
|
||||
"ServiceName": "cognito",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "Other",
|
||||
"ResourceGroup": "IAM",
|
||||
"Description": "**Amazon Cognito user pools** do not rank `verified_phone_number` as the first **account recovery** mechanism. The evaluation inspects the `RecoveryMechanisms` of each user pool's `AccountRecoverySetting` and looks at the entry with the lowest `Priority`.",
|
||||
"Risk": "SMS is the weakest recovery channel: an attacker who performs a **SIM swap** or intercepts **SS7** traffic receives the reset code and takes over the account, bypassing the password. A pool with no `AccountRecoverySetting` is equally exposed, since Cognito then applies **legacy behavior preferring SMS over email**.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/cognito/latest/developerguide/how-to-recover-a-user-account.html",
|
||||
"https://docs.aws.amazon.com/cognito-user-identity-pools/latest/APIReference/API_AccountRecoverySettingType.html",
|
||||
"https://pages.nist.gov/800-63-3/sp800-63b.html#-5122-out-of-band-verifiers"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws cognito-idp update-user-pool --user-pool-id <example_resource_id> --account-recovery-setting RecoveryMechanisms=[{Priority=1,Name=verified_email}]",
|
||||
"NativeIaC": "```yaml\nResources:\n <example_resource_name>:\n Type: AWS::Cognito::UserPool\n Properties:\n AccountRecoverySetting:\n RecoveryMechanisms:\n - Name: verified_email # Critical: email is ranked ahead of SMS\n Priority: 1\n```",
|
||||
"Other": "1. Open the AWS Management Console and go to Amazon Cognito\n2. Click User pools and select your pool\n3. Go to Sign-in > Account recovery and click Edit\n4. Set Email only (or Admin only) as the recovery method, or move Email above SMS in the priority order\n5. Click Save changes",
|
||||
"Terraform": "```hcl\nresource \"aws_cognito_user_pool\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n\n account_recovery_setting {\n recovery_mechanism {\n name = \"verified_email\" # Critical: email is ranked ahead of SMS\n priority = 1\n }\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Set an explicit **account recovery** order that ranks `verified_email` (or `admin_only`) ahead of `verified_phone_number`.\n- Never rely on the **default**, which silently prefers SMS\n- Prefer `admin_only` for privileged pools so recovery requires an operator\n- Require **verified email** on sign-up so the stronger channel is always available",
|
||||
"Url": "https://hub.prowler.com/check/cognito_user_pool_account_recovery_sms_not_preferred"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"identity-access"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": ""
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.cognito.cognito_idp_client import cognito_idp_client
|
||||
|
||||
|
||||
class cognito_user_pool_account_recovery_sms_not_preferred(Check):
|
||||
"""Verify that user pools do not prefer SMS for account recovery.
|
||||
|
||||
A pool PASSES when the recovery mechanism with the lowest ``Priority`` is
|
||||
something other than ``verified_phone_number``. A pool with no
|
||||
``AccountRecoverySetting`` at all FAILS, because Amazon Cognito then applies
|
||||
the legacy behavior that prefers SMS over email.
|
||||
"""
|
||||
|
||||
def execute(self) -> list[Check_Report_AWS]:
|
||||
"""Execute the Cognito user pool account recovery check.
|
||||
|
||||
Returns:
|
||||
A list of reports with each user pool preferred recovery mechanism.
|
||||
"""
|
||||
findings = []
|
||||
for pool in cognito_idp_client.user_pools.values():
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=pool)
|
||||
mechanisms = (pool.account_recovery_settings or {}).get(
|
||||
"RecoveryMechanisms", []
|
||||
)
|
||||
if not mechanisms:
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"User pool {pool.name} does not define an account recovery setting, so SMS is preferred over email."
|
||||
else:
|
||||
preferred = min(mechanisms, key=lambda mechanism: mechanism["Priority"])
|
||||
if preferred["Name"] == "verified_phone_number":
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"User pool {pool.name} prefers SMS for account recovery."
|
||||
)
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"User pool {pool.name} prefers {preferred['Name']} over SMS for account recovery."
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -1,3 +1,5 @@
|
||||
from typing import ClassVar
|
||||
|
||||
from botocore.exceptions import ClientError
|
||||
from pydantic.v1 import BaseModel, Field
|
||||
|
||||
@@ -13,6 +15,7 @@ class Pipeline(BaseModel):
|
||||
name: str
|
||||
arn: str
|
||||
region: str
|
||||
sensitive_fields: ClassVar[tuple[str, ...]] = ("definition",)
|
||||
definition: dict = Field(default_factory=dict)
|
||||
tags: list[dict] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
from ipaddress import IPv4Address, IPv6Address, ip_address
|
||||
from typing import Optional, Union
|
||||
from typing import ClassVar, Optional, Union
|
||||
|
||||
from botocore.client import ClientError
|
||||
from pydantic.v1 import BaseModel
|
||||
@@ -776,6 +776,7 @@ class Instance(BaseModel):
|
||||
private_ip: Optional[str]
|
||||
public_dns: Optional[str]
|
||||
public_ip: Optional[str]
|
||||
sensitive_fields: ClassVar[tuple[str, ...]] = ("user_data",)
|
||||
user_data: Optional[str]
|
||||
http_tokens: Optional[str]
|
||||
http_endpoint: Optional[str]
|
||||
@@ -898,6 +899,7 @@ class InstanceMetadataDefaults(BaseModel):
|
||||
|
||||
|
||||
class TemplateData(BaseModel):
|
||||
sensitive_fields: ClassVar[tuple[str, ...]] = ("user_data",)
|
||||
user_data: str
|
||||
network_interfaces: Optional[list[NetworkInterface]]
|
||||
associate_public_ip_address: Optional[bool]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import datetime
|
||||
from itertools import zip_longest
|
||||
from re import sub
|
||||
from typing import Optional
|
||||
from typing import ClassVar, Optional
|
||||
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
@@ -286,6 +286,7 @@ class ECS(AWSService):
|
||||
|
||||
class ContainerEnvVariable(BaseModel):
|
||||
name: str
|
||||
sensitive_fields: ClassVar[tuple[str, ...]] = ("value",)
|
||||
value: str
|
||||
|
||||
|
||||
|
||||
Whitespace-only changes.
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "elasticbeanstalk_environment_php_platform_updated",
|
||||
"CheckTitle": "Elastic Beanstalk PHP environment runs a platform version with the fixed PHP release",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/Patch Management",
|
||||
"Software and Configuration Checks/Vulnerabilities/CVE"
|
||||
],
|
||||
"ServiceName": "elasticbeanstalk",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "critical",
|
||||
"ResourceType": "AwsElasticBeanstalkEnvironment",
|
||||
"ResourceGroup": "compute",
|
||||
"Description": "**Elastic Beanstalk PHP environments** are evaluated to confirm they run a supported PHP branch (`elasticbeanstalk_php_min_branch`, `8.2` by default) on an **Amazon Linux 2023 platform version** that bundles the fixed PHP release (`elasticbeanstalk_php_min_platform_version`, `4.13.13` by default). The platform family, version and PHP branch are read from the environment's solution stack name.",
|
||||
"Risk": "PHP platform versions published before the fixed release bundle a PHP runtime vulnerable to **unauthenticated remote code execution** through crafted web requests, giving attackers code execution with the web worker's privileges, access to application data and the ability to crash the environment. Branches that reached end of life never receive the fix.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/elasticbeanstalk/latest/relnotes/release-2026-10-21-al2023-php84-php85.html",
|
||||
"https://docs.aws.amazon.com/elasticbeanstalk/latest/relnotes/release-2026-10-22-al2023-php82-php83.html",
|
||||
"https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/using-features.platform.upgrade.html",
|
||||
"https://www.php.net/supported-versions.php"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws elasticbeanstalk update-environment --environment-name <environment-name> --solution-stack-name \"64bit Amazon Linux 2023 v4.13.13 running PHP 8.4\"",
|
||||
"NativeIaC": "```yaml\nResources:\n <example_resource_name>:\n Type: AWS::ElasticBeanstalk::Environment\n Properties:\n ApplicationName: <example_resource_name>\n SolutionStackName: \"64bit Amazon Linux 2023 v4.13.13 running PHP 8.4\" # Critical: platform version with the fixed PHP release\n```",
|
||||
"Other": "1. Open the AWS Management Console and go to Elastic Beanstalk\n2. Select the environment and open its dashboard\n3. Under Platform, choose Change version\n4. Select platform version 4.13.13 or newer for a supported PHP branch (8.2 or newer)\n5. Click Save and wait for the environment update to finish",
|
||||
"Terraform": "```hcl\nresource \"aws_elastic_beanstalk_environment\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n application = \"<example_resource_name>\"\n solution_stack_name = \"64bit Amazon Linux 2023 v4.13.13 running PHP 8.4\" # Critical: platform version with the fixed PHP release\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Update every PHP environment to the latest Amazon Linux 2023 platform version and keep **managed platform updates** enabled so future security releases are applied automatically. Move environments still on PHP 8.1 or older to a supported branch, since those branches receive no fixes. Validate in staging and roll out with a maintenance window.",
|
||||
"Url": "https://hub.prowler.com/check/elasticbeanstalk_environment_php_platform_updated"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"vulnerabilities"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"elasticbeanstalk_environment_managed_updates_enabled"
|
||||
],
|
||||
"Notes": "Only environments whose solution stack runs PHP are evaluated. Platform versions are compared only within the Amazon Linux 2023 family; older families are reported as failing because they did not receive fixed PHP releases."
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
import re
|
||||
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.elasticbeanstalk.elasticbeanstalk_client import (
|
||||
elasticbeanstalk_client,
|
||||
)
|
||||
|
||||
# "64bit Amazon Linux 2023 v4.13.13 running PHP 8.4"
|
||||
SOLUTION_STACK_RE = re.compile(
|
||||
r"^(?P<family>.+?) v(?P<version>\d+(?:\.\d+)+) running PHP (?P<branch>\d+\.\d+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# "arn:aws:elasticbeanstalk:us-east-1::platform/PHP 8.4 running on 64bit Amazon Linux 2023/4.13.13"
|
||||
PLATFORM_ARN_RE = re.compile(
|
||||
r"platform/PHP (?P<branch>\d+\.\d+) running on (?P<family>[^/]+)/(?P<version>\d+(?:\.\d+)+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
AMAZON_LINUX_YEAR_RE = re.compile(r"Amazon Linux (\d{4})")
|
||||
# First Amazon Linux generation that received platform versions with the fixed PHP release
|
||||
FIXED_PLATFORM_YEAR = 2023
|
||||
|
||||
|
||||
def _version_tuple(value: str) -> tuple[int, ...]:
|
||||
return tuple(int(part) for part in value.split(".")) if value else ()
|
||||
|
||||
|
||||
def _family_has_fixed_releases(family: str) -> bool:
|
||||
match = AMAZON_LINUX_YEAR_RE.search(family)
|
||||
return bool(match) and int(match.group(1)) >= FIXED_PLATFORM_YEAR
|
||||
|
||||
|
||||
def _php_platform(environment) -> tuple[str, str, str] | None:
|
||||
"""Return (branch, family, version) for PHP environments, None otherwise."""
|
||||
for pattern, value in (
|
||||
(SOLUTION_STACK_RE, environment.solution_stack_name),
|
||||
(PLATFORM_ARN_RE, environment.platform_arn),
|
||||
):
|
||||
match = pattern.search(value or "")
|
||||
if match:
|
||||
return match["branch"], match["family"], match["version"]
|
||||
return None
|
||||
|
||||
|
||||
class elasticbeanstalk_environment_php_platform_updated(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
# `or` covers an explicit null in the config file, which .get() would return as None
|
||||
min_php_branch = (
|
||||
elasticbeanstalk_client.audit_config.get("elasticbeanstalk_php_min_branch")
|
||||
or "8.2"
|
||||
)
|
||||
min_platform_version = (
|
||||
elasticbeanstalk_client.audit_config.get(
|
||||
"elasticbeanstalk_php_min_platform_version"
|
||||
)
|
||||
or "4.13.13"
|
||||
)
|
||||
|
||||
for environment in elasticbeanstalk_client.environments.values():
|
||||
php_platform = _php_platform(environment)
|
||||
if not php_platform:
|
||||
continue
|
||||
|
||||
php_branch, family, platform_version = php_platform
|
||||
stack = environment.solution_stack_name or environment.platform_arn
|
||||
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=environment)
|
||||
report.status = "FAIL"
|
||||
|
||||
if _version_tuple(php_branch) < _version_tuple(min_php_branch):
|
||||
report.status_extended = f"Elastic Beanstalk environment {environment.name} runs PHP {php_branch}, a branch that no longer receives security fixes (minimum supported branch is {min_php_branch})."
|
||||
elif not _family_has_fixed_releases(family):
|
||||
report.status_extended = f"Elastic Beanstalk environment {environment.name} runs PHP {php_branch} on platform '{stack}', a platform family without fixed PHP releases; migrate it to a PHP {php_branch} Amazon Linux 2023 platform version {min_platform_version} or newer."
|
||||
elif _version_tuple(platform_version) < _version_tuple(
|
||||
min_platform_version
|
||||
):
|
||||
report.status_extended = f"Elastic Beanstalk environment {environment.name} runs PHP {php_branch} on platform version {platform_version}, below the minimum fixed platform version {min_platform_version}."
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Elastic Beanstalk environment {environment.name} runs PHP {php_branch} on platform version {platform_version}, which includes the fixed PHP release."
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -1,4 +1,4 @@
|
||||
from typing import Optional
|
||||
from typing import ClassVar, Optional
|
||||
|
||||
from botocore.client import ClientError
|
||||
from pydantic.v1 import BaseModel
|
||||
@@ -39,6 +39,10 @@ class ElasticBeanstalk(AWSService):
|
||||
application_name=environment["ApplicationName"],
|
||||
name=environment["EnvironmentName"],
|
||||
region=regional_client.region,
|
||||
platform_arn=environment.get("PlatformArn", ""),
|
||||
solution_stack_name=environment.get(
|
||||
"SolutionStackName", ""
|
||||
),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
@@ -121,8 +125,11 @@ class Environment(BaseModel):
|
||||
arn: str
|
||||
region: str
|
||||
application_name: str
|
||||
platform_arn: str = ""
|
||||
solution_stack_name: str = ""
|
||||
health_reporting: Optional[str]
|
||||
managed_platform_updates: Optional[str]
|
||||
cloudwatch_stream_logs: Optional[str]
|
||||
tags: Optional[list] = []
|
||||
sensitive_fields: ClassVar[tuple[str, ...]] = ("option_settings",)
|
||||
option_settings: Optional[list] = None
|
||||
@@ -1,5 +1,5 @@
|
||||
import json
|
||||
from typing import Dict, List, Optional
|
||||
from typing import ClassVar, Dict, List, Optional
|
||||
|
||||
from botocore.exceptions import ClientError
|
||||
from pydantic.v1 import BaseModel, Field
|
||||
@@ -275,6 +275,7 @@ class Connection(BaseModel):
|
||||
name: str
|
||||
arn: str
|
||||
type: str
|
||||
sensitive_fields: ClassVar[tuple[str, ...]] = ("properties",)
|
||||
properties: dict
|
||||
region: str
|
||||
tags: Optional[List[Dict[str, str]]] = Field(default_factory=list)
|
||||
@@ -308,6 +309,7 @@ class Job(BaseModel):
|
||||
arn: str
|
||||
name: str
|
||||
security: Optional[str]
|
||||
sensitive_fields: ClassVar[tuple[str, ...]] = ("arguments",)
|
||||
arguments: Optional[Dict[str, str]] = Field(default_factory=dict)
|
||||
region: str
|
||||
tags: Optional[List[Dict[str, str]]] = Field(default_factory=list)
|
||||
|
||||
Whitespace-only changes.
+43
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "lightsail_instance_php_blueprint_updated",
|
||||
"CheckTitle": "Lightsail instance created from a PHP blueprint runs a fixed PHP release",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/Patch Management",
|
||||
"Software and Configuration Checks/Vulnerabilities/CVE"
|
||||
],
|
||||
"ServiceName": "lightsail",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:partition:lightsail:region:account-id:Instance/resource-id",
|
||||
"Severity": "high",
|
||||
"ResourceType": "Other",
|
||||
"ResourceGroup": "compute",
|
||||
"Description": "**Lightsail instances** created from blueprints that ship PHP (WordPress, WordPress Multisite, LAMP, Nginx and other PHP applications, configurable through `lightsail_php_blueprints`) are flagged for manual verification, because the PHP runtime inside the instance is not visible through the Lightsail API and running instances are not patched in place.",
|
||||
"Risk": "A PHP release older than the fixed versions is vulnerable to **unauthenticated remote code execution** through crafted web requests. Lightsail does not update running instances, and Bitnami-packaged blueprints bundle their own PHP copy that the system package manager never updates, so the instance stays exploitable until it is patched by hand or recreated from an updated blueprint.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/lightsail/latest/userguide/amazon-lightsail-update-php-security-vulnerabilities.html",
|
||||
"https://docs.aws.amazon.com/lightsail/latest/userguide/migrate-from-bitnami-to-lightsail-blueprints.html",
|
||||
"https://www.php.net/supported-versions.php"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "sudo apt update && sudo apt upgrade -y && sudo systemctl restart apache2 && php -v",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Create a snapshot of the instance\n2. Connect over SSH and check the PHP release: php -v on Lightsail-packaged blueprints, /opt/bitnami/php/bin/php -v on Bitnami-packaged blueprints\n3. Lightsail-packaged blueprints (WordPress, WordPress Multisite, LAMP, Nginx): run sudo apt update && sudo apt upgrade, then restart Apache (sudo systemctl restart apache2) or PHP-FPM on Nginx (sudo systemctl restart php8.2-fpm)\n4. Bitnami-packaged WordPress, WordPress Multisite and LAMP: create a new instance from the matching Lightsail-packaged blueprint, move the content, database and static IP, then delete the old instance\n5. Bitnami-packaged applications without a Lightsail-packaged equivalent (Drupal, Joomla, Magento, PrestaShop and others): install a package-managed PHP-FPM alongside the Bitnami copy and point the Bitnami web server at it, following the AWS guide linked above\n6. Confirm the web server uses the fixed release: 8.2.35, 8.3.36, 8.4.27, 8.5.12 or newer",
|
||||
"Terraform": ""
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Verify the PHP release on every instance created from a PHP blueprint and update it immediately. Prefer **Lightsail-packaged blueprints**, whose PHP is maintained through the system package manager, over Bitnami-packaged ones; where no Lightsail-packaged equivalent exists, replace the bundled Bitnami PHP with a package-managed PHP-FPM so future updates arrive through apt. Upgrade instances still on PHP 8.1 or older, since those branches receive no security fixes.",
|
||||
"Url": "https://hub.prowler.com/check/lightsail_instance_php_blueprint_updated"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"vulnerabilities"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"lightsail_instance_automated_snapshots"
|
||||
],
|
||||
"Notes": "This check always reports MANUAL for instances matching a PHP blueprint prefix, because the PHP version is only observable from inside the instance. Instances created from other blueprints are not evaluated."
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.lightsail.lightsail_client import lightsail_client
|
||||
|
||||
default_php_blueprints = [
|
||||
"wordpress",
|
||||
"lamp",
|
||||
"nginx",
|
||||
"drupal",
|
||||
"joomla",
|
||||
"magento",
|
||||
"prestashop",
|
||||
"plesk",
|
||||
"cpanel",
|
||||
]
|
||||
|
||||
|
||||
class lightsail_instance_php_blueprint_updated(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
php_blueprints = (
|
||||
lightsail_client.audit_config.get("lightsail_php_blueprints")
|
||||
or default_php_blueprints
|
||||
)
|
||||
|
||||
for instance in lightsail_client.instances.values():
|
||||
blueprint = instance.blueprint_id.lower()
|
||||
if not any(blueprint.startswith(prefix) for prefix in php_blueprints):
|
||||
continue
|
||||
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=instance)
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = f"Lightsail instance {instance.name} was created from the PHP blueprint '{instance.blueprint_id}'; the PHP runtime cannot be inspected through the API, verify over SSH that it runs a fixed release (8.2.35, 8.3.36, 8.4.27, 8.5.12 or newer); Bitnami-packaged blueprints bundle a PHP copy that apt never updates, so migrate them to the Lightsail-packaged blueprint or replace the bundled PHP with a package-managed one."
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -81,6 +81,8 @@ class Lightsail(AWSService):
|
||||
ip_address_type=instance.get("ipAddressType", "ipv4"),
|
||||
ports=ports,
|
||||
auto_snapshot=auto_snapshot_enabled,
|
||||
blueprint_id=instance.get("blueprintId", ""),
|
||||
blueprint_name=instance.get("blueprintName", ""),
|
||||
)
|
||||
|
||||
except Exception as error:
|
||||
@@ -183,6 +185,8 @@ class Instance(BaseModel):
|
||||
ip_address_type: str
|
||||
ports: List[PortRange]
|
||||
auto_snapshot: bool
|
||||
blueprint_id: str = ""
|
||||
blueprint_name: str = ""
|
||||
|
||||
|
||||
class Database(BaseModel):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import base64
|
||||
from typing import Optional
|
||||
from typing import ClassVar, Optional
|
||||
|
||||
from botocore.client import ClientError
|
||||
from pydantic.v1 import BaseModel
|
||||
@@ -594,6 +594,7 @@ class NotebookInstance(BaseModel):
|
||||
lifecycle_config_name: str = None
|
||||
# Decoded lifecycle scripts keyed by "<hook>[<index>]" (e.g. "OnStart[0]"),
|
||||
# populated by _describe_notebook_instance_lifecycle_config.
|
||||
sensitive_fields: ClassVar[tuple[str, ...]] = ("lifecycle_scripts",)
|
||||
lifecycle_scripts: dict = {}
|
||||
# True if the lifecycle configuration could not be fully described/decoded,
|
||||
# so the secrets check reports MANUAL instead of a false PASS.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from typing import ClassVar, Optional
|
||||
|
||||
from botocore.client import ClientError
|
||||
from pydantic.v1 import BaseModel
|
||||
@@ -194,6 +194,7 @@ class Document(BaseModel):
|
||||
arn: str
|
||||
name: str
|
||||
region: str
|
||||
sensitive_fields: ClassVar[tuple[str, ...]] = ("content",)
|
||||
content: dict = None
|
||||
account_owners: list[str] = None
|
||||
tags: Optional[list] = []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional
|
||||
from typing import ClassVar, Dict, List, Optional
|
||||
|
||||
from botocore.exceptions import ClientError
|
||||
from pydantic.v1 import BaseModel, Field
|
||||
@@ -130,6 +130,7 @@ class StateMachine(BaseModel):
|
||||
arn: str
|
||||
name: Optional[str] = None
|
||||
status: StateMachineStatus
|
||||
sensitive_fields: ClassVar[tuple[str, ...]] = ("definition",)
|
||||
definition: Optional[str] = None
|
||||
role_arn: Optional[str] = None
|
||||
type: StateMachineType
|
||||
|
||||
@@ -133,11 +133,23 @@ class WAFv2(AWSService):
|
||||
try:
|
||||
rules = get_web_acl.get("WebACL", {}).get("Rules", [])
|
||||
for rule in rules:
|
||||
managed_statement = rule.get("Statement", {}).get(
|
||||
"ManagedRuleGroupStatement"
|
||||
)
|
||||
new_rule = Rule(
|
||||
name=rule.get("Name", ""),
|
||||
cloudwatch_metrics_enabled=rule.get(
|
||||
"VisibilityConfig", {}
|
||||
).get("CloudWatchMetricsEnabled", False),
|
||||
managed_rule_group=(
|
||||
ManagedRuleGroup(
|
||||
vendor_name=managed_statement.get("VendorName", ""),
|
||||
name=managed_statement.get("Name", ""),
|
||||
version=managed_statement.get("Version", ""),
|
||||
)
|
||||
if managed_statement
|
||||
else None
|
||||
),
|
||||
)
|
||||
if (
|
||||
rule.get("Statement", {})
|
||||
@@ -200,11 +212,20 @@ class Scope(Enum):
|
||||
CLOUDFRONT = "CLOUDFRONT"
|
||||
|
||||
|
||||
class ManagedRuleGroup(BaseModel):
|
||||
"""Managed rule group referenced by a Web ACL rule."""
|
||||
|
||||
vendor_name: str
|
||||
name: str
|
||||
version: str = ""
|
||||
|
||||
|
||||
class Rule(BaseModel):
|
||||
"""Model representing a rule for the Web ACL."""
|
||||
|
||||
name: str
|
||||
cloudwatch_metrics_enabled: bool = False
|
||||
managed_rule_group: Optional[ManagedRuleGroup] = None
|
||||
|
||||
|
||||
class WebAclv2(BaseModel):
|
||||
|
||||
Whitespace-only changes.
+44
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"Provider": "aws",
|
||||
"CheckID": "wafv2_webacl_managed_rule_groups_enabled",
|
||||
"CheckTitle": "WAFv2 Web ACL uses managed rule groups",
|
||||
"CheckType": [
|
||||
"Software and Configuration Checks/AWS Security Best Practices",
|
||||
"Effects/Data Exposure"
|
||||
],
|
||||
"ServiceName": "wafv2",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "arn:partition:wafv2:region:account-id:scope/webacl/webacl-name/webacl-id",
|
||||
"Severity": "medium",
|
||||
"ResourceType": "AwsWafv2WebAcl",
|
||||
"ResourceGroup": "network",
|
||||
"Description": "**WAFv2 Web ACLs** are evaluated to confirm at least one rule references a **managed rule group** (`ManagedRuleGroupStatement`), such as the AWS Core rule set or the PHP application rule set, whose signatures the vendor updates when new attack patterns appear.",
|
||||
"Risk": "A Web ACL built only from custom rules receives no vendor-maintained signatures, so newly disclosed exploitation patterns against the protected application reach the backend uninspected until someone writes a rule by hand. Managed rule groups are the compensating control cloud providers ship while runtime patches roll out, including request body size limits and runtime-specific rules.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups.html",
|
||||
"https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-use-case.html#aws-managed-rule-groups-use-case-php-app",
|
||||
"https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-baseline.html"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "aws wafv2 get-web-acl --name <webacl-name> --scope <REGIONAL|CLOUDFRONT> --id <webacl-id> > webacl.json && <append {\"Name\":\"AWS-AWSManagedRulesCommonRuleSet\",\"Priority\":<unused-priority>,\"Statement\":{\"ManagedRuleGroupStatement\":{\"VendorName\":\"AWS\",\"Name\":\"AWSManagedRulesCommonRuleSet\"}},\"OverrideAction\":{\"None\":{}},\"VisibilityConfig\":{\"SampledRequestsEnabled\":true,\"CloudWatchMetricsEnabled\":true,\"MetricName\":\"AWSManagedRulesCommonRuleSet\"}} to .WebACL.Rules in webacl.json> && aws wafv2 update-web-acl --name <webacl-name> --scope <REGIONAL|CLOUDFRONT> --id <webacl-id> --lock-token <LockToken from webacl.json> --default-action <DefaultAction from webacl.json> --visibility-config <VisibilityConfig from webacl.json> --rules <Rules from webacl.json including the new rule>",
|
||||
"NativeIaC": "```yaml\nResources:\n <example_resource_name>:\n Type: AWS::WAFv2::WebACL\n Properties:\n Name: <example_resource_name>\n Scope: REGIONAL\n DefaultAction:\n Allow: {}\n VisibilityConfig:\n SampledRequestsEnabled: true\n CloudWatchMetricsEnabled: true\n MetricName: <example_resource_name>\n Rules:\n - Name: AWS-AWSManagedRulesCommonRuleSet\n Priority: 0\n OverrideAction:\n None: {}\n Statement:\n ManagedRuleGroupStatement: # Critical: managed rule group to pass the check\n VendorName: AWS\n Name: AWSManagedRulesCommonRuleSet\n VisibilityConfig:\n SampledRequestsEnabled: true\n CloudWatchMetricsEnabled: true\n MetricName: AWSManagedRulesCommonRuleSet\n```",
|
||||
"Other": "1. Open the AWS WAF console and select the Web ACL\n2. Open the Rules tab and choose Add rules > Add managed rule groups\n3. Enable the AWS Core rule set and the rule groups matching the protected application (for example PHP application); the console keeps the existing rules and default action\n4. Save the Web ACL and review sampled requests before switching rules from Count to Block",
|
||||
"Terraform": "```hcl\nresource \"aws_wafv2_web_acl\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n scope = \"REGIONAL\"\n\n default_action {\n allow {}\n }\n\n rule {\n name = \"AWS-AWSManagedRulesCommonRuleSet\"\n priority = 0\n\n override_action {\n none {}\n }\n\n statement {\n managed_rule_group_statement { # Critical: managed rule group to pass the check\n name = \"AWSManagedRulesCommonRuleSet\"\n vendor_name = \"AWS\"\n }\n }\n\n visibility_config {\n cloudwatch_metrics_enabled = true\n metric_name = \"AWSManagedRulesCommonRuleSet\"\n sampled_requests_enabled = true\n }\n }\n\n visibility_config {\n cloudwatch_metrics_enabled = true\n metric_name = \"<example_resource_name>\"\n sampled_requests_enabled = true\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Attach the **AWS Core rule set** to every Web ACL and add the managed rule groups that match the protected stack (PHP, SQL database, known bad inputs). Keep the default rule group version so vendor updates apply automatically, and review sampled requests before moving rules from Count to Block.",
|
||||
"Url": "https://hub.prowler.com/check/wafv2_webacl_managed_rule_groups_enabled"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"internet-exposed"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [
|
||||
"wafv2_webacl_with_rules",
|
||||
"wafv2_webacl_logging_enabled"
|
||||
],
|
||||
"Notes": "Rule groups attached through Firewall Manager are not inspected for managed statements, so a Web ACL protected only by Firewall Manager policies may be reported as failing. UpdateWebACL replaces the whole rule list and default action, so any CLI or IaC remediation must resend the existing configuration together with the new managed rule group."
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
from prowler.lib.check.models import Check, Check_Report_AWS
|
||||
from prowler.providers.aws.services.wafv2.wafv2_client import wafv2_client
|
||||
|
||||
|
||||
class wafv2_webacl_managed_rule_groups_enabled(Check):
|
||||
def execute(self):
|
||||
findings = []
|
||||
for web_acl in wafv2_client.web_acls.values():
|
||||
report = Check_Report_AWS(metadata=self.metadata(), resource=web_acl)
|
||||
managed_rule_groups = [
|
||||
f"{rule.managed_rule_group.vendor_name}/{rule.managed_rule_group.name}"
|
||||
for rule in web_acl.rules
|
||||
if rule.managed_rule_group
|
||||
]
|
||||
report.status = "FAIL"
|
||||
report.status_extended = (
|
||||
f"AWS WAFv2 Web ACL {web_acl.name} does not use any managed rule group."
|
||||
)
|
||||
|
||||
if managed_rule_groups:
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"AWS WAFv2 Web ACL {web_acl.name} uses managed rule groups: {', '.join(managed_rule_groups)}."
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
Whitespace-only changes.
@@ -0,0 +1,32 @@
|
||||
import re
|
||||
|
||||
_VERSION_RE = re.compile(r"\d+(?:\.\d+)*")
|
||||
_JAVA_SUFFIX_RE = re.compile(r"-(?:java|jre)(\d+)", re.IGNORECASE)
|
||||
|
||||
|
||||
def parse_runtime_version(value) -> tuple[int, ...]:
|
||||
"""Return the first dotted number in a stack string as an int tuple, empty if none."""
|
||||
match = _VERSION_RE.search("" if value is None else str(value))
|
||||
return tuple(int(part) for part in match.group().split(".")) if match else ()
|
||||
|
||||
|
||||
def parse_java_version(value) -> tuple[int, ...]:
|
||||
"""Java major from 'JAVA|17-java17', 'TOMCAT|10.0-java11', '1.8' or '17'."""
|
||||
match = _JAVA_SUFFIX_RE.search("" if value is None else str(value))
|
||||
if match:
|
||||
return (int(match.group(1)),)
|
||||
version = parse_runtime_version(value)
|
||||
# Legacy '1.8' naming for Java 8
|
||||
if len(version) > 1 and version[0] == 1:
|
||||
return (version[1],)
|
||||
return version[:1]
|
||||
|
||||
|
||||
def runtime_version_at_least(
|
||||
current: tuple[int, ...], minimum: tuple[int, ...]
|
||||
) -> bool:
|
||||
"""Compare only the components the platform exposes (no patch level in linuxFxVersion)."""
|
||||
if not current or not minimum:
|
||||
return False
|
||||
length = min(len(current), len(minimum))
|
||||
return current[:length] >= minimum[:length]
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "app_ensure_java_version_is_latest",
|
||||
"CheckTitle": "App Service web app uses the latest supported Java version or 17 by default",
|
||||
"CheckTitle": "App Service web app runs a supported Java version (17 or newer by default)",
|
||||
"CheckType": [],
|
||||
"ServiceName": "app",
|
||||
"SubServiceName": "",
|
||||
@@ -9,7 +9,7 @@
|
||||
"Severity": "low",
|
||||
"ResourceType": "microsoft.web/sites",
|
||||
"ResourceGroup": "serverless",
|
||||
"Description": "**Azure App Service web apps** that run **Java** are assessed to ensure their configured runtime uses the **latest supported major version** (LTS) for the environment, across Linux and Windows.\n\n*Only apps with Java enabled are considered.*",
|
||||
"Description": "**Azure App Service web apps** that run **Java** are assessed to ensure their configured runtime major version is at or above the configured **minimum supported LTS** (`java_latest_version`, `17` by default), across Linux and Windows.\n\n*Only apps with Java enabled are considered.*",
|
||||
"Risk": "Using an **outdated Java runtime** enables known exploits like **remote code execution**, unsafe **deserialization**, and **cryptographic flaws**, risking data theft and tampering (**confidentiality, integrity**) and outages or takeover (**availability**). Unsupported versions also delay critical security patches.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
|
||||
+28
-18
@@ -1,4 +1,8 @@
|
||||
from prowler.lib.check.models import Check, Check_Report_Azure
|
||||
from prowler.providers.azure.lib.runtime.runtime_version import (
|
||||
parse_java_version,
|
||||
runtime_version_at_least,
|
||||
)
|
||||
from prowler.providers.azure.services.app.app_client import app_client
|
||||
|
||||
|
||||
@@ -14,27 +18,33 @@ class app_ensure_java_version_is_latest(Check):
|
||||
subscription_id, subscription_id
|
||||
)
|
||||
for app in apps.values():
|
||||
linux_framework = getattr(app.configurations, "linux_fx_version", "")
|
||||
windows_framework_version = getattr(
|
||||
app.configurations, "java_version", None
|
||||
# The SDK can return None for linuxFxVersion, which getattr preserves
|
||||
linux_framework = (
|
||||
getattr(app.configurations, "linux_fx_version", "") or ""
|
||||
)
|
||||
windows_version = getattr(app.configurations, "java_version", None)
|
||||
|
||||
if "java" in linux_framework.lower() or windows_framework_version:
|
||||
report = Check_Report_Azure(metadata=self.metadata(), resource=app)
|
||||
report.subscription = subscription_id
|
||||
report.status = "FAIL"
|
||||
java_latest_version = app_client.audit_config.get(
|
||||
"java_latest_version", "17"
|
||||
)
|
||||
report.status_extended = f"Java version is set to '{f'java{windows_framework_version}' if windows_framework_version else linux_framework}', but should be set to 'java {java_latest_version}' for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'."
|
||||
if "java" not in linux_framework.lower() and not windows_version:
|
||||
continue
|
||||
|
||||
if (
|
||||
f"java{java_latest_version}" in linux_framework
|
||||
or java_latest_version == windows_framework_version
|
||||
):
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Java version is set to 'java {java_latest_version}' for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'."
|
||||
framework = (
|
||||
linux_framework
|
||||
if "java" in linux_framework.lower()
|
||||
else f"java{windows_version}"
|
||||
)
|
||||
minimum = app_client.audit_config.get("java_latest_version") or "17"
|
||||
|
||||
findings.append(report)
|
||||
report = Check_Report_Azure(metadata=self.metadata(), resource=app)
|
||||
report.subscription = subscription_id
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"Java version is set to '{framework}', which is below the minimum supported version 'java {minimum}', for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'."
|
||||
|
||||
if runtime_version_at_least(
|
||||
parse_java_version(framework), parse_java_version(minimum)
|
||||
):
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Java version is set to '{framework}', which meets the minimum supported version 'java {minimum}', for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'."
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+7
-7
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "app_ensure_php_version_is_latest",
|
||||
"CheckTitle": "App Service web app uses the latest supported PHP version or 8.2 by default",
|
||||
"CheckTitle": "App Service web app runs a supported PHP version (8.5 or newer by default)",
|
||||
"CheckType": [],
|
||||
"ServiceName": "app",
|
||||
"SubServiceName": "",
|
||||
@@ -9,7 +9,7 @@
|
||||
"Severity": "low",
|
||||
"ResourceType": "microsoft.web/sites",
|
||||
"ResourceGroup": "serverless",
|
||||
"Description": "**Azure App Service web apps** running **PHP** are evaluated to ensure the runtime is configured to the **latest supported** release. The finding compares the app's PHP stack (from `linuxFxVersion` or `php_version`) with the newest available version.",
|
||||
"Description": "**Azure App Service web apps** running **PHP** are evaluated to ensure the runtime is at or above the configured **minimum supported** release. The finding compares the app's PHP stack (from `linuxFxVersion` or `php_version`) with `php_latest_version` (`8.5` by default); any version at or above it passes.",
|
||||
"Risk": "Using **outdated PHP** enables exploitation of known flaws, including **remote code execution**, causing secret disclosure (confidentiality), unauthorized changes (integrity), and crashes or downtime (availability). Deprecated versions lack patches, widening exposure and instability.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
@@ -19,10 +19,10 @@
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "az webapp config set --resource-group <RESOURCE_GROUP_NAME> --name <APP_NAME> --linux-fx-version \"PHP|8.2\"",
|
||||
"NativeIaC": "```bicep\n// Update App Service runtime to latest PHP\nresource appConfig 'Microsoft.Web/sites/config@2022-09-01' = {\n name: '<example_resource_name>/web'\n properties: {\n linuxFxVersion: 'PHP|8.2' // Critical: sets the app runtime to PHP 8.2 (latest) to pass the check\n }\n}\n```",
|
||||
"Other": "1. In Azure Portal, go to App Services and select your app\n2. Navigate to Settings > Configuration > General settings\n3. Under Stack settings, select PHP and set Version to 8.2\n4. Click Save and confirm the restart",
|
||||
"Terraform": "```hcl\n# Set latest PHP version on Linux Web App\nresource \"azurerm_linux_web_app\" \"app\" {\n name = \"<example_resource_name>\"\n resource_group_name = \"<example_resource_name>\"\n location = \"<example_location>\"\n service_plan_id = \"<example_resource_id>\"\n\n site_config {\n application_stack {\n php_version = \"8.2\" # Critical: sets PHP to latest to pass the check\n }\n }\n}\n```"
|
||||
"CLI": "az webapp config set --resource-group <RESOURCE_GROUP_NAME> --name <APP_NAME> --linux-fx-version \"PHP|8.5\"",
|
||||
"NativeIaC": "```bicep\n// Update App Service runtime to latest PHP\nresource appConfig 'Microsoft.Web/sites/config@2022-09-01' = {\n name: '<example_resource_name>/web'\n properties: {\n linuxFxVersion: 'PHP|8.5' // Critical: sets the app runtime to PHP 8.5 (latest) to pass the check\n }\n}\n```",
|
||||
"Other": "1. In Azure Portal, go to App Services and select your app\n2. Navigate to Settings > Configuration > General settings\n3. Under Stack settings, select PHP and set Version to 8.5\n4. Click Save and confirm the restart",
|
||||
"Terraform": "```hcl\n# Set latest PHP version on Linux Web App\nresource \"azurerm_linux_web_app\" \"app\" {\n name = \"<example_resource_name>\"\n resource_group_name = \"<example_resource_name>\"\n location = \"<example_location>\"\n service_plan_id = \"<example_resource_id>\"\n\n site_config {\n application_stack {\n php_version = \"8.5\" # Critical: sets PHP to latest to pass the check\n }\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Standardize on the **latest supported PHP** and avoid EoL releases. Update promptly after security advisories, validate in staging, and automate version governance across apps. Prefer supported Linux runtimes, limit optional extensions, and apply **defense in depth** and **least privilege** to reduce blast radius.",
|
||||
@@ -34,5 +34,5 @@
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "If your app is written using version-dependent features or libraries, they may not be available on the latest version. If you wish to upgrade, research the impact thoroughly. Upgrading may have unforeseen consequences that could result in downtime"
|
||||
"Notes": "App Service exposes only the PHP major.minor version, not the patch level, so this check cannot confirm a specific patch release is deployed. If your app depends on version-specific features or libraries, research the impact before upgrading."
|
||||
}
|
||||
+29
-21
@@ -1,4 +1,8 @@
|
||||
from prowler.lib.check.models import Check, Check_Report_Azure
|
||||
from prowler.providers.azure.lib.runtime.runtime_version import (
|
||||
parse_runtime_version,
|
||||
runtime_version_at_least,
|
||||
)
|
||||
from prowler.providers.azure.services.app.app_client import app_client
|
||||
|
||||
|
||||
@@ -14,29 +18,33 @@ class app_ensure_php_version_is_latest(Check):
|
||||
subscription_id, subscription_id
|
||||
)
|
||||
for app in apps.values():
|
||||
framework = getattr(app.configurations, "linux_fx_version", "")
|
||||
# The SDK can return None for linuxFxVersion, which getattr preserves
|
||||
linux_framework = (
|
||||
getattr(app.configurations, "linux_fx_version", "") or ""
|
||||
)
|
||||
windows_version = getattr(app.configurations, "php_version", "")
|
||||
|
||||
if "php" in framework.lower() or getattr(
|
||||
app.configurations, "php_version", ""
|
||||
if "php" not in linux_framework.lower() and not windows_version:
|
||||
continue
|
||||
|
||||
framework = (
|
||||
linux_framework
|
||||
if "php" in linux_framework.lower()
|
||||
else windows_version
|
||||
)
|
||||
minimum = app_client.audit_config.get("php_latest_version") or "8.5"
|
||||
|
||||
report = Check_Report_Azure(metadata=self.metadata(), resource=app)
|
||||
report.subscription = subscription_id
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"PHP version is set to '{framework}', which is below the minimum supported version '{minimum}', for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'."
|
||||
|
||||
if runtime_version_at_least(
|
||||
parse_runtime_version(framework), parse_runtime_version(minimum)
|
||||
):
|
||||
report = Check_Report_Azure(metadata=self.metadata(), resource=app)
|
||||
report.subscription = subscription_id
|
||||
report.status = "FAIL"
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"PHP version is set to '{framework}', which meets the minimum supported version '{minimum}', for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'."
|
||||
|
||||
php_latest_version = app_client.audit_config.get(
|
||||
"php_latest_version", "8.2"
|
||||
)
|
||||
|
||||
report.status_extended = f"PHP version is set to '{framework}', the latest version that you could use is the '{php_latest_version}' version, for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'."
|
||||
|
||||
if (
|
||||
php_latest_version in framework
|
||||
or getattr(app.configurations, "php_version", None)
|
||||
== php_latest_version
|
||||
):
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"PHP version is set to '{php_latest_version}' for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'."
|
||||
|
||||
findings.append(report)
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"Provider": "azure",
|
||||
"CheckID": "app_ensure_python_version_is_latest",
|
||||
"CheckTitle": "App Service web app uses the latest supported Python version or 3.12 by default",
|
||||
"CheckTitle": "App Service web app runs a supported Python version (3.12 or newer by default)",
|
||||
"CheckType": [],
|
||||
"ServiceName": "app",
|
||||
"SubServiceName": "",
|
||||
@@ -9,7 +9,7 @@
|
||||
"Severity": "low",
|
||||
"ResourceType": "microsoft.web/sites",
|
||||
"ResourceGroup": "serverless",
|
||||
"Description": "**Azure App Service web apps** using **Python** are assessed to confirm the runtime is the **latest supported version** (e.g., `3.12`). The evaluation reads the app's stack configuration to detect Python usage and compares the configured runtime against the defined latest baseline.",
|
||||
"Description": "**Azure App Service web apps** using **Python** are assessed to confirm the runtime is at or above the configured **minimum supported version** (`python_latest_version`, `3.12` by default). The evaluation reads the app's stack configuration to detect Python usage and compares the configured runtime against that baseline.",
|
||||
"Risk": "Outdated **Python runtimes** weaken security and reliability:\n- Compromise confidentiality via known interpreter/SSL flaws\n- Undermine integrity through RCE and package exploitation\n- Reduce availability when deprecated versions lose patches and break under load",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
|
||||
+29
-19
@@ -1,4 +1,8 @@
|
||||
from prowler.lib.check.models import Check, Check_Report_Azure
|
||||
from prowler.providers.azure.lib.runtime.runtime_version import (
|
||||
parse_runtime_version,
|
||||
runtime_version_at_least,
|
||||
)
|
||||
from prowler.providers.azure.services.app.app_client import app_client
|
||||
|
||||
|
||||
@@ -14,27 +18,33 @@ class app_ensure_python_version_is_latest(Check):
|
||||
subscription_id, subscription_id
|
||||
)
|
||||
for app in apps.values():
|
||||
framework = getattr(app.configurations, "linux_fx_version", "")
|
||||
# The SDK can return None for linuxFxVersion, which getattr preserves
|
||||
linux_framework = (
|
||||
getattr(app.configurations, "linux_fx_version", "") or ""
|
||||
)
|
||||
windows_version = getattr(app.configurations, "python_version", "")
|
||||
|
||||
if "python" in framework.lower() or getattr(
|
||||
app.configurations, "python_version", ""
|
||||
if "python" not in linux_framework.lower() and not windows_version:
|
||||
continue
|
||||
|
||||
framework = (
|
||||
linux_framework
|
||||
if "python" in linux_framework.lower()
|
||||
else windows_version
|
||||
)
|
||||
minimum = app_client.audit_config.get("python_latest_version") or "3.12"
|
||||
|
||||
report = Check_Report_Azure(metadata=self.metadata(), resource=app)
|
||||
report.subscription = subscription_id
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"Python version is set to '{framework}', which is below the minimum supported version '{minimum}', for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'."
|
||||
|
||||
if runtime_version_at_least(
|
||||
parse_runtime_version(framework), parse_runtime_version(minimum)
|
||||
):
|
||||
report = Check_Report_Azure(metadata=self.metadata(), resource=app)
|
||||
report.subscription = subscription_id
|
||||
report.status = "FAIL"
|
||||
python_latest_version = app_client.audit_config.get(
|
||||
"python_latest_version", "3.12"
|
||||
)
|
||||
report.status_extended = f"Python version is '{framework}', the latest version that you could use is the '{python_latest_version}' version, for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'."
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Python version is set to '{framework}', which meets the minimum supported version '{minimum}', for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'."
|
||||
|
||||
if (
|
||||
python_latest_version in framework
|
||||
or getattr(app.configurations, "python_version", None)
|
||||
== python_latest_version
|
||||
):
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"Python version is set to '{python_latest_version}' for app '{app.name}' in subscription '{subscription_name} ({subscription_id})'."
|
||||
|
||||
findings.append(report)
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
Whitespace-only changes.
@@ -0,0 +1,4 @@
|
||||
from prowler.providers.common.provider import Provider
|
||||
from prowler.providers.gcp.services.appengine.appengine_service import AppEngine
|
||||
|
||||
appengine_client = AppEngine(Provider.get_global_provider())
|
||||
@@ -0,0 +1,109 @@
|
||||
from googleapiclient.errors import HttpError
|
||||
from pydantic.v1 import BaseModel
|
||||
|
||||
from prowler.lib.logger import logger
|
||||
from prowler.providers.gcp.config import DEFAULT_RETRY_ATTEMPTS
|
||||
from prowler.providers.gcp.gcp_provider import GcpProvider
|
||||
from prowler.providers.gcp.lib.service.service import GCPService
|
||||
|
||||
|
||||
class AppEngine(GCPService):
|
||||
"""App Engine Admin API client exposing every service version per project."""
|
||||
|
||||
def __init__(self, provider: GcpProvider) -> None:
|
||||
super().__init__("appengine", provider)
|
||||
self.versions = []
|
||||
self._get_versions()
|
||||
|
||||
def _get_versions(self) -> None:
|
||||
for project_id in self.project_ids:
|
||||
try:
|
||||
app = (
|
||||
self.client.apps()
|
||||
.get(appsId=project_id)
|
||||
.execute(num_retries=DEFAULT_RETRY_ATTEMPTS)
|
||||
)
|
||||
except HttpError as http_error:
|
||||
# 404 means the API is enabled but no App Engine application exists
|
||||
if http_error.resp.status == 404:
|
||||
logger.warning(
|
||||
f"{project_id} -- App Engine API is enabled but no application exists"
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f"{project_id} -- {http_error.__class__.__name__}[{http_error.__traceback__.tb_lineno}]: {http_error}"
|
||||
)
|
||||
continue
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{project_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
continue
|
||||
|
||||
location = app.get("locationId", "")
|
||||
try:
|
||||
services = self.client.apps().services()
|
||||
services_request = services.list(appsId=project_id)
|
||||
while services_request is not None:
|
||||
services_response = services_request.execute(
|
||||
num_retries=DEFAULT_RETRY_ATTEMPTS
|
||||
)
|
||||
for service in services_response.get("services", []):
|
||||
service_id = service.get("id", "")
|
||||
self._get_service_versions(project_id, location, service_id)
|
||||
services_request = services.list_next(
|
||||
previous_request=services_request,
|
||||
previous_response=services_response,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{project_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
def _get_service_versions(
|
||||
self, project_id: str, location: str, service_id: str
|
||||
) -> None:
|
||||
try:
|
||||
versions = self.client.apps().services().versions()
|
||||
request = versions.list(appsId=project_id, servicesId=service_id)
|
||||
while request is not None:
|
||||
response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS)
|
||||
for version in response.get("versions", []):
|
||||
version_id = version.get("id", "")
|
||||
self.versions.append(
|
||||
Version(
|
||||
id=version.get(
|
||||
"name",
|
||||
f"apps/{project_id}/services/{service_id}/versions/{version_id}",
|
||||
),
|
||||
name=f"{service_id}/{version_id}",
|
||||
version_id=version_id,
|
||||
service=service_id,
|
||||
project_id=project_id,
|
||||
location=location,
|
||||
runtime=version.get("runtime", ""),
|
||||
env=version.get("env", "standard"),
|
||||
serving_status=version.get("servingStatus", "SERVING"),
|
||||
)
|
||||
)
|
||||
request = versions.list_next(
|
||||
previous_request=request, previous_response=response
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
f"{project_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
|
||||
)
|
||||
|
||||
|
||||
class Version(BaseModel):
|
||||
"""App Engine service version consumed by GCP checks."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
version_id: str
|
||||
service: str
|
||||
project_id: str
|
||||
location: str
|
||||
runtime: str
|
||||
env: str = "standard"
|
||||
serving_status: str = "SERVING"
|
||||
Whitespace-only changes.
+38
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"Provider": "gcp",
|
||||
"CheckID": "appengine_version_php_runtime_supported",
|
||||
"CheckTitle": "App Engine serving version runs a supported PHP runtime",
|
||||
"CheckType": [],
|
||||
"ServiceName": "appengine",
|
||||
"SubServiceName": "",
|
||||
"ResourceIdTemplate": "",
|
||||
"Severity": "critical",
|
||||
"ResourceType": "appengine.googleapis.com/Version",
|
||||
"Description": "**App Engine** serving versions that run **PHP** are evaluated to confirm the runtime is a supported branch (`appengine_php_min_version`, `8.2` by default). Standard environment runtimes such as `php82` are compared directly; flexible environment versions are reported for manual verification because their PHP binary lives in a customer-built image that is not patched in place.",
|
||||
"Risk": "PHP runtimes older than the supported branches are vulnerable to **unauthenticated remote code execution** through crafted web requests and receive no fixes, so a serving version pinned to one of them stays exploitable. Flexible environment versions keep the vulnerable binary until they are redeployed on a fixed release.",
|
||||
"RelatedUrl": "",
|
||||
"AdditionalURLs": [
|
||||
"https://cloud.google.com/appengine/docs/standard/lifecycle/support-schedule",
|
||||
"https://cloud.google.com/appengine/docs/standard/php-gen2/runtime",
|
||||
"https://cloud.google.com/appengine/docs/flexible/php/runtime",
|
||||
"https://www.php.net/supported-versions.php"
|
||||
],
|
||||
"Remediation": {
|
||||
"Code": {
|
||||
"CLI": "gcloud app deploy app.yaml --project <PROJECT_ID> # with runtime: php84 in app.yaml",
|
||||
"NativeIaC": "",
|
||||
"Other": "1. Edit the service's app.yaml and set runtime to a supported PHP branch (for example php84)\n2. For flexible environment services, rebuild the image on a fixed PHP release\n3. Run gcloud app deploy to create a new version\n4. Route traffic to the new version and stop or delete versions still on unsupported runtimes",
|
||||
"Terraform": "```hcl\nresource \"google_app_engine_standard_app_version\" \"<example_resource_name>\" {\n version_id = \"v2\"\n service = \"default\"\n runtime = \"php84\" # Critical: supported PHP runtime to pass the check\n\n entrypoint {\n shell = \"serve index.php\"\n }\n\n deployment {\n zip {\n source_url = \"https://storage.googleapis.com/<example_resource_id>/app.zip\"\n }\n }\n}\n```"
|
||||
},
|
||||
"Recommendation": {
|
||||
"Text": "Deploy every App Engine service on a **supported PHP runtime** and redeploy whenever a branch approaches end of support. Standard environment runtimes on supported branches receive security fixes automatically; flexible environment images must be rebuilt and redeployed, so prefer the standard environment for PHP workloads where possible.",
|
||||
"Url": "https://hub.prowler.com/check/appengine_version_php_runtime_supported"
|
||||
}
|
||||
},
|
||||
"Categories": [
|
||||
"vulnerabilities"
|
||||
],
|
||||
"DependsOn": [],
|
||||
"RelatedTo": [],
|
||||
"Notes": "Only versions with serving status SERVING and a PHP runtime are evaluated. Flexible environment versions are reported as MANUAL since the deployed PHP release is not visible through the Admin API; versions on the custom runtime are not evaluated at all because the Admin API does not expose which language they run."
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import re
|
||||
|
||||
from prowler.lib.check.models import Check, Check_Report_GCP
|
||||
from prowler.providers.gcp.services.appengine.appengine_client import appengine_client
|
||||
|
||||
# Standard environment runtimes look like "php82"; flexible ones are just "php"
|
||||
PHP_RUNTIME_RE = re.compile(r"^php(?P<major>\d)(?P<minor>\d+)?$", re.IGNORECASE)
|
||||
|
||||
|
||||
def _version_tuple(value: str) -> tuple[int, ...]:
|
||||
return tuple(int(part) for part in value.split(".")) if value else ()
|
||||
|
||||
|
||||
class appengine_version_php_runtime_supported(Check):
|
||||
"""Check that serving App Engine versions run a supported PHP runtime."""
|
||||
|
||||
def execute(self) -> list[Check_Report_GCP]:
|
||||
findings = []
|
||||
# `or` covers an explicit null in the config file, which .get() would return as None
|
||||
minimum = (
|
||||
appengine_client.audit_config.get("appengine_php_min_version") or "8.2"
|
||||
)
|
||||
|
||||
for version in appengine_client.versions:
|
||||
runtime = version.runtime.lower()
|
||||
if not runtime.startswith("php") or version.serving_status != "SERVING":
|
||||
continue
|
||||
|
||||
report = Check_Report_GCP(
|
||||
metadata=self.metadata(),
|
||||
resource=version,
|
||||
resource_id=version.id,
|
||||
)
|
||||
match = PHP_RUNTIME_RE.match(runtime)
|
||||
|
||||
if version.env.lower() in ("flex", "flexible") or not (
|
||||
match and match["minor"]
|
||||
):
|
||||
report.status = "MANUAL"
|
||||
report.status_extended = f"App Engine version {version.name} runs on the flexible environment with runtime '{version.runtime}', which is not patched in place; redeploy it on a fixed PHP release ({minimum} or newer) and verify the image."
|
||||
else:
|
||||
php_version = f"{match['major']}.{match['minor']}"
|
||||
if _version_tuple(php_version) < _version_tuple(minimum):
|
||||
report.status = "FAIL"
|
||||
report.status_extended = f"App Engine version {version.name} runs PHP {php_version} ('{version.runtime}'), a runtime that no longer receives security fixes (minimum supported version is {minimum})."
|
||||
else:
|
||||
report.status = "PASS"
|
||||
report.status_extended = f"App Engine version {version.name} runs PHP {php_version} ('{version.runtime}'), a supported runtime that receives security fixes automatically."
|
||||
|
||||
findings.append(report)
|
||||
|
||||
return findings
|
||||
@@ -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"
|
||||
Loaded 100 of 299 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user