From b5067f96cedeff7592e93a8b23fe85389dd34135 Mon Sep 17 00:00:00 2001 From: Dipesh Ray Date: Fri, 14 Aug 2026 09:20:53 +0100 Subject: [PATCH 1/6] 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 9adc0520..b9465349 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -159,6 +159,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 e328198b..c24d52bb 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -154,6 +154,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 ae145cf9..303d6c20 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -159,6 +159,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 22ae99ac..97f0a6f4 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -174,6 +174,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 a1ac9997..4dfd8235 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 f9af2702..47e8df77 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] = [] @@ -199,6 +200,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 @@ -226,6 +231,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 c89191be..485b8422 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 ce6867f58500165fd2242bccacf1201b4372593a Mon Sep 17 00:00:00 2001 From: Dipesh Ray Date: Fri, 14 Aug 2026 23:24:15 +0100 Subject: [PATCH 2/6] 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 b9465349..792007c6 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -159,8 +159,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 c24d52bb..214327ef 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -154,7 +154,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 303d6c20..a5476ef5 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -159,7 +159,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 97f0a6f4..22ff682f 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -174,7 +174,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 0b007d5de904f65039e907ff1ec06f27f920b1e2 Mon Sep 17 00:00:00 2001 From: Dipesh Ray Date: Wed, 19 Aug 2026 11:39:47 +0100 Subject: [PATCH 3/6] 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 753bb893193b5c101ef743b67169a456b1281bbe Mon Sep 17 00:00:00 2001 From: Dipesh Ray Date: Mon, 31 Aug 2026 16:44:23 +0100 Subject: [PATCH 4/6] 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)], From 392fe537189ae4a3fd10163e8a4de759ac704a84 Mon Sep 17 00:00:00 2001 From: Dipesh Ray Date: Tue, 1 Sep 2026 08:52:02 +0100 Subject: [PATCH 5/6] fix: three real gaps in AZ-CMP-006 flagged by review - Only ip_configurations that actually carry a public IP are checked against subnet NSG state. A non-primary ip_config with no public IP is not internet-reachable, so its subnet must not be able to force a finding on an otherwise-compliant net_config. - Removed the break after the first non-compliant net_config. A VMSS with several exposed configs now gets one finding per config instead of silently hiding every attack surface after the first. - Indeterminate findings now carry vnets_collected in metadata, so a persistent zero across many findings is visible as a VNet-collection problem instead of reading as an ordinary per-subnet indeterminate result. Signed-off-by: Dipesh Ray --- scanner/rules/az_cmp_006.py | 43 ++++++++++++++++------- tests/test_rules_compute.py | 69 ++++++++++++++++++++++++++++++++++--- 2 files changed, 95 insertions(+), 17 deletions(-) diff --git a/scanner/rules/az_cmp_006.py b/scanner/rules/az_cmp_006.py index 50286ae4..3d69355b 100644 --- a/scanner/rules/az_cmp_006.py +++ b/scanner/rules/az_cmp_006.py @@ -45,7 +45,7 @@ logger = logging.getLogger(__name__) -def _subnet_nsg_map(azure_client: Any) -> Dict[str, bool]: +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 @@ -56,7 +56,7 @@ def _subnet_nsg_map(azure_client: Any) -> Dict[str, bool]: lowercase to avoid missing a match on casing differences alone. """ subnet_nsgs: Dict[str, bool] = {} - for vnet in azure_client.get_virtual_networks(): + for vnet in vnets: for subnet in getattr(vnet, "subnets", []) or []: subnet_id = getattr(subnet, "id", None) if subnet_id: @@ -77,7 +77,14 @@ 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]] = [] - subnet_nsgs = _subnet_nsg_map(azure_client) + 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", "") @@ -93,13 +100,19 @@ 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: 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) + # 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 has_public_ip or has_nic_nsg: + if not public_ip_configs or has_nic_nsg: continue - subnet_statuses = [_subnet_nsg_status(subnet_nsgs, ip_cfg) for ip_cfg in ip_configs] + 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 @@ -108,6 +121,14 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: 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, @@ -121,14 +142,10 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: "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", - }, + "metadata": metadata, } ) - break # one finding per VMSS is sufficient + # 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/test_rules_compute.py b/tests/test_rules_compute.py index f25cb7dc..562f86b7 100644 --- a/tests/test_rules_compute.py +++ b/tests/test_rules_compute.py @@ -597,14 +597,20 @@ def test_cmp_007_subnet_level_nsg_exposure_is_flagged(mock_azure, subscription_i # ── AZ-CMP-006: VMSS public IP with no NSG on the network profile ────────── -def _net_config(name, has_public_ip, has_nsg, subnet_id=None): - ip_cfg = make_resource( +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_cfg], + ip_configurations=ip_configs, network_security_group=make_resource(id="nsg1") if has_nsg else None, ) @@ -680,7 +686,7 @@ def test_cmp_006_missing_network_profile_returns_no_findings(mock_azure, subscri 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).""" + """A compliant config alongside one bad config produces exactly one finding, for the bad one.""" vmss = _vmss( "vmss-mixed", [ @@ -692,6 +698,61 @@ def test_cmp_006_one_bad_config_among_several_returns_one_finding(mock_azure, su 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): From cbf57d5394a07b7218c5a501a4af7017fee4ce0c Mon Sep 17 00:00:00 2001 From: Dipesh Ray Date: Thu, 10 Sep 2026 08:12:21 +0100 Subject: [PATCH 6/6] fix: require all public ip_configs protected, not any, in AZ-CMP-006 A net_config can carry several public ip_configs on different subnets. 'any(status is True for status in subnet_statuses)' treated the whole net_config as compliant if any one of them was subnet -protected, even when another public ip_config on the same net_config was still exposed on an unprotected subnet. Compliance now requires every public ip_config to be protected. A confirmed-unprotected ip_config makes the net_config non-compliant regardless of whether another ip_config on it is merely unresolved, matching the existing confirmed-beats-indeterminate severity model. Added a regression test: two public ip_configs on different subnets, one protected and one not, expecting one confirmed HIGH finding. Signed-off-by: Dipesh Ray --- scanner/rules/az_cmp_006.py | 18 ++++++++++++------ tests/test_rules_compute.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/scanner/rules/az_cmp_006.py b/scanner/rules/az_cmp_006.py index 3d69355b..4468a3ce 100644 --- a/scanner/rules/az_cmp_006.py +++ b/scanner/rules/az_cmp_006.py @@ -112,13 +112,19 @@ def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: if not public_ip_configs or has_nic_nsg: continue + # A net_config can carry several public ip_configs on different + # subnets. It is only compliant if EVERY one of them is + # protected; one protected subnet must not wave through another + # public ip_config on the same net_config that is still exposed. 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 + if all(status is True for status in subnet_statuses): + continue # every public ip_config is protected, compliant + + # A confirmed-unprotected ip_config is a real exposure regardless + # of whether another ip_config on the same net_config is merely + # unresolved; only fall back to indeterminate when nothing is + # confirmed open. + confirmed = any(status is False for status in subnet_statuses) parsed = azure_client.parse_resource_id(vmss_id) metadata = { diff --git a/tests/test_rules_compute.py b/tests/test_rules_compute.py index 562f86b7..8edcc5ce 100644 --- a/tests/test_rules_compute.py +++ b/tests/test_rules_compute.py @@ -738,6 +738,34 @@ def test_cmp_006_non_primary_ip_config_without_public_ip_is_not_checked(mock_azu assert az_cmp_006.scan(mock_azure, subscription_id) == [] +def test_cmp_006_second_public_ip_config_not_protected_by_first_subnet_nsg(mock_azure, subscription_id): + """A net_config with two public ip_configs on different subnets must not be waved through + as compliant just because one of them is subnet-protected (regression: `any(status is True + for status in subnet_statuses)` treated the whole net_config as compliant even when a second + public ip_config on an unprotected subnet was still exposed).""" + subnet_protected = _vnet_subnet_id("vnet1", "subnet-a") + subnet_exposed = _vnet_subnet_id("vnet1", "subnet-b") + net_config = _net_config( + "nic-config", + has_public_ip=True, + has_nsg=False, + subnet_id=subnet_protected, + extra_ip_configs=[_ip_config(has_public_ip=True, subnet_id=subnet_exposed)], + ) + vmss = _vmss("vmss-partial-subnet-protection", [net_config]) + mock_azure.set_virtual_machine_scale_sets([vmss]) + mock_azure.set_virtual_networks( + [ + _vnet_with_subnet(subnet_protected, has_nsg=True), + _vnet_with_subnet(subnet_exposed, has_nsg=False), + ] + ) + findings = az_cmp_006.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["severity"] == "HIGH" + assert findings[0]["metadata"]["determination"] == "non_compliant" + + 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