From 4c50f4d811ea86479c71bc9041f16b1cb575bc1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20De=20la=20Torre=20Vico?= Date: Tue, 8 Jul 2025 11:01:22 +0200 Subject: [PATCH] feat(azure/vm): add new check `vm_backup_enabled` (#8182) Co-authored-by: Sergio Garcia --- poetry.lock | 42 ++- prowler/CHANGELOG.md | 2 +- .../services/recovery/recovery_client.py | 4 + .../services/recovery/recovery_service.py | 101 ++++++ .../services/vm/vm_backup_enabled/__init__.py | 0 .../vm_backup_enabled.metadata.json | 30 ++ .../vm/vm_backup_enabled/vm_backup_enabled.py | 50 +++ pyproject.toml | 2 + .../vm_backup_enabled_test.py | 301 ++++++++++++++++++ 9 files changed, 529 insertions(+), 3 deletions(-) create mode 100644 prowler/providers/azure/services/recovery/recovery_client.py create mode 100644 prowler/providers/azure/services/recovery/recovery_service.py create mode 100644 prowler/providers/azure/services/vm/vm_backup_enabled/__init__.py create mode 100644 prowler/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled.metadata.json create mode 100644 prowler/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled.py create mode 100644 tests/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled_test.py diff --git a/poetry.lock b/poetry.lock index ababbb8ef8..5849257a58 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand. [[package]] name = "about-time" @@ -666,6 +666,42 @@ azure-common = ">=1.1,<2.0" azure-mgmt-core = ">=1.3.0,<2.0.0" msrest = ">=0.6.21" +[[package]] +name = "azure-mgmt-recoveryservices" +version = "3.1.0" +description = "Microsoft Azure Recovery Services Client Library for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "azure_mgmt_recoveryservices-3.1.0-py3-none-any.whl", hash = "sha256:21c58afdf4ae66806783e95f8cd17e3bec31be7178c48784db21f0b05de7fa66"}, + {file = "azure_mgmt_recoveryservices-3.1.0.tar.gz", hash = "sha256:7f2db98401708cf145322f50bc491caf7967bec4af3bf7b0984b9f07d3092687"}, +] + +[package.dependencies] +azure-common = ">=1.1" +azure-mgmt-core = ">=1.5.0" +isodate = ">=0.6.1" +typing-extensions = ">=4.6.0" + +[[package]] +name = "azure-mgmt-recoveryservicesbackup" +version = "9.2.0" +description = "Microsoft Azure Recovery Services Backup Management Client Library for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "azure_mgmt_recoveryservicesbackup-9.2.0-py3-none-any.whl", hash = "sha256:c0002858d0166b6a10189a1fd580a49c83dc31b111e98010a5b2ea0f767dfff1"}, + {file = "azure_mgmt_recoveryservicesbackup-9.2.0.tar.gz", hash = "sha256:c402b3e22a6c3879df56bc37e0063142c3352c5102599ff102d19824f1b32b29"}, +] + +[package.dependencies] +azure-common = ">=1.1" +azure-mgmt-core = ">=1.3.2" +isodate = ">=0.6.1" +typing-extensions = ">=4.6.0" + [[package]] name = "azure-mgmt-resource" version = "23.3.0" @@ -2678,6 +2714,8 @@ python-versions = "*" groups = ["dev"] files = [ {file = "jsonpath-ng-1.7.0.tar.gz", hash = "sha256:f6f5f7fd4e5ff79c785f1573b394043b39849fb2bb47bcead935d12b00beab3c"}, + {file = "jsonpath_ng-1.7.0-py2-none-any.whl", hash = "sha256:898c93fc173f0c336784a3fa63d7434297544b7198124a68f9a3ef9597b0ae6e"}, + {file = "jsonpath_ng-1.7.0-py3-none-any.whl", hash = "sha256:f3d7f9e848cba1b6da28c55b1c26ff915dc9e0b1ba7e752a53d6da8d5cbd00b6"}, ] [package.dependencies] @@ -6622,4 +6660,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">3.9.1,<3.13" -content-hash = "c442552635c8e904d1c7a50f4787c8e90ec90787960ee1867f2235a7aa2205f0" +content-hash = "a0c3e917dcedf073426ae47c942c1db1e04e14ea1ab1a81d7fb91f2873daf1cb" diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index c466cd33f0..e83dff951f 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -6,9 +6,9 @@ All notable changes to the **Prowler SDK** are documented in this file. ### Added - `storage_smb_channel_encryption_with_secure_algorithm` check for Azure provider [(#8123)](https://github.com/prowler-cloud/prowler/pull/8123) +- `vm_backup_enabled` check for Azure provider [(#8182)](https://github.com/prowler-cloud/prowler/pull/8182) - `vm_linux_enforce_ssh_authentication` check for Azure provider [(#8149)](https://github.com/prowler-cloud/prowler/pull/8149) - `vm_ensure_using_approved_images` check for Azure provider [(#8168)](https://github.com/prowler-cloud/prowler/pull/8168) - - `vm_scaleset_associated_load_balancer` check for Azure provider [(#8181)](https://github.com/prowler-cloud/prowler/pull/8181) ### Changed diff --git a/prowler/providers/azure/services/recovery/recovery_client.py b/prowler/providers/azure/services/recovery/recovery_client.py new file mode 100644 index 0000000000..5c17199237 --- /dev/null +++ b/prowler/providers/azure/services/recovery/recovery_client.py @@ -0,0 +1,4 @@ +from prowler.providers.azure.services.recovery.recovery_service import Recovery +from prowler.providers.common.provider import Provider + +recovery_client = Recovery(Provider.get_global_provider()) diff --git a/prowler/providers/azure/services/recovery/recovery_service.py b/prowler/providers/azure/services/recovery/recovery_service.py new file mode 100644 index 0000000000..e39a6fc1f7 --- /dev/null +++ b/prowler/providers/azure/services/recovery/recovery_service.py @@ -0,0 +1,101 @@ +from typing import Optional + +from azure.mgmt.recoveryservices import RecoveryServicesClient +from azure.mgmt.recoveryservicesbackup import RecoveryServicesBackupClient +from azure.mgmt.recoveryservicesbackup.activestamp.models import DataSourceType +from pydantic import BaseModel, Field + +from prowler.lib.logger import logger +from prowler.providers.azure.azure_provider import AzureProvider +from prowler.providers.azure.lib.service.service import AzureService + + +class BackupItem(BaseModel): + """Minimal BackupItem: only essential identifying and descriptive fields.""" + + id: str + name: str + workload_type: Optional[DataSourceType] + + +class BackupVault(BaseModel): + """Minimal BackupVault: only essential identifying fields and its backup items.""" + + id: str + name: str + location: str + backup_protected_items: dict[str, BackupItem] = Field(default_factory=dict) + + +class Recovery(AzureService): + def __init__(self, provider: AzureProvider): + super().__init__(RecoveryServicesClient, provider) + self.vaults: dict[str, dict[str, BackupVault]] = self._get_vaults() + RecoveryBackup(provider, self.vaults) + + def _get_vaults(self) -> dict[str, dict[str, BackupVault]]: + """ + Retrieve all Recovery Services vaults for each subscription. + + Returns: + Nested dictionary of vaults by subscription. + """ + logger.info("Recovery - Getting Recovery Services vaults...") + vaults_dict: dict[str, dict[str, BackupVault]] = {} + try: + vaults_dict: dict[str, dict[str, BackupVault]] = {} + for subscription_name, client in self.clients.items(): + vaults = client.vaults.list_by_subscription_id() + vaults_dict[subscription_name] = {} + for vault in vaults: + vault_obj = BackupVault( + id=vault.id, + name=vault.name, + location=vault.location, + ) + vaults_dict[subscription_name][vault_obj.id] = vault_obj + except Exception as error: + logger.error( + f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return vaults_dict + + +class RecoveryBackup(AzureService): + def __init__( + self, provider: AzureProvider, vaults: dict[str, dict[str, BackupVault]] + ): + super().__init__(RecoveryServicesBackupClient, provider) + for subscription_name, vaults in vaults.items(): + for vault in vaults.values(): + vault.backup_protected_items = self._get_backup_protected_items( + subscription_name=subscription_name, vault=vault + ) + + def _get_backup_protected_items( + self, subscription_name: str, vault: BackupVault + ) -> dict[str, BackupItem]: + """ + Retrieve all backup protected items for a given vault. + """ + logger.info("Recovery - Getting backup protected items...") + backup_protected_items_dict: dict[str, BackupItem] = {} + try: + backup_protected_items = self.clients[ + subscription_name + ].backup_protected_items.list( + vault_name=vault.name, + resource_group_name=vault.id.split("/")[4], + ) + for item in backup_protected_items: + item_properties = getattr(item, "properties", None) + backup_protected_items_dict[item.id] = BackupItem( + id=item.id, + name=item.name, + workload_type=( + item_properties.workload_type if item_properties else None + ), + ) + except Exception as e: + logger.error(f"Recovery - Error getting backup protected items: {e}") + return backup_protected_items_dict diff --git a/prowler/providers/azure/services/vm/vm_backup_enabled/__init__.py b/prowler/providers/azure/services/vm/vm_backup_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled.metadata.json b/prowler/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled.metadata.json new file mode 100644 index 0000000000..331609596f --- /dev/null +++ b/prowler/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "azure", + "CheckID": "vm_backup_enabled", + "CheckTitle": "Ensure Backups are enabled for Azure Virtual Machines", + "CheckType": [], + "ServiceName": "vm", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "Microsoft.Compute/virtualMachines", + "Description": "Ensure that Microsoft Azure Backup service is in use for your Azure virtual machines (VMs) to protect against accidental deletion or corruption.", + "Risk": "Without Azure Backup enabled, VMs are at risk of data loss due to accidental deletion, corruption, or other failures, and recovery options are limited.", + "RelatedUrl": "https://docs.microsoft.com/en-us/azure/backup/backup-overview", + "Remediation": { + "Code": { + "CLI": "az backup protection enable-for-vm --resource-group --vm --vault-name --policy-name DefaultPolicy", + "NativeIaC": "", + "Other": "https://learn.microsoft.com/en-us/azure/backup/quick-backup-vm-portal", + "Terraform": "" + }, + "Recommendation": { + "Text": "Enable Azure Backup for each VM by associating it with a Recovery Services vault and a backup policy.", + "Url": "https://docs.microsoft.com/en-us/azure/backup/quick-backup-vm-portal" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled.py b/prowler/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled.py new file mode 100644 index 0000000000..e9da4662ed --- /dev/null +++ b/prowler/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled.py @@ -0,0 +1,50 @@ +from azure.mgmt.recoveryservicesbackup.activestamp.models import DataSourceType + +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.recovery.recovery_client import recovery_client +from prowler.providers.azure.services.vm.vm_client import vm_client + + +class vm_backup_enabled(Check): + """ + Ensure that Microsoft Azure Backup service is in use for your Azure virtual machines (VMs). + + This check evaluates whether each Azure VM in the subscription is protected by Azure Backup. + + - PASS: The VM is protected by Azure Backup (present in a Recovery Services vault). + - FAIL: The VM is not protected by Azure Backup (not present in any Recovery Services vault). + """ + + def execute(self) -> list[Check_Report_Azure]: + """Execute Azure VM backup enabled check. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + for subscription_name, vms in vm_client.virtual_machines.items(): + vaults = recovery_client.vaults.get(subscription_name, {}) + for vm in vms.values(): + found = False + found_vault_name = None + for vault in vaults.values(): + for backup_item in vault.backup_protected_items.values(): + if ( + backup_item.workload_type == DataSourceType.VM + and backup_item.name.split(";")[-1] == vm.resource_name + ): + found = True + found_vault_name = vault.name + break + if found: + break + report = Check_Report_Azure(metadata=self.metadata(), resource=vm) + report.subscription = subscription_name + if found: + report.status = "PASS" + report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} is protected by Azure Backup (vault: {found_vault_name})." + else: + report.status = "FAIL" + report.status_extended = f"VM {vm.resource_name} in subscription {subscription_name} is not protected by Azure Backup." + findings.append(report) + return findings diff --git a/pyproject.toml b/pyproject.toml index 5d9d90c879..85f2009a23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,8 @@ dependencies = [ "azure-mgmt-monitor==6.0.2", "azure-mgmt-network==28.1.0", "azure-mgmt-rdbms==10.1.0", + "azure-mgmt-recoveryservices==3.1.0", + "azure-mgmt-recoveryservicesbackup==9.2.0", "azure-mgmt-resource==23.3.0", "azure-mgmt-search==9.1.0", "azure-mgmt-security==7.0.0", diff --git a/tests/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled_test.py b/tests/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled_test.py new file mode 100644 index 0000000000..23394addca --- /dev/null +++ b/tests/providers/azure/services/vm/vm_backup_enabled/vm_backup_enabled_test.py @@ -0,0 +1,301 @@ +from unittest import mock +from uuid import uuid4 + +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_vm_backup_enabled: + def test_vm_backup_enabled_no_subscriptions(self): + vm_client = mock.MagicMock + recovery_client = mock.MagicMock + + vm_client.virtual_machines = {} + recovery_client.vaults = {} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled.vm_client", + new=vm_client, + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled.recovery_client", + new=recovery_client, + ), + ): + from prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled import ( + vm_backup_enabled, + ) + + check = vm_backup_enabled() + result = check.execute() + assert len(result) == 0 + + def test_no_vms(self): + mock_vm_client = mock.MagicMock() + mock_vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {}} + mock_recovery_client = mock.MagicMock() + mock_recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {}} + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled.vm_client", + new=mock_vm_client, + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled.recovery_client", + new=mock_recovery_client, + ), + ): + from prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled import ( + vm_backup_enabled, + ) + + check = vm_backup_enabled() + result = check.execute() + assert len(result) == 0 + + def test_vm_protected_by_backup(self): + vm_id = str(uuid4()) + vm_name = "VMTest" + vault_id = str(uuid4()) + vault_name = "vault1" + mock_vm_client = mock.MagicMock() + mock_recovery_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled.vm_client", + new=mock_vm_client, + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled.recovery_client", + new=mock_recovery_client, + ), + ): + from azure.mgmt.recoveryservicesbackup.activestamp.models import ( + DataSourceType, + ) + + from prowler.providers.azure.services.recovery.recovery_service import ( + BackupItem, + BackupVault, + ) + from prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled import ( + vm_backup_enabled, + ) + from prowler.providers.azure.services.vm.vm_service import ( + ManagedDiskParameters, + OSDisk, + StorageProfile, + VirtualMachine, + ) + + vm = VirtualMachine( + resource_id=vm_id, + resource_name=vm_name, + location="eastus", + security_profile=None, + extensions=[], + storage_profile=StorageProfile( + os_disk=OSDisk( + name="os_disk_name", + operating_system_type="Linux", + managed_disk=ManagedDiskParameters(id="managed_disk_id"), + ), + data_disks=[], + ), + ) + backup_item = BackupItem( + id=str(uuid4()), + name=f"someprefix;{vm_name}", + workload_type=DataSourceType.VM, + ) + vault = BackupVault( + id=vault_id, + name=vault_name, + location="eastus", + backup_protected_items={backup_item.id: backup_item}, + ) + mock_vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}} + mock_recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {vault_id: vault}} + check = vm_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == vm_name + assert result[0].resource_id == vm_id + assert ( + result[0].status_extended + == f"VM {vm_name} in subscription {AZURE_SUBSCRIPTION_ID} is protected by Azure Backup (vault: {vault_name})." + ) + + def test_vm_not_protected_by_backup(self): + vm_id = str(uuid4()) + vm_name = "VMTest" + vault_id = str(uuid4()) + vault_name = "vault1" + mock_vm_client = mock.MagicMock() + mock_recovery_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled.vm_client", + new=mock_vm_client, + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled.recovery_client", + new=mock_recovery_client, + ), + ): + from azure.mgmt.recoveryservicesbackup.activestamp.models import ( + DataSourceType, + ) + + from prowler.providers.azure.services.recovery.recovery_service import ( + BackupItem, + BackupVault, + ) + from prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled import ( + vm_backup_enabled, + ) + from prowler.providers.azure.services.vm.vm_service import ( + ManagedDiskParameters, + OSDisk, + StorageProfile, + VirtualMachine, + ) + + vm = VirtualMachine( + resource_id=vm_id, + resource_name=vm_name, + location="eastus", + security_profile=None, + extensions=[], + storage_profile=StorageProfile( + os_disk=OSDisk( + name="os_disk_name", + operating_system_type="Linux", + managed_disk=ManagedDiskParameters(id="managed_disk_id"), + ), + data_disks=[], + ), + ) + backup_item = BackupItem( + id=str(uuid4()), + name="someprefix;OtherVM", + workload_type=DataSourceType.VM, + ) + vault = BackupVault( + id=vault_id, + name=vault_name, + location="eastus", + backup_protected_items={backup_item.id: backup_item}, + ) + mock_vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}} + mock_recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {vault_id: vault}} + check = vm_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == vm_name + assert result[0].resource_id == vm_id + assert ( + result[0].status_extended + == f"VM {vm_name} in subscription {AZURE_SUBSCRIPTION_ID} is not protected by Azure Backup." + ) + + def test_vm_protected_by_backup_non_vm_workload(self): + vm_id = str(uuid4()) + vm_name = "VMTest" + vault_id = str(uuid4()) + vault_name = "vault1" + mock_vm_client = mock.MagicMock() + mock_recovery_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled.vm_client", + new=mock_vm_client, + ), + mock.patch( + "prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled.recovery_client", + new=mock_recovery_client, + ), + ): + from azure.mgmt.recoveryservicesbackup.activestamp.models import ( + DataSourceType, + ) + + from prowler.providers.azure.services.recovery.recovery_service import ( + BackupItem, + BackupVault, + ) + from prowler.providers.azure.services.vm.vm_backup_enabled.vm_backup_enabled import ( + vm_backup_enabled, + ) + from prowler.providers.azure.services.vm.vm_service import ( + ManagedDiskParameters, + OSDisk, + StorageProfile, + VirtualMachine, + ) + + vm = VirtualMachine( + resource_id=vm_id, + resource_name=vm_name, + location="eastus", + security_profile=None, + extensions=[], + storage_profile=StorageProfile( + os_disk=OSDisk( + name="os_disk_name", + operating_system_type="Linux", + managed_disk=ManagedDiskParameters(id="managed_disk_id"), + ), + data_disks=[], + ), + ) + backup_item = BackupItem( + id=str(uuid4()), + name=f"someprefix;{vm_name}", + workload_type=DataSourceType.FILE_FOLDER, + ) + vault = BackupVault( + id=vault_id, + name=vault_name, + location="eastus", + backup_protected_items={backup_item.id: backup_item}, + ) + mock_vm_client.virtual_machines = {AZURE_SUBSCRIPTION_ID: {vm_id: vm}} + mock_recovery_client.vaults = {AZURE_SUBSCRIPTION_ID: {vault_id: vault}} + check = vm_backup_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == vm_name + assert result[0].resource_id == vm_id + assert ( + result[0].status_extended + == f"VM {vm_name} in subscription {AZURE_SUBSCRIPTION_ID} is not protected by Azure Backup." + )