Files
ansible-testing/methodologies/ansible/playbooks/examples/cisco_switch.yml
T

286 lines
15 KiB
YAML

---
# ══════════════════════════════════════════════════════════════════════════════
# 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 assets.yml)
#
# Run:
# ansible-playbook -i assets.yml methodologies/ansible/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