diff --git a/inventory.ini b/inventory.ini index dac46d9..624c8de 100644 --- a/inventory.ini +++ b/inventory.ini @@ -1,19 +1,136 @@ # inventory.ini — Target hosts for IEC 62443-3-3 SL2 compliance validation # -# [all] group is the default target for site.yml (hosts: all). -# For local testing, uncomment localhost. For remote targets, -# ensure SSH credentials are configured or use -K for sudo. +# Each platform type has its own group with the connection variables +# required by the matching example playbook in playbooks/examples/. # -# Usage: -# ansible-playbook -i inventory.ini playbooks/site.yml --limit localhost -K -# ansible-playbook -i inventory.ini playbooks/site.yml --limit ics_assets +# Store secrets in Ansible Vault: +# ansible-vault encrypt_string 'MyP@ss' --name ansible_password +# +# Run a specific platform: +# ansible-playbook -i inventory.ini playbooks/examples/windows_server.yml +# ansible-playbook -i inventory.ini playbooks/examples/cisco_switch.yml +# +# Run all Linux assets: +# ansible-playbook -i inventory.ini playbooks/site.yml --limit linux_vms -K +# ── Local control-node self-test ───────────────────────────────────────────── [all] -# For testing on the control node itself: -# localhost ansible_connection=local +localhost ansible_connection=local -[ics_assets] -# Add real ICS/OT targets here. Example: -# plc-rack01.example.com ansible_user=auditor -# hmi-station02.example.com ansible_user=auditor -# engineering-ws03.example.com +# ── Linux VMs / Servers (SSH — native Ansible) ─────────────────────────────── +# Example: playbooks/examples/linux_vm.yml +[linux_vms] +# linux-vm-01.example.com ansible_user=auditor +# linux-vm-02.example.com ansible_user=auditor ansible_become=yes + +# ── Windows Servers (WinRM) ─────────────────────────────────────────────────── +# Example: playbooks/examples/windows_server.yml +# Preferred transport: kerberos (domain) or ntlm (workgroup/local admin) +[windows_servers] +# win-srv-01.example.com +# win-srv-02.example.com + +[windows_servers:vars] +ansible_connection=winrm +ansible_winrm_transport=ntlm +ansible_winrm_server_cert_validation=ignore +ansible_port=5985 +# ansible_user=DOMAIN\auditor +# ansible_password="{{ vault_win_password }}" + +# ── Windows Clients / Workstations (WinRM) ──────────────────────────────────── +# Example: playbooks/examples/windows_client.yml +[windows_clients] +# win-ws-01.example.com +# win-ws-02.example.com + +[windows_clients:vars] +ansible_connection=winrm +ansible_winrm_transport=ntlm +ansible_winrm_server_cert_validation=ignore +ansible_port=5985 +# ansible_user=DOMAIN\auditor +# ansible_password="{{ vault_win_password }}" + +# ── MS SQL Servers (WinRM to Windows host; SQL queried via PowerShell) ──────── +# Example: playbooks/examples/mssql_server.yml +[mssql_servers] +# sql-srv-01.example.com mssql_instance=MSSQLSERVER +# sql-srv-02.example.com mssql_instance=NAMED_INSTANCE + +[mssql_servers:vars] +ansible_connection=winrm +ansible_winrm_transport=ntlm +ansible_winrm_server_cert_validation=ignore +ansible_port=5985 +# ansible_user=DOMAIN\auditor +# ansible_password="{{ vault_win_password }}" + +# ── VMware vSphere ESXi Hosts (vSphere API via vCenter — no SSH) ────────────── +# Example: playbooks/examples/vmware_vsphere.yml +# The inventory host IS the ESXi hostname. Connection goes via vCenter API. +[vmware_esxi] +# esxi-01.example.com +# esxi-02.example.com + +[vmware_esxi:vars] +ansible_connection=local +vcenter_hostname=vcenter.example.com +vcenter_username=audit@vsphere.local +# vcenter_password="{{ vault_vcenter_password }}" +vmware_validate_certs=false + +# ── Hyper-V Clusters (WinRM to cluster node) ────────────────────────────────── +# Example: playbooks/examples/hyperv_cluster.yml +[hyperv_hosts] +# hv-node-01.example.com +# hv-node-02.example.com + +[hyperv_hosts:vars] +ansible_connection=winrm +ansible_winrm_transport=ntlm +ansible_winrm_server_cert_validation=ignore +ansible_port=5985 +# ansible_user=DOMAIN\auditor +# ansible_password="{{ vault_win_password }}" + +# ── Cisco Switches (IOS / IOS-XE — SSH via network_cli) ────────────────────── +# Example: playbooks/examples/cisco_switch.yml +[cisco_switches] +# sw-core-01.example.com +# sw-acc-01.example.com + +[cisco_switches:vars] +ansible_connection=ansible.netcommon.network_cli +ansible_network_os=cisco.ios.ios +ansible_become=yes +ansible_become_method=enable +# ansible_user=audit +# ansible_password="{{ vault_ios_password }}" +# ansible_become_password="{{ vault_ios_enable }}" + +# ── Cisco Firewalls (ASA — SSH via network_cli) ─────────────────────────────── +# Example: playbooks/examples/cisco_firewall.yml +[cisco_firewalls] +# asa-fw-01.example.com +# asa-fw-02.example.com + +[cisco_firewalls:vars] +ansible_connection=ansible.netcommon.network_cli +ansible_network_os=cisco.asa.asa +ansible_become=yes +ansible_become_method=enable +# ansible_user=audit +# ansible_password="{{ vault_asa_password }}" +# ansible_become_password="{{ vault_asa_enable }}" + +# ── Convenience group: all ICS/OT assets (excludes localhost) ──────────────── +[ics_assets:children] +linux_vms +windows_servers +windows_clients +mssql_servers +vmware_esxi +hyperv_hosts +cisco_switches +cisco_firewalls diff --git a/playbooks/examples/cisco_firewall.yml b/playbooks/examples/cisco_firewall.yml new file mode 100644 index 0000000..0f4e833 --- /dev/null +++ b/playbooks/examples/cisco_firewall.yml @@ -0,0 +1,268 @@ +--- +# ══════════════════════════════════════════════════════════════════════════════ +# IEC 62443-3-3 SL2 — Cisco ASA Firewall Compliance +# +# Target type : Cisco ASA 9.x+ (physical or virtual) +# Connection : SSH via ansible.netcommon.network_cli +# Collections : cisco.asa, ansible.netcommon (installed in ansible-node image) +# Python pkg : paramiko (installed in ansible-node image) +# +# Inventory group : [cisco_firewalls] (see inventory.ini) +# +# Run: +# ansible-playbook -i inventory.ini playbooks/examples/cisco_firewall.yml +# +# ASA-specific notes: +# - asa_command returns stdout as a list, same as ios_command. +# - 'show running-config' on ASA is a single large string; use regex_search +# and regex_findall to extract specific configuration lines. +# - 'enable' privilege is required for most 'show' commands. +# ansible_become=yes + ansible_become_method=enable handles this. +# - Multi-context ASAs: add 'changeto context ' as a command prefix, +# or target individual context admin contexts. +# ══════════════════════════════════════════════════════════════════════════════ + +- name: "IEC 62443-3-3 SL2 — Cisco ASA Firewall Compliance" + hosts: cisco_firewalls + gather_facts: yes # runs cisco.asa.asa_facts → ansible_net_* + 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: + + # ── FR5 · SR 5.3: No Telnet on VTY lines — SSH only ────────────────────── + + - block: + - name: "Gather: VTY line transport configuration" + cisco.asa.asa_command: + commands: + - show running-config | include telnet|ssh + register: _mgmt_access + + - name: "Evaluate: FW-RDF-01 — Telnet management access disabled" + ansible.builtin.set_fact: + test_results: "{{ test_results | default([]) + [{ + 'test_id': 'FW-RDF-01', + 'category': 'FR5 — Restricted Data Flow', + 'requirement': 'SR 5.3 — Communication Constraints', + 'description': 'Telnet management access to the ASA shall be disabled', + 'passed': ( + _mgmt_access.stdout[0] | regex_search('telnet [0-9]') is none + ), + 'expected': 'No telnet lines in running-config', + 'actual': _mgmt_access.stdout[0] | regex_findall('telnet[^\n]+') | join(' | ') | default('No telnet statements found', true), + 'severity': 'critical', + 'remediation': 'Remove all "telnet" management statements; use "ssh" only' + }] }}" + + - name: "Evaluate: FW-RDF-02 — SSH management access configured" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'FW-RDF-02', + 'category': 'FR5 — Restricted Data Flow', + 'requirement': 'SR 5.3 — Communication Constraints', + 'description': 'SSH management access shall be restricted to specific management networks', + 'passed': (_mgmt_access.stdout[0] | regex_search('ssh [0-9]') is not none), + 'expected': 'At least one ssh statement present', + 'actual': _mgmt_access.stdout[0] | regex_findall('ssh[^\n]+') | join(' | ') | default('No SSH access statements', true), + 'severity': 'high', + 'remediation': 'ssh \nssh version 2' + }] }}" + ignore_errors: yes + + # ── FR1 · SR 1.2: IKEv1 disabled — IKEv2 only for VPN ──────────────────── + # IKEv1 is vulnerable to several known attacks. IEC 62443 SL2 requires v2. + + - block: + - name: "Gather: IKE/ISAKMP policy configuration" + cisco.asa.asa_command: + commands: + - show running-config | include crypto isakmp|crypto ikev + register: _ike_cfg + + - name: "Evaluate: FW-IAC-01 — IKEv1 disabled" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'FW-IAC-01', + 'category': 'FR1 — Identification and Authentication Control', + 'requirement': 'SR 1.2 — Software Process and Device Identification', + 'description': 'IKEv1 (crypto isakmp) shall be disabled; only IKEv2 is permitted', + 'passed': ( + _ike_cfg.stdout[0] | regex_search('crypto isakmp enable') is none and + _ike_cfg.stdout[0] | regex_search('crypto isakmp policy') is none + ), + 'expected': 'No crypto isakmp enable or isakmp policy statements', + 'actual': _ike_cfg.stdout[0] | regex_findall('crypto isakmp[^\n]+') | join(' | ') | default('No IKEv1 config found', true), + 'severity': 'high', + 'remediation': 'no crypto isakmp enable\nno crypto isakmp policy ' + }] }}" + ignore_errors: yes + + # ── FR2 · SR 2.8: Syslog forwarding to remote server ───────────────────── + + - block: + - name: "Gather: Syslog configuration" + cisco.asa.asa_command: + commands: + - show running-config | include logging + register: _syslog_cfg + + - name: "Evaluate: FW-UC-01 — Syslog forwarding to remote host" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'FW-UC-01', + 'category': 'FR2 — Use Control', + 'requirement': 'SR 2.8 — Auditable Events', + 'description': 'ASA syslog shall be forwarded to a remote syslog server (not stored locally only)', + 'passed': ( + _syslog_cfg.stdout[0] | regex_search('logging host') is not none and + _syslog_cfg.stdout[0] | regex_search('logging enable') is not none + ), + 'expected': 'logging enable; logging host ', + 'actual': _syslog_cfg.stdout[0] | regex_findall('logging[^\n]+') | join(' | ') | default('No logging config', true), + 'severity': 'high', + 'remediation': 'logging enable\nlogging host ' + }] }}" + ignore_errors: yes + + # ── FR1 · SR 1.11: AAA authentication for management ───────────────────── + # Require AAA (TACACS+/RADIUS) for management access; reject local fallback + # without documented justification. + + - block: + - name: "Gather: AAA and local user configuration" + cisco.asa.asa_command: + commands: + - show running-config | include ^aaa|^username + register: _aaa_cfg + + - name: "Evaluate: FW-IAC-02 — AAA authentication configured for SSH/enable" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'FW-IAC-02', + 'category': 'FR1 — Identification and Authentication Control', + 'requirement': 'SR 1.11 — Unsuccessful Login Attempts', + 'description': 'Management access (SSH and enable) shall use AAA authentication', + 'passed': ( + _aaa_cfg.stdout[0] | regex_search('aaa authentication ssh') is not none or + _aaa_cfg.stdout[0] | regex_search('aaa authentication enable') is not none + ), + 'expected': 'aaa authentication ssh|enable console LOCAL', + 'actual': _aaa_cfg.stdout[0] | regex_findall('aaa authentication[^\n]+') | join(' | ') | default('No AAA authentication config', true), + 'severity': 'high', + 'remediation': 'aaa authentication ssh console TACACS+ LOCAL\naaa authentication enable console TACACS+ LOCAL' + }] }}" + ignore_errors: yes + + # ── FR5 · SR 5.2: Default deny — check access-group on interfaces ───────── + + - block: + - name: "Gather: Interface ACL bindings and ACL counts" + cisco.asa.asa_command: + commands: + - show running-config | include access-group + - show access-list | include elements + register: _acl_cfg + + - name: "Evaluate: FW-RDF-03 — Access lists applied inbound on all interfaces" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'FW-RDF-03', + 'category': 'FR5 — Restricted Data Flow', + 'requirement': 'SR 5.2 — Zone Boundary Protection', + 'description': 'Access control lists shall be applied inbound on all zone-facing interfaces', + 'passed': (_acl_cfg.stdout[0] | regex_findall('access-group.*in interface') | length > 0), + 'expected': 'At least one access-group in interface ', + 'actual': _acl_cfg.stdout[0] | regex_findall('access-group[^\n]+') | join(' | ') | default('No access-group statements', true), + 'severity': 'critical', + 'remediation': 'access-group in interface ' + }] }}" + ignore_errors: yes + + # ── HITL · FR5 · SR 5.2: Firewall rule review ──────────────────────────── + # Collect the full ACL and ask the reviewer if rules are minimal / correct. + + - block: + - name: "Gather: [HITL] Full access-list detail for review" + cisco.asa.asa_command: + commands: + - show access-list + register: _full_acl + + - name: "Display: [HITL] FW-RDF-HITL-01 — ACL rule review" + ansible.builtin.debug: + msg: | + ══════════════════════════════════════════════════════════════ + MANUAL REVIEW REQUIRED · FW-RDF-HITL-01 · {{ inventory_hostname }} + ══════════════════════════════════════════════════════════════ + Requirement : SR 5.2 — Zone Boundary Protection + Check : Firewall rules implement least-privilege; no + 'permit any any' or broad permit rules exist + + Access list summary + ─────────────────── + {{ _full_acl.stdout[0] | truncate(1200, false) | indent(1) }} + + Verify: + - No 'permit ip any any' or 'permit any any' rules present + - Rules are specific (source/destination/service all named) + - Each rule has a documented business justification + - Implicit deny at the end of each list + ══════════════════════════════════════════════════════════════ + + - name: "Prompt: FW-RDF-HITL-01 — verdict for {{ inventory_hostname }}" + ansible.builtin.pause: + prompt: | + Do the firewall ACLs implement least-privilege with no broad permit rules? + Enter verdict [pass / fail / skip]: + register: _hitl_acl_verdict + delegate_to: localhost + + - name: "Prompt: FW-RDF-HITL-01 — notes on failure" + ansible.builtin.pause: + prompt: "Describe the offending rule(s) (e.g. 'ACL OUTSIDE line 3: permit ip any any'):" + register: _hitl_acl_notes + delegate_to: localhost + when: _hitl_acl_verdict.user_input | lower | trim in ['fail', 'f'] + + - name: "Evaluate: FW-RDF-HITL-01" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'FW-RDF-HITL-01', + 'category': 'FR5 — Restricted Data Flow', + 'requirement': 'SR 5.2 — Zone Boundary Protection', + 'description': 'Firewall rules shall implement least-privilege; no broad permit rules', + 'passed': ( + 'skipped' if (_hitl_acl_verdict.user_input | lower | trim in ['skip', 's', '']) + else (_hitl_acl_verdict.user_input | lower | trim in ['pass', 'p']) + ), + 'expected': 'All permit rules are specific (src/dst/svc); no permit any any', + 'actual': 'Full ACL captured — see report evidence', + 'severity': 'critical', + 'remediation': 'Replace broad permit rules with specific source/destination/service entries', + 'reviewer': ansible_user_id, + 'notes': (_hitl_acl_notes.user_input | trim) if _hitl_acl_notes is defined else '' + }] }}" + ignore_errors: yes + + # ── Generate report ──────────────────────────────────────────────────────── + + - name: "Generate compliance report" + ansible.builtin.include_tasks: ../library/report.yml diff --git a/playbooks/examples/cisco_switch.yml b/playbooks/examples/cisco_switch.yml new file mode 100644 index 0000000..6251651 --- /dev/null +++ b/playbooks/examples/cisco_switch.yml @@ -0,0 +1,285 @@ +--- +# ══════════════════════════════════════════════════════════════════════════════ +# IEC 62443-3-3 SL2 — Cisco Switch Compliance (IOS / IOS-XE) +# +# Target type : Cisco Catalyst / IOS-XE access and distribution switches +# Connection : SSH via ansible.netcommon.network_cli +# Collections : cisco.ios, ansible.netcommon (installed in ansible-node image) +# Python pkg : paramiko, netmiko (installed in ansible-node image) +# +# Inventory group : [cisco_switches] (see inventory.ini) +# +# Run: +# ansible-playbook -i inventory.ini playbooks/examples/cisco_switch.yml +# +# How network_cli output works: +# - cisco.ios.ios_command returns stdout as a list, one entry per command. +# Access the first command's output with: _result.stdout[0] +# - Output is a plain text string; use regex_search / regex_findall to parse. +# - ios_facts populates ansible_net_* variables (hostname, version, interfaces) +# and is used here to gather facts once for multiple tests. +# +# Note on gather_facts: +# Ansible's default gather_facts runs ios_facts automatically when +# ansible_network_os is set. Set gather_facts: yes to use ansible_net_* +# variables, or gather_facts: no and call ios_facts explicitly. +# ══════════════════════════════════════════════════════════════════════════════ + +- name: "IEC 62443-3-3 SL2 — Cisco Switch Compliance" + hosts: cisco_switches + gather_facts: yes # runs cisco.ios.ios_facts → populates ansible_net_* + 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: + + # ── FR5 · SR 5.3: SSH v2 only, Telnet disabled on VTY lines ────────────── + + - block: + - name: "Gather: SSH version and VTY transport settings" + cisco.ios.ios_command: + commands: + - show ip ssh + - show running-config | section line vty + register: _ssh_vty + + - name: "Evaluate: SW-RDF-01 — SSH version 2 configured" + ansible.builtin.set_fact: + test_results: "{{ test_results | default([]) + [{ + 'test_id': 'SW-RDF-01', + 'category': 'FR5 — Restricted Data Flow', + 'requirement': 'SR 5.3 — Communication Constraints', + 'description': 'SSH version shall be 2 (SSHv1 disabled)', + 'passed': (_ssh_vty.stdout[0] | regex_search('SSH Enabled.*version 2') is not none), + 'expected': 'SSH Enabled - version 2.0', + 'actual': _ssh_vty.stdout[0] | regex_search('SSH Enabled[^\n]+') | default('SSH status not found', true), + 'severity': 'high', + 'remediation': 'ip ssh version 2' + }] }}" + + - name: "Evaluate: SW-RDF-02 — Telnet disabled on VTY lines" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'SW-RDF-02', + 'category': 'FR5 — Restricted Data Flow', + 'requirement': 'SR 5.3 — Communication Constraints', + 'description': 'VTY lines shall only permit SSH transport (no Telnet)', + 'passed': ( + 'transport input ssh' in _ssh_vty.stdout[1] and + 'transport input telnet' not in _ssh_vty.stdout[1] and + 'transport input all' not in _ssh_vty.stdout[1] + ), + 'expected': 'transport input ssh (only) on all VTY lines', + 'actual': _ssh_vty.stdout[1] | regex_findall('transport input[^\n]+') | join(' | ') | default('NOT SET', true), + 'severity': 'critical', + 'remediation': 'line vty 0 15\n transport input ssh' + }] }}" + ignore_errors: yes + + # ── FR1 · SR 1.1: No SNMPv1 or SNMPv2c communities ─────────────────────── + # SNMPv1/v2c use cleartext community strings — equivalent to passwords in clear. + + - block: + - name: "Gather: SNMP community string configuration" + cisco.ios.ios_command: + commands: + - show running-config | include snmp-server community + register: _snmp_config + + - name: "Evaluate: SW-IAC-01 — No SNMPv1/v2c community strings" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'SW-IAC-01', + 'category': 'FR1 — Identification and Authentication Control', + 'requirement': 'SR 1.1 — Unique User Identification', + 'description': 'SNMPv1/v2c community strings shall be absent; use SNMPv3 with auth+priv', + 'passed': (_snmp_config.stdout[0] | trim | length == 0), + 'expected': 'No snmp-server community lines in running-config', + 'actual': ( + _snmp_config.stdout[0] | trim | default('No SNMP community strings found', true) + ), + 'severity': 'high', + 'remediation': 'Remove all snmp-server community entries; configure snmp-server group/user with authPriv' + }] }}" + ignore_errors: yes + + # ── FR1 · SR 1.7: Login banner configured ───────────────────────────────── + # A warning banner is a legal and technical requirement under IEC 62443. + + - block: + - name: "Gather: Login banner text" + cisco.ios.ios_command: + commands: + - show running-config | section banner login + register: _banner + + - name: "Evaluate: SW-IAC-02 — Login warning banner configured" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'SW-IAC-02', + 'category': 'FR1 — Identification and Authentication Control', + 'requirement': 'SR 1.7 — Strength of Password-based Authentication', + 'description': 'A warning banner shall be displayed before login (authorised use only)', + 'passed': ('banner login' in _banner.stdout[0]), + 'expected': 'banner login block configured', + 'actual': _banner.stdout[0] | regex_search('banner login [^\n]+') | default('No banner login configured', true), + 'severity': 'medium', + 'remediation': "banner login ^C\nAUTHORIZED ACCESS ONLY. Unauthorised access is prohibited.\n^C" + }] }}" + ignore_errors: yes + + # ── FR5 · SR 5.3: Unused interfaces shut down ───────────────────────────── + # Uses ios_facts (already gathered) — no additional command needed. + + - block: + - name: "Evaluate: SW-RDF-03 — No interfaces in admin-down state with connected status" + # ansible_net_interfaces is a dict keyed by interface name. + # We look for interfaces that are 'up' operationally but not in shutdown config. + # A simpler check: count of interfaces in 'administratively down' that have + # a connected line protocol (should never exist if properly shut). + ansible.builtin.set_fact: + _intf_up_no_shutdown: "{{ ansible_net_interfaces | dict2items + | selectattr('value.operstatus', 'equalto', 'up') + | selectattr('value.lineprotocol', 'equalto', 'down') + | map(attribute='key') | list }}" + + - name: "Gather: Interfaces shutdown in config (no description = unused)" + cisco.ios.ios_command: + commands: + - show interfaces status | include notconnect|disabled + register: _intf_status + + - name: "Evaluate: SW-RDF-03 — record result" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'SW-RDF-03', + 'category': 'FR5 — Restricted Data Flow', + 'requirement': 'SR 5.3 — Communication Constraints', + 'description': 'All unused access ports shall be administratively shut down', + 'passed': 'review', + 'expected': 'All notconnect ports in shutdown state in config', + 'actual': 'Not-connected or disabled ports:\n' + _intf_status.stdout[0] | trim | truncate(300, false), + 'severity': 'medium', + 'remediation': 'interface range shutdown' + }] }}" + ignore_errors: yes + + # ── FR2 · SR 2.8: NTP authentication ────────────────────────────────────── + + - block: + - name: "Gather: NTP configuration" + cisco.ios.ios_command: + commands: + - show ntp status + - show running-config | include ntp + register: _ntp_cfg + + - name: "Evaluate: SW-UC-01 — NTP configured and synchronised" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'SW-UC-01', + 'category': 'FR2 — Use Control', + 'requirement': 'SR 2.8 — Auditable Events', + 'description': 'NTP shall be configured and the clock synchronised for audit log accuracy', + 'passed': ( + _ntp_cfg.stdout[0] | regex_search('Clock is synchronized') is not none and + _ntp_cfg.stdout[1] | regex_search('ntp server') is not none + ), + 'expected': 'Clock is synchronized; ntp server configured with authentication', + 'actual': 'NTP status: ' + (_ntp_cfg.stdout[0] | regex_search('Clock is [^\n]+') | default('NOT synchronised', true)) + + ' | Config: ' + (_ntp_cfg.stdout[1] | regex_findall('ntp [^\n]+') | join('; ') | default('no ntp config', true)), + 'severity': 'high', + 'remediation': 'ntp authenticate\nntp authentication-key 1 md5 \nntp trusted-key 1\nntp server key 1' + }] }}" + ignore_errors: yes + + # ── HITL · FR5 · SR 5.2: Port labelling and physical access ────────────── + + - block: + - name: "Gather: [HITL] Interface descriptions and VLAN assignments" + cisco.ios.ios_command: + commands: + - show interfaces description + - show vlan brief + register: _intf_desc + + - name: "Display: [HITL] SW-RDF-HITL-01 — Physical port labelling review" + ansible.builtin.debug: + msg: | + ══════════════════════════════════════════════════════════════ + MANUAL REVIEW REQUIRED · SW-RDF-HITL-01 · {{ inventory_hostname }} + ══════════════════════════════════════════════════════════════ + Requirement : SR 5.2 — Zone Boundary Protection + Check : ICS-connected ports are in the correct VLAN and + physically labelled to prevent misconnection + + Interface descriptions + ───────────────────── + {{ _intf_desc.stdout[0] | truncate(800, false) | indent(1) }} + + VLAN assignments + ──────────────── + {{ _intf_desc.stdout[1] | truncate(400, false) | indent(1) }} + + Verify: + - ICS device ports are in the dedicated ICS VLAN (not default VLAN 1) + - All connected ports have a description identifying the device + - Physical port labels match the connected device + ══════════════════════════════════════════════════════════════ + + - name: "Prompt: SW-RDF-HITL-01 — verdict for {{ inventory_hostname }}" + ansible.builtin.pause: + prompt: | + Are ICS device ports in the correct VLAN and physically labelled? + Enter verdict [pass / fail / skip]: + register: _hitl_port_verdict + delegate_to: localhost + + - name: "Prompt: SW-RDF-HITL-01 — notes on failure" + ansible.builtin.pause: + prompt: "Describe the finding (e.g. 'Port Gi0/5 in VLAN 1, no label'):" + register: _hitl_port_notes + delegate_to: localhost + when: _hitl_port_verdict.user_input | lower | trim in ['fail', 'f'] + + - name: "Evaluate: SW-RDF-HITL-01" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'SW-RDF-HITL-01', + 'category': 'FR5 — Restricted Data Flow', + 'requirement': 'SR 5.2 — Zone Boundary Protection', + 'description': 'ICS ports shall be in the dedicated ICS VLAN and physically labelled', + 'passed': ( + 'skipped' if (_hitl_port_verdict.user_input | lower | trim in ['skip', 's', '']) + else (_hitl_port_verdict.user_input | lower | trim in ['pass', 'p']) + ), + 'expected': 'ICS ports in ICS VLAN, not VLAN 1; physical labels applied', + 'actual': 'See interface description and VLAN table in evidence', + 'severity': 'high', + 'remediation': 'Move ICS ports to ICS VLAN; apply description labels; physically label ports', + 'reviewer': ansible_user_id, + 'notes': (_hitl_port_notes.user_input | trim) if _hitl_port_notes is defined else '' + }] }}" + ignore_errors: yes + + # ── Generate report ──────────────────────────────────────────────────────── + + - name: "Generate compliance report" + ansible.builtin.include_tasks: ../library/report.yml diff --git a/playbooks/examples/hyperv_cluster.yml b/playbooks/examples/hyperv_cluster.yml new file mode 100644 index 0000000..a1346a2 --- /dev/null +++ b/playbooks/examples/hyperv_cluster.yml @@ -0,0 +1,261 @@ +--- +# ══════════════════════════════════════════════════════════════════════════════ +# 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 inventory.ini) +# +# Run: +# ansible-playbook -i inventory.ini 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 diff --git a/playbooks/examples/linux_vm.yml b/playbooks/examples/linux_vm.yml new file mode 100644 index 0000000..4930f03 --- /dev/null +++ b/playbooks/examples/linux_vm.yml @@ -0,0 +1,207 @@ +--- +# ══════════════════════════════════════════════════════════════════════════════ +# IEC 62443-3-3 SL2 — Linux VM / Server Compliance +# +# Target type : Linux (any distro with systemd + SSH) +# Connection : SSH — native Ansible, no extra collection required +# Privilege : become: yes (sudo) for /etc/shadow, audit rules, sysctl +# +# Inventory group : [linux_vms] (see inventory.ini) +# +# Run: +# ansible-playbook -i inventory.ini playbooks/examples/linux_vm.yml -K +# ansible-playbook -i inventory.ini playbooks/examples/linux_vm.yml \ +# --limit linux-vm-01.example.com -K +# +# What this covers (beyond the core fr1/fr2/fr5 suites): +# FR1: SSH daemon hardening (PermitRootLogin, PasswordAuthentication) +# FR2: Sudo command logging (Defaults log_input/log_output) +# FR3: Kernel integrity — /proc/sys hardening via sysctl +# FR5: IPv4 forwarding disabled (host is not a router) +# Kernel module loading restricted (kmod_blacklist) +# ══════════════════════════════════════════════════════════════════════════════ + +- name: "IEC 62443-3-3 SL2 — Linux VM Compliance" + hosts: linux_vms + gather_facts: yes + become: 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: + + # ── FR1 · SR 1.1: SSH root login disabled ───────────────────────────────── + + - block: + - name: "Gather: SSH PermitRootLogin setting" + ansible.builtin.shell: | + sshd -T 2>/dev/null | grep -i '^permitrootlogin' | awk '{print $2}' + register: _sshd_rootlogin + changed_when: false + + - name: "Evaluate: LX-IAC-01 — SSH root login disabled" + ansible.builtin.set_fact: + test_results: "{{ test_results | default([]) + [{ + 'test_id': 'LX-IAC-01', + 'category': 'FR1 — Identification and Authentication Control', + 'requirement': 'SR 1.1 — Unique User Identification', + 'description': 'SSH shall not permit direct root login', + 'passed': (_sshd_rootlogin.stdout | trim | lower in ['no', 'prohibit-password', 'forced-commands-only']), + 'expected': 'PermitRootLogin no (or prohibit-password / forced-commands-only)', + 'actual': 'PermitRootLogin ' + (_sshd_rootlogin.stdout | trim | default('NOT SET', true)), + 'severity': 'high', + 'remediation': 'Set PermitRootLogin no in /etc/ssh/sshd_config and restart sshd' + }] }}" + ignore_errors: yes + + # ── FR1 · SR 1.4: SSH password authentication disabled ──────────────────── + + - block: + - name: "Gather: SSH PasswordAuthentication setting" + ansible.builtin.shell: | + sshd -T 2>/dev/null | grep -i '^passwordauthentication' | awk '{print $2}' + register: _sshd_pwauth + changed_when: false + + - name: "Evaluate: LX-IAC-02 — SSH key-only authentication" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'LX-IAC-02', + 'category': 'FR1 — Identification and Authentication Control', + 'requirement': 'SR 1.4 — Identifier Strength', + 'description': 'SSH shall use key-based authentication only (PasswordAuthentication no)', + 'passed': (_sshd_pwauth.stdout | trim | lower == 'no'), + 'expected': 'PasswordAuthentication no', + 'actual': 'PasswordAuthentication ' + (_sshd_pwauth.stdout | trim | default('NOT SET', true)), + 'severity': 'high', + 'remediation': 'Set PasswordAuthentication no in /etc/ssh/sshd_config and restart sshd' + }] }}" + ignore_errors: yes + + # ── FR2 · SR 2.1: Sudo command logging ──────────────────────────────────── + + - block: + - name: "Gather: Sudo log_input / log_output Defaults" + ansible.builtin.shell: | + grep -rh 'Defaults.*log_' /etc/sudoers /etc/sudoers.d/ 2>/dev/null | + grep -v '^\s*#' | tr -s ' ' | head -5 + register: _sudo_log + changed_when: false + + - name: "Evaluate: LX-UC-01 — Sudo session logging enabled" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'LX-UC-01', + 'category': 'FR2 — Use Control', + 'requirement': 'SR 2.4 — Audit Log Integrity', + 'description': 'Sudo shall log all input and output (Defaults log_input, log_output)', + 'passed': ( + 'log_input' in _sudo_log.stdout and + 'log_output' in _sudo_log.stdout + ), + 'expected': 'Defaults log_input, log_output in /etc/sudoers[.d]', + 'actual': _sudo_log.stdout | trim | default('No sudo logging Defaults found', true), + 'severity': 'medium', + 'remediation': 'Add "Defaults log_input,log_output" to /etc/sudoers' + }] }}" + ignore_errors: yes + + # ── FR3 · SR 3.1: Kernel IP forwarding disabled ──────────────────────────── + + - block: + - name: "Gather: IPv4 forwarding sysctl" + ansible.builtin.command: + cmd: sysctl net.ipv4.ip_forward + register: _ip_forward + changed_when: false + + - name: "Evaluate: LX-SI-01 — IP forwarding disabled" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'LX-SI-01', + 'category': 'FR3 — System Integrity', + 'requirement': 'SR 3.1 — Communication Integrity', + 'description': 'Kernel IP forwarding shall be disabled (host is not a router)', + 'passed': (_ip_forward.stdout | trim | regex_search('= 0$') is not none), + 'expected': 'net.ipv4.ip_forward = 0', + 'actual': _ip_forward.stdout | trim, + 'severity': 'high', + 'remediation': 'Add "net.ipv4.ip_forward = 0" to /etc/sysctl.d/99-ics-hardening.conf and run sysctl -p' + }] }}" + ignore_errors: yes + + # ── FR3 · SR 3.1: ICMP redirect acceptance disabled ────────────────────── + + - block: + - name: "Gather: ICMP accept_redirects (all interfaces)" + ansible.builtin.shell: | + sysctl net.ipv4.conf.all.accept_redirects net.ipv4.conf.default.accept_redirects 2>/dev/null + register: _redirects + changed_when: false + + - name: "Evaluate: LX-SI-02 — ICMP redirects rejected" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'LX-SI-02', + 'category': 'FR3 — System Integrity', + 'requirement': 'SR 3.1 — Communication Integrity', + 'description': 'ICMP redirect acceptance shall be disabled on all interfaces', + 'passed': ( + _redirects.stdout | regex_findall('= ([01])') | unique | list == ['0'] + ), + 'expected': 'net.ipv4.conf.*.accept_redirects = 0', + 'actual': _redirects.stdout | trim, + 'severity': 'medium', + 'remediation': 'Set net.ipv4.conf.all.accept_redirects = 0 in /etc/sysctl.d/99-ics-hardening.conf' + }] }}" + ignore_errors: yes + + # ── FR5 · SR 5.3: Core dump disabled ────────────────────────────────────── + # Core dumps can expose sensitive memory content; disable on ICS nodes. + + - block: + - name: "Gather: Core dump hard limit (ulimit -c)" + ansible.builtin.shell: | + grep -rh '^[^#]*hard.*core' /etc/security/limits.conf /etc/security/limits.d/ 2>/dev/null | + awk '{print $NF}' | head -1 || echo "NOT SET" + register: _core_limit + changed_when: false + + - name: "Gather: systemd DefaultLimitCORE" + ansible.builtin.shell: | + grep -h 'DefaultLimitCORE' /etc/systemd/system.conf /etc/systemd/user.conf 2>/dev/null | + tail -1 | awk -F= '{print $2}' || echo "NOT SET" + register: _systemd_core + changed_when: false + + - name: "Evaluate: LX-RDF-01 — Core dumps disabled" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'LX-RDF-01', + 'category': 'FR5 — Restricted Data Flow', + 'requirement': 'SR 5.3 — Communication Constraints', + 'description': 'Core dumps shall be disabled to prevent memory disclosure', + 'passed': ( + (_core_limit.stdout | trim in ['0', '']) or + (_systemd_core.stdout | trim == '0') + ), + 'expected': 'hard core 0 in limits.conf OR DefaultLimitCORE=0 in systemd', + 'actual': 'limits.conf: ' + _core_limit.stdout | trim + ' | systemd: ' + _systemd_core.stdout | trim, + 'severity': 'medium', + 'remediation': 'Add "* hard core 0" to /etc/security/limits.d/99-no-core.conf' + }] }}" + ignore_errors: yes + + # ── Generate report ──────────────────────────────────────────────────────── + + - name: "Generate compliance report" + ansible.builtin.include_tasks: ../library/report.yml diff --git a/playbooks/examples/mssql_server.yml b/playbooks/examples/mssql_server.yml new file mode 100644 index 0000000..a1f0546 --- /dev/null +++ b/playbooks/examples/mssql_server.yml @@ -0,0 +1,248 @@ +--- +# ══════════════════════════════════════════════════════════════════════════════ +# IEC 62443-3-3 SL2 — Microsoft SQL Server Compliance +# +# Target type : SQL Server 2016+ on Windows Server +# Connection : WinRM to the Windows host; SQL checks run via PowerShell +# Invoke-Sqlcmd on the target (no direct TCP/SQL connection +# from the control node required) +# Collections : ansible.windows +# Python pkg : pywinrm +# +# Inventory group : [mssql_servers] (see inventory.ini) +# Add per-host var "mssql_instance" to target a named instance: +# sql-srv-01.example.com mssql_instance=MSSQLSERVER +# sql-srv-02.example.com mssql_instance=SQLEXPRESS +# +# Run: +# ansible-playbook -i inventory.ini playbooks/examples/mssql_server.yml +# +# Prerequisites on target: +# - SQLPS or SqlServer PowerShell module (Invoke-Sqlcmd) +# Install-Module SqlServer -Force -AllowClobber +# - WinRM enabled (see windows_server.yml header) +# - Audit user needs: VIEW SERVER STATE, VIEW ANY DEFINITION on SQL Server +# ══════════════════════════════════════════════════════════════════════════════ + +- name: "IEC 62443-3-3 SL2 — MS SQL Server Compliance" + hosts: mssql_servers + gather_facts: yes + vars: + report_dir: "../../reports" + # Override per-host with mssql_instance inventory variable + _sql_instance: "{{ mssql_instance | default('MSSQLSERVER') }}" + # Invoke-Sqlcmd connection string fragment reused across tasks + _sql_connect: "-ServerInstance . -TrustServerCertificate -ErrorAction Stop" + + pre_tasks: + - 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.2: SQL authentication mode (Windows-only preferred) ──────── + # Mixed-mode (SQL + Windows auth) allows SQL logins with weaker controls. + # IEC 62443 SL2 requires Windows-integrated (Kerberos) authentication. + + - block: + - name: "Gather: SQL Server authentication mode" + ansible.windows.win_shell: | + Import-Module SqlServer -ErrorAction SilentlyContinue + $result = Invoke-Sqlcmd {{ _sql_connect }} -Query " + SELECT SERVERPROPERTY('IsIntegratedSecurityOnly') AS WindowsAuthOnly, + SERVERPROPERTY('ServerName') AS ServerName" + [PSCustomObject]@{ + WindowsAuthOnly = [int]$result.WindowsAuthOnly + ServerName = $result.ServerName + } | ConvertTo-Json -Compress + register: _sql_auth_mode + + - name: "Evaluate: SQL-IAC-01 — Windows-only authentication" + ansible.builtin.set_fact: + _sql_auth: "{{ _sql_auth_mode.stdout | from_json }}" + + - name: "Evaluate: SQL-IAC-01 — record result" + ansible.builtin.set_fact: + test_results: "{{ test_results | default([]) + [{ + 'test_id': 'SQL-IAC-01', + 'category': 'FR1 — Identification and Authentication Control', + 'requirement': 'SR 1.2 — Software Process and Device Identification', + 'description': 'SQL Server shall use Windows Authentication only (not mixed mode)', + 'passed': (_sql_auth.WindowsAuthOnly | int == 1), + 'expected': 'IsIntegratedSecurityOnly = 1 (Windows auth only)', + 'actual': 'WindowsAuthOnly = ' + (_sql_auth.WindowsAuthOnly | string) + ' on ' + _sql_auth.ServerName, + 'severity': 'critical', + 'remediation': 'SSMS → Server Properties → Security → Server Authentication: Windows Authentication Mode. Requires SQL service restart.' + }] }}" + ignore_errors: yes + + # ── FR1 · SR 1.3: SA account disabled ───────────────────────────────────── + # The default "sa" superuser account shall be disabled when Windows auth is used. + + - block: + - name: "Gather: SA account enabled/disabled state" + ansible.windows.win_shell: | + Import-Module SqlServer -ErrorAction SilentlyContinue + $result = Invoke-Sqlcmd {{ _sql_connect }} -Query " + SELECT name, is_disabled + FROM sys.server_principals + WHERE name = 'sa' AND type = 'S'" + if ($result) { + [PSCustomObject]@{ is_disabled = [int]$result.is_disabled } | ConvertTo-Json -Compress + } else { + '{"is_disabled": 2}' + } + register: _sa_account + + - name: "Evaluate: SQL-IAC-02 — SA account disabled" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'SQL-IAC-02', + 'category': 'FR1 — Identification and Authentication Control', + 'requirement': 'SR 1.3 — Account Management', + 'description': 'The built-in SA (system administrator) login shall be disabled', + 'passed': ((_sa_account.stdout | from_json).is_disabled | int != 0), + 'expected': 'sa: is_disabled = 1 (or account not found)', + 'actual': 'sa: is_disabled = ' + ((_sa_account.stdout | from_json).is_disabled | string), + 'severity': 'critical', + 'remediation': 'ALTER LOGIN sa DISABLE; -- run in SSMS or sqlcmd' + }] }}" + ignore_errors: yes + + # ── FR2 · SR 2.1: xp_cmdshell disabled ──────────────────────────────────── + # xp_cmdshell allows OS command execution from SQL; must be disabled. + + - block: + - name: "Gather: xp_cmdshell configuration value" + ansible.windows.win_shell: | + Import-Module SqlServer -ErrorAction SilentlyContinue + $result = Invoke-Sqlcmd {{ _sql_connect }} -Query " + SELECT value_in_use + FROM sys.configurations + WHERE name = 'xp_cmdshell'" + [PSCustomObject]@{ value_in_use = [int]$result.value_in_use } | ConvertTo-Json -Compress + register: _xpcmd + + - name: "Evaluate: SQL-UC-01 — xp_cmdshell disabled" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'SQL-UC-01', + 'category': 'FR2 — Use Control', + 'requirement': 'SR 2.1 — Authorization Enforcement', + 'description': 'The xp_cmdshell extended stored procedure shall be disabled', + 'passed': ((_xpcmd.stdout | from_json).value_in_use | int == 0), + 'expected': 'xp_cmdshell value_in_use = 0', + 'actual': 'xp_cmdshell value_in_use = ' + ((_xpcmd.stdout | from_json).value_in_use | string), + 'severity': 'critical', + 'remediation': "EXEC sp_configure 'xp_cmdshell', 0; RECONFIGURE;" + }] }}" + ignore_errors: yes + + # ── FR2 · SR 2.8: Login auditing level ──────────────────────────────────── + + - block: + - name: "Gather: SQL Server audit level (0=None 1=Success 2=Failure 3=Both)" + ansible.windows.win_shell: | + Import-Module SqlServer -ErrorAction SilentlyContinue + $result = Invoke-Sqlcmd {{ _sql_connect }} -Query " + SELECT value_in_use + FROM sys.configurations + WHERE name = 'audit level'" + [PSCustomObject]@{ audit_level = [int]$result.value_in_use } | ConvertTo-Json -Compress + register: _audit_level + + - name: "Evaluate: SQL-UC-02 — Login auditing records failures and successes" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'SQL-UC-02', + 'category': 'FR2 — Use Control', + 'requirement': 'SR 2.8 — Auditable Events', + 'description': 'SQL Server login auditing shall record both successful and failed logins (level 3)', + 'passed': ((_audit_level.stdout | from_json).audit_level | int == 3), + 'expected': 'audit level = 3 (both success and failure)', + 'actual': 'audit level = ' + ((_audit_level.stdout | from_json).audit_level | string) + ' (0=None 1=Success 2=Failure 3=Both)', + 'severity': 'high', + 'remediation': "EXEC xp_instance_regwrite N'HKEY_LOCAL_MACHINE', N'Software\\Microsoft\\MSSQLServer\\MSSQLServer', N'AuditLevel', REG_DWORD, 3" + }] }}" + ignore_errors: yes + + # ── FR1 · SR 1.1: Sysadmin role membership review (HITL) ───────────────── + # Automated: lists current sysadmin members. Human reviewer confirms list. + + - block: + - name: "Gather: [HITL] Sysadmin role members" + ansible.windows.win_shell: | + Import-Module SqlServer -ErrorAction SilentlyContinue + Invoke-Sqlcmd {{ _sql_connect }} -Query " + SELECT sp.name AS principal_name, + sp.type_desc AS principal_type, + sp.is_disabled + FROM sys.server_role_members rm + JOIN sys.server_principals sp ON rm.member_principal_id = sp.principal_id + WHERE rm.role_principal_id = SUSER_ID('sysadmin') + ORDER BY sp.name" | + Select-Object principal_name, principal_type, is_disabled | + Format-Table -AutoSize | Out-String + register: _sysadmin_members + + - name: "Display: [HITL] SQL-IAC-HITL-01 — Sysadmin role membership" + ansible.builtin.debug: + msg: | + ══════════════════════════════════════════════════════════════ + MANUAL REVIEW REQUIRED · SQL-IAC-HITL-01 · {{ inventory_hostname }} + ══════════════════════════════════════════════════════════════ + Requirement : SR 1.1 — Unique User Identification + Check : All sysadmin members are authorised and documented + + Current sysadmin role members + ───────────────────────────── + {{ _sysadmin_members.stdout | indent(1) }} + + Review against your authorised administrator list. + Service accounts should NOT be sysadmin unless explicitly required. + ══════════════════════════════════════════════════════════════ + + - name: "Prompt: SQL-IAC-HITL-01 — verdict for {{ inventory_hostname }}" + ansible.builtin.pause: + prompt: | + Do all listed sysadmin members match the authorised administrator list for {{ inventory_hostname }}? + Enter verdict [pass / fail / skip]: + register: _hitl_sysadmin_verdict + delegate_to: localhost + + - name: "Prompt: SQL-IAC-HITL-01 — notes on failure" + ansible.builtin.pause: + prompt: "Name the unauthorised principals found:" + register: _hitl_sysadmin_notes + delegate_to: localhost + when: _hitl_sysadmin_verdict.user_input | lower | trim in ['fail', 'f'] + + - name: "Evaluate: SQL-IAC-HITL-01" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'SQL-IAC-HITL-01', + 'category': 'FR1 — Identification and Authentication Control', + 'requirement': 'SR 1.1 — Unique User Identification', + 'description': 'All sysadmin role members shall be authorised and documented', + 'passed': ( + 'skipped' if (_hitl_sysadmin_verdict.user_input | lower | trim in ['skip', 's', '']) + else (_hitl_sysadmin_verdict.user_input | lower | trim in ['pass', 'p']) + ), + 'expected': 'Only approved accounts in sysadmin role', + 'actual': _sysadmin_members.stdout | trim, + 'severity': 'critical', + 'remediation': 'EXEC sp_dropsrvrolemember '''', ''sysadmin'';', + 'reviewer': ansible_user_id, + 'notes': (_hitl_sysadmin_notes.user_input | trim) if _hitl_sysadmin_notes is defined else '' + }] }}" + ignore_errors: yes + + # ── Generate report ──────────────────────────────────────────────────────── + + - name: "Generate compliance report" + ansible.builtin.include_tasks: ../library/report.yml diff --git a/playbooks/examples/vmware_vsphere.yml b/playbooks/examples/vmware_vsphere.yml new file mode 100644 index 0000000..6490195 --- /dev/null +++ b/playbooks/examples/vmware_vsphere.yml @@ -0,0 +1,301 @@ +--- +# ══════════════════════════════════════════════════════════════════════════════ +# 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 inventory.ini) +# 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 inventory.ini 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 diff --git a/playbooks/examples/windows_client.yml b/playbooks/examples/windows_client.yml new file mode 100644 index 0000000..9890ebf --- /dev/null +++ b/playbooks/examples/windows_client.yml @@ -0,0 +1,246 @@ +--- +# ══════════════════════════════════════════════════════════════════════════════ +# IEC 62443-3-3 SL2 — Windows Client / Workstation Compliance +# +# Target type : Windows 10 / 11 workstations, operator HMI stations +# Connection : WinRM — same as windows_server.yml +# Collections : ansible.windows +# Python pkg : pywinrm +# +# Inventory group : [windows_clients] (see inventory.ini) +# +# Run: +# ansible-playbook -i inventory.ini playbooks/examples/windows_client.yml +# +# Notes: +# - Operator HMI workstations often run as local accounts (not domain-joined). +# The domain_check HITL test flags this for reviewer attention. +# - BitLocker status requires the Hyper-V / TPM chip; VMs may legitimately +# fail WIN-CLI-02 if they are not TPM-enabled. +# ══════════════════════════════════════════════════════════════════════════════ + +- name: "IEC 62443-3-3 SL2 — Windows Client Compliance" + hosts: windows_clients + 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: + + # ── FR1 · SR 1.3: Domain membership ─────────────────────────────────────── + # Domain-joined workstations inherit password and lockout policy via GPO. + # Standalone / local-account machines need local policy verified separately. + + - block: + - name: "Gather: Domain membership status" + ansible.windows.win_shell: | + $cs = Get-WmiObject -Class Win32_ComputerSystem + [PSCustomObject]@{ + PartOfDomain = $cs.PartOfDomain + Domain = if ($cs.PartOfDomain) { $cs.Domain } else { 'WORKGROUP' } + } | ConvertTo-Json -Compress + register: _domain_info + + - name: "Evaluate: WIN-CLI-01 — Workstation is domain-joined" + ansible.builtin.set_fact: + test_results: "{{ test_results | default([]) + [{ + 'test_id': 'WIN-CLI-01', + 'category': 'FR1 — Identification and Authentication Control', + 'requirement': 'SR 1.3 — Account Management', + 'description': 'Workstations shall be domain-joined for centralised identity management', + 'passed': ((_domain_info.stdout | from_json).PartOfDomain | bool), + 'expected': 'PartOfDomain = true', + 'actual': 'Domain = ' + (_domain_info.stdout | from_json).Domain, + 'severity': 'medium', + 'remediation': 'Join workstation to Active Directory domain' + }] }}" + ignore_errors: yes + + # ── FR3 · SR 3.4: BitLocker full-disk encryption ────────────────────────── + + - block: + - name: "Gather: BitLocker protection status on OS drive" + ansible.windows.win_shell: | + $vol = Get-BitLockerVolume -MountPoint $env:SystemDrive -ErrorAction SilentlyContinue + if ($vol) { + [PSCustomObject]@{ + ProtectionStatus = $vol.ProtectionStatus.ToString() + EncryptionMethod = $vol.EncryptionMethod.ToString() + VolumeStatus = $vol.VolumeStatus.ToString() + } | ConvertTo-Json -Compress + } else { + '{"ProtectionStatus":"NotFound","EncryptionMethod":"None","VolumeStatus":"None"}' + } + register: _bitlocker + + - name: "Evaluate: WIN-CLI-02 — BitLocker enabled on OS drive" + ansible.builtin.set_fact: + _bl: "{{ _bitlocker.stdout | from_json }}" + + - name: "Evaluate: WIN-CLI-02 — record result" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'WIN-CLI-02', + 'category': 'FR3 — System Integrity', + 'requirement': 'SR 3.4 — Software and Information Integrity', + 'description': 'OS drive shall be protected with BitLocker full-disk encryption', + 'passed': (_bl.ProtectionStatus == 'On'), + 'expected': 'ProtectionStatus = On', + 'actual': 'ProtectionStatus = ' + _bl.ProtectionStatus + ', Method = ' + _bl.EncryptionMethod, + 'severity': 'high', + 'remediation': 'Enable-BitLocker -MountPoint C: -RecoveryPasswordProtector -EncryptionMethod XtsAes256' + }] }}" + ignore_errors: yes + + # ── FR2 · SR 2.5: Screen lock timeout (registry-based check) ───────────── + # GPO sets HKLM\Software\Policies\Microsoft\Windows\Personalization\ScreenSaveTimeOut + # or the legacy HKCU path. We check the machine-level policy value. + + - block: + - name: "Gather: Screen saver timeout registry value (machine policy)" + ansible.windows.win_reg_stat: + path: HKLM:\Software\Policies\Microsoft\Windows\Control Panel\Desktop + name: ScreenSaveTimeOut + register: _screensaver_timeout + + - name: "Gather: Screen saver active registry value" + ansible.windows.win_reg_stat: + path: HKLM:\Software\Policies\Microsoft\Windows\Control Panel\Desktop + name: ScreenSaveActive + register: _screensaver_active + + - name: "Evaluate: WIN-CLI-03 — Screen lock timeout ≤ 900 seconds" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'WIN-CLI-03', + 'category': 'FR2 — Use Control', + 'requirement': 'SR 2.5 — Session Lock', + 'description': 'Workstation shall lock after ≤ 900 seconds (15 min) of inactivity', + 'passed': ( + _screensaver_active.exists and + (_screensaver_active.value | string == '1') and + _screensaver_timeout.exists and + (_screensaver_timeout.value | int > 0) and + (_screensaver_timeout.value | int <= 900) + ), + 'expected': 'ScreenSaveActive=1, ScreenSaveTimeOut ≤ 900', + 'actual': ( + 'ScreenSaveActive=' + (_screensaver_active.value | default('NOT SET') | string) + + ', ScreenSaveTimeOut=' + (_screensaver_timeout.value | default('NOT SET') | string) + ), + 'severity': 'medium', + 'remediation': 'Apply GPO: Computer Configuration → Admin Templates → Control Panel → Personalization → Screen saver timeout = 900' + }] }}" + ignore_errors: yes + + # ── FR5 · SR 5.1: Windows Firewall on Public profile ───────────────────── + # Clients in the ICS zone should enforce the Public profile firewall. + + - block: + - name: "Gather: Public firewall profile state and default inbound action" + ansible.windows.win_shell: | + Get-NetFirewallProfile -Name Public | + Select-Object Name, Enabled, DefaultInboundAction | + ConvertTo-Json -Compress + register: _pub_fw + + - name: "Evaluate: WIN-CLI-04 — Public firewall profile blocks inbound" + ansible.builtin.set_fact: + _pub_fw_json: "{{ _pub_fw.stdout | from_json }}" + + - name: "Evaluate: WIN-CLI-04 — record result" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'WIN-CLI-04', + 'category': 'FR5 — Restricted Data Flow', + 'requirement': 'SR 5.1 — Network Segmentation', + 'description': 'Public firewall profile shall be enabled with default inbound Block', + 'passed': ( + _pub_fw_json.Enabled | bool and + _pub_fw_json.DefaultInboundAction == 'Block' + ), + 'expected': 'Public profile: Enabled=True, DefaultInboundAction=Block', + 'actual': 'Public: Enabled=' + (_pub_fw_json.Enabled | string) + ', DefaultInboundAction=' + _pub_fw_json.DefaultInboundAction, + 'severity': 'high', + 'remediation': 'Set-NetFirewallProfile -Name Public -Enabled True -DefaultInboundAction Block' + }] }}" + ignore_errors: yes + + # ── HITL · FR1 · SR 1.3: Physical access and USB port controls ──────────── + # Cannot be verified remotely; requires physical inspection or policy review. + + - block: + - name: "Gather: [HITL] USB storage device policy registry" + ansible.windows.win_reg_stat: + path: HKLM:\SYSTEM\CurrentControlSet\Services\UsbStor + name: Start + register: _usb_stor + + - name: "Display: [HITL] WIN-CLI-HITL-01 — USB storage and physical access" + ansible.builtin.debug: + msg: | + ══════════════════════════════════════════════════════════════ + MANUAL REVIEW REQUIRED · WIN-CLI-HITL-01 · {{ inventory_hostname }} + ══════════════════════════════════════════════════════════════ + Requirement : SR 1.3 — Account Management + Check : Physical USB ports and removable media controls + + Registry evidence (UsbStor Start value): + HKLM\SYSTEM\CurrentControlSet\Services\UsbStor\Start + Value: {{ _usb_stor.value | default('KEY NOT FOUND') }} + (4 = disabled, 3 = manual/enabled, key absent = check GPO) + + Also confirm: + - Unused USB ports physically blocked or disabled in BIOS + - No unauthorised removable media found on the workstation + ══════════════════════════════════════════════════════════════ + + - name: "Prompt: WIN-CLI-HITL-01 — verdict for {{ inventory_hostname }}" + ansible.builtin.pause: + prompt: | + USB storage is {{ 'DISABLED (Start=4)' if _usb_stor.value | default(3) | int == 4 else 'ENABLED or UNKNOWN' }} by registry policy. + After physical confirmation, does this workstation satisfy SR 1.3 USB/removable media controls? + Enter verdict [pass / fail / skip]: + register: _hitl_usb_verdict + delegate_to: localhost + + - name: "Prompt: WIN-CLI-HITL-01 — notes on failure" + ansible.builtin.pause: + prompt: "Describe finding (e.g. 'USB port active, no media policy applied'):" + register: _hitl_usb_notes + delegate_to: localhost + when: _hitl_usb_verdict.user_input | lower | trim in ['fail', 'f'] + + - name: "Evaluate: WIN-CLI-HITL-01" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'WIN-CLI-HITL-01', + 'category': 'FR1 — Identification and Authentication Control', + 'requirement': 'SR 1.3 — Account Management', + 'description': 'USB storage and removable media shall be disabled or physically controlled', + 'passed': ( + 'skipped' if (_hitl_usb_verdict.user_input | lower | trim in ['skip', 's', '']) + else (_hitl_usb_verdict.user_input | lower | trim in ['pass', 'p']) + ), + 'expected': 'USB storage disabled (UsbStor Start=4) AND physical ports secured', + 'actual': 'UsbStor Start = ' + (_usb_stor.value | default('NOT SET') | string), + 'severity': 'high', + 'remediation': 'Set HKLM\SYSTEM\CurrentControlSet\Services\UsbStor\Start = 4 via GPO, and physically block or tape ports', + 'reviewer': ansible_user_id, + 'notes': (_hitl_usb_notes.user_input | trim) if _hitl_usb_notes is defined else '' + }] }}" + ignore_errors: yes + + # ── Generate report ──────────────────────────────────────────────────────── + + - name: "Generate compliance report" + ansible.builtin.include_tasks: ../library/report.yml diff --git a/playbooks/examples/windows_server.yml b/playbooks/examples/windows_server.yml new file mode 100644 index 0000000..a6d8da6 --- /dev/null +++ b/playbooks/examples/windows_server.yml @@ -0,0 +1,204 @@ +--- +# ══════════════════════════════════════════════════════════════════════════════ +# IEC 62443-3-3 SL2 — Windows Server Compliance +# +# Target type : Windows Server 2016 / 2019 / 2022 +# Connection : WinRM (HTTP :5985 or HTTPS :5986) +# Collections : ansible.windows (ships with Ansible) +# Python pkg : pywinrm (installed in ansible-node image) +# Privilege : No become required — WinRM user needs local admin rights +# +# Inventory group : [windows_servers] (see inventory.ini) +# +# Run: +# ansible-playbook -i inventory.ini playbooks/examples/windows_server.yml +# +# WinRM quick-enable on target (run as Administrator): +# winrm quickconfig -q +# winrm set winrm/config/service/auth '@{Basic="true"}' +# winrm set winrm/config/service '@{AllowUnencrypted="true"}' +# # For production: use HTTPS and Kerberos transport instead +# +# Vault usage (recommended for passwords): +# ansible-vault encrypt_string 'MyPassword' --name ansible_password +# ══════════════════════════════════════════════════════════════════════════════ + +- name: "IEC 62443-3-3 SL2 — Windows Server Compliance" + hosts: windows_servers + 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: + + # ── FR1 · SR 1.5: Minimum password length ───────────────────────────────── + # Uses win_security_policy — no PowerShell shell-out needed. + + - block: + - name: "Gather: Minimum password length (security policy)" + ansible.windows.win_security_policy: + section: System Access + key: MinimumPasswordLength + register: _min_pw_len + + - name: "Evaluate: WIN-IAC-01 — Password minimum length ≥ 14" + ansible.builtin.set_fact: + test_results: "{{ test_results | default([]) + [{ + 'test_id': 'WIN-IAC-01', + 'category': 'FR1 — Identification and Authentication Control', + 'requirement': 'SR 1.5 — Authenticator Strength', + 'description': 'Windows local password policy minimum length shall be ≥ 14 characters', + 'passed': (_min_pw_len.value | int >= 14), + 'expected': 'MinimumPasswordLength ≥ 14', + 'actual': 'MinimumPasswordLength = ' + (_min_pw_len.value | string), + 'severity': 'high', + 'remediation': 'Computer Configuration → Windows Settings → Security Settings → Account Policies → Password Policy → Minimum password length: 14' + }] }}" + ignore_errors: yes + + # ── FR1 · SR 1.11: Account lockout threshold ────────────────────────────── + + - block: + - name: "Gather: Account lockout threshold" + ansible.windows.win_security_policy: + section: System Access + key: LockoutBadCount + register: _lockout_count + + - name: "Evaluate: WIN-IAC-02 — Account lockout ≤ 5 attempts" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'WIN-IAC-02', + 'category': 'FR1 — Identification and Authentication Control', + 'requirement': 'SR 1.11 — Unsuccessful Login Attempts', + 'description': 'Account lockout shall trigger after ≤ 5 failed login attempts', + 'passed': ( + (_lockout_count.value | int > 0) and + (_lockout_count.value | int <= 5) + ), + 'expected': 'LockoutBadCount between 1 and 5', + 'actual': 'LockoutBadCount = ' + (_lockout_count.value | string), + 'severity': 'high', + 'remediation': 'Set Account lockout threshold to 5 in Local Security Policy' + }] }}" + ignore_errors: yes + + # ── FR2 · SR 2.8: Audit policy — logon events ───────────────────────────── + # Uses win_audit_policy_system — no auditpol.exe shell-out needed. + + - block: + - name: "Gather: Audit policy — Logon subcategory" + ansible.windows.win_audit_policy_system: + subcategory: Logon + register: _audit_logon + + - name: "Evaluate: WIN-UC-01 — Logon events audited" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'WIN-UC-01', + 'category': 'FR2 — Use Control', + 'requirement': 'SR 2.8 — Auditable Events', + 'description': 'Logon/logoff events shall be audited (success and failure)', + 'passed': (_audit_logon.auditing_mode == 'success and failure'), + 'expected': 'Logon: success and failure', + 'actual': 'Logon: ' + _audit_logon.auditing_mode, + 'severity': 'high', + 'remediation': 'auditpol /set /subcategory:"Logon" /success:enable /failure:enable' + }] }}" + ignore_errors: yes + + # ── FR2 · SR 2.8: Audit policy — privilege use ──────────────────────────── + + - block: + - name: "Gather: Audit policy — Sensitive Privilege Use" + ansible.windows.win_audit_policy_system: + subcategory: Sensitive Privilege Use + register: _audit_privuse + + - name: "Evaluate: WIN-UC-02 — Privilege use audited" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'WIN-UC-02', + 'category': 'FR2 — Use Control', + 'requirement': 'SR 2.8 — Auditable Events', + 'description': 'Sensitive privilege use (SeDebugPrivilege, SeTcbPrivilege, etc.) shall be audited', + 'passed': (_audit_privuse.auditing_mode in ['success and failure', 'failure']), + 'expected': 'Sensitive Privilege Use: failure (at minimum)', + 'actual': 'Sensitive Privilege Use: ' + _audit_privuse.auditing_mode, + 'severity': 'medium', + 'remediation': 'auditpol /set /subcategory:"Sensitive Privilege Use" /failure:enable' + }] }}" + ignore_errors: yes + + # ── FR5 · SR 5.1: Windows Firewall enabled on all profiles ──────────────── + # PowerShell ConvertTo-Json + Ansible from_json filter avoids regex parsing. + + - block: + - name: "Gather: Windows Firewall profile states" + ansible.windows.win_shell: | + @(Get-NetFirewallProfile | Select-Object Name, Enabled, DefaultInboundAction) | + ConvertTo-Json -Compress + register: _fw_profiles + + - name: "Evaluate: WIN-RDF-01 — Firewall enabled on all profiles" + ansible.builtin.set_fact: + _fw_json: "{{ _fw_profiles.stdout | from_json }}" + + - name: "Evaluate: WIN-RDF-01 — record result" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'WIN-RDF-01', + 'category': 'FR5 — Restricted Data Flow', + 'requirement': 'SR 5.1 — Network Segmentation', + 'description': 'Windows Firewall shall be enabled on Domain, Private, and Public profiles', + 'passed': (_fw_json | selectattr('Enabled', 'equalto', true) | list | length == 3), + 'expected': 'All 3 firewall profiles Enabled = True', + 'actual': _fw_json | map(attribute='Name') | zip(_fw_json | map(attribute='Enabled')) | list | string, + 'severity': 'critical', + 'remediation': 'Set-NetFirewallProfile -All -Enabled True' + }] }}" + ignore_errors: yes + + # ── FR5 · SR 5.3: Telnet / FTP / RDP services ───────────────────────────── + # Uses win_service_info — typed return dict, no regex needed. + + - block: + - name: "Gather: Telnet service state" + ansible.windows.win_service_info: + name: TlntSvr + register: _telnet_svc + + - name: "Evaluate: WIN-RDF-02 — Telnet service disabled" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'WIN-RDF-02', + 'category': 'FR5 — Restricted Data Flow', + 'requirement': 'SR 5.3 — Communication Constraints', + 'description': 'The Telnet Server service (TlntSvr) shall be absent or disabled', + 'passed': ( + _telnet_svc.services | length == 0 or + _telnet_svc.services[0].start_mode == 'disabled' + ), + 'expected': 'TlntSvr: absent or start_mode=disabled', + 'actual': ( + 'TlntSvr: state=' + _telnet_svc.services[0].state + + ', start_mode=' + _telnet_svc.services[0].start_mode + ) if _telnet_svc.services | length > 0 else 'TlntSvr: not installed', + 'severity': 'critical', + 'remediation': 'Stop-Service TlntSvr; Set-Service TlntSvr -StartupType Disabled' + }] }}" + ignore_errors: yes + + # ── Generate report ──────────────────────────────────────────────────────── + + - name: "Generate compliance report" + ansible.builtin.include_tasks: ../library/report.yml diff --git a/playbooks/library/report.yml b/playbooks/library/report.yml index a57a26a..969e09f 100644 --- a/playbooks/library/report.yml +++ b/playbooks/library/report.yml @@ -27,11 +27,10 @@ 'total': test_results | length, 'passed': test_results | selectattr('passed', 'equalto', true) | list | length, 'failed': test_results | selectattr('passed', 'equalto', false) | list | length, + 'review': test_results | selectattr('passed', 'equalto', 'review') | list | length, 'skipped': test_results | selectattr('passed', 'equalto', 'skipped') | list | length }, - 'by_category': test_results | groupby('category') | map( - 'regex_replace', '^(.*)$', '\\1' - ) | list, + 'by_category': test_results | groupby('category') | list, 'by_severity': { 'critical': test_results | selectattr('severity', 'equalto', 'critical') | list, 'high': test_results | selectattr('severity', 'equalto', 'high') | list, @@ -61,4 +60,3 @@ content: "{{ __report | to_nice_json(indent=2) }}" dest: "./reports/{{ inventory_hostname }}-{{ ansible_date_time.date }}.json" delegate_to: localhost - run_once: true diff --git a/playbooks/suites/fr1_auth.yml b/playbooks/suites/fr1_auth.yml index 542cb2f..ab56e13 100644 --- a/playbooks/suites/fr1_auth.yml +++ b/playbooks/suites/fr1_auth.yml @@ -137,7 +137,7 @@ 'requirement': 'SR 1.5 — Authenticator Strength', 'description': 'Password minimum length shall be ≥ 14 characters', 'passed': ( - _minlen.stdout | regex_search('minlen\s*=\s*([0-9]+)') | regex_replace('minlen\s*=\s*', '') | int >= 14 + (_minlen.stdout | regex_search('minlen\s*=\s*(\d+)', '\1') | default(['0'], true) | first | int) >= 14 ), 'expected': 'minlen >= 14 in /etc/security/pwquality.conf', 'actual': _minlen.stdout | trim, @@ -194,7 +194,11 @@ 'category': 'FR1 - Identification and Authentication Control', 'requirement': 'SR 1.7 — Password Lifetime', 'description': 'Password maximum age shall be ≤ 90 days', - 'passed': (_max_days.stdout | trim | int <= 90), + 'passed': ( + _max_days.stdout | trim | regex_search('^[0-9]+$') and + (_max_days.stdout | trim | int > 0) and + (_max_days.stdout | trim | int <= 90) + ), 'expected': 'PASS_MAX_DAYS ≤ 90', 'actual': 'PASS_MAX_DAYS=' + (_max_days.stdout | trim | default('NOT SET', true)), 'severity': 'medium', diff --git a/playbooks/templates/test_automated.yml b/playbooks/templates/test_automated.yml new file mode 100644 index 0000000..d9884f5 --- /dev/null +++ b/playbooks/templates/test_automated.yml @@ -0,0 +1,70 @@ +--- +# ══════════════════════════════════════════════════════════════════════ +# TEMPLATE: Automated Shell-Based Test +# ══════════════════════════════════════════════════════════════════════ +# +# The standard gather → evaluate pattern used throughout this framework. +# Copy this block into the appropriate FR suite file (suites/frN_*.yml) +# and fill in all UPPERCASE placeholders. +# +# How to use: +# 1. Copy the block below into suites/frN_category.yml +# 2. Replace every UPPERCASE placeholder +# 3. Write your gather shell command to produce meaningful stdout +# 4. Write the 'passed' Jinja2 expression that evaluates the result +# 5. Set severity: critical | high | medium | low +# +# Pass/fail expression patterns: +# +# # Empty output means no findings (good): +# 'passed': (_result.stdout | trim | length == 0), +# +# # Numeric threshold (value must exist and be within range): +# 'passed': ( +# _result.stdout | trim | regex_search('^[0-9]+$') and +# (_result.stdout | trim | int > 0) and +# (_result.stdout | trim | int <= 90) +# ), +# +# # Extract a number from labelled output (e.g. "minlen = 14"): +# 'passed': ( +# (_result.stdout | regex_search('label\s*=\s*(\d+)', '\1') +# | default(['0'], true) | first | int) >= 14 +# ), +# +# # String match: +# 'passed': (_result.stdout | trim == 'expected_value'), +# +# # Specific value is absent: +# 'passed': ('dangerous_string' not in _result.stdout), +# +# ══════════════════════════════════════════════════════════════════════ + +# ── SUITE_ID: SHORT_DESCRIPTION ───────────────────────────────────── + +- block: + - name: "Gather: DESCRIBE_WHAT_IS_COLLECTED" + ansible.builtin.shell: | + # Replace with your data collection command. + # Guidelines: + # - Use grep/awk/cut to narrow output to only the relevant data. + # - Produce empty stdout when no finding exists (makes 'passed' easy). + # - Exit 0 always; let Ansible evaluate the output, not the exit code. + echo "replace_me" + register: _result + changed_when: false + + - name: "Evaluate: TEST_ID" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'TEST_ID', + 'category': 'FR_NUMBER — CATEGORY_NAME', + 'requirement': 'SR X.Y — REQUIREMENT_NAME', + 'description': 'One-line description of what is being checked', + 'passed': (_result.stdout | trim | length == 0), + 'expected': 'What a passing system looks like', + 'actual': (_result.stdout | trim | default('OK', true)), + 'severity': 'high', + 'remediation': 'Exact command or configuration change to fix this finding' + }] }}" + ignore_errors: yes diff --git a/playbooks/templates/test_file_check.yml b/playbooks/templates/test_file_check.yml new file mode 100644 index 0000000..056b037 --- /dev/null +++ b/playbooks/templates/test_file_check.yml @@ -0,0 +1,73 @@ +--- +# ══════════════════════════════════════════════════════════════════════ +# TEMPLATE: File Permission / Ownership Check +# ══════════════════════════════════════════════════════════════════════ +# +# Uses ansible.builtin.stat — no shell command needed. +# Prefer this over shelling out to stat(1) for file attribute checks. +# The stat module returns a structured dict with typed values, which +# makes the 'passed' expression straightforward and readable. +# +# Useful stat attributes: +# stat.exists — bool: file is present +# stat.mode — string: octal permissions, e.g. '0640' +# stat.pw_name — string: owning user name, e.g. 'root' +# stat.gr_name — string: owning group name, e.g. 'shadow' +# stat.size — int: file size in bytes +# stat.isreg — bool: is a regular file +# stat.isdir — bool: is a directory +# stat.islnk — bool: is a symlink +# +# Common 'passed' expression patterns: +# +# # File exists with exact owner/group/mode: +# 'passed': ( +# _stat.stat.exists and +# _stat.stat.pw_name == 'root' and +# _stat.stat.gr_name == 'root' and +# _stat.stat.mode == '0640' +# ), +# +# # File must NOT exist: +# 'passed': not _stat.stat.exists, +# +# # File must be a regular file (not a symlink) with tight permissions: +# 'passed': ( +# _stat.stat.exists and +# _stat.stat.isreg and +# not _stat.stat.islnk and +# _stat.stat.mode in ['0400', '0440', '0600'] +# ), +# +# ══════════════════════════════════════════════════════════════════════ + +# ── SUITE_ID: SHORT_DESCRIPTION ───────────────────────────────────── + +- block: + - name: "Gather: Stat /path/to/file" + ansible.builtin.stat: + path: /path/to/file + register: _stat + + - name: "Evaluate: TEST_ID" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'TEST_ID', + 'category': 'FR_NUMBER — CATEGORY_NAME', + 'requirement': 'SR X.Y — REQUIREMENT_NAME', + 'description': '/path/to/file shall be owned by root:root with mode 0640', + 'passed': ( + _stat.stat.exists and + _stat.stat.pw_name == 'root' and + _stat.stat.gr_name == 'root' and + _stat.stat.mode == '0640' + ), + 'expected': 'root:root 0640', + 'actual': ( + (_stat.stat.pw_name + ':' + _stat.stat.gr_name + ' ' + _stat.stat.mode) + if _stat.stat.exists else 'FILE NOT FOUND' + ), + 'severity': 'high', + 'remediation': 'chown root:root /path/to/file && chmod 0640 /path/to/file' + }] }}" + ignore_errors: yes diff --git a/playbooks/templates/test_hitl.yml b/playbooks/templates/test_hitl.yml new file mode 100644 index 0000000..ce1bb68 --- /dev/null +++ b/playbooks/templates/test_hitl.yml @@ -0,0 +1,107 @@ +--- +# ══════════════════════════════════════════════════════════════════════ +# TEMPLATE: Human-in-the-Loop (HITL) Test +# ══════════════════════════════════════════════════════════════════════ +# +# Use when a control cannot be evaluated automatically and requires a +# human reviewer to observe evidence and record a verdict. +# +# Examples of controls that need HITL: +# - Physical access controls / badge logs +# - Operator training records +# - Network diagram review +# - Custom application security configuration +# - Vendor-specific proprietary interfaces +# +# How it works (Ansible-native): +# 1. Gather tasks run against the remote host as normal. +# 2. ansible.builtin.debug displays the evidence on the console. +# 3. ansible.builtin.pause with 'delegate_to: localhost' prompts the +# reviewer on the control node, regardless of the remote target. +# For multi-host runs, the prompt fires once per host so each +# target gets an independent human verdict. +# 4. The reviewer's verdict (pass/fail/skip) and any notes are +# captured in the test_results[] record alongside the raw evidence. +# +# 'passed' field values used here: +# true — reviewer entered 'pass' or 'p' +# false — reviewer entered 'fail' or 'f' +# 'skipped' — reviewer pressed Enter or entered 'skip'/'s' +# +# All three values are handled by the report renderers. +# +# ══════════════════════════════════════════════════════════════════════ + +# ── SUITE_ID: SHORT_DESCRIPTION ───────────────────────────────────── + +- block: + # ── Gather evidence from the remote host ──────────────────────── + - name: "Gather: [HITL] DESCRIBE_WHAT_IS_COLLECTED" + ansible.builtin.shell: | + # Collect the evidence the reviewer needs to make a decision. + # Keep output focused: show only what is relevant to the check. + echo "Replace with your evidence-gathering command" + register: _hitl_evidence + changed_when: false + + # ── Present the evidence to the reviewer (appears in Ansible log) ─ + - name: "Display: [HITL] TEST_ID — evidence for review" + ansible.builtin.debug: + msg: | + ══════════════════════════════════════════════════════════════ + MANUAL REVIEW REQUIRED · TEST_ID · {{ inventory_hostname }} + ══════════════════════════════════════════════════════════════ + Requirement : SR X.Y — REQUIREMENT_NAME + Check : DESCRIPTION + + Evidence + ──────── + {{ _hitl_evidence.stdout | default('(no output collected)') | indent(1) }} + ══════════════════════════════════════════════════════════════ + + # ── Reviewer enters verdict on the control node ───────────────── + # delegate_to: localhost ensures the prompt appears locally even + # when this playbook targets remote hosts. + - name: "Prompt: TEST_ID — verdict for {{ inventory_hostname }}" + ansible.builtin.pause: + prompt: | + Review the evidence above for {{ inventory_hostname }}. + Does it satisfy SR X.Y — REQUIREMENT_NAME? + Enter verdict [pass / fail / skip]: + register: _hitl_verdict + delegate_to: localhost + + # ── Capture reviewer notes on failure ─────────────────────────── + # This task only runs when the verdict is fail/f, so _hitl_notes + # may be undefined for pass/skip results. The evaluate task below + # uses 'is defined' to handle this safely. + - name: "Prompt: TEST_ID — notes for {{ inventory_hostname }} (fail only)" + ansible.builtin.pause: + prompt: "Describe the gap or finding (required for audit trail):" + register: _hitl_notes + delegate_to: localhost + when: _hitl_verdict.user_input | lower | trim in ['fail', 'f'] + + # ── Evaluate: record verdict + evidence in test_results[] ─────── + - name: "Evaluate: TEST_ID" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'TEST_ID', + 'category': 'FR_NUMBER — CATEGORY_NAME', + 'requirement': 'SR X.Y — REQUIREMENT_NAME', + 'description': 'DESCRIPTION', + 'passed': ( + 'skipped' + if (_hitl_verdict.user_input | lower | trim in ['skip', 's', '']) + else (_hitl_verdict.user_input | lower | trim in ['pass', 'p']) + ), + 'expected': 'Reviewer confirmed control is in place', + 'actual': _hitl_evidence.stdout | trim | default('(no evidence collected)', true), + 'severity': 'SEVERITY', + 'remediation': 'REMEDIATION', + 'reviewer': ansible_user_id, + 'notes': (_hitl_notes.user_input | trim) + if _hitl_notes is defined + else '' + }] }}" + ignore_errors: yes diff --git a/playbooks/templates/test_service_check.yml b/playbooks/templates/test_service_check.yml new file mode 100644 index 0000000..367f19b --- /dev/null +++ b/playbooks/templates/test_service_check.yml @@ -0,0 +1,107 @@ +--- +# ══════════════════════════════════════════════════════════════════════ +# TEMPLATE: Service State Check +# ══════════════════════════════════════════════════════════════════════ +# +# Uses ansible.builtin.service_facts — no shell command needed. +# service_facts gathers all service states into ansible_facts.services +# as a dict keyed by service name. Prefer this over shelling out to +# systemctl for any service-related check. +# +# IMPORTANT — run service_facts ONCE per suite, not once per test. +# Put this gather task at the TOP of your suite file: +# +# - name: "Gather: Load all service states" +# ansible.builtin.service_facts: +# +# Then each test block below can query ansible_facts.services without +# running additional commands. +# +# Service dict structure (ansible_facts.services['sshd.service']): +# name: 'sshd.service' +# state: 'running' | 'stopped' | 'failed' | 'inactive' +# status: 'enabled' | 'disabled' | 'masked' | 'static' | 'unknown' +# +# Common 'passed' expression patterns: +# +# # Service must be running and enabled: +# 'passed': ( +# ansible_facts.services['sshd.service'] is defined and +# ansible_facts.services['sshd.service'].state == 'running' and +# ansible_facts.services['sshd.service'].status == 'enabled' +# ), +# +# # Service must NOT be running (insecure service check): +# 'passed': ( +# ansible_facts.services['telnet.socket'] is not defined or +# ansible_facts.services['telnet.socket'].state != 'running' +# ), +# +# # Any of several insecure services must all be absent/inactive: +# 'passed': ( +# ['telnet.socket', 'rsh.socket', 'ftp.service'] +# | map('extract', ansible_facts.services) +# | select('defined') +# | selectattr('state', 'equalto', 'running') +# | list | length == 0 +# ), +# +# ══════════════════════════════════════════════════════════════════════ + +# ── Put this ONCE at the top of the suite file ─────────────────────── +# +# - name: "Gather: Load all service states (suite-wide)" +# ansible.builtin.service_facts: +# +# ── Per-test blocks below ──────────────────────────────────────────── + +# ── SUITE_ID: Service must be running ─────────────────────────────── + +- block: + - name: "Evaluate: TEST_ID — SERVICE_NAME is running and enabled" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'TEST_ID', + 'category': 'FR_NUMBER — CATEGORY_NAME', + 'requirement': 'SR X.Y — REQUIREMENT_NAME', + 'description': 'SERVICE_NAME shall be running and enabled at boot', + 'passed': ( + ansible_facts.services['SERVICE_NAME.service'] is defined and + ansible_facts.services['SERVICE_NAME.service'].state == 'running' and + ansible_facts.services['SERVICE_NAME.service'].status == 'enabled' + ), + 'expected': 'SERVICE_NAME: state=running, status=enabled', + 'actual': ( + 'state=' + ansible_facts.services['SERVICE_NAME.service'].state + + ', status=' + ansible_facts.services['SERVICE_NAME.service'].status + ) if ansible_facts.services['SERVICE_NAME.service'] is defined + else 'SERVICE_NAME.service: not found in service facts', + 'severity': 'high', + 'remediation': 'systemctl enable --now SERVICE_NAME' + }] }}" + ignore_errors: yes + + +# ── SUITE_ID: Insecure service must NOT be running ────────────────── + +- block: + - name: "Evaluate: TEST_ID — INSECURE_SERVICE_NAME is not running" + ansible.builtin.set_fact: + test_results: "{{ test_results + [{ + 'test_id': 'TEST_ID', + 'category': 'FR_NUMBER — CATEGORY_NAME', + 'requirement': 'SR X.Y — REQUIREMENT_NAME', + 'description': 'INSECURE_SERVICE_NAME shall be disabled and not running', + 'passed': ( + ansible_facts.services['INSECURE_SERVICE_NAME.service'] is not defined or + ansible_facts.services['INSECURE_SERVICE_NAME.service'].state != 'running' + ), + 'expected': 'INSECURE_SERVICE_NAME: absent or not running', + 'actual': ( + 'state=' + ansible_facts.services['INSECURE_SERVICE_NAME.service'].state + ) if ansible_facts.services['INSECURE_SERVICE_NAME.service'] is defined + else 'not installed', + 'severity': 'critical', + 'remediation': 'systemctl disable --now INSECURE_SERVICE_NAME' + }] }}" + ignore_errors: yes diff --git a/reports/report.gohtml b/reports/report.gohtml index ed03da7..499a55d 100644 --- a/reports/report.gohtml +++ b/reports/report.gohtml @@ -17,11 +17,11 @@ To customize: copy this file, modify, run: ╔══════════════════════════════════════════════════════════════════════════╗ ║ IEC 62443-3-3 SECURITY LEVEL 2 — COMPLIANCE REPORT ║ ╠══════════════════════════════════════════════════════════════════════════╣ -║ Target: {{ (index .meta "target") }} -║ Standard: {{ (index .meta "standard") }} -║ Level: {{ (index .meta "security_level") }} -║ Timestamp: {{ (index .meta "timestamp") }} -║ Executed by: {{ (index .meta "executed_by") }} +║ Target: {{ printf "%-56s" (index .meta "target") }}║ +║ Standard: {{ printf "%-56s" (index .meta "standard") }}║ +║ Level: {{ printf "%-56s" (index .meta "security_level") }}║ +║ Timestamp: {{ printf "%-56s" (index .meta "timestamp") }}║ +║ Executed by: {{ printf "%-55s" (index .meta "executed_by") }}║ ╠══════════════════════════════════════════════════════════════════════════╣ ║ EXECUTIVE SUMMARY ║ ╠══════════════════════════════════════════════════════════════════════════╣