Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions compliance/frameworks/cis_azure_benchmark.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
5 changes: 5 additions & 0 deletions compliance/frameworks/iso27001.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions compliance/frameworks/nist_csf.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions compliance/frameworks/soc2.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions playbooks/cli/fix_az_cmp_005.sh
Original file line number Diff line number Diff line change
@@ -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 <resource-group> <vm-name>
# Note: only supported on Gen2 VM sizes/images; the update requires a restart to take effect.

set -euo pipefail

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please use ${1:-} and ${2:-} like how it is present in fix_az_net_016.sh to avoid further bugs

RG="${1:-}"
VM="${2:-}"

if [ -z "$RG" ] || [ -z "$VM" ]; then
echo "Usage: $0 <resource-group> <vm-name>"
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."
126 changes: 126 additions & 0 deletions scanner/rules/az_cmp_005.py
Original file line number Diff line number Diff line change
@@ -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 <vm> "
"--resource-group <rg> --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
81 changes: 81 additions & 0 deletions tests/test_rules_compute.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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) == []
Loading