286 lines
15 KiB
YAML
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
|