From 1cfe610d472c53c782ac6b19ff204243fbf70b6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20De=20la=20Torre=20Vico?= Date: Fri, 25 Jul 2025 12:42:03 +0200 Subject: [PATCH] feat(azure/vm): add new check `vm_scaleset_not_empty` (#8192) Co-authored-by: Sergio Garcia --- prowler/CHANGELOG.md | 1 + .../vm/vm_scaleset_not_empty/__init__.py | 0 .../vm_scaleset_not_empty.metadata.json | 30 +++ .../vm_scaleset_not_empty.py | 29 +++ .../providers/azure/services/vm/vm_service.py | 46 +++++ ...eset_associated_with_load_balancer_test.py | 5 + .../vm_scaleset_not_empty_test.py | 174 ++++++++++++++++++ 7 files changed, 285 insertions(+) create mode 100644 prowler/providers/azure/services/vm/vm_scaleset_not_empty/__init__.py create mode 100644 prowler/providers/azure/services/vm/vm_scaleset_not_empty/vm_scaleset_not_empty.metadata.json create mode 100644 prowler/providers/azure/services/vm/vm_scaleset_not_empty/vm_scaleset_not_empty.py create mode 100644 tests/providers/azure/services/vm/vm_scaleset_not_empty/vm_scaleset_not_empty_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 977eb8ff97..efb0f5fb0a 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - Support App Key Content in GitHub provider [(#8271)](https://github.com/prowler-cloud/prowler/pull/8271) - CIS 4.0 for the Azure provider [(#7782)](https://github.com/prowler-cloud/prowler/pull/7782) - `vm_desired_sku_size` check for Azure provider [(#8191)](https://github.com/prowler-cloud/prowler/pull/8191) +- `vm_scaleset_not_empty` check for Azure provider [(#8192)](https://github.com/prowler-cloud/prowler/pull/8192) ### Changed - Handle some AWS errors as warnings instead of errors [(#8347)](https://github.com/prowler-cloud/prowler/pull/8347) diff --git a/prowler/providers/azure/services/vm/vm_scaleset_not_empty/__init__.py b/prowler/providers/azure/services/vm/vm_scaleset_not_empty/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/vm/vm_scaleset_not_empty/vm_scaleset_not_empty.metadata.json b/prowler/providers/azure/services/vm/vm_scaleset_not_empty/vm_scaleset_not_empty.metadata.json new file mode 100644 index 0000000000..7c24b62db6 --- /dev/null +++ b/prowler/providers/azure/services/vm/vm_scaleset_not_empty/vm_scaleset_not_empty.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "azure", + "CheckID": "vm_scaleset_not_empty", + "CheckTitle": "Check for Empty Virtual Machine Scale Sets", + "CheckType": [], + "ServiceName": "vm", + "SubServiceName": "scaleset", + "ResourceIdTemplate": "/subscriptions/{subscriptionId}/resourceGroups/{resourceGroupName}/providers/Microsoft.Compute/virtualMachineScaleSets/{vmScaleSetName}", + "Severity": "low", + "ResourceType": "Microsoft.Compute/virtualMachineScaleSets", + "Description": "Identify and remove empty virtual machine scale sets from your Azure cloud account.", + "Risk": "Empty virtual machine scale sets may incur unnecessary costs and complicate cloud resource management, impacting cost optimization and compliance.", + "RelatedUrl": "https://learn.microsoft.com/en-us/azure/virtual-machine-scale-sets/overview", + "Remediation": { + "Code": { + "CLI": "az vmss delete --name --resource-group ", + "NativeIaC": "", + "Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/VirtualMachines/empty-vm-scale-sets.html", + "Terraform": "" + }, + "Recommendation": { + "Text": "Remove empty Azure virtual machine scale sets to optimize costs and simplify management.", + "Url": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/azure/VirtualMachines/empty-vm-scale-sets.html" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/vm/vm_scaleset_not_empty/vm_scaleset_not_empty.py b/prowler/providers/azure/services/vm/vm_scaleset_not_empty/vm_scaleset_not_empty.py new file mode 100644 index 0000000000..4061fe4790 --- /dev/null +++ b/prowler/providers/azure/services/vm/vm_scaleset_not_empty/vm_scaleset_not_empty.py @@ -0,0 +1,29 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.vm.vm_client import vm_client + + +class vm_scaleset_not_empty(Check): + """ + Ensure that Azure virtual machine scale sets are not empty (i.e., have no VM instances and no load balancer attached). + + This check evaluates whether each VM scale set has zero VM instances and is not associated with any load balancer backend pool. + - PASS: The scale set has at least one VM instance or is associated with a load balancer backend pool. + - FAIL: The scale set has no VM instances and is not associated with any load balancer backend pool (i.e., it is empty). + """ + + 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 + if not scale_set.instance_ids: + report.status = "FAIL" + report.status_extended = f"Scale set '{scale_set.resource_name}' in subscription '{subscription}' is empty: no VM instances present." + else: + report.status = "PASS" + report.status_extended = f"Scale set '{scale_set.resource_name}' in subscription '{subscription}' has {len(scale_set.instance_ids)} VM instances." + 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 a2daaa9266..a3b8e3237f 100644 --- a/prowler/providers/azure/services/vm/vm_service.py +++ b/prowler/providers/azure/services/vm/vm_service.py @@ -234,12 +234,17 @@ class VirtualMachines(AzureService): for pool in pools: if getattr(pool, "id", None): backend_pools.append(pool.id) + # Get instance IDs using the private method + instance_ids = self._get_vmss_instance_ids( + subscription_name, scale_set.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, + instance_ids=instance_ids, ) ) except Exception as error: @@ -248,6 +253,46 @@ class VirtualMachines(AzureService): ) return vm_scale_sets + def _get_vmss_instance_ids( + self, subscription_name: str, scale_set_id: str + ) -> list[str]: + """ + Given a subscription and scale set ID, return the list of VM instance IDs in the scale set. + + Args: + subscription_name: The name of the subscription. + scale_set_id: The ID of the scale set. + + Returns: + A list of VM instance IDs that compose the scale set. + """ + logger.info( + f"VirtualMachines - Getting VM scale set instance IDs for {scale_set_id} in {subscription_name}..." + ) + vm_instance_ids = [] + client = self.clients.get(subscription_name, None) + try: + resource_id_parts = scale_set_id.split("/") + resource_group = "" + scale_set_name = "" + for i, part in enumerate(resource_id_parts): + if part.lower() == "resourcegroups" and i + 1 < len(resource_id_parts): + resource_group = resource_id_parts[i + 1] + if part.lower() == "virtualmachinescalesets" and i + 1 < len( + resource_id_parts + ): + scale_set_name = resource_id_parts[i + 1] + if resource_group and scale_set_name: + instances = client.virtual_machine_scale_set_vms.list( + resource_group, scale_set_name + ) + vm_instance_ids = [instance.instance_id for instance in instances] + except Exception as e: + logger.error( + f"Failed to list instances for scale set {scale_set_name} in {resource_group}: {e}" + ) + return vm_instance_ids + @dataclass class UefiSettings: @@ -320,3 +365,4 @@ class VirtualMachineScaleSet(BaseModel): resource_name: str location: str load_balancer_backend_pools: list[str] + instance_ids: 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 index d9d0717f4e..532e1b0b63 100644 --- 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 @@ -59,6 +59,7 @@ class Test_vm_scaleset_associated_with_load_balancer: resource_name="compliant-vmss", location="eastus", load_balancer_backend_pools=[backend_pool_id], + instance_ids=[], ) } } @@ -98,6 +99,7 @@ class Test_vm_scaleset_associated_with_load_balancer: resource_name="noncompliant-vmss", location="westeurope", load_balancer_backend_pools=[], + instance_ids=[], ) } } @@ -139,12 +141,14 @@ class Test_vm_scaleset_associated_with_load_balancer: resource_name="compliant-vmss", location="eastus", load_balancer_backend_pools=[backend_pool_id], + instance_ids=[], ), noncompliant_id: VirtualMachineScaleSet( resource_id=noncompliant_id, resource_name="noncompliant-vmss", location="westeurope", load_balancer_backend_pools=[], + instance_ids=[], ), } } @@ -191,6 +195,7 @@ class Test_vm_scaleset_associated_with_load_balancer: resource_name="", location="", load_balancer_backend_pools=[], + instance_ids=[], ) } } diff --git a/tests/providers/azure/services/vm/vm_scaleset_not_empty/vm_scaleset_not_empty_test.py b/tests/providers/azure/services/vm/vm_scaleset_not_empty/vm_scaleset_not_empty_test.py new file mode 100644 index 0000000000..27d36f0697 --- /dev/null +++ b/tests/providers/azure/services/vm/vm_scaleset_not_empty/vm_scaleset_not_empty_test.py @@ -0,0 +1,174 @@ +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_not_empty: + 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_not_empty.vm_scaleset_not_empty import ( + vm_scaleset_not_empty, + ) + + check = vm_scaleset_not_empty() + 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_not_empty.vm_scaleset_not_empty import ( + vm_scaleset_not_empty, + ) + + check = vm_scaleset_not_empty() + result = check.execute() + assert len(result) == 0 + + def test_scale_set_with_no_instances(self): + vmss_id = str(uuid4()) + vm_scale_sets = { + AZURE_SUBSCRIPTION_ID: { + vmss_id: VirtualMachineScaleSet( + resource_id=vmss_id, + resource_name="empty-vmss", + location="eastus", + load_balancer_backend_pools=[], + instance_ids=[], + ) + } + } + 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_not_empty.vm_scaleset_not_empty import ( + vm_scaleset_not_empty, + ) + + check = vm_scaleset_not_empty() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].resource_id == vmss_id + assert result[0].resource_name == "empty-vmss" + assert result[0].location == "eastus" + expected_status_extended = f"Scale set 'empty-vmss' in subscription '{AZURE_SUBSCRIPTION_ID}' is empty: no VM instances present." + assert result[0].status_extended == expected_status_extended + + def test_scale_set_with_instances(self): + vmss_id = str(uuid4()) + instance_ids = ["1", "2"] + vm_scale_sets = { + AZURE_SUBSCRIPTION_ID: { + vmss_id: VirtualMachineScaleSet( + resource_id=vmss_id, + resource_name="nonempty-vmss", + location="westeurope", + load_balancer_backend_pools=[], + instance_ids=instance_ids, + ) + } + } + 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_not_empty.vm_scaleset_not_empty import ( + vm_scaleset_not_empty, + ) + + check = vm_scaleset_not_empty() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == vmss_id + assert result[0].resource_name == "nonempty-vmss" + assert result[0].location == "westeurope" + expected_status_extended = f"Scale set 'nonempty-vmss' in subscription '{AZURE_SUBSCRIPTION_ID}' has {len(instance_ids)} VM instances." + assert result[0].status_extended == expected_status_extended + + def test_multiple_scale_sets(self): + empty_id = str(uuid4()) + nonempty_id = str(uuid4()) + instance_ids = ["1"] + vm_scale_sets = { + AZURE_SUBSCRIPTION_ID: { + empty_id: VirtualMachineScaleSet( + resource_id=empty_id, + resource_name="empty-vmss", + location="eastus", + load_balancer_backend_pools=[], + instance_ids=[], + ), + nonempty_id: VirtualMachineScaleSet( + resource_id=nonempty_id, + resource_name="nonempty-vmss", + location="westeurope", + load_balancer_backend_pools=[], + instance_ids=instance_ids, + ), + } + } + 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_not_empty.vm_scaleset_not_empty import ( + vm_scaleset_not_empty, + ) + + check = vm_scaleset_not_empty() + result = check.execute() + assert len(result) == 2 + for r in result: + if r.resource_name == "empty-vmss": + expected_status_extended = f"Scale set 'empty-vmss' in subscription '{AZURE_SUBSCRIPTION_ID}' is empty: no VM instances present." + assert r.status == "FAIL" + assert r.status_extended == expected_status_extended + elif r.resource_name == "nonempty-vmss": + expected_status_extended = f"Scale set 'nonempty-vmss' in subscription '{AZURE_SUBSCRIPTION_ID}' has {len(instance_ids)} VM instances." + assert r.status == "PASS" + assert r.status_extended == expected_status_extended