Integrating Wazuh SIEM
with DFIR-IRIS v2

A version-specific guide to connect Wazuh SIEM to DFIR-IRIS v2.x: deploy the v2 stack, configure a dedicated API identity, forward Wazuh alerts, format event context, extract typed IOCs, and validate the v2 API payload.

By Ashraf GamalUpdated 12 October 2026Wazuh SIEM + DFIR-IRIS v2Approx. 12 min read

Version scope: This is the legacy Wazuh-to-DFIR-IRIS v2.x integration guide. Its deployment steps and alert payload/schema are version-specific and should not be copied unchanged into IRIS v3. For the combined v3 installation, Active Directory LDAPS authentication, and Wazuh SIEM integration guide, see DFIR-IRIS v3 + AD LDAPS + Wazuh. This article records a lab implementation, not official vendor guidance.

1. Objective

The goal is to send actionable Wazuh alerts into DFIR-IRIS so analysts can triage the alert, inspect its original event, pivot on indicators, and promote selected alerts into cases. This implementation uses the Wazuh Integrator to pass a JSON alert file to a custom Python script, which shapes a documented IRIS Alerts API request.

The work in this article comes from a lab integration and the troubleshooting recorded while refining it: HTML notes were replaced with structured alert context; tags were expanded with rule, agent, and MITRE fields; IOC extraction errors were corrected; and IOC type IDs were mapped from the actual IRIS taxonomy rather than guessed.

2. Architecture and data flow

StageResponsibilityOutput
Wazuh managerRules generate alerts; the Integrator applies level filtering and invokes the custom integration.JSON alert file and script arguments
Python integrationParse alert JSON, exclude unwanted groups, build structured context/tags, extract candidate IOCs, send an HTTP request.JSON request to IRIS
DFIR-IRIS Alerts APIValidate and store alert metadata, source content, context, tags, and supported IOC objects.Alert available for analyst triage
Analyst workflowReview the alert and select which IOCs/assets to bring into a case when promoting or merging alerts.Case with selected investigation data

IRIS alerts and IRIS cases are related but not interchangeable. An alert can be triaged and later merged into a case; the API does not imply that every alert automatically creates a case. IRIS documents a merge/escalation workflow for choosing IOCs and assets to import. See the official IRIS Alerts documentation.

3. Install DFIR-IRIS v2 first

Version boundary: this guide targets IRIS v2.x

The installation examples and alert payload in this article follow the v2.x deployment and API model. IRIS v3 has a different deployment and API migration path. Do not use the v2 integration script unchanged against v3; use the new v3 deployment and integration guide for the v3 workflow.

For the v2 Docker-based installation path documented by the IRIS project, the high-level sequence is to install Docker/Compose, clone the official iris-web repository, check out a stable release tag, configure the environment file, then pull/build and start the services.

# On the IRIS host
git clone https://github.com/dfir-iris/iris-web.git
cd iris-web

# Select a stable tagged version appropriate for your environment.
# Example shown by the versioned 2.4 documentation:
git checkout v2.4.13

# Create the local environment configuration
cp .env.model .env
# Edit .env and set strong secrets and environment-specific values.

# Use the commands specified by the chosen release's official guide.
docker compose pull
docker compose up -d

# Inspect the running services
docker compose ps
docker compose logs --tail=100

These are a release-specific pattern, not a guarantee that every IRIS version uses identical environment files or container commands. The official v2.4 Quick Start currently shows the v2.4.13 tag in its Docker sequence; verify the exact stable tag in the versioned documentation before using it. Follow the versioned IRIS v2.4 Quick Start for the target release. The separate DFIR-IRIS v3 guide covers the v3 install path, Active Directory LDAPS authentication, and Wazuh integration. Install Docker Engine and the Compose plugin using the official Docker Engine installation guide for your operating system.

4. Create an API identity and validate access

IRIS API endpoints use Bearer authentication. Create or select a dedicated account with the permissions needed to write alerts; the current alert documentation identifies alerts_read and alerts_write as relevant permissions. Use the API key from the appropriate user settings and store it outside the script, for example in the Wazuh Integrator configuration arguments. Do not commit real keys into Git.

Before connecting Wazuh, test API reachability from the Wazuh manager using a non-production test request or an authorized read endpoint supported by the installed IRIS version. Use the exact base path documented for your release (for example, v2 uses the versioned API route) and validate certificate trust. Avoid using verify=False in production; it disables TLS certificate verification.

5. Configure the Wazuh Integrator

Wazuh's official configuration places integrations inside /var/ossec/etc/ossec.conf. A custom integration name must start with custom-, and the script must be located in /var/ossec/integrations/ with the same base name. With alert_format set to json, the script receives the alert file as argument 1, the configured API key as argument 2, and the webhook URL as argument 3.

<ossec_config>
  <integration>
    <name>custom-wazuh_iris</name>
    <hook_url>https://IRIS_HOST/api/v2/alerts</hook_url>
    <api_key>IRIS_API_KEY</api_key>
    <level>7</level>
    <alert_format>json</alert_format>
  </integration>
</ossec_config>

Replace the host, route, and key placeholders locally. IRIS API documentation is versioned: the surfaced v2.1 reference documents POST /api/v2/alerts, while the older v2.0.1 reference uses POST /alerts/add. Put the route documented for your installed release in hook_url; in the lab script this URL is supplied as argument 3, not constructed from an assumed path. If using Wazuh's documented JSON alert filtering, configure a threshold such as <level>7</level> and let Python apply exclusions that the Integrator configuration cannot express as a blacklist.

Use Wazuh's documented permissions and ownership for custom integrations:

sudo chmod 750 /var/ossec/integrations/custom-wazuh_iris
sudo chown root:wazuh /var/ossec/integrations/custom-wazuh_iris

Ensure the file's first line points to the intended Python interpreter. This lab used the Wazuh-bundled interpreter:

#!/var/ossec/framework/python/bin/python3

Wazuh's documentation says to restart the manager after integration configuration changes. During development, you can execute the script directly with a saved alert JSON, API key, and endpoint to validate parsing before waiting for a live event.

sudo systemctl restart wazuh-manager
sudo tail -f /var/ossec/logs/integrations.log
sudo tail -f /var/ossec/logs/ossec.log

6. Integration script

The key design choice is to keep each IRIS field fit for purpose: the title/description stay concise; alert_context is an object of key/value fields; alert_source_content retains the original alert JSON; alert_tags contains searchable metadata; and alert_iocs uses IRIS IOC objects rather than a comma-separated list of indicators.

The following is a focused implementation template based on the lab workflow. Confirm the endpoint schema and status/severity IDs against the deployed IRIS version and your own taxonomy before production use.

#!/var/ossec/framework/python/bin/python3
import ipaddress
import json
import logging
import os
import re
import sys
import uuid
from urllib.parse import urlsplit

import requests

LOG_FILE = "/var/ossec/logs/integrations.log"
LEVEL_THRESHOLD = 7
CUSTOMER_ID = 1  # Set to the customer ID from your IRIS instance.
STATUS_ID_NEW = 2  # Verify against your installed IRIS taxonomy.
WAZUH_DASHBOARD_URL = "https://WAZUH_DASHBOARD_HOST/app/wz-home"

# These IDs came from the IRIS IOC taxonomy inspected in the lab.
# Reconfirm them after upgrades or any taxonomy customization.
IOC_TYPE_IDS = {
    "ip-any": 76,
    "domain": 20,
    "url": 141,
    "md5": 90,
    "sha1": 111,
    "sha256": 113,
    "filename": 37,
    "email": 22,
}

logging.basicConfig(
    filename=LOG_FILE,
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
)

FILE_EXTENSIONS = {
    ".exe", ".dll", ".sys", ".bat", ".cmd", ".ps1", ".vbs",
    ".js", ".hta", ".scr", ".msi", ".jar", ".sh", ".py",
}


def flatten_for_context(value, prefix=""):
    """Return flattened key/value pairs while preserving list positions."""
    result = {}
    if isinstance(value, dict):
        for key, child in value.items():
            name = f"{prefix}.{key}" if prefix else str(key)
            result.update(flatten_for_context(child, name))
    elif isinstance(value, list):
        if all(not isinstance(x, (dict, list)) for x in value):
            result[prefix] = ", ".join(str(x) for x in value)
        else:
            for index, child in enumerate(value):
                result.update(flatten_for_context(child, f"{prefix}[{index}]"))
    else:
        result[prefix] = "" if value is None else str(value)
    return result


def build_context(alert):
    # IRIS v2 schema expects an object: { "key": "value" }.
    context = {}
    for key, value in flatten_for_context(alert).items():
        value = str(value).replace("\r", " ").replace("\n", " ")
        if len(value) > 1000:
            value = value[:1000] + "…"
        context[key] = value
    return context


def is_valid_ipv4(value):
    try:
        return isinstance(ipaddress.ip_address(value), ipaddress.IPv4Address)
    except ValueError:
        return False


def add_ioc(items, value, type_name, description):
    value = str(value).strip().rstrip(".,;)")
    if not value:
        return
    type_id = IOC_TYPE_IDS.get(type_name)
    if type_id is None:
        return
    items[(value.lower(), type_id)] = {
        "ioc_value": value,
        "ioc_description": description,
        "ioc_tlp_id": 1,  # Confirm TLP ID in your IRIS environment.
        "ioc_type_id": type_id,
        "ioc_tags": "wazuh",
    }


def extract_iocs(alert):
    """Prefer structured event fields, then scan selected text values."""
    found = {}

    data = alert.get("data", {})
    win = data.get("win", {}) if isinstance(data, dict) else {}
    eventdata = win.get("eventdata", {}) if isinstance(win, dict) else {}

    # Pull known structured fields first; file paths remain filenames, not domains.
    structured_candidates = []
    for field in ("image", "targetFilename", "ParentImage", "filePath", "filename"):
        candidate = eventdata.get(field)
        if isinstance(candidate, str) and candidate.strip():
            structured_candidates.append(candidate.strip())

    # Scan all string-valued fields to catch other telemetry, but classify by syntax.
    searchable = list(structured_candidates)

    def collect_strings(obj):
        if isinstance(obj, dict):
            for val in obj.values():
                collect_strings(val)
        elif isinstance(obj, list):
            for val in obj:
                collect_strings(val)
        elif isinstance(obj, str):
            searchable.append(obj)

    collect_strings(alert)

    seen_texts = set()
    for text in searchable:
        if text in seen_texts:
            continue
        seen_texts.add(text)

        # URLs first; strip common trailing punctuation.
        for match in re.findall(r"https?://[^\s\"'<>]+", text, flags=re.IGNORECASE):
            url = match.rstrip(".,;)]}")
            add_ioc(found, url, "url", "Extracted from Wazuh alert")

        # IPv4: validate each candidate instead of accepting 999.999.999.999.
        for match in re.findall(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", text):
            if is_valid_ipv4(match):
                add_ioc(found, match, "ip-any", "Extracted from Wazuh alert")

        # Hashes are matched by length.
        for match in re.findall(r"\b[a-fA-F0-9]{64}\b", text):
            add_ioc(found, match, "sha256", "SHA-256 candidate extracted from Wazuh alert")
        for match in re.findall(r"\b[a-fA-F0-9]{40}\b", text):
            add_ioc(found, match, "sha1", "SHA-1 candidate extracted from Wazuh alert")
        for match in re.findall(r"\b[a-fA-F0-9]{32}\b", text):
            add_ioc(found, match, "md5", "MD5 candidate extracted from Wazuh alert")

        # Extract complete filenames. Non-capturing groups keep the full match.
        for match in re.findall(
            r"(?i)(?= 13 else 5 if level >= 10 else 4 if level >= 7 else 3

    payload = {
        "alert_title": title,
        "alert_description": title,
        "alert_source": "Wazuh",
        "alert_source_ref": str(alert.get("id") or uuid.uuid4()),
        "alert_source_link": WAZUH_DASHBOARD_URL,
        "alert_severity_id": severity_id,
        "alert_status_id": STATUS_ID_NEW,
        "alert_source_event_time": alert.get("timestamp"),
        "alert_note": "",
        "alert_tags": build_tags(alert),
        "alert_customer_id": CUSTOMER_ID,
        "alert_context": build_context(alert),
        "alert_iocs": extract_iocs(alert),
        "alert_source_content": alert,
    }

    try:
        response = requests.post(
            hook_url,
            json=payload,
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json",
                "Accept": "application/json",
            },
            timeout=15,
        )
        if response.status_code in (200, 201, 202, 204):
            logging.info(
                "Sent Wazuh rule=%s level=%s IOC_count=%s HTTP=%s",
                rule.get("id"), level, len(payload["alert_iocs"]), response.status_code,
            )
            return 0

        # Avoid logging credentials. Response bodies may contain internal details.
        logging.error("IRIS API rejected alert: HTTP %s; response=%s",
                      response.status_code, response.text[:1000])
        return 1

    except requests.RequestException as exc:
        logging.error("IRIS request failed: %s", exc)
        return 1


if __name__ == "__main__":
    sys.exit(main())
Important before production

This is a reviewed reference implementation for the v2-style alert schema, not a universal drop-in for all IRIS releases. Set CUSTOMER_ID, STATUS_ID_NEW, the severity mapping, TLP ID, dashboard link, and IOC type IDs to values verified in your own IRIS environment. The example deliberately avoids disabling TLS verification; configure a trusted certificate/CA for the IRIS endpoint instead.

7. What this implementation adds beyond a minimal example

The goal is not to claim that every public example is inferior. A small proof of concept can be useful. This lab implementation adds controls for the specific issues encountered during integration:

Engineering choicePractical benefit
Structured alert_context objectDisplays key/value metadata in IRIS Context without relying on large HTML tables inside the alert note.
Typed alert_iocs in the alert requestCreates IOC records in the IOC section in the same request when supported by the deployed API schema; IOCs are not reduced to comma-separated tags.
Instance-specific IOC type IDsAvoids mapping an IP or filename to unrelated taxonomy entries when an installation has a different IOC type order.
Structured fields first, then syntax-aware extractionReduces mistakes such as treating powershell.exe as a domain or returning only exe from a captured regex group.
Context, source content, and tags are separatedAnalysts get readable context; the original alert remains preserved; tags include the Wazuh rule, agent, IP, and MITRE metadata for searching.
Group exclusion and explicit HTTP error loggingSuppresses the selected cis/sca noise groups without maintaining a long allowlist, and records API failures with a timeout.

These are focused improvements based on the failures observed in the lab, not a measured performance comparison against every GitHub repository. Re-test them against your own IRIS version, data taxonomy, load, and security requirements.

8. IOC extraction and type mapping

One of the most useful debugging lessons from the lab was that IOC extraction must preserve both the full indicator value and the correct IRIS type ID. The type IDs shown below came from the taxonomy response inspected in this environment; do not assume they are universal across instances or after taxonomy changes.

IOC kindTaxonomy typeID in the inspected instanceDetection note
IPv4ip-any76Validate IP octets with a parser, not regex alone.
Domaindomain20Do not classify executable names such as powershell.exe as domains.
URLurl141Trim common trailing punctuation where the source is prose.
MD5 / SHA-1 / SHA-256md5, sha1, sha25690 / 111 / 113Length-based candidates are not proof that a value is a malicious hash.
Filenamefilename37Use a non-capturing extension group so the full filename is returned, not only exe or ps1.
Email addressemail22Candidate extraction; investigate context before treating it as malicious.

For the supplied Sysmon Event ID 11 example, structured fields included the executable image and target filename. Extract those fields directly where possible; scanning the entire serialized JSON can produce false positives from paths, labels, timestamps, or unrelated text. IOC regexes should be treated as candidates and enriched/validated separately where required.

IRIS v2 documents alert_iocs as an array of objects containing fields such as ioc_value, ioc_description, ioc_tlp_id, ioc_type_id, and ioc_tags. This is why sending IOCs directly in the alert request is preferable to placing a comma-separated IOC list in alert_tags. Tags remain labels; they are not a replacement for typed IOC records.

9. Validation workflow

  1. Check syntax using the Python interpreter configured in the script.
  2. Use a saved redacted Wazuh JSON alert to test parsing and group/level filters before posting to production IRIS.
  3. Send a controlled test alert to a non-production IRIS customer, then inspect title, description, context, tags, raw source content, and IOC table individually.
  4. Verify the created alert appears to the intended IRIS user/customer and that the API account has the required write permissions.
  5. Promote a test alert to a case manually and confirm that the analyst can select the IOCs to import.
  6. Review integration logs for status codes; do not log API keys or include them in shell history or source control.
# Check Python syntax; no output means compile succeeded.
sudo /var/ossec/framework/python/bin/python3 -m py_compile \
  /var/ossec/integrations/custom-wazuh_iris.py

# Inspect integration and Wazuh logs
sudo tail -n 100 /var/ossec/logs/integrations.log
sudo tail -n 100 /var/ossec/logs/ossec.log

# Validate file permissions
sudo stat /var/ossec/integrations/custom-wazuh_iris.py

For direct testing, use the saved alert file as argument 1, a test API key as argument 2, and the exact API endpoint as argument 3. Use a test customer and an indicator-free sample first; after the simple path works, test structured IOCs and edge cases one at a time.

10. Known issues & troubleshooting

Observed symptomRoot causeFix / validation
Invalid URL 'None'The script received no usable webhook URL, commonly because shell environment variables were assumed to be inherited by the Wazuh service.Pass the endpoint using the documented Integrator argument/config, validate arguments, and fail clearly when missing.
HTTP 400: ioc_type invalid input typeThe request used a string type such as ip where the IRIS schema expects a numeric ioc_type_id.Send ioc_type_id and map it to the taxonomy returned by the current IRIS instance.
IOC shows as AS, aba-rtn, or authentihashNumeric IDs were guessed and resolved to unrelated entries in the local taxonomy.Use the Manage IOC Types endpoint/UI to confirm names and IDs; do not copy IDs blindly from a different installation.
powershell.exe appears as a domainA broad dotted-token regex interpreted a filename as a hostname.Extract known file fields first, exclude known executable/script suffixes from domain candidates, and retain the original complete value.
IOC value is only exe or ps1A capturing group in the filename regex returned only the matching extension.Use a non-capturing group (?:...) or a full-match approach.
Context renders as 0:, 1:, etc.Context was encoded as a list of objects; IRIS expects alert_context as a key/value object for this API schema.Send a JSON object such as {"Rule ID":"92213","Agent Name":"host-01"}, then validate the resulting UI.
Alert note appears empty or HTML layout changesHTML in the note can be sanitized or displayed differently by the UI; the context field is better for key/value metadata.Keep description concise; use structured context for metadata, source content for raw JSON, and notes for analyst commentary.
IRIS accepts alert but user cannot see itCustomer access or service-account permissions may not match the alert's customer.Check the account's alert permissions and customer association in IRIS access control.

For the cis and sca exclusions, this implementation checks group names in Python so it can exclude only the two noisy groups while preserving the wider set of active detection groups. Wazuh also supports level, rule ID, and group filters in the Integrator configuration, but its group filter is an inclusion filter—not a general exclusion expression.

11. Engineering lessons

12. Official references

This guide records a practical lab implementation and its troubleshooting process. It is not official vendor guidance and does not claim that any one script is the best or only implementation. Validate API fields, version compatibility, severity/status IDs, customer IDs, IOC type IDs, and TLS configuration in your own environment before production rollout.

Discuss SIEM-to-DFIR automation

Building a Wazuh-to-case-management workflow or debugging alert enrichment?

Reach out to discuss Wazuh integrations, typed IOC extraction, IRIS alert workflows, and SOC automation design.