From 3ceb86c4d90e83d5d03b589fb69d2bf3464407e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20De=20la=20Torre=20Vico?= Date: Tue, 8 Jul 2025 10:40:43 +0200 Subject: [PATCH] feat(azure/vm): add new check `vm_scaleset_associated_load_balancer` (#8181) --- prowler/CHANGELOG.md | 2 + .../__init__.py | 0 ...ssociated_with_load_balancer.metadata.json | 30 +++ ..._scaleset_associated_with_load_balancer.py | 36 +++ .../providers/azure/services/vm/vm_service.py | 71 ++++++ ...eset_associated_with_load_balancer_test.py | 216 ++++++++++++++++++ 6 files changed, 355 insertions(+) create mode 100644 prowler/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/__init__.py create mode 100644 prowler/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer.metadata.json create mode 100644 prowler/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer.py create mode 100644 tests/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 432b31acfc..c466cd33f0 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -9,6 +9,8 @@ All notable changes to the **Prowler SDK** are documented in this file. - `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 ### Fixed diff --git a/prowler/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/__init__.py b/prowler/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer.metadata.json b/prowler/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer.metadata.json new file mode 100644 index 0000000000..d73e44fd10 --- /dev/null +++ b/prowler/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "azure", + "CheckID": "vm_scaleset_associated_with_load_balancer", + "CheckTitle": "VM Scale Set Is Associated With Load Balancer", + "CheckType": [], + "ServiceName": "vm", + "SubServiceName": "scaleset", + "ResourceIdTemplate": "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Compute/virtualMachineScaleSets/{vmScaleSetName}", + "Severity": "medium", + "ResourceType": "Microsoft.Compute/virtualMachineScaleSets", + "Description": "Ensure that your Azure virtual machine scale sets are using load balancers for traffic distribution.", + "Risk": "Without load balancer integration, Azure virtual machine scale sets may experience reduced availability and potential service disruptions during traffic spikes or instance failures, leading to degraded user experience and potential business impact.", + "RelatedUrl": "https://learn.microsoft.com/en-us/azure/virtual-network/network-overview", + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/VirtualMachines/associated-load-balancers.html", + "Terraform": "" + }, + "Recommendation": { + "Text": "Attach a load balancer to your Azure virtual machine scale set to ensure high availability and optimal traffic distribution.", + "Url": "https://docs.microsoft.com/en-us/azure/load-balancer/load-balancer-overview" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer.py b/prowler/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer.py new file mode 100644 index 0000000000..c6150f1d7f --- /dev/null +++ b/prowler/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer.py @@ -0,0 +1,36 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.vm.vm_client import vm_client + + +class vm_scaleset_associated_with_load_balancer(Check): + """ + Ensure that Azure virtual machine scale sets are associated with a load balancer backend pool. + + This check evaluates whether each VM scale set is associated with at least one load balancer backend pool. + - PASS: The scale set is associated with a load balancer backend pool. + - FAIL: The scale set is not associated with any load balancer backend pool. + """ + + def execute(self): + findings = [] + for subscription, scale_sets in vm_client.vm_scale_sets.items(): + for scale_set in scale_sets.values(): + report = Check_Report_Azure( + metadata=self.metadata(), resource=scale_set + ) + report.subscription = subscription + report.resource_id = scale_set.resource_id + report.resource_name = scale_set.resource_name + report.location = scale_set.location + if scale_set.load_balancer_backend_pools: + report.status = "PASS" + backend_pool_names = [ + pool.split("/")[-1] + for pool in scale_set.load_balancer_backend_pools + ] + report.status_extended = f"Scale set '{scale_set.resource_name}' in subscription '{subscription}' is associated with load balancer backend pool(s): {', '.join(backend_pool_names)}." + else: + report.status = "FAIL" + report.status_extended = f"Scale set '{scale_set.resource_name}' in subscription '{subscription}' is not associated with any load balancer backend pool." + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/vm/vm_service.py b/prowler/providers/azure/services/vm/vm_service.py index 7a2807b3da..ea7d252acc 100644 --- a/prowler/providers/azure/services/vm/vm_service.py +++ b/prowler/providers/azure/services/vm/vm_service.py @@ -15,6 +15,7 @@ class VirtualMachines(AzureService): super().__init__(ComputeManagementClient, provider) self.virtual_machines = self._get_virtual_machines() self.disks = self._get_disks() + self.vm_scale_sets = self._get_vm_scale_sets() def _get_virtual_machines(self): logger.info("VirtualMachines - Getting virtual machines...") @@ -155,6 +156,69 @@ class VirtualMachines(AzureService): return disks + def _get_vm_scale_sets(self) -> dict[str, dict]: + """ + Get all needed information about VM scale sets. + + Returns: + A nested dictionary with the following structure: + { + "subscription_name": { + "vm_scale_set_id": VirtualMachineScaleSet() + } + } + """ + logger.info( + "VirtualMachines - Getting VM scale sets and their load balancer associations..." + ) + vm_scale_sets = {} + for subscription_name, client in self.clients.items(): + try: + scale_sets = client.virtual_machine_scale_sets.list_all() + vm_scale_sets[subscription_name] = {} + for scale_set in scale_sets: + backend_pools = [] + nic_configs = [] + virtual_machine_profile = getattr( + scale_set, "virtual_machine_profile", None + ) + if virtual_machine_profile: + network_profile = getattr( + virtual_machine_profile, "network_profile", None + ) + if network_profile: + nic_configs = ( + getattr( + network_profile, + "network_interface_configurations", + [], + ) + or [] + ) + for nic in nic_configs: + ip_confs = getattr(nic, "ip_configurations", []) + for ipconf in ip_confs: + pools = getattr( + ipconf, "load_balancer_backend_address_pools", [] + ) + if pools: + for pool in pools: + if getattr(pool, "id", None): + backend_pools.append(pool.id) + vm_scale_sets[subscription_name][scale_set.id] = ( + VirtualMachineScaleSet( + resource_id=scale_set.id, + resource_name=scale_set.name, + location=scale_set.location, + load_balancer_backend_pools=backend_pools, + ) + ) + except Exception as error: + logger.error( + f"Subscription name: {subscription_name} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return vm_scale_sets + @dataclass class UefiSettings: @@ -219,3 +283,10 @@ class Disk(BaseModel): vms_attached: list[str] encryption_type: str location: str + + +class VirtualMachineScaleSet(BaseModel): + resource_id: str + resource_name: str + location: str + load_balancer_backend_pools: list[str] diff --git a/tests/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer_test.py b/tests/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer_test.py new file mode 100644 index 0000000000..d9d0717f4e --- /dev/null +++ b/tests/providers/azure/services/vm/vm_scaleset_associated_with_load_balancer/vm_scaleset_associated_with_load_balancer_test.py @@ -0,0 +1,216 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.vm.vm_service import VirtualMachineScaleSet +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_vm_scaleset_associated_with_load_balancer: + def test_no_subscriptions(self): + vm_scale_sets = {} + 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_client.vm_client.vm_scale_sets", + new=vm_scale_sets, + ), + ): + from prowler.providers.azure.services.vm.vm_scaleset_associated_with_load_balancer.vm_scaleset_associated_with_load_balancer import ( + vm_scaleset_associated_with_load_balancer, + ) + + check = vm_scaleset_associated_with_load_balancer() + result = check.execute() + assert len(result) == 0 + + def test_empty_scale_sets(self): + vm_scale_sets = {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_client.vm_client.vm_scale_sets", + new=vm_scale_sets, + ), + ): + from prowler.providers.azure.services.vm.vm_scaleset_associated_with_load_balancer.vm_scaleset_associated_with_load_balancer import ( + vm_scaleset_associated_with_load_balancer, + ) + + check = vm_scaleset_associated_with_load_balancer() + result = check.execute() + assert len(result) == 0 + + def test_compliant_scale_set(self): + vmss_id = str(uuid4()) + backend_pool_id = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg/providers/Microsoft.Network/loadBalancers/lb/backendAddressPools/bepool" + vm_scale_sets = { + AZURE_SUBSCRIPTION_ID: { + vmss_id: VirtualMachineScaleSet( + resource_id=vmss_id, + resource_name="compliant-vmss", + location="eastus", + load_balancer_backend_pools=[backend_pool_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_client.vm_client.vm_scale_sets", + new=vm_scale_sets, + ), + ): + from prowler.providers.azure.services.vm.vm_scaleset_associated_with_load_balancer.vm_scaleset_associated_with_load_balancer import ( + vm_scaleset_associated_with_load_balancer, + ) + + check = vm_scaleset_associated_with_load_balancer() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == vmss_id + assert result[0].resource_name == "compliant-vmss" + assert result[0].location == "eastus" + expected_status_extended = ( + f"Scale set 'compliant-vmss' in subscription '{AZURE_SUBSCRIPTION_ID}' " + f"is associated with load balancer backend pool(s): bepool." + ) + assert result[0].status_extended == expected_status_extended + + def test_noncompliant_scale_set(self): + vmss_id = str(uuid4()) + vm_scale_sets = { + AZURE_SUBSCRIPTION_ID: { + vmss_id: VirtualMachineScaleSet( + resource_id=vmss_id, + resource_name="noncompliant-vmss", + location="westeurope", + load_balancer_backend_pools=[], + ) + } + } + 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_client.vm_client.vm_scale_sets", + new=vm_scale_sets, + ), + ): + from prowler.providers.azure.services.vm.vm_scaleset_associated_with_load_balancer.vm_scaleset_associated_with_load_balancer import ( + vm_scaleset_associated_with_load_balancer, + ) + + check = vm_scaleset_associated_with_load_balancer() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == vmss_id + assert result[0].resource_name == "noncompliant-vmss" + assert result[0].location == "westeurope" + expected_status_extended = ( + f"Scale set 'noncompliant-vmss' in subscription '{AZURE_SUBSCRIPTION_ID}' " + f"is not associated with any load balancer backend pool." + ) + assert result[0].status_extended == expected_status_extended + + def test_multiple_scale_sets(self): + compliant_id = str(uuid4()) + noncompliant_id = str(uuid4()) + backend_pool_id = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/rg/providers/Microsoft.Network/loadBalancers/lb/backendAddressPools/bepool" + vm_scale_sets = { + AZURE_SUBSCRIPTION_ID: { + compliant_id: VirtualMachineScaleSet( + resource_id=compliant_id, + resource_name="compliant-vmss", + location="eastus", + load_balancer_backend_pools=[backend_pool_id], + ), + noncompliant_id: VirtualMachineScaleSet( + resource_id=noncompliant_id, + resource_name="noncompliant-vmss", + location="westeurope", + load_balancer_backend_pools=[], + ), + } + } + 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_client.vm_client.vm_scale_sets", + new=vm_scale_sets, + ), + ): + from prowler.providers.azure.services.vm.vm_scaleset_associated_with_load_balancer.vm_scaleset_associated_with_load_balancer import ( + vm_scaleset_associated_with_load_balancer, + ) + + check = vm_scaleset_associated_with_load_balancer() + result = check.execute() + assert len(result) == 2 + for r in result: + if r.resource_name == "compliant-vmss": + expected_status_extended = ( + f"Scale set 'compliant-vmss' in subscription '{AZURE_SUBSCRIPTION_ID}' " + f"is associated with load balancer backend pool(s): bepool." + ) + assert r.status == "PASS" + assert r.status_extended == expected_status_extended + elif r.resource_name == "noncompliant-vmss": + expected_status_extended = ( + f"Scale set 'noncompliant-vmss' in subscription '{AZURE_SUBSCRIPTION_ID}' " + f"is not associated with any load balancer backend pool." + ) + assert r.status == "FAIL" + assert r.status_extended == expected_status_extended + + def test_missing_attributes(self): + # Simulate a scale set with missing optional attributes + vmss_id = str(uuid4()) + vm_scale_sets = { + AZURE_SUBSCRIPTION_ID: { + vmss_id: VirtualMachineScaleSet( + resource_id=vmss_id, + resource_name="", + location="", + load_balancer_backend_pools=[], + ) + } + } + 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_client.vm_client.vm_scale_sets", + new=vm_scale_sets, + ), + ): + from prowler.providers.azure.services.vm.vm_scaleset_associated_with_load_balancer.vm_scaleset_associated_with_load_balancer import ( + vm_scaleset_associated_with_load_balancer, + ) + + check = vm_scaleset_associated_with_load_balancer() + result = check.execute() + assert len(result) == 1 + expected_status_extended = f"Scale set '' in subscription '{AZURE_SUBSCRIPTION_ID}' is not associated with any load balancer backend pool." + assert result[0].status == "FAIL" + assert result[0].status_extended == expected_status_extended