Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Wazuh Indexer Technical Documentation

This folder contains the technical documentation for the Wazuh Indexer. The documentation is organized into the following guides:

  • Development Guide: Instructions for building, testing, and packaging the Indexer.
  • Reference Manual: Detailed information on the Indexer’s architecture, configuration, and usage.

Requirements

To work with this documentation, you need mdBook installed.

ToolRequired Version
mdbook0.5.2
mdbook-mermaid0.17.0
  • Get the latest cargo (hit enter when prompted for a default install)

    curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
    
  • Install mdbook and mdbook-mermaid

    cargo install mdbook --version 0.5.2 --locked
    cargo install mdbook-mermaid --version 0.17.0 --locked
    

Usage

  • To build the documentation, run:

    ./build.sh
    

    The output will be generated in the book directory.

  • To serve the documentation locally for preview, run:

    ./server.sh
    

    The documentation will be available at http://127.0.0.1:3000.

Development documentation

Under this section, you will find the development documentation of Wazuh Indexer. This documentation contains instructions to compile, run, test and package the source code. Moreover, you will find instructions to set up a development environment in order to get started at developing the Wazuh Indexer.

This documentation assumes basic knowledge of certain tools and technologies, such as Docker, Bash (Linux) or Git.

Before you start coding, read the sections below: they cover how to open good pull requests and how our GitHub Actions behave when you do. Getting this right up front saves CI minutes and review cycles for everyone.

Pull requests

These are the standard procedures for creating, updating, and reviewing pull requests across the Wazuh Indexer repositories.

Lifecycle

┌──────────┐    ┌──────────────┐    ┌─────────────────┐    ┌───────┐
│  Draft   │───▶│ Local build  │───▶│ Ready for       │───▶│ Merge │
│  PR      │    │ & test       │    │ review (CI runs)│    │       │
└──────────┘    └──────────────┘    └─────────────────┘    └───────┘

Every pull request must start in Draft status. Workflows do not run on Draft PRs — this is enforced across all repositories to avoid wasting GitHub Actions minutes on work in progress — so use Draft status freely while iterating on your changes.

Before marking the PR as ready, build the project successfully and run the tests locally to verify they pass. This prevents avoidable CI failures that waste runner time and delay reviews. Once everything is complete and locally validated, click “Ready for review” and move the linked issue to Pending review. This is the moment workflows are triggered for the first time.

To address review feedback, push new commits on top of the branch and re-request review once you have resolved all comments. Avoid amending or rebasing published commits during review, and if CI fails after pushing, investigate and fix it before requesting re-review. When the PR is approved and CI passes, it can be merged. Use squash merge for single-purpose PRs to keep a clean history.

Body template

Use the following template when creating a pull request:

## Description

<!-- Brief description of the changes and the reasoning behind them. -->

Resolves #<issue_number>

## Checklist

- [ ] ...

Always link the related issue with Resolves #<number> so it auto-closes on merge, and describe why rather than just what — the diff already shows what changed, so the description should explain the motivation.

Include instructions to test your changes, and any other relevant information for reviewers. Use the checklist to indicate that you have completed all required steps before requesting review.

Reviewing a PR

Start from the linked issue to understand the context and acceptance criteria, then read the description and checklist before reading the code. Focus your feedback on correctness, clarity, and maintainability, and use GitHub’s suggestion feature for small fixes to speed up the process. Approve only when you are confident the changes are correct and complete.

Changelog

Every PR is expected to include a changelog entry. The 5_codequality_changelog.yml workflow enforces this. Apply the skip-changelog label to bypass the check when the linked issue belongs to a private repository, or when the PR is linked to a public issue but genuinely does not require a changelog update.

Best practices

  • Keep PRs small and focused. One issue per PR whenever possible.
  • Write descriptive commit messages. They should explain why, not just what.
  • Do not trigger CI unnecessarily. Keep PRs in Draft until ready, and validate locally first.

Workflows and Actions

This section defines the naming conventions and operational rules for the GitHub Actions and Workflows used across the Wazuh Indexer repositories.

Naming convention

Both Actions and Workflows follow the same pattern:

<major>_<prefix>_<target>
ComponentDescription
MajorProduct major version (e.g. 4, 5).
PrefixCategory prefix from the use cases below.
TargetThe action target: a component, module, subsystem, tool, language, etc.

The prefix is drawn from the following set of use cases:

Use casePrefixTargetExample
Code analysis (static/dynamic)codeanalysisCode analysis tool4_codeanalysis_coverity
Linter / auto-docscodelinterLinter5_codelinter_clangformat
Code quality (groups codeanalysis + codelinter)codequalityRepository5_codequality_changelog
Unit teststestunitModule5_testunit_engine
Component teststestcomponentComponent/module5_testcomponent_indexerconnector
Integration teststestintegrationModule4_testintegration_cluster
Package builderbuilderpackageSubsystem4_builderpackage_server
Precompiled object builderbuilderprecompiledSubsystem5_builderprecompiled_agent
Version bumpingbumperRepository5_bumper_repository

For workflows triggered on PR or push events, append _onpush to the name to distinguish them from their workflow_dispatch counterparts:

5_builderpackage_indexer.yml          ← workflow_dispatch (manual)
5_builderpackage_indexer_onpush.yml   ← PR / push trigger (automatic)

When composing jobs from Actions, a single job step cannot mix Actions with different prefixes, and steps must use matrices whenever possible.

Runners

Two types of runners are available:

  • Default (GitHub-hosted) — used for all workflows unless there is a justified reason to use the dedicated runner.
  • Dedicated (self-hosted) — reserved for resource-intensive workflows only. Currently used exclusively by 5_builderpackage_indexer (the full package builder).

Always prefer the default runner. The dedicated runner is a shared, limited resource — use it only when the workflow genuinely requires the extra capacity (e.g. the full product builder).

Draft PR enforcement

All PR workflows must be configured to skip Draft PRs, so that no CI minutes are consumed on work-in-progress PRs. This is enforced by adding the following condition to every PR-triggered workflow:

on:
  pull_request:
    types: [opened, synchronize, ready_for_review]

jobs:
  <job_name>:
    if: ${{ !github.event.pull_request.draft }}

Set up the development environment

1. Git

Install and configure Git (SSH keys, commits and tags signing, user and email).

  1. Set your username.
  2. Set your email address.
  3. Generate an SSH key.
  4. Add the public key to your GitHub account for authentication and signing.
  5. Configure Git to sign commits with your SSH key.

2. Repositories

Clone the Wazuh Indexer repositories (use SSH). Before you start, you need to properly configure your working repositories to have origin and upstream remotes.

mkdir -p ~/wazuh && cd ~/wazuh

# Plugins (no upstream fork)
git clone git@github.com:wazuh/wazuh-indexer-plugins.git

# Indexer core (forked from OpenSearch)
git clone git@github.com:wazuh/wazuh-indexer.git
cd wazuh-indexer
git remote add upstream git@github.com:opensearch-project/opensearch.git
cd ..

# Reporting plugin (forked from OpenSearch)
git clone git@github.com:wazuh/wazuh-indexer-reporting.git
cd wazuh-indexer-reporting
git remote add upstream git@github.com:opensearch-project/reporting.git
cd ..

# Security Analytics (forked from OpenSearch)
git clone git@github.com:wazuh/wazuh-indexer-security-analytics.git
cd wazuh-indexer-security-analytics
git remote add upstream git@github.com:opensearch-project/security-analytics.git
cd ..

# Notifications plugin (forked from OpenSearch)
git clone git@github.com:wazuh/wazuh-indexer-notifications.git
cd wazuh-indexer-notifications
git remote add upstream git@github.com:opensearch-project/notifications.git
cd ..

# Common Utils plugin (forked from OpenSearch)
git clone git@github.com:wazuh/wazuh-indexer-common-utils.git
cd wazuh-indexer-common-utils
git remote add upstream git@github.com:opensearch-project/common-utils.git
cd ..

# Alerting plugin (forked from OpenSearch)
git clone git@github.com:wazuh/wazuh-indexer-alerting.git
cd wazuh-indexer-alerting
git remote add upstream git@github.com:opensearch-project/alerting.git
cd ..

3. Vagrant

Install Vagrant with the Libvirt provider following the guide.

Then install the Vagrant SCP plugin:

vagrant plugin install vagrant-scp

4. IntelliJ IDEA

Prepare your IDE:

  1. Install IDEA Community Edition as per the official documentation.
  2. Set a global SDK to Eclipse Temurin following this guide.

You can find the JDK version to use under the wazuh-indexer/gradle/libs.versions.toml file. IntelliJ IDEA includes some JDKs by default. If you need to change it, or if you want to use a different distribution, follow the instructions in the next section.

5. Set up Java

When you open a Java project for the first time, IntelliJ will ask you to install the appropriate JDK for the project.

Using IDEA, install a JDK following this guide. The version to install must match the JDK version used by the Indexer, currently Eclipse Temurin 25 (check bundled_jdk in wazuh-indexer/gradle/libs.versions.toml).

Once the JDK is installed, configure it as the default system-wide Java installation using update-alternatives. IntelliJ installs JDKs under ~/.jdks/; adjust the path to the version you installed:

sudo update-alternatives --install /usr/bin/java java /home/$USER/.jdks/temurin-25.0.2/bin/java 0

Check Java is correctly configured:

java --version

If you need to install or switch JDK versions, use sudo update-alternatives --config java to select the JDK of your preference.

Set the JAVA_HOME and PATH environment variables by adding these lines to your shell RC file (.bashrc, .zshrc, etc.):

export JAVA_HOME=/home/$USER/.jdks/temurin-25.0.2
export PATH=$PATH:$JAVA_HOME/bin

After that, restart your shell or run source ~/.zshrc (or similar) to apply the changes. Verify with java --version.

Tip: SDKMAN is a convenient tool for managing multiple JDK versions. Its identifiers carry the full patch version, so run sdk list java to find the current Temurin 25 identifier:

sdk install java 25.0.4-tem
sdk use java 25.0.4-tem

6. Docker (optional)

Docker is useful for running integration tests and local test environments. Install Docker Engine following the official instructions.

Verify the installation:

docker --version
docker run hello-world

7. Test cluster (optional)

The repository includes a Vagrant-based test cluster at tools/test-cluster/ for end-to-end testing against a real Wazuh Indexer instance.

Prerequisites:

Refer to the tools/test-cluster/README.md for provisioning and usage instructions.

8. Verify the setup

After completing the setup, verify everything works:

For the Common Utils plugin (Shared Library):

cd wazuh-indexer-common-utils
./gradlew clean publishToMavenLocal

For the Alerting plugin:

cd wazuh-indexer-alerting
./gradlew clean publishToMavenLocal

For the Notifications plugin (Kotlin-based, separate repository):

cd wazuh-indexer-notifications/notifications
./gradlew clean publishToMavenLocal

For the Security Analytics plugin:

cd wazuh-indexer-security-analytics/commons
../gradlew clean publishToMavenLocal
cd ..
./gradlew clean publishToMavenLocal
cd wazuh-indexer-plugins
./gradlew :wazuh-indexer-content-manager:compileJava

If compilation succeeds, your environment is ready. See Build from Sources for more build commands.

How to generate a package

This guide includes instructions to generate distribution packages locally using Docker.

Wazuh Indexer supports any of these combinations:

  • distributions: ['tar', 'deb', 'rpm']
  • architectures: ['x64', 'arm64']

Windows is currently not supported.

For more information navigate to the compatibility section.

Before you get started, make sure to clean your environment by running ./gradlew clean on the root level of the wazuh-indexer repository.

Prerequisites

The process to build packages requires Docker and Docker Compose.

Your workstation must meet the minimum hardware requirements (the more resources the better):

  • 8 GB of RAM (minimum)
  • 4 cores

The tools and source code to generate a package of Wazuh Indexer are hosted in the wazuh-indexer repository, so clone it if you haven’t done already.

A wazuh-engine tarball is required to build the Wazuh Indexer package. Follow the Engine build instructions in the wazuh/wazuh repository to produce it. The resulting .tar.gz is passed to builder.sh via the -e flag.

Building wazuh-indexer packages

The Docker environment under wazuh-indexer/build-scripts/builder automates the build and assemble process for the Wazuh Indexer and its plugins, making it easy to create packages on any system.

Use the builder.sh script to build a package.

./builder.sh -h
Usage: ./builder.sh [args]

Arguments:
-p INDEXER_PLUGINS_BRANCH       [Optional] wazuh-indexer-plugins repo branch, default is 'main'.
-r INDEXER_REPORTING_BRANCH     [Optional] wazuh-indexer-reporting repo branch, default is 'main'.
-s SECURITY_ANALYTICS_BRANCH    [Optional] wazuh-indexer-security-analytics repo branch, default is 'main'.
-n NOTIFICATIONS_BRANCH         [Optional] wazuh-indexer-notifications repo branch, default is 'main'.
-t INDEXER_ALERTING_BRANCH      [Optional] wazuh-indexer-alerting repo branch, default is 'main'.
-c COMMON_UTILS_BRANCH          [Optional] wazuh-indexer-common-utils repo branch, default is 'main'.
-e ENGINE_TARBALL               [Required] Path to wazuh-engine tarball (.tar.gz) on the host.
-R REVISION     [Optional] Package revision, default is '0'.
-S STAGE        [Optional] Staging build, default is 'false'.
-d DISTRIBUTION [Optional] Distribution, default is 'rpm'.
-a ARCHITECTURE [Optional] Architecture, default is 'x64'.
-D      Destroy the docker environment
-h      Print help

The example below generates a wazuh-indexer package for Debian based systems, for the x64 architecture, using 0 (the default) as revision number and using the production naming convention.

# Within wazuh-indexer/build-scripts/builder
bash builder.sh -d deb -a x64 -R 0 -S true -e ./wazuh-engine-5.0.0-linux-amd64.tar.gz

The resulting package will be stored at wazuh-indexer/artifacts/dist.

The STAGE option defines the naming of the package. When set to false, the package will be unequivocally named with the commits’ SHA of the wazuh-indexer, wazuh-indexer-plugins, wazuh-indexer-reporting, wazuh-indexer-security-analytics, wazuh-indexer-notifications and wazuh-indexer-alerting repositories, in that order. For example: wazuh-indexer_5.0.0-0_x86_64_aff30960363-846f143-494d125-9c0c1fe-3b2a8d1-7e5f094.rpm.

Installing the generated package

The package produced under wazuh-indexer/artifacts/dist is the input to the standard Wazuh Indexer installation procedure. Follow the Installation guide for certificate creation, node configuration, and cluster initialization steps.

How to generate a container image

This guide includes instructions to generate distribution packages locally using Docker.

Wazuh Indexer supports any of these combinations:

  • distributions: ['tar', 'deb', 'rpm']
  • architectures: ['x64', 'arm64']

Windows is currently not supported.

For more information navigate to the compatibility section.

Before you get started, make sure to clean your environment by running ./gradlew clean on the root level of the wazuh-indexer repository.

Prerequisites

The process to build packages requires Docker and Docker Compose.

Your workstation must meet the minimum hardware requirements (the more resources the better):

  • 8 GB of RAM (minimum)
  • 4 cores

The tools and source code to generate a package of Wazuh Indexer are hosted in the wazuh-indexer repository, so clone it if you haven’t done already.

Building wazuh-indexer Docker images

The docker folder (wazuh-indexer/build-scripts/docker) contains the code to build Docker images. Below there is an example of the command needed to build the image. Set the build arguments and the image tag accordingly.

The Docker image is built from a wazuh-indexer tarball (tar.gz), which must be placed in wazuh-indexer/build-scripts/docker and named following the convention wazuh-indexer-<arch>.tar.gz, where <arch> matches Docker’s architecture naming (amd64 or arm64).

# Copy and rename the tarball to the expected name (from wazuh-indexer/build-scripts/docker)
cp wazuh-indexer_<version>-<revision>_linux-x64.tar.gz wazuh-indexer-amd64.tar.gz

# Build the image
docker build --build-arg="VERSION=<version>" --tag=wazuh-indexer:<version>-<revision> --progress=plain --no-cache .

Then, start a container with:

docker run -p 9200:9200 -it --rm wazuh-indexer:<version>-<revision>

The build-and-push-docker-image.sh script automates the process to build and push Wazuh Indexer Docker images to our repository in quay.io. The script takes several parameters. Use the -h option to display them.

To push images, credentials must be set at environment level:

  • QUAY_USERNAME
  • QUAY_TOKEN
Usage: build-scripts/build-and-push-docker-image.sh [args]

Arguments:
-a ARCHITECTURE [Optional] Target architecture (amd64 or arm64), default is host arch.
-r REVISION     [Optional] Revision qualifier, default is 0.
-h help

The script will stop if the credentials are not set, or if the expected tarball is not found.

This script is used in the 5_builderpackage_docker.yml GitHub Workflow, which is used to automate the process even more. When possible, prefer this method.

Note: all the scripts under the ci folder are used in the GitHub workflows and are not intended to be used manually..

How to build from sources

The Wazuh Indexer Plugins repository uses Gradle as its build system. The root project contains multiple subprojects, one per plugin.

Building the entire project

To build all plugins (compile, test, and package):

./gradlew build

When completed, distribution artifacts for each plugin are located in their respective build/distributions/ directories.

Building a specific plugin

To build only the Content Manager plugin:

./gradlew :wazuh-indexer-content-manager:build

Other plugin targets follow the same pattern. To see all available projects:

./gradlew projects

Compile only (no tests)

For a faster feedback loop during development, compile without running tests:

./gradlew :wazuh-indexer-content-manager:compileJava

This is useful for checking that your code changes compile correctly before running the full test suite.

Output locations

ArtifactLocation
Plugin ZIP distributionplugins/<plugin-name>/build/distributions/
Compiled classesplugins/<plugin-name>/build/classes/
Test reportsplugins/<plugin-name>/build/reports/tests/
Generated JARsplugins/<plugin-name>/build/libs/

Common build issues

JDK version mismatch

The project is built and tested with JDK 25 (Eclipse Temurin — the bundled_jdk in wazuh-indexer/gradle/libs.versions.toml), and the build refuses to run on anything older than JDK 21. If you see compilation errors related to Java version, check:

java --version

Ensure JAVA_HOME points to the correct JDK. See Setup for details.

Dependency resolution failures

If Gradle cannot resolve dependencies:

  1. Check your network connection (dependencies are downloaded from Maven Central and repositories).
  2. Try clearing the Gradle cache: rm -rf ~/.gradle/caches/
  3. Re-run with --refresh-dependencies: ./gradlew build --refresh-dependencies

Out of memory

For large builds, increase Gradle’s heap size in gradle.properties:

org.gradle.jvmargs=-Xmx4g

Linting and formatting errors

The build includes code quality checks (Spotless, etc.). If formatting checks fail:

./gradlew spotlessApply

Then rebuild.

Useful Gradle flags

  • --info — verbose output.
  • --debug — debug-level output.
  • --stacktrace — print stack traces on failure.
  • --parallel — run tasks in parallel (faster on multi-core).
  • -x test — skip tests: ./gradlew build -x test.
  • --continuous — watch mode; rebuilds on file changes.

How to run from sources

Every Wazuh Indexer repository includes one or more Gradle projects with predefined tasks to run and build the source code.

In this case, to run a Gradle project from source code, run the ./gradlew run command.

For Wazuh Indexer, additional plugins may be installed by passing the -PinstalledPlugins flag:

./gradlew run -PinstalledPlugins="['plugin1', 'plugin2']"

The ./gradlew run command will build and start the project, writing its log above Gradle’s status message. A lot of stuff is logged on startup, specifically these lines tell you that OpenSearch is ready.

[2020-05-29T14:50:35,167][INFO ][o.e.h.AbstractHttpServerTransport] [runTask-0] publish_address {127.0.0.1:9200}, bound_addresses {[::1]:9200}, {127.0.0.1:9200}
[2020-05-29T14:50:35,169][INFO ][o.e.n.Node               ] [runTask-0] started

It’s typically easier to wait until the console stops scrolling, and then run curl in another window to check if OpenSearch instance is running.

curl localhost:9200

{
  "name" : "runTask-0",
  "cluster_name" : "runTask",
  "cluster_uuid" : "oX_S6cxGSgOr_mNnUxO6yQ",
  "version" : {
    "number" : "1.0.0-SNAPSHOT",
    "build_type" : "tar",
    "build_hash" : "0ba0e7cc26060f964fcbf6ee45bae53b3a9941d0",
    "build_date" : "2021-04-16T19:45:44.248303Z",
    "build_snapshot" : true,
    "lucene_version" : "8.7.0",
    "minimum_wire_compatibility_version" : "6.8.0",
    "minimum_index_compatibility_version" : "6.0.0-beta1"
  }
}

Use -Dtests.opensearch. to pass additional settings to the running instance. For example, to enable OpenSearch to listen on an external IP address, pass -Dtests.opensearch.http.host. Make sure your firewall or security policy allows external connections for this to work.

./gradlew run -Dtests.opensearch.http.host=0.0.0.0

How to run the tests

This section explains how to run the Wazuh Indexer Plugins tests at various levels.

Full suite

To execute all tests and code quality checks (linting, documentation, formatting):

./gradlew check

This runs unit tests, integration tests, and static analysis tasks.

Unit tests

Run all unit tests across the entire project:

./gradlew test

Run unit tests for a specific plugin:

./gradlew :wazuh-indexer-content-manager:test

Integration tests

Run integration tests for a specific plugin:

./gradlew :wazuh-indexer-content-manager:integTest

YAML REST tests

Plugins can define REST API tests using YAML test specs. To run them:

./gradlew :wazuh-indexer-content-manager:yamlRestTest

Reproducible test runs

Tests use randomized seeds. When a test fails, the output includes the seed that was used. To reproduce the exact same run:

./gradlew :wazuh-indexer-content-manager:test -Dtests.seed=DEADBEEF

Replace DEADBEEF with the actual seed from the failure output.

Viewing test reports

After running tests, HTML reports are generated at:

plugins/<plugin-name>/build/reports/tests/test/index.html

Open this file in a browser to see detailed results with pass/fail status, stack traces, and timing.

For integration tests:

plugins/<plugin-name>/build/reports/tests/integTest/index.html

Code coverage

Code coverage is measured with JaCoCo. The configuration lives in gradle/coverage.gradle, which both plugins apply.

Coverage of the unit tests:

./gradlew :wazuh-indexer-content-manager:jacocoTestReport

jacocoTestReport runs the unit tests first if needed. To also measure the plugin code that runs inside the integration test cluster, add -Dtests.coverage=true. This runs integTest as well:

./gradlew :wazuh-indexer-content-manager:jacocoTestReport -Dtests.coverage=true

The reports are written in three formats:

plugins/<plugin-name>/build/reports/jacoco/test/html/index.html
plugins/<plugin-name>/build/reports/jacoco/test/jacocoTestReport.xml
plugins/<plugin-name>/build/reports/jacoco/test/jacocoTestReport.csv

Open the HTML report in a browser to browse the coverage per package, class and source line.

In CI, the Gradle check workflow runs ./gradlew check jacocoTestReport -PnumNodes=1 -Dtests.coverage=true for each modified plugin. It adds a coverage table to the job summary and uploads the reports as the coverage-<plugin-name> workflow artifact.

The workflow also runs on every push to main or to a release branch (<major>.<minor>.<patch>) that changes plugin code. There it measures both plugins, and its coverage-badge job generates the coverage and branches badges from the two CSV reports and commits them to the badges branch, in a folder named after the pushed branch (for example 5.0.0/jacoco.svg). It creates the badges branch on the first run. The README’s coverage badge shows main/jacoco.svg, the default branch’s.

The integration test nodes are stopped with SIGKILL, so the JaCoCo agent on each node cannot write its data on exit. Instead, each node’s agent serves its data over TCP on a free port, and integTest pulls it into build/jacoco/integTestCluster.exec after the last test, before the cluster stops.

Running a single test class

To run a specific test class:

./gradlew :wazuh-indexer-content-manager:test --tests "com.wazuh.contentmanager.rest.service.RestPostRuleActionTests"

Test cluster (Vagrant)

For end-to-end testing on a real Wazuh Indexer service, the repository includes a Vagrant-based test cluster at tools/test-cluster/. This provisions a virtual machine with Wazuh Indexer installed and configured.

Refer to its README.md for setup and usage instructions.

Package testing

Built packages are tested by the package builder Workflow, each test in a throwaway container:

  • DEB packages — an Ubuntu 22.04 container running systemd, started by build-scripts/run_in_systemd_container.sh.
  • RPM packages — Red Hat UBI 9 containers: redhat/ubi9, or redhat/ubi9-init when the test needs systemd, through the same script with SYSTEMD_IMAGE set.

The tests cover:

  • Installation and removal — the package installs, and removes (DEB: purges) without errors.
  • Removal leftovers — build-scripts/ci/test_purge.sh: a purge always removes the wazuh-indexer user and group; what the service account owned in the package’s four directories belongs to root and keeps its mode, and the directories are closed to everyone but root; only a directory that still holds files is listed as kept; the engine’s runtime files and the JVM’s performance data in /tmp do not outlive the purge; the certificates issued from the removed CA go with it, and an operator’s own pair stays; a custom path.repo is left exactly as it was; a directory that cannot be handed over is reported and not listed as kept; a reinstall restores every owner and mode, and the node’s certificates chain to its CA; and the resolver refuses a pair the CA did not issue. See When the package is purged.
  • Credential and TLS resolution — build-scripts/ci/test_credentials.sh. See Credential and TLS resolution.
  • Host tools — build-scripts/ci/test_host_tools.sh: the maintainer scripts and the scripts the package ships run no host tool outside the declared dependencies. See Host dependencies.
  • Upgrades — from the previous version, with the indexer stopped and running. Only when a previous version exists.
  • 4.x upgrade block — build-scripts/ci/test_upgrade_block.sh: the package refuses to upgrade a 4.x installation.

Each script documents its requirements in its header and can also be run on a throwaway VM.

Useful test flags

  • -Dtests.seed=<seed> — reproduce a specific randomized test run.
  • -Dtests.verbose=true — print test output to stdout.
  • --tests "ClassName" — run a single test class.
  • --tests "ClassName.methodName" — run a single test method.
  • -x test — skip unit tests in a build.

Packages

This page is for the people who build and maintain the Wazuh Indexer Debian (DEB) and RPM packages: how a package is put together, how it is named, built in continuous integration (CI) and tested, and how its maintainer scripts behave on installation, upgrade and removal. For the commands to build a package yourself, see Build packages.

Everything described here lives in the wazuh-indexer repository, and every path on this page is relative to it: the build tooling is in build-scripts/, the package definitions and maintainer scripts in distribution/packages/src/.

Supported systems

The build produces three formats, deb, rpm and tar, each for the x64 and arm64 architectures. Windows is not supported.

The operating systems the packages are supported and tested on are listed in Compatibility. That table is the one to update when support changes, and it must always match the list in the Wazuh documentation. The Indexer team keeps both up to date.

How a package is built

A package is built in two stages, each driven by its own script:

  • Build — build-scripts/build.sh compiles the OpenSearch fork with Gradle and produces the minimal package, wazuh-indexer-min: the search engine alone, with no plugins and no Wazuh configuration.
  • Assemble — build-scripts/assemble.sh unpacks the minimal package, adds everything that makes it the Wazuh Indexer, and packs the final package, wazuh-indexer, the one installed on hosts.
flowchart LR
    src["wazuh-indexer sources"] -->|build.sh| min["wazuh-indexer-min package"]
    min --> asm["assemble.sh"]
    plugins["Wazuh plugin ZIPs"] --> asm
    central["OpenSearch plugins<br/>(Maven Central)"] --> asm
    engine["Wazuh Engine tarball"] --> asm
    cti["CTI snapshots"] --> asm
    asm --> final["wazuh-indexer package<br/>and .sha512 checksum"]

GitHub Actions provides the infrastructure, but the build logic is self-contained in Gradle and these Bash scripts, so the same process runs unchanged in the Docker builder, in CI and on a workstation. build.sh keeps the shape of the per-component build script that OpenSearch’s opensearch-build repository expects, which is why it also publishes Maven artifacts.

Build

build.sh runs publishToMavenLocal and publishNebulaPublicationToTestRepository, then the :distribution:<type>:<target>:assemble Gradle task for the requested format and architecture (<type> is packages for DEB and RPM, archives for the tarball), and bundles the workload-management plugin, which is built from this repository rather than downloaded. Gradle writes the package to distribution/<type>/<target>/build/distributions/, and the script copies it to artifacts/dist/ under the name given with -n.

Assemble

assemble.sh finds the minimal package in artifacts/dist/, unpacks it in artifacts/tmp/ (with ar and tar for DEB, rpm2cpio and cpio for RPM), and then:

  1. Writes VERSION.json into the product directory, with a commit field holding the commit hashes the package was built from (see Package naming).
  2. Installs the plugins with opensearch-plugin, from three lists at the top of the script: plugins, the OpenSearch plugins, downloaded from Maven Central at the OpenSearch version in buildSrc/version.properties; internal_plugins, built by build.sh; and wazuh_plugins, read from artifacts/plugins/.
  3. Copies the Content Manager’s Cyber Threat Intelligence (CTI) snapshots, when artifacts/snapshots/ exists, into plugins/wazuh-indexer-content-manager/snapshots/.
  4. Installs the Wazuh Engine from the tarball in artifacts/engine/ into engine/.
  5. Applies the Wazuh configuration. Once the Security plugin has created its default files in opensearch-security/, the script appends Wazuh’s *.wazuh.yml files to them (see Security), deletes the demo users and role mappings the product does not use, replaces the admin and kibanaserver hashes with credential placeholders (see How a password reaches the cluster) and disables multi-tenancy. It then installs opensearch.prod.yml as opensearch.yml, and removes symbolic links, .bat files and the Security plugin’s install_demo_configuration.sh.
  6. Builds the installation assistant tools from that repository’s sources: config.yml, wazuh-passwords-tool.sh and wazuh-certs-tool.sh go into tools/, with the indexer’s own wazuh-indexer-credentials.sh put in place of the assistant’s shared library they embed (see Where things live), and that library itself goes into lib/.
  7. Packs the result, with debmake and debuild from distribution/packages/src/deb/, or with rpmbuild from distribution/packages/src/rpm/wazuh-indexer.rpm.spec. It drops the -min from the name and writes a .sha512 checksum beside the package.

Assembly needs network access, to Maven Central and to the installation assistant’s repository on GitHub.

To add a plugin to the package, add an OpenSearch plugin to the plugins list in assemble.sh. A Wazuh plugin goes in wazuh_plugins there and in the maven_plugins list of copy_builds in build-scripts/builder/entrypoint.sh; one from a new repository also needs a clone entry and a build function in that file. A file the service writes at runtime also needs an ownership exception, as described in File ownership.

The Docker builder

build-scripts/builder/builder.sh runs both stages inside a container (compose.yml and Dockerfile in the same directory) that carries JDK 21 and every packaging tool, so nothing has to be installed on the host. The container’s entrypoint.sh:

  1. Clones the six plugin repositories at the branch, tag or commit given for each, into named volumes that persist between runs.
  2. Downloads the CTI snapshots with build-scripts/download_snapshots.sh from CTI_API_URL, which defaults to the pre-production CTI API.
  3. Builds the plugins with ./gradlew publishToMavenLocal -x check, versioned <version>.<revision>, in dependency order: Common Utils, Alerting, Security Analytics (its commons module first), Notifications, Reporting, and last the Setup and Content Manager plugins from wazuh-indexer-plugins.
  4. Copies the plugin ZIPs to artifacts/plugins/ and the Engine tarball to artifacts/engine/.
  5. Clears artifacts/dist/ and artifacts/tmp/, names the minimal package with baptizer.sh, and runs build.sh and assemble.sh.

The builder’s flags and prerequisites, including how to obtain the Engine tarball, are in Build packages.

build.sh and assemble.sh can also run directly, from the repository root, once artifacts/plugins/ and artifacts/engine/ are populated. The builder exists so that this is rarely worth doing: the host then needs jq at /usr/bin/jq, Maven, a JDK and debmake or rpmbuild; assemble.sh overwrites ~/.devscripts when it packs a DEB; and a minimal package left in artifacts/dist/ by an earlier run breaks assembly, because the script finds the minimal package by pattern.

The artifacts directory

Both stages read from and write to artifacts/, at the repository root:

DirectoryContentsWritten by
dist/The minimal package, the final package and its .sha512 checksumbuild.sh, assemble.sh
maven/The OpenSearch fork’s Maven publicationsbuild.sh
plugins/The Wazuh plugin ZIPsDocker builder
engine/The Wazuh Engine tarballDocker builder
snapshots/The CTI snapshots and their manifest.jsonDocker builder
tmp/Scratch space for unpacking, deleted when assembly succeedsassemble.sh

Package naming

The stages hand packages to each other by file name, so names are generated rather than typed: build-scripts/baptizer.sh prints the name for a combination of parameters. It only prints, so it is safe to run with any combination to get familiar with the convention. Run it from the repository root.

The version comes from the version field of VERSION.json, and the Wazuh plugins are versioned <version>.<revision> from it. The stage field (rc1, for example) is not part of the name: it is only sent as the version tag when the builder requests the CTI snapshots.

There are two conventions:

  • Release — the name a published package carries. Selected with -x (the builder’s -S true, the Workflow’s is_stage).
    • DEB and tarball: wazuh-indexer_<version>-<revision>_<architecture>.<extension>
    • RPM: wazuh-indexer-<version>-<revision>.<architecture>.rpm
  • Development — the default, the same for every format: wazuh-indexer_<version>-<revision>_<architecture>_<commits>.<extension>. <commits> is the short hash of the wazuh-indexer commit or, when all five plugin hashes are given (the builder always gives them), six hashes joined by -: wazuh-indexer, wazuh-indexer-plugins, wazuh-indexer-reporting, wazuh-indexer-security-analytics, wazuh-indexer-notifications and wazuh-indexer-alerting, in that order.

-m replaces the wazuh-indexer prefix with wazuh-indexer-min, the name build.sh gives the minimal package. assemble.sh drops the -min again.

The architecture is spelled the way each package manager expects:

Formatx64arm64
DEBamd64arm64
RPMx86_64aarch64
Tarballlinux-x64linux-arm64
bash build-scripts/baptizer.sh -d rpm -a x64 -r 0 -x
# wazuh-indexer-5.0.0-0.x86_64.rpm
bash build-scripts/baptizer.sh -d deb -a arm64 -r 0 -x
# wazuh-indexer_5.0.0-0_arm64.deb
bash build-scripts/baptizer.sh -d rpm -a x64 -r 0
# wazuh-indexer_5.0.0-0_x86_64_a55f704b69c.rpm

The commit field assemble.sh writes into /usr/share/wazuh-indexer/VERSION.json holds the same six hashes, so an installed node records what it was built from even under a release name. The wazuh-indexer-common-utils commit, which several of the plugins are built against, appears in neither the name nor that field.

Continuous integration

Packages are built by the 5_builderpackage_indexer.yml Workflow, (5.x) Build packages. To run it by hand, open the repository’s Actions tab, select (5.x) Build packages, fill in the form and click Run workflow. The inputs that change the result:

  • revision (default 0) — the package revision.
  • is_stage (default false) — use the release naming convention.
  • distribution and architecture — the formats and architectures to build, one job per combination.
  • plugins_ref (default main) — the branch, tag or commit to build the plugin repositories and the Wazuh Engine from. Each repository uses the same-named branch when it has one, and otherwise the branch for the product version, so a feature branch pushed with the same name to several repositories is built together.
  • upload and checksum — upload the packages, and their checksums, to the S3 bucket.

The Workflow first checks that every repository declares the same product and OpenSearch versions (build-scripts/check_compatibility_versions.sh) and builds the Wazuh Engine through the wazuh/wazuh repository’s 5_builderpackage_engine-standalone.yml. It then runs the same builder.sh described above, on the dedicated runner, followed by the package tests. 5_builderpackage_indexer_onpush.yml calls it for every pull request that is ready for review and changes more than Markdown files.

Testing

Every package the Workflow builds is installed, removed and, when a previous version exists, upgraded, in throwaway containers before it is uploaded. Package testing lists those tests and the scripts behind them.

To try a package on a real service, use the Vagrant environment described in Test cluster (Vagrant). More extensive testing, including end-to-end (E2E) tests, is done by the quality assurance (QA) team.

Maintainer scripts

PackageScriptsDependencies
DEBdeb/debian/: preinst, postinst, prerm, postrmDepends: in deb/debian/control
RPMrpm/wazuh-indexer.rpm.spec: %pre, %post, %posttrans, %preun, %postunRequires: in the spec

Paths are relative to distribution/packages/src/. The systemd unit, its environment file, the SysV init script and the sysctl settings are shared by both packages, under common/. A change to one package’s scripts almost always needs its counterpart in the other.

The two package managers run these scripts in a different order, and that order decides where each step has to go. Fedora’s scriptlet ordering and the Debian maintainer scripts flowcharts are the references.

Upgrades keep the service state

An upgrade leaves the service as it found it: a node that was running is running again afterwards, with no operator action, and a stopped node stays stopped.

  1. Before the new files are unpacked, DEB preinst upgrade or RPM %pre (with $1 set to 2) refuses to upgrade a 4.x installation, then stops the service if it is active and creates the flag file /etc/wazuh-indexer/.was_active.
  2. The package manager replaces the files.
  3. DEB postinst configure (with the previous version in $2) or RPM %posttrans restarts the service if the flag exists, and deletes it. Without the flag, it prints how to start the service instead.

The RPM restart is in %posttrans, not %post, because during an RPM upgrade the new package’s %post runs before the old package’s %preun, its file removal and its %postun. %posttrans is the only scriptlet that runs after all of them. The old package’s removal scriptlets act only when $1 is 0, a real erase, and DEB prerm upgrade and postrm upgrade do nothing, so an upgrade never disables the service. The spec declares the flag file %ghost, so RPM owns it without shipping it.

For the operator’s side of an upgrade, see Upgrade.

Configuration files

The package manager never overwrites a configuration file the operator changed. The two formats handle the conflict differently:

  • RPM — the files marked %config(noreplace) in %files: opensearch.yml, jvm.options, log4j2.properties, the opensearch-security/ files, /etc/sysconfig/wazuh-indexer and a few others. An upgrade replaces an unmodified file. A modified one is kept, and when the packaged version changed, the new version is written beside it with the .rpmnew suffix. On erase, a modified file is saved with the .rpmsave suffix.
  • DEB — debhelper marks every file under /etc as a conffile. An upgrade replaces an unmodified conffile. For a modified one whose packaged version also changed, dpkg asks the operator which to keep, unless --force-confold or --force-confnew answers for them; the CI upgrade tests pass --force-confnew. remove keeps conffiles and purge deletes them.

resolve-credentials.sh writes the node and admin distinguished names (DNs) into opensearch.yml and the password digests into internal_users.yml when it resolves them, normally at installation, so on every host both files count as modified from the start. Any change to their shipped content therefore produces an .rpmnew file or a dpkg prompt on every upgrade. For the same reason, the RPM erase deletes their .rpmsave copies: they carry credential material.

Further reading

Credential and TLS resolution

The Wazuh Indexer package resolves its own credentials and Transport Layer Security (TLS) material rather than shipping defaults. This section covers when that runs and what it does; the commands it needs from the host are listed in Host dependencies. For the accounts and roles themselves, see Security.

Where things live

Everything the package resolves is under its own configuration directory, /etc/wazuh-indexer, and nothing is shared with the other Wazuh packages on the host:

PathOwner and modeHolds
export/root:wazuh-indexer 0750What this package publishes for the Wazuh Manager and the Wazuh Dashboard on the same host: credentials.env (root:root 0600), with WAZUH_INDEXER_ADMIN_PASSWORD, WAZUH_INDEXER_KIBANASERVER_PASSWORD and WAZUH_INDEXER_MANAGER_PASSWORD; ca.pem (root:wazuh-indexer 0640), the CA certificate; and the client pairs manager-client.pem, manager-client-key.pem, dashboard-client.pem and dashboard-client-key.pem (root:root 0600), issued from the CA for the two consumers.
ca/root:wazuh-indexer 0750The CA this node issues its certificates from: root-ca.pem (0640) and root-ca.key (root:root 0400). The package mints it when the directory is empty and records that in .wazuh-indexer-bootstrap-ca; a CA staged there by the operator has no marker, is used as it is and is never removed.
local.envroot:root 0600, optionalThe operator’s values, read before anything else: a password instead of a generated one, or WAZUH_INDEXER_CERT_SANS. The package never writes it.
certs/root:wazuh-indexer 0750The node’s pair and the CA it trusts, root:wazuh-indexer 0640; the admin pair, root:root 0600.

The lookup order for a value is the process environment, then local.env, then what the package already published in export/credentials.env, then generation. The indexer owns its three accounts and consumes nothing from another package, so a password is never left unresolved.

The manager and the dashboard read export/ when they are installed or started on the same host, after checking that it is root’s; on other hosts the operator copies the values into their own local.env and certs/. The names and formats of the files in export/ are a contract with those packages.

lib/wazuh-indexer-credentials.sh, shipped beside the resolver, is the library that reads and writes these files, mints and validates the CA, and generates and validates passwords. wazuh-certs-tool.sh and wazuh-passwords-tool.sh carry a copy of it, put there by assemble.sh in place of the installation assistant’s shared library they are built with.

Files root reads and writes

Every file the resolver reads as root must be a regular file, not a symbolic link, owned by root and writable by nobody else, in a directory with the same properties; export/ and ca/ may carry the service group, never a group write bit, and local.env, export/credentials.env and ca/root-ca.key must be readable by root alone. Anything else is reported as UNSAFE <path>: <reason>, never repaired and never followed. Writes go to a temporary file in the target directory followed by a rename, so nothing is ever written through an existing path. The same checks run at every start — --check, which --prestart runs first — whether or not anything is left to resolve, so trust material planted after the install stops the service rather than being used.

The security plugin walks the whole configuration directory at start and logs a warning for every file readable by others and every directory others can enter. Nothing under /etc/wazuh-indexer is therefore world-readable, including the CA certificates, which the other packages read as root.

Resolution happens once

resolve-credentials.sh runs from four places — postinst / %post, the unit’s ExecStartPre, the SysV start path, and a container entrypoint — but it does its work once. A run that resolves everything it was responsible for records the fact in /var/lib/wazuh-indexer/.initialized, and every later run only checks the files and exits without resolving anything again.

The marker, not the mode, is what makes that true. Every mode resolves the same things — the passwords, the certificates and the trust anchor in the bundled JDK — so a run that finds the marker absent finishes whatever an earlier one could not.

That is not an optimisation. A password an operator rotated, or a certificate pair they replaced with their own, has to survive a service restart and a package upgrade, and the only way to guarantee that is to stop looking. A partial run — one that could not issue a certificate, say — deliberately does not record completion, so the next install can still finish the job.

resolve-credentials.sh --clear is the one way back. It takes back every half of everything this component resolved: the passwords and the CA certificate published in export/ and the digests in internal_users.yml, which return to their ${NAME} placeholders; the certificates, a CA it minted itself, and the node and admin DNs derived from them; and the CA entry in the bundled JDK truststore. A CA the package did not mint — one the operator staged, with or without its key — stays, and so does local.env.

It exists for container images built by installing the package, which would otherwise bake one host’s credentials into a layer every container shares. The next start resolves all of it again.

indexer-security-init.sh keeps no state of its own. It is run by an operator, once, and the package never calls it — so there is nothing for it to guard against repeating.

The password rule

A password, supplied or generated, has 12 to 64 characters from A-Z a-z 0-9 . , _ + : @ % ^ = ~ -, with at least one uppercase letter, one lowercase letter, one digit and one symbol. It is the same rule the Server API applies, so one value is accepted in both realms.

wazuh_password_validate in lib/wazuh-indexer-credentials.sh is the only place that checks it. The start-time error that names a rejected key restates the rule, so that an operator can correct the value without reading the library.

How a password reaches the cluster

internal_users.wazuh.yml ships each account’s hash as a ${NAME} placeholder rather than a digest, so the package carries no usable credential. resolve-credentials.sh resolves the value, bcrypts it with the Security plugin’s hash.sh, and substitutes the digest in place.

The placeholder is deliberately bare. The Security plugin substitutes ${env.X}, ${envbc.X} and ${envbase64.X} itself, node-side, at every configuration load — which would tie each account to a variable that has to stay in the service environment for the life of the deployment. A placeholder with no such prefix is left untouched by the plugin.

The digest and the published password are one credential in two halves, and resolution keeps them together rather than writing either alone. An account whose placeholder is already gone while nothing supplies its password — a host upgrading from a version that predates this mechanism, whose internal_users.yml the package manager kept — is reported and left as it is, instead of being given a freshly generated password that its digest would not match.

Loading the result into the cluster stays a manual step, indexer-security-init.sh. A package cannot know whether other nodes of the same cluster are still to be installed elsewhere, so running it automatically would either race those nodes or overwrite what they uploaded.

Resolver modes

resolve-credentials.sh takes one of five modes:

  • --install — from postinst / %post on a fresh install. Creates what it can and never fails: a maintainer script that aborts leaves the package half-configured. It is the only mode that replaces a DN setting that already has a value.
  • --upgrade — from postinst / %post on an upgrade. Fills in only what this host never had.
  • --prestart — from the unit’s ExecStartPre and from the SysV start path. Runs the file checks first, on every start, then refuses to start the service when something is unresolved or unsafe, naming it.
  • --check — the file checks alone, for an operator or a test: one line per problem, exit 1 on any. See Files root reads and writes.
  • --clear — takes back everything this component resolved, so a later run resolves from nothing. Nothing in the package calls it; see Resolution happens once.

-q / --quiet silences the progress lines, and is not a mode. The maintainer scripts pass it so that the install ends with the next steps an operator needs — where the passwords are, how to start and enable the service, and that the security configuration is loaded once from one node — rather than with a list of resolved keys. Anything that could not be resolved still prints.

Certificate resolution

Which case applies is decided entirely by what is present in the certificate authority (CA) directory, with no mode flag — the presence of a private key beside the trust anchor is the signal, so a host never given one cannot sign:

In the CA directoryPair already in placeResult
NothingNoMint a CA, then self-issue
NothingYesUse the pair if the root-ca.pem beside it issued it; mint nothing
Anchor and keyNoIssue from the CA found
Anchor, with or without keyYesUse the pair if the anchor issued it
Anchor onlyNoUnresolved; the service will not start

A pair already in place is used only once openssl verify confirms that it chains to the anchor the node will trust: the CA directory’s, or, with nothing there, the root-ca.pem staged beside the pair, which is then published as export/ca.pem too. The check passes -partial_chain, so the anchor can be the CA that issued the pair rather than a self-signed root, as the node’s trust store accepts. The helper takes only a self-signed root into the CA directory, so an issuing CA can only be staged beside the pair. A pair that does not chain, or one with nothing to verify it against, is unresolved. The resolver names it and records no completion, rather than leaving a node whose own certificate its trust anchor rejects while the resolution reports success. With no anchor, the error names both places to stage one, and the ownership and modes the CA directory requires. With nothing in the CA directory and a pair in place, no CA is minted: a new one could not have issued that pair.

A staged pair must be root’s, like everything else in certs/: root:wazuh-indexer 0640 for the node’s pair and the anchor, root:root 0600 for the admin pair. One owned by the service account is reported as UNSAFE and refused, never chowned: a repair would turn a planted file into a trusted one.

The Subject Alternative Names (SANs) default to the hostname, the fully qualified domain name (FQDN), loopback and the global addresses of default-route interfaces. WAZUH_INDEXER_CERT_SANS, from the process environment or from local.env, replaces that list wholesale: a comma-separated list in OpenSSL’s form, each entry a DNS: name or an IP: address, for example WAZUH_INDEXER_CERT_SANS=DNS:node-1.example.com,IP:10.0.0.1. localhost and the loopback addresses are always appended; an empty list, or an entry without one of those prefixes, is refused with a message that names the variable.

The host name comes from the kernel (/proc/sys/kernel/hostname) and the FQDN from getent hosts, the addresses from ip: the resolver runs no other host tool, and every text transformation is shell. The certificates are issued with openssl, because the bundled JDK has no tool that issues PEM certificates.

Every certificate the package issues, the CA included, is signed through openssl ca with a throwaway database (wazuh_ca_sign in the helper): RSA 2048, SHA-256, valid for ten years and backdated one day, so that a consumer whose clock lags does not refuse a certificate minted a minute ago. openssl ca is the one command on the supported OpenSSL versions that can set the start of the validity; -preserveDN keeps the subject in the order of the request, so the DNs written into opensearch.yml are the same as before.

Client pairs

The indexer trusts only its own CA, so a client that presents a certificate to it has to present one this CA issued, and only this package holds the key. At every resolution where ca/ holds the key, the resolver issues a pair for the manager (CN=wazuh-manager) and one for the dashboard (CN=wazuh-dashboard), OU=Wazuh, O=Wazuh, L=California, C=US, with extendedKeyUsage = clientAuth and no Subject Alternative Name, and publishes them in export/ as root’s private files; a pair already there that chains to the current CA and whose key matches is kept. The manager imports its pair as certs/indexer-connector.pem; the dashboard does not use its pair yet. A consumer on another host takes a copy of the pair from any indexer node of the cluster: the pair names no host, and the indexer maps its subject to nothing.

The pairs grant no rights by themselves, and three things keep it so, asserted by the acceptance suite: the security plugin’s certificate authentication domain stays disabled in the shipped config.yml, plugins.security.ssl.http.clientauth_mode stays at its default (OPTIONAL), and the subjects are in neither admin_dn nor nodes_dn, which --check verifies. With OPTIONAL, a client that presents a certificate from any other CA fails the TLS handshake, which is what makes trust separation testable. --clear removes the pairs with the rest of export/.

The package and wazuh-certs-tool.sh issue these certificates with the same subject, in the same order. That order is part of the contract, not a detail: the Security plugin compares the rendered DN, so a deployment that replaces the package certificates with the tool’s own keeps the DNs already written into opensearch.yml valid only while both agree.

Distinguished names

A node whose DN is not listed is rejected by the cluster, so plugins.security.nodes_dn and plugins.security.authcz.admin_dn are filled from the certificates in the same step that resolves them. --install replaces whatever those keys hold. Every other mode fills them only when they are empty, because by then the list may be the operator’s own — one entry per node of their cluster.

This is what makes the deferred case work. A deployment that brings its own public key infrastructure (PKI) installs the package with only the trust anchor in the CA directory, so the install cannot issue anything and records no completion; the pair is staged afterwards, and the next start finishes the job, DNs included.

The JDK truststore

The bundled JDK has to trust the Wazuh CA, so the resolver imports certs/root-ca.pem into jdk/lib/security/cacerts under the alias wazuh-root-ca whenever it resolves the certificates, and --clear deletes it again. It belongs to the resolver rather than to the maintainer scripts for both of those reasons: a pair staged after the install reaches the truststore too, and an image built by installing the package does not keep the build host’s CA in every copy.

File ownership

Root runs bin/resolve-credentials.sh — from the maintainer scripts and from the unit’s ExecStartPre — and that script sources lib/wazuh-indexer-credentials.sh. So the product tree is root-owned, group wazuh-indexer, mode 750/640: the service account reads and executes everything and writes nothing. A service account able to rewrite either file could have root run its own code.

Two directories under the product tree are exceptions, because the service writes them at runtime:

  • engine/ — sockets, logs and data
  • plugins/wazuh-indexer-content-manager/snapshots/ — the content manager deletes the shipped snapshot once it has consumed it

Adding a file the service must write means adding a third exception, in the DEB postinst and in the spec’s %files. Adding one it only reads needs nothing: the default covers it. The acceptance suite asserts both the root ownership and the two exceptions, so getting this wrong fails the build rather than shipping quietly.

The same reasoning applies outside the product tree. /etc/default/wazuh-indexer and /usr/lib/sysctl.d/wazuh-indexer.conf are read by systemd as root, so both stay root-owned.

The configuration directory, /etc/wazuh-indexer, is root’s as well: directories root:wazuh-indexer 0750, files 0640, so the service reads its configuration, its keystore and its trust material through its group and can rewrite none of them. The one file the node used to write there, opensearch.keystore, is created by postinst / %post when absent and upgraded when its format changed, as root, so the node never writes it. The admin certificate pair is root’s alone (0600): indexer-security-init.sh runs securityadmin.sh as root, and the service account must not be able to act as the security administrator. The ownership is set by a walk in postinst / %post that touches regular files and directories only, never through a symbolic link, with chown -h, and the spec’s %files attributes say the same, so rpm -V reports no ownership or mode change.

The data and log directories, /var/lib/wazuh-indexer and /var/log/wazuh-indexer, are the service’s: the node writes its indices and logs there. The resolver’s completion marker lives in the data directory, so a link planted in its place is never followed: the marker is written by renaming a fresh file over whatever the entry is, and a link is not read as a marker. The same rule covers the SysV script, which reads a pid from a file the service account owns and therefore confirms the process is the indexer’s before signalling it.

When the package is purged

A purge (DEB purge, RPM erase) always deletes the wazuh-indexer user and group. That frees their IDs: the next system account created would inherit them, and with them anything still on disk that they own — the keystore, the logs, the indexed data and any certificates left behind. So before userdel runs, the purge hands every file the account or its group owns in the package’s own directories over to root, and closes those directories:

  • Files owned by wazuh-indexer become root:root; files that only its group owns get group root. Their modes do not change.
  • Each of the four directories loses group and other access (go-rwx). That, not the files’ own modes, is what keeps what is inside unreadable to anyone but root, and it is all a reinstall has to undo. On DEB, postinst sets the four back to their packaged 750 on a fresh install, beside the ownership it sets anyway, and an upgrade leaves the operator’s modes alone. RPM needs no such step: rpm applies the packaged mode of every directory in its file list on each install and upgrade, and %post sets the ownership again.
  • The walk never enters a symbolic link, and chown -h changes links themselves, so a link the service account planted cannot aim the purge at another file. One pass hands over both the files the account owns and those only its group owns. The DEB postrm uses find -H for it, which is Essential on Debian; the RPM %postun walks the tree in shell, because findutils is not a dependency of the package.

The package’s own directories are these four, and only these:

  • /etc/wazuh-indexer — configuration
  • /usr/share/wazuh-indexer — home
  • /var/lib/wazuh-indexer — data
  • /var/log/wazuh-indexer — logs

Nothing outside them is touched, including a custom path.data, path.logs or path.repo. Such a directory is not necessarily this node’s alone: a shared file system (fs) snapshot repository is mounted on every node of the cluster, and handing it over to root on the node being purged would stop the others from writing snapshots. Files there keep the removed account’s ID. Handling them is the operator’s job, and the purge prints a note saying so every time it runs.

If a handover fails, on a read-only mount or a network file system that squashes root, say, the purge names the directory and still deletes the user and group. A directory is reported as kept for root only when its handover succeeded and it still holds something other than directories. dpkg removes the package’s own empty directories only after postrm purge has run, so a directory that holds nothing but directories at that point is about to go, and reporting it as kept would be wrong.

The purge deletes only what the package itself created:

  • tmp/, and each of the four directories once nothing else is left in it. On RPM, opensearch-security/ and the product directory go as well: rpm could not remove them while the .rpmsave file and the engine’s runtime files that %postun deletes were still in them.
  • /tmp/hsperfdata_wazuh-indexer, when the account owns it. A Java Virtual Machine (JVM) keeps its performance data there, and a node the SysV script starts runs outside the unit’s private /tmp. The directory is empty once the JVM exits, but the next account given the freed ID would own it.
  • export/, ca/ and the completion marker: what the package published for the other components, its CA, and the record that it resolved its credentials.
  • opensearch.keystore, when it is still exactly as the package created it. postinst / %post record its digest in .wazuh-indexer-keystore at creation; a keystore the operator added secrets to has another digest and is kept, with the marker.
  • The certificates it issued from that CA. Without it they are an identity nothing trusts, and a reinstall would find them beside a new one. Only a CA with its key in ca/ issued anything on this host, and each pair is checked against it with openssl verify first, so a pair it did not issue — the operator’s own — stays, and a reinstall with nothing to verify it against reports it rather than minting a CA beside it.

What the operator sees is described in Uninstall.

A directory the package itself creates outside those four must be added to the purge’s list, in the DEB postrm and in the spec’s %postun, or its files are left with an orphaned owner. build-scripts/ci/test_purge.sh asserts that no file with an orphaned owner is left in the four directories, that only a directory still holding files is reported as kept, that the engine’s runtime files and the JVM’s performance data do not outlive the purge, that a reinstall restores every owner and mode, that the node’s certificates chain to its CA afterwards, and that a custom path.repo is left exactly as it was.

Host dependencies

Both packages declare these (Depends: in debian/control, Requires: in the spec, where AutoReqProv is off so nothing is inferred). The list is exactly the set of host tools the maintainer scripts, the resolver and the scripts they start run: everything else they run is a shell builtin or coreutils, which rpm itself requires and which is Essential on Debian, plus getent (glibc), systemctl, and restorecon when the host has it.

CommandProvided by (yum / apt)Used for
opensslopensslMinting the CA and issuing this node’s certificate pair; the bundled JDK has no tool that issues PEM certificates
ipiproute / iproute2Deriving the certificate’s Subject Alternative Names from the default-route interfaces
groupadd, useradd, userdel, groupdelshadow-utils / passwdCreating the service account at install and removing it at purge. The RPM requires the four by path, in %pre and %postun; passwd is required priority on Debian but not Essential, so the DEB declares it

coreutils is not listed: rpm requires it, and it is Essential on Debian. The DEB maintainer scripts may also use grep, sed and find, which are Essential on Debian; the RPM scriptlets and every shipped script may not, so the privilege drop in the SysV script uses chroot --userspec (coreutils) rather than runuser (util-linux), process checks read /proc rather than calling ps or pgrep (procps), the host name comes from /proc/sys/kernel/hostname rather than hostname, and the text handling of the resolver and the init scripts is done in shell rather than with awk, sed, grep or cut. build-scripts/ci/test_host_tools.sh scans the scripts of a built package for anything outside this closure and fails the build on a hit.

A minimal or container base image frequently carries neither openssl nor ip, and the failures are quiet and easy to misread: a missing openssl leaves the node with no certificates, and a missing ip leaves the certificate without the host’s addresses. Both are declared so that the package manager installs them.

Wazuh Indexer Setup plugin — development guide

This document describes how to extend the Wazuh Indexer setup plugin to create new index templates and index management policies (ISM) for OpenSearch. See Architecture for the conceptual overview.


Class diagram

---
title: Wazuh Indexer setup plugin
---
classDiagram
    %% Classes
    class IndexInitializer
    <<interface>> IndexInitializer
    class Index
    <<abstract>> Index
    class IndexStateManagement
    class WazuhIndex
    <<abstract>> WazuhIndex
    class StateIndex
    class StreamIndex
    class ContentIndex

    %% Relations
    IndexInitializer <|-- Index : implements
    Index <|-- IndexStateManagement
    Index <|-- WazuhIndex
    WazuhIndex <|-- StateIndex
    WazuhIndex <|-- StreamIndex
    WazuhIndex <|-- ContentIndex

    %% Schemas
    class IndexInitializer {
        +createIndex(String index) void
        +createTemplate(String template) void
    }
    class Index {
        Client client
        ClusterService clusterService
        IndexUtils utils
        String index
        String template
        +Index(String index, String template)
        +setClient(Client client) IndexInitializer
        +setClusterService(ClusterService clusterService) IndexInitializer
        +setIndexUtils(IndexUtils utils) IndexInitializer
        +indexExists(String indexName) bool
        +initialize() void
        +createIndex(String index) void
        +createTemplate(String template) void
    }
    class IndexStateManagement {
        -List~String~ policies
        +initialize() void
        -createPolicies() void
        -indexPolicy(String policy) void
    }
    class WazuhIndex {
    }
    class StreamIndex {
        -String alias
        +StreamIndex(String index, String template, String alias)
        +createIndex(String index)
    }
    class StateIndex {
    }
    class ContentIndex {
        +String SUFFIX_A
        +ContentIndex(String index, String template)
        +getPhysicalName() String
        +createIndex(String index)
        +createTemplate(String template)
        -deleteSquatterIndex(String alias) void
        -ensureAlias(String alias, String physicalName) void
    }

The SetupPlugin class holds the list of indices to create. The logic for the creation of the index templates and the indices is encapsulated in the Index abstract class. Each subclass can override this logic if necessary. The SetupPlugin::onNodeStarted() method invokes the Index::initialize() method, effectively creating every index in the list. The plugin implements the ClusterPlugin interface to hook into this method.

Sequence diagram

Note Calls to Client are asynchronous.

sequenceDiagram
    actor Node
    participant SetupPlugin
    participant Index
    participant Client
    Node->>SetupPlugin: plugin.onNodeStarted()
    activate SetupPlugin
    Note over Node,SetupPlugin: Invoked on Node::start()

    activate Index
    loop i..n indices
        SetupPlugin->>Index: i.initialize()


        Index-)Client: createTemplate(i)
        Client--)Index: response

        Index-)Client: indexExists(i)
        Client--)Index: response
        alt index i does not exist
            Index-)Client: createIndex(i)
            Client--)Index: response
        end
    end

    deactivate Index
    deactivate SetupPlugin

JavaDoc

The plugin is documented using JavaDoc. You can compile the documentation using the Gradle task for that purpose. The generated JavaDoc is in the build/docs folder.

./gradlew javadoc

Creating a new index

1. Add a new index template

Create a new JSON file in the directory: /plugins/setup/src/main/resources

Follow the existing structure and naming convention. Example:

{
  "index_patterns": ["<pattern>"],
  "mappings": {
    "date_detection": false,
    "dynamic": "strict",
    "properties": {
      <custom mappings and fields>
    }
  },
  "order": 1,
  "settings": {
    "index": {
      "number_of_shards": 1,
      "number_of_replicas": 0
    }
  }
}

2. Register the index in the code

Edit the constructor of the SetupPlugin class located at: /plugins/setup/src/main/java/com/wazuh/setup/SetupPlugin.java

Add the template and index entry to the indices map. There are three kinds of indices:

  • Stream index. Stream indices contain time-based events of any kind (alerts, statistics, logs…). These are created as Data Streams.
  • Stateful index. Stateful indices represent the most recent information of a subject (active vulnerabilities, installed packages, open ports, …). These indices are different from Stream indices as they do not contain timestamps. The information is not based on time, as they always represent the most recent state.
  • Content index. Content indices hold the CTI catalog content the Content Manager downloads. They are not addressed by their own name: see Threat intel content indices below.
/**
* Main class of the Indexer Setup plugin. This plugin is responsible for the creation of the index
* templates and indices required by Wazuh to work properly.
*/
public class SetupPlugin extends Plugin implements ClusterPlugin {

  // ...

  // Stream indices
  this.indices.add(new StreamIndex("my-stream-index", "templates/streams/my-index-template-1"));
  // State indices
  this.indices.add(new StateIndex("my-state-index", "templates/states/my-index-template-2"));
  // Content indices
  this.indices.add(new ContentIndex("my-content-index", "templates/content/my-index-template-3"));

  //...
}

Verifying template and index creation After building the plugin and deploying the Wazuh Indexer with it, you can verify the index templates and indices using the following commands:

curl -X GET <indexer-IP>:9200/_index_template/
curl -X GET <indexer-IP>:9200/_cat/indices?v

Alternatively, use the Developer Tools console from the Wazuh Dashboard, or your browser.

Creating a new ISM (Index State Management) policy

1. Add rollover alias and policy ID to the index template

Edit the existing index template JSON file and add the following settings:

"plugins.index_state_management.rollover_alias": "<index-name>",
"plugins.index_state_management.policy_id": "<index-name>-policy"

2. Define the ISM policy

Refer to the OpenSearch ISM Policies documentation for more details.

Here is an example ISM policy:

{
  "policy": {
    "policy_id": "<index-name>-policy",
    "description": "<policy-description>",
    "last_updated_time": <unix-timestamp-in-milliseconds>,
    "schema_version": 1,
    "default_state": "hot",
    "states": [
      {
        "name": "hot",
        "actions": [
          {
            "retry": {
              "count": 3,
              "backoff": "exponential",
              "delay": "1m"
            },
            "rollover": {
              "min_doc_count": 200000000,
              "min_primary_shard_size": "20gb"
            }
          }
        ],
        "transitions": [
          {
            "state_name": "delete",
            "conditions": {
              "min_index_age": "<retention-time>"
            }
          }
        ]
      },
      {
        "name": "delete",
        "actions": [
          {
            "retry": {
              "count": 3,
              "backoff": "exponential",
              "delay": "1m"
            },
            "delete": {}
          }
        ],
        "transitions": []
      }
    ],
    "ism_template": [
      {
        "index_patterns": [
          "wazuh-<pattern>-*"
        ],
        "priority": <priority-int>
      }
    ]
  }
}

3. Register the ISM policy in the plugin code

Edit the IndexStateManagement class located at: /plugins/setup/src/main/java/com/wazuh/setup/index/IndexStateManagement.java

Register the new policy constant and add it in the constructor:

// ISM policy name constant (filename without .json extension)
static final String MY_POLICY = "my-policy-filename";

...

/**
 * Constructor
 *
 * @param index    Index name
 * @param template Index template name
 */
public IndexStateManagement(String index, String template) {
    super(index, template);
    this.policies = new ArrayList<>();

    // Register the ISM policy to be created
    this.policies.add(MY_POLICY);
}

Additional notes

Always follow existing naming conventions to maintain consistency.

Use epoch timestamps (in milliseconds) for last_updated_time fields. Update the policy’s one whenever you change the policy, and keep it in the past: IndexStateManagement.indexPolicy() copies it into every ism_template entry before indexing the policy, and ISM applies a template only to indices created after that timestamp. A template without it is stamped with the current time on every read, so it matches no index at all.

ISM policies and templates must be properly deployed before the indices are created.

IndexStateManagement.initialize() runs on every cluster-manager election, and indexPolicy() writes each policy with a plain IndexRequest keyed by policy id, so the six shipped policies are overwritten on every restart. Any edit an operator makes to one of them through the ISM API is reverted on the next boot. This is why Retention tells operators to create a policy under their own id rather than editing a shipped one.


Event stream templates

Overview

Event and findings data streams are both category-based: 8 event categories share a single base template (templates/streams/events.json), and the 8 corresponding findings categories share their own base template (templates/streams/findings.json). At deployment time, StreamIndex.createTemplate() generates one index template per category from the applicable base, overriding exactly two fields:

  • index_patterns — always set to <category-index-name>*.
  • settings["plugins.index_state_management.rollover_alias"] — set to the category’s index name, but only if that key already exists in the base template’s settings.

Every other part of the generated template — most importantly mappings — is copied through unchanged. This means all category templates within a stream family (all 8 event categories, or all 8 findings categories) have identical mappings; only the index pattern and rollover alias differ.

  • Source of truth: Only events.json and findings.json exist in the repository; no per-category template files exist.
  • At runtime: One index template is created for each category (e.g., wazuh-events-v5-cloud-services-template, wazuh-events-v5-security-template, etc.), and likewise for each findings category.

The StreamIndex class handles this: when constructed with only an index name (no explicit template path), it defaults to templates/streams/events and rewrites index_patterns and (conditionally) rollover_alias to match the specific index. Findings categories are registered the same way but with the two-arg constructor pointing at templates/streams/findings.

How it works

// Single-arg constructor defaults to the shared events template
new StreamIndex("wazuh-events-v5-cloud-services");
// Equivalent to:
new StreamIndex("wazuh-events-v5-cloud-services", "templates/streams/events")

During createTemplate(), the plugin:

  1. Reads events.json from the classpath
  2. Overrides index_patterns to ["wazuh-events-v5-cloud-services*"]
  3. If rollover_alias is present in the base template’s settings, overrides it to "wazuh-events-v5-cloud-services"
  4. Creates the composable index template in OpenSearch, with mappings copied through unchanged

Verifying deployed templates

To list all event templates in a running cluster:

GET /_index_template/wazuh-events-*

Likewise, to list all findings templates:

GET /_index_template/wazuh-findings-*

Specialized stream templates

Some data streams use their own dedicated templates instead of the shared events.json:

Data StreamTemplateNotes
wazuh-events-raw-v5templates/streams/raw.jsonStores original unprocessed events
wazuh-active-responsestemplates/streams/active-responses.jsonActive Response execution requests
wazuh-ai-assistant-sessionstemplates/streams/ai-assistant-sessions.jsonAI assistant conversation history

These are registered with the two-arg constructor:

new StreamIndex("wazuh-events-raw-v5", "templates/streams/raw");
new StreamIndex("wazuh-active-responses", "templates/streams/active-responses");
new StreamIndex("wazuh-ai-assistant-sessions", "templates/streams/ai-assistant-sessions");

Events data stream ISM policy (stream-events-policy)

Overview

The stream-events-policy manages all wazuh-events-v5-* data streams. It combines rollover (based on shard size or document count) with deletion of rolled-over indices once they reach a minimum age. See Retention for the effective retention this yields.

Policy details

  • Policy Name: stream-events-policy
  • Location: plugins/setup/src/main/resources/policies/stream-events-policy.json
  • Index Pattern: wazuh-events-v5-*
  • Deletion age (floor): 1 hour
  • Rollover Conditions: 20 GB primary shard size or 200,000,000 documents
  • ISM template priority: 0

Policy states

  1. Hot State

    • Actions: Rollover when primary shard reaches 20 GB or 200M documents
    • Transition Condition: Transitions to delete once the index reaches an age of 1 hour
  2. Delete State

    • Actions: Deletes the index
    • Retry Policy: 3 attempts with exponential backoff (1-minute initial delay)

Findings data stream ISM policy (stream-findings-policy)

Overview

The stream-findings-policy manages all wazuh-findings-v5-* data streams. It combines rollover with deletion of rolled-over indices once they reach 90 days of age, to maintain detection findings for compliance and investigation purposes. See Retention.

Policy details

  • Policy Name: stream-findings-policy
  • Location: plugins/setup/src/main/resources/policies/stream-findings-policy.json
  • Index Pattern: wazuh-findings-v5-*
  • Deletion age (floor): 90 days
  • Rollover Conditions: 20 GB primary shard size or 200,000,000 documents
  • ISM template priority: 0

Policy states

  1. Hot State

    • Actions: Rollover when primary shard reaches 20 GB or 200M documents
    • Transition Condition: Transitions to delete once the index reaches an age of 90 days
  2. Delete State

    • Actions: Deletes the index
    • Retry Policy: 3 attempts with exponential backoff (1-minute initial delay)

Raw events data stream ISM policy (stream-raw-events-policy)

Overview

The stream-raw-events-policy manages the wazuh-events-raw-v5 data stream. Rolled-over indices are deleted once they reach 10 minutes of age. Note that the write index only rolls over on volume, so raw events are not bounded in time — see Retention.

Policy details

  • Policy Name: stream-raw-events-policy
  • Location: plugins/setup/src/main/resources/policies/stream-raw-events-policy.json
  • Index Pattern: wazuh-events-raw-v5*
  • Deletion age (floor): 10 minutes
  • Rollover Conditions: 20 GB primary shard size or 200,000,000 documents
  • ISM template priority: 0

Policy states

  1. Hot State

    • Actions: Rollover when primary shard reaches 20 GB or 200M documents
    • Transition Condition: Transitions to delete once the index reaches an age of 10 minutes
  2. Delete State

    • Actions: Deletes the index
    • Retry Policy: 3 attempts with exponential backoff (1-minute initial delay)

Active responses data stream (wazuh-active-responses)

Overview

The wazuh-active-responses data stream stores Active Response execution requests generated when monitor triggers match their conditions. This is part of the Active Response 5.0 integration with Wazuh XDR, using the Indexer Alerting and Notifications plugins as the foundation.

Purpose

  • Active Response Pipeline: Structured and auditable execution pipeline for Active Response actions
  • Manager Retrieval: The Wazuh manager retrieves documents from this index to distribute and execute Active Responses on agents
  • Event Correlation: Each document references the source event (document ID and index) that triggered the response

Data stream configuration

Index template

  • Location: plugins/setup/src/main/resources/templates/streams/active-responses.json
  • Index Pattern: wazuh-active-responses*
  • Rollover Alias: wazuh-active-responses
  • Priority: 1

Fields included (WCS-compatible)

  • @timestamp: When the document was inserted into the wazuh-active-responses index (indexing time)
  • event.doc_id: Document ID of the matched alert that triggered the active response
  • event.index: Source index of the matched alert
  • wazuh.active_response.name: Name of the active response configured in the channel
  • wazuh.active_response.executable: Executable configured in the active response channel
  • wazuh.active_response.extra_arguments: Arguments configured in the channel
  • wazuh.active_response.location: Where to execute (local, defined-agent, all)
  • wazuh.active_response.agent_id: Agent configured in the channel
  • wazuh.active_response.type: Response type (stateless, stateful)
  • wazuh.active_response.stateful_timeout: Seconds configured in the channel (for stateful)
  • wazuh.agent.*: Agent metadata
  • wazuh.cluster.*: Cluster information
  • wazuh.space.name: Wazuh space/tenant information

ISM policy

Policy details

  • Policy Name: stream-active-responses-policy
  • Location: plugins/setup/src/main/resources/policies/stream-active-responses-policy.json
  • Deletion age (floor): 3 days
  • Rollover Conditions: 20 GB primary shard size or 200,000,000 documents
  • ISM template priority: 0

Configuration

The data stream is created automatically during plugin initialization. Ensure:

  1. The template file active-responses.json exists in templates/streams/
  2. The ISM policy file stream-active-responses-policy.json exists in policies/
  3. Both are registered in SetupPlugin.java and IndexStateManagement.java

Testing

Integration tests for the active responses data stream are located at: plugins/setup/src/test/java/com/wazuh/setup/ActiveResponsesIT.java


Metrics data stream ISM policy (stream-metrics-policy)

Overview

The stream-metrics-policy manages all wazuh-metrics-* data streams (wazuh-metrics-agents, wazuh-metrics-comms-v4, wazuh-metrics-normalization). Rolled-over indices are deleted once they reach 30 days of age. See Retention.

Policy details

  • Policy Name: stream-metrics-policy
  • Location: plugins/setup/src/main/resources/policies/stream-metrics-policy.json
  • Index Pattern: wazuh-metrics-*
  • Deletion age (floor): 30 days
  • Rollover Conditions: 20 GB primary shard size or 200,000,000 documents
  • ISM template priority: 0

Policy states

  1. Hot State

    • Actions: Rollover when primary shard reaches 20 GB or 200M documents
    • Transition Condition: Transitions to delete once the index reaches an age of 30 days
  2. Delete State

    • Actions: Deletes the index
    • Retry Policy: 3 attempts with exponential backoff (1-minute initial delay)

Threat intel content indices

Overview

The wazuh-threatintel-* indices hold the CTI catalog content the Content Manager downloads: IoCs, CVEs and the Engine ruleset. The setup plugin provisions all of them.

AliasConcrete indexTemplate
wazuh-threatintel-enrichmentswazuh-threatintel-enrichments-atemplates/content/ioc.json
wazuh-threatintel-filterswazuh-threatintel-filters-atemplates/content/filters.json
wazuh-threatintel-decoderswazuh-threatintel-decoders-atemplates/content/decoders.json
wazuh-threatintel-ruleswazuh-threatintel-rules-atemplates/content/rules.json
wazuh-threatintel-kvdbswazuh-threatintel-kvdbs-atemplates/content/kvdbs.json
wazuh-threatintel-integrationswazuh-threatintel-integrations-atemplates/content/integrations.json
wazuh-threatintel-policieswazuh-threatintel-policies-atemplates/content/policies.json
.wazuh-threatintel-vulnerabilities.wazuh-threatintel-vulnerabilities-atemplates/content/vulnerabilities.json

Why the alias

A content index is never addressed by its own name. ContentIndex creates the concrete index as <name>-a and points a write alias named <name> at it. The Content Manager needs that indirection: when the content source changes (a plan upgrade or downgrade), it rebuilds the whole catalog into a hidden <name>-b shadow index and swaps the alias in a single atomic action, so readers never see a partially populated index. The two slots alternate on each swap.

ContentIndex.createTemplate() therefore rewrites the template’s index_patterns to <name>*, whatever the shipped JSON declares. That covers the live slot, the shadow slot and — as a last line of defence — the alias name itself.

Who creates them

This plugin does, and only this plugin. The Content Manager no longer provisions these indices: it waits for the .wazuh-setup-status readiness marker, and if setup has not marked itself ready it aborts its own initialization instead of creating anything. On a deployment without this plugin the threat-intel indices are therefore never created and the custom-ruleset endpoints do not work. Previously the Content Manager created them from its own copies, and without that wait it won the race by a wide margin — it created six of the eight about 16 seconds before this plugin reached its threat-intel block, so they were created before their index templates were installed and did not pick them up. That race is the bug this change removes.

Downloading CTI content requires two things to be true, and the Content Manager checks both before any write reaches these indices:

  1. .wazuh-setup-status reports status: ready, which this plugin writes only after every index it owns exists.
  2. The target indices of that consumer exist.

If either check fails the consumer skips its pass and logs at ERROR; it creates nothing. That is what keeps a mis-provisioned index from becoming a squatted one: a write through the alias name would otherwise auto-create a concrete index there with dynamic mappings. See issues #1476 and #1481, where the second of those took out vulnerability detection and package inventory because the global-map documents could not be indexed into an inferred mapping.

To confirm on a running cluster, read the templates field of the creation entries in the log:

grep 'creating index' /var/log/wazuh-indexer/wazuh-cluster.log | grep threatintel

Every wazuh-threatintel-*-a line must name its own <name>-template. An empty templates [] means something created the index before the template existed.

Recovering an index that squats the alias name

If a write reaches the alias name before anything creates the alias, OpenSearch auto-creates a concrete index called <name> with dynamic mappings. Every keyword the schema declares becomes a text field, which has no doc_values and so cannot be aggregated or sorted on, and the index occupies the name the alias needs. ContentIndex.createIndex() deletes such an index before creating <name>-a. The Content Manager then finds the index empty with a non-zero local offset and resets the offset, so the content is downloaded again on the same synchronization pass.

Where the mappings live

The mappings exist in two places on purpose. This plugin ships them as index templates under plugins/setup/src/main/resources/templates/content/, and the Content Manager keeps its own copies under plugins/content-manager/src/main/resources/mappings/cti-*-mappings.json, which it needs to create the <name>-b shadow indices during a blue/green content swap, without setup intervention. The verifyContentTemplates Gradle task, wired into check, fails the build if the two sets diverge.

All eight templates/content/*.json are generated by the WCS generator from the modules under wcs/content/, and the Content Manager copies are derived from them by sync_content_mappings.py; editing either set by hand is overwritten on the next generator run. templates/cve.json is not the CVE content template: it is a WCS artifact for the unrelated wazuh-cve* pattern and is intentionally not registered.

Testing

./gradlew :wazuh-indexer-setup:integTest --tests '*ThreatIntelIndicesIT*'

Against a deployed cluster, the alias must resolve to the -a index and its fields must be aggregatable:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD 'https://localhost:9200/_alias/wazuh-threatintel-*'
curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD \
  'https://localhost:9200/wazuh-threatintel-enrichments/_field_caps?fields=document.type'

The indices array in the _field_caps response names what the alias resolved to. It must read ["wazuh-threatintel-enrichments-a"] with "aggregatable": true.


AI assistant indices

Overview

The AI assistant stores its conversation history in the wazuh-ai-assistant-sessions data stream (a StreamIndex). Its providers configuration, assistant-wide settings and field policy live in the hidden .wazuh-internal-state index reached only through the setup plugin’s administrative AI assistant API, described below. Both indices use strict mappings.

Sessions data stream (wazuh-ai-assistant-sessions)

Index template

  • Location: plugins/setup/src/main/resources/templates/streams/ai-assistant-sessions.json
  • Index Pattern: wazuh-ai-assistant-sessions*
  • Rollover Alias: wazuh-ai-assistant-sessions
  • Priority: 1

Fields

  • @timestamp: Indexing time of the document (data stream timestamp field, required)
  • user: Username the conversation belongs to. Used as the Document Level Security discriminator
  • title: Conversation title, usually the first user message
  • created_at / updated_at: Conversation creation and last update times
  • messages: The conversation turns (e.g. created_at, role, content, plus whatever else a given AI provider returns). Mapped as {"type": "object", "enabled": false}: stored in _source and returned as-is on _search/_get, but not parsed into the mapping at all — no sub-fields, no indexing, no strict-mapping enforcement inside it. This is deliberate: the shape of a message varies by provider, so there is no fixed schema to declare, and the assistant never queries into messages — it only reads whole documents back to reconstruct a conversation in the UI.

Access control

Access is granted by the wazuh_ai_assistant role, defined in the wazuh-indexer repository and mapped to every authenticated user. Reads are filtered with DLS parameter substitution ({"term": {"user": "${user.name}"}}), so a user only retrieves their own conversations. The restriction also applies to users holding a role that grants read on the * index pattern.

Reads and writes are scoped differently on purpose. DLS is a read-path filter and cannot scope a write: no role syntax can express “this document’s user must equal your name”. An index-level write grant on this data stream would therefore be unscoped, and every authenticated user holds this role.

So the role grants no index-level write. Sessions are still read directly against the data stream, under the per-owner DLS, but every write goes through the session API below, gated by the plugin:wazuh/ai_assistant/session/write cluster permission, which derives user from the authenticated principal. That is also what the read filter depends on: user is only meaningful to filter on because the server, not the client, decides it.

Session write API

EndpointMethodCluster permissionBacked by
/_plugins/_setup/ai_assistant/sessionsPOSTplugin:wazuh/ai_assistant/session/writePutAiAssistantSessionAction / TransportPutAiAssistantSessionAction, Operation.CREATE
/_plugins/_setup/ai_assistant/sessions/{id}PUTsamesame action, Operation.UPDATE
/_plugins/_setup/ai_assistant/sessions/{id}PATCHsamesame action, Operation.RENAME
/_plugins/_setup/ai_assistant/sessions/{id}DELETEsamesame action, Operation.DELETE

There is deliberately no read endpoint and no session/read permission. Listing sessions and reading a transcript are plain DLS-scoped searches, and OpenSearch already provides search, sorting, pagination, track_total_hits and source filtering; putting an endpoint in front of that would be reimplementing the search API for nothing. The consequence to accept is that the index mapping stays part of the Dashboard’s contract.

TransportPutAiAssistantSessionAction resolves the caller from the _opendistro_security_user_info thread-context transient (com.wazuh.setup.utils.AuthenticatedUser) before stashing the context, stamps it onto user, and only then calls AiAssistantSessionsIndex with the plugin’s own privileges. Every lookup behind PUT/PATCH/DELETE filters on that same resolved owner, so a session belonging to somebody else simply is not found — reported as 404, never 403, since a 403 would confirm that another user’s session id exists. There is no impersonation and no administrative override, not even for all_access, which preserves the DLS behaviour that already applied to admin.

Three data-stream mechanics shape the implementation, all of them platform constraints rather than choices:

  • A get-by-id is unusable. The backing index rolls over daily and the get API targets exactly one concrete index. findHit searches the stream instead, which fans out across every backing index and reports the one holding the document (hit.getIndex()), together with the seq_no/primary_term pair the following write needs — one round trip for all three facts.
  • Update and delete must target the backing index, never the stream name, which accepts appends only. And they must be a full index replace: the partial _update API is refused outright wherever DLS applies to the role (security_exception: Update is not supported when FLS or DLS or Fieldmasking is activated).
  • Optimistic concurrency is mandatory. A backing index rejects an unconditional write (illegal_argument_exception: index request with op_type=index and no if_primary_term and if_seq_no set targeting backing indices is disallowed), so every write carries a pair — the caller’s expected_version when it sent a decodable one, otherwise the pair the request just read. The version the API returns is that pair encoded as "<seq_no>:<primary_term>", opaque to clients. A genuine mismatch is a 409, never retried: retrying with a freshly read pair is exactly the silent overwrite expected_version exists to prevent.

Request bodies are capped at 5 MiB on every write route, checked against the raw payload before parsing so an oversized body never becomes a map in heap. MAX_MESSAGES (1000) bounds the number of turns but not their size, and the fallback ceiling would otherwise be http.max_content_length (100MB), which would leave the 500-session per-owner cap meaningless as a storage bound.

Two smaller rules worth knowing: PATCH deliberately does not re-stamp updated_at (a rename is not session activity, and bumping it would reorder a list sorted by last activity), and PUT’s title is optional (a chat client auto-saves every turn, and resending a recomputed title would silently revert a rename the user had just made). Every write uses RefreshPolicy.WAIT_UNTIL, because every read here is a search and a search only sees a write after the shard refreshes.

With the security plugin absent — the integTest cluster — there is no transient and the owner resolves to the _shared sentinel, treated as a real owner rather than as a bypass, so the tests exercise the whole stamp-and-scope path. See AiAssistantSessionsIT and TransportPutAiAssistantSessionActionTests.

Settings, field policy and providers (.wazuh-internal-state)

.wazuh-internal-state is created and owned by the Content Manager plugin (CredentialsIndex), not by setup — setup only reads and writes it through the administrative AI assistant API described below. Its mapping is generated by the wcs/internal-state WCS module and lands at plugins/content-manager/src/main/resources/mappings/internal-state-mapping.json; it is dynamic: strict.

The index holds several kinds of documents under one mapping, avoiding a separate index for what would otherwise be a handful of settings fields.

  • One document per configured AI provider, id an arbitrary UUID: name, type, base_url, model, api_key, is_default, updated_at. listProviders() caps the result at AiAssistantSettingsAdminIndex.MAX_PROVIDERS = 500.
  • A single reserved-id document (id "wazuh-ai-assistant-settings") holding the assistant-wide settings and the field anonymization policy, under field_policy. Its privacy_default_per_provider is keyed by provider document id, so it is mapped as flat_object: the keys are UUIDs minted at runtime and a plain object would reject every one of them under this index’s dynamic: strict mapping.
  • A single reserved-id document ("credentials"), owned entirely by Content Manager. The administrative AI assistant API never reads or returns this document.

Example documents:

// Provider document
{ "name": "test-ai", "type": "anthropic", "base_url": "https://api.anthropic.com", "model": "claude-opus-4-6", "api_key": "enc:v1:SC/RyOIBkdm+kGl", "is_default": true, "updated_at": "2026-08-03T09:54:52.193Z" }

// Settings + field policy document (reserved id "wazuh-ai-assistant-settings")
{
  "privacy_default_on": false,
  "privacy_default_per_provider": { "3f6b5f7e-6f2d-4a4f-9c1e-2f6d0a1b2c3d": true },
  "user_can_override": true,
  "field_policy": [
    { "field": "wazuh.agent.name", "action": "anonymize", "kind": "HOST" },
    { "field": "wazuh.agent.host.ip", "action": "anonymize", "kind": "IP" },
    { "field": "wazuh.agent.id", "action": "allow" }
  ]
}

Administrative AI assistant API

.wazuh-internal-state is registered as an OpenSearch Security system index, so no role’s index permissions can reach its documents. Every query to this index goes through the administrative API below:

| Endpoint | Method | Cluster permission | Backed by |

EndpointMethodCluster permissionBacked by
/_plugins/_setup/ai_assistant/settingsGETplugin:wazuh/ai_assistant/settings/readGetAiAssistantSettingsAction / TransportGetAiAssistantSettingsAction, Operation.SETTINGS
/_plugins/_setup/ai_assistant/settingsPUTplugin:wazuh/ai_assistant/settings/writePutAiAssistantSettingsAction / TransportPutAiAssistantSettingsAction, Operation.SETTINGS
/_plugins/_setup/ai_assistant/providersGETplugin:wazuh/ai_assistant/settings/readsame Get* action, Operation.LIST_PROVIDERS — AiAssistantSettingsAdminIndex.listProviders()
/_plugins/_setup/ai_assistant/providersPOSTplugin:wazuh/ai_assistant/settings/writesame Put* action, Operation.PUT_PROVIDER; body must carry the UUID id to create with
/_plugins/_setup/ai_assistant/providers/{id}PUT, DELETEplugin:wazuh/ai_assistant/settings/writesame, Operation.PUT_PROVIDER / Operation.DELETE_PROVIDER

GET /ai_assistant/settings returns the settings document’s source as-is, with its fields flat at the root. Providers are a separate resource, listed via GET /ai_assistant/providers, which excludes the two reserved document ids ("wazuh-ai-assistant-settings" and "credentials").

PUT /ai_assistant/settings always replaces the whole document: the caller sends the complete set of settings fields and the complete field_policy array on every write, not a partial diff

POST /ai_assistant/providers’s body must include an id field, since other integrations depend on the document ending up with the exact id they sent: it must be a UUID, rejected with 400 when missing or malformed

DELETE /ai_assistant/providers/{id} returns 404 when no provider exists with that id, including on a repeated delete.

PUT/DELETE /ai_assistant/providers/{id} both reject the reserved document ids (wazuh-ai-assistant-settings, credentials) with 400, checked in TransportPutAiAssistantSettingsAction before either operation reaches the index.

GET /ai_assistant/providers returns {"providers": [{..., "_id": "..."}]}, each entry the provider document’s source flattened with its _id, assembled by AiAssistantSettingsAdminIndex.listProviders().

ISM policy (ai-assistant-sessions-policy)

Policy details

  • Policy Name: ai-assistant-sessions-policy
  • Location: plugins/setup/src/main/resources/policies/ai-assistant-sessions-policy.json
  • Index Patterns: .ds-wazuh-ai-assistant-sessions-*, wazuh-ai-assistant-sessions*
  • Deletion age (floor): 7 days
  • Rollover Conditions: index age of 1 day (daily rotation), or 20 GB primary shard size / 200,000,000 documents, whichever comes first
  • ISM template priority: 0

Policy states

  1. Hot State

    • Actions: Rollover when the index is 1 day old, or reaches 20 GB / 200M documents
    • Transition Condition: Transitions to delete once the index reaches an age of 7 days
  2. Delete State

    • Actions: Deletes the index
    • Retry Policy: 3 attempts with exponential backoff (1-minute initial delay)

Testing

Integration tests for the AI assistant indices are located at: plugins/setup/src/test/java/com/wazuh/setup/AIAssistantIndicesIT.java

Integration tests for the administrative AI assistant API are located at: plugins/setup/src/test/java/com/wazuh/setup/AiAssistantSettingsAdminIT.java

Defining default users and roles for Wazuh Indexer

The Wazuh Indexer packages include a set of default users and roles specially crafted for Wazuh’s use cases. This guide provides instructions to extend or modify these users and roles so they end up being included in the Wazuh Indexer package by default.

Note that the access control and permissions management are handled by the OpenSearch’s security plugin. As a result, we provide configuration files for it. The data is applied during the cluster’s initialization, as a result of running the indexer-security-init.sh script.

Considerations and conventions

As these configuration files are included in the Wazuh Indexer package, they are hosted in the wazuh-indexer repository. Be aware of that when reading this guide.

Any security related resource (roles, action groups, users, …) created by us must be reserved (reserved: true). This ensures they cannot be modified by the users, in order to guarantee the correct operation of Wazuh Central Components. Also, they should be visible (hidden: false) unless explicitly defined otherwise.

1. Adding a new user

Add the new user to the internal_users.wazuh.yml file located at: wazuh-indexer/distribution/src/config/security/.

new-user:
  hash: "${WAZUH_INDEXER_NEWUSER_PASSWORD}"
  reserved: true
  hidden: false
  backend_roles: []
  description: "New user description"

Do not write a hash. hash carries a ${NAME} placeholder naming the environment variable that will hold the password; a password generated at installation replaces it with a bcrypt digest. A literal hash would ship the same password in every deployment, which is what the placeholder exists to prevent. Add the variable to the resolver’s list of owned accounts — see Credential and TLS resolution.

OpenSearch’s reference:

2. Adding a new role

Add the new role to the roles.wazuh.yml file located at: wazuh-indexer/distribution/src/config/security/.

  • Under index_permissions.index_patterns, list the index patterns the role will have effect on.
  • Under index_permissions.allowed_actions, list the allowed action groups or individual permissions granted to this role.

The default action groups for cluster_permissions and index_permissions are listed in the Default action groups documentation

role-read:
   reserved: true
   hidden: false
   cluster_permissions: []
   index_permissions:
     - index_patterns:
         - "wazuh-*"
       dls: ""
       fls: []
       masked_fields: []
       allowed_actions:
         - "read"
   tenant_permissions: []
   static: true
role-write:
   reserved: true
   hidden: false
   cluster_permissions: []
   index_permissions:
     - index_patterns:
         - "wazuh-*"
       dls: ""
       fls: []
       masked_fields: []
       allowed_actions:
         - "index"
   tenant_permissions: []
   static: true

OpenSearch’s reference:

3. Adding a new role mapping

Add the new role mapping to roles_mapping.wazuh.yml file located at: wazuh-indexer/distribution/src/config/security/. Note that the mapping name must match the role name.

  • Under users, list the users the role will be mapped to.
role-read:
   reserved: true
   hidden: false
   backend_roles: [ ]
   hosts: [ ]
   users:
     - "new-user"
   and_backend_roles: [ ]
role-write:
   reserved: true
   hidden: false
   backend_roles: [ ]
   hosts: [ ]
   users:
     - "new-user"
   and_backend_roles: [ ]

OpenSearch’s reference:

Testing the configuration

The validation of the new configuration needs to be tested on a running deployment of Wazuh Indexer containing the security plugin.

You can follow any of these paths:

A. Generating a new Wazuh Indexer package

  1. Apply your changes to the configuration files in wazuh-indexer/distribution/src/config/security/.
  2. Generate a new package (see Build Packages).
  3. Follow the official installation and configuration steps.
  4. Check the new changes are applied (you can use the UI or the API).

B. Applying the new configuration to an existing Wazuh Indexer deployment (using the UI or API)

  1. Use the Wazuh Indexer API or the Wazuh Dashboard to create a new security resource. Follow the steps in Defining users and roles.

C. Applying the new configuration to an existing Wazuh Indexer deployment (using configuration files)

  1. Add the new configuration to the affected file within /etc/wazuh-indexer/opensearch-security/.
  2. Run the /usr/share/wazuh-indexer/bin/indexer-security-init.sh script to load the new configuration.

The indexer-security-init.sh will overwrite your security configuration, including passwords. Use it under your own risk.

Alternatively, apply the new configuration using fine-grained options. See Applying changes to configuration files

Wazuh Indexer Reporting plugin — development guide

This document describes the Reporting plugin’s structure and REST surface. For setting up a local test environment (Vagrant + Mailpit) to exercise the plugin end to end, see Reporting test environment.

Overview

The wazuh-indexer-reporting plugin is a Wazuh fork of the OpenSearch reports-scheduler plugin. It manages report definitions (what to generate and on what schedule) and report instances (individual generation runs), and integrates with the Job Scheduler plugin for scheduled reports and the Notifications plugin for email delivery.

Plugin structure

The plugin registers as an OpenSearch Plugin, ActionPlugin, SystemIndexPlugin, and JobSchedulerExtension. Report definitions and instances are persisted in two system indices:

IndexPurpose
.opendistro-reports-definitionsStores report definitions (source, trigger schedule, delivery options).
.opendistro-reports-instancesStores individual report generation runs and their status.

REST handlers

All routes are registered under a base URI (with a legacy alias for backwards compatibility) and grouped by concern:

HandlerConcern
ReportDefinitionRestHandlerCreate, update, get, and delete a single report definition.
ReportDefinitionListRestHandlerList/search report definitions.
ReportInstanceRestHandlerGet a report instance and update its status.
ReportInstanceListRestHandlerList/search report instances.
OnDemandReportRestHandlerTrigger on-demand report generation, including in-context report creation.
ReportStatsRestHandlerExpose plugin metrics/counters.

Scheduling

ReportDefinitionJobRunner and ReportDefinitionJobParser integrate with the OpenSearch Job Scheduler plugin to run report definitions on their configured schedule, alongside the on-demand generation path exposed via the REST API.

Security

UserAccessManager and SecurityAccess enforce RBAC on report definitions and instances, consistent with the rest of the Wazuh Indexer’s Security plugin integration.

Notification delivery

Report delivery (e.g., emailing a generated report) goes through the Notifications plugin rather than implementing its own delivery transport.

Reporting test environment

This document describes how to build a local test environment to exercise the Reporting plugin, including email delivery through a local SMTP server.

Working from a minimal environment

To deploy a minimal environment for developing the reporting plugin for testing purposes, you must have at least a Wazuh Indexer and a Wazuh Dashboard environment running. Then, you can create your own SMTP server to test the email notifications from the following Mailpit configuration. To verify everything is working correctly, try generating reports following the user’s guide.

Working from real scenario packages

Preparing packages

  • Wazuh Indexer package (debian package based on OpenSearch 3.1.0). Compiled locally using the Docker builder: bash builder.sh -d deb -a x64.
  • Wazuh Dashboard package (debian package based on OpenSearch 3.1.0). Downloaded from wazuh-dashboard actions.

Note: Artifacts are no longer uploaded to the public wazuh-dashboard actions workflow. You must now use an specific workflow to obtain the S3 links and download the artifacts directly from the S3 bucket.

Note: To test using RPM packages, update the Vagrant configuration and provisioning scripts accordingly (for example, change generic/ubuntu2204 to generic/centos7 in the Vagrantfile and replace Debian-specific installation commands with RPM equivalents).

Preparing a development environment

Prepare a multi-VM Vagrant environment with the following components:

  • Server
    • Wazuh Indexer (including the reporting plugin).
    • Wazuh Dashboard (including the reporting plugin).
  • Mailpit
    • Mailpit SMTP server.

File location should be:

working-dir/
├── Vagrantfile
├── data/
│   ├── wazuh-indexer_*.deb
│   ├── wazuh-dashboard_*.deb
│   ├── gencerts.sh
│   ├── mailpit.sh
│   └── server.sh

Vagrantfile

Details

class VagrantPlugins::ProviderVirtualBox::Action::Network
  def dhcp_server_matches_config?(dhcp_server, config)
    true
  end
end

Vagrant.configure("2") do |config|

    config.vm.define "server" do |server|
      server.vm.box = "generic/ubuntu2204"
      server.vm.provider "virtualbox" do |vb|
        vb.memory = "8192"
      end
      # For Hyper-V provider
      #server.vm.provider "hyperv" do |hv|
      #  hv.memory = 8192
      #end
      server.vm.network "private_network", type: "dhcp"
      server.vm.hostname = "rhel-server"
      config.vm.provision "file", source: "data", destination: "/tmp/vagrant_data"

      server.vm.provision "shell", privileged: true, path: "data/server.sh"
    end

    config.vm.define "mailpit" do |mailpit|
      mailpit.vm.box = "generic/ubuntu2204"
      mailpit.vm.provider "virtualbox" do |vb|
        vb.memory = "1024"
      end
      # For Hyper-V provider
      #client.vm.provider "hyperv" do |hv|
      #  hv.memory = 8192
      #end
      mailpit.vm.network "private_network", type: "dhcp"
      mailpit.vm.hostname = "mailpit"

      config.vm.provision "file", source: "data", destination: "/tmp/vagrant_data"

      mailpit.vm.provision "shell", privileged: true, path: "data/mailpit.sh"
    end

end

server.sh

Details

#!/bin/bash

# Install
dpkg -i /tmp/vagrant_data/wazuh-indexer*.deb
dpkg -i /tmp/vagrant_data/wazuh-dashboard*.deb

# Setup

## Create certs
mkdir certs
cd certs || exit 1
bash /tmp/vagrant_data/gencerts.sh .

mkdir -p /etc/wazuh-indexer/certs
cp admin.pem  /etc/wazuh-indexer/certs/admin.pem
cp admin.key /etc/wazuh-indexer/certs/admin-key.pem
cp indexer.pem  /etc/wazuh-indexer/certs/indexer.pem
cp indexer-key.pem /etc/wazuh-indexer/certs/indexer-key.pem
cp ca.pem /etc/wazuh-indexer/certs/root-ca.pem
chown -R wazuh-indexer.wazuh-indexer /etc/wazuh-indexer/certs/

mkdir -p /etc/wazuh-dashboard/certs
cp dashboard.pem  /etc/wazuh-dashboard/certs/dashboard.pem
cp dashboard-key.pem /etc/wazuh-dashboard/certs/dashboard-key.pem
cp ca.pem /etc/wazuh-dashboard/certs/root-ca.pem
chown -R wazuh-dashboard.wazuh-dashboard /etc/wazuh-dashboard/certs/

systemctl daemon-reload

## set up Indexer
systemctl enable wazuh-indexer
systemctl start wazuh-indexer
/usr/share/wazuh-indexer/bin/indexer-security-init.sh

## set up Dashboard
systemctl enable wazuh-dashboard
systemctl start wazuh-dashboard

## enable IPv6
modprobe ipv6
sysctl -w net.ipv6.conf.all.disable_ipv6=0

## turn off firewalld
sudo ufw disable

mailpit.sh

Details

#!/bin/bash

# Install
curl -sOL https://raw.githubusercontent.com/axllent/mailpit/develop/install.sh && INSTALL_PATH=/usr/bin sudo bash ./install.sh

# Setup
## set up Mailpit
useradd -r -s /bin/false mailpit
groupadd -r mailpit
### Create directories
mkdir -p /var/lib/mailpit
chown -R mailpit.mailpit /var/lib/mailpit

### Create password file
mkdir -p /etc/mailpit
echo "admin:$(openssl passwd -apr1 admin)" > /etc/mailpit/passwords
chown -R mailpit.mailpit /var/lib/mailpit

## Create certs
mkdir certs
cd certs || exit 1
bash /tmp/vagrant_data/gencerts.sh .

mkdir -p /etc/mailpit/certs
cp admin.pem  /etc/mailpit/certs/admin.pem
cp admin.key /etc/mailpit/certs/admin-key.pem
cp mailpit.pem  /etc/mailpit/certs/mailpit.pem
cp mailpit-key.pem /etc/mailpit/certs/mailpit-key.pem
cp ca.pem /etc/mailpit/certs/root-ca.pem
chown -R mailpit.mailpit /etc/mailpit/certs/

## enable IPv6
modprobe ipv6
sysctl -w net.ipv6.conf.all.disable_ipv6=0

## turn off firewalld
sudo ufw disable

echo "======================================================"
echo "Start Mailpit with the following command:"
echo ""
echo "mailpit --listen 0.0.0.0:8025 --smtp 0.0.0.0:1025 --database /var/lib/mailpit.db --ui-auth-file /etc/mailpit/passwords --ui-tls-cert /etc/mailpit/certs/admin.pem --ui-tls-key /etc/mailpit/certs/admin-key.pem --smtp-tls-cert /etc/mailpit/certs/mailpit.pem --smtp-tls-key /etc/mailpit/certs/mailpit-key.pem"
echo "======================================================"

# Adding HTTPS: https://mailpit.axllent.org/docs/configuration/http/
# mailpit --ui-tls-cert /path/to/cert.pem --ui-tls-key /path/to/key.pem

# Adding basic authentication: https://mailpit.axllent.org/docs/configuration/passwords/
# mailpit --ui-auth-file /path/to/password-file

gencerts.sh

Details

#!/bin/bash

if [[ $# -ne 1 ]]; then
fs=$(mktemp -d)
else
fs=$1
shift
fi

echo Working directory $fs
cd $fs

if [[ ! -e $fs/cfssl ]]; then
curl -s -L -o $fs/cfssl https://pkg.cfssl.org/R1.2/cfssl_linux-amd64
curl -s -L -o $fs/cfssljson https://pkg.cfssl.org/R1.2/cfssljson_linux-amd64
chmod 755 $fs/cfssl*
fi

cfssl=$fs/cfssl
cfssljson=$fs/cfssljson

if [[ ! -e $fs/ca.pem ]]; then

cat << EOF | $cfssl gencert -initca - | $cfssljson -bare ca -
{
  "CN": "Wazuh",
  "key": {
    "algo": "rsa",
    "size": 2048
  },
  "names": [
  {
    "C": "US",
    "L": "San Francisco",
    "O": "Wazuh",
    "OU": "Wazuh Root CA"
  }
 ]
}
EOF

fi

if [[ ! -e $fs/ca-config.json ]]; then
$cfssl print-defaults config > ca-config.json
fi

gencert_rsa() {
        name=$1
        profile=$2
cat << EOF | $cfssl gencert -ca=ca.pem -ca-key=ca-key.pem -config=ca-config.json -profile=$profile -hostname="$name,127.0.0.1,localhost" - | $cfssljson -bare $name -
{
  "CN": "$i",
  "key": {
    "algo": "rsa",
    "size": 2048
  },
  "names": [
  {
    "C": "US",
    "L": "California",
    "O": "Wazuh",
    "OU": "Wazuh"
  }
  ],
  "hosts": [
    "$i",
    "localhost"
  ]
}
EOF
openssl pkcs8 -topk8 -inform pem -in $name-key.pem -outform pem -nocrypt -out $name.key
}

gencert_ec() {
    openssl ecparam -name secp256k1 -genkey -noout -out jwt-private.pem
    openssl ec -in jwt-private.pem -pubout -out jwt-public.pem
}

hosts=(indexer dashboard mailpit)
for i in "${hosts[@]}"; do
        gencert_rsa $i www
done

users=(admin)
for i in "${users[@]}"; do
        gencert_rsa $i client
done

gencert_ec

  1. Bring up the environment with vagrant up. Use the command provided in the console to start mailpit from within its VM. mailpit is configured to use TLS and access credentials (admin:admin). Use ip addr to check for the public IP address given to the VM and use that IP to access mailpit UI (e.g: https://172.28.128.136:8025/).
  2. Add the username and password for mailpit to the Wazuh Indexer keystore.
    echo "admin" | /usr/share/wazuh-indexer/bin/opensearch-keystore add opensearch.notifications.core.email.mailpit.username
    echo "admin" | /usr/share/wazuh-indexer/bin/opensearch-keystore add opensearch.notifications.core.email.mailpit.password
    chown root:wazuh-indexer /etc/wazuh-indexer/opensearch.keystore
    chmod 640 /etc/wazuh-indexer/opensearch.keystore
    
  3. Ensure mailpit is accessible within the server VM (e.g curl https://172.28.128.136:8025 -k -u admin:admin should return HTML code). If not, add it to the list of known hosts in /etc/hosts (e.g echo "172.28.128.136 mailpit mailpit" >> /etc/hosts).

Wazuh Indexer Content Manager plugin — development guide

This document describes the architecture, components, and extension points of the Content Manager plugin, which manages threat intelligence content synchronization from the Wazuh CTI API and provides REST endpoints for user-generated content management.


Overview

The Content Manager plugin handles:

  • Wazuh Cloud credentials: stores the CTI access token in .wazuh-internal-state and caches it in PluginSettings.accessToken for REST handler use.
  • Pre-registration with Wazuh Cloud: supports pre-registration of the Wazuh instance with Wazuh Cloud, via environment variable.
  • Job scheduling: periodically checks for updates using the OpenSearch Job Scheduler.
  • Update check service: sends a daily heartbeat to CTI so Wazuh can notify users when a newer version is available.
  • Content synchronization: keeps local indices in sync with the Wazuh CTI Catalog via snapshots and incremental JSON Patch updates.
  • Security Analytics integration: pushes rules, integrations, and detectors to the Security Analytics plugin.
  • User-generated content: full CUD (create, update, delete) for rules, decoders, integrations, KVDBs, and policies in the draft space.
  • Engine communication: validates and promotes content via Unix Domain Socket (UDS) to the Wazuh Engine.
  • Space management: manages content lifecycle through draft → test → custom promotion.

Tuning the development environment

The build.gradlefile defines the development environment for the plugin. There, you can configure and modify the plugin’s behavior by setting custom values for any of the settings exposed by the plugin, or by setting up environment variables, as follows:

testClusters.integTest {
  // JVM tweaks.
  jvmArgs '-Xms2g', '-Xmx2g'

  // Environment variables.
  systemProperty "wazuh.version", "${wazuh_version}-beta3"

  // Plugin settings.
  setting 'plugins.content_manager.catalog.update_on_start', 'true'
}

Pre-registration with Wazuh Cloud

The Content Manager supports pre-registration of the Wazuh instance with Wazuh Cloud using the DEPLOY_KEY environment variable. If this variable is set at startup, the Content Manager automatically registers the token as if it were sent through the REST API, enabling immediate synchronization with the CTI API without manual intervention. Snapshots bundled with the package are removed in favor of fetching the latest content directly from the CTI API using the provided token. This streamlines the setup process for new deployments and ensures that they start with the most up-to-date detection content from their subscription plan.

State diagram

---
title: XDR pre-deploy on Cloud
---
stateDiagram-v2
    state if_state <<choice>>
    env_var_exists: Does env var exist?
    no_state: Unregistered mode
    yes_state: Registered mode
    initialization: Initialization


    [*] --> env_var_exists
    env_var_exists --> if_state
    if_state --> no_state: No
    if_state --> yes_state : yes

    no_state --> initialization : Init from the public catalog, packaged snapshot as fallback
    yes_state --> initialization : Init from active plan
    initialization --> [*]

Sequence diagram

---
title: XDR pre-deploy on Cloud
---
sequenceDiagram
    onNodeStarted->>onNodeStarted: deployKeyExists()
    alt DEPLOY_KEY env var exists
        onNodeStarted->>SubscriptionService: register(deployKey)
        onNodeStarted->>SnapshotService: deleteSnapshots(snapshotsDir)
    end
    onNodeStarted->>CatalogSyncJob: trigger()


System indices

The plugin manages the following indices. The 8 content indices marked “alias-backed” use the alias-backed blue/green storage scheme (see Index alias convention below); .wazuh-cti-consumers, .wazuh-internal-state, and .wazuh-content-manager-jobs are single physical indices, not blue/green’d.

Alias or index namePurposeHiddenAlias-backed
.wazuh-cti-consumersSync state (status, offsets) per consumeryesno
.wazuh-internal-statePersisted CTI access tokenyesno
wazuh-threatintel-policiesPolicy documentsnoyes
wazuh-threatintel-integrationsIntegration definitionsnoyes
wazuh-threatintel-rulesDetection rulesnoyes
wazuh-threatintel-decodersDecoder definitionsnoyes
wazuh-threatintel-kvdbsKey-value databasesnoyes
wazuh-threatintel-filtersEngine filter rulesnoyes
wazuh-threatintel-enrichmentsIndicators of Compromise (IoC)noyes
.wazuh-threatintel-vulnerabilitiesCVE vulnerability datayesyes
.wazuh-content-manager-jobsJob scheduler metadatayesno

This is the authoritative index list for the plugin; the Reference Manual’s System Indices table links here rather than repeating it.


Index alias convention

Each content index uses an alias-backed blue/green storage scheme to enable zero-downtime content replacement during subscription plan changes.

Naming

  • Alias (public name): the stable name used by all readers, REST handlers, and dashboards. Example: wazuh-threatintel-rules.
  • Physical index: the actual index storing data, suffixed with -a or -b. Example: wazuh-threatintel-rules-a.

Only one physical index is live at a time. The alias points to it with is_write_index: true. The other suffix is reserved as the shadow (staging) slot for the next plan-change swap.

Key classes

ClassResponsibility
ContentIndexCreates alias-backed physical indices. Has a 4-arg constructor for targeting shadow physical names directly. createIndex() creates the physical index and assigns the alias. createShadowIndex() creates a hidden physical index without an alias.
IndexSwapHelperStateless utility class for swap operations: resolveShadowName(), resolveLivePhysicalName(), shadowIndexExists(), createShadowIndices(), reindexUserContent(), atomicSwap(), deleteIndices().
SyncPassOne synchronization pass: the subscription plan, looked up once when the pass starts, and what the pass’s swap did to each consumer (UNCHANGED, SWAPPED, PENDING or HELD).
ShadowSwapServiceMoves every consumer whose data source changes, together, through the shadow swap below, before any consumer synchronizes. Also rebuilds a single consumer from a newer snapshot of its own data source (see Error handling).
AbstractConsumerServiceDecides whether its consumer changes data source (resolveContentSource()) and provides the per-consumer pieces of a swap (prepareSwap(), loadStagedSnapshot(), onBeforeAliasSwap()). Its synchronize(SyncPass) never swaps: a consumer the pass’s swap moved catches up from the snapshot offset, one whose change is pending keeps its content.

Shadow swap flow (plan change)

CatalogSyncJob looks the plan up once per pass (SyncPass.resolve()) and runs ShadowSwapService.swapChangedSources() before any consumer synchronizes. Every consumer whose plan-provided resource URL differs from the persisted one, and every consumer left mid-swap by an interrupted pass, moves in one swap:

  1. Pre-flight, all or nothing: each moving consumer needs its target indices and a remote consumer whose snapshot link is available. If any fails, nothing is created or written and the consumers stay on their content (PENDING). The same happens when whether a consumer changes cannot be determined, for example because its document cannot be read: that consumer is HELD and the others wait for it, since it may be changing as well.
  2. Create hidden shadow physical indices for every moving consumer (the -a/-b suffix not currently live; stale ones are deleted first)
  3. Rewrite the documents of all moving consumers in one bulk request: new identity, running, local_offset 0, remote_offset = new head
  4. Download each snapshot into its shadow indices, one consumer after another (SnapshotServiceImpl.forStaging(), which leaves the consumer documents alone)
  5. Reindex user content (space.name != “standard”) from live → shadow (only for consumer types with hasUserContent()=true, i.e., ruleset)
  6. onBeforeAliasSwap() hooks (the ruleset collects its pre-swap Security Analytics resources)
  7. Refresh all shadow indices, so the last bulks of the loads are searchable the moment they go live (a shard that fails to refresh fails the swap, which is then undone like any failure before the switch)
  8. Unhide non-CVE shadow indices, then one atomic alias swap: a single IndicesAliasesRequest for the aliases of every moving consumer (all 8 for a full plan change)
  9. Record the snapshot offsets in the documents of all moving consumers, in one bulk request
  10. Delete the old physical indices

Each consumer’s synchronize(SyncPass) then catches up from its snapshot offset to the remote head and runs the post-sync cascade (onSyncComplete(true): Security Analytics sync, stale resource cleanup, engine reload, IoC notification) before reporting ready.

The order of the writes keeps an interrupted swap recognizable: the shadow indices exist before the documents change (step 2 before 3), local_offset leaves 0 only after the switch (step 8), and a rollback restores the documents before deleting the shadow indices. A document that is running with local_offset 0 while a shadow index of its consumer exists is a swap that did not finish, and the next pass redoes it. A shadow index next to a consumer whose document has content live is a leftover and is deleted.

Failure modes

Failure pointSystem state after failureNext sync behavior
Pre-flight (step 1), or a consumer whose decision failsNothing created or written.The consumers report PENDING (the undecided one HELD): their content is kept, and the pass is retried once right away.
Steps 2–7, before the alias swapDocuments are restored to their previous data source, then shadow indices are deleted; live aliases are untouched.Retries the swap from a clean state against the same target plan, once right away.
Steps 2–7, and the documents cannot be restoredDocuments still name the new data sources (running, local_offset 0); shadow indices are kept.The consumers are HELD in this pass; the next pass recognizes the interrupted swap and redoes it.
Node stops during steps 3–7Documents name the new data sources (running, local_offset 0); live aliases still point at the previous content; shadow indices exist.Recognized as an interrupted swap and redone. Until then, the documents keep showing the swap in progress.
Between step 7 (alias swap) and step 8 (offsets)Aliases point at the new content; documents still read local_offset 0; old indices are kept.Recognized as an interrupted swap and redone — at most one wasted rebuild, no user-visible corruption since readers already see the new content via the alias.
Concurrent REST writes to draft/test/custom during shadow population (steps 2–5)Content written after the reindex snapshot (step 5) but before the alias swap (step 7) exists only in the old live indices.Lost on swap unless the write lands in the same sync cycle; acceptable because the CatalogSyncJob semaphore (below) makes this window narrow, not eliminated.
Engine/Security Analytics unavailable during the post-sync cascadeAliases and consumer documents are already committed to the new source; only downstream propagation (Engine promote(), Security Analytics sync) is incomplete.Cascade is idempotent and retried on the next scheduled sync, which now runs against the already-swapped source.

Concurrency: The CatalogSyncJob semaphore and cluster lock span the whole pass, which includes the swap. No additional locking is needed, but it also means the swap holds them for its full duration — the incremental syncs of the consumers that do not change source run after it.

Disk: the shadow indices of every moving consumer coexist with the live ones until step 9, so a full plan change temporarily duplicates all the downloaded content.

Normal incremental syncs

Regular incremental updates (no plan change) write through the alias to the live physical index. They are completely unaware of the -a/-b scheme.


Plugin architecture

Entry point

ContentManagerPlugin is the main class. It implements Plugin, ClusterPlugin, JobSchedulerExtension, and SystemIndexPlugin (which extends ActionPlugin). On startup, it:

  1. Initializes PluginSettings, ConsumersIndex, CredentialsIndex, CtiConsole, CatalogSyncJob, EngineServiceImpl, and SpaceService.
  2. Registers all REST handlers via getRestHandlers().
  3. Creates the .wazuh-cti-consumers and .wazuh-internal-state indices on cluster manager nodes.
  4. Schedules the periodic CatalogSyncJob via the OpenSearch Job Scheduler.
  5. Optionally triggers an immediate sync on start.
  6. Registers/schedules TelemetryPingJob (wazuh-telemetry-ping-job) when plugins.content_manager.telemetry.enabled is true.
  7. Registers a dynamic settings consumer to enable/disable telemetry at runtime.
  8. Registers dynamic settings consumers for each resource creation limit (max_integrations, max_decoders, max_rules, max_kvdbs, max_filters) so limits can be updated at runtime via the Cluster Settings API.

Update check service internals

The update check flow is split into two classes:

  • TelemetryPingJob (jobscheduler/jobs/TelemetryPingJob.java)

    • Runs through Job Scheduler every 1 day.
    • Reads cluster UUID from ClusterService metadata.
    • Reads Wazuh version through ContentManagerPlugin.getVersion().
    • Prevents overlap using a Semaphore (tryAcquire() guard).
    • Exposes a trigger() method for immediate invocation, used by ContentManagerPlugin to fire the first ping as soon as the job document is indexed.
  • TelemetryClient (cti/console/client/TelemetryClient.java)

    • Sends an asynchronous GET request to CTI /ping.
    • Headers sent:
      • wazuh-uid: cluster UUID
      • wazuh-tag: v<version>
      • Accept-Encoding: gzip
    • Fire-and-forget behavior: callback logs success/failure without blocking scheduler threads.

Version check internals

GET /version/check is served by TransportVersionCheckAction, which delegates to VersionCheckService (cti/catalog/service/VersionCheckService.java):

  • Dedicated thread pool. The blocking CTI request runs on the content_manager_version_check pool (fixed, size 1, queue 1, registered in ContentManagerPlugin.getExecutorBuilders(...)), never on the transport thread.
  • Shared in-flight request. At most one CTI request is in flight per node. Checks that arrive while it runs wait for it and receive its answer.
  • Token-bucket rate limit. BUCKET_CAPACITY (5) checks in a burst, refilled at one per TOKEN_REFILL_MILLIS (12 s). Every CTI request spends a token whatever its outcome; checks that join an in-flight request spend none. An empty bucket answers 429 with a Retry-After header. Nothing is cached.
  • Single attempt. ApiClient.getReleaseUpdates() makes no 429 retries, so a CTI 429 is returned to the caller instead of being slept off. The catalog sync keeps its client.max_retries behaviour.
  • Shared CTI client. One long-lived ApiClient, created on first use and closed when the plugin closes. Timed-out requests are cancelled so their pooled connections are released.

CTI HTTP client User-Agent and Accept-Encoding

All HTTP clients that communicate with CTI services include a custom User-Agent header set as a default header on the HTTP client builder:

User-Agent: Wazuh Indexer <version>

The version is read from VERSION.json at plugin startup and stored in PluginSettings. The user-agent string is built by PluginSettings.getUserAgent() using the Constants.USER_AGENT_PREFIX constant. If the version is unavailable, the fallback value unknown is used.

They also send Accept-Encoding: gzip (via Constants.ACCEPT_ENCODING_GZIP) so CTI responses are transferred compressed. Unlike User-Agent, this header is added per request (via each request builder) rather than as a client default header: HttpClient5’s ChainElement.COMPRESS step runs before ChainElement.PROTOCOL, where default headers (RequestDefaultHeaders) are applied. If no Accept-Encoding header is present yet when COMPRESS runs, it adds its own — HttpClient5’s built-in default (gzip, x-gzip, deflate), not ours — and RequestDefaultHeaders then skips a header whose name is already present. So a default header would never actually be inserted; it isn’t overwritten, it’s preempted. Setting it directly on each request is what makes it reach the wire as gzip.

Affected clients:

  • Console ApiClient (cti/console/client/ApiClient.java) — async HTTP client for CTI Console authentication and plans.
  • Catalog ApiClient (cti/catalog/client/ApiClient.java) — async HTTP client for CTI Catalog consumer and changes.
  • SnapshotClient (cti/catalog/client/SnapshotClient.java) — sync HTTP client for downloading CTI snapshots.
  • TelemetryClient (cti/console/client/TelemetryClient.java) — inherits from Console ApiClient.

Runtime toggle behavior:

  • plugins.content_manager.telemetry.enabled is a dynamic setting.
  • Enabling it schedules the job; the immediate first ping is fired from within scheduleTelemetryPingJob() only after the job document has been successfully indexed, guaranteeing the ping only runs when the scheduled job is correctly registered.
  • Disabling it removes the telemetry job document from .wazuh-content-manager-jobs.

REST handlers

The plugin registers 27 REST handlers, grouped by domain:

DomainHandlerMethodURI
SubscriptionRestPostSubscriptionActionPOST/_plugins/_content_manager/subscription
RestGetSubscriptionActionGET/_plugins/_content_manager/subscription
RestDeleteSubscriptionActionDELETE/_plugins/_content_manager/subscription
UpdateRestPostUpdateActionPOST/_plugins/_content_manager/update
Version checkRestGetVersionCheckActionGET/_plugins/_content_manager/version/check
LogtestRestPostLogtestActionPOST/_plugins/_content_manager/logtest
RestPostLogtestNormalizationActionPOST/_plugins/_content_manager/logtest/normalization
RestPostLogtestDetectionActionPOST/_plugins/_content_manager/logtest/detection
PolicyRestPutPolicyActionPUT/_plugins/_content_manager/policy/{space}
RulesRestPostRuleActionPOST/_plugins/_content_manager/rules
RestPutRuleActionPUT/_plugins/_content_manager/rules/{id}
RestDeleteRuleActionDELETE/_plugins/_content_manager/rules/{id}
DecodersRestPostDecoderActionPOST/_plugins/_content_manager/decoders
RestPutDecoderActionPUT/_plugins/_content_manager/decoders/{id}
RestDeleteDecoderActionDELETE/_plugins/_content_manager/decoders/{id}
IntegrationsRestPostIntegrationActionPOST/_plugins/_content_manager/integrations
RestPutIntegrationActionPUT/_plugins/_content_manager/integrations/{id}
RestDeleteIntegrationActionDELETE/_plugins/_content_manager/integrations/{id}
KVDBsRestPostKvdbActionPOST/_plugins/_content_manager/kvdbs
RestPutKvdbActionPUT/_plugins/_content_manager/kvdbs/{id}
RestDeleteKvdbActionDELETE/_plugins/_content_manager/kvdbs/{id}
FiltersRestPostFilterActionPOST/_plugins/_content_manager/filters
RestPutFilterActionPUT/_plugins/_content_manager/filters/{id}
RestDeleteFilterActionDELETE/_plugins/_content_manager/filters/{id}
PromoteRestPostPromoteActionPOST/_plugins/_content_manager/promote
RestGetPromoteActionGET/_plugins/_content_manager/promote
SpacesRestDeleteSpaceActionDELETE/_plugins/_content_manager/space/{space}

Class hierarchy

The REST handlers follow a Template Method pattern through a three-level abstract class hierarchy. There are two parallel branches — one where the target space is always draft (AbstractCreateAction / AbstractUpdateAction / AbstractDeleteAction) and one where the target space is supplied at runtime from the request body (AbstractCreateActionSpaces / AbstractUpdateActionSpaces / AbstractDeleteActionSpaces). The latter is used for resources like Filters that can live in either draft or standard space.

BaseRestHandler
├── AbstractContentAction
│   ├── AbstractCreateAction               # Target space always: draft
│   │   ├── RestPostRuleAction
│   │   ├── RestPostDecoderAction
│   │   ├── RestPostIntegrationAction
│   │   └── RestPostKvdbAction
│   ├── AbstractUpdateAction               # Target space always: draft
│   │   ├── RestPutRuleAction
│   │   ├── RestPutDecoderAction
│   │   ├── RestPutIntegrationAction
│   │   └── RestPutKvdbAction
│   ├── AbstractDeleteAction               # Target space always: draft
│   │   ├── RestDeleteRuleAction
│   │   ├── RestDeleteDecoderAction
│   │   ├── RestDeleteIntegrationAction
│   │   └── RestDeleteKvdbAction
│   ├── AbstractCreateActionSpaces         # Target space from request body (draft|standard)
│   │   └── RestPostFilterAction
│   ├── AbstractUpdateActionSpaces         # Target space from request body (draft|standard)
│   │   └── RestPutFilterAction
│   └── AbstractDeleteActionSpaces         # Target space from request body (draft|standard)
│       └── RestDeleteFilterAction
├── RestPutPolicyAction
├── RestDeleteSpaceAction
├── RestPostSubscriptionAction
├── RestPostUpdateAction
├── RestPostLogtestAction
├── RestPostPromoteAction
└── RestGetPromoteAction

AbstractContentAction

Base class for all content CUD actions. It:

  • Overrides prepareRequest() from BaseRestHandler.
  • Initializes shared services: SpaceService, SecurityAnalyticsService, IntegrationService.
  • Validates that a Draft policy exists before executing any content action.
  • Delegates to the abstract executeRequest() method for concrete logic.

AbstractCreateAction / AbstractCreateActionSpaces

Handles POST requests to create new resources. AbstractCreateAction hard-codes the target space to draft. AbstractCreateActionSpaces reads the space from the request body instead, allowing draft or standard as the target.

The executeRequest() workflow:

  1. Validate request body — ensures the request has content and valid JSON.
  2. Validate payload structure — checks for required resource key and optional integration key.
  3. Resource-specific validation — delegates to validatePayload() (abstract). Concrete handlers check required fields, duplicate titles, parent integration existence, and the configured creation limit. The limit check counts existing Draft documents in the target index and returns HTTP 400 if the count is at or above plugins.content_manager.max_<type>; if the index does not exist yet, the check is skipped.
  4. Generate ID and metadata — creates a UUID; sets date and modified timestamps to the current time unless the caller already supplied a non-blank value for either (Resource.setCreationTime() / setLastModificationTime()); defaults enabled to true.
  5. External sync — delegates to syncExternalServices() (abstract). Typically upserts the resource in Security Analytics or validates via the Engine.
  6. Index — wraps the resource in the CTI document structure and indexes it in the Draft space.
  7. Link to parent — delegates to linkToParent() (abstract). Usually adds the new resource ID to a parent integration’s resource list.
  8. Update hash — recalculates the Draft space policy hash via SpaceService.

Returns 201 Created with the new resource UUID on success.

AbstractUpdateAction / AbstractUpdateActionSpaces

Handles PUT requests to update existing resources. AbstractUpdateAction restricts updates to the draft space. AbstractUpdateActionSpaces accepts a space value (draft or standard) from the request body.

The executeRequest() workflow:

  1. Validate ID — checks the path parameter is present and correctly formatted.
  2. Check existence and space — verifies the resource exists and belongs to the Draft space.
  3. Parse and validate payload — same structural checks as create.
  4. Resource-specific validation — delegates to validatePayload() (abstract).
  5. Update timestamps — sets modified to the current time unless the caller already supplied a non-blank value. preserveMetadata() then unconditionally overwrites date with the existing document’s stored creation date, ignoring any caller-supplied value — date is fully immutable once a resource is created.
  6. External sync — delegates to syncExternalServices() (abstract).
  7. Re-index — overwrites the document in the index.
  8. Update hash — recalculates the Draft space hash.

Returns 200 OK with the resource UUID on success.

AbstractDeleteAction / AbstractDeleteActionSpaces

Handles DELETE requests. AbstractDeleteAction restricts deletions to the draft space. AbstractDeleteActionSpaces resolves the target space from the stored document (allowing deletion from both draft and standard).

The executeRequest() workflow:

  1. Validate ID — checks format and presence.
  2. Check existence and space — resource must exist in Draft space.
  3. Pre-delete validation — delegates to validateDelete() (optional override). Can prevent deletion if dependent resources exist.
  4. External sync — delegates to deleteExternalServices() (abstract). Removes from Security Analytics. Handles 404 gracefully.
  5. Unlink from parent — delegates to unlinkFromParent() (abstract). Removes the resource ID from the parent integration’s list.
  6. Delete from index — removes the document.
  7. Update hash — recalculates the Draft space hash.

Returns 200 OK with the resource UUID on success.

Integration mode field

Integration documents carry a document.mode field with one of two values:

  • protected — the integration is Wazuh core content and cannot be modified or deleted through the REST API.
  • user-managed — the integration can be modified by the user.

The value is set by whoever produces the content: CTI content carries its own mode (core integrations are protected, the rest user-managed), and integrations created through the REST API are always user-managed.

IntegrationSpacePUT /integrations/{id}
protectedanyRejected with 400 Bad Request.
user-manageddraftFully editable (metadata, category, enabled).
user-managedstandardOnly enabled can change; every other field is preserved from the stored document.

When a standard integration’s enabled is toggled, its related Security Analytics detector is disabled/enabled in lockstep as part of the same update flow. The detector shares the integration’s document id, so the Content Manager calls the Security Analytics WSetDetectorEnabledAction (setDetectorEnabled(id, enabled)) to flip only the enabled flag on the existing detector, preserving its inputs, triggers and monitors. If the detector sync fails the whole update is aborted, so the two never drift.

On the synchronization side, buildDetectorRequest resolves the detector’s state as:

detector.enabled = integration.enabled && (user override ?? document.detector.enabled)

The integration gates it, so a disabled integration never leaves a running detector whatever the user chose for it. When the integration is on, the user’s override wins and CTI’s document.detector.enabled is the default — absent there means CTI has no preference and the integration alone decides. The rest of the detector block is CTI-owned and supplies only the schedule and the source indices.

The override is the user’s Start/Stop from Security Analytics, kept in the registry. Toggling the integration clears it, so re-enabling an integration brings its detector back rather than leaving a stale decision in charge.


YAML content-type support

Decoders, KVDBs, and Filters accept Content-Type: application/yaml requests in addition to JSON. This is implemented through an opt-in pattern in the abstract handler hierarchy.

Architecture

The YAML support is built on three mechanisms in AbstractContentAction:

  1. isYamlRequest(RestRequest) — Detects YAML content type via XContentType.YAML.equals(request.getMediaType()). Returns false on any exception (e.g., test mocks that don’t stub getMediaType()).

  2. supportsYamlField() — Returns false by default. Overridden to true in concrete handlers that support YAML field storage: RestPostDecoderAction, RestPutDecoderAction, RestPostKvdbAction, RestPutKvdbAction, RestPostFilterAction, RestPutFilterAction.

  3. YAML/JSON branching in executeRequest() — Both AbstractCreateAction and AbstractUpdateAction (and their *Spaces variants) branch on isYamlRequest() and supportsYamlField():

    • YAML path: Parses the body via YamlUtils.fromYaml(), then validates the envelope structure with the same validateResourcePayload() call as the JSON path. The rawYaml for the yaml field is generated from the resource subtree via YamlUtils.toYaml().
    • JSON path: Unchanged — parses via Jackson ObjectMapper.readTree().

Both paths converge after parsing: resource-specific validation, ID generation, external sync, and indexing are identical regardless of content type.

Envelope structure

YAML requests use the same envelope as JSON. The integration (or space for filters) and resource keys appear at the top level of the YAML document:

---
integration: <uuid>
resource:
  metadata:
    title: my-resource
    author: "Wazuh Inc."
  content:
    key1: value1

This is parsed into a JsonNode tree identical to what the JSON path produces.

YAML field storage

When supportsYamlField() returns true, the handler populates a yaml field on the CTI wrapper before indexing:

  • YAML requests: rawYaml is generated from the parsed resource subtree (not the raw request body, which includes the envelope).
  • JSON requests: YamlUtils.toYaml(resourceNode) auto-generates the YAML representation.

The yaml field is stored as text in the index mappings (see cti-decoders-mappings.json, cti-kvdbs-mappings.json, cti-filters-mappings.json).

Type fidelity

YamlUtils is configured with DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS to preserve floating-point precision. A post-parse fixDecimalScale() step ensures values like 5.0 retain scale 1 in their BigDecimal representation, preventing coercion to integer 5 during serialization.

The ContentIndex.create() method skips processPayload() when it receives a fully-formed CTI wrapper (with document, space, and hash keys), avoiding a lossy valueToTree() round-trip that would strip BigDecimal scale.

Key classes

ClassRole
YamlUtilsYAML - JSON conversion with USE_BIG_DECIMAL_FOR_FLOATS, fixDecimalScale()
DecoderModel with yaml field, fromPayload() generates YAML from document
KvdbModel with yaml field, same pattern as Decoder
FilterModel with yaml field, same pattern as Decoder
AbstractContentActionisYamlRequest(), supportsYamlField() base methods
ContentIndexcreate() skips processPayload() for pre-built wrappers

Engine communication

The plugin communicates with the Wazuh Engine via a Unix Domain Socket (UDS) for validation and promotion of content.

EngineSocketClient

Located at: engine/client/EngineSocketClient.java

  • Connects to the socket at /usr/share/wazuh-indexer/engine/sockets/engine-api-http.sock.
  • Sends HTTP-over-UDS requests: builds a standard HTTP/1.1 request string (method, headers, JSON body) and writes it to the socket channel.
  • Each request opens a new SocketChannel (using StandardProtocolFamily.UNIX) that is closed after the response is read.
  • Parses the HTTP response, extracting the status code and JSON body.

EngineService interface

Defines the Engine operations:

MethodDescription
logtest(JsonNode log)Forwards a log test payload to the Engine
validate(JsonNode resource)Validates a resource payload
promote(JsonNode policy)Validates a full policy for promotion
validateResource(String type, JsonNode resource)Wraps a resource with its type and delegates to validate()

EngineServiceImpl

Implementation using EngineSocketClient. Maps methods to Engine API endpoints:

MethodEngine endpointHTTP method
logtest()/logtestPOST
validate()/content/validate/resourcePOST
promote()/content/validate/policyPOST

Space model

Resources live in spaces that represent their lifecycle stage. The Space enum defines four spaces:

SpaceDescription
STANDARDProduction-ready CTI resources from the upstream catalog
CUSTOMUser-created resources that have been promoted to production
DRAFTResources under development — all user edits happen here
TESTIntermediate space for validation before production

Promotion flow

Spaces promote in a fixed chain:

DRAFT → TEST → CUSTOM

The Space.promote() method returns the next space in the chain. STANDARD and CUSTOM spaces cannot be promoted further.

SpaceService

Located at: cti/catalog/service/SpaceService.java

Manages space-related operations:

  • getSpaceResources(spaceName) — Fetches all resources (document IDs and hashes) from all managed indices for a given space.
  • promoteSpace(indexName, resources, targetSpace) — Copies documents from one space to another via bulk indexing, updating the space.name field.
  • calculateAndUpdate(targetSpaces) — Recalculates the aggregate SHA-256 hash for each policy in the given spaces. The hash is computed by concatenating hashes of the policy and all its linked resources (integrations, decoders, KVDBs, rules).
  • buildEnginePayload(...) — Assembles the full policy payload (policy + all resources from target space with modifications applied) for Engine validation during promotion.
  • deleteResources(indexName, ids, targetSpace) — Bulk-deletes resources from a target space.

Document structure

Every resource document follows this envelope structure:

{
  "document": {
    "id": "<uuid>",
    "title": "...",
    "date": "2026-01-01T00:00:00Z",
    "modified": "2026-01-15T00:00:00Z",
    "enabled": true
  },
  "hash": {
    "sha256": "abc123..."
  },
  "space": {
    "name": "draft",
    "hash": {
      "sha256": "xyz789..."
    }
  }
}

User overrides in the standard space

The standard space is rebuilt from CTI, so what the user changes there is recorded in a registry document and re-applied afterwards: the policy’s settings, the filters they created, and each integration’s enabled state along with its detector’s.

UserOverridesService manages a single document in the policies index under wazuh-user-overrides (Constants.USER_OVERRIDES_DOC_ID):

{
  "user_overrides": {
    "standard": {
      "policy": {
        "enabled": true,
        "index_unclassified_events": true,
        "index_discarded_events": false,
        "enrichments": ["connection", "url_full"]
      },
      "filters": [{ "id": "<uuid>", "document": "<the stored filter, serialized>" }],
      "integrations": {
        "<uuid>": { "enabled": false },
        "<another uuid>": { "detector_enabled": false }
      }
    }
  }
}

Both integration fields are optional, and absence means the user never decided: the two entries above are “the user disabled this integration” and “the user stopped this one’s detector without touching the integration”.

It is the one document in that index with no space field: the pre-snapshot wipe selects by space.name and never sees it, and the plan-change reindex carries it into the new physical index.

Enrichments are stored as the resulting list, and on apply only the values CTI still publishes are kept. Filters are stored as serialized strings, to keep their fields out of the "dynamic": "true" policies mapping. Integrations are keyed by id rather than listed, so Security Analytics can record a detector decision with a single partial update — a doc merge combines objects recursively but replaces arrays wholesale.

Written on a standard policy update, on filter create, update and delete, and on integration update. The detector decision is written by Security Analytics itself, which cannot call this plugin — the dependency only runs the other way — but writes the registry document directly. Applied at the start of ConsumerRulesetService.onSyncComplete, before the space hash is recalculated and the engine reloaded. Idempotent, and a failure is logged without aborting the synchronization.


Content synchronization pipeline

Overview

sequenceDiagram
    participant Scheduler as JobScheduler/RestAction
    participant SyncJob as CatalogSyncJob
    participant Swap as ShadowSwapService
    participant Synchronizer as ConsumerRulesetService
    participant ConsumerSvc as ConsumerService
    participant CTI as External CTI API
    participant Snapshot as SnapshotService
    participant Update as UpdateService
    participant Indices as Content Indices
    participant Consumers as .wazuh-cti-consumers
    participant SA as SecurityAnalyticsServiceImpl

    Scheduler->>SyncJob: Trigger Execution
    activate SyncJob

    SyncJob->>CTI: Look the plan up (SyncPass.resolve)

    SyncJob->>Swap: swapChangedSources(consumers, pass)
    opt A consumer's data source changes (resource URL differs)
        Swap->>CTI: Fetch the new remote consumer of every changing consumer
        Swap->>Indices: Create hidden shadow indices (-a/-b spare suffix)
        Swap->>Consumers: Rewrite every changing consumer (new resource, running, local_offset = 0)
        Swap->>CTI: Download each snapshot into its shadow indices
        Swap->>Indices: Reindex user content (space.name != "standard")
        Swap->>Indices: Refresh shadow indices
        Swap->>Indices: Unhide shadow indices
        Swap->>Indices: Atomic alias swap (single IndicesAliasesRequest for every changing consumer)
        Swap->>Consumers: Record the snapshot offsets (local_offset = snapshot offset)
        Swap->>Indices: Delete old physical indices
    end
    Swap-->>SyncJob: Outcome per consumer, in the pass

    SyncJob->>Synchronizer: synchronize(pass)

    Synchronizer->>ConsumerSvc: getLocalConsumer() / getRemoteConsumer()
    ConsumerSvc->>CTI: Fetch Metadata
    ConsumerSvc-->>Synchronizer: Offsets & Metadata
    Synchronizer->>Consumers: running, remote_offset = remote head

    alt Local Offset == 0 (Initialization)
        Synchronizer->>Snapshot: initialize(remoteConsumer)
        Snapshot->>CTI: Download Snapshot ZIP
        Snapshot->>Indices: Bulk Index Content (Rules/Integrations/etc.)
        Snapshot-->>Synchronizer: Done
    else Local Offset < Remote Offset (Update, or catch-up after a swap)
        Synchronizer->>Update: update(localOffset, remoteOffset)
        Update->>CTI: Fetch Changes
        Update->>Indices: Apply JSON Patches
        Update-->>Synchronizer: Done
    end

    opt Changes Applied, or swapped in this pass (onSyncComplete)
        Synchronizer->>Indices: Refresh Indices

        Synchronizer->>SA: upsertIntegration(doc)
        loop For each Integration
            SA->>SA: WIndexIntegrationAction
        end

        Synchronizer->>SA: upsertRule(doc)
        loop For each Rule
            SA->>SA: WIndexRuleAction
        end

        Synchronizer->>SA: upsertDetector(doc)
        loop For each Integration
            SA->>SA: WIndexDetectorAction
        end

        Synchronizer->>Synchronizer: calculatePolicyHash()
    end

    deactivate SyncJob

The opt Changes Applied (onSyncComplete) cascade also runs for a consumer the pass’s swap moved, even if nothing is left to catch up — by the time it executes, Indices and Consumers already resolve through the swapped alias and rewritten consumer document. A consumer whose data source changes but could not be swapped is not synchronized from its new source: it keeps its content until a swap of every changing consumer succeeds.

Initialization phase

When local_offset = 0:

  1. Downloads a ZIP snapshot from the CTI API.
  2. Reads each NDJSON entry of the ZIP straight from the archive, line by line and as bytes: a line is parsed in place in a reusable buffer (NdjsonLineReader), never decoded into a string, and each document is copied once, into its index request. The vulnerabilities snapshot alone is over 4 GB of NDJSON with documents of up to 17 MB, so string copies of its lines are enough to push a 2 GB heap over the parent circuit breaker.
  3. Bulk-indexes content into respective indices.
  4. Registers all content with the Security Analytics Plugin via SecurityAnalyticsServiceImpl.

Update phase

When local_offset > 0 and local_offset < remote_offset:

  1. Marks the consumer running and, in the same write, records the remote head as remote_offset, so the document shows the gap still to be applied from its first running read.
  2. Fetches the changes in batches from the CTI API.
  3. Applies each change: creates or deletes the document, or patches it with the change’s JSON Patch (RFC 6902) operations through zjsonpatch. Patching is strict: an operation whose target does not exist fails the change (zjsonpatch needs FORBID_REMOVE_MISSING_OBJECT for removals). The operations are applied in place, without copying the document, on a tree parsed from the stored source bytes; if one fails, that tree is discarded and the stored document is left as it was. The patched document is serialized back to UTF-8 bytes for indexing. Several changes of the same document within a batch are applied in order, each on top of the previous one.
  4. Pushes the changes to the Security Analytics Plugin via SecurityAnalyticsServiceImpl.
  5. Updates the local offset.

Post-synchronization phase

  1. Refreshes all content indices.
  2. Re-applies the user overrides to the standard space, before anything reads the rebuilt content.
  3. Upserts integrations, rules, and detectors into the Security Analytics Plugin via SecurityAnalyticsServiceImpl.
  4. Recalculates SHA-256 hashes for policy integrity verification.
  5. Sets consumer status to ready in .wazuh-cti-consumers (or failed if an unexpected exception interrupted the cycle). See the Reference Manual’s architecture page for the full ready / running / failed lifecycle.

Error handling

If a critical error or data corruption is detected, the system resets local_offset to 0, triggering a full snapshot re-initialization on the next run.

A change that cannot be applied to the stored content fails as a ContentIndex.PatchException, which carries the change’s offset and resource: its patch does not fit the stored document, or, for the consumers updated in batches (IoCs and vulnerabilities), the document is missing. Retrying it fails the same way, so AbstractConsumerService handles it in the same pass: when CTI has a snapshot newer than the consumer’s local offset, it rebuilds the content from that snapshot and applies the remaining changes from the snapshot’s offset. A snapshot at or past the failing offset already holds the change’s result; an older one still rebuilds stored content that diverged from CTI, which is what usually makes a valid change fail. The snapshot goes through the same blue/green path as a plan change (ShadowSwapService.rebuild(), for that consumer alone): it loads into hidden staging indices and the aliases are swapped only once it has loaded, so the current content stays available meanwhile and is kept if the snapshot cannot be loaded. The consumer document is not touched until the switch. After a rebuild the local offset is at or past the snapshot, so a given snapshot is used at most once; without a newer snapshot, the failure propagates and later runs retry the change. Any other failure (CTI unreachable, cluster errors) propagates as before and resumes from the last checkpoint.


Configuration settings

To register a new setting, follow the existing pattern in PluginSettings.java. That will make it available in opensearch.yml.

For existing settings, check the settings reference.

When registering a new setting, document it in the section linked above.

REST API URIs

All endpoints are under /_plugins/_content_manager. The URI constants are defined in PluginSettings:

ConstantValue
PLUGINS_BASE_URI/_plugins/_content_manager
SUBSCRIPTION_URI/_plugins/_content_manager/subscription
UPDATE_URI/_plugins/_content_manager/update
LOGTEST_URI/_plugins/_content_manager/logtest
RULES_URI/_plugins/_content_manager/rules
DECODERS_URI/_plugins/_content_manager/decoders
INTEGRATIONS_URI/_plugins/_content_manager/integrations
KVDBS_URI/_plugins/_content_manager/kvdbs
FILTERS_URI/_plugins/_content_manager/filters
PROMOTE_URI/_plugins/_content_manager/promote
POLICY_URI/_plugins/_content_manager/policy
SPACE_URI/_plugins/_content_manager/space

REST API reference

The full API is defined in openapi.yml.

Logtest

The Indexer acts as a proxy between the UI and the Engine. POST /logtest accepts the payload and forwards it to the Engine via UDS. No validation is performed. If the Engine responds, its response is returned directly. If the Engine is unreachable, a 500 error is returned.

A testing policy must be loaded in the Engine for logtest to work. Load a policy via the policy promotion endpoint.

---
title: Logtest execution
---
sequenceDiagram
    actor User
    participant UI
    participant Indexer
    participant Engine

    User->>UI: run logtest
    UI->>Indexer: POST /logtest
    Indexer->>Engine: POST /logtest (via UDS)
    Engine-->>Indexer: response
    Indexer-->>UI: response

Content CUD (rules, decoders, integrations, KVDBs)

All four resource types follow the same patterns via the abstract class hierarchy:

Create (POST)

sequenceDiagram
    actor User
    participant Indexer
    participant EngineSA as Engine or Security Analytics
    participant ContentIndex
    participant IntegrationIndex

    User->>Indexer: POST /_plugins/_content_manager/{resource_type}
    Indexer->>Indexer: Validate payload, generate UUID, timestamps
    Indexer->>EngineSA: Sync (validate/upsert)
    EngineSA-->>Indexer: OK
    Indexer->>ContentIndex: Index in Draft space
    Indexer->>IntegrationIndex: Link to parent integration
    Indexer-->>User: 201 Created + UUID

Update (PUT)

sequenceDiagram
    actor User
    participant Indexer
    participant ContentIndex
    participant EngineSA as Engine or Security Analytics

    User->>Indexer: PUT /_plugins/_content_manager/{resource_type}/{id}
    Indexer->>ContentIndex: Check exists + is in Draft space
    Indexer->>Indexer: Validate, preserve metadata, update timestamps
    Indexer->>EngineSA: Sync (validate/upsert)
    Indexer->>ContentIndex: Re-index document
    Indexer-->>User: 200 OK + UUID

Delete (DELETE)

sequenceDiagram
    actor User
    participant Indexer
    participant ContentIndex
    participant EngineSA as Engine or Security Analytics
    participant IntegrationIndex

    User->>Indexer: DELETE /_plugins/_content_manager/{resource_type}/{id}
    Indexer->>ContentIndex: Check exists + is in Draft space
    Indexer->>EngineSA: Delete from external service
    Indexer->>IntegrationIndex: Unlink from parent
    Indexer->>ContentIndex: Delete document
    Indexer-->>User: 200 OK + UUID

Policy update

The policy endpoint now accepts a {space} path parameter (draft or standard), allowing the same handler to serve both spaces with different validation rules.

  • Draft space — all policy fields are accepted. The integrations and filters arrays allow reordering but not adding or removing entries. author, description, documentation, and references are required in addition to the boolean fields.
  • Standard space — only enrichments, filters, enabled, index_unclassified_events, and index_discarded_events can be modified. All other fields are preserved from the existing standard policy document. After a successful update, if the standard space hash changed, the updated policy is automatically loaded into the Engine.
flowchart TD
    UI[UI] -->|"PUT /policy/{space}"| Indexer
    Indexer -->|Validate space| SpaceCheck{is a valid space?}
    SpaceCheck -->|No| Error400[400 Bad Request]
    SpaceCheck -->|Yes| Parse[Parse & validate fields]
    Parse --> SpaceBranch{Space?}
    SpaceBranch -->|draft| StoreDraft[Update draft policy in wazuh-threatintel-policies]
    SpaceBranch -->|standard| StoreStd[Merge allowed fields into standard policy]
    StoreDraft --> Hash[Recalculate space hash]
    StoreStd --> Hash
    Hash --> EngineCheck{Standard hash changed?}
    EngineCheck -->|Yes| Engine[Load standard space into Engine]
    EngineCheck -->|No| OK[200 OK]
    Engine --> OK

Policy schema

The wazuh-threatintel-policies index stores policy configurations. See Document structure above for the envelope format.

Policy document fields

FieldTypeDescriptionEditable in standard space
idkeywordUnique identifierNo
titlekeywordHuman-readable nameNo
datedateCreation timestampNo
modifieddateLast modification timestampNo
root_decoderkeywordRoot decoder for event processingNo
integrationskeyword[]Active integration IDsNo
authorkeywordPolicy authorNo
descriptiontextBrief descriptionNo
documentationkeywordDocumentation linkNo
referenceskeyword[]External reference URLsNo
filterskeyword[]Filter UUIDs (reordering allowed, no add/remove)Yes
enrichmentskeyword[]Enrichment types (file, domain-name, ip, url, geo)Yes
enabledbooleanWhether the policy is activeYes
index_unclassified_eventsbooleanIndex events that match no ruleYes
index_discarded_eventsbooleanIndex events explicitly discarded by rulesYes

Filters CUD (Engine filters)

Filters follow the same CUD pattern as other resource types but use the AbstractCreateActionSpaces / AbstractUpdateActionSpaces / AbstractDeleteActionSpaces hierarchy. The key difference is that the target space is supplied in the request body rather than being fixed to draft. Both draft and standard are accepted.

Filters are linked directly to their space’s policy document (the filters array) rather than to a parent integration.

Create (POST)

sequenceDiagram
    actor User
    participant Indexer
    participant Engine
    participant FilterIndex as wazuh-threatintel-filters
    participant PoliciesIndex as wazuh-threatintel-policies

    User->>Indexer: POST /_plugins/_content_manager/filters
    Indexer->>Indexer: Validate payload + space (draft|standard)
    Indexer->>Engine: validateResource("filter", resource)
    Engine-->>Indexer: OK
    Indexer->>FilterIndex: Index in target space
    Indexer->>PoliciesIndex: Add filter ID to space policy filters[]
    Indexer-->>User: 201 Created + UUID

Update (PUT)

sequenceDiagram
    actor User
    participant Indexer
    participant Engine
    participant FilterIndex as wazuh-threatintel-filters

    User->>Indexer: PUT /_plugins/_content_manager/filters/{id}
    Indexer->>FilterIndex: Check exists + validate space (draft|standard)
    Indexer->>Indexer: Validate payload
    Indexer->>Engine: validateResource("filter", resource)
    Engine-->>Indexer: OK
    Indexer->>FilterIndex: Re-index document
    Indexer-->>User: 200 OK + UUID

Delete (DELETE)

sequenceDiagram
    actor User
    participant Indexer
    participant FilterIndex as wazuh-threatintel-filters
    participant PoliciesIndex as wazuh-threatintel-policies

    User->>Indexer: DELETE /_plugins/_content_manager/filters/{id}
    Indexer->>FilterIndex: Check exists + resolve space
    Indexer->>PoliciesIndex: Remove filter ID from space policy filters[]
    Indexer->>FilterIndex: Delete document
    Indexer-->>User: 200 OK + UUID

Space reset

flowchart TD
    UI[UI] -->|"DELETE /space/{space}"| Indexer
    Indexer -->|Validate space| Check{space == draft?}
    Check -->|No| Error400[400 Bad Request]
    Check -->|Yes| DeleteSA[Delete draft resources from Security Analytics]
    DeleteSA --> DeleteCTI[Delete all draft documents from wazuh-threatintel-* indices]
    DeleteCTI --> RegenPolicy[Re-generate default draft policy]
    RegenPolicy --> OK[200 OK]

Only the draft space can be reset. Attempting to reset any other space returns 400 Bad Request. Failures to delete individual Security Analytics detectors, rules or integrations are logged but do not block the reset — the primary goal is clearing the content indices and regenerating the policy. If the Security Analytics request fails outright (for example, the draft integration lookup fails), the reset stops and returns an error.


Debugging

Check consumer status

GET /.wazuh-cti-consumers/_search
{
  "query": { "match_all": {} }
}

The status field indicates the sync lifecycle state:

  • ready — sync complete; content is safe to read.
  • running — sync in progress; content may be partially written.
  • failed — the previous sync cycle was interrupted by an unexpected exception.

To find consumers that are currently syncing or that failed mid-sync:

GET /.wazuh-cti-consumers/_search
{
  "query": { "terms": { "status": ["running", "failed"] } }
}

Check content by space

GET /wazuh-threatintel-rules/_search
{
  "query": { "term": { "space.name": "draft" } },
  "size": 10
}

Monitor plugin logs

tail -f var/log/wazuh-indexer/wazuh-cluster.log | grep -E "ContentManager|CatalogSyncJob|SnapshotServiceImpl|UpdateServiceImpl|AbstractContentAction"

Important notes

  • The plugin only runs on cluster manager nodes.
  • CTI API must be accessible for content synchronization.
  • All user content CUD operations require a Draft policy to exist.
  • The Engine socket must be available at the configured path for logtest, validation, and promotion.
  • Offset-based synchronization ensures no content is missed.

Testing

The plugin includes integration tests defined in the tests/content-manager directory. These tests cover various scenarios for managing integrations, decoders, rules, and KVDBs through the REST API, grouped below by resource and operation.

Resource / operationScenario countCovers
Integrations: create9Success; duplicate title; missing title/author/category; explicit id in resource; missing resource object; empty body; no authentication
Integrations: update12Success; title collision with an existing draft integration; missing required fields; not found; invalid UUID; id in request body; attempting to add/remove dependency lists; no authentication; protected integration rejected; toggling enabled on a user-managed integration in the standard space; user-managed standard update changes only enabled (other fields preserved); protected standard integration rejected
Integrations: delete7Success (no attached resources); has attached resources; not found; invalid UUID; missing ID; not in draft space; no authentication
Decoders: create7Success; missing integration reference; explicit id in resource; integration not in draft space; missing resource object; empty body; no authentication
Decoders: update7Success; not found; invalid UUID; not in draft space; missing resource object; empty body; no authentication
Decoders: delete7Success; not found; invalid UUID; not in draft space; missing ID; no authentication; verify removal from index
Rules: create7Success; missing title; missing integration reference; explicit id in resource; integration not in draft space; empty body; no authentication
Rules: update7Success; missing title; not found; invalid UUID; not in draft space; empty body; no authentication
Rules: delete7Success; not found; invalid UUID; not in draft space; missing ID; no authentication; verify removal from index
KVDBs: create9Success; missing title/author/content; missing integration reference; explicit id in resource; integration not in draft space; empty body; no authentication
KVDBs: update7Success; missing required fields; not found; invalid UUID; not in draft space; empty body; no authentication
KVDBs: delete7Success; not found; invalid UUID; not in draft space; missing ID; no authentication; verify removal from index
Policy: initialization6wazuh-threatintel-policies index exists; exactly four policy documents (one per space); standard policy has a distinct document ID; draft/test/custom start with empty integrations/root_decoder; document structure; valid SHA-256 hash
Policy: update draft12Success; missing/wrong type; missing resource object; missing required fields; attempting to add/remove an integration; reordering integrations (allowed); empty body; no authentication; changes not reflected in test space until promotion; changes reflected after promotion
Logtest4Success; empty body; invalid JSON; no authentication
Promote: preview7Draft → test; test → custom; missing/empty/invalid space parameter; preview from custom (not allowed); no authentication
Promote: execute18Success draft → test and test → custom, each verified for resource presence, hash regeneration, and hash match; deleting a draft decoder doesn’t affect a promoted test space; promote from custom (not allowed); invalid space; missing/incomplete changes object; non-update operation on policy; empty body; no authentication

Tutorial: adding a REST endpoint to the Content Manager plugin

This tutorial walks through adding a new REST endpoint to the Content Manager plugin, using a concrete example: a GET endpoint to retrieve a single rule by ID.

By the end, you will have a working GET /_plugins/_content_manager/rules/{id} endpoint that fetches a rule document from the wazuh-threatintel-rules index.


Prerequisites

  • Development environment set up (see Setup)
  • The project compiles: ./gradlew :wazuh-indexer-content-manager:compileJava

Step 1: add the URI constant

If your endpoint uses a new base URI, add it to PluginSettings. In this case, rules already have RULES_URI, and our GET endpoint uses the same base path with an {id} parameter, so no changes are needed.

The existing constant in PluginSettings.java:

public static final String RULES_URI = PLUGINS_BASE_URI + "/rules";

Our endpoint will match /_plugins/_content_manager/rules/{id} using the same base URI.


Step 2: create the handler class

Create a new file at:

plugins/content-manager/src/main/java/com/wazuh/contentmanager/rest/service/RestGetRuleAction.java
package com.wazuh.contentmanager.rest.service;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.opensearch.core.rest.RestStatus;
import org.opensearch.rest.BaseRestHandler;
import org.opensearch.rest.BytesRestResponse;
import org.opensearch.rest.RestHandler.Route;
import org.opensearch.rest.RestRequest;
import org.opensearch.transport.client.node.NodeClient;

import java.util.List;

import com.wazuh.contentmanager.cti.catalog.index.ContentIndex;
import com.wazuh.contentmanager.settings.PluginSettings;
import com.wazuh.contentmanager.utils.Constants;

/**
 * GET /_plugins/_content_manager/rules/{id}
 *
 * Retrieves a single rule document by its ID from the wazuh-threatintel-rules index.
 */
public class RestGetRuleAction extends BaseRestHandler {

    private static final Logger log = LogManager.getLogger(RestGetRuleAction.class);
    private static final ObjectMapper MAPPER = new ObjectMapper();

    // A short identifier for log output and debugging.
    private static final String ENDPOINT_NAME = "content_manager_rule_get";

    @Override
    public String getName() {
        return ENDPOINT_NAME;
    }

    /**
     * Define the route. The {id} path parameter is automatically extracted
     * by OpenSearch and available via request.param("id").
     */
    @Override
    public List<Route> routes() {
        return List.of(
                new Route(RestRequest.Method.GET, PluginSettings.RULES_URI + "/{id}"));
    }

    /**
     * Prepare and execute the request. This method is called by the
     * OpenSearch REST framework for each incoming request.
     *
     * @param request the incoming REST request
     * @param client  the node client for index operations
     * @return a RestChannelConsumer that writes the response
     */
    @Override
    protected RestChannelConsumer prepareRequest(RestRequest request, NodeClient client) {
        // Extract the {id} path parameter.
        String id = request.param(Constants.KEY_ID);

        return channel -> {
            try {
                // Validate the ID parameter is present.
                if (id == null || id.isBlank()) {
                    channel.sendResponse(new BytesRestResponse(
                            RestStatus.BAD_REQUEST,
                            "application/json",
                            "{\"error\": \"Missing required parameter: id\"}"));
                    return;
                }

                // Use ContentIndex to retrieve the document.
                ContentIndex index = new ContentIndex(client, Constants.INDEX_RULES, null);
                JsonNode document = index.getDocument(id);

                if (document == null) {
                    channel.sendResponse(new BytesRestResponse(
                            RestStatus.NOT_FOUND,
                            "application/json",
                            "{\"error\": \"Rule not found: " + id + "\"}"));
                    return;
                }

                // Return the document as JSON.
                String responseBody = MAPPER.writeValueAsString(document);
                channel.sendResponse(new BytesRestResponse(
                        RestStatus.OK,
                        "application/json",
                        responseBody));

            } catch (Exception e) {
                log.error("Failed to retrieve rule [{}]: {}", id, e.getMessage(), e);
                channel.sendResponse(new BytesRestResponse(
                        RestStatus.INTERNAL_SERVER_ERROR,
                        "application/json",
                        "{\"error\": \"Internal server error: " + e.getMessage() + "\"}"));
            }
        };
    }
}

Key concepts

  • getName() — Returns a short identifier used in logs and debugging.
  • routes() — Defines the HTTP method and URI pattern. Uses Route to register the endpoint with OpenSearch’s REST framework.
  • prepareRequest() — The core method. Returns a RestChannelConsumer lambda that executes asynchronously and writes the response to the channel.
  • Path parameters — {id} in the route path is automatically parsed. Access it with request.param("id").

Step 3: register the handler

Open ContentManagerPlugin.java and add the new handler to getRestHandlers():

@Override
public List<RestHandler> getRestHandlers(
        Settings settings,
        RestController restController,
        ClusterSettings clusterSettings,
        IndexScopedSettings indexScopedSettings,
        SettingsFilter settingsFilter,
        IndexNameExpressionResolver indexNameExpressionResolver,
        Supplier<DiscoveryNodes> nodesInCluster) {
    return List.of(
            // ... existing handlers ...

            // Rule endpoints
            new RestPostRuleAction(),
            new RestPutRuleAction(),
            new RestDeleteRuleAction(),
            new RestGetRuleAction(),  // <-- Add the new handler

            // ... remaining handlers ...
    );
}

Make sure to add the import at the top of the file:

import com.wazuh.contentmanager.rest.service.RestGetRuleAction;

Step 4: build and verify

Compile the plugin to check for errors:

./gradlew :wazuh-indexer-content-manager:compileJava

If compilation succeeds, run the full build (including tests):

./gradlew :wazuh-indexer-content-manager:build

Step 5: test the endpoint

Manual testing

Start a local cluster (see tools/test-cluster) and test:

# Create a rule first (so there's something to fetch)
curl -X POST "https://localhost:9200/_plugins/_content_manager/rules" \
  -H "Content-Type: application/json" \
  -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD --insecure \
  -d '{
    "integration": "<integration-id>",
    "resource": {
      "metadata": {
        "title": "Test Rule",
        "author": "Tester"
      },
      "status": "experimental",
      "level": "low",
      "logsource": {
        "product": "<integration-title>",
        "category": "authentication"
      },
      "detection": {
        "condition": "selection",
        "selection": {
          "event.outcome": "failure"
        }
      }
    }
  }'

# The response returns the UUID. Use it to fetch:
curl -X GET "https://localhost:9200/_plugins/_content_manager/rules/<uuid>" \
  -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD --insecure

Writing a unit test

Create a test file at:

plugins/content-manager/src/test/java/com/wazuh/contentmanager/rest/service/RestGetRuleActionTests.java

At minimum, test that getName() and routes() return expected values:

package com.wazuh.contentmanager.rest.service;

import org.opensearch.rest.RestRequest;
import org.opensearch.test.OpenSearchTestCase;

public class RestGetRuleActionTests extends OpenSearchTestCase {

    public void testGetName() {
        RestGetRuleAction action = new RestGetRuleAction();
        assertEquals("content_manager_rule_get", action.getName());
    }

    public void testRoutes() {
        RestGetRuleAction action = new RestGetRuleAction();
        assertEquals(1, action.routes().size());
        assertEquals(RestRequest.Method.GET, action.routes().get(0).getMethod());
        assertTrue(action.routes().get(0).getPath().contains("/rules/{id}"));
    }
}

Run:

./gradlew :wazuh-indexer-content-manager:test

Summary

To add a new REST endpoint to the Content Manager plugin:

  1. Create the handler class — Extend BaseRestHandler (for simple endpoints) or one of the abstract classes (AbstractCreateAction, AbstractUpdateAction, AbstractDeleteAction) for standard CUD.
  2. Define routes — Use Route to declare the HTTP method and URI pattern.
  3. Implement logic — Override prepareRequest() (or executeRequest() if extending the abstract hierarchy).
  4. Register — Add the instance to ContentManagerPlugin.getRestHandlers().
  5. Build and test — ./gradlew :wazuh-indexer-content-manager:compileJava then ./gradlew :wazuh-indexer-content-manager:test.

For content CUD endpoints that need Draft space validation, Engine sync, and hash updates, extend AbstractContentAction or one of its children instead of BaseRestHandler directly.

Logtest architecture and developer guide

Component overview

The logtest flow involves four layers: a thin REST handler, a transport action that owns request validation, an orchestration service, and the external services it calls.

RestPostLogtestAction               →  TransportLogtestAction               →  LogtestService  →  EngineService + SecurityAnalyticsService
RestPostLogtestNormalizationAction  →  TransportLogtestNormalizationAction  →       ↑                    ↑
RestPostLogtestDetectionAction      →  TransportLogtestDetectionAction      →       ↑                    ↑
       (REST handlers)                      (Validation)                     (Orchestration)       (External services)

REST handlers no longer validate requests or contain any business logic — that responsibility moved to the transport action layer. This is a plugin-wide pattern, not specific to logtest: every REST handler in rest/service/ delegates to a same-named Transport*Action via client.execute(), per AbstractContentAction’s own javadoc (“Business logic has been moved to transport actions; REST handlers now delegate to the transport layer via client.execute()”).

RestPostLogtestAction (combined)

Path: rest/service/RestPostLogtestAction.java

The REST handler for POST /_plugins/_content_manager/logtest. Before dispatch it enforces the request-body size cap via PayloadValidations.validateLogtestBodySize(...) — a body larger than plugins.content_manager.logtest.max_body_bytes is rejected with 413 at the REST layer, before parsing (see Request size limit and concurrency). Otherwise it reads the raw request body into a LogtestRequest and calls client.execute(LogtestAction.INSTANCE, logtestRequest, listener). It performs no field-level validation and does not interact with indices or external services directly.

TransportLogtestAction

Path: transport/TransportLogtestAction.java

The validation and dispatch layer for the combined endpoint. Responsibilities:

  1. Validates the request has content and is valid JSON.
  2. Validates the required field space.
  3. Validates that space is not "draft".
  4. Extracts the optional integration field (if present) and strips it from the Engine payload.
  5. Delegates to LogtestService.executeLogtest(integrationId, space, enginePayload). If integrationId is null, only engine normalization is performed.

RestPostLogtestNormalizationAction

Path: rest/service/RestPostLogtestNormalizationAction.java

The REST handler for POST /_plugins/_content_manager/logtest/normalization. Enforces the body-size cap (413) the same way as the combined handler, then reads the request body and calls client.execute(LogtestNormalizationAction.INSTANCE, ...). No field-level validation.

TransportLogtestNormalizationAction

Path: transport/TransportLogtestNormalizationAction.java

Responsibilities:

  1. Validates the request has content and is valid JSON.
  2. Validates the required field space.
  3. Validates that space is not "draft".
  4. Strips the integration field if present (not used for normalization).
  5. Delegates to LogtestService.executeNormalization(enginePayload).

RestPostLogtestDetectionAction

Path: rest/service/RestPostLogtestDetectionAction.java

The REST handler for POST /_plugins/_content_manager/logtest/detection. Enforces the body-size cap (413) the same way as the combined handler, then reads the request body and calls client.execute(LogtestDetectionAction.INSTANCE, ...). No field-level validation.

TransportLogtestDetectionAction

Path: transport/TransportLogtestDetectionAction.java

Responsibilities:

  1. Validates the request has content and is valid JSON.
  2. Validates the required fields space, integration, and input.
  3. Validates that space is not "draft".
  4. Validates that input is a JSON object (not a string or array).
  5. Delegates to LogtestService.executeDetectionAsync(integrationId, space, inputEvent).

All three transport actions run this work on the dedicated content_manager_logtest thread pool rather than the transport thread (see Request size limit and concurrency).

Request size limit and concurrency

The logtest endpoints are deliberately reachable by low-privilege accounts. To keep that surface from being used to exhaust the indexer’s heap, two independent controls bound the work each request can cause:

  • Body-size cap (413). Every logtest REST handler checks the raw request-body length against plugins.content_manager.logtest.max_body_bytes (default 1 MiB; dynamic; range 1 KiB–16 MiB) before parsing or dispatching, via the shared PayloadValidations.validateLogtestBodySize(...) helper. An oversized body is rejected with 413 REQUEST_ENTITY_TOO_LARGE and the message logtest payload exceeds the maximum allowed size of <N> bytes., and the offending account and size are logged at WARN. This removes the input-to-response amplification the endpoint would otherwise permit, since the input is bounded before any work happens.
  • Bounded thread pool (429). The three transport actions offload execution onto a dedicated FixedExecutorBuilder pool named content_manager_logtest (size max(1, allocatedProcessors / 2), queue 100), registered in ContentManagerPlugin.getExecutorBuilders(...). This keeps the blocking Engine-socket call off the transport threads, and when the bounded queue is full excess requests are shed with 429 TOO_MANY_REQUESTS (logtest is busy: too many concurrent requests. Please retry later.) instead of accumulating on the heap. Worst-case in-flight payload is bounded to roughly (pool size + queue) * max_body_bytes.

LogtestService

Path: cti/catalog/service/LogtestService.java

The orchestrator. Provides three public entry points:

  • executeLogtest() — Full combined flow (normalization + detection)
  • executeNormalization() — Engine-only: forwards payload to EngineService.logtest() and returns the response directly with parseMessageAsJson()
  • executeDetectionAsync() — Security Analytics-only: looks up integration, fetches rule IDs/bodies, evaluates via SecurityAnalyticsService.evaluateRulesAsync(), and returns the result

The full logtest flow:

  1. No-integration shortcut — If integrationId is null, delegates to executeEngineOnly(): runs the Engine normalization and returns the result with detection.status: "skipped" and reason: "'integration' field not provided". Steps 2–5 below are skipped.
  2. Integration lookup — Queries wazuh-threatintel-integrations for a document matching document.id == integrationId and space.name == space. Returns 400 if not found.
  3. Engine processing — Sends the event payload to the Wazuh Engine via EngineService.logtest(). Extracts the normalized event from the output field. The engine result fields (output, asset_traces, validation) are included directly in the response (no wrapper).
  4. Rule fetching — Extracts rule IDs from the integration’s document.rules array, then fetches rule bodies from wazuh-threatintel-rules by document.id, filtered by the same space.
  5. Security Analytics evaluation — Passes the normalized event JSON, the rule bodies and the integration’s detector coordinates to SecurityAnalyticsService.evaluateRulesAsync().
  6. Response building — Combines engine and Security Analytics results into a single JSON response under the keys normalization and detection.

Error handling:

  • If the Engine fails (HTTP error or exception), Security Analytics evaluation is skipped and the response includes status: "skipped" with the reason.
  • If no integration is provided, detection is skipped (normalization-only mode).
  • If the integration has no rules, Security Analytics returns rules_evaluated: 0, rules_matched: 0 with success status.
  • If Security Analytics evaluation returns unparseable JSON, the result is status: "error".

SecurityAnalyticsService / PercolateRuleEvaluator

The Security Analytics evaluation happens in the security-analytics repository:

  • SecurityAnalyticsServiceImpl.evaluateRulesAsync() — Sends the event, the rule bodies and the integration’s detector coordinates (id, log type, source indices) to the WEvaluateRulesAction transport action.
  • WTransportEvaluateRulesAction — Parses the rule bodies into SigmaRule objects and delegates to PercolateRuleEvaluator.
  • PercolateRuleEvaluator.evaluate() — Compiles each rule with OSQueryBackend, stores the compiled queries in the logtest percolator index, and percolates the normalized event against them. Returns a JSON result string.

Why percolation. Detection has to answer one question: would the deployed detector fire on this event? A detector answers it by percolating compiled query_string queries against its query index, so logtest answers it the same way. Evaluating the Sigma condition directly in the JVM would answer a subtly different question — it cannot reproduce query_string parsing, the query index’s analyzers, or the percolator’s own field-mapping requirements — which makes its answer an approximation rather than a prediction. Sharing the compiler, the analyzers and the percolator makes the two answers the same by construction, and keeps them that way as any of the three changes.

Everything the evaluator relies on is the same artifact production uses:

ConcernShared mechanism
Sigma → queryOSQueryBackend.convertRule(), as rule upload does
Analysisthe .opensearch-sap-*-detectors-queries* index template (rule_analyzer, rule_ws_normalizer)
Field mappingscopied from the integration’s source indices, with the same per-type overrides a detector’s query index gets
Matchingalerting’s percolate_ext query over a percolator_ext field

LogtestQueryIndex owns the percolator index, one per log type (.opensearch-sap-<logtype>-detectors-queries-logtest). The name is deliberately inside the detector query index template’s pattern, so the index inherits the analysis settings rather than redefining them. The index is created on demand and document ids are content-addressed, so a rule whose text has not changed reuses its document and repeated calls neither overwrite nor accumulate; documents left by an earlier revision of a rule are pruned once past a short grace period.

Match results use a nested rule object per match entry, where matched_conditions lists the conditions the event satisfies, one entry per condition:

{
  "rule": { "id": "...", "title": "...", "level": "...", "tags": [...] },
  "matched_conditions": ["http.request.method matched 'GET'", "url.original matched '*UNION SELECT*'"]
}

Data flow

Client request
    │
    ▼
RestPostLogtestAction (combined)
    │  reads body into LogtestRequest, no validation
    │  client.execute(LogtestAction.INSTANCE, ...)
    ▼
TransportLogtestAction
    │  validates request
    │  strips "integration" field
    ▼
LogtestService.executeLogtest(integrationId, space, payload)
    │
    ├──► [if integrationId == null]
    │       → executeEngineOnly(payload)
    │       → returns normalization + detection: { status: "skipped" }
    │
    ├──► client.prepareSearch("wazuh-threatintel-integrations")
    │       → finds integration in given space (test or standard)
    │       → extracts rule IDs from document.rules
    │
    ├──► engineService.logtest(payload)
    │       → sends to Wazuh Engine socket
    │       → receives normalized event
    │       → extracts "output" node as normalized event JSON
    │
    ├──► client.prepareSearch("wazuh-threatintel-rules")
    │       → fetches rule bodies by document.id + space filter
    │
    ├──► securityAnalytics.evaluateRulesAsync(normalizedEventJson, ruleBodies,
    │                                          integrationId, logType, sourceIndices)
    │       → parses YAML → SigmaRule objects
    │       → OSQueryBackend compiles each rule to a query_string query
    │       → queries are upserted into the logtest percolator index
    │       → the event is percolated against them
    │       → returns JSON result
    │
    └──► builds combined response
            { normalization: {...}, detection: {...} }

Split endpoints

In addition to the combined flow, there are two dedicated endpoints that execute normalization and detection independently:

RestPostLogtestNormalizationAction           RestPostLogtestDetectionAction
    │  no validation, delegates via client.execute()  │  no validation, delegates via client.execute()
    ▼                                            ▼
TransportLogtestNormalizationAction          TransportLogtestDetectionAction
    │  validates: space                          │  validates: space, integration, input
    │  strips integration field                  │
    ▼                                            ▼
LogtestService.executeNormalization(payload)  LogtestService.executeDetection(id, space, input)
    │                                            │
    └──► engineService.logtest(payload)          ├──► client.prepareSearch(".cti-integrations")
         → returns engine response directly      │       → finds integration
                                                 ├──► extractRuleIds() + fetchRuleBodies()
                                                 │       → fetches rule content from .cti-rules
                                                 └──► securityAnalytics.evaluateRulesAsync(inputJson, ruleBodies, ...)
                                                         → returns Security Analytics result directly

Key differences from the combined endpoint

  • Normalization returns the raw Engine response (no detection wrapper). The integration field is stripped if present but has no effect on behavior.
  • Detection accepts a pre-normalized event as the input JSON object. It does not call the Engine — it goes straight to integration lookup → rule fetch → Security Analytics evaluation.

Index dependencies

IndexUsageQuery
wazuh-threatintel-integrationsLook up integration by ID in the given spacedocument.id == X AND space.name == {space}
wazuh-threatintel-rulesFetch rule bodies by document IDs in the given spacedocument.id IN [...] AND space.name == {space}

Both indices must exist and have document.id mapped as keyword for term queries to work.

Security Analytics additionally reads the integration’s detector source indices (document.detector.source, or wazuh-events-v5-<category> when it names none) to build the percolator index mappings, and owns .opensearch-sap-<logtype>-detectors-queries-logtest.

Testing

Unit tests

Test classCovers
TransportLogtestActionTestsRequest validation for combined endpoint (empty body, invalid JSON, missing fields, wrong space, delegation to service)
TransportLogtestNormalizationActionTestsRequest validation for normalization endpoint (empty body, invalid JSON, missing space, invalid space, delegation, integration stripping)
TransportLogtestDetectionActionTestsRequest validation for detection endpoint (empty body, invalid JSON, missing fields, invalid space, non-object input, delegation)
LogtestServiceTestsOrchestration logic (integration lookup, engine errors, rule fetching, Security Analytics evaluation, response structure)
LogtestQueryIndexTestsPercolator index naming and the source-mapping copy (analysis overrides, merges, conflicts)
RuleTopicIndicesTestsThe query index analysis settings both a detector and logtest depend on

Integration tests

Test classCovers
LogtestITEnd-to-end REST workflow against a live test cluster (request validation, integration lookup, promote + logtest, response structure) and real percolation through the detection endpoint: exact-case matching, case-sensitivity, non-matching events, and rules reported as skipped

Integration tests extend ContentManagerRestTestCase and run against a real OpenSearch cluster. Since the Wazuh Engine is not available in the test environment, engine-dependent tests validate graceful error handling (engine error → Security Analytics skipped). Detection is not mocked: the test cluster installs Security Analytics and alerting, and plugins.content_manager.security_analytics.mock is set to false there so rule evaluation percolates for real. plugins.content_manager.engine.mock stays true because the Engine’s Unix socket genuinely is absent; the two settings are separate for exactly this reason.

Adding new logtest features

Supporting a new validation field

  1. Add the field constant to Constants.java.
  2. Add validation logic in the relevant transport action(s): TransportLogtestAction, TransportLogtestNormalizationAction, and/or TransportLogtestDetectionAction. The REST handlers themselves need no changes — they only read the body and dispatch.
  3. Add unit tests in the corresponding test classes.
  4. Add integration test in LogtestIT.

Supporting a new Engine response field

  1. Update LogtestService.executeEngine() to extract the field.
  2. Include it in the normalization map within buildCombinedResponse().
  3. Add unit test scenarios in LogtestServiceTests.
  4. Update the API docs (api.md) response fields table.

Extending Security Analytics evaluation

Detection semantics live in the Sigma compiler, not in logtest: a new modifier is implemented in rules/modifiers/ and rendered by OSQueryBackend, and logtest picks it up for free because it compiles rules with the same backend.

  1. Add the modifier under rules/modifiers/ and register it in SigmaModifierFacade.
  2. Render it in OSQueryBackend, and pin the generated query in QueryBackendTests.
  3. Cover it end to end in LogtestIT (detection endpoint) so the percolate path is exercised.
  4. Update the Sigma rules doc (Sigma Rules), including the case-sensitivity behaviour.

Wazuh Indexer Notifications plugin — development guide

This document describes the architecture, components, and extension points of the Notifications plugin, which provides multi-channel notification capabilities to the Wazuh Indexer.


Overview

The Notifications plugin handles:

  • Channel Management: CRUD operations for notification channels (Slack, Email, Chime, Microsoft Teams, Webhooks, SNS, SES, Wazuh Active Response).
  • Message Delivery: Abstracts different communication protocols (SMTP, HTTP, AWS SES/SNS) into a unified transport layer.
  • Test Notifications: Allows sending test messages to validate channel configuration.
  • Plugin Features: Exposes dynamic feature discovery so other plugins can query supported notification types.
  • Security Integration: Integrates with the Wazuh Indexer Security plugin for RBAC-based access control.

Project structure

The plugin is organized into three Gradle subprojects:

SubprojectDescription
notifications/core-spiService Provider Interface. Defines destination models (SlackDestination, SmtpDestination, ChimeDestination, etc.) and the NotificationCore contract.
notifications/coreCore implementation. Contains HTTP/SMTP/SES/SNS clients, transport providers, and all configurable settings (PluginSettings).
notifications/notificationsMain plugin module. Registers REST handlers, transport actions, index operations, metrics, and security access management.

Class hierarchy

Destination models (core-spi)

BaseDestination
├── SlackDestination
├── ChimeDestination
├── MicrosoftTeamsDestination
├── CustomWebhookDestination
├── WebhookDestination
├── SmtpDestination
├── SesDestination
├── SnsDestination
└── ActiveResponseDestination

ActiveResponseDestination has no transport: active_response channels are handled in the notifications module (see Notification flow).

Transport layer (core)

DestinationTransport (interface)
├── WebhookDestinationTransport      (Slack, Chime, Teams, Webhooks)
├── SmtpDestinationTransport         (SMTP Email)
├── SesDestinationTransport          (AWS SES Email)
└── SnsDestinationTransport          (AWS SNS)

REST handlers (notifications)

HandlerMethodURI
NotificationConfigRestHandlerPOST/_plugins/_notifications/configs
PUT/_plugins/_notifications/configs/{config_id}
GET/_plugins/_notifications/configs/{config_id}
GET/_plugins/_notifications/configs
DELETE/_plugins/_notifications/configs/{config_id}
DELETE/_plugins/_notifications/configs
NotificationFeaturesRestHandlerGET/_plugins/_notifications/features
NotificationChannelListRestHandlerGET/_plugins/_notifications/channels
SendTestMessageRestHandlerPOST/_plugins/_notifications/feature/test/{config_id}

Setup environment

Requirements

  • JDK: 21 or later. The build compiles to Java 21 (sourceCompatibility, targetCompatibility and Kotlin jvmTarget in notifications/build.gradle). CI builds with JDK 21 and 25, and the Wazuh Indexer bundles JDK 25.
  • Gradle: Use the included ./gradlew wrapper (no separate install needed).
  • IDE: IntelliJ IDEA with Kotlin plugin is recommended.

Clone and build

The build resolves wazuh-indexer-common-utils from the local Maven repository, so publish it first by running ./gradlew publishToMavenLocal in a checkout of that repository. The Gradle wrapper lives in the notifications/ directory:

git clone <notifications-repo-url>
cd wazuh-indexer-notifications/notifications
./gradlew build

The distribution zips are generated at (paths relative to the repository root):

notifications/core/build/distributions/wazuh-indexer-notifications-core-<version>.zip
notifications/notifications/build/distributions/wazuh-indexer-notifications-<version>.zip

Build packages

To create distribution packages:

# Full build (compile + test + assemble)
./gradlew build

# Assemble only (skip tests)
./gradlew assemble

The output zips can be installed on a Wazuh Indexer node with opensearch-plugin. Install the core plugin first, since the Notifications plugin extends it:

bin/opensearch-plugin install file:///path/to/wazuh-indexer-notifications-core-<version>.zip
bin/opensearch-plugin install file:///path/to/wazuh-indexer-notifications-<version>.zip

Run tests

Unit tests

./gradlew test

Integration tests

The integration test suite is located at:

notifications/notifications/src/test/kotlin/org/opensearch/integtest/

To execute the full integration test suite, from the notifications/ directory:

./gradlew :notifications:integTest

Key integration test classes:

Test ClassDescription
SlackNotificationConfigCrudITFull CRUD lifecycle for Slack channels.
ChimeNotificationConfigCrudITFull CRUD lifecycle for Chime channels.
EmailNotificationConfigCrudITFull CRUD lifecycle for Email channels (SMTP/SES).
MicrosoftTeamsNotificationConfigCrudITFull CRUD lifecycle for Microsoft Teams channels.
WebhookNotificationConfigCrudITFull CRUD lifecycle for custom webhooks.
SnsNotificationConfigCrudITFull CRUD lifecycle for SNS channels.
CreateNotificationConfigITConfig creation edge cases and validation.
DeleteNotificationConfigITConfig deletion including bulk delete.
QueryNotificationConfigITFiltering, sorting, and pagination queries.
GetPluginFeaturesITFeature discovery endpoint tests.
GetNotificationChannelListITChannel list endpoint tests.
SendTestMessageRestHandlerITTest message delivery flow.
SendTestMessageWithMockServerITTest message with mock destination.
SecurityNotificationITRBAC and access control tests.
MaxHTTPResponseSizeITHTTP response size limit enforcement.
NotificationsBackwardsCompatibilityITBackwards compatibility between versions.

Notification flow

The data flow when sending a notification follows this sequence:

sequenceDiagram
    participant C as Alerting / Reporting / ISM
    participant N as SendNotificationAction
    participant CI as .opensearch-notifications-config
    participant T as DestinationTransportProvider
    participant EXT as External service
    participant B as ActiveResponseBulkIndexer
    participant AR as wazuh-active-responses

    C->>N: sendNotification (channel IDs, message)
    N->>N: UserAccessManager.validateUser
    N->>CI: Get channel configs (and email account/group configs)
    CI-->>N: Configs
    loop Each channel, in parallel (Dispatchers.IO)
        alt Missing, access denied or muted
            N->>N: Error EventStatus, nothing sent
        else Slack, Chime, Teams, webhook, email, SNS
            N->>T: NotificationCore.sendMessage(destination)
            T->>EXT: Webhook / SMTP / SES / SNS transport
            EXT-->>T: Response
            T-->>N: DestinationMessageResponse
        else active_response
            N->>N: Get the triggering document, build the request
            N->>B: add(IndexRequest)
            B->>AR: Bulk on flush interval or max actions
        end
    end
    N-->>C: SendNotificationResponse (EventStatus per channel)
  1. An internal plugin (Alerting, Reporting, ISM) or a user invokes the Notification plugin via Transport or REST API. SendNotificationAction delegates to SendMessageActionHelper.executeRequest.
  2. UserAccessManager.validateUser rejects a user without backend roles when filter_by_backend_roles is enabled.
  3. The channel configs, and the smtp_account/ses_account/email_group configs that email channels reference, are read from the .opensearch-notifications-config index (NotificationConfigIndex). A missing, inaccessible or disabled (muted) channel gets an error EventStatus and is skipped.
  4. For the remaining channels, in parallel, sendMessageThroughSpi calls NotificationCore.sendMessage, and the DestinationTransportProvider resolves the correct transport based on the destination type. Webhook URLs are checked against host_deny_list before sending.
  5. The transport client delivers the message to the external service. There is no plugin-level retry: only the default retry strategy of the Apache HTTP client (DefaultHttpRequestRetryStrategy) applies, to webhook deliveries. A failed delivery is returned as a non-200 DeliveryStatus.
  6. active_response channels never reach NotificationCore. sendActiveResponseMessage parses <doc_id>|<index> from the message, reads that document, copies its wazuh object (refusing with 400 when location is local and the document has no wazuh.agent.id), adds wazuh.active_response with the channel parameters, and adds an IndexRequest (op type create) for the wazuh-active-responses data stream to the ActiveResponseBulkIndexer. The status is 200 as soon as the request is queued; the BulkProcessor flushes every opensearch.notifications.active_response.bulk_flush_interval_ms or after bulk_max_actions requests, and bulk failures are only logged and counted in a metric.
  7. The EventStatus list is returned to the caller in a SendNotificationResponse. If any channel’s status is not 200, the action fails with an OpenSearchStatusException that carries the whole event_status_list. Nothing is persisted about the delivery itself.

Default channel initialization

The plugin creates a set of default notification channels on startup so that users have pre-configured templates for common integrations (Slack, Jira, PagerDuty, Shuffle). These channels are created disabled with placeholder URLs.

Implementation

The feature is implemented in DefaultChannelInitializer (notifications/notifications/src/main/kotlin/.../index/DefaultChannelInitializer.kt).

Adding or modifying default channels

To add a new default channel:

  1. Add a new ChannelDefinition entry to the DEFAULT_CHANNELS list in DefaultChannelInitializer.kt.
  2. Choose a unique, stable id prefixed with default_ (e.g., default_teams_channel).
  3. Set isEnabled = false and use a placeholder URL with clear instructions in the description.
  4. Add a corresponding test case in DefaultChannelInitializerTests.kt.

ClusterPlugin interface

The NotificationPlugin class implements ClusterPlugin to gain access to the onNodeStarted(DiscoveryNode) lifecycle hook.

Testing

Unit tests for the default channel initialization are in:

notifications/notifications/src/test/kotlin/.../index/DefaultChannelInitializerTests.kt

The tests verify:

  • All default channel definitions have valid configurations.
  • Channel IDs are unique and follow the naming convention.
  • The initializer correctly identifies missing channels and skips existing ones.

Extending with a new destination

To add a new notification destination:

  1. Define the destination model in core-spi:

    • Create a new class extending BaseDestination in notifications/core-spi/src/main/kotlin/.../destination/.
  2. Implement the transport in core:

    • Create a new class implementing DestinationTransport in notifications/core/src/main/kotlin/.../transport/.
    • Register it in DestinationTransportProvider.
  3. Add the config type to the DEFAULT_ALLOWED_CONFIG_TYPES list in core/setting/PluginSettings.kt.

  4. Write tests: Add integration tests in notifications/notifications/src/test/kotlin/org/opensearch/integtest/config/.

Security Analytics

The Security Analytics plugin is a fork of the OpenSearch Security Analytics plugin adapted for Wazuh. This page documents Wazuh-specific implementation details and extensions. See Architecture for the conceptual overview.

A note on naming: the Reference Manual documents this plugin as Ruleset Management, the name it is presented under in the Wazuh Dashboard. The Development Guide keeps the name Security Analytics, which is what the code, the plugins.security_analytics.* settings, the /_plugins/_security_analytics/ API paths, the .opensearch-sap-* indices and the wazuh-indexer-security-analytics repository all use. Both names describe the same plugin — see Ruleset Management for the user-facing reference.

Enriched findings pipeline

WazuhEnrichedFindingService implements the enrichment pipeline described in the Reference Manual’s architecture page.

Its input is the in-memory Finding the Alerting plugin publishes for every match: the doc-level monitor fan-out (TransportDocLevelMonitorFanOutAction.createFindings()) calls AlertingPluginInterface.publishFinding(), TransportCorrelateFindingAction receives it as SUBSCRIBE_FINDINGS_ACTION and dispatches it to enrich(). Publishing does not depend on Alerting storing the finding: the raw .opensearch-sap-{log_type}-findings-* indices are only written when plugins.alerting.alert_finding_enabled is true, which defaults to false in the Wazuh fork, so with the shipped defaults the enriched wazuh-findings-v5-{category}* documents are the only stored findings.

Fire-and-forget execution

WazuhEnrichedFindingService.enrich() returns immediately after adding the finding to the internal queue. All network I/O and document assembly happen on async transport threads. Failures are logged at WARN level and never surface to the Security Analytics write path.

Bounded, batch-oriented concurrency

Enrichment is batch-oriented, not per-finding: processQueue() drains the internal findingsQueue in batches of up to enriched_findings_enrich_batch_size findings (default 100, range 1–1000, dynamic) and acquires a single semaphore permit for the whole batch, not one permit per finding. The semaphore is an AdjustableSemaphore sized by enriched_findings_max_in_flight (default 5, range 1–10, dynamic) — its permit count can be resized live via setMaxInFlight() when the setting changes, with no restart required. Batches that arrive while all permits are held stay queued in findingsQueue until a permit frees up.

Within a batch, per-finding completion is tracked with an AtomicInteger remaining counter; the batch’s single permit is only released once every finding in the batch has completed (onOneDone callback).

Batched triggering-event fetch

Instead of one GetRequest per finding, the service fetches all triggering events for a batch in a single deduplicated MultiGetRequest (deduplicated by index|docId, since multiple findings in a batch can share the same source event). This is the core throughput optimization: it eliminates roughly enrichBatchSize - 1 out of every enrichBatchSize round-trips to the event index under load. Rule-metadata lookups are unaffected by this batching and remain per-finding (see below).

Rule metadata cache

Rule metadata (severity level, compliance mappings, MITRE ATT&CK tags) is cached in a LinkedHashMap in access-order mode wrapped with Collections.synchronizedMap, with an overridden removeEldestEntry providing LRU eviction — not a plain ConcurrentHashMap (which has no eviction capability). The cache is bounded by plugins.security_analytics.enriched_findings_rule_cache_max_size (default 10000, minimum 0). Unlike the other enriched-findings settings, this one is static: it has no registered settings-update-consumer, so changing it requires a node restart.

On a cache miss, the service issues a MultiGetRequest against both the pre-packaged rules index (.opensearch-sap-pre-packaged-rules-config, Rule.PRE_PACKAGED_RULES_INDEX) and the custom rules index (.opensearch-sap-custom-rules-config, Rule.CUSTOM_RULES_INDEX). Subsequent findings from the same detector reuse the cached entry, eliminating repeated round-trips.

Bulk indexing

Index requests are accumulated in a ConcurrentLinkedQueue<IndexRequest>. Two flush paths drain this queue:

  • Batch trigger: every time the pending count reaches a multiple of enriched_findings_bulk_size (default 100, range 10–1000, dynamic), the thread that incremented the counter calls drainAndFlush() immediately.
  • Periodic flush: a fixed-delay scheduler fires drainAndFlush() every enriched_findings_flush_interval (default 5 seconds, range 1–60, dynamic) to drain any remainder that has not yet reached the batch threshold. Changing this setting at runtime cancels and reschedules the flush job (setFlushInterval()).

drainAndFlush() polls all pending requests into a single BulkRequest and calls client.bulk(). The call is wrapped in threadPool.getThreadContext().stashContext() so the security plugin accepts the request regardless of which thread pool the flush runs on.

Document build offloading

Synchronous document-assembly work (copying event sources, interpolating templates) runs on the GENERIC thread pool rather than the transport/listener thread that completed the upstream MultiGetRequest — this keeps that work from competing with request handling on the transport thread.

Category resolution

Before assembling an enriched document, the service reads wazuh.integration.category from the triggering event. If the field is absent or its value is not one of the recognized LOG_CATEGORY values, enrichment is skipped for that finding and a WARN log entry is emitted.

Document layout

buildDocAndIndex starts from a shallow copy of the triggering event source and overlays the following fields:

FieldSource
@timestamp@timestamp of the original triggering event
event.*Pre-existing event fields plus doc_id, index
wazuh.ruleSigma rule metadata (id, title, tags, and any of sigma_id, level, status, compliance, mitre present in the rule index entry)

Rule metadata is nested under wazuh.rule. title and tags come from the matched DocLevelQuery, not from the rule index entry: the query’s tags are the rule’s level, its log type (the integration) and then its Sigma tags, so wazuh.rule.tags carries all three. mitre keeps the per-category layout of SigmaMitre.toMitreMap(); subtechniques are never folded into technique. Because the event’s wazuh map (which carries wazuh.integration.*) is shared with the shallow copy, the service defensively copies it before adding rule, so the original event source is never mutated.

event.index is finding.getIndex(), the concrete backing index (.ds-wazuh-events-v5-<category>-<generation>), not the data stream name. The document _id is not generated by OpenSearch: dedupId() hashes the backing index, the event _id and the rule ID with SHA-256, keeps the first 128 bits and encodes them as base64url, and the request uses op type create. A finding replayed for the same event and rule therefore fails as a duplicate instead of being indexed twice, as long as it lands in the same backing index: OpenSearch only enforces _id uniqueness within a backing index, not across a data stream’s rollovers. No field holds the time the enriched finding was built: the timestamp of the Alerting Finding is not copied.

The user-facing description of these fields is in the Reference Manual’s Wazuh Common Schema page.

Sequence diagram

sequenceDiagram
    participant A as Wazuh Manager
    participant I as Wazuh Indexer
    participant AL as Alerting (doc-level monitor)
    participant TC as TransportCorrelateFindingAction
    participant WS as WazuhEnrichedFindingService
    participant SI as Source Index
    participant RI as Rules Index
    participant WF as wazuh-findings-v5-{category}*

    A->>I: Ingest event
    I->>AL: Monitor evaluates event against Sigma rules
    AL->>AL: Rule matches → create raw finding (indexed only if plugins.alerting.alert_finding_enabled)
    AL->>TC: publishFinding() → SUBSCRIBE_FINDINGS_ACTION
    TC->>WS: enrich(finding)
    WS->>WS: Add to findingsQueue
    WS->>WS: processQueue() drains a batch (up to enrichBatchSize findings)
    WS->>WS: Acquire semaphore permit for the whole batch (max_in_flight)
    WS->>SI: MultiGetRequest (deduplicated triggering events for the batch)
    SI-->>WS: Event source maps
    loop For each finding in the batch
        WS->>WS: resolveCategory(wazuh.integration.category)
        alt Rule metadata cache hit
            WS->>WS: Read from ruleMetadataCache
        else Cache miss
            WS->>RI: MultiGetRequest (pre-packaged + custom rules indices)
            RI-->>WS: Rule metadata
            WS->>WS: Store in ruleMetadataCache
        end
        WS->>WS: buildDocAndIndex (assemble enriched document, on GENERIC thread pool)
        WS->>WS: Add to pendingRequests queue
    end
    alt Batch trigger (bulk_size reached)
        WS->>WF: client.bulk (stashed thread context)
    else Periodic flush (every flush_interval)
        WS->>WF: client.bulk (stashed thread context)
    end
    WS->>WS: Release batch's semaphore permit once every finding in it has completed

Tuning settings

  • plugins.security_analytics.enriched_findings_bulk_size (default 100, range 10–1000, dynamic) — bulk flush batch size: number of pending index requests accumulated before a batch-trigger flush.
  • plugins.security_analytics.enriched_findings_max_in_flight (default 5, range 1–10, dynamic) — maximum number of concurrent in-flight enrichment batches.
  • plugins.security_analytics.enriched_findings_flush_interval (default 5 seconds, range 1–60, dynamic) — interval between periodic flush runs.
  • plugins.security_analytics.enriched_findings_enrich_batch_size (default 100, range 1–1000, dynamic) — number of findings drained from the queue per in-flight permit.
  • plugins.security_analytics.enriched_findings_rule_cache_max_size (default 10000, minimum 0, static — requires a node restart) — maximum number of rule-metadata entries cached in memory.
  • Index operation type (CREATE, not configurable) — prevents overwriting existing enriched findings.

See the Configuration reference for the full settings list.

Detector provisioning

Threat detectors for Wazuh integrations are created dynamically based on CTI content, via a request-driven model (WIndexDetectorRequest) rather than hardcoded configuration.

Dynamic detector factory

The DetectorFactory class assembles the Detector object, consuming parameters provided by the Content Manager:

  • Enabled status: controlled by CTI to activate or deactivate detectors globally.
  • Scan interval: customizable per integration (e.g., critical integrations can have shorter intervals).
  • Source indices: defines the target indices or index patterns the detector monitors.

Fallback logic

To ensure system stability, DetectorFactory implements a fallback mechanism for source indices:

  • If the sources list is provided and not empty, it is used as the detector’s input.
  • If sources is null or empty, the factory defaults to the legacy pattern: wazuh-events-v5-{category}.

Dynamic configuration injection

WTransportIndexDetectorAction serves as the entry point for detector creation. It extracts the enabled, interval, and sources fields from the WIndexDetectorRequest and injects them into the factory method. This ensures that any change in the CTI catalog is reflected in the Security Analytics engine without requiring code changes or restarts.

Case management

Case management adds triage capabilities to Security Analytics findings, allowing analysts to track status, classification, a multi-comment discussion thread, tags, and user attribution on individual findings.

Case fields

WCS fields under wazuh.case, all defined in the findings index template:

  • wazuh.case.title (match_only_text) — case summary.
  • wazuh.case.description (match_only_text) — case description.
  • wazuh.case.tags (keyword, array) — organizational tags.
  • wazuh.case.user.name (keyword) — user who performed the update.
  • wazuh.case.status (keyword) — workflow status: active, acknowledged, completed, error, deleted, audit (lowercase).
  • wazuh.case.severity (keyword) — informational, low, medium, high, critical (lowercase).
  • wazuh.case.priority (keyword) — low, medium, high, urgent (lowercase).
  • wazuh.case.tlp (keyword) — TLP:RED, TLP:AMBER, TLP:GREEN, TLP:CLEAR (uppercase, TLP: prefix — the one enum field that isn’t lowercase).
  • wazuh.case.created_at, wazuh.case.updated_at (date) — case timestamps.
  • wazuh.case.comments (array of objects) — replaces the old single comment field. Each entry has author (keyword), created_at (date), updated_at (date), and comment (match_only_text). The WCS source declares the field nested, but the generated findings template maps it through dynamic templates, so the shipped indices map it as a plain object.

These fields are present in the index template but not populated at finding creation time — they are written exclusively through the update endpoint.

REST endpoint

RestUpdateFindingsAction

File: src/main/java/org/opensearch/securityanalytics/resthandler/RestUpdateFindingsAction.java

Route: PUT /_plugins/_security_analytics/findings/_update

Design decisions

  1. Bulk-based: the number of finding updates per call is capped by the dynamic setting plugins.security_analytics.max_case_management_bulk_size (SecurityAnalyticsSettings.MAX_CASE_MANAGEMENT_BULK_SIZE, default 10, range 0–100), read from ClusterSettings on every request. 0 disables the endpoint.

  2. Partial doc update: uses UpdateRequest.doc() which merges the provided fields into the existing document. Only wazuh.case is touched, other finding fields are never modified. Arrays (tags, comments) are replaced, not appended to.

  3. Schema validation: CaseValidator.validateAndNormalize() checks each case object against the WCS fields before the bulk request is built. Unknown keys (in case, case.user or a comment) and enum values outside the allowed sets are rejected; status, severity and priority are lowercased and tlp uppercased in place, so stored values are always in the canonical case that keyword queries must use.

Request validation

The handler performs eager validation before building the bulk request:

CheckHTTP statusMessage
Invalid/missing JSON body400Invalid JSON body: ...
Missing findings array400Request body must contain a "findings" array
Empty findings array400Findings array is empty
Bulk size limit set to 0400Case management is disabled
More than the limit400Cannot update more than N findings at once (N = max_case_management_bulk_size)
Element not a JSON object400Element at index N is not a JSON object
Missing _id400Element at index N is missing _id
Missing _index400Element at index N is missing _index
Missing/invalid case400Element at index N is missing or invalid case object
case fails CaseValidator400Element at index N: <reason> (e.g. unknown case field "comment")

Validation errors short-circuit, the first error aborts the entire request.

Response format

{
  "took": 12,
  "errors": false,
  "items": [
    {
      "_id": "...",
      "_index": "...",
      "status": 200,
      "result": "updated"
    }
  ]
}
  • On full success: HTTP 200
  • When any item fails (for example, a finding that does not exist), including when every item fails: HTTP 207 MULTI_STATUS, with each item’s own status
  • When the bulk request itself fails: HTTP 500

Registration

The handler is registered in SecurityAnalyticsPlugin.getRestHandlers():

new RestUpdateFindingsAction(clusterSettings)

Testing

Integration tests live in src/test/java/org/opensearch/securityanalytics/resthandler/UpdateFindingsIT.java.

The test class extends SecurityAnalyticsRestTestCase and covers:

  • Happy path: single update with all fields, partial updates, bulk updates, overwrite scenarios
  • Validation: empty array, missing fields (_id, _index, case), invalid JSON, exceeding max bulk items
  • Error handling: non-existent document (expects 207), response structure verification
  • Helpers: creates a temporary index with the wazuh.case mapping and indexes minimal finding documents for testing

Tests use the REST test client (makeRequest) and don’t require a full detector/monitor setup since the endpoint operates directly on documents by _id and _index.

Sequence diagram

sequenceDiagram
    participant UI as Wazuh Dashboard
    participant SA as Security Analytics
    participant OS as OpenSearch (Bulk API)
    participant FI as Findings Index

    UI->>SA: PUT /findings/_update { findings: [...] }
    SA->>SA: Validate request (JSON, required fields, limits)
    alt Validation fails
        SA-->>UI: 400 Bad Request
    else Validation passes
        SA->>OS: BulkRequest (UpdateRequest per finding)
        OS->>FI: Update doc (merge wazuh.case)
        FI-->>OS: Update result
        OS-->>SA: BulkResponse
        alt All succeeded
            SA-->>UI: 200 OK { took, errors: false, items }
        else Partial failure
            SA-->>UI: 207 Multi-Status { took, errors: true, items }
        end
    end

Wazuh Indexer Common Utils — development guide

wazuh-indexer-common-utils is a Wazuh fork of the OpenSearch Common Utils library. It is not a standalone plugin — it ships as a shared JAR dependency consumed by the Wazuh forks of Alerting, Notifications, and Security Analytics, and by the Content Manager plugin.

What it provides

The library defines cross-plugin models and transport-action contracts so that plugins can call each other’s functionality without a direct compile-time dependency on each other’s internals:

  • org.opensearch.commons.alerting — shared alerting models and AlertingPluginInterface, the transport bridge other plugins use to call into Alerting (e.g., Security Analytics fetches findings via AlertingPluginInterface.INSTANCE.getFindings()).
  • org.opensearch.commons.notifications — shared notification channel/config models and NotificationsPluginInterface, used to send notifications from other plugins without depending on the Notifications plugin directly.
  • org.opensearch.commons.notifications.model.ActiveResponse — the Active Response channel definition; the Wazuh-specific extension that lets a Notifications channel drive Active Response execution requests.
  • org.opensearch.commons.replication — shared cross-cluster replication models and ReplicationPluginInterface.
  • org.opensearch.commons.authuser — shared user/role context passed across plugin boundaries for RBAC enforcement.
  • org.opensearch.commons.destination — shared destination message/response models used by notification transports.

Relationship to the Security Analytics commons/ submodule

Don’t confuse this repository with the commons/ submodule inside wazuh-indexer-security-analytics (wazuh-indexer-security-analytics/commons/src/main/java/com/wazuh/securityanalytics/action/). That submodule defines the W*Action classes (WIndexIntegrationAction, WIndexRuleAction, WIndexDetectorAction, etc.) used specifically for Content Manager → Security Analytics communication — it’s a separate, narrower set of shared classes scoped to that one integration, not part of this library.

Working with this library

Changes here affect every plugin that depends on it. When modifying a shared model or interface:

  1. Check all consumers (wazuh-indexer-alerting, wazuh-indexer-notifications, wazuh-indexer-security-analytics, and wazuh-indexer-plugins’ Content Manager) for usages before changing a method signature or model field.
  2. Bump the version and republish before consumers can pick up the change — this is a versioned dependency, not a monorepo shared source set.
  3. See RELEASING.md and CHANGELOG.md in the repository root for the release process.

Claude Code skills

Wazuh Indexer development is assisted by Claude Code skills: packaged, versioned instructions that live under .claude/skills/<name>/ in this repository. A skill captures a process or a body of domain knowledge that’s too detailed to keep in a contributor’s head, too easily forgotten between sessions, or too specific to belong in the top-level CLAUDE.md.

Structure

Each skill is a directory containing:

  • SKILL.md — required. Frontmatter with a name and a description (used to match the skill to a task automatically), followed by the actual instructions.
  • Related artifacts — optional supporting files referenced from SKILL.md, such as a style guide or a settings catalog, kept alongside the skill so they version together with it.

A skill is invoked automatically when a task matches its description, or explicitly by name (for example, /docs-review).

Available skills

  • Documentation review — audits the mdBook documentation tree for coverage gaps, staleness against source, and style violations.
  • Performance tuning — reduces or validates Wazuh Indexer’s memory, CPU, and GC footprint via settings and index/shard topology changes.

When you add a new skill under .claude/skills/, add a corresponding page here so it stays discoverable outside of Claude Code itself.

Documentation review

The docs-review skill audits docs/ — the mdBook site covering the Development Guide and Reference Manual — for coverage gaps, staleness against source code, structural and navigation issues, and style inconsistency.

The skill produces a findings report; it does not rewrite prose itself. Rewriting is a separate, later phase driven by the style guide below.

Process

The audit runs in five passes, each building on the last:

  1. Inventory the doc tree against SUMMARY.md — orphaned files, broken links, stub pages.
  2. Cross-check coverage against source — plugins, REST endpoints, settings, and documented mechanisms or call chains, not just names that still technically exist.
  3. Review structure and navigation — heading hierarchy, table-of-contents depth, cross-linking, and whether content sits in the right track.
  4. Audit style and consistency against the style guide below, including a set of known bug patterns (bolded pseudo-headings, stale numeric claims baked into prose or diagrams, incomplete diagram branches) that have recurred since the last pass.
  5. Write the findings report.
  • SKILL.md — the full instructions, including the known bug patterns to grep for and what’s already fixed versus intentionally deferred (for example, ref/glossary.md is a known-empty stub).
  • STYLE_GUIDE.md — the actual rules produced from a prior audit (heading case, terminology, table-vs-list, register per track, and more). This is the checklist the style pass runs against.

Usage

Invoke with /docs-review when asked to review, audit, or assess the Wazuh Indexer documentation. A full audit and module-by-module rewrite has already been done once; re-running the skill is a regression/drift check against an already-conforming corpus, not a first pass.

Performance tuning

The perf-tuning skill reduces or validates Wazuh Indexer 5.0’s memory, CPU, and GC footprint by tuning existing OpenSearch and plugin settings, or by changing index/shard topology. It covers content-manager, security-analytics, alerting, and setup settings, plus OpenSearch core settings such as thread pools, circuit breakers, and indexing buffers.

Mental model

The skill separates every memory-relevant setting into one of three mechanisms: blast radius (peak memory held during a fan-out), retained state (steady-state resident bytes independent of any single burst), and structural shard count (Lucene index/shard objects resident regardless of load). Attributing an observed effect to the wrong mechanism is the most common way to draw a wrong conclusion from a load test.

Known traps

The skill documents confirmed dead ends and counterintuitive findings from prior investigation rounds — for example, a shard-consolidation attempt that increased memory rather than reducing it, because Security Analytics scopes detectors purely by which index they read, and why zeroing the correlation cache TTLs can trade heap for CPU/search-thread-pool load on a small VM instead of saving memory. See SKILL.md for the full list — re-deriving these costs real load-test time.

  • SKILL.md — methodology, known traps, and the Docker-versus-VM division of labor for running a test.
  • SETTINGS_CATALOG.md — every candidate setting across content-manager, security-analytics, alerting, and setup, cross-checked against the actual registered Setting<?> objects in source, with confirmed defaults, ranges, dynamic/static status, and recommended test values.

Usage

Invoke when asked to investigate, reduce, or test Wazuh Indexer memory usage, heap pressure, circuit breaker trips, or shard/index count, or to tune throughput-versus-memory tradeoffs. The skill relies on an internal load-test harness and VM that aren’t part of this repository — see SKILL.md’s “Related artifacts” section for access.

Reference manual

This section contains the Wazuh Indexer reference manual, composed by installation, upgrade, removal, restore and configuration instructions, performance and security recommendations, architecture and API references for each of the modules composing the Wazuh Indexer.

About the examples

API examples authenticate as admin with $WAZUH_INDEXER_ADMIN_PASSWORD. That password is generated during installation and is unique to each deployment, so load it into your shell before running them:

WAZUH_INDEXER_ADMIN_PASSWORD=$(sudo grep '^WAZUH_INDEXER_ADMIN_PASSWORD=' /etc/wazuh-indexer/export/credentials.env | cut -d= -f2- | tail -n 1)

See Retrieving the generated credentials.

Description

The Wazuh Indexer is a highly scalable, full-text search and analytics engine built on top of OpenSearch 3. In Wazuh 5.0 it becomes the core component of the Wazuh platform: in addition to indexing and storing security data, it now embeds the Wazuh Engine and hosts the threat detection, alerting, notification, reporting, and content management logic that previously ran on the Wazuh Manager.

The Wazuh Indexer can be deployed as a single-node instance for development and small environments, or as a multi-node cluster for production workloads requiring high availability and horizontal scalability.

What’s new in 5.0

Wazuh 5.0 consolidates most of the platform’s data plane and detection logic inside the Indexer:

  • The Wazuh Engine is bundled into the Wazuh Indexer packages and Docker images (x86_64 and aarch64). Plugins communicate with the Engine over a local Unix socket.
  • Threat detection has been migrated from the Wazuh Manager to the Indexer through the Ruleset Management plugin (a Wazuh fork of the OpenSearch Security Analytics plugin), with extended Sigma rules syntax and per-space rules, log types and detectors.
  • Active Response has been migrated to the Indexer, driven by a dedicated Alerting monitor and persisted in the wazuh-active-responses data stream.
  • Filebeat is no longer used to forward events between the Wazuh Manager and the Indexer. Events now reach the Indexer through a built-in indexer connector.
  • Time-series data (events, findings, metrics, raw events, active responses) is stored in data streams with ISM policies for automatic rollover and retention.
  • A new Content Manager plugin owns the lifecycle of detection content (ruleset, vulnerabilities feed, IoC feed) and exposes a REST API for user-defined content with a draft → test → custom promotion workflow.
  • Case Management is introduced as a first-class feature — analysts can create cases directly from findings, attach evidence, assign ownership, track status through a configurable workflow, and generate reports, without leaving the platform.
  • The Wazuh Common Schema (WCS) has been reworked and now lives in the wazuh-indexer-plugins repository, bumped to ECS 9.1.0, with per-category event and finding data streams.

See the release notes for the full list of changes and breaking changes.

Core concepts

The Wazuh Indexer stores data as JSON documents. Each document contains a set of fields (keys) mapped to values — strings, numbers, booleans, dates, arrays, nested objects, and more.

An index is a collection of related documents. For time-series data such as events, findings, metrics and active responses, the Wazuh Indexer uses data streams backed by rolling indices, managed by Index State Management (ISM) policies that handle rollover and retention based on age and size.

Documents are distributed across shards spread across cluster nodes. This distribution provides redundancy against hardware failures and allows query throughput to scale as nodes are added.

Detection content (rules, decoders, integrations, KVDBs, filters, policies, IoCs and the vulnerabilities feed) is organized into spaces:

  • standard — read-only content sourced from the Wazuh CTI API.
  • draft — user-editable workspace for new or modified resources.
  • test — staging space used to validate draft content against the Wazuh Engine.
  • custom — promoted user-defined content active in the Engine.

The Content Manager enforces the draft → test → custom promotion workflow and keeps the Engine synchronized with the active content.

Bundled plugins

The Wazuh Indexer ships with a curated set of plugins. Some are Wazuh-developed; others are Wazuh forks of upstream OpenSearch plugins tailored for the platform.

Setup plugin

The Setup plugin initializes the indexer environment on cluster startup. It creates all required index templates, ISM policies, data streams, and internal state indices, ensuring the correct schema and lifecycle rules are in place before any data is ingested. It also defines the Wazuh Common Schema — the standardized field mappings used across all Wazuh indices — and exposes a Settings API used to manage Wazuh-level settings that now reside in the Indexer (including Engine settings).

Content Manager plugin

The Content Manager keeps the Wazuh detection content up to date. It synchronizes the ruleset, vulnerabilities feed, and IoC feed from the Wazuh CTI API, and provides a REST API for user-defined threat intelligence resources — rules, decoders, integrations, KVDBs, filters and policies — supporting drafting, testing, promotion, manual or scheduled updates, subscription management and version checks.

A daily ping to the Wazuh CTI API surfaces content updates and deployment telemetry. The plugin communicates with the bundled Wazuh Engine through a Unix socket to validate user content and execute the logtest feature, which is split into a normalization phase (decoders) and a detection phase (rules).

A snapshot of the ruleset, vulnerabilities feed and IoC feed is bundled with the Wazuh Indexer packages, so a freshly installed cluster has content available offline.

See Content Manager for details.

Ruleset Management plugin

A Wazuh fork of the OpenSearch Security Analytics plugin. It is the home of threat detection in 5.0:

  • Per-space log types, rules and threat detectors.
  • Extended Sigma rules syntax, including case-insensitive operators, the exists modifier, IPv6 support and dynamic event field referencing in findings.
  • Enriched findings written to wazuh-findings-v5-{category} data streams, embedding the full triggering event source and rule metadata (id, title, tags, level, status, MITRE, compliance).

Alerting plugin

A Wazuh fork of the OpenSearch Alerting plugin. It provides real-time alerting based on predefined monitors. Monitors are the core component used by the Ruleset Management plugin for threat detection, and a dedicated monitor drives Active Response.

Notifications plugin

A Wazuh fork of the OpenSearch Notifications plugin. It supports multiple delivery channels — Slack, Microsoft Teams, Amazon Chime, Email (SMTP/SES), AWS SNS, and custom webhooks — and ships default webhooks for Slack, Jira, PagerDuty and Shuffle. It also provides a dedicated channel type that powers Active Response, batching execution requests through a bulk processor.

Reporting plugin

A Wazuh fork of the OpenSearch Reporting plugin, bundled by default in Wazuh Indexer packages. It generates PDF and CSV reports from dashboards and saved searches, on demand or on a schedule, with optional email delivery.

Common Utils library

A Wazuh fork of the OpenSearch Common Utils library. It provides the shared models and transport actions used across the Wazuh forks of Alerting, Notifications, Ruleset Management and the Content Manager — including the Active Response channel definition.

Security plugin

The Security plugin provides role-based access control (RBAC), user authentication, and TLS encryption for both the REST API and inter-node transport layers. Wazuh 5.0 ships with a new set of reserved users and roles aligned with the new plugins (Content Manager, Alerting, Notifications, Reporting, Ruleset Management). See Access Control for details.

Bundled Wazuh Engine

The Wazuh Engine is shipped inside the Wazuh Indexer packages and Docker images. It is responsible for:

  • Decoding and normalizing events before they are indexed (only in Wazuh Manager).
  • Validating user-defined threat intel content submitted through the Content Manager.
  • Enrichment: IoC content management, GeoIP enrichment, and engine filters for event pre-processing.
  • Executing logtest requests issued by the Content Manager.

The Engine listens on a local Unix socket with restricted permissions (750) and is reachable only by the Indexer plugins running on the same node.

Data storage

The Wazuh Indexer organizes data into purpose-specific indices and data streams. Time-series streams are categorized per event type (access management, applications, cloud services, network activity, security, system activity, other).

Index patternDescription
wazuh-events-v5-{cat}Decoded and normalized security events from monitored endpoints, per category.
wazuh-events-raw-v5Raw incoming events, retained briefly under an aggressive ISM purge policy (gated by an Engine setting).
wazuh-findings-v5-{cat}Enriched findings produced by Ruleset Management, embedding the triggering event and rule metadata.
wazuh-states-v5-*Stateful inventory data (vulnerabilities, packages, ports, FIM, services, browser extensions, SCA, etc.).
wazuh-active-responsesActive Response execution requests, driven by a dedicated Alerting monitor.
wazuh-metrics-*Agent and communications telemetry metrics.
wazuh-threatintel-*Content Manager system indices for CTI content (rules, decoders, integrations, KVDBs, filters, policies, IoCs).
.wazuh-cti-consumersInternal index tracking consumer state for CTI synchronization.
wazuh-ai-assistant-sessionsAI assistant conversation history, rotated daily and kept for 7 days. Each user sees only their own conversations (Document Level Security), and writes are mediated by the setup plugin’s session API so the owning username cannot be forged.
.wazuh-internal-stateHidden system index holding the AI providers configuration, the assistant-wide settings and field policy, plus Content Manager’s internal state. Reachable only through the setup plugin’s administrative AI assistant API.

Agent and rule metadata is now relocated under the wazuh.* namespace, and inventory coverage has been extended to Linux systemd units and macOS launchd daemons/agents alongside Windows services. For a complete list of indices and their schemas, see the Setup Plugin documentation.

Integration with the Wazuh platform

In 5.0 the Wazuh Indexer is the central processing and storage tier of the platform:

  • Wazuh Agents collect endpoint data and send it to the Wazuh Manager.
  • Wazuh Manager acts as the ingestion gateway. It no longer runs analysis, threat detection, content management or active response — these have moved into the Indexer. Events are normalized and forwarded to the Indexer through the built-in indexer connector (Filebeat is no longer required).
  • Wazuh Indexer, through the bundled Wazuh Engine and its plugins, analyzes events, runs threat detection, manages detection content, dispatches notifications and active responses, and stores all resulting data.
  • Wazuh Dashboard (an OpenSearch Dashboards fork) provides the web UI for searching, visualizing and managing Wazuh data, and interacts with the Setup, Content Manager, Ruleset Management, Alerting, Notifications and Reporting plugin APIs.

The Indexer exposes a standard REST API compatible with the OpenSearch API, so existing OpenSearch tools, clients and integrations work with the Wazuh Indexer out of the box.

Architecture

The Wazuh Indexer is built on top of OpenSearch and extends it with a set of purpose-built plugins that provide security event indexing, content management, access control, and reporting capabilities.

Component overview

┌─────────────────────────────────────────────────────────────────────┐
│                        Wazuh Indexer                                │
│                                                                     │
│  ┌──────────────┐  ┌──────────────────┐  ┌──────────┐  ┌─────────┐  │
│  │ Setup Plugin │  │ Content Manager  │  │ Security │  │Reporting│  │
│  │              │  │     Plugin       │  │  Plugin  │  │ Plugin  │  │
│  └──────┬───────┘  └────────┬─────────┘  └────┬─────┘  └───┬─────┘  │
│         │                   │                 │            │        │
│  ┌──────┴────────┐   ┌──────┴───────────┐  ┌──┴───────┐    │        │
│  │Index Templates│   │  CTI API Client  │  │  RBAC &  │    │        │
│  │ISM Policies   │   │  Engine Client   │  │  Access  │    │        │
│  │Stream Indices │   │  Job Scheduler   │  │  Control │    │        │
│  │State Indices  │   │  Space Service   │  └──────────┘    │        │
│  └───────────────┘   └───────┬──────────┘                  │        │
│                              │                             │        │
│                    ┌─────────┴───────────────────────┐     │        │
│                    │  System Indices                 │     │        │
│                    │  .wazuh-cti-consumers           │     │        │
│                    │  wazuh-threatintel-rules        │     │        │
│                    │  wazuh-threatintel-decoders     │     │        │
│                    │  wazuh-threatintel-integrations │     │        │
│                    │  wazuh-threatintel-kvdbs        │     │        │
│                    │  wazuh-threatintel-policies     │     │        │
│                    │  wazuh-threatintel-enrichments  │     │        │
│                    └─────────────────────────────────┘     │        │
└─────────────────────────────────┬──────────────────────────┼────────┘
                                  │ Unix Socket              │
                          ┌───────┴────────┐          ┌──────┴───────┐
                          │  Wazuh Engine  │          │  Wazuh       │
                          │  (Analysis &   │          │  Dashboard   │
                          │   Detection)   │          │  (UI)        │
                          └────────────────┘          └──────────────┘

Plugins

Setup plugin

The Setup plugin initializes the Wazuh Indexer environment when the cluster starts. It is responsible for:

  • Index templates: Defines the mappings and settings for all Wazuh indices (alerts, events, statistics, vulnerabilities, etc.).
  • ISM (Index State Management) policies: Configures lifecycle policies for automatic rollover, deletion, and retention of time-series indices.
  • Data streams: Creates the initial data stream indices that receive incoming event data.
  • State indices: Sets up internal indices used by other Wazuh components to track operational state.

The Setup plugin runs once during cluster initialization and ensures the required infrastructure is in place before other plugins begin operating.

Content Manager plugin

The Content Manager is the most feature-rich plugin. It handles:

  • CTI synchronization: Periodically fetches threat intelligence content (rules, decoders, integrations, KVDBs, IoCs) from the Wazuh CTI API. On first run, it downloads a full snapshot; subsequent runs apply incremental patches.
  • User-generated content: Provides a REST API for creating, updating, and deleting custom decoders, rules, integrations, and KVDBs in a draft space.
  • Promotion workflow: Changes made in the draft space can be previewed and promoted to the Wazuh Engine for activation.
  • Engine communication: Communicates with the Wazuh Engine via a Unix socket for logtest execution, content validation, and configuration reload.
  • Policy management: Manages the Engine routing policy that controls how events are processed.

See Content Manager for full details.

Security plugin

The Security plugin extends OpenSearch’s security capabilities for Wazuh-specific needs:

  • Role-based access control (RBAC): Defines predefined roles and permissions for Wazuh operations.
  • User management: Provides APIs and configuration for managing users and their access levels.
  • TLS/SSL: Handles transport and REST layer encryption.

Reporting plugin

The Reporting plugin enables on-demand and scheduled report generation from the Wazuh Dashboard, producing PDF or CSV exports of dashboards and saved searches.

Data flow

  1. Wazuh Agents collect security events from monitored endpoints and forward them to the Wazuh Manager.
  2. The Wazuh Engine on the server analyzes events using rules and decoders, then forwards alerts and events to the Wazuh Indexer via the Indexer API.
  3. The Setup plugin ensures the correct index templates, data streams, and lifecycle policies exist.
  4. The Content Manager plugin keeps the Engine’s detection content up to date by synchronizing with the CTI API and managing user customizations.
  5. The Wazuh Dashboard queries the Indexer to visualize alerts, events, and security analytics.

Compatibility

Supported operating systems

We aim to support as many operating systems as OpenSearch does. Wazuh indexer should work on many Linux distributions, but we only test a handful. The following table lists the operating system versions that we currently support.

For 5.0.0 and above, we support the operating system versions and architectures included in the table below.

NameVersionArchitecture
Red Hat9, 10x86_64, aarch64
Ubuntu24.04, 26.04x86_64, aarch64
Amazon Linux2023x86_64, aarch64

OpenSearch

Currently, Wazuh indexer is using version 3.6.0 of OpenSearch.

Getting started

Requirements

Supported operating systems

The Wazuh indexer runs on 64-bit Linux, on x86_64 (AMD64) or aarch64 (ARM64) processors. See Compatibility for the supported distributions and versions.

Hardware recommendations

The Wazuh indexer can be installed as a single-node or as a multi-node cluster.

Hardware recommendations for each node

Minimum Recommended
Component RAM (GB) CPU (cores) RAM (GB) CPU (cores)
Wazuh indexer 8 4 16 8

Disk space requirements

The amount of data depends on the generated events per second (EPS). This table details the estimated disk space needed per agent to store 90 days of events on a Wazuh indexer server, depending on the type of monitored endpoints.

Monitored endpointsEPSStorage in Wazuh indexer (GB/90 days)
Servers0.253.7
Workstations0.11.5
Network devices0.57.4

For example, for an environment with 80 workstations, 10 servers, and 10 network devices, the storage needed on the Wazuh indexer server for 90 days of events is 231 GB.

Network ports

Allow the following inbound traffic on every Wazuh indexer node:

  • 9200/TCP — REST API, over HTTPS. Must be reachable from the Wazuh Manager and Wazuh Dashboard nodes, and from any client that uses the API.
  • 9300/TCP — transport, the communication between the nodes of a cluster, over TLS. Must be reachable from every other Wazuh indexer node. A single-node deployment does not need to expose it.

These are the default ports; http.port and transport.port in /etc/wazuh-indexer/opensearch.yml change them.

The Content Manager plugin also connects out to the Wazuh Cyber Threat Intelligence (CTI) API over HTTPS (443/TCP) to synchronize content, check for updates and send telemetry. An installation without internet access can disable those tasks instead; see Offline configuration.

Installation

Note: This documentation assumes you are already provisioned with a wazuh-indexer package through any of the possible methods:

Installing the Wazuh indexer step by step

Install and configure the Wazuh indexer as a single-node or multi-node cluster, following step-by-step instructions. To move an existing 4.x deployment to 5.x, see Migration guide. The installation process is divided into three stages.

  1. Certificates creation

  2. Nodes installation

  3. Cluster initialization

Note: You need root user privileges to run all the commands described below.

1. Certificates

The Wazuh indexer issues its own certificates when the package is installed, signed by a certificate authority it creates in /etc/wazuh-indexer/ca/. A single-node deployment needs nothing from this stage — skip to Nodes installation.

Every node of a cluster must trust the same authority, and the authority each package creates is local to its own host. For a multi-node cluster, either stage one authority on every node before installing the package, or generate one set of certificates for the whole cluster and distribute it, as described below.

To stage the authority, install the package on the first node, then copy its /etc/wazuh-indexer/ca/root-ca.pem and /etc/wazuh-indexer/ca/root-ca.key into /etc/wazuh-indexer/ca/ on each other node before installing the package there: the directory owned by root:root with mode 750, the certificate 640 and the key 400. Each package then issues its own certificates from that authority, and the first node’s /etc/wazuh-indexer/export/credentials.env holds the passwords of the whole cluster once the security configuration is loaded. Add every node’s Distinguished Name to plugins.security.nodes_dn on every node, as described in Configuring the Wazuh indexer.

The same applies if you bring certificates from your own PKI: before installing the package, stage each node’s pair, the admin pair and the CA that issued them, as described in On every other Wazuh indexer node. The installation then uses them and creates no authority of its own. To have it also issue the client pairs of the Wazuh manager and the Wazuh dashboard from that CA, stage the CA’s key as well, as /etc/wazuh-indexer/ca/root-ca.key with mode 400.

Generating the SSL certificates

The certificates tool ships with the Wazuh indexer package, so install the package on one node first and generate the cluster’s certificates from there. Installing does not start the service, so nothing runs before you are ready.

  1. Install the Wazuh indexer package on one node, following Nodes installation, and stop after the package is installed.

  2. Edit /usr/share/wazuh-indexer/tools/config.yml and replace the node names and IP values with the corresponding names and IP addresses. You need to do this for all Wazuh Manager, Wazuh indexer, and Wazuh dashboard nodes. Add as many node fields as needed.

    nodes:
      # Wazuh indexer nodes
      indexer:
        - name: node-1
          ip: "<indexer-node-ip>"
        #- name: node-2
        #  ip: "<indexer-node-ip>"
        #- name: node-3
        #  ip: "<indexer-node-ip>"
    
      # Wazuh manager nodes
      # If there is more than one Wazuh manager
      # node, each one must have a node_type
      manager:
        - name: wazuh-1
          ip: "<wazuh-manager-ip>"
        #  node_type: master
        #- name: wazuh-2
        #  ip: "<wazuh-manager-ip>"
        #  node_type: worker
        #- name: wazuh-3
        #  ip: "<wazuh-manager-ip>"
        #  node_type: worker
    
      # Wazuh dashboard nodes
      dashboard:
        - name: dashboard
          ip: "<dashboard-node-ip>"
    

    To learn more about how to create and configure the certificates, see the Certificates deployment section.

  3. Run the tool to create the certificates, signed by the authority this node already has in /etc/wazuh-indexer/ca/. It reads config.yml from beside itself and writes the result to wazuh-certificates/ in the same directory.

    WAZUH_CA_DIR=/etc/wazuh-indexer/ca /usr/share/wazuh-indexer/tools/wazuh-certs-tool.sh -A
    

    Without WAZUH_CA_DIR, the tool creates an authority of its own in /usr/share/wazuh-indexer/tools/wazuh-ca/ and signs with that one instead. This node published the authority it was installed with as /etc/wazuh-indexer/export/ca.pem and does not publish it again, so the Wazuh manager and the Wazuh dashboard on this host could no longer verify the Wazuh indexer.

  4. Compress all the necessary files.

    cd /usr/share/wazuh-indexer/tools/
    tar -cvf /tmp/wazuh-certificates.tar -C ./wazuh-certificates/ .
    rm -rf ./wazuh-certificates
    
  5. Copy the wazuh-certificates.tar file to all the nodes, including the Wazuh indexer, Wazuh Manager, and Wazuh dashboard nodes. This can be done by using the scp utility.

    On the node that generated them, replace the certificates the package issued with this node’s pair. On every other Wazuh indexer node, stage that node’s pair before installing the package. See Deploying certificates.

2. Nodes installation

Installing package dependencies

Install the following packages if missing:

yum
yum install iproute openssl shadow-utils
apt
apt-get install iproute2 openssl passwd

Supplying your own passwords

The installation generates a password for each internal user, but only for the users it does not already find a password for. To choose them yourself, write them to /etc/wazuh-indexer/local.env before installing the package.

That file is yours: the package reads it and never writes it. It holds passwords, so it must be owned by root with mode 600, and the installation refuses to read it otherwise:

install -d -m 0750 -o root -g root /etc/wazuh-indexer
cat > /etc/wazuh-indexer/local.env <<'EOF'
WAZUH_INDEXER_ADMIN_PASSWORD=<admin-password>
WAZUH_INDEXER_KIBANASERVER_PASSWORD=<kibanaserver-password>
WAZUH_INDEXER_MANAGER_PASSWORD=<wazuh-manager-password>
EOF
chmod 600 /etc/wazuh-indexer/local.env

Set as many or as few as you like: any key you leave out is generated. Each password must have 12 to 64 characters from A-Z a-z 0-9 . , _ + : @ % ^ = ~ -, with at least one uppercase letter, one lowercase letter, one digit and one symbol. A value that does not meet the rule is never replaced silently — the service refuses to start and names the key in its log.

The same file takes WAZUH_INDEXER_CERT_SANS, a comma-separated list of the names and addresses to put on the node’s certificate (for example DNS:node-1.example.com,IP:10.0.0.1) instead of the ones the installation discovers; localhost and the loopback addresses are always added.

Installing the Wazuh indexer package

If this node uses certificates generated in advance and is not the node that generated them, stage them first, as described in On every other Wazuh indexer node.

Replace the file name with that of the package you downloaded. <ARCH> is x86_64 or aarch64 for RPM packages, and amd64 or arm64 for DEB packages. See Packages.

rpm
rpm -ivh --replacepkgs wazuh-indexer-<VERSION>-<REVISION>.<ARCH>.rpm
dpkg
dpkg -i wazuh-indexer_<VERSION>-<REVISION>_<ARCH>.deb

Retrieving the generated credentials

The installation publishes one password per internal user in /etc/wazuh-indexer/export/credentials.env, readable only by root — the ones it generated, and the ones it was given:

cat /etc/wazuh-indexer/export/credentials.env
# Published by wazuh-indexer for the manager and the dashboard on this host. Do not edit.
WAZUH_INDEXER_ADMIN_PASSWORD=...
WAZUH_INDEXER_KIBANASERVER_PASSWORD=...
WAZUH_INDEXER_MANAGER_PASSWORD=...

Values are written as they are, without quotes — see About the examples. admin is the login for the Wazuh dashboard and the Wazuh indexer API; kibanaserver and wazuh-manager are the accounts the Wazuh dashboard and the Wazuh manager connect with. The directory also holds ca.pem, the certificate of the authority this node’s certificates are issued from, and the client certificate pairs that authority issued for the Wazuh manager (manager-client.pem, manager-client-key.pem) and the Wazuh dashboard (dashboard-client.pem, dashboard-client-key.pem), readable only by root. A client pair identifies the component to the Wazuh indexer; it grants no permission by itself, and each component keeps authenticating with its password.

The Wazuh Manager and the Wazuh Dashboard installed on the same host read export/ when they are installed and at every start, so keep it. On another host, copy the values they need into their own local.env and certs/ before installing them: WAZUH_INDEXER_MANAGER_PASSWORD, ca.pem and the manager client pair for the manager, WAZUH_INDEXER_KIBANASERVER_PASSWORD and ca.pem for the dashboard. The client pair of any Wazuh indexer node of the cluster serves: it names no host.

Passwords are generated once. Reinstalling, restarting or upgrading the Wazuh indexer does not change them. To change one afterwards, use the passwords tool, which also ships with the package. It prompts for the new password twice and echoes nothing, so the password does not reach your shell history:

/usr/share/wazuh-indexer/tools/wazuh-passwords-tool.sh -u admin -p

A new password must meet the same rule as a supplied one — see Supplying your own passwords.

When the standard input is not a terminal the tool reads the password from it instead of prompting, which is how a script sets one:

printf '%s' '<new-password>' | /usr/share/wazuh-indexer/tools/wazuh-passwords-tool.sh -u admin -p

-p takes no value: the tool prompts for the new password, or reads it from standard input when that is not a terminal, for example printf '%s\n' '<new-password>' | /usr/share/wazuh-indexer/tools/wazuh-passwords-tool.sh -u admin -p. Without -p, the tool generates a random password. Either way the new password is published in /etc/wazuh-indexer/export/credentials.env.

Configuring the Wazuh indexer

Edit the /etc/wazuh-indexer/opensearch.yml configuration file and replace the values in steps (a) to (e). Then set the JVM heap size in /etc/wazuh-indexer/jvm.options, as described in step (f).

The package ships a working single-node configuration: one node named node-1, listening on all interfaces (0.0.0.0). On a single node, steps (a) to (e) are optional: apply (a) to listen on a specific address, and (b) together with (c) to rename the node. Step (f) applies to every node, including a single node, because the package starts with a 1 GB heap. A multi-node cluster needs all six steps, on every node.

a. network.host: Sets the address of this node for both HTTP and transport traffic. The node will bind to this address and use it as its publish address. Accepts an IP address or a hostname. On a multi-node cluster, use the same node address set in config.yml to create the SSL certificates.

b. node.name: Name of the Wazuh indexer node as defined in the config.yml file. For example, node-1. If you change it on a single node, change cluster.initial_cluster_manager_nodes to match.

c. cluster.initial_cluster_manager_nodes: List of the names of the master-eligible nodes. These names are defined in the config.yml file. A single node lists only its own node.name. For a multi-node cluster, uncomment the node-2 and node-3 lines, change the names, or add more lines, according to your config.yml definitions.

cluster.initial_cluster_manager_nodes:
- "node-1"
- "node-2"
- "node-3"

d. discovery.seed_hosts: List of the addresses of the master-eligible nodes. Each element can be either an IP address or a hostname. You may leave this setting commented if you are configuring the Wazuh indexer as a single node. For multi-node configurations, uncomment this setting and set the IP addresses of each master-eligible node.

discovery.seed_hosts:
- "10.0.0.1"
- "10.0.0.2"
- "10.0.0.3"

e. plugins.security.nodes_dn: List of the Distinguished Names of the certificates of all the Wazuh indexer cluster nodes. The installation writes the Distinguished Name of the certificate the package issued for this node, so a single node needs no change. For a multi-node cluster, list one line per node with the subjects of the certificates you deploy in Deploying certificates. The certificates tool issues them with the node name from config.yml as CN, and the Security plugin compares each entry as a string, so keep this order:

plugins.security.nodes_dn:
- "C=US,L=California,O=Wazuh,OU=Wazuh,CN=node-1"
- "C=US,L=California,O=Wazuh,OU=Wazuh,CN=node-2"
- "C=US,L=California,O=Wazuh,OU=Wazuh,CN=node-3"

f. JVM heap size: Set it in /etc/wazuh-indexer/jvm.options, before the first start. The package sets it to 1 GB. Use half of the system RAM when the Wazuh indexer is alone on the host, or a quarter when the Wazuh manager or the Wazuh dashboard runs on the same host. Set the initial (-Xms) and maximum (-Xmx) heap size to the same value. For example, on a dedicated node with 8 GB of RAM:

-Xms4g
-Xmx4g

For a size that is not a whole number of gigabytes, use megabytes, for example -Xms1536m and -Xmx1536m. See JVM heap size for the sizing limits, and Memory locking for the memory the node needs beyond the heap.

Deploying certificates

Note: This step applies only if you generated the certificates in advance, as described in Certificates. A single-node deployment already has the certificates the package issued.

Make sure that a copy of the wazuh-certificates.tar file, created during the initial configuration step, is placed in your working directory.

The installation takes in certificates only when the package is installed: certificates replaced afterwards are used, but their authority is not published to the Wazuh manager and the Wazuh dashboard on the host. Run with WAZUH_CA_DIR, as in Generating the SSL certificates, the certificates tool signs with the authority in /etc/wazuh-indexer/ca/, so that node can replace its pair afterwards, while every other node must have its pair and that authority staged before the package is installed.

In the commands below, replace <INDEXER_NODE_NAME> with the name of the Wazuh indexer node you are configuring as defined in config.yml. For example, node-1.

NODE_NAME=<INDEXER_NODE_NAME>
On the node that generated the certificates

The package is already installed here. Replace the pair it issued:

mkdir -p /etc/wazuh-indexer/certs
tar -xf ./wazuh-certificates.tar -C /etc/wazuh-indexer/certs/ ./$NODE_NAME.pem ./$NODE_NAME-key.pem ./admin.pem ./admin-key.pem ./root-ca.pem
mv -f /etc/wazuh-indexer/certs/$NODE_NAME.pem /etc/wazuh-indexer/certs/indexer.pem
mv -f /etc/wazuh-indexer/certs/$NODE_NAME-key.pem /etc/wazuh-indexer/certs/indexer-key.pem
chown -R root:wazuh-indexer /etc/wazuh-indexer/certs
chmod 750 /etc/wazuh-indexer/certs
chmod 640 /etc/wazuh-indexer/certs/*
chown root:root /etc/wazuh-indexer/certs/admin.pem /etc/wazuh-indexer/certs/admin-key.pem
chmod 600 /etc/wazuh-indexer/certs/admin.pem /etc/wazuh-indexer/certs/admin-key.pem

mv -f is required: the package already issued indexer.pem and indexer-key.pem, and the cluster’s pair must replace them. The files belong to root: the Wazuh indexer reads its own pair and the trust anchor through its group, and the admin pair is used by indexer-security-init.sh alone, which runs as root. A pair owned by the wazuh-indexer user is refused when the service starts.

The certificates tool and the package issue certificates with the same subject format, so the plugins.security.authcz.admin_dn the installation wrote into /etc/wazuh-indexer/opensearch.yml stays valid. The node Distinguished Name it wrote stays valid only if the host’s short name matches the node name in config.yml, because the package uses the hostname as CN and the certificates tool uses the node name.

On every other Wazuh indexer node

Run these commands before installing the package, replacing <INDEXER_NODE_NAME> with the name of this node as defined in config.yml. They stage this node’s pair, the admin pair and the authority that issued them, root-ca.pem, both beside the pair and in /etc/wazuh-indexer/ca/:

NODE_NAME=<INDEXER_NODE_NAME>
install -d -m 750 /etc/wazuh-indexer /etc/wazuh-indexer/certs /etc/wazuh-indexer/ca
tar -xf ./wazuh-certificates.tar -C /etc/wazuh-indexer/certs/ ./$NODE_NAME.pem ./$NODE_NAME-key.pem ./admin.pem ./admin-key.pem ./root-ca.pem
mv /etc/wazuh-indexer/certs/$NODE_NAME.pem /etc/wazuh-indexer/certs/indexer.pem
mv /etc/wazuh-indexer/certs/$NODE_NAME-key.pem /etc/wazuh-indexer/certs/indexer-key.pem
cp /etc/wazuh-indexer/certs/root-ca.pem /etc/wazuh-indexer/ca/root-ca.pem
chmod 640 /etc/wazuh-indexer/certs/* /etc/wazuh-indexer/ca/root-ca.pem
chmod 600 /etc/wazuh-indexer/certs/admin.pem /etc/wazuh-indexer/certs/admin-key.pem

The installation gives the node’s pair and the authority to the wazuh-indexer group, which does not exist before it, and writes the subjects of the pair and the admin pair into plugins.security.nodes_dn and plugins.security.authcz.admin_dn. It also checks that the authority issued the pair, and when it did not, the service does not start:

resolve-credentials: the certificate pair in /etc/wazuh-indexer/certs was not issued by /etc/wazuh-indexer/ca/root-ca.pem

The installation publishes the authority as /etc/wazuh-indexer/export/ca.pem for the Wazuh manager and the Wazuh dashboard on this host. Its key stays on the node that generated the certificates, so this node issues no client pairs for them: copy the client pairs from /etc/wazuh-indexer/export/ on that node, as described in Retrieving the generated credentials.

Distinguished Names

Make sure plugins.security.nodes_dn lists every node, as described in Configuring the Wazuh indexer: the installation writes only this node’s subject. To print the subject of a certificate in the form these settings expect, run:

openssl x509 -noout -subject -nameopt RFC2253 -in /etc/wazuh-indexer/certs/indexer.pem

indexer-security-init.sh warns when the admin certificate is not among the subjects in plugins.security.authcz.admin_dn.

Set up Wazuh Indexer in your environment

Follow the instructions in the Configuration section to set up Wazuh Indexer in your environment.

On offline installations, disable every task that requires an internet connection to prevent failures.

# opensearch.yml
plugins.content_manager.catalog.update_on_start: false
plugins.content_manager.catalog.update_on_schedule: false
plugins.content_manager.telemetry.enabled: false

plugins.content_manager.catalog.update_on_schedule and plugins.content_manager.telemetry.enabled are dynamic, so on a running deployment they can be applied without a restart:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT "https://127.0.0.1:9200/_cluster/settings" -H 'Content-Type: application/json' -d'
{
  "persistent": {
    "plugins.content_manager.catalog.update_on_schedule": false,
    "plugins.content_manager.telemetry.enabled": false
  }
}'

Disabling scheduled synchronization takes effect immediately and applies to an already-initialized node as well as a fresh one: the plugin updates the periodic job accordingly, and a run that is already scheduled is refused rather than reaching Wazuh CTI.

Disabling scheduled synchronization does not remove content that has already been downloaded. It only stops further updates from Wazuh CTI.

plugins.content_manager.catalog.update_on_start is not dynamic — it only governs the synchronization triggered as the plugin starts, so it takes effect on the next node start.

Starting the service

Enable and start the Wazuh indexer service. The installation does not start it, and it does not start at boot until it is enabled. The package reloads the service manager itself, so no systemctl daemon-reload is needed here.

Systemd
systemctl enable --now wazuh-indexer

SysV

Choose one option according to the operating system used.

a. RPM-based operating system:

chkconfig --add wazuh-indexer
service wazuh-indexer start

b. Debian-based operating system:

update-rc.d wazuh-indexer defaults 95 10
service wazuh-indexer start

Repeat this stage of the installation process for every Wazuh indexer node in your cluster. Then proceed with initializing your single-node or multi-node cluster in the next stage.

3. Cluster initialization

Run the Wazuh indexer indexer-security-init.sh script on any Wazuh indexer node to load the new certificates information and start the single-node or multi-node cluster.

/usr/share/wazuh-indexer/bin/indexer-security-init.sh

Note: You only have to initialize the cluster once, there is no need to run this command on every node.

Testing the cluster installation

  1. Replace $WAZUH_INDEXER_IP_ADDRESS and run the following commands to confirm that the installation is successful. $WAZUH_INDEXER_ADMIN_PASSWORD is the admin password from /etc/wazuh-indexer/export/credentials.env. Load it with WAZUH_INDEXER_ADMIN_PASSWORD=$(sudo grep '^WAZUH_INDEXER_ADMIN_PASSWORD=' /etc/wazuh-indexer/export/credentials.env | cut -d= -f2- | tail -n 1).

    curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X GET "https://$WAZUH_INDEXER_IP_ADDRESS:9200"
    

    Output

    {
      "name" : "node-1",
      "cluster_name" : "wazuh-cluster",
      "cluster_uuid" : "lEzYZtwXTYaGiSAdbcqQmQ",
      "version" : {
        "distribution" : "opensearch",
        "number" : "3.6.0",
        "build_type" : "deb",
        "build_hash" : "1b0a897cd71105595b450b48b591581865f8b46b",
        "build_date" : "2026-10-01T01:45:08.798733103Z",
        "build_snapshot" : false,
        "lucene_version" : "10.4.0",
        "minimum_wire_compatibility_version" : "2.19.0",
        "minimum_index_compatibility_version" : "2.0.0"
      },
      "tagline" : "The OpenSearch Project: https://opensearch.org/"
    }
    
  2. Replace $WAZUH_INDEXER_IP_ADDRESS and run the following command to check if the single-node or multi-node cluster is working correctly.

    curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X GET "https://$WAZUH_INDEXER_IP_ADDRESS:9200/_cat/nodes?v"
    

Requirements

Requirements

Supported operating systems

The Wazuh indexer runs on 64-bit Linux, on x86_64 (AMD64) or aarch64 (ARM64) processors. See Compatibility for the supported distributions and versions.

Hardware recommendations

The Wazuh indexer can be installed as a single-node or as a multi-node cluster.

Hardware recommendations for each node

Minimum Recommended
Component RAM (GB) CPU (cores) RAM (GB) CPU (cores)
Wazuh indexer 8 4 16 8

Disk space requirements

The amount of data depends on the generated events per second (EPS). This table details the estimated disk space needed per agent to store 90 days of events on a Wazuh indexer server, depending on the type of monitored endpoints.

Monitored endpointsEPSStorage in Wazuh indexer (GB/90 days)
Servers0.253.7
Workstations0.11.5
Network devices0.57.4

For example, for an environment with 80 workstations, 10 servers, and 10 network devices, the storage needed on the Wazuh indexer server for 90 days of events is 231 GB.

Network ports

Allow the following inbound traffic on every Wazuh indexer node:

  • 9200/TCP — REST API, over HTTPS. Must be reachable from the Wazuh Manager and Wazuh Dashboard nodes, and from any client that uses the API.
  • 9300/TCP — transport, the communication between the nodes of a cluster, over TLS. Must be reachable from every other Wazuh indexer node. A single-node deployment does not need to expose it.

These are the default ports; http.port and transport.port in /etc/wazuh-indexer/opensearch.yml change them.

The Content Manager plugin also connects out to the Wazuh Cyber Threat Intelligence (CTI) API over HTTPS (443/TCP) to synchronize content, check for updates and send telemetry. An installation without internet access can disable those tasks instead; see Offline configuration.

Packages

Packages

Wazuh Indexer packages can be downloaded from the internal S3 buckets through the following links. Note these links are placeholders, and that you need to replace the RELEASE_SERIES, the VERSION and the REVISION with the appropriate values.

wazuh_indexer_aarch64_rpm: "https://packages-staging.xdrsiem.wazuh.info/pre-release/<RELEASE_SERIES>/yum/wazuh-indexer-<VERSION>-<REVISION>.aarch64.rpm"
wazuh_indexer_amd64_deb: "https://packages-staging.xdrsiem.wazuh.info/pre-release/<RELEASE_SERIES>/apt/pool/main/w/wazuh-indexer/wazuh-indexer_<VERSION>-<REVISION>_amd64.deb"
wazuh_indexer_arm64_deb: "https://packages-staging.xdrsiem.wazuh.info/pre-release/<RELEASE_SERIES>/apt/pool/main/w/wazuh-indexer/wazuh-indexer_<VERSION>-<REVISION>_arm64.deb"
wazuh_indexer_x86_64_rpm: "https://packages-staging.xdrsiem.wazuh.info/pre-release/<RELEASE_SERIES>/yum/wazuh-indexer-<VERSION>-<REVISION>.x86_64.rpm"

Examples

wazuh_indexer_aarch64_rpm: "https://packages-staging.xdrsiem.wazuh.info/pre-release/5.x/yum/wazuh-indexer-5.0.0-alpha99.aarch64.rpm"
wazuh_indexer_amd64_deb: "https://packages-staging.xdrsiem.wazuh.info/pre-release/5.x/apt/pool/main/w/wazuh-indexer/wazuh-indexer_5.0.0-alpha99_amd64.deb"
wazuh_indexer_arm64_deb: "https://packages-staging.xdrsiem.wazuh.info/pre-release/5.x/apt/pool/main/w/wazuh-indexer/wazuh-indexer_5.0.0-alpha99_arm64.deb"
wazuh_indexer_x86_64_rpm: "https://packages-staging.xdrsiem.wazuh.info/pre-release/5.x/yum/wazuh-indexer-5.0.0-alpha99.x86_64.rpm"

Compatibility

Please refer to this section for information pertaining to compatibility.

Installation

Installation

Note: This documentation assumes you are already provisioned with a wazuh-indexer package through any of the possible methods:

Installing the Wazuh indexer step by step

Install and configure the Wazuh indexer as a single-node or multi-node cluster, following step-by-step instructions. To move an existing 4.x deployment to 5.x, see Migration guide. The installation process is divided into three stages.

  1. Certificates creation

  2. Nodes installation

  3. Cluster initialization

Note: You need root user privileges to run all the commands described below.

1. Certificates

The Wazuh indexer issues its own certificates when the package is installed, signed by a certificate authority it creates in /etc/wazuh-indexer/ca/. A single-node deployment needs nothing from this stage — skip to Nodes installation.

Every node of a cluster must trust the same authority, and the authority each package creates is local to its own host. For a multi-node cluster, either stage one authority on every node before installing the package, or generate one set of certificates for the whole cluster and distribute it, as described below.

To stage the authority, install the package on the first node, then copy its /etc/wazuh-indexer/ca/root-ca.pem and /etc/wazuh-indexer/ca/root-ca.key into /etc/wazuh-indexer/ca/ on each other node before installing the package there: the directory owned by root:root with mode 750, the certificate 640 and the key 400. Each package then issues its own certificates from that authority, and the first node’s /etc/wazuh-indexer/export/credentials.env holds the passwords of the whole cluster once the security configuration is loaded. Add every node’s Distinguished Name to plugins.security.nodes_dn on every node, as described in Configuring the Wazuh indexer.

The same applies if you bring certificates from your own PKI: before installing the package, stage each node’s pair, the admin pair and the CA that issued them, as described in On every other Wazuh indexer node. The installation then uses them and creates no authority of its own. To have it also issue the client pairs of the Wazuh manager and the Wazuh dashboard from that CA, stage the CA’s key as well, as /etc/wazuh-indexer/ca/root-ca.key with mode 400.

Generating the SSL certificates

The certificates tool ships with the Wazuh indexer package, so install the package on one node first and generate the cluster’s certificates from there. Installing does not start the service, so nothing runs before you are ready.

  1. Install the Wazuh indexer package on one node, following Nodes installation, and stop after the package is installed.

  2. Edit /usr/share/wazuh-indexer/tools/config.yml and replace the node names and IP values with the corresponding names and IP addresses. You need to do this for all Wazuh Manager, Wazuh indexer, and Wazuh dashboard nodes. Add as many node fields as needed.

    nodes:
      # Wazuh indexer nodes
      indexer:
        - name: node-1
          ip: "<indexer-node-ip>"
        #- name: node-2
        #  ip: "<indexer-node-ip>"
        #- name: node-3
        #  ip: "<indexer-node-ip>"
    
      # Wazuh manager nodes
      # If there is more than one Wazuh manager
      # node, each one must have a node_type
      manager:
        - name: wazuh-1
          ip: "<wazuh-manager-ip>"
        #  node_type: master
        #- name: wazuh-2
        #  ip: "<wazuh-manager-ip>"
        #  node_type: worker
        #- name: wazuh-3
        #  ip: "<wazuh-manager-ip>"
        #  node_type: worker
    
      # Wazuh dashboard nodes
      dashboard:
        - name: dashboard
          ip: "<dashboard-node-ip>"
    

    To learn more about how to create and configure the certificates, see the Certificates deployment section.

  3. Run the tool to create the certificates, signed by the authority this node already has in /etc/wazuh-indexer/ca/. It reads config.yml from beside itself and writes the result to wazuh-certificates/ in the same directory.

    WAZUH_CA_DIR=/etc/wazuh-indexer/ca /usr/share/wazuh-indexer/tools/wazuh-certs-tool.sh -A
    

    Without WAZUH_CA_DIR, the tool creates an authority of its own in /usr/share/wazuh-indexer/tools/wazuh-ca/ and signs with that one instead. This node published the authority it was installed with as /etc/wazuh-indexer/export/ca.pem and does not publish it again, so the Wazuh manager and the Wazuh dashboard on this host could no longer verify the Wazuh indexer.

  4. Compress all the necessary files.

    cd /usr/share/wazuh-indexer/tools/
    tar -cvf /tmp/wazuh-certificates.tar -C ./wazuh-certificates/ .
    rm -rf ./wazuh-certificates
    
  5. Copy the wazuh-certificates.tar file to all the nodes, including the Wazuh indexer, Wazuh Manager, and Wazuh dashboard nodes. This can be done by using the scp utility.

    On the node that generated them, replace the certificates the package issued with this node’s pair. On every other Wazuh indexer node, stage that node’s pair before installing the package. See Deploying certificates.

2. Nodes installation

Installing package dependencies

Install the following packages if missing:

yum
yum install iproute openssl shadow-utils
apt
apt-get install iproute2 openssl passwd

Supplying your own passwords

The installation generates a password for each internal user, but only for the users it does not already find a password for. To choose them yourself, write them to /etc/wazuh-indexer/local.env before installing the package.

That file is yours: the package reads it and never writes it. It holds passwords, so it must be owned by root with mode 600, and the installation refuses to read it otherwise:

install -d -m 0750 -o root -g root /etc/wazuh-indexer
cat > /etc/wazuh-indexer/local.env <<'EOF'
WAZUH_INDEXER_ADMIN_PASSWORD=<admin-password>
WAZUH_INDEXER_KIBANASERVER_PASSWORD=<kibanaserver-password>
WAZUH_INDEXER_MANAGER_PASSWORD=<wazuh-manager-password>
EOF
chmod 600 /etc/wazuh-indexer/local.env

Set as many or as few as you like: any key you leave out is generated. Each password must have 12 to 64 characters from A-Z a-z 0-9 . , _ + : @ % ^ = ~ -, with at least one uppercase letter, one lowercase letter, one digit and one symbol. A value that does not meet the rule is never replaced silently — the service refuses to start and names the key in its log.

The same file takes WAZUH_INDEXER_CERT_SANS, a comma-separated list of the names and addresses to put on the node’s certificate (for example DNS:node-1.example.com,IP:10.0.0.1) instead of the ones the installation discovers; localhost and the loopback addresses are always added.

Installing the Wazuh indexer package

If this node uses certificates generated in advance and is not the node that generated them, stage them first, as described in On every other Wazuh indexer node.

Replace the file name with that of the package you downloaded. <ARCH> is x86_64 or aarch64 for RPM packages, and amd64 or arm64 for DEB packages. See Packages.

rpm
rpm -ivh --replacepkgs wazuh-indexer-<VERSION>-<REVISION>.<ARCH>.rpm
dpkg
dpkg -i wazuh-indexer_<VERSION>-<REVISION>_<ARCH>.deb

Retrieving the generated credentials

The installation publishes one password per internal user in /etc/wazuh-indexer/export/credentials.env, readable only by root — the ones it generated, and the ones it was given:

cat /etc/wazuh-indexer/export/credentials.env
# Published by wazuh-indexer for the manager and the dashboard on this host. Do not edit.
WAZUH_INDEXER_ADMIN_PASSWORD=...
WAZUH_INDEXER_KIBANASERVER_PASSWORD=...
WAZUH_INDEXER_MANAGER_PASSWORD=...

Values are written as they are, without quotes — see About the examples. admin is the login for the Wazuh dashboard and the Wazuh indexer API; kibanaserver and wazuh-manager are the accounts the Wazuh dashboard and the Wazuh manager connect with. The directory also holds ca.pem, the certificate of the authority this node’s certificates are issued from, and the client certificate pairs that authority issued for the Wazuh manager (manager-client.pem, manager-client-key.pem) and the Wazuh dashboard (dashboard-client.pem, dashboard-client-key.pem), readable only by root. A client pair identifies the component to the Wazuh indexer; it grants no permission by itself, and each component keeps authenticating with its password.

The Wazuh Manager and the Wazuh Dashboard installed on the same host read export/ when they are installed and at every start, so keep it. On another host, copy the values they need into their own local.env and certs/ before installing them: WAZUH_INDEXER_MANAGER_PASSWORD, ca.pem and the manager client pair for the manager, WAZUH_INDEXER_KIBANASERVER_PASSWORD and ca.pem for the dashboard. The client pair of any Wazuh indexer node of the cluster serves: it names no host.

Passwords are generated once. Reinstalling, restarting or upgrading the Wazuh indexer does not change them. To change one afterwards, use the passwords tool, which also ships with the package. It prompts for the new password twice and echoes nothing, so the password does not reach your shell history:

/usr/share/wazuh-indexer/tools/wazuh-passwords-tool.sh -u admin -p

A new password must meet the same rule as a supplied one — see Supplying your own passwords.

When the standard input is not a terminal the tool reads the password from it instead of prompting, which is how a script sets one:

printf '%s' '<new-password>' | /usr/share/wazuh-indexer/tools/wazuh-passwords-tool.sh -u admin -p

-p takes no value: the tool prompts for the new password, or reads it from standard input when that is not a terminal, for example printf '%s\n' '<new-password>' | /usr/share/wazuh-indexer/tools/wazuh-passwords-tool.sh -u admin -p. Without -p, the tool generates a random password. Either way the new password is published in /etc/wazuh-indexer/export/credentials.env.

Configuring the Wazuh indexer

Edit the /etc/wazuh-indexer/opensearch.yml configuration file and replace the values in steps (a) to (e). Then set the JVM heap size in /etc/wazuh-indexer/jvm.options, as described in step (f).

The package ships a working single-node configuration: one node named node-1, listening on all interfaces (0.0.0.0). On a single node, steps (a) to (e) are optional: apply (a) to listen on a specific address, and (b) together with (c) to rename the node. Step (f) applies to every node, including a single node, because the package starts with a 1 GB heap. A multi-node cluster needs all six steps, on every node.

a. network.host: Sets the address of this node for both HTTP and transport traffic. The node will bind to this address and use it as its publish address. Accepts an IP address or a hostname. On a multi-node cluster, use the same node address set in config.yml to create the SSL certificates.

b. node.name: Name of the Wazuh indexer node as defined in the config.yml file. For example, node-1. If you change it on a single node, change cluster.initial_cluster_manager_nodes to match.

c. cluster.initial_cluster_manager_nodes: List of the names of the master-eligible nodes. These names are defined in the config.yml file. A single node lists only its own node.name. For a multi-node cluster, uncomment the node-2 and node-3 lines, change the names, or add more lines, according to your config.yml definitions.

cluster.initial_cluster_manager_nodes:
- "node-1"
- "node-2"
- "node-3"

d. discovery.seed_hosts: List of the addresses of the master-eligible nodes. Each element can be either an IP address or a hostname. You may leave this setting commented if you are configuring the Wazuh indexer as a single node. For multi-node configurations, uncomment this setting and set the IP addresses of each master-eligible node.

discovery.seed_hosts:
- "10.0.0.1"
- "10.0.0.2"
- "10.0.0.3"

e. plugins.security.nodes_dn: List of the Distinguished Names of the certificates of all the Wazuh indexer cluster nodes. The installation writes the Distinguished Name of the certificate the package issued for this node, so a single node needs no change. For a multi-node cluster, list one line per node with the subjects of the certificates you deploy in Deploying certificates. The certificates tool issues them with the node name from config.yml as CN, and the Security plugin compares each entry as a string, so keep this order:

plugins.security.nodes_dn:
- "C=US,L=California,O=Wazuh,OU=Wazuh,CN=node-1"
- "C=US,L=California,O=Wazuh,OU=Wazuh,CN=node-2"
- "C=US,L=California,O=Wazuh,OU=Wazuh,CN=node-3"

f. JVM heap size: Set it in /etc/wazuh-indexer/jvm.options, before the first start. The package sets it to 1 GB. Use half of the system RAM when the Wazuh indexer is alone on the host, or a quarter when the Wazuh manager or the Wazuh dashboard runs on the same host. Set the initial (-Xms) and maximum (-Xmx) heap size to the same value. For example, on a dedicated node with 8 GB of RAM:

-Xms4g
-Xmx4g

For a size that is not a whole number of gigabytes, use megabytes, for example -Xms1536m and -Xmx1536m. See JVM heap size for the sizing limits, and Memory locking for the memory the node needs beyond the heap.

Deploying certificates

Note: This step applies only if you generated the certificates in advance, as described in Certificates. A single-node deployment already has the certificates the package issued.

Make sure that a copy of the wazuh-certificates.tar file, created during the initial configuration step, is placed in your working directory.

The installation takes in certificates only when the package is installed: certificates replaced afterwards are used, but their authority is not published to the Wazuh manager and the Wazuh dashboard on the host. Run with WAZUH_CA_DIR, as in Generating the SSL certificates, the certificates tool signs with the authority in /etc/wazuh-indexer/ca/, so that node can replace its pair afterwards, while every other node must have its pair and that authority staged before the package is installed.

In the commands below, replace <INDEXER_NODE_NAME> with the name of the Wazuh indexer node you are configuring as defined in config.yml. For example, node-1.

NODE_NAME=<INDEXER_NODE_NAME>
On the node that generated the certificates

The package is already installed here. Replace the pair it issued:

mkdir -p /etc/wazuh-indexer/certs
tar -xf ./wazuh-certificates.tar -C /etc/wazuh-indexer/certs/ ./$NODE_NAME.pem ./$NODE_NAME-key.pem ./admin.pem ./admin-key.pem ./root-ca.pem
mv -f /etc/wazuh-indexer/certs/$NODE_NAME.pem /etc/wazuh-indexer/certs/indexer.pem
mv -f /etc/wazuh-indexer/certs/$NODE_NAME-key.pem /etc/wazuh-indexer/certs/indexer-key.pem
chown -R root:wazuh-indexer /etc/wazuh-indexer/certs
chmod 750 /etc/wazuh-indexer/certs
chmod 640 /etc/wazuh-indexer/certs/*
chown root:root /etc/wazuh-indexer/certs/admin.pem /etc/wazuh-indexer/certs/admin-key.pem
chmod 600 /etc/wazuh-indexer/certs/admin.pem /etc/wazuh-indexer/certs/admin-key.pem

mv -f is required: the package already issued indexer.pem and indexer-key.pem, and the cluster’s pair must replace them. The files belong to root: the Wazuh indexer reads its own pair and the trust anchor through its group, and the admin pair is used by indexer-security-init.sh alone, which runs as root. A pair owned by the wazuh-indexer user is refused when the service starts.

The certificates tool and the package issue certificates with the same subject format, so the plugins.security.authcz.admin_dn the installation wrote into /etc/wazuh-indexer/opensearch.yml stays valid. The node Distinguished Name it wrote stays valid only if the host’s short name matches the node name in config.yml, because the package uses the hostname as CN and the certificates tool uses the node name.

On every other Wazuh indexer node

Run these commands before installing the package, replacing <INDEXER_NODE_NAME> with the name of this node as defined in config.yml. They stage this node’s pair, the admin pair and the authority that issued them, root-ca.pem, both beside the pair and in /etc/wazuh-indexer/ca/:

NODE_NAME=<INDEXER_NODE_NAME>
install -d -m 750 /etc/wazuh-indexer /etc/wazuh-indexer/certs /etc/wazuh-indexer/ca
tar -xf ./wazuh-certificates.tar -C /etc/wazuh-indexer/certs/ ./$NODE_NAME.pem ./$NODE_NAME-key.pem ./admin.pem ./admin-key.pem ./root-ca.pem
mv /etc/wazuh-indexer/certs/$NODE_NAME.pem /etc/wazuh-indexer/certs/indexer.pem
mv /etc/wazuh-indexer/certs/$NODE_NAME-key.pem /etc/wazuh-indexer/certs/indexer-key.pem
cp /etc/wazuh-indexer/certs/root-ca.pem /etc/wazuh-indexer/ca/root-ca.pem
chmod 640 /etc/wazuh-indexer/certs/* /etc/wazuh-indexer/ca/root-ca.pem
chmod 600 /etc/wazuh-indexer/certs/admin.pem /etc/wazuh-indexer/certs/admin-key.pem

The installation gives the node’s pair and the authority to the wazuh-indexer group, which does not exist before it, and writes the subjects of the pair and the admin pair into plugins.security.nodes_dn and plugins.security.authcz.admin_dn. It also checks that the authority issued the pair, and when it did not, the service does not start:

resolve-credentials: the certificate pair in /etc/wazuh-indexer/certs was not issued by /etc/wazuh-indexer/ca/root-ca.pem

The installation publishes the authority as /etc/wazuh-indexer/export/ca.pem for the Wazuh manager and the Wazuh dashboard on this host. Its key stays on the node that generated the certificates, so this node issues no client pairs for them: copy the client pairs from /etc/wazuh-indexer/export/ on that node, as described in Retrieving the generated credentials.

Distinguished Names

Make sure plugins.security.nodes_dn lists every node, as described in Configuring the Wazuh indexer: the installation writes only this node’s subject. To print the subject of a certificate in the form these settings expect, run:

openssl x509 -noout -subject -nameopt RFC2253 -in /etc/wazuh-indexer/certs/indexer.pem

indexer-security-init.sh warns when the admin certificate is not among the subjects in plugins.security.authcz.admin_dn.

Set up Wazuh Indexer in your environment

Follow the instructions in the Configuration section to set up Wazuh Indexer in your environment.

On offline installations, disable every task that requires an internet connection to prevent failures.

# opensearch.yml
plugins.content_manager.catalog.update_on_start: false
plugins.content_manager.catalog.update_on_schedule: false
plugins.content_manager.telemetry.enabled: false

plugins.content_manager.catalog.update_on_schedule and plugins.content_manager.telemetry.enabled are dynamic, so on a running deployment they can be applied without a restart:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT "https://127.0.0.1:9200/_cluster/settings" -H 'Content-Type: application/json' -d'
{
  "persistent": {
    "plugins.content_manager.catalog.update_on_schedule": false,
    "plugins.content_manager.telemetry.enabled": false
  }
}'

Disabling scheduled synchronization takes effect immediately and applies to an already-initialized node as well as a fresh one: the plugin updates the periodic job accordingly, and a run that is already scheduled is refused rather than reaching Wazuh CTI.

Disabling scheduled synchronization does not remove content that has already been downloaded. It only stops further updates from Wazuh CTI.

plugins.content_manager.catalog.update_on_start is not dynamic — it only governs the synchronization triggered as the plugin starts, so it takes effect on the next node start.

Starting the service

Enable and start the Wazuh indexer service. The installation does not start it, and it does not start at boot until it is enabled. The package reloads the service manager itself, so no systemctl daemon-reload is needed here.

Systemd
systemctl enable --now wazuh-indexer

SysV

Choose one option according to the operating system used.

a. RPM-based operating system:

chkconfig --add wazuh-indexer
service wazuh-indexer start

b. Debian-based operating system:

update-rc.d wazuh-indexer defaults 95 10
service wazuh-indexer start

Repeat this stage of the installation process for every Wazuh indexer node in your cluster. Then proceed with initializing your single-node or multi-node cluster in the next stage.

3. Cluster initialization

Run the Wazuh indexer indexer-security-init.sh script on any Wazuh indexer node to load the new certificates information and start the single-node or multi-node cluster.

/usr/share/wazuh-indexer/bin/indexer-security-init.sh

Note: You only have to initialize the cluster once, there is no need to run this command on every node.

Testing the cluster installation

  1. Replace $WAZUH_INDEXER_IP_ADDRESS and run the following commands to confirm that the installation is successful. $WAZUH_INDEXER_ADMIN_PASSWORD is the admin password from /etc/wazuh-indexer/export/credentials.env. Load it with WAZUH_INDEXER_ADMIN_PASSWORD=$(sudo grep '^WAZUH_INDEXER_ADMIN_PASSWORD=' /etc/wazuh-indexer/export/credentials.env | cut -d= -f2- | tail -n 1).

    curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X GET "https://$WAZUH_INDEXER_IP_ADDRESS:9200"
    

    Output

    {
      "name" : "node-1",
      "cluster_name" : "wazuh-cluster",
      "cluster_uuid" : "lEzYZtwXTYaGiSAdbcqQmQ",
      "version" : {
        "distribution" : "opensearch",
        "number" : "3.6.0",
        "build_type" : "deb",
        "build_hash" : "1b0a897cd71105595b450b48b591581865f8b46b",
        "build_date" : "2026-10-01T01:45:08.798733103Z",
        "build_snapshot" : false,
        "lucene_version" : "10.4.0",
        "minimum_wire_compatibility_version" : "2.19.0",
        "minimum_index_compatibility_version" : "2.0.0"
      },
      "tagline" : "The OpenSearch Project: https://opensearch.org/"
    }
    
  2. Replace $WAZUH_INDEXER_IP_ADDRESS and run the following command to check if the single-node or multi-node cluster is working correctly.

    curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X GET "https://$WAZUH_INDEXER_IP_ADDRESS:9200/_cat/nodes?v"
    

Configuration

Wazuh Indexer shares the same configuration system as OpenSearch. Refer to the OpenSearch documentation for general information about configuration files and settings.

Configuration files

Wazuh Indexer bundles two main configuration files on each node:

  • /etc/wazuh-indexer/opensearch.yml - main configuration file for Wazuh Indexer. This file contains the settings for the Wazuh Indexer cluster, such as cluster name, node name, network settings, and more.
  • /etc/wazuh-indexer/jvm.options - configuration file for the Java Virtual Machine (JVM) that runs Wazuh Indexer. This file contains settings for the JVM, such as heap size, garbage collection, and more.

System configuration

For production workloads, tune the following operating system and JVM settings on every Wazuh Indexer node before starting the service. The package installations create the wazuh-indexer service user; the settings below apply to that user and the host it runs on.

Note: All the commands in this section require root privileges.

JVM heap size

Wazuh Indexer runs on the Java Virtual Machine (JVM). The heap size determines how much memory the indexer can use for its internal data structures, caches, and request processing. Set it in the /etc/wazuh-indexer/jvm.options file.

Follow these recommendations when sizing the heap:

  • Set the initial heap size (-Xms) and the maximum heap size (-Xmx) to the same value. This prevents performance degradation caused by the JVM resizing the heap at runtime.
  • Set the heap to no more than 50% of the available system RAM. The other half is left for the operating system file system cache, which Wazuh Indexer relies on heavily. On a host that also runs the Wazuh manager or the Wazuh dashboard, set it to 25% of the RAM.
  • Do not set the heap above approximately 32 GB. Above this threshold the JVM can no longer use compressed ordinary object pointers, which wastes memory and reduces performance.

For example, on a node with 8 GB of RAM, set the heap to 4 GB:

-Xms4g
-Xmx4g

Where:

  • -Xms4g sets the initial heap size to 4 GB.
  • -Xmx4g sets the maximum heap size to 4 GB.

During installation, set the heap before the service starts for the first time, as described in Configuring the Wazuh indexer. On a running node, restart the service after changing the heap size:

systemctl restart wazuh-indexer

Memory locking

Configure Wazuh Indexer to lock its process address space into RAM so that none of the JVM is ever swapped out.

  1. bootstrap.memory_lock: true is enabled by default in /etc/wazuh-indexer/opensearch.yml. No changes are needed for package installations.

  2. Grant the wazuh-indexer service user permission to lock unlimited memory. The RPM and Debian packages already configure this for both systemd-based and SysVinit-based systems — no additional configuration is required for package installations.

    • systemd: LimitMEMLOCK=infinity is set in the service file.
    • SysVinit: ulimit -l unlimited is applied by the init script before starting the process.
  3. Reload the service manager and restart Wazuh Indexer:

    systemctl daemon-reload
    systemctl restart wazuh-indexer
    
  4. Verify that memory locking is active by checking that the mlockall value is true:

    curl -k -u <INDEXER_USERNAME>:<INDEXER_PASSWORD> "https://<INDEXER_IP_ADDRESS>:9200/_nodes?filter_path=**.mlockall&pretty"
    
    {
      "nodes" : {
        "sRuGbIQRRfC54wzwIHjJWQ" : {
          "process" : {
            "mlockall" : true
          }
        }
      }
    }
    

    If the output is false, memory locking failed and the following line appears in /var/log/wazuh-indexer/wazuh-indexer.log:

    memory locking requested for wazuh-indexer process but memory is not locked
    

    This usually means the wazuh-indexer user lacks the memlock permission. For systemd-based systems, confirm that LimitMEMLOCK=infinity is present in the service file, reload with systemctl daemon-reload, and restart the service. For SysVinit-based systems, confirm that step 2 was applied correctly.

Note: Enabling bootstrap.memory_lock causes the JVM to reserve all the memory it needs at startup, including native memory beyond the configured heap. Make sure the node has enough physical RAM for the heap plus this overhead, otherwise the service may fail to start.

Virtual memory

Wazuh Indexer uses memory-mapped files (mmapfs) to store its indices. The default operating system limit on memory map areas is too low for production use, which can cause the node to fail to start or run out of memory.

Set vm.max_map_count to at least 262144. To check the current value:

sysctl vm.max_map_count

To increase it permanently, add the following line to /etc/sysctl.conf:

vm.max_map_count=262144

Apply the change without rebooting:

sysctl -p

Note: When running Wazuh Indexer in a container, set vm.max_map_count on the host machine, not inside the container.

File descriptors

Wazuh Indexer uses a large number of file descriptors. Running out of them can lead to data loss, so increase the limit for the wazuh-indexer user to 65535 or higher.

The RPM and Debian packages already set this limit to 65535 through the systemd service, so no additional configuration is required for package installations. To raise the limit manually, create or edit a systemd service override:

mkdir -p /etc/systemd/system/wazuh-indexer.service.d/
cat > /etc/systemd/system/wazuh-indexer.service.d/override.conf << EOF
[Service]
LimitNOFILE=65535
EOF

Reload and restart the service:

systemctl daemon-reload
systemctl restart wazuh-indexer

Verify the limit applied to the running node by checking max_file_descriptors:

curl -k -u <INDEXER_USERNAME>:<INDEXER_PASSWORD> "https://<INDEXER_IP_ADDRESS>:9200/_nodes/stats/process?filter_path=**.max_file_descriptors&pretty"

Avoiding unnecessary service restarts (needrestart)

On Debian and Ubuntu systems with needrestart installed, Wazuh Indexer may get restarted automatically every time apt installs or upgrades any package, even one unrelated to Wazuh Indexer. On Ubuntu 24.04 and 26.04 this happens silently, without asking for confirmation.

This happens because some bundled Java native libraries (used for compression and networking) extract a shared object file into /var/lib/wazuh-indexer/tmp/ and delete it right after loading it, keeping it mapped in memory but no longer present on disk. needrestart interprets processes with deleted mapped libraries as running outdated code and restarts them.

To stop needrestart from restarting Wazuh Indexer on unrelated apt operations, add one of the following to /etc/needrestart/conf.d/:

needrestart still checks the service for other legitimate reasons to restart:

echo 'push(@{$nrconf{blacklist_mappings}}, qr(^/var/lib/wazuh-indexer/tmp/));' > /etc/needrestart/conf.d/wazuh-indexer-tmp.conf

Ignore the service entirely

needrestart will never flag Wazuh Indexer for restart, including for legitimate reasons such as a libc update:

echo 'push(@{$nrconf{blacklist_rc}}, qr(^wazuh-indexer));' > /etc/needrestart/conf.d/wazuh-indexer.conf

Verify the change:

needrestart -v

wazuh-indexer.service should no longer appear in the list of services to restart.

Plugin settings

Setup settings

The Setup plugin is configured through settings in opensearch.yml. All settings use the plugins.setup prefix.

  • plugins.setup.timeout (Integer, default 30, range 5–120) — timeout in seconds for index and search operations.
  • plugins.setup.backoff (Integer, default 15, range 5–60) — delay in seconds for the retry mechanism involving initialization tasks.
  • plugins.setup.max_retries (Integer, default 1, range 0–10) — number of times an initialization task that timed out is re-attempted, each attempt separated by plugins.setup.backoff. 0 disables retrying. The budget is per task: each index, index template and ISM policy gets its own. When it runs out the exception is rethrown, initialization stops, every index registered after the failing one is skipped, and the setup status is marked failed. Raise it on clusters where a cluster-manager election, and therefore the first index creation, routinely outlasts the default single re-attempt.
  • plugins.setup.settings_update.enabled (Boolean, default true) — when false, the settings update endpoint (PUT /_plugins/_setup/settings) returns 403 Forbidden for every caller, regardless of role.

Content Manager settings

The Content Manager plugin is configured through settings in opensearch.yml. All settings use the plugins.content_manager prefix.

  • plugins.content_manager.cti.api (String, default https://api.pre.cloud.wazuh.com/api/v1) — base URL for the Wazuh CTI API. Covers both the catalog endpoints (snapshots, changes, plans) and the CTI Console ones (instance registration, environment lookup, resource-token exchange).

  • plugins.content_manager.catalog.sync_interval (Integer, default 60, range 10–1440, dynamic) — interval, in minutes, between scheduled synchronizations.

  • plugins.content_manager.setup_wait.max_retries (Integer, default 4, range 0–10) — number of retries the catalog sync job performs while waiting for the Setup plugin to report readiness on startup, before giving up until the next scheduled sync.

  • plugins.content_manager.setup_wait.backoff_base_seconds (Integer, default 20, range 1–120) — base delay, in seconds, for the exponential backoff between those retries (delay for retry n is base * 2^n; with the defaults, 20s/40s/80s/160s = 300s / 5 min worst case).

  • plugins.content_manager.max_items_per_bulk (Integer, default 1000, range 10–1000) — maximum documents per bulk indexing request.

  • plugins.content_manager.max_concurrent_bulks (Integer, default 5, range 1–5) — maximum concurrent bulk operations.

  • plugins.content_manager.max_bulk_bytes (Long, default 5242880 / 5 MB, range 1048576–104857600 / 1–100 MB) — maximum request body size, in bytes, for a single bulk indexing request.

  • plugins.content_manager.logtest.max_body_bytes (Long, default 1048576 / 1 MiB, range 1024–16777216 / 1 KiB–16 MiB, dynamic) — maximum size, in bytes, of a logtest request body (POST /logtest, /logtest/normalization, /logtest/detection). Requests whose body exceeds this are rejected with HTTP 413 at the REST layer, before parsing or dispatch, so an oversized event cannot be amplified into the response and exhaust the indexer’s heap.

  • plugins.content_manager.client.timeout (Long, default 10, range 10–50) — HTTP client timeout in seconds for CTI API requests.

  • plugins.content_manager.client.max_retries (Integer, default 3, range 0–10) — number of times a CTI API request is retried after an HTTP 429 (Too Many Requests) response, before the 429 is returned to the caller.

  • plugins.content_manager.client.retry_backoff_base_seconds (Integer, default 30, range 1–300) — base delay, in seconds, for the exponential backoff used between 429 retries when the response carries no usable Retry-After header (delay for retry n is base * 2^n).

  • plugins.content_manager.pit_keepalive (Long, default 120, range 60–600) — point-in-time keepalive in seconds used during paginated index scans.

  • plugins.content_manager.engine.mock (Boolean, default false) — bypasses real Engine socket calls, returning mocked responses instead. Intended for testing only.

  • plugins.content_manager.catalog.update_on_start (Boolean, default true) — trigger content sync when the plugin starts.

  • plugins.content_manager.catalog.update_on_schedule (Boolean, default true, dynamic) — enable the periodic sync job. When false the job is disabled and any scheduled run is refused, so no content is fetched from Wazuh CTI on a schedule. Registering a CTI access token does not start a content update either. On-demand updates (POST /update) are unaffected — they are controlled by plugins.content_manager.catalog.update_on_demand.

  • plugins.content_manager.catalog.ruleset (String, default "") — full CTI consumer URL for ruleset content.

  • plugins.content_manager.catalog.iocs (String, default "") — full CTI consumer URL for IoC content.

  • plugins.content_manager.catalog.vulnerabilities (String, default "") — full CTI consumer URL for vulnerabilities content.

  • plugins.content_manager.catalog.create_detectors (Boolean, default true) — automatically create Ruleset Management detectors from CTI content.

  • plugins.content_manager.telemetry.enabled (Boolean, default true, dynamic) — enable or disable the daily Update check service ping.

  • plugins.content_manager.catalog.update_on_demand (Boolean, default true) — when false, on-demand content updates (POST /update) return 403 Forbidden for every caller, regardless of role.

  • plugins.content_manager.catalog.policy_update.enabled (Boolean, default true) — when false, policy updates (PUT /policy/{space}) return 403 Forbidden for every caller, regardless of role.

  • plugins.content_manager.max_integrations (Integer, default 100, minimum 0, no upper bound, dynamic) — maximum number of integrations that can be created. Requests that would exceed this limit are rejected with HTTP 400.

  • plugins.content_manager.max_decoders (Integer, default 200, minimum 0, no upper bound, dynamic) — maximum number of decoders that can be created. Requests that would exceed this limit are rejected with HTTP 400.

  • plugins.content_manager.max_rules (Integer, default 200, minimum 0, no upper bound, dynamic) — maximum number of rules that can be created. Requests that would exceed this limit are rejected with HTTP 400.

  • plugins.content_manager.max_kvdbs (Integer, default 100, minimum 0, no upper bound, dynamic) — maximum number of KVDBs that can be created. Requests that would exceed this limit are rejected with HTTP 400.

  • plugins.content_manager.max_filters (Integer, default 100, minimum 0, no upper bound, dynamic) — maximum number of filters that can be created per space. Requests that would exceed this limit are rejected with HTTP 400.

  • plugins.content_manager.cti.console.timeout (Integer, default 5, range 1–120) — request timeout, in seconds, for CTI Console calls (instance registration, plans, token exchange). The catalog client has its own timeout in plugins.content_manager.client.timeout; the Console endpoints resolve against plugins.content_manager.cti.api, so moving that setting moves both services.

  • plugins.content_manager.bulk.retry.shed.max_retries (Integer, default 3, range 0–10, dynamic) — number of times a bulk operation the cluster shed under load (circuit breaker trip, indexing-pressure rejection, HTTP 429/503) is re-submitted before its documents are counted as dropped.

  • plugins.content_manager.bulk.retry.shed.initial_backoff_millis (Long, default 1000, range 100–60000, dynamic) — delay, in milliseconds, before the first re-submission of a shed bulk operation. Each subsequent retry doubles it, capped at the max backoff.

  • plugins.content_manager.bulk.retry.shed.max_backoff_millis (Long, default 30000, range 100–60000, dynamic) — ceiling, in milliseconds, for the exponential backoff between shed bulk re-submissions.

  • plugins.content_manager.bulk.retry.topology.max_retries (Integer, default 5, range 0–10, dynamic) — number of times a bulk operation deferred by a transient cluster-topology change (an index recreated mid-load, a shard left unavailable, the node holding it leaving) is re-submitted before its documents are counted as dropped. The default was sized to outlast one rolling restart; raise it on clusters where a restart takes longer, otherwise a synchronization running across a restart drops documents.

  • plugins.content_manager.bulk.retry.topology.initial_backoff_millis (Long, default 5000, range 100–60000, dynamic) — delay, in milliseconds, before the first re-submission of a topology-deferred bulk operation. Each subsequent retry doubles it, capped at the max backoff.

  • plugins.content_manager.bulk.retry.topology.max_backoff_millis (Long, default 30000, range 100–60000, dynamic) — ceiling, in milliseconds, for the exponential backoff between topology-deferred bulk re-submissions.

  • plugins.content_manager.job_schedule.max_retries (Integer, default 3, range 0–10) — number of attempts to register a periodic job with the job scheduler before giving up and logging an error.

  • plugins.content_manager.job_schedule.retry_backoff_seconds (Integer, default 15, range 1–300) — base delay, in seconds, for the linear backoff between job-registration attempts (delay before attempt n is base * n).

  • plugins.content_manager.resource_lock.max_retries (Integer, default 20, range 1–100, dynamic) — number of attempts to acquire the resource-creation lock before the request is rejected with HTTP 503.

  • plugins.content_manager.resource_lock.retry_backoff_millis (Long, default 100, range 10–10000, dynamic) — delay, in milliseconds, between resource-creation lock acquisition attempts.

  • plugins.content_manager.resource_lock.stale_threshold_millis (Long, default 30000, range 5000–600000, dynamic) — age, in milliseconds, past which a held resource-creation lock is treated as orphaned by a crashed node and stolen by the next caller. Set this to the same value on every node, and above the worst-case resource-creation time: the lock is cluster-wide but the setting is read from each node’s own configuration, so nodes that disagree can both consider themselves inside the critical section. The holder never renews the lock, so a value below the time a creation takes lets every concurrent request steal it and the mutex stops working.

  • plugins.content_manager.user_overrides.max_update_attempts (Integer, default 3, range 1–20, dynamic) — number of attempts to write the shared user-overrides registry document before giving up. The registry is a single document, so concurrent writers are serialized optimistically and each version conflict re-reads and re-applies.

  • plugins.content_manager.integration.max_update_attempts (Integer, default 5, range 1–20, dynamic) — number of attempts to update an integration document on a version conflict, when linking or unlinking a resource, before the operation fails.

  • plugins.content_manager.engine.socket_path (String, default /usr/share/wazuh-indexer/engine/sockets/engine-api-http.sock) — filesystem path of the Unix domain socket the Engine API listens on.

  • plugins.content_manager.engine.reload_timeout_minutes (Integer, default 10, range 1–1440) — upper bound, in minutes, on how long a single Engine content reload may stay in flight. It guards against a lost callback wedging the single-flight guard forever.

  • plugins.content_manager.engine.not_ready_grace_minutes (Integer, default 5, range 1–1440) — how long, in minutes, the content indices may stay unable to serve reads before the deferral is escalated from a debug line to a warning.

  • plugins.content_manager.security_analytics.sync_timeout_seconds (Integer, default 60, range 1–3600) — how long, in seconds, a Ruleset Management bulk synchronization step (uploading rules or integrations) may run before it is abandoned and the pass reported as unsuccessful.

  • plugins.content_manager.security_analytics.detector_timeout_seconds (Integer, default 30, range 1–3600) — how long, in seconds, to wait for the first detector creation to complete.

  • plugins.content_manager.security_analytics.cleanup_timeout_seconds (Integer, default 120, range 120–3600) — how long, in seconds, to wait for the deletion of stale Ruleset Management rules and integrations after a content swap. The floor is the default: on timeout the deletion is not retried, so lowering it would make stale resources routine rather than exceptional.

  • plugins.content_manager.security_analytics.detector_interval (Integer, default 2, range 1–10080, dynamic) — detector schedule interval, in minutes, applied when the CTI integration document does not specify one, or specifies one outside the bounds Ruleset Management accepts.

  • plugins.content_manager.update_sub_batch_size (Integer, default 50, range 1–1000) — maximum number of UPDATE offsets batched into a single MultiGet + bulk request while applying a catalog changeset.

  • plugins.content_manager.offset_flush_interval (Integer, default 10, range 1–1000) — how many batches (changeset application) or bulks (snapshot load) are processed between consumer-offset checkpoints. A smaller value narrows the window of work replayed after a crash, at the cost of more writes to the consumer-state document.

  • plugins.content_manager.search_page_size (Integer, default 1000, range 100–10000) — page size used by the paginated IoC reconciliation scan and the IoC export to the Engine. Lower values mean more round trips over the same documents, not fewer documents. Higher values cost more heap per search, and on a busy node search backpressure may cancel the page.

Settings marked dynamic can be changed on a running cluster and take effect on the next operation:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT "https://127.0.0.1:9200/_cluster/settings" -H 'Content-Type: application/json' -d'
{
  "persistent": {
    "plugins.content_manager.bulk.retry.topology.max_retries": 8,
    "plugins.content_manager.bulk.retry.topology.max_backoff_millis": 45000
  }
}'

Ruleset Management settings

The Ruleset Management plugin is configured through settings in opensearch.yml. All node-scope settings use the plugins.security_analytics prefix. Almost every setting is dynamic and can be changed at runtime via the Cluster Settings API.

  • plugins.security_analytics.alert_finding_enabled (Boolean, default false) — enable rollover for the finding history indices, and keep the current write index out of the retention sweep while it is on. Retention deletion itself runs either way. It does not make raw findings be stored; that is the Alerting plugin’s plugins.alerting.alert_finding_enabled.
  • plugins.security_analytics.alert_finding_max_docs (Long, default 1000, minimum 0) — Deprecated. Maximum document count for a finding history index before rollover.
  • plugins.security_analytics.alert_finding_rollover_period (Time, default 12h) — how often the finding history rollover job runs.
  • plugins.security_analytics.alert_history_enabled (Boolean, default false) — enable rollover for the alert history indices, and keep the current write index out of the retention sweep while it is on. Retention deletion itself runs either way.
  • plugins.security_analytics.alert_history_max_age (Time, default 30d) — maximum age of an alert history index before rollover.
  • plugins.security_analytics.alert_history_max_docs (Long, default 1000, minimum 0) — maximum document count for an alert history index before rollover.
  • plugins.security_analytics.alert_history_retention_period (Time, default 60d) — retention period after which alert history indices are deleted.
  • plugins.security_analytics.alert_history_rollover_period (Time, default 12h) — how often the alert history rollover job runs.
  • plugins.security_analytics.auto_correlations_enabled (Boolean, default false) — automatically generate correlation rules from new findings.
  • plugins.security_analytics.correlation.detector_cache_ttl (Time, default 5m) — TTL for the in-memory monitor-id to detector cache. Set to 0s to disable the cache.
  • plugins.security_analytics.correlation.events_backpressure.enabled (Boolean, default true) — write-block the events indices when the correlation backlog fills, so ingestion pauses and the backlog drains instead of the node running out of memory.
  • plugins.security_analytics.correlation.events_backpressure.high_watermark_percent (Integer, default 100, range 1–100) — backlog level, as a percent of correlation.max_pending_findings, at or above which the events indices are write-blocked.
  • plugins.security_analytics.correlation.events_backpressure.low_watermark_percent (Integer, default 60, range 0–99) — backlog level, as a percent of correlation.max_pending_findings, at or below which the events-index write block is lifted.
  • plugins.security_analytics.correlation.max_in_flight_findings (Integer, default 50, range 1–1000) — maximum number of correlation pipelines running concurrently.
  • plugins.security_analytics.correlation.max_pending_findings (Integer, default 10000, range 1–1000000) — maximum findings waiting for a free correlation slot. When the backlog is full, new findings are shed (correlation and enrichment skipped) so the node does not run out of memory under overload.
  • plugins.security_analytics.correlation.metadata_cache_ttl (Time, default 5m) — TTL for the in-memory caches of log-type list and correlation rules by detector type. Set to 0s to disable both caches.
  • plugins.security_analytics.correlation_history_max_age (Time, default 30d) — maximum age of a correlation history index before rollover.
  • plugins.security_analytics.correlation_history_max_docs (Long, default 1000, minimum 0) — maximum document count for a correlation history index before rollover.
  • plugins.security_analytics.correlation_history_retention_period (Time, default 60d) — retention period after which correlation history indices are deleted.
  • plugins.security_analytics.correlation_history_rollover_period (Time, default 12h) — how often the correlation history rollover job runs.
  • plugins.security_analytics.correlation_time_window (Time, default 5m) — time window used to group findings into correlations.
  • plugins.security_analytics.enable_detectors_with_dedicated_query_indices (Boolean, default true) — create dedicated query indices for new detectors.
  • plugins.security_analytics.enable_workflow_usage (Boolean, default true) — use Alerting composite workflows when running detectors.
  • plugins.security_analytics.enriched_findings_bulk_size (Integer, default 100, range 10–1000) — number of enriched findings accumulated before a bulk index request is fired.
  • plugins.security_analytics.enriched_findings_enrich_batch_size (Integer, default 100, range 1–1000) — maximum number of findings drained from the queue per in-flight permit, fetched via a single combined MultiGet.
  • plugins.security_analytics.enriched_findings_flush_interval (Integer, default 5, range 1–60) — interval in seconds at which pending enriched findings are flushed regardless of batch size.
  • plugins.security_analytics.enriched_findings_index_enabled (Boolean, default true) — toggle the enriched findings pipeline.
  • plugins.security_analytics.enriched_findings_max_in_flight (Integer, default 5, range 1–10) — maximum number of concurrent async enrichment chains.
  • plugins.security_analytics.enriched_findings_rule_cache_max_size (Integer, default 10000, minimum 0, static — requires a node restart to change) — maximum number of rule-metadata entries cached in memory. Least-recently-used entries are evicted past this size.
  • plugins.security_analytics.filter_by_backend_roles (Boolean, default false) — restrict access to detectors, rules, and findings based on the requester’s backend roles.
  • plugins.security_analytics.finding_history_max_age (Time, default 30d) — maximum age of a finding history index before rollover.
  • plugins.security_analytics.finding_history_retention_period (Time, default 60d) — retention period after which finding history indices are deleted.
  • plugins.security_analytics.index_timeout (Time, default 60s) — timeout for Ruleset Management index operations.
  • plugins.security_analytics.max_case_management_bulk_size (Integer, default 10, range 0–100, dynamic) — maximum number of findings that can be updated in a single request to the update findings endpoint (PUT /_plugins/_security_analytics/findings/_update). Setting it to 0 disables the endpoint entirely.
  • plugins.security_analytics.max_detectors (Integer, default 10, minimum 0, no upper bound, dynamic) — maximum number of user-created detectors (Content Manager detectors do not count).
  • plugins.security_analytics.max_rules_per_detector (Integer, default 50, minimum 0, no upper bound, dynamic) — maximum number of rules (custom or pre-packaged) allowed in a single detector input. Requests that would exceed this limit are rejected with HTTP 400.
  • plugins.security_analytics.mappings.default_schema (String, default ecs) — default field-mapping schema used to resolve a Sigma rule’s raw field names to Wazuh Common Schema fields when a log type does not declare its own schema.

Wazuh Indexer setup plugin

The wazuh-indexer-setup plugin is a module composing the Wazuh Indexer responsible for the initialization of the indices required by Wazuh.

The Wazuh Indexer Setup plugin is responsible for:

  • Creating the index templates, to define the mappings and settings for the indices.
  • Creating the initial indices. We distinguish between stateful and stream indices. While stream indices contain immutable time-series data and are rolled over periodically, stateful indices store dynamic data that can change over time and reside in a single index.
  • Stream indices are created with a data stream configuration and an ISM rollover policy. See Retention for what those policies actually guarantee — rollover is driven by volume, so the deletion ages are a floor and not a ceiling.

Indices

The following tables list the indices created by this plugin.

Stream indices

IndexDescription
wazuh-events-raw-v5Stores original unprocessed events.
wazuh-active-responsesStores active response execution requests.
wazuh-events-v5-<category>Stores events received by the Wazuh Manager, categorized by their origin or type. Refer to Wazuh Common Schema for more information.
wazuh-findings-v5-<category>Stores security findings generated by the Threat Detectors. These are created each time an event trips a detection rule.
wazuh-metrics-agentsStores statistics about the Wazuh Agents state.
wazuh-metrics-comms-v4Stores statistics about the Wazuh Manager usage and performance for the legacy communication protocol, used by agents below v5.0.0. The information includes the number of events decoded, bytes received, and TCP sessions.
wazuh-metrics-normalizationStores statistics about the Wazuh Engine’s event normalization (decoding) stage.
wazuh-ai-assistant-sessionsStores the users’ conversations with the AI assistant. Rolled over daily, kept for 7 days. Each document carries the owning username in its user field, stamped by the plugin from the authenticated caller; Document Level Security restricts every read to that user’s own conversations, and every write goes through the session API.

Stateful indices

IndexDescription
wazuh-agent-configMost recent configuration reported by each agent, per module. The existing document is overwritten on each report, so no history is kept.
wazuh-agent-statsMost recent statistics reported by each agent, keyed by module. The existing document is overwritten on each report, so no history is kept.
wazuh-states-scaSecurity Configuration Assessment (SCA) scan results.
wazuh-states-fim-filesFile Integrity Monitoring: information about monitored files.
wazuh-states-fim-registry-keysFile Integrity Monitoring: information about the Windows registry (keys).
wazuh-states-fim-registry-valuesFile Integrity Monitoring: information about the Windows registry (values).
wazuh-states-inventory-browser-extensionsStores browser extensions/add-ons detected on the endpoint (Chromium-based browsers — Chrome/Edge/Brave/Opera —, Firefox, and Safari).
wazuh-states-inventory-groupsStores existing groups on the endpoint.
wazuh-states-inventory-hardwareBasic information about the hardware components of the endpoint.
wazuh-states-inventory-hotfixesContains information about the updates installed on Windows endpoints. This information is used by the vulnerability detector module to discover what vulnerabilities have been patched on Windows endpoints.
wazuh-states-inventory-interfacesStores information (up and down interfaces) as well as packet transfer information about the interfaces on a monitored endpoint.
wazuh-states-inventory-networksStores the IPv4 and IPv6 addresses associated with each network interface, as referenced in the wazuh-states-inventory-interfaces index.
wazuh-states-inventory-packagesStores information about the currently installed software on the endpoint.
wazuh-states-inventory-portsBasic information about open network ports on the endpoint.
wazuh-states-inventory-processesStores the detected running processes on the endpoints.
wazuh-states-inventory-protocolsStores routing configuration details for each network interface, as referenced in the wazuh-states-inventory-interfaces index.
wazuh-states-inventory-servicesStores system services detected on the endpoint (Windows Services, Linux systemd units, and macOS launchd daemons/agents).
wazuh-states-inventory-systemOperating system information, hostname and architecture.
wazuh-states-inventory-usersStores existing users on the endpoint.
wazuh-states-vulnerabilitiesActive vulnerabilities on the endpoint and its details.

Internal indices

These indices support the plugin’s own operation rather than storing Wazuh data directly:

IndexDescription
.wazuh-setup-statusHidden, single-document index recording the plugin’s initialization state (running, ready, or failed). See Architecture.
.wazuh-settingsStores cluster-wide Wazuh settings managed through the API reference, such as the Engine’s raw-event indexing flag.
.wazuh-internal-stateHidden system index holding the AI assistant’s providers configuration, assistant-wide settings and field policy, plus Content Manager’s internal state. Reachable only through the plugin’s administrative AI assistant API.
ISM policy config indexInternal OpenSearch Index State Management configuration index used to register the plugin’s rollover policies.

Install

The wazuh-indexer-setup plugin is part of the official Wazuh Indexer packages and is installed by default. However, to manually install the plugin, follow the next steps.

Note: You need to use the wazuh-indexer or root user to run these commands.

/usr/share/wazuh-indexer/bin/opensearch-plugin install file://[absolute-path-to-the-plugin-zip]

Once installed, restart the Wazuh Indexer service.

Uninstall

Note You need to use the wazuh-indexer or root user to run these commands.

To list the installed plugins, run: /usr/share/wazuh-indexer/bin/opensearch-plugin list

To remove a plugin, use its name as a parameter with the remove command: /usr/share/wazuh-indexer/bin/opensearch-plugin remove <plugin-name>

/usr/share/wazuh-indexer/bin/opensearch-plugin remove wazuh-indexer-setup

Architecture

Design

The plugin hooks into the node’s startup lifecycle and creates every required index template, index, and data stream before the node is considered ready to serve Wazuh data. By design, the plugin overwrites any existing index template under the same name, so template changes shipped in a new version take effect automatically on restart.

Retry mechanism

The plugin features a retry mechanism to handle transient faults. In case of a temporal failure (timeouts or similar) during the initialization of the indices, the task is retried after a given amount of time (back-off). If two consecutive faults occur during the initialization of the same index, the initialization process is halted, and the node is shut down. Proper logging is in place to notify administrators before the shutdown occurs.

The back-off time is configurable. Head to Configuration for more information.

Readiness marker

The plugin persists its initialization state in the hidden, single-document .wazuh-setup-status index. Other plugins (currently Content Manager) read this marker to defer their own startup work until Setup has finished creating its indices, avoiding races where a dependent index template doesn’t exist yet.

The marker transitions once per boot:

ValueMeaning
runningIndex initialization is in progress (set at the start of index initialization, overwriting any marker left over from a previous boot).
readyAll index templates, indices and data streams have been created successfully.
failedIndex initialization could not complete (an unhandled exception was thrown while initializing one of the indices).

Writing the marker is best-effort: a failure to persist it is logged but never interrupts node startup.

Replica configuration

During the node initialization, the plugin checks for the presence of the cluster.default_number_of_replicas setting in the node configuration. If this setting is defined, the plugin automatically updates the cluster’s persistent settings with this value. This ensures that the default number of replicas is consistently applied across the cluster as defined in the configuration file.

Wazuh Common Schema

Refer to the docs for complete definitions of the indices. The indices inherit the settings and mappings defined in the index templates.

Event stream templates

All event categories share a single base template. One index template per category is generated dynamically at deployment time from this shared base. Specialized streams (raw, active-responses) use their own dedicated template files.

The WCS field definitions are organized under wcs/stateless/events/:

wcs/stateless/events/
├── findings/   # Fields for findings events
├── main/       # Shared fields for all event categories
└── raw/        # Fields for raw (unprocessed) events

For the underlying class structure and implementation details, see the development guide.

Configuration

Setup settings

The Setup plugin is configured through settings in opensearch.yml. All settings use the plugins.setup prefix.

  • plugins.setup.timeout (Integer, default 30, range 5–120) — timeout in seconds for index and search operations.
  • plugins.setup.backoff (Integer, default 15, range 5–60) — delay in seconds for the retry mechanism involving initialization tasks.
  • plugins.setup.max_retries (Integer, default 1, range 0–10) — number of times an initialization task that timed out is re-attempted, each attempt separated by plugins.setup.backoff. 0 disables retrying. The budget is per task: each index, index template and ISM policy gets its own. When it runs out the exception is rethrown, initialization stops, every index registered after the failing one is skipped, and the setup status is marked failed. Raise it on clusters where a cluster-manager election, and therefore the first index creation, routinely outlasts the default single re-attempt.
  • plugins.setup.settings_update.enabled (Boolean, default true) — when false, the settings update endpoint (PUT /_plugins/_setup/settings) returns 403 Forbidden for every caller, regardless of role.

plugins.setup.settings_update.enabled follows the disable-endpoint pattern shared with Content Manager. See Protecting sensitive configuration for the full pattern.

API reference

The Setup plugin exposes a REST API under /_plugins/_setup/. All endpoints require authentication.


Settings

Update settings

Persists configuration settings to the .wazuh-settings index. Currently, it supports the engine.index_raw_events boolean flag, which controls whether the Engine indexes raw events into the wazuh-events-raw-v5 data stream.

Request

  • Method: PUT
  • Path: /_plugins/_setup/settings

Request body

  • engine (Object, required) — Engine settings object.
  • engine.index_raw_events (Boolean, required) — whether the Engine indexes raw events into the wazuh-events-raw-v5 data stream.

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT \
  "https://127.0.0.1:9200/_plugins/_setup/settings" \
  -H 'Content-Type: application/json' \
  -d '{
    "engine": {
      "index_raw_events": true
    }
  }'

Example response (success)

{
  "message": "Settings updated successfully.",
  "status": 200
}

Example response (missing field)

{
  "message": "Missing required field: 'engine.index_raw_events'.",
  "status": 400
}

Example response (invalid type)

{
  "message": "Field 'engine.index_raw_events' must be of type boolean.",
  "status": 400
}

Status codes

  • 200 — settings updated successfully.
  • 400 — invalid request body, missing required fields, or wrong field type.
  • 500 — internal server error (e.g., failed to persist settings to the index).

AI Assistant Sessions

Writes to the caller’s own AI assistant sessions, stored in the wazuh-ai-assistant-sessions data stream. Every operation requires the plugin:wazuh/ai_assistant/session/write cluster permission, which the wazuh_ai_assistant role — mapped to every authenticated user — grants.

user is derived from the authenticated caller, not from the request body. A value sent for it is discarded, so a session can only ever be created under the caller’s own name. Update, rename and delete load the stored session first and refuse anything that is not the caller’s, reported as 404 rather than 403 so the response does not confirm that somebody else’s session id exists.

There are no read endpoints, by design. Listing sessions and reading a transcript are direct queries against the data stream, scoped by the role’s Document Level Security filter ({"term": {"user": "${user.name}"}}), which correctly scopes reads and gives you OpenSearch’s own search, sorting and pagination. See Access control.

Optimistic concurrency

Every write returns an opaque version token. A data stream permits no unconditional write to one of its backing indices, so every write carries a concurrency check; version is what makes that check visible to clients. Remember the token from the last response seen for a session and send it back as expected_version on the next PUT — never parse or compare it.

On 409, re-read the session from the index with seq_no_primary_term: true, merge, and retry with the version that read returned. Do not retry with a freshly read version without merging: that is the silent overwrite expected_version exists to prevent.

Create a session

Only title and messages are persisted. user, created_at, updated_at and @timestamp are stamped by the server; values sent for them, and any key outside the allowlist, are discarded, not rejected, so a client may POST back a document it previously read. The id is generated by the server: the data stream mints it, and client-chosen ids are not supported.

Request

  • Method: POST
  • Path: /_plugins/_setup/ai_assistant/sessions

Request body

  • title (String, required) — must contain something other than whitespace, 200 characters or fewer. Trimmed before storing.
  • messages (Array, required) — the transcript, up to 1000 entries. May be [] to open a blank session. Each entry is treated as an opaque object: messages is mapped as {"type": "object", "enabled": false}, so entries are stored and returned verbatim but never indexed or schema-checked, and a provider returning unanticipated fields cannot break saving.

The whole request body must be 5 MiB or smaller, on every write route. The entry count bounds how many turns a session holds but not how large each one is; without a byte ceiling the only limit would be the cluster’s http.max_content_length (100MB by default), which would make the 500-session per-owner cap meaningless as a storage bound. Oversized bodies are rejected with 400 before being parsed.

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_setup/ai_assistant/sessions" \
  -H 'Content-Type: application/json' \
  -d '{
    "title": "Any brute force attempts today?",
    "messages": [
      { "role": "user", "content": "Any brute force attempts today?", "createdAt": 1788961419723 }
    ]
  }'

Example response (success)

{
  "id": "8itvhqABtpJqXy3PWi31",
  "title": "Any brute force attempts today?",
  "created_at": "2026-09-09T13:50:39.861Z",
  "updated_at": "2026-09-09T13:50:39.861Z",
  "messages": [
    { "role": "user", "content": "Any brute force attempts today?", "createdAt": 1788961419723 }
  ],
  "version": "12:1"
}

Status codes

  • 200 — session created. 200, not 201, matching POST /_plugins/_setup/ai_assistant/providers.
  • 400 — empty or malformed body; a missing, blank or over-long title; a missing messages; more than 1000 messages; a body over 5 MiB.
  • 403 — the caller lacks the plugin:wazuh/ai_assistant/session/write cluster permission.
  • 409 — the caller already owns the maximum of 500 sessions. 409 rather than 400: the request is well formed, it is the stored state that blocks it. Deleting a session frees a slot; updating an existing session is never blocked by the cap.

Replace a session’s transcript

Replaces messages, and title when one is sent. user, created_at and @timestamp are carried over from the stored session; updated_at is re-stamped.

Request

  • Method: PUT
  • Path: /_plugins/_setup/ai_assistant/sessions/{id}

Request body

  • messages (Array, required) — replaces the stored transcript wholesale. Up to 1000 entries.
  • title (String, optional) — optional on purpose. A chat client auto-saves on every turn; if each save resent a recomputed title it would silently revert a rename the user had just made. When absent, the stored title is kept.
  • expected_version (String, optional) — the version the client last saw. When present, the write is rejected with 409 if the stored session has moved past it. When absent, the write still carries the version this request just read, so the check narrows to “since this request started” rather than being disabled.

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT \
  "https://127.0.0.1:9200/_plugins/_setup/ai_assistant/sessions/8itvhqABtpJqXy3PWi31" \
  -H 'Content-Type: application/json' \
  -d '{
    "messages": [
      { "role": "user", "content": "Any brute force attempts today?", "createdAt": 1788961419723 },
      { "role": "assistant", "content": "No brute force activity in the last 24 hours.", "createdAt": 1788961419744 }
    ],
    "expected_version": "12:1"
  }'

Example response (conflict)

{
  "message": "Session was updated by another session since you last loaded it. Refresh and retry.",
  "status": 409
}

Status codes

  • 200 — session updated. Returns the full session with the write’s own fresh version.
  • 400 — empty or malformed body; a title that is present but blank or over-long; a missing messages; more than 1000 messages; a body over 5 MiB.
  • 403 — the caller lacks the cluster permission.
  • 404 — no session exists with the given id, or it belongs to another user.
  • 409 — the session changed since the version being checked against. The write was not applied.

Rename a session

Changes title and nothing else. messages, user, created_at and @timestamp are carried over untouched, and updated_at is deliberately not re-stamped: a rename is not session activity, and bumping it would reorder a session list sorted by last activity.

Separate from PUT because a rename happens from a list row, where the client holds only a summary and not the transcript.

Request

  • Method: PATCH
  • Path: /_plugins/_setup/ai_assistant/sessions/{id}

Request body

  • title (String, required) — must contain something other than whitespace, 200 characters or fewer. Trimmed before storing.

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PATCH \
  "https://127.0.0.1:9200/_plugins/_setup/ai_assistant/sessions/8itvhqABtpJqXy3PWi31" \
  -H 'Content-Type: application/json' \
  -d '{ "title": "Agent 003 connectivity" }'

Example response (success)

{
  "id": "8itvhqABtpJqXy3PWi31",
  "title": "Agent 003 connectivity",
  "updated_at": "2026-09-09T13:44:09.066Z",
  "version": "13:1"
}

The response carries the write’s own fresh version, so a client whose renamed session is also the open one does not conflict on its next auto-save.

Status codes

  • 200 — session renamed.
  • 400 — empty or malformed body; a title that is blank, whitespace-only or over-long; a body over 5 MiB.
  • 403 — the caller lacks the cluster permission.
  • 404 — no session exists with the given id, or it belongs to another user.
  • 409 — the session was written or deleted between this request’s read and its write.

Delete a session

Ownership is checked against the stored session first; one belonging to another user is reported as 404 and left untouched.

Request

  • Method: DELETE
  • Path: /_plugins/_setup/ai_assistant/sessions/{id}

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X DELETE \
  "https://127.0.0.1:9200/_plugins/_setup/ai_assistant/sessions/8itvhqABtpJqXy3PWi31"

Example response (success)

{
  "message": "Session deleted.",
  "status": 200,
  "id": "8itvhqABtpJqXy3PWi31"
}

Status codes

  • 200 — session deleted.
  • 403 — the caller lacks the cluster permission.
  • 404 — no session exists with the given id, or it belongs to another user. A repeated delete also returns 404.

Documentation maintenance — modifications to the REST API must be reflected in both openapi.yml and this file.

Retention

Stream indices (events, raw events, findings, metrics, active responses and AI assistant sessions) are data streams managed by Index State Management (ISM) policies shipped by the Setup plugin. Each policy has two states:

  • hot — runs a rollover action. The write index is closed and a new backing index is created once the index reaches 20 GB per primary shard (or 200,000,000 documents, whichever comes first).
  • delete — deletes the index. The hot state transitions to it once the index reaches the policy’s deletion age.

The two conditions are chained, not independent: ISM evaluates a state’s transitions only after that state’s actions have completed. A backing index therefore becomes eligible for deletion only after it has rolled over, and min_index_age is measured from index creation.

The retention formula

retention ≈ max(deletion age, time to fill 20 GB per primary shard)

The deletion age is a floor, not a ceiling.

  • At high ingest rates the shard fills quickly and the deletion age binds: it guarantees at least that much history however fast indices fill.
  • At low ingest rates the fill time dominates and the deletion age never binds: data is kept much longer than the number in the policy description suggests.

Per-stream values

Every stream rolls over at 20 GB per primary shard or 200,000,000 documents. Only the AI assistant sessions policy adds an age condition (min_index_age: 1d) to the rollover itself.

PolicyStreamsRolloverDeletion age (floor)
stream-events-policywazuh-events-v5-<category>20 GB / 200M docs1 hour
stream-raw-events-policywazuh-events-raw-v520 GB / 200M docs10 minutes
stream-findings-policywazuh-findings-v5-<category>20 GB / 200M docs90 days
stream-metrics-policywazuh-metrics-*20 GB / 200M docs30 days
stream-active-responses-policywazuh-active-responses20 GB / 200M docs3 days
ai-assistant-sessions-policywazuh-ai-assistant-sessions1 day, or 20 GB / 200M docs7 days

The AI assistant sessions policy is the exception on purpose: 7-day conversation retention only means something if indices roll over on a daily schedule, so that stream rotates by age. The other five rotate by volume.

Disk sizing

Stream templates use one primary shard per index and auto_expand_replicas: 0-1, so on any cluster of two or more nodes the on-disk cost doubles. The 200M document condition effectively never fires first — at ~1 KB per event that would be 200 GB on a shard that rolls at 20 GB.

Per active event category the cost is the 20 GB write index plus the ingest accumulated during the deletion-age window:

Events ingestActive categoriesFill timePrimariesWith 1 replicaRetention
10 GB/day3~6 days~60 GB~120 GB~6 days
100 GB/day3~14 h~64 GB~128 GB~14 h
1 TB/day4~2 h~120 GB~240 GB~2 h
10 TB/day4~12 min~500 GB~1 TB1 h (floor binds)

The ceiling is one hour of total event ingest plus 20 GB per active category, doubled if a replica is allocated. Events have no unbounded-disk failure mode. What is unbounded on a quiet deployment is retention time, not disk.

Low-volume deployments retain data indefinitely

ISM never deletes a data stream’s write index, and below 20 GB per primary shard the stream never rolls over. On a low-volume deployment the events and raw events streams therefore retain their data indefinitely: the backing index stays in the hot state on the attempt_rollover step with condition_not_met, and the 1-hour and 10-minute deletion ages never apply.

This is the designed behaviour — disk usage stays bounded — but it has a consequence worth stating plainly:

The deletion ages in these policies cannot be used as privacy or compliance statements. “Raw events are deleted after 10 minutes” is only true of a deployment that fills 20 GB per primary shard. On a quiet deployment, raw events persist until the index rolls over or is removed by hand.

You can confirm what a given backing index is doing:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD \
  'https://localhost:9200/_plugins/_ism/explain/.ds-wazuh-events-v5-security-000001?pretty'

A response showing "step": {"name": "attempt_rollover", "step_status": "condition_not_met"} and "rolled_over": false means the index has not yet met a rollover condition and is not eligible for deletion.

The shipped policies are rewritten on every restart

The Setup plugin re-indexes its six policy documents whenever a node is elected cluster manager, which includes every restart. It writes each one by policy id, so the write overwrites whatever is there — an edit made to a shipped policy through the ISM API is silently reverted on the next boot, and the plugin logs ISM policy [<name>] created for all six as if they were new.

It reverts partially, which is the dangerous part. Each managed index holds its own embedded copy of the policy it was registered with, so indices already moved onto an edited policy keep the edit while every backing index created afterwards gets the shipped one. The result is two retention regimes in the same data stream, with nothing to signal it.

Never edit one of the six shipped policies in place. To change retention, create your own policy instead, as described next.

Opting in to a hard time ceiling

If your deployment needs retention bounded in time rather than in volume — a regulatory maximum, or a data-minimization requirement — put an age condition on the rollover action, the way ai-assistant-sessions-policy does.

Do it in a policy of your own. The plugin only ever rewrites its own six policy ids, so a policy under a different id is never touched.

1. Create the policy under its own policy_id. Put min_index_age on the rollover action, shorter than the deletion age that follows it, and give its ism_template the same index patterns as the shipped policy at priority: 1, so it wins over the shipped policy (priority: 0) for newly created backing indices:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT \
  'https://localhost:9200/_plugins/_ism/policies/custom-events-24h-ceiling' \
  -H 'Content-Type: application/json' -d '{
  "policy": {
    "policy_id": "custom-events-24h-ceiling",
    "description": "Events, capped at 24 hours of retention.",
    "default_state": "hot",
    "states": [
      {
        "name": "hot",
        "actions": [
          {
            "retry": { "count": 3, "backoff": "exponential", "delay": "1m" },
            "rollover": {
              "min_index_age": "1h",
              "min_doc_count": 200000000,
              "min_primary_shard_size": "20gb"
            }
          }
        ],
        "transitions": [
          { "state_name": "delete", "conditions": { "min_index_age": "1d" } }
        ]
      },
      {
        "name": "delete",
        "actions": [
          {
            "retry": { "count": 3, "backoff": "exponential", "delay": "1m" },
            "delete": {}
          }
        ],
        "transitions": []
      }
    ],
    "ism_template": [
      {
        "index_patterns": [".ds-wazuh-events-v5-*", "wazuh-events-v5*"],
        "priority": 1
      }
    ]
  }
}'

2. Move the already-managed backing indices onto it. The ism_template only applies to indices created from now on; existing ones keep the policy they were registered with:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  'https://localhost:9200/_plugins/_ism/change_policy/.ds-wazuh-events-v5-*' \
  -H 'Content-Type: application/json' -d '{"policy_id": "custom-events-24h-ceiling"}'

3. Wait for the change to actually apply, and verify it. change_policy is asynchronous: the response reports the indices it queued, not indices it changed. The switch happens on the managed index’s next ISM run, roughly a minute later. Confirm before going further:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD \
  'https://localhost:9200/_plugins/_ism/explain/.ds-wazuh-events-v5-*?pretty'

Every index must report your policy_id. Do not restart the cluster until it does — a restart while the change is still pending drops it, and you are left with some indices on your policy and some on the shipped one, which is the split described above.

Two trade-offs to weigh before doing this:

  • More indices, more shards. Rolling over on a schedule on a quiet deployment produces many small backing indices, each with its own shard overhead.
  • Data loss relative to today. A deployment that currently keeps months of events inside a single 20 GB shard will start ageing them out at the ceiling you choose.

Do not use min_rollover_age in the transition instead: it expresses “N after rollover”, stacking the retention window on top of an already-full generation, which costs more disk at exactly the ingest rates where disk matters.

Wazuh Common Schema

The Wazuh Common Schema (WCS) is the data model of the indices Wazuh writes: events, findings, inventory states, metrics and content. It derives from version 9.1.0 of the Elastic Common Schema (ECS): it keeps the ECS field names and types, and adds the fields Wazuh needs, most of them under the wazuh object.

A field keeps its ECS meaning unless its WCS description says otherwise. Wazuh gives a few ECS fields a specific meaning, the time fields in particular. This page explains them, together with the Wazuh fields that events and findings rely on:

Field reference

Every index has a WCS module in the wcs/ folder of the repository. Each module lists its fields in two generated files under docs/:

  • fields.csv — one row per field, with its field set, type, level, an example and a one-line description.
  • wcs_flat.yml — the same fields with their full description.

The modules behind events and findings are:

The Level column tells where a field comes from: core and extended fields come from ECS, custom fields are Wazuh additions.

The events and findings data streams accept WCS fields only: a document carrying a field outside the schema is rejected. Their index templates map each field the first time a document uses it, so the mapping of a backing index only lists the fields it has received so far.

WCS also covers the inventory state indices and the metrics data streams (see Indices), and the content indices of the Content Manager plugin (see System indices). This page focuses on events and findings.

From log to finding

sequenceDiagram
    participant A as Wazuh agent
    participant E as Wazuh Engine
    participant R as wazuh-events-raw-v5
    participant V as wazuh-events-v5-{category}
    participant D as Ruleset Management
    participant F as wazuh-findings-v5-{category}

    A->>E: Log, with the agent metadata, queue and location
    E->>E: Set @timestamp and wazuh.event.id
    E-->>R: Raw event, only when raw indexing is enabled
    E->>E: Decode, add wazuh.integration, wazuh.space and wazuh.cluster
    E->>V: Normalized event
    D->>V: Evaluate new events against the detection rules
    D->>F: One finding per event and matching rule
  1. The Wazuh agent sends every log together with metadata about itself (wazuh.agent.*), the queue it used and the location it read the log from.
  2. On receiving the log, the Wazuh Engine sets @timestamp and wazuh.event.id. When raw indexing is enabled through the engine.index_raw_events setting, it writes the log as received to wazuh-events-raw-v5.
  3. The decoders of the matching integration turn the log into WCS fields. The Engine adds the integration, space and cluster fields, removes the temporary fields (those whose name starts with _), and writes the event to the data stream of the integration’s category.
  4. Ruleset Management detectors evaluate the new events against their rules and write a finding for every match.

Categories and data streams

Every integration belongs to a category, and the category selects the data streams its events and findings are written to: wazuh-events-v5-<key> and wazuh-findings-v5-<key>. The key is the identifier used everywhere — in data stream names, integrations, rules, decoders and Ruleset Management — so use it exactly as shown.

NameKeyExample log types
Access Managementaccess-managementad_ldap, apache_access, okta
Applicationsapplicationsgithub, gworkspace, m365
Cloud Servicescloud-servicesazure, cloudtrail, s3
Network Activitynetwork-activitydns, network, vpcflow
Securitysecuritywaf
System Activitysystem-activitylinux, windows, others_macos
Otherotherothers_application, others_apt, others_web
Unclassifiedunclassified

Events that no integration recognizes are unclassified. They are only indexed when the policy sets index_unclassified_events.

All event categories share one index template definition, and so do all findings categories. The Setup plugin registers one index template per category from that definition, changing only the index pattern and the rollover alias, so every category has the same mappings. To list the event templates:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X GET \
  "https://127.0.0.1:9200/_index_template/wazuh-events-*"

See Stream indices for the other data streams.

Time fields

Two fields answer two different questions:

  • @timestamp — when the Wazuh Engine received the event. The Engine sets it once for every log it receives, before any decoder runs, in Coordinated Universal Time (UTC) with millisecond precision. The raw event, the normalized event and its findings carry the same value, and every event has it.
  • event.start — when the logged activity happened, or started when it has a duration, such as a network session. The decoders set it from the timestamp found in the original log; a date parsed without a time zone is taken as UTC. Events whose source has no timestamp of its own, such as inventory or rootcheck events, do not have it.

@timestamp is therefore later than event.start. The gap is the time the log took to reach the Engine: usually a few seconds, longer when the agent sends a backlog, for example after reconnecting.

{
  "@timestamp": "2026-10-13T16:01:29.016Z",
  "event": {
    "original": "Oct 13 16:01:22 wazuh-aio sudo[61793]:  vagrant : PWD=/home/vagrant ; USER=root ; COMMAND=/usr/bin/systemctl status wazuh-manager",
    "start": "2026-10-13T16:01:22.000Z"
  }
}

Use each field for what it measures:

  • The Wazuh Dashboard filters events and findings by @timestamp. An event is listed at the time Wazuh received it, so a log that arrives late appears at its arrival time; its event.start tells when it happened.
  • To search by the time something happened, filter on event.start.
  • Custom decoders should follow the convention of the Wazuh ruleset: write the time found in the log to event.start and leave @timestamp as the Engine sets it.

The other ECS time fields play a smaller role:

  • event.end — when the activity ended or was last observed, for sources that report it, such as flow logs or cloud findings.
  • event.duration — duration of the activity, in nanoseconds, for sources that report it.
  • event.timezone — time zone of the source, set by decoders whose sources write timestamps without one.
  • event.created and event.ingested — neither the Wazuh agent nor the Wazuh Engine sets them. A few decoders copy them from sources that report them, such as events forwarded from another platform.

The rules of the Wazuh ruleset do not filter on any time field. Findings carry the @timestamp of the event that matched; no field records when the finding itself was created.

The wazuh object

The wazuh object describes how Wazuh collected and processed an event, as opposed to what the log says. None of its fields is parsed from the log: the Wazuh agent, the Wazuh Engine and Ruleset Management set them, except the case management fields of findings, which users manage.

  • wazuh.agent.* — the Wazuh agent that collected the event: its id, name, version and groups, and its host.* (hostname, architecture and operating system). The agent sends them with every log.
  • wazuh.event.id — the identifier of the received log. See Following a log through its documents.
  • wazuh.integration.name and wazuh.integration.category — the integration whose decoder matched the event, and its category. The category selects the data stream the event and its findings are written to. Detection rules commonly filter on the integration name.
  • wazuh.integration.decoders — the decoders that matched the event, from the root decoder to the most specific one.
  • wazuh.protocol.location — where the agent collected the event: the path of a monitored file, a log channel such as journald, or the agent module that generated the event, such as syscollector.
  • wazuh.protocol.queue — the agent queue the event came through, stored as the byte value of the queue character: 49 (1) for log collector events, 57 (9) for rootcheck and 100 (d) for syscollector.
  • wazuh.cluster.name and wazuh.cluster.node — the Wazuh Manager cluster and node that processed the event.
  • wazuh.space.name — the space of the policy that processed the event: standard for the Wazuh ruleset, custom for user content. wazuh.space.event_discarded is true on events that a filter discarded, which are only indexed when the policy sets index_discarded_events.
  • wazuh.threat.enrichments.* — the Indicators of Compromise (IoC) that matched a field of the event, added by the Engine’s IoC enrichment.
  • wazuh.rule.* — on findings only, the Wazuh rule that matched. See Findings.
  • wazuh.case.* — on findings only, the case management fields. See Case management.

wazuh.integration.rules and wazuh.schema.version belong to the schema, but events and findings do not carry them.

Root fields and wazuh fields

agent, host and rule exist both at the root and under wazuh, and they describe different things:

  • wazuh.agent.* is the Wazuh agent that collected the event. agent.* and host.* at the root hold what the log reports, such as the host.hostname of a syslog header, which can be another machine when the agent collects logs forwarded by other systems. Network and security devices that report events appear under observer.*.
  • wazuh.rule.* is the Wazuh rule that matched, on findings only. rule.* at the root is the rule of the source device or service as the log reports it, such as the ID of a firewall rule.

Following a log through its documents

A single log can produce several documents: the raw event, the normalized event and a finding for every rule it matches. The Wazuh Engine generates wazuh.event.id, a universally unique identifier (UUID) version 4, once for every log it receives, and all those documents carry the same value. Search for it to gather them:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X GET \
  "https://127.0.0.1:9200/wazuh-events-raw-v5,wazuh-events-v5-*,wazuh-findings-v5-*/_search" \
  -H 'Content-Type: application/json' \
  -d '{"query": {"term": {"wazuh.event.id": "cdd5d3ca-be21-47e5-b65e-9dc03814aab5"}}}'

wazuh.event.id is not the document _id. It is not event.id either: that field holds the identifier the source gave the event, when the log has one.

The event object and message

The decoders fill most event fields, following ECS:

  • event.kind, event.category, event.type and event.outcome — the ECS categorization fields, restricted to the values ECS allows. Detection rules commonly filter on event.kind and event.category.
  • event.code, event.action, event.provider and event.dataset — what the event is according to its source, such as a Windows event ID in event.code. event.code and event.action are the fields the rules of the Wazuh ruleset filter on most.
  • event.id — the identifier the source gave the event, when the log has one.
  • event.changed_fields — on inventory and file integrity monitoring change events, the fields that changed since the previous scan.

The Wazuh Engine sets event.original, and Ruleset Management adds two fields to findings:

  • event.original — the log as the Wazuh Engine received it, before decoding. It is not indexed: it cannot be searched or aggregated, only read from _source, so search the decoded fields instead. In raw events it keeps the <queue>:<location>: prefix the agent adds.
  • event.doc_id and event.index — on findings, the _id of the event that matched and the backing index that holds it. See Findings.

message holds a human-readable message that the decoder extracts from the log, such as the free-text part of a syslog line, or composes from other fields. Not every decoder sets it, and it never replaces event.original. It is mapped as keyword, so a query on it matches the whole value: use a wildcard query to find a word inside it. Values longer than 4096 characters are stored but not indexed.

Findings

A finding records that an event matched a detection rule. Ruleset Management writes one finding for every event and matching rule to wazuh-findings-v5-<category>, where <category> is the event’s wazuh.integration.category. See Wazuh enriched findings for how findings are generated.

A finding is the matched event with the rule added:

  • Every field of the event, copied unchanged and in the same place: @timestamp, event.*, wazuh.event.id, wazuh.integration.* and every decoded field.
  • event.doc_id and event.index — the _id of the event and the backing index that holds it, such as .ds-wazuh-events-v5-system-activity-000001.
  • wazuh.rule — stores information about the rule that matched:
    • id — the ID of the rule document.
    • title — the rule title, with its placeholders filled from the event (see Dynamic event field referencing).
    • level and status — the rule’s severity level and maturity status.
    • tags — the rule level, the integration the rule belongs to, and then the rule’s own tags.
    • sigma_id — the identifier of the Sigma rule it was imported from, when it has one.
    • compliance.* and mitre.* — the compliance and MITRE ATT&CK fields of the rule.

Keep in mind that:

  • @timestamp is the event’s. No field records when the finding was created.

  • The finding’s _id is not the event’s. Ruleset Management derives it from the event and the rule, so a repeated match of the same event and rule, written to the same backing index, is rejected as a duplicate instead of producing a second finding.

  • The event stays in its own data stream. To read it, fetch event.doc_id from event.index:

    curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X GET \
      "https://127.0.0.1:9200/.ds-wazuh-events-v5-system-activity-000001/_doc/2u0ANqEB364TRbsiOf5A"
    

A finding, trimmed to its main fields:

{
  "_index": ".ds-wazuh-findings-v5-system-activity-000001",
  "_id": "lLMqOa0Wyqi5jb0AmqiyIQ",
  "_source": {
    "@timestamp": "2026-10-13T16:01:29.016Z",
    "event": {
      "doc_id": "2u0ANqEB364TRbsiOf5A",
      "index": ".ds-wazuh-events-v5-system-activity-000001",
      "kind": "event",
      "category": ["authentication", "process"],
      "action": "sudo",
      "start": "2026-10-13T16:01:22.000Z",
      "original": "Oct 13 16:01:22 wazuh-aio sudo[61793]:  vagrant : PWD=/home/vagrant ; USER=root ; COMMAND=/usr/bin/systemctl status wazuh-manager"
    },
    "message": "vagrant : PWD=/home/vagrant ; USER=root ; COMMAND=/usr/bin/systemctl status wazuh-manager",
    "user": { "name": "vagrant", "effective": { "name": "root" } },
    "wazuh": {
      "event": { "id": "cdd5d3ca-be21-47e5-b65e-9dc03814aab5" },
      "agent": { "id": "001", "name": "agent-aio", "version": "v5.0.0", "groups": ["default"] },
      "integration": {
        "name": "system-auth",
        "category": "system-activity",
        "decoders": ["decoder/core-wazuh-message/0", "decoder/syslog/0", "decoder/system-auth/0"]
      },
      "protocol": { "location": "journald", "queue": 49 },
      "cluster": { "name": "wazuh", "node": "node01" },
      "space": { "name": "standard" },
      "rule": {
        "id": "8784f095-729a-5912-9315-119722b444ed",
        "title": "Sudo command executed - vagrant",
        "level": "low",
        "status": "stable",
        "tags": ["low", "wazuh-generic-1", "attack.privilege-escalation", "attack.t1548", "attack.t1548.003"],
        "compliance": { "pci_dss": ["7.2", "10.2.1.5", "10.2.2"] },
        "mitre": {
          "tactic": { "id": ["TA0004"], "name": ["Privilege Escalation"] },
          "technique": { "id": ["T1548"], "name": ["Abuse Elevation Control Mechanism"] },
          "subtechnique": { "id": ["T1548.003"], "name": ["Sudo and Sudo Caching"] }
        }
      }
    }
  }
}

Raw events

When the engine.index_raw_events setting is true (it is false by default), the Wazuh Engine writes every log it receives to wazuh-events-raw-v5 before decoding it. A raw event holds only what arrived with the log:

  • @timestamp and wazuh.event.id — the same values as the normalized event.
  • event.original — the log, with the <queue>:<location>: prefix the agent adds.
  • wazuh.agent.* and wazuh.cluster.name — the metadata the agent sends with the log.
{
  "@timestamp": "2026-10-16T15:53:03.843Z",
  "event": {
    "original": "1:/var/log/apache2/access.log:192.0.2.12 - - [10/Oct/2026:12:30:00 +0000] \"GET /index.html HTTP/1.1\" 404 196 \"-\" \"curl/8.0\""
  },
  "wazuh": {
    "event": { "id": "a715177a-217c-4103-bc79-37c5766025ac" },
    "agent": {
      "id": "001",
      "name": "agent-aio",
      "version": "v5.0.0",
      "groups": ["default"],
      "host": {
        "hostname": "wazuh-aio",
        "architecture": "x86_64",
        "os": { "name": "Ubuntu", "platform": "ubuntu", "type": "linux", "version": "22.04.3 LTS (Jammy Jellyfish)" }
      }
    },
    "cluster": { "name": "wazuh" }
  }
}

The log in this example dates from 10 October and reached the Engine on 16 October: @timestamp records the arrival, while the decoded event keeps the log’s own time in event.start.

Content Manager

The Content Manager is a Wazuh Indexer plugin responsible for managing detection content — rules, decoders, integrations, key-value databases (KVDBs), and Indicators of Compromise (IoCs). It synchronizes content from the Wazuh Cyber Threat Intelligence (CTI) API, provides a REST API for user-generated content, and communicates with the Wazuh Engine to activate changes.

It also includes the Update check system, which communicates with the CTI Update check API once per day to let Wazuh determine whether a newer Wazuh version is available for the deployment.

Update check components are:

  • Update check API (CTI)
  • Update check system (Wazuh Indexer)
  • Update check UI (Wazuh Dashboard)

Content synchronization

The Content Manager synchronizes three categories of detection content from the Wazuh CTI API, each updated independently:

  • Catalog content — detection rules, decoders, integrations, key-value databases (KVDBs), and the routing policy.
  • IoC feed — Indicators of Compromise (IoC) for threat detection enrichment.
  • CVE feed — Common Vulnerabilities and Exposures (CVE) data for vulnerability detection. CVE entries are only added or updated, never removed.

On first start, the plugin initializes from a snapshot. It first downloads the latest snapshot from the Wazuh CTI API, using the configured catalog URL or, when none is set, the one of the subscription plan, the one stored by the previous sync, or the one recorded in the snapshot bundled with the Wazuh Indexer package. If the download fails, it loads the bundled snapshot instead, so detection content is available immediately even without network access.

Once initialized, the plugin keeps content current automatically. A sync check runs at startup and again on a regular schedule — every 60 minutes by default. Each check fetches only the changes since the last sync: new or updated resources are added, removed resources are deleted. If the local content cannot be reconciled with the remote state, the plugin recovers by re-downloading the latest snapshot.

These behaviors are configurable in opensearch.yml:

  • plugins.content_manager.catalog.update_on_start (Boolean, default true) — whether to check for updates when the plugin starts.
  • plugins.content_manager.catalog.update_on_schedule (Boolean, default true, dynamic) — whether the periodic sync runs at all.
  • plugins.content_manager.catalog.sync_interval (Integer, default 60, dynamic) — how often periodic sync runs, in minutes.

The last two are dynamic: they can be changed on a running deployment through the Cluster Settings API, and take effect without a restart. See Configuration for turning scheduled updates off.

When telemetry is enabled (the default), the plugin also sends a daily heartbeat to the Wazuh CTI service with the cluster UUID and the deployed Wazuh version. This powers the update notification shown in the Wazuh Dashboard when a newer release is available. To opt out, set plugins.content_manager.telemetry.enabled to false.

User-generated content

The Content Manager provides a full CUD (create, update, delete) REST API for creating custom detection content:

  • Rules: custom detection rules associated with an integration.
  • Decoders: custom log decoders associated with an integration.
  • Integrations: logical groupings of related rules, decoders, and KVDBs.
  • KVDBs: key-value databases used by rules and decoders for lookups.

User-generated content is stored in the draft space and is separate from the CTI-managed standard space. This separation ensures that user customizations never conflict with upstream CTI content.

See the API reference for endpoint details.

Content spaces

The Content Manager organizes content into spaces:

SpaceDescription
StandardRead-only content synced from the CTI API. This is the baseline detection content.
DraftWritable space for user-generated content. CUD operations target this space.
TestUsed for logtest operations and content validation before final promotion.
CustomThe final space for user content. Content promoted to this space is used by the Wazuh Engine (via the manager package) to actively decode and process logs.

Content flows through spaces in a promotion chain: Draft → Test → Custom. The Standard space exists independently as the upstream CTI baseline. Each space maintains its own copies of rules, decoders, integrations, KVDBs, filters, and the routing policy within the system indices.

Policy management

The routing policy defines how the Wazuh Engine processes incoming events — which integrations are active and in what order. The Content Manager provides an API to update the draft policy:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT \
  "https://127.0.0.1:9200/_plugins/_content_manager/policy/draft" \
  -H 'Content-Type: application/json' \
  -d '{
    "resource": {
      "metadata": {
        "title": "Draft policy",
        "author": "Wazuh Inc.",
        "description": "Custom policy",
        "documentation": "",
        "references": ["https://wazuh.com"]
      },
      "enabled": true,
      "index_unclassified_events": false,
      "index_discarded_events": false,
      "integrations": ["<ids currently in the policy>"],
      "filters": ["<ids currently in the policy>"]
    }
  }'

The space is part of the path, and the metadata block above is required in the draft space. See Update policy for the full field list.

Policy changes are applied to the draft space and take effect after promotion.

Promotion workflow

The promotion workflow moves content through the space chain (Draft → Test → Custom):

  1. Preview changes: GET /_plugins/_content_manager/promote?space=draft returns a diff of what will change (additions, updates, deletions for each content type).
  2. Execute promotion: POST /_plugins/_content_manager/promote promotes the content from the source space to the next space in the chain.

The promotion chain works as follows:

  • Draft → Test: content is promoted for validation and logtest operations.
  • Test → Custom: once validated, content is promoted to the Custom space where it becomes active — the Wazuh Engine (via the manager package) uses this space to decode and process logs in production.

During promotion, the Content Manager:

  • Sends updated content to the Engine
  • Validates the configuration
  • Triggers a configuration reload
  • Updates the target space to reflect the promoted content

Engine communication

The Content Manager communicates with the Wazuh Engine through a Unix domain socket located at:

/usr/share/wazuh-indexer/engine/sockets/engine-api-http.sock

This socket is used for:

  • Logtest: sends a log event to the Engine for analysis and returns the decoded/matched result.
  • Content validation: validates rules and decoders before promotion.
  • Configuration reload: signals the Engine to reload its configuration after promotion.

System indices

The Content Manager uses the following system indices:

IndexDescription
.wazuh-cti-consumersSynchronization state for each CTI consumer type (type, resource, is_public, offsets, status, pending sync phases)
.wazuh-internal-statePersisted CTI access token (hidden, single document)
wazuh-threatintel-rulesDetection rules (both CTI-synced and user-generated, across all spaces)
wazuh-threatintel-decodersLog decoders
wazuh-threatintel-integrationsIntegration definitions
wazuh-threatintel-kvdbsKey-value databases
wazuh-threatintel-policiesRouting policies
wazuh-threatintel-enrichmentsIndicators of Compromise (IoC)
.wazuh-threatintel-vulnerabilitiesCommon Vulnerabilities and Exposures (CVE) data from CTI — hidden, no spaces, offset-tracked
wazuh-threatintel-filtersEngine filters (routing filters for event classification)
.wazuh-content-manager-jobsJob Scheduler metadata for periodic sync and update check jobs

For the alias-backed blue/green storage details and the exact hidden/alias status of each index, see the development guide’s system indices table.

Wazuh Cloud subscription

To synchronize the content of a subscription plan from the CTI API, the Wazuh Indexer requires a valid CTI access token. The token is registered via the REST API:

  1. Store credentials by sending the CTI access token via POST /_plugins/_content_manager/subscription. The token is persisted in the .wazuh-internal-state hidden index and loaded into memory, and a content update starts right away to apply the subscription plan.
  2. The Content Manager uses the in-memory token for all CTI API requests.
  3. Without a registered token, the instance synchronizes the public content: GET /_plugins/_content_manager/subscription reports the public plan and "is_registered": false.

See Subscription management in the API reference.

Pre-registration with Wazuh Cloud

The Content Manager supports pre-registration of the Wazuh instance with Wazuh Cloud using the DEPLOY_KEY environment variable. If this variable is set at startup, the Content Manager automatically registers the token as if it were sent through the REST API, enabling immediate synchronization with the CTI API without manual intervention. Snapshots bundled with the package are removed in favor of fetching the latest content directly from the CTI API using the provided token. This streamlines the setup process for new deployments and ensures that they start with the most up-to-date detection content from their subscription plan.

Architecture

The Content Manager plugin operates within the Wazuh Indexer environment. It is composed of several components that handle REST API requests, background job scheduling, content synchronization, user-generated content management, and Engine communication.

Components

REST layer

Exposes HTTP endpoints under /_plugins/_content_manager/ for:

  • Subscription management (store CTI access token)
  • Manual content sync trigger
  • Version check
  • CUD operations on rules, decoders, integrations, filters, and KVDBs
  • Policy management
  • Promotion preview and execution
  • Logtest execution (combined, normalization-only, and detection-only)
  • Space reset

Credentials store

Manages the CTI access token used for all CTI API requests. The token is submitted via POST /subscription, persisted in the .wazuh-internal-state hidden index, and cached in memory. On node startup, the token is loaded from the index into memory. Without a registered token, sync and update operations download the public content instead of the subscription plan’s.

All HTTP clients that communicate with CTI services send a custom User-Agent header in the format Wazuh Indexer <version> (e.g., Wazuh Indexer 5.0.0), and an Accept-Encoding: gzip header so responses are transferred compressed. This applies to the Catalog API client, Snapshot client, and Telemetry client.

Job scheduler

Runs content synchronization on a schedule, at a configurable interval (default: 60 minutes). The job is defined by the wazuh-catalog-sync-job document in .wazuh-content-manager-jobs; the OpenSearch Job Scheduler reads that document to decide whether the job is active and how often it runs.

Two settings control it, and both are dynamic:

  • plugins.content_manager.catalog.update_on_schedule — whether the job is active.
  • plugins.content_manager.catalog.sync_interval — how often it runs.

Because the job document is what the Job Scheduler acts on, the plugin keeps it in step with those settings rather than writing it once. On every node start, and whenever either setting changes, the plugin compares the document against the current values and rewrites it if they diverge. A document that already matches is left untouched, so a restart with unchanged settings does not disturb the next scheduled run.

flowchart TD
    A[Node starts, or a setting changes] --> B{Job document exists?}
    B -->|No| C[Create it from the current settings]
    B -->|Yes| D{Matches the current settings?}
    D -->|Yes| E[Leave it untouched<br/>next run keeps its schedule]
    D -->|No| F[Rewrite it and log the change]
    C --> G[Job Scheduler applies the document]
    F --> G
    G --> H{Scheduled run fires}
    H --> I{update_on_schedule enabled?}
    I -->|Yes| J[Synchronize ruleset, IoCs and CVEs]
    I -->|No| K[Refuse the run<br/>no Wazuh CTI request]

The check at the moment a run fires is deliberate: it means that even if the job document is out of step — restored from a snapshot, or edited by hand — a deployment with scheduled synchronization disabled still never contacts Wazuh CTI on a schedule. On-demand updates go through a separate path and are unaffected; they are controlled by plugins.content_manager.catalog.update_on_demand.

Each reconciliation is logged, naming the previous and new values, so the applied configuration can be confirmed from the indexer log.

Update check service

Runs a daily heartbeat job that calls the CTI Update check API endpoint (/ping).

  • Enabled by default through plugins.content_manager.telemetry.enabled.
  • Can be toggled at runtime because it is a dynamic setting.
  • Sends deployment metadata required for update checks (cluster UUID, deployed Wazuh version, and user-agent).
  • Job metadata is stored in .wazuh-content-manager-jobs.
  • The first ping is dispatched immediately after the job is registered in the scheduler; subsequent runs follow the 1-day interval.

Consumer service

Orchestrates synchronization for each catalog consumer type (ruleset, IoCs, vulnerabilities). Compares local offsets (from .wazuh-cti-consumers) with remote offsets from the CTI API, then delegates to either the snapshot service or the update service. Tracks the sync lifecycle through the status field in .wazuh-cti-consumers — see Index structure below for the status values.

Snapshot service

Handles initial content loading. Initializes from the remote CTI snapshot, falling back to the last stable snapshot and then to the local packaged snapshot, then extracts and bulk-indexes content into the appropriate system indices. Performs data enrichment (e.g., converting JSON payloads to YAML for decoders).

Update service

Handles incremental updates. Fetches change batches from the CTI API based on offset differences and applies create, update, and delete operations to content indices.

Ruleset Management service

Interfaces with the Ruleset Management plugin. Creates, updates, and deletes Ruleset Management rules, integrations, and detectors to keep them in sync with CTI content.

Dynamic configuration: instead of using hardcoded defaults, the service extracts the enabled state, interval, and source index patterns directly from the CTI integration payload. This allows CTI to control detector behavior dynamically.

Document ID model: Ruleset Management documents for the draft, test and custom spaces use their own auto-generated UUIDs as primary IDs, independent of the CTI document UUIDs; standard-space documents keep the CTI document UUID. Each Ruleset Management document stores the UUID of the original CTI document and the space it belongs to (draft, test, custom, or standard), so the same CTI resource can exist across multiple spaces without ID collisions.

Note: Ruleset Management enforces a configurable maximum number of rules per detector (plugins.security_analytics.max_rules_per_detector, default 50). If an integration has more enabled rules than the configured limit, the detector creation or update request will be rejected. See Ruleset Management — Detector constraints for details.

Space service

Manages the four content spaces (standard, draft, test, custom). Routes CUD operations to the correct space partitions within system indices. Handles promotion by computing diffs between spaces in the promotion chain (draft → test → custom).

Resource creation lock

Serializes the “count existing resources, then create if under the configured maximum” sequence so concurrent create requests cannot all observe a stale count and overshoot the limit. The mutex is a short-lived document with a deterministic ID per (resource type, space) in the hidden .wazuh-content-manager-resource-locks index, written with op_type=create so only one request can hold it at a time. It is released when the creation chain finishes, and a lock older than plugins.content_manager.resource_lock.stale_threshold_millis (default 30 s) is stolen by the next caller so a crashed node cannot block creations permanently. That threshold must be set identically on every node — see Configuration.

The lock index is plugin-internal bookkeeping and is never addressed by API consumers:

  • It is created once per node at plugin startup, not on the request path.
  • Every operation on it (existence check, create, index, get, delete) runs with the caller’s security context stashed, so it is evaluated as the plugin rather than as the REST user. No role needs index-level privileges on .wazuh-content-manager-resource-locks — see Access control — Plugin-internal indices.
  • The stash covers the lock index only. The resource creation that follows runs with the caller’s context restored, so the user’s own index-level privileges on the content indices still apply.

Engine client

Communicates with the Wazuh Engine via Unix domain socket at /usr/share/wazuh-indexer/engine/sockets/engine-api-http.sock. Used for logtest execution, content validation, and configuration reload.

Data flows

CTI sync (snapshot)

Job scheduler triggers
  → Consumer service checks .wazuh-cti-consumers (offset = 0)
  → Resolve the catalog URL: setting → plan → previous sync → packaged manifest
  → Try the remote snapshot first
  → If remote init fails: fallback to the stable snapshot, then to the local packaged snapshot
  → Extracts and bulk-indexes into wazuh-threatintel-rules, wazuh-threatintel-decoders, etc.
  → Updates .wazuh-cti-consumers with new offset
  → Ruleset Management service creates detectors using dynamic CTI configuration (max rules per detector configurable, default 50)

Offline update

POST /update?offline=true
  → Rejected with 400 if this node has no ruleset.zip, iocs.zip or vulnerabilities.zip in its snapshots directory
  → The pass does not contact Wazuh CTI: no plan lookup, no remote consumer, no incremental changes
  → For each consumer with a snapshot file and content already loaded:
      → Reject the file, keeping the content, unless every line is a content document of this consumer with an offset
      → Load the file into staging indices, copy user content over, switch the aliases (blue/green swap)
      → Record the highest offset of the file as local_offset and remote_offset
      → Promote the file to <name>.stable.zip on every node
  → A consumer with a file but no content yet loads it as a first-time snapshot
  → Consumers without a file are left as they are

See Offline content updates for the procedure.

CTI sync (incremental)

Job scheduler triggers
  → Consumer service checks .wazuh-cti-consumers (local_offset < remote_offset)
  → Update service fetches change batches from CTI API
  → Applies create/update/delete operations to content indices
  → Updates .wazuh-cti-consumers offset
  → Ruleset Management service syncs changes

Update check heartbeat

Registration (on node start or dynamic enable)
  → Heartbeat job document indexed in .wazuh-content-manager-jobs
  → Immediate first ping fired once the document is written

Job scheduler triggers (every 24h thereafter)
  → Checks plugins.content_manager.telemetry.enabled
  → Reads cluster UUID and current Wazuh version
  → Sends GET /ping to CTI Update check API
  → Wazuh Dashboard can surface update availability to users

User-generated content (CUD)

REST request (POST/PUT/DELETE)
  → (create only) Acquires the per-(resource type, space) resource creation lock
  → (create only) Checks the configured resource limit for the space
  → Space service routes to draft space
  → Writes to wazuh-threatintel-rules / wazuh-threatintel-decoders / wazuh-threatintel-integrations / wazuh-threatintel-kvdbs
  → (create only) Releases the lock
  → Returns created/updated/deleted resource

Standard policy Engine loading

The local Wazuh Engine must always reflect the latest version of the standard space policy. Whenever the standard space policy hash changes, the full policy — including all referenced integrations, decoders, KVDBs, filters, and rules — is built and sent to the Engine for loading.

The policy hash is an aggregate SHA-256 computed from the individual hashes of the policy and every resource it references. Any change to the policy will trigger a reload. These changes include:

  • New or updated integrations, decoders, rules, KVDBs, or filters (via CTI sync)
  • Changes to policy settings (enabled, index_unclassified_events, index_discarded_events)
  • Changes to the enrichment types list
  • Reordering of the filters list

The engine load is best-effort: if the Engine is unreachable or refuses the policy, the error is logged but the operation (sync or REST update) still succeeds. The node retries the load on its own, after 5 seconds and then with a delay that doubles up to 5 minutes, until the Engine accepts it.

Each node loads a space into its Engine one load at a time. A change to the standard space, whether by CTI sync or a REST update, is broadcast to every node, and each node reloads the spaces whose hash changed. A change that arrives while a reload is running makes that reload check the spaces once more when it ends, so it is never left out. All the policies a node sends to its Engine, including the validations of a promotion, are sent one after the other, because the Engine fails two loads of the same space that overlap.

Promotion

GET /promote?space=draft
  → Space service computes diff (draft vs test, or test vs custom)
  → Returns changes preview (adds, updates, deletes per content type)

POST /promote
  → Capture pre-promotion snapshots of target-space resources
  → Engine validates configuration (both draft → test and test → custom, when
    the changeset includes decoders, kvdbs, or filters, and also when it does
    not but the target space already holds engine resources)
  → Consolidate changes to Content Manager indices (tracked for rollback)
      → Apply adds/updates: policy, integrations, kvdbs, decoders, filters, rules
      → Apply deletes: integrations, kvdbs, decoders, filters, rules
  → Sync integrations and rules to Ruleset Management:
      → Adds use POST (new document)
      → Updates use PUT (existing document)
  → Delete removed integrations/rules from Ruleset Management

A space can be promoted regardless of its policy’s enabled value. Promoting a space whose policy has enabled: false is expected behavior, not an error condition.

Rollback on failure

If any Content Manager index mutation fails during the consolidation phase, the promotion endpoint automatically performs a last-in-first-out (LIFO) rollback to restore the system to its pre-promotion state.

Pre-promotion snapshots

Before any writes, the system captures:

  • Old versions: for each resource being added or updated, the current target-space version is fetched and stored. If the resource does not exist in the target space, no version is stored.
  • Delete snapshots: for each resource being deleted, the full document is fetched from the source space and stored.

Content Manager index rollback

Each successful index mutation is recorded as a rollback step. On failure, steps are replayed in strict reverse (LIFO) order:

Forward operationOld versionRollback action
Add (apply)noneDelete the newly created document
Update (apply)existsRestore the previous version
DeletesnapshotRe-index the snapshotted document

Individual rollback step failures are logged and skipped so remaining steps can proceed.

Ruleset Management reconciliation

After the Content Manager rollback completes, a best-effort Ruleset Management reconciliation runs in dependency order:

  1. Revert applied rules — adds are deleted from Ruleset Management; updates are restored to the old version.
  2. Revert applied integrations — same as above.
  3. Restore deleted integrations — re-created from pre-deletion snapshots via POST.
  4. Restore deleted rules — same as above.

Ruleset Management reconciliation failures are logged as warnings but do not cause the overall rollback to fail, since the sync is considered best-effort.

Consolidation fails at step N
  → LIFO rollback: undo step N-1, N-2, ..., 1
      → Add + no old version → delete from target index
      → Add/update + old version → restore old version to target index
      → Delete → re-index snapshot to target index
  → Ruleset Management reconciliation (best-effort):
      → Delete rules that were added
      → Restore rules that were updated
      → Restore integrations that were added/updated
      → Re-create integrations/rules that were deleted
  → Return 500 with error message

Plan change handling (blue/green swap)

When a subscription plan changes (e.g., from the public plan to a paid one, or vice versa), all downloaded content must be replaced with the content matching the new plan. The Content Manager uses a blue/green index swap to perform this replacement without any user-visible downtime.

How it works

  1. Detection. Each sync cycle looks the subscription plan up once and, for each consumer, compares the plan-provided catalog URL against the one stored locally. Every consumer whose URL differs changes data source. Besides the scheduled and on-demand cycles, registering a CTI access token (POST /subscription) starts one right away.
  2. Shadow download. The new content of every changing consumer is downloaded into hidden staging indices, one consumer after another. These shadow indices are invisible to users, dashboards, and REST queries during the rebuild.
  3. User content preservation. Any user-created content (draft rules, test decoders, custom integrations, etc.) is copied from the live indices into the shadow indices.
  4. Atomic switch. Once the shadow indices of every changing consumer are ready and refreshed, so that all their content is searchable, the aliases of all of them are swapped in a single atomic operation. Users see either the entire old content or the entire new content — never a mix of two plans or an empty state.
  5. Cleanup. The old indices are deleted, freeing the temporary disk space.

The change is all or nothing: if one changing consumer cannot be prepared, for example because its new data source offers no snapshot, or if whether a consumer changes cannot be determined, for example because its record cannot be read, no consumer switches and the change is retried.

Consumer status during a swap

From the moment the shadow download starts, every changing consumer reports running in .wazuh-cti-consumers, already under its new data source (name, context, resource and is_public). All of them change in the same write, so they are never shown on different plans. Their offsets show how much of the new content is live: local_offset is 0 until the switch and the snapshot offset after it, and remote_offset is the latest offset of the new data source. After the switch, each consumer applies the changes published after its snapshot, runs its post-sync work and reports ready with local_offset equal to remote_offset.

MomentEach changing consumer
Before the changeprevious data source · ready · L/L
Shadow downloadnew data source · running · 0/H
After the switchnew data source · running · S/H
Donenew data source · ready · H/H

L is the previous local offset, H the latest offset of the new data source and S its snapshot offset. Components that wait for ready before reading the content, such as the Engine and the vulnerability detector, keep the content they already loaded while a consumer is running; the live content does not change until the switch.

Failure behavior

If the new content cannot be downloaded or processed (network error, source unavailable, etc.), the swap is abandoned cleanly: the staging indices are discarded, every consumer record returns to its previous data source, users continue to see the old content, and the sync is retried once right away and then on each scheduled sync. A failed swap is invisible to the end user.

If the node stops in the middle of a swap, the next sync recognizes the interrupted swap — a consumer that is running with local_offset 0 while its staging indices exist — and redoes it.

Disk usage

The staging indices of every changing consumer exist next to the live ones until the switch, so a full plan change temporarily needs room for a second copy of all the downloaded content, vulnerabilities and IoCs included. The space is freed as soon as the swap completes.

Index structure

Each content index (e.g., wazuh-threatintel-rules) is backed by an alias. The public alias name is the stable identifier used by all queries, dashboards, and REST APIs. The actual data lives in a physical index suffixed with -a or -b:

Public alias (stable name)Physical index (actual storage)
wazuh-threatintel-ruleswazuh-threatintel-rules-a or wazuh-threatintel-rules-b

Only one physical index is live at a time. The other is reserved as the staging slot for the next plan-change swap. Administrators and users should always address indices by their alias name — the physical suffix is an internal implementation detail.

Each content index stores documents from all spaces. The resource itself is held under document; space.name records the space the document belongs to and hash.sha256 the SHA-256 of document. Content synchronized from Wazuh CTI also carries the catalog offset it was last changed at. The document _id is a UUID assigned at creation time.

Example document structure in wazuh-threatintel-rules (the rule body under document is abbreviated):

{
  "_index": "wazuh-threatintel-rules-a",
  "_id": "a1b2c3d4-e5f6-4890-abcd-ef1234567890",
  "_source": {
    "document": {
      "id": "a1b2c3d4-e5f6-4890-abcd-ef1234567890",
      "metadata": {
        "title": "SSH brute force attempt",
        "author": "Security Team"
      },
      "logsource": {
        "product": "ssh-brute-force"
      },
      "level": "medium",
      "status": "experimental",
      "enabled": true
    },
    "hash": {
      "sha256": "fca6ddd52f26c96955c1f7d28ebe0d0b220f74153e9ed07f19439f52590018c7"
    },
    "space": {
      "name": "draft"
    }
  }
}

The .wazuh-cti-consumers index stores one document per consumer type:

{
  "_index": ".wazuh-cti-consumers",
  "_id": "cti:catalog:consumer:ruleset",
  "_source": {
    "name": "public-ruleset-5",
    "context": "beta-2-ruleset-5",
    "type": "cti:catalog:consumer:ruleset",
    "resource": "https://api.pre.cloud.wazuh.com/api/v1/catalog/contexts/beta-2-ruleset-5/consumers/public-ruleset-5",
    "is_public": true,
    "status": "ready",
    "local_offset": 3932,
    "remote_offset": 3932,
    "pending_sync_phases": []
  }
}

The hidden .wazuh-content-manager-resource-locks index holds the short-lived mutex documents described in Resource creation lock. It is created at node startup, is not alias-backed, and is not part of the blue/green swap. Each document is keyed by a UUID derived from the resource type and space, and only records when the lock was taken:

{
  "_index": ".wazuh-content-manager-resource-locks",
  "_id": "0f3c9a1e-6f0e-3a4c-9a5a-1b8d4f7c2e11",
  "_source": {
    "acquired_at": 1773078119040
  }
}

Documents are deleted as soon as the creation that took the lock finishes, so the index is normally empty. Being hidden and unaliased, it shows up in neither _cat/aliases nor a plain _cat/indices; list it with GET _cat/indices/.wazuh-content-manager-resource-locks?expand_wildcards=all&v.

The status field reflects the consumer’s synchronization lifecycle:

ValueMeaning
readySync is complete; content indices are up-to-date and safe to read.
runningSync is in progress; content may be partially written or inconsistent.
failedThe previous sync cycle was interrupted by an unexpected exception.

The status is set to running at the start of a sync cycle, before any content is applied, and transitions to ready after all post-sync work finishes — including hash recalculation, Ruleset Management synchronization, and Engine IoC notification — or to failed if an unexpected exception interrupts the cycle. The job scheduler logs the failure and retries on the next scheduled run regardless of the consumer’s status.

The offsets tell how far the content is synced. local_offset is the offset of the content that is live. While a sync cycle is running, remote_offset is the offset the cycle syncs to, recorded by the same write that marks the consumer running, so local_offset lower than remote_offset shows the changes still to be applied. A running consumer with equal offsets has no changes left to apply: it is checking for new ones or finishing its post-sync work, such as the Ruleset Management synchronization of the ruleset. The cycle ends ready with both equal. During a plan change, the offsets belong to the new data source, see Consumer status during a swap.

For the ruleset consumer, pending_sync_phases lists which of integrations, rules, and detectors still have to be synced to Ruleset Management. A sync records all of them before it changes the content, by a snapshot, incremental changes or a swap, and removes the ones that succeeded when it ends. A phase that fails stays listed, and a sync cut by a restart or an exception keeps all of them; they are retried on the next scheduled sync, even without new CTI content. A sync that resumes them after a cut also re-applies the user’s overrides and reloads the standard space into the engines, which the cut sync may not have reached. A sync with nothing pending also checks that each integration with enabled rules has its detector, and syncs all three phases again when any is missing.

For the IoC consumer, pending_sync_phases holds ioc_load, the storing of the IoC type hashes and the export of the IoCs to the engine. It is recorded before the content changes and removed once both finish, so a sync cut before them, by a restart or a failure, has the next sync run them even without new CTI content. The engine’s answer does not keep it listed: the node running the sync may have no engine.

Configuration

Content Manager settings

The Content Manager plugin is configured through settings in opensearch.yml. All settings use the plugins.content_manager prefix.

  • plugins.content_manager.cti.api (String, default https://api.pre.cloud.wazuh.com/api/v1) — base URL for the Wazuh CTI API. Covers both the catalog endpoints (snapshots, changes, plans) and the CTI Console ones (instance registration, environment lookup, resource-token exchange).

  • plugins.content_manager.catalog.sync_interval (Integer, default 60, range 10–1440, dynamic) — interval, in minutes, between scheduled synchronizations.

  • plugins.content_manager.setup_wait.max_retries (Integer, default 4, range 0–10) — number of retries the catalog sync job performs while waiting for the Setup plugin to report readiness on startup, before giving up until the next scheduled sync.

  • plugins.content_manager.setup_wait.backoff_base_seconds (Integer, default 20, range 1–120) — base delay, in seconds, for the exponential backoff between those retries (delay for retry n is base * 2^n; with the defaults, 20s/40s/80s/160s = 300s / 5 min worst case).

  • plugins.content_manager.max_items_per_bulk (Integer, default 1000, range 10–1000) — maximum documents per bulk indexing request.

  • plugins.content_manager.max_concurrent_bulks (Integer, default 5, range 1–5) — maximum concurrent bulk operations.

  • plugins.content_manager.max_bulk_bytes (Long, default 5242880 / 5 MB, range 1048576–104857600 / 1–100 MB) — maximum request body size, in bytes, for a single bulk indexing request.

  • plugins.content_manager.logtest.max_body_bytes (Long, default 1048576 / 1 MiB, range 1024–16777216 / 1 KiB–16 MiB, dynamic) — maximum size, in bytes, of a logtest request body (POST /logtest, /logtest/normalization, /logtest/detection). Requests whose body exceeds this are rejected with HTTP 413 at the REST layer, before parsing or dispatch, so an oversized event cannot be amplified into the response and exhaust the indexer’s heap.

  • plugins.content_manager.client.timeout (Long, default 10, range 10–50) — HTTP client timeout in seconds for CTI API requests.

  • plugins.content_manager.client.max_retries (Integer, default 3, range 0–10) — number of times a CTI API request is retried after an HTTP 429 (Too Many Requests) response, before the 429 is returned to the caller.

  • plugins.content_manager.client.retry_backoff_base_seconds (Integer, default 30, range 1–300) — base delay, in seconds, for the exponential backoff used between 429 retries when the response carries no usable Retry-After header (delay for retry n is base * 2^n).

  • plugins.content_manager.pit_keepalive (Long, default 120, range 60–600) — point-in-time keepalive in seconds used during paginated index scans.

  • plugins.content_manager.engine.mock (Boolean, default false) — bypasses real Engine socket calls, returning mocked responses instead. Intended for testing only.

  • plugins.content_manager.catalog.update_on_start (Boolean, default true) — trigger content sync when the plugin starts.

  • plugins.content_manager.catalog.update_on_schedule (Boolean, default true, dynamic) — enable the periodic sync job. When false the job is disabled and any scheduled run is refused, so no content is fetched from Wazuh CTI on a schedule. Registering a CTI access token does not start a content update either. On-demand updates (POST /update) are unaffected — they are controlled by plugins.content_manager.catalog.update_on_demand.

  • plugins.content_manager.catalog.ruleset (String, default "") — full CTI consumer URL for ruleset content.

  • plugins.content_manager.catalog.iocs (String, default "") — full CTI consumer URL for IoC content.

  • plugins.content_manager.catalog.vulnerabilities (String, default "") — full CTI consumer URL for vulnerabilities content.

  • plugins.content_manager.catalog.create_detectors (Boolean, default true) — automatically create Ruleset Management detectors from CTI content.

  • plugins.content_manager.telemetry.enabled (Boolean, default true, dynamic) — enable or disable the daily Update check service ping.

  • plugins.content_manager.catalog.update_on_demand (Boolean, default true) — when false, on-demand content updates (POST /update) return 403 Forbidden for every caller, regardless of role.

  • plugins.content_manager.catalog.policy_update.enabled (Boolean, default true) — when false, policy updates (PUT /policy/{space}) return 403 Forbidden for every caller, regardless of role.

  • plugins.content_manager.max_integrations (Integer, default 100, minimum 0, no upper bound, dynamic) — maximum number of integrations that can be created. Requests that would exceed this limit are rejected with HTTP 400.

  • plugins.content_manager.max_decoders (Integer, default 200, minimum 0, no upper bound, dynamic) — maximum number of decoders that can be created. Requests that would exceed this limit are rejected with HTTP 400.

  • plugins.content_manager.max_rules (Integer, default 200, minimum 0, no upper bound, dynamic) — maximum number of rules that can be created. Requests that would exceed this limit are rejected with HTTP 400.

  • plugins.content_manager.max_kvdbs (Integer, default 100, minimum 0, no upper bound, dynamic) — maximum number of KVDBs that can be created. Requests that would exceed this limit are rejected with HTTP 400.

  • plugins.content_manager.max_filters (Integer, default 100, minimum 0, no upper bound, dynamic) — maximum number of filters that can be created per space. Requests that would exceed this limit are rejected with HTTP 400.

  • plugins.content_manager.cti.console.timeout (Integer, default 5, range 1–120) — request timeout, in seconds, for CTI Console calls (instance registration, plans, token exchange). The catalog client has its own timeout in plugins.content_manager.client.timeout; the Console endpoints resolve against plugins.content_manager.cti.api, so moving that setting moves both services.

  • plugins.content_manager.bulk.retry.shed.max_retries (Integer, default 3, range 0–10, dynamic) — number of times a bulk operation the cluster shed under load (circuit breaker trip, indexing-pressure rejection, HTTP 429/503) is re-submitted before its documents are counted as dropped.

  • plugins.content_manager.bulk.retry.shed.initial_backoff_millis (Long, default 1000, range 100–60000, dynamic) — delay, in milliseconds, before the first re-submission of a shed bulk operation. Each subsequent retry doubles it, capped at the max backoff.

  • plugins.content_manager.bulk.retry.shed.max_backoff_millis (Long, default 30000, range 100–60000, dynamic) — ceiling, in milliseconds, for the exponential backoff between shed bulk re-submissions.

  • plugins.content_manager.bulk.retry.topology.max_retries (Integer, default 5, range 0–10, dynamic) — number of times a bulk operation deferred by a transient cluster-topology change (an index recreated mid-load, a shard left unavailable, the node holding it leaving) is re-submitted before its documents are counted as dropped. The default was sized to outlast one rolling restart; raise it on clusters where a restart takes longer, otherwise a synchronization running across a restart drops documents.

  • plugins.content_manager.bulk.retry.topology.initial_backoff_millis (Long, default 5000, range 100–60000, dynamic) — delay, in milliseconds, before the first re-submission of a topology-deferred bulk operation. Each subsequent retry doubles it, capped at the max backoff.

  • plugins.content_manager.bulk.retry.topology.max_backoff_millis (Long, default 30000, range 100–60000, dynamic) — ceiling, in milliseconds, for the exponential backoff between topology-deferred bulk re-submissions.

  • plugins.content_manager.job_schedule.max_retries (Integer, default 3, range 0–10) — number of attempts to register a periodic job with the job scheduler before giving up and logging an error.

  • plugins.content_manager.job_schedule.retry_backoff_seconds (Integer, default 15, range 1–300) — base delay, in seconds, for the linear backoff between job-registration attempts (delay before attempt n is base * n).

  • plugins.content_manager.resource_lock.max_retries (Integer, default 20, range 1–100, dynamic) — number of attempts to acquire the resource-creation lock before the request is rejected with HTTP 503.

  • plugins.content_manager.resource_lock.retry_backoff_millis (Long, default 100, range 10–10000, dynamic) — delay, in milliseconds, between resource-creation lock acquisition attempts.

  • plugins.content_manager.resource_lock.stale_threshold_millis (Long, default 30000, range 5000–600000, dynamic) — age, in milliseconds, past which a held resource-creation lock is treated as orphaned by a crashed node and stolen by the next caller. Set this to the same value on every node, and above the worst-case resource-creation time: the lock is cluster-wide but the setting is read from each node’s own configuration, so nodes that disagree can both consider themselves inside the critical section. The holder never renews the lock, so a value below the time a creation takes lets every concurrent request steal it and the mutex stops working.

  • plugins.content_manager.user_overrides.max_update_attempts (Integer, default 3, range 1–20, dynamic) — number of attempts to write the shared user-overrides registry document before giving up. The registry is a single document, so concurrent writers are serialized optimistically and each version conflict re-reads and re-applies.

  • plugins.content_manager.integration.max_update_attempts (Integer, default 5, range 1–20, dynamic) — number of attempts to update an integration document on a version conflict, when linking or unlinking a resource, before the operation fails.

  • plugins.content_manager.engine.socket_path (String, default /usr/share/wazuh-indexer/engine/sockets/engine-api-http.sock) — filesystem path of the Unix domain socket the Engine API listens on.

  • plugins.content_manager.engine.reload_timeout_minutes (Integer, default 10, range 1–1440) — upper bound, in minutes, on how long a single Engine content reload may stay in flight. It guards against a lost callback wedging the single-flight guard forever.

  • plugins.content_manager.engine.not_ready_grace_minutes (Integer, default 5, range 1–1440) — how long, in minutes, the content indices may stay unable to serve reads before the deferral is escalated from a debug line to a warning.

  • plugins.content_manager.security_analytics.sync_timeout_seconds (Integer, default 60, range 1–3600) — how long, in seconds, a Ruleset Management bulk synchronization step (uploading rules or integrations) may run before it is abandoned and the pass reported as unsuccessful.

  • plugins.content_manager.security_analytics.detector_timeout_seconds (Integer, default 30, range 1–3600) — how long, in seconds, to wait for the first detector creation to complete.

  • plugins.content_manager.security_analytics.cleanup_timeout_seconds (Integer, default 120, range 120–3600) — how long, in seconds, to wait for the deletion of stale Ruleset Management rules and integrations after a content swap. The floor is the default: on timeout the deletion is not retried, so lowering it would make stale resources routine rather than exceptional.

  • plugins.content_manager.security_analytics.detector_interval (Integer, default 2, range 1–10080, dynamic) — detector schedule interval, in minutes, applied when the CTI integration document does not specify one, or specifies one outside the bounds Ruleset Management accepts.

  • plugins.content_manager.update_sub_batch_size (Integer, default 50, range 1–1000) — maximum number of UPDATE offsets batched into a single MultiGet + bulk request while applying a catalog changeset.

  • plugins.content_manager.offset_flush_interval (Integer, default 10, range 1–1000) — how many batches (changeset application) or bulks (snapshot load) are processed between consumer-offset checkpoints. A smaller value narrows the window of work replayed after a crash, at the cost of more writes to the consumer-state document.

  • plugins.content_manager.search_page_size (Integer, default 1000, range 100–10000) — page size used by the paginated IoC reconciliation scan and the IoC export to the Engine. Lower values mean more round trips over the same documents, not fewer documents. Higher values cost more heap per search, and on a busy node search backpressure may cancel the page.

Settings marked dynamic can be changed on a running cluster and take effect on the next operation:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT "https://127.0.0.1:9200/_cluster/settings" -H 'Content-Type: application/json' -d'
{
  "persistent": {
    "plugins.content_manager.bulk.retry.topology.max_retries": 8,
    "plugins.content_manager.bulk.retry.topology.max_backoff_millis": 45000
  }
}'

Offline configuration / disabling automatic updates

On offline installations, disable every task that requires an internet connection to prevent failures.

# opensearch.yml
plugins.content_manager.catalog.update_on_start: false
plugins.content_manager.catalog.update_on_schedule: false
plugins.content_manager.telemetry.enabled: false

plugins.content_manager.catalog.update_on_schedule and plugins.content_manager.telemetry.enabled are dynamic, so on a running deployment they can be applied without a restart:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT "https://127.0.0.1:9200/_cluster/settings" -H 'Content-Type: application/json' -d'
{
  "persistent": {
    "plugins.content_manager.catalog.update_on_schedule": false,
    "plugins.content_manager.telemetry.enabled": false
  }
}'

Disabling scheduled synchronization takes effect immediately and applies to an already-initialized node as well as a fresh one: the plugin updates the periodic job accordingly, and a run that is already scheduled is refused rather than reaching Wazuh CTI.

Disabling scheduled synchronization does not remove content that has already been downloaded. It only stops further updates from Wazuh CTI.

plugins.content_manager.catalog.update_on_start is not dynamic — it only governs the synchronization triggered as the plugin starts, so it takes effect on the next node start.

To keep the content of an offline installation up to date, load newer snapshots as described in Offline content updates.

How the plugin keeps the periodic job in step with update_on_schedule, and refuses a run while it is disabled, is described in Job scheduler.

On online installations, manual synchronization can be performed on demand using the Content Manager API:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST "https://127.0.0.1:9200/_plugins/_content_manager/update"

Custom scheduled synchronization interval

The plugin checks for new content every 60 minutes by default, but this can be customized by changing the plugins.content_manager.catalog.sync_interval setting. The value is specified in minutes and must be between 10 and 1440 (24 hours).

# opensearch.yml
plugins.content_manager.catalog.sync_interval: 1440

The setting is dynamic, so the interval can also be changed on a running deployment:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT "https://127.0.0.1:9200/_cluster/settings" -H 'Content-Type: application/json' -d'
{
  "persistent": {
    "plugins.content_manager.catalog.sync_interval": 1440
  }
}'

The new interval is applied to the periodic job immediately; the next synchronization is scheduled from the moment of the change.

Setup readiness wait (startup race)

On node startup, the catalog sync job waits for the Setup plugin to finish creating its indices (signaled by the .wazuh-setup-status marker document) before it runs. If Setup has not finished within that wait, the sync is skipped for that run and deferred to the next scheduled run (plugins.content_manager.catalog.sync_interval minutes later, an hour by default) — on a slow-starting node (e.g. cluster still recovering shards), this can leave .wazuh-cti-consumers and the CTI content indices empty for up to that long.

The wait uses exponential backoff, controlled by plugins.content_manager.setup_wait.max_retries and plugins.content_manager.setup_wait.backoff_base_seconds. With the defaults (4 retries, 20s base), the schedule is 20s, 40s, 80s, 160s — 300s (5 min) total before giving up. Increase these on environments where Setup is known to take longer to initialize:

# opensearch.yml
plugins.content_manager.setup_wait.max_retries: 4
plugins.content_manager.setup_wait.backoff_base_seconds: 30

The example above raises the total worst-case wait to 30+60+120+240 = 450s (7.5 min).

CTI rate-limit retries (HTTP 429)

When the CTI API rate-limits a request it responds with HTTP 429 and a Retry-After header. The HTTP client honors that header: it waits the indicated time and retries the request, so a rate-limit no longer fails the synchronization pass. If the response carries no usable Retry-After header, it falls back to exponential backoff (base * 2^n).

The behavior is controlled by plugins.content_manager.client.max_retries and plugins.content_manager.client.retry_backoff_base_seconds. With the defaults (3 retries, 30s base), the fallback schedule is 30s, 60s, 120s — 210s (3.5 min) worst case before the 429 is surfaced and the pass defers to the next scheduled run:

# opensearch.yml
plugins.content_manager.client.max_retries: 3
plugins.content_manager.client.retry_backoff_base_seconds: 30

The Retry-After header, when present, always takes precedence over the backoff base — the base applies only when the header is missing or unparseable.

Custom CTI API endpoint

To point to a different CTI API (e.g., production):

# opensearch.yml
plugins.content_manager.cti.api: "https://cti.wazuh.com/api/v1"

The setting moves every CTI request the plugin makes, not just content retrieval. Besides the catalog endpoints (snapshots, changes, plans), it is also the base URL for the CTI Console calls: instance registration (POST /_plugins/_content_manager/subscription), environment and plan lookup, and the resource-token exchange that produces the HMAC-signed URLs used during synchronization.

A registered instance is not valid against a different CTI environment. After changing this setting, re-register the instance with POST /_plugins/_content_manager/subscription.

Custom catalog consumer URLs

To override default consumers, provide full HTTP(S) consumer URLs:

# opensearch.yml
plugins.content_manager.catalog.ruleset: "https://api.pre.cloud.wazuh.com/api/v1/catalog/contexts/beta-2-ruleset-5/consumers/public-ruleset-5"
plugins.content_manager.catalog.iocs: "https://api.pre.cloud.wazuh.com/api/v1/catalog/contexts/t1-iocs-5/consumers/public-iocs-5"
plugins.content_manager.catalog.vulnerabilities: "https://api.pre.cloud.wazuh.com/api/v1/catalog/contexts/t1-vulnerabilities-5/consumers/public-vulnerabilities-5"

Behavior:

  • Content Manager attempts remote snapshot initialization first. If a setting is empty, it uses the catalog URL of the subscription plan, the one stored by the previous sync, or, on a fresh install, the one recorded in the packaged snapshot’s manifest.
  • If remote initialization fails, it falls back to the last stable snapshot (the last one loaded in full), and then to the local packaged snapshot when available.

Tune bulk operations

For environments with limited resources, reduce the bulk operation concurrency:

# opensearch.yml
plugins.content_manager.max_items_per_bulk: 10
plugins.content_manager.max_concurrent_bulks: 2
plugins.content_manager.client.timeout: 30

Disable Ruleset Management detector creation

If you do not use the Ruleset Management plugin:

# opensearch.yml
plugins.content_manager.catalog.create_detectors: false

CTI communication headers

All HTTP clients that communicate with Wazuh CTI services send a custom User-Agent header, and an Accept-Encoding header so responses are transferred compressed:

User-Agent: Wazuh Indexer <version>
Accept-Encoding: gzip

For example: Wazuh Indexer 5.0.0. This applies to the Console API client, Catalog API client, Snapshot client, and Telemetry client. The version is read from VERSION.json at plugin startup.

Update check service behavior

The update check service is enabled by default and runs once per day, with an immediate first ping fired as soon as the job is registered in the scheduler.

  • It is implemented by a scheduled job (wazuh-telemetry-ping-job) in .wazuh-content-manager-jobs.
  • It sends a request to the CTI Update check API endpoint (/ping).
  • The request includes:
    • Deployment identifier (wazuh-uid: cluster UUID)
    • Running version (wazuh-tag: v<version>)
    • User agent (Wazuh Indexer <version>)
    • Accept-Encoding (gzip)

This data allows Wazuh to determine if a newer version is available and notify users in the update check UI.

The service only sends deployment identification/version metadata required for update checks. It does not send rules, events, or log payloads.

Enable or disable the update check service dynamically

The update check service can be enabled or disabled at runtime without restarting the node using the Cluster Settings API:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT "https://127.0.0.1:9200/_cluster/settings" -H 'Content-Type: application/json' -d'
{
  "persistent": {
    "plugins.content_manager.telemetry.enabled": false
  }
}'

Protecting sensitive configuration

Some endpoints modify configuration with a high impact on the platform and are protected by two independent controls:

  • PUT /_plugins/_content_manager/policy/{space} — permission cluster:admin/content_manager/policy/update.
  • POST /_plugins/_content_manager/update — permission cluster:admin/content_manager/update/trigger.
  • PUT /_plugins/_setup/settings — permission plugin:wazuh/settings/write.
  1. RBAC — each endpoint is gated by a cluster permission (the action name above), enforced by the security plugin. No default role holds these permissions: dashboard_server and wazuh_manager are both excluded, so out of the box only the superuser admin (role all_access, cluster wildcard *) can call them. To delegate any of these actions without granting full superuser, create a dedicated role for the permission(s) above. See the access control reference.

  2. Per-endpoint disable settings — each endpoint can be disabled independently with its own node setting; when disabled it returns 403 Forbidden for every caller, including admin / all_access. This is intended for externally managed (e.g. Wazuh Cloud) deployments.

    • POST /_plugins/_content_manager/update — disable via plugins.content_manager.catalog.update_on_demand: false.
    • PUT /_plugins/_content_manager/policy/{space} — disable via plugins.content_manager.catalog.policy_update.enabled: false.
    • PUT /_plugins/_setup/settings — disable via plugins.setup.settings_update.enabled: false.
# opensearch.yml — disable sensitive configuration endpoints on a managed deployment
plugins.content_manager.catalog.update_on_demand: false
plugins.content_manager.catalog.policy_update.enabled: false
plugins.setup.settings_update.enabled: false

Resource creation limits

The plugin enforces configurable upper bounds on the number of resources that can be created. Each limit applies to POST (creation) requests only — existing resources are not affected when a limit is lowered. The count is checked against the relevant index at request time; if the index does not yet exist, the check is skipped and creation proceeds.

All limit settings are dynamic and can be changed at runtime:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT "https://127.0.0.1:9200/_cluster/settings" \
  -H 'Content-Type: application/json' \
  -d '{
    "persistent": {
      "plugins.content_manager.max_integrations": 50,
      "plugins.content_manager.max_decoders": 100,
      "plugins.content_manager.max_rules": 100,
      "plugins.content_manager.max_kvdbs": 50,
      "plugins.content_manager.max_filters": 50
    }
  }'

Setting a limit to 0 blocks all new creation of that resource type.

Notes

  • Changes to opensearch.yml require a restart of the Wazuh Indexer to take effect, except for dynamic settings, which can be updated at runtime via the OpenSearch API. Dynamic settings include plugins.content_manager.catalog.update_on_schedule, plugins.content_manager.catalog.sync_interval, plugins.content_manager.telemetry.enabled, plugins.content_manager.logtest.max_body_bytes, and all five resource creation limits (max_integrations, max_decoders, max_rules, max_kvdbs, max_filters).
  • The catalog URL settings (plugins.content_manager.catalog.ruleset, plugins.content_manager.catalog.iocs, and plugins.content_manager.catalog.vulnerabilities) should only be changed if instructed by Wazuh support or documentation, and must point to valid absolute HTTP(S) CTI consumer endpoints.
  • The sync interval is enforced by the OpenSearch Job Scheduler. The actual sync timing may vary slightly depending on cluster load.
  • plugins.content_manager.catalog.update_on_schedule and plugins.content_manager.catalog.sync_interval are reconciled with the periodic job on every node start and whenever either setting changes, so a value set in opensearch.yml applies to an existing deployment and not only to a fresh install.
  • The update check service runs with a fixed interval of 1 day when enabled. The first ping is sent immediately after the job is registered (on node start or when the setting is dynamically enabled); subsequent pings follow the 1-day interval.
  • Detector configuration: the settings for Ruleset Management detectors (interval, enabled status, and source indices) are managed directly via CTI integration files. If an integration’s detector object is missing in the CTI source, the system will use built-in safety defaults.

API reference

The Content Manager plugin exposes a REST API under /_plugins/_content_manager/. All endpoints require authentication. The full machine-readable specification is available in openapi.yml.

Sections


Timestamps on create and update

Rules, decoders, filters, integrations, KVDBs, and the policy all accept optional date and modified fields inside their metadata object, but the two fields behave differently depending on whether the request is a create or an update:

  • On create — both date and modified are optional. If provided, the value is used as-is; if omitted, the server generates it from the current time.
  • On update — date is always read-only: any caller-supplied value is ignored, and the resource’s original creation date is preserved from the existing document. modified still accepts a caller-supplied value; if omitted, the server generates it from the current time.

If provided, either value is stored as-is — the plugin does not validate or reformat it beyond what the underlying index mapping requires (strict_date_optional_time||epoch_millis). A malformed value fails the request with 400 Bad Request.


YAML content-type support

The Decoders, KVDBs, and Filters endpoints accept requests with Content-Type: application/yaml in addition to the standard Content-Type: application/json. When using YAML, the request body uses the same envelope structure as JSON — the only difference is the serialization format.

Envelope structure

Both JSON and YAML requests use the same envelope:

JSON example

{
  "integration": "<uuid>",
  "resource": {
    "metadata": { "title": "My Decoder", "author": "Wazuh" },
    "name": "decoder/my-decoder/0",
    "enabled": true
  }
}

Equivalent YAML example

---
integration: <uuid>
resource:
  metadata:
    title: "My Decoder"
    author: "Wazuh"
  name: decoder/my-decoder/0
  enabled: true

For resource types that do not require an integration field (e.g., Filters, which use space instead), the corresponding field appears at the top level of the envelope in both formats.

YAML field in responses

When a Decoder, KVDB, or Filter is created or updated, a yaml field is stored alongside the document in the indexed record. This field contains a YAML representation of the resource content:

  • YAML requests: the yaml field is generated from the resource subtree of the parsed envelope.
  • JSON requests: the yaml field is auto-generated from the resource content.

Type fidelity

YAML parsing preserves numeric type fidelity. Floating-point values like 5.0 are stored as 5.0 in both the yaml field and the document field — they are not coerced to integers.

Supported endpoints

  • /_plugins/_content_manager/decoders (POST, PUT) — YAML supported.
  • /_plugins/_content_manager/kvdbs (POST, PUT) — YAML supported.
  • /_plugins/_content_manager/filters (POST, PUT) — YAML supported.
  • /_plugins/_content_manager/integrations (POST, PUT) — JSON only.
  • /_plugins/_content_manager/rules (POST, PUT) — JSON only.
  • /_plugins/_content_manager/policy/{space} (PUT) — JSON only.

Subscription management

Store CTI credentials

Stores the provided CTI access token in the .wazuh-internal-state hidden index and loads it into memory. If the index does not exist it is recreated automatically before writing.

Once the token is stored, the node starts a content update in the background to apply the environment’s plan, without waiting for the next scheduled synchronization. Each consumer whose plan provides a different data source is moved to it through a blue/green swap; the rest are updated as usual. If an update is already running, it may have looked the plan up before the token was stored, so a new one starts once it ends. The response does not wait for the update. No update is started when plugins.content_manager.catalog.update_on_schedule is false; run POST /update to apply the plan.

To find out whether the current user is allowed to register, without registering, see Check registration permission.

Request

  • Method: POST
  • Path: /_plugins/_content_manager/subscription

Request body

  • access_token (String, required) — the CTI access token used to authenticate against the CTI API.

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/subscription" \
  -H 'Content-Type: application/json' \
  -d '{
    "access_token": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS"
  }'

Example response

{
  "message": "Access token received successfully.",
  "status": 201
}

Status codes

  • 201 — credentials stored successfully.
  • 400 — missing or empty access_token field.
  • 412 — a required precondition is not met (for example, the credentials index is not declared as a system index — see plugins.security.system_indices.indices in opensearch.yml).
  • 500 — internal error.

Check registration permission

Answers whether the current user is allowed to register a CTI subscription, without registering one. Intended for callers that need to decide before starting the CTI OAuth device flow, since approving that flow creates the environment on the CTI side and an authorization failure afterwards leaves it orphaned.

The check is performed with the perform_permission_check query parameter, which is provided natively by the OpenSearch security plugin for any REST endpoint. The permission evaluated is the same one a real registration requires: plugin:content_manager/subscription/post, mapped to the cluster:admin/content_manager/subscription/create action. No default role holds it — out of the box only all_access does.

The request has no side effects: the security plugin answers before the action executes, so no CTI API call is made and no credentials are written. It is idempotent and safe to call repeatedly.

Request

  • Method: POST
  • Path: /_plugins/_content_manager/subscription

Query parameters

  • perform_permission_check (Boolean, optional, default false) — when true, return the authorization decision instead of registering a subscription. An empty value is treated as true.

Request body

None. The body is not read in this mode, and access_token is not required.

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/subscription?perform_permission_check=true"

Example response (allowed)

{
  "accessAllowed": true,
  "missingPrivileges": []
}

Example response (denied)

{
  "accessAllowed": false,
  "missingPrivileges": ["cluster:admin/content_manager/subscription/create"]
}

Status codes

  • 200 — decision returned. Both the allowed and the denied case use this status.
  • 401 — missing or invalid credentials.

Note: This mode always responds with 200; the outcome is carried by the accessAllowed field. Branch on that field, not on the status code. The missingPrivileges array contains raw action names intended for logs and support, not for display to end users.

The response fields are produced by the security plugin and are therefore camelCase, identical on every endpoint that supports this parameter.


Get CTI subscription status

Returns the current subscription status and active plan. For registered instances the plan comes from the authenticated CTI endpoint; for unregistered instances, the public plan is returned.

If the CTI Console rejects the stored token with 401 (expired or revoked), the credentials document is deleted automatically, the in-memory token is cleared, and the response falls back to the public plan as if the instance were unregistered.

Any other failure to fetch the plan, such as a network error, a timeout, a 429 or a 5xx from the CTI Console, keeps the token: the instance stays registered and the response is a 502. Content synchronization follows the same rule: only a rejected token (401, or 400 unauthorized_client from the token exchange) makes a registered instance fall back to the public content. If the plan lookup fails, the synchronization keeps the current content source and carries on with it. If the token exchange that signs the content URLs fails, the token is kept, and that synchronization pass fails as if the feed were unreachable and is retried. In neither case is the instance switched back to the public content.

Request

  • Method: GET
  • Path: /_plugins/_content_manager/subscription

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X GET \
  "https://127.0.0.1:9200/_plugins/_content_manager/subscription"

Example response (registered)

{
  "message": {
    "plan": {
      "name": "Premium Plan",
      "is_public": false
    },
    "is_registered": true
  },
  "status": 200
}

Example response (unregistered)

{
  "message": {
    "plan": {
      "name": "Open",
      "is_public": true
    },
    "is_registered": false
  },
  "status": 200
}

Example response (plan unavailable)

{
  "message": "Unable to retrieve the subscription plan from the CTI Console. The registration is unchanged; try again later.",
  "status": 502
}

Status codes

  • 200 — subscription status returned successfully.
  • 500 — internal error.
  • 502 — the instance is registered but its plan could not be fetched from the CTI Console. The stored token is kept; retry later.

Delete CTI credentials

Clears the stored CTI access token document from the credentials index and clears the in-memory token. The credentials index is preserved. After this operation the instance is unregistered. If the credentials index does not exist the operation succeeds without error.

Request

  • Method: DELETE
  • Path: /_plugins/_content_manager/subscription

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X DELETE \
  "https://127.0.0.1:9200/_plugins/_content_manager/subscription"

Example response

{
  "message": "Credentials removed",
  "status": 200
}

Status codes

  • 200 — credentials removed successfully.
  • 500 — internal error.

Content updates

Trigger manual sync

Triggers an immediate content synchronization with the CTI API. A registered instance synchronizes the content of its subscription plan; an unregistered one synchronizes the public content.

With offline=true, the update does not contact the CTI API. Instead, every consumer that has a snapshot file (ruleset.zip, iocs.zip or vulnerabilities.zip) in the plugins/wazuh-indexer-content-manager/snapshots directory of the node that receives the request is reloaded from that file, whatever its current offset. Consumers without a file are left as they are. This is how air-gapped deployments are kept up to date; see Offline content updates.

Request

  • Method: POST
  • Path: /_plugins/_content_manager/update

Parameters

NameInTypeRequiredDescription
offlinequerybooleanNoLoad the snapshot files placed in the snapshots directory of the node instead of synchronizing with the CTI API. Defaults to false.

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/update"

Example response (accepted)

{
  "message": "The update request has been accepted for processing.",
  "status": 202
}

Example response (update in progress)

{
  "message": "A content update is already in progress.",
  "status": 409
}

Example request (offline update)

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/update?offline=true"

Example response (offline update without snapshot files on the node)

{
  "message": "No snapshot file to load in [plugins/wazuh-indexer-content-manager/snapshots] on node [node-1]. Place the new snapshot files in that directory, on every node, before requesting an offline update.",
  "status": 400
}

Status codes

  • 202 — update request accepted for processing.
  • 400 — offline=true was requested but the node has no snapshot file to load, or the offline value is not a boolean.
  • 403 — on-demand updates are disabled (plugins.content_manager.catalog.update_on_demand: false); returned for every caller, regardless of role.
  • 409 — a content update is already in progress.
  • 500 — internal error during sync.

Logtest

Execute logtest

Sends a log event to the Wazuh Engine for analysis. If an integration ID is provided, the integration’s Sigma rules are also evaluated against the normalized event via the Ruleset Management plugin. If integration is omitted, only the normalization step is performed and the detection section is returned with status: "skipped".

Note: A testing policy must be loaded in the Engine for logtest to execute successfully. Load a policy via the policy promotion endpoint. When an integration is specified, it must exist in the specified space.

Request

  • Method: POST
  • Path: /_plugins/_content_manager/logtest

Request body

  • integration (String, optional) — ID of the integration to test against. If omitted, only normalization is performed.
  • space (String, required) — "test", "standard", or "custom".
  • queue (Integer, required) — queue number for logtest execution.
  • location (String, required) — log file path or logical source location.
  • event (String, required) — raw log event to test.
  • metadata (Object, optional) — optional metadata passed to the Engine.
  • trace_level (String, optional) — trace verbosity: NONE, ASSET_ONLY, or ALL.

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/logtest" \
  -H 'Content-Type: application/json' \
  -d '{
    "integration": "a0b448c8-3d3c-47d4-b7b9-cbc3c175f509",
    "space": "test",
    "queue": 1,
    "location": "/var/log/cassandra/system.log",
    "event": "INFO  [main] 2026-03-31 10:00:00 StorageService.java:123 - Node is ready to serve",
    "trace_level": "NONE"
  }'

Example response (success with rule match)

{
  "status": 200,
  "message": {
    "normalization": {
      "output": {
        "event": {
          "category": ["database"],
          "kind": "event",
          "original": "INFO  [main] 2026-03-31 10:00:00 StorageService.java:123 - Node is ready to serve"
        },
        "wazuh": {
          "integration": {
            "name": "test-integ",
            "category": "other",
            "decoders": ["decoder/cassandra-default/0"]
          }
        },
        "message": "Node is ready to serve"
      },
      "asset_traces": [],
      "validation": {
        "valid": true,
        "errors": []
      }
    },
    "detection": {
      "status": "success",
      "rules_evaluated": 2,
      "rules_matched": 1,
      "matches": [
        {
          "rule": {
            "id": "85bba177-a2e9-4468-9d59-26f4798906c9",
            "title": "Cassandra Database Event Detected",
            "level": "low",
            "tags": []
          },
          "matched_conditions": [
            "event.category matched 'database'",
            "event.kind matched 'event'"
          ]
        }
      ]
    }
  }
}

Example response (Engine error, detection skipped)

{
  "status": 200,
  "message": {
    "normalization": {
      "status": "error",
      "error": {
        "message": "Failed to parse protobuff json request: invalid value",
        "code": "ENGINE_ERROR"
      }
    },
    "detection": {
      "status": "skipped",
      "reason": "Engine processing failed"
    }
  }
}

Example response (no rules in integration)

{
  "status": 200,
  "message": {
    "normalization": {
      "output": { "..." : "..." },
      "asset_traces": [],
      "validation": { "valid": true, "errors": [] }
    },
    "detection": {
      "status": "success",
      "rules_evaluated": 0,
      "rules_matched": 0,
      "matches": []
    }
  }
}

Example request (normalization only, no integration)

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/logtest" \
  -H 'Content-Type: application/json' \
  -d '{
    "space": "test",
    "queue": 1,
    "location": "/var/log/syslog",
    "event": "Mar 31 10:00:00 myhost sshd[1234]: Accepted publickey for user from 192.168.1.1 port 22 ssh2",
    "trace_level": "NONE"
  }'

Example response (normalization only)

{
  "status": 200,
  "message": {
    "normalization": {
      "output": {
        "event": {
          "original": "Mar 31 10:00:00 myhost sshd[1234]: Accepted publickey for user from 192.168.1.1 port 22 ssh2"
        }
      },
      "asset_traces": [],
      "validation": { "valid": true, "errors": [] }
    },
    "detection": {
      "status": "skipped",
      "reason": "No integration provided"
    }
  }
}

Response fields

  • normalization.output (Object) — Engine normalized event output.
  • normalization.asset_traces (Array) — list of decoders that processed the event.
  • normalization.validation (Object) — validation result (valid, errors).
  • normalization.status (String) — present on error: "error".
  • normalization.error (Object) — present on error: message and code.
  • detection.status (String) — "success", "error", or "skipped".
  • detection.reason (String) — present when status is "skipped".
  • detection.rules_evaluated (Integer) — number of Sigma rules evaluated.
  • detection.rules_matched (Integer) — number of rules that matched.
  • detection.matches (Array) — list of matched rules with details.
  • detection.matches[].rule (Object) — rule metadata: id, title, level, tags.
  • detection.matches[].matched_conditions (Array) — human-readable descriptions of the rule’s conditions that this event satisfies. A condition offering several values contributes only the ones present in the event, so two events caught by the same rule describe their own match.

Status codes

  • 200 — logtest executed (check inner status fields).
  • 400 — missing/invalid fields or integration not found.
  • 413 — request body exceeds plugins.content_manager.logtest.max_body_bytes (default 1 MiB); rejected before processing.
  • 500 — Engine socket communication error or internal error.

Normalization only

Sends a log event to the Wazuh Engine for decoding and normalization without performing Sigma rule detection. Use this to validate that decoders correctly parse events before testing detection rules.

Note: A testing policy must be loaded in the Engine for normalization to execute successfully.

Request

  • Method: POST
  • Path: /_plugins/_content_manager/logtest/normalization

Request body

  • space (String, required) — "test", "standard", or "custom".
  • queue (Integer, optional) — queue number for logtest execution.
  • location (String, optional) — log file path or logical source location.
  • event (String, optional) — raw log event to normalize.
  • metadata (Object, optional) — optional metadata passed to the Engine.
  • trace_level (String, optional) — trace verbosity: NONE, ASSET_ONLY, or ALL.

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/logtest/normalization" \
  -H 'Content-Type: application/json' \
  -d '{
    "space": "test",
    "queue": 1,
    "location": "/var/log/cassandra/system.log",
    "metadata": {},
    "trace_level": "NONE",
    "event": "INFO  [CompactionExecutor-3] 2025-11-30 14:23:45 CassandraDaemon.java:250 - Some message - 7500 - 4"
  }'

Example response

{
  "status": 200,
  "message": {
    "output": {
      "log": {
        "level": "INFO",
        "origin": {
          "file": {
            "name": "CassandraDaemon.java",
            "line": 250
          }
        }
      },
      "wazuh": {
        "space": { "name": "test" },
        "protocol": { "location": "/var/log/cassandra/system.log", "queue": 1 },
        "integration": {
          "decoders": ["decoder/cassandra-default/0"],
          "name": "my-integration",
          "category": "other"
        }
      },
      "message": "Some message",
      "event": {
        "duration": 7500,
        "category": ["database"],
        "kind": "event",
        "severity": 4
      },
      "source": { "ip": "10.42.3.15" },
      "process": {
        "thread": { "name": "CompactionExecutor-3" }
      }
    },
    "asset_traces": [],
    "validation": {
      "valid": true,
      "errors": []
    }
  }
}

Response fields

  • message.output (Object) — Engine normalized event output.
  • message.asset_traces (Array) — list of decoders that processed the event.
  • message.validation (Object) — validation result (valid, errors).

Status codes

  • 200 — normalization executed successfully.
  • 400 — missing/invalid fields.
  • 413 — request body exceeds plugins.content_manager.logtest.max_body_bytes (default 1 MiB); rejected before processing.
  • 500 — Engine socket communication error or internal error.

Detection only

Evaluates an already-normalized event against the Sigma rules of a given integration via the Ruleset Management plugin. This endpoint does not call the Wazuh Engine — the normalized event must be provided directly in the input field.

Use this after obtaining a normalized event from the /logtest/normalization endpoint, or when you already have a normalized event and want to test different integrations’ rules against it.

Note: The integration must exist in the specified space. The input field must be a JSON object (the normalized event), not a raw log string.

Request

  • Method: POST
  • Path: /_plugins/_content_manager/logtest/detection

Request body

  • space (String, required) — "test", "standard", or "custom".
  • integration (String, required) — UUID of the integration whose rules to evaluate.
  • input (Object, required) — normalized event object to evaluate rules against.

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/logtest/detection" \
  -H 'Content-Type: application/json' \
  -d '{
    "space": "test",
    "integration": "d3f3b0b8-4e25-4273-83ef-56a62003bcf7",
    "input": {
      "event": {
        "duration": 7500,
        "category": ["database"],
        "kind": "event",
        "severity": 4,
        "type": ["info"]
      },
      "source": { "ip": "10.42.3.15" },
      "process": {
        "thread": { "name": "CompactionExecutor-3" },
        "command_line": "/query tables"
      },
      "log": {
        "origin": {
          "file": { "name": "CassandraDaemon.java", "line": 250 }
        }
      }
    }
  }'

Example response (matches found)

{
  "status": 200,
  "message": {
    "status": "success",
    "rules_evaluated": 12,
    "rules_matched": 6,
    "matches": [
      {
        "rule": {
          "id": "4e52f215-bccc-4c0f-a37c-70606022be8e",
          "title": "TEST: Numeric gte+lt only",
          "level": "high",
          "tags": ["attack.execution", "attack.t1059"]
        },
        "matched_conditions": [
          "event.duration matched '>= 5000'",
          "event.severity matched '< 10'"
        ]
      },
      {
        "rule": {
          "id": "1d489ded-7523-4329-8cd0-ebb21865a318",
          "title": "TEST: Exact match event.kind=event",
          "level": "low",
          "tags": ["attack.execution", "attack.t1059"]
        },
        "matched_conditions": [
          "event.kind matched 'event'"
        ]
      }
    ]
  }
}

Example response (no rules in integration)

{
  "status": 200,
  "message": {
    "status": "success",
    "rules_evaluated": 0,
    "rules_matched": 0,
    "matches": []
  }
}

Response fields

  • message.status (String) — "success" or "error".
  • message.rules_evaluated (Integer) — number of Sigma rules evaluated.
  • message.rules_matched (Integer) — number of rules that matched.
  • message.matches (Array) — list of matched rules with details.
  • message.matches[].rule (Object) — rule metadata: id, title, level, tags.
  • message.matches[].matched_conditions (Array) — human-readable descriptions of the rule’s conditions that this event satisfies. A condition offering several values contributes only the ones present in the event, so two events caught by the same rule describe their own match.

Status codes

  • 200 — detection executed (check message.status).
  • 400 — missing/invalid fields or integration not found.
  • 413 — request body exceeds plugins.content_manager.logtest.max_body_bytes (default 1 MiB); rejected before processing.
  • 500 — internal error.

Policy

Update policy

Updates the routing policy in the specified space. The policy defines which integrations are active, the root decoder, enrichment types, and how events are routed through the Engine.

Note: The integrations and filters arrays allow reordering but do not allow adding or removing entries — membership is managed via their respective CRUD endpoints.

Space-specific behavior

  • Draft space (/policy/draft): all policy fields are accepted. The metadata fields author, description, documentation, and references are required in addition to the boolean fields.
  • Standard space (/policy/standard): only enrichments, filters, enabled, index_unclassified_events, and index_discarded_events can be modified. All other fields are preserved from the existing standard policy document. If the update changes the space hash, the full standard policy is automatically loaded to the local Engine.

Request

  • Method: PUT
  • Path: /_plugins/_content_manager/policy/{space}

Path parameters

  • space (String, required) — target space (draft or standard).

Request body

  • resource (Object, required) — the policy resource object.

Fields within resource:

  • metadata (Object, required in draft) — policy metadata (see below).
  • root_decoder (String, optional) — identifier of the root decoder for event processing.
  • integrations (Array, optional) — list of integration IDs (reorder only, no add/remove).
  • filters (Array, optional) — list of filter UUIDs (reorder only, no add/remove).
  • enrichments (Array, optional) — enrichment types (no duplicates; values depend on engine capabilities).
  • enabled (Boolean, required) — whether the policy is active and synchronized by the Engine.
  • index_unclassified_events (Boolean, required) — whether uncategorized events are indexed.
  • index_discarded_events (Boolean, required) — whether discarded events are indexed.

Fields within resource.metadata:

  • title (String, optional) — human-readable policy name.
  • author (String, required in draft) — author of the policy.
  • description (String, required in draft) — brief description.
  • documentation (String, required in draft) — documentation text or URL.
  • references (Array, required in draft) — external reference URLs.
  • modified (String, optional) — see Timestamps on create and update. date cannot be modified here either; any caller-supplied value is ignored, and the policy’s original creation date is preserved.

Example request (draft space)

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT \
  "https://127.0.0.1:9200/_plugins/_content_manager/policy/draft" \
  -H 'Content-Type: application/json' \
  -d '{
    "resource": {
      "metadata": {
        "title": "Draft policy",
        "author": "Wazuh Inc.",
        "description": "Custom policy",
        "documentation": "",
        "references": [
          "https://wazuh.com"
        ]
      },
      "root_decoder": "",
      "integrations": [
        "f16f33ec-a5ea-4dc4-bf33-616b1562323a"
      ],
      "filters": [],
      "enrichments": [],
      "enabled": true,
      "index_unclassified_events": false,
      "index_discarded_events": false
    }
  }'

Example request (standard space)

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT \
  "https://127.0.0.1:9200/_plugins/_content_manager/policy/standard" \
  -H 'Content-Type: application/json' \
  -d '{
    "resource": {
      "enrichments": ["connection"],
      "filters": [],
      "enabled": true,
      "index_unclassified_events": false,
      "index_discarded_events": false
    }
  }'

Example response

{
  "message": "kQPmV5wBi_TgruUn97RT",
  "status": 200
}

The message field contains the OpenSearch document ID of the updated policy.

Status codes

  • 200 — policy updated.
  • 400 — invalid space, missing resource field, missing required fields, invalid enrichments, or disallowed modification of integrations/filters.
  • 500 — internal error.

Rules

Rules follow the Sigma format with Wazuh extensions. See Sigma Rules for the full format reference, including the mitre, compliance, and metadata blocks.

Validation notes:

  • The logsource.product field must exactly match the metadata.title of the parent integration. The examples below use system, so they apply as written to an integration titled system; replace it with your own integration’s title.
  • Detection fields are validated against the Wazuh Common Schema (WCS); rules referencing unknown fields are rejected. A field used in a check or detection expression that is not part of WCS must be prefixed with an underscore to mark it as temporary (see Troubleshooting) — otherwise the Engine rejects the resource.
  • IPv6 addresses are supported in detection conditions (standard, compressed, and CIDR notation).

Create rule

Creates a new detection rule in the draft space. The rule is linked to the specified parent integration and validated by the Ruleset Management plugin.

The rule is also synchronized to Ruleset Management, where a separate document is created with its own auto-generated UUID. That document stores the CTI document UUID in a document.id field and the space in a space field (e.g., draft) for cross-reference.

Request

  • Method: POST
  • Path: /_plugins/_content_manager/rules

Request body

  • integration (String, required) — UUID of the parent integration (must be in draft space).
  • resource (Object, required) — the rule definition.

Fields within resource:

  • metadata (Object, required) — rule metadata (see below).
  • status (String, required) — rule status: stable, experimental, test, deprecated or unsupported. A rule with no status, or with a value outside that list, is rejected.
  • level (String, required) — alert level: informational, low, medium, high or critical. A rule with no level, or with a value outside that list, is rejected.
  • logsource (Object, required) — log source definition. Only product is required within it, and it must exactly match the parent integration’s metadata.title; category, service and definition are optional.
  • detection (Object, required) — Sigma detection logic: a condition plus the selection blocks it names.
  • sigma_id (String, optional) — Sigma rule ID.
  • enabled (Boolean, optional) — whether the rule is enabled. Defaults to true.
  • tags (Array, optional) — Sigma tags, each a dotted namespace.name pair (e.g., attack.credential-access).
  • mitre (Object, optional) — MITRE ATT&CK mapping. Each of tactic, technique and subtechnique is an object holding parallel id and name arrays (see Sigma Rules).
  • compliance (Object, optional) — compliance framework mapping (see Sigma Rules).

Fields within resource.metadata:

  • title (String, required) — rule title (must be unique within the draft space).
  • author (String, optional) — rule author.
  • description (String, optional) — rule description.
  • references (Array, optional) — reference URLs.
  • documentation (String, optional) — documentation text or URL.
  • date, modified (String, optional) — see Timestamps on create and update.

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/rules" \
  -H 'Content-Type: application/json' \
  -d '{
    "integration": "6b7b7645-00da-44d0-a74b-cffa7911e89c",
    "resource": {
      "metadata": {
        "title": "Test Rule",
        "description": "A Test rule",
        "author": "Tester",
        "references": [
          "https://wazuh.com"
        ]
      },
      "sigma_id": "19aefed0-ffd4-47dc-a7fc-f8b1425e84f9",
      "enabled": true,
      "status": "experimental",
      "logsource": {
        "product": "system",
        "category": "system"
      },
      "detection": {
        "condition": "selection",
        "selection": {
          "event.action": [
            "hash_test_event"
          ]
        }
      },
      "level": "low",
      "mitre": {
        "tactic": {
          "id": ["TA0001"],
          "name": ["Initial Access"]
        },
        "technique": {
          "id": ["T1190"],
          "name": ["Exploit Public-Facing Application"]
        }
      },
      "compliance": {
        "pci_dss": ["6.5.1"]
      }
    }
  }'

Example response

{
  "message": "6e1c43f1-f09b-4cec-bb59-00e3a52b7930",
  "status": 201
}

The message field contains the UUID of the created rule.

Status codes

  • 201 — rule created.
  • 400 — missing fields, integration not in draft space, validation failure, or max_rules limit reached (default: 200).
  • 409 — a rule with the same title already exists in the space.
  • 500 — internal error or Ruleset Management unavailable.

Update rule

Updates an existing rule in the draft space. Unlike on create, detection and logsource are required on update, in addition to metadata.

Request

  • Method: PUT
  • Path: /_plugins/_content_manager/rules/{id}

Parameters

  • id (Path, String/UUID, required) — rule document ID.

Request body

  • resource (Object, required) — updated rule definition (same fields as create, except metadata.date, which is read-only on update; see Timestamps on create and update).

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT \
  "https://127.0.0.1:9200/_plugins/_content_manager/rules/6e1c43f1-f09b-4cec-bb59-00e3a52b7930" \
  -H 'Content-Type: application/json' \
  -d '{
    "resource": {
      "metadata": {
        "title": "Test Hash Generation Rule",
        "description": "A rule to verify that SHA-256 hashes are calculated correctly upon creation.",
        "author": "Tester"
      },
      "enabled": true,
      "status": "experimental",
      "logsource": {
        "product": "system",
        "category": "system"
      },
      "detection": {
        "condition": "selection",
        "selection": {
          "event.action": [
            "hash_test_event"
          ]
        }
      },
      "level": "low"
    }
  }'

Example response

{
  "message": "6e1c43f1-f09b-4cec-bb59-00e3a52b7930",
  "status": 200
}

Status codes

  • 200 — rule updated.
  • 400 — invalid request, not in draft space, or validation failure.
  • 404 — rule not found.
  • 409 — the new title is already used by another rule in the space.
  • 500 — internal error.

Delete rule

Deletes a rule from the draft space. The rule is also removed from any integrations that reference it.

Request

  • Method: DELETE
  • Path: /_plugins/_content_manager/rules/{id}

Parameters

  • id (Path, String/UUID, required) — rule document ID.

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X DELETE \
  "https://127.0.0.1:9200/_plugins/_content_manager/rules/6e1c43f1-f09b-4cec-bb59-00e3a52b7930"

Example response

{
  "message": "6e1c43f1-f09b-4cec-bb59-00e3a52b7930",
  "status": 200
}

Status codes

  • 200 — rule deleted.
  • 404 — rule not found.
  • 500 — internal error.

Decoders

Create decoder

Creates a new log decoder in the draft space. The decoder is validated against the Wazuh Engine before being stored, and automatically linked to the specified integration.

Note: A testing policy must be loaded in the Engine for decoder validation to succeed.

Request

  • Method: POST
  • Path: /_plugins/_content_manager/decoders

Request body

  • integration (String, required) — UUID of the parent integration (must be in draft space).
  • resource (Object, required) — the decoder definition.

Fields within resource:

  • name (String, required) — decoder name, given as the three-part Engine asset name decoder/<name>/<version> (e.g., decoder/core-wazuh-message/0). The first part must be decoder; any other shape is rejected by the Engine.
  • enabled (Boolean, optional) — whether the decoder is enabled. Defaults to true.
  • check (Array, optional) — decoder check logic — array of condition objects. Fields referenced here that aren’t part of WCS must be prefixed with an underscore (see the validation note under Rules).
  • normalize (Array, optional) — normalization rules — array of mapping objects.
  • metadata (Object, optional) — decoder metadata (see below).

Fields within metadata:

  • title (String) — human-readable decoder title.
  • description (String) — decoder description.
  • module (String) — module name.
  • compatibility (String) — compatibility description.
  • author (String) — author name, stored as a keyword.
  • references (Array) — reference URLs.
  • versions (Array) — supported versions.
  • date, modified (String, optional) — see Timestamps on create and update.

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/decoders" \
  -H 'Content-Type: application/json' \
  -d '{
    "integration": "0aa4fc6f-1cfd-4a7c-b30b-643f32950f1f",
    "resource": {
      "enabled": true,
      "metadata": {
        "author": "Wazuh, Inc.",
        "compatibility": "All wazuh events.",
        "description": "Base decoder to process Wazuh message format.",
        "module": "wazuh",
        "references": [
          "https://documentation.wazuh.com/"
        ],
        "title": "Wazuh message decoder",
        "versions": [
          "Wazuh 5.*"
        ]
      },
      "name": "decoder/core-wazuh-message/0",
      "check": [
        {
          "_tmp_json.event.action": "string_equal(\"netflow_flow\")"
        }
      ],
      "normalize": [
        {
          "map": [
            {
              "@timestamp": "get_date()"
            }
          ]
        }
      ]
    }
  }'

Example response

{
  "message": "d_0a6aaebe-dd0b-44cc-a787-ffefd4aac175",
  "status": 201
}

The message field contains the UUID of the created decoder (prefixed with d_).

Example request (YAML)

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/decoders" \
  -H 'Content-Type: application/yaml' \
  --data-binary '---
integration: 0aa4fc6f-1cfd-4a7c-b30b-643f32950f1f
resource:
  enabled: true
  metadata:
    author: "Wazuh, Inc."
    compatibility: "All wazuh events."
    description: "Base decoder to process Wazuh message format."
    module: wazuh
    references:
      - "https://documentation.wazuh.com/"
    title: "Wazuh message decoder"
    versions:
      - "Wazuh 5.*"
  name: decoder/core-wazuh-message/0
  check:
    - _tmp_json.event.action: "string_equal(\"netflow_flow\")"
  normalize:
    - map:
        - "@timestamp": "get_date()"
'

Note: See YAML content-type support for details on the YAML envelope format and type fidelity.

Status codes

  • 201 — decoder created.
  • 400 — missing integration field, integration not in draft space, Engine validation failure, or max_decoders limit reached (default: 200). See Troubleshooting if the failure mentions an unrecognized WCS field.
  • 500 — Engine unavailable or internal error.

Update decoder

Updates an existing decoder in the draft space. The decoder is re-validated against the Wazuh Engine.

Request

  • Method: PUT
  • Path: /_plugins/_content_manager/decoders/{id}

Parameters

  • id (Path, String, required) — decoder document ID.

Request body

  • resource (Object, required) — updated decoder definition (same fields as create, except metadata.date, which is read-only on update; see Timestamps on create and update).

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT \
  "https://127.0.0.1:9200/_plugins/_content_manager/decoders/bb6d0245-8c1d-42d1-8edb-4e0907cf45e0" \
  -H 'Content-Type: application/json' \
  -d '{
    "resource": {
      "name": "decoder/test-decoder/0",
      "enabled": false,
      "metadata": {
        "title": "Test Decoder UPDATED",
        "description": "Updated description",
        "author": "Wazuh, Inc."
      },
      "check": [
        {
          "event.action": "string_equal(\"netflow_flow\")"
        }
      ],
      "normalize": [
        {
          "map": [
            {
              "@timestamp": "get_date()"
            }
          ]
        }
      ]
    }
  }'

Example response

{
  "message": "bb6d0245-8c1d-42d1-8edb-4e0907cf45e0",
  "status": 200
}

Status codes

  • 200 — decoder updated.
  • 400 — invalid request, not in draft space, or Engine validation failure.
  • 404 — decoder not found.
  • 500 — Engine unavailable or internal error.

Delete decoder

Deletes a decoder from the draft space. The decoder is also removed from any integrations that reference it. A decoder cannot be deleted if it is currently set as the root decoder in the draft policy.

Request

  • Method: DELETE
  • Path: /_plugins/_content_manager/decoders/{id}

Parameters

  • id (Path, String/UUID, required) — decoder document ID.

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X DELETE \
  "https://127.0.0.1:9200/_plugins/_content_manager/decoders/acbdba85-09c4-45a0-a487-61c8eeec58e6"

Example response

{
  "message": "acbdba85-09c4-45a0-a487-61c8eeec58e6",
  "status": 200
}

Example response (set as root decoder)

{
  "message": "Cannot remove decoder [acbdba85-09c4-45a0-a487-61c8eeec58e6] as it is set as root decoder.",
  "status": 400
}

Status codes

  • 200 — decoder deleted.
  • 400 — decoder is set as root decoder.
  • 404 — decoder not found.
  • 500 — internal error.

Filters

Create filter

Creates a new filter in the draft or standard space. The filter is validated against the Wazuh Engine before being stored and automatically linked to the specified space’s policy.

Request

  • Method: POST
  • Path: /_plugins/_content_manager/filters

Request body

  • space (String, required) — target space: draft or standard.
  • resource (Object, required) — the filter definition.

Fields within resource:

  • name (String, required) — filter name, given as the three-part Engine asset name filter/<name>/<version> (e.g., filter/prefilter/0). The first part must be filter; any other shape is rejected by the Engine.
  • type (String, required) — stage at which the filter is evaluated: pre-filter (before decoding) or post-filter (before output). No other value is accepted, and a filter with no type is rejected by the Engine.
  • metadata (Object, required) — filter metadata (see below).
  • enabled (Boolean, optional) — whether the filter is enabled. Defaults to true.
  • check (String, optional) — filter check expression.

Fields within metadata:

  • title (String, required) — human-readable filter title.
  • author (String, required) — author name, stored as a keyword.
  • description (String, optional) — filter description.
  • date, modified (String, optional) — see Timestamps on create and update.

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/filters" \
  -H 'Content-Type: application/json' \
  -d '{
    "space": "draft",
    "resource": {
      "name": "filter/prefilter/0",
      "enabled": true,
      "metadata": {
        "title": "Default Prefilter",
        "description": "Default filter to allow all events (for default ruleset)",
        "author": "Wazuh, Inc."
      },
      "check": "$host.os.platform == '\''ubuntu'\''",
      "type": "pre-filter"
    }
  }'

Example response

{
  "message": "f_a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6",
  "status": 201
}

The message field contains the UUID of the created filter (prefixed with f_).

Example request (YAML)

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/filters" \
  -H 'Content-Type: application/yaml' \
  --data-binary '---
space: draft
resource:
  name: filter/prefilter/0
  enabled: true
  metadata:
    title: "Default Prefilter"
    description: "Default filter to allow all events (for default ruleset)"
    author: "Wazuh, Inc."
  check: "$host.os.platform == '\''ubuntu'\''"
  type: pre-filter
'

Note: See YAML content-type support for details on the YAML envelope format and type fidelity.

Status codes

  • 201 — filter created.
  • 400 — missing space field, invalid space, Engine validation failure, or max_filters limit reached (default: 100).
  • 500 — Engine unavailable or internal error.

Update filter

Updates an existing filter in the draft or standard space. The filter is re-validated against the Wazuh Engine.

Request

  • Method: PUT
  • Path: /_plugins/_content_manager/filters/{id}

Parameters

  • id (Path, String/UUID, required) — filter document ID.

Request body

  • space (String, required) — target space: draft or standard.
  • resource (Object, required) — updated filter definition (same fields as create, except metadata.date, which is read-only on update; see Timestamps on create and update). enabled is required here: unlike on create it is not defaulted.

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT \
  "https://127.0.0.1:9200/_plugins/_content_manager/filters/a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6" \
  -H 'Content-Type: application/json' \
  -d '{
    "space": "draft",
    "resource": {
      "name": "filter/prefilter/0",
      "enabled": true,
      "metadata": {
        "title": "Default Prefilter",
        "description": "Updated filter description",
        "author": "Wazuh, Inc."
      },
      "check": "$host.os.platform == '\''ubuntu'\''",
      "type": "pre-filter"
    }
  }'

Example response

{
  "message": "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6",
  "status": 200
}

Status codes

  • 200 — filter updated.
  • 400 — invalid request, invalid space, or Engine validation failure.
  • 404 — filter not found.
  • 500 — Engine unavailable or internal error.

Delete filter

Deletes a filter from the draft or standard space. The filter is also removed from the associated policy.

Request

  • Method: DELETE
  • Path: /_plugins/_content_manager/filters/{id}

Parameters

  • id (Path, String/UUID, required) — filter document ID.

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X DELETE \
  "https://127.0.0.1:9200/_plugins/_content_manager/filters/a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6"

Example response

{
  "message": "a1b2c3d4-e5f6-47a8-b9c0-d1e2f3a4b5c6",
  "status": 200
}

Status codes

  • 200 — filter deleted.
  • 404 — filter not found.
  • 500 — internal error.

Integrations

Create integration

Creates a new integration in the draft space. An integration is a logical grouping of related rules, decoders, and KVDBs. The integration is validated against the Engine and registered with the Ruleset Management plugin.

The integration is also synchronized to Ruleset Management, where a separate document is created with its own auto-generated UUID. That document stores the CTI document UUID in a document.id field and the space in a space field (e.g., draft) for cross-reference.

Request

  • Method: POST
  • Path: /_plugins/_content_manager/integrations

Request body

  • resource (Object, required) — the integration definition.

Fields within resource:

  • metadata (Object, required) — integration metadata (see below).
  • category (String, required) — integration category. The Engine accepts only access-management, applications, cloud-services, network-activity, other, security, system-activity and unclassified. The match is case-sensitive, so Security is rejected while security is accepted; any other value fails with Integration category is not valid: <value>.
  • enabled (Boolean, optional) — whether the integration is enabled.

Fields within resource.metadata:

  • title (String, required) — integration title. Must be unique in the draft space. It is also the name the Engine gives the integration, so it cannot contain a space or any of \ / : * ? " < > | (e.g., azure-functions, not Azure Functions (test)), and every rule in this integration must repeat it in logsource.product.
  • author (String, required) — author of the integration.
  • description (String, optional) — description.
  • documentation (String, optional) — documentation text or URL.
  • references (Array, optional) — reference URLs.
  • date, modified (String, optional) — see Timestamps on create and update.

Note: Do not include the id field — it is auto-generated by the Indexer.

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/integrations" \
  -H 'Content-Type: application/json' \
  -d '{
    "resource": {
      "metadata": {
        "title": "azure-functions",
        "author": "Wazuh Inc.",
        "description": "This integration supports Azure Functions app logs.",
        "documentation": "https://docs.wazuh.com/integrations/azure-functions",
        "references": [
          "https://wazuh.com"
        ]
      },
      "category": "cloud-services",
      "enabled": true
    }
  }'

Example response

{
  "message": "94e5a2af-505e-4164-ab62-576a71873308",
  "status": 201
}

The message field contains the UUID of the created integration.

Status codes

  • 201 — integration created.
  • 400 — missing required fields (title, author, category), validation failure, or max_integrations limit reached (default: 100).
  • 409 — an integration with the same title already exists in the space.
  • 500 — internal error or Ruleset Management/Engine unavailable.

Update integration

Updates an existing integration in the draft space. Only integrations in the draft space can be updated. All fields within resource are required on update, including rules, decoders, and kvdbs, to allow reordering — pass empty arrays [] if the integration has none.

Request

  • Method: PUT
  • Path: /_plugins/_content_manager/integrations/{id}

Parameters

  • id (Path, String/UUID, required) — integration document ID.

Request body

  • resource (Object, required) — updated integration definition.

Fields within resource (all required for update):

  • metadata (Object) — integration metadata (see below).
  • category (String) — category.
  • enabled (Boolean) — whether the integration is enabled.
  • rules (Array) — ordered list of rule IDs.
  • decoders (Array) — ordered list of decoder IDs.
  • kvdbs (Array) — ordered list of KVDB IDs.

Fields within resource.metadata:

  • title (String, required) — integration title (see Create integration for the constraints on its value).
  • author (String, required) — author.
  • description (String, optional) — description.
  • documentation (String, optional) — documentation text or URL.
  • references (Array, optional) — reference URLs.
  • modified (String, optional) — see Timestamps on create and update. date cannot be modified on update; any caller-supplied value is ignored.

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT \
  "https://127.0.0.1:9200/_plugins/_content_manager/integrations/94e5a2af-505e-4164-ab62-576a71873308" \
  -H 'Content-Type: application/json' \
  -d '{
    "resource": {
      "metadata": {
        "title": "azure-functions-update",
        "author": "Wazuh Inc.",
        "description": "This integration supports Azure Functions app logs.",
        "documentation": "updated documentation",
        "references": []
      },
      "category": "cloud-services",
      "enabled": true,
      "rules": [],
      "decoders": [],
      "kvdbs": []
    }
  }'

Example response

{
  "message": "94e5a2af-505e-4164-ab62-576a71873308",
  "status": 200
}

Status codes

  • 200 — integration updated.
  • 400 — invalid request, missing required fields, or not in draft space.
  • 404 — integration not found.
  • 409 — the new title is already used by another integration in the space.
  • 500 — internal error or Ruleset Management/Engine unavailable.

Delete integration

Deletes an integration from the draft space. The integration must have no attached decoders, rules, or KVDBs — delete those first.

Request

  • Method: DELETE
  • Path: /_plugins/_content_manager/integrations/{id}

Parameters

  • id (Path, String/UUID, required) — integration document ID.

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X DELETE \
  "https://127.0.0.1:9200/_plugins/_content_manager/integrations/94e5a2af-505e-4164-ab62-576a71873308"

Example response

{
  "message": "94e5a2af-505e-4164-ab62-576a71873308",
  "status": 200
}

Example response (has dependencies)

{
  "message": "Cannot delete integration because it has decoders attached",
  "status": 400
}

Status codes

  • 200 — integration deleted.
  • 400 — integration has dependent resources (decoders/rules/kvdbs).
  • 404 — integration not found.
  • 500 — internal error.

KVDBs

Create KVDB

Creates a new key-value database in the draft space, linked to the specified integration.

Request

  • Method: POST
  • Path: /_plugins/_content_manager/kvdbs

Request body

  • integration (String, required) — UUID of the parent integration (must be in draft space).
  • resource (Object, required) — the KVDB definition.

Fields within resource:

  • metadata (Object, required) — KVDB metadata (see below).
  • content (Object, required) — key-value data (at least one entry required).
  • enabled (Boolean, optional) — whether the KVDB is enabled. Defaults to true.

Fields within resource.metadata:

  • title (String, required) — KVDB title. This is also the name the Engine gives the KVDB, so a request without it fails with Failed to validate resource of type 'kvdb': KVDB JSON must have a valid name — that message refers to this field, and a KVDB resource has no top-level name. It cannot contain a space or any of \ / : * ? " < > | (e.g., non_standard_timezones, not Non standard timezones (test)).
  • author (String, required) — author.
  • description (String, optional) — description.
  • documentation (String, optional) — documentation.
  • references (Array, optional) — reference URLs.
  • date, modified (String, optional) — see Timestamps on create and update.

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/kvdbs" \
  -H 'Content-Type: application/json' \
  -d '{
    "integration": "f16f33ec-a5ea-4dc4-bf33-616b1562323a",
    "resource": {
      "metadata": {
        "title": "non_standard_timezones",
        "author": "Wazuh Inc.",
        "description": "",
        "documentation": "",
        "references": [
          "https://wazuh.com"
        ]
      },
      "enabled": true,
      "content": {
        "non_standard_timezones": {
          "AEST": "Australia/Sydney",
          "CEST": "Europe/Berlin",
          "CST": "America/Chicago",
          "EDT": "America/New_York",
          "EST": "America/New_York",
          "IST": "Asia/Kolkata",
          "MST": "America/Denver",
          "PKT": "Asia/Karachi",
          "SST": "Asia/Singapore",
          "WEST": "Europe/London"
        }
      }
    }
  }'

Example response

{
  "message": "9d4ec6d5-8e30-4ea3-be05-957968c02dae",
  "status": 201
}

The message field contains the UUID of the created KVDB.

Example request (YAML)

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/kvdbs" \
  -H 'Content-Type: application/yaml' \
  --data-binary '---
integration: f16f33ec-a5ea-4dc4-bf33-616b1562323a
resource:
  metadata:
    title: non_standard_timezones
    author: "Wazuh Inc."
    description: ""
    documentation: ""
    references:
      - "https://wazuh.com"
  enabled: true
  content:
    non_standard_timezones:
      AEST: Australia/Sydney
      CEST: Europe/Berlin
      CST: America/Chicago
      EDT: America/New_York
      EST: America/New_York
      IST: Asia/Kolkata
      MST: America/Denver
      PKT: Asia/Karachi
      SST: Asia/Singapore
      WEST: Europe/London
'

Note: See YAML content-type support for details on the YAML envelope format and type fidelity.

Status codes

  • 201 — KVDB created.
  • 400 — missing integration or required resource fields, integration not in draft space, or max_kvdbs limit reached (default: 100).
  • 500 — Engine unavailable or internal error.

Update KVDB

Updates an existing KVDB in the draft space. content, enabled, metadata.title and metadata.author are all required on update; the rest of metadata is optional.

Request

  • Method: PUT
  • Path: /_plugins/_content_manager/kvdbs/{id}

Parameters

  • id (Path, String/UUID, required) — KVDB document ID.

Request body

  • resource (Object, required) — updated KVDB definition.

Fields within resource:

  • metadata (Object, required) — KVDB metadata (see below).
  • content (Object, required) — key-value data.
  • enabled (Boolean, required) — whether the KVDB is enabled. Unlike on create it is not defaulted.

Fields within resource.metadata:

  • title (String, required) — KVDB title, and the name the Engine gives the KVDB (see Create KVDB).
  • author (String, required) — author.
  • description (String, optional) — description.
  • documentation (String, optional) — documentation.
  • references (Array, optional) — reference URLs.
  • modified (String, optional) — see Timestamps on create and update. date cannot be modified on update; any caller-supplied value is ignored.

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT \
  "https://127.0.0.1:9200/_plugins/_content_manager/kvdbs/9d4ec6d5-8e30-4ea3-be05-957968c02dae" \
  -H 'Content-Type: application/json' \
  -d '{
    "resource": {
      "metadata": {
        "title": "non_standard_timezones-2",
        "author": "Wazuh.",
        "description": "UPDATE",
        "documentation": "UPDATE.doc",
        "references": [
          "https://wazuh.com"
        ]
      },
      "enabled": true,
      "content": {
        "non_standard_timezones": {
          "AEST": "Australia/Sydney",
          "CEST": "Europe/Berlin",
          "CST": "America/Chicago",
          "EDT": "America/New_York",
          "EST": "America/New_York",
          "IST": "Asia/Kolkata",
          "MST": "America/Denver",
          "PKT": "Asia/Karachi",
          "SST": "Asia/Singapore",
          "WEST": "Europe/London"
        }
      }
    }
  }'

Example response

{
  "message": "9d4ec6d5-8e30-4ea3-be05-957968c02dae",
  "status": 200
}

Status codes

  • 200 — KVDB updated.
  • 400 — invalid request, missing required fields, or not in draft space.
  • 404 — KVDB not found.
  • 500 — Engine unavailable or internal error.

Delete KVDB

Deletes a KVDB from the draft space. The KVDB is also removed from any integrations that reference it.

Request

  • Method: DELETE
  • Path: /_plugins/_content_manager/kvdbs/{id}

Parameters

  • id (Path, String/UUID, required) — KVDB document ID.

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X DELETE \
  "https://127.0.0.1:9200/_plugins/_content_manager/kvdbs/9d4ec6d5-8e30-4ea3-be05-957968c02dae"

Example response

{
  "message": "9d4ec6d5-8e30-4ea3-be05-957968c02dae",
  "status": 200
}

Status codes

  • 200 — KVDB deleted.
  • 404 — KVDB not found.
  • 500 — internal error.

Promotion

Preview promotion changes

Returns a preview of changes that would be applied when promoting from the specified space. This is a dry-run operation that does not modify any content.

Request

  • Method: GET
  • Path: /_plugins/_content_manager/promote

Parameters

  • space (Query, String, required) — source space to preview: draft or test.

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X GET \
  "https://127.0.0.1:9200/_plugins/_content_manager/promote?space=draft"

Example response

{
  "changes": {
    "kvdbs": [
      {
        "operation": "add",
        "id": "4441d331-847a-43ed-acc6-4e09d8d6abb9"
      }
    ],
    "rules": [],
    "decoders": [],
    "filters": [],
    "integrations": [
      {
        "operation": "add",
        "id": "f16f33ec-a5ea-4dc4-bf33-616b1562323a"
      }
    ],
    "policy": [
      {
        "operation": "update",
        "id": "f75bda3d-1926-4a8d-9c75-66382109ab04"
      }
    ]
  }
}

The response lists changes grouped by content type. Each change includes:

  • operation: add, update, or remove.
  • id: document ID of the affected resource.

Status codes

  • 200 — preview returned successfully.
  • 400 — invalid or missing space parameter.
  • 500 — internal error.

Execute promotion

Promotes content from the source space to the next space in the promotion chain (Draft → Test → Custom). The request body must include the source space and the changes to apply (typically obtained from the preview endpoint).

Both promotion targets (test and custom) are validated by the local Wazuh Engine. The Engine is called when the changeset includes decoders, kvdbs, or filters, and also when it does not but the target space already holds engine resources; a changeset limited to integrations, rules, or the policy against an empty target space skips the call.

In addition to copying documents across CTI indices, promotion also synchronizes integrations and rules with the Ruleset Management plugin. For each promoted resource, a new document is created in the target space with:

  • A newly generated UUID as the primary ID.
  • A document.id field storing the original CTI document UUID for cross-reference.
  • A space field indicating the target space (e.g., test, custom).

New resources (add operations) use POST to create these documents; existing resources (update operations) use PUT to update them in-place.

This ensures that the same CTI resource can exist in multiple spaces with independent Ruleset Management documents.

Rollback on failure

If any Content Manager index mutation fails during the consolidation phase, the endpoint automatically performs a LIFO rollback to restore the system to its pre-promotion state:

  1. Pre-promotion snapshots are captured before any writes — old versions for adds/updates, full documents for deletes.
  2. Content Manager rollback: each completed mutation is undone in reverse order. Adds are deleted, updates are restored to their previous version, deletes are re-indexed from the snapshot.
  3. Ruleset Management reconciliation (best-effort): rules and integrations synced during the forward pass are reverted — new documents are deleted, updated ones are restored, and deleted ones are re-created from snapshots.

Individual rollback or reconciliation step failures are logged but do not prevent remaining steps from executing. On rollback, the endpoint returns a 500 status.

Request

  • Method: POST
  • Path: /_plugins/_content_manager/promote

Request body

  • space (String, required) — source space: draft or test.
  • changes (Object, required) — changes to promote (from the preview response).

The changes object contains arrays for each content type (policy, integrations, kvdbs, decoders, rules, filters), each with operation and id fields.

The policy entry only accepts the update operation, and its id must be the source space policy’s document.id, as returned by the preview — any other value is rejected. A space holds exactly one policy, and the promotion updates the target space’s policy in place.

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/promote" \
  -H 'Content-Type: application/json' \
  -d '{
    "space": "draft",
    "changes": {
      "kvdbs": [],
      "decoders": [
        {
          "operation": "add",
          "id": "f56f3865-2827-464b-8335-30561b0f381b"
        }
      ],
      "rules": [],
      "filters": [],
      "integrations": [
        {
          "operation": "add",
          "id": "0aa4fc6f-1cfd-4a7c-b30b-643f32950f1f"
        }
      ],
      "policy": [
        {
          "operation": "update",
          "id": "baf9b03f-5872-4409-ab02-507b7f93d0c8"
        }
      ]
    }
  }'

Example response

{
  "message": "Promotion completed successfully",
  "status": 200
}

Status codes

  • 200 — promotion successful.
  • 400 — invalid request body, missing space field, a policy id that is not the space policy’s document.id, or Engine validation failure.
  • 409 — a promoted resource already exists in the target space (use an update operation instead).
  • 500 — Engine communication error or internal error.

Spaces

Reset space

Resets a user space (draft) to its initial state.

When resetting the draft space, this operation will:

  • Remove all documents (integrations, rules, decoders, kvdbs, filters, and the space’s policy document) that belong to the given space.
  • Re-generate the default policy for the given space.

Note: Only the draft space can be reset.

Request

  • Method: DELETE
  • Path: /_plugins/_content_manager/space/{space}

Parameters

  • space (Path, String, required) — the name of the user space to reset (draft).

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X DELETE \
  "https://127.0.0.1:9200/_plugins/_content_manager/space/draft"

Example response

{
  "message": "Space reset successfully",
  "status": 200
}

Status codes

  • 200 — space reset successfully.
  • 400 — invalid space identifier, or attempted to reset a space different from draft.
  • 500 — internal error (e.g., Engine unavailable or deletion failure).

Version check

Check available updates

Returns whether there are newer versions of Wazuh available for download. The endpoint reads the current installed version from VERSION.json and queries the CTI API for available updates. The response includes the latest available major, minor, and patch updates when available.

Every call queries CTI; nothing is cached. Calls that arrive while a check is in progress share its result. Each node rate limits its CTI queries with a token bucket. Once the bucket is empty, the endpoint answers 429 with a Retry-After header holding the seconds until the next check is allowed.

Request

  • Method: GET
  • Path: /_plugins/_content_manager/version/check

Example request

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X GET \
  "https://127.0.0.1:9200/_plugins/_content_manager/version/check"

Example response (updates available)

{
  "message": {
    "uuid": "bd7f0db0-d094-48ca-b883-7019484ce71f",
    "last_check_date": "2026-04-14T15:28:41.347387+00:00",
    "current_version": "v5.0.0",
    "last_available_major": {
      "tag": "v6.0.0",
      "title": "Wazuh v6.0.0",
      "description": "Major release with new features...",
      "published_date": "2026-03-01T10:00:00Z",
      "semver": { "major": 6, "minor": 0, "patch": 0 }
    },
    "last_available_minor": {
      "tag": "v5.1.0",
      "title": "Wazuh v5.1.0",
      "description": "Minor improvements and enhancements...",
      "published_date": "2026-02-15T10:00:00Z",
      "semver": { "major": 5, "minor": 1, "patch": 0 }
    },
    "last_available_patch": {
      "tag": "v5.0.1",
      "title": "Wazuh v5.0.1",
      "description": "Bug fixes and stability improvements...",
      "published_date": "2026-01-20T10:00:00Z",
      "semver": { "major": 5, "minor": 0, "patch": 1 }
    }
  },
  "status": 200
}

Example response (no updates)

{
  "message": {
    "uuid": "bd7f0db0-d094-48ca-b883-7019484ce71f",
    "last_check_date": "2026-04-14T15:28:41.347387+00:00",
    "current_version": "v5.0.0",
    "last_available_major": {},
    "last_available_minor": {},
    "last_available_patch": {}
  },
  "status": 200
}

Example response (version not found)

{
  "message": "Unable to determine current Wazuh version.",
  "status": 500
}

Example response (rate limited)

HTTP/1.1 429 Too Many Requests
Retry-After: 12
content-type: application/json; charset=UTF-8

{
  "message": "Too many version checks. Please retry in 12 seconds.",
  "status": 429
}

Status codes

  • 200 — version check completed (may include updates or empty).
  • 429 — rate limit exhausted on this node; retry after the Retry-After seconds.
  • 500 — unable to determine version or internal error.
  • 502 — CTI API returned an error.

Note: Categories with no available updates are represented as empty objects {}.


Documentation maintenance

To maintain technical consistency, any modification, addition or removal of endpoints in the REST API source code must be reflected in the openapi.yml specification and this api.md reference guide.

Offline content updates

Air-gapped deployments cannot reach Wazuh CTI, so they cannot download content changes as they are published. Instead, they are updated with full snapshots of the content: you download the latest snapshots on a machine with internet access, copy them to the Wazuh Indexer nodes, and request an offline update.

Incremental changes cannot be applied offline. Each offline update replaces the content of a consumer with the snapshot you provide, so the content is as recent as the snapshot. Wazuh CTI decides when new snapshots are published.

The Wazuh Indexer packages include a snapshot of each consumer, loaded by the first update: at start, or, with plugins.content_manager.catalog.update_on_start disabled, by POST /_plugins/_content_manager/update. Use this procedure to update that content later.

Before you start

Disable every task that needs an internet connection, as described in Offline configuration.

On-demand updates must be enabled. They are by default; check that plugins.content_manager.catalog.update_on_demand is not set to false.

1. Download the snapshots

The snapshots directory of each node holds a manifest.json file. Its resource field gives the Wazuh CTI address of each consumer:

cat /usr/share/wazuh-indexer/plugins/wazuh-indexer-content-manager/snapshots/manifest.json

On a machine with internet access, ask Wazuh CTI for the link to the latest snapshot of each consumer, and download it under the name the Content Manager expects:

Consumermanifest.json keyFile name
Rulesetruleset.zipruleset.zip
IoCsiocs.zipiocs.zip
Vulnerabilitiesvulnerabilities.zipvulnerabilities.zip

For example, for the vulnerabilities feed:

RESOURCE="<resource of vulnerabilities.zip in manifest.json>"
LINK=$(curl -s "$RESOURCE" | jq -r '.data.last_snapshot_link')
curl -s -o vulnerabilities.zip "$LINK"

The response of the first request also gives last_snapshot_offset and last_snapshot_at, the offset and date of that snapshot. Keep the offset to check the result in step 4.

You only need to download the consumers you want to update.

2. Copy the snapshots to every node

Copy the files to the snapshots directory of every Wazuh Indexer node, and give them to the wazuh-indexer user:

D=/usr/share/wazuh-indexer/plugins/wazuh-indexer-content-manager/snapshots
cp vulnerabilities.zip "$D/"
chown wazuh-indexer:wazuh-indexer "$D/vulnerabilities.zip"
chmod 640 "$D/vulnerabilities.zip"

The update runs on the node that receives the request in step 3, so that node must have the files. Copy them to the other nodes as well: each node keeps the last snapshot it loaded as a fallback, and a node that never received the new files would fall back to older content.

Do not change manifest.json or the *.stable.zip files in the same directory.

3. Request an offline update

Send the request to any node that has the new files:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/update?offline=true"
{
  "message": "The update request has been accepted for processing.",
  "status": 202
}

The update runs in the background. Every consumer with a snapshot file on that node is rebuilt from it, whatever content it had before. Consumers without a file are left as they are. Wazuh CTI is not contacted.

If the node has no snapshot file to load, the request is rejected and nothing changes:

{
  "message": "No snapshot file to load in [plugins/wazuh-indexer-content-manager/snapshots] on node [node-1]. Place the new snapshot files in that directory, on every node, before requesting an offline update.",
  "status": 400
}

If another update is running, the request is answered with 409. Wait for it to end and send the request again.

4. Check the result

Each consumer reports running while it loads and ready once it is done:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD \
  "https://127.0.0.1:9200/.wazuh-cti-consumers/_search?filter_path=hits.hits._source.type,hits.hits._source.status,hits.hits._source.local_offset"

Once a consumer is ready, its local_offset is the offset of the snapshot you loaded, the last_snapshot_offset from step 1.

Its remote_offset shows the same value: offline, the snapshot is the latest content the deployment knows of.

The node log shows two lines per consumer rebuilt:

Offline update: rebuilding consumer [cti:catalog:consumer:vulnerabilities] from local snapshot [vulnerabilities.zip] (local offset 1077533).
Offline update: consumer [cti:catalog:consumer:vulnerabilities] rebuilt from its local snapshot, now at offset 1077535.

Before replacing any content, each file is checked to be a snapshot of that consumer. A file is rejected if it is not a ZIP archive, holds no .json entry, is empty, or holds lines that are not content documents of that consumer: for example the vulnerabilities snapshot saved as iocs.zip, or a line without an offset. A rejected file changes nothing: the consumer keeps its content and offsets, the file is not renamed, and the *.stable.zip fallback is untouched. The log tells why:

Snapshot file [iocs.zip] rejected for consumer [cti:catalog:consumer:iocs]: its content is of type [cves], which this consumer does not hold. Nothing was loaded.
Offline update: consumer [cti:catalog:consumer:iocs] could not be rebuilt from local snapshot [iocs.zip]; its current content was kept.

Replace the file with the right snapshot and request the update again.

After the update, each node renames the file to <name>.stable.zip (for example vulnerabilities.stable.zip), replacing the previous one. That file is the fallback the node reloads from if the content ever needs to be rebuilt. The directory then holds no file waiting to be loaded until you copy new ones.

Considerations

  • Duration. The vulnerabilities feed and the IoCs take a few minutes each, the ruleset under a minute, depending on the hardware.
  • Content during the update. The snapshot loads into hidden staging indices and the content switches to it in one step once it has loaded, the same blue/green swap used for plan changes. The previous content stays available until then.
  • Disk space. While a consumer is rebuilt, its previous and new content are both stored. Make sure the nodes have room for a second copy of the largest feed you update.
  • User content. Decoders, rules, integrations and other resources in the draft, test and custom spaces are carried over to the rebuilt ruleset. Only the standard content changes, and Ruleset Management integrations and rules that are no longer in it are removed.
  • Older snapshots. The update loads whatever snapshot you provide, so you can also return to an earlier one.

Rule testing workflow

This guide explains how to create, test, and promote custom detection rules using the Content Manager’s logtest feature. The logtest endpoint lets you validate that your rules and decoders correctly detect events before deploying them to production.

Overview

The rule testing workflow follows the Content Manager’s space promotion chain:

Draft → Test → Custom
  1. Draft: Create your integration, decoders, and rules.
  2. Test: Promote to the test space and validate with logtest.
  3. Custom: Once validated, promote to custom for production use.

Logtest sends a raw log event through the full detection pipeline — the Wazuh Engine normalizes the event, and the Ruleset Management plugin evaluates your Sigma rules against the normalized output. The combined result shows exactly what was decoded and which rules matched.

Rules are evaluated the way a deployed threat detector evaluates them: compiled into the same queries, analyzed by the same analyzers, and matched by the same percolator. A logtest result is therefore a prediction of the finding, not an approximation of it.

Logtest supports the test, standard, and custom spaces. Use test for validating draft content, standard for testing against production rules, and custom for validating content promoted to production


Step 1: create an integration

An integration groups related decoders, rules, and KVDBs together. Start by creating one:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/integrations" \
  -H 'Content-Type: application/json' \
  -d '{
    "resource": {
      "category": "security",
      "enabled": true,
      "metadata": {
        "title": "ssh-brute-force",
        "author": "Security Team",
        "description": "Detects SSH brute force attempts from auth logs.",
        "references": ["https://attack.mitre.org/techniques/T1110/"]
      }
    }
  }'

Two constraints on this payload are worth noting, because both are rejected with 400 Bad Request:

  • category must be one of access-management, applications, cloud-services, network-activity, other, security, system-activity or unclassified, matched case-sensitively.
  • metadata.title becomes the integration’s name in the Engine, so it cannot contain spaces or any of \ / : * ? " < > |. It is also the value every rule in this integration must repeat in logsource.product.

The response returns the integration ID:

{
  "message": "a0b448c8-3d3c-47d4-b7b9-cbc3c175f509",
  "status": 201
}

Save this ID — you’ll need it for creating rules and running logtest.

Step 2: create a decoder

Decoders tell the Engine how to parse and normalize raw log events. Link a decoder to your integration:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/decoders" \
  -H 'Content-Type: application/json' \
  -d '{
    "integration": "a0b448c8-3d3c-47d4-b7b9-cbc3c175f509",
    "resource": {
      "enabled": true,
      "metadata": {
        "title": "SSH Auth Log Decoder",
        "author": "Security Team",
        "description": "Parses sshd authentication events from auth.log.",
        "module": "sshd",
        "references": ["https://wazuh.com"],
        "versions": ["Wazuh 5.*"]
      },
      "name": "decoder/sshd-auth/0",
      "check": [
        {"_tmp_json.event.original": "regex_match(sshd\\\\[)"}
      ],
      "normalize": [
        {
          "map": [
            {"event.category": "[\"authentication\"]"},
            {"event.kind": "event"},
            {"@timestamp": "get_date()"}
          ]
        }
      ]
    }
  }'

Step 3: create a rule

Rules use the Sigma format to define detection logic. Link a rule to the same integration:

logsource.product must repeat the parent integration’s metadata.title exactly — ssh-brute-force here — otherwise the rule is rejected. status and level are required too, and each accepts a closed set of values (see Create rule).

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/rules" \
  -H 'Content-Type: application/json' \
  -d '{
    "integration": "a0b448c8-3d3c-47d4-b7b9-cbc3c175f509",
    "resource": {
      "metadata": {
        "title": "SSH Failed Password Attempt",
        "description": "Detects failed SSH password authentication attempts.",
        "author": "Security Team",
        "references": ["https://attack.mitre.org/techniques/T1110/001/"]
      },
      "sigma_id": "ssh-failed-password",
      "enabled": true,
      "status": "experimental",
      "logsource": {
        "product": "ssh-brute-force",
        "category": "authentication"
      },
      "detection": {
        "condition": "selection",
        "selection": {
          "event.category": "authentication",
          "event.outcome": "failure"
        }
      },
      "level": "medium",
      "tags": ["attack.credential-access", "attack.t1110.001"],
      "mitre": {
        "tactic": {
          "id": ["TA0006"],
          "name": ["Credential Access"]
        },
        "technique": {
          "id": ["T1110"],
          "name": ["Brute Force"]
        },
        "subtechnique": {
          "id": ["T1110.001"],
          "name": ["Brute Force: Password Guessing"]
        }
      }
    }
  }'

Step 4: promote to test space

Before running logtest, your content must be in the test space.

# 1. Preview what will be promoted
curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X GET \
  "https://127.0.0.1:9200/_plugins/_content_manager/promote?space=draft"

# 2. Execute the promotion, passing back the changes the preview reported
curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/promote" \
  -H 'Content-Type: application/json' \
  -d '{
    "space": "draft",
    "changes": {
      "integrations": [
        {"operation": "add", "id": "a0b448c8-3d3c-47d4-b7b9-cbc3c175f509"}
      ],
      "decoders": [
        {"operation": "add", "id": "f56f3865-2827-464b-8335-30561b0f381b"}
      ],
      "rules": [
        {"operation": "add", "id": "85bba177-a2e9-4468-9d59-26f4798906c9"}
      ],
      "kvdbs": [],
      "filters": [],
      "policy": []
    }
  }'

Step 5: run logtest

Send a sample event to validate your detection pipeline:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/logtest" \
  -H 'Content-Type: application/json' \
  -d '{
    "integration": "a0b448c8-3d3c-47d4-b7b9-cbc3c175f509",
    "space": "test",
    "queue": 1,
    "location": "/var/log/auth.log",
    "event": "Dec 19 12:00:00 host sshd[12345]: Failed password for root from 10.0.0.1 port 54321 ssh2",
    "trace_level": "ALL"
  }'

Understanding the response

The response has two sections:

normalization — Shows how the Engine decoded and normalized the event:

{
  "normalization": {
    "output": {
      "event": {
        "category": ["authentication"],
        "kind": "event",
        "outcome": "failure",
        "original": "Dec 19 12:00:00 host sshd[12345]: Failed password for root from 10.0.0.1 port 54321 ssh2"
      },
      "source": { "ip": "10.0.0.1" },
      "user": { "name": "root" }
    },
    "asset_traces": ["decoder/sshd-auth/0"],
    "validation": { "valid": true, "errors": [] }
  }
}

detection — Shows which Sigma rules matched the normalized event:

{
  "detection": {
    "status": "success",
    "rules_evaluated": 1,
    "rules_matched": 1,
    "matches": [
      {
        "rule": {
          "id": "85bba177-a2e9-4468-9d59-26f4798906c9",
          "title": "SSH Failed Password Attempt",
          "level": "medium",
          "tags": ["attack.credential-access", "attack.t1110.001"]
        },
        "matched_conditions": [
          "event.category matched 'authentication'",
          "event.outcome matched 'failure'"
        ]
      }
    ]
  }
}

Trace levels

The trace_level field controls how much detail the Engine returns:

  • NONE — only the final normalized output. Use for quick checks.
  • ASSET_ONLY — output plus the list of decoders that matched (asset traces).
  • ALL — full trace including every decoder attempted. Use for debugging decoder issues.

Step 6: iterate

If the results aren’t what you expect:

  1. Decoder not matching? Check asset_traces — if your decoder isn’t listed, review the check conditions. Use trace_level: ALL to see which decoders were attempted.
  2. Rule not matching? Compare the normalized event fields with your rule’s detection block. Field names and string values must both match exactly — string comparison is case-sensitive.
  3. Unexpected matches? Review matched_conditions: it lists the conditions this event satisfies, so it shows what in the event triggered the rule rather than restating the rule.

After making changes:

  • Update the rule or decoder via PUT on the respective endpoint.
  • Re-promote draft → test.
  • Run logtest again.

Step 7: promote to custom

Once your rules are validated, promote from test to custom for production use:

# Preview
curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X GET \
  "https://127.0.0.1:9200/_plugins/_content_manager/promote?space=test"

# Execute, passing back the changes the preview reported
curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/promote" \
  -H 'Content-Type: application/json' \
  -d '{
    "space": "test",
    "changes": {
      "integrations": [
        {"operation": "add", "id": "a0b448c8-3d3c-47d4-b7b9-cbc3c175f509"}
      ],
      "decoders": [
        {"operation": "add", "id": "f56f3865-2827-464b-8335-30561b0f381b"}
      ],
      "rules": [
        {"operation": "add", "id": "85bba177-a2e9-4468-9d59-26f4798906c9"}
      ],
      "kvdbs": [],
      "filters": [],
      "policy": []
    }
  }'

Content in the custom space is picked up by the Wazuh Engine and actively used for log processing.


Best practices

Rule design

  • Start specific, broaden later. Begin with tight detection conditions and loosen them as you understand the log patterns. Overly broad rules generate noise.
  • Use meaningful field names. Align your decoder’s normalize output with the Wazuh Common Schema (WCS) — e.g., event.category, source.ip, user.name.
  • Set appropriate severity levels. Use informational for visibility rules, low/medium for suspicious activity, and high/critical only for confirmed threats or high-confidence detections.
  • Add context to rules. Include description, references, falsepositives, and MITRE mappings. This helps analysts triage alerts and understand why a rule exists.

Testing strategy

  • Test with real log samples. Use actual log events from your environment, not fabricated examples. Real logs expose edge cases (encoding, missing fields, unexpected formats).
  • Test positive AND negative cases. Verify that your rule matches what it should, and verify it does NOT match what it shouldn’t. Send benign events that look similar to confirm no false positives.
  • Use trace_level: ALL when debugging. The full trace shows every decoder attempt, making it easy to spot why a particular decoder was or wasn’t selected.
  • Test one change at a time. When iterating on rules or decoders, change one thing per cycle. This makes it clear what fixed (or broke) the detection.

Promotion workflow

  • Always preview before promoting. The promote preview shows exactly what will change. Review it to avoid promoting unintended modifications.
  • Keep draft as your working space. Make all edits in draft. Never try to modify content directly in test or custom.
  • Promote frequently in small batches. Smaller promotions are easier to validate and roll back. Avoid accumulating dozens of changes before testing.
  • Validate in test before promoting to custom. The test space exists specifically for this purpose. Don’t skip it.

Split endpoints: normalization and detection

In addition to the combined logtest endpoint, you can run normalization and detection as separate steps. This is useful for:

  • Debugging decoders without noise from detection results.
  • Testing multiple integrations against the same normalized event without re-running the Engine each time.
  • Iterating on rules without waiting for normalization on each call.

Normalization only

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/logtest/normalization" \
  -H 'Content-Type: application/json' \
  -d '{
    "space": "test",
    "queue": 1,
    "location": "/var/log/auth.log",
    "event": "Dec 19 12:00:00 host sshd[12345]: Failed password for root from 10.0.0.1 port 54321 ssh2",
    "trace_level": "ALL"
  }'

The response contains only the Engine’s normalized output (no detection section):

{
  "status": 200,
  "message": {
    "output": {
      "event": {
        "category": ["authentication"],
        "kind": "event",
        "outcome": "failure"
      },
      "source": { "ip": "10.0.0.1" },
      "user": { "name": "root" }
    },
    "asset_traces": ["decoder/sshd-auth/0"],
    "validation": { "valid": true, "errors": [] }
  }
}

Detection only

Take the normalized event (the output object from normalization) and pass it as input along with the integration ID:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/logtest/detection" \
  -H 'Content-Type: application/json' \
  -d '{
    "space": "test",
    "integration": "a0b448c8-3d3c-47d4-b7b9-cbc3c175f509",
    "input": {
      "event": {
        "category": ["authentication"],
        "kind": "event",
        "outcome": "failure"
      },
      "source": { "ip": "10.0.0.1" },
      "user": { "name": "root" }
    }
  }'

The response contains only the detection result:

{
  "status": 200,
  "message": {
    "status": "success",
    "rules_evaluated": 1,
    "rules_matched": 1,
    "matches": [
      {
        "rule": {
          "id": "85bba177-a2e9-4468-9d59-26f4798906c9",
          "title": "SSH Failed Password Attempt",
          "level": "medium",
          "tags": ["attack.credential-access", "attack.t1110.001"]
        },
        "matched_conditions": [
          "event.category matched 'authentication'",
          "event.outcome matched 'failure'"
        ]
      }
    ]
  }
}

Quick reference

ActionEndpointMethod
Create integration/_plugins/_content_manager/integrationsPOST
Create decoder/_plugins/_content_manager/decodersPOST
Create rule/_plugins/_content_manager/rulesPOST
Update rule/_plugins/_content_manager/rules/{id}PUT
Preview promotion/_plugins/_content_manager/promote?space={space}GET
Execute promotion/_plugins/_content_manager/promotePOST
Run logtest (combined)/_plugins/_content_manager/logtestPOST
Normalization only/_plugins/_content_manager/logtest/normalizationPOST
Detection only/_plugins/_content_manager/logtest/detectionPOST

For full endpoint details, see the API Reference. For Sigma rule format details, see Sigma Rules.

Troubleshooting

Common issues and diagnostic procedures for the Content Manager plugin.

Common errors

“Cannot reach the Wazuh Engine: its API socket is not available”

The Wazuh Engine is not running or the Unix socket is not accessible.

Resolution:

  1. Check the socket file exists:

    ls -la /usr/share/wazuh-indexer/engine/sockets/engine-api-http.sock
    
  2. Ensure the Wazuh Indexer process has permission to access the socket file.

Subscription content is not synchronized

No CTI access token has been registered, so the instance synchronizes only the public content. GET /_plugins/_content_manager/subscription reports "is_registered": false in this state.

Resolution

Register credentials by posting the CTI access token:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_content_manager/subscription" \
  -H 'Content-Type: application/json' \
  -d '{
    "access_token": "<your-cti-access-token>"
  }'

A successful registration returns {"message":"Access token received successfully.","status":201}. The token is persisted in .wazuh-internal-state and loaded into memory immediately, and a content update starts to apply the subscription plan. The node log shows Access token registered; content update started to apply the environment's plan., or, if an update was already running, that a new one starts once it ends. With plugins.content_manager.catalog.update_on_schedule set to false, no update starts on its own: run POST /_plugins/_content_manager/update.

“Unable to retrieve the subscription plan from the CTI Console”

GET /_plugins/_content_manager/subscription answers 502 with this message when the instance is registered but its plan can’t be fetched from the CTI Console: the Console is unreachable, the request timed out, or the Console answered 429 or 5xx. The stored token is kept, so the instance stays registered and keeps its current content. The node log shows the cause in a Could not retrieve the subscription plan from the CTI Console warning. The same kind of outage during a synchronization logs Token exchange failed for resource [...] (...). The access token is kept. (that pass is retried) or Keeping the current data source. (the pass carries on with the current content).

Resolution

Check that the node can reach the URL in plugins.content_manager.cti.api, then repeat the request. You don’t need to register again. The credentials are only deleted when the CTI Console rejects the token with 401; GET /subscription then reports "is_registered": false.

Every consumer shows running after a plan change

This is expected while the content of the new plan is prepared. After a subscription plan change, every consumer whose data source changes reports running under its new data source, with local_offset 0, until the content of all of them is ready and switched in one operation. The previous content stays live meanwhile. A full plan change usually takes several minutes, mostly to download the vulnerabilities and IoCs, and temporarily needs disk space for a second copy of the content. See Plan change handling.

If the new content cannot be prepared, the consumers return to their previous data source and content, and the change is retried on every sync. The node log shows why, in a could not be prepared or could not be determined warning, or a Failed to prepare the new content of consumers error. Consumers that stay running with local_offset 0 across several syncs belong to a swap that was interrupted, for example by a restart, and could not be redone yet; the log shows the same messages, and a waiting for a swap to complete warning for each of them. The next sync redoes an interrupted swap; to redo it without waiting for the scheduled sync, trigger a sync on demand as shown in Sync not running.

No detectors while the ruleset consumer is in sync

GET _plugins/_security_analytics/detectors/_search returns fewer detectors than the integrations that have rules, while the ruleset consumer is ready with equal offsets and an empty pending_sync_phases. No findings are produced for the integrations without a detector.

Every sync checks that each integration with enabled rules has its detector. When some are missing, it syncs the integrations, rules and detectors to Ruleset Management again, and the node log shows a detector(s) missing from the Security Analytics plugin although nothing is pending warning that lists them. If the detectors can’t be created, detectors stays in pending_sync_phases and the sync retries it, as for any other failed phase.

Resolution

Wait for the next scheduled sync, or trigger one on demand as shown in Sync not running. If the warning repeats with the same integrations, look in the log for the error that keeps their detectors from being created, for example missing wazuh-events-v5-* source indices. The check is skipped while plugins.content_manager.catalog.create_detectors is false.

Sync not running

Content is not being updated despite having a valid subscription.

Diagnosis

  1. Check consumer state and offsets:

    curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X GET \
      "https://127.0.0.1:9200/.wazuh-cti-consumers/_search?pretty"
    

    If local_offset equals remote_offset, the content is already up-to-date.

  2. Check the sync job is registered and enabled:

    curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X GET \
      "https://127.0.0.1:9200/.wazuh-content-manager-jobs/_search?pretty"
    

    Verify the wazuh-catalog-sync-job document has "enabled": true and that its schedule interval matches your configuration. The plugin keeps this document in step with the settings automatically, so a mismatch means reconciliation has not run — check the log for a Catalog Sync Job reconciled entry rather than editing the document by hand.

  3. Check the effective value of the scheduled sync setting. It can be set in opensearch.yml:

    plugins.content_manager.catalog.update_on_schedule: true
    

    It is also dynamic, and a value set through the Cluster Settings API overrides opensearch.yml, so check there too:

    curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X GET \
      "https://127.0.0.1:9200/_cluster/settings?pretty"
    

    When the setting is disabled, every scheduled run is refused and logged:

    INFO   ... Scheduled catalog synchronization (ID: wazuh-catalog-sync-job) skipped: plugins.content_manager.catalog.update_on_schedule is false.
    
  4. Trigger a manual sync to test:

    curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
      "https://127.0.0.1:9200/_plugins/_content_manager/update"
    

Engine validation rejects a temporary field

Symptoms: Creating or updating a decoder (or any resource with a check/detection expression) fails with an Engine validation error naming a field that looks correct, for example:

{"message":"Engine validation failed. Failed to validate resource of type 'decoder': Validation failed for 'decoder': Failed to build operation 'tmp_json.event.action: string_equal(\"netflow_flow\")': Field 'tmp_json.event.action' is not defined in WCS schema and is not a temporary field"}

Cause: The Engine validates every field referenced in a check or detection expression against the Wazuh Common Schema (WCS). A field that is intentionally temporary — used only during decoding and not part of the final normalized event — is not in WCS by definition, so the Engine only accepts it if it’s prefixed with an underscore.

Resolution

Prefix temporary fields with _ in both the check expression and anywhere else the field is referenced within the same resource:

{
  "check": [
    {
      "_tmp_json.event.action": "string_equal(\"netflow_flow\")"
    }
  ]
}

Fields that are part of WCS (e.g., event.action, source.ip) never need the underscore prefix — only add it for genuinely temporary, decoder-internal fields.

Socket file not found

The Unix socket used for Engine communication does not exist.

Expected path: /usr/share/wazuh-indexer/engine/sockets/engine-api-http.sock

Resolution

  1. Verify the Wazuh Engine is installed and running.
  2. Check the Engine configuration for the socket path.
  3. Ensure the engine/sockets/ directory exists under the Wazuh Indexer installation path.

Sync fails with a request timeout during a CTI rate-limit

A synchronization pass fails and the log shows a timeout on a CTI request, e.g.:

Error during content update: Timeout deadline: 10000 MILLISECONDS, actual: 10000 MILLISECONDS

This means the CTI API rate-limited the request (HTTP 429). The client now honors the response Retry-After header and retries automatically (see CTI rate-limit retries), so a transient rate-limit self-recovers within the pass.

Resolution

  1. If the failure is transient, no action is needed — the request is retried and the next scheduled sync resumes from the last checkpoint (no data is lost or duplicated).
  2. If 429s persist across passes, the instance is being rate-limited by CTI for longer than the retry budget. Increase plugins.content_manager.client.max_retries and/or plugins.content_manager.client.retry_backoff_base_seconds.
  3. Look for the warning CTI API rate-limited request [...] (HTTP 429); retrying in {}s in the logs to confirm the retry path is engaging.

Resource creation fails with a missing indices:admin/create permission

A POST to /rules, /decoders, /integrations, /kvdbs or /filters returns HTTP 429 with Too many concurrent requests creating this resource. Please retry. even though there is no concurrency, and the indexer log shows the security plugin denying indices:admin/create on the lock index for the requesting user:

[INFO ][o.o.s.p.PrivilegesEvaluatorImpl] No index-level perm match for User [name=<caller>, ...]
  Resolved [... allIndices=[.wazuh-content-manager-resource-locks] ...]:
  Insufficient permissions for the referenced index [Action [indices:admin/create]]
[WARN ][c.w.c.t.AbstractTransportCreateAction] Failed to acquire resource-creation lock for [integration]:
  no permissions for [indices:admin/create] and User [name=<caller>, ...]

The .wazuh-content-manager-resource-locks index is created at node startup and every operation on it runs as the plugin, so this can only happen on a node that never completed that startup step (check the log for Failed to create .wazuh-content-manager-resource-locks index, due to: ...).

Note that the index is hidden and has no alias, so its absence cannot be confirmed with _cat/aliases or a plain _cat/indices.

Resolution

  1. Confirm whether the index exists:

    curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X GET "https://127.0.0.1:9200/_cat/indices/.wazuh-content-manager-resource-locks?expand_wildcards=all&v"
    
  2. If it is missing, restart the node — the plugin recreates it during initialization — and check the startup log for the reason it failed the first time (a red cluster or a node still joining are the usual causes).

  3. If a restart is not an option, create it manually with an account that holds indices:admin/create (for example admin, as below). Only the acquired_at field is needed:

    curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT "https://127.0.0.1:9200/.wazuh-content-manager-resource-locks" \
      -H 'Content-Type: application/json' -d '{
        "settings": { "index": { "number_of_replicas": 0, "auto_expand_replicas": "0-1", "hidden": true, "refresh_interval": "-1" } },
        "mappings": { "dynamic": "strict", "properties": { "acquired_at": { "type": "long" } } }
      }'
    
  4. Do not add indices:admin/create to the user’s role to work around this. No role needs index-level privileges on the lock index — see Access control — Plugin-internal indices.

Diagnostic commands

Check consumer state

View synchronization state for all content contexts:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X GET \
  "https://127.0.0.1:9200/.wazuh-cti-consumers/_search?pretty"

Example output:

{
  "hits": {
    "hits": [
      {
        "_id": "cti:catalog:consumer:ruleset",
        "_source": {
          "name": "public-ruleset-5",
          "context": "beta-2-ruleset-5",
          "type": "cti:catalog:consumer:ruleset",
          "resource": "https://api.pre.cloud.wazuh.com/api/v1/catalog/contexts/beta-2-ruleset-5/consumers/public-ruleset-5",
          "is_public": true,
          "status": "ready",
          "local_offset": 3932,
          "remote_offset": 3932,
          "pending_sync_phases": []
        }
      }
    ]
  }
}
  • status == ready: Sync is complete; content is safe to read.
  • status == running: Sync is in progress. If this persists after a sync should have finished, the node process may have been interrupted (e.g. killed) mid-cycle without a chance to record failed.
  • status == failed: The previous sync cycle was interrupted by an unexpected exception. Check the Content Manager logs around the time this consumer was last synced; the job retries automatically on its next scheduled run.
  • local_offset == remote_offset: Content is up-to-date. While the consumer is running, the sync has no changes left to apply: it is checking for new ones or finishing its post-sync work.
  • local_offset < remote_offset: Content needs updating. While the consumer is running, remote_offset is the offset the sync in progress updates to.
  • local_offset == 0: Content has never been synced (snapshot required). With status running during a plan change, none of the new plan’s content is live yet: it is being prepared (see Every consumer shows running after a plan change).
  • pending_sync_phases: for the ruleset consumer, lists which of integrations, rules, and detectors still have to be synced to Ruleset Management and are retried on the next scheduled sync. They are recorded before the content changes and the ones that succeeded are removed when the sync ends, so a sync cut by a restart leaves them all listed. Empty means nothing is owed; a sync with nothing pending still recreates the detectors if any are missing (see No detectors while the ruleset consumer is in sync). For the IoC consumer, it lists ioc_load while the IoC type hashes and the load of the IoCs into the engine are still owed: a sync cut by a restart or a failure leaves it listed, and the next sync runs it even without new CTI content.

Check sync job

View the periodic sync job configuration:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X GET \
  "https://127.0.0.1:9200/.wazuh-content-manager-jobs/_search?pretty"

Count content documents

Check how many rules, decoders, etc. have been indexed:

# Rules
curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X GET "https://127.0.0.1:9200/wazuh-threatintel-rules/_count?pretty"

# Decoders
curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X GET "https://127.0.0.1:9200/wazuh-threatintel-decoders/_count?pretty"

# Integrations
curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X GET "https://127.0.0.1:9200/wazuh-threatintel-integrations/_count?pretty"

# KVDBs
curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X GET "https://127.0.0.1:9200/wazuh-threatintel-kvdbs/_count?pretty"

# IoCs
curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X GET "https://127.0.0.1:9200/wazuh-threatintel-enrichments/_count?pretty"

Job scheduling on startup

During node startup, the catalog sync job and the update check job both require the .wazuh-content-manager-jobs index to reach yellow status with at least one active shard before they can register their job documents. On a freshly initialized or resource-constrained cluster this can time out, producing entries like:

INFO   ... Failed to schedule Telemetry Ping Job: Index .wazuh-content-manager-jobs not ready
INFO   ... Retrying Telemetry Ping Job (attempt 1/3) in 15s.

The plugin automatically retries each registration up to plugins.content_manager.job_schedule.max_retries times (default 3) with a linear backoff of plugins.content_manager.job_schedule.retry_backoff_seconds × attempt (default 15 s, so 15 s, 30 s, 45 s). Each attempt logs the failure reason and the scheduled retry delay at INFO — these are expected during startup and do not require action.

If all retries fail, the plugin logs ERROR ... Giving up scheduling <job> after N attempts. and the job will only be retried on the next node start. Raising plugins.content_manager.job_schedule.max_retries gives a slow-starting cluster more room. A persistent failure usually indicates the cluster cannot allocate shards — check cluster health with GET _cluster/health and verify index allocation settings.

Log monitoring

Content Manager logs are part of the Wazuh Indexer logs. Use the following patterns to filter relevant entries:

# General Content Manager activity
grep -i "content.manager\|ContentManager\|CatalogSync" \
  /var/log/wazuh-indexer/wazuh-indexer.log

# Sync job execution
grep -i "CatalogSyncJob\|consumer-sync" \
  /var/log/wazuh-indexer/wazuh-indexer.log

# CTI API communication
grep -i "cti\|CTIClient" \
  /var/log/wazuh-indexer/wazuh-indexer.log

# Engine socket communication
grep -i "engine.*socket\|EngineClient" \
  /var/log/wazuh-indexer/wazuh-indexer.log

# Errors only
grep -i "ERROR.*content.manager" \
  /var/log/wazuh-indexer/wazuh-indexer.log

Resetting content

To force a full re-sync from snapshot, delete the consumer state documents and restart the indexer:

# Delete consumer state (forces snapshot on next sync)
curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/.wazuh-cti-consumers/_delete_by_query?refresh=true" \
  -H 'Content-Type: application/json' \
  -d '{"query": {"match_all": {}}}'

# Restart indexer to trigger sync
systemctl restart wazuh-indexer

Warning: This will re-download and re-index all content from scratch. Use only when troubleshooting persistent sync issues.

Reporting

The wazuh-indexer-reporting plugin provides functionality for generating customizable reports based on data stored in the Wazuh Indexer. Most of this data originates from the Wazuh Manager, which collects security events from registered agents. The plugin supports both scheduled and on-demand report generation. Reports can be delivered via email or downloaded on demand through the Wazuh Dashboard or the API. Users can create, read, update, and delete custom reports. Access to these actions is governed by the Wazuh Indexer’s role-based access control (RBAC) permissions. This plugin is built on top of OpenSearch’s native Reporting and Notifications plugins.

Report types

  • Scheduled reports — generated automatically on a defined schedule from a saved report definition.
  • On-demand reports — generated immediately when requested, either from a report definition or directly from a saved search, dashboard, or visualization.

Generated reports are PDF or PNG for dashboards/visualizations, or CSV/XLSX for saved searches.

Delivery

Reports can be delivered by email through the Notifications plugin, or downloaded on demand through the Wazuh Dashboard or the API.

For a walkthrough of configuring an email delivery channel and generating a report, see How to configure email notifications for reports.

Configuration

The Reporting plugin is configured through cluster settings.

  • plugins.reports.max_report_definitions (Integer, default 50, minimum 0, no upper bound, dynamic) — maximum number of report definitions allowed. Creation requests that would exceed this limit are rejected with HTTP 400. Existing report definitions are not affected when the limit is lowered.
  • opensearch.reports.general.operationTimeoutMs (Long, default 60000, min 100) — timeout in milliseconds for report generation operations.
  • opensearch.reports.general.defaultItemsQueryCount (Integer, default 100, min 10) — default number of items fetched per query when building report data.

To change it at runtime:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT "https://127.0.0.1:9200/_cluster/settings" -H 'Content-Type: application/json' -d'
{
  "persistent": {
    "plugins.reports.max_report_definitions": 20
  }
}'

Managing permissions on reporting via RBAC

The Reporting plugin uses the Wazuh Indexer RBAC (role-based access control) system to manage permissions. This means that users must have the appropriate roles assigned to them in order to create, read, update, or delete reports. The roles can be managed through the Wazuh Dashboard Indexer management -> Security -> Roles section. The following permissions are available for the Reporting plugin:

1. cluster:admin/opendistro/reports/definition/create
2. cluster:admin/opendistro/reports/definition/update
3. cluster:admin/opendistro/reports/definition/on_demand
4. cluster:admin/opendistro/reports/definition/delete
5. cluster:admin/opendistro/reports/definition/get
6. cluster:admin/opendistro/reports/definition/list
7. cluster:admin/opendistro/reports/instance/list
8. cluster:admin/opendistro/reports/instance/get
9. cluster:admin/opendistro/reports/menu/download

There are already some predefined roles that can be used to manage permissions on reporting:

  • reports_read_access: permissions 5 to 9.
  • reports_instances_read_access: 7 to 9.
  • reports_full_access: permissions 1 to 9.

More information on how to create and map roles on the Wazuh Indexer can be found in Defining users and roles.

How to configure email notifications for reports

This page walks through configuring an email delivery channel in the Wazuh Dashboard so generated reports can be sent by email, and creating a report. See Reporting for the capabilities and permissions reference.

Configuring the email notifications channel

In Wazuh Dashboard, go to Explore > Notifications > Channels and click on Create channel:

  1. Fill in a name (e.g Email notifications).
  2. Select Email as Channel Type.
  3. Check SMTP sender as Sender Type.
  4. Click on Create SMTP sender.
    1. Fill in a name (e.g mailpit).
    2. Fill in an email address.
    3. In Host, type mailpit (adapt this to your SMTP server Domain Name).
    4. For port, type 1025 (adapt this to your SMTP server settings).
    5. Select None as Encryption method.
    6. Click on Create.
  5. Click on Create recipient group.
    1. Fill in a name (e.g email-notifications-recipient-group).
    2. On Emails, type any email.
    3. Click on Create.

The fields should now be filled in as follows: Create Channel

  1. Click on Send test message to validate the configuration, a green message should pop up.
  2. Finally, click on Create.

More information on how to configure the email notifications channel can be found in the OpenSearch documentation.

Creating a new report

For more information on how to create reports, please refer to the OpenSearch documentation. The reporting plugin also allows you to create notifications following the behaviour on OpenSearch’s notifications plugin.

Generate and download a report

To create a new report you must have predefined the report settings. Once the report is configured, you can generate it by clicking the “Generate Report” button. This is only available on “On demand” report definitions as scheduled reports will be generated automatically. The report will be processed and made available for download at the Reports section on Explore -> Reporting.

You can also create a csv or xlsx report without a report definition by saving a search on Explore -> Discover. Remember to have an available index pattern.

Generate a report definition

Before creating a report definition you must have generated and saved a Dashboard, a Visualization or a search. Then you can do so at the Explore -> Reporting section, choosing the intended configuration. This generates PDF/PNG reports or CSV/XLSX reports in case a saved search is selected.

Ruleset Management

The Ruleset Management plugin is a fork of the OpenSearch Security Analytics plugin adapted for Wazuh. It evaluates incoming events against Sigma detection rules and creates a finding for every event that matches.

The Ruleset Management plugin runs inside the Wazuh Indexer and operates as an OpenSearch plugin, using the standard OpenSearch transport layer for all internal communication.

A note on naming: Ruleset Management is the user-facing name of the plugin. Internally the plugin is still called Security Analytics, and that name remains visible in the identifiers it exposes — the plugins.security_analytics.* settings prefix, the /_plugins/_security_analytics/ API base path, the .opensearch-sap-* index patterns, and the cluster:admin/*/securityanalytics/* action namespaces. The Development Guide also refers to it as Security Analytics throughout. Both names describe the same plugin.

Concepts

  • Integration: a data source and the content that detects on it, delivered by the Wazuh CTI catalog: aws, windows, suricata, and so on. Each integration has a category, which names the data streams its events and findings are written to. Integrations are what the upstream OpenSearch plugin calls log types.
  • Detector: evaluates the events of one integration against a set of Sigma rules on a schedule, and records a finding for every event that matches. Wazuh provides one standard detector per integration, provisioned from the CTI catalog; users can also create their own.
  • Rule: a Sigma detection rule. Standard rules come from the CTI catalog, custom rules are user-created. See Sigma rules.
  • Space: the origin of a piece of content, either Standard (CTI-provided) or Custom (user-created). A single detector references rules from one space only, see Detector rule space restriction.
  • Finding: the record that an event matched a rule, carrying the triggering event in full together with the relevant fields of the rule. One is written to wazuh-findings-v5-{category}* for every match, and that document is what the Wazuh Dashboard reads and case management triages, through status, severity, priority and comments held on it. See Wazuh enriched findings for how it is assembled.
  • Trigger: a condition on a detector that decides which findings are escalated, together with the actions that deliver a notification through the Notifications plugin.
  • Alert: the record created when a trigger condition is met, stored in .opensearch-sap-{integration}-alerts*.
  • Correlation: an upstream mechanism for linking findings from different integrations through correlation rules. It is not used in 5.0.0: no correlation rules are shipped, and plugins.security_analytics.auto_correlations_enabled is false by default.

Note: the standard threat detectors Wazuh provides do not include alerting triggers. They record detections as findings; no alert is raised, and no notification channel is called. Triggers and alerts are available to detectors that users create.

Detector rule space restriction

A detector can only reference rules from a single space type — either Standard (pre-packaged Sigma rules) or Custom (user-promoted rules) — never both simultaneously. This applies to both detector creation and update operations.

When the restriction is violated, the API returns 400 Bad Request.

Detector constraints

  • Max rules per detector — each detector input can reference at most plugins.security_analytics.max_rules_per_detector rules (custom or pre-packaged), default 50. Requests that exceed this limit are rejected with HTTP 400.
  • Max detectors — at most plugins.security_analytics.max_detectors user-created detectors are allowed, default 10. Detectors created by the Content Manager plugin do not count towards this limit.

Both limits are dynamic and enforced at the transport layer, applying to all detector creation and update paths, including inter-plugin calls from the Content Manager. Both accept any value from 0 upwards; there is no hard-coded ceiling. See Configuration for details.

Enabling and disabling detectors

A standard detector accepts one user change and one only: switching enabled on or off. Every other field is owned by the CTI catalog and is rejected with 400 Bad Request and Standard detectors cannot be modified by users. Only enabling or disabling is allowed. A standard detector also cannot be deleted; the API answers 400 Bad Request with Standard detectors cannot be deleted by users.

# Disable a detector (as an administrator)
curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT \
  "https://127.0.0.1:9200/_plugins/_security_analytics/detectors/<detector_id>" \
  -H 'Content-Type: application/json' \
  --data-binary @detector-with-enabled-false.json

Note: the detector API returns last_update_time and enabled_time as ISO-8601 strings but expects epoch milliseconds on write. Sending back an unmodified response body fails with illegal_argument_exception: For input string: "...", which is a parse error, not a permission one.

Who can switch a detector off

Switching a detector on or off requires cluster:admin/opensearch/securityanalytics/detector/write, which no default role holds — out of the box only all_access does.

The detection gap

A disabled detector produces no findings, and the events missed while it was off are never recovered. A detector analyses the events of each scheduled run, and a disabled detector is not run at all. Events indexed while it was off therefore fall outside every run, before and after, so re-enabling it resumes detection from that moment forward — it does not go back over the interval that was skipped.

The consequences are worth stating plainly:

  • The events themselves are not lost; they are indexed as usual and remain searchable in wazuh-events-v5-*.
  • No finding exists for them and none will be created later, so anything that reads findings — the Dashboard, case management, triggers and alerts on user-created detectors, correlations — behaves as if nothing matched.
  • There is no re-scan or backfill API. The only way to evaluate an event against rules after the fact is logtest, one event at a time.

Plan a maintenance window that disables a detector accordingly, and re-ingest the source data if the interval matters.

Auditing state changes

Every transition is recorded in the Wazuh Indexer log at INFO, together with the account that requested it:

[2026-09-10T13:54:15,906][INFO ][o.o.s.t.TransportIndexDetectorAction] [indexer] Detector [h1BSbaABJb1ilIZ5JvzP] (windows, standard) was disabled by [admin]. No findings will be generated for its integration until it is enabled again.
[2026-09-10T13:54:20,282][INFO ][o.o.s.t.TransportIndexDetectorAction] [indexer] Detector [h1BSbaABJb1ilIZ5JvzP] (windows, standard) was enabled by [admin]. Events indexed while it was disabled are not re-evaluated.

A change carried with no authenticated user attached to the request — the Content Manager switching a detector off because its integration was disabled in the CTI catalog, or any cluster running without the security plugin — is logged the same way, with internal in place of the account name. Nothing is written when a request leaves the state unchanged, and nothing is written until the change is persisted.

Wazuh enriched findings

What is a finding?

A finding is a record that a monitored event matched a Sigma detection rule. The detector’s monitor creates one raw finding per matching event and hands it to Ruleset Management for enrichment. Each raw finding contains:

  • id — unique finding identifier.
  • monitor_id — the monitor of the detector that produced the finding.
  • related_doc_ids — IDs of the source documents that triggered the match.
  • queries — the Sigma rule(s) that matched.
  • index — the source index where the triggering event lives.
  • timestamp — when the finding was created.

Raw findings contain only identifiers — they do not embed the triggering event payload or rule metadata.

Raw findings are not stored by default. They are written to .opensearch-sap-{integration}-findings-* only when the Alerting plugin’s plugins.alerting.alert_finding_enabled setting is true (default false). Enriched findings, described below, do not depend on this setting. For the same reason, the findings endpoint inherited from upstream (GET /_plugins/_security_analytics/findings/_search), which only searches the raw findings indices, returns no results with the default settings: query wazuh-findings-v5-* instead.

What is an enriched finding?

An enriched finding is an augmented version of a raw Ruleset Management finding. Because the Wazuh Dashboard needs the full event payload and rule context to render alert details, each finding is enriched with:

  • The full triggering event source (fetched from the source index by document ID), including its @timestamp and wazuh.event.id
  • A reference to the event: its _id in event.doc_id and its backing index in event.index
  • The matching rule under wazuh.rule: its ID, title, severity level, status, tags, compliance and MITRE ATT&CK fields

Rule metadata is merged into the event’s existing wazuh object, so wazuh.integration.* from the triggering event sits alongside wazuh.rule.* in the enriched document.

Enriched findings are written to wazuh-findings-v5-{category}*, where {category} is derived from the wazuh.integration.category field in the triggering event. See Findings in the Wazuh Common Schema for the field layout of a finding.

How findings are generated (high level)

The following steps happen for every event that matches a detection rule:

  1. A Wazuh Manager sends an event to the Wazuh Indexer. The event is indexed in the monitored data stream.
  2. The Ruleset Management plugin’s Alerting monitor evaluates the event against all active Sigma rules for the configured log category.
  3. On a match, the monitor creates a raw finding and hands it to Ruleset Management, which queues it for enrichment. The raw finding itself is not stored unless plugins.alerting.alert_finding_enabled is true.
  4. The enrichment step asynchronously fetches the triggering event source and the matching rule’s metadata, assembles the enriched document, and bulk-indexes it into wazuh-findings-v5-{category}*.

Enrichment is fire-and-forget: it never blocks the Ruleset Management write path and failures are logged without propagating to the caller.

See Architecture for the data flow, and the development guide for implementation details.

API

Most endpoints (detectors, alerts, findings, correlations, and integrations — logtype in the upstream API) are inherited from the upstream OpenSearch Security Analytics plugin — see the OpenSearch API reference for those. Wazuh-specific additions and modifications:

  • Case management update (PUT /_plugins/_security_analytics/findings/_update) — see Case management.
  • Detector rule-space restriction and the per-detector rule and detector-count limits — see Detector rule space restriction and Detector constraints above.
  • Detector updates (PUT /_plugins/_security_analytics/detectors/{detector_id}) — a detector provisioned from the CTI catalog accepts a change to enabled and nothing else, since each content update rebuilds it from the catalog. See Enabling and disabling detectors for who may do it and what a disabled detector costs.

Architecture

Enrichment pipeline

When a Sigma rule matches an event, the detector’s monitor creates a raw finding and hands it to Ruleset Management in memory. An asynchronous enrichment step then fetches the triggering event and the matching rule’s metadata, assembles an enriched document, and bulk-indexes it into wazuh-findings-v5-{category}*. The enrichment never reads raw findings back from an index, so it works whether or not they are stored (see What is a finding?).

The complete flow is shown in the sequence diagram below:

sequenceDiagram
    participant A as Wazuh Manager
    participant I as Wazuh Indexer
    participant RM as Ruleset Management
    participant SI as Source index
    participant RI as Rules index
    participant WF as wazuh-findings-v5-{category}*

    A->>I: Ingest event
    I->>RM: Monitor evaluates event against Sigma rules
    RM->>RM: Rule matches → create raw finding (in memory)
    RM->>RM: Queue finding for enrichment
    RM->>SI: Fetch triggering event by document ID
    SI-->>RM: Event source
    RM->>RM: Resolve log category from the event
    alt Rule metadata cached
        RM->>RM: Read from in-memory cache
    else Cache miss
        RM->>RI: Fetch rule metadata (pre-packaged + custom rules indices)
        RI-->>RM: Rule metadata
        RM->>RM: Cache the result
    end
    RM->>RM: Assemble enriched document
    alt Batch full
        RM->>WF: Bulk-index accumulated findings
    else Periodic flush
        RM->>WF: Bulk-index accumulated findings
    end

Enrichment is fire-and-forget: it never blocks the monitor run that produced the raw finding, and failures are logged without propagating to the caller. Concurrency is bounded so that heavy finding volume can’t overload the transport layer; findings that arrive while the concurrency limit is reached are queued and processed as capacity frees up.

Detector provisioning

Threat detectors for Wazuh integrations are created dynamically based on CTI content rather than fixed configuration:

  • Enabled status: controlled by CTI to activate or deactivate detectors globally.
  • Scan interval: customizable per integration (e.g., critical integrations can have shorter intervals).
  • Source indices: defines the target indices or index patterns the detector monitors. If no source indices are provided, the detector falls back to the legacy per-category events pattern.
  • Triggers and threat intelligence matching: not used. Detectors are created with no triggers, so a detection is recorded as a finding instead of raising an alert, and with threat_intel_enabled set to false, because Indicators of Compromise (IoCs) are matched by the Wazuh Engine as it processes events.

Any change in the CTI catalog is reflected in detector configuration without requiring code changes or restarts. The catalog owns the detectors it creates: each content update rebuilds them, which is why a provisioned detector accepts no user change other than enabling or disabling it.

Behavior notes

  • Rule metadata caching: rule metadata (severity level, compliance mappings, MITRE ATT&CK tags) is cached in memory, keyed by rule ID, so repeated findings from the same detector don’t repeatedly query the rules indices. The cache size is bounded by enriched_findings_rule_cache_max_size (see Configuration); least-recently-used entries are evicted and re-fetched on demand.
  • Category resolution: if the triggering event doesn’t carry a recognized log category, enrichment is skipped for that finding and a warning is logged.
  • Document layout: the enriched document is a copy of the triggering event’s source, with rule metadata nested under wazuh.rule (id, title, tags, and any of level, status, compliance, MITRE present in the rule). The original event source is never mutated.
  • Write semantics: enriched findings are indexed as new documents, never overwriting an existing enriched finding for the same event.

Technical parameters

See Configuration for the settings that control batch size, flush interval, concurrency, and cache size.

System indices

IndexDescription
.opensearch-sap-{integration}-findings-*Raw findings. Only created when plugins.alerting.alert_finding_enabled is true (default false)
.opensearch-sap-pre-packaged-rules-configWazuh-provided Sigma rules; source for rule metadata
.opensearch-sap-custom-rules-configUser-created custom rules; fallback source for rule metadata
.opensearch-sap-log-types-configLog types. Every integration is registered as one, which is why a rule’s logsource.product names its integration — see Rules
.opensearch-sap-detectors-configThreat detector configurations
wazuh-findings-v5-{category}*Enriched findings

Access control

Access to Ruleset Management is governed by the default Wazuh roles. The plugin authorizes requests against two action namespaces: the Wazuh custom actions cluster:admin/wazuh/securityanalytics/* and the upstream OpenSearch actions cluster:admin/opensearch/securityanalytics/* (see Permissions).

  • all_access — full access: create/update/delete detectors, rules, integrations, and correlations; read findings and alerts.
  • wazuh_manager — no access to the Ruleset Management API. The role can read the enriched findings in wazuh-findings-v5-*, but not create, update or delete any Ruleset Management resource.

No default Wazuh role grants write access; to allow it without granting all_access, define a role with the actions above and map it to the user.

Configuration

Ruleset Management settings

The Ruleset Management plugin is configured through settings in opensearch.yml. All node-scope settings use the plugins.security_analytics prefix. Almost every setting is dynamic and can be changed at runtime via the Cluster Settings API.

  • plugins.security_analytics.alert_finding_enabled (Boolean, default false) — enable rollover for the finding history indices, and keep the current write index out of the retention sweep while it is on. Retention deletion itself runs either way. It does not make raw findings be stored; that is the Alerting plugin’s plugins.alerting.alert_finding_enabled.
  • plugins.security_analytics.alert_finding_max_docs (Long, default 1000, minimum 0) — Deprecated. Maximum document count for a finding history index before rollover.
  • plugins.security_analytics.alert_finding_rollover_period (Time, default 12h) — how often the finding history rollover job runs.
  • plugins.security_analytics.alert_history_enabled (Boolean, default false) — enable rollover for the alert history indices, and keep the current write index out of the retention sweep while it is on. Retention deletion itself runs either way.
  • plugins.security_analytics.alert_history_max_age (Time, default 30d) — maximum age of an alert history index before rollover.
  • plugins.security_analytics.alert_history_max_docs (Long, default 1000, minimum 0) — maximum document count for an alert history index before rollover.
  • plugins.security_analytics.alert_history_retention_period (Time, default 60d) — retention period after which alert history indices are deleted.
  • plugins.security_analytics.alert_history_rollover_period (Time, default 12h) — how often the alert history rollover job runs.
  • plugins.security_analytics.auto_correlations_enabled (Boolean, default false) — automatically generate correlation rules from new findings.
  • plugins.security_analytics.correlation.detector_cache_ttl (Time, default 5m) — TTL for the in-memory monitor-id to detector cache. Set to 0s to disable the cache.
  • plugins.security_analytics.correlation.events_backpressure.enabled (Boolean, default true) — write-block the events indices when the correlation backlog fills, so ingestion pauses and the backlog drains instead of the node running out of memory.
  • plugins.security_analytics.correlation.events_backpressure.high_watermark_percent (Integer, default 100, range 1–100) — backlog level, as a percent of correlation.max_pending_findings, at or above which the events indices are write-blocked.
  • plugins.security_analytics.correlation.events_backpressure.low_watermark_percent (Integer, default 60, range 0–99) — backlog level, as a percent of correlation.max_pending_findings, at or below which the events-index write block is lifted.
  • plugins.security_analytics.correlation.max_in_flight_findings (Integer, default 50, range 1–1000) — maximum number of correlation pipelines running concurrently.
  • plugins.security_analytics.correlation.max_pending_findings (Integer, default 10000, range 1–1000000) — maximum findings waiting for a free correlation slot. When the backlog is full, new findings are shed (correlation and enrichment skipped) so the node does not run out of memory under overload.
  • plugins.security_analytics.correlation.metadata_cache_ttl (Time, default 5m) — TTL for the in-memory caches of log-type list and correlation rules by detector type. Set to 0s to disable both caches.
  • plugins.security_analytics.correlation_history_max_age (Time, default 30d) — maximum age of a correlation history index before rollover.
  • plugins.security_analytics.correlation_history_max_docs (Long, default 1000, minimum 0) — maximum document count for a correlation history index before rollover.
  • plugins.security_analytics.correlation_history_retention_period (Time, default 60d) — retention period after which correlation history indices are deleted.
  • plugins.security_analytics.correlation_history_rollover_period (Time, default 12h) — how often the correlation history rollover job runs.
  • plugins.security_analytics.correlation_time_window (Time, default 5m) — time window used to group findings into correlations.
  • plugins.security_analytics.enable_detectors_with_dedicated_query_indices (Boolean, default true) — create dedicated query indices for new detectors.
  • plugins.security_analytics.enable_workflow_usage (Boolean, default true) — use Alerting composite workflows when running detectors.
  • plugins.security_analytics.enriched_findings_bulk_size (Integer, default 100, range 10–1000) — number of enriched findings accumulated before a bulk index request is fired.
  • plugins.security_analytics.enriched_findings_enrich_batch_size (Integer, default 100, range 1–1000) — maximum number of findings drained from the queue per in-flight permit, fetched via a single combined MultiGet.
  • plugins.security_analytics.enriched_findings_flush_interval (Integer, default 5, range 1–60) — interval in seconds at which pending enriched findings are flushed regardless of batch size.
  • plugins.security_analytics.enriched_findings_index_enabled (Boolean, default true) — toggle the enriched findings pipeline.
  • plugins.security_analytics.enriched_findings_max_in_flight (Integer, default 5, range 1–10) — maximum number of concurrent async enrichment chains.
  • plugins.security_analytics.enriched_findings_rule_cache_max_size (Integer, default 10000, minimum 0, static — requires a node restart to change) — maximum number of rule-metadata entries cached in memory. Least-recently-used entries are evicted past this size.
  • plugins.security_analytics.filter_by_backend_roles (Boolean, default false) — restrict access to detectors, rules, and findings based on the requester’s backend roles.
  • plugins.security_analytics.finding_history_max_age (Time, default 30d) — maximum age of a finding history index before rollover.
  • plugins.security_analytics.finding_history_retention_period (Time, default 60d) — retention period after which finding history indices are deleted.
  • plugins.security_analytics.index_timeout (Time, default 60s) — timeout for Ruleset Management index operations.
  • plugins.security_analytics.max_case_management_bulk_size (Integer, default 10, range 0–100, dynamic) — maximum number of findings that can be updated in a single request to the update findings endpoint (PUT /_plugins/_security_analytics/findings/_update). Setting it to 0 disables the endpoint entirely.
  • plugins.security_analytics.max_detectors (Integer, default 10, minimum 0, no upper bound, dynamic) — maximum number of user-created detectors (Content Manager detectors do not count).
  • plugins.security_analytics.max_rules_per_detector (Integer, default 50, minimum 0, no upper bound, dynamic) — maximum number of rules (custom or pre-packaged) allowed in a single detector input. Requests that would exceed this limit are rejected with HTTP 400.
  • plugins.security_analytics.mappings.default_schema (String, default ecs) — default field-mapping schema used to resolve a Sigma rule’s raw field names to Wazuh Common Schema fields when a log type does not declare its own schema.

History indices

Each history group (alerts, findings, correlations) is managed by an independent rollover job with the same five knobs: an enable toggle, a rollover period, a max age, a max document count, and a retention period after which old indices are deleted.

To tune retention for the alert history indices:

# opensearch.yml
plugins.security_analytics.alert_history_enabled: true
plugins.security_analytics.alert_history_rollover_period: 12h
plugins.security_analytics.alert_history_max_age: 30d
plugins.security_analytics.alert_history_max_docs: 1000
plugins.security_analytics.alert_history_retention_period: 60d

The same pattern applies to finding history (plugins.security_analytics.alert_finding_*, plugins.security_analytics.finding_history_*), and correlation history (plugins.security_analytics.correlation_history_*). Finding history indices only exist when the Alerting plugin stores raw findings (plugins.alerting.alert_finding_enabled: true, default false). Setting plugins.security_analytics.alert_finding_enabled does not make raw findings be stored: it enables rollover of the finding history indices and, while enabled, keeps the current write index out of the retention sweep. Retention deletion runs either way.

plugins.security_analytics.alert_finding_max_docs is deprecated. Configure finding history rollover through the other finding_history_* settings.

Correlation tuning

The correlation engine runs after every matched finding and consults two in-memory caches plus a concurrency limiter:

# opensearch.yml
plugins.security_analytics.correlation_time_window: 5m
plugins.security_analytics.auto_correlations_enabled: false
plugins.security_analytics.correlation.detector_cache_ttl: 5m
plugins.security_analytics.correlation.metadata_cache_ttl: 5m
plugins.security_analytics.correlation.max_in_flight_findings: 50

Both detector_cache_ttl and metadata_cache_ttl accept 0s to disable the cache entirely, which forces a lookup against the corresponding system index on every finding. Lower correlation.max_in_flight_findings on resource-constrained nodes to bound peak demand on the search thread pool.

Detector behavior

# opensearch.yml
plugins.security_analytics.enable_detectors_with_dedicated_query_indices: true
plugins.security_analytics.enriched_findings_index_enabled: true
plugins.security_analytics.enable_workflow_usage: true
plugins.security_analytics.filter_by_backend_roles: false

Setting enriched_findings_index_enabled to false disables the Wazuh enriched findings pipeline described in Architecture: no wazuh-findings-v5-{category}* documents are produced. Raw findings are unaffected — they are written to .opensearch-sap-{integration}-findings-* only when the Alerting plugin’s plugins.alerting.alert_finding_enabled is true (default false), so with that setting at its default, disabling enrichment leaves no finding stored at all.

Resource creation limits

# opensearch.yml
plugins.security_analytics.max_detectors: 10
plugins.security_analytics.max_rules_per_detector: 50
plugins.security_analytics.max_case_management_bulk_size: 10
  • max_detectors — caps the number of user-created detectors; Content Manager–created detectors are exempt.
  • max_rules_per_detector — caps the number of rules (custom or pre-packaged) a single detector input can reference.
  • max_case_management_bulk_size — caps the number of findings that can be updated in one call to the update findings endpoint. Set it to 0 to disable the endpoint entirely.

All three settings are dynamic.

Enrichment tuning

The enriched findings service batches index requests and limits concurrency to avoid overloading the transport layer. The settings below can be tuned independently:

# opensearch.yml
plugins.security_analytics.enriched_findings_bulk_size: 100
plugins.security_analytics.enriched_findings_max_in_flight: 5
plugins.security_analytics.enriched_findings_flush_interval: 5
plugins.security_analytics.enriched_findings_enrich_batch_size: 100
plugins.security_analytics.enriched_findings_rule_cache_max_size: 10000
  • bulk_size — findings are buffered until this count is reached, then flushed as a single bulk request. Lower it on low-throughput nodes to reduce latency; raise it on high-throughput nodes to improve indexing efficiency.
  • max_in_flight — caps the number of concurrent enrichment chains (MultiGet + build + index). Lower it on resource-constrained nodes to reduce peak demand on the transport layer.
  • flush_interval — interval in seconds at which any remaining buffered findings are flushed, regardless of bulk_size. Prevents findings from sitting in the buffer indefinitely during low-activity periods.
  • enrich_batch_size — maximum number of findings drained from the queue per in-flight permit; their triggering events are fetched in a single combined MultiGet instead of one per finding, reducing round-trips under load.
  • rule_cache_max_size — bounds the in-memory rule-metadata cache. Each cached entry holds a full rule document (compliance and MITRE maps included); least-recently-used entries are evicted past this size and re-fetched on demand.

Overload protection and enrichment throughput

Under sustained load, doc-level monitors publish findings faster than correlation and enrichment can process them. These settings bound that work so the node sheds or pauses load instead of running out of memory, and tune how efficiently the enrichment pipeline writes wazuh-findings-v5-*.

# opensearch.yml
# Bound the correlation backlog
plugins.security_analytics.correlation.max_pending_findings: 10000

# Pause ingestion when the backlog fills, resume when it drains
plugins.security_analytics.correlation.events_backpressure.enabled: true
plugins.security_analytics.correlation.events_backpressure.high_watermark_percent: 100
plugins.security_analytics.correlation.events_backpressure.low_watermark_percent: 60

# Enrichment pipeline throughput
plugins.security_analytics.enriched_findings_bulk_size: 100
plugins.security_analytics.enriched_findings_enrich_batch_size: 100
plugins.security_analytics.enriched_findings_max_in_flight: 5
plugins.security_analytics.enriched_findings_flush_interval: 5
plugins.security_analytics.enriched_findings_rule_cache_max_size: 10000

Two independent overload guards act on the correlation backlog:

  • correlation.max_pending_findings caps how many findings may wait for a free correlation slot (the slots themselves are limited by correlation.max_in_flight_findings). When the backlog is full and events_backpressure is disabled, extra findings are shed, so the node stays up.
  • With events_backpressure.enabled, instead of shedding findings the plugin write-blocks the events indices when the backlog reaches high_watermark_percent, so no new events are indexed and the backlog can drain; the block is lifted at low_watermark_percent.

The enrichment throughput settings shape the load the pipeline puts on the cluster: enrich_batch_size findings are drained per in-flight permit and their source events are fetched in one combined MultiGet; enriched documents are buffered and written in bulks of bulk_size, flushed at least every flush_interval seconds; max_in_flight bounds the concurrent enrichment chains; and rule_cache_max_size bounds the in-memory rule-metadata cache.

Updating a setting at runtime

Almost every Ruleset Management setting is dynamic. To change one without restarting the node, use the Cluster Settings API:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT "https://127.0.0.1:9200/_cluster/settings" -H 'Content-Type: application/json' -d'
{
  "persistent": {
    "plugins.security_analytics.correlation.max_in_flight_findings": 100
  }
}'

Notes

  • Changes to opensearch.yml require a restart of the Wazuh Indexer to take effect. Dynamic settings can additionally be updated at runtime via the Cluster Settings API shown above.
  • index.correlation is an index-scope setting and must be applied to individual indices (for example, via an index template or the _settings API), not to the cluster as a whole.
  • Rollover jobs are enforced by the OpenSearch Job Scheduler. Actual rollover timing may vary slightly depending on cluster load.

Rules

Wazuh uses the Sigma rule format as the standard for Ruleset Management detection rules. The Content Manager plugin accepts rules that follow the Sigma specification, extended with Wazuh-specific blocks for metadata, threat intelligence mapping, and compliance coverage.

This page describes the supported rule format, including field requirements, detection logic, supported modifiers, and Wazuh extensions.

For the full Sigma standard, see the Sigma Rules Specification.


Starting example

The following example demonstrates a complete Sigma rule using all supported blocks. Its logsource.product names the integration the rule belongs to, which is a requirement rather than a description of the platform; see Log source.

metadata:
  title: Python SQL Exceptions
  author: Thomas Patzke
  description: Detects SQL exceptions in Python applications according to PEP 249.

sigma_id: 19aefed0-ffd4-47dc-a7fc-f8b1425e84f9
status: stable
level: medium
enabled: true

tags:
  - attack.initial-access
  - attack.t1190

logsource:
  category: application
  product: wazuh-generic-1

detection:
  keywords:
    - DataError
    - IntegrityError
    - ProgrammingError
    - OperationalError
  condition: keywords

falsepositives:
  - Application bugs

mitre:
  tactic:
    id:
      - TA0001
    name:
      - Initial Access
  technique:
    id:
      - T1190
    name:
      - Exploit Public-Facing Application

compliance:
  pci_dss:
    - "6.5.1"
  gdpr:
    - Art. 32

Components

A Wazuh Sigma rule is composed of the following blocks:

  • Detection
    The rule’s matching logic — selections, keywords, and conditions.
  • Log Source
    The type of log data the rule targets.
  • Metadata
    Authorship and lifecycle information (title, author, description, dates, references).
  • MITRE ATT&CK
    Threat intelligence mapping to MITRE tactics, techniques, and subtechniques.
  • Compliance
    Compliance framework mapping (GDPR, PCI DSS, NIST 800-53, etc.).

The sections below describe each component in detail.


Top-level fields

The following fields are the supported top-level fields in a Wazuh Sigma rule. Fields marked Required must be present for the rule to pass validation.

  • id (String, server-generated) — globally unique rule identifier (UUIDv4). Generated by the Content Manager: leave it out of create and update payloads, where any value supplied is replaced.
  • status (String, required) — rule maturity status: stable, experimental, test, deprecated or unsupported.
  • level (String, required) — alert severity: informational, low, medium, high, or critical.
  • sigma_id (String, optional) — original Sigma rule identifier (UUID), preserved when importing from upstream.
  • enabled (Boolean, optional, default true) — whether the rule is active.
  • tags (Array, optional) — categorization tags (e.g., attack.initial-access).
  • falsepositives (Array, optional) — known sources of false positives.
  • detection (Object, required) — detection logic: selections, keywords, and conditions.
  • logsource (Object, required) — classifies the type of log data the rule targets. Its product field names the integration the rule belongs to and selects the detector that evaluates it.
  • mitre (Object, optional) — MITRE ATT&CK threat intelligence mapping.
  • compliance (Object, optional) — compliance framework mapping.
  • metadata (Object, required) — other information.

Detection

Required

The detection section defines the rule’s matching logic. It consists of one or more named selections (or keywords) and a condition that combines them using boolean logic.

detection:
  condition: selection
  selection:
    event.action: account-locked
    event.category|contains: authentication

The detection section must always contain:

  • At least one named selection or a keywords list.
  • A condition field that references those selections.

Important

All fields referenced in the detection section are validated against the Wazuh Common Schema. Rules that reference unknown fields are rejected with a structured error response identifying the offending field names. This prevents silent mismatches where a rule appears active but never triggers because it queries a non-existent field.

Selections

A selection is a named object whose keys correspond to existing WCS fields and whose values define the matching criteria. A selection matches when any or all of its field conditions are satisfied, depending on the chosen syntax:

Field list (implicit OR)

detection:
  selection:
    event.action:
      - login_failed # or
      - authentication_error
  condition: selection

This rule matches when event.action is either "login_failed" or "authentication_error".

Field dictionary (implicit AND)

detection:
  selection:
    log.level: ERROR # and
    event.kind: event
  condition: selection

This rule matches when log.level is "ERROR" and event.kind is "event".

Keywords (implicit OR)

The detection by keywords performs value-only searches across all event fields, without specifying a target field name:

detection:
  keywords:
    - DataError # or
    - IntegrityError
    - OperationalError
  condition: keywords

Each item in the list is effectively separated by a logical “OR” operator, meaning that the rule will match if any of the specified keywords are found in any field of the event.

Conditions

The condition field is a string expression that combines named selections using boolean logic to define when the rule triggers. Each identifier in the condition must correspond to a named selection defined in the same detection object.

condition: (selection_one or selection_two) and not filter
OperatorDescriptionExample
andBoth operands must matchselection1 and selection2
orAt least one operand must matchsel_error or sel_warn
notNegates the following operandselection and not filter
( )Groups expressions for precedence(sel_a or sel_b) and not exclusion

Example: simple condition

detection:
  selection:
    log.level: ERROR
  condition: selection

Example: OR condition

detection:
  sel_error:
    log.level: ERROR
  sel_warn:
    log.level: WARN
  condition: sel_error or sel_warn

Example: AND with NOT (exclusion pattern)

detection:
  selection:
    event.kind: event
  filter:
    process.thread.name|startswith: Test
  condition: selection and not filter

Example: multi-selection AND

detection:
  sel_severity:
    event.severity|gte: 8
  sel_message:
    message|contains: fatal
  condition: sel_severity and sel_message

Reference: See Sigma Conditions for the full specification of condition syntax.

Modifiers

Modifiers transform how a field value is compared during detection. They are appended to the field name using the pipe (|) character:

field_name|modifier: value

Multiple modifiers can be chained: field|modifier1|modifier2: value.

Case sensitivity: string comparison is case-sensitive. This applies to plain equality and to contains, startswith, endswith and wildcard values alike: a rule matching UNION SELECT does not match union select or UNION sElect. To catch several spellings of a keyword, list them as separate values.

Both evaluation paths agree: a deployed detector and logtest compile a rule with the same backend and match it with the same percolator, so a logtest result predicts what the detector will do rather than approximating it.

contains

Matches when the field value contains the specified substring. Wildcards are inserted around the value.

message|contains: timeout

startswith

Matches when the field value begins with the specified string. A wildcard is inserted at the end of the value.

process.thread.name|startswith: Gossip

endswith

Matches when the field value ends with the specified string. A wildcard is inserted at the beginning of the value.

process.thread.name|endswith: "-5"

base64

Encodes the provided value as a Base64 string before comparison. Used to detect commands or parameters that an attacker has Base64-encoded to evade plain-text detection.

process.command_line|base64: "/bin/bash"

base64offset

Generates all three possible Base64 offsets of the value to account for the byte position where it might appear inside a larger Base64-encoded blob. Usually preferred over base64 when matching a substring inside an encoded stream, and typically chained with contains.

process.command_line|base64offset|contains: "/bin/bash"

wide

Transforms the value to a UTF-16 (wide-character) byte sequence before comparison. Must be chained with an encoding modifier such as base64 or base64offset — it cannot be the final modifier in the chain because the intermediate representation contains null bytes.

process.command_line|wide|base64offset|contains: "ping"

windash

Expands command-line flag prefixes to match all Windows dash variants: -, /, – (en dash), — (em dash), and ― (horizontal bar). Useful for detecting invocations where attackers swap dash characters to evade signatures.

process.command_line|windash|contains: " -enc "

re

Matches the field value against a regular expression.

process.thread.name|re: "^Repair"

Submodifiers can be chained with re|<flag>:

  • i — case-insensitive matching.
  • m — multi-line mode (^/$ match the start/end of each line).
  • s — single-line mode (. also matches newline characters).

cidr

Matches when the field value (an IPv4 or IPv6 address) falls within the specified CIDR subnet.

source.ip|cidr: 10.42.0.0/16

IPv6 addresses are supported in the following formats:

  • Standard: Full 8-group notation with leading zeros.

    E.g., 2001:0db8:85a3:0000:0000:8a2e:0370:7334.

  • Compressed: Zero-compression using :: to omit consecutive zero groups.

    E.g., 2001:db8:85a3::8a2e:370:7334.

  • CIDR: Subnet notation with a prefix length.

    E.g., 2001:db8::/32.

exists

Checks whether the field is present in the event. The value must be true (field must exist) or false (field must be absent).

source.ip|exists: true

all

By default, list values are combined with OR. The all modifier changes the logic to AND, requiring every value in the list to match. Cannot be applied to single-item lists.

event.category|contains|all:
  - authentication
  - failure

lt

Matches when the field value is less than the specified number.

event.severity|lt: 10

lte

Matches when the field value is less than or equal to the specified number.

event.severity|lte: 3

gt

Matches when the field value is greater than the specified number.

event.severity|gt: 7

gte

Matches when the field value is greater than or equal to the specified number.

event.duration|gte: 5000

Reference: See Sigma Modifiers for additional context on value transformation modifiers.


Log source

Required

The logsource section classifies the type of log data the rule targets. Its category, service and definition fields are descriptive: they help organize rules by their applicable data source, and do not affect detection matching. Its product field is not descriptive — it selects which detector evaluates the rule, as described below.

product

Required

The name of the integration the rule belongs to: it must hold the same value as that integration’s metadata.title.

logsource:
  product: wazuh-generic-1

Unlike the rest of the logsource block, product is a routing key rather than a description, because an integration is also a Ruleset Management log type:

  • Each integration is registered as a log type.
  • A rule’s compiled query is stored in the rule index of the log type its product names.
  • Each integration has a detector, which evaluates the rules stored under its own log type.

So a rule whose product does not name the integration that lists it is stored where that integration’s detector never reads it, and it matches nothing. Rules created through the API are rejected if the two disagree; see Create rule.

Some integrations are named after the platform they collect from, so windows, linux and apache-http are valid product values on a deployment that ships those integrations. That is a consequence of how those integrations are named, not a second, platform-based meaning for the field: wazuh-generic-1, wazuh-fim and wazuh-rootcheck are equally valid, and no platform name is accepted unless an integration carries it. Use category and service to record the telemetry a rule needs.

category

Optional

A broad classification of the telemetry the rule targets (e.g., authentication, process_creation, application, webserver, firewall). Useful for grouping related rules across integrations. Unlike product, this field is descriptive and is not constrained to any list.

logsource:
  category: authentication

service

Optional

The specific service, daemon, or log channel the log comes from (e.g., sshd, security, syslog, kerberos). Use this when the log can be attributed to a particular subsystem or event channel.

logsource:
  service: sshd

definition

Optional

Free-form notes describing onboarding requirements or prerequisites for the log source — for example, audit policies that must be enabled, agent configuration needed, or specific event IDs to collect.

logsource:
  definition: Script Block Logging must be enabled

Reference: See Sigma Log Sources for general guidance on log source classification, including the standard combinations of product, category, and service. Note that product diverges from that guidance here, as described above: it names an integration rather than a platform. A rule taken from an upstream Sigma ruleset needs its product changed to the integration it is being added to, and a rule exported from Wazuh carries an integration name that other Sigma tooling does not recognize.


Metadata

Required

The metadata block contains authorship and lifecycle fields. Only title is required; the others are optional.

title

Required

Human-readable rule title shown in alerts and the rule catalog.

metadata:
  title: Suspicious SSH Login from IPv6

Keep titles short and avoid prefixes like “Detects when …” or “This rule will …”.

author

Optional

The author of the rule. Free-form text; may include contact information such as an email address or handle.

metadata:
  author: Security Team

date

Optional

Creation date in ISO 8601 format (YYYY-MM-DD). Auto-managed when the rule is first registered.

metadata:
  date: "2026-01-15"

modified

Optional

Last modification date in ISO 8601 format (YYYY-MM-DD). Auto-managed when the rule’s content changes.

metadata:
  modified: "2026-03-02"

The modified date changes when the rule is updated.

description

Optional

Brief explanation of what the rule detects and the context in which it is useful. Used by other products and services as a short summary of the rule’s intent — avoid prefixes like “Detects when …” or “This rule will …”.

metadata:
  description: SSH login attempts from known malicious IPv6 ranges.

references

Optional

URLs or plain-text references (advisories, CVE IDs, blog posts, documentation) explaining the motivation for the rule or providing background for analysts.

metadata:
  references:
    - https://example.com/advisory/2025-001
    - CVE-2025-22222

documentation

Optional

Free-form documentation text or a documentation URL providing additional context for analysts triaging the alert.

metadata:
  documentation: https://docs.example.com/rules/ssh-ipv6

supports

Optional

List of supported platforms, products, or contexts in which the rule is intended to operate.

metadata:
  supports:
    - linux
    - macos

MITRE ATT&CK

Optional

The mitre block maps a rule to MITRE ATT&CK tactics, techniques, and subtechniques. It accepts three category keys, each an object holding an id array and a name array:

  • tactic
    ATT&CK tactics. id holds tactic IDs (e.g., TA0002, TA0005); name holds the matching tactic names (e.g., Execution, Defense Evasion).
  • technique
    ATT&CK techniques. id holds technique IDs (e.g., T1059, T1562); name holds the matching technique names (e.g., Command and Scripting Interpreter, Impair Defenses).
  • subtechnique
    ATT&CK subtechniques. id holds subtechnique IDs (e.g., T1059.001); name holds the matching subtechnique names (e.g., Command and Scripting Interpreter: PowerShell).

The id and name arrays of a category are parallel: the n-th name describes the n-th ID. Supply name for every entry of a category or for none of them. Categories that do not apply to the rule can be omitted entirely.

The block is stored on the rule as mitre.tactic.id, mitre.tactic.name, mitre.technique.id, mitre.technique.name, mitre.subtechnique.id and mitre.subtechnique.name, and is copied onto every finding the rule produces under wazuh.rule.mitre.*.

Query mitre.subtechnique.id to match rules by subtechnique, or both mitre.technique.id and mitre.subtechnique.id to cover a technique together with its subtechniques.

Example

mitre:
  tactic:
    id:
      - TA0002
      - TA0005
    name:
      - Execution
      - Defense Evasion
  technique:
    id:
      - T1059
      - T1562
    name:
      - Command and Scripting Interpreter
      - Impair Defenses
  subtechnique:
    id:
      - T1059.001
    name:
      - "Command and Scripting Interpreter: PowerShell"

Per-entry form

A category may equivalently be written as an array of objects, one per ATT&CK entry, which pairs each ID with its name directly. The two forms are interchangeable and produce identical indexed fields:

mitre:
  tactic:
    - id: TA0002
      name: Execution
    - id: TA0005
      name: Defense Evasion
  technique:
    - id: T1059
      name: Command and Scripting Interpreter

Deprecated shorthand

A category written as a plain array of ID strings is still accepted, for rules authored against the older documentation:

mitre:
  tactic:
    - TA0002
  technique:
    - T1059

This form carries no ATT&CK names, so mitre.tactic.name, mitre.technique.name and mitre.subtechnique.name are left empty. Use one of the two named forms above for new rules.


Compliance

Optional

The compliance block maps a rule to one or more compliance frameworks. Each key is a normalized framework identifier and its value is an array of requirement ID strings.

Supported frameworks

  • gdpr
    GDPR
  • pci_dss
    PCI DSS
  • cmmc
    CMMC
  • nist_800_53
    NIST 800-53
  • nist_800_171
    NIST 800-171
  • hipaa
    HIPAA
  • iso_27001
    ISO 27001
  • nis2
    NIS2
  • tsc
    TSC
  • fedramp
    FedRAMP

Example

compliance:
  gdpr:
    - Art. 32
    - Art. 25
  pci_dss:
    - "2.2.1"
    - "6.3.3"
  cmmc:
    - AC.1.001
  nist_800_53:
    - AC-3
    - AU-2
  hipaa:
    - 164.312(a)(1)

Dynamic event field referencing

A Sigma rule’s metadata is normally static: the title, tags, mitre, and compliance blocks describe the rule itself and are attached unchanged to every finding it generates. Wazuh extends Sigma with dynamic event-field referencing, allowing those metadata fields to embed placeholders that resolve against the triggering event at enrichment time. Each finding written to wazuh-findings-v5-{category}* then reflects the specific context of the event that matched — for example, the agent ID, hostname, or any other field present in the normalized event.

Syntax

A placeholder takes the form {{ field.path }}, where field.path is a dot-separated path into the triggering event’s _source document. Whitespace inside the delimiters is trimmed, so {{ wazuh.agent.id }} and {{wazuh.agent.id}} are equivalent. Placeholders may appear anywhere inside a supported field’s value and may be mixed with literal text.

Supported fields

Interpolation is applied only to the following fields of the enriched finding’s wazuh.rule object:

  • title
  • tags
  • mitre.tactic.id, mitre.tactic.name, mitre.technique.id, mitre.technique.name, mitre.subtechnique.id, mitre.subtechnique.name
  • compliance.* (every framework sub-array)

The detection block — both selection and condition — is never interpolated.

Example

id: ed85157d-711b-4edb-8390-492ec63c92ac
sigma_id: 12345678-90ab-cdef-1234-567890abcdef
logsource:
  product: apache-http
tags:
  - attack.impact
  - attack.t1499.004
  - "{{ wazuh.agent.host.name }}"
level: high
status: test
detection:
  condition: selection
  selection:
    message|contains:
      - exit signal Segmentation Fault
    wazuh.integration.name: apache-http
metadata:
  title: "Apache segmentation fault in agent {{ wazuh.agent.id }}"
  description: Segmentation faults raised by an Apache worker process.
mitre:
  tactic:
    id:
      - TA0040
    name:
      - Impact
  technique:
    id:
      - T1499
    name:
      - Endpoint Denial of Service
  subtechnique:
    id:
      - T1499.004
    name:
      - "Endpoint Denial of Service: Application or System Exploitation"
compliance:
  pci_dss:
    - "6.2"
    - "11.4"

When this rule matches an event where wazuh.agent.id = "001" and wazuh.agent.host.name = "web-prod-01", the wazuh.rule object of the resulting enriched finding contains the following fields, alongside id, sigma_id, level and status, which are copied unchanged:

{
  "title": "Apache segmentation fault in agent 001",
  "tags": ["high", "apache-http", "attack.impact", "attack.t1499.004", "web-prod-01"],
  "mitre": {
    "tactic": {
      "id": ["TA0040"],
      "name": ["Impact"]
    },
    "technique": {
      "id": ["T1499"],
      "name": ["Endpoint Denial of Service"]
    },
    "subtechnique": {
      "id": ["T1499.004"],
      "name": ["Endpoint Denial of Service: Application or System Exploitation"]
    }
  },
  "compliance": {
    "pci_dss": ["6.2", "11.4"]
  }
}

wazuh.rule.tags starts with the rule’s level and the integration it belongs to (its logsource.product), followed by the rule’s own tags. Each mitre category keeps its own id and name arrays: a subtechnique appears under subtechnique only, never under technique.

Resolution rules

  • Scalars (string, number, boolean) are coerced to their string representation and substituted in place of the placeholder.
  • Scalar arrays are expanded — each array element is coerced to a string and contributed as an additional element of the surrounding array. Supported for tags, the id and name arrays of every mitre category, and compliance.*. Duplicate values are removed from each of these arrays after interpolation.
  • Missing, null, or non-scalar (object) values resolve to the empty string. Finding generation never fails because of an unresolved placeholder.
  • A field whose value consists solely of a placeholder that resolves to the empty string is dropped from the surrounding array or map. For instance, a tag of "{{ missing.field }}" will not appear in wazuh.rule.tags.
  • A mitre category whose id and name arrays both end up empty after interpolation is removed from wazuh.rule.mitre entirely.

Scope

Interpolation runs after the matching rule is fetched and before the enriched finding is indexed. It affects only the document written to wazuh-findings-v5-{category}*. The raw rule document stored in the rule index is unchanged.

Case management

Case management allows analysts to track and manage the lifecycle of findings produced by Ruleset Management detectors. Each finding can be annotated with case metadata enabling triage workflows directly on the indexed data.

Overview

When a detection rule matches an event, Ruleset Management creates a finding. By default, findings contain only detection fields. Case management extends findings with a wazuh.case object that supports:

  • Classification — a title, description, severity, priority, and TLP (Traffic Light Protocol) label to support prioritization and triage.
  • Status tracking — move findings through a workflow (e.g., active → acknowledged → completed).
  • Multiple comments — a discussion thread of any number of comments, each with its own author and timestamps, independent of the case-level user and timestamps.
  • Tags — organize findings with keyword labels.
  • User attribution — record which analyst last updated the case.
  • Timestamps — track when the case was created and last updated.

Case fields

The following fields are available under wazuh.case in the findings data stream:

  • wazuh.case.title (match_only_text) — short summary of the case.
  • wazuh.case.description (match_only_text) — longer free-form description of the case.
  • wazuh.case.tags (keyword[]) — tags for organization and filtering.
  • wazuh.case.user.name (keyword) — name of the user who last updated the case. Managed by the UI, not editable directly.
  • wazuh.case.status (keyword) — current status. One of active, acknowledged, completed, error, deleted, audit (lowercase).
  • wazuh.case.severity (keyword) — one of informational, low, medium, high, critical (lowercase).
  • wazuh.case.priority (keyword) — one of low, medium, high, urgent (lowercase).
  • wazuh.case.tlp (keyword) — Traffic Light Protocol classification. One of TLP:RED, TLP:AMBER, TLP:GREEN, TLP:CLEAR — uppercase, with the TLP: prefix, unlike the other enum fields.
  • wazuh.case.created_at (date) — when the case was created.
  • wazuh.case.updated_at (date) — when the case was last updated.
  • wazuh.case.comments (array of objects) — the comment thread (replaces the earlier single comment field). Each comment has:
    • wazuh.case.comments.author (keyword) — the user who wrote the comment.
    • wazuh.case.comments.created_at (date) — when the comment was created.
    • wazuh.case.comments.updated_at (date) — when the comment was last edited.
    • wazuh.case.comments.comment (match_only_text) — the comment text.

A case with a single comment is represented as a one-element comments array — there’s no separate single-comment shape.

The update endpoint normalizes the enum fields before storing them: status, severity and priority are lowercased and tlp is uppercased, so a request sending "status": "ACKNOWLEDGED" stores acknowledged. A value outside the lists above, or any key not listed here, is rejected.

Updating findings

Use the update findings endpoint to set or modify case fields on one or more existing findings.

Request

PUT /_plugins/_security_analytics/findings/_update

Body

{
  "findings": [
    {
      "_id": "<finding-document-id>",
      "_index": "<finding-index-name>",
      "case": {
        "title": "Sample Case Title",
        "description": "This is a sample description for the case.",
        "tags": ["tag1", "tag2", "tag3"],
        "user": {
          "name": "admin"
        },
        "status": "acknowledged",
        "severity": "medium",
        "priority": "medium",
        "tlp": "TLP:CLEAR",
        "comments": [
          {
            "author": "admin",
            "created_at": "2026-06-10T08:00:00.000Z",
            "updated_at": "2026-06-10T08:00:00.000Z",
            "comment": "Reviewed by SOC analyst"
          }
        ]
      }
    }
  ]
}

Note: The fields user.name, created_at, updated_at, comments[].created_at, and comments[].updated_at are automatically managed by the Wazuh Dashboard. They should not be set manually.

  • findings (required) — array of finding updates. Maximum size is controlled by plugins.security_analytics.max_case_management_bulk_size (default 10, dynamic; see Configuration). Setting it to 0 disables this endpoint entirely — every request is rejected with 400 Bad Request.
  • findings[]._id (required) — document ID of the finding.
  • findings[]._index (required) — backing index where the finding is stored, as returned in the _index of a search hit (for example .ds-wazuh-findings-v5-security-000001). The data stream name (wazuh-findings-v5-security) is not accepted: that item fails with 400 inside a 207 response.
  • findings[].case (required) — object with the case fields to set or update.

All fields inside case are optional — you can update only the fields you need (partial update). To add a new comment without disturbing existing ones, submit the full comments array including the previous entries plus the new one; the update replaces the array rather than appending to it.

Response

{
  "took": 12,
  "errors": false,
  "items": [
    {
      "_id": "abc123",
      "_index": ".ds-wazuh-findings-v5-security-000001",
      "status": 200,
      "result": "updated"
    }
  ]
}

Error responses

  • 400 — invalid JSON, missing required fields, empty array, unknown or invalid case field, exceeding the configured bulk-size limit, or case management disabled (limit set to 0).
  • 207 — partial failure; some items succeeded, some failed (e.g., document not found, or _index naming the data stream instead of its backing index).

Example: triage workflow

Note: Case management is designed to be performed through the Wazuh Dashboard, which handles timestamps and user attribution automatically. The examples below use curl for illustration purposes.

# 1. Classify and acknowledge a finding
curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT "https://127.0.0.1:9200/_plugins/_security_analytics/findings/_update" \
  -H "Content-Type: application/json" \
  -d '{
    "findings": [{
      "_id": "finding-001",
      "_index": ".ds-wazuh-findings-v5-security-000001",
      "case": {
        "title": "Suspicious SSH activity",
        "severity": "high",
        "priority": "high",
        "tlp": "TLP:AMBER",
        "status": "acknowledged",
        "comments": [
          {
            "author": "admin",
            "comment": "Under investigation"
          }
        ]
      }
    }]
  }'

# 2. Add a follow-up comment and close the finding after investigation
curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT "https://127.0.0.1:9200/_plugins/_security_analytics/findings/_update" \
  -H "Content-Type: application/json" \
  -d '{
    "findings": [{
      "_id": "finding-001",
      "_index": ".ds-wazuh-findings-v5-security-000001",
      "case": {
        "status": "completed",
        "comments": [
          {
            "author": "admin",
            "comment": "Under investigation"
          },
          {
            "author": "admin",
            "comment": "False positive - benign admin activity"
          }
        ]
      }
    }]
  }'

Querying findings by case status

Since wazuh.case.status is a keyword field, you can filter findings by status using standard queries. Keyword matching is case-sensitive and the update endpoint always stores the status in lowercase, so query it in lowercase:

# Get all acknowledged findings
curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X GET "https://127.0.0.1:9200/wazuh-findings-v5-*/_search" -H 'Content-Type: application/json' -d'
{
  "query": {
    "term": {
      "wazuh.case.status": {
        "value": "acknowledged"
      }
    }
  }
}'

Notifications

The Wazuh Indexer Notifications plugin is a specialized component designed to extend the Wazuh Indexer (based on OpenSearch) with multi-channel notification capabilities. It allows the system to send alerts, reports, and messages via Email (SMTP/SES), Slack, Microsoft Teams, Amazon Chime, Amazon SNS and Custom Webhooks, and to queue Wazuh Active Response executions.

Key capabilities

  • Multi-channel delivery: Send notifications to Slack, Microsoft Teams, Chime, Email (SMTP and AWS SES), AWS SNS, custom HTTP webhooks, and Wazuh Active Response.
  • Unified REST API: Create, update, delete, and query notification channel configurations through a single API surface at /_plugins/_notifications/.
  • Test notifications: Validate channel configuration by sending a test message before relying on it for production alerts.
  • Feature discovery: Other plugins can query supported notification features dynamically.
  • RBAC integration: Access to notification configurations is governed by the Wazuh Indexer Security plugin, with backend-role–based filtering.
  • Extensible architecture: The plugin uses a Service Provider Interface (SPI) pattern, making it straightforward to add new destination types.

Supported channel types

  • slack (HTTPS webhook) — posts messages to a Slack channel via an Incoming Webhook URL.
  • chime (HTTPS webhook) — posts messages to an Amazon Chime room via a webhook URL.
  • microsoft_teams (HTTPS webhook) — posts messages to a Microsoft Teams channel via a connector webhook.
  • webhook (HTTP/HTTPS) — sends a payload to an arbitrary HTTP endpoint with configurable method, headers, and URL.
  • email (SMTP / AWS SES) — sends email messages. Requires an smtp_account or ses_account configuration.
  • sns (AWS SNS SDK) — publishes a message to an Amazon SNS topic.
  • smtp_account — defines SMTP server connection details (host, port, method, from address). Its credentials are stored in the keystore, see Email destination secure settings.
  • ses_account — defines AWS SES sending details (region, role ARN, from address).
  • email_group — defines a group of email recipients for reuse across email-type channels.
  • active_response (Wazuh Active Response) — queues an execution request in the wazuh-active-responses data stream for the Wazuh Manager to run on agents. Active Response monitors send through this type. See the Active response example.

Default notification channels

On first startup, the Notifications plugin automatically creates a set of default notification channels. These channels are pre-configured with placeholder URLs and are disabled by default, they serve as templates that users can customize with their own credentials and then enable.

The following default channels are created:

  • Slack Channel (slack) — targets Slack, default URL https://hooks.slack.com/services/YOUR_WORKSPACE_ID/YOUR_CHANNEL_ID/YOUR_WEBHOOK_TOKEN.
  • Jira Channel (webhook) — targets Jira Cloud, default URL https://your-domain.atlassian.net/rest/api/3/issue.
  • PagerDuty Channel (webhook) — targets the PagerDuty Events API v2, default URL https://events.pagerduty.com/v2/enqueue.
  • Shuffle Channel (webhook) — targets Shuffle SOAR, default URL https://shuffler.io/api/v1/hooks/WEBHOOK_ID.

Behavior

  • Default channels are created only on the cluster manager node during startup.
  • The initialization is idempotent: channels that already exist are not recreated or overwritten.
  • All default channels are created with an empty access list, making them visible to all users.
  • Each channel has a fixed ID (e.g., default_slack_channel), so they can be referenced predictably.

Configuring a default channel

To activate a default channel:

  1. Retrieve the channel configuration using the List Notification Configs API or through the Wazuh Dashboard.
  2. Update the channel with your real credentials (webhook URL, API keys, headers, etc.).
  3. Set is_enabled to true.

For example, to configure the Slack channel:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT \
  "https://127.0.0.1:9200/_plugins/_notifications/configs/default_slack_channel" \
  -H 'Content-Type: application/json' \
  -d '{
    "config": {
      "name": "Slack Channel",
      "description": "Production Slack notifications",
      "config_type": "slack",
      "is_enabled": true,
      "slack": {
        "url": "https://hooks.slack.com/services/T0123/B0456/xyzSecretToken"
      }
    }
  }'

Dependencies

This plugin has a dependency on the wazuh-indexer-common-utils repository. It uses the Common Utils jar to provide shared utility functions and common components required for plugin functionality.

Architecture

The Notifications plugin follows a layered architecture that separates destination definitions, transport logic, and plugin orchestration.

High-level architecture

The Notifications plugin runs inside the Wazuh Indexer and acts as a bridge between internal producers of alerts (such as Alerting, Reporting, and ISM) and external delivery services like SMTP servers, webhooks, and AWS services.

At a high level, the architecture is composed of three main parts:

  • Notification producers (inside the Indexer) Internal plugins such as Alerting, Reporting, ISM, and other Wazuh Indexer components generate alerts and events. When they need to send a notification (for example, a Slack message or an email), they call the Notifications plugin either through the REST API exposed by the Indexer, or internal transport actions.

  • Notifications plugin (inside the Indexer) The plugin itself is structured in several layers:

    • REST / Transport layer — exposes the /_plugins/_notifications/... REST endpoints. Receives requests to create, update, list, and delete notification channel configurations, send test notifications, and query features. Validates requests and delegates the work internally.
    • Security integration — uses the Security plugin to validate permissions for each request. When filter_by_backend_roles is enabled, it filters which notification configurations each user can see or use based on backend roles.
    • Destination and transport layer — defines each supported channel type (Slack, Chime, Microsoft Teams, custom webhook, SMTP, SES, SNS) and the corresponding delivery logic. Manages HTTP client pools, connection and socket timeouts, host deny lists, and HTTP response size limits. Reads SMTP credentials from the Wazuh Indexer keystore. SES and SNS authenticate with the AWS default credentials chain, optionally assuming the IAM role set in the channel’s role_arn.
    • Persistence and configuration — stores notification channel configurations in the .opensearch-notifications-config system index, and uses the .opensearch-notifications-config-locks system index to serialize the resource creation limit checks. The plugin keeps no record of the notifications it sends.
  • External destination services (outside the Indexer) After the plugin resolves the destination type, the corresponding transport sends the message to SMTP servers (corporate mail, Gmail, etc.), webhook endpoints (Slack, Microsoft Teams, Amazon Chime, custom HTTP integrations), or AWS services such as SES and SNS.

    Once delivery is attempted, the plugin returns a delivery status for each channel (a status code and text) to the caller (Alerting, Reporting, or the user calling the REST API). The status is not stored.

The active_response channel type is the exception: it delivers nothing to an external service. It writes an execution request to the wazuh-active-responses data stream, which the Wazuh Manager reads to run the response on agents. See Active Response.

For the underlying module layout, class hierarchy, and REST handler mapping, see the development guide.

Send notification sequence

The following sequence describes the flow when an internal plugin (e.g., Alerting) sends a notification:

sequenceDiagram
    participant AL as Alerting
    participant N as Notifications plugin
    participant CI as .opensearch-notifications-config
    participant EXT as External service
    participant AR as wazuh-active-responses

    AL->>N: Send notification (channel IDs, message)
    N->>N: Check the caller's backend roles
    N->>CI: Read the channel configurations
    CI-->>N: Configurations
    loop Each enabled channel the caller can access, in parallel
        alt Email, webhook or SNS channel
            N->>EXT: Deliver the message
            EXT-->>N: Response
        else active_response channel
            N->>N: Queue an execution request
            N->>AR: Bulk-write the queued requests
        end
    end
    N-->>AL: Delivery status for each channel
  1. The alerting monitor triggers an alert and calls the Notification plugin via the internal transport interface.
  2. The plugin verifies the caller: when filter_by_backend_roles is enabled, a user without backend roles is rejected.
  3. The plugin reads the channel configurations from the .opensearch-notifications-config index. A channel that does not exist, that the caller cannot access, or that is disabled gets an error status and receives nothing.
  4. For each remaining channel, in parallel, the plugin resolves the destination type and delegates to the matching transport (email via SMTP or SES, webhook for Slack/Chime/Teams/custom, or SNS). The plugin has no retry setting: only the default retry policy of its HTTP client applies, to webhook deliveries. An active_response channel instead queues an execution request built from the event that triggered the alert. Queued requests are bulk-written to the wazuh-active-responses data stream every opensearch.notifications.active_response.bulk_flush_interval_ms, or as soon as opensearch.notifications.active_response.bulk_max_actions requests accumulate.
  5. The plugin returns the delivery status of each channel to the caller. If any channel fails, the call fails and carries the status of every channel.
  6. The calling plugin acknowledges the result and updates its own alert status.

Configuration management sequence

  1. A user (via Dashboard or REST API) creates or updates a notification channel configuration.
  2. The configuration is validated and persisted in the .opensearch-notifications-config index. Creations are first checked against the resource creation limits.
  3. On retrieval, configurations can be filtered by type, name, status, and other fields.

Configuration

Notifications settings

The Notifications plugin is configured through settings in opensearch.yml and cluster-level dynamic settings. The plugin also supports default values from a YAML configuration file bundled with the plugin.

Settings marked dynamic can be changed at runtime through the cluster settings API (see Dynamic settings update). Settings marked static are read from opensearch.yml when the node starts, so changing them requires a node restart.

Configuration files

On startup, the plugin loads default settings from:

  • Core defaults: /etc/wazuh-indexer/wazuh-indexer-notifications-core/notifications-core.yml
  • Plugin defaults: /etc/wazuh-indexer/wazuh-indexer-notifications/notifications.yml

These files provide initial values that can be overridden by settings in opensearch.yml or through the cluster settings API.

The host_deny_list and allowed_config_types keys of notifications-core.yml are not read: the defaults of those two settings are built into the plugin, so the shipped host_deny_list: [] does not empty the deny list. Set them in opensearch.yml or through the cluster settings API instead.


Core settings (opensearch.notifications.core.*)

These settings control the core notification delivery engine.

Email settings

  • opensearch.notifications.core.email.size_limit (Integer, default 10000000 / 10 MB, minimum 10000 / 10 KB, dynamic) — maximum total size of an email message including attachments.
  • opensearch.notifications.core.email.minimum_header_length (Integer, default 160, dynamic) — minimum header length for email messages. Used to calculate available body size.

HTTP connection settings

The HTTP settings apply to the webhook-based channels (slack, chime, microsoft_teams and webhook), not to email. The plugin builds its HTTP client once, the first time it sends a notification after the node starts, using the values of max_connections, max_connection_per_route, connection_timeout and socket_timeout in effect at that moment. The cluster settings API accepts changes to these four settings, but they only take effect after a node restart.

  • opensearch.notifications.core.http.max_connections (Integer, default 60, dynamic, applied after a restart) — maximum number of simultaneous HTTP connections for webhooks.

  • opensearch.notifications.core.http.max_connection_per_route (Integer, default 20, dynamic, applied after a restart) — maximum HTTP connections per destination route.

  • opensearch.notifications.core.http.connection_timeout (Integer, default 5000, dynamic, applied after a restart) — HTTP connection timeout in milliseconds.

  • opensearch.notifications.core.http.socket_timeout (Integer, default 50000, dynamic, applied after a restart) — HTTP socket timeout in milliseconds.

  • opensearch.notifications.core.http.host_deny_list (List<String>, default: the reserved ranges listed below, dynamic) — hosts and IP ranges that webhook-based channels cannot send to. A message to a matching host fails with Host of url is denied, based on plugin setting [notification.core.http.host_deny_list]. Email, SES and SNS deliveries are not checked. A value you set replaces the whole default list, so include every range that must stay blocked. If not set, inherits the legacy plugins.destination.host.deny_list setting when that one is set in opensearch.yml. By default, the list blocks loopback, link-local (including the cloud instance metadata endpoint 169.254.169.254), private and other reserved addresses, so webhooks to hosts on an internal network are denied:

    127.0.0.0/8, 169.254.0.0/16, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 0.0.0.0/8, 100.64.0.0/10, 192.0.0.0/24, 192.0.2.0/24, 198.18.0.0/15, 192.88.99.0/24, 198.51.100.0/24, 203.0.113.0/24, 224.0.0.0/4, 240.0.0.0/4, 255.255.255.255/32, ::1/128, fe80::/10, fc00::/7, ::/128, 2001:db8::/32, ff00::/8.

General core settings

  • opensearch.notifications.core.max_http_response_size (Integer, default 104857600 / 100 MB, the default of http.max_content_length, dynamic) — maximum allowed HTTP response size in bytes. Protects against oversized responses from webhook endpoints.
  • opensearch.notifications.core.allowed_config_types (List<String>, default ["slack", "chime", "microsoft_teams", "webhook", "email", "sns", "ses_account", "smtp_account", "email_group", "active_response"], dynamic) — channel types the plugin advertises through GET /_plugins/_notifications/features. Clients such as the Wazuh Dashboard read this list to decide which channel types to offer, so removing active_response hides the channel type that Active Response monitors send through from the Dashboard. The REST API does not enforce the list: configurations of a type missing from it can still be created and used.
  • opensearch.notifications.core.tooltip_support (Boolean, default true, dynamic) — enable or disable tooltip support in the Dashboard UI.

Plugin settings (opensearch.notifications.*)

These settings control the plugin’s general behavior.

  • opensearch.notifications.general.operation_timeout_ms (Long, default 60000, minimum 100, dynamic) — timeout in milliseconds for internal operations (index reads/writes).
  • opensearch.notifications.general.default_items_query_count (Integer, default 100, minimum 10, dynamic) — default number of items returned per query when not specified.
  • opensearch.notifications.general.filter_by_backend_roles (Boolean, default false, dynamic) — when true, users can only see notification configurations created by users who share the same backend role. Inherits from plugins.alerting.filter_by_backend_roles if not set.
  • opensearch.notifications.active_response.bulk_flush_interval_ms (Long, default 500, minimum 100, static) — maximum time, in milliseconds, that queued Active Response documents wait before the plugin bulk-writes them to the wazuh-active-responses data stream.
  • opensearch.notifications.active_response.bulk_max_actions (Integer, default 1000, minimum 1, static) — number of queued Active Response documents that triggers a bulk write before the flush interval elapses.

Resource creation limits (plugins.notifications.*)

These settings cap how many notification configuration documents can exist, to bound resource usage. All are dynamic. Creation requests that would exceed a limit are rejected with HTTP 400; existing configurations are unaffected when a limit is lowered.

  • plugins.notifications.max_notification_configs (Integer, default 40, minimum 0, no upper bound, dynamic) — global cap on the total number of notification configuration documents of any type (channels, groups, senders, and active responses all count against this shared limit).
  • plugins.notifications.max_notification_groups (Integer, default 10, minimum 0, no upper bound, dynamic) — cap on the number of email_group configurations. Counts against, and in addition to, max_notification_configs.
  • plugins.notifications.max_notification_senders (Integer, default 5, minimum 0, no upper bound, dynamic) — cap on the number of smtp_account and ses_account configurations combined. Counts against, and in addition to, max_notification_configs.
  • plugins.notifications.max_active_responses (Integer, default 10, minimum 0, no upper bound, dynamic) — cap on the number of active_response configurations. Counts against, and in addition to, max_notification_configs.

Note: These are separate from opensearch.notifications.general.default_items_query_count and other general.* settings above — they live under a distinct plugins.notifications.* prefix.


Email destination secure settings

SMTP credentials are stored securely in the Wazuh Indexer keystore rather than in plain text configuration files. SES and SNS channels do not use the keystore: they authenticate with the AWS default credentials chain, optionally assuming the IAM role set in the channel’s role_arn.

SMTP account credentials

The account name in the keystore key is the name of the smtp_account configuration. To configure credentials for an smtp_account named my_smtp_account, run the following commands on every node. Each command prompts for the value.

# Add SMTP username
sudo /usr/share/wazuh-indexer/bin/opensearch-keystore add opensearch.notifications.core.email.my_smtp_account.username

# Add SMTP password
sudo /usr/share/wazuh-indexer/bin/opensearch-keystore add opensearch.notifications.core.email.my_smtp_account.password

# The keystore belongs to root and is read by the Wazuh Indexer through its group
sudo chown root:wazuh-indexer /etc/wazuh-indexer/opensearch.keystore
sudo chmod 640 /etc/wazuh-indexer/opensearch.keystore

Run opensearch-keystore as root, as shown: the configuration directory is root’s, and the wazuh-indexer user cannot write in it. The tool leaves the keystore owned by root:root, which the Wazuh Indexer cannot read, so the two commands after it give it back to the service’s group. Without them, reloading the keystore fails with access_denied_exception and the Wazuh Indexer fails to start.

The credentials are reloadable secure settings. Apply them without a restart by reloading the keystore on every node:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_nodes/reload_secure_settings"

Set both the username and the password. If either is missing, the plugin connects to the SMTP server without authenticating.

The secure setting key prefix is opensearch.notifications.core.email.<account_name>.username and opensearch.notifications.core.email.<account_name>.password.

Note: Legacy settings from Alerting (plugins.alerting.destination.email.<account_name>.*) are also supported as fallback.


Example configuration

A minimal opensearch.yml configuration for the Notifications plugin:

# Notification core settings
opensearch.notifications.core.email.size_limit: 10000000
opensearch.notifications.core.http.max_connections: 60
opensearch.notifications.core.http.connection_timeout: 5000
opensearch.notifications.core.http.socket_timeout: 50000

# Channel types advertised to clients such as the Wazuh Dashboard
opensearch.notifications.core.allowed_config_types:
  - slack
  - chime
  - microsoft_teams
  - webhook
  - email
  - sns
  - ses_account
  - smtp_account
  - email_group
  - active_response

# Plugin settings
opensearch.notifications.general.operation_timeout_ms: 60000
opensearch.notifications.general.default_items_query_count: 100
opensearch.notifications.general.filter_by_backend_roles: false

# Active Response bulk indexing (static)
opensearch.notifications.active_response.bulk_flush_interval_ms: 500
opensearch.notifications.active_response.bulk_max_actions: 1000

# Resource creation limits
plugins.notifications.max_notification_configs: 40
plugins.notifications.max_notification_groups: 10
plugins.notifications.max_notification_senders: 5
plugins.notifications.max_active_responses: 10

Dynamic settings update

Settings marked as dynamic can be updated at runtime through the cluster settings API:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT \
  "https://127.0.0.1:9200/_cluster/settings" \
  -H 'Content-Type: application/json' \
  -d '{
    "persistent": {
      "opensearch.notifications.core.email.size_limit": 20000000,
      "opensearch.notifications.general.filter_by_backend_roles": true,
      "plugins.notifications.max_notification_configs": 20
    }
  }'

Static settings are rejected with not dynamically updateable; set them in opensearch.yml and restart the node.

API reference

All Notification plugin endpoints use the base path /_plugins/_notifications.


Notification configs

Create a notification config

Creates a new notification channel configuration.

  • Method: POST
  • Path: /_plugins/_notifications/configs

Request body

{
  "config": {
    "name": "<config-name>",
    "description": "<config-description>",
    "config_type": "<channel-type>",
    "is_enabled": true,
    "<channel-type>": {
      // channel-specific fields
    }
  }
}

Slack example

{
  "config": {
    "name": "my-slack-channel",
    "description": "Slack notifications for alerts",
    "config_type": "slack",
    "is_enabled": true,
    "slack": {
      "url": "https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXX"
    }
  }
}

Email example (with SMTP account)

{
  "config": {
    "name": "my-email-channel",
    "description": "Email alerts via SMTP",
    "config_type": "email",
    "is_enabled": true,
    "email": {
      "email_account_id": "<smtp-account-config-id>",
      "recipient_list": [
        { "recipient": "alerts@example.com" }
      ],
      "email_group_id_list": []
    }
  }
}

SMTP account example

{
  "config": {
    "name": "my_smtp_account",
    "description": "Corporate SMTP server",
    "config_type": "smtp_account",
    "is_enabled": true,
    "smtp_account": {
      "host": "smtp.example.com",
      "port": 587,
      "method": "start_tls",
      "from_address": "noreply@example.com"
    }
  }
}

Webhook example

{
  "config": {
    "name": "my-custom-webhook",
    "description": "Custom webhook for incident system",
    "config_type": "webhook",
    "is_enabled": true,
    "webhook": {
      "url": "https://incident.example.com/api/alert",
      "header_params": {
        "Content-Type": "application/json"
      },
      "method": "POST"
    }
  }
}

Microsoft Teams example

{
  "config": {
    "name": "my-teams-channel",
    "description": "Teams notifications",
    "config_type": "microsoft_teams",
    "is_enabled": true,
    "microsoft_teams": {
      "url": "https://outlook.office.com/webhook/..."
    }
  }
}

SNS example

{
  "config": {
    "name": "my-sns-topic",
    "description": "SNS notifications",
    "config_type": "sns",
    "is_enabled": true,
    "sns": {
      "topic_arn": "arn:aws:sns:us-east-1:123456789012:my-topic",
      "role_arn": "arn:aws:iam::123456789012:role/sns-publish-role"
    }
  }
}

Active response example

An active_response channel is the target of Active Response monitors. Instead of sending a message, each notification writes an execution request to the wazuh-active-responses data stream, from which the Wazuh Manager runs executable on the target agents.

{
  "config": {
    "name": "block-source-ip",
    "description": "Block the source IP address on the agent that reported the event",
    "config_type": "active_response",
    "is_enabled": true,
    "active_response": {
      "type": "stateful",
      "stateful_timeout": 600,
      "executable": "block-ip",
      "location": "local"
    }
  }
}
  • type (required) — stateless runs the command once; stateful also asks the agent to revert it after stateful_timeout.
  • stateful_timeout (Integer) — required, and greater than 0, when type is stateful.
  • executable (required) — the command to run on the agent.
  • extra_args (String) — additional arguments for the command.
  • location (required) — local (the agent that reported the event), defined-agent (the agent in agent_id), or all (every agent).
  • agent_id (String) — numeric agent ID. Required when location is defined-agent.

Response

{
  "config_id": "<generated-config-id>"
}

Update a notification config

Updates an existing notification channel configuration.

  • Method: PUT
  • Path: /_plugins/_notifications/configs/{config_id}

Request body

Same structure as create. All fields in the config object are replaced.

{
  "config": {
    "name": "updated-slack-channel",
    "description": "Updated description",
    "config_type": "slack",
    "is_enabled": true,
    "slack": {
      "url": "https://hooks.slack.com/services/T00000000/B00000000/YYYYYYYY"
    }
  }
}

Response

{
  "config_id": "<config-id>"
}

Get a notification config

Retrieves a specific notification configuration by ID.

  • Method: GET
  • Path: /_plugins/_notifications/configs/{config_id}

Response

{
  "start_index": 0,
  "total_hits": 1,
  "total_hit_relation": "eq",
  "config_list": [
    {
      "config_id": "<config-id>",
      "last_updated_time_ms": 1234567890,
      "created_time_ms": 1234567890,
      "config": {
        "name": "my-slack-channel",
        "description": "Slack notifications for alerts",
        "config_type": "slack",
        "is_enabled": true,
        "slack": {
          "url": "https://hooks.slack.com/services/..."
        }
      }
    }
  ]
}

List notification configs

Retrieves notification configurations with filtering, sorting, and pagination.

  • Method: GET
  • Path: /_plugins/_notifications/configs

Query parameters

  • config_id (String) — filter by a single config ID.
  • config_id_list (String) — comma-separated list of config IDs.
  • from_index (Integer, default 0) — pagination offset.
  • max_items (Integer, default 100) — maximum items to return.
  • sort_field (String) — field to sort by (e.g., config_type, name, last_updated_time_ms).
  • sort_order (String) — sort order: asc or desc.
  • config_type (String) — filter by channel type (e.g., slack,email).
  • is_enabled (Boolean) — filter by enabled status.
  • name (String) — filter by name (text search).
  • description (String) — filter by description (text search).
  • last_updated_time_ms (String) — range filter (e.g., 1609459200000..1640995200000).
  • created_time_ms (String) — range filter.
  • slack.url (String) — filter by Slack webhook URL (text search).
  • chime.url (String) — filter by Chime webhook URL.
  • microsoft_teams.url (String) — filter by Teams webhook URL.
  • webhook.url (String) — filter by custom webhook URL.
  • smtp_account.host (String) — filter by SMTP host.
  • smtp_account.from_address (String) — filter by SMTP from address.
  • smtp_account.method (String) — filter by SMTP method (ssl, start_tls, none).
  • sns.topic_arn (String) — filter by SNS topic ARN.
  • sns.role_arn (String) — filter by SNS role ARN.
  • ses_account.region (String) — filter by SES region.
  • ses_account.role_arn (String) — filter by SES role ARN.
  • ses_account.from_address (String) — filter by SES from address.
  • email.email_account_id (String) — filter by the sender account config ID of email channels.
  • email.email_group_id_list (String) — filter by email group config ID.
  • email.recipient_list.recipient (String) — filter by a recipient of email channels (text search).
  • email_group.recipient_list.recipient (String) — filter by a recipient of email_group configs (text search).
  • active_response.type (String) — filter by Active Response type (stateless, stateful).
  • active_response.executable (String) — filter by Active Response executable.
  • active_response.extra_args (String) — filter by Active Response extra arguments.
  • active_response.location (String) — filter by Active Response location (local, defined-agent, all).
  • active_response.agent_id (String) — filter by Active Response target agent ID.
  • active_response.stateful_timeout (String) — filter by Active Response stateful timeout.
  • query (String) — search across all keyword and text filter fields.
  • text_query (String) — search across text filter fields only.

Text filters also accept a .keyword suffix for an exact match, for example name.keyword or active_response.executable.keyword. The two recipient_list.recipient filters are the exception: they accept only the text form.

Example

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD \
  "https://127.0.0.1:9200/_plugins/_notifications/configs?config_type=slack&max_items=10&sort_order=desc"

Delete a notification config

Deletes one or more notification configurations.

  • Method: DELETE
  • Path: /_plugins/_notifications/configs/{config_id}

Or for bulk delete:

  • Method: DELETE
  • Path: /_plugins/_notifications/configs?config_id_list=id1,id2,id3

Response

{
  "delete_response_list": {
    "<config-id>": "OK"
  }
}

Channels

List notification channels

Returns a simplified list of all configured notification channels (ID, name, type, and enabled status).

  • Method: GET
  • Path: /_plugins/_notifications/channels

Response

{
  "start_index": 0,
  "total_hits": 1,
  "total_hit_relation": "eq",
  "channel_list": [
    {
      "config_id": "<id>",
      "name": "my-slack-channel",
      "description": "Slack notifications for alerts",
      "config_type": "slack",
      "is_enabled": true
    }
  ]
}

Features

Get plugin features

Returns the notification features and allowed config types supported by the plugin.

  • Method: GET
  • Path: /_plugins/_notifications/features

Response

{
  "allowed_config_type_list": [
    "slack",
    "chime",
    "microsoft_teams",
    "webhook",
    "email",
    "sns",
    "ses_account",
    "smtp_account",
    "email_group",
    "active_response"
  ],
  "plugin_features": {
    "tooltip_support": "true"
  }
}

Test notifications

Send test notification

Sends a test notification to a configured channel to validate the configuration.

  • Method: POST
  • Path: /_plugins/_notifications/feature/test/{config_id}

Note: GET is also supported for backwards compatibility but is deprecated and will be removed in a future major version.

Example

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_notifications/feature/test/<config-id>"

Response

{
  "event_source": {
    "title": "Test Message Title-<config-id>",
    "reference_id": "<config-id>",
    "severity": "info",
    "tags": []
  },
  "status_list": [
    {
      "config_id": "<config-id>",
      "config_type": "slack",
      "config_name": "my-slack-channel",
      "email_recipient_status": [],
      "delivery_status": {
        "status_code": "200",
        "status_text": "ok"
      }
    }
  ]
}

Summary table

EndpointMethodDescription
/_plugins/_notifications/configsPOSTCreate a new notification channel.
/_plugins/_notifications/configs/{id}PUTUpdate an existing notification channel.
/_plugins/_notifications/configs/{id}GETGet a specific notification channel.
/_plugins/_notifications/configsGETList/search notification channels with filters.
/_plugins/_notifications/configs/{id}DELETEDelete a notification channel.
/_plugins/_notifications/configsDELETEBulk delete (with config_id_list param).
/_plugins/_notifications/channelsGETList all channels (simplified view).
/_plugins/_notifications/featuresGETGet supported features and config types.
/_plugins/_notifications/feature/test/{id}POSTSend a test notification.

Troubleshooting

Common issues and solutions when working with the Notifications plugin.


Channel configuration issues

Slack notifications are not delivered

Symptoms: Creating a Slack config succeeds, but test notifications fail with a non-200 status.

Possible causes

  1. Invalid webhook URL. Verify the Incoming Webhook URL is active in your Slack workspace settings.
  2. Host deny list. Check if the Slack domain is included in opensearch.notifications.core.http.host_deny_list.
  3. Network connectivity. The Wazuh Indexer node must have outbound HTTPS access to hooks.slack.com.

Resolution

# Verify the config
curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD \
  "https://127.0.0.1:9200/_plugins/_notifications/configs/<config-id>"

# Send a test notification
curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_notifications/feature/test/<config-id>"

Check the delivery_status in the response for the HTTP status code and error message.


Webhook to an internal host is denied

Symptoms: A webhook-based channel (Slack, Chime, Microsoft Teams or custom webhook) that targets a host on your network fails with status 400 and Host of url is denied, based on plugin setting [notification.core.http.host_deny_list].

Cause: By default, opensearch.notifications.core.http.host_deny_list blocks loopback, link-local, private and other reserved address ranges, so webhooks to internal services, such as a self-hosted Jira or Shuffle instance, are denied. See HTTP connection settings for the full default list.

Resolution

Set the deny list without the range your endpoint is in. The value replaces the whole default list, so keep every other range. For example, to allow 192.168.0.0/16:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT \
  "https://127.0.0.1:9200/_cluster/settings" \
  -H 'Content-Type: application/json' \
  -d '{
    "persistent": {
      "opensearch.notifications.core.http.host_deny_list": [
        "127.0.0.0/8", "169.254.0.0/16", "10.0.0.0/8", "172.16.0.0/12",
        "0.0.0.0/8", "100.64.0.0/10", "192.0.0.0/24", "192.0.2.0/24",
        "198.18.0.0/15", "192.88.99.0/24", "198.51.100.0/24", "203.0.113.0/24",
        "224.0.0.0/4", "240.0.0.0/4", "255.255.255.255/32",
        "::1/128", "fe80::/10", "fc00::/7", "::/128", "2001:db8::/32", "ff00::/8"
      ]
    }
  }'

Email delivery fails with a connection error

Symptoms: Email notifications fail with status 503 and Couldn't connect to host, or take a long time to fail.

Possible causes

  1. SMTP server unreachable. Verify the Wazuh Indexer node can reach the SMTP server on the configured port.
  2. TLS configuration mismatch. Ensure the SMTP method (none, ssl, start_tls) matches the server’s requirements.

The opensearch.notifications.core.http.* timeouts do not apply to email: they control only webhook-based channels. The plugin sets no timeout on SMTP connections, so an unresponsive server can hold up the delivery for a long time.


Webhook delivery fails with timeout

Symptoms: Notifications to a webhook-based channel fail with a timeout error.

Cause: The endpoint takes longer to accept the connection or to answer than the configured timeouts allow. The defaults are 5000 ms to connect and 50000 ms to wait for the response.

Resolution

Increase the timeouts, then restart the Wazuh Indexer on every node. The plugin builds its HTTP client once, the first time it sends a notification after the node starts, so a new timeout only takes effect after a restart.

# Increase timeouts via cluster settings
curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT \
  "https://127.0.0.1:9200/_cluster/settings" \
  -H 'Content-Type: application/json' \
  -d '{
    "persistent": {
      "opensearch.notifications.core.http.connection_timeout": 10000,
      "opensearch.notifications.core.http.socket_timeout": 120000
    }
  }'

SMTP credentials not found

Symptoms: Email delivery to an SMTP server that requires authentication fails with an authentication error from the server.

Cause: The keystore holds no username and password for the account, so the plugin connects without authenticating. The account name in the keystore key must match the name of the smtp_account configuration, and both the username and the password must be set.

Resolution

SMTP credentials must be stored in the Wazuh Indexer keystore, not in opensearch.yml. On every node, add them as the wazuh-indexer user. Running opensearch-keystore as root leaves the keystore unreadable by the Wazuh Indexer, which then fails to start.

sudo -u wazuh-indexer /usr/share/wazuh-indexer/bin/opensearch-keystore add opensearch.notifications.core.email.<account_name>.username
sudo -u wazuh-indexer /usr/share/wazuh-indexer/bin/opensearch-keystore add opensearch.notifications.core.email.<account_name>.password

Then reload the keystore. No restart is needed:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_nodes/reload_secure_settings"

If the reload reports access_denied_exception, the Wazuh Indexer cannot read the keystore. Run sudo chown root:wazuh-indexer /etc/wazuh-indexer/opensearch.keystore && sudo chmod 640 /etc/wazuh-indexer/opensearch.keystore and reload again. See Email destination secure settings.


Permission issues

“User doesn’t have backend roles configured”

Symptoms: API calls return 403 Forbidden with the message “User doesn’t have backend roles configured.”

Cause: The setting opensearch.notifications.general.filter_by_backend_roles is true, but the current user has no backend roles assigned.

Resolution

  • Assign backend roles to the user in the Security plugin, or
  • Disable RBAC filtering:
curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT \
  "https://127.0.0.1:9200/_cluster/settings" \
  -H 'Content-Type: application/json' \
  -d '{
    "persistent": {
      "opensearch.notifications.general.filter_by_backend_roles": false
    }
  }'

User cannot see other users’ configurations

Cause: When filter_by_backend_roles is enabled, users can only see configurations created by users who share at least one backend role. Users with the all_access role can see all configurations.


HTTP response size limit

Webhook response is truncated

Symptoms: The status_text of a webhook-based delivery holds only the beginning of the endpoint’s response.

Cause: The plugin reads at most half of opensearch.notifications.core.max_http_response_size, counted in characters, from a webhook response and drops the rest. The delivery itself still succeeds.

Resolution

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT \
  "https://127.0.0.1:9200/_cluster/settings" \
  -H 'Content-Type: application/json' \
  -d '{
    "persistent": {
      "opensearch.notifications.core.max_http_response_size": 209715200
    }
  }'

Logs

Enable debug logging for the Notifications plugin:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT \
  "https://127.0.0.1:9200/_cluster/settings" \
  -H 'Content-Type: application/json' \
  -d '{
    "persistent": {
      "logger.org.opensearch.notifications": "DEBUG",
      "logger.org.opensearch.notifications.core": "DEBUG"
    }
  }'

Check the Wazuh Indexer logs for entries prefixed with notifications:.

Alerting

The Wazuh Indexer Alerting plugin monitors data stored in the Wazuh Indexer, evaluates user-defined trigger conditions on a schedule, and executes actions when those conditions are met. Actions typically deliver notifications through the Notifications plugin (Slack, email, webhooks, etc.) but can also drive Wazuh-specific workflows such as Active Response.

The plugin is a fork of the OpenSearch Alerting plugin adapted for Wazuh.

Key capabilities

  • Multiple monitor types: Query-level, bucket-level, document-level, and the Wazuh-specific Active Response monitor. See Architecture for details.
  • Flexible triggers: Define conditions using the full OpenSearch query DSL, aggregation results, or per-document matching with percolate queries.
  • Notification actions: When a trigger fires, send alerts through any channel configured in the Notifications plugin — Slack, Microsoft Teams, email, custom webhooks, PagerDuty, and more.
  • Workflows: Chain multiple monitors into composite workflows for complex detection scenarios.
  • Alert lifecycle management: Track alerts through Active, Acknowledged, Completed, and Error states. Add comments to alerts for collaboration once comments are enabled with plugins.alerting.comments_enabled (off by default).
  • RBAC integration: Access to monitors, alerts, and destinations is governed by the Security plugin, with optional backend-role–based filtering (plugins.alerting.filter_by_backend_roles, off by default).
  • Cross-cluster monitoring: Monitor indices on remote clusters connected via cross-cluster search.
  • REST API: Full programmatic control over monitors, workflows, alerts, findings, and comments. See API Reference.
  • Dashboard UI: Create, manage, and monitor alerts through the Wazuh Dashboard interface.

Limits

Wazuh integration points

Ruleset Management

The Ruleset Management plugin uses alerting monitors to evaluate incoming events against Sigma detection rules. The alerting monitor drives the detection loop, periodically querying new events and running them through the configured detectors, and creating a finding for every event that matches a rule.

Notifications

Alerting actions route through the Notifications plugin for message delivery. When a trigger fires, the alerting plugin calls the Notifications plugin via its internal transport interface to send messages to configured channels. This means any destination supported by Notifications (Slack, Teams, email, webhooks, SNS) is available as an alerting action target.

Active Response

The Alerting plugin includes a Wazuh-specific Active Response monitor type that extends document-level monitoring for automated response workflows. This monitor type has specific constraints:

  • Indices: Must target indices matching the wazuh-findings-v5-* prefix.
  • Schedule: Interval schedules only (cron schedules are rejected), with a maximum interval of 1 minute (60,000 ms).
  • Triggers: Only DocumentLevelTrigger is supported.
  • Actions: Only the per_alert action execution scope is accepted. An action without an action_execution_policy defaults to per_alert.

When an Active Response monitor triggers, its actions send execution requests through an active_response notification channel, which writes them to the wazuh-active-responses data stream in batches. The Wazuh Manager retrieves documents from this data stream to distribute and execute Active Response actions on agents. Each document references the source event that triggered the response.

Each matched event produces its own request, however many events a single run matches. The plugins.alerting.max_actionable_alert_count limit does not apply to Active Response monitors, and a run that ends with an error still sends one request per matched event, logging a WARN message.

Dependencies

DependencyPurpose
Notifications pluginDelivers alert notifications to configured channels
Security pluginEnforces RBAC on monitors, alerts, and destinations
Job Scheduler pluginSchedules and executes monitors at configured intervals
wazuh-indexer-common-utilsShared utility functions and common components

Further reading

For the full upstream API reference, advanced configuration, and Dashboard usage guides, see the OpenSearch Alerting documentation.

Architecture

The Alerting plugin runs inside the Wazuh Indexer as an OpenSearch plugin. It schedules monitors that query indices, evaluates trigger conditions against the results, and executes actions (typically sending notifications) when conditions are met.

Core concepts

The alerting pipeline follows a linear flow:

  1. A Monitor runs on a schedule, executing a query against one or more indices.
  2. The query results are evaluated against one or more Triggers — boolean conditions that determine whether an alert should fire.
  3. When a trigger condition is met, the monitor executes its configured Actions — typically sending a notification through the Notifications plugin.
  4. An Alert record is created to track the triggered condition through its lifecycle.
  5. For document-level monitors, Findings record which specific documents matched the monitor’s queries. Findings are generated whether or not the monitor defines a trigger, so a monitor with an empty trigger list still produces them. They are stored only when finding storage is enabled; see Findings.

Monitor types

Monitor TypeDescriptionTrigger TypeInput Type
Query-level (per query)Executes an OpenSearch query and evaluates the aggregation results as a whole. Suitable for threshold-based alerts (e.g., error count > 100).QueryLevelTriggerSearchInput
Bucket-level (per bucket)Monitors aggregation bucket results individually. Each bucket that meets the trigger condition generates a separate alert.BucketLevelTriggerSearchInput (with aggregations)
Cluster metrics (per cluster metrics)Periodically calls OpenSearch cluster APIs (cluster health, stats, tasks, etc.) and evaluates the response. Suitable for monitoring cluster state rather than indexed data.QueryLevelTriggerClusterMetricsInput
Document-level (per document)Matches individual documents using percolate queries. Creates a finding for each matching document.DocumentLevelTriggerDocLevelMonitorInput
CompositeChains multiple monitors into a workflow and evaluates conditions across their alerts. See Workflows.ChainedAlertTriggerCompositeInput
Active ResponseWazuh-specific extension of document-level monitoring for automated response. See Active Response.DocumentLevelTriggerDocLevelMonitorInput

Active Response monitor constraints

The Active Response monitor type enforces stricter validation than standard document-level monitors:

  • Indices must match the wazuh-findings-v5-* prefix.
  • The schedule must be an interval schedule (cron schedules are rejected), and the interval cannot exceed 60,000 milliseconds (1 minute).
  • Only DocumentLevelTrigger is accepted — other trigger types are rejected.
  • Actions must use the per_alert execution scope. A per_execution action is rejected, because each Active Response message carries a single <doc_id>|<index> reference and would answer only the first alert of a run.

At run time, actions of an Active Response monitor always run once per alert. Unlike other monitor types, they never fall back to a single execution for the whole run when the run exceeds plugins.alerting.max_actionable_alert_count or ends with an error.

Triggers

Each monitor type uses a corresponding trigger type:

  • QueryLevelTrigger: Evaluates a script condition against the full query response. The script has access to the query results, aggregations, and monitor metadata.
  • BucketLevelTrigger: Evaluates a condition per aggregation bucket. Supports composite aggregations for paginating through large result sets.
  • DocumentLevelTrigger: Defines per-document matching conditions using query DSL. Documents that match the trigger’s queries generate alerts.
  • ChainedAlertTrigger: Evaluates a condition over the alerts produced by the delegate monitors of a composite (workflow) monitor, allowing alerts to fire based on combinations of upstream monitor results.

Cluster metrics monitors reuse QueryLevelTrigger, evaluating a script condition against the cluster API response.

Actions

Actions define what happens when a trigger fires. Each action specifies:

  • A destination — a notification channel configured in the Notifications plugin (Slack, email, webhook, etc.).
  • A message template — a Mustache template that formats the alert details into the notification body.
  • An optional throttle — a minimum interval between repeated notifications for the same alert (up to plugins.alerting.action_throttle_max_value, default 24 hours).

When a trigger fires, the plugin calls the Notifications plugin via its internal transport interface to deliver the message.

Alert lifecycle

Alerts transition through the following states:

StateDescription
ActiveThe trigger condition is currently met. The alert was just created or continues to fire.
AcknowledgedA user has acknowledged the alert through the Dashboard or API.
CompletedThe trigger condition is no longer met. The alert resolved naturally.
ErrorAn error occurred during monitor execution or action delivery.

Findings

Document-level monitors produce findings — records of individual documents that matched the monitor’s queries. Each finding contains:

  • The matching document IDs and source index.
  • The queries (rules) that matched.
  • A timestamp of when the match was detected.

Findings are stored only when plugins.alerting.alert_finding_enabled is true, which is off by default. Stored findings go to rolling indices (.opensearch-alerting-finding-history-*) and are deleted after 60 days by default; see Alerting indices.

These raw alerting findings are not the same as the findings surfaced in the Wazuh context. Detectors managed by the Ruleset Management plugin run on document-level monitors internally, but produce their own enriched findings — augmented with the full event payload and rule metadata — which are written to wazuh-findings-v5-* indices. A plain document-level monitor only produces the raw findings described above; it does not perform this enrichment.

Workflows

Workflows chain multiple monitors into a composite execution unit. A workflow defines an ordered sequence of monitors (delegates) that run together. This enables multi-stage detection scenarios where the output of one monitor informs the next.

Workflows have their own CRUD API and can be executed and managed independently of individual monitors. There is no separate workflow search endpoint: workflows are stored with the monitors and returned by the monitor search API.

Alerting indices

The plugin manages the following system indices:

IndexDescriptionRetention
.opendistro-alerting-alertsCurrent active alerts—
.opendistro-alerting-alert-history-*Historical alert records. Created only when plugins.alerting.alert_history_enabled is true (off by default)60 days
.opensearch-alerting-finding-history-*Document-level monitor findings. Created only when plugins.alerting.alert_finding_enabled is true (off by default)60 days
.opensearch-alerting-comments-history-*Alert comments and annotations. Used only when plugins.alerting.comments_enabled is true (off by default)60 days
.opendistro-alerting-configMonitor and workflow definitions—
.opensearch-alerting-queries*Queries of document-level monitors, prepared for matching—
.opensearch-alerting-config-lockShort-lived locks that keep a monitor from running on two nodes at once—

Every 12 hours (the rollover period), the plugin checks each history index. It rolls the current index over to a new one if it holds 1,000 documents or is older than 30 days, and deletes the history indices that are older than 60 days. Alert and finding history only roll over while their setting (alert_history_enabled, alert_finding_enabled) is on, but the deletion of old indices runs either way; while the setting is on, the current write index is never deleted. Comments history rolls over and is pruned whether or not comments are enabled. The two limits do different things: max_age (30 days) only decides when a new index is started, while retention_period (60 days) decides when data is deleted.

Rollover periods, rollover limits and retention are configurable through plugin settings.

Configuration

The Alerting plugin is configured through cluster settings under the plugins.alerting.* namespace. All settings can be updated dynamically via the cluster settings API.

Monitor settings

  • plugins.alerting.monitor.max_monitors (Integer, default 10, minimum 0, no upper bound) — maximum number of monitors in the cluster. Monitors created by Ruleset Management detectors don’t count toward this limit.
  • plugins.alerting.monitor.max_triggers (Integer, default 10, hard max 50) — maximum number of triggers per monitor.
  • plugins.alerting.monitor.doc_level_monitor_shard_fetch_size (Integer, default 10000) — number of documents fetched per shard for document-level monitors.
  • plugins.alerting.monitor.doc_level_monitor_fan_out_nodes (Integer, default 1000) — maximum number of nodes to fan out document-level monitor queries to.
  • plugins.alerting.monitor.doc_level_monitor_fanout_max_duration (TimeValue, default 3m) — maximum duration for fan-out operations in document-level monitors.
  • plugins.alerting.monitor.doc_level_monitor_execution_max_duration (TimeValue, default 4m) — maximum total execution duration for document-level monitors.
  • plugins.alerting.monitor.percolate_query_max_num_docs_in_memory (Integer, default 50000) — maximum number of documents held in memory for percolate queries.
  • plugins.alerting.monitor.percolate_query_docs_size_memory_percentage_limit (Integer, default 10) — maximum percentage of JVM heap used for percolate query documents.
  • plugins.alerting.monitor.doc_level_monitor_query_field_names_enabled (Boolean, default true) — enable field name extraction for document-level monitor queries.

Timeout settings

  • plugins.alerting.input_timeout (TimeValue, default 30s) — timeout for monitor input (query) execution.
  • plugins.alerting.index_timeout (TimeValue, default 60s) — timeout for index operations (writing alerts, findings).
  • plugins.alerting.bulk_timeout (TimeValue, default 120s) — timeout for bulk index operations.
  • plugins.alerting.request_timeout (TimeValue, default 10s) — timeout for internal transport requests.

Alert history settings

  • plugins.alerting.alert_history_enabled (Boolean, default false) — keep completed alerts in the alert history indices. While false, an alert that completes is deleted together with its comments, and no alert history index is created.
  • plugins.alerting.alert_history_rollover_period (TimeValue, default 12h) — how often the plugin checks whether the alert history index must roll over, and deletes alert history indices older than the retention period.
  • plugins.alerting.alert_history_max_age (TimeValue, default 30d) — age at which the current alert history index is rolled over to a new one. It doesn’t delete anything; deletion is governed by alert_history_retention_period.
  • plugins.alerting.alert_history_max_docs (Long, default 1000) — number of documents at which the current alert history index is rolled over to a new one.
  • plugins.alerting.alert_history_retention_period (TimeValue, default 60d) — alert history indices older than this are deleted.
  • plugins.alerting.alert_backoff_millis (TimeValue, default 50ms) — backoff interval between alert write retries.
  • plugins.alerting.alert_backoff_count (Integer, default 2) — number of retry attempts for failed alert writes.
  • plugins.alerting.move_alerts_backoff_millis (TimeValue, default 250ms) — backoff interval between retries when moving alerts between indices.
  • plugins.alerting.move_alerts_backoff_count (Integer, default 3) — number of retry attempts when moving alerts between indices.
  • plugins.alerting.max_actionable_alert_count (Long, default 50) — maximum number of alerts that can trigger per_alert actions in a single monitor execution. Above it, the action runs once for the whole execution instead of once per alert; -1 removes the limit. Does not apply to Active Response monitors, which always run their actions once per alert.

Finding history settings

  • plugins.alerting.alert_finding_enabled (Boolean, default false) — store findings in the finding history indices. While false, document-level monitors still generate findings, but they aren’t stored: the findings API returns none and no finding history index is created. The enriched findings that Ruleset Management writes to wazuh-findings-v5-* are not affected.
  • plugins.alerting.alert_finding_rollover_period (TimeValue, default 12h) — how often the plugin checks whether the finding history index must roll over, and deletes finding history indices older than the retention period.
  • plugins.alerting.finding_history_max_age (TimeValue, default 30d) — age at which the current finding history index is rolled over to a new one. It doesn’t delete anything; deletion is governed by finding_history_retention_period.
  • plugins.alerting.alert_finding_max_docs (Long, default 1000, deprecated) — number of documents at which the current finding history index is rolled over to a new one. The setting is deprecated but still applies.
  • plugins.alerting.alert_findings_indexing_batch_size (Integer, default 1000) — batch size for bulk-indexing findings.
  • plugins.alerting.finding_history_retention_period (TimeValue, default 60d) — finding history indices older than this are deleted.

Comment settings

  • plugins.alerting.comments_enabled (Boolean, default false) — enable the alert comments feature. While false, every comments API request is rejected with 403 Forbidden.
  • plugins.alerting.comments_history_max_docs (Long, default 1000) — number of documents at which the current comments history index is rolled over to a new one.
  • plugins.alerting.comments_history_max_age (TimeValue, default 30d) — age at which the current comments history index is rolled over to a new one. It doesn’t delete anything; deletion is governed by comments_history_retention_period.
  • plugins.alerting.comments_history_rollover_period (TimeValue, default 12h) — how often the plugin checks whether the comments history index must roll over, and deletes comments history indices older than the retention period.
  • plugins.alerting.comments_history_retention_period (TimeValue, default 60d) — comments history indices older than this are deleted.
  • plugins.alerting.max_comment_character_length (Long, default 2000) — maximum character length for a single comment.
  • plugins.alerting.max_comments_per_alert (Long, default 500) — maximum number of comments allowed per alert.
  • plugins.alerting.max_comments_per_notification (Integer, default 3) — maximum number of comments included in alert notification messages.

General settings

  • plugins.alerting.filter_by_backend_roles (Boolean, default false) — restrict monitors, workflows, alerts and findings to users who share a backend role with their creator. While false, every user with Alerting permissions sees every monitor and alert. Enabling it changes who can see existing monitors: users without the all_access role then only see those created by a user who shares at least one of their backend roles, and users with no backend role get 403 Forbidden when creating, reading or deleting monitors and workflows.
  • plugins.alerting.action_throttle_max_value (TimeValue, default 24h) — maximum throttle duration for alert actions.
  • plugins.alerting.cross_cluster_monitoring_enabled (Boolean, default true) — enable monitoring of indices on remote clusters via cross-cluster search.
  • plugins.alerting.notification_context_results_allowed_roles (List<String>, default []) — roles whose monitors may include the query results (ctx.results) in action messages. While the setting is unset, every monitor’s messages can include them. Once it is set, only monitors created by a user who holds one of the listed roles can; for all other monitors the results are empty.

Updating settings

All settings can be updated at runtime through the cluster settings API:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X PUT \
  "https://127.0.0.1:9200/_cluster/settings" \
  -H 'Content-Type: application/json' \
  -d '{
    "persistent": {
      "plugins.alerting.monitor.max_monitors": 5,
      "plugins.alerting.alert_history_enabled": true
    }
  }'

API reference

The Alerting plugin exposes a REST API under the /_plugins/_alerting/ base path. This page summarizes the available endpoints. For full request/response schemas, see the OpenSearch Alerting API documentation.

Sections

Endpoint summary

Monitors

MethodEndpointDescription
POST/_plugins/_alerting/monitorsCreate a monitor
PUT/_plugins/_alerting/monitors/{id}Update a monitor
GET/_plugins/_alerting/monitors/{id}Get a monitor by ID
DELETE/_plugins/_alerting/monitors/{id}Delete a monitor
GET, POST/_plugins/_alerting/monitors/_searchSearch monitors and workflows
POST/_plugins/_alerting/monitors/{id}/_executeExecute a monitor immediately
POST/_plugins/_alerting/monitors/_executeExecute a monitor definition sent in the request body, without saving it

Workflows

MethodEndpointDescription
POST/_plugins/_alerting/workflowsCreate a workflow
PUT/_plugins/_alerting/workflows/{id}Update a workflow
GET/_plugins/_alerting/workflows/{id}Get a workflow by ID
DELETE/_plugins/_alerting/workflows/{id}Delete a workflow
POST/_plugins/_alerting/workflows/{id}/_executeExecute a workflow immediately

There is no separate workflow search endpoint. Workflows are returned by /_plugins/_alerting/monitors/_search.

Alerts

MethodEndpointDescription
GET/_plugins/_alerting/monitors/alertsList alerts across all monitors. Filter by monitor with the monitorId query parameter
GET/_plugins/_alerting/workflows/alertsList workflow alerts. Filter by workflow with the workflowIds query parameter
POST/_plugins/_alerting/monitors/{id}/_acknowledge/alertsAcknowledge one or more alerts of a monitor
POST/_plugins/_alerting/workflows/{id}/_acknowledge/alertsAcknowledge one or more alerts of a workflow

Findings

MethodEndpointDescription
GET/_plugins/_alerting/findings/_searchSearch the findings of document-level monitors. Get a single finding with the findingId query parameter

Only stored findings are returned, and findings are stored only when plugins.alerting.alert_finding_enabled is true (off by default). See Configuration.

Comments

MethodEndpointDescription
POST/_plugins/_alerting/comments/{alertId}Add a comment to an alert
PUT/_plugins/_alerting/comments/{commentId}Update a comment
DELETE/_plugins/_alerting/comments/{commentId}Delete a comment
GET, POST/_plugins/_alerting/comments/_searchSearch comments. The query goes in the request body

Comments are disabled by default: every comments endpoint returns 403 Forbidden until plugins.alerting.comments_enabled is set to true. See Configuration.

Destinations (legacy)

MethodEndpointDescription
GET/_plugins/_alerting/destinationsList destinations
GET/_plugins/_alerting/destinations/{id}Get a destination by ID
GET/_plugins/_alerting/destinations/email_accounts/{id}Get an email account by ID
GET, POST/_plugins/_alerting/destinations/email_accounts/_searchSearch email accounts
GET/_plugins/_alerting/destinations/email_groups/{id}Get an email group by ID
GET, POST/_plugins/_alerting/destinations/email_groups/_searchSearch email groups

Note: Destination management has been migrated to the Notifications plugin. Use the Notifications API (/_plugins/_notifications/) for creating and managing notification channels.

Statistics and remote indices

MethodEndpointDescription
GET/_plugins/_alerting/statsMonitor scheduling statistics for every node
GET/_plugins/_alerting/stats/{metric}A single statistics metric: job_scheduling_metrics or jobs_info
GET/_plugins/_alerting/{nodeId}/statsMonitor scheduling statistics for the given nodes
GET/_plugins/_alerting/{nodeId}/stats/{metric}A single statistics metric for the given nodes
GET/_plugins/_alerting/remote/indexesList the indices, with their health, that match the patterns in the indexes query parameter on the local and remote clusters (<cluster>:<pattern>), for cross-cluster monitoring

Examples

Create a query-level monitor

This example creates a monitor that checks every 5 minutes whether the number of error-level events (log.level is error) in the last hour exceeds 100:

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_alerting/monitors" \
  -H 'Content-Type: application/json' \
  -d '{
    "type": "monitor",
    "name": "High error rate",
    "monitor_type": "query_level_monitor",
    "enabled": true,
    "schedule": {
      "period": {
        "interval": 5,
        "unit": "MINUTES"
      }
    },
    "inputs": [
      {
        "search": {
          "indices": ["wazuh-events-v5-*"],
          "query": {
            "size": 0,
            "query": {
              "bool": {
                "filter": [
                  { "range": { "@timestamp": { "gte": "now-1h" } } },
                  { "term": { "log.level": "error" } }
                ]
              }
            },
            "aggs": {
              "error_count": {
                "value_count": { "field": "@timestamp" }
              }
            }
          }
        }
      }
    ],
    "triggers": [
      {
        "query_level_trigger": {
          "name": "Error threshold exceeded",
          "severity": "1",
          "condition": {
            "script": {
              "source": "ctx.results[0].aggregations.error_count.value > 100",
              "lang": "painless"
            }
          },
          "actions": []
        }
      }
    ]
  }'

To test a monitor definition without saving it, send the same request body to POST /_plugins/_alerting/monitors/_execute?dryrun=true. The response shows the query results and whether each trigger fired.

Acknowledge alerts

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_alerting/monitors/{monitorId}/_acknowledge/alerts" \
  -H 'Content-Type: application/json' \
  -d '{
    "alerts": ["alert-id-1", "alert-id-2"]
  }'

Execute a monitor on-demand

curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD -X POST \
  "https://127.0.0.1:9200/_plugins/_alerting/monitors/{monitorId}/_execute"

Upgrade

This section guides you through the upgrade process of the Wazuh indexer.

The Wazuh indexer cluster remains operational throughout the upgrade. The rolling upgrade process allows nodes to be updated one at a time, ensuring continuous service availability and minimizing disruptions. The steps detailed in the following sections apply to both single-node and multi-node Wazuh indexer clusters. For multi-node Wazuh indexer clusters, repeat the following steps on every node.

This procedure upgrades a 5.x cluster. A 4.x cluster cannot be upgraded to 5.x: moving to 5.x is a migration to a new cluster. See Migration guide.

Note: This documentation assumes you are already provisioned with a wazuh-indexer package through any of the possible methods:

Preparing the upgrade

Perform the following steps on any of the Wazuh indexer nodes replacing $WAZUH_INDEXER_IP_ADDRESS, $USERNAME, and $PASSWORD.

  1. Disable shard replication to prevent shard replicas from being created while Wazuh indexer nodes are being taken offline for the upgrade.

    curl -X PUT "https://$WAZUH_INDEXER_IP_ADDRESS:9200/_cluster/settings" \
    -u $USERNAME:$PASSWORD -k -H "Content-Type: application/json" -d '
    {
        "persistent": {
            "cluster.routing.allocation.enable": "primaries"
        }
    }'
    

    Output

    {
      "acknowledged": true,
      "persistent": {
        "cluster": {
          "routing": {
            "allocation": {
              "enable": "primaries"
            }
          }
        }
      },
      "transient": {}
    }
    
  2. Perform a flush operation on the cluster to commit transaction log entries to the index.

    curl -X POST "https://$WAZUH_INDEXER_IP_ADDRESS:9200/_flush" -u $USERNAME:$PASSWORD -k
    

    Output

    {
    "_shards" : {
        "total" : 19,
        "successful" : 19,
        "failed" : 0
       }
    }
    

Upgrading the Wazuh indexer nodes

  1. Stop the Wazuh indexer service.

    Systemd

    systemctl stop wazuh-indexer
    

    SysV

    service wazuh-indexer stop
    
  2. Upgrade the Wazuh indexer to the latest version.

    rpm

    rpm -ivh --replacepkgs wazuh-indexer-<VERSION>.rpm
    

    dpkg

    dpkg -i wazuh-indexer-<VERSION>.deb
    
  3. Restart the Wazuh indexer service.

    Systemd

    systemctl enable --now wazuh-indexer
    

    SysV

    Choose one option according to the operating system used.

    a. RPM-based operating system:

    chkconfig --add wazuh-indexer
    service wazuh-indexer start
    

    b. Debian-based operating system:

    update-rc.d wazuh-indexer defaults 95 10
    service wazuh-indexer start
    

Repeat steps 1 to 3 above on all Wazuh indexer nodes before proceeding to the post-upgrade actions.

Post-upgrade actions

Perform the following steps on any of the Wazuh indexer nodes replacing $WAZUH_INDEXER_IP_ADDRESS, $USERNAME, and $PASSWORD.

  1. Check that the newly upgraded Wazuh indexer nodes are in the cluster.

    curl -k -u $USERNAME:$PASSWORD https://$WAZUH_INDEXER_IP_ADDRESS:9200/_cat/nodes?v
    
  2. Re-enable shard allocation.

    curl -X PUT "https://$WAZUH_INDEXER_IP_ADDRESS:9200/_cluster/settings" \
    -u $USERNAME:$PASSWORD -k -H "Content-Type: application/json" -d '
    {
        "persistent": {
            "cluster.routing.allocation.enable": "all"
        }
    }
    '
    

    Output

    {
        "acknowledged" : true,
        "persistent" : {
            "cluster" : {
            "routing" : {
                "allocation" : {
                "enable" : "all"
                }
            }
            }
        },
        "transient" : {}
    }
    
  3. Check the status of the Wazuh indexer cluster again to see if the shard allocation has finished.

    curl -k -u $USERNAME:$PASSWORD https://$WAZUH_INDEXER_IP_ADDRESS:9200/_cat/nodes?v
    

    Output

    ip         heap.percent ram.percent cpu load_1m load_5m load_15m node.role node.roles                                        cluster_manager name
    172.18.0.3           34          86  32    6.67    5.30     2.53 dimr      cluster_manager,data,ingest,remote_cluster_client -               wazuh2.indexer
    172.18.0.4           21          86  32    6.67    5.30     2.53 dimr      cluster_manager,data,ingest,remote_cluster_client *               wazuh1.indexer
    172.18.0.2           16          86  32    6.67    5.30     2.53 dimr      cluster_manager,data,ingest,remote_cluster_client -               wazuh3.indexer
    

Uninstall

Note: You need root user privileges to run all the commands described below.

Removing the package stops the Wazuh indexer and deletes its program files. What happens to the configuration, the credentials and the data depends on how you remove it.

Remove or purge

On DEB systems there are two ways to remove the package:

  • Remove keeps the configuration, the credentials, the certificates, the logs, the indexed data and the wazuh-indexer user. Use it when you intend to install the Wazuh indexer again on this host.

    apt-get remove wazuh-indexer -y
    
  • Purge also removes the configuration files, the Wazuh indexer’s credentials, and the wazuh-indexer user and group. See What a purge keeps for what stays on disk.

    apt-get purge wazuh-indexer -y
    

On RPM systems, removing the package always does what a purge does:

yum remove wazuh-indexer -y

What a purge keeps

A purge never deletes indexed data. It manages the package’s own directories, and only these:

  • /etc/wazuh-indexer/ — the configuration, including the keystore
  • /usr/share/wazuh-indexer/ — the installation
  • /var/lib/wazuh-indexer/ — the indexed data
  • /var/log/wazuh-indexer/ — the logs

Before it removes the wazuh-indexer user and group, the purge makes root the owner of what the Wazuh indexer left in these directories, and closes each directory to everyone but root. The files inside keep their permissions. The user’s ID is then free for the system to give to another account, but that account cannot read anything left there.

The purge lists every directory it kept that still holds files:

Kept /var/lib/wazuh-indexer, now owned by root. Reinstalling wazuh-indexer takes it back.

If a file cannot be given to root, for example because the directory is on a read-only file system, the purge names the directory instead of listing it as kept, and still removes the user and group:

Some files under /var/log/wazuh-indexer could not be handed over to root; they keep the ID of the removed wazuh-indexer user.

Give those files to root yourself once the file system is writable, and close the directory:

chown -R root:root /var/log/wazuh-indexer
chmod go-rwx /var/log/wazuh-indexer

Directories outside the default locations

The purge does not touch any directory set in opensearch.yml outside the four above, such as a custom path.data, path.logs or path.repo. Such a directory can be shared with the other nodes of the cluster, as a shared file system snapshot repository must be, so changing it on the node being purged could break them. Its files keep the ID of the removed wazuh-indexer user, and an account created later can be given that ID. The purge reminds you every time it runs:

Note: the package only manages /etc/wazuh-indexer, /usr/share/wazuh-indexer, /var/lib/wazuh-indexer and /var/log/wazuh-indexer.
Directories set elsewhere in opensearch.yml (a custom path.data, path.logs or path.repo) are left as they are, still owned by the ID of the removed wazuh-indexer user.

Handling these directories is up to you, before or after the purge. Delete the ones you no longer need. Give the ones only this node used, and that you want to keep, to root:

chown -R root:root /srv/indexer-data
chmod go-rwx /srv/indexer-data

Leave a snapshot repository that other nodes still use as it is.

Credentials and certificates

The purge removes what the Wazuh indexer published for the other components in /etc/wazuh-indexer/export/ (see Retrieving the generated credentials), its certificate authority (CA) in /etc/wazuh-indexer/ca/, and the node and admin certificates it issued from that CA in /etc/wazuh-indexer/certs/. Certificates that CA did not issue, such as your own, are kept, and so is /etc/wazuh-indexer/local.env. The keystore the package created at installation goes too, unless you added secrets to it, in which case it is kept.

Reinstalling after a purge

Installing the package again gives the files kept in the data and log directories back to the wazuh-indexer user, with the permissions they had before the purge, and the configuration directory back to root, read by the service through its group.

If the purge removed the certificates, the installation issues new ones. If you kept your own, the node uses them only after checking that they were issued by the CA it trusts: the one in /etc/wazuh-indexer/ca/, or, when that directory is empty, the root-ca.pem beside them in /etc/wazuh-indexer/certs/. When they were not, or when there is no CA to check them against, the installation says so and the service does not start:

resolve-credentials: the certificate pair in /etc/wazuh-indexer/certs was not issued by /etc/wazuh-indexer/ca/root-ca.pem
resolve-credentials:         stage the CA that issued it, or remove the pair so that this node issues a new one

Stage the CA that issued the pair, or remove the pair so that the node issues a new one, then start the service. There are two places to stage it:

  • Beside the pair, as /etc/wazuh-indexer/certs/root-ca.pem, owned by root:wazuh-indexer with mode 640, when /etc/wazuh-indexer/ca/ is empty. This can be the CA that issued the pair directly, even if it is not a root CA. The package publishes it in /etc/wazuh-indexer/export/ca.pem for the other Wazuh components on this host.
  • In /etc/wazuh-indexer/ca/, as root-ca.pem. It must be a self-signed root CA, the directory owned by root with mode 750 and the file owned by root with mode 640.

A directory outside the four the package manages is not given back. Set it in opensearch.yml again, then give it to the new user:

chown -R wazuh-indexer:wazuh-indexer /srv/indexer-data

Deleting everything

To remove the Wazuh indexer and all its data for good, purge the package, then delete the directories it kept, and any directory you set in opensearch.yml that no other node uses:

apt-get purge wazuh-indexer -y   # or: yum remove wazuh-indexer -y
rm -rf /etc/wazuh-indexer/ /usr/share/wazuh-indexer/ /var/lib/wazuh-indexer/ /var/log/wazuh-indexer/

This cannot be undone: it deletes the indexed data and the certificates’ private keys.

Back up and restore

In this section you can find instructions on how to create and restore a backup of your Wazuh Indexer key files, preserving file permissions, ownership, and path. Later, you can move this folder contents back to the corresponding location to restore your certificates and configurations. Backing up these files is useful in cases such as moving your Wazuh installation to another system.

Note: This backup only restores the configuration files, not the data. To back up data stored in the indexer, use snapshots.

Creating a backup

To create a backup of the Wazuh indexer, follow these steps. Repeat them on every cluster node you want to back up.

Note: You need root user privileges to run all the commands described below.

Preparing the backup

  1. Back up the existing Wazuh indexer security configuration files.

    /usr/share/wazuh-indexer/bin/indexer-security-init.sh --options "-backup /etc/wazuh-indexer/opensearch-security -icl -nhnv"
    
  2. Create the destination folder to store the files. For version control, add the date and time of the backup to the name of the folder.

    backup_folder=~/wazuh_files_backup/$(date +%F_%H:%M)
    mkdir -p $backup_folder && echo $backup_folder
    
  3. Save the host information.

    cat /etc/*release* > $backup_folder/host-info.txt
    echo -e "\n$(hostname): $(hostname -I)" >> $backup_folder/host-info.txt
    

Backing up the Wazuh indexer

Back up the Wazuh indexer certificates and configuration

rsync -aREz \
/etc/wazuh-indexer/certs/ \
/etc/wazuh-indexer/jvm.options \
/etc/wazuh-indexer/jvm.options.d \
/etc/wazuh-indexer/log4j2.properties \
/etc/wazuh-indexer/opensearch.yml \
/etc/wazuh-indexer/opensearch.keystore \
/etc/wazuh-indexer/opensearch-security/ \
/etc/wazuh-indexer/wazuh-indexer-reports-scheduler/ \
/etc/wazuh-indexer/wazuh-indexer-notifications/ \
/etc/wazuh-indexer/wazuh-indexer-notifications-core/ \
/etc/wazuh-indexer/ca/ \
/etc/wazuh-indexer/export/ \
/usr/lib/sysctl.d/wazuh-indexer.conf $backup_folder

ca/ holds the certificate authority that signed the node’s certificates, including its private key when the package created it, and export/ the passwords and the CA certificate the node published for the Wazuh manager and the Wazuh dashboard. Back up /etc/wazuh-indexer/local.env too if you created it.

Compress the files and transfer them to the new server. The archive contains private keys, so store and transfer it securely:

tar -cvzf wazuh-indexer-backup.tar.gz $backup_folder

Restoring Wazuh indexer from backup

This guide explains how to restore a backup of your configuration files.

Note: This guide is designed specifically for restoration from a backup of the same version.


Note: For a multi-node setup, there should be a backup file for each node within the cluster. You need root user privileges to execute the commands below.

Preparing the data restoration

  1. In the new node, move the compressed backup file to the root / directory:

    mv wazuh-indexer-backup.tar.gz /
    cd /
    
  2. Decompress the backup files and change the current working directory to the directory based on the date and time of the backup files. Replace <DATE_TIME> with the date and time in the name of the backup folder:

    tar -xzvf wazuh-indexer-backup.tar.gz
    cd ~/wazuh_files_backup/<DATE_TIME>
    

Restoring Wazuh indexer files

Perform the following steps to restore the Wazuh indexer files on the new server.

Note: The restored opensearch.yml binds the node to the old server’s address, and the restored certificates were issued for the old server’s hostname and IP addresses, both recorded in host-info.txt. This procedure assumes the new server takes them over.

  1. Stop the Wazuh indexer to prevent any modifications to the Wazuh indexer files during the restoration process:

    systemctl stop wazuh-indexer
    
  2. Restore the Wazuh indexer configuration files and change the file permissions and ownership accordingly:

    cp -rp etc/wazuh-indexer/. /etc/wazuh-indexer/
    cp -p usr/lib/sysctl.d/wazuh-indexer.conf /usr/lib/sysctl.d/wazuh-indexer.conf
    
    chown -R root:wazuh-indexer /etc/wazuh-indexer
    find /etc/wazuh-indexer -type d -exec chmod 750 {} +
    find /etc/wazuh-indexer -type f -exec chmod 640 {} +
    chown root:root /etc/wazuh-indexer/certs/admin.pem /etc/wazuh-indexer/certs/admin-key.pem
    chmod 600 /etc/wazuh-indexer/certs/admin.pem /etc/wazuh-indexer/certs/admin-key.pem
    chown root:root /etc/wazuh-indexer/ca/root-ca.key /etc/wazuh-indexer/export/credentials.env /etc/wazuh-indexer/export/*-client*.pem
    chmod 400 /etc/wazuh-indexer/ca/root-ca.key
    chmod 600 /etc/wazuh-indexer/export/credentials.env /etc/wazuh-indexer/export/*-client*.pem /etc/wazuh-indexer/ca/.wazuh-indexer-bootstrap-ca
    chown root:root /etc/wazuh-indexer/local.env /etc/wazuh-indexer/.wazuh-indexer-keystore 2>/dev/null
    chmod 600 /etc/wazuh-indexer/local.env /etc/wazuh-indexer/.wazuh-indexer-keystore 2>/dev/null
    chown root:root /usr/lib/sysctl.d/wazuh-indexer.conf
    

    The configuration directory belongs to root: the Wazuh indexer reads it through its group and writes nothing in it. The admin certificate pair, the CA private key, the published passwords and client pairs, local.env and the keystore marker are root’s alone; the service refuses to start while local.env is readable by anyone else.

    The first command copies everything the backup saved under /etc/wazuh-indexer/ over the existing files: the certificates, opensearch.yml, the JVM options, log4j2.properties, the keystore, the security configuration, the plugin configuration directories, the certificate authority in ca/ and what the node published in export/. Copying the contents of the directory (etc/wazuh-indexer/.) matters: the package already created these directories on the new server, and cp -r of a directory onto an existing one nests the copy inside it (certs/certs), where the Wazuh indexer never reads it.

    Note: the sysctl drop-in stays root-owned, as the package ships it. It is applied by systemd-sysctl as root, so a copy the service account can edit would let that account set kernel parameters.

  3. Start the Wazuh indexer service:

    systemctl start wazuh-indexer
    
  4. Load the restored security configuration into the cluster. Until you do, the restored files under /etc/wazuh-indexer/opensearch-security/ are not used: the cluster keeps the users, roles and role mappings it already had.

    /usr/share/wazuh-indexer/bin/indexer-security-init.sh
    

    In a multi-node cluster, run it once, on any node, after every node has been restored and started.

    Note: From this point on, the internal users have the passwords they had on the old server, and the restored /etc/wazuh-indexer/export/credentials.env lists them. If you did not restore export/, the passwords it lists are the ones generated when the package was installed on the new server, and no longer work: the Wazuh Manager and the Wazuh Dashboard on this host read that file, so put the old server’s passwords there before installing or starting either of them.

  5. Clear the backup files to free up space:

    rm -rf ~/wazuh_files_backup/<DATE_TIME>
    rm -f /wazuh-indexer-backup.tar.gz
    

Security

Wazuh Indexer uses the OpenSearch Security plugin to manage access control and security features.

Configuration files

The configuration files for the security plugin are located under the /etc/wazuh-indexer/opensearch-security/ directory by default.

Modifying these files directly is not recommended. Instead, use the Wazuh Dashboard Security plugin to create new security resources. See Defining users and roles.

Among these files, Wazuh Indexer uses these particularly to add its own security resources:

  • internal_users.yml: Defines the internal users for the Wazuh Indexer. Each user has a hashed password, reserved status, backend roles, and a description. The hashes are generated during installation and are unique to each deployment.

  • roles.yml: Defines the roles and their permissions within the Wazuh Indexer. Each role specifies the cluster permissions, index permissions, and tenant permissions.

  • roles_mapping.yml: Maps users and backend roles to the defined roles. This file specifies which users or backend roles have access to each role.

  • action_groups.yml: Defines named groups of permissions. Wazuh Indexer adds the action groups that name the Content Manager and Setup plugin permissions, listed in Permissions.

The Access control section contains information about the security resources added to the Wazuh Indexer by default.

Access control

Wazuh Indexer uses the OpenSearch Security plugin to manage access control and security features. This allows you to define users, roles, and permissions for accessing indices and performing actions within the Wazuh Indexer.

You can find a more detailed overview of the OpenSearch Security plugin in the OpenSearch documentation.

Wazuh default internal users

Wazuh defines internal users and roles for the different Wazuh components to handle index management.

These default users and roles definitions are stored in the internal_users.yml, roles.yml, and roles_mapping.yml files on the /etc/wazuh-indexer/opensearch-security/ directory. Content Manager permission names are resolved through action groups defined in action_groups.yml.

Find more info about the configurations files in the Configuration files section.

Users

Three internal users ship with the indexer:

  • admin — superuser, holding all_access. Administration goes through this account.
  • kibanaserver → dashboard_server — service account the Wazuh Dashboard authenticates as internally. It is also mapped to the upstream kibana_server role.
  • wazuh-manager → wazuh_manager — service account for the Wazuh Manager: read/write on stateless (events, raw events, metrics) indices, read/write/delete on stateful (states) indices and on the agent statistics and configuration indices, and read on settings, consumers, threat intelligence, findings and active-responses.

Passwords are generated during installation, one per account, unique to each deployment. No account ships with a password, and none can be authenticated as until the security configuration is loaded. See Retrieving the generated credentials.

No administrative persona ships beyond admin. To give somebody administrative access without handing them all_access, define a role with the permissions that job needs — the permission names available are listed in Permissions — and map it to your own user (see Defining Users and Roles).

Besides the 1:1 roles, wazuh_ai_assistant is mapped to every authenticated user. It is the only role mapped to more than one account, and exists to attach a Document Level Security query to an index.

Roles

Three default roles are defined in roles.yml. Each role is reserved - it cannot be edited in place. wazuh_manager and wazuh_ai_assistant are self-contained, but dashboard_server is not the whole of what the Dashboard service account holds: kibanaserver is additionally mapped to the upstream kibana_server role, which grants the access the Dashboard needs on its own .kibana* indices. To customize, duplicate the role and edit the copy (see Defining Users and Roles).

dashboard_server

Internal service account used by the Wazuh Dashboard to read notification configs and query Wazuh indices on behalf of dashboard users. Mapped to the built-in kibanaserver user, not to any wazuh-* user.

  • Cluster permissions: cluster:admin/opensearch/notifications/configs/get.
  • Index permissions: read, indices:admin/mappings/get on wazuh-*.

wazuh_manager

Service account used by the Wazuh Manager for data ingestion and content reads.

  • Cluster permissions: cluster_composite_ops, indices:data/read/scroll/clear, cluster_monitor.
  • Index permissions:
    • read on .wazuh-settings.
    • read on .wazuh-cti-consumers, wazuh-active-responses*, wazuh-threatintel-*, wazuh-findings-v5-*.
    • read, index on wazuh-events-v5-*, wazuh-events-raw-v5*, wazuh-metrics-*.
    • read, index, delete on wazuh-states-*.
    • read, index, delete on wazuh-agent-*.
    • manage_point_in_time on .wazuh-threatintel-vulnerabilities*, wazuh-threatintel-*.

wazuh_ai_assistant

Grants every authenticated user access to their own AI assistant conversations, stored in the wazuh-ai-assistant-sessions data stream. Mapped to * (all users) in roles_mapping.yml.

  • Cluster permissions:
    • plugin:wazuh/ai_assistant/session/write — the setup plugin’s session write API.
  • Index permissions:
    • read on wazuh-ai-assistant-sessions*, .ds-wazuh-ai-assistant-sessions-*, restricted with the DLS query {"term": {"user": "${user.name}"}}.

${user.name} is substituted at query time with the name of the authenticated user, so each user retrieves only the conversations whose user field holds their own username.

Reads and writes are scoped by two different mechanisms, and the asymmetry is deliberate. Document Level Security is a read-path filter: it cannot scope a write. A role granting index-level write on this data stream would therefore let any account holding it store a document naming somebody else in user — including one the victim then sees as their own. So the role grants no index-level write at all. Every write goes through POST/PUT/PATCH/DELETE on /_plugins/_setup/ai_assistant/sessions, gated by the cluster permission above, where the setup plugin derives user from the authenticated caller and discards whatever the request body said (see Setup — API reference).

That is also what makes the read filter trustworthy: user is only a safe thing to filter on once it stops being client input.

Listing sessions and reading a transcript stay direct queries against the data stream, under the DLS filter above — there is no read endpoint, because DLS already scopes reads correctly and OpenSearch already provides search, sorting and pagination.

The per-owner DLS applies to every user, including admin.

Plugin-internal indices

Some indices are bookkeeping owned by a plugin rather than data a user queries. They are created by the plugin at node startup and are only ever read or written by the plugin itself, never on behalf of a REST caller, so custom roles must not be given index-level privileges on them — a role that omits them behaves exactly the same:

IndexOwnerPurpose
.wazuh-content-manager-resource-locksContent ManagerShort-lived mutex documents that serialize the resource-limit check when creating rules, decoders, integrations, KVDBs and filters. See Content Manager — Resource creation lock.
.wazuh-cti-consumersContent ManagerCTI synchronization state (status, offsets, source URL) per consumer.
.wazuh-content-manager-jobsContent ManagerJob Scheduler metadata for the catalog sync and telemetry ping jobs.

The one exception among the default roles is wazuh_manager, which grants read on .wazuh-cti-consumers so that the Wazuh Manager can read the synchronization state.

Where such an index has to be touched while serving a user request — the resource-creation lock is taken and released inside a create request — the plugin stashes the caller’s identity for the duration of that operation, so it is authorized as the plugin and not as the user. This is what keeps these indices out of every role, including custom roles that hold only plugin:content_manager/* permissions. The stash is scoped to the internal index: the content operation the request came for is still authorized against the user’s own index permissions on wazuh-threatintel-*.

.wazuh-internal-state is deliberately not in this list. It is also written by the plugins in their own context, but it is additionally declared as a security-plugin system index (plugins.security.system_indices.indices). No default role grants it either: the Setup plugin’s AI assistant endpoints read and write it on behalf of users in the plugin’s own context, gated by the cluster permissions listed in AI assistant administrative API.

AI assistant administrative API

The AI assistant’s providers configuration, assistant-wide settings and field policy live together in the hidden .wazuh-internal-state index.

EndpointMethodCluster permission
/_plugins/_setup/ai_assistant/settingsGETplugin:wazuh/ai_assistant/settings/read
/_plugins/_setup/ai_assistant/settingsPUTplugin:wazuh/ai_assistant/settings/write
/_plugins/_setup/ai_assistant/providersGETplugin:wazuh/ai_assistant/settings/read
/_plugins/_setup/ai_assistant/providersPOSTplugin:wazuh/ai_assistant/settings/write
/_plugins/_setup/ai_assistant/providers/{id}PUT, DELETEplugin:wazuh/ai_assistant/settings/write

No default role holds either permission: dashboard_server and wazuh_manager hold neither read nor write. Both are held only by all_access.

Sensitive configuration endpoints

A small set of endpoints modify configuration with a high impact on the platform. They are protected by two independent controls:

EndpointMethodPermission (cluster action)
/_plugins/_content_manager/policy/{space}PUTcluster:admin/content_manager/policy/update
/_plugins/_content_manager/updatePOSTcluster:admin/content_manager/update/trigger
/_plugins/_setup/settingsPUTplugin:wazuh/settings/write
  1. RBAC - each endpoint is gated by the cluster permission above, enforced by the OpenSearch Security plugin. No default role holds these permissions: dashboard_server and wazuh_manager are both excluded, so out of the box only the superuser admin (role all_access, cluster wildcard *) can call them. To delegate any of these actions without granting full superuser, create a dedicated role granting only the permission(s) above and map it to the chosen user.
  2. Per-endpoint disable settings - each endpoint can be disabled independently by setting its node setting to false, after which it returns 403 Forbidden for every caller, regardless of role (intended for externally managed deployments such as Wazuh Cloud): plugins.content_manager.catalog.update_on_demand (content update trigger), plugins.content_manager.catalog.policy_update.enabled (policy updates), and plugins.setup.settings_update.enabled (setup settings). See Protecting sensitive configuration.

Defining users and roles

You can create and manage users and roles through the Wazuh Dashboard UI.

Default users and roles cannot be modified. Instead, duplicate them and modify the duplicates.

Creating a new user, role, and role mapping via the Wazuh Dashboard

Prerequisites

  • You must be logged in as a user with administrative privileges (e.g., admin).

Follow these steps:

1. Create a role

  1. In the Wazuh Dashboard, go to Indexer management -> Security -> Roles.
  2. Click Create role.
  3. Enter a Role name (e.g., custom-read-write).
  4. Under Cluster permissions, select permissions if needed.
  5. Under Index permissions:
  6. Click Create to save the role.

2. Create a user

  1. In the Wazuh Dashboard, go to Indexer management -> Security -> Internal users.
  2. Click Create internal user.
  3. Fill in the following:
    • Username (e.g., new-user)
    • Password (enter and confirm)
    • Description (optional)
  4. Click Create to create the user.

3. Map the user to the role

Creating an internal user does not assign it a role. To map the user to the role you created:

  1. In Security, go to Roles.
  2. Find and click your role (custom-read-write).
  3. Go to Mapped users
  4. Click Map users.
  5. Fill in the following:
    • Users (e.g., new-user).
    • Backend roles (optional).
  6. Click Map to save the mapping.

4. Test access

After creating the user and role:

  1. Log out from the Dashboard.
  2. Log in with the new user’s credentials.
  3. Navigate to Indexer management -> Dev Tools.
  4. Run a query to test access, such as:
    GET /wazuh-*/_search
    

Additional resources

Permissions

This page lists the permissions registered by the Wazuh Indexer plugins, to grant in the roles you define. The default roles use only a few of them. Content Manager permission names are action groups (defined in action_groups.yml) that resolve to the actual transport actions registered by the plugin: cluster:admin/content_manager/* for the actions that change state, and cluster:monitor/content_manager/* for the read-only ones (subscription get, logtest, promote get and version check); the Setup entries are raw cluster actions, each also exposed as an action group; the Ruleset Management entries are raw cluster actions.

Setup plugin permissions

  • cluster:admin/setup/settings/update — update the Wazuh settings (PUT /_plugins/_setup/settings), exposed as the action group plugin:wazuh/settings/write
  • cluster:admin/ai_assistant/session/write — create, update and delete the caller’s own AI assistant sessions (POST /_plugins/_setup/ai_assistant/sessions, PUT, PATCH and DELETE /_plugins/_setup/ai_assistant/sessions/{id}), exposed as the action group plugin:wazuh/ai_assistant/session/write. Granted to every user by the default wazuh_ai_assistant role.
  • cluster:admin/ai_assistant/settings/read — read the AI assistant’s providers, settings and field policy (GET /_plugins/_setup/ai_assistant/settings, GET /_plugins/_setup/ai_assistant/providers), exposed as the action group plugin:wazuh/ai_assistant/settings/read
  • cluster:admin/ai_assistant/settings/write — write the AI assistant’s providers, settings and field policy (PUT /_plugins/_setup/ai_assistant/settings, POST /_plugins/_setup/ai_assistant/providers, PUT and DELETE /_plugins/_setup/ai_assistant/providers/{id}), exposed as the action group plugin:wazuh/ai_assistant/settings/write

Content Manager plugin permissions

Each resource exposes create, update and delete, plus a * action group that aggregates the three:

  • plugin:content_manager/integration/{create,update,delete} (and plugin:content_manager/integration/*)
  • plugin:content_manager/decoder/{create,update,delete} (and plugin:content_manager/decoder/*)
  • plugin:content_manager/rule/{create,update,delete} (and plugin:content_manager/rule/*)
  • plugin:content_manager/kvdb/{create,update,delete} (and plugin:content_manager/kvdb/*)
  • plugin:content_manager/filter/{create,update,delete} (and plugin:content_manager/filter/*)

Other Content Manager permissions:

  • plugin:content_manager/policy/update — create/update a policy (PUT /_plugins/_content_manager/policy/{space})
  • plugin:content_manager/update — trigger an on-demand CTI catalog sync (POST /_plugins/_content_manager/update)
  • plugin:content_manager/subscription/get — read the CTI subscription
  • plugin:content_manager/subscription/post — create/update the CTI subscription. Also the permission evaluated by POST /_plugins/_content_manager/subscription?perform_permission_check=true, which reports whether the caller holds it instead of registering.
  • plugin:content_manager/subscription/delete — delete the CTI subscription
  • plugin:content_manager/promote/get — preview a promotion diff
  • plugin:content_manager/promote/post — execute a space promotion
  • plugin:content_manager/promote/* — groups promote/get and promote/post
  • plugin:content_manager/logtest — run the combined logtest (POST /_plugins/_content_manager/logtest). Also grants cluster:admin/wazuh/securityanalytics/rules/evaluate, which the logtest needs downstream.
  • plugin:content_manager/logtest/detection, plugin:content_manager/logtest/normalization — run each logtest phase on its own (POST /_plugins/_content_manager/logtest/detection, POST /_plugins/_content_manager/logtest/normalization). logtest/detection also grants cluster:admin/wazuh/securityanalytics/rules/evaluate; logtest/normalization does not need it.
  • plugin:content_manager/logtest/* — groups logtest/detection and logtest/normalization, and through logtest/detection also cluster:admin/wazuh/securityanalytics/rules/evaluate; it does not grant the combined POST /_plugins/_content_manager/logtest
  • plugin:content_manager/space/delete — delete a space
  • plugin:content_manager/version/check — check the catalog version

Ruleset Management plugin permissions

Wazuh custom actions:

  • cluster:admin/wazuh/securityanalytics/detector/write
  • cluster:admin/wazuh/securityanalytics/detector/delete
  • cluster:admin/wazuh/securityanalytics/detector/set_enabled — switch an existing detector on or off without changing anything else (used by the Content Manager when an integration is enabled or disabled)
  • cluster:admin/wazuh/securityanalytics/logtype/write
  • cluster:admin/wazuh/securityanalytics/logtype/delete
  • cluster:admin/wazuh/securityanalytics/rule/write
  • cluster:admin/wazuh/securityanalytics/rule/delete
  • cluster:admin/wazuh/securityanalytics/rule/custom/write
  • cluster:admin/wazuh/securityanalytics/rule/custom/delete
  • cluster:admin/wazuh/securityanalytics/rules/evaluate
  • cluster:admin/wazuh/securityanalytics/space/delete

No default role grants the upstream OpenSearch Security Analytics actions (cluster:admin/opensearch/securityanalytics/*); the full /* set is held only by all_access.

Note: cluster:admin/opensearch/securityanalytics/detector/write is the permission that switches a detector on or off, and no default role holds it — out of the box only all_access does. Disabling a detector stops detection for its integration and the interval missed is never re-evaluated — see Enabling and disabling detectors.

Release notes

Highlights

  • New “Setup” initialization plugin.
    • Creation of Wazuh indices, index templates and ISM policies on startup #425.
    • Data streams by default for time-series indices (events, findings, metrics, active responses, raw events).
    • Adds ISM policies for data streams automatic rollover and removal based on age and size #466.
    • Adds metrics data streams for agent and communications telemetry #34711.
    • Some Wazuh settings now reside in the Indexer, and can be managed using the Settings API in the Setup plugin #833.
  • New “Content Manager” plugin.
    • Official threat intel content management for Wazuh CTI (ruleset, vulnerabilities feed, IoC feed).
    • Custom content management for user-defined threat intel resources (rules, decoders, integrations, KVDBs, filters, policies).
    • Content organized into spaces: standard (read-only, sourced from CTI), draft, test, custom — with a draft → test → custom promotion workflow.
    • Scheduled automatic updates by default, with manual updates also supported.
    • Implements a log test feature split into normalization (decoders) and detection (rules) phases.
    • Implements a REST API for content management, log testing, manual updates, promotion, subscription management, and version checks.
    • Daily version-check ping to Wazuh CTI to surface content updates and deployment telemetry.
  • Fork of OpenSearch’s Security Analytics plugin. [1]
    • Threat Detection migrated from the Wazuh Manager to the Wazuh Indexer Ruleset Management plugin.
    • Extended Sigma rules syntax #47.
    • Per-space support for Log Types and Rules #37.
    • Per-space threat detectors #117.
    • Rules parser improvements:
      • Case-insensitive Sigma operators #182.
      • Add exists Sigma modifiers #173.
      • Add support for IPv6 addresses in Sigma rules.
    • Dynamic event field referencing in findings #181.
    • Enriched findings written to wazuh-findings-v5-{category} data streams, embedding the full triggering event source and rule metadata (id, title, tags, level, status, MITRE, compliance).
  • Fork of OpenSearch’s Reporting plugin. [2]
    • Bundled by default in Wazuh Indexer packages — PDF/CSV reports from dashboards and saved searches, on-demand or on a schedule, with email delivery.
  • Fork of OpenSearch’s Notifications plugin. [3]
    • Webhooks for Slack, Jira, PagerDuty and Shuffle created by default.
    • Dedicated monitor for Active Response #8.
    • Multi-channel support: Slack, Microsoft Teams, Amazon Chime, Email (SMTP/SES), AWS SNS, and custom webhooks.
  • Fork of OpenSearch’s Alerting plugin. [4]
    • Dedicated monitor for Active Response #8.
  • Fork of OpenSearch’s Common Utils repository. [5]
    • Shared models and actions used across the Wazuh forks of Alerting, Notifications, Ruleset Management and the Content Manager.
  • Built-in Wazuh Engine.
    • Bundled in Wazuh Indexer packages and Docker images (x86_64 and aarch64).
    • Communicates with the Content Manager over a local Unix socket.
    • Validation of user-defined threat intel content.
    • Engine enrichment: IoC content management, GeoIP enrichment, and engine filters for event pre-processing #33493.
  • Active Response has been migrated to the Wazuh Indexer.
    • Dedicated wazuh-active-responses data stream for execution requests, with its own ISM policy.
    • Driven by a dedicated Alerting monitor.
  • New mdBook documentation (#254).
  • Reworked Wazuh Indexer packages and build scripts.
    • Wazuh Indexer packages now work for Systemd, SysV and initd service managers #602.
    • Snapshots for ruleset, vulnerabilities feed and IoC feed are now included in Wazuh Indexer packages so a freshly installed cluster has content available offline.
  • New set of default users and roles #1538.
    • Reserved Wazuh roles aligned with the new plugins (Content Manager, Alerting, Notifications, Reporting, Ruleset Management).
  • Reworked and extended Wazuh Common Schema.
    • Bump to ECS v9.1.0.
    • Per-category event and finding data streams (wazuh-events-v5-{category}, wazuh-findings-v5-{category}) covering access management, applications, cloud services, network activity, security, system activity, and unclassified events.
    • Raw events stream wazuh-events-raw-v5 with an aggressive purge ISM policy (gated by an Engine setting in the Setup plugin).
    • Agent and rule metadata relocated under the wazuh.* namespace.
    • New inventory coverage for Linux systemd units and macOS launchd daemons/agents alongside Windows services.

Breaking changes

  • Wazuh Indexer 4.x can not be upgraded to 5.x. A new installation of Wazuh Indexer 5.x is required.
  • Multi-tenancy disabled by default #1080.
  • Remove Performance Analyzer plugin from Wazuh Indexer packages #891.
  • Filebeat is no longer used to forward events from the Wazuh Manager to the Wazuh indexer — replaced by the built-in indexer connector.
  • Upgrade to OpenSearch 3.0 #874.
    • Replace and remove deprecated settings — configurations carried over from 4.x are no longer valid #475.
    • Update to JDK 25 #1341.
  • Migration of the Wazuh Common Schema from the wazuh-indexer repository to the wazuh-indexer-plugins repository. Folder renamed to wcs #879.
  • Supported operating systems updated for 5.0.0: Red Hat 9/10, Ubuntu 24.04/26.04, and Amazon Linux 2023 (x86_64 and aarch64). Earlier distributions supported in 4.x are no longer covered.

Migration guide

This guide describes how to migrate an existing Wazuh indexer 4.x deployment to Wazuh indexer 5.x.

Important Only configuration is migrated, and it is migrated manually. There is no automatic upgrade tooling, and indexed data is not migrated (see Indexed data). The procedure requires a fresh 5.x installation and manual re-creation of configuration and security settings. If you need to retain access to historical 4.x data, see Optionally keeping a legacy 4.x environment.

Scope

Wazuh indexer 5.x is a major release based on OpenSearch 3.x. It ships with new index schemas, a revised security model, and renamed or removed configuration settings. As a result, a 4.x cluster cannot be upgraded in place. The migration covers configuration only — base node settings, certificates, and OpenSearch Security authentication/authorization. It does not cover indexed data.

The procedure is:

  1. Stand up a new 5.x cluster on a fresh host.
  2. Re-create base configuration, certificates, and security settings against the 5.x layout.

The 4.x cluster is never modified by this procedure.

Prerequisites

Before starting:

Configuration migration

Migration from 4.x to 5.x is a selective carry-over: re-create each setting against the 5.x configuration tree rather than copying 4.x files verbatim. Both versions install configuration under /etc/wazuh-indexer/, and the layout has changed only slightly, but several OpenSearch 3.x settings have been renamed or removed and must be reviewed before any 4.x value is reused.

Important Do not copy 4.x configuration files over the 5.x files. The defaults shipped with 5.x are tuned for the new base engine. Use the 4.x files as a reference and re-apply each setting into the corresponding 5.x file.

The canonical 5.x configuration layout is:

PathPurpose
/etc/wazuh-indexer/opensearch.ymlMain cluster and node configuration
/etc/wazuh-indexer/jvm.optionsJVM heap and GC settings
/etc/wazuh-indexer/log4j2.propertiesLogging configuration
/etc/wazuh-indexer/certs/Transport and HTTP TLS certificates
/etc/wazuh-indexer/ca/Certificate authority the package created at installation, or the one you staged before installing it. New in 5.x
/etc/wazuh-indexer/local.envOptional. Passwords and certificate names you supply before installing. New in 5.x, never written by the package
/etc/wazuh-indexer/export/Passwords, CA certificate and client certificates published for the Wazuh manager and the Wazuh dashboard. New in 5.x
/etc/wazuh-indexer/opensearch.keystoreSecure settings (see Keystore)
/etc/wazuh-indexer/opensearch-security/Security plugin configuration (see Security migration)

For a full description of each file, see Configuration.

Configuration procedure

Perform these steps on every node of the new 5.x cluster. Steps 2 to 6 run with the service stopped. Step 7 starts it, and steps 8 to 10 need it running.

  1. Install the 5.x package on a fresh host following Installation. This creates the default 5.x configuration tree under /etc/wazuh-indexer/. To keep the 4.x certificates, stage them before installing the package, as described in Keeping the 4.x certificates.

  2. Stop the new service before editing configuration:

    systemctl stop wazuh-indexer
    
  3. Copy the relevant 4.x configuration values into the corresponding 5.x files — do not overwrite the 5.x files. Review each setting against Settings changes below. Add the 4.x keystore entries again as described in Keystore.

  4. If you staged the 4.x certificates in step 1, the installation set plugins.security.nodes_dn and plugins.security.authcz.admin_dn to the subjects of this node’s certificates. On a multi-node cluster, add the subjects of the other nodes’ certificates to plugins.security.nodes_dn. To keep the certificates the 5.x package issued instead, skip this step. See Generated at installation.

  5. Port jvm.options and log4j2.properties by copying only individual non-default lines into the 5.x files. Do not replace the 5.x files outright.

  6. Edit the security configuration files. See steps 1 and 2 of the Security procedure.

  7. Start the service:

    systemctl enable --now wazuh-indexer
    
  8. Once the service is running on every node, load the security configuration from a single node. See step 3 of the Security procedure. The node does not accept authenticated requests until this step is done.

  9. Confirm the nodes joined the new 5.x cluster. $WAZUH_INDEXER_ADMIN_PASSWORD is the admin password from /etc/wazuh-indexer/export/credentials.env, as described in Retrieving the generated credentials, or the one you set in /etc/wazuh-indexer/local.env if you supplied it:

    curl -sk -u admin:$WAZUH_INDEXER_ADMIN_PASSWORD "https://127.0.0.1:9200/_cat/nodes?v"
    
  10. Verify authentication for each backend. See step 4 of the Security procedure.

Settings changes

The following 4.x settings have changed in 5.x and must be reviewed before reuse. This list is not exhaustive: validate every remaining setting against the upstream OpenSearch 3.x breaking changes and release notes before starting the service.

Removed

  • compatibility.override_main_response_version: present in 4.x opensearch.yml for legacy Filebeat compatibility. Removed in OpenSearch 3.0; a node that still defines it will not boot. Delete the setting.
  • opensearch_performance_analyzer.*: the opensearch-performance-analyzer plugin is no longer shipped. Remove any related entries.
  • plugins.anomaly_detection.*: the opensearch-anomaly-detection plugin is no longer shipped. Remove any related entries.
  • plugins.asynchronous_search.*: the opensearch-asynchronous-search plugin is no longer shipped. Remove any related entries.
  • plugins.ml_commons.*: the opensearch-ml plugin is no longer shipped. Remove any related entries.
  • plugins.query.datasources.*: the opensearch-sql plugin is no longer shipped. Remove any related entries.
  • plugins.neural_search.*: the opensearch-neural-search plugin is no longer shipped. Remove any related entries.
  • knn.*: the opensearch-knn plugin is no longer shipped. Remove any related entries.
  • Observability and notebooks: the opensearch-observability plugin is no longer shipped. Remove any related entries.

Renamed or changed

  • cluster.initial_master_nodes → cluster.initial_cluster_manager_nodes. The old name is deprecated. The 5.x file already sets the new one: carry the node list over to it and do not add the old setting.
  • plugins.security.ssl.transport.enforce_hostname_verification → transport.ssl.enforce_hostname_verification, and plugins.security.ssl.transport.resolve_hostname → transport.ssl.resolve_hostname. The 5.x file already sets the new keys, and a node that defines both the old and the new name of a setting fails to start, so do not add the 4.x lines. transport.ssl.enforce_hostname_verification is true in 5.x (it was false in 4.x), so each node certificate must list the node’s address in its subject alternative names. transport.ssl.resolve_hostname stays false.
  • plugins.security.ssl.http.enabled_ciphers and plugins.security.ssl.http.enabled_protocols: the 4.x installation assistant pinned TLS 1.2 and four cipher suites. 5.x does not set them and uses the OpenSearch defaults. Carry them over only if your security policy requires a restricted list.
  • plugins.security.system_indices.indices: the 5.x list drops the indices of the plugins that are no longer shipped and adds .wazuh-internal-state. Keep the 5.x values.
  • Multi-tenancy settings: dashboard multi-tenancy is disabled by default in 5.x.

Generated at installation

The 5.x package issues the node’s TLS certificates and generates the passwords of its default users when it is installed, and writes the following settings itself. See Installation. Their values differ from the ones the 4.x installation wrote, and that is expected. Keep the generated values. If you staged the 4.x certificates before installing the package, the installation writes the values that match them instead (see Keeping the 4.x certificates).

  • Certificate paths, plugins.security.ssl.http.pem*_filepath and plugins.security.ssl.transport.pem*_filepath: 5.x always names the node pair indexer.pem and indexer-key.pem. A 4.x node installed step by step uses the same names, while the 4.x installation assistant named the pair after the node, for example node-1.pem.
  • plugins.security.authcz.admin_dn and plugins.security.nodes_dn: 5.x fills them in with the subjects of the certificates it issued. The fields appear in a different order than in the 4.x files (C=US,...,CN=admin instead of CN=admin,...,C=US). Keep the 5.x values.

Keeping the 4.x certificates

The 5.x package takes in certificates only when it is installed: certificates replaced afterwards are used, but the package does not publish their authority to the Wazuh manager and the Wazuh dashboard on the host, which keep trusting the authority the package created. To keep the 4.x certificates, stage them on each node before installing the package, with the commands in On every other Wazuh indexer node, run against the 4.x wazuh-certificates.tar. The 4.x installation assistant keeps them under wazuh-install-files/ in wazuh-install-files.tar instead, so repack them first: tar -xf wazuh-install-files.tar && tar -cf wazuh-certificates.tar -C wazuh-install-files ..

There, NODE_NAME is the name of the node in the 4.x config.yml, which is how its pair is named inside the tar. The 4.x pair also carries the addresses of the 4.x node in its subject alternative names, and 5.x verifies them on the transport layer, so keep those addresses on the 5.x host or reissue the certificates.

The 4.x tar also holds the key of the 4.x authority, root-ca.key. Stage it as well, as /etc/wazuh-indexer/ca/root-ca.key with mode 400, for the installation to issue the client pairs of the Wazuh manager and the Wazuh dashboard from the 4.x authority.

Keystore

The 5.x package creates its own /etc/wazuh-indexer/opensearch.keystore. Do not copy the 4.x keystore over it: an entry for a setting that 5.x does not know stops the node at start with unknown secure setting. List the 4.x entries on a 4.x node with /usr/share/wazuh-indexer/bin/opensearch-keystore list, and add the ones you still need on every node of the new 5.x cluster as described in SMTP account credentials: as root, then giving the keystore back to the service’s group. Skip keystore.seed, which every keystore creates for itself, and the entries of plugins that 5.x no longer ships (see Settings changes).

Security migration

Authentication and authorization are managed by the OpenSearch Security plugin in both versions, but 5.x ships a new set of default internal users, roles, and role mappings tailored to the Wazuh stack — the 4.x defaults are not carried over. For the full, up-to-date list of 5.x default users, roles, and permissions, see Access control.

In 5.x, all security plugin configuration lives under /etc/wazuh-indexer/opensearch-security/:

FilePurpose
config.ymlAuthentication and authorization backends (internal, LDAP, SAML, OIDC, JWT, etc.)
internal_users.ymlLocal user accounts and password hashes
roles.ymlRole definitions
roles_mapping.ymlMapping from authenticated identities to roles
action_groups.ymlReusable groups of permissions referenced by roles
tenants.ymlDashboard tenants
nodes_dn.ymlNode certificate distinguished names allowed into the cluster
allowlist.ymlWhen enabled, limits every user except the super admin to the listed REST endpoints and methods. 5.x no longer ships the deprecated whitelist.yml
audit.ymlAudit-logging configuration

Security procedure

Step 1 runs on the 4.x cluster. The remaining steps run on the new 5.x cluster: step 2 on every node, with the service running or stopped, and steps 3 and 4 with the service running on every node.

  1. Export the live 4.x security configuration. The on-disk files under /etc/wazuh-indexer/opensearch-security/ may be stale, since the active configuration is stored in the security index. Use the backup procedure to write the live configuration to disk before reusing it. See Back up and restore.

  2. On every node of the new 5.x cluster, do not overwrite the shipped files. Edit them in place under /etc/wazuh-indexer/opensearch-security/, making the same changes on each node. For each custom entry in the 4.x files, decide whether it should be re-created in 5.x:

    • Custom internal users → add to internal_users.yml (existing password hashes can be reused as-is).
    • Custom roles → add to roles.yml, keeping the 5.x index patterns and permission names.
    • Role mappings → add to roles_mapping.yml, referencing the new role names.
    • External authentication backends (LDAP, Active Directory, SAML, OIDC, JWT, Kerberos, client-certificate) → re-create the corresponding authc / authz blocks in config.yml against the 5.x schema.

    Tip — bulk copy alternative Reviewing every entry individually is the safest option, but it is tedious and risks silently dropping a custom user or role you set up long ago and no longer remember. As an alternative, copy all custom entries from the 4.x files into the corresponding 5.x files at once, then prune afterwards. This guarantees nothing is lost, at the cost of dragging along stale entries. Copied entries may reference 4.x index patterns or permission names that changed in 5.x, and may collide with the new 5.x default users and roles — so still validate the result against Access control before applying.

  3. Apply the configuration with the /usr/share/wazuh-indexer/bin/indexer-security-init.sh script shipped with the package. Run it once, from a single node whose files carry all the changes. The cluster stores the security configuration in its security index, so the change reaches every node.

  4. Verify authentication works for each backend, against every node, before pointing production traffic at the new cluster. Changes applied with the script take effect without a restart.

Warning indexer-security-init.sh uploads the files under /etc/wazuh-indexer/opensearch-security/ of the node it runs on to the security index, replacing the configuration of the whole cluster. Running it later from a node whose files lack the migrated entries removes them on every node, and the migrated users can no longer log in. Keep the files of all nodes in sync, both now and whenever you change the security configuration later.

The exact syntax for each external authentication backend is defined and maintained by the upstream OpenSearch Security plugin and may evolve between OpenSearch versions. Always cross-check the backend configuration against the upstream documentation before applying it:

Indexed data

Wazuh indexer 4.x data is not migrated to 5.x. There is no in-place upgrade and no _reindex path that turns 4.x data into 5.x data. To retain access to historical 4.x data, see Optionally keeping a legacy 4.x environment.

A snapshot of 4.x indices can still be restored on a 5.x cluster: this is a standard OpenSearch feature (see Snapshot and restore). Only indices created on OpenSearch 2.x can be restored: a 4.x cluster upgraded from an earlier 4.x release that ran on OpenSearch 1.x may still hold indices created back then, and those cannot be restored on 5.x. The restored indices are plain OpenSearch indices, though. None of the Wazuh 5.x central components reads the legacy wazuh-alerts-* and wazuh-archives-* indices, so restoring them does not make the 4.x data part of the 5.x deployment.

Why

Wazuh indexer 5.x introduces new index schemas (the v5 suffix) and new index templates. The schemas, field types, and routing of the 5.x indices differ from 4.x, so the 4.x data cannot be turned into 5.x data:

ConcernDescription
Index schema5.x uses new templates and mappings for wazuh-events-v5-*, wazuh-findings-v5-*, and wazuh-states-*. These have no direct counterpart in 4.x.
Index compatibilityThe OpenSearch 3.x base in 5.x reads indices created on its own and the immediately preceding major version only. A snapshot of 4.x indices created on OpenSearch 2.x can be restored.
Document shape5.x events and findings are data streams whose field names follow the Wazuh Common Schema, and their mappings reject fields they do not define. 4.x documents cannot be written into them without being transformed, and that transformation is not lossless.

Optionally keeping a legacy 4.x environment

Whether to retain the old cluster is entirely your decision and depends on whether you still need historical 4.x data. If you do not, the 4.x environment can be decommissioned once the 5.x cluster is in service.

If you do need historical visibility, you can run the existing 4.x cluster alongside the new 5.x deployment:

  1. Leave the 4.x cluster in place after the migration; do not uninstall it.
  2. Switch the 4.x cluster to a read-only role: stop ingestion from the Wazuh server into 4.x, and optionally mark its indices read-only to prevent accidental writes.
  3. Keep the existing 4.x dashboard pointed at the 4.x cluster for users who need historical data; the Wazuh 5.x dashboard modules do not read 4.x indices.
  4. Plan a retention window after which the 4.x environment can be decommissioned according to your data-retention policy.