cyber-0 updated with more examples

This commit is contained in:
Ole
2026-08-17 10:42:32 +02:00
parent 0658f743b4
commit 5d114b551a
16 changed files with 2520 additions and 24 deletions
+130 -13
View File
@@ -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
+268
View File
@@ -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
+285
View File
@@ -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
+261
View File
@@ -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
+207
View File
@@ -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
+248
View File
@@ -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
+301
View File
@@ -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
+246
View File
@@ -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
+204
View File
@@ -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
+2 -4
View File
@@ -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
+6 -2
View File
@@ -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',
+70
View File
@@ -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
+73
View File
@@ -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
+107
View File
@@ -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
+107
View File
@@ -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
+5 -5
View File
@@ -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 ║
╠══════════════════════════════════════════════════════════════════════════╣