302 lines
15 KiB
YAML
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
|