diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index 156c38ad..bd7e09d8 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -133,6 +133,11 @@ "control_name": "Ensure that 'OS patching' is enabled for virtual machines", "description": "The virtual machine does not have automatic OS patching enabled. CIS 8.3 requires that OS patches are applied in a timely manner. Unpatched VMs are vulnerable to known exploits targeting unpatched OS vulnerabilities." }, + "AZ-CMP-006": { + "control_id": "N/A-CMP-006", + "control_name": "VM Scale Set NSG baseline (covered by the repository's CIS 7.1 network-interface NSG rule)", + "description": "CIS Azure Foundations recommendation 7.1 (\"Ensure that Network Security Groups are attached to network interfaces with public IP addresses\") is assigned to AZ-CMP-001 under the repository's one-CIS-ID-per-rule convention. This is the same underlying control applied to VM Scale Set network interface configurations instead of standalone VM NICs, so it is not assigned a second numbered mapping." + }, "AZ-CMP-007": { "control_id": "N/A-CMP-007", "control_name": "Just-In-Time (JIT) VM access - Defender for Cloud recommendation, no numbered CIS Azure Foundations 2.0.0 control", diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 2f2ee5c6..c8c6a7ec 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -128,6 +128,11 @@ "control_name": "Management of technical vulnerabilities", "description": "The virtual machine does not have automatic OS patching enabled. A.12.6.1 requires that information about technical vulnerabilities is obtained and the organisation's exposure evaluated. Without automatic patching, known OS vulnerabilities remain unmitigated." }, + "AZ-CMP-006": { + "control_id": "A.13.1.1", + "control_name": "Network controls", + "description": "VM Scale Set instances with public IPs and no NSG on their network interface configuration have unrestricted network access. Network controls should be applied to all compute resources accessible from the internet." + }, "AZ-CMP-007": { "control_id": "A.13.1.1", "control_name": "Network controls", diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index 8fa9992b..be71d5af 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -133,6 +133,11 @@ "control_name": "A vulnerability management plan is developed and implemented", "description": "The virtual machine does not have automatic OS patching enabled. PR.IP-12 requires that a vulnerability management plan is developed and implemented. Without automatic patching, known OS vulnerabilities remain unmitigated and exploitable." }, + "AZ-CMP-006": { + "control_id": "PR.AC-3", + "control_name": "Remote access is managed", + "description": "VM Scale Set instances with public IPs and no NSG on their network interface configuration have unrestricted network access. NSGs should be attached to control inbound and outbound traffic and manage remote access to compute resources." + }, "AZ-CMP-007": { "control_id": "PR.AC-3", "control_name": "Remote access is managed", diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index 4e312355..39f34a03 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -148,6 +148,11 @@ "control_name": "System Vulnerabilities are Identified and Managed", "description": "The virtual machine does not have automatic OS patching enabled. CC7.1 requires that vulnerabilities in system components are identified and managed through a defined process. Without automatic patching, known OS vulnerabilities are left unmitigated and exploitable." }, + "AZ-CMP-006": { + "control_id": "CC6.6", + "control_name": "Restricts Access from Outside the Network Boundary", + "description": "A VM Scale Set network interface configuration with a public IP and no NSG has unrestricted inbound network access from the internet with no filtering in place. CC6.6 requires that logical access from outside the network boundary is restricted and controlled." + }, "AZ-CMP-007": { "control_id": "CC6.6", "control_name": "Restricts Access from Outside the Network Boundary", diff --git a/playbooks/cli/fix_az_cmp_006.sh b/playbooks/cli/fix_az_cmp_006.sh new file mode 100644 index 00000000..32130f1d --- /dev/null +++ b/playbooks/cli/fix_az_cmp_006.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -euo pipefail +# AZ-CMP-006: Associate an NSG with a VM Scale Set's network interface configuration +# Usage: ./fix_az_cmp_006.sh +# +# Find (0-based) with: +# az vmss show --resource-group --name \ +# --query 'virtualMachineProfile.networkProfile.networkInterfaceConfigurations[].name' +# +# If the NSG does not yet exist, create it first: +# az network nsg create --resource-group --name +RESOURCE_GROUP="${1:-}" +VMSS_NAME="${2:-}" +NIC_CONFIG_INDEX="${3:-}" +NSG_ID="${4:-}" + +if [ -z "$RESOURCE_GROUP" ] || [ -z "$VMSS_NAME" ] || [ -z "$NIC_CONFIG_INDEX" ] || [ -z "$NSG_ID" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "WARNING: this changes the VMSS model and requires upgrading existing instances to take" +echo "effect on already-running VMs, which can briefly disrupt traffic depending on your" +echo "upgrade policy. Review the scale set's upgrade policy before proceeding." + +echo "Associating NSG with network interface configuration index $NIC_CONFIG_INDEX on VMSS '$VMSS_NAME'..." + +az vmss update \ + --resource-group "$RESOURCE_GROUP" \ + --name "$VMSS_NAME" \ + --set "virtualMachineProfile.networkProfile.networkInterfaceConfigurations[$NIC_CONFIG_INDEX].networkSecurityGroup.id=$NSG_ID" + +echo "Model updated for $VMSS_NAME. Existing instances still need to be upgraded to pick up the" +echo "change (Manual/Rolling upgrade policy):" +echo " az vmss update-instances --resource-group $RESOURCE_GROUP --name $VMSS_NAME --instance-ids '*'" diff --git a/scanner/azure_client.py b/scanner/azure_client.py index 1818889d..1ffbced8 100644 --- a/scanner/azure_client.py +++ b/scanner/azure_client.py @@ -671,6 +671,15 @@ def get_virtual_machines(self) -> List[Any]: logger.error("get_virtual_machines failed: %s", exc) return [] + def get_virtual_machine_scale_sets(self) -> List[Any]: + """List all VM Scale Sets across all resource groups in the subscription.""" + try: + client = ComputeManagementClient(self.credential, self.subscription_id) + return list(client.virtual_machine_scale_sets.list_all()) + except Exception as exc: + logger.error("get_virtual_machine_scale_sets failed: %s", exc) + return [] + def get_web_apps(self) -> List[Any]: """List all App Services in the subscription.""" try: diff --git a/scanner/rules/az_cmp_006.py b/scanner/rules/az_cmp_006.py new file mode 100644 index 00000000..3d69355b --- /dev/null +++ b/scanner/rules/az_cmp_006.py @@ -0,0 +1,151 @@ +"""AZ-CMP-006: VM Scale Set network profile has a public IP with no associated NSG.""" + +import logging +from typing import Any, Dict, List, Optional + +RULE_ID = "AZ-CMP-006" +RULE_NAME = "VM Scale Set with Public IP and No Associated NSG on Network Interface" +SEVERITY = "HIGH" +CATEGORY = "Compute" +FRAMEWORKS = {"CIS": "N/A-CMP-006", "NIST": "PR.AC-3", "ISO27001": "A.13.1.1", "SOC2": "CC6.6"} +DESCRIPTION = ( + "A VM Scale Set network interface configuration provisions a public IP address " + "for its instances but has no Network Security Group protecting that interface, " + "either directly or via the subnet it deploys into. Without an NSG, all inbound " + "ports are open to the internet by default on every instance created from this " + "scale set, creating an unrestricted attack surface." +) +REMEDIATION = ( + "Attach an NSG to the scale set's network interface configuration or to the " + "subnet it deploys into, with rules that allow only required inbound traffic. " + "Remove the public IP configuration if internet access is not needed and use " + "Azure Bastion or a load balancer for administrative/application access instead." +) +PLAYBOOK = "playbooks/cli/fix_az_cmp_006.sh" + +# A subnet reference that can't be resolved (VNet collection failure, missing +# permissions, or an ID this scan never saw) says nothing about whether that +# subnet actually has an NSG — it must not be treated the same as a resolved +# subnet confirmed to have none, or a scan-visibility gap silently turns into +# a false HIGH finding on an already-protected VMSS. +INDETERMINATE_SEVERITY = "LOW" +INDETERMINATE_DESCRIPTION = ( + "A VM Scale Set network interface configuration provisions a public IP address and has no " + "NSG directly attached, but the NSG state of the subnet it deploys into could not be " + "verified (virtual network collection failed, the scanning principal lacks " + "Microsoft.Network/virtualNetworks/read, or the subnet reference could not be matched). " + "This is not a confirmed violation — the subnet may already be protected by an NSG this " + "scan could not see." +) +INDETERMINATE_REMEDIATION = ( + "Grant the scanning principal Microsoft.Network/virtualNetworks/read on the relevant " + "virtual network(s) and re-run the scan to determine the actual subnet NSG state." +) + +logger = logging.getLogger(__name__) + + +def _subnet_nsg_map(vnets: List[Any]) -> Dict[str, bool]: + """Map subnet resource ID (lowercased) -> whether that subnet has an NSG attached. + + A VMSS network interface configuration only references its subnet by ID + (ApiEntityReference); the subnet's own NSG lives on the VirtualNetwork + resource, so it must be resolved separately to avoid flagging a VMSS that + is actually protected at the subnet level instead of the NIC level. + Azure resource IDs are case-insensitive, so keys are normalized to + lowercase to avoid missing a match on casing differences alone. + """ + subnet_nsgs: Dict[str, bool] = {} + for vnet in vnets: + for subnet in getattr(vnet, "subnets", []) or []: + subnet_id = getattr(subnet, "id", None) + if subnet_id: + subnet_nsgs[subnet_id.lower()] = bool(getattr(subnet, "network_security_group", None)) + return subnet_nsgs + + +def _subnet_nsg_status(subnet_nsgs: Dict[str, bool], ip_cfg: Any) -> Optional[bool]: + """Return True/False if the ip config's subnet NSG state is known, None if unresolved.""" + subnet_ref = getattr(ip_cfg, "subnet", None) + subnet_id = getattr(subnet_ref, "id", None) + if not subnet_id: + return None + return subnet_nsgs.get(subnet_id.lower()) + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Detect VM Scale Sets whose network interface configuration has a public IP + but no NSG protecting it, at either the NIC or the subnet level.""" + findings: List[Dict[str, Any]] = [] + vnets = azure_client.get_virtual_networks() + subnet_nsgs = _subnet_nsg_map(vnets) + # get_virtual_networks() returns [] on a genuinely empty subscription and on + # collection failure alike, so this can't tell the two apart on its own — + # but reporting the raw count lets an operator spot the pattern (many + # indeterminate findings, always vnets_collected: 0) and escalate instead + # of it silently reading as "checked, subnet just has no NSG." + vnets_collected = len(vnets) + + for vmss in azure_client.get_virtual_machine_scale_sets(): + vmss_id = getattr(vmss, "id", "") + vmss_name = getattr(vmss, "name", "") + if not vmss_id or not vmss_name: + continue + + vm_profile = getattr(vmss, "virtual_machine_profile", None) + network_profile = getattr(vm_profile, "network_profile", None) + if not network_profile: + continue + + net_configs = getattr(network_profile, "network_interface_configurations", []) or [] + for net_config in net_configs: + ip_configs = getattr(net_config, "ip_configurations", []) or [] + # Only an ip_configuration that itself carries a public IP is + # internet-reachable. A non-primary ip_config with no public IP + # contributes nothing to exposure, so its subnet must not be + # allowed to force a finding on an otherwise-compliant net_config. + public_ip_configs = [ + ip_cfg for ip_cfg in ip_configs if getattr(ip_cfg, "public_ip_address_configuration", None) + ] + has_nic_nsg = bool(getattr(net_config, "network_security_group", None)) + + if not public_ip_configs or has_nic_nsg: + continue + + subnet_statuses = [_subnet_nsg_status(subnet_nsgs, ip_cfg) for ip_cfg in public_ip_configs] + has_subnet_nsg = any(status is True for status in subnet_statuses) + if has_subnet_nsg: + continue # protected at the subnet level, compliant + + has_unresolved_subnet = any(status is None for status in subnet_statuses) + confirmed = not has_unresolved_subnet + + parsed = azure_client.parse_resource_id(vmss_id) + metadata = { + "resource_group": parsed.get("resource_group", ""), + "location": getattr(vmss, "location", ""), + "network_interface_configuration": getattr(net_config, "name", ""), + "determination": "non_compliant" if confirmed else "indeterminate", + } + if not confirmed: + metadata["vnets_collected"] = vnets_collected + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY if confirmed else INDETERMINATE_SEVERITY, + "category": CATEGORY, + "resource_id": vmss_id, + "resource_name": vmss_name, + "resource_type": "Microsoft.Compute/virtualMachineScaleSets", + "description": DESCRIPTION if confirmed else INDETERMINATE_DESCRIPTION, + "remediation": REMEDIATION if confirmed else INDETERMINATE_REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": metadata, + } + ) + # No break: every exposed net_config on this VMSS must be its own + # finding, or remediators only ever see the first of several. + + return findings diff --git a/tests/helpers/mock_azure.py b/tests/helpers/mock_azure.py index 5b3c3ebb..a019a892 100644 --- a/tests/helpers/mock_azure.py +++ b/tests/helpers/mock_azure.py @@ -50,6 +50,7 @@ def __init__(self) -> None: self._network_security_groups: List[Any] = [] self._express_route_ports: Optional[List[Any]] = [] self._virtual_machines: List[Any] = [] + self._virtual_machine_scale_sets: List[Any] = [] self._key_vaults: List[Any] = [] self._sql_servers: List[Any] = [] self._service_principals: List[Any] = [] @@ -190,6 +191,10 @@ def set_virtual_machines(self, vms: List[Any]) -> "MockAzureClient": self._virtual_machines = vms return self + def set_virtual_machine_scale_sets(self, scale_sets: List[Any]) -> "MockAzureClient": + self._virtual_machine_scale_sets = scale_sets + return self + def set_key_vaults(self, vaults: List[Any]) -> "MockAzureClient": self._key_vaults = vaults return self @@ -217,6 +222,9 @@ def get_network_security_groups(self) -> List[Any]: def get_virtual_machines(self) -> List[Any]: return self._virtual_machines + def get_virtual_machine_scale_sets(self) -> List[Any]: + return self._virtual_machine_scale_sets + def get_key_vaults(self) -> List[Any]: return self._key_vaults diff --git a/tests/test_azure_client_management.py b/tests/test_azure_client_management.py index b541f1d6..45f93ac6 100644 --- a/tests/test_azure_client_management.py +++ b/tests/test_azure_client_management.py @@ -32,6 +32,11 @@ def test_parse_resource_id_handles_full_and_short_ids(): ("get_public_ip_addresses", "scanner.azure_client.NetworkManagementClient", "public_ip_addresses.list_all"), ("get_load_balancers", "scanner.azure_client.NetworkManagementClient", "load_balancers.list_all"), ("get_virtual_machines", "scanner.azure_client.ComputeManagementClient", "virtual_machines.list_all"), + ( + "get_virtual_machine_scale_sets", + "scanner.azure_client.ComputeManagementClient", + "virtual_machine_scale_sets.list_all", + ), ("get_postgresql_servers", "scanner.azure_client.PostgreSQLManagementClient", "servers.list"), ("get_sql_servers", "scanner.azure_client.SqlManagementClient", "servers.list"), ("get_key_vaults", "scanner.azure_client.KeyVaultManagementClient", "vaults.list_by_subscription"), diff --git a/tests/test_rules_compute.py b/tests/test_rules_compute.py index 751bcb77..562f86b7 100644 --- a/tests/test_rules_compute.py +++ b/tests/test_rules_compute.py @@ -12,6 +12,7 @@ import scanner.rules.az_cmp_002 as az_cmp_002 import scanner.rules.az_cmp_003 as az_cmp_003 import scanner.rules.az_cmp_004 as az_cmp_004 +import scanner.rules.az_cmp_006 as az_cmp_006 import scanner.rules.az_cmp_007 as az_cmp_007 from tests.helpers.mock_azure import make_resource @@ -48,6 +49,10 @@ def _nic_id(name): return f"/subscriptions/{_SUB}/resourceGroups/{_RG}/providers/Microsoft.Network/networkInterfaces/{name}" +def _vmss_id(name): + return f"/subscriptions/{_SUB}/resourceGroups/{_RG}/providers/Microsoft.Compute/virtualMachineScaleSets/{name}" + + def _disk_id(name): return f"/subscriptions/{_SUB}/resourceGroups/{_RG}/providers/Microsoft.Compute/disks/{name}" @@ -587,3 +592,236 @@ def test_cmp_007_subnet_level_nsg_exposure_is_flagged(mock_azure, subscription_i assert len(findings) == 1 assert findings[0]["resource_name"] == "vm-subnet" assert findings[0]["metadata"]["open_management_ports"] == ["22"] + + +# ── AZ-CMP-006: VMSS public IP with no NSG on the network profile ────────── + + +def _ip_config(has_public_ip, subnet_id=None): + return make_resource( + public_ip_address_configuration=make_resource(name="pip-cfg") if has_public_ip else None, + subnet=make_resource(id=subnet_id) if subnet_id else None, + ) + + +def _net_config(name, has_public_ip, has_nsg, subnet_id=None, extra_ip_configs=None): + ip_configs = [_ip_config(has_public_ip, subnet_id)] + if extra_ip_configs: + ip_configs.extend(extra_ip_configs) + return make_resource( + name=name, + ip_configurations=ip_configs, + network_security_group=make_resource(id="nsg1") if has_nsg else None, + ) + + +def _vnet_subnet_id(vnet_name, subnet_name): + return ( + f"/subscriptions/{_SUB}/resourceGroups/{_RG}/providers/Microsoft.Network/" + f"virtualNetworks/{vnet_name}/subnets/{subnet_name}" + ) + + +def _vnet_with_subnet(subnet_id, has_nsg): + subnet = make_resource( + id=subnet_id, + network_security_group=make_resource(id="subnet-nsg") if has_nsg else None, + ) + vnet_id = f"/subscriptions/{_SUB}/resourceGroups/{_RG}/providers/Microsoft.Network/virtualNetworks/vnet1" + return make_resource(id=vnet_id, name="vnet1", subnets=[subnet]) + + +def _vmss(name, net_configs, has_profile=True): + network_profile = make_resource(network_interface_configurations=net_configs) if has_profile else None + vm_profile = make_resource(network_profile=network_profile) if has_profile else None + return make_resource( + id=_vmss_id(name), + name=name, + location="eastus", + virtual_machine_profile=vm_profile, + ) + + +def test_cmp_006_compliant_public_ip_with_nsg_returns_no_findings(mock_azure, subscription_id): + """A network config with a public IP and a protecting NSG is compliant.""" + vmss = _vmss("vmss-compliant", [_net_config("nic-config", has_public_ip=True, has_nsg=True)]) + mock_azure.set_virtual_machine_scale_sets([vmss]) + assert az_cmp_006.scan(mock_azure, subscription_id) == [] + + +def test_cmp_006_compliant_no_public_ip_returns_no_findings(mock_azure, subscription_id): + """A network config with no public IP at all is compliant regardless of NSG.""" + vmss = _vmss("vmss-private", [_net_config("nic-config", has_public_ip=False, has_nsg=False)]) + mock_azure.set_virtual_machine_scale_sets([vmss]) + assert az_cmp_006.scan(mock_azure, subscription_id) == [] + + +def test_cmp_006_noncompliant_public_ip_no_nsg_returns_one_finding(mock_azure, subscription_id): + """A network config with a public IP, no NIC NSG, and a resolved subnet confirmed to + have no NSG either, must produce exactly one confirmed HIGH finding.""" + subnet_id = _vnet_subnet_id("vnet1", "subnet1") + vmss = _vmss( + "vmss-exposed", + [_net_config("nic-config", has_public_ip=True, has_nsg=False, subnet_id=subnet_id)], + ) + mock_azure.set_virtual_machine_scale_sets([vmss]) + mock_azure.set_virtual_networks([_vnet_with_subnet(subnet_id, has_nsg=False)]) + findings = az_cmp_006.scan(mock_azure, subscription_id) + assert len(findings) == 1 + f = findings[0] + assert _REQUIRED_FIELDS.issubset(f.keys()) + assert f["rule_id"] == "AZ-CMP-006" + assert f["severity"] == "HIGH" + assert f["resource_name"] == "vmss-exposed" + assert f["resource_type"] == "Microsoft.Compute/virtualMachineScaleSets" + assert f["metadata"]["network_interface_configuration"] == "nic-config" + assert f["metadata"]["determination"] == "non_compliant" + + +def test_cmp_006_missing_network_profile_returns_no_findings(mock_azure, subscription_id): + """A VMSS with no virtual_machine_profile/network_profile must not crash or flag.""" + vmss = _vmss("vmss-bare", [], has_profile=False) + mock_azure.set_virtual_machine_scale_sets([vmss]) + assert az_cmp_006.scan(mock_azure, subscription_id) == [] + + +def test_cmp_006_one_bad_config_among_several_returns_one_finding(mock_azure, subscription_id): + """A compliant config alongside one bad config produces exactly one finding, for the bad one.""" + vmss = _vmss( + "vmss-mixed", + [ + _net_config("nic-config-ok", has_public_ip=True, has_nsg=True), + _net_config("nic-config-bad", has_public_ip=True, has_nsg=False), + ], + ) + mock_azure.set_virtual_machine_scale_sets([vmss]) + findings = az_cmp_006.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["resource_name"] == "vmss-mixed" + assert findings[0]["metadata"]["network_interface_configuration"] == "nic-config-bad" + + +def test_cmp_006_multiple_bad_configs_each_reported(mock_azure, subscription_id): + """A VMSS with several exposed network interface configurations must report every one of + them, not just the first (regression: scan() used to `break` after the first match, + silently hiding every other exposed attack surface on the same VMSS).""" + vmss = _vmss( + "vmss-multi-exposed", + [ + _net_config("nic-config-bad-1", has_public_ip=True, has_nsg=False), + _net_config("nic-config-ok", has_public_ip=True, has_nsg=True), + _net_config("nic-config-bad-2", has_public_ip=True, has_nsg=False), + ], + ) + mock_azure.set_virtual_machine_scale_sets([vmss]) + findings = az_cmp_006.scan(mock_azure, subscription_id) + assert len(findings) == 2 + flagged = {f["metadata"]["network_interface_configuration"] for f in findings} + assert flagged == {"nic-config-bad-1", "nic-config-bad-2"} + + +def test_cmp_006_non_primary_ip_config_without_public_ip_is_not_checked(mock_azure, subscription_id): + """A non-primary ip_configuration with no public IP must not force a finding just because + its subnet is unresolved or unprotected (regression: the old code checked the subnet of + every ip_configuration on the net_config, not only the ones that are actually internet + -reachable, producing a false HIGH on a compliant net_config).""" + unresolved_subnet_id = _vnet_subnet_id("vnet1", "subnet-private") + net_config = _net_config( + "nic-config", + has_public_ip=True, + has_nsg=True, # the actual public ip_config is protected at the NIC level + extra_ip_configs=[_ip_config(has_public_ip=False, subnet_id=unresolved_subnet_id)], + ) + vmss = _vmss("vmss-non-primary-private", [net_config]) + mock_azure.set_virtual_machine_scale_sets([vmss]) + mock_azure.set_virtual_networks([]) # the non-primary config's subnet is unresolved + assert az_cmp_006.scan(mock_azure, subscription_id) == [] + + +def test_cmp_006_indeterminate_finding_reports_vnets_collected_count(mock_azure, subscription_id): + """An indeterminate finding must surface how many VNets were actually collected, so a + persistent zero across many findings is visible as a collection problem instead of + reading as an ordinary per-subnet indeterminate result.""" + subnet_id = _vnet_subnet_id("vnet1", "subnet1") + vmss = _vmss( + "vmss-degraded-collection", + [_net_config("nic-config", has_public_ip=True, has_nsg=False, subnet_id=subnet_id)], + ) + mock_azure.set_virtual_machine_scale_sets([vmss]) + mock_azure.set_virtual_networks([]) + findings = az_cmp_006.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["determination"] == "indeterminate" + assert findings[0]["metadata"]["vnets_collected"] == 0 + + +def test_cmp_006_compliant_subnet_level_nsg_returns_no_findings(mock_azure, subscription_id): + """A VMSS protected only at the subnet level (no NIC-level NSG) must not be flagged. + + Regression case for the reviewer-reported false positive: a VMSS network + interface configuration with no network_security_group of its own is + still compliant if the subnet it deploys into has one. + """ + subnet_id = _vnet_subnet_id("vnet1", "subnet1") + vmss = _vmss( + "vmss-subnet-protected", + [_net_config("nic-config", has_public_ip=True, has_nsg=False, subnet_id=subnet_id)], + ) + mock_azure.set_virtual_machine_scale_sets([vmss]) + mock_azure.set_virtual_networks([_vnet_with_subnet(subnet_id, has_nsg=True)]) + assert az_cmp_006.scan(mock_azure, subscription_id) == [] + + +def test_cmp_006_noncompliant_no_nic_or_subnet_nsg_returns_one_finding(mock_azure, subscription_id): + """Neither a NIC-level nor a resolved subnet-level NSG must still be flagged as confirmed.""" + subnet_id = _vnet_subnet_id("vnet1", "subnet1") + vmss = _vmss( + "vmss-fully-exposed", + [_net_config("nic-config", has_public_ip=True, has_nsg=False, subnet_id=subnet_id)], + ) + mock_azure.set_virtual_machine_scale_sets([vmss]) + mock_azure.set_virtual_networks([_vnet_with_subnet(subnet_id, has_nsg=False)]) + findings = az_cmp_006.scan(mock_azure, subscription_id) + assert len(findings) == 1 + f = findings[0] + assert f["resource_name"] == "vmss-fully-exposed" + assert f["severity"] == "HIGH" + assert f["metadata"]["determination"] == "non_compliant" + + +def test_cmp_006_unresolved_subnet_returns_indeterminate_not_confirmed(mock_azure, subscription_id): + """A subnet reference that can't be resolved must not produce a confirmed HIGH finding. + + Regression case for the reviewer-reported false positive: when VNet + collection fails or returns no matching subnet (missing permissions, + transient API failure, or the subnet genuinely absent from the + collected inventory), subnet_nsgs.get(subnet_id, False) used to treat + that identically to a resolved subnet confirmed to have no NSG, wrongly + re-flagging an already-protected VMSS as a definite HIGH violation. + """ + subnet_id = _vnet_subnet_id("vnet1", "subnet1") + vmss = _vmss( + "vmss-unresolved-subnet", + [_net_config("nic-config", has_public_ip=True, has_nsg=False, subnet_id=subnet_id)], + ) + mock_azure.set_virtual_machine_scale_sets([vmss]) + mock_azure.set_virtual_networks([]) # simulates a VNet collection failure/empty result + findings = az_cmp_006.scan(mock_azure, subscription_id) + assert len(findings) == 1 + f = findings[0] + assert f["resource_name"] == "vmss-unresolved-subnet" + assert f["severity"] == "LOW" + assert f["metadata"]["determination"] == "indeterminate" + + +def test_cmp_006_compliant_subnet_match_is_case_insensitive(mock_azure, subscription_id): + """Subnet ID matching must not miss a match purely due to casing differences.""" + subnet_id_upper = _vnet_subnet_id("VNET1", "SUBNET1") + vmss = _vmss( + "vmss-case-mismatch", + [_net_config("nic-config", has_public_ip=True, has_nsg=False, subnet_id=subnet_id_upper)], + ) + mock_azure.set_virtual_machine_scale_sets([vmss]) + # The VNet API returns the same subnet with different casing than the VMSS reference. + mock_azure.set_virtual_networks([_vnet_with_subnet(subnet_id_upper.lower(), has_nsg=True)]) + assert az_cmp_006.scan(mock_azure, subscription_id) == []