Files
ansible-testing/methodologies/ansible/playbooks/examples/hyperv_cluster.yml
T

262 lines
14 KiB
YAML

---
# ══════════════════════════════════════════════════════════════════════════════
# 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 <VMName> -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> -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