--- # ══════════════════════════════════════════════════════════════════════════════ # 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 assets.yml) # # Run: # ansible-playbook -i assets.yml methodologies/ansible/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 ' 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 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 statement present', 'actual': _mgmt_access.stdout[0] | regex_findall('ssh[^\n]+') | join(' | ') | default('No SSH access statements', true), 'severity': 'high', 'remediation': 'ssh \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 ' }] }}" 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 ', 'actual': _syslog_cfg.stdout[0] | regex_findall('logging[^\n]+') | join(' | ') | default('No logging config', true), 'severity': 'high', 'remediation': 'logging enable\nlogging host ' }] }}" 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 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 in interface ', 'actual': _acl_cfg.stdout[0] | regex_findall('access-group[^\n]+') | join(' | ') | default('No access-group statements', true), 'severity': 'critical', 'remediation': 'access-group in interface ' }] }}" 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