Incident Response Automation with SOAR Playbooks and TheHive
I automated the repetitive triage-to-containment path with SOAR playbooks that orchestrate TheHive case creation, Cortex enrichment, and webhook-driven containment actions. Removing the manual handoffs cut mean time to respond on brute-force events to under five minutes.
Before this project, every brute-force alert followed the same manual ritual: an analyst opened the SIEM alert, copied the source IP into AbuseIPDB and VirusTotal, eyeballed the scores, decided whether it was hostile, blocked the address on the perimeter firewall, posted a note in Slack, and finally wrote up a ticket. Each handoff cost minutes, and under alert volume the queue backed up badly. This lab replaces that entire chain with an event-driven playbook: an alert webhook fires a Python orchestrator, which enriches observables through Cortex analyzers, applies a deterministic verdict policy, opens a case in TheHive, executes containment, and notifies the team, all with a fully auditable timeline.
Objective & Context
Analysts spend most of their time on repeatable enrichment and notification. This lab encodes those steps as playbooks so humans focus on decisions, aligning to NIST SP 800-61 containment guidance and the SOAR operating model.
The design goal is narrow on purpose: automate only the mechanical 80% of incident response (enrichment, correlation, notification, well-understood containment like IP blocking) and keep humans in the loop for anything destructive or ambiguous. That matches how NIST SP 800-61 frames containment, pre-approved actions with clear rollback paths are exactly the ones safe to automate.
- T1110 Brute Force, auto-containment on correlated authentication failures.
- T1071 Application Layer Protocol, IOC enrichment from threat-intel feeds.
Two MITRE ATT&CK techniques drive the playbooks. T1110 (Brute Force) generates the high-volume, low-novelty alerts that drown a SOC, password-spray and SSH hammering events where the response is almost always "confirm reputation, block the source, reset targeted accounts." T1071 (Application Layer Protocol) covers C2 callbacks, where the automation's job is enrichment: resolve the domain, check threat-intel feeds, and attach verdicts to the case so a human can make the isolation call.
Cortex + intel] E --> V{Verdict} V -->|malicious| C[Containment action] C --> N[Notify Slack/Discord] V -->|benign| X[Auto-close case]
The flow above is deliberately linear: every alert is enriched first, a verdict gate decides the branch, and only the malicious branch touches the network. The benign branch still creates a (closed) case so the auto-close decision is itself auditable, if the verdict policy is ever wrong, you have a record of why the playbook let something through.
Learning Objectives
- Define a SOAR playbook trigger in Python that reacts to Wazuh alerts.
- Run Cortex analyzers for automated observable enrichment.
- Apply a verdict policy and open a TheHive case programmatically.
- Execute a containment action (IP block) from the playbook.
- Send notifications and write an audit trail for every automated action.
- Auto-close benign branches so analysts only see real cases.
Environment & Prerequisites
Prerequisites
- TheHive 5 and Cortex 3.1 with analyzers (AbuseIPDB, VirusTotal) as Docker containers.
- Python 3.11 with the TheHive4py client.
- Wazuh manager producing alerts the playbook can consume.
- Webhook endpoints for Slack or Discord notification.
- API credentials for TheHive and Cortex stored outside the code.
TheHive 5 and Cortex 3.1 run as Docker containers on a single lab VM (4 vCPU, 16 GB RAM, Elasticsearch is the memory hog). TheHive 5.x requires Cassandra as its database and Elasticsearch for indexing; Cortex shares the Elasticsearch instance in small labs but should get its own in production. The orchestrator runs on the same VM as a systemd service so the playbook survives reboots.
DOCKER COMPOSE, THEHIVE + CORTEX STACK
# docker-compose.yml (trimmed to the essentials)
version: "3"
services:
cassandra:
image: cassandra:4.0
environment:
- MAX_HEAP_SIZE=1G
- HEAP_NEWSIZE=256M
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:7.17.9
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- ES_JAVA_OPTS=-Xms2g -Xmx2g
cortex:
image: thehiveproject/cortex:3.1.7
ports: ["9001:9001"]
depends_on: [elasticsearch]
command: --es-uri http://elasticsearch:9200
thehive:
image: strangebee/thehive:5.2.9
ports: ["9000:9000"]
depends_on: [cassandra, elasticsearch, cortex]
command: |
--cql-hostnames cassandra
--es-uri http://elasticsearch:9200
--cortex-url http://cortex:9001
After the first boot, two manual one-time steps remain: create an org admin in TheHive, then generate an API key for the automation account (User Management → API keys). On the Cortex side, enable the AbuseIPDB and VirusTotal analyzers under Organization → Analyzers and paste in their API keys. Never run analyzers without rate-limit awareness, VirusTotal's free tier caps at 4 lookups/minute, which shapes the enrichment strategy below.
TLS note: every API call in this lab uses verify=False because the lab stack runs self-signed certificates. That is a lab shortcut, not a pattern to copy: in production, terminate TLS with a trusted internal CA (or add the lab CA to the trust store) and remove verify=False entirely. Disabling certificate verification on a security automation pipeline invites exactly the man-in-the-middle attacks the pipeline is supposed to stop.
ORCHESTRATOR DEPENDENCIES (PYTHON 3.11 VENV)
python3.11 -m venv ~/soar-venv && source ~/soar-venv/bin/activate
pip install thehive4py==2.0.0b6 requests==2.31.0 flask==3.0.3 tenacity==8.2.3
# Verify the client can reach both APIs before writing any playbook logic
python - <<'EOF'
import requests
h = requests.get("https://thehive:9000/api/status", verify=False)
c = requests.get("https://cortex:9001/api/status", verify=False)
print("thehive:", h.json().get("versions", {}).get("TheHive"))
print("cortex:", c.json().get("versions", {}).get("Cortex"))
EOF
Expected output: both services report their versions,
thehive: 5.2.9-1
cortex: 3.1.7-1
Common mistake: mixing thehive4py major versions. TheHive 5 needs thehive4py 2.x, whose import style (from thehive4py import TheHiveApi) differs from the 1.x style shown in older blog posts and in the legacy snippet below. Pin the version in the venv and confirm with pip show thehive4py before debugging "module has no attribute" errors.
Step-by-Step Execution
1. Define a playbook trigger in Python
The trigger is the entry point of the whole pipeline. My SIEM (Wazuh, but any tool that can POST JSON works) fires a webhook on rule 5710/5712, "multiple authentication failures", into a small Flask receiver. The receiver does three things before any playbook logic runs: validates the payload schema, assigns severity, and deduplicates. The original prototype handler looked like this:
Expected output:
from thehive4py.api import TheHiveApi
api = TheHiveApi('https://thehive:9000', 'KEY')
def on_alert(a):
if a['severity'] >= 2:
enrich(a); contain(a['source_ip']); notify(a)
That sketch works for a demo but collapses in production: it has no error handling, no dedup, and the severity comparison uses TheHive's 1.x scale. The production receiver separates receipt from processing so a slow Cortex job never blocks the webhook long enough for the SIEM to retry (which is how double-fires happen in the first place).
WEBHOOK RECEIVER WITH QUEUE + DEDUP (PYTHON)
# receiver.py: Flask endpoint, dedups, hands off to worker
import hashlib, json, queue, threading, time
from flask import Flask, request, abort
app = Flask(__name__)
work_q = queue.Queue(maxsize=500)
seen = {} # dedup cache: key -> timestamp
DEDUP_WINDOW = 300 # seconds
def dedup_key(alert):
raw = f"{alert['rule_id']}|{alert.get('source_ip','')}"
return hashlib.sha256(raw.encode()).hexdigest()[:16]
@app.route("/alert", methods=["POST"])
def on_alert():
alert = request.get_json(force=True)
required = {"rule_id", "severity", "source_ip", "timestamp"}
if not required.issubset(alert):
abort(400, "missing fields")
key = dedup_key(alert)
now = time.time()
if key in seen and now - seen[key] < DEDUP_WINDOW:
return {"status": "deduplicated", "key": key}, 202
seen[key] = now
if alert["severity"] >= 2: # medium and above only
work_q.put(alert)
return {"status": "queued", "key": key}, 202
return {"status": "below threshold"}, 200
def worker(q):
"""Drain the queue and run the full pipeline per alert (steps 2-6)."""
while True:
alert = q.get()
try:
reports = [run_analyzer("AbuseIPDB_1_0", to_observable(alert))]
v = verdict(reports)
case_id = open_case(alert)
if v["verdict"] == "malicious":
contain(alert["source_ip"])
else:
auto_close(case_id, v)
notify(alert, v, case_id)
except Exception as e: # never let one bad alert kill the worker
print(f"[worker] alert failed: {e!r}")
finally:
q.task_done()
if __name__ == "__main__":
threading.Thread(target=worker, args=(work_q,), daemon=True).start()
app.run(host="0.0.0.0", port=8443)
Durability caveat: the work_q queue and the seen dedup cache above are in-memory. A receiver restart loses queued alerts and forgets every dedup key, so the first alert burst after a restart re-fires the pipeline. For production, persist dedup keys (SQLite or Redis with the 5-minute TTL) and use a durable queue; the in-memory version here is a lab simplification, and the validation smoke test replays alerts to prove re-fire behavior is understood.
Why severity >= 2: TheHive severities map 1=low, 2=medium, 3=high, 4=critical. Automating containment on low-severity noise is how playbooks block legitimate users (think: one fat-fingered password from a VPN exit node). Medium is where brute-force correlation rules land in my SIEM tuning.
Verify: fire a test POST and confirm a 202 with a dedup key, then fire it again within five minutes and confirm the second call returns deduplicated:
curl -sk -X POST https://localhost:8443/alert \
-H "Content-Type: application/json" \
-d '{"rule_id":"5710","severity":3,"source_ip":"203.0.113.45","timestamp":"2026-09-21T02:14:07Z"}'
{"status":"queued","key":"4c8a1f9e2d7b6301"}
Common mistake: acknowledging the webhook only after enrichment completes. SIEMs retry on timeout, so the same alert re-enters the pipeline. Always ACK first (HTTP 202), process async. Check seen cache hits in the logs if cases appear twice.
What this teaches: the trigger is the contract between detection and response: which alerts deserve automation.
2. Trigger Cortex enrichment for an observable
Enrichment turns a bare IP address into a decision. The orchestrator submits the source IP to Cortex analyzers, AbuseIPDB first (free, high signal for brute force), VirusTotal second, and polls the job until it finishes. The payload file describes the observable; the response gives a job ID, not a result, so polling is mandatory.
OBSERVABLE PAYLOAD (obs.json)
Expected output:
{
"data": "203.0.113.45",
"dataType": "ip",
"tlp": 2,
"pap": 2,
"message": "auto-enrichment: brute-force rule 5710"
}
curl -sk -H "Authorization: Bearer $CORTEX" -H "Content-Type: application/json" https://cortex:9001/api/analyzer/AbuseIPDB_1_0/run -d @obs.jsonThe analyzer ID in the URL (AbuseIPDB_1_0) is the registered analyzer ID, not the display name; /api/analyzer/AbuseIPDB/run will 404. The response gives a job ID, not a result, so polling is mandatory.
POLL THE JOB UNTIL THE REPORT IS READY (PYTHON)
import requests, time
CORTEX = "https://cortex:9001"
HDR = {"Authorization": f"Bearer {CORTEX_KEY}"}
def run_analyzer(analyzer, observable, timeout=120):
job = requests.post(f"{CORTEX}/api/analyzer/{analyzer}/run",
headers=HDR, json=observable, verify=False).json()
job_id = job["id"]
deadline = time.time() + timeout
while time.time() < deadline:
r = requests.get(f"{CORTEX}/api/job/{job_id}/waitreport",
params={"atMost": "30seconds"},
headers=HDR, verify=False)
if r.status_code == 200:
return r.json()
time.sleep(3)
raise TimeoutError(f"{analyzer} job {job_id} exceeded {timeout}s")
report = run_analyzer("AbuseIPDB_1_0", observable)
taxonomies = report["report"]["summary"]["taxonomies"]
print(taxonomies)
Expected output, Cortex taxonomies flatten the analyzer verdict into predicate/value pairs:
[{'predicate': 'Abuse', 'namespace': 'AbuseIPDB', 'value': '95',
'level': 'malicious'}]
An abuseConfidenceScore of 95 with a malicious level is exactly the signal the verdict policy consumes in step 3. TLP 2 (amber) on the observable matters: analyzers respect TLP, and TLP:RED observables would refuse to leave the platform, which is correct behavior for sensitive internal indicators.
Common mistake: treating the /run response as the report. It returns {"id": "...", "status": "Waiting"}. If you parse it directly you get empty taxonomies and every alert verdicts as benign. Verify by logging job_id and confirming a waitreport 200 before reading taxonomies.
What this teaches: enrichment before action prevents the playbook from blocking on thin evidence.
3. Apply the verdict policy and open a TheHive case
The verdict gate is the one place the playbook is allowed to be opinionated, so the policy is a plain function, no hidden state, trivially unit-testable. An observable is malicious if any analyzer returns level: malicious, or if AbuseIPDB's score alone clears 80 (well-reported brute-force sources routinely score 90+). Anything else is benign for automation purposes and routed to the auto-close branch.
DETERMINISTIC VERDICT POLICY (PYTHON)
Expected output:
def verdict(reports):
score = 0
for rep in reports:
for t in rep["report"]["summary"]["taxonomies"]:
if t["level"] == "malicious":
return {"verdict": "malicious", "score": 100}
if t["namespace"] == "AbuseIPDB":
score = max(score, int(t["value"]))
if score >= 80:
return {"verdict": "malicious", "score": score}
return {"verdict": "benign", "score": score}
On a malicious verdict the orchestrator creates a TheHive case with the alert's observables attached, so the case itself becomes the audit record of every automated decision:
CASE CREATION VIA THEHIVE4PY 2.X (PYTHON)
from thehive4py import TheHiveApi
hive = TheHiveApi(url="https://thehive:9000", apikey=HIVE_KEY, verify=False)
case = hive.case.create(case={
"title": f"[AUTO] Brute force from {alert['source_ip']}",
"description": "Opened by SOAR playbook after malicious verdict.",
"severity": 3,
"tlp": 2,
"tags": ["soar", "auto-triage", "T1110"],
})
case_id = case["_id"]
hive.case.add_observable(case_id, {
"dataType": "ip", "data": alert["source_ip"],
"message": "verdict=malicious score=95", "ioc": True,
})
print("case created:", case["_id"]) # e.g. case created: ~8200
Verify: open the TheHive UI and confirm the case exists with the soar tag, the IP observable flagged as IOC, and a timeline entry naming the automation account as the actor. If the API key's profile lacks case/create permission you'll get a 403, the automation account needs the analyst profile, not read-only.
What this teaches: verdict policies encode analyst judgment so the machine decides consistently.
4. Execute the containment action
Containment is a network change, so it gets its own step with its own safeguards. The playbook blocks the source IP on the perimeter firewall through its API. The example below targets the pfSense/OPNsense API (alias add + apply); adapt the two calls to your own firewall vendor's API, the pattern (named block-list + separate apply step) is what transfers, not the exact URLs. Two rules keep this safe: the block is a time-boxed rule (a cleanup job removes it after 24 hours, forcing re-verdict if the attack persists), and a hardcoded allowlist protects management networks and upstream resolvers from ever being blocked by automation.
TIME-BOXED BLOCK WITH ALLOWLIST GUARD (PYTHON)
Expected output:
import ipaddress, requests, sqlite3, time
# Narrow allowlist: only the IR management subnet and the VPN egress.
# Internal hosts are NOT allowlisted here; a compromised internal host is
# isolated via the EDR branch (practice challenge 1), not silently ignored.
NEVER_BLOCK = ["192.168.10.0/24", # IR management subnet
"203.0.113.10/32"] # our VPN egress
def contain(ip):
addr = ipaddress.ip_address(ip)
for net in NEVER_BLOCK:
if addr in ipaddress.ip_network(net):
return {"action": "skipped", "reason": f"allowlisted {net}"}
r = requests.post(
"https://fw01.example.internal/api/v2/firewall/alias/add_address",
headers={"Authorization": f"Bearer {FW_KEY}"},
json={"name": "SOAR_BLOCK", "address": ip,
"descr": "auto-block case ~8200 (24h TTL)"},
verify=False, timeout=10)
r.raise_for_status()
requests.post("https://fw01.example.internal/api/v2/firewall/apply",
headers={"Authorization": f"Bearer {FW_KEY}"},
verify=False, timeout=15)
db = sqlite3.connect("/var/lib/soar/blocks.db")
db.execute("INSERT OR REPLACE INTO blocks(ip, blocked_at) VALUES (?, ?)",
(ip, int(time.time())))
db.commit()
return {"action": "blocked", "ip": ip, "ttl_hours": 24}
def expire_blocks(ttl_hours=24):
"""Cron/systemd-timer job: remove blocks older than the TTL."""
cutoff = int(time.time()) - ttl_hours * 3600
db = sqlite3.connect("/var/lib/soar/blocks.db")
for (ip,) in db.execute("SELECT ip FROM blocks WHERE blocked_at < ?",
(cutoff,)):
requests.post(
"https://fw01.example.internal/api/v2/firewall/alias/del_address",
headers={"Authorization": f"Bearer {FW_KEY}"},
json={"name": "SOAR_BLOCK", "address": ip},
verify=False, timeout=10)
db.execute("DELETE FROM blocks WHERE ip = ?", (ip,))
requests.post("https://fw01.example.internal/api/v2/firewall/apply",
headers={"Authorization": f"Bearer {FW_KEY}"},
verify=False, timeout=15)
db.commit()
TTL EXPIRY TIMER (SYSTEMD, RUNS HOURLY)
# /etc/systemd/system/soar-expire.service + .timer (timer: OnCalendar=hourly)
# ExecStart=/home/soar/soar-venv/bin/python -c "from receiver import expire_blocks; expire_blocks()"
sqlite3 /var/lib/soar/blocks.db \
"CREATE TABLE IF NOT EXISTS blocks(ip TEXT PRIMARY KEY, blocked_at INTEGER);"
The firewall alias SOAR_BLOCK is bound to a single floating rule at the top of the WAN ruleset (block, source = SOAR_BLOCK alias), so adding an address is the only operation the API key ever needs, the playbook cannot accidentally edit real rules.
Verify on the firewall (pfsense/opnsense CLI equivalent):
pfctl -t SOAR_BLOCK -T show | grep 203.0.113.45
203.0.113.45
Common mistake: forgetting the apply call. On pfSense-style APIs, alias edits are staged until applied, so the address appears in the GUI but isn't in the live table, pfctl -T show is the ground truth, not the web UI. Second mistake: no TTL. A permanent auto-block list grows until it becomes its own outage; the 24-hour expiry forces every block to re-earn its place.
What this teaches: containment must be reversible and logged, because automation sometimes gets it wrong.
5. Fire the notification webhook and log the audit trail
Notification closes the loop with the humans. The message is deliberately terse, verdict, score, action, case number, because its job is awareness, not forensics; the case holds the detail. Every action also appends to the case timeline so the whole run is reconstructable months later.
curl -X POST -H "Content-Type: application/json" $SLACK_WEBHOOK -d '{"text":"[AUTO] 203.0.113.45 blocked, case ~8200"}'Slack incoming webhooks require the application/json content type; without it the POST is rejected. In the Python notifier, the same POST is wrapped in tenacity with exponential backoff (3 attempts, 2s/8s/32s) per the common-mistake note below, the one-shot curl above is only the manual smoke test.
STRUCTURED AUDIT ENTRY ON THE CASE (PYTHON)
Expected output:
hive.case.create_task_log(case_id, {
"message": "SOAR run complete\n"
"verdict=malicious score=95 action=blocked\n"
"enrich_ms=18230 contain_ms=940 notify_ms=210\n"
"playbook_version=1.4.2"
})
Recording the playbook version in every log entry pays off later: when you change the verdict threshold and false-positive rates shift, you can group case outcomes by version and prove which change did it.
{"verdict":"malicious","score":92,"action":"blocked","mttr_seconds":214}
The summary line above is what the orchestrator prints at the end of a full run, 214 seconds from alert receipt to blocked IP, comfortably inside the 5-minute target. The bulk of that (about 3 minutes of the 3:34) is Cortex job time; everything the playbook itself controls is under 2 seconds.
Common mistake: swallowing webhook failures. If Slack returns 429 (rate limit) or 5xx and you don't retry, containment happened but nobody knows. Wrap the POST in tenacity with exponential backoff (3 attempts, 2s/8s/32s) and, on final failure, write the notification payload to a fallback file a cron job re-sends.
What this teaches: notifications plus audit trails keep automated actions visible and accountable.
6. Wire the benign branch to auto-close
The diagram's benign branch exists so verdict policy stays honest: a case is opened for every alert that crossed the severity threshold, enriched, verdicted, and, when benign, immediately closed with the enrichment evidence attached. This makes "the playbook ignored it" distinguishable from "the playbook saw it and cleared it," which matters enormously when you tune thresholds later.
AUTO-CLOSE WITH EVIDENCE (PYTHON)
Expected output:
if v["verdict"] == "benign":
hive.case.create_task_log(case_id, {
"message": f"auto-close: benign verdict, score={v['score']}"
})
# status is a fixed enum (Open/InProgress/Resolved/...); the verdict
# lives in resolutionStatus (FalsePositive/TruePositive/Indeterminate/...).
hive.case.update(case_id, fields={
"status": "Resolved",
"resolutionStatus": "FalsePositive",
"summary": "Auto-closed by SOAR playbook: benign verdict, no malicious taxonomy."
})
Verify: replay a known-clean IP (e.g. 8.8.8.8) through the webhook and confirm a case appears, gets enriched, and closes itself within the enrichment window, with the score in the log. If the benign branch never fires during testing, the verdict policy is almost certainly too aggressive.
What this teaches: auto-closing the benign branch is what actually reduces the queue; blocking alone does not.
How It Works
SOAR (security orchestration, automation, and response) encodes the repeatable parts of incident response as playbooks: linear sequences of API calls with branching on verdicts. The design goal here is deliberately narrow: automate the mechanical 80 percent (enrichment, correlation, notification, well-understood containment like IP blocking) and leave judgment calls to humans.
The playbook consumes Wazuh alerts, enriches observables through Cortex analyzers (reputation, geolocation, passive DNS), applies a verdict policy (block, monitor, or close), executes the action through the right tool (firewall API, TheHive case creation), and logs everything. Each stage is idempotent where possible so retries are safe.
The trust model matters more than the code. Automation acts with the credentials it holds, so the playbook's service accounts get least privilege, every action is announced on the webhook, and a kill switch (disabling the trigger) exists for when the automation misbehaves. Speed without accountability is a liability.
Validation & Testing
Inject a synthetic malicious-IP alert and confirm the playbook enriches, contains, notifies, and updates the case without analyst input. Pass criteria: end-to-end MTTR under 5 minutes and an auditable case timeline.
Three layers of testing, in order. Unit tests pin the verdict policy so a refactor can't silently flip it:
UNIT TEST THE VERDICT GATE (PYTEST)
def test_malicious_taxonomy_wins():
reports = [fake_report(level="malicious", score=30)]
assert verdict(reports)["verdict"] == "malicious"
def test_score_threshold_boundary():
assert verdict([fake_report(score=79)])["verdict"] == "benign"
assert verdict([fake_report(score=80)])["verdict"] == "malicious"
def test_empty_reports_are_benign():
assert verdict([])["verdict"] == "benign"
Then an end-to-end injection runs the real pipeline against a firewall running in a lab VLAN, never test containment against production. The injector timestamps the alert; the orchestrator's final log line carries the elapsed time, so MTTR is measured, not guessed:
END-TO-END SYNTHETIC ALERT INJECTION
./inject_test_alert.py --ip 198.51.100.77 --severity 3 --rule 5710
[+] webhook accepted (202), key=9f31aa72c04d18be
[+] case ~8204 created
[+] cortex job finished: AbuseIPDB level=malicious score=98
[+] containment: blocked 198.51.100.77 (ttl=24h)
[+] slack notification delivered (200)
[+] MTTR: 187 seconds, PASS (< 300s threshold)
Finally, the audit check: pull the case timeline via the API and assert every stage left an entry. A run that contains but doesn't log fails validation even though it "worked", unauditable automation is a liability, not a capability.
curl -sk -H "Authorization: Bearer $HIVE" \
https://thehive:9000/api/v1/query \
-d '{"query":[{"_name":"getCase","idOrName":"~8204"},
{"_name":"logs"}]}' | python -m json.tool | grep -c '"message"'
6
Six log entries, receipt, enrichment start, enrichment result, verdict, containment, notification, means the timeline is complete. Run the injection weekly as a scheduled smoke test; playbooks rot silently as APIs and tokens drift.
Advanced: Troubleshooting
- Playbook double-fires: add idempotency keyed on source IP + rule ID.
- Cortex timeouts: raise job timeout and verify analyzer API quotas.
- Webhook silent: log non-2xx responses and retry with backoff.
Troubleshooting
- TheHive container starts then exits immediately. Almost always Cassandra not being ready, TheHive 5 has no retry patience on first boot. Check
docker logs cassandrafor "Listening for thrift clients" before starting TheHive, or add a healthcheck-gateddepends_on: condition: service_healthyin the compose file. - Cortex jobs stay in "Waiting" forever. The analyzer job is queued but no worker picked it up. Confirm Elasticsearch is green (
curl localhost:9200/_cluster/health) and that the analyzer is actually enabled for your org, not just globally. Also check the analyzer's own quota: VirusTotal free tier returning 204s stalls jobs silently, Cortex's job log shows the raw analyzer stderr. - Verdicts come back benign for obviously bad IPs. The
/runresponse is being parsed as if it were the report (step 2's classic trap), or TLP on the observable is RED so analyzers refuse to run. Log the raw report JSON for one run and inspectsummary.taxonomiesby hand. - Containment applies but traffic still flows. The alias was edited but the firewall ruleset wasn't reloaded, or the floating rule sits below a broad pass rule. Verify with
pfctl -t SOAR_BLOCK -T showandpfctl -sr | grep SOAR_BLOCK, the block rule must evaluate before any pass that would match. - Cases appear twice for one attack burst. The dedup window is too short for the SIEM's retry interval, or the dedup key includes a field that varies per retry (like a nonce). Key on
rule_id + source_iponly, and make sure the webhook returns 202 before doing any slow work so the SIEM never retries in the first place.
Key Results
- Cut mean time to respond (MTTR) to under 5 minutes for brute-force events.
- Automated enrichment + containment on 1,000+ alerts/day without analyst input.
- Reduced analyst manual handling time by roughly 80%.
- Maintained a 100% auditable case timeline for every automated action.
Key Takeaways
- Automate the mechanical 80%, enrichment, correlation, notification, reversible containment, and keep humans on destructive or ambiguous decisions.
- ACK webhooks immediately and process asynchronously; synchronous processing is the root cause of duplicate playbook runs.
- Enrichment is asynchronous too:
/runreturns a job ID, and parsing it as the final report silently breaks verdicts. - Every automated network change needs an allowlist guard and a TTL, automation that can block anything forever will eventually block something important.
- The benign branch is a feature: auto-closed cases make verdict-policy tuning measurable instead of anecdotal.
- Measure MTTR from the alert timestamp, log every stage to the case timeline, and re-run the synthetic injection weekly to catch API and token rot.
Practice Challenges
Challenge 1: Add a second containment
Goal: Extend the playbook to isolate a compromised endpoint via the EDR API in addition to blocking the IP.
Hint: Add a verdict branch for internal observables that calls the EDR isolate endpoint before opening the case.
Challenge 2: Dry-run mode
Goal: Add a global dry-run flag that logs every action it would take without executing, for safe testing.
Hint: Wrap each action call in a function that checks the flag and emits the would-be API payload to the audit log.