Automating CISA ED 25-03 Compliance for Cisco ASA with Ansible

Automating CISA ED 25-03 Compliance for Cisco ASA with Ansible

The Challenge: CISA Emergency Directive 25-03

On September 25, 2025, the Cybersecurity and Infrastructure Security Agency (CISA) issued Emergency Directive (ED) 25-03, accompanied by supplemental instructions. This directive requires U.S. federal agencies to urgently remediate critical vulnerabilities in their Cisco Adaptive Security Appliance (ASA) device fleets.

Directive ED 25-03 specifically addresses the active exploitation of two major Cisco ASA vulnerabilities:

An Automated Solution with Ansible

This guide demonstrates how to use the Red Hat Ansible Automation Platform to automate the identification and remediation tasks required by CISA ED 25-03 for Part One. We will walk through a series of Ansible Roles and Playbooks designed to be flexible, extensible, and easy to customize for your environment.

All example content for this post is available in the following GitLab repository:

For a more curated experience, an alternative community collection (hot-fix) is also available:
Please note, this post does not validate the following galaxy collection or Github repo.

Overview of the Automation Roles

The primary GitLab repository is structured into four distinct Ansible Roles, each designed to handle a specific part of the CISA directive. This guide will focus on how these roles map directly to the mitigation steps outlined in Part One of the directive’s supplemental instructions.

  • identification: Gathers device facts (model, serial, software version) and checks against lists of end-of-life hardware and vulnerable software versions.
  • collect_artifacts: Collects forensic data, such as show checkheaps output and show tech-support, to determine if a device shows signs of compromise.
  • core_dumps: Automates the complex and service-impacting process of generating and collecting a core dump for deep forensic analysis.
  • system_memory: Gathers additional forensic data by collecting a copy of the device’s system memory.

Step One: Identification and Initial Data Collection

The first step in the CISA directive is to run initial commands and collect outputs to understand the state of your fleet. Our identification role is designed specifically for this task. To begin, we’ll edit the main ansible-ED-25-03-asa.yml playbook to activate this role.

For the ansible-ED-25-03-asa.yml playbook, we are using a controlled rollout strategy by setting serial: 2 and max_fail_percentage: 0. This allows us to apply changes in small batches of two devices at a time and ensures the entire process halts immediately if a single error occurs, preventing widespread issues. We can increase the batch size as we gain confidence in the automation.

- name: ASA playbook for CISA ED-25-03
  hosts: asa
  gather_facts: false
  serial: 2              # Process two servers at a time for a rolling update
  max_fail_percentage: 0 # <-- THE CRITICAL SETTING

  tasks:

  roles:
   - "../roles/identification"
   #- "../roles/collect_artifacts"
#The Core Dump works best after a reboot
   #- "../roles/core_dump"
   #- "../roles/system_memory"

For this demonstration, we will run the playbook against the asa inventory group, which includes two ASAv firewalls.


inventory group_vars:

inventory host_vars

Identification role and tasks

https://gitlab.com/redhatautomation/asa-ed-25-03/-/blob/main/roles/identification/tasks/main.yaml?ref_type=heads
The identification role begins by gathering standard device facts.

- name: Gather ASA Information for ED-25-03 Identification 
  cisco.asa.asa_facts:

Output:



Next, the role checks if the discovered hardware model is on the End-of-Life (EoL) list.

- name: "Run task to determine End of Life ASA models on or prior to September 30, 2025"
  ansible.builtin.debug:
    msg: 
      - "Device model {{ ansible_net_model }} with serial number: {{ ansible_net_serialnum }} is EoL on o prior to September 30, 2025"
      - "Permanently disconnect these devices on or before September 30, 2025"
      - "Otherwise update afected software by 11:59PM EDT on September 26, 2025, and apply all subsequent updates via Cisco’s download portal within 48 hours of release."
      - "Report to CISA mission critical needs preventing such action and plans for eventual decommissioning of the device as directed by requirement 6."
  when: ansible_net_model in end_of_life_asa_models

This check is powered by the end_of_life_asa_models list defined in our group variables, which you can customize for your environment.
https://gitlab.com/redhatautomation/asa-ed-25-03/-/blob/main/group_vars/asa/vars.yaml?ref_type=heads

end_of_life_asa_models:
  - "ASA5525-X"
  - "ASA5545-X"
  - "ASA5555-X"
  - "ASA5585-X"
  - "ASA5505"
  - "ASA5510"
  - "ASA5520"
  - "ASA5540"
  - "ASA5580"

Output:

Skipped (ASAv)
- name: "Run task to determine all ASAv or ASA hardware with an end of support date of August 31, 2026"
  ansible.builtin.debug:
    msg: 
      - "Device model {{ ansible_net_model }} with serial number: {{ ansible_net_serialnum }} is an ASAv still supported or ASA hardware with end of support date of August 31, 2026"
      - "You must update any affected software by 11:59PM EDT on September 26, 2025, and apply all subsequent updates via Cisco download portal within 48 hours of release."
  when: ansible_net_model in end_of_support_asa_models

A similar task checks for models that are approaching their end-of-support date. The ASAv virtual appliance is included here for remediation purposes.
https://gitlab.com/redhatautomation/asa-ed-25-03/-/blob/main/group_vars/asa/vars.yaml?ref_type=heads
This task uses the end_of_support_asa_models list from the group variables.

end_of_support_asa_models:
  - "ASAv"
  - "ASA5508-X"
  - "ASA5516-X"

Output:

TASK [../roles/identification : Run task to determine all ASAv or ASA hardware with an end of support date of August 31, 2026] ***
ok: [asa2] => {
    "msg": [
        "Device model ASAv with serial number: 9AUP07K1PBT is an ASAv still supported or ASA hardware with end of support date of August 31, 2026",
        "You must update any affected software by 11:59PM EDT on September 26, 2025, and apply all subsequent updates via Cisco download portal within 48 hours of release."
    ]
}
ok: [asa1] => {
    "msg": [
        "Device model ASAv with serial number: 9AW7A20KTW6 is an ASAv still supported or ASA hardware with end of support date of August 31, 2026",
        "You must update any affected software by 11:59PM EDT on September 26, 2025, and apply all subsequent updates via Cisco download portal within 48 hours of release."

Finally, the role checks the device’s current software version against a comprehensive list of affected versions.

- name: "Run task to determine affected ASA software version"
  ansible.builtin.debug:
    msg:
    - "Device version {{ ansible_net_version }} is impacted by ED-25-03, CVE-2025-20362, or CVE-2025-20333 and must be upgraded."
    - "Download and apply the latest Cisco-provided updates for software by 11:59PM EDT on September 26, 2025, and apply all subsequent updates via Cisco download portal within 48 hours of release."  
  when: ansible_net_version in affected_versions

The affected_versions variable in group_vars/asa/vars.yaml contains all versions listed in the CISA directive and related CVEs.
https://gitlab.com/redhatautomation/asa-ed-25-03/-/blob/main/group_vars/asa/vars.yaml?ref_type=heads
Truncated

affected_versions:
  - '9.8(1)'
  - '9.8(1)5'
  - '9.8(1)7'
  - '9.8(2)'
  - '9.8(2)8'
  - '9.8(2)14'

Output:

TASK [../roles/identification : Run task to determine affected ASA software version] ***
ok: [asa1] => {
    "msg": [
        "Device version 9.22(2) is impacted by ED-25-03, CVE-2025-20362, or CVE-2025-20333 and must be upgraded.",
        "Download and apply the latest Cisco-provided updates for software by 11:59PM EDT on September 26, 2025, and apply all subsequent updates via Cisco download portal within 48 hours of release."
    ]
}
ok: [asa2] => {
    "msg": [
        "Device version 9.19(1)37 is impacted by ED-25-03, CVE-2025-20362, or CVE-2025-20333 and must be upgraded.",
        "Download and apply the latest Cisco-provided updates for software by 11:59PM EDT on September 26, 2025, and apply all subsequent updates via Cisco download portal within 48 hours of release."
    ]
}

This role completes the system identification task. It is a read-only component designed to gather and display diagnostic information without making any system changes. Due to its lightweight design and minimal dependencies, it ensures safe, high-performance execution across a wide variety of target environments.
Identification Recap:

  • Gathers hardware and software facts (model, version, etc.).
  • Identifies models that are End-of-Sale.
  • Identifies models that are approaching End-of-Support.
  • Identifies all software versions impacted by ED-25-03 and its associated CVEs.

The collect_artifacts Role

With our devices identified, the next phase is to collect forensic artifacts to determine if a compromise has occurred. This corresponds to the collect_artifacts role and aligns with Step One of the directive’s hunt instructions.
First, we update the main playbook to enable the collect_artifacts role.
https://gitlab.com/redhatautomation/asa-ed-25-03/-/blob/main/roles/collect_artifacts/tasks/main.yml?ref_type=heads
ansible-ED-25-03-asa.yml

- name: ASA playbook for CISA ED-25-03
  hosts: asa
  gather_facts: false
  serial: 2              # Process two servers at a time for a rolling update
  max_fail_percentage: 0 # <-- THE CRITICAL SETTING

  tasks:

  roles:
  # - "../roles/identification"
   - "../roles/collect_artifacts"
#The Core Dump works best after a reboot
   #- "../roles/core_dump"
   #- "../roles/system_memory"

This role begins by gathering standard facts, which will be used later.

- name: Gather and Save ASA Artifacts
  cisco.asa.asa_facts:

Output:

TASK [../roles/collect_artifacts : Retrieve a repository from a distant location and make it available to the local EE] ***
changed: [asa1 -> localhost]

TASK [../roles/collect_artifacts : Run show checkheaps] ************************
ok: [asa1]
ok: [asa2]

Truncated

```
ansible_facts:
  ansible_net_version: 9.19(1)37
```

To provide a central, auditable location for all collected evidence, the playbook uses the ansible.scm.git_retrieve module to clone a Git repository (in this case, from Gitea) into the Ansible Automation Platform execution environment.

- name: Retrieve a repository from a distant location and make it available to the local EE
  ansible.scm.git_retrieve:
    origin:
      url: "http://gitea:{{password}}@aap:3000/gitea/network-demos-repo.git"
    parent_directory: /tmp
    branch:
      name: "master"
      duplicate_detection: no
  register: repository
  delegate_to: localhost
  run_once: true

Output:

TASK [../roles/collect_artifacts : Retrieve a repository from a distant location and make it available to the local EE] ***
changed: [asa1 -> localhost]

This local repository will be used to store all artifacts before they are committed and pushed back to the central server. The first piece of evidence we collect is the output of the show checkheaps command.

- name: Run show checkheaps
  cisco.asa.asa_command:
    commands:
      - show checkheaps
  register: checkheaps1_output

Output:

TASK [../roles/collect_artifacts : Run show checkheaps] ************************
ok: [asa1]
ok: [asa2]

To ensure a clean workspace for our artifacts, the next set of tasks removes any old data from previous playbook runs and recreates the necessary directory structure within the cloned repository.

- name: Remove any existing folders
  ansible.builtin.file:
    path: "{{ repository['path'] }}/ansible-ED-25-03/{{ item }}/"
    state: absent
  delegate_to: localhost
  run_once: true
  loop:
    - show_tech
    - check_heaps
    - show_ver

- name: Add folders
  ansible.builtin.file:
    path: "{{ repository['path'] }}/ansible-ED-25-03/{{ item }}/"
    state: directory
  delegate_to: localhost
  run_once: true
  loop:
    - show_tech
    - check_heaps
    - show_ver

- name: Create a .gitkeep file if it does not exist
  ansible.builtin.file:
    path: "{{ repository['path'] }}/ansible-ED-25-03/{{ item }}/.gitkeep"
    state: touch
  delegate_to: localhost
  run_once: true
  loop:
    - show_tech
    - check_heaps
    - show_ver

Output:

TASK [../roles/collect_artifacts : Remove any existing folders] ****************
changed: [asa1 -> localhost] => (item=show_tech)
changed: [asa1 -> localhost] => (item=check_heaps)
changed: [asa1 -> localhost] => (item=show_ver)

TASK [../roles/collect_artifacts : Add folders] ********************************
changed: [asa1 -> localhost] => (item=show_tech)
changed: [asa1 -> localhost] => (item=check_heaps)
changed: [asa1 -> localhost] => (item=show_ver)

TASK [../roles/collect_artifacts : Create a .gitkeep file if it does not exist] ***
changed: [asa1 -> localhost] => (item=show_tech)
changed: [asa1 -> localhost] => (item=check_heaps)
changed: [asa1 -> localhost] => (item=show_ver)

Now, we save the first show checkheaps output to a file.

- name: Save First Check Heaps output to file
  ansible.builtin.copy:
    content: "{{ checkheaps1_output.stdout[0] }}"
    dest: "{{ repository['path'] }}/ansible-ED-25-03/check_heaps/{{ inventory_hostname }}_checkheaps_first.txt"
  delegate_to: localhost

Output:

TASK [../roles/collect_artifacts : Save First Check Heaps output to file] ******
changed: [asa1 -> localhost]
changed: [asa2 -> localhost]

The CISA instructions require waiting five minutes before running the command a second time to check for changes in heap statistics.

- name: pause for 5 minutes
  ansible.builtin.pause:
    minutes: 5

Output:

TASK [../roles/collect_artifacts : pause for 5 minutes] ************************
ok: [asa1]

After the pause, we collect the second show checkheaps output and save it to a separate file.

- name: Run show checkheaps
  cisco.asa.asa_command:
    commands:
      - show checkheaps
  register: checkheaps2_output

- name: Save Second Check Heaps output to file
  ansible.builtin.copy:
    content: "{{ checkheaps2_output.stdout[0] }}"
    dest: "{{ repository['path'] }}/ansible-ED-25-03/check_heaps/{{ inventory_hostname }}_checkheaps_second.txt"
  delegate_to: localhost
  register: after

Output omitted, similar to the first check_heap.

The next step uses the ansible.utils.fact_diff module to compare the two outputs. We are looking for the Total number of runs counter to increment, which is expected behavior. If it does not, this could be evidence of a compromise.

- name: >
    Compare Diff between checkheap1 and checkheap2
    Look specifically at the return in the last row, which indicates (“Total number of runs”).
    It should increase by 1 every 60 seconds for 5 minutes
  ansible.utils.fact_diff:
    before: "{{ checkheaps1_output.stdout[0] }}"
    after: "{{ checkheaps2_output.stdout[0] }}"
  diff: true
  delegate_to: localhost

Output:
As you can see in the diff, the “Total number of runs” value increased by 5 on both devices, which indicates normal operation.

  1. If there was no observable positive change in the “Total number of runs” value, this is evidence of a potential compromise!
TASK [../roles/collect_artifacts : Compare Diff between checkheap1 and checkheap2 Look specifically at the return in the last row, which indicates (“Total number of runs”). It should increase by 1 every 60 seconds for 5 minutes] ***
--- before
+++ after
@@ -1,10 +1,10 @@
 Checkheaps stats from buffer validation runs 
 --------------------------------------------
Truncated
-Total number of runs            : 1028
\\ No newline at end of file
+Total number of runs            : 1033
\\ No newline at end of file

changed: [asa1 -> localhost]
--- before
+++ after
@@ -1,10 +1,10 @@
 Checkheaps stats from buffer validation runs 
 --------------------------------------------
Truncated
-Total number of runs            : 1564
\\ No newline at end of file
+Total number of runs            : 1569
\\ No newline at end of file

changed: [asa2 -> localhost]

Next, we extract key device information from the previously gathered facts and save it to a structured YAML file for easy parsing and record-keeping.

- name: "Show Version Facts"
  ansible.builtin.set_fact:
    device_info:
      model: "{{ ansible_net_model }}"
      serial_number: "{{ ansible_net_serialnum }}"
      version: "{{ ansible_net_version }}"
      device_manager_version: "{{ ansible_net_device_mgr_version }}"

- name: "Write the dictionary to a YAML file"
  ansible.builtin.copy:
    content: "{{ device_info | to_yaml }}"
    dest: "{{ repository['path'] }}/ansible-ED-25-03/show_ver/{{ inventory_hostname }}_version.yml"
  delegate_to: localhost

Output:

TASK [../roles/collect_artifacts : Show Version Facts] *************************
ok: [asa1]
ok: [asa2]

TASK [../roles/collect_artifacts : Write the dictionary to a YAML file] ********
changed: [asa2 -> localhost]
changed: [asa1 -> localhost]

To gather more comprehensive diagnostic data, we generate a show tech-support detail file on the ASA’s flash memory and then use ansible.netcommon.net_get to securely copy it to our local repository.

- name: Save Show Tech
  cisco.asa.asa_command:
    commands:
      - terminal pager 0
      - sh tech-support detail | redirect flash:show_tech_detail.txt

- name: SCP show tech
  ansible.netcommon.net_get:
    src: "flash:show_tech_detail.txt"
    dest: "{{ repository['path'] }}/ansible-ED-25-03/show_tech/{{ inventory_hostname }}_show_tech_detail.txt"

Output:

TASK [../roles/collect_artifacts : Save Show Tech] *****************************
ok: [asa2]
ok: [asa1]

TASK [../roles/collect_artifacts : SCP show tech] ******************************
changed: [asa2]
changed: [asa1]

The next tasks perform a critical check for a known implant by searching the system memory for specific hexadecimal patterns. If the command returns any output, the device is flagged as potentially compromised.

- name: Implant Check
  cisco.asa.asa_command:
    commands:
      - more /binary system:/text | grep 55534154 41554156 41575756 488bb3a0
  register: collect_artifacts_implant_check

- name: Save implant check output
  ansible.builtin.copy:
    dest: "{{ repository['path'] }}/ansible-ED-25-03/implant_check/{{ inventory_hostname }}_implant_check.txt"
    content: |
      {{ collect_artifacts_implant_check.stdout[0] | default('') }}
    mode: "0644"
  delegate_to: localhost

- name: Set compromised flag if any implant output is present
  ansible.builtin.set_fact:
    collect_artifacts_ed25_compromised: >
      {{
        (collect_artifacts_implant_check.stdout[0] | default('') | trim) != ''
      }}

Output:

TASK [../roles/collect_artifacts : Implant Check] ******************************
ok: [asa2]
ok: [asa1]

TASK [../roles/collect_artifacts : Save implant check output] ******************
ok: [asa1 -> localhost]
ok: [asa2 -> localhost]

TASK [../roles/collect_artifacts : Set compromised flag if any implant output is present] ***
ok: [asa1]
ok: [asa2]

Finally, the ansible.scm.git_publish module commits all the newly created artifact files and pushes them to the central Gitea repository, creating an immutable, timestamped audit trail.

- name: Publish the changes
  ansible.scm.git_publish:
    path: "{{ repository['path'] }}"
    timeout: 1200
    token: "{{ password }}"
    user:
      name: "{{ username }}"
      email: "{{ email }}"
  delegate_to: localhost
  run_once: true
  vars:
    ansible_command_timeout: 1200

Output:

TASK [../roles/collect_artifacts : Publish the changes] ************************
changed: [asa1 -> localhost]

After the playbook completes, a git pull in your local clone of the repository will reveal the neatly organized artifacts collected from each device.

[tdubiel@aap ansible-ED-25-03]$ tree
.
├── ansible-ED-25-03-asa.yml
├── check_heaps
│   ├── asa1_checkheaps_first.txt
│   ├── asa1_checkheaps_second.txt
│   ├── asa2_checkheaps_first.txt
│   └── asa2_checkheaps_second.txt
├── core_dump
│   └──.gitkeep
├── group_vars
│   └── asa
│       └── vars.yaml
├── implant_check
│   ├── asa1_implant_check.txt
│   └── asa2_implant_check.txt
├── roles
│   ├── collect_artifacts
│   │   └── tasks
│   │       └── main.yml
│   ├── core_dump
│   │   └── tasks
│   │       └── main.yml
│   ├── identification
│   │   └── tasks
│   │       └── main.yaml
│   └── system_memory
│       └── tasks
│           └── main.yml
├── show_tech
│   ├── asa1_show_tech_detail.txt
│   └── asa2_show_tech_detail.txt
├── show_ver
│   ├── asa1_version.yml
│   └── asa2_version.yml
└── system_memory
     └──.gitkeep

The core_dumps Role

This role automates Step Two: Core Dump Collection, a highly sensitive and service-impacting procedure.

WARNING: Service Interruption Ahead. The tasks in this role will trigger an immediate reload of the targeted ASA device. This will cause a network outage or service degradation. It is strongly recommended to limit your inventory to a single device at a time and perform this action during a planned maintenance window.
https://gitlab.com/redhatautomation/asa-ed-25-03/-/blob/main/roles/core_dump/tasks/main.yml?ref_type=heads

To execute this role, we update the playbook to target a single host (asa1) and enable the core_dump role.
ansible-ED-25-03-asa.yml

- name: ASA playbook for CISA ED-25-03
  hosts: asa1
  gather_facts: false
  serial: 1              # Process one server at a time for a rolling update
  max_fail_percentage: 0 # <-- THE CRITICAL SETTING
  
  tasks:

  roles:
   #- "../roles/identification"
   #- "../roles/collect_artifacts"
#The Core Dump works best after a reboot
   - "../roles/core_dump"
   #- "../roles/system_memory"

Similar to the previous role, we start by retrieving the Git repository and cleaning up any existing artifacts.

- name: Retrieve a repository from a distant location and make it available to the local EE
  ansible.scm.git_retrieve:
    origin:
      url: "http://gitea:{{password}}@aap:3000/gitea/network-demos-repo.git"
    parent_directory: /tmp
    branch:
      name: "master"
      duplicate_detection: no
  register: repository
  delegate_to: localhost
  run_once: true

Removing existing files and folders from previous runs.
Explained above

- name: Remove any existing folders
  ansible.builtin.file:
   path: "{{ repository['path'] }}/ansible-ED-25-03/{{ item }}/"
   state: absent
  delegate_to: localhost
  run_once: true
  loop:
    - core_dump
    - system_memory
    - show_ver

- name: Add folders
  ansible.builtin.file:
   path: "{{ repository['path'] }}/ansible-ED-25-03/{{ item }}/"
   state: directory
  delegate_to: localhost
  run_once: true
  loop:
   - core_dump
   - system_memory
   - show_ver

- name: Create a .gitkeep file if it does not exist
  ansible.builtin.file:
    path: "{{ repository['path'] }}/ansible-ED-25-03/{{ item }}/.gitkeep"
    state: touch 
  delegate_to: localhost
  run_once: true
  loop:
   - core_dump
   - system_memory
   - show_ver

The next task is unique, as it requires entering configuration mode and answering a confirmation prompt. We use ansible.netcommon.cli_command with a loop of commands and an empty string ('') to simulate pressing Enter at the prompt.

- name: run config mode command to enable filesystem and handle prompt/answer
  ansible.netcommon.cli_command:
    command: "{{ item }}"
    sendonly: true
  loop:
    - configure terminal
    - 'coredump enable filesystem disk0:'
    - ''

Output:

TASK [../roles/core_dump : run config mode command to enable filesystem and handle prompt/answer] ***
ok: [asa1] => (item=configure terminal)
ok: [asa1] => (item=coredump enable filesystem disk0:)
ok: [asa1] => (item=)

At this point, the device is ready for the core dump. The following task initiates the crashinfo command, which forces a reboot, and waits for the device to come back online.

- name: Initiate core dump and reboot
  ansible.netcommon.cli_command:
    command: crashinfo force page-fault
    prompt: '\[confirm\]'
    answer: ''
    sendonly: true
 
- name: "Reset the connection to the server"
  ansible.builtin.meta: reset_connection

- name: Waiting for reboot
  ansible.builtin.wait_for_connection:
      delay: 60
      timeout: 300

Output:

TASK [../roles/core_dump : Initiate core dump and reboot] **********************
ok: [asa1]

TASK [../roles/core_dump : Waiting for reboot] *********************************
ok: [asa1]

The next sequence of tasks check to determine if a core dump exists and if so, it will use netcommon.net_get to down load them for committing to the gitea repo.

Once the ASA has rebooted, the playbook lists the contents of the coredumpfsys directory, identifies the newest core dump file, and uses net_get to download it to our Git repository.

- name: List files in disk0
  cisco.asa.asa_command:
    commands: 'dir disk0:/coredumpfsys/'
  register: dir_output

- name: Identify the latest core_dump file
  ansible.builtin.set_fact:
    latest_core_dump_file: >-
      {{
        dir_output.stdout[0].splitlines()
        | select('search', 'core_smp.')
        | map('split')
        | map('last')
        | sort(reverse=true)
        | first
      }}
  when: "'core_smp.' in dir_output.stdout[0]"

- name: "Fail if no core_dump files were found"
  ansible.builtin.fail:
    msg: "No core dump files were found on the device."
  when: latest_core_dump_file is not defined

- name: Download the latest core_dump file
  ansible.netcommon.net_get:
    src: "disk0:/coredumpfsys/{{ latest_core_dump_file }}"
    dest: "{{ repository['path'] }}/ansible-ED-25-03/core_dump/{{ inventory_hostname }}_{{ latest_core_dump_file }}"
  when: latest_core_dump_file is defined

- name: Publish the changes
  ansible.scm.git_publish:
    path: "{{ repository['path'] }}"
    timeout: 1200
    token: "{{ password }}"
    user:
      name: "{{ username }}"
      email: "{{ email }}"
  delegate_to: localhost
  run_once: true
  vars:
   ansible_command_timeout: 1200
TASK [../roles/core_dump : List files in disk0] ********************************
ok: [asa1]

TASK [../roles/core_dump : Identify the latest core_dump file] *****************
ok: [asa1]

TASK [../roles/core_dump : Download the latest core_dump file] *****************
changed: [asa1]

TASK [../roles/core_dump : Publish the changes] ********************************
changed: [asa1 -> localhost]

The core dump files will now be availble in the Git repo.

├── core_dump
│   └── asa1_core_smp.2025Sep30_210548.1435.11.gz

The system_memory Role

As a final forensic step, this role maps to Step Two and automates the collection of a system memory snapshot for further analysis.
https://gitlab.com/redhatautomation/asa-ed-25-03/-/blob/main/roles/system_memory/tasks/main.yml?ref_type=heads

ansible-ED-25-03-asa.yml

- name: ASA playbook for CISA ED-25-03
  hosts: asa1
  gather_facts: false
  serial: 1              # Process one server at a time for a rolling update
  max_fail_percentage: 0 # <-- THE CRITICAL SETTING
  
  tasks:

  roles:
   #- "../roles/identification"
   #- "../roles/collect_artifacts"
#The Core Dump works best after a reboot
   #- "../roles/core_dump"
   - "../roles/system_memory"

The initial sequence of tasks gathers the device version, calculates the SHA-512 hash of the system:memory/text file for integrity verification, and then copies it to disk0 to prepare for transfer.

- name: Show Version
  cisco.asa.asa_command:
    commands: 'show version | inc Version'
  register: output

- name: Print Show Version
  ansible.builtin.debug:
    msg: "{{ output.stdout_lines[0] }}"

- name: Verify hash for verify /sha-512 system:memory/text
  cisco.asa.asa_command:
    commands: 'verify /sha-512 system:memory/text'
  register: verify

- name: Print Sha512 hash
  debug: 
   msg: "{{ verify.stdout_lines[0] }}"

- name: Copy system_memory file to disk0
  ansible.netcommon.cli_command:
    command: "copy system:memory/text disk0:{{ inventory_hostname }}_text"
    check_all: true
    prompt:
      - 'Source filename [memory/text]\?'
      - '\?' 
      - '\[confirm\]' 
    answer:
      - ''
      - ''
      - ''

Output:

TASK [../roles/system_memory : Show Version] ***********************************
ok: [asa1]
[WARNING]: Collection ansible.utils does not support Ansible version 2.15.13

TASK [../roles/system_memory : Print Show Version] *****************************
ok: [asa1] => {
    "msg": [
        "Cisco Adaptive Security Appliance Software Version 9.22(2) ",
        "SSP Operating System Version 2.16(1.111)",
        "Device Manager Version 7.22(1)"
    ]
}
[WARNING]: Collection ansible.utils does not support Ansible version 2.15.13

TASK [../roles/system_memory : Verify hash for verify /sha-512 system:memory/text] ***
ok: [asa1]
[WARNING]: Collection ansible.utils does not support Ansible version 2.15.13

TASK [../roles/system_memory : Print Sha512 hash] ******************************
ok: [asa1] => {
    "msg": [
        Done!",
        "verify /SHA-512 (system:memory/text) = 2b1a0912ff855ad56f4f8d0e7ba9d2472eeaffa13164cd895a77d0cf2739d211c4b7b373b38a53cbf97d780995f8b568c75bd54372b7cf3171cdeb9feabd1b68"
    ]
}

The final task copies the memory file off the device. While net_get is one option, this example demonstrates an alternative: using the ASA’s native copy command to transfer the file to another device via SCP. This highlights the flexibility of Ansible in handling complex, interactive CLI prompts. For security, you can add no_log: true to this task to prevent credentials from being exposed in logs.

 - name: Copy system_memory offbox
  ansible.netcommon.cli_command:
    command: "copy disk0:{{inventory_hostname}}_text scp://{{dest_ip}}/{{inventory_hostname}}_text"
    check_all: true
    prompt:
      - 'Source filename \[{{inventory_hostname}}_text\]\?'
      - 'Address or name of remote host \[{{dest_ip}}\]\?' 
      - 'Destination username \[\]\?' 
      - 'Destination filename \[{{inventory_hostname}}_text\]\?' 
      - 'password\:'
    answer:
      - ''
      - ''
      - '{{ ansible_become_user }}'
      - ''
      - '{{ ansible_pass }}'
  #no_log: true

Output:

TASK [../roles/system_memory : Copy system_memory offbox] **********************
ok: [asa1]

Details:

```
changed: false
stdout: >-
  Source filename [asa1_text]? 
  Address or name of remote host [18.118.80.164]? 
  Destination username []? admin
  Destination filename [asa1_text]? 
  admin@18.118.80.164's password: *********
```

Summary of Ansible for Part One of CISA ED 25-03

Manually addressing the requirements of CISA ED 25-03 is a monumental task, consuming countless hours and carrying a high risk of human error. By leveraging the Red Hat Ansible Automation Platform, organizations can transform this reactive, high-effort process into a streamlined, reliable, and auditable workflow.

The value of adopting this automation-first approach extends far beyond this single directive:

  • Drastic Reduction in Time-to-Compliance
  • Verifiable Audit Trail
  • Accuracy and Consistency
  • Enhanced Security and Risk Reduction
  • Reusable and Extensible Framework

Ultimately, this automation strategy does more than just check a compliance box. It empowers network and security teams to shift their focus from tedious, repetitive tasks to high-value analysis and strategic remediation. By investing in automation for this critical directive, you are building the capability to manage your network more securely, efficiently, and proactively for the challenges of tomorrow.

Next Steps

With the initial identification and artifact collection automated, the next steps involve submitting the collected data to CISA, patching your devices, and performing further analysis as directed.

Part Two: Submitting Forensic Data to CISA

The Ansible automation detailed in this guide has collected and organized all the required artifacts (such as core dumps and show tech-support files) into your Git repository. The next step is to manually upload these files to CISA’s Malware Next-Generation Analysis Platform for review.

  1. Using a web browser, navigate to the submission portal: https://malware.cisa.gov.
  2. Follow the on-screen instructions to upload the relevant files you collected from each potentially compromised device.

Part Three: Applying Patches and Upgrades

Once you have completed the forensic steps, the next critical action is to patch all vulnerable devices as mandated by the directive. While outside the scope of this specific post, a separate project provides a robust starting point for automating Cisco ASA upgrades.

You can find example roles and playbooks for patching in this repository:

Please Note: These examples were developed and tested using virtual ASAv appliances. You may need to adapt them to suit the specific hardware models and operational requirements of your environment.

Part Four: Advanced Threat Hunting

The CISA directive includes further instructions for advanced threat hunting that go beyond initial artifact collection. Automating these more complex, investigative steps is an area of ongoing research.

Stay tuned for a potential future blog post where we will explore advanced hunting techniques with Ansible.