I Automated My Incident Response


After building a home SOC lab with Splunk and catching a web shell scanner probing my server with 580 requests, I had a follow-up thought: what if I did not have to be at the keyboard to respond to this stuff?

The manual workflow was getting old. Attacker hits the server. Splunk alerts. I log in, investigate, run ufw deny, close the terminal. Repeat. It worked but it required me to actually be awake and paying attention, which is frankly a design flaw.

So I built an automated incident response playbook script. It runs as a persistent systemd service, monitors my server logs in real time, detects two specific threat patterns, fires a formatted Slack alert with the attacker details, and automatically blocks the IP via UFW. No manual intervention required.

Within 24 hours of deploying it, three real attackers had been detected and blocked overnight while I slept. Which is either a great outcome or a depressing commentary on how many people are scanning the internet at 11 PM on a Sunday. Probably both.


The script monitors two threat patterns that showed up repeatedly in the Splunk lab data.

The script reads the systemd journal for Invalid user events. When the same IP hits the threshold of 3 failed attempts it fires a Critical Slack alert and blocks the IP. On a hardened server with SSH key-only authentication these events still appear in the journal even though the connection is rejected before any password is attempted. That means the detection works correctly regardless of whether password authentication is enabled.

Why systemd journal instead of auth.log?
On Ubuntu 22.04 and 24.04, SSH authentication events go to the systemd journal rather than being written to /var/log/auth.log by default. The script uses journalctl to read from the journal directly, which is the correct approach on modern Ubuntu systems.

The script reads the Nginx access log and flags any IP sending requests to suspicious PHP paths. The pattern list covers randomized PHP filenames, WordPress-specific backdoor paths, .env file probes, and known shell names. After 10 matching requests from the same IP it fires an alert and blocks the IP.

Why 10 requests for web scanning vs 3 for SSH?
Web scanners typically send dozens to hundreds of requests in a burst. Setting the threshold too low would generate alerts for legitimate crawlers and search bots that occasionally hit a PHP path. 10 requests from the same IP to suspicious paths in a short window is a reliable signal of automated scanning rather than accidental traffic.


The architecture is intentionally simple. A Python script runs in an infinite loop with a 60-second sleep between each check. On each iteration, it reads new lines from the Nginx access log and queries the systemd journal for SSH events since the last check. If a threshold is crossed it fires a Slack webhook and runs UFW.

The script runs as a systemd service so it starts automatically on every reboot and restarts itself if it crashes. The service file is straightforward:

[Unit] Description=IR Playbook Bot – Automated Incident Response After=network.target [Service] Type=simple User=agent47 ExecStart=/usr/bin/python3 /home/agent47/ir_playbook.py Restart=always RestartSec=10 [Install] WantedBy=multi-user.target

One important detail, the script needs to run two sudo commands (journalctl and ufw) without a password prompt since there is no interactive terminal when running as a service. The fix is a targeted sudoers entry that grants passwordless access to exactly those two commands and nothing else. Least privilege, same as it should be.


I deployed the bot on a Sunday evening. By Monday morning it had caught three real attackers. None of these were test data. All three were legitimate malicious actors scanning the internet and running into my server.

Time Attacker IP Path Probed Attack Type Result
7:54 PM Sun 20.38.9.144 /.env.sample Credential harvesting Blocked
8:06 PM Sun 74.248.96.44 /100.php Web shell scanner Blocked
11:00 PM Sun 20.151.129.194 /classwithtostring.php?p= PHP object injection Blocked

The first alert came in less than two hours after deployment. 20.38.9.144 was probing for /.env.sample, a file that contains application environment variables including database passwords, API keys, and service credentials. This is one of the most common automated scans on the internet because exposed .env files are a goldmine for attackers who find one. This server does not have one. The probe returned 404, Slack fired, UFW blocked.

Twelve minutes later a second IP showed up. 74.248.96.44 was probing for /100.php, a randomized PHP filename associated with web shell scanning. The same pattern we saw in the Splunk lab with 580 requests from 4.223.96.99, just a different IP and filename. Detected, alerted, blocked.

The late night one was the most interesting. 20.151.129.194 was probing for /classwithtostring.php?p=, a path associated with PHP object injection attacks and Java deserialization exploits. The ?p= query parameter is the tell: the attacker was testing whether a PHP file would accept and execute a serialized object passed as a parameter. This is a more sophisticated probe than a generic web shell scanner and suggests an automated tool specifically targeting PHP deserialization vulnerabilities. Detected at 11 PM while I was asleep. Blocked automatically. Zero manual intervention.


The script has three main pieces worth understanding if you want to build your own.

Every detection routes through a single send_slack_alert function that formats the message with an emoji, timestamp, and structured fields before posting to the webhook. Keeping alerting centralized means the format is consistent regardless of which detection triggered it.

def send_slack_alert(title, message, severity=’WARNING’): emoji = ‘:rotating_light:’ if severity == ‘CRITICAL’ else ‘:warning:’ timestamp = datetime.now().strftime(‘%Y-%m-%d %H:%M:%S’) payload = { ‘text’: f'{emoji} *{title}*\n{message}\n_Detected at {timestamp}_’ } response = requests.post(WEBHOOK_URL, json=payload, timeout=10)

Auto-blocking is optional via the AUTO_BLOCK flag. When enabled, a confirmed attacker IP gets a UFW deny rule added immediately after the alert fires. The function uses subprocess to run the UFW command and reports success or failure back to the caller so the Slack message can include the block confirmation.

def block_ip(ip): subprocess.run( [‘sudo’, ‘ufw’, ‘deny’, ‘from’, ip, ‘to’, ‘any’], check=True, capture_output=True ) return True

The main loop tracks the last read position in the Nginx log file using Python’s file seek and tell methods. On each iteration it reads only the new lines added since the last check, which means it does not reprocess the entire log file every 60 seconds. For the SSH journal it queries journalctl with a –since timestamp set to one check interval ago.


The full script and installation guide are on GitHub. Here is the short version of what you need:

  • Ubuntu Linux server (tested on 24.04)
  • Python 3 with the requests library installed
  • A Slack workspace with an incoming webhook configured
  • Nginx with access logging enabled
  • UFW firewall installed and active
Security note before you deploy
Never commit your Slack webhook URL to a public GitHub repository. GitHub’s push protection will catch it and block the push, but the URL may still be exposed in the git history. Use a placeholder in the version you push and set the real URL directly on the server. If you accidentally expose it, regenerate the webhook immediately in your Slack app settings.

The full README on GitHub walks through the complete installation including the sudoers configuration, the systemd service file, and the Nginx log permission fix that is required for the script to read the access log as a non-root user.

GitHub: https://github.com/RonMercier/ir-playbook-bot


  • Automation does not have to be complex to be effective. This is a 150-line Python script running a while loop. It caught three real attackers in its first 24 hours.
  • The systemd service pattern is worth learning. Any Python script that needs to run continuously on a Linux server should be a service, not a cron job or a backgrounded terminal session that disappears on reboot.
  • The sudo least-privilege pattern matters. Granting passwordless sudo for exactly two specific commands is meaningfully more secure than broad sudo access, even on a personal server.
  • Real attack data is more compelling than lab data. Every detection in this post came from genuine malicious activity, not simulated traffic. If you run a public-facing server, you will see real attackers within hours of deployment.
What is next
The obvious extension is adding more detection patterns, failed authentication attempts for other services, port scanning signatures in the firewall logs, and repeated 404s suggesting directory enumeration. A future version will also log all detections to a local file for historical analysis rather than relying solely on Slack message history.
// Before you go

Get the security checklist most
businesses skip.

A free 25-point audit covering the exact gaps attackers hit first, written by an engineer in plain language. Plus one practical security breakdown every Tuesday.

Get the Free Checklist →

Free on signup  ·  Unsubscribe anytime  ·  ~1 email per week

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top