--- # ══════════════════════════════════════════════════════════════════════════════ # IEC 62443-3-3 SL2 — VMware vSphere / ESXi Compliance # # Target type : VMware ESXi hosts managed by vCenter # Connection : vSphere REST/SOAP API — all tasks run on the Ansible control # node (delegate_to: localhost) and talk to vCenter. # No SSH to ESXi hosts is required or used. # Collections : community.vmware (installed in ansible-node image) # Python pkg : pyvmomi (installed in ansible-node image) # # Inventory group : vmware_esxi (see assets.yml) # inventory_hostname = ESXi FQDN as known to vCenter # vcenter_hostname = group var pointing to the vCenter appliance # vcenter_username = audit@vsphere.local (read-only role sufficient) # vcenter_password = from Ansible Vault # # Run: # ansible-playbook -i assets.yml methodologies/ansible/playbooks/examples/vmware_vsphere.yml # # Read-only vCenter role needed (minimum permissions): # Host → Configuration → Security Profile → View # Host → Configuration → Advanced Settings → View # Global → Settings → View # # Note on gather_facts: # gather_facts is disabled because Ansible cannot SSH into ESXi. # A setup task on localhost provides ansible_date_time and ansible_user_id # for the report metadata. # ══════════════════════════════════════════════════════════════════════════════ - name: "IEC 62443-3-3 SL2 — VMware vSphere ESXi Compliance" hosts: vmware_esxi gather_facts: no vars: report_dir: "../../reports" pre_tasks: - name: "Gather local facts for report timestamp and user" ansible.builtin.setup: gather_subset: - date_time - user_id delegate_to: localhost run_once: true - name: "Ensure report directory exists" ansible.builtin.file: path: "{{ report_dir }}" state: directory mode: "0755" delegate_to: localhost run_once: true tasks: # ── FR1 · SR 1.1: ESXi lockdown mode ────────────────────────────────────── # Lockdown mode disables direct API access to ESXi; all management must # go through vCenter. 'normal' = lockdown, 'strict' = lockdown + DCUI off. - block: - name: "Gather: ESXi lockdown mode" community.vmware.vmware_host_lockdown_info: hostname: "{{ vcenter_hostname }}" username: "{{ vcenter_username }}" password: "{{ vcenter_password }}" esxi_host_name: "{{ inventory_hostname }}" validate_certs: "{{ vmware_validate_certs | default(false) }}" delegate_to: localhost register: _lockdown_info - name: "Evaluate: VMW-IAC-01 — ESXi lockdown mode enabled" ansible.builtin.set_fact: test_results: "{{ test_results | default([]) + [{ 'test_id': 'VMW-IAC-01', 'category': 'FR1 — Identification and Authentication Control', 'requirement': 'SR 1.1 — Unique User Identification', 'description': 'ESXi host shall be in lockdown mode (normal or strict)', 'passed': ( _lockdown_info.host_lockdown_info[inventory_hostname].lockdown_mode in ['normal', 'strict'] ), 'expected': 'lockdown_mode = normal or strict', 'actual': 'lockdown_mode = ' + _lockdown_info.host_lockdown_info[inventory_hostname].lockdown_mode, 'severity': 'high', 'remediation': 'vCenter → Host → Configure → Security Profile → Edit Lockdown Mode → Normal' }] }}" ignore_errors: yes # ── FR2 · SR 2.8: NTP configuration ─────────────────────────────────────── # Accurate time is required for audit log integrity and certificate validity. - block: - name: "Gather: ESXi NTP servers" community.vmware.vmware_host_ntp_info: hostname: "{{ vcenter_hostname }}" username: "{{ vcenter_username }}" password: "{{ vcenter_password }}" cluster_name: "{{ cluster_name | default(omit) }}" esxi_host_name: "{{ inventory_hostname }}" validate_certs: "{{ vmware_validate_certs | default(false) }}" delegate_to: localhost register: _ntp_info - name: "Evaluate: VMW-UC-01 — NTP servers configured" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'VMW-UC-01', 'category': 'FR2 — Use Control', 'requirement': 'SR 2.8 — Auditable Events', 'description': 'ESXi host shall have at least one NTP server configured for audit log time accuracy', 'passed': ( _ntp_info.hosts_ntp_info[inventory_hostname].ntp_servers | length > 0 ), 'expected': 'At least 1 NTP server configured', 'actual': 'NTP servers: ' + (_ntp_info.hosts_ntp_info[inventory_hostname].ntp_servers | join(', ') | default('none', true)), 'severity': 'high', 'remediation': 'vCenter → Host → Configure → Time Configuration → Add NTP servers and start ntpd service' }] }}" ignore_errors: yes # ── FR5 · SR 5.3: ESXi Shell and SSH services disabled ──────────────────── # In lockdown mode these should be off; explicit check catches misconfig. - block: - name: "Gather: ESXi host services (SSH, Shell, etc.)" community.vmware.vmware_host_service_info: hostname: "{{ vcenter_hostname }}" username: "{{ vcenter_username }}" password: "{{ vcenter_password }}" esxi_host_name: "{{ inventory_hostname }}" validate_certs: "{{ vmware_validate_certs | default(false) }}" delegate_to: localhost register: _svc_info - name: "Evaluate: VMW-RDF-01 — ESXi Shell service disabled" ansible.builtin.set_fact: _shell_svc: "{{ _svc_info.host_service_info[inventory_hostname] | selectattr('key', 'equalto', 'TSM') | list | first | default({}) }}" - name: "Evaluate: VMW-RDF-01 — record result" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'VMW-RDF-01', 'category': 'FR5 — Restricted Data Flow', 'requirement': 'SR 5.3 — Communication Constraints', 'description': 'ESXi Shell service (TSM) shall be stopped and not set to automatic start', 'passed': ( _shell_svc | length == 0 or (not _shell_svc.running and _shell_svc.policy != 'on') ), 'expected': 'TSM: running=false, policy != on', 'actual': ( 'TSM: running=' + (_shell_svc.running | string) + ', policy=' + (_shell_svc.policy | default('unknown')) ) if _shell_svc | length > 0 else 'TSM service not found', 'severity': 'high', 'remediation': 'vCenter → Host → Configure → Security Profile → Services → ESXi Shell: Stop and set Policy to Off' }] }}" ignore_errors: yes - block: - name: "Evaluate: VMW-RDF-02 — SSH service disabled" ansible.builtin.set_fact: _ssh_svc: "{{ _svc_info.host_service_info[inventory_hostname] | selectattr('key', 'equalto', 'TSM-SSH') | list | first | default({}) }}" - name: "Evaluate: VMW-RDF-02 — record result" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'VMW-RDF-02', 'category': 'FR5 — Restricted Data Flow', 'requirement': 'SR 5.3 — Communication Constraints', 'description': 'ESXi SSH service (TSM-SSH) shall be stopped and not set to automatic start', 'passed': ( _ssh_svc | length == 0 or (not _ssh_svc.running and _ssh_svc.policy != 'on') ), 'expected': 'TSM-SSH: running=false, policy != on', 'actual': ( 'TSM-SSH: running=' + (_ssh_svc.running | string) + ', policy=' + (_ssh_svc.policy | default('unknown')) ) if _ssh_svc | length > 0 else 'TSM-SSH service not found', 'severity': 'high', 'remediation': 'vCenter → Host → Configure → Security Profile → Services → SSH: Stop and set Policy to Off' }] }}" ignore_errors: yes # ── FR1 · SR 1.5: ESXi advanced config — account lockout ───────────────── - block: - name: "Gather: ESXi advanced settings (account lockout policy)" community.vmware.vmware_host_config_info: hostname: "{{ vcenter_hostname }}" username: "{{ vcenter_username }}" password: "{{ vcenter_password }}" esxi_host_name: "{{ inventory_hostname }}" validate_certs: "{{ vmware_validate_certs | default(false) }}" delegate_to: localhost register: _adv_config - name: "Evaluate: VMW-IAC-02 — Account lockout max failures ≤ 5" ansible.builtin.set_fact: _max_failures: "{{ _adv_config.hosts_config_info[inventory_hostname] | dict2items | selectattr('key', 'equalto', 'Security.AccountLockFailures') | map(attribute='value') | first | default('NOT SET') }}" - name: "Evaluate: VMW-IAC-02 — record result" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'VMW-IAC-02', 'category': 'FR1 — Identification and Authentication Control', 'requirement': 'SR 1.11 — Unsuccessful Login Attempts', 'description': 'ESXi account lockout shall trigger after ≤ 5 failed attempts', 'passed': ( _max_failures != 'NOT SET' and (_max_failures | int > 0) and (_max_failures | int <= 5) ), 'expected': 'Security.AccountLockFailures between 1 and 5', 'actual': 'Security.AccountLockFailures = ' + (_max_failures | string), 'severity': 'high', 'remediation': 'vCenter → Host → Configure → Advanced System Settings → Security.AccountLockFailures = 5' }] }}" ignore_errors: yes # ── HITL · FR5 · SR 5.2: Zone boundary and vSwitch isolation ───────────── - block: - name: "Gather: [HITL] Virtual switch configuration summary" community.vmware.vmware_vswitch_info: hostname: "{{ vcenter_hostname }}" username: "{{ vcenter_username }}" password: "{{ vcenter_password }}" esxi_host_name: "{{ inventory_hostname }}" validate_certs: "{{ vmware_validate_certs | default(false) }}" delegate_to: localhost register: _vswitch_info - name: "Display: [HITL] VMW-RDF-HITL-01 — vSwitch isolation" ansible.builtin.debug: msg: | ══════════════════════════════════════════════════════════════ MANUAL REVIEW REQUIRED · VMW-RDF-HITL-01 · {{ inventory_hostname }} ══════════════════════════════════════════════════════════════ Requirement : SR 5.2 — Zone Boundary Protection Check : ICS/OT VM network traffic is isolated from IT network Virtual switches on this host ────────────────────────────── {{ _vswitch_info.hosts_vswitch_info[inventory_hostname] | to_nice_yaml | indent(1) }} Confirm: - ICS VMs are on a dedicated vSwitch with no uplink to the IT LAN - No vSwitch spans both the ICS zone and the IT/corporate zone - Promiscuous mode and MAC address changes are DISABLED ══════════════════════════════════════════════════════════════ - name: "Prompt: VMW-RDF-HITL-01 — verdict for {{ inventory_hostname }}" ansible.builtin.pause: prompt: | Review the vSwitch layout for {{ inventory_hostname }}. Are ICS VMs isolated from the IT network at the vSwitch level? Enter verdict [pass / fail / skip]: register: _hitl_vswitch_verdict delegate_to: localhost - name: "Prompt: VMW-RDF-HITL-01 — notes on failure" ansible.builtin.pause: prompt: "Describe the isolation gap (e.g. 'vSwitch0 carries both ICS and IT VLANs'):" register: _hitl_vswitch_notes delegate_to: localhost when: _hitl_vswitch_verdict.user_input | lower | trim in ['fail', 'f'] - name: "Evaluate: VMW-RDF-HITL-01" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'VMW-RDF-HITL-01', 'category': 'FR5 — Restricted Data Flow', 'requirement': 'SR 5.2 — Zone Boundary Protection', 'description': 'ICS virtual machines shall be isolated on dedicated vSwitches with no IT LAN uplink', 'passed': ( 'skipped' if (_hitl_vswitch_verdict.user_input | lower | trim in ['skip', 's', '']) else (_hitl_vswitch_verdict.user_input | lower | trim in ['pass', 'p']) ), 'expected': 'ICS VMs on isolated vSwitch, no shared uplinks with IT network', 'actual': 'See vSwitch evidence in report', 'severity': 'critical', 'remediation': 'Create a dedicated vSwitch for ICS traffic; remove IT LAN uplinks', 'reviewer': ansible_user_id, 'notes': (_hitl_vswitch_notes.user_input | trim) if _hitl_vswitch_notes is defined else '' }] }}" ignore_errors: yes # ── Generate report ──────────────────────────────────────────────────────── - name: "Generate compliance report" ansible.builtin.include_tasks: ../library/report.yml