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

302 lines
15 KiB
YAML

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