cyber-0 updated with more examples
This commit is contained in:
+130
-13
@@ -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
|
||||
|
||||
@@ -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 <name>' 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 <network> 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 <network> statement present',
|
||||
'actual': _mgmt_access.stdout[0] | regex_findall('ssh[^\n]+') | join(' | ') | default('No SSH access statements', true),
|
||||
'severity': 'high',
|
||||
'remediation': 'ssh <management-net> <mask> <interface>\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 <n>'
|
||||
}] }}"
|
||||
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 <interface> <syslog-server>',
|
||||
'actual': _syslog_cfg.stdout[0] | regex_findall('logging[^\n]+') | join(' | ') | default('No logging config', true),
|
||||
'severity': 'high',
|
||||
'remediation': 'logging enable\nlogging host <inside/mgmt> <syslog-server-ip>'
|
||||
}] }}"
|
||||
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 <server-group> 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 <name> in interface <name>',
|
||||
'actual': _acl_cfg.stdout[0] | regex_findall('access-group[^\n]+') | join(' | ') | default('No access-group statements', true),
|
||||
'severity': 'critical',
|
||||
'remediation': 'access-group <ACL_NAME> in interface <outside|ics_zone>'
|
||||
}] }}"
|
||||
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
|
||||
@@ -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 <unused> 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 <key>\nntp trusted-key 1\nntp server <ip> 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
|
||||
@@ -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 <VMName> -EnableSecureBoot On'
|
||||
}] }}"
|
||||
ignore_errors: yes
|
||||
|
||||
# ── FR5 · SR 5.2: Virtual switch types — no external switch for ICS VMs ──
|
||||
|
||||
- block:
|
||||
- name: "Gather: Virtual switch list with type and bound adapters"
|
||||
ansible.windows.win_shell: |
|
||||
@(Get-VMSwitch | Select-Object Name, SwitchType, AllowManagementOS,
|
||||
@{N='NetAdapterNames';E={ ($_ | Get-VMSwitchTeam -ErrorAction SilentlyContinue).NetAdapterNames -join ',' }}) |
|
||||
ConvertTo-Json -AsArray -Compress
|
||||
register: _vswitches
|
||||
|
||||
- name: "Evaluate: HV-RDF-01 — No ICS VM on switch shared with management OS"
|
||||
ansible.builtin.set_fact:
|
||||
_sw_json: "{{ _vswitches.stdout | from_json }}"
|
||||
|
||||
- name: "Evaluate: HV-RDF-01 — External switches with AllowManagementOS=true"
|
||||
ansible.builtin.set_fact:
|
||||
test_results: "{{ test_results + [{
|
||||
'test_id': 'HV-RDF-01',
|
||||
'category': 'FR5 — Restricted Data Flow',
|
||||
'requirement': 'SR 5.2 — Zone Boundary Protection',
|
||||
'description': 'External vSwitches shared with the management OS shall not carry ICS VM traffic',
|
||||
'passed': 'review',
|
||||
'expected': 'ICS VMs connected to Private or Internal switches only',
|
||||
'actual': 'External switches with AllowManagementOS=true: '
|
||||
+ (_sw_json | selectattr('SwitchType', 'equalto', 'External')
|
||||
| selectattr('AllowManagementOS')
|
||||
| map(attribute='Name') | join(', ') | default('none', true)),
|
||||
'severity': 'critical',
|
||||
'remediation': 'Assign ICS VMs to a dedicated Internal vSwitch; disable AllowManagementOS on ICS switches'
|
||||
}] }}"
|
||||
ignore_errors: yes
|
||||
|
||||
# ── FR2 · SR 2.8: Hyper-V audit logging — VM connect activity ────────────
|
||||
|
||||
- block:
|
||||
- name: "Gather: Hyper-V audit log entries (last 50 events)"
|
||||
ansible.windows.win_shell: |
|
||||
$events = Get-WinEvent -LogName 'Microsoft-Windows-Hyper-V-VMMS-Admin' `
|
||||
-MaxEvents 50 -ErrorAction SilentlyContinue
|
||||
$count = if ($events) { $events.Count } else { 0 }
|
||||
[PSCustomObject]@{
|
||||
LogExists = [bool](Get-WinEvent -ListLog 'Microsoft-Windows-Hyper-V-VMMS-Admin' -ErrorAction SilentlyContinue)
|
||||
EventCount = $count
|
||||
} | ConvertTo-Json -Compress
|
||||
register: _hv_audit
|
||||
|
||||
- name: "Evaluate: HV-UC-01 — Hyper-V admin event log active"
|
||||
ansible.builtin.set_fact:
|
||||
_hv_audit_json: "{{ _hv_audit.stdout | from_json }}"
|
||||
|
||||
- name: "Evaluate: HV-UC-01 — record result"
|
||||
ansible.builtin.set_fact:
|
||||
test_results: "{{ test_results + [{
|
||||
'test_id': 'HV-UC-01',
|
||||
'category': 'FR2 — Use Control',
|
||||
'requirement': 'SR 2.8 — Auditable Events',
|
||||
'description': 'Hyper-V VMMS Admin event log shall be present and collecting events',
|
||||
'passed': (_hv_audit_json.LogExists | bool),
|
||||
'expected': 'Microsoft-Windows-Hyper-V-VMMS-Admin log exists',
|
||||
'actual': 'LogExists=' + (_hv_audit_json.LogExists | string) + ', RecentEvents=' + (_hv_audit_json.EventCount | string),
|
||||
'severity': 'medium',
|
||||
'remediation': 'Enable the Hyper-V VMMS Admin event log via Event Viewer or wevtutil'
|
||||
}] }}"
|
||||
ignore_errors: yes
|
||||
|
||||
# ── FR3 · SR 3.2: VM integration services version ─────────────────────────
|
||||
# Outdated integration services can expose VMs to vulnerabilities.
|
||||
|
||||
- block:
|
||||
- name: "Gather: VM integration services version summary"
|
||||
ansible.windows.win_shell: |
|
||||
@(Get-VM | Where-Object { $_.State -eq 'Running' } |
|
||||
ForEach-Object {
|
||||
$vmics = Get-VMIntegrationService -VM $_ |
|
||||
Where-Object { -not $_.Enabled }
|
||||
[PSCustomObject]@{
|
||||
VMName = $_.Name
|
||||
DisabledServices = ($vmics | Select-Object -ExpandProperty Name) -join ', '
|
||||
DisabledCount = $vmics.Count
|
||||
}
|
||||
}) | ConvertTo-Json -AsArray -Compress
|
||||
register: _ics_versions
|
||||
|
||||
- name: "Evaluate: HV-SI-02 — All critical integration services enabled"
|
||||
ansible.builtin.set_fact:
|
||||
_ics_json: "{{ _ics_versions.stdout | from_json }}"
|
||||
|
||||
- name: "Evaluate: HV-SI-02 — record result"
|
||||
ansible.builtin.set_fact:
|
||||
test_results: "{{ test_results + [{
|
||||
'test_id': 'HV-SI-02',
|
||||
'category': 'FR3 — System Integrity',
|
||||
'requirement': 'SR 3.2 — Malicious Code Protection',
|
||||
'description': 'All running VMs shall have Hyper-V Integration Services fully enabled',
|
||||
'passed': (
|
||||
_ics_json | selectattr('DisabledCount', 'greaterthan', 0) | list | length == 0
|
||||
),
|
||||
'expected': 'No disabled integration services on any running VM',
|
||||
'actual': (
|
||||
'VMs with disabled services: '
|
||||
+ (_ics_json | selectattr('DisabledCount', 'greaterthan', 0) | map(attribute='VMName') | join(', ') | default('none', true))
|
||||
),
|
||||
'severity': 'medium',
|
||||
'remediation': 'Enable-VMIntegrationService -VMName <name> -Name "Guest Service Interface"'
|
||||
}] }}"
|
||||
ignore_errors: yes
|
||||
|
||||
# ── HITL · FR5 · SR 5.2: Physical host network cable review ──────────────
|
||||
# Confirm management NIC and ICS NIC are physically separate cables/switches.
|
||||
|
||||
- block:
|
||||
- name: "Gather: [HITL] Physical network adapter list"
|
||||
ansible.windows.win_shell: |
|
||||
@(Get-NetAdapter | Where-Object { $_.Status -ne 'Not Present' } |
|
||||
Select-Object Name, InterfaceDescription, Status, LinkSpeed, MacAddress) |
|
||||
ConvertTo-Json -AsArray -Compress
|
||||
register: _net_adapters
|
||||
|
||||
- name: "Display: [HITL] HV-RDF-HITL-01 — Physical NIC segregation"
|
||||
ansible.builtin.debug:
|
||||
msg: |
|
||||
══════════════════════════════════════════════════════════════
|
||||
MANUAL REVIEW REQUIRED · HV-RDF-HITL-01 · {{ inventory_hostname }}
|
||||
══════════════════════════════════════════════════════════════
|
||||
Requirement : SR 5.2 — Zone Boundary Protection
|
||||
Check : Physical NICs for ICS vSwitch are on a separate
|
||||
physical switch from the management/IT network
|
||||
|
||||
Detected network adapters
|
||||
─────────────────────────
|
||||
{% for nic in _net_adapters.stdout | from_json %}
|
||||
{{ nic.Name }} | {{ nic.InterfaceDescription }} | {{ nic.Status }} | {{ nic.LinkSpeed }}
|
||||
{% endfor %}
|
||||
|
||||
Verify physically:
|
||||
- Which adapters are bound to the ICS vSwitch?
|
||||
- Do those cables go to a different physical switch than management NICs?
|
||||
══════════════════════════════════════════════════════════════
|
||||
|
||||
- name: "Prompt: HV-RDF-HITL-01 — verdict for {{ inventory_hostname }}"
|
||||
ansible.builtin.pause:
|
||||
prompt: |
|
||||
Are ICS vSwitch uplink NICs physically separated (different switch) from management NICs?
|
||||
Enter verdict [pass / fail / skip]:
|
||||
register: _hitl_nic_verdict
|
||||
delegate_to: localhost
|
||||
|
||||
- name: "Prompt: HV-RDF-HITL-01 — notes on failure"
|
||||
ansible.builtin.pause:
|
||||
prompt: "Describe the cabling gap (e.g. 'ICS and management share same ToR switch'):"
|
||||
register: _hitl_nic_notes
|
||||
delegate_to: localhost
|
||||
when: _hitl_nic_verdict.user_input | lower | trim in ['fail', 'f']
|
||||
|
||||
- name: "Evaluate: HV-RDF-HITL-01"
|
||||
ansible.builtin.set_fact:
|
||||
test_results: "{{ test_results + [{
|
||||
'test_id': 'HV-RDF-HITL-01',
|
||||
'category': 'FR5 — Restricted Data Flow',
|
||||
'requirement': 'SR 5.2 — Zone Boundary Protection',
|
||||
'description': 'ICS vSwitch uplink NICs shall be physically separate from management network NICs',
|
||||
'passed': (
|
||||
'skipped' if (_hitl_nic_verdict.user_input | lower | trim in ['skip', 's', ''])
|
||||
else (_hitl_nic_verdict.user_input | lower | trim in ['pass', 'p'])
|
||||
),
|
||||
'expected': 'Separate physical cables and switches for ICS and management traffic',
|
||||
'actual': 'Adapters found: ' + (_net_adapters.stdout | from_json | map(attribute='Name') | join(', ')),
|
||||
'severity': 'critical',
|
||||
'remediation': 'Install dedicated NICs for ICS vSwitch and connect to isolated physical switch',
|
||||
'reviewer': ansible_user_id,
|
||||
'notes': (_hitl_nic_notes.user_input | trim) if _hitl_nic_notes is defined else ''
|
||||
}] }}"
|
||||
ignore_errors: yes
|
||||
|
||||
# ── Generate report ────────────────────────────────────────────────────────
|
||||
|
||||
- name: "Generate compliance report"
|
||||
ansible.builtin.include_tasks: ../library/report.yml
|
||||
@@ -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
|
||||
@@ -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 ''<principal>'', ''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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 ║
|
||||
╠══════════════════════════════════════════════════════════════════════════╣
|
||||
|
||||
Reference in New Issue
Block a user