--- # ══════════════════════════════════════════════════════════════════════════════ # IEC 62443-3-3 SL2 — Hyper-V Cluster Node Compliance # # Target type : Windows Server Hyper-V hosts (standalone or cluster nodes) # Connection : WinRM — same as windows_server.yml # Collections : ansible.windows # Python pkg : pywinrm # # Inventory group : hyperv_hosts (see assets.yml) # # Run: # ansible-playbook -i assets.yml methodologies/ansible/playbooks/examples/hyperv_cluster.yml # # This playbook runs two layers of checks: # Host layer — Windows Server hardening (same as windows_server.yml) # Hyper-V layer — VM configuration, vSwitch isolation, secure boot # # PowerShell modules used: # Hyper-V — built-in on all Hyper-V hosts # FailoverClusters — for cluster-aware checks (if applicable) # ══════════════════════════════════════════════════════════════════════════════ - name: "IEC 62443-3-3 SL2 — Hyper-V Cluster Node Compliance" hosts: hyperv_hosts gather_facts: yes vars: report_dir: "../../reports" pre_tasks: - name: "Ensure report directory exists" ansible.builtin.file: path: "{{ report_dir }}" state: directory mode: "0755" delegate_to: localhost run_once: true tasks: # ── FR3 · SR 3.4: VMs using Generation 2 (UEFI + Secure Boot) ──────────── # Gen 2 VMs support UEFI, Secure Boot, and vTPM. Gen 1 cannot. - block: - name: "Gather: VM list with generation and Secure Boot state" ansible.windows.win_shell: | @(Get-VM | Where-Object { $_.State -ne 'Off' -or $true } | ForEach-Object { $sb = $false try { $sb = (Get-VMFirmware -VM $_ -ErrorAction Stop).SecureBootEnabled } catch {} [PSCustomObject]@{ Name = $_.Name Generation = $_.Generation State = $_.State.ToString() SecureBoot = $sb } }) | ConvertTo-Json -AsArray -Compress register: _vm_list - name: "Evaluate: HV-SI-01 — All VMs use Generation 2 with Secure Boot" ansible.builtin.set_fact: _vms: "{{ _vm_list.stdout | from_json }}" - name: "Evaluate: HV-SI-01 — record result" ansible.builtin.set_fact: test_results: "{{ test_results | default([]) + [{ 'test_id': 'HV-SI-01', 'category': 'FR3 — System Integrity', 'requirement': 'SR 3.4 — Software and Information Integrity', 'description': 'All VMs shall use Generation 2 (UEFI) with Secure Boot enabled', 'passed': ( _vms | length > 0 and (_vms | rejectattr('Generation', 'equalto', 2) | list | length == 0) and (_vms | selectattr('SecureBoot', 'equalto', false) | list | length == 0) ), 'expected': 'All VMs: Generation=2, SecureBoot=true', 'actual': 'Gen1: ' + (_vms | rejectattr('Generation', 'equalto', 2) | map(attribute='Name') | join(', ') | default('none', true)) + ' | SecureBoot off: ' + (_vms | selectattr('SecureBoot', 'equalto', false) | map(attribute='Name') | join(', ') | default('none', true)), 'severity': 'high', 'remediation': 'Convert Gen1 VMs to Gen2 at next maintenance window; Set-VMFirmware -EnableSecureBoot On' }] }}" ignore_errors: yes # ── FR5 · SR 5.2: Virtual switch types — no external switch for ICS VMs ── - block: - name: "Gather: Virtual switch list with type and bound adapters" ansible.windows.win_shell: | @(Get-VMSwitch | Select-Object Name, SwitchType, AllowManagementOS, @{N='NetAdapterNames';E={ ($_ | Get-VMSwitchTeam -ErrorAction SilentlyContinue).NetAdapterNames -join ',' }}) | ConvertTo-Json -AsArray -Compress register: _vswitches - name: "Evaluate: HV-RDF-01 — No ICS VM on switch shared with management OS" ansible.builtin.set_fact: _sw_json: "{{ _vswitches.stdout | from_json }}" - name: "Evaluate: HV-RDF-01 — External switches with AllowManagementOS=true" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'HV-RDF-01', 'category': 'FR5 — Restricted Data Flow', 'requirement': 'SR 5.2 — Zone Boundary Protection', 'description': 'External vSwitches shared with the management OS shall not carry ICS VM traffic', 'passed': 'review', 'expected': 'ICS VMs connected to Private or Internal switches only', 'actual': 'External switches with AllowManagementOS=true: ' + (_sw_json | selectattr('SwitchType', 'equalto', 'External') | selectattr('AllowManagementOS') | map(attribute='Name') | join(', ') | default('none', true)), 'severity': 'critical', 'remediation': 'Assign ICS VMs to a dedicated Internal vSwitch; disable AllowManagementOS on ICS switches' }] }}" ignore_errors: yes # ── FR2 · SR 2.8: Hyper-V audit logging — VM connect activity ──────────── - block: - name: "Gather: Hyper-V audit log entries (last 50 events)" ansible.windows.win_shell: | $events = Get-WinEvent -LogName 'Microsoft-Windows-Hyper-V-VMMS-Admin' ` -MaxEvents 50 -ErrorAction SilentlyContinue $count = if ($events) { $events.Count } else { 0 } [PSCustomObject]@{ LogExists = [bool](Get-WinEvent -ListLog 'Microsoft-Windows-Hyper-V-VMMS-Admin' -ErrorAction SilentlyContinue) EventCount = $count } | ConvertTo-Json -Compress register: _hv_audit - name: "Evaluate: HV-UC-01 — Hyper-V admin event log active" ansible.builtin.set_fact: _hv_audit_json: "{{ _hv_audit.stdout | from_json }}" - name: "Evaluate: HV-UC-01 — record result" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'HV-UC-01', 'category': 'FR2 — Use Control', 'requirement': 'SR 2.8 — Auditable Events', 'description': 'Hyper-V VMMS Admin event log shall be present and collecting events', 'passed': (_hv_audit_json.LogExists | bool), 'expected': 'Microsoft-Windows-Hyper-V-VMMS-Admin log exists', 'actual': 'LogExists=' + (_hv_audit_json.LogExists | string) + ', RecentEvents=' + (_hv_audit_json.EventCount | string), 'severity': 'medium', 'remediation': 'Enable the Hyper-V VMMS Admin event log via Event Viewer or wevtutil' }] }}" ignore_errors: yes # ── FR3 · SR 3.2: VM integration services version ───────────────────────── # Outdated integration services can expose VMs to vulnerabilities. - block: - name: "Gather: VM integration services version summary" ansible.windows.win_shell: | @(Get-VM | Where-Object { $_.State -eq 'Running' } | ForEach-Object { $vmics = Get-VMIntegrationService -VM $_ | Where-Object { -not $_.Enabled } [PSCustomObject]@{ VMName = $_.Name DisabledServices = ($vmics | Select-Object -ExpandProperty Name) -join ', ' DisabledCount = $vmics.Count } }) | ConvertTo-Json -AsArray -Compress register: _ics_versions - name: "Evaluate: HV-SI-02 — All critical integration services enabled" ansible.builtin.set_fact: _ics_json: "{{ _ics_versions.stdout | from_json }}" - name: "Evaluate: HV-SI-02 — record result" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'HV-SI-02', 'category': 'FR3 — System Integrity', 'requirement': 'SR 3.2 — Malicious Code Protection', 'description': 'All running VMs shall have Hyper-V Integration Services fully enabled', 'passed': ( _ics_json | selectattr('DisabledCount', 'greaterthan', 0) | list | length == 0 ), 'expected': 'No disabled integration services on any running VM', 'actual': ( 'VMs with disabled services: ' + (_ics_json | selectattr('DisabledCount', 'greaterthan', 0) | map(attribute='VMName') | join(', ') | default('none', true)) ), 'severity': 'medium', 'remediation': 'Enable-VMIntegrationService -VMName -Name "Guest Service Interface"' }] }}" ignore_errors: yes # ── HITL · FR5 · SR 5.2: Physical host network cable review ────────────── # Confirm management NIC and ICS NIC are physically separate cables/switches. - block: - name: "Gather: [HITL] Physical network adapter list" ansible.windows.win_shell: | @(Get-NetAdapter | Where-Object { $_.Status -ne 'Not Present' } | Select-Object Name, InterfaceDescription, Status, LinkSpeed, MacAddress) | ConvertTo-Json -AsArray -Compress register: _net_adapters - name: "Display: [HITL] HV-RDF-HITL-01 — Physical NIC segregation" ansible.builtin.debug: msg: | ══════════════════════════════════════════════════════════════ MANUAL REVIEW REQUIRED · HV-RDF-HITL-01 · {{ inventory_hostname }} ══════════════════════════════════════════════════════════════ Requirement : SR 5.2 — Zone Boundary Protection Check : Physical NICs for ICS vSwitch are on a separate physical switch from the management/IT network Detected network adapters ───────────────────────── {% for nic in _net_adapters.stdout | from_json %} {{ nic.Name }} | {{ nic.InterfaceDescription }} | {{ nic.Status }} | {{ nic.LinkSpeed }} {% endfor %} Verify physically: - Which adapters are bound to the ICS vSwitch? - Do those cables go to a different physical switch than management NICs? ══════════════════════════════════════════════════════════════ - name: "Prompt: HV-RDF-HITL-01 — verdict for {{ inventory_hostname }}" ansible.builtin.pause: prompt: | Are ICS vSwitch uplink NICs physically separated (different switch) from management NICs? Enter verdict [pass / fail / skip]: register: _hitl_nic_verdict delegate_to: localhost - name: "Prompt: HV-RDF-HITL-01 — notes on failure" ansible.builtin.pause: prompt: "Describe the cabling gap (e.g. 'ICS and management share same ToR switch'):" register: _hitl_nic_notes delegate_to: localhost when: _hitl_nic_verdict.user_input | lower | trim in ['fail', 'f'] - name: "Evaluate: HV-RDF-HITL-01" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'HV-RDF-HITL-01', 'category': 'FR5 — Restricted Data Flow', 'requirement': 'SR 5.2 — Zone Boundary Protection', 'description': 'ICS vSwitch uplink NICs shall be physically separate from management network NICs', 'passed': ( 'skipped' if (_hitl_nic_verdict.user_input | lower | trim in ['skip', 's', '']) else (_hitl_nic_verdict.user_input | lower | trim in ['pass', 'p']) ), 'expected': 'Separate physical cables and switches for ICS and management traffic', 'actual': 'Adapters found: ' + (_net_adapters.stdout | from_json | map(attribute='Name') | join(', ')), 'severity': 'critical', 'remediation': 'Install dedicated NICs for ICS vSwitch and connect to isolated physical switch', 'reviewer': ansible_user_id, 'notes': (_hitl_nic_notes.user_input | trim) if _hitl_nic_notes is defined else '' }] }}" ignore_errors: yes # ── Generate report ──────────────────────────────────────────────────────── - name: "Generate compliance report" ansible.builtin.include_tasks: ../library/report.yml