From 0a98fee2c6f886deeb45882c63d6f12a834dead0 Mon Sep 17 00:00:00 2001 From: Dipesh Ray Date: Fri, 14 Aug 2026 09:20:53 +0100 Subject: [PATCH 1/4] feat: add VM Scale Set inventory collector and rule AZ-CMP-005 Adds AzureClient.get_virtual_machine_scale_sets() (list_all across the subscription, following the get_virtual_machines() pattern) and its MockAzureClient test double. Ships AZ-CMP-005 as the first rule to use it: flags VMSS network interface configurations that provision a public IP with no NSG attached, the VMSS-template equivalent of AZ-CMP-001's per-VM NIC check. Detection reads the network interface configuration template directly (network_interface_configurations[].ip_configurations[] / .network_security_group) rather than resolving separate NIC resources, since a VMSS profile embeds these settings inline. Includes a remediation playbook (az vmss update --set on the network profile, with an explicit warning about the required instance upgrade), collector and rule tests, and compliance framework mappings. CIS is mapped to N/A-CMP-005 following the repository's established convention for the same real control (7.1, owned by AZ-CMP-001) applied to a second resource type, since the one-CIS-ID-per-rule convention doesn't allow reusing 7.1 directly. Closes #271 Signed-off-by: Dipesh Ray --- .../frameworks/cis_azure_benchmark.json | 5 ++ compliance/frameworks/iso27001.json | 5 ++ compliance/frameworks/nist_csf.json | 5 ++ compliance/frameworks/soc2.json | 5 ++ playbooks/cli/fix_az_cmp_005.sh | 35 ++++++++ scanner/azure_client.py | 9 +++ scanner/rules/az_cmp_005.py | 75 +++++++++++++++++ tests/helpers/mock_azure.py | 8 ++ tests/test_azure_client_management.py | 5 ++ tests/test_rules_compute.py | 81 +++++++++++++++++++ 10 files changed, 233 insertions(+) create mode 100644 playbooks/cli/fix_az_cmp_005.sh create mode 100644 scanner/rules/az_cmp_005.py diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index 156c38ad..a8973875 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-005": { + "control_id": "N/A-CMP-005", + "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..e5f761a9 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-005": { + "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..efa30f23 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-005": { + "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..18d9e034 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-005": { + "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_005.sh b/playbooks/cli/fix_az_cmp_005.sh new file mode 100644 index 00000000..a678ac0f --- /dev/null +++ b/playbooks/cli/fix_az_cmp_005.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -euo pipefail +# AZ-CMP-005: Associate an NSG with a VM Scale Set's network interface configuration +# Usage: ./fix_az_cmp_005.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_005.py b/scanner/rules/az_cmp_005.py new file mode 100644 index 00000000..b5b73cf2 --- /dev/null +++ b/scanner/rules/az_cmp_005.py @@ -0,0 +1,75 @@ +"""AZ-CMP-005: VM Scale Set network profile has a public IP with no associated NSG.""" + +import logging +from typing import Any, Dict, List + +RULE_ID = "AZ-CMP-005" +RULE_NAME = "VM Scale Set with Public IP and No Associated NSG on Network Interface" +SEVERITY = "HIGH" +CATEGORY = "Compute" +FRAMEWORKS = {"CIS": "N/A-CMP-005", "NIST": "PR.AC-3", "ISO27001": "A.13.1.1"} +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. " + "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 the " + "subnet it deploys into) that allows 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_005.sh" + +logger = logging.getLogger(__name__) + + +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.""" + findings: List[Dict[str, Any]] = [] + + 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: + has_public_ip = any( + getattr(ip_cfg, "public_ip_address_configuration", None) + for ip_cfg in (getattr(net_config, "ip_configurations", []) or []) + ) + has_nsg = bool(getattr(net_config, "network_security_group", None)) + + if has_public_ip and not has_nsg: + parsed = azure_client.parse_resource_id(vmss_id) + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": vmss_id, + "resource_name": vmss_name, + "resource_type": "Microsoft.Compute/virtualMachineScaleSets", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "resource_group": parsed.get("resource_group", ""), + "location": getattr(vmss, "location", ""), + "network_interface_configuration": getattr(net_config, "name", ""), + }, + } + ) + break # one finding per VMSS is sufficient + + 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..1a31ad08 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_005 as az_cmp_005 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,79 @@ 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-005: VMSS public IP with no NSG on the network profile ────────── + + +def _net_config(name, has_public_ip, has_nsg): + ip_cfg = make_resource( + public_ip_address_configuration=make_resource(name="pip-cfg") if has_public_ip else None, + ) + return make_resource( + name=name, + ip_configurations=[ip_cfg], + network_security_group=make_resource(id="nsg1") if has_nsg else None, + ) + + +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_005_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_005.scan(mock_azure, subscription_id) == [] + + +def test_cmp_005_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_005.scan(mock_azure, subscription_id) == [] + + +def test_cmp_005_noncompliant_public_ip_no_nsg_returns_one_finding(mock_azure, subscription_id): + """A network config with a public IP and no NSG must produce exactly one finding.""" + vmss = _vmss("vmss-exposed", [_net_config("nic-config", has_public_ip=True, has_nsg=False)]) + mock_azure.set_virtual_machine_scale_sets([vmss]) + findings = az_cmp_005.scan(mock_azure, subscription_id) + assert len(findings) == 1 + f = findings[0] + assert _REQUIRED_FIELDS.issubset(f.keys()) + assert f["rule_id"] == "AZ-CMP-005" + 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" + + +def test_cmp_005_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_005.scan(mock_azure, subscription_id) == [] + + +def test_cmp_005_one_bad_config_among_several_returns_one_finding(mock_azure, subscription_id): + """Multiple network configs on one VMSS still produce exactly one finding (break after first).""" + 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_005.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["resource_name"] == "vmss-mixed" From 99b17e97058a9f9005e61fb45f5cda9d14a80442 Mon Sep 17 00:00:00 2001 From: Dipesh Ray Date: Fri, 14 Aug 2026 23:24:15 +0100 Subject: [PATCH 2/4] fix: renumber AZ-CMP-005 to AZ-CMP-006, fix subnet-NSG false positive AZ-CMP-005 collided with #273 (Trusted Launch check), opened a day before this PR and already claiming that rule ID. Renumbered the rule file, playbook, tests, and all four compliance framework entries to AZ-CMP-006. Also fixes a false-positive gap flagged in review: the rule only checked for an NSG on the VMSS network interface configuration itself, missing the case where the NSG is attached at the subnet level instead. A VMSS whose NIC has no NSG but deploys into a subnet that does have one was being incorrectly flagged. Now resolves each network interface configuration's subnet (via the existing get_virtual_networks() collector, no new collector needed) and treats either a NIC-level or subnet-level NSG as compliant, matching how AZ-NET-010 already reads subnet.network_security_group. Added SOC2 to the rule's own FRAMEWORKS dict (was previously only in soc2.json, inconsistent with how several other rules, e.g. AZ-KV-006, already include it directly). Addresses review feedback from TFT444 and m-khan-97 on #275. Signed-off-by: Dipesh Ray --- .../frameworks/cis_azure_benchmark.json | 4 +- compliance/frameworks/iso27001.json | 2 +- compliance/frameworks/nist_csf.json | 2 +- compliance/frameworks/soc2.json | 2 +- .../{fix_az_cmp_005.sh => fix_az_cmp_006.sh} | 4 +- .../rules/{az_cmp_005.py => az_cmp_006.py} | 55 +++++++++----- tests/test_rules_compute.py | 76 +++++++++++++++---- 7 files changed, 107 insertions(+), 38 deletions(-) rename playbooks/cli/{fix_az_cmp_005.sh => fix_az_cmp_006.sh} (92%) rename scanner/rules/{az_cmp_005.py => az_cmp_006.py} (51%) diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index a8973875..bd7e09d8 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -133,8 +133,8 @@ "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-005": { - "control_id": "N/A-CMP-005", + "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." }, diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index e5f761a9..c8c6a7ec 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -128,7 +128,7 @@ "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-005": { + "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." diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index efa30f23..be71d5af 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -133,7 +133,7 @@ "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-005": { + "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." diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index 18d9e034..39f34a03 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -148,7 +148,7 @@ "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-005": { + "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." diff --git a/playbooks/cli/fix_az_cmp_005.sh b/playbooks/cli/fix_az_cmp_006.sh similarity index 92% rename from playbooks/cli/fix_az_cmp_005.sh rename to playbooks/cli/fix_az_cmp_006.sh index a678ac0f..32130f1d 100644 --- a/playbooks/cli/fix_az_cmp_005.sh +++ b/playbooks/cli/fix_az_cmp_006.sh @@ -1,7 +1,7 @@ #!/bin/bash set -euo pipefail -# AZ-CMP-005: Associate an NSG with a VM Scale Set's network interface configuration -# Usage: ./fix_az_cmp_005.sh +# 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 \ diff --git a/scanner/rules/az_cmp_005.py b/scanner/rules/az_cmp_006.py similarity index 51% rename from scanner/rules/az_cmp_005.py rename to scanner/rules/az_cmp_006.py index b5b73cf2..ca0dde9a 100644 --- a/scanner/rules/az_cmp_005.py +++ b/scanner/rules/az_cmp_006.py @@ -1,33 +1,53 @@ -"""AZ-CMP-005: VM Scale Set network profile has a public IP with no associated NSG.""" +"""AZ-CMP-006: VM Scale Set network profile has a public IP with no associated NSG.""" import logging from typing import Any, Dict, List -RULE_ID = "AZ-CMP-005" +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-005", "NIST": "PR.AC-3", "ISO27001": "A.13.1.1"} +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. " - "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." + "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 the " - "subnet it deploys into) that allows 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." + "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_005.sh" +PLAYBOOK = "playbooks/cli/fix_az_cmp_006.sh" logger = logging.getLogger(__name__) +def _subnet_nsg_map(azure_client: Any) -> Dict[str, bool]: + """Map subnet resource ID -> 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. + """ + subnet_nsgs: Dict[str, bool] = {} + for vnet in azure_client.get_virtual_networks(): + for subnet in getattr(vnet, "subnets", []) or []: + subnet_id = getattr(subnet, "id", None) + if subnet_id: + subnet_nsgs[subnet_id] = bool(getattr(subnet, "network_security_group", None)) + return subnet_nsgs + + 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.""" + """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]] = [] + subnet_nsgs = _subnet_nsg_map(azure_client) for vmss in azure_client.get_virtual_machine_scale_sets(): vmss_id = getattr(vmss, "id", "") @@ -42,13 +62,14 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: net_configs = getattr(network_profile, "network_interface_configurations", []) or [] for net_config in net_configs: - has_public_ip = any( - getattr(ip_cfg, "public_ip_address_configuration", None) - for ip_cfg in (getattr(net_config, "ip_configurations", []) or []) + ip_configs = getattr(net_config, "ip_configurations", []) or [] + has_public_ip = any(getattr(ip_cfg, "public_ip_address_configuration", None) for ip_cfg in ip_configs) + has_nic_nsg = bool(getattr(net_config, "network_security_group", None)) + has_subnet_nsg = any( + subnet_nsgs.get(getattr(getattr(ip_cfg, "subnet", None), "id", None), False) for ip_cfg in ip_configs ) - has_nsg = bool(getattr(net_config, "network_security_group", None)) - if has_public_ip and not has_nsg: + if has_public_ip and not has_nic_nsg and not has_subnet_nsg: parsed = azure_client.parse_resource_id(vmss_id) findings.append( { diff --git a/tests/test_rules_compute.py b/tests/test_rules_compute.py index 1a31ad08..6ba7ba62 100644 --- a/tests/test_rules_compute.py +++ b/tests/test_rules_compute.py @@ -12,7 +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_005 as az_cmp_005 +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 @@ -594,12 +594,13 @@ def test_cmp_007_subnet_level_nsg_exposure_is_flagged(mock_azure, subscription_i assert findings[0]["metadata"]["open_management_ports"] == ["22"] -# ── AZ-CMP-005: VMSS public IP with no NSG on the network profile ────────── +# ── AZ-CMP-006: VMSS public IP with no NSG on the network profile ────────── -def _net_config(name, has_public_ip, has_nsg): +def _net_config(name, has_public_ip, has_nsg, subnet_id=None): ip_cfg = 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, ) return make_resource( name=name, @@ -608,6 +609,22 @@ def _net_config(name, has_public_ip, has_nsg): ) +def _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 @@ -619,43 +636,43 @@ def _vmss(name, net_configs, has_profile=True): ) -def test_cmp_005_compliant_public_ip_with_nsg_returns_no_findings(mock_azure, subscription_id): +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_005.scan(mock_azure, subscription_id) == [] + assert az_cmp_006.scan(mock_azure, subscription_id) == [] -def test_cmp_005_compliant_no_public_ip_returns_no_findings(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_005.scan(mock_azure, subscription_id) == [] + assert az_cmp_006.scan(mock_azure, subscription_id) == [] -def test_cmp_005_noncompliant_public_ip_no_nsg_returns_one_finding(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 and no NSG must produce exactly one finding.""" vmss = _vmss("vmss-exposed", [_net_config("nic-config", has_public_ip=True, has_nsg=False)]) mock_azure.set_virtual_machine_scale_sets([vmss]) - findings = az_cmp_005.scan(mock_azure, subscription_id) + 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-005" + 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" -def test_cmp_005_missing_network_profile_returns_no_findings(mock_azure, subscription_id): +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_005.scan(mock_azure, subscription_id) == [] + assert az_cmp_006.scan(mock_azure, subscription_id) == [] -def test_cmp_005_one_bad_config_among_several_returns_one_finding(mock_azure, subscription_id): +def test_cmp_006_one_bad_config_among_several_returns_one_finding(mock_azure, subscription_id): """Multiple network configs on one VMSS still produce exactly one finding (break after first).""" vmss = _vmss( "vmss-mixed", @@ -665,6 +682,37 @@ def test_cmp_005_one_bad_config_among_several_returns_one_finding(mock_azure, su ], ) mock_azure.set_virtual_machine_scale_sets([vmss]) - findings = az_cmp_005.scan(mock_azure, subscription_id) + findings = az_cmp_006.scan(mock_azure, subscription_id) assert len(findings) == 1 assert findings[0]["resource_name"] == "vmss-mixed" + + +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 = _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 subnet-level NSG must still be flagged.""" + subnet_id = _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 + assert findings[0]["resource_name"] == "vmss-fully-exposed" From 7da2d2c180b9a6725da820308bffec9016f32ed1 Mon Sep 17 00:00:00 2001 From: Dipesh Ray Date: Wed, 19 Aug 2026 11:39:47 +0100 Subject: [PATCH 3/4] fix: distinguish unresolved subnet from confirmed no-NSG in AZ-CMP-006 subnet_nsgs.get(subnet_id, False) treated an unresolved subnet reference (VNet collection failure, missing permissions, or a subnet ID this scan never saw) identically to a resolved subnet confirmed to have no NSG, reintroducing the false-positive the previous commit was meant to fix. Now distinguishes three states per ip_configuration: resolved+has NSG (compliant), resolved+no NSG (confirmed non-compliant, HIGH), and unresolved (indeterminate, LOW), mirroring the confirmed/indeterminate pattern already established in az_cmp_002.py. Also normalizes subnet IDs to lowercase before comparison, since Azure resource IDs are case-insensitive and the two APIs involved (VMSS network profile vs. VNet subnets) aren't guaranteed to return matching casing. Adds regression tests for an unresolved subnet (VNet collection returns empty) and for a differently-cased subnet ID match. Addresses review feedback from ritiksah141 on #275. Signed-off-by: Dipesh Ray --- scanner/rules/az_cmp_006.py | 96 ++++++++++++++++++++++++++----------- tests/test_rules_compute.py | 56 ++++++++++++++++++++-- 2 files changed, 119 insertions(+), 33 deletions(-) diff --git a/scanner/rules/az_cmp_006.py b/scanner/rules/az_cmp_006.py index ca0dde9a..50286ae4 100644 --- a/scanner/rules/az_cmp_006.py +++ b/scanner/rules/az_cmp_006.py @@ -1,7 +1,7 @@ """AZ-CMP-006: VM Scale Set network profile has a public IP with no associated NSG.""" import logging -from typing import Any, Dict, List +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" @@ -23,26 +23,56 @@ ) 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(azure_client: Any) -> Dict[str, bool]: - """Map subnet resource ID -> whether that subnet has an NSG attached. + """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 azure_client.get_virtual_networks(): for subnet in getattr(vnet, "subnets", []) or []: subnet_id = getattr(subnet, "id", None) if subnet_id: - subnet_nsgs[subnet_id] = bool(getattr(subnet, "network_security_group", None)) + 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.""" @@ -65,32 +95,40 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: ip_configs = getattr(net_config, "ip_configurations", []) or [] has_public_ip = any(getattr(ip_cfg, "public_ip_address_configuration", None) for ip_cfg in ip_configs) has_nic_nsg = bool(getattr(net_config, "network_security_group", None)) - has_subnet_nsg = any( - subnet_nsgs.get(getattr(getattr(ip_cfg, "subnet", None), "id", None), False) for ip_cfg in ip_configs - ) - if has_public_ip and not has_nic_nsg and not has_subnet_nsg: - parsed = azure_client.parse_resource_id(vmss_id) - findings.append( - { - "rule_id": RULE_ID, - "rule_name": RULE_NAME, - "severity": SEVERITY, - "category": CATEGORY, - "resource_id": vmss_id, - "resource_name": vmss_name, - "resource_type": "Microsoft.Compute/virtualMachineScaleSets", - "description": DESCRIPTION, - "remediation": REMEDIATION, - "playbook": PLAYBOOK, - "frameworks": FRAMEWORKS, - "metadata": { - "resource_group": parsed.get("resource_group", ""), - "location": getattr(vmss, "location", ""), - "network_interface_configuration": getattr(net_config, "name", ""), - }, - } - ) - break # one finding per VMSS is sufficient + if not has_public_ip or has_nic_nsg: + continue + + subnet_statuses = [_subnet_nsg_status(subnet_nsgs, ip_cfg) for ip_cfg in 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) + 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": { + "resource_group": parsed.get("resource_group", ""), + "location": getattr(vmss, "location", ""), + "network_interface_configuration": getattr(net_config, "name", ""), + "determination": "non_compliant" if confirmed else "indeterminate", + }, + } + ) + break # one finding per VMSS is sufficient return findings diff --git a/tests/test_rules_compute.py b/tests/test_rules_compute.py index 6ba7ba62..3cbfb7f5 100644 --- a/tests/test_rules_compute.py +++ b/tests/test_rules_compute.py @@ -651,9 +651,15 @@ def test_cmp_006_compliant_no_public_ip_returns_no_findings(mock_azure, subscrip def test_cmp_006_noncompliant_public_ip_no_nsg_returns_one_finding(mock_azure, subscription_id): - """A network config with a public IP and no NSG must produce exactly one finding.""" - vmss = _vmss("vmss-exposed", [_net_config("nic-config", has_public_ip=True, has_nsg=False)]) + """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 = _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] @@ -663,6 +669,7 @@ def test_cmp_006_noncompliant_public_ip_no_nsg_returns_one_finding(mock_azure, s 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): @@ -705,7 +712,7 @@ def test_cmp_006_compliant_subnet_level_nsg_returns_no_findings(mock_azure, subs def test_cmp_006_noncompliant_no_nic_or_subnet_nsg_returns_one_finding(mock_azure, subscription_id): - """Neither a NIC-level nor a subnet-level NSG must still be flagged.""" + """Neither a NIC-level nor a resolved subnet-level NSG must still be flagged as confirmed.""" subnet_id = _subnet_id("vnet1", "subnet1") vmss = _vmss( "vmss-fully-exposed", @@ -715,4 +722,45 @@ def test_cmp_006_noncompliant_no_nic_or_subnet_nsg_returns_one_finding(mock_azur 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 - assert findings[0]["resource_name"] == "vmss-fully-exposed" + 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 = _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 = _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) == [] From c194f6eaa48018920c634af9daa29484a66b3620 Mon Sep 17 00:00:00 2001 From: Dipesh Ray Date: Mon, 31 Aug 2026 16:44:23 +0100 Subject: [PATCH 4/4] fix: resolve _subnet_id name collision from rebasing onto AZ-CMP-007 AZ-CMP-007 (merged into dev while this branch was in review) and this branch's subnet-NSG fix for AZ-CMP-006 each defined their own _subnet_id() helper with different signatures. Landing them in the same file after the rebase left two same-named functions, and the second definition silently shadowed the first, breaking the AZ-CMP-007 subnet-exposure test. Renamed this branch's helper to _vnet_subnet_id() to remove the collision. Signed-off-by: Dipesh Ray --- tests/test_rules_compute.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/test_rules_compute.py b/tests/test_rules_compute.py index 3cbfb7f5..f25cb7dc 100644 --- a/tests/test_rules_compute.py +++ b/tests/test_rules_compute.py @@ -609,7 +609,7 @@ def _net_config(name, has_public_ip, has_nsg, subnet_id=None): ) -def _subnet_id(vnet_name, subnet_name): +def _vnet_subnet_id(vnet_name, subnet_name): return ( f"/subscriptions/{_SUB}/resourceGroups/{_RG}/providers/Microsoft.Network/" f"virtualNetworks/{vnet_name}/subnets/{subnet_name}" @@ -653,7 +653,7 @@ def test_cmp_006_compliant_no_public_ip_returns_no_findings(mock_azure, subscrip 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 = _subnet_id("vnet1", "subnet1") + 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)], @@ -701,7 +701,7 @@ def test_cmp_006_compliant_subnet_level_nsg_returns_no_findings(mock_azure, subs interface configuration with no network_security_group of its own is still compliant if the subnet it deploys into has one. """ - subnet_id = _subnet_id("vnet1", "subnet1") + 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)], @@ -713,7 +713,7 @@ def test_cmp_006_compliant_subnet_level_nsg_returns_no_findings(mock_azure, subs 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 = _subnet_id("vnet1", "subnet1") + 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)], @@ -738,7 +738,7 @@ def test_cmp_006_unresolved_subnet_returns_indeterminate_not_confirmed(mock_azur 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 = _subnet_id("vnet1", "subnet1") + 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)], @@ -755,7 +755,7 @@ def test_cmp_006_unresolved_subnet_returns_indeterminate_not_confirmed(mock_azur 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 = _subnet_id("VNET1", "SUBNET1") + 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)],