feat(azure/vm): add new check vm_scaleset_not_empty (#8192)

Co-authored-by: Sergio Garcia <hello@mistercloudsec.com>
This commit is contained in:
Rubén De la Torre Vico
2025-07-25 12:42:03 +02:00
committed by GitHub
parent d9a9236ab7
commit 1cfe610d47
7 changed files with 285 additions and 0 deletions
+1
View File
@@ -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)
@@ -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 <scale-set-name> --resource-group <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": ""
}
@@ -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
@@ -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]
@@ -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=[],
)
}
}
@@ -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