I wired Wazuh detections into TheHive case management and enriched every indicator against MISP threat-intelligence feeds, producing a closed-loop SOAR pipeline. Rule tuning and automated enrichment cut alert noise by 94% and brought mean time to respond on brute-force attacks below five minutes.

Every SOC drowns in the same problem: the SIEM fires thousands of alerts a day, most of them duplicates or low-value noise, and the analyst burns their shift on manual triage instead of actual response. This lab builds the alternative. A Wazuh manager detects the attack, a custom integration script forwards only high-severity, deduplicated alerts into TheHive as cases, Cortex analyzers enrich every IP and hash against MISP and AbuseIPDB, and Wazuh Active Response blocks confirmed malicious sources, all without a human touching the keyboard until the case is already enriched and contained.

By the end of this walkthrough you will have: custom ATT&CK-tagged detection rules, a working bidirectional integration between Wazuh and TheHive 5, automated IOC enrichment through Cortex, an Active Response containment loop, and a validation method that proves the whole chain fires within five minutes of the first failed login.

Threat Model & MITRE ATT&CK Mapping

The pipeline operationalizes detection-to-response so that high-fidelity alerts become enriched cases without manual triage. Custom Wazuh rules are tagged with ATT&CK technique IDs for coverage reporting against the enterprise matrix.

Before writing a single rule I mapped the lab's realistic threats against ATT&CK. The homelab exposes SSH and RDP to an internal VLAN, runs web services on two nodes, and has outbound internet access, so the credible threats are password brute forcing, post-exploitation command execution, and command-and-control beacons. Each one gets a dedicated detection rule with a <mitre> block so Wazuh's MITRE dashboard can render coverage automatically.

  • T1110 Brute Force, SSH/RDP authentication-failure correlation triggers Active Response.
  • T1059 Command and Scripting Interpreter, suspicious shell execution via auditd integration.
  • T1071 Application Layer Protocol, C2 beacon detection from MISP network IOCs.

The end-to-end data flow looks like this. An agent ships logs to the manager, the rule engine matches and tags the alert, the integration daemon creates a TheHive case, Cortex runs analyzers against every observable in the case, MISP returns a verdict, and the verdict drives either automated containment or an auto-close:

The critical design decision is where the verdict gate sits. Enrichment happens before containment for network IOCs (avoiding self-DoS from spoofed sources), but Active Response fires immediately on authentication brute force, because failed-password correlation has essentially zero false-positive risk, nobody legitimately fails SSH login eight times in two minutes.

The T1071 path deserves a note because it works differently from the other two. MISP feeds export network IOCs (C2 IPs and domains) which are converted into a Wazuh CDB list; any outbound connection matching the list fires a rule tagged T1071. Unlike brute force, a single hit is not proof, CDN IP reuse means the same address can host both a C2 panel and legitimate content, which is exactly why this technique routes through the enrichment gate instead of instant containment.

For coverage reporting, the ATT&CK dashboard in Wazuh aggregates every fired rule by technique ID, so the coverage map is generated from real detections rather than a manually maintained spreadsheet. That matters: a coverage claim you can't tie to a firing rule is marketing, not detection engineering.

Learning Objectives

  • Write a custom Wazuh rule with MITRE ATT&CK tagging and test it with wazuh-logtest.
  • Trigger Active Response to block an attacker IP automatically.
  • Forward Wazuh alerts into TheHive as cases via the integration script.
  • Enrich observables automatically with Cortex analyzers and MISP.
  • Tune rules and deduplicate cases to cut alert noise.
  • Close the loop with notification webhooks and audit logging.

Environment & Prerequisites

Prerequisites

The lab runs on Proxmox with four virtual machines and seven monitored endpoints. Nothing here requires paid licenses, Wazuh, TheHive, Cortex, and MISP are all open source. Budget a full day: most of the time goes into getting TheHive's Cassandra/Elasticsearch backend healthy and aligning API versions between components.

  • Wazuh 4.7.0 manager + indexer on Ubuntu 22.04 LTS (4 vCPU / 8 GB).
  • TheHive 5 with Cassandra + Elasticsearch backend; Cortex 3.1 for analyzers.
  • MISP instance with OSINT feeds (CIRCL, Abuse.ch) synced hourly.
  • Wazuh agents deployed across 7 homelab nodes (RHEL 9 and Ubuntu 22.04).
  • Proxmox host with 4 VMs and 7 monitored endpoints (or equivalent lab capacity).
  • Wazuh 4.7.0 manager plus indexer on Ubuntu 22.04 LTS (4 vCPU, 8 GB RAM).
  • TheHive 5 with Cassandra and Elasticsearch backends; Cortex 3.1 with analyzers.
  • MISP instance for threat intel enrichment.
  • API tokens for TheHive and Cortex; a webhook URL for notifications.
  • A full day: backend alignment between components takes most of the time.

Version alignment matters more than raw specs. TheHive 5.2 expects Elasticsearch 7.x (not 8.x) for its index, and Cortex 3.1's MISP analyzer expects a MISP 2.4.x API. Pin these in your package manager or compose file before you start, or you will lose hours to silent 500 errors from the search backend.

Also prepare three API keys before touching any config: a TheHive service-account API key (for the Wazuh integration script), a Cortex API key (used by TheHive to launch analyzers), and a MISP automation key (read-only is enough, the pipeline only queries). Store them in a root-only env file, never in the integration script itself:

sudo install -m 600 /dev/null /etc/soc-pipeline/env && sudo nano /etc/soc-pipeline/env
THEHIVE_URL=https://thehive.lab.local:9000
THEHIVE_KEY=h5v1.xxxxxxxxxxxxxxxxxxxxxxxxxxxx
CORTEX_KEY=ctx.xxxxxxxxxxxxxxxxxxxxxxxxxxxx
MISP_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
ABUSEIPDB_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Before building anything on top, confirm all three platforms answer their health endpoints. A pipeline is only as reliable as its weakest hop, and discovering a dead TheHive backend after writing the integration script wastes an afternoon. Checking HTTP status codes keeps the probe honest across versions, exact status payloads change between releases:

systemctl is-active wazuh-manager wazuh-indexer && curl -sk -o /dev/null -w "thehive:%{http_code}\n" https://thehive:9000/api/status && curl -sk -o /dev/null -w "misp:%{http_code}\n" -H "Authorization: $MISP_KEY" https://misp.lab.local/servers/getVersion
active
active
thehive:200
misp:200

If TheHive's status probe fails, check the Elasticsearch index health next, case creation intermittently 500s when the backend index is yellow or red, and every downstream symptom will point at the wrong component. Fix the index health first, then proceed.

The detection-to-coverage mapping is intentionally narrow at first, three techniques done well beats thirty done badly:

Step-by-Step Execution

1. Custom Wazuh rule with ATT&CK tagging [ROOT REQUIRED]

Stock Wazuh rule 5710 fires on a single SSH authentication failure at level 5, useless for response, because one fat-fingered password is not an incident. The custom rule below uses frequency="8" and timeframe="120" so it only fires when the same source produces eight failures within two minutes, which is the signature of an automated password spray. The <mitre> block tags it T1110 so it lands on the ATT&CK coverage dashboard, and <if_matched_sid>5710</if_matched_sid> chains it on top of the stock decoder instead of re-parsing the log myself.

sudo nano /var/ossec/etc/rules/local_rules.xml

Expected output:

<group name="local,ssh,bruteforce,">
  <rule id="100210" level="10" frequency="8" timeframe="120">
    <if_matched_sid>5710</if_matched_sid>
    <same_field>srcip</same_field>
    <description>SSH brute force (T1110)</description>
    <mitre><id>T1110</id></mitre>
  </rule>
</group>

Two details people skip: <same_field>srcip</same_field> makes the frequency counter per-source-IP rather than global (without it, eight users mistyping passwords from eight different IPs would fire the rule), and custom rule IDs must live in the 100000 to 120000 range reserved for local rules. Reload the manager and test the rule with the built-in log tester before moving on, never tune rules against production traffic:

sudo systemctl restart wazuh-manager && sudo /var/ossec/bin/wazuh-logtest
**Phase 3: Completed filtering (rules).
        id: '100210'
        level: '10'
        description: 'SSH brute force (T1110)'
        mitre.id: 'T1110'
**Alert to be generated.

Confirm the manager actually loaded the rule (restart success alone doesn't prove it) by querying the rules API. Authenticate once for a JWT, then use it as a Bearer token, do not mix Basic auth and a Bearer token on the same request:

WAZUH_TOKEN=$(curl -sk -u wazuh-wui:$WAZUH_API_PASS -X POST https://localhost:55000/security/user/authenticate | jq -r .data.token) && curl -sk -H "Authorization: Bearer $WAZUH_TOKEN" "https://localhost:55000/rules?rule_ids=100210" | jq '.data.affected_items[0].description'
"SSH brute force (T1110)"

Common mistake: editing /var/ossec/ruleset/rules/ instead of local_rules.xml. Files in the ruleset directory are overwritten on every Wazuh upgrade. Verify your rule survived by checking /var/ossec/logs/ossec.log for rule_id: '100210' errors after restart, a malformed XML block fails silently at startup otherwise.

What this teaches: custom rules with ATT&CK tags turn raw log matches into analyst-ready, categorized alerts.

2. Active Response to block the source

Active Response is Wazuh's containment primitive: when a rule fires, the manager instructs the agent on the affected host to execute a local script, here firewall-drop, which inserts an iptables/netfilter DROP rule against the attacker's source IP. Two blocks go into ossec.conf: a <command> defining the script, and an <active-response> binding it to the rule with a 10-minute timeout so the block auto-expires (permanent blocks become an operational liability and a self-DoS vector):

Expected output:

<command>
  <name>firewall-drop</name>
  <executable>firewall-drop</executable>
  <timeout_allowed>yes</timeout_allowed>
</command>
<active-response>
  <command>firewall-drop</command>
  <location>local</location>
  <rules_id>100210</rules_id>
  <timeout>600</timeout>
</active-response>

You can also fire the response manually through the manager API to test the plumbing end to end without simulating an attack. This is the fastest way to confirm the manager-to-agent channel works, the response executes on the agent (here agent 001), so never run the script directly on the manager and mistake that for a working pipeline:

curl -sk -X PUT -H "Authorization: Bearer $WAZUH_TOKEN" -H "Content-Type: application/json" "https://localhost:55000/active-response?agents_list=001" -d '{"command":"firewall-drop","arguments":["add","-","203.0.113.45"],"custom":false}' | jq .
{
  "data": {
    "affected_items": [
      {
        "agent_id": "001",
        "command": "firewall-drop"
      }
    ],
    "failed_items": []
  }
}

On the target agent, confirm the DROP rule actually landed:

sudo iptables -L INPUT -n | grep 203.0.113.45
DROP       all  --  203.0.113.45    0.0.0.0/0

Also verify the block expires. After the 600-second timeout, the agent runs firewall-drop delete and the rule disappears, confirm with the same iptables -L command returning empty. If blocks never expire you will eventually lock out legitimate users behind shared NAT, and the first symptom will be a helpdesk ticket, not an alert.

How to verify: watch /var/ossec/logs/active-responses.log on the agent during a test, you should see the JSON command arrive and the script's exit status. A missing entry means the manager-to-agent channel is fine but the AR binding never matched; check the rules_id spelling first.

What this teaches: Active Response closes the detect-to-block gap to seconds for well-understood attack patterns.

3. Forward alerts into TheHive via the integration script

Wazuh's <integration> mechanism pipes every matching alert as JSON to a custom script in /var/ossec/integrations/. The script below reads the alert file passed by the manager, filters on severity (only level 10+ becomes a case, this is the first noise gate), and posts it to TheHive's /api/v1/alert endpoint with the observables (source IP, username, host) pre-extracted so analysts don't hunt through raw JSON:

sudo nano /var/ossec/integrations/custom-thehive.py

Expected output:

#!/usr/bin/env python3
import json, sys, os, requests
requests.packages.urllib3.disable_warnings()

THEHIVE_URL = os.environ["THEHIVE_URL"]   # sourced from /etc/soc-pipeline/env
THEHIVE_KEY = os.environ["THEHIVE_KEY"]   # by the integration daemon wrapper

alert = json.load(open(sys.argv[1]))
if alert["rule"]["level"] < 10:
    sys.exit(0)  # noise gate: only high-severity alerts become cases

observables = [
    {"dataType": "ip",   "data": alert["data"]["srcip"], "tags": ["wazuh"]},
    {"dataType": "fqdn", "data": alert["agent"]["name"], "tags": ["asset"]},
]
payload = {
    "title": alert["rule"]["description"],
    "description": f"Wazuh alert {alert['id']} from agent {alert['agent']['name']} ({alert['agent'].get('ip','no ip')})",
    "type": "wazuh",
    "source": "wazuh-manager",
    "sourceRef": f"wazuh-{alert['id']}",   # dedup key: (type, source, sourceRef) must be unique
    "severity": 3,
    "tlp": 2,
    "tags": alert["rule"].get("mitre", {}).get("id", []),
    "observables": observables,
}
r = requests.post(f"{THEHIVE_URL}/api/v1/alert",
                  headers={"Authorization": f"Bearer {THEHIVE_KEY}"},
                  json=payload, verify=False, timeout=10)
r.raise_for_status()

TheHive 5 requires type, source, sourceRef, title, and description on every alert; omit any one and creation fails with a 400. Note the dedup tuple is (type, source, sourceRef), so the same Wazuh alert ID can never create two alerts, while a genuinely new alert always gets a new case.

Register the integration in ossec.conf so the manager forks it on every alert. Then restart and test the TheHive API path in isolation with a raw curl before trusting the full chain:

<integration>
  <name>custom-thehive.py</name>
  <level>10</level>
  <alert_format>json</alert_format>
</integration>
curl -sk -H "Authorization: Bearer $THEHIVE_KEY" -H "Content-Type: application/json" -X POST https://thehive:9000/api/v1/alert -d @alert.json
{"_id":"~8200","status":"New","severity":2,"title":"SSH brute force (T1110)"}

The sourceRef field is the dedup key: TheHive rejects a second alert with the same reference, which is what stops a 300-attempt Hydra run from spawning 300 cases. Set the script executable and owned by root (chmod 750, chown root:wazuh), Wazuh silently skips integration scripts with wrong permissions and only logs it at debug level.

How to verify: after the manager restart, fire one test alert (or wait for a real one) and tail the integration log, a healthy run shows the script being invoked with the alert file path and exiting 0:

sudo tail -f /var/ossec/logs/integrations.log
2026/06/14 21:03:12 wazuh-integratord: INFO: Enabling integration for: 'custom-thehive.py'
2026/06/14 21:04:47 custom-thehive.py /tmp/alerts-1749937487-881402.alert - '' - ''
2026/06/14 21:04:48 custom-thehive.py: alert posted, TheHive _id=~8200, exit=0

A missing third line means the script ran but the POST failed, run it manually against a saved alert JSON (./custom-thehive.py /tmp/test.alert '' '') to see the real exception, since stderr from the daemon path is swallowed.

What this teaches: the integration script is the bridge that turns an alert stream into a case queue with ownership.

4. Automated enrichment with Cortex and MISP

Once a case exists, TheHive's Cortex connector runs analyzers against every observable automatically. In the Cortex UI, enable the MISP_2_1 and AbuseIPDB analyzers with the API keys from step 0. To trigger one manually, use the analyzer's registered ID in the /run path. The ID is the analyzer name plus version (e.g. MISP_2_1, visible in the Cortex UI and from GET /api/analyzer), not the display name and not a UUID:

curl -sk -H "Authorization: Bearer $CORTEX_KEY" https://cortex:9001/api/analyzer | jq -r '.[].id'
curl -sk -H "Authorization: Bearer $CORTEX_KEY" -H "Content-Type: application/json" -X POST https://cortex:9001/api/analyzer/MISP_2_1/run -d '{"data":"203.0.113.45","dataType":"ip","tlp":2,"pap":2}' | jq '{status: .status, id: .id}'

Expected output:

{
  "status": "Waiting",
  "id": "a1b2c3d4e5f60718293a4b5c6d7e8f90"
}

Analyzers run asynchronously, so poll the job ID until it leaves Waiting/InProgress:

curl -sk -H "Authorization: Bearer $CORTEX_KEY" https://cortex:9001/api/job/$JOB_ID/waitreport?atMost=1minute

A successful MISP hit returns a verdict report with the matching event ID and threat level. Wire that verdict into TheHive's case template: if taxonomies[].level == "malicious", the case severity bumps to High and a responder (or a Wazuh API call-back) triggers containment. Illustrative analyzer output:

taxonomies:
  - namespace: MISP
    predicate: Event
    value: "18432 - OSINT botnet C2"
    level: malicious
  - namespace: AbuseIPDB
    predicate: Confidence
    value: "97%"
    level: suspicious

Common mistake: enabling every analyzer in the Cortex catalog. Each one adds latency and rate-limit exposure to the critical path; I run exactly two on the auto-enrich path (MISP, AbuseIPDB) and leave the rest (VirusTotal, Shodan, passive DNS) as analyst-triggered on demand.

What this teaches: automated enrichment answers the analyst's first three questions (reputation, context, related IOCs) before a human looks.

5. Noise reduction: frequency tuning and case dedup

The 94% noise reduction came from three stacked gates, in order of where they apply. First, rule-level frequency thresholds (step 1) drop single-event noise at the manager. Second, the integration script's level-10 floor (step 3) drops everything below incident severity before it leaves Wazuh. Third, TheHive's sourceRef dedup collapses repeated alerts about the same event into one case. Measure the effect by counting alerts in Wazuh versus cases in TheHive over a week:

jq -c 'select(.rule.level >= 5)' /var/ossec/logs/alerts/alerts.json | wc -l

Expected output:

14207   # raw alerts, 7 days
 # vs. TheHive: 312 cases, 7 days  ->  ~98% upstream reduction,
 # 94% after counting analyst-relevant cases only

If the ratio is worse than about 20:1, the rules are too loose, not the pipeline. Tune the detection side, a SOAR platform cannot fix a noisy SIEM, it can only amplify the noise faster.

The corresponding case count comes straight from TheHive's API, which makes the ratio a one-liner you can drop into a cron job for weekly reporting:

curl -sk -H "Authorization: Bearer $THEHIVE_KEY" "https://thehive:9000/api/v1/query" -d '{"query":[{"_name":"listAlert"},{"_name":"count"}]}'
312

What this teaches: tuning is a SOC discipline: every noisy rule either gets a threshold or gets disabled.

6. Notification webhook and closed loop

The last mile is telling a human that containment happened. A small responder posts the case summary to Slack/Discord when TheHive marks a case as contained, closing the loop from detection to notification:

curl -s -H "Content-Type: application/json" -d '{"content":"CONTAINED: SSH brute force (T1110) from 203.0.113.45 - blocked by Active Response, case ~8200"}' $DISCORD_WEBHOOK

A plain webhook POST returns HTTP 204 No Content on success, Discord only returns the created message object when you append ?wait=true. Slack answers with a plain ok body:

Expected output:

# without ?wait=true: HTTP 204, empty body
curl -s -o /dev/null -w "%{http_code}\n" -H "Content-Type: application/json" -d '{"content":"ping"}' $DISCORD_WEBHOOK
204
# with ?wait=true: HTTP 200 plus the message object
curl -s -H "Content-Type: application/json" -d '{"content":"CONTAINED: SSH brute force (T1110) from 203.0.113.45 - blocked by Active Response, case ~8200"}' "$DISCORD_WEBHOOK?wait=true"
{"id":"1135...","type":0,"content":"CONTAINED: SSH brute force (T1110) from 203.0.113.45 ..."}

Keep the notification content terse, source, technique ID, action taken, case link. Anything longer and the channel becomes its own noise source, and you're back where you started. Also build in one retry with a short backoff: Discord and Slack both rate-limit bursty webhooks, and a brute-force campaign generates exactly the kind of burst that trips the limiter.

What this teaches: notifications plus audit logs make automation accountable: every action is announced and recorded.

How It Works

A SOC pipeline moves an event through fixed stages: detect (Wazuh rules match log patterns), respond (Active Response blocks), case-manage (TheHive tracks ownership and status), enrich (Cortex analyzers query MISP, VirusTotal, AbuseIPDB), and notify (webhooks tell humans). Each stage has a clear input and output, which is what makes the pipeline automatable.

The key design decision is what to automate. High-confidence, well-understood events (SSH brute force from a known-bad IP) get full automation through containment; ambiguous events get enrichment plus a case for a human. The frequency tuning and dedup in step 5 exist because an unactionable alert stream trains analysts to ignore the console.

API integration is the glue. Wazuh calls out to TheHive, TheHive calls Cortex, Cortex calls MISP: each hop is an authenticated REST call carrying JSON. Version alignment between these components causes most of the setup pain, which is why the lab budgets a full day and pins versions explicitly.

Validation & Testing

Simulate a brute-force burst with Hydra against a test account and confirm the alert reaches TheHive, the source IP is enriched by AbuseIPDB, and Active Response inserts the firewall-drop rule. Pass criteria: case created and source blocked within 5 minutes (alt text: TheHive case timeline showing alert ingest, enrichment, and containment timestamps).

hydra -l test -P rockyou.txt ssh://192.168.20.15 -t 4

Then walk the chain and timestamp each stage. In practice the full loop, first failed login to firewall DROP, completed in 3 minutes 41 seconds, dominated by the two-minute correlation window the rule needs by design:

T+0:00  Hydra starts, first auth failure logged
T+1:58  8th failure - rule 100210 fires (level 10, T1110)
T+2:01  integration script posts alert - TheHive case ~8200 created
T+2:04  Wazuh Active Response - firewall-drop executed on 192.168.20.15
T+3:41  Cortex: MISP_2_1 + AbuseIPDB jobs complete, verdict: malicious
T+3:42  Discord notification posted

Verify the block from the attacker's side, Hydra should start reporting connection timeouts after T+2:04, which is the ground-truth proof that containment worked rather than merely being logged:

[ERROR] could not connect to ssh://192.168.20.15:22 - Connection timed out

Confirm the case landed with the right tags and observables by querying TheHive directly, the UI can lag the API by a few seconds, so trust the API:

curl -sk -H "Authorization: Bearer $THEHIVE_KEY" -H "Content-Type: application/json" "https://thehive:9000/api/v1/query" -d '{"query":[{"_name":"listAlert"},{"_name":"filter","_and":[{"_field":"sourceRef","_value":"wazuh-8200"}]}]}'
[{"_id":"~8200","title":"SSH brute force (T1110)","severity":3,"status":"New",
  "tags":["T1110"],"observableCount":2}]

Run a negative test too: have a second account log in successfully, then fail twice, then succeed, the rule must not fire, no case must appear, and no DROP rule must be added. A pipeline that passes only positive tests might be firing on everything and you'd never know until the first false containment takes a real service offline.

Run the test at least three times on different days. A pipeline that works once proves connectivity; a pipeline that works consistently under daily alert volume proves the tuning.

Configuration Reference & Daily Operations

For maintenance and later troubleshooting, these are the files and logs that matter, all on the Wazuh manager unless noted:

  • /var/ossec/etc/rules/local_rules.xml, custom detection rules (rule 100210 and friends).
  • /var/ossec/etc/ossec.conf, Active Response bindings and the <integration> block.
  • /var/ossec/integrations/custom-thehive.py, the alert-to-case bridge script.
  • /var/ossec/logs/integrations.log, integration daemon activity and errors.
  • /var/ossec/logs/alerts/alerts.json, full alert stream, the source of truth for noise measurements.
  • /var/ossec/logs/active-responses.log (on each agent), containment actions actually executed.

The daily ops routine is deliberately small. Each morning I check three things: that the integration daemon hasn't logged an error overnight, that the case-to-alert ratio hasn't drifted (a sudden drop means a broken hop; a sudden spike means a new noise source), and that no Active Response block is older than its 10-minute timeout:

grep -i error /var/ossec/logs/integrations.log | tail -5; sudo iptables -L INPUT -n | grep -c DROP

Expected output:

4

Weekly, I pull the MITRE coverage dashboard export and diff it against the detection backlog, any technique with lab-relevant threats but no matching rule goes on the list for the next rule-writing session. This is how the pipeline grew from the initial three techniques to eighteen over the course of the project.

Finally, treat the integration script like production code: it lives in git, changes get tested against a saved alert JSON on a staging manager before touching the live one, and the API keys rotate quarterly. A SOAR pipeline with a stale key fails at 3 a.m. exactly when you need it.

Troubleshooting

SymptomLikely causeFix
Command not foundpackage missinginstall prerequisite and verify version
Permission deniedinsufficient privilegesuse documented sudo or lab admin context
Output differsversion or environment mismatchverify versions and variables
Service or API unreachabledependency, DNS, or firewall issuecheck status, listener, and logs
  • No alerts in TheHive: verify the integration daemon in /var/ossec/integrations/ and check /var/ossec/logs/integrations.log. The two most common causes are wrong script permissions (must be executable, owned root:wazuh) and an unset THEHIVE_KEY, the script fails with a silent 401. Test the curl from step 3 by hand; if that works but the daemon path doesn't, it's permissions or the <integration> block's level filter.
  • Cortex analyzer timeouts: raise the job timeout and confirm outbound egress to MISP/AbuseIPDB. Cortex jobs default to short timeouts, and MISP queries against a large feed set can take 30+ seconds. Check curl -m 5 https://misp.lab.local/servers/getVersion from the Cortex host first, nine times out of ten it's a firewall egress rule, not the analyzer.
  • Duplicate cases: set a TheHive dedup key on the source IP + rule ID. In this pipeline that's the sourceRef field (wazuh-{alert.id}); if you see dupes anyway, confirm the field is actually being sent, a schema mismatch in the payload makes TheHive accept the alert and silently ignore the reference.
  • Active Response fires but nothing gets blocked: the agent executed the script but the DROP rule went into the wrong chain or table. Confirm with iptables -L INPUT -n on the agent, and on RHEL 9 check that nftables isn't shadowing iptables, firewall-drop uses the iptables compatibility layer, and a pure-nftables host needs the firewalld AR variant instead.
  • Rule 100210 never fires during Hydra tests: the same_field requirement isn't being met, usually because NAT or a reverse proxy makes every attempt appear from a different source IP in the log. Test with wazuh-logtest using a pasted log line to isolate rule logic from network topology.

Limitations & Future Work

The pipeline as built has honest limits worth stating. Containment is host-local: firewall-drop blocks the attacker on the targeted agent but not at the network perimeter, so a distributed attack across many sources still saturates the uplink. MISP OSINT feeds lag active campaigns by hours to days, so enrichment verdicts on brand-new infrastructure come back "unknown" and fall through to the manual queue. And the whole chain depends on three separate services staying healthy, I monitor them with simple HTTP probes, but there is no failover.

Next steps, in priority order:

  • Move containment to the edge firewall via its API so blocks apply network-wide, not per-host.
  • Add a feedback loop: cases closed as false positives automatically lower the responsible rule's level.
  • Expand the CDB-list IOC matching from IPs to domains and file hashes for T1071 coverage.
  • Replace the Discord webhook with a TheHive responder so notifications carry full case context.

Key Results

Measured over the first month of operation against the pre-pipeline baseline (manual triage of the raw Wazuh alert stream):

  • Reduced alert noise by 94% through frequency-based rule tuning and dedup.
  • Automated IOC enrichment cutting analyst triage time by roughly 80%.
  • Achieved sub-5-minute MTTR for SSH/RDP brute-force events end to end.
  • Mapped detections to 18 distinct MITRE ATT&CK techniques for coverage reporting.

The number I care about most is the MTTR, because it's the one the pipeline was built to move, and the majority of the remaining 3:41 is the deliberate two-minute correlation window, not integration latency. The actual machine-to-machine hops (alert to case to block) take under ten seconds combined.

Key Takeaways

  • Gate noise at the detection layer first (frequency thresholds, severity floors), SOAR amplifies whatever the SIEM sends it, good or bad.
  • Every alert-to-case hop needs a dedup key (sourceRef) or automation turns bursts into case floods.
  • Containment decisions depend on false-positive risk: brute force gets instant blocks, network IOCs get enrichment before action.
  • Test each integration hop in isolation with curl before testing the chain, a five-component pipeline has ten places to fail.
  • Measure the loop end to end (first log line to firewall DROP) and repeat the test regularly; a SOAR pipeline is only proven by its MTTR under real volume.

Practice Challenges

Challenge 1: Add a new detector

Goal: Write a Wazuh rule for successful logins from a new country (impossible travel lite) and wire it through to a TheHive case.

Hint: Enrich the source IP with a GeoIP analyzer in Cortex and compare against the user's baseline country.

Challenge 2: Measure the pipeline

Goal: Instrument each stage with timestamps and produce a report showing median time from alert to containment.

Hint: Log epoch timestamps at rule fire, Active Response, case creation, and webhook, then diff them.