--- # ══════════════════════════════════════════════════════════════════════════════ # IEC 62443-3-3 SL2 — Cisco Switch Compliance (IOS / IOS-XE) # # Target type : Cisco Catalyst / IOS-XE access and distribution switches # Connection : SSH via ansible.netcommon.network_cli # Collections : cisco.ios, ansible.netcommon (installed in ansible-node image) # Python pkg : paramiko, netmiko (installed in ansible-node image) # # Inventory group : [cisco_switches] (see inventory.ini) # # Run: # ansible-playbook -i inventory.ini playbooks/examples/cisco_switch.yml # # How network_cli output works: # - cisco.ios.ios_command returns stdout as a list, one entry per command. # Access the first command's output with: _result.stdout[0] # - Output is a plain text string; use regex_search / regex_findall to parse. # - ios_facts populates ansible_net_* variables (hostname, version, interfaces) # and is used here to gather facts once for multiple tests. # # Note on gather_facts: # Ansible's default gather_facts runs ios_facts automatically when # ansible_network_os is set. Set gather_facts: yes to use ansible_net_* # variables, or gather_facts: no and call ios_facts explicitly. # ══════════════════════════════════════════════════════════════════════════════ - name: "IEC 62443-3-3 SL2 — Cisco Switch Compliance" hosts: cisco_switches gather_facts: yes # runs cisco.ios.ios_facts → populates ansible_net_* vars: report_dir: "../../reports" pre_tasks: - name: "Gather local facts for report timestamp and user" ansible.builtin.setup: gather_subset: - date_time - user_id delegate_to: localhost run_once: true - name: "Ensure report directory exists" ansible.builtin.file: path: "{{ report_dir }}" state: directory mode: "0755" delegate_to: localhost run_once: true tasks: # ── FR5 · SR 5.3: SSH v2 only, Telnet disabled on VTY lines ────────────── - block: - name: "Gather: SSH version and VTY transport settings" cisco.ios.ios_command: commands: - show ip ssh - show running-config | section line vty register: _ssh_vty - name: "Evaluate: SW-RDF-01 — SSH version 2 configured" ansible.builtin.set_fact: test_results: "{{ test_results | default([]) + [{ 'test_id': 'SW-RDF-01', 'category': 'FR5 — Restricted Data Flow', 'requirement': 'SR 5.3 — Communication Constraints', 'description': 'SSH version shall be 2 (SSHv1 disabled)', 'passed': (_ssh_vty.stdout[0] | regex_search('SSH Enabled.*version 2') is not none), 'expected': 'SSH Enabled - version 2.0', 'actual': _ssh_vty.stdout[0] | regex_search('SSH Enabled[^\n]+') | default('SSH status not found', true), 'severity': 'high', 'remediation': 'ip ssh version 2' }] }}" - name: "Evaluate: SW-RDF-02 — Telnet disabled on VTY lines" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'SW-RDF-02', 'category': 'FR5 — Restricted Data Flow', 'requirement': 'SR 5.3 — Communication Constraints', 'description': 'VTY lines shall only permit SSH transport (no Telnet)', 'passed': ( 'transport input ssh' in _ssh_vty.stdout[1] and 'transport input telnet' not in _ssh_vty.stdout[1] and 'transport input all' not in _ssh_vty.stdout[1] ), 'expected': 'transport input ssh (only) on all VTY lines', 'actual': _ssh_vty.stdout[1] | regex_findall('transport input[^\n]+') | join(' | ') | default('NOT SET', true), 'severity': 'critical', 'remediation': 'line vty 0 15\n transport input ssh' }] }}" ignore_errors: yes # ── FR1 · SR 1.1: No SNMPv1 or SNMPv2c communities ─────────────────────── # SNMPv1/v2c use cleartext community strings — equivalent to passwords in clear. - block: - name: "Gather: SNMP community string configuration" cisco.ios.ios_command: commands: - show running-config | include snmp-server community register: _snmp_config - name: "Evaluate: SW-IAC-01 — No SNMPv1/v2c community strings" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'SW-IAC-01', 'category': 'FR1 — Identification and Authentication Control', 'requirement': 'SR 1.1 — Unique User Identification', 'description': 'SNMPv1/v2c community strings shall be absent; use SNMPv3 with auth+priv', 'passed': (_snmp_config.stdout[0] | trim | length == 0), 'expected': 'No snmp-server community lines in running-config', 'actual': ( _snmp_config.stdout[0] | trim | default('No SNMP community strings found', true) ), 'severity': 'high', 'remediation': 'Remove all snmp-server community entries; configure snmp-server group/user with authPriv' }] }}" ignore_errors: yes # ── FR1 · SR 1.7: Login banner configured ───────────────────────────────── # A warning banner is a legal and technical requirement under IEC 62443. - block: - name: "Gather: Login banner text" cisco.ios.ios_command: commands: - show running-config | section banner login register: _banner - name: "Evaluate: SW-IAC-02 — Login warning banner configured" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'SW-IAC-02', 'category': 'FR1 — Identification and Authentication Control', 'requirement': 'SR 1.7 — Strength of Password-based Authentication', 'description': 'A warning banner shall be displayed before login (authorised use only)', 'passed': ('banner login' in _banner.stdout[0]), 'expected': 'banner login block configured', 'actual': _banner.stdout[0] | regex_search('banner login [^\n]+') | default('No banner login configured', true), 'severity': 'medium', 'remediation': "banner login ^C\nAUTHORIZED ACCESS ONLY. Unauthorised access is prohibited.\n^C" }] }}" ignore_errors: yes # ── FR5 · SR 5.3: Unused interfaces shut down ───────────────────────────── # Uses ios_facts (already gathered) — no additional command needed. - block: - name: "Evaluate: SW-RDF-03 — No interfaces in admin-down state with connected status" # ansible_net_interfaces is a dict keyed by interface name. # We look for interfaces that are 'up' operationally but not in shutdown config. # A simpler check: count of interfaces in 'administratively down' that have # a connected line protocol (should never exist if properly shut). ansible.builtin.set_fact: _intf_up_no_shutdown: "{{ ansible_net_interfaces | dict2items | selectattr('value.operstatus', 'equalto', 'up') | selectattr('value.lineprotocol', 'equalto', 'down') | map(attribute='key') | list }}" - name: "Gather: Interfaces shutdown in config (no description = unused)" cisco.ios.ios_command: commands: - show interfaces status | include notconnect|disabled register: _intf_status - name: "Evaluate: SW-RDF-03 — record result" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'SW-RDF-03', 'category': 'FR5 — Restricted Data Flow', 'requirement': 'SR 5.3 — Communication Constraints', 'description': 'All unused access ports shall be administratively shut down', 'passed': 'review', 'expected': 'All notconnect ports in shutdown state in config', 'actual': 'Not-connected or disabled ports:\n' + _intf_status.stdout[0] | trim | truncate(300, false), 'severity': 'medium', 'remediation': 'interface range shutdown' }] }}" ignore_errors: yes # ── FR2 · SR 2.8: NTP authentication ────────────────────────────────────── - block: - name: "Gather: NTP configuration" cisco.ios.ios_command: commands: - show ntp status - show running-config | include ntp register: _ntp_cfg - name: "Evaluate: SW-UC-01 — NTP configured and synchronised" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'SW-UC-01', 'category': 'FR2 — Use Control', 'requirement': 'SR 2.8 — Auditable Events', 'description': 'NTP shall be configured and the clock synchronised for audit log accuracy', 'passed': ( _ntp_cfg.stdout[0] | regex_search('Clock is synchronized') is not none and _ntp_cfg.stdout[1] | regex_search('ntp server') is not none ), 'expected': 'Clock is synchronized; ntp server configured with authentication', 'actual': 'NTP status: ' + (_ntp_cfg.stdout[0] | regex_search('Clock is [^\n]+') | default('NOT synchronised', true)) + ' | Config: ' + (_ntp_cfg.stdout[1] | regex_findall('ntp [^\n]+') | join('; ') | default('no ntp config', true)), 'severity': 'high', 'remediation': 'ntp authenticate\nntp authentication-key 1 md5 \nntp trusted-key 1\nntp server key 1' }] }}" ignore_errors: yes # ── HITL · FR5 · SR 5.2: Port labelling and physical access ────────────── - block: - name: "Gather: [HITL] Interface descriptions and VLAN assignments" cisco.ios.ios_command: commands: - show interfaces description - show vlan brief register: _intf_desc - name: "Display: [HITL] SW-RDF-HITL-01 — Physical port labelling review" ansible.builtin.debug: msg: | ══════════════════════════════════════════════════════════════ MANUAL REVIEW REQUIRED · SW-RDF-HITL-01 · {{ inventory_hostname }} ══════════════════════════════════════════════════════════════ Requirement : SR 5.2 — Zone Boundary Protection Check : ICS-connected ports are in the correct VLAN and physically labelled to prevent misconnection Interface descriptions ───────────────────── {{ _intf_desc.stdout[0] | truncate(800, false) | indent(1) }} VLAN assignments ──────────────── {{ _intf_desc.stdout[1] | truncate(400, false) | indent(1) }} Verify: - ICS device ports are in the dedicated ICS VLAN (not default VLAN 1) - All connected ports have a description identifying the device - Physical port labels match the connected device ══════════════════════════════════════════════════════════════ - name: "Prompt: SW-RDF-HITL-01 — verdict for {{ inventory_hostname }}" ansible.builtin.pause: prompt: | Are ICS device ports in the correct VLAN and physically labelled? Enter verdict [pass / fail / skip]: register: _hitl_port_verdict delegate_to: localhost - name: "Prompt: SW-RDF-HITL-01 — notes on failure" ansible.builtin.pause: prompt: "Describe the finding (e.g. 'Port Gi0/5 in VLAN 1, no label'):" register: _hitl_port_notes delegate_to: localhost when: _hitl_port_verdict.user_input | lower | trim in ['fail', 'f'] - name: "Evaluate: SW-RDF-HITL-01" ansible.builtin.set_fact: test_results: "{{ test_results + [{ 'test_id': 'SW-RDF-HITL-01', 'category': 'FR5 — Restricted Data Flow', 'requirement': 'SR 5.2 — Zone Boundary Protection', 'description': 'ICS ports shall be in the dedicated ICS VLAN and physically labelled', 'passed': ( 'skipped' if (_hitl_port_verdict.user_input | lower | trim in ['skip', 's', '']) else (_hitl_port_verdict.user_input | lower | trim in ['pass', 'p']) ), 'expected': 'ICS ports in ICS VLAN, not VLAN 1; physical labels applied', 'actual': 'See interface description and VLAN table in evidence', 'severity': 'high', 'remediation': 'Move ICS ports to ICS VLAN; apply description labels; physically label ports', 'reviewer': ansible_user_id, 'notes': (_hitl_port_notes.user_input | trim) if _hitl_port_notes is defined else '' }] }}" ignore_errors: yes # ── Generate report ──────────────────────────────────────────────────────── - name: "Generate compliance report" ansible.builtin.include_tasks: ../library/report.yml