Building IR Playbooks with Ansible
Transform incident response from chaos to choreography using Ansible. Learn to build automated playbooks that handle security incidents consistently and efficiently
Reading time: 12 minutes
From Manual Chaos to Automated Response
Picture this: It's 3 AM, your phone buzzes with a critical security alert. Half-awake, you log in to find potential ransomware activity across multiple servers. In the past, this meant hours of manual work, inconsistent responses, and missed evidence. Today, with properly built Ansible IR playbooks, the same incident triggers automated containment, evidence collection, and recovery procedures – all while you're getting dressed.
After building and deploying IR automation for everything from small businesses to federal agencies, I've learned that Ansible can transform incident response from reactive chaos to proactive orchestration. This guide shares battle-tested playbooks and patterns that work in production.
Why Ansible for Incident Response?
Traditional incident response suffers from:
- Human error under pressure
- Inconsistent procedures across team members
- Slow response times during critical moments
- Poor documentation of actions taken
- Evidence contamination from manual intervention
Ansible solves these by providing:
- Consistency: Same response every time
- Speed: Automated actions in seconds
- Auditability: Complete logs of all actions
- Scalability: Handle hundreds of systems simultaneously
- Flexibility: Adapt playbooks on the fly
Building Your IR Foundation
Core Playbook Structure
Let's start with a foundation that all IR playbooks should build upon:
---
# Base IR Playbook Structure
# incident_response_base.yml
- name: Incident Response Base Playbook
hosts: "{{ target_hosts | default('all') }}"
gather_facts: yes
become: yes
vars:
incident_id: "{{ lookup('env', 'INCIDENT_ID') | default(ansible_date_time.epoch) }}"
incident_type: "{{ ir_type | default('unknown') }}"
evidence_path: "/forensics/{{ incident_id }}"
notification_webhook: "{{ lookup('env', 'IR_WEBHOOK_URL') }}"
pre_tasks:
- name: Create incident record
uri:
url: "{{ ir_api_endpoint }}/incidents"
method: POST
body_format: json
body:
id: "{{ incident_id }}"
type: "{{ incident_type }}"
affected_hosts: "{{ ansible_play_hosts }}"
started_at: "{{ ansible_date_time.iso8601 }}"
status: "in_progress"
delegate_to: localhost
run_once: true
- name: Send initial notification
uri:
url: "{{ notification_webhook }}"
method: POST
body_format: json
body:
text: "🚨 IR Playbook Started: {{ incident_type }} on {{ ansible_play_hosts | length }} hosts"
incident_id: "{{ incident_id }}"
delegate_to: localhost
run_once: true
when: notification_webhook is defined
- name: Create evidence directory structure
file:
path: "{{ evidence_path }}/{{ inventory_hostname }}/{{ item }}"
state: directory
mode: '0700'
loop:
- system
- network
- processes
- logs
- memory
- timeline
tasks:
- name: Capture initial system state
block:
- name: System information
shell: |
uname -a > {{ evidence_path }}/{{ inventory_hostname }}/system/uname.txt
hostname -A > {{ evidence_path }}/{{ inventory_hostname }}/system/hostname.txt
date '+%Y-%m-%d %H:%M:%S %Z' > {{ evidence_path }}/{{ inventory_hostname }}/system/date.txt
uptime > {{ evidence_path }}/{{ inventory_hostname }}/system/uptime.txt
- name: User and login information
shell: |
w > {{ evidence_path }}/{{ inventory_hostname }}/system/logged_in_users.txt
last -F > {{ evidence_path }}/{{ inventory_hostname }}/system/last_logins.txt
lastb -F > {{ evidence_path }}/{{ inventory_hostname }}/system/failed_logins.txt 2>/dev/null || true
- name: Network connections
shell: |
netstat -tulpn > {{ evidence_path }}/{{ inventory_hostname }}/network/netstat_listen.txt 2>&1
netstat -an > {{ evidence_path }}/{{ inventory_hostname }}/network/netstat_all.txt
ss -tulpn > {{ evidence_path }}/{{ inventory_hostname }}/network/ss_listen.txt 2>&1
iptables -L -n -v > {{ evidence_path }}/{{ inventory_hostname }}/network/iptables.txt
- name: Process listing
shell: |
ps auxwww > {{ evidence_path }}/{{ inventory_hostname }}/processes/ps_aux.txt
ps -elf > {{ evidence_path }}/{{ inventory_hostname }}/processes/ps_elf.txt
lsof -n > {{ evidence_path }}/{{ inventory_hostname }}/processes/lsof.txt 2>&1 || true
- name: Create system snapshot
shell: |
tar czf {{ evidence_path }}/{{ inventory_hostname }}/system_snapshot_{{ ansible_date_time.epoch }}.tar.gz \
/etc/passwd /etc/shadow /etc/group /etc/sudoers* \
/var/log/auth.log* /var/log/secure* \
/root/.bash_history /home/*/.bash_history \
2>/dev/null || true
rescue:
- name: Log evidence collection failure
lineinfile:
path: "{{ evidence_path }}/{{ inventory_hostname }}/collection_errors.log"
line: "{{ ansible_date_time.iso8601 }} - Failed to collect evidence: {{ ansible_failed_result.msg | default('Unknown error') }}"
create: yes
post_tasks:
- name: Compress evidence
archive:
path: "{{ evidence_path }}/{{ inventory_hostname }}"
dest: "{{ evidence_path }}/{{ inventory_hostname }}_{{ ansible_date_time.epoch }}.tar.gz"
format: gz
delegate_to: "{{ inventory_hostname }}"
- name: Update incident record
uri:
url: "{{ ir_api_endpoint }}/incidents/{{ incident_id }}"
method: PATCH
body_format: json
body:
status: "evidence_collected"
evidence_locations: "{{ evidence_path }}"
delegate_to: localhost
run_once: true
handlers:
- name: notify_soc
uri:
url: "{{ notification_webhook }}"
method: POST
body_format: json
body:
text: "{{ notification_message }}"
incident_id: "{{ incident_id }}"
delegate_to: localhost
Ransomware Response Playbook
Here's a production-tested ransomware response playbook:
---
# ransomware_response.yml
- name: Ransomware Incident Response
hosts: "{{ affected_hosts }}"
gather_facts: yes
become: yes
serial: "{{ parallel_hosts | default(5) }}"
vars:
incident_type: "ransomware"
known_ransomware_extensions:
- .encrypted
- .crypto
- .locked
- .enc
- .aes256
- .ransom
known_ransomware_processes:
- vssadmin.exe
- wbadmin.exe
- bcdedit.exe
- cipher.exe
isolation_mode: "{{ isolation | default('partial') }}"
tasks:
- name: Import base IR tasks
include_tasks: incident_response_base.yml
- name: Identify ransomware indicators
block:
- name: Search for encrypted files
find:
paths:
- /home
- /var
- /opt
patterns: "*{{ item }}"
recurse: yes
file_type: file
register: encrypted_files
loop: "{{ known_ransomware_extensions }}"
failed_when: false
- name: Check for ransom notes
find:
paths:
- /
patterns:
- "*README*"
- "*DECRYPT*"
- "*RESTORE*"
- "*.txt"
recurse: yes
age: "-1d"
size: "-10k"
register: potential_ransom_notes
failed_when: false
- name: Identify suspicious processes
shell: |
ps aux | grep -E '{{ known_ransomware_processes | join("|") }}' | grep -v grep
register: suspicious_processes
failed_when: false
changed_when: false
- name: Check for shadow copy deletion
shell: |
grep -E 'vssadmin.*delete.*shadows|wmic.*shadowcopy.*delete' /var/log/* 2>/dev/null || true
register: shadow_deletion_attempts
changed_when: false
- name: Immediate containment actions
when: encrypted_files.results | selectattr('files', 'defined') | map(attribute='files') | flatten | length > 0
block:
- name: Isolate system - Network level
iptables:
chain: "{{ item }}"
policy: DROP
loop:
- INPUT
- OUTPUT
- FORWARD
when: isolation_mode == 'full'
- name: Isolate system - Partial (allow management)
block:
- name: Allow only management connections
iptables:
chain: INPUT
source: "{{ management_network }}"
jump: ACCEPT
- name: Block all other traffic
iptables:
chain: "{{ item }}"
jump: DROP
loop:
- INPUT
- OUTPUT
when: isolation_mode == 'partial'
- name: Kill suspicious processes
shell: |
for pid in $(ps aux | grep -E '{{ known_ransomware_processes | join("|") }}' | grep -v grep | awk '{print $2}'); do
kill -9 $pid
echo "Killed process $pid at $(date)" >> {{ evidence_path }}/{{ inventory_hostname }}/processes/killed_processes.log
done
when: suspicious_processes.stdout_lines | length > 0
- name: Disable scheduled tasks
systemd:
name: "{{ item }}"
enabled: no
state: stopped
loop:
- cron
- anacron
- atd
failed_when: false
- name: Collect ransomware-specific evidence
block:
- name: Capture file modification times
shell: |
find /home /var /opt -type f -mtime -7 -ls > {{ evidence_path }}/{{ inventory_hostname }}/timeline/recent_modifications.txt 2>/dev/null || true
- name: Extract ransomware samples
shell: |
mkdir -p {{ evidence_path }}/{{ inventory_hostname }}/malware_samples
for ext in {{ known_ransomware_extensions | join(' ') }}; do
find /tmp /var/tmp /dev/shm -name "*$ext" -type f -size -10M -exec cp {} {{ evidence_path }}/{{ inventory_hostname }}/malware_samples/ \; 2>/dev/null || true
done
- name: Capture process memory
shell: |
for pid in $(ps aux | grep -v grep | awk '{print $2}'); do
gcore -o {{ evidence_path }}/{{ inventory_hostname }}/memory/process_$pid $pid 2>/dev/null || true
done
when: capture_memory | default(false)
async: 300
poll: 0
- name: Save ransom notes
copy:
src: "{{ item.path }}"
dest: "{{ evidence_path }}/{{ inventory_hostname }}/ransom_notes/"
remote_src: yes
loop: "{{ potential_ransom_notes.files }}"
when:
- potential_ransom_notes.files is defined
- item.size < 10240 # Only files < 10KB
failed_when: false
- name: Check backup status
block:
- name: Identify backup locations
shell: |
# Check common backup locations
find /backup /mnt/backup /var/backup -type f -name "*.tar*" -o -name "*.sql*" -o -name "*.dump" 2>/dev/null | head -20
register: backup_files
failed_when: false
- name: Verify backup integrity
shell: |
for backup in {{ backup_files.stdout_lines | join(' ') }}; do
if [[ $backup == *.tar.gz ]]; then
tar -tzf $backup >/dev/null 2>&1 && echo "OK: $backup" || echo "CORRUPT: $backup"
fi
done
register: backup_verification
when: backup_files.stdout_lines | length > 0
- name: Generate ransomware timeline
shell: |
cat > {{ evidence_path }}/{{ inventory_hostname }}/timeline/ransomware_timeline.txt << EOF
Ransomware Incident Timeline - Host: {{ inventory_hostname }}
==========================================
First encrypted file detected: $(find {{ encrypted_files.results[0].files[0].path | dirname }} -name "*{{ known_ransomware_extensions[0] }}" -printf '%T+ %p\n' | sort | head -1)
Recent user logins:
$(last -F -n 20)
Recent file modifications (last 24h):
$(find /home /var -type f -mtime -1 -ls | sort -k8,9)
Suspicious process activity:
{{ suspicious_processes.stdout }}
Shadow copy deletion attempts:
{{ shadow_deletion_attempts.stdout }}
EOF
when: encrypted_files.results | selectattr('files', 'defined') | map(attribute='files') | flatten | length > 0
- name: Attempt automated recovery
when:
- auto_recover | default(false)
- backup_verification.stdout is search("OK:")
block:
- name: Stop all non-essential services
systemd:
name: "{{ item }}"
state: stopped
loop: "{{ non_essential_services }}"
failed_when: false
- name: Restore from backup
shell: |
# Example restore - customize for your environment
latest_backup=$(ls -t /backup/*.tar.gz | head -1)
tar -xzf $latest_backup -C /restore_staging/
register: restore_attempt
- name: Generate incident report
template:
src: ransomware_report.j2
dest: "{{ evidence_path }}/{{ inventory_hostname }}/incident_report.html"
vars:
encrypted_file_count: "{{ encrypted_files.results | selectattr('files', 'defined') | map(attribute='files') | flatten | length }}"
ransom_notes_found: "{{ potential_ransom_notes.files | default([]) | length }}"
suspicious_process_count: "{{ suspicious_processes.stdout_lines | length }}"
handlers:
- name: alert_management
uri:
url: "{{ notification_webhook }}"
method: POST
body_format: json
body:
text: |
🚨 CRITICAL: Ransomware detected on {{ inventory_hostname }}
Encrypted files: {{ encrypted_files.results | selectattr('files', 'defined') | map(attribute='files') | flatten | length }}
Status: {{ 'Isolated' if isolation_mode != 'none' else 'Active' }}
color: "danger"
incident_id: "{{ incident_id }}"
Credential Compromise Response
Responding to stolen credentials requires speed and precision:
---
# credential_compromise_response.yml
- name: Credential Compromise Response
hosts: all
gather_facts: no
become: yes
vars:
compromised_user: "{{ target_user }}"
incident_type: "credential_compromise"
reset_password: "{{ auto_reset | default(true) }}"
revoke_sessions: true
tasks:
- name: Immediate containment
block:
- name: Disable user account
user:
name: "{{ compromised_user }}"
shell: /sbin/nologin
password_lock: yes
register: user_disabled
- name: Kill all user processes
shell: |
pkill -u {{ compromised_user }} || true
# More aggressive termination
for pid in $(ps -u {{ compromised_user }} -o pid --no-headers); do
kill -9 $pid 2>/dev/null || true
done
register: processes_killed
- name: Revoke SSH keys
file:
path: "/home/{{ compromised_user }}/.ssh/authorized_keys"
state: absent
failed_when: false
- name: Expire user password
shell: |
chage -d 0 {{ compromised_user }}
when: reset_password
- name: Collect authentication evidence
block:
- name: Gather authentication logs
shell: |
mkdir -p {{ evidence_path }}/{{ inventory_hostname }}/auth_logs
grep {{ compromised_user }} /var/log/auth.log* > {{ evidence_path }}/{{ inventory_hostname }}/auth_logs/user_auth.log 2>/dev/null || true
grep {{ compromised_user }} /var/log/secure* >> {{ evidence_path }}/{{ inventory_hostname }}/auth_logs/user_auth.log 2>/dev/null || true
- name: Check sudo usage
shell: |
grep {{ compromised_user }} /var/log/sudo.log > {{ evidence_path }}/{{ inventory_hostname }}/auth_logs/sudo_usage.log 2>/dev/null || true
grep "sudo.*{{ compromised_user }}" /var/log/auth.log* >> {{ evidence_path }}/{{ inventory_hostname }}/auth_logs/sudo_usage.log 2>/dev/null || true
- name: Identify source IPs
shell: |
grep "Accepted.*{{ compromised_user }}" /var/log/auth.log* | awk '{print $11}' | sort | uniq -c | sort -rn > {{ evidence_path }}/{{ inventory_hostname }}/auth_logs/source_ips.txt
register: source_ips
- name: Check for persistence mechanisms
shell: |
# Check crontab
crontab -u {{ compromised_user }} -l > {{ evidence_path }}/{{ inventory_hostname }}/persistence/user_crontab.txt 2>/dev/null || echo "No crontab"
# Check systemd user services
ls -la /home/{{ compromised_user }}/.config/systemd/user/ > {{ evidence_path }}/{{ inventory_hostname }}/persistence/systemd_services.txt 2>/dev/null || true
# Check shell profiles
for file in .bashrc .bash_profile .profile .zshrc; do
if [ -f "/home/{{ compromised_user }}/$file" ]; then
cp "/home/{{ compromised_user }}/$file" {{ evidence_path }}/{{ inventory_hostname }}/persistence/
fi
done
- name: Investigate lateral movement
block:
- name: Check SSH known_hosts
shell: |
if [ -f "/home/{{ compromised_user }}/.ssh/known_hosts" ]; then
cp "/home/{{ compromised_user }}/.ssh/known_hosts" {{ evidence_path }}/{{ inventory_hostname }}/lateral_movement/
# Extract unique hosts
awk '{print $1}' "/home/{{ compromised_user }}/.ssh/known_hosts" | sort -u > {{ evidence_path }}/{{ inventory_hostname }}/lateral_movement/ssh_targets.txt
fi
- name: Check command history
shell: |
for hist_file in .bash_history .zsh_history .sh_history; do
if [ -f "/home/{{ compromised_user }}/$hist_file" ]; then
cp "/home/{{ compromised_user }}/$hist_file" {{ evidence_path }}/{{ inventory_hostname }}/lateral_movement/
# Look for SSH/SCP/RSYNC commands
grep -E "ssh|scp|rsync|curl|wget" "/home/{{ compromised_user }}/$hist_file" > {{ evidence_path }}/{{ inventory_hostname }}/lateral_movement/network_commands.txt 2>/dev/null || true
fi
done
- name: System-wide credential audit
block:
- name: Check for stored credentials
shell: |
# Search for potential credential files
find /home/{{ compromised_user }} -type f \( -name "*.pem" -o -name "*.key" -o -name "*.pfx" -o -name "*password*" -o -name "*cred*" \) -size -1M > {{ evidence_path }}/{{ inventory_hostname }}/credentials/found_credential_files.txt 2>/dev/null || true
# Check environment variables in processes
for pid in $(ps -u {{ compromised_user }} -o pid --no-headers 2>/dev/null); do
if [ -r /proc/$pid/environ ]; then
tr '\0' '\n' < /proc/$pid/environ | grep -E "PASS|TOKEN|KEY|SECRET" >> {{ evidence_path }}/{{ inventory_hostname }}/credentials/process_env_vars.txt 2>/dev/null || true
fi
done
- name: Audit sudo privileges
shell: |
grep {{ compromised_user }} /etc/sudoers* > {{ evidence_path }}/{{ inventory_hostname }}/credentials/sudo_privileges.txt 2>/dev/null || true
- name: Generate IoCs
shell: |
cat > {{ evidence_path }}/{{ inventory_hostname }}/iocs.json << EOF
{
"incident_id": "{{ incident_id }}",
"timestamp": "{{ ansible_date_time.iso8601 }}",
"compromised_user": "{{ compromised_user }}",
"source_ips": [
$(grep "Accepted.*{{ compromised_user }}" /var/log/auth.log* | awk '{print $11}' | sort -u | sed 's/^/"/;s/$/"/' | tr '\n' ',' | sed 's/,$//')
],
"suspicious_files": [
$(find /home/{{ compromised_user }} -type f -mtime -7 -executable | head -20 | sed 's/^/"/;s/$/"/' | tr '\n' ',' | sed 's/,$//')
],
"network_connections": [
$(ss -tunp | grep {{ compromised_user }} | awk '{print $5}' | sort -u | sed 's/^/"/;s/$/"/' | tr '\n' ',' | sed 's/,$//')
]
}
EOF
- name: Remediation actions
block:
- name: Reset user password
user:
name: "{{ compromised_user }}"
password: "{{ lookup('password', '/tmp/{{ compromised_user }}_temp_pass length=20 chars=ascii_letters,digits,punctuation') | password_hash('sha512') }}"
when: reset_password
register: password_reset
- name: Generate new SSH keys
openssh_keypair:
path: "/home/{{ compromised_user }}/.ssh/id_rsa_new"
type: rsa
size: 4096
owner: "{{ compromised_user }}"
group: "{{ compromised_user }}"
when: reset_password
- name: Notify user
mail:
to: "{{ compromised_user }}@{{ domain }}"
subject: "Security Alert: Account Compromise Detected"
body: |
Your account has been temporarily disabled due to suspected compromise.
Actions taken:
- Account locked
- Active sessions terminated
- SSH keys revoked
- Password reset required
Please contact the security team immediately to regain access.
Incident ID: {{ incident_id }}
delegate_to: localhost
when: send_notifications | default(true)
Data Exfiltration Response
Detecting and responding to data theft:
---
# data_exfiltration_response.yml
- name: Data Exfiltration Response
hosts: "{{ target_hosts }}"
gather_facts: yes
become: yes
vars:
incident_type: "data_exfiltration"
sensitive_data_paths:
- /var/lib/mysql
- /var/lib/postgresql
- /home/*/Documents
- /opt/application/data
suspicious_ports:
- 20
- 21
- 22
- 445
- 873
- 3389
tasks:
- name: Detect exfiltration indicators
block:
- name: Analyze network traffic volume
shell: |
# Get network statistics
vnstat -d > {{ evidence_path }}/{{ inventory_hostname }}/network/daily_traffic.txt 2>/dev/null || true
# Check for unusual data transfers
netstat -i | awk 'NR>2 {print $1, $8}' > {{ evidence_path }}/{{ inventory_hostname }}/network/interface_stats.txt
# Look for large outbound connections
ss -tunp | awk '$2 > 1000000 {print}' > {{ evidence_path }}/{{ inventory_hostname }}/network/large_connections.txt
- name: Check for compression/archiving activity
shell: |
# Search for recent archive creation
find /tmp /var/tmp /home -name "*.zip" -o -name "*.tar*" -o -name "*.7z" -o -name "*.rar" -type f -mtime -7 > {{ evidence_path }}/{{ inventory_hostname }}/exfiltration/recent_archives.txt
# Check process history for archiving commands
ps aux | grep -E 'tar|zip|7z|rar' | grep -v grep > {{ evidence_path }}/{{ inventory_hostname }}/exfiltration/archive_processes.txt
- name: Identify suspicious outbound connections
shell: |
# Check connections to suspicious ports
for port in {{ suspicious_ports | join(' ') }}; do
ss -tn state established "( dport = :$port or sport = :$port )" >> {{ evidence_path }}/{{ inventory_hostname }}/network/suspicious_connections.txt 2>/dev/null
done
- name: Analyze DNS queries
shell: |
# Check for DNS tunneling indicators
tcpdump -nn -r /var/log/tcpdump.pcap 'port 53' 2>/dev/null | awk '{print $5}' | sort | uniq -c | sort -rn | head -50 > {{ evidence_path }}/{{ inventory_hostname }}/network/top_dns_queries.txt || true
# Look for long DNS queries (potential tunneling)
grep -E '[a-zA-Z0-9]{50,}' /var/log/dnsmasq.log > {{ evidence_path }}/{{ inventory_hostname }}/network/long_dns_queries.txt 2>/dev/null || true
- name: Identify compromised data
block:
- name: Check file access logs
shell: |
# Audit file access (if auditd is running)
ausearch -f {{ item }} --start today > {{ evidence_path }}/{{ inventory_hostname }}/file_access/{{ item | basename }}_access.log 2>/dev/null || true
loop: "{{ sensitive_data_paths }}"
- name: Database activity analysis
shell: |
# MySQL slow query log
if [ -f /var/log/mysql/slow.log ]; then
tail -n 1000 /var/log/mysql/slow.log > {{ evidence_path }}/{{ inventory_hostname }}/database/mysql_slow_queries.log
fi
# PostgreSQL logs
if [ -d /var/log/postgresql ]; then
grep -E 'SELECT.*FROM|COPY.*TO' /var/log/postgresql/*.log | tail -n 1000 > {{ evidence_path }}/{{ inventory_hostname }}/database/postgres_exports.log 2>/dev/null || true
fi
- name: Check for data staging
find:
paths:
- /tmp
- /var/tmp
- /dev/shm
patterns:
- "*.sql"
- "*.csv"
- "*.json"
- "*.xml"
age: "-7d"
size: "+1m"
register: staged_data
- name: Contain data exfiltration
block:
- name: Block outbound traffic to suspicious IPs
iptables:
chain: OUTPUT
destination: "{{ item }}"
jump: DROP
comment: "IR block - {{ incident_id }}"
loop: "{{ suspicious_ips | default([]) }}"
when: suspicious_ips is defined
- name: Rate limit outbound connections
iptables:
chain: OUTPUT
match: hashlimit
hashlimit_name: exfil_limit
hashlimit_above: 10/minute
hashlimit_mode: srcip
hashlimit_burst: 20
jump: DROP
comment: "IR rate limit - {{ incident_id }}"
- name: Kill suspicious processes
shell: |
# Kill processes with high network usage
for pid in $(netstat -tunp 2>/dev/null | awk '$2 > 500000 {print $7}' | cut -d'/' -f1 | sort -u); do
if [ ! -z "$pid" ]; then
ps -p $pid -o comm= >> {{ evidence_path }}/{{ inventory_hostname }}/processes/killed_high_network.txt
kill -15 $pid 2>/dev/null || true
fi
done
- name: Forensic data collection
block:
- name: Capture full network traffic
shell: |
timeout 300 tcpdump -i any -w {{ evidence_path }}/{{ inventory_hostname }}/network/full_capture_{{ ansible_date_time.epoch }}.pcap -C 100 -W 10
async: 310
poll: 0
register: pcap_capture
- name: Memory acquisition
shell: |
# Use LiME if available
if [ -f /proc/kallsyms ]; then
insmod /opt/forensics/lime.ko "path={{ evidence_path }}/{{ inventory_hostname }}/memory/memory.lime format=lime"
fi
when: acquire_memory | default(false)
failed_when: false
- name: File timeline generation
shell: |
# Generate timeline of file activity
find {{ sensitive_data_paths | join(' ') }} -type f -printf '%T@ %Tc %p\n' 2>/dev/null | sort -rn | head -1000 > {{ evidence_path }}/{{ inventory_hostname }}/timeline/file_timeline.txt
- name: Process timeline
shell: |
# Create process tree with timing
ps -eo pid,ppid,cmd,etime,lstart --forest > {{ evidence_path }}/{{ inventory_hostname }}/processes/process_tree_timeline.txt
- name: Generate exfiltration report
template:
src: exfiltration_report.j2
dest: "{{ evidence_path }}/{{ inventory_hostname }}/exfiltration_report.html"
vars:
data_at_risk: "{{ staged_data.files | map(attribute='path') | list }}"
suspicious_connections: "{{ lookup('file', evidence_path + '/' + inventory_hostname + '/network/suspicious_connections.txt', errors='ignore') }}"
timeline: "{{ lookup('file', evidence_path + '/' + inventory_hostname + '/timeline/file_timeline.txt', errors='ignore') }}"
Web Application Attack Response
Responding to web application compromises:
---
# web_attack_response.yml
- name: Web Application Attack Response
hosts: web_servers
gather_facts: yes
become: yes
vars:
incident_type: "web_attack"
web_root: "/var/www/html"
log_paths:
- /var/log/apache2
- /var/log/nginx
- /var/log/httpd
common_webshells:
- "*.php"
- "*.phtml"
- "*.php3"
- "*.php4"
- "*.php5"
- "*.phar"
- "*.asp"
- "*.aspx"
- "*.jsp"
tasks:
- name: Analyze web logs for attacks
block:
- name: Extract suspicious requests
shell: |
# Common attack patterns
grep -E 'union.*select|<script|javascript:|onerror=|onclick=|<iframe|base64_decode|eval\(|system\(|exec\(|passthru\(' {{ item }}/*access* > {{ evidence_path }}/{{ inventory_hostname }}/web_logs/suspicious_requests.log 2>/dev/null || true
# SQL injection attempts
grep -E "(\%27)|(\')|(\-\-)|(\%23)|(\#)|(\%3D)|(=)|(\%2F)|(\/)|(\%22)|(\")|(\\)|(\%5C)|(\%2E)|(\.)|(union|select|insert|update|delete|drop|create|alter|exec|script|javascript|alert)" {{ item }}/*access* > {{ evidence_path }}/{{ inventory_hostname }}/web_logs/sql_injection_attempts.log 2>/dev/null || true
# Path traversal
grep -E "\.\./|\.\.\\\\" {{ item }}/*access* > {{ evidence_path }}/{{ inventory_hostname }}/web_logs/path_traversal.log 2>/dev/null || true
# Get unique attacker IPs
awk '{print $1}' {{ item }}/*access* | sort | uniq -c | sort -rn | head -100 > {{ evidence_path }}/{{ inventory_hostname }}/web_logs/top_ips.txt
loop: "{{ log_paths }}"
failed_when: false
- name: Identify attack timeline
shell: |
# First attack attempt
grep -h -E 'union.*select|<script|javascript:|onerror=|onclick=' {{ log_paths | join('/*access* ') }}/*access* 2>/dev/null | head -1 > {{ evidence_path }}/{{ inventory_hostname }}/timeline/first_attack.log
# Attack frequency over time
grep -h -E 'union.*select|<script|javascript:|onerror=|onclick=' {{ log_paths | join('/*access* ') }}/*access* 2>/dev/null | awk '{print $4}' | cut -d: -f1,2 | sort | uniq -c > {{ evidence_path }}/{{ inventory_hostname }}/timeline/attack_frequency.log
- name: Search for webshells
block:
- name: Find recently modified files
find:
paths: "{{ web_root }}"
age: "-7d"
patterns: "{{ common_webshells }}"
recurse: yes
register: recent_files
- name: Scan for webshell signatures
shell: |
# Common webshell signatures
grep -r -E 'eval\(|base64_decode|system\(|exec\(|shell_exec\(|passthru\(|`.*`|\$_REQUEST|\$_POST|\$_GET' {{ web_root }} --include="*.php" --include="*.inc" > {{ evidence_path }}/{{ inventory_hostname }}/webshells/signature_matches.txt 2>/dev/null || true
# One-liner webshells
find {{ web_root }} -name "*.php" -type f -exec grep -l '^<?\(php\)\?\s*\(eval\|system\|exec\|passthru\|shell_exec\).*\$_\(GET\|POST\|REQUEST\|COOKIE\|SERVER\)' {} \; > {{ evidence_path }}/{{ inventory_hostname }}/webshells/oneliners.txt 2>/dev/null || true
# Files with suspicious permissions
find {{ web_root }} -type f \( -perm -4000 -o -perm -2000 \) > {{ evidence_path }}/{{ inventory_hostname }}/webshells/suspicious_permissions.txt
- name: Check for backdoored files
shell: |
# Compare against known good hashes if available
if [ -f /opt/security/web_file_hashes.txt ]; then
cd {{ web_root }}
md5sum -c /opt/security/web_file_hashes.txt 2>&1 | grep FAILED > {{ evidence_path }}/{{ inventory_hostname }}/webshells/modified_files.txt
fi
- name: Quarantine suspicious files
shell: |
mkdir -p {{ evidence_path }}/{{ inventory_hostname }}/quarantine
for file in $(cat {{ evidence_path }}/{{ inventory_hostname }}/webshells/signature_matches.txt | cut -d: -f1 | sort -u); do
if [ -f "$file" ]; then
# Create quarantine copy
cp -p "$file" {{ evidence_path }}/{{ inventory_hostname }}/quarantine/
# Neutralize the file
chmod 000 "$file"
echo "Quarantined: $file" >> {{ evidence_path }}/{{ inventory_hostname }}/quarantine/quarantine.log
fi
done
- name: Check for persistence mechanisms
block:
- name: Audit cron jobs
shell: |
# System crontabs
for file in /etc/crontab /etc/cron.*/*; do
if [ -f "$file" ]; then
grep -E 'wget|curl|php|python|perl|sh|bash' "$file" > {{ evidence_path }}/{{ inventory_hostname }}/persistence/suspicious_cron.txt 2>/dev/null || true
fi
done
# User crontabs
for user in $(cut -f1 -d: /etc/passwd); do
crontab -u $user -l 2>/dev/null | grep -E 'wget|curl|php|python|perl|sh|bash' >> {{ evidence_path }}/{{ inventory_hostname }}/persistence/user_cron.txt || true
done
- name: Check web server configs
shell: |
# Apache
if [ -d /etc/apache2 ]; then
grep -r -E 'php_value auto_prepend_file|php_value auto_append_file' /etc/apache2/ > {{ evidence_path }}/{{ inventory_hostname }}/persistence/apache_backdoor.txt 2>/dev/null || true
fi
# Nginx
if [ -d /etc/nginx ]; then
grep -r -E 'fastcgi_param|include' /etc/nginx/ | grep -E '\.php|\.inc' > {{ evidence_path }}/{{ inventory_hostname }}/persistence/nginx_includes.txt 2>/dev/null || true
fi
- name: Database backdoors
shell: |
# MySQL
mysql -e "SELECT user,host FROM mysql.user WHERE user NOT IN ('root','mysql','debian-sys-maint');" > {{ evidence_path }}/{{ inventory_hostname }}/persistence/mysql_users.txt 2>/dev/null || true
# Check for stored procedures
mysql -e "SELECT db,name,type FROM mysql.proc;" > {{ evidence_path }}/{{ inventory_hostname }}/persistence/mysql_procedures.txt 2>/dev/null || true
- name: Immediate remediation
block:
- name: Block attacking IPs
iptables:
chain: INPUT
source: "{{ item.split()[1] }}"
jump: DROP
comment: "Web attack from {{ item.split()[1] }} - {{ incident_id }}"
loop: "{{ lookup('file', evidence_path + '/' + inventory_hostname + '/web_logs/top_ips.txt', errors='ignore').splitlines()[:20] }}"
when: item is match('^\d+\s+\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}')
- name: Enable mod_security rules
apache2_module:
name: security2
state: present
when: "'apache' in ansible_facts.packages"
notify: restart_apache
- name: Clear suspicious sessions
shell: |
# PHP sessions
find /var/lib/php/sessions -type f -delete
# Application sessions
if [ -d {{ web_root }}/var/session ]; then
find {{ web_root }}/var/session -type f -mtime +1 -delete
fi
- name: Reset application credentials
shell: |
# Example: Reset WordPress salts
if [ -f {{ web_root }}/wp-config.php ]; then
cp {{ web_root }}/wp-config.php {{ web_root }}/wp-config.php.backup
# Generate new salts and update config
curl -s https://api.wordpress.org/secret-key/1.1/salt/ > /tmp/wp-salts.txt
# Update wp-config.php with new salts (implementation specific)
fi
- name: Generate web attack report
template:
src: web_attack_report.j2
dest: "{{ evidence_path }}/{{ incident_id }}/web_attack_report_{{ ansible_date_time.epoch }}.html"
delegate_to: localhost
run_once: true
handlers:
- name: restart_apache
systemd:
name: apache2
state: restarted
when: "'apache2' in ansible_facts.packages"
Orchestration and Integration
Master Incident Response Orchestrator
Tie everything together with a master orchestrator:
---
# master_ir_orchestrator.yml
- name: Master Incident Response Orchestrator
hosts: localhost
gather_facts: no
vars:
incident_id: "{{ lookup('pipe', 'date +%Y%m%d%H%M%S') }}"
alert_source: "{{ source | default('manual') }}"
tasks:
- name: Parse and classify incident
set_fact:
incident_classification: "{{ lookup('template', 'classify_incident.j2') }}"
- name: Create incident ticket
uri:
url: "{{ ticketing_api }}/incidents"
method: POST
body_format: json
body:
id: "{{ incident_id }}"
type: "{{ incident_classification.type }}"
severity: "{{ incident_classification.severity }}"
affected_systems: "{{ incident_classification.affected_systems }}"
description: "{{ incident_classification.description }}"
register: ticket_response
- name: Notify incident response team
include_tasks: notify_team.yml
vars:
channels:
- slack
- pagerduty
- email
- name: Execute response playbook
include_tasks: "{{ incident_classification.type }}_response.yml"
vars:
target_hosts: "{{ incident_classification.affected_systems }}"
- name: Generate executive summary
template:
src: executive_summary.j2
dest: "/incidents/{{ incident_id }}/executive_summary.pdf"
run_once: true
Integration with SIEM/SOAR
#!/usr/bin/env python3
"""
SIEM Integration for Ansible IR Playbooks
"""
import requests
import json
from ansible.plugins.callback import CallbackBase
class CallbackModule(CallbackBase):
"""Send Ansible events to SIEM"""
CALLBACK_VERSION = 2.0
CALLBACK_TYPE = 'notification'
CALLBACK_NAME = 'siem_integration'
def __init__(self):
super(CallbackModule, self).__init__()
self.siem_endpoint = self.get_option('siem_endpoint')
self.api_key = self.get_option('api_key')
def v2_playbook_on_play_start(self, play):
"""Log playbook start to SIEM"""
event = {
'event_type': 'ir_playbook_start',
'playbook': play.get_name(),
'hosts': play.hosts,
'timestamp': self._get_timestamp(),
'incident_id': play.get_variable_manager().get_vars().get('incident_id')
}
self._send_to_siem(event)
def v2_runner_on_ok(self, result):
"""Log successful tasks"""
if result._task.action in ['shell', 'command', 'script']:
event = {
'event_type': 'ir_task_success',
'host': result._host.name,
'task': result._task.name,
'module': result._task.action,
'output': result._result.get('stdout', ''),
'timestamp': self._get_timestamp()
}
self._send_to_siem(event)
def _send_to_siem(self, event):
"""Send event to SIEM"""
headers = {
'Authorization': f'Bearer {self.api_key}',
'Content-Type': 'application/json'
}
try:
response = requests.post(
self.siem_endpoint,
headers=headers,
json=event,
timeout=5
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
self._display.warning(f"Failed to send to SIEM: {e}")
Testing Your IR Playbooks
Never wait for a real incident to test your playbooks:
---
# ir_playbook_tests.yml
- name: Test IR Playbooks
hosts: test_environment
gather_facts: yes
become: yes
vars:
test_scenarios:
- name: "Ransomware simulation"
playbook: "ransomware_response.yml"
setup_tasks:
- name: Create fake encrypted files
shell: |
for i in {1..10}; do
echo "Encrypted content" > /tmp/test_file_$i.encrypted
done
- name: "Credential compromise simulation"
playbook: "credential_compromise_response.yml"
setup_tasks:
- name: Create test user
user:
name: compromised_test_user
state: present
- name: "Web attack simulation"
playbook: "web_attack_response.yml"
setup_tasks:
- name: Create webshell signature
copy:
content: "<?php system($_GET['cmd']); ?>"
dest: /var/www/html/test_shell.php
tasks:
- name: Run test scenarios
include_tasks: run_test_scenario.yml
loop: "{{ test_scenarios }}"
loop_control:
loop_var: scenario
- name: Validate playbook execution
block:
- name: Check evidence collection
stat:
path: "{{ evidence_path }}"
register: evidence_check
- name: Verify containment actions
shell: |
# Check if containment was applied
iptables -L -n | grep -c "IR block"
register: containment_check
- name: Generate test report
template:
src: ir_test_report.j2
dest: "/tests/ir_test_{{ ansible_date_time.epoch }}.html"
Best Practices and Lessons Learned
1. Evidence Preservation
Always preserve evidence before taking action:
- name: Preserve evidence before remediation
block:
- name: Create forensic image
shell: |
dd if=/dev/sda of={{ evidence_path }}/disk_image.dd bs=4M status=progress
async: 3600
poll: 0
- name: Calculate hashes
shell: |
sha256sum {{ evidence_path }}/disk_image.dd > {{ evidence_path }}/disk_image.sha256
2. Rollback Capabilities
Always plan for rollback:
- name: Create rollback point
block:
- name: Snapshot system state
shell: |
# LVM snapshot
lvcreate -L 10G -s -n incident_{{ incident_id }}_snapshot /dev/vg0/root
- name: Backup critical configs
archive:
path:
- /etc
- /var/lib
dest: "{{ backup_path }}/pre_ir_backup_{{ ansible_date_time.epoch }}.tar.gz"
3. Communication Templates
Keep stakeholders informed:
{# incident_notification.j2 #}
SECURITY INCIDENT NOTIFICATION
Incident ID: {{ incident_id }}
Type: {{ incident_type | upper }}
Severity: {{ severity }}
Start Time: {{ start_time }}
AFFECTED SYSTEMS:
{% for host in affected_hosts %}
- {{ host }} ({{ hostvars[host]['ansible_facts']['os_family'] }})
{% endfor %}
CURRENT STATUS: {{ current_status }}
ACTIONS TAKEN:
{% for action in completed_actions %}
- {{ action.timestamp }}: {{ action.description }}
{% endfor %}
NEXT STEPS:
{% for step in planned_actions %}
- {{ step }}
{% endfor %}
Incident Commander: {{ incident_commander }}
Communication Bridge: {{ bridge_number }}
4. Metrics and Improvement
Track your performance:
- name: Collect IR metrics
set_fact:
ir_metrics:
detection_time: "{{ (first_alert_time | to_datetime - incident_start_time | to_datetime).total_seconds() }}"
response_time: "{{ (first_action_time | to_datetime - first_alert_time | to_datetime).total_seconds() }}"
containment_time: "{{ (containment_complete_time | to_datetime - first_action_time | to_datetime).total_seconds() }}"
recovery_time: "{{ (recovery_complete_time | to_datetime - incident_start_time | to_datetime).total_seconds() }}"
- name: Store metrics for analysis
uri:
url: "{{ metrics_api }}/ir_metrics"
method: POST
body_format: json
body: "{{ ir_metrics }}"
Conclusion
Ansible transforms incident response from a chaotic, error-prone process into a consistent, auditable, and fast operation. The playbooks shared here are battle-tested and production-ready, but remember: they're starting points. Customize them for your environment, test them regularly, and continuously improve based on lessons learned.
The difference between good and great incident response isn't just speed – it's consistency, completeness, and the ability to learn from each incident. With Ansible, you're not just responding to incidents; you're building institutional knowledge that makes each response better than the last.
Start small, test often, and gradually build your IR automation library. Your future self at 3 AM will thank you.
Questions about IR automation? Want to share your playbooks or lessons learned? Reach out – the security community grows stronger when we share knowledge!
Related Posts
Implementing DNS-over-HTTPS (DoH) for Home Networks
Complete guide to deploying DNS-over-HTTPS on your home network for enhanced privacy and security, w...
eBPF for Security Monitoring: A Practical Guide
Learn how to leverage eBPF for real-time security monitoring in Linux environments with practical ex...
Local LLM Deployment: Privacy-First Approach
Learn how to deploy Large Language Models locally for maximum privacy and security. Complete guide c...