diff --git a/compliance/frameworks/cis_azure_benchmark.json b/compliance/frameworks/cis_azure_benchmark.json index 156c38ad..23bb6819 100644 --- a/compliance/frameworks/cis_azure_benchmark.json +++ b/compliance/frameworks/cis_azure_benchmark.json @@ -138,6 +138,11 @@ "control_name": "Just-In-Time (JIT) VM access - Defender for Cloud recommendation, no numbered CIS Azure Foundations 2.0.0 control", "description": "CIS Microsoft Azure Foundations Benchmark 2.0.0 has no numbered recommendation for Just-In-Time VM access (it is a Microsoft Defender for Cloud recommendation), so under the repository's one-CIS-ID-per-rule convention this rule is not assigned a fabricated control id. It is mapped under NIST CSF PR.AC-3, ISO 27001 A.13.1.1, and SOC 2 CC6.6 instead." }, + "AZ-CMP-005": { + "control_id": "N/A-CMP-005", + "control_name": "Trusted Launch (Secure Boot and vTPM) — no dedicated CIS Azure Foundations 2.0.0 recommendation", + "description": "CIS Microsoft Azure Foundations Benchmark 2.0.0 has no numbered recommendation for VM Trusted Launch, so under the repository's one-CIS-ID-per-rule convention this rule is not assigned a fabricated control id. It enforces Secure Boot and vTPM on Generation 2 VMs as boot-integrity hardening and is mapped under NIST CSF PR.DS-6, ISO 27001 A.12.5.1, and SOC 2 CC6.8 instead." + }, "AZ-KV-001": { "control_id": "N/A-KV-001", "control_name": "Key Vault soft-delete baseline (covered by the repository's CIS 8.5 purge-protection rule)", diff --git a/compliance/frameworks/iso27001.json b/compliance/frameworks/iso27001.json index 2f2ee5c6..ee030ef5 100644 --- a/compliance/frameworks/iso27001.json +++ b/compliance/frameworks/iso27001.json @@ -133,6 +133,11 @@ "control_name": "Network controls", "description": "A VM has management ports (SSH/RDP) open to the internet with no Just-In-Time VM access policy covering them. A.13.1.1 requires network controls that manage and protect access to systems. JIT limits management-port exposure to approved, time-boxed windows." }, + "AZ-CMP-005": { + "control_id": "A.12.5.1", + "control_name": "Installation of software on operational systems", + "description": "A Generation 2 virtual machine does not have Trusted Launch (Secure Boot and vTPM) fully enabled. A.12.5.1 requires procedures to control the installation of software on operational systems. Secure Boot enforces that only signed, trusted boot software executes, preventing unsigned or malicious boot-level code from loading beneath the operating system." + }, "AZ-CMP-003": { "control_id": "A.12.2.1", "control_name": "Controls against malware", diff --git a/compliance/frameworks/nist_csf.json b/compliance/frameworks/nist_csf.json index 8fa9992b..36b06f0a 100644 --- a/compliance/frameworks/nist_csf.json +++ b/compliance/frameworks/nist_csf.json @@ -138,6 +138,11 @@ "control_name": "Remote access is managed", "description": "A VM has management ports (SSH/RDP) open to the internet with no Just-In-Time VM access policy covering them. PR.AC-3 requires that remote access is managed. JIT restricts management-port access to approved, time-boxed requests instead of leaving the ports standing open." }, + "AZ-CMP-005": { + "control_id": "PR.DS-6", + "control_name": "Integrity checking mechanisms are used to verify software, firmware, and information integrity", + "description": "A Generation 2 virtual machine does not have Trusted Launch (Secure Boot and vTPM) fully enabled. PR.DS-6 requires integrity checking mechanisms to verify software and firmware integrity. Secure Boot ensures only signed boot components run and the vTPM measures boot integrity, protecting against boot-level tampering, bootkits, and rootkits that persist beneath the OS." + }, "AZ-KV-001": { "control_id": "PR.IP-4", "control_name": "Backups of information are conducted, maintained, and tested", diff --git a/compliance/frameworks/soc2.json b/compliance/frameworks/soc2.json index 4e312355..4aeecf09 100644 --- a/compliance/frameworks/soc2.json +++ b/compliance/frameworks/soc2.json @@ -153,6 +153,11 @@ "control_name": "Restricts Access from Outside the Network Boundary", "description": "A VM has management ports (SSH/RDP) open to the internet with no Just-In-Time VM access policy covering them. CC6.6 requires that access from outside the network boundary is restricted. JIT opens management ports only for approved, time-boxed requests instead of continuously." }, + "AZ-CMP-005": { + "control_id": "CC6.8", + "control_name": "Prevents or Detects Unauthorized Software", + "description": "A Generation 2 virtual machine does not have Trusted Launch (Secure Boot and vTPM) fully enabled. CC6.8 requires controls that prevent or detect the introduction of unauthorized software. Secure Boot prevents unsigned boot components from executing and the vTPM attests boot integrity, blocking boot-level malware from loading and persisting beneath the OS." + }, "AZ-KV-001": { "control_id": "A1.2", "control_name": "Environmental Threats and Recovery", diff --git a/playbooks/cli/fix_az_cmp_005.sh b/playbooks/cli/fix_az_cmp_005.sh new file mode 100644 index 00000000..a032f24d --- /dev/null +++ b/playbooks/cli/fix_az_cmp_005.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# fix_az_cmp_005.sh +# Enables Trusted Launch (Secure Boot + vTPM) on a Generation 2 VM +# Usage: ./fix_az_cmp_005.sh +# Note: only supported on Gen2 VM sizes/images; the update requires a restart to take effect. + +set -euo pipefail + +RG="${1:-}" +VM="${2:-}" + +if [ -z "$RG" ] || [ -z "$VM" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "Enabling Trusted Launch (Secure Boot + vTPM) on VM $VM..." + +az vm update \ + --resource-group "$RG" \ + --name "$VM" \ + --security-type TrustedLaunch \ + --enable-secure-boot true \ + --enable-vtpm true + +echo "Done. Trusted Launch enabled on $VM. A restart may be required for it to take effect." diff --git a/scanner/rules/az_cmp_005.py b/scanner/rules/az_cmp_005.py new file mode 100644 index 00000000..26963429 --- /dev/null +++ b/scanner/rules/az_cmp_005.py @@ -0,0 +1,126 @@ +"""AZ-CMP-005: Generation 2 VM without Trusted Launch (Secure Boot and vTPM) enabled.""" + +import logging +from typing import Any, Dict, List, Optional + +RULE_ID = "AZ-CMP-005" +RULE_NAME = "VM Without Trusted Launch (Secure Boot and vTPM) Enabled" +SEVERITY = "MEDIUM" +CATEGORY = "Compute" +FRAMEWORKS = { + "CIS": "N/A-CMP-005", + "NIST": "PR.DS-6", + "ISO27001": "A.12.5.1", + "SOC2": "CC6.8", +} +DESCRIPTION = ( + "A Generation 2 virtual machine does not have Trusted Launch fully enabled " + "(security type TrustedLaunch with both Secure Boot and vTPM turned on). " + "Without Secure Boot and a virtual TPM, unsigned or malicious code can run " + "during boot and persist beneath the OS, evading OS-level antimalware and EDR. " + "Generation 1 VMs do not support Trusted Launch and are not flagged." +) +REMEDIATION = ( + "Enable Trusted Launch on the VM: set the security type to TrustedLaunch and " + "turn on Secure Boot and vTPM, e.g. `az vm update --name " + "--resource-group --security-type TrustedLaunch --enable-secure-boot true " + "--enable-vtpm true` (requires a restart; only supported on Gen2 VM sizes/images)." +) +PLAYBOOK = "playbooks/cli/fix_az_cmp_005.sh" + +logger = logging.getLogger(__name__) + +_TRUSTED_LAUNCH = "TrustedLaunch" +_CONFIDENTIAL_VM = "ConfidentialVM" + + +def _trusted_launch_fully_enabled(security_profile: Any) -> bool: + """True only when security type is TrustedLaunch and Secure Boot and vTPM are both on.""" + if security_profile is None: + return False + if getattr(security_profile, "security_type", None) != _TRUSTED_LAUNCH: + return False + uefi = getattr(security_profile, "uefi_settings", None) + if uefi is None: + return False + return getattr(uefi, "secure_boot_enabled", None) is True and getattr(uefi, "v_tpm_enabled", None) is True + + +def _os_disk_generation(azure_client: Any, vm: Any) -> Optional[str]: + """Resolve the VM's OS-disk Hyper-V generation ('V1'/'V2'), or None if undeterminable. + + A VM's list_all() representation does not carry its Hyper-V generation, but the + underlying managed OS disk does. Returns None when the disk id is missing or the + Disk resource cannot be read (permissions/deletion); callers must treat None as + 'generation unknown', never as Gen2. + """ + storage_profile = getattr(vm, "storage_profile", None) + os_disk = getattr(storage_profile, "os_disk", None) if storage_profile else None + managed_disk = getattr(os_disk, "managed_disk", None) if os_disk else None + disk_id = getattr(managed_disk, "id", "") if managed_disk else "" + if not disk_id: + return None + disk = azure_client.get_disk(disk_id) + if disk is None: + return None + return getattr(disk, "hyper_v_generation", None) or None + + +def scan(azure_client: Any, subscription_id: str) -> List[Dict[str, Any]]: + """Flag Generation 2 VMs that do not have Trusted Launch fully enabled. + + Generation 1 VMs (which cannot use Trusted Launch) and VMs whose generation + cannot be confirmed as Gen2 are treated as NOT_APPLICABLE and are not flagged, + so the rule never raises a false finding against hardware that could not satisfy + it. A readable Gen1 OS disk reports 'V1'; only a confirmed 'V2' (or a security + type already declared as TrustedLaunch, which is itself Gen2-only) is flagged. + """ + findings: List[Dict[str, Any]] = [] + + for vm in azure_client.get_virtual_machines(): + security_profile = getattr(vm, "security_profile", None) + + if _trusted_launch_fully_enabled(security_profile): + continue # compliant + + security_type = getattr(security_profile, "security_type", None) if security_profile else None + if security_type == _CONFIDENTIAL_VM: + # Confidential VMs provide Secure Boot and vTPM by construction; out of scope. + continue + + if security_type == _TRUSTED_LAUNCH: + # Security type is TrustedLaunch (hence definitely Gen2) but Secure Boot + # and/or vTPM is not on — a real, confirmable misconfiguration, no disk + # lookup needed. + generation: Optional[str] = "V2" + else: + generation = _os_disk_generation(azure_client, vm) + if generation != "V2": + # Gen1 (NOT_APPLICABLE) or generation unknown — do not raise a finding. + continue + + uefi = getattr(security_profile, "uefi_settings", None) if security_profile else None + parsed = azure_client.parse_resource_id(getattr(vm, "id", "")) + findings.append( + { + "rule_id": RULE_ID, + "rule_name": RULE_NAME, + "severity": SEVERITY, + "category": CATEGORY, + "resource_id": getattr(vm, "id", ""), + "resource_name": getattr(vm, "name", None) or parsed.get("name", ""), + "resource_type": "Microsoft.Compute/virtualMachines", + "description": DESCRIPTION, + "remediation": REMEDIATION, + "playbook": PLAYBOOK, + "frameworks": FRAMEWORKS, + "metadata": { + "security_type": security_type or "None", + "secure_boot_enabled": getattr(uefi, "secure_boot_enabled", None) if uefi else None, + "v_tpm_enabled": getattr(uefi, "v_tpm_enabled", None) if uefi else None, + "hyper_v_generation": generation, + }, + } + ) + + return findings diff --git a/tests/test_rules_compute.py b/tests/test_rules_compute.py index 751bcb77..80f6aa07 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 @@ -587,3 +588,83 @@ 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: VM without Trusted Launch (Secure Boot + vTPM) ─────────────── +# +# A VM's list_all() representation does not carry its Hyper-V generation, so the +# rule resolves the OS disk's hyper_v_generation to tell Gen2 (Trusted-Launch +# capable) apart from Gen1 (NOT_APPLICABLE). These fixtures mirror that: a +# security_profile carrying security_type/uefi_settings on the VM, and an OS disk +# whose hyper_v_generation is "V1"/"V2" resolved via mock_azure.set_disk(). + + +def _security_profile(security_type="TrustedLaunch", secure_boot=True, vtpm=True, with_uefi=True): + uefi = make_resource(secure_boot_enabled=secure_boot, v_tpm_enabled=vtpm) if with_uefi else None + return make_resource(security_type=security_type, uefi_settings=uefi) + + +def _vm_with_osdisk(name, disk_name, security_profile=None): + return make_resource( + id=_vm_id(name), + name=name, + security_profile=security_profile, + storage_profile=make_resource(os_disk=make_resource(managed_disk=_managed_disk(disk_name)), data_disks=[]), + ) + + +def test_cmp_005_compliant_trusted_launch_returns_no_findings(mock_azure, subscription_id): + """A Gen2 VM with TrustedLaunch + Secure Boot + vTPM is compliant.""" + vm = _vm_with_osdisk("vm-tl", "disk-tl", _security_profile()) + mock_azure.set_virtual_machines([vm]) + mock_azure.set_disk(_disk_id("disk-tl"), make_resource(hyper_v_generation="V2")) + assert az_cmp_005.scan(mock_azure, subscription_id) == [] + + +def test_cmp_005_noncompliant_gen2_without_trusted_launch_returns_one_finding(mock_azure, subscription_id): + """A Gen2 VM (OS disk hyper_v_generation V2) with no security profile must be flagged.""" + vm = _vm_with_osdisk("vm-gen2", "disk-gen2", security_profile=None) + mock_azure.set_virtual_machines([vm]) + mock_azure.set_disk(_disk_id("disk-gen2"), make_resource(hyper_v_generation="V2")) + 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"] == "MEDIUM" + assert f["resource_name"] == "vm-gen2" + assert f["metadata"]["hyper_v_generation"] == "V2" + + +def test_cmp_005_trusted_launch_declared_but_vtpm_off_flags_without_disk_lookup(mock_azure, subscription_id): + """security_type=TrustedLaunch is Gen2-only, so a vTPM-off VM is a confirmable finding + even when the OS disk cannot be resolved (no set_disk call here).""" + vm = _vm_with_osdisk("vm-partial", "disk-partial", _security_profile(secure_boot=True, vtpm=False)) + mock_azure.set_virtual_machines([vm]) + findings = az_cmp_005.scan(mock_azure, subscription_id) + assert len(findings) == 1 + assert findings[0]["metadata"]["v_tpm_enabled"] is False + assert findings[0]["metadata"]["secure_boot_enabled"] is True + + +def test_cmp_005_gen1_vm_is_not_applicable_returns_no_findings(mock_azure, subscription_id): + """A Gen1 VM (OS disk hyper_v_generation V1) cannot use Trusted Launch and must not be flagged.""" + vm = _vm_with_osdisk("vm-gen1", "disk-gen1", security_profile=None) + mock_azure.set_virtual_machines([vm]) + mock_azure.set_disk(_disk_id("disk-gen1"), make_resource(hyper_v_generation="V1")) + assert az_cmp_005.scan(mock_azure, subscription_id) == [] + + +def test_cmp_005_unknown_generation_is_not_flagged(mock_azure, subscription_id): + """When the OS disk cannot be read (generation unknown) and no Trusted Launch is declared, + the VM must not be flagged — an unreadable disk could be Gen1, which is NOT_APPLICABLE.""" + vm = _vm_with_osdisk("vm-unknown", "disk-missing", security_profile=None) + mock_azure.set_virtual_machines([vm]) + # No set_disk call: get_disk() returns None. + assert az_cmp_005.scan(mock_azure, subscription_id) == [] + + +def test_cmp_005_confidential_vm_is_out_of_scope_returns_no_findings(mock_azure, subscription_id): + """Confidential VMs provide Secure Boot and vTPM by construction and are not flagged.""" + vm = _vm_with_osdisk("vm-cvm", "disk-cvm", _security_profile(security_type="ConfidentialVM")) + mock_azure.set_virtual_machines([vm]) + mock_azure.set_disk(_disk_id("disk-cvm"), make_resource(hyper_v_generation="V2")) + assert az_cmp_005.scan(mock_azure, subscription_id) == []