From 7dd08bc6bf585a1876fb15f97ca806e5aaef2c09 Mon Sep 17 00:00:00 2001 From: s1ns3nz0 Date: Thu, 18 Jun 2026 19:41:04 +0900 Subject: [PATCH] feat(azure): add mysql_flexible_server_geo_redundant_backup_enabled check (#11041) Co-authored-by: Daniel Barranquero --- prowler/CHANGELOG.md | 1 + 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 | 31 +++++ .../azure/services/mysql/mysql_service.py | 6 + ...erver_geo_redundant_backup_enabled_test.py | 125 ++++++++++++++++++ 8 files changed, 206 insertions(+), 3 deletions(-) create mode 100644 prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/__init__.py create mode 100644 prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.metadata.json create mode 100644 prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.py create mode 100644 tests/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index bd0d0bb956..196d2a467e 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -20,6 +20,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `databricks_workspace_public_network_access_disabled` check for Azure provider, verifying Databricks workspaces have public network access disabled so connectivity is restricted to Azure Private Link private endpoints [(#11035)](https://github.com/prowler-cloud/prowler/pull/11035) - `databricks_workspace_no_public_ip_enabled` check for Azure provider, verifying Databricks workspaces use secure cluster connectivity (no public IP) so compute nodes are not assigned public IP addresses [(#11036)](https://github.com/prowler-cloud/prowler/pull/11036) - `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) - `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) diff --git a/prowler/compliance/azure/hipaa_azure.json b/prowler/compliance/azure/hipaa_azure.json index 3b1184e0e5..70f1e7e13f 100644 --- a/prowler/compliance/azure/hipaa_azure.json +++ b/prowler/compliance/azure/hipaa_azure.json @@ -439,7 +439,8 @@ "keyvault_recoverable", "sqlserver_auditing_retention_90_days", "postgresql_flexible_server_log_retention_days_greater_3", - "cosmosdb_account_backup_policy_continuous" + "cosmosdb_account_backup_policy_continuous", + "mysql_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 719f358de5..6f686d24f0 100644 --- a/prowler/compliance/azure/iso27001_2022_azure.json +++ b/prowler/compliance/azure/iso27001_2022_azure.json @@ -1267,7 +1267,8 @@ } ], "Checks": [ - "cosmosdb_account_backup_policy_continuous" + "cosmosdb_account_backup_policy_continuous", + "mysql_flexible_server_geo_redundant_backup_enabled" ] }, { @@ -1296,7 +1297,8 @@ "storage_secure_transfer_required_is_enabled", "vm_ensure_using_managed_disks", "vm_trusted_launch_enabled", - "cosmosdb_account_automatic_failover_enabled" + "cosmosdb_account_automatic_failover_enabled", + "mysql_flexible_server_geo_redundant_backup_enabled" ] }, { diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/__init__.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.metadata.json b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.metadata.json new file mode 100644 index 0000000000..1dac541283 --- /dev/null +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.metadata.json @@ -0,0 +1,37 @@ +{ + "Provider": "azure", + "CheckID": "mysql_flexible_server_geo_redundant_backup_enabled", + "CheckTitle": "MySQL flexible server has geo-redundant backup enabled", + "CheckType": [], + "ServiceName": "mysql", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.dbformysql/flexibleservers", + "ResourceGroup": "database", + "Description": "**Azure MySQL Flexible Server** is evaluated for **geo-redundant backup**. Geo-redundant backup stores backup copies in a paired Azure region, enabling restore and recovery from a full regional outage.", + "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/mysql/flexible-server/concepts-backup-restore", + "https://learn.microsoft.com/en-us/azure/templates/microsoft.dbformysql/flexibleservers" + ], + "Remediation": { + "Code": { + "CLI": "az mysql flexible-server create --name --resource-group --location --geo-redundant-backup Enabled", + "NativeIaC": "```bicep\n// Bicep: MySQL Flexible Server with geo-redundant backup (set at creation)\nresource mysql 'Microsoft.DBforMySQL/flexibleServers@2023-12-30' = {\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 MySQL 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: MySQL Flexible Server with geo-redundant backup (set at creation)\nresource \"azurerm_mysql_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 MySQL 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/mysql_flexible_server_geo_redundant_backup_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Geo-redundant backup for Azure MySQL Flexible Server can only be configured at server creation time and cannot be changed afterwards." +} diff --git a/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.py b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.py new file mode 100644 index 0000000000..5b1685354c --- /dev/null +++ b/prowler/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled.py @@ -0,0 +1,31 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.mysql.mysql_client import mysql_client + + +class mysql_flexible_server_geo_redundant_backup_enabled(Check): + """ + Ensure Azure MySQL Flexible Servers have geo-redundant backup enabled. + + This check evaluates whether each Azure MySQL Flexible Server stores backups in a paired Azure region, enabling recovery from a full regional outage. + + - 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_id, servers in mysql_client.flexible_servers.items(): + subscription_name = mysql_client.subscriptions.get( + subscription_id, subscription_id + ) + for server in servers.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=server) + report.subscription = subscription_id + if server.geo_redundant_backup == "Enabled": + report.status = "PASS" + report.status_extended = f"Geo-redundant backup is enabled for server {server.name} in subscription {subscription_name} ({subscription_id})." + else: + report.status = "FAIL" + report.status_extended = f"Geo-redundant backup is disabled for server {server.name} in subscription {subscription_name} ({subscription_id})." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/mysql/mysql_service.py b/prowler/providers/azure/services/mysql/mysql_service.py index 565e14da01..c626661f98 100644 --- a/prowler/providers/azure/services/mysql/mysql_service.py +++ b/prowler/providers/azure/services/mysql/mysql_service.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import Optional from azure.mgmt.rdbms.mysql_flexibleservers import MySQLManagementClient @@ -21,6 +22,7 @@ class MySQL(AzureService): servers_list = client.servers.list() servers.update({subscription_id: {}}) for server in servers_list: + backup = getattr(server, "backup", None) servers[subscription_id].update( { server.id: FlexibleServer( @@ -31,6 +33,9 @@ class MySQL(AzureService): configurations=self._get_configurations( client, server.id.split("/")[4], server.name ), + geo_redundant_backup=getattr( + backup, "geo_redundant_backup", None + ), ) } ) @@ -78,3 +83,4 @@ class FlexibleServer: location: str version: str configurations: dict[Configuration] + geo_redundant_backup: Optional[str] = None diff --git a/tests/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled_test.py b/tests/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled_test.py new file mode 100644 index 0000000000..c5e1f27de2 --- /dev/null +++ b/tests/providers/azure/services/mysql/mysql_flexible_server_geo_redundant_backup_enabled/mysql_flexible_server_geo_redundant_backup_enabled_test.py @@ -0,0 +1,125 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.mysql.mysql_service import FlexibleServer +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + + +class Test_mysql_flexible_server_geo_redundant_backup_enabled: + def test_mysql_no_subscriptions(self): + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_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.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled import ( + mysql_flexible_server_geo_redundant_backup_enabled, + ) + + check = mysql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 0 + + def test_mysql_geo_redundant_backup_disabled(self): + server_id = str(uuid4()) + server_name = "test-server" + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + server_id: FlexibleServer( + resource_id=server_id, + name=server_name, + location="eastus", + version="8.0", + configurations={}, + geo_redundant_backup="Disabled", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled import ( + mysql_flexible_server_geo_redundant_backup_enabled, + ) + + check = mysql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Geo-redundant backup is disabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." + ) + 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_mysql_geo_redundant_backup_enabled(self): + server_id = str(uuid4()) + server_name = "test-server" + mysql_client = mock.MagicMock + mysql_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + mysql_client.flexible_servers = { + AZURE_SUBSCRIPTION_ID: { + server_id: FlexibleServer( + resource_id=server_id, + name=server_name, + location="eastus", + version="8.0", + configurations={}, + geo_redundant_backup="Enabled", + ) + } + } + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled.mysql_client", + new=mysql_client, + ), + ): + from prowler.providers.azure.services.mysql.mysql_flexible_server_geo_redundant_backup_enabled.mysql_flexible_server_geo_redundant_backup_enabled import ( + mysql_flexible_server_geo_redundant_backup_enabled, + ) + + check = mysql_flexible_server_geo_redundant_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Geo-redundant backup is enabled for server {server_name} in subscription {AZURE_SUBSCRIPTION_DISPLAY}." + ) + 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"