feat(azure): add postgresql_flexible_server_geo_redundant_backup_enabled check (#11045)

Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com>
This commit is contained in:
s1ns3nz0
2026-06-19 17:21:38 +09:00
committed by GitHub
parent 151dcd2895
commit d27ec7d62e
9 changed files with 329 additions and 11 deletions
+5
View File
@@ -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)
+2 -1
View File
@@ -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"
]
},
{
@@ -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"
]
},
{
@@ -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 <server_name> --resource-group <resource_group_name> --location <region> --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: '<example_resource_name>'\n location: '<region>'\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\" \"<example_resource_name>\" {\n name = \"<example_resource_name>\"\n resource_group_name = \"<example_resource_name>\"\n location = \"<region>\"\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."
}
@@ -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
@@ -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
@@ -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"
@@ -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 (