idps-escape

Wazuh manager Ansible playbook logic documentation

Overview

The Wazuh manager deployment playbook (at soar-radar/roles/wazuh_manager/tasks/main.yml) automates the deployment and configuration of Wazuh Manager instances with RADAR scenario-specific customizations. It supports three deployment modes (local Docker, remote Docker, and remote host) and ensures idempotent, scenario-aware configuration management.

Table of contents


High-level architecture

┌─────────────────────────────────────────────────────────────┐
│  Playbook entry point: initialize variables & resolve paths │
└─────────────────────────────────────────────────────────────┘
                            ↓
        ┌───────────────────┬───────────────────┐
        ↓                   ↓                   ↓                   
   DOCKER_LOCAL     DOCKER_REMOTE         HOST_REMOTE       
   (Local Dev)      (Remote Container)    (Bare Metal)

The playbook consists of four logical parts: one handles initial configuration and flow choices, and the rest branches into three mutually exclusive blocks based on the manager_mode variable set in the inventory, which are as follows:


Execution flow diagram

START
  ↓
┌─────────────────────────────┐
│ Load Variables & Resolve    │
│ Scenario Path               │
└────────────┬────────────────┘
             ↓
    ┌────────────────────┐
    │ Determine Manager  │
    │ Mode               │
    └─┬──────┬───────┬───┤
      │      │       │   └─→ host_remote
      │      │       └─────→ docker_remote
      │      └──────────────→ docker_local
      │
      ├─ (If docker_local)
      │  ├─ Check container running
      │  ├─ Upload templates to OpenSearch
      │  ├─ Copy config files
      │  ├─ Stage RADAR snippets
      │  ├─ Append decoders (idempotent)
      │  ├─ Append rules (idempotent)
      │  ├─ Handle SSH override (conditional)
      │  ├─ Modify ossec.conf
      │  ├─ Restart Wazuh (if changed)
      │  ├─ Configure filebeat
      │  └─ Setup filebeat pipelines
      │
      ├─ (If docker_remote)
      │  ├─ Create staging dir on remote
      │  ├─ Bootstrap manager (if needed)
      │  ├─ Check container running
      │  ├─ [Same as docker_local, executed on remote]
      │
      └─ (If host_remote)
         ├─ Verify target directories
         ├─ Read snippets on controller
         ├─ Insert configs via Ansible modules
         ├─ Copy active response files
         ├─ Validate XML (optional)
         └─ Restart Wazuh (if changed)
           ↓
        END

Key design principles

1. Idempotency first

2. Scenario isolation

3. Multi-mode support

4. Graceful error handling

5. Minimal restarts


Pseudocode: automation algorithm

Here we provide a summarized description of the entire automation pipeline in the form of pseudocode.

FUNCTION DeployRadarScenario(scenario_name, manager_mode, bootstrap_flag):
  
  // ============================================
  // PHASE 1: INITIALIZATION
  // ============================================
  scenario_path ← RESOLVE_SCENARIO_DIR(scenario_name)
  manager_vars ← LOAD_INVENTORY_VARS(manager_mode)
  
  IF scenario_path DOES NOT EXIST:
    FAIL "Scenario path not found"
  END IF
  
  // ============================================
  // PHASE 2: BRANCH ON DEPLOYMENT MODE
  // ============================================
  
  SWITCH manager_mode:
    
    CASE "docker_local":
      RETURN DeployLocal(scenario_path, manager_vars)
    
    CASE "docker_remote":
      RETURN DeployRemoteContainer(scenario_path, manager_vars, bootstrap_flag)
    
    CASE "host_remote":
      RETURN DeployRemoteHost(scenario_path, manager_vars)
  
  END SWITCH


// ============================================
// DOCKER_LOCAL DEPLOYMENT
// ============================================
FUNCTION DeployLocal(scenario_path, manager_vars):
  
  container ← manager_vars.container_name
  
  // Step 1: Verify container running
  REPEAT 3 TIMES with 30sec delay:
    IF docker.ps(container) == RUNNING:
      BREAK
  END REPEAT
  
  // Step 2: Upload templates to OpenSearch
  template_json ← READ_FILE(scenario_path + "/wazuh-alerts-template.json")
  HTTP_PUT("https://indexer:9200/_index_template/wazuh-alerts-*", 
           auth=(admin_user, admin_pass),
           body=template_json,
           retries=3)
  
  // Step 3: Copy configuration files
  CopyConfigFiles(container, scenario_path)
  
  // Step 4: Stage scenario snippets
  DOCKER_EXEC(container, "mkdir -p /tmp/radar_snippets")
  COPY_TO_CONTAINER(container, [
    scenario_path + "/radar-ossec-snippet.xml",
    scenario_path + "/local_decoder.xml",
    scenario_path + "/local_rules.xml"
  ], "/tmp/radar_snippets/")
  
  // Step 5: Append decoders (idempotent)
  IF NOT MarkerExists(container, "/var/ossec/etc/decoders/local_decoder.xml",
                      "RADAR_DECODERS: " + scenario_name):
    decoder_content ← READ_FILE("/tmp/radar_snippets/local_decoder.xml")
    AppendToFile(container, "/var/ossec/etc/decoders/local_decoder.xml",
                 markers=SCENARIO_NAME, content=decoder_content)
  END IF
  
  // Step 6: Append rules (idempotent)
  SAME AS Step 5 but for rules file
  
  // Step 7: Handle SSH decoder override (if applicable)
  IF scenario_name IN ["suspicious_login", "geoip_detection"]:
    CopySshDecoderOverride(container, scenario_path)
  END IF
  
  // Step 8: Modify ossec.conf (idempotent)
  ossec_conf ← DOCKER_CP(container, "/var/ossec/etc/ossec.conf", "/tmp/")
  
  IF MarkerNotExists(ossec_conf, "RADAR: " + scenario_name):
    snippet ← READ_FILE(scenario_path + "/radar-ossec-snippet.xml")
    InsertBefore(ossec_conf, "</ossec_config>", 
                 markers="RADAR: " + scenario_name,
                 content=snippet)
  END IF
  
  AddIfMissing(ossec_conf, "<logall>yes</logall>")
  AddIfMissing(ossec_conf, "<logall_json>yes</logall_json>")
  
  DOCKER_CP(ossec_conf, container, "/var/ossec/etc/ossec.conf")
  SET_PERMS(container, "/var/ossec/etc/ossec.conf", "root:wazuh", "0640")
  
  // Step 9: Restart if config changed
  IF any_file_changed:
    DOCKER_EXEC(container, "/var/ossec/bin/wazuh-control restart")
  END IF
  
  // Step 10: Configure filebeat
  ConfigureFilebeat(container)
  
  // Step 11: Setup filebeat pipelines
  DOCKER_EXEC(container, 
    "filebeat setup --pipelines --modules wazuh --strict.ssl=false",
    ignore_errors=TRUE)  // Non-critical
  
  RETURN SUCCESS


// ============================================
// DOCKER_REMOTE DEPLOYMENT
// ============================================
FUNCTION DeployRemoteContainer(scenario_path, manager_vars, bootstrap_flag):
  
  remote_host ← manager_vars.ansible_host
  remote_user ← manager_vars.ansible_user
  container ← manager_vars.container_name
  
  // Step 1: Bootstrap if needed
  IF bootstrap_flag:
    stage_dir ← CREATE_TEMP_DIR_ON_REMOTE(remote_host)
    COPY_DOCKER_COMPOSE(stage_dir, remote_host)
    SSH_EXEC(remote_host, "docker compose up -d")
    WAIT_FOR_CONTAINER(remote_host, container, retries=20)
  END IF
  
  // Step 2: Verify container running
  REPEAT 3 TIMES with 30sec delay:
    IF docker.ps(container, remote_host) == RUNNING:
      BREAK
  END REPEAT
  
  // Step 3-11: Same as docker_local, but:
  //    - Operations run on remote_host via SSH
  //    - Use stage_dir for file transfers first
  //    - Use "become: true" for privilege escalation
  
  // Example:
  stage_dir ← CREATE_TEMP_DIR_ON_REMOTE(remote_host)
  SCP(scenario_path + "/*", remote_host:stage_dir)
  SSH_EXEC(remote_host, "docker cp " + stage_dir + "/* " + container + ":/tmp/")
  
  RETURN SUCCESS


// ============================================
// HOST_REMOTE DEPLOYMENT  
// ============================================
FUNCTION DeployRemoteHost(scenario_path, manager_vars):
  
  remote_host ← manager_vars.ansible_host
  remote_user ← manager_vars.ansible_user
  
  // Step 1: Verify target directories
  ENSURE_DIRS_EXIST(remote_host, [
    "/var/ossec",
    "/var/ossec/etc",
    "/var/ossec/etc/lists",
    "/var/ossec/active-response/bin"
  ])
  
  // Step 2: Read scenario snippets on controller
  ossec_snippet ← READ_FILE(scenario_path + "/radar-ossec-snippet.xml")
  decoder_snippet ← READ_FILE(scenario_path + "/local_decoder.xml")
  rules_snippet ← READ_FILE(scenario_path + "/local_rules.xml")
  
  // Step 3: Insert into host files via Ansible modules (idempotent)
  INSERT_INTO_FILE(remote_host, "/var/ossec/etc/ossec.conf",
                   marker="RADAR: " + scenario_name,
                   content=ossec_snippet)
  
  APPEND_TO_FILE(remote_host, "/var/ossec/etc/decoders/local_decoder.xml",
                 marker="RADAR_DECODERS: " + scenario_name,
                 content=decoder_snippet)
  
  APPEND_TO_FILE(remote_host, "/var/ossec/etc/rules/local_rules.xml",
                 marker="RADAR_RULES: " + scenario_name,
                 content=rules_snippet)
  
  // Step 4: Copy active response files
  IF file_exists(scenario_path + "/email_ar.py"):
    SCP(scenario_path + "/email_ar.py",
        remote_host + ":/var/ossec/active-response/bin/")
  END IF
  
  // Step 5: Handle SSH decoder (if applicable)
  IF scenario_name IN ["suspicious_login", "geoip_detection"]:
    CopySshDecoderOverride(remote_host, scenario_path)
  END IF
  
  // Step 6: Validate ossec.conf XML (if Python available)
  TRY:
    SSH_EXEC(remote_host, "python3 -m xml.etree.ElementTree " +
                          "/var/ossec/etc/ossec.conf")
  CATCH:
    WARN "Could not validate XML syntax"
  END TRY
  
  // Step 7: Restart Wazuh if config changed
  IF any_file_changed:
    SSH_EXEC(remote_host, "/var/ossec/bin/wazuh-control restart",
             become=true)
  END IF
  
  RETURN SUCCESS


// ============================================
// HELPER FUNCTIONS
// ============================================

FUNCTION CopyConfigFiles(container, scenario_path):
  
  // Active response env vars
  IF file_exists(".env"):
    DOCKER_CP(".env", container + ":/var/ossec/active-response/bin/active_responses.env")
  END IF
  
  // Whitelist (geoip scenario)
  IF file_exists(scenario_path + "/whitelist_countries"):
    DOCKER_EXEC(container, "mkdir -p /var/ossec/etc/lists")
    DOCKER_CP(scenario_path + "/whitelist_countries",
              container + ":/var/ossec/etc/lists/whitelist_countries")
    SET_PERMS(container, "/var/ossec/etc/lists/whitelist_countries",
              "root:wazuh", "0644")
    AddToOssecConf(container, "<list>etc/lists/whitelist_countries</list>")
  END IF
  
  // Email active response
  IF file_exists(scenario_path + "/active_responses/email_ar.py"):
    COPY_IF_DIFFERENT(scenario_path + "/active_responses/email_ar.py",
                      container + ":/var/ossec/active-response/bin/email_ar.py")
  END IF
  
  // AD context helpers
  FOR each file IN ListDir(scenario_path + "/active_responses/ad_context_*.py"):
    COPY_IF_DIFFERENT(file, 
                      container + ":/var/ossec/active-response/bin/" + BASENAME(file))
  END FOR

END FUNCTION


FUNCTION ConfigureFilebeat(container):
  
  filebeat_yml ← DOCKER_CP(container, "/etc/filebeat/filebeat.yml", "/tmp/")
  
  // Enable archives
  REPLACE_IN_FILE(filebeat_yml, "enabled: false", "enabled: true",
                  within_section="archives")
  REPLACE_IN_FILE(filebeat_yml, "var.paths: []", 
                  "var.paths:\n  - /var/ossec/logs/archives/archives.json",
                  within_section="archives")
  
  IF file_changed:
    DOCKER_CP(filebeat_yml, container + ":/etc/filebeat/filebeat.yml")
    DOCKER_EXEC(container, "docker restart " + container)
  END IF

END FUNCTION


FUNCTION MarkerExists(container, file_path, marker_text):
  result ← DOCKER_EXEC(container, "grep -F '" + marker_text + "' " + file_path)
  RETURN result.exit_code == 0
END FUNCTION


FUNCTION COPY_IF_DIFFERENT(src, dest_container_path):
  src_sum ← HASH_FILE(src)
  dest_sum ← DOCKER_EXEC(container, "sha256sum " + dest_container_path)
  
  IF src_sum != dest_sum:
    DOCKER_CP(src, container + ":" + dest_container_path)
    RETURN TRUE
  END IF
  
  RETURN FALSE
END FUNCTION


Block 1: Variable resolution & initialization

Purpose

Establish common variables used across all blocks.

Key variables resolved

| Variable | Source | Purpose | |———-|——–|———| | _scenario_path | Ansible fact | Path to scenario directory (e.g., soar-radar/suspicious_login) | | _mgr_mode | Inventory host var | Deployment mode: docker_local, docker_remote, or host_remote | | _mgr_container | Inventory (default: wazuh.manager) | Docker container name | | _list_marker | Hardcoded | Marker for geoip whitelist insertion | | _email_ar_dest | Hardcoded | Container path to email active response script | | _lists_dir | Hardcoded | Container path to Wazuh lists directory |

Logic

- Check .env file existence on controller (for active response env vars)
- Set scenario paths and destination paths
- Debug output current manager mode

Blocks breakdown

Block 2: DOCKER_LOCAL

Conditions

when: _mgr_mode == 'docker_local'

Purpose

Deploy scenario to a local Docker container on the controller machine.

Sub-blocks and logic flow

2.1 Container health check

What: Verify manager container is running
How: docker ps with retries (3x, 30sec delay)
Why: Ensure container is ready before modifications
Idempotency: Register variable, use changed_when: false (check-only)


2.2 OpenSearch template upload

What: Upload wazuh-alerts index template to OpenSearch
How: HTTP PUT request to /_index_template/wazuh-alerts-*
When: Always executed (essential for dashboard)
Idempotency: Check HTTP status (200/201 = success)
TLS: validate_certs: no (handles self-signed certs)


2.3 Configuration files copy

Transfers scenario-specific files into container:

File Source Destination Purpose
.env Controller /var/ossec/active-response/bin/active_responses.env Active response environment vars
whitelist_countries Scenario dir /var/ossec/etc/lists/ IP whitelist for geoip scenario
email_ar.py Scenario dir /var/ossec/active-response/bin/ Email active response script
ad_context_*.py Scenario dir /var/ossec/active-response/bin/ AD context helpers
RADAR snippets Scenario dir /tmp/radar_snippets/ Temporary staging for config insertion

Idempotency:


2.4 Decoder insertion

What: Append scenario decoders to /var/ossec/etc/decoders/local_decoder.xml

Idempotency mechanism:

marker: "<!-- RADAR_DECODERS:  BEGIN -->"
marker: "<!-- RADAR_DECODERS:  END -->"

Key Fix: Uses touch instead of truncation (:> "$f") to preserve multiple scenarios

Flow:

  1. Check if marker already present → skip if yes
  2. Append decoder content between markers
  3. Set ownership/perms

2.5 Rules insertion

Identical to decoders, but for /var/ossec/etc/rules/local_rules.xml


2.6 SSH decoder override

When: Only for suspicious_login or geoip_detection scenarios

What:

Why: Custom SSH decoding rules may conflict with Wazuh defaults


2.7 ossec.conf modification

Complex multi-step process:

  1. Pull: Copy /var/ossec/etc/ossec.conf from container to /tmp/ossec.conf.from_container
  2. Insert RADAR snippet: Use blockinfile with scenario-specific markers (idempotent)
  3. Add SSH decoder exclusion: Insert <decoder_exclude> if custom override present
  4. Ensure logging: Set <logall>yes</logall> and <logall_json>yes</logall_json>
  5. Push back: Copy modified file to container
  6. Fix perms: Set root:wazuh ownership, 0640 mode

Idempotency:


2.8 Service restart

When: If any of these changed:

How: docker exec wazuh.manager /var/ossec/bin/wazuh-control restart


2.9 Filebeat configuration

What: Enable archives in filebeat.yml for log collection

Steps:

  1. Pull /etc/filebeat/filebeat.yml from container
  2. Modify to enable archives and set paths
  3. Push back if changed
  4. Restart container if needed

Idempotency:


2.10 Filebeat setup

What: Initialize filebeat ingest pipelines and index templates

Command: filebeat setup --pipelines --modules wazuh --strict.ssl=false

Why:

Error Handling: failed_when: false (non-critical if fails)


Block 3: DOCKER_REMOTE

Conditions

when: _mgr_mode == 'docker_remote'

Purpose

Deploy scenario to a Docker container on a remote host via SSH.

Key differences from docker_local

3.1 Staging directory

Creates temporary directory on remote host using tempfile module
Why: Clean, isolated staging area for all files before copying to container

_stage.path = /tmp/radar_mgr_XXXXX/

3.2 Bootstrap manager option

When: If manager_bootstrap == true (i.e., manager doesn’t exist yet)

Steps:

  1. Create directory tree on remote
  2. Copy docker-compose files, configs, certs
  3. Run docker-compose up -d
  4. Wait for container readiness (20 retries, 3sec delay)

Why: Enables automatic Wazuh stack provisioning from scratch

3.3 File staging pattern

Differences from docker_local:

Pattern:

Controller → (scp) → Remote _stage.path → (docker cp) → Container

3.4 Webhook bootstrap

When: If manager_bootstrap == true AND webhook container not running

Purpose: Deploy webhook container alongside manager
Scope: Out of manager scope, separate block


3.5 Decoders/rules

Identical logic to docker_local, but executed on remote via docker exec + become: true


3.6 ossec.conf handling

Enhanced vs docker_local:


Block 4: HOST_REMOTE

Conditions

when: _mgr_mode == 'host_remote'

Purpose

Deploy to bare-metal Wazuh Manager on remote host (no Docker).

Key differences

4.1 Directly modifying host files

No Docker intermediate → modify host files directly:

4.2 Directory validation

Ensures target directories exist (parent creation):

/var/ossec
/var/ossec/etc
/var/ossec/etc/lists
/var/ossec/active-response/bin

4.3 Read snippets to variables

Uses lookup() to read scenario snippets on controller
Why: Single file transfer, then insert from variable (fewer SSH ops)

4.4 blockinfile for config insertion

Uses Ansible blockinfile module (preferred for idempotency over shell scripts)

4.5 XML validation (Optional)

Attempts to validate ossec.conf well-formedness using Python XML parser
Why: Catch config errors early
Graceful: Skips if Python not available


Idempotency mechanisms

1. Marker-based insertion (Primary)

blockinfile:
  marker: "<!-- RADAR:  {mark} -->"

2. Checksum comparison

lsum="$(sha256sum "$f" | awk '{print $1}')"
rsum="$(docker exec ... sha256sum ...)"
if [ "$lsum" != "$rsum" ]; then copy; fi

3. Existence checks

if grep -Fq "$marker" "$file"; then
  echo "NOCHANGE"
else
  append content
fi

4. Conditional execution

when:
  - file_stat.stat.exists
  - previous_task is changed

Error handling strategy

Retry pattern

retries: 3
delay: 30
until: result.rc == 0

Used for:

Failed-when false

failed_when: false

Used for:

Status tracking

register: _variable_name
changed_when: "'CHANGED' in stdout"

Scenario-specific customization

Scenario selection

--extra-vars "scenario_name=suspicious_login"

Scenario path resolution

controller:/home/user/soar-radar//
├── radar-ossec-snippet.xml
├── local_decoder.xml
├── local_rules.xml
├── radar-template.json (if log_volume)
└── active_responses/
    ├── email_ar.py
    └── ad_context_*.py

Conditional blocks

| Scenario | Conditional Block | |———-|——————| | suspicious_login, geoip_detection | 0310 SSH decoder override | | log_volume | Index template upload + filebeat setup | | All | Decoders + Rules + ossec.conf modifications |


Deployment mode selection logic

# From build-radar.sh
--manager local   → _mgr_mode = docker_local     (block 2)
--manager remote  → _mgr_mode = docker_remote    (block 3)
--manager host    → _mgr_mode = host_remote      (block 4)

Each block:


File modification tracking

Files modified per block

docker_local/docker_remote:

host_remote:


Extending the playbook

Adding a new scenario

  1. Create directory: soar-radar//
  2. Populate with:
    • radar-ossec-snippet.xml → ossec.conf insertions
    • local_decoder.xml → custom decoders
    • local_rules.xml → custom rules
    • active_responses/ → scripts
    • radar-template.json (optional) → index template
  3. Conditionals in playbook automatically handle new scenario

Adding a new deployment mode

  1. Create new block with when: _mgr_mode == 'new_mode'
  2. Adapt logic to target environment (e.g., Kubernetes, cloud)
  3. Maintain same file modification pattern