From d27ec7d62e8e5db4a6c4e8cfbd614cb53dc5b0f3 Mon Sep 17 00:00:00 2001 From: s1ns3nz0 Date: Fri, 19 Jun 2026 17:21:38 +0900 Subject: [PATCH] feat(azure): add postgresql_flexible_server_geo_redundant_backup_enabled check (#11045) Co-authored-by: Daniel Barranquero --- prowler/CHANGELOG.md | 5 + prowler/compliance/azure/hipaa_azure.json | 3 +- .../compliance/azure/iso27001_2022_azure.json | 6 +- .../__init__.py | 0 ...geo_redundant_backup_enabled.metadata.json | 37 +++++ ...ble_server_geo_redundant_backup_enabled.py | 36 +++++ .../services/postgresql/postgresql_service.py | 42 +++++- ...erver_geo_redundant_backup_enabled_test.py | 132 ++++++++++++++++++ .../postgresql/postgresql_service_test.py | 79 ++++++++++- 9 files changed, 329 insertions(+), 11 deletions(-) create mode 100644 prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/__init__.py create mode 100644 prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled.metadata.json create mode 100644 prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled.py create mode 100644 tests/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 197a7f961d..c3f0fdfa9e 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -22,6 +22,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `defender_ensure_defender_cspm_is_on` check for Azure provider, verifying Microsoft Defender Cloud Security Posture Management (CSPM) is enabled on the Standard tier [(#11037)](https://github.com/prowler-cloud/prowler/pull/11037) - `mysql_flexible_server_geo_redundant_backup_enabled` check for Azure provider, verifying MySQL Flexible Servers have geo-redundant backup enabled so backups are replicated to the paired region [(#11041)](https://github.com/prowler-cloud/prowler/pull/11041) - `mysql_flexible_server_high_availability_enabled` check for Azure provider, verifying MySQL Flexible Servers have high availability enabled for automatic failover to a standby replica [(#11042)](https://github.com/prowler-cloud/prowler/pull/11042) +- `postgresql_flexible_server_geo_redundant_backup_enabled` check for Azure provider, verifying PostgreSQL Flexible Servers have geo-redundant backup enabled so backups are replicated to the paired region [(#11045)](https://github.com/prowler-cloud/prowler/pull/11045) - `aks_cluster_auto_upgrade_enabled` check for Azure provider [(#11027)](https://github.com/prowler-cloud/prowler/pull/11027) - Public `Provider.get_class()` method that resolves a provider class by name for both built-in and external (entry-point) providers [(#11398)](https://github.com/prowler-cloud/prowler/pull/11398) - Jira timeout preventing the calls from hanging indefinitely when the Jira endpoint is unreachable or slow [(#11602)](https://github.com/prowler-cloud/prowler/pull/11602) @@ -38,6 +39,10 @@ All notable changes to the **Prowler SDK** are documented in this file. - Replaced the unmaintained `awsipranges` dependency with a small standard-library helper for the `route53_dangling_ip_subdomain_takeover` check [(#9293)](https://github.com/prowler-cloud/prowler/pull/9293) +### 🐞 Fixed + +- Azure PostgreSQL flexible server inventory no longer aborts the whole subscription when the `connection_throttle.enable` parameter is missing (e.g. PostgreSQL v18), and logs the expected "Entra ID authentication not enabled" case as a warning instead of an error, so servers are still scanned [(#11045)](https://github.com/prowler-cloud/prowler/pull/11045) + ### 🔐 Security - `pytest` from 8.3.5 to 9.0.3, patching a known vulnerability in the SDK test dependency [(#11291)](https://github.com/prowler-cloud/prowler/pull/11291) diff --git a/prowler/compliance/azure/hipaa_azure.json b/prowler/compliance/azure/hipaa_azure.json index 70f1e7e13f..b27344582f 100644 --- a/prowler/compliance/azure/hipaa_azure.json +++ b/prowler/compliance/azure/hipaa_azure.json @@ -440,7 +440,8 @@ "sqlserver_auditing_retention_90_days", "postgresql_flexible_server_log_retention_days_greater_3", "cosmosdb_account_backup_policy_continuous", - "mysql_flexible_server_geo_redundant_backup_enabled" + "mysql_flexible_server_geo_redundant_backup_enabled", + "postgresql_flexible_server_geo_redundant_backup_enabled" ] }, { diff --git a/prowler/compliance/azure/iso27001_2022_azure.json b/prowler/compliance/azure/iso27001_2022_azure.json index ee0e4ca89b..fb5b2d6915 100644 --- a/prowler/compliance/azure/iso27001_2022_azure.json +++ b/prowler/compliance/azure/iso27001_2022_azure.json @@ -1268,7 +1268,8 @@ ], "Checks": [ "cosmosdb_account_backup_policy_continuous", - "mysql_flexible_server_geo_redundant_backup_enabled" + "mysql_flexible_server_geo_redundant_backup_enabled", + "postgresql_flexible_server_geo_redundant_backup_enabled" ] }, { @@ -1299,7 +1300,8 @@ "vm_trusted_launch_enabled", "cosmosdb_account_automatic_failover_enabled", "mysql_flexible_server_geo_redundant_backup_enabled", - "mysql_flexible_server_high_availability_enabled" + "mysql_flexible_server_high_availability_enabled", + "postgresql_flexible_server_geo_redundant_backup_enabled" ] }, { diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/__init__.py b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled.metadata.json b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled.metadata.json new file mode 100644 index 0000000000..7be125ecf2 --- /dev/null +++ b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "postgresql_flexible_server_geo_redundant_backup_enabled", + "CheckTitle": "PostgreSQL flexible server has geo-redundant backup enabled", + "CheckType": [], + "ServiceName": "postgresql", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.dbforpostgresql/flexibleservers", + "ResourceGroup": "database", + "Description": "**Azure PostgreSQL Flexible Server** is evaluated for **geo-redundant backup**. Geo-redundant backup stores backup copies in a paired Azure region, enabling restore and cross-region disaster recovery.", + "Risk": "Without **geo-redundant backup**, a regional disaster can cause **permanent data loss**. Locally redundant backups only protect against storage hardware failures within the same region and cannot be restored if the primary region becomes unavailable.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-backup-restore", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.dbforpostgresql/flexibleservers" + ], + "Remediation": { + "Code": { + "CLI": "az postgres flexible-server create --name --resource-group --location --geo-redundant-backup Enabled", + "NativeIaC": "```bicep\n// Bicep: PostgreSQL Flexible Server with geo-redundant backup (set at creation)\nresource postgresql 'Microsoft.DBforPostgreSQL/flexibleServers@2023-06-01-preview' = {\n name: ''\n location: ''\n properties: {\n backup: {\n geoRedundantBackup: 'Enabled' // CRITICAL: stores backups in the paired region\n }\n }\n}\n```", + "Other": "1. Geo-redundant backup must be configured when the server is created; it cannot be enabled on an existing server\n2. In the Azure portal, start creating a new Azure Database for PostgreSQL flexible server\n3. On the Basics tab, under Compute + storage, open Configure server\n4. Set Geographically redundant backup to Enabled and save\n5. Finish creating the server and migrate workloads to it", + "Terraform": "```hcl\n# Terraform: PostgreSQL Flexible Server with geo-redundant backup (set at creation)\nresource \"azurerm_postgresql_flexible_server\" \"\" {\n name = \"\"\n resource_group_name = \"\"\n location = \"\"\n geo_redundant_backup_enabled = true # CRITICAL: stores backups in the paired region\n}\n```" + }, + "Recommendation": { + "Text": "Enable **geo-redundant backup** on PostgreSQL Flexible Servers so backups are replicated to the paired Azure region and can be restored during a regional outage. Because this is set at server creation, plan a migration for existing servers, and pair it with an appropriate backup retention period and periodic restore testing.", + "Url": "https://hub.prowler.com/check/postgresql_flexible_server_geo_redundant_backup_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Geo-redundant backup for Azure PostgreSQL Flexible Server can only be configured at server creation time and cannot be changed afterwards." +} diff --git a/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled.py b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled.py new file mode 100644 index 0000000000..94fdaa7a1c --- /dev/null +++ b/prowler/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled.py @@ -0,0 +1,36 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.postgresql.postgresql_client import ( + postgresql_client, +) + + +class postgresql_flexible_server_geo_redundant_backup_enabled(Check): + """ + Ensure Azure PostgreSQL Flexible Servers have geo-redundant backup enabled. + + This check evaluates whether each Azure PostgreSQL Flexible Server stores backups in a paired Azure region, enabling cross-region disaster recovery. + + - PASS: The server has geo-redundant backup enabled (geo_redundant_backup is "Enabled"). + - FAIL: The server does not have geo-redundant backup enabled. + """ + + def execute(self) -> Check_Report_Azure: + findings = [] + for ( + subscription, + flexible_servers, + ) in postgresql_client.flexible_servers.items(): + subscription_name = postgresql_client.subscriptions.get( + subscription, subscription + ) + for server in flexible_servers: + report = Check_Report_Azure(metadata=self.metadata(), resource=server) + report.subscription = subscription + if server.geo_redundant_backup == "Enabled": + report.status = "PASS" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) has geo-redundant backup enabled." + else: + report.status = "FAIL" + report.status_extended = f"Flexible Postgresql server {server.name} from subscription {subscription_name} ({subscription}) does not have geo-redundant backup enabled." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/postgresql/postgresql_service.py b/prowler/providers/azure/services/postgresql/postgresql_service.py index 13081ad270..dd8121d9dd 100644 --- a/prowler/providers/azure/services/postgresql/postgresql_service.py +++ b/prowler/providers/azure/services/postgresql/postgresql_service.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Optional from azure.mgmt.postgresqlflexibleservers import PostgreSQLManagementClient @@ -53,6 +54,7 @@ class PostgreSQL(AzureService): subscription, resource_group, postgresql_server.name ) location = server_details.location + backup = getattr(server_details, "backup", None) flexible_servers[subscription].append( Server( id=postgresql_server.id, @@ -68,6 +70,9 @@ class PostgreSQL(AzureService): connection_throttling=connection_throttling, log_retention_days=log_retention_days, firewall=firewall, + geo_redundant_backup=getattr( + backup, "geo_redundant_backup", None + ), ) ) except Exception as error: @@ -149,15 +154,37 @@ class PostgreSQL(AzureService): ) return admin_list except Exception as e: - logger.error(f"Error getting Entra ID admins for {server_name}: {e}") + if "authentication is not enabled" in str(e): + # Expected when the server uses PostgreSQL authentication only + # (Entra/Azure AD auth disabled); not an error. + logger.warning( + f"Entra ID authentication is not enabled for {server_name}; skipping Entra ID admins." + ) + else: + logger.error(f"Error getting Entra ID admins for {server_name}: {e}") return [] def _get_connection_throttling(self, subscription, resouce_group_name, server_name): client = self.clients[subscription] - connection_throttling = client.configurations.get( - resouce_group_name, server_name, "connection_throttle.enable" - ) - return connection_throttling.value.upper() + try: + connection_throttling = client.configurations.get( + resouce_group_name, server_name, "connection_throttle.enable" + ) + return connection_throttling.value.upper() + except Exception as error: + message = str(error).lower() + if "connection_throttle.enable" in message and ( + "not exist" in message or "not found" in message + ): + # The "connection_throttle.enable" parameter does not exist on + # newer PostgreSQL versions (e.g. v18); this is expected. + return None + # Any other failure is a genuine problem: surface it, but still + # degrade gracefully instead of aborting the subscription inventory. + logger.error( + f"Error getting connection throttling for {server_name}: {error}" + ) + return None def _get_log_retention_days(self, subscription, resouce_group_name, server_name): client = self.clients[subscription] @@ -214,6 +241,7 @@ class Server: log_checkpoints: str log_connections: str log_disconnections: str - connection_throttling: str - log_retention_days: str + connection_throttling: Optional[str] + log_retention_days: Optional[str] firewall: list[Firewall] + geo_redundant_backup: Optional[str] = None diff --git a/tests/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled_test.py b/tests/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled_test.py new file mode 100644 index 0000000000..db0291c989 --- /dev/null +++ b/tests/providers/azure/services/postgresql/postgresql_flexible_server_geo_redundant_backup_enabled/postgresql_flexible_server_geo_redundant_backup_enabled_test.py @@ -0,0 +1,132 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.postgresql.postgresql_service import Server +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +def _make_server(server_id, server_name, geo_redundant_backup): + return Server( + id=server_id, + name=server_name, + resource_group="resource_group", + location="eastus", + require_secure_transport="ON", + active_directory_auth="Enabled", + entra_id_admins=[], + log_checkpoints="ON", + log_connections="ON", + log_disconnections="ON", + connection_throttling="ON", + log_retention_days="3", + firewall=[], + geo_redundant_backup=geo_redundant_backup, + ) + + +class Test_postgresql_flexible_server_geo_redundant_backup_enabled: + def test_no_postgresql_flexible_servers(self): + postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + postgresql_client.flexible_servers = {} + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.postgresql.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_client", + new=postgresql_client, + ), + ): + from prowler.providers.azure.services.postgresql.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_flexible_server_geo_redundant_backup_enabled import ( + postgresql_flexible_server_geo_redundant_backup_enabled, + ) + + check = postgresql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 0 + + def test_postgresql_geo_redundant_backup_disabled(self): + server_id = str(uuid4()) + server_name = "test-server" + postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + postgresql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: [_make_server(server_id, server_name, "Disabled")] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.postgresql.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_client", + new=postgresql_client, + ), + ): + from prowler.providers.azure.services.postgresql.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_flexible_server_geo_redundant_backup_enabled import ( + postgresql_flexible_server_geo_redundant_backup_enabled, + ) + + check = postgresql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Flexible Postgresql server {server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} does not have geo-redundant backup enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" + + def test_postgresql_geo_redundant_backup_enabled(self): + server_id = str(uuid4()) + server_name = "test-server" + postgresql_client = mock.MagicMock + postgresql_client.subscriptions = { + AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME + } + postgresql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: [_make_server(server_id, server_name, "Enabled")] + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.postgresql.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_client", + new=postgresql_client, + ), + ): + from prowler.providers.azure.services.postgresql.postgresql_flexible_server_geo_redundant_backup_enabled.postgresql_flexible_server_geo_redundant_backup_enabled import ( + postgresql_flexible_server_geo_redundant_backup_enabled, + ) + + check = postgresql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Flexible Postgresql server {server_name} from subscription {AZURE_SUBSCRIPTION_DISPLAY} has geo-redundant backup enabled." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == server_name + assert result[0].resource_id == server_id + assert result[0].location == "eastus" diff --git a/tests/providers/azure/services/postgresql/postgresql_service_test.py b/tests/providers/azure/services/postgresql/postgresql_service_test.py index f36b5675ec..c0b8bca6c4 100644 --- a/tests/providers/azure/services/postgresql/postgresql_service_test.py +++ b/tests/providers/azure/services/postgresql/postgresql_service_test.py @@ -1,4 +1,4 @@ -from unittest.mock import patch +from unittest.mock import MagicMock, patch from prowler.providers.azure.services.postgresql.postgresql_service import ( EntraIdAdmin, @@ -115,6 +115,44 @@ class Test_SqlServer_Service: == "ON" ) + def test_get_connection_throttling_missing_parameter_returns_none(self): + # PostgreSQL v18 removed the "connection_throttle.enable" parameter; the + # service must degrade gracefully (quiet None) instead of raising and + # aborting the whole subscription's server inventory. + postgresql = PostgreSQL(set_mocked_azure_provider()) + mock_client = MagicMock() + mock_client.configurations.get.side_effect = Exception( + "The configuration 'connection_throttle.enable' does not exist for " + "server version 18." + ) + postgresql.clients[AZURE_SUBSCRIPTION_ID] = mock_client + with patch( + "prowler.providers.azure.services.postgresql.postgresql_service.logger" + ) as mock_logger: + result = postgresql._get_connection_throttling( + AZURE_SUBSCRIPTION_ID, "resource_group", "server_name" + ) + assert result is None + mock_logger.error.assert_not_called() + + def test_get_connection_throttling_unexpected_error_logs_error(self): + # Any other failure (permissions, throttling, transient API errors) must + # still be logged as an error, while keeping the scan resilient (None). + postgresql = PostgreSQL(set_mocked_azure_provider()) + mock_client = MagicMock() + mock_client.configurations.get.side_effect = Exception( + "Some unexpected failure" + ) + postgresql.clients[AZURE_SUBSCRIPTION_ID] = mock_client + with patch( + "prowler.providers.azure.services.postgresql.postgresql_service.logger" + ) as mock_logger: + result = postgresql._get_connection_throttling( + AZURE_SUBSCRIPTION_ID, "resource_group", "server_name" + ) + assert result is None + mock_logger.error.assert_called_once() + def test_get_log_retention_days(self): postgesql = PostgreSQL(set_mocked_azure_provider()) assert ( @@ -138,6 +176,45 @@ class Test_SqlServer_Service: assert admins[0].principal_name == "Test Admin User" assert admins[0].object_id == "11111111-1111-1111-1111-111111111111" + def test_get_entra_id_admins_aad_not_enabled_logs_warning(self): + # A server using PostgreSQL authentication only (Entra/Azure AD auth + # disabled) is an expected state; it should be logged as a warning, not + # an error, and return an empty admin list. + postgresql = PostgreSQL(set_mocked_azure_provider()) + mock_client = MagicMock() + mock_client.administrators.list_by_server.side_effect = Exception( + "Azure AD authentication is not enabled for the given server" + ) + postgresql.clients[AZURE_SUBSCRIPTION_ID] = mock_client + with patch( + "prowler.providers.azure.services.postgresql.postgresql_service.logger" + ) as mock_logger: + result = postgresql._get_entra_id_admins( + AZURE_SUBSCRIPTION_ID, "resource_group", "server_name" + ) + assert result == [] + mock_logger.warning.assert_called_once() + mock_logger.error.assert_not_called() + + def test_get_entra_id_admins_unexpected_error_logs_error(self): + # Any other failure (permissions, throttling, transient API errors) is a + # genuine problem and must still be logged as an error. + postgresql = PostgreSQL(set_mocked_azure_provider()) + mock_client = MagicMock() + mock_client.administrators.list_by_server.side_effect = Exception( + "Some unexpected failure" + ) + postgresql.clients[AZURE_SUBSCRIPTION_ID] = mock_client + with patch( + "prowler.providers.azure.services.postgresql.postgresql_service.logger" + ) as mock_logger: + result = postgresql._get_entra_id_admins( + AZURE_SUBSCRIPTION_ID, "resource_group", "server_name" + ) + assert result == [] + mock_logger.error.assert_called_once() + mock_logger.warning.assert_not_called() + def test_get_firewall(self): postgesql = PostgreSQL(set_mocked_azure_provider()) assert (