Compare commits

...
Author SHA1 Message Date
renovate[bot] aa73ef6ab2 fix(sdk): update dependency h2 to v4.4.1 [security] 2026-10-25 02:22:14 +00:00
Pedro MartínandRubén De la Torre Vico 669df91ac9 feat(checks): PHP runtime coverage after the October security release (#13127)
Co-authored-by: Rubén De la Torre Vico <98956809+puchy22@users.noreply.github.com>
2026-10-23 12:49:36 +02:00
Alejandro Bailo 35d25a7218 feat(ui): limit locked tenants to a ten-row findings preview (#13130) 2026-10-23 10:58:15 +02:00
Pepe Fagoaga 819f5fd7a0 chore(googleworkspace): name the Policy API reducer the precedence rule implements (#13125) 2026-10-23 08:38:13 +02:00
Pedro Martín 7d71220a25 fix(sdk): redact secret fields in resource metadata (#13129) 2026-10-23 08:37:27 +02:00
Rubén De la Torre Vico 69b80f6a3f chore(azure): raise App Service PHP latest version default to 8.5 (#13128) 2026-10-22 15:33:06 +02:00
Alejandro Bailo f26b9776d4 fix(ui): bump Node to 24.22.1 to patch the October 2026 security release (#13126) 2026-10-22 14:28:46 +02:00
Pepe Fagoaga 54f2400aee fix(googleworkspace): map Gmail spoofing actions to the right fields (#13114) 2026-10-22 08:26:11 +02:00
Pepe Fagoaga 8c5357b087 feat(googleworkspace): explain findings gated by the Workspace edition (#13113) 2026-10-22 08:25:52 +02:00
Pedro Martín 13202b8a70 fix(api): require manage_account for SAML configuration (#13121) 2026-10-22 08:24:22 +02:00
Utkarsh Batham 680acf8b31 feat(cognito): add cognito_user_pool_account_recovery_sms_not_preferred check (#12251) 2026-10-22 01:32:19 +02:00
Jonathan Nguyen 1468596b61 feat(bedrock): assess invocation log content and guardrail output filtering (#12663) 2026-10-22 01:25:39 +02:00
Pepe Fagoaga 02c8881378 fix(googleworkspace): honor the sharing mode in Drive publishing check (#13115) 2026-10-22 01:11:14 +02:00
Pepe Fagoaga eae17a5e17 feat(googleworkspace): pass app checks when the service is off for everyone (#13117) 2026-10-22 01:07:19 +02:00
299 changed files with 9709 additions and 1527 deletions

No files matched your search

+10 -9
View File
@@ -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
View File
@@ -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)",
+85
View File
@@ -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.
+3 -1
View File
@@ -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):
+41
View File
@@ -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(
+24 -1
View File
@@ -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"
]
},
+2 -1
View File
@@ -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": [
{
+4 -2
View File
@@ -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": [
{
+7 -2
View File
@@ -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",
+7 -2
View File
@@ -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"
]
}
},
+9 -4
View File
@@ -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": []
},
+3 -1
View File
@@ -4427,7 +4427,9 @@
"ComplementaryCriteria": ""
}
],
"Checks": []
"Checks": [
"appengine_version_php_runtime_supported"
]
},
{
"Id": "OPS-33.01AS",
+4 -2
View File
@@ -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"
]
},
{
+28 -1
View File
@@ -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:
+22
View File
@@ -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[
+1 -1
View File
@@ -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(
+13 -2
View File
@@ -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".',
)
+2
View File
@@ -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 []
+43
View File
@@ -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,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 "
@@ -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."
}
@@ -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
@@ -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": ""
}
@@ -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
@@ -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": ""
}
@@ -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
@@ -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."
}
@@ -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)
@@ -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."
}
@@ -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):
@@ -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."
}
@@ -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]
@@ -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": [
@@ -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
@@ -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."
}
@@ -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
@@ -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": [
@@ -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"
@@ -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."
}
@@ -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