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.
| Tool | Required Version |
|---|---|
| mdbook | 0.5.2 |
| mdbook-mermaid | 0.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
mdbookandmdbook-mermaidcargo install mdbook --version 0.5.2 --locked cargo install mdbook-mermaid --version 0.17.0 --locked
Usage
-
To build the documentation, run:
./build.shThe output will be generated in the
bookdirectory. -
To serve the documentation locally for preview, run:
./server.shThe 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
<!--
Provide a brief description of the problem this pull request addresses. Include relevant context to help reviewers understand the purpose and scope of the changes.
If this pull request resolves an existing issue, reference it here. For example:
Closes #<issue_number>
-->
## Proposed Changes
<!--
Summarize the changes made in this pull request. Include:
- Features added
- Bugs fixed
- Any relevant technical details
-->
### Results and Evidence
<!--
Provide evidence of the changes made, such as:
- Logs
- Screenshots
- Before/after comparisons
-->
### Artifacts Affected
<!--
List the artifacts impacted by this pull request, such as:
- Executables (specify platforms if applicable)
- Default configuration files
- Packages
-->
### Configuration Changes
<!--
If applicable, list any configuration changes introduced by this pull request, including:
- New configuration parameters
- Changes to default values
- Backward compatibility notes
-->
### Documentation Updates
<!--
If applicable, list the sections of documentation that have been updated as part of this pull request.
-->
### Tests Introduced
<!--
If applicable, describe any new unit or integration tests added as part of this pull request. Include:
- Scope of the tests
- Any relevant details about test coverage
-->
## Review Checklist
<!--
List any manual tests completed to verify the functionality of the changes. Include any manual tests that are still required for final approval.
-->
- [ ] Code changes reviewed
- [ ] Relevant evidence provided
- [ ] Tests cover the new functionality
- [ ] Configuration changes documented
- [ ] Developer documentation reflects the changes
- [ ] Meets requirements and/or definition of done
- [ ] No unresolved dependencies with other issues
- [ ] PR is linked to the relevant issue(s)
- [ ] Correct labels applied (e.g., `no-changelog`)
- [ ] ...
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, classified as Added, Changed, Removed, or Fixed. The 5_codequality_changelog.yml workflow enforces this. Apply the no-changelog label to bypass the check when the linked issue belongs to a private repository, or when the PR genuinely does not require a changelog update.
Changelog entries must always reference the issue, not the pull request, so the entry stays meaningful independently of how the change was implemented. An issue only belongs in the changelog if it affects the product and represents a change from a previously released version — internal/CI changes are never included, and neither are fixes for problems introduced in an unpublished version.
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>
| Component | Description |
|---|---|
| Major | Product major version (e.g. 4, 5). |
| Prefix | Category prefix from the use cases below. |
| Target | The action target: a component, module, subsystem, tool, language, etc. |
The prefix is drawn from the following set of use cases:
| Use case | Prefix | Target | Example |
|---|---|---|---|
| Code analysis (static/dynamic) | codeanalysis | Code analysis tool | 4_codeanalysis_coverity |
| Linter / auto-docs | codelinter | Linter | 5_codelinter_clangformat |
Code quality (groups codeanalysis + codelinter) | codequality | Repository | 5_codequality_changelog |
| Unit tests | testunit | Module | 5_testunit_engine |
| Component tests | testcomponent | Component/module | 5_testcomponent_indexerconnector |
| Integration tests | testintegration | Module | 4_testintegration_cluster |
| Package builder | builderpackage | Subsystem | 4_builderpackage_server |
| Precompiled object builder | builderprecompiled | Subsystem | 5_builderprecompiled_agent |
| Version bumping | bumper | Repository | 5_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).
- Set your username.
- Set your email address.
- Generate an SSH key.
- Add the public key to your GitHub account for authentication and signing.
- 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:
- Install IDEA Community Edition as per the official documentation.
- 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.tomlfile. 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 (check wazuh-indexer/gradle/libs.versions.toml).
Once the JDK is installed, configure it as the default system-wide Java installation using update-alternatives:
sudo update-alternatives --install /usr/bin/java java /home/$USER/.jdks/temurin-21.0.9/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=/usr/lib/jvm/temurin-24-jdk-amd64
export PATH=$PATH:/usr/lib/jvm/temurin-24-jdk-amd64/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:
sdk install java 24-tem sdk use java 24-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:
- Vagrant
- VirtualBox or another supported provider
Refer to the tools/test-cluster/README.md for provisioning and usage instructions.
8. Verify the setup
After completing the setup, verify everything works:
cd wazuh-indexer-plugins
./gradlew :wazuh-indexer-content-manager:compileJava
For the Notifications plugin (Kotlin-based, separate repository):
cd wazuh-indexer-notifications
./gradlew build
For the Common Utils plugin (Shared Library):
cd wazuh-indexer-common-utils
./gradlew clean build publishToMavenLocal
For the Alerting plugin:
cd wazuh-indexer-alerting
./gradlew build
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 it will generate a wazuh-indexer package for Debian based systems, for the x64 architecture, using 1 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
STAGEoption defines the naming of the package. When set tofalse, the package will be unequivocally named with the commits’ SHA of thewazuh-indexer,wazuh-indexer-plugins,wazuh-indexer-reporting,wazuh-indexer-security-analytics,wazuh-indexer-notificationsandwazuh-indexer-alertingrepositories, 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 wazuh-indexer/build-scripts/docker folder 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 present in the same folder as the Dockerfile in wazuh-indexer/build-scripts/docker.
docker build \
--build-arg="VERSION=<version>" \
--build-arg="INDEXER_TAR_NAME=wazuh-indexer_<version>-<revision>_linux-x64.tar.gz" \
--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:
-n NAME [required] Tarball name.
-r REVISION [Optional] Revision qualifier, default is 0.
-h help
The script will stop if the credentials are not set, or if any of the required parameters are not provided.
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.
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
| Artifact | Location |
|---|---|
| Plugin ZIP distribution | plugins/<plugin-name>/build/distributions/ |
| Compiled classes | plugins/<plugin-name>/build/classes/ |
| Test reports | plugins/<plugin-name>/build/reports/tests/ |
| Generated JARs | plugins/<plugin-name>/build/libs/ |
Common build issues
JDK version mismatch
The project requires a specific JDK version (currently JDK 24, Eclipse Temurin). 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:
- Check your network connection (dependencies are downloaded from Maven Central and repositories).
- Try clearing the Gradle cache:
rm -rf ~/.gradle/caches/ - 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
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
Smoke tests on built packages are run via GitHub Actions Workflows. These install packages on supported operating systems:
- DEB packages — installed on the Ubuntu 24.04 GitHub Actions runner.
- RPM packages — installed in a Red Hat 9 Docker container.
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.
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
%% Relations
IndexInitializer <|-- Index : implements
Index <|-- IndexStateManagement
Index <|-- WazuhIndex
WazuhIndex <|-- StateIndex
WazuhIndex <|-- StreamIndex
%% 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 {
}
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
Clientare 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 two 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.
/**
* 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"));
//...
}
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.
ISM policies and templates must be properly deployed before the indices are created.
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.jsonandfindings.jsonexist 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:
- Reads
events.jsonfrom the classpath - Overrides
index_patternsto["wazuh-events-v5-cloud-services*"] - If
rollover_aliasis present in the base template’s settings, overrides it to"wazuh-events-v5-cloud-services" - Creates the composable index template in OpenSearch, with
mappingscopied 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 Stream | Template | Notes |
|---|---|---|
wazuh-events-raw-v5 | templates/streams/raw.json | Stores original unprocessed events |
wazuh-active-responses | templates/streams/active-responses.json | Active Response execution requests |
wazuh-ai-assistant-sessions | templates/streams/ai-assistant-sessions.json | AI 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 a short retention period to ensure timely cleanup of processed event data.
Policy details
- Policy Name:
stream-events-policy - Location:
plugins/setup/src/main/resources/policies/stream-events-policy.json - Index Pattern:
wazuh-events-v5-* - Retention Period: 1 hour
- Rollover Conditions: 20 GB primary shard size or 200,000,000 documents
- ISM template priority: 0
Policy states
-
Hot State
- Actions: Rollover when primary shard reaches 20 GB or 200M documents
- Transition Condition: Transitions to
deleteafter 1 hour
-
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 a 90-day retention period to maintain detection findings for compliance and investigation purposes.
Policy details
- Policy Name:
stream-findings-policy - Location:
plugins/setup/src/main/resources/policies/stream-findings-policy.json - Index Pattern:
wazuh-findings-v5-* - Retention Period: 90 days
- Rollover Conditions: 20 GB primary shard size or 200,000,000 documents
- ISM template priority: 0
Policy states
-
Hot State
- Actions: Rollover when primary shard reaches 20 GB or 200M documents
- Transition Condition: Transitions to
deleteafter 90 days
-
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 with an aggressive 10-minute retention for temporary raw event storage.
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* - Retention Period: 10 minutes
- Rollover Conditions: 20 GB primary shard size or 200,000,000 documents
- ISM template priority: 0
Policy states
-
Hot State
- Actions: Rollover when primary shard reaches 20 GB or 200M documents
- Transition Condition: Transitions to
deleteafter 10 minutes
-
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 - Retention Period: 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:
- The template file
active-responses.jsonexists intemplates/streams/ - The ISM policy file
stream-active-responses-policy.jsonexists inpolicies/ - Both are registered in
SetupPlugin.javaandIndexStateManagement.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) with a 30-day retention period.
Policy details
- Policy Name:
stream-metrics-policy - Location:
plugins/setup/src/main/resources/policies/stream-metrics-policy.json - Index Pattern:
wazuh-metrics-* - Retention Period: 30 days
- Rollover Conditions: 20 GB primary shard size or 200,000,000 documents
- ISM template priority: 0
Policy states
-
Hot State
- Actions: Rollover when primary shard reaches 20 GB or 200M documents
- Transition Condition: Transitions to
deleteafter 30 days
-
Delete State
- Actions: Deletes the index
- Retry Policy: 3 attempts with exponential backoff (1-minute initial delay)
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_sourceand 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 intomessages— 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; writes carry no DLS query. The restriction also applies to users holding a role that grants read on the * index pattern.
Sessions are read and written by each user directly against the data stream, under that per-owner DLS; the setup plugin exposes no administrative API over them.
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 atAiAssistantSettingsAdminIndex.MAX_PROVIDERS= 500. - A single reserved-id document (id
"wazuh-ai-assistant-settings") holding the assistant-wide settings and the field anonymization policy, underfield_policy - 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": {},
"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 |
| Endpoint | Method | Cluster permission | Backed by |
|---|---|---|---|
/_plugins/_setup/ai_assistant/settings | GET | plugin:wazuh/ai_assistant/settings/read | GetAiAssistantSettingsAction / TransportGetAiAssistantSettingsAction, Operation.SETTINGS |
/_plugins/_setup/ai_assistant/settings | PUT | plugin:wazuh/ai_assistant/settings/write | PutAiAssistantSettingsAction / TransportPutAiAssistantSettingsAction, Operation.SETTINGS |
/_plugins/_setup/ai_assistant/providers | GET | plugin:wazuh/ai_assistant/settings/read | same Get* action, Operation.LIST_PROVIDERS — AiAssistantSettingsAdminIndex.listProviders() |
/_plugins/_setup/ai_assistant/providers | POST | plugin:wazuh/ai_assistant/settings/write | same Put* action, Operation.PUT_PROVIDER; body must carry the UUID id to create with |
/_plugins/_setup/ai_assistant/providers/{id} | PUT, DELETE | plugin:wazuh/ai_assistant/settings/write | same, 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* - Retention Period: 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
-
Hot State
- Actions: Rollover when the index is 1 day old, or reaches 20 GB / 200M documents
- Transition Condition: Transitions to
deleteafter 7 days
-
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:
# Generate the hash using the tool at `plugins/opensearch-security/tools/hash.sh -p <new-password>`
hash: "<HASHED-PASSWORD>"
reserved: true
hidden: false
backend_roles: []
description: "New user description"
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
- Apply your changes to the configuration files in
wazuh-indexer/distribution/src/config/security/. - Generate a new package (see Build Packages).
- Follow the official installation and configuration steps.
- 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)
- 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)
- Add the new configuration to the affected file within
/etc/wazuh-indexer/opensearch-security/. - Run the
/usr/share/wazuh-indexer/bin/indexer-security-init.shscript 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:
| Index | Purpose |
|---|---|
.opendistro-reports-definitions | Stores report definitions (source, trigger schedule, delivery options). |
.opendistro-reports-instances | Stores 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:
| Handler | Concern |
|---|---|
ReportDefinitionRestHandler | Create, update, get, and delete a single report definition. |
ReportDefinitionListRestHandler | List/search report definitions. |
ReportInstanceRestHandler | Get a report instance and update its status. |
ReportInstanceListRestHandler | List/search report instances. |
OnDemandReportRestHandler | Trigger on-demand report generation, including in-context report creation. |
ReportStatsRestHandler | Expose 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/ubuntu2204togeneric/centos7in 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
- 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). Useip addrto 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/). - 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 wazuh-indexer:wazuh-indexer /etc/wazuh-indexer/opensearch.keystore - Ensure
mailpitis accessible within theserverVM (e.gcurl https://172.28.128.136:8025 -k -u admin:adminshould return HTML code). If not, add it to the list of known hosts in/etc/hosts(e.gecho "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-stateand caches it inPluginSettings.accessTokenfor 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 local snapshots
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 name | Purpose | Hidden | Alias-backed |
|---|---|---|---|
.wazuh-cti-consumers | Sync state (status, offsets) per consumer | yes | no |
.wazuh-internal-state | Persisted CTI access token | yes | no |
wazuh-threatintel-policies | Policy documents | no | yes |
wazuh-threatintel-integrations | Integration definitions | no | yes |
wazuh-threatintel-rules | Detection rules | no | yes |
wazuh-threatintel-decoders | Decoder definitions | no | yes |
wazuh-threatintel-kvdbs | Key-value databases | no | yes |
wazuh-threatintel-filters | Engine filter rules | no | yes |
wazuh-threatintel-enrichments | Indicators of Compromise (IoC) | no | yes |
.wazuh-threatintel-vulnerabilities | CVE vulnerability data | yes | yes |
.wazuh-content-manager-jobs | Job scheduler metadata | yes | no |
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
-aor-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
| Class | Responsibility |
|---|---|
ContentIndex | Creates 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. |
IndexSwapHelper | Stateless utility class for swap operations: resolveShadowName(), resolveLivePhysicalName(), createShadowIndices(), reindexUserContent(), atomicSwap(), deleteIndices(). |
AbstractConsumerService | Detects plan changes and delegates to performShadowSwap() instead of the old resetConsumer() wipe-and-reload path. |
Shadow swap flow (plan change)
When AbstractConsumerService.syncConsumerServices() detects a plan change (the plan-provided resource URL differs from the persisted one), it runs the shadow swap path:
- Resolve shadow physical names (the -a/-b suffix not currently live)
- Create hidden shadow physical indices (index.hidden=true, no alias)
- Download snapshot into shadow indices (reuse SnapshotServiceImpl)
- Reindex user content (space.name != “standard”) from live → shadow (only for consumer types with hasUserContent()=true, i.e., ruleset)
- Unhide non-CVE shadow indices (set index.hidden=false)
- Atomic alias swap (single IndicesAliasesRequest for all 8 aliases)
- Rewrite consumer document in .wazuh-cti-consumers
- Run post-sync cascade (onSyncComplete: Security Analytics sync, engine promote, etc.)
- Delete old physical indices
Failure modes
| Failure point | System state after failure | Next sync behavior |
|---|---|---|
| Before step 6 (alias swap) | Shadow indices are deleted; live alias and consumer document are untouched. | Retries the shadow swap from a clean state against the same target plan. |
| Between step 6 (alias swap) and step 7 (consumer doc rewrite) | Alias already points at the new physical indices, but .wazuh-cti-consumers still names the old resource URL. | Re-detects the plan change (persisted resource still differs from the plan) and re-runs the shadow swap — 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–4) | Content written after the reindex snapshot (step 4) but before the alias swap (step 6) 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 cascade (step 8) | Alias and consumer document 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 spans the entire synchronize() call, which includes the shadow swap. No additional locking is needed, but it also means the swap holds that semaphore for its full duration — normal incremental syncs for other consumers queue behind it.
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:
- Initializes
PluginSettings,ConsumersIndex,CredentialsIndex,CtiConsole,CatalogSyncJob,EngineServiceImpl, andSpaceService. - Registers all REST handlers via
getRestHandlers(). - Creates the
.wazuh-cti-consumersand.wazuh-internal-stateindices on cluster manager nodes. - Schedules the periodic
CatalogSyncJobvia the OpenSearch Job Scheduler. - Optionally triggers an immediate sync on start.
- Registers/schedules
TelemetryPingJob(wazuh-telemetry-ping-job) whenplugins.content_manager.telemetry.enabledis true. - Registers a dynamic settings consumer to enable/disable telemetry at runtime.
- 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
ClusterServicemetadata. - Reads Wazuh version through
ContentManagerPlugin.getVersion(). - Prevents overlap using a
Semaphore(tryAcquire()guard). - Exposes a
trigger()method for immediate invocation, used byContentManagerPluginto 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 UUIDwazuh-tag:v<version>
- Fire-and-forget behavior: callback logs success/failure without blocking scheduler threads.
- Sends an asynchronous GET request to CTI
CTI HTTP client User-Agent
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.
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 ConsoleApiClient.
Runtime toggle behavior:
plugins.content_manager.telemetry.enabledis 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:
| Domain | Handler | Method | URI |
|---|---|---|---|
| Subscription | RestPostSubscriptionAction | POST | /_plugins/_content_manager/subscription |
RestGetSubscriptionAction | GET | /_plugins/_content_manager/subscription | |
RestDeleteSubscriptionAction | DELETE | /_plugins/_content_manager/subscription | |
| Update | RestPostUpdateAction | POST | /_plugins/_content_manager/update |
| Version check | RestGetVersionCheckAction | GET | /_plugins/_content_manager/version/check |
| Logtest | RestPostLogtestAction | POST | /_plugins/_content_manager/logtest |
RestPostLogtestNormalizationAction | POST | /_plugins/_content_manager/logtest/normalization | |
RestPostLogtestDetectionAction | POST | /_plugins/_content_manager/logtest/detection | |
| Policy | RestPutPolicyAction | PUT | /_plugins/_content_manager/policy/{space} |
| Rules | RestPostRuleAction | POST | /_plugins/_content_manager/rules |
RestPutRuleAction | PUT | /_plugins/_content_manager/rules/{id} | |
RestDeleteRuleAction | DELETE | /_plugins/_content_manager/rules/{id} | |
| Decoders | RestPostDecoderAction | POST | /_plugins/_content_manager/decoders |
RestPutDecoderAction | PUT | /_plugins/_content_manager/decoders/{id} | |
RestDeleteDecoderAction | DELETE | /_plugins/_content_manager/decoders/{id} | |
| Integrations | RestPostIntegrationAction | POST | /_plugins/_content_manager/integrations |
RestPutIntegrationAction | PUT | /_plugins/_content_manager/integrations/{id} | |
RestDeleteIntegrationAction | DELETE | /_plugins/_content_manager/integrations/{id} | |
| KVDBs | RestPostKvdbAction | POST | /_plugins/_content_manager/kvdbs |
RestPutKvdbAction | PUT | /_plugins/_content_manager/kvdbs/{id} | |
RestDeleteKvdbAction | DELETE | /_plugins/_content_manager/kvdbs/{id} | |
| Filters | RestPostFilterAction | POST | /_plugins/_content_manager/filters |
RestPutFilterAction | PUT | /_plugins/_content_manager/filters/{id} | |
RestDeleteFilterAction | DELETE | /_plugins/_content_manager/filters/{id} | |
| Promote | RestPostPromoteAction | POST | /_plugins/_content_manager/promote |
RestGetPromoteAction | GET | /_plugins/_content_manager/promote | |
| Spaces | RestDeleteSpaceAction | DELETE | /_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()fromBaseRestHandler. - 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:
- Validate request body — ensures the request has content and valid JSON.
- Validate payload structure — checks for required
resourcekey and optionalintegrationkey. - 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 aboveplugins.content_manager.max_<type>; if the index does not exist yet, the check is skipped. - Generate ID and metadata — creates a UUID; sets
dateandmodifiedtimestamps to the current time unless the caller already supplied a non-blank value for either (Resource.setCreationTime()/setLastModificationTime()); defaultsenabledtotrue. - External sync — delegates to
syncExternalServices()(abstract). Typically upserts the resource in Security Analytics or validates via the Engine. - Index — wraps the resource in the CTI document structure and indexes it in the Draft space.
- Link to parent — delegates to
linkToParent()(abstract). Usually adds the new resource ID to a parent integration’s resource list. - 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:
- Validate ID — checks the path parameter is present and correctly formatted.
- Check existence and space — verifies the resource exists and belongs to the Draft space.
- Parse and validate payload — same structural checks as create.
- Resource-specific validation — delegates to
validatePayload()(abstract). - Update timestamps — sets
modifiedto the current time unless the caller already supplied a non-blank value.preserveMetadata()then unconditionally overwritesdatewith the existing document’s stored creation date, ignoring any caller-supplied value —dateis fully immutable once a resource is created. - External sync — delegates to
syncExternalServices()(abstract). - Re-index — overwrites the document in the index.
- 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:
- Validate ID — checks format and presence.
- Check existence and space — resource must exist in Draft space.
- Pre-delete validation — delegates to
validateDelete()(optional override). Can prevent deletion if dependent resources exist. - External sync — delegates to
deleteExternalServices()(abstract). Removes from Security Analytics. Handles 404 gracefully. - Unlink from parent — delegates to
unlinkFromParent()(abstract). Removes the resource ID from the parent integration’s list. - Delete from index — removes the document.
- 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.
| Integration | Space | PUT /integrations/{id} |
|---|---|---|
protected | any | Rejected with 400 Bad Request. |
user-managed | draft | Fully editable (metadata, category, enabled). |
user-managed | standard | Only 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.
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:
-
isYamlRequest(RestRequest)— Detects YAML content type viaXContentType.YAML.equals(request.getMediaType()). Returnsfalseon any exception (e.g., test mocks that don’t stubgetMediaType()). -
supportsYamlField()— Returnsfalseby default. Overridden totruein concrete handlers that support YAML field storage:RestPostDecoderAction,RestPutDecoderAction,RestPostKvdbAction,RestPutKvdbAction,RestPostFilterAction,RestPutFilterAction. -
YAML/JSON branching in
executeRequest()— BothAbstractCreateActionandAbstractUpdateAction(and their*Spacesvariants) branch onisYamlRequest()andsupportsYamlField():- YAML path: Parses the body via
YamlUtils.fromYaml(), then validates the envelope structure with the samevalidateResourcePayload()call as the JSON path. TherawYamlfor theyamlfield is generated from theresourcesubtree viaYamlUtils.toYaml(). - JSON path: Unchanged — parses via Jackson
ObjectMapper.readTree().
- YAML path: Parses the body via
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"
content: { ... }
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:
rawYamlis generated from the parsedresourcesubtree (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, engine-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
| Class | Role |
|---|---|
YamlUtils | YAML - JSON conversion with USE_BIG_DECIMAL_FOR_FLOATS, fixDecimalScale() |
Decoder | Model with yaml field, fromPayload() generates YAML from document |
Kvdb | Model with yaml field, same pattern as Decoder |
Filter | Model with yaml field, same pattern as Decoder |
AbstractContentAction | isYamlRequest(), supportsYamlField() base methods |
ContentIndex | create() 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.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(usingStandardProtocolFamily.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:
| Method | Description |
|---|---|
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:
| Method | Engine endpoint | HTTP method |
|---|---|---|
logtest() | /logtest | POST |
validate() | /content/validate/resource | POST |
promote() | /content/validate/policy | POST |
Space model
Resources live in spaces that represent their lifecycle stage. The Space enum defines four spaces:
| Space | Description |
|---|---|
STANDARD | Production-ready CTI resources from the upstream catalog |
CUSTOM | User-created resources that have been promoted to production |
DRAFT | Resources under development — all user edits happen here |
TEST | Intermediate 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 thespace.namefield.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..."
}
}
}
Content synchronization pipeline
Overview
sequenceDiagram
participant Scheduler as JobScheduler/RestAction
participant SyncJob as CatalogSyncJob
participant Synchronizer as ConsumerRulesetService
participant ConsumerSvc as ConsumerService
participant CTI as External CTI API
participant Snapshot as SnapshotService
participant Update as UpdateService
participant Swap as IndexSwapHelper
participant Indices as Content Indices
participant Consumers as .wazuh-cti-consumers
participant SA as SecurityAnalyticsServiceImpl
Scheduler->>SyncJob: Trigger Execution
activate SyncJob
SyncJob->>Synchronizer: synchronize()
Synchronizer->>ConsumerSvc: getLocalConsumer() / getRemoteConsumer()
ConsumerSvc->>CTI: Fetch Metadata
ConsumerSvc-->>Synchronizer: Offsets & Metadata
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, no plan change)
Synchronizer->>Update: update(localOffset, remoteOffset)
Update->>CTI: Fetch Changes
Update->>Indices: Apply JSON Patches
Update-->>Synchronizer: Done
else Plan change detected (resource URL differs)
Synchronizer->>Swap: performShadowSwap()
Swap->>Indices: Create hidden shadow indices (-a/-b spare suffix)
Swap->>CTI: Download snapshot into shadow indices
Swap->>Indices: Reindex user content (space.name != "standard")
Swap->>Indices: Unhide shadow indices
Swap->>Indices: Atomic alias swap (single IndicesAliasesRequest)
Swap->>Consumers: Rewrite consumer document (new resource, local_offset = remote_offset)
Swap->>Indices: Delete old physical indices
Swap-->>Synchronizer: Done
end
opt Changes Applied (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 runs after all three branches, including the shadow swap — by the time it executes, Indices and Consumers already resolve through the swapped alias and rewritten consumer document.
Initialization phase
When local_offset = 0:
- Downloads a ZIP snapshot from the CTI API.
- Extracts and parses JSON files for each content type.
- Bulk-indexes content into respective indices.
- Registers all content with the Security Analytics Plugin via
SecurityAnalyticsServiceImpl.
Update phase
When local_offset > 0 and local_offset < remote_offset:
- Fetches the changes in batches from the CTI API.
- Applies JSON Patch operations (add, update, delete).
- Pushes the changes to the Security Analytics Plugin via
SecurityAnalyticsServiceImpl. - Updates the local offset.
Post-synchronization phase
- Refreshes all content indices.
- Upserts integrations, rules, and detectors into the Security Analytics Plugin via
SecurityAnalyticsServiceImpl. - Recalculates SHA-256 hashes for policy integrity verification.
- Sets consumer
statustoreadyin.wazuh-cti-consumers(orfailedif an unexpected exception interrupted the cycle). See the Reference Manual’s architecture page for the fullready/running/failedlifecycle.
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.
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:
| Constant | Value |
|---|---|
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
integrationsandfiltersarrays allow reordering but not adding or removing entries.author,description,documentation, andreferencesare required in addition to the boolean fields. - Standard space — only
enrichments,filters,enabled,index_unclassified_events, andindex_discarded_eventscan 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
| Field | Type | Description | Editable in standard space |
|---|---|---|---|
id | keyword | Unique identifier | No |
title | keyword | Human-readable name | No |
date | date | Creation timestamp | No |
modified | date | Last modification timestamp | No |
root_decoder | keyword | Root decoder for event processing | No |
integrations | keyword[] | Active integration IDs | No |
author | keyword | Policy author | No |
description | text | Brief description | No |
documentation | keyword | Documentation link | No |
references | keyword[] | External reference URLs | No |
filters | keyword[] | Filter UUIDs (reordering allowed, no add/remove) | Yes |
enrichments | keyword[] | Enrichment types (file, domain-name, ip, url, geo) | Yes |
enabled | boolean | Whether the policy is active | Yes |
index_unclassified_events | boolean | Index events that match no rule | Yes |
index_discarded_events | boolean | Index events explicitly discarded by rules | Yes |
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]
DeleteSAP --> 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 in Security Analytics cleanup are logged but do not block the reset — the primary goal is clearing the content indices and regenerating the policy.
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 / operation | Scenario count | Covers |
|---|---|---|
| Integrations: create | 9 | Success; duplicate title; missing title/author/category; explicit id in resource; missing resource object; empty body; no authentication |
| Integrations: update | 12 | Success; 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: delete | 7 | Success (no attached resources); has attached resources; not found; invalid UUID; missing ID; not in draft space; no authentication |
| Decoders: create | 7 | Success; missing integration reference; explicit id in resource; integration not in draft space; missing resource object; empty body; no authentication |
| Decoders: update | 7 | Success; not found; invalid UUID; not in draft space; missing resource object; empty body; no authentication |
| Decoders: delete | 7 | Success; not found; invalid UUID; not in draft space; missing ID; no authentication; verify removal from index |
| Rules: create | 7 | Success; missing title; missing integration reference; explicit id in resource; integration not in draft space; empty body; no authentication |
| Rules: update | 7 | Success; missing title; not found; invalid UUID; not in draft space; empty body; no authentication |
| Rules: delete | 7 | Success; not found; invalid UUID; not in draft space; missing ID; no authentication; verify removal from index |
| KVDBs: create | 9 | Success; missing title/author/content; missing integration reference; explicit id in resource; integration not in draft space; empty body; no authentication |
| KVDBs: update | 7 | Success; missing required fields; not found; invalid UUID; not in draft space; empty body; no authentication |
| KVDBs: delete | 7 | Success; not found; invalid UUID; not in draft space; missing ID; no authentication; verify removal from index |
| Policy: initialization | 6 | wazuh-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 draft | 12 | Success; 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 |
| Logtest | 4 | Success; empty body; invalid JSON; no authentication |
| Promote: preview | 7 | Draft → test; test → custom; missing/empty/invalid space parameter; preview from custom (not allowed); no authentication |
| Promote: execute | 18 | Success 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 |
Related documentation
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. UsesRouteto register the endpoint with OpenSearch’s REST framework.prepareRequest()— The core method. Returns aRestChannelConsumerlambda that executes asynchronously and writes the response to the channel.- Path parameters —
{id}in the route path is automatically parsed. Access it withrequest.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:admin --insecure \
-d '{
"integration": "<integration-id>",
"resource": {
"title": "Test Rule"
}
}'
# The response returns the UUID. Use it to fetch:
curl -X GET "https://localhost:9200/_plugins/_content_manager/rules/<uuid>" \
-u admin:admin --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:
- Create the handler class — Extend
BaseRestHandler(for simple endpoints) or one of the abstract classes (AbstractCreateAction,AbstractUpdateAction,AbstractDeleteAction) for standard CUD. - Define routes — Use
Routeto declare the HTTP method and URI pattern. - Implement logic — Override
prepareRequest()(orexecuteRequest()if extending the abstract hierarchy). - Register — Add the instance to
ContentManagerPlugin.getRestHandlers(). - Build and test —
./gradlew :wazuh-indexer-content-manager:compileJavathen./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. It reads the raw request body into a LogtestRequest and calls client.execute(LogtestAction.INSTANCE, logtestRequest, listener). It performs no 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:
- Validates the request has content and is valid JSON.
- Validates the required field
space. - Validates that
spaceis not"draft". - Extracts the optional
integrationfield (if present) and strips it from the Engine payload. - Delegates to
LogtestService.executeLogtest(integrationId, space, enginePayload). IfintegrationIdisnull, only engine normalization is performed.
RestPostLogtestNormalizationAction
Path: rest/service/RestPostLogtestNormalizationAction.java
The REST handler for POST /_plugins/_content_manager/logtest/normalization. Reads the request body and calls client.execute(LogtestNormalizationAction.INSTANCE, ...). No validation.
TransportLogtestNormalizationAction
Path: transport/TransportLogtestNormalizationAction.java
Responsibilities:
- Validates the request has content and is valid JSON.
- Validates the required field
space. - Validates that
spaceis not"draft". - Strips the
integrationfield if present (not used for normalization). - Delegates to
LogtestService.executeNormalization(enginePayload).
RestPostLogtestDetectionAction
Path: rest/service/RestPostLogtestDetectionAction.java
The REST handler for POST /_plugins/_content_manager/logtest/detection. Reads the request body and calls client.execute(LogtestDetectionAction.INSTANCE, ...). No validation.
TransportLogtestDetectionAction
Path: transport/TransportLogtestDetectionAction.java
Responsibilities:
- Validates the request has content and is valid JSON.
- Validates the required fields
space,integration, andinput. - Validates that
spaceis not"draft". - Validates that
inputis a JSON object (not a string or array). - Delegates to
LogtestService.executeDetection(integrationId, space, inputEvent).
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 toEngineService.logtest()and returns the response directly withparseMessageAsJson()executeDetection()— Security Analytics-only: looks up integration, fetches rule IDs/bodies, evaluates viaSecurityAnalyticsService.evaluateRules(), and returns the result
The full logtest flow:
- No-integration shortcut — If
integrationIdisnull, delegates toexecuteEngineOnly(): runs the Engine normalization and returns the result withdetection.status: "skipped"andreason: "No integration provided". Steps 2–5 below are skipped. - Integration lookup — Queries
wazuh-threatintel-integrationsfor a document matchingdocument.id == integrationIdandspace.name == space. Returns 400 if not found. - Engine processing — Sends the event payload to the Wazuh Engine via
EngineService.logtest(). Extracts the normalized event from theoutputfield. The engine result fields (output,asset_traces,validation) are included directly in the response (no wrapper). - Rule fetching — Extracts rule IDs from the integration’s
document.rulesarray, then fetches rule bodies fromwazuh-threatintel-rulesbydocument.id, filtered by the same space. - Security Analytics evaluation — Passes the normalized event JSON and rule bodies to
SecurityAnalyticsService.evaluateRules(). - Response building — Combines engine and Security Analytics results into a single JSON response under the keys
normalizationanddetection.
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: 0with success status. - If Security Analytics evaluation returns unparseable JSON, the result is
status: "error".
SecurityAnalyticsService / EventMatcher
The Security Analytics evaluation happens in the security-analytics repository:
SecurityAnalyticsServiceImpl.evaluateRules()— Parses Sigma rule YAML strings intoSigmaRuleobjects, then delegates toEventMatcher.EventMatcher.evaluate()— Flattens the normalized event JSON into dot-notation keys, then evaluates each rule’s detection conditions against the flat map. Returns a JSON result string.
The EventMatcher handles:
- Field-equals-value conditions (exact match, case-insensitive)
- Keyword (value-only) conditions (searches all event fields)
- Wildcards (
*for multi-char,?for single-char) via cached compiled regex patterns - String modifiers:
contains,startswith,endswith - Explicit regex (
remodifier) - CIDR subnet matching (IPv4 and IPv6)
- Boolean, numeric (gt, gte, lt, lte), null, and string comparisons
- Composite conditions: AND, OR, NOT
- List values (any element matching counts as a match)
Match results use a nested rule object per match entry:
{
"rule": { "id": "...", "title": "...", "level": "...", "tags": [...] },
"matched_conditions": [...]
}
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.evaluateRules(normalizedEventJson, ruleBodies)
│ → parses YAML → SigmaRule objects
│ → EventMatcher flattens event + evaluates conditions
│ → 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.evaluateRules(inputJson, ruleBodies)
→ returns Security Analytics result directly
Key differences from the combined endpoint
- Normalization returns the raw Engine response (no detection wrapper). The
integrationfield is stripped if present but has no effect on behavior. - Detection accepts a pre-normalized event as the
inputJSON object. It does not call the Engine — it goes straight to integration lookup → rule fetch → Security Analytics evaluation.
Index dependencies
| Index | Usage | Query |
|---|---|---|
wazuh-threatintel-integrations | Look up integration by ID in the given space | document.id == X AND space.name == {space} |
wazuh-threatintel-rules | Fetch rule bodies by document IDs in the given space | document.id IN [...] AND space.name == {space} |
Both indices must exist and have document.id mapped as keyword for term queries to work.
Testing
Unit tests
| Test class | Covers |
|---|---|
TransportLogtestActionTests | Request validation for combined endpoint (empty body, invalid JSON, missing fields, wrong space, delegation to service) |
TransportLogtestNormalizationActionTests | Request validation for normalization endpoint (empty body, invalid JSON, missing space, invalid space, delegation, integration stripping) |
TransportLogtestDetectionActionTests | Request validation for detection endpoint (empty body, invalid JSON, missing fields, invalid space, non-object input, delegation) |
LogtestServiceTests | Orchestration logic (integration lookup, engine errors, rule fetching, Security Analytics evaluation, response structure) |
EventMatcherTests | Sigma rule evaluation (field matching, wildcards, numerics, booleans, nulls, AND/OR/NOT conditions) |
Integration tests
| Test class | Covers |
|---|---|
LogtestIT | End-to-end REST workflow against a live test cluster (request validation, integration lookup, promote + logtest, response structure) |
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).
Adding new logtest features
Supporting a new validation field
- Add the field constant to
Constants.java. - Add validation logic in the relevant transport action(s):
TransportLogtestAction,TransportLogtestNormalizationAction, and/orTransportLogtestDetectionAction. The REST handlers themselves need no changes — they only read the body and dispatch. - Add unit tests in the corresponding test classes.
- Add integration test in
LogtestIT.
Supporting a new Engine response field
- Update
LogtestService.executeEngine()to extract the field. - Include it in the
normalizationmap withinbuildCombinedResponse(). - Add unit test scenarios in
LogtestServiceTests. - Update the API docs (
api.md) response fields table.
Extending Security Analytics evaluation
- Modify
EventMatcher.matchValue()to handle newSigmaTypesubclasses. - Add test cases in
EventMatcherTests. - Update the Sigma rules doc (Sigma Rules) if new detection modifiers are supported.
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).
- 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:
| Subproject | Description |
|---|---|
notifications/core-spi | Service Provider Interface. Defines destination models (SlackDestination, SmtpDestination, ChimeDestination, etc.) and the NotificationCore contract. |
notifications/core | Core implementation. Contains HTTP/SMTP/SES/SNS clients, transport providers, and all configurable settings (PluginSettings). |
notifications/notifications | Main 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
Transport layer (core)
DestinationTransport (interface)
├── WebhookDestinationTransport (Slack, Chime, Teams, Webhooks)
├── SmtpDestinationTransport (SMTP Email)
├── SesDestinationTransport (AWS SES Email)
└── SnsDestinationTransport (AWS SNS)
REST handlers (notifications)
| Handler | Method | URI |
|---|---|---|
NotificationConfigRestHandler | POST | /_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 | |
NotificationFeaturesRestHandler | GET | /_plugins/_notifications/features |
NotificationChannelListRestHandler | GET | /_plugins/_notifications/channels |
SendTestMessageRestHandler | POST | /_plugins/_notifications/feature/test/{config_id} |
Setup environment
Requirements
- JDK: version 11 or 17 (depending on the target Wazuh Indexer version).
- Gradle: Use the included
./gradlewwrapper (no separate install needed). - IDE: IntelliJ IDEA with Kotlin plugin is recommended.
Clone and build
git clone <notifications-repo-url>
cd wazuh-indexer-notifications
./gradlew build
The distribution zip will be generated at:
notifications/notifications/build/distributions/
Build packages
To create distribution packages:
# Full build (compile + test + assemble)
./gradlew build
# Assemble only (skip tests)
./gradlew assemble
The output zip can be installed on a running Wazuh Indexer using:
bin/opensearch-plugin install file:///path/to/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:
./gradlew :notifications:notifications:integTest
Key integration test classes:
| Test Class | Description |
|---|---|
SlackNotificationConfigCrudIT | Full CRUD lifecycle for Slack channels. |
ChimeNotificationConfigCrudIT | Full CRUD lifecycle for Chime channels. |
EmailNotificationConfigCrudIT | Full CRUD lifecycle for Email channels (SMTP/SES). |
MicrosoftTeamsNotificationConfigCrudIT | Full CRUD lifecycle for Microsoft Teams channels. |
WebhookNotificationConfigCrudIT | Full CRUD lifecycle for custom webhooks. |
SnsNotificationConfigCrudIT | Full CRUD lifecycle for SNS channels. |
CreateNotificationConfigIT | Config creation edge cases and validation. |
DeleteNotificationConfigIT | Config deletion including bulk delete. |
QueryNotificationConfigIT | Filtering, sorting, and pagination queries. |
GetPluginFeaturesIT | Feature discovery endpoint tests. |
GetNotificationChannelListIT | Channel list endpoint tests. |
SendTestMessageRestHandlerIT | Test message delivery flow. |
SendTestMessageWithMockServerIT | Test message with mock destination. |
SecurityNotificationIT | RBAC and access control tests. |
MaxHTTPResponseSizeIT | HTTP response size limit enforcement. |
NotificationsBackwardsCompatibilityIT | Backwards compatibility between versions. |
Notification flow
The data flow when sending a notification follows this sequence:
Monitor/Alerting Plugin
│
▼
Notification Plugin Interface (REST / Transport)
│
▼
Security Plugin (verify permissions)
│
▼
.notifications index (persist notification, status = pending)
│
▼
Transport Action (resolve destination type)
│
├──► WebhookDestinationTransport ──► Slack / Chime / Teams / Custom Webhook
├──► SmtpDestinationTransport ──► External SMTP Server
├──► SesDestinationTransport ──► AWS SES
└──► SnsDestinationTransport ──► AWS SNS
│
▼
Recipient
- An internal plugin (Alerting, Reporting, ISM) or a user invokes the Notification plugin via Transport or REST API.
- The Security plugin verifies the caller’s permissions.
- The notification is persisted in the
.notificationsindex withpendingstatus. - The
DestinationTransportProviderresolves the correct transport based on the channel type. - The transport client delivers the message to the external service.
- On failure, retries are attempted up to the configured limit.
- The notification status is updated to
sentorfailed.
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:
- Add a new
ChannelDefinitionentry to theDEFAULT_CHANNELSlist inDefaultChannelInitializer.kt. - Choose a unique, stable
idprefixed withdefault_(e.g.,default_teams_channel). - Set
isEnabled = falseand use a placeholder URL with clear instructions in thedescription. - 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:
-
Define the destination model in
core-spi:- Create a new class extending
BaseDestinationinnotifications/core-spi/src/main/kotlin/.../destination/.
- Create a new class extending
-
Implement the transport in
core:- Create a new class implementing
DestinationTransportinnotifications/core/src/main/kotlin/.../transport/. - Register it in
DestinationTransportProvider.
- Create a new class implementing
-
Add the config type to the
DEFAULT_ALLOWED_CONFIG_TYPESlist incore/setting/PluginSettings.kt. -
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.
Enriched findings pipeline
WazuhEnrichedFindingService implements the enrichment pipeline described in the Reference Manual’s architecture page.
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-pre-packaged-rules) and the custom rules index (opensearch-custom-rules). 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(default100, range 10–1000, dynamic), the thread that incremented the counter callsdrainAndFlush()immediately. - Periodic flush: a fixed-delay scheduler fires
drainAndFlush()everyenriched_findings_flush_interval(default5seconds, 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
buildAndIndex starts from a shallow copy of the triggering event source and overlays the following fields:
| Field | Source |
|---|---|
@timestamp | @timestamp of the original triggering event |
event.* | Pre-existing event fields plus doc_id, index |
wazuh.rule | Sigma rule metadata (id, title, tags, sigma_id, and any of level, status, compliance, mitre present in the rule index entry) |
Rule metadata is nested under wazuh.rule. 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.
Sequence diagram
sequenceDiagram
participant A as Wazuh Manager
participant I as Wazuh Indexer
participant SA as Security Analytics
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->>SA: Monitor evaluates event against Sigma rules
SA->>SA: Rule matches → create raw finding
SA->>TC: 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: buildAndIndex (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(default100, 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(default5, range 1–10, dynamic) — maximum number of concurrent in-flight enrichment batches.plugins.security_analytics.enriched_findings_flush_interval(default5seconds, range 1–60, dynamic) — interval between periodic flush runs.plugins.security_analytics.enriched_findings_enrich_batch_size(default100, range 1–1000, dynamic) — number of findings drained from the queue per in-flight permit.plugins.security_analytics.enriched_findings_rule_cache_max_size(default10000, minimum0, 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
sourceslist is provided and not empty, it is used as the detector’s input. - If
sourcesis 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.comments(nested, array) — replaces the old singlecommentfield. Each entry hasauthor(keyword),created_at(date),updated_at(date), andcomment(match_only_text).
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
-
Bulk-based: the endpoint allows up to 50 finding updates per call.
-
Partial doc update: uses
UpdateRequest.doc()which merges the provided fields into the existing document. Onlywazuh.caseis touched, other finding fields are never modified.
Request validation
The handler performs eager validation before building the bulk request:
| Check | HTTP status | Message |
|---|---|---|
| Invalid/missing JSON body | 400 | Invalid JSON body: ... |
Missing findings array | 400 | Request body must contain a "findings" array |
Empty findings array | 400 | Findings array is empty |
| More than 50 items | 400 | Cannot update more than 50 findings at once |
| Element not a JSON object | 400 | Element at index N is not a JSON object |
Missing _id | 400 | Element at index N is missing _id |
Missing _index | 400 | Element at index N is missing _index |
Missing/invalid case | 400 | Element at index N is missing or invalid case object |
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 - On partial failure (some docs not found): HTTP
207 MULTI_STATUS - On total bulk failure: HTTP
500
Registration
The handler is registered in SecurityAnalyticsPlugin.getRestHandlers():
new RestUpdateFindingsAction()
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.casemapping 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 andAlertingPluginInterface, the transport bridge other plugins use to call into Alerting (e.g., Security Analytics fetches findings viaAlertingPluginInterface.INSTANCE.getFindings()).org.opensearch.commons.notifications— shared notification channel/config models andNotificationsPluginInterface, 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 andReplicationPluginInterface.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:
- Check all consumers (
wazuh-indexer-alerting,wazuh-indexer-notifications,wazuh-indexer-security-analytics, andwazuh-indexer-plugins’ Content Manager) for usages before changing a method signature or model field. - Bump the version and republish before consumers can pick up the change — this is a versioned dependency, not a monorepo shared source set.
- See
RELEASING.mdandCHANGELOG.mdin 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 anameand adescription(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:
- Inventory the doc tree against
SUMMARY.md— orphaned files, broken links, stub pages. - Cross-check coverage against source — plugins, REST endpoints, settings, and documented mechanisms or call chains, not just names that still technically exist.
- Review structure and navigation — heading hierarchy, table-of-contents depth, cross-linking, and whether content sits in the right track.
- 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.
- Write the findings report.
Related artifacts
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.mdis 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.
Related artifacts
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 registeredSetting<?>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.
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 Security Analytics 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-responsesdata 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 → custompromotion 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-pluginsrepository, 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.
Security Analytics 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
existsmodifier, 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 Security Analytics 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, Security Analytics 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, Security Analytics). 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 pattern | Description |
|---|---|
wazuh-events-v5-{cat} | Decoded and normalized security events from monitored endpoints, per category. |
wazuh-events-raw-v5 | Raw incoming events, retained briefly under an aggressive ISM purge policy (gated by an Engine setting). |
wazuh-findings-v5-{cat} | Enriched findings produced by Security Analytics, embedding the triggering event and rule metadata. |
wazuh-states-v5-* | Stateful inventory data (vulnerabilities, packages, ports, FIM, services, browser extensions, SCA, etc.). |
wazuh-active-responses | Active 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-consumers | Internal index tracking consumer state for CTI synchronization. |
wazuh-ai-assistant-sessions | AI assistant conversation history, rotated daily and kept for 7 days. Each user sees only their own conversations (Document Level Security). |
.wazuh-internal-state | Hidden 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, Security Analytics, 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
- Wazuh Agents collect security events from monitored endpoints and forward them to the Wazuh Manager.
- 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.
- The Setup plugin ensures the correct index templates, data streams, and lifecycle policies exist.
- The Content Manager plugin keeps the Engine’s detection content up to date by synchronizing with the CTI API and managing user customizations.
- 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.
| Name | Version | Architecture |
|---|---|---|
| Red Hat | 9, 10 | x86_64, aarch64 |
| Ubuntu | 26.04, 24.04 | x86_64, aarch64 |
| Amazon Linux | 2023 | x86_64, aarch64 |
OpenSearch
Currently, Wazuh indexer is using version 3.6.0 of OpenSearch.
Getting Started
Requirements
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 endpoints | EPS | Storage in Wazuh indexer (GB/90 days) |
|---|---|---|
| Servers | 0.25 | 3.7 |
| Workstations | 0.1 | 1.5 |
| Network devices | 0.5 | 7.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 230 GB.
Installation
Note: This documentation assumes you are already provisioned with a wazuh-indexer package through any of the possible methods:
- Local package generation (recommended).
- GH Workflows artifacts.
- Staging S3 buckets
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. The installation process is divided into three stages.
-
Certificates creation
-
Nodes installation
-
Cluster initialization
Note: You need root user privileges to run all the commands described below.
1. Certificates creation
Generating the SSL certificates
-
Download the
wazuh-certs-tool.shscript and theconfig.ymlconfiguration file. This creates the certificates that encrypt communications between the Wazuh central components.curl -sO https://packages-dev.wazuh.com/5.0/wazuh-certs-tool.sh curl -sO https://packages-dev.wazuh.com/5.0/config.yml -
Edit
./config.ymland 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.
-
Run
./wazuh-certs-tool.shto create the certificates. For a multi-node cluster, these certificates need to be later deployed to all Wazuh instances in your cluster../wazuh-certs-tool.sh -A -
Compress all the necessary files.
tar -cvf ./wazuh-certificates.tar -C ./wazuh-certificates/ . rm -rf ./wazuh-certificates -
Copy the
wazuh-certificates.tarfile to all the nodes, including the Wazuh indexer, Wazuh Manager, and Wazuh dashboard nodes. This can be done by using thescputility.
2. Nodes installation
Installing package dependencies
Install the following packages if missing:
yum
yum install coreutils
apt
apt-get install debconf adduser procps
Installing the Wazuh indexer package
rpm
rpm -ivh --replacepkgs wazuh-indexer-<VERSION>.rpm
dpkg
dpkg -i wazuh-indexer-<VERSION>.deb
Configuring the Wazuh indexer
Edit the /etc/wazuh-indexer/opensearch.yml configuration file and replace the following values:
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.
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.
c. cluster.initial_cluster_manager_nodes: List of the names of the master-eligible nodes. These names are defined in the config.yml file. Uncomment the node-2 and config.yml 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. Uncomment the lines for node-2 and node-3 and change the common names (CN) and values according to your settings and your config.yml definitions.
plugins.security.nodes_dn:
- "CN=node-1,OU=Wazuh,O=Wazuh,L=California,C=US"
- "CN=node-2,OU=Wazuh,O=Wazuh,L=California,C=US"
- "CN=node-3,OU=Wazuh,O=Wazuh,L=California,C=US"
Deploying certificates
Note: Make sure that a copy of the
wazuh-certificates.tarfile, created during the initial configuration step, is placed in your working directory.
Run the following commands, replacing <INDEXER_NODE_NAME> with the name of the Wazuh indexer node you are configuring as defined in config.yml. For example, node-1. This deploys the SSL certificates to encrypt communications between the Wazuh central components.
NODE_NAME=<INDEXER_NODE_NAME>
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 -n /etc/wazuh-indexer/certs/$NODE_NAME.pem /etc/wazuh-indexer/certs/indexer.pem
mv -n /etc/wazuh-indexer/certs/$NODE_NAME-key.pem /etc/wazuh-indexer/certs/indexer-key.pem
chmod 500 /etc/wazuh-indexer/certs
chmod 400 /etc/wazuh-indexer/certs/*
chown -R wazuh-indexer:wazuh-indexer /etc/wazuh-indexer/certs
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
Starting the service
Enable and start the Wazuh indexer service.
Systemd
systemctl daemon-reload
systemctl enable wazuh-indexer
systemctl start 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
-
Replace
$WAZUH_INDEXER_IP_ADDRESSand run the following commands to confirm that the installation is successful.curl -k -u admin:admin https://$WAZUH_INDEXER_IP_ADDRESS:9200Output
{ "name" : "node-1", "cluster_name" : "wazuh-cluster", "cluster_uuid" : "095jEW-oRJSFKLz5wmo5PA", "version" : { "number" : "7.10.2", "build_type" : "rpm", "build_hash" : "db90a415ff2fd428b4f7b3f800a51dc229287cb4", "build_date" : "2023-06-03T06:24:25.112415503Z", "build_snapshot" : false, "lucene_version" : "9.6.0", "minimum_wire_compatibility_version" : "7.10.0", "minimum_index_compatibility_version" : "7.0.0" }, "tagline" : "The OpenSearch Project: https://opensearch.org/" } -
Replace
$WAZUH_INDEXER_IP_ADDRESSand run the following command to check if the single-node or multi-node cluster is working correctly.curl -k -u admin:admin https://$WAZUH_INDEXER_IP_ADDRESS:9200/_cat/nodes?v
Requirements
Requirements
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 endpoints | EPS | Storage in Wazuh indexer (GB/90 days) |
|---|---|---|
| Servers | 0.25 | 3.7 |
| Workstations | 0.1 | 1.5 |
| Network devices | 0.5 | 7.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 230 GB.
Packages
Packages
Wazuh Indexer packages can be downloaded from the internal S3 buckets though 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:
- Local package generation (recommended).
- GH Workflows artifacts.
- Staging S3 buckets
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. The installation process is divided into three stages.
-
Certificates creation
-
Nodes installation
-
Cluster initialization
Note: You need root user privileges to run all the commands described below.
1. Certificates creation
Generating the SSL certificates
-
Download the
wazuh-certs-tool.shscript and theconfig.ymlconfiguration file. This creates the certificates that encrypt communications between the Wazuh central components.curl -sO https://packages-dev.wazuh.com/5.0/wazuh-certs-tool.sh curl -sO https://packages-dev.wazuh.com/5.0/config.yml -
Edit
./config.ymland 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.
-
Run
./wazuh-certs-tool.shto create the certificates. For a multi-node cluster, these certificates need to be later deployed to all Wazuh instances in your cluster../wazuh-certs-tool.sh -A -
Compress all the necessary files.
tar -cvf ./wazuh-certificates.tar -C ./wazuh-certificates/ . rm -rf ./wazuh-certificates -
Copy the
wazuh-certificates.tarfile to all the nodes, including the Wazuh indexer, Wazuh Manager, and Wazuh dashboard nodes. This can be done by using thescputility.
2. Nodes installation
Installing package dependencies
Install the following packages if missing:
yum
yum install coreutils
apt
apt-get install debconf adduser procps
Installing the Wazuh indexer package
rpm
rpm -ivh --replacepkgs wazuh-indexer-<VERSION>.rpm
dpkg
dpkg -i wazuh-indexer-<VERSION>.deb
Configuring the Wazuh indexer
Edit the /etc/wazuh-indexer/opensearch.yml configuration file and replace the following values:
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.
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.
c. cluster.initial_cluster_manager_nodes: List of the names of the master-eligible nodes. These names are defined in the config.yml file. Uncomment the node-2 and config.yml 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. Uncomment the lines for node-2 and node-3 and change the common names (CN) and values according to your settings and your config.yml definitions.
plugins.security.nodes_dn:
- "CN=node-1,OU=Wazuh,O=Wazuh,L=California,C=US"
- "CN=node-2,OU=Wazuh,O=Wazuh,L=California,C=US"
- "CN=node-3,OU=Wazuh,O=Wazuh,L=California,C=US"
Deploying certificates
Note: Make sure that a copy of the
wazuh-certificates.tarfile, created during the initial configuration step, is placed in your working directory.
Run the following commands, replacing <INDEXER_NODE_NAME> with the name of the Wazuh indexer node you are configuring as defined in config.yml. For example, node-1. This deploys the SSL certificates to encrypt communications between the Wazuh central components.
NODE_NAME=<INDEXER_NODE_NAME>
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 -n /etc/wazuh-indexer/certs/$NODE_NAME.pem /etc/wazuh-indexer/certs/indexer.pem
mv -n /etc/wazuh-indexer/certs/$NODE_NAME-key.pem /etc/wazuh-indexer/certs/indexer-key.pem
chmod 500 /etc/wazuh-indexer/certs
chmod 400 /etc/wazuh-indexer/certs/*
chown -R wazuh-indexer:wazuh-indexer /etc/wazuh-indexer/certs
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
Starting the service
Enable and start the Wazuh indexer service.
Systemd
systemctl daemon-reload
systemctl enable wazuh-indexer
systemctl start 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
-
Replace
$WAZUH_INDEXER_IP_ADDRESSand run the following commands to confirm that the installation is successful.curl -k -u admin:admin https://$WAZUH_INDEXER_IP_ADDRESS:9200Output
{ "name" : "node-1", "cluster_name" : "wazuh-cluster", "cluster_uuid" : "095jEW-oRJSFKLz5wmo5PA", "version" : { "number" : "7.10.2", "build_type" : "rpm", "build_hash" : "db90a415ff2fd428b4f7b3f800a51dc229287cb4", "build_date" : "2023-06-03T06:24:25.112415503Z", "build_snapshot" : false, "lucene_version" : "9.6.0", "minimum_wire_compatibility_version" : "7.10.0", "minimum_index_compatibility_version" : "7.0.0" }, "tagline" : "The OpenSearch Project: https://opensearch.org/" } -
Replace
$WAZUH_INDEXER_IP_ADDRESSand run the following command to check if the single-node or multi-node cluster is working correctly.curl -k -u admin:admin 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.
- 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:
-Xms4gsets the initial heap size to 4 GB.-Xmx4gsets the maximum heap size to 4 GB.
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.
-
bootstrap.memory_lock: trueis enabled by default in/etc/wazuh-indexer/opensearch.yml. No changes are needed for package installations. -
Grant the
wazuh-indexerservice 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=infinityis set in the service file. - SysVinit:
ulimit -l unlimitedis applied by the init script before starting the process.
- systemd:
-
Reload the service manager and restart Wazuh Indexer:
systemctl daemon-reload systemctl restart wazuh-indexer -
Verify that memory locking is active by checking that the
mlockallvalue istrue: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 lockedThis usually means the
wazuh-indexeruser lacks thememlockpermission. For systemd-based systems, confirm thatLimitMEMLOCK=infinityis present in the service file, reload withsystemctl daemon-reload, and restart the service. For SysVinit-based systems, confirm that step 2 was applied correctly.
Note: Enabling
bootstrap.memory_lockcauses 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_counton 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"
Related documentation
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, default30) — timeout in seconds for index and search operations.plugins.setup.backoff(Integer, default15) — delay in seconds for the retry mechanism involving initialization tasks.plugins.setup.settings_update.enabled(Boolean, defaulttrue) — whenfalse, the settings update endpoint (PUT /_plugins/_setup/settings) returns403 Forbiddenfor every caller, regardless of role. See Protecting sensitive configuration for the full disable-endpoint pattern shared with Content Manager.
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, defaulthttps://api.pre.cloud.wazuh.com/api/v1) — base URL for the Wazuh CTI API.plugins.content_manager.catalog.sync_interval(Integer, default60, range 10–1440) — sync interval in minutes.plugins.content_manager.setup_wait.max_retries(Integer, default4, 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, default20, range 1–120) — base delay, in seconds, for the exponential backoff between those retries (delay for retrynisbase * 2^n; with the defaults, 20s/40s/80s/160s = 300s / 5 min worst case).plugins.content_manager.max_items_per_bulk(Integer, default999, range 10–999) — maximum documents per bulk indexing request.plugins.content_manager.max_concurrent_bulks(Integer, default5, range 1–5) — maximum concurrent bulk operations.plugins.content_manager.max_bulk_bytes(Long, default5242880/ 5 MB, range 1048576–104857600 / 1–100 MB) — maximum request body size, in bytes, for a single bulk indexing request.plugins.content_manager.client.timeout(Long, default10, range 10–50) — HTTP client timeout in seconds for CTI API requests.plugins.content_manager.client.max_retries(Integer, default3, 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, default30, range 1–300) — base delay, in seconds, for the exponential backoff used between 429 retries when the response carries no usableRetry-Afterheader (delay for retrynisbase * 2^n).plugins.content_manager.pit_keepalive(Long, default120, range 60–600) — point-in-time keepalive in seconds used during paginated index scans.plugins.content_manager.engine.mock(Boolean, defaultfalse) — bypasses real Engine socket calls, returning mocked responses instead. Intended for testing only.plugins.content_manager.catalog.update_on_start(Boolean, defaulttrue) — trigger content sync when the plugin starts.plugins.content_manager.catalog.update_on_schedule(Boolean, defaulttrue) — enable the periodic sync job.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, defaulttrue) — automatically create Security Analytics detectors from CTI content.plugins.content_manager.telemetry.enabled(Boolean, defaulttrue, dynamic) — enable or disable the daily Update check service ping.plugins.content_manager.catalog.update_on_demand(Boolean, defaulttrue) — whenfalse, on-demand content updates (POST /update) return403 Forbiddenfor every caller, regardless of role.plugins.content_manager.catalog.policy_update.enabled(Boolean, defaulttrue) — whenfalse, policy updates (PUT /policy/{space}) return403 Forbiddenfor every caller, regardless of role.plugins.content_manager.max_integrations(Integer, default100, minimum0, 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, default200, minimum0, 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, default200, minimum0, 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, default100, minimum0, 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, default100, minimum0, 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.
Security Analytics settings
The Security Analytics 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, defaultfalse) — enable rollover and retention management for the finding history indices.plugins.security_analytics.alert_finding_max_docs(Long, default1000, minimum0) — Deprecated. Maximum document count for a finding history index before rollover.plugins.security_analytics.alert_finding_rollover_period(Time, default12h) — how often the finding history rollover job runs.plugins.security_analytics.alert_history_enabled(Boolean, defaultfalse) — enable rollover and retention management for the alert history indices.plugins.security_analytics.alert_history_max_age(Time, default30d) — maximum age of an alert history index before rollover.plugins.security_analytics.alert_history_max_docs(Long, default1000, minimum0) — maximum document count for an alert history index before rollover.plugins.security_analytics.alert_history_retention_period(Time, default60d) — retention period after which alert history indices are deleted.plugins.security_analytics.alert_history_rollover_period(Time, default12h) — how often the alert history rollover job runs.plugins.security_analytics.auto_correlations_enabled(Boolean, defaultfalse) — automatically generate correlation rules from new findings.plugins.security_analytics.correlation.detector_cache_ttl(Time, default5m) — TTL for the in-memory monitor-id to detector cache. Set to0sto disable the cache.plugins.security_analytics.correlation.events_backpressure.enabled(Boolean, defaulttrue) — 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, default100, range 1–100) — backlog level, as a percent ofcorrelation.max_pending_findings, at or above which the events indices are write-blocked.plugins.security_analytics.correlation.events_backpressure.low_watermark_percent(Integer, default60, range 0–99) — backlog level, as a percent ofcorrelation.max_pending_findings, at or below which the events-index write block is lifted.plugins.security_analytics.correlation.max_in_flight_findings(Integer, default50, range 1–1000) — maximum number of correlation pipelines running concurrently.plugins.security_analytics.correlation.max_pending_findings(Integer, default10000, 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, default5m) — TTL for the in-memory caches of log-type list and correlation rules by detector type. Set to0sto disable both caches.plugins.security_analytics.correlation_history_max_age(Time, default30d) — maximum age of a correlation history index before rollover.plugins.security_analytics.correlation_history_max_docs(Long, default1000, minimum0) — maximum document count for a correlation history index before rollover.plugins.security_analytics.correlation_history_retention_period(Time, default60d) — retention period after which correlation history indices are deleted.plugins.security_analytics.correlation_history_rollover_period(Time, default12h) — how often the correlation history rollover job runs.plugins.security_analytics.correlation_time_window(Time, default5m) — time window used to group findings into correlations.plugins.security_analytics.enable_detectors_with_dedicated_query_indices(Boolean, defaulttrue) — create dedicated query indices for new detectors.plugins.security_analytics.enable_workflow_usage(Boolean, defaulttrue) — use Alerting composite workflows when running detectors.plugins.security_analytics.enriched_findings_bulk_size(Integer, default100, range 10–1000) — number of enriched findings accumulated before a bulk index request is fired.plugins.security_analytics.enriched_findings_enrich_batch_size(Integer, default100, 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, default5, 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, defaulttrue) — toggle the enriched findings pipeline (see Architecture).plugins.security_analytics.enriched_findings_max_in_flight(Integer, default5, range 1–10) — maximum number of concurrent async enrichment chains.plugins.security_analytics.enriched_findings_rule_cache_max_size(Integer, default10000, minimum0, 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, defaultfalse) — restrict access to detectors, rules, and findings based on the requester’s backend roles.plugins.security_analytics.finding_history_max_age(Time, default30d) — maximum age of a finding history index before rollover.plugins.security_analytics.finding_history_retention_period(Time, default60d) — retention period after which finding history indices are deleted.plugins.security_analytics.index_timeout(Time, default60s) — timeout for Security Analytics index operations.plugins.security_analytics.max_case_management_bulk_size(Integer, default10, range 0–100, dynamic) — maximum number of findings that can be updated in a single request to the update findings endpoint. Setting it to0disables the endpoint entirely.plugins.security_analytics.max_detectors(Integer, default10, minimum0, no upper bound, dynamic) — maximum number of user-created detectors (Content Manager detectors do not count).plugins.security_analytics.max_rules_per_detector(Integer, default50, minimum0, 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, defaultecs) — 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.
Indices
The following tables list the indices created by this plugin.
Stream indices
| Index | Description |
|---|---|
wazuh-events-raw-v5 | Stores original unprocessed events. |
wazuh-active-responses | Stores 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-agents | Stores statistics about the Wazuh Agents state. |
wazuh-metrics-comms-v4 | Stores 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-normalization | Stores statistics about the Wazuh Engine’s event normalization (decoding) stage. |
wazuh-ai-assistant-sessions | Stores the users’ conversations with the AI assistant. Rolled over daily, kept for 7 days. Each document carries the owning username in its user field; Document Level Security restricts every user to their own conversations. |
Stateful indices
| Index | Description |
|---|---|
wazuh-agent-config | Most recent configuration reported by each agent, per module. The existing document is overwritten on each report, so no history is kept. |
wazuh-agent-stats | Most recent statistics reported by each agent, keyed by module. The existing document is overwritten on each report, so no history is kept. |
wazuh-states-sca | Security Configuration Assessment (SCA) scan results. |
wazuh-states-fim-files | File Integrity Monitoring: information about monitored files. |
wazuh-states-fim-registry-keys | File Integrity Monitoring: information about the Windows registry (keys). |
wazuh-states-fim-registry-values | File Integrity Monitoring: information about the Windows registry (values). |
wazuh-states-inventory-browser-extensions | Stores browser extensions/add-ons detected on the endpoint (Chromium-based browsers — Chrome/Edge/Brave/Opera —, Firefox, and Safari). |
wazuh-states-inventory-groups | Stores existing groups on the endpoint. |
wazuh-states-inventory-hardware | Basic information about the hardware components of the endpoint. |
wazuh-states-inventory-hotfixes | Contains 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-interfaces | Stores information (up and down interfaces) as well as packet transfer information about the interfaces on a monitored endpoint. |
wazuh-states-inventory-networks | Stores the IPv4 and IPv6 addresses associated with each network interface, as referenced in the wazuh-states-inventory-interfaces index. |
wazuh-states-inventory-packages | Stores information about the currently installed software on the endpoint. |
wazuh-states-inventory-ports | Basic information about open network ports on the endpoint. |
wazuh-states-inventory-processes | Stores the detected running processes on the endpoints. |
wazuh-states-inventory-protocols | Stores routing configuration details for each network interface, as referenced in the wazuh-states-inventory-interfaces index. |
wazuh-states-inventory-services | Stores system services detected on the endpoint (Windows Services, Linux systemd units, and macOS launchd daemons/agents). |
wazuh-states-inventory-system | Operating system information, hostname and architecture. |
wazuh-states-inventory-users | Stores existing users on the endpoint. |
wazuh-states-vulnerabilities | Active vulnerabilities on the endpoint and its details. |
Internal indices
These indices support the plugin’s own operation rather than storing Wazuh data directly:
| Index | Description |
|---|---|
.wazuh-setup-status | Hidden, single-document index recording the plugin’s initialization state (running, ready, or failed). See Architecture. |
.wazuh-settings | Stores cluster-wide Wazuh settings managed through the API reference, such as the Engine’s raw-event indexing flag. |
.wazuh-internal-state | Hidden 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 index | Internal 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-indexerorrootuser 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-indexerorrootuser 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:
| Value | Meaning |
|---|---|
running | Index initialization is in progress (set at the start of index initialization, overwriting any marker left over from a previous boot). |
ready | All index templates, indices and data streams have been created successfully. |
failed | Index 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.
Readiness marker
The plugin persists its initialization state in a hidden, single-document index,
.wazuh-setup-status, managed by the SetupStatusIndex class. Other plugins (currently the
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:
| Value | Meaning |
|---|---|
running | Index initialization is in progress (set when SetupStatusIndex.initialize() runs at the start of index initialization, overwriting any marker left over from a previous boot). |
ready | All index templates, indices and data streams have been created successfully. |
failed | Index 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, default30) — timeout in seconds for index and search operations.plugins.setup.backoff(Integer, default15) — delay in seconds for the retry mechanism involving initialization tasks.plugins.setup.settings_update.enabled(Boolean, defaulttrue) — whenfalse, the settings update endpoint (PUT /_plugins/_setup/settings) returns403 Forbiddenfor every caller, regardless of role. See Protecting sensitive configuration for the full disable-endpoint pattern shared with Content Manager.
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 thewazuh-events-raw-v5data stream.
Example request
curl -sk -u admin:admin -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).
Documentation maintenance — modifications to the REST API must be reflected in both
openapi.ymland this file.
Wazuh Common Schema
The Wazuh Common Schema (WCS) is a standardized structure for organizing and categorizing security event data collected by Wazuh. It is designed to facilitate data analysis, correlation, and reporting across different data sources and types.
This page documents the event category taxonomy shared by the stateless event and finding data streams. WCS also covers stateful inventory indices (see Indices above), the metrics streams, and the content and CVE indices owned by the Content Manager plugin (see Content Manager) — those aren’t repeated here.
Categorization
The Wazuh Common Schema categorizes events into several key areas to streamline data management and analysis.
All event categories share a single base index template (events.json), and the same applies to findings categories with their own shared base (findings.json). At deployment time, the setup plugin generates one index template per category from the applicable shared base, overriding only two fields: index_patterns (set to wazuh-events-v5-<category>* or wazuh-findings-v5-<category>*) and, where present in the base template’s settings, rollover_alias (set to the category’s index name). The mappings are copied through unchanged — every category’s index template has identical mappings, since they all originate from the same base template. This means only one template file exists per stream family in the repository, but each category still gets its own index template registered in the cluster.
To list all deployed event templates:
GET /_index_template/wazuh-events-*
Categories
The Key column is the canonical identifier used throughout the system — in data stream names, integrations, rules, decoders, and the Security Analytics plugin. Use it exactly as shown when creating or referencing any of these resources.
| Name | Key | Example log types |
|---|---|---|
| Access Management | access-management | ad_ldap, apache_access, okta |
| Applications | applications | github, gworkspace, m365 |
| Cloud Services | cloud-services | azure, cloudtrail, s3 |
| Network Activity | network-activity | dns, network, vpcflow |
| Security | security | waf |
| System Activity | system-activity | linux, windows, others_macos |
| Other | other | others_application, others_apt, others_web |
| Unclassified | unclassified |
Data streams
Each category maps to a dedicated data stream following the pattern wazuh-events-v5-{key}:
Events
wazuh-events-v5-access-management
wazuh-events-v5-applications
wazuh-events-v5-cloud-services
wazuh-events-v5-network-activity
wazuh-events-v5-other
wazuh-events-v5-security
wazuh-events-v5-system-activity
wazuh-events-v5-unclassified
Findings
wazuh-findings-v5-access-management
wazuh-findings-v5-applications
wazuh-findings-v5-cloud-services
wazuh-findings-v5-network-activity
wazuh-findings-v5-other
wazuh-findings-v5-security
wazuh-findings-v5-system-activity
wazuh-findings-v5-unclassified
Check Stream indices for details.
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. If a custom CTI catalog URL is configured, it downloads the snapshot from that source; otherwise it uses the snapshot bundled with the Wazuh Indexer package, 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.
Both behaviors are configurable in opensearch.yml:
plugins.content_manager.catalog.update_on_start(Boolean, defaulttrue) — whether to check for updates when the plugin starts.plugins.content_manager.catalog.sync_interval(Integer, default60) — how often periodic sync runs, in minutes.
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:
| Space | Description |
|---|---|
| Standard | Read-only content synced from the CTI API. This is the baseline detection content. |
| Draft | Writable space for user-generated content. CUD operations target this space. |
| Test | Used for logtest operations and content validation before final promotion. |
| Custom | The 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:admin -X PUT \
"https://127.0.0.1:9200/_plugins/_content_manager/policy" \
-H 'Content-Type: application/json' \
-d '{"resource": { ... }}'
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):
- Preview changes:
GET /_plugins/_content_manager/promote?space=draftreturns a diff of what will change (additions, updates, deletions for each content type). - Execute promotion:
POST /_plugins/_content_manager/promotepromotes 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.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:
| Index | Description |
|---|---|
.wazuh-cti-consumers | Synchronization state for each CTI consumer type (type, resource, is_public, offsets, status) |
.wazuh-internal-state | Persisted CTI access token (hidden, single document) |
wazuh-threatintel-rules | Detection rules (both CTI-synced and user-generated, across all spaces) |
wazuh-threatintel-decoders | Log decoders |
wazuh-threatintel-integrations | Integration definitions |
wazuh-threatintel-kvdbs | Key-value databases |
wazuh-threatintel-policies | Routing policies |
wazuh-threatintel-enrichments | Indicators of Compromise (IoC) |
.wazuh-threatintel-vulnerabilities | Common Vulnerabilities and Exposures (CVE) data from CTI — hidden, no spaces, offset-tracked |
wazuh-threatintel-filters | Engine filters (routing filters for event classification) |
.wazuh-content-manager-jobs | Job 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 content from the CTI API, the Wazuh Indexer requires a valid CTI access token. The token is registered via the REST API:
- Store credentials by sending the CTI access token via
POST /_plugins/_content_manager/subscription. The token is persisted in the.wazuh-internal-statehidden index and loaded into memory. - The Content Manager uses the in-memory token for all CTI API requests.
- Without a registered token, sync operations return a
404 Token not founderror.
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 are rejected.
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). This applies to the Catalog API client, Snapshot client, and Telemetry client.
Job scheduler
Registers a periodic job that triggers content synchronization at a configurable interval (default: 60 minutes). The job metadata is stored in .wazuh-content-manager-jobs.
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 either a remote CTI snapshot (when a custom consumer URL is configured) or a 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.
Security Analytics service
Interfaces with the Security Analytics plugin. Creates, updates, and deletes Security Analytics 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: Security Analytics documents use their own auto-generated UUIDs as primary IDs, independent of the CTI document UUIDs. Each Security Analytics 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: Security Analytics enforces a configurable maximum number of rules per detector (
plugins.security_analytics.max_rules_per_detector, default50). If an integration has more enabled rules than the configured limit, the detector creation or update request will be rejected. See Security Analytics — 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).
Engine client
Communicates with the Wazuh Engine via Unix domain socket at /usr/share/wazuh-indexer/engine/sockets/engine-api.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)
→ If custom catalog URL is configured: try remote snapshot first
→ If remote init fails: fallback to local packaged snapshot
→ If no custom catalog URL: initialize from local packaged snapshot
→ Extracts and bulk-indexes into wazuh-threatintel-rules, wazuh-threatintel-decoders, etc.
→ Updates .wazuh-cti-consumers with new offset
→ Security Analytics service creates detectors using dynamic CTI configuration (max rules per detector configurable, default 50)
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
→ Security Analytics 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)
→ Space service routes to draft space
→ Writes to wazuh-threatintel-rules / wazuh-threatintel-decoders / wazuh-threatintel-integrations / wazuh-threatintel-kvdbs
→ 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, the error is logged but the operation (sync or REST update) still succeeds.
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 (draft → test only, and only when the
changeset includes decoders, kvdbs, or filters — promotions limited to
integrations, rules, or the policy skip the engine call)
→ 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 Security Analytics:
→ Adds use POST (new document)
→ Updates use PUT (existing document)
→ Delete removed integrations/rules from Security Analytics
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 operation | Old version | Rollback action |
|---|---|---|
| Add (apply) | none | Delete the newly created document |
| Update (apply) | exists | Restore the previous version |
| Delete | snapshot | Re-index the snapshotted document |
Individual rollback step failures are logged and skipped so remaining steps can proceed.
Security Analytics reconciliation
After the Content Manager rollback completes, a best-effort Security Analytics reconciliation runs in dependency order:
- Revert applied rules — adds are deleted from Security Analytics; updates are restored to the old version.
- Revert applied integrations — same as above.
- Restore deleted integrations — re-created from pre-deletion snapshots via POST.
- Restore deleted rules — same as above.
Security Analytics 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
→ Security Analytics 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., free → pro, 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
- Detection. During each sync cycle, the Content Manager compares the plan-provided catalog URL against the one stored locally. If they differ, a plan change is detected.
- Shadow download. New content is downloaded into hidden staging indices. These shadow indices are invisible to users, dashboards, and REST queries during the rebuild.
- User content preservation. Any user-created content (draft rules, test decoders, custom integrations, etc.) is copied from the live indices into the shadow indices.
- Atomic switch. Once the shadow indices are fully ready, all index aliases are swapped in a single atomic operation. Users see either the entire old content or the entire new content — never a mix or an empty state.
- Cleanup. The old indices are deleted, freeing the temporary disk space.
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, users continue to see the old content, and the system retries on the next scheduled sync. A failed swap is invisible to the end user.
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-rules | wazuh-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. Documents are differentiated by internal metadata fields that indicate their space membership. The document _id is a UUID assigned at creation time.
Example document structure in wazuh-threatintel-rules:
{
"_index": "wazuh-threatintel-rules-a",
"_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"_source": {
"title": "SSH brute force attempt",
"integration": "openssh",
"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
}
}
The status field reflects the consumer’s synchronization lifecycle:
| Value | Meaning |
|---|---|
ready | Sync is complete; content indices are up-to-date and safe to read. |
running | Sync is in progress; content may be partially written or inconsistent. |
failed | The previous sync cycle was interrupted by an unexpected exception. |
The status is set to running at the very start of a sync cycle and transitions to ready after all post-sync work finishes — including hash recalculation, Security Analytics Plugin 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.
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, defaulthttps://api.pre.cloud.wazuh.com/api/v1) — base URL for the Wazuh CTI API.plugins.content_manager.catalog.sync_interval(Integer, default60, range 10–1440) — sync interval in minutes.plugins.content_manager.setup_wait.max_retries(Integer, default4, 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, default20, range 1–120) — base delay, in seconds, for the exponential backoff between those retries (delay for retrynisbase * 2^n; with the defaults, 20s/40s/80s/160s = 300s / 5 min worst case).plugins.content_manager.max_items_per_bulk(Integer, default999, range 10–999) — maximum documents per bulk indexing request.plugins.content_manager.max_concurrent_bulks(Integer, default5, range 1–5) — maximum concurrent bulk operations.plugins.content_manager.max_bulk_bytes(Long, default5242880/ 5 MB, range 1048576–104857600 / 1–100 MB) — maximum request body size, in bytes, for a single bulk indexing request.plugins.content_manager.client.timeout(Long, default10, range 10–50) — HTTP client timeout in seconds for CTI API requests.plugins.content_manager.client.max_retries(Integer, default3, 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, default30, range 1–300) — base delay, in seconds, for the exponential backoff used between 429 retries when the response carries no usableRetry-Afterheader (delay for retrynisbase * 2^n).plugins.content_manager.pit_keepalive(Long, default120, range 60–600) — point-in-time keepalive in seconds used during paginated index scans.plugins.content_manager.engine.mock(Boolean, defaultfalse) — bypasses real Engine socket calls, returning mocked responses instead. Intended for testing only.plugins.content_manager.catalog.update_on_start(Boolean, defaulttrue) — trigger content sync when the plugin starts.plugins.content_manager.catalog.update_on_schedule(Boolean, defaulttrue) — enable the periodic sync job.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, defaulttrue) — automatically create Security Analytics detectors from CTI content.plugins.content_manager.telemetry.enabled(Boolean, defaulttrue, dynamic) — enable or disable the daily Update check service ping.plugins.content_manager.catalog.update_on_demand(Boolean, defaulttrue) — whenfalse, on-demand content updates (POST /update) return403 Forbiddenfor every caller, regardless of role.plugins.content_manager.catalog.policy_update.enabled(Boolean, defaulttrue) — whenfalse, policy updates (PUT /policy/{space}) return403 Forbiddenfor every caller, regardless of role.plugins.content_manager.max_integrations(Integer, default100, minimum0, 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, default200, minimum0, 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, default200, minimum0, 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, default100, minimum0, 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, default100, minimum0, 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.
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
On online installations, manual synchronization can be performed on demand using the Content Manager API:
POST /_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
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"
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:
- If a setting is non-empty, Content Manager attempts remote snapshot initialization first.
- If remote initialization fails, it falls back to the local packaged snapshot when available.
- If a setting is empty, initialization uses the local packaged snapshot directly.
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 Security Analytics detector creation
If you do not use the OpenSearch Security Analytics 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:
User-Agent: Wazuh Indexer <version>
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>)
- Deployment identifier (
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:admin -X PUT "https://192.168.56.6: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}— permissioncluster:admin/content_manager/policy/update.POST /_plugins/_content_manager/update— permissioncluster:admin/content_manager/update/trigger.PUT /_plugins/_setup/settings— permissionplugin:wazuh/settings/write.
-
RBAC — each endpoint is gated by a cluster permission (the action name above), enforced by the security plugin. Among the bundled users, only
wazuh-adminholds these permissions;wazuh-manager,wazuh-demoandwazuh-readonlyare excluded. The superuseradmin(roleall_access, cluster wildcard*) also holds 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. -
Per-endpoint disable settings — each endpoint can be disabled independently with its own node setting; when disabled it returns
403 Forbiddenfor every caller, includingadmin/all_access. This is intended for externally managed (e.g. Wazuh Cloud) deployments.POST /_plugins/_content_manager/update— disable viaplugins.content_manager.catalog.update_on_demand: false.PUT /_plugins/_content_manager/policy/{space}— disable viaplugins.content_manager.catalog.policy_update.enabled: false.PUT /_plugins/_setup/settings— disable viaplugins.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 -X PUT "https://localhost: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.ymlrequire 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 includeplugins.content_manager.telemetry.enabledand 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, andplugins.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.
- 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 Security Analytics detectors (interval, enabled status, and source indices) are managed directly via CTI integration files. If an integration’s
detectorobject 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
- YAML content-type support
- Subscription management
- Content updates
- Logtest
- Policy
- Rules
- Decoders
- Filters
- Integrations
- KVDBs
- Promotion
- Spaces
- Version check
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
dateandmodifiedare optional. If provided, the value is used as-is; if omitted, the server generates it from the current time. - On update —
dateis always read-only: any caller-supplied value is ignored, and the resource’s original creation date is preserved from the existing document.modifiedstill 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
yamlfield is generated from theresourcesubtree of the parsed envelope. - JSON requests: the
yamlfield 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.
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:admin -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": "Credentials received",
"status": 201
}
Status codes
- 201 — credentials stored successfully.
- 400 — missing or empty
access_tokenfield. - 412 — a required precondition is not met (for example, the credentials index is not declared as a system index — see
plugins.security.system_indices.indicesinopensearch.yml). - 500 — internal error.
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 free plan is returned.
If the stored token is rejected by the CTI API (e.g. expired or revoked), the credentials document is deleted automatically, the in-memory token is cleared, and the response falls back to the public free plan as if the instance were unregistered.
Request
- Method:
GET - Path:
/_plugins/_content_manager/subscription
Example request
curl -sk -u admin:admin -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": "Free",
"is_public": true
},
"is_registered": false
},
"status": 200
}
Status codes
- 200 — subscription status returned successfully.
- 500 — internal error.
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:admin -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. Requires a valid subscription.
Request
- Method:
POST - Path:
/_plugins/_content_manager/update
Example request
curl -sk -u admin:admin -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 (no credentials)
{
"message": "Token not found. Please create a subscription before attempting to update.",
"status": 404
}
Example response (update in progress)
{
"message": "A content update is already in progress.",
"status": 409
}
Status codes
- 202 — update request accepted for processing.
- 404 — no access token registered.
- 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 Security Analytics 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, orALL.
Example request
curl -sk -u admin:admin -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:admin -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:messageandcode.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 conditions that matched.
Status codes
- 200 — logtest executed (check inner status fields).
- 400 — missing/invalid fields or integration not found.
- 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, orALL.
Example request
curl -sk -u admin:admin -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.
- 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 Security Analytics 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
inputfield 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:admin -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 matched conditions.
Status codes
- 200 — detection executed (check
message.status). - 400 — missing/invalid fields or integration not found.
- 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
integrationsandfiltersarrays 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 fieldsauthor,description,documentation, andreferencesare required in addition to the boolean fields. - Standard space (
/policy/standard): onlyenrichments,filters,enabled,index_unclassified_events, andindex_discarded_eventscan 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 (draftorstandard).
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.datecannot 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:admin -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:admin -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
resourcefield, missing required fields, invalid enrichments, or disallowed modification ofintegrations/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.productfield must exactly match themetadata.titleof the parent integration.- 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 Security Analytics plugin.
The rule is also synchronized to Security Analytics, 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 source 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).sigma_id(String, optional) — Sigma rule ID.enabled(Boolean, optional) — whether the rule is enabled.status(String, optional) — rule status (e.g.,experimental,stable).level(String, optional) — alert level (e.g.,low,medium,high,critical).logsource(Object, optional) — log source definition (product,category).detection(Object, optional) — Sigma detection logic withconditionand selection fields.mitre(Object, optional) — MITRE ATT&CK mapping (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:admin -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": ["TA0001"],
"technique": ["T1190"],
"subtechnique": []
},
"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, duplicate title, integration not in draft space, validation failure, or
max_ruleslimit reached (default: 100). - 500 — internal error or Security Analytics 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, exceptmetadata.date, which is read-only on update; see Timestamps on create and update).
Example request
curl -sk -u admin:admin -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.
- 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:admin -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) — decoder name identifier (e.g.,decoder/core-wazuh-message/0).enabled(Boolean) — whether the decoder is enabled.check(Array) — 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) — normalization rules — array of mapping objects.metadata(Object) — 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:admin -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:admin -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 — issing
integrationfield, integration not in draft space, Engine validation failure, ormax_decoderslimit reached (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, exceptmetadata.date, which is read-only on update; see Timestamps on create and update).
Example request
curl -sk -u admin:admin -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": "Hello there"
},
"check": [],
"normalize": []
}
}'
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 — 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:admin -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:draftorstandard.resource(Object, required) — the filter definition.
Fields within resource:
name(String) — filter name identifier (e.g.,filter/prefilter/0).enabled(Boolean) — whether the filter is enabled.check(String) — filter check expression.type(String) — filter type (e.g.,pre-filter).metadata(Object) — filter metadata (see below).
Fields within metadata:
description(String) — filter description.author(String) — author name, stored as a keyword.date,modified(String, optional) — see Timestamps on create and update.
Example request
curl -sk -u admin:admin -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": {
"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:admin -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:
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
spacefield, invalid space, Engine validation failure, ormax_filterslimit 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:draftorstandard.resource(Object, required) — updated filter definition (same fields as create, exceptmetadata.date, which is read-only on update; see Timestamps on create and update).
Example request
curl -sk -u admin:admin -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": {
"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 — 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:admin -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 Security Analytics plugin.
The integration is also synchronized to Security Analytics, 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 source 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) — category (e.g.,cloud-services,network-activity,security,system-activity).enabled(Boolean, optional) — whether the integration is enabled.
Fields within resource.metadata:
title(String, required) — integration title (must be unique in draft space).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
idfield — it is auto-generated by the Indexer.
Example request
curl -sk -u admin:admin -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), duplicate title, validation failure, ormax_integrationslimit reached (default: 100). - 500 — internal error or Security Analytics/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 (all required for update):
title(String) — integration title.author(String) — author.description(String) — description.documentation(String) — documentation text or URL.references(Array) — reference URLs.modified(String, optional) — see Timestamps on create and update.datecannot be modified on update; any caller-supplied value is ignored.
Example request
curl -sk -u admin:admin -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, not in draft space, or duplicate title.
- 404 — integration not found.
- 500 — internal error.
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:admin -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).name(String, optional) — KVDB identifier name.enabled(Boolean, optional) — whether the KVDB is enabled.
Fields within resource.metadata:
title(String, required) — KVDB title.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:admin -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"
]
},
"name": "non_standard_timezones",
"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:admin -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"
name: non_standard_timezones
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
integrationor required resource fields, integration not in draft space, ormax_kvdbslimit reached (default: 100). - 500 — internal error.
Update KVDB
Updates an existing KVDB in the draft space. All fields within resource are required on update.
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 (all required for update):
metadata(Object) — KVDB metadata (see below).content(Object) — key-value data.name(String) — KVDB identifier name.enabled(Boolean) — whether the KVDB is enabled.
Fields within resource.metadata (all required for update):
title(String) — KVDB title.author(String) — author.description(String) — description.documentation(String) — documentation.references(Array) — reference URLs.modified(String, optional) — see Timestamps on create and update.datecannot be modified on update; any caller-supplied value is ignored.
Example request
curl -sk -u admin:admin -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"
]
},
"name": "test-UPDATED",
"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 — 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:admin -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:draftortest.
Example request
curl -sk -u admin:admin \
"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, orremove.id: document ID of the affected resource.
Status codes
- 200 — preview returned successfully.
- 400 — invalid or missing
spaceparameter. - 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).
For Draft → Test promotions, the changeset is forwarded to the local Wazuh Engine for validation only when it includes decoders, kvdbs, or filters. Promotions limited to integrations, rules, or the policy skip the engine call entirely. Test → Custom promotions never invoke the engine.
In addition to copying documents across CTI indices, promotion also synchronizes integrations and rules with the Security Analytics 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.idfield storing the original CTI document UUID for cross-reference. - A
sourcefield 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 Security Analytics 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:
- Pre-promotion snapshots are captured before any writes — old versions for adds/updates, full documents for deletes.
- 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.
- Security Analytics 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:draftortest.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.
Example request
curl -sk -u admin:admin -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 or missing
spacefield. - 500 — Engine communication error or validation failure.
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) that belong to the given space.
- Re-generate the default policy for the given space.
Note: Only the
draftspace 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:admin -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.
Request
- Method:
GET - Path:
/_plugins/_content_manager/version/check
Example request
curl -sk -u admin:admin \
"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
}
Status codes
- 200 — version check completed (may include updates or empty).
- 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.
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
- Draft: Create your integration, decoders, and rules.
- Test: Promote to the test space and validate with logtest.
- 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 Security Analytics plugin evaluates your Sigma rules against the normalized output. The combined result shows exactly what was decoded and which rules matched.
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:admin -X POST \
"https://localhost:9200/_plugins/_content_manager/integrations" \
-H 'Content-Type: application/json' \
-d '{
"resource": {
"category": "endpoint-security",
"enabled": true,
"metadata": {
"title": "SSH Brute Force Detection",
"author": "Security Team",
"description": "Detects SSH brute force attempts from auth logs.",
"references": ["https://attack.mitre.org/techniques/T1110/"]
}
}
}'
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:admin -X POST \
"https://localhost: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:
curl -sk -u admin:admin -X POST \
"https://localhost: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": "linux",
"category": "authentication"
},
"detection": {
"condition": "selection",
"selection": {
"event.category": "authentication",
"event.outcome": "failure"
}
},
"level": "medium",
"tags": ["attack.credential-access", "attack.t1110.001"],
"mitre": {
"tactic": ["TA0006"],
"technique": ["T1110"],
"subtechnique": ["T1110.001"]
}
}
}'
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:admin \
"https://localhost:9200/_plugins/_content_manager/promote?space=draft"
# 2. Execute the promotion (use the changes from the preview response)
curl -sk -u admin:admin -X POST \
"https://localhost:9200/_plugins/_content_manager/promote" \
-H 'Content-Type: application/json' \
-d '{
"space": "draft",
"changes": { ... }
}'
Step 5: run logtest
Send a sample event to validate your detection pipeline:
curl -sk -u admin:admin -X POST \
"https://localhost: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:
- Decoder not matching? Check
asset_traces— if your decoder isn’t listed, review thecheckconditions. Usetrace_level: ALLto see which decoders were attempted. - Rule not matching? Compare the normalized event fields with your rule’s
detectionblock. Field names and values must match exactly (case-insensitive for strings). - Unexpected matches? Review
matched_conditionsto understand why a rule triggered.
After making changes:
- Update the rule or decoder via
PUTon 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:admin \
"https://localhost:9200/_plugins/_content_manager/promote?space=test"
# Execute
curl -sk -u admin:admin -X POST \
"https://localhost:9200/_plugins/_content_manager/promote" \
-H 'Content-Type: application/json' \
-d '{
"space": "test",
"changes": { ... }
}'
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
normalizeoutput with the Wazuh Common Schema (WCS) — e.g.,event.category,source.ip,user.name. - Set appropriate severity levels. Use
informationalfor visibility rules,low/mediumfor suspicious activity, andhigh/criticalonly 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: ALLwhen 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:admin -X POST \
"https://localhost:9200/_plugins/_content_manager/logtest/normalization" \
-H 'Content-Type: application/json' \
-d '{
"space": "test",
"queue": 1,
"location": "/var/log/auth.log",
"input": "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:admin -X POST \
"https://localhost: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
| Action | Endpoint | Method |
|---|---|---|
| Create integration | /_plugins/_content_manager/integrations | POST |
| Create decoder | /_plugins/_content_manager/decoders | POST |
| Create rule | /_plugins/_content_manager/rules | POST |
| Update rule | /_plugins/_content_manager/rules/{id} | PUT |
| Preview promotion | /_plugins/_content_manager/promote?space={space} | GET |
| Execute promotion | /_plugins/_content_manager/promote | POST |
| Run logtest (combined) | /_plugins/_content_manager/logtest | POST |
| Normalization only | /_plugins/_content_manager/logtest/normalization | POST |
| Detection only | /_plugins/_content_manager/logtest/detection | POST |
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
“Error communicating with Engine socket: Connection refused”
The Wazuh Engine is not running or the Unix socket is not accessible.
Resolution:
-
Check the socket file exists:
ls -la /usr/share/wazuh-indexer/engine/sockets/engine-api.sock -
Ensure the Wazuh Indexer process has permission to access the socket file.
“Token not found”
No CTI access token has been registered. The Content Manager cannot sync content without a valid token.
Resolution
Register credentials by posting the CTI access token:
curl -sk -u admin:admin -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":"Credentials received","status":201}. The token is persisted in .wazuh-internal-state and loaded into memory immediately.
Sync not running
Content is not being updated despite having a valid subscription.
Diagnosis
-
Check consumer state and offsets:
curl -sk -u admin:admin \ "https://127.0.0.1:9200/.wazuh-cti-consumers/_search?pretty"If
local_offsetequalsremote_offset, the content is already up-to-date. -
Check the sync job is registered and enabled:
curl -sk -u admin:admin \ "https://127.0.0.1:9200/.wazuh-content-manager-jobs/_search?pretty"Verify the job has
"enabled": trueand the schedule interval matches your configuration. -
Check if scheduled sync is enabled in
opensearch.yml:plugins.content_manager.catalog.update_on_schedule: true -
Trigger a manual sync to test:
curl -sk -u admin:admin -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.sock
Resolution
- Verify the Wazuh Engine is installed and running.
- Check the Engine configuration for the socket path.
- 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
- 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).
- 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_retriesand/orplugins.content_manager.client.retry_backoff_base_seconds. - Look for the warning
CTI API rate-limited request [...] (HTTP 429); retrying in {}sin the logs to confirm the retry path is engaging.
Diagnostic commands
Check consumer state
View synchronization state for all content contexts:
curl -sk -u admin:admin \
"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
}
}
]
}
}
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 recordfailed.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.local_offset < remote_offset: Content needs updating.local_offset == 0: Content has never been synced (snapshot required).
Check sync job
View the periodic sync job configuration:
curl -sk -u admin:admin \
"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:admin "https://127.0.0.1:9200/wazuh-threatintel-rules/_count?pretty"
# Decoders
curl -sk -u admin:admin "https://127.0.0.1:9200/wazuh-threatintel-decoders/_count?pretty"
# Integrations
curl -sk -u admin:admin "https://127.0.0.1:9200/wazuh-threatintel-integrations/_count?pretty"
# KVDBs
curl -sk -u admin:admin "https://127.0.0.1:9200/wazuh-threatintel-kvdbs/_count?pretty"
# IoCs
curl -sk -u admin:admin "https://127.0.0.1:9200/wazuh-threatintel-enrichments/_count?pretty"
Job scheduling on startup
During node startup, scheduleCatalogSyncJob and scheduleTelemetryPingJob 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 3 times with a linear backoff (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 3 attempts. and the job will only be retried on the next node start. 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 document and restart the indexer:
# Delete consumer state (forces snapshot on next sync)
curl -sk -u admin:admin -X DELETE \
"https://127.0.0.1:9200/.wazuh-cti-consumers/_doc/*"
# 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 and analyzes 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, visualization, or notebook.
Generated reports are PDF or PNG for dashboards/visualizations/notebooks, 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, default50, minimum0, 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, default60000, min100) — timeout in milliseconds for report generation operations.opensearch.reports.general.defaultItemsQueryCount(Integer, default100, min10) — default number of items fetched per query when building report data.
To change it at runtime:
curl -sk -u admin:admin -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 Index 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 modify and map roles on the Wazuh Indexer can be found in the Wazuh Indexer documentation.
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 Notifications > Channels and click on Create channel:
- Fill in a name (e.g
Email notifications). - Select Email as Channel Type.
- Check SMTP sender as Sender Type.
- Click on Create SMTP sender.
- Fill in a name (e.g
mailpit). - Fill in an email address.
- In Host, type
mailpit(adapt this to your SMTP server Domain Name). - For port, type 1025 (adapt this to your SMTP server settings).
- Select None as Encryption method.
- Click on Create.
- Fill in a name (e.g
- Click on Create recipient group.
- Fill in a name (e.g
email-notifications-recipient-group). - On Emails, type any email.
- Click on Create.
- Fill in a name (e.g
The fields should now be filled in as follows:

- Click on Send test message to validate the configuration, a green message should pop up.
- 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 -> Report.
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, a search or a Notebook. 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.
Security Analytics
The Security Analytics plugin is a fork of the OpenSearch Security Analytics plugin adapted for Wazuh. It evaluates incoming events against Sigma detection rules, creates findings when rules match, and correlates related findings across detectors.
The Security Analytics plugin runs inside the Wazuh Indexer and operates as an OpenSearch plugin, using the standard OpenSearch transport layer for all internal communication.
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_detectorrules (custom or pre-packaged), default50. Requests that exceed this limit are rejected with HTTP 400. - Max detectors — at most
plugins.security_analytics.max_detectorsuser-created detectors are allowed, default10. 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.
Wazuh enriched findings
What is a finding?
A finding is a record that a monitored event matched a Sigma detection rule. Security Analytics creates one finding per matching event and stores it in the .opensearch-sap-{category}-findings-* data stream. Each finding contains:
id— unique finding identifier.detector_id— 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.
What is an enriched finding?
An enriched finding is an augmented version of a raw Security Analytics 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)
- Rule metadata under
wazuh.rule: name, severity level, compliance mappings, MITRE ATT&CK tags
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.
How findings are generated (high level)
The following steps happen for every event that matches a detection rule:
- A Wazuh Manager sends an event to the Wazuh Indexer. The event is indexed in the monitored data stream.
- The Security Analytics plugin’s Alerting monitor evaluates the event against all active Sigma rules for the configured log category.
- On a match, Security Analytics creates a raw finding and queues it for enrichment.
- 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 Security Analytics 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, log types) 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 100-rule-per-detector limit — see Detector rule space restriction and Detector constraints above.
Architecture
Enrichment pipeline
When a Sigma rule matches an event, Security Analytics writes a raw finding, and an asynchronous enrichment step fetches the triggering event and the matching rule’s metadata, assembles an enriched document, and bulk-indexes it into wazuh-findings-v5-{category}*.
The complete flow is shown in the sequence diagram below:
sequenceDiagram
participant A as Wazuh Manager
participant I as Wazuh Indexer
participant SA as Security Analytics
participant SI as Source index
participant RI as Rules index
participant WF as wazuh-findings-v5-{category}*
A->>I: Ingest event
I->>SA: Monitor evaluates event against Sigma rules
SA->>SA: Rule matches → create raw finding
SA->>SA: Queue finding for enrichment
SA->>SI: Fetch triggering event by document ID
SI-->>SA: Event source
SA->>SA: Resolve log category from the event
alt Rule metadata cached
SA->>SA: Read from in-memory cache
else Cache miss
SA->>RI: Fetch rule metadata (pre-packaged + custom rules indices)
RI-->>SA: Rule metadata
SA->>SA: Cache the result
end
SA->>SA: Assemble enriched document
alt Batch full
SA->>WF: Bulk-index accumulated findings
else Periodic flush
SA->>WF: Bulk-index accumulated findings
end
Enrichment is fire-and-forget: it never blocks the write path for 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.
Any change in the CTI catalog is reflected in detector configuration without requiring code changes or restarts.
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
| Index | Description |
|---|---|
.opensearch-sap-{category}-findings-* | Raw findings written by the Security Analytics plugin |
.opensearch-sap-pre-packaged-rules-config | Wazuh-provided Sigma rules; source for rule metadata |
.opensearch-sap-custom-rules-config | User-created custom rules; fallback source for rule metadata |
.opensearch-sap-log-types-config | Integrations |
.opensearch-sap-detectors-config | Threat detector configurations |
wazuh-findings-v5-{category}* | Enriched findings |
Access control
Access to Security Analytics 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).
wazuh_admin— full access: create/update/delete detectors, rules, log types, and correlations; read findings and alerts.wazuh_demo— full access, same endpoints aswazuh_admin.wazuh_readonly— read-only: get/search detectors, rules, findings, alerts, mappings, correlations, and threat intel;rules/evaluate.wazuh_manager— no access.
Configuration
Security Analytics settings
The Security Analytics 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, defaultfalse) — enable rollover and retention management for the finding history indices.plugins.security_analytics.alert_finding_max_docs(Long, default1000, minimum0) — Deprecated. Maximum document count for a finding history index before rollover.plugins.security_analytics.alert_finding_rollover_period(Time, default12h) — how often the finding history rollover job runs.plugins.security_analytics.alert_history_enabled(Boolean, defaultfalse) — enable rollover and retention management for the alert history indices.plugins.security_analytics.alert_history_max_age(Time, default30d) — maximum age of an alert history index before rollover.plugins.security_analytics.alert_history_max_docs(Long, default1000, minimum0) — maximum document count for an alert history index before rollover.plugins.security_analytics.alert_history_retention_period(Time, default60d) — retention period after which alert history indices are deleted.plugins.security_analytics.alert_history_rollover_period(Time, default12h) — how often the alert history rollover job runs.plugins.security_analytics.auto_correlations_enabled(Boolean, defaultfalse) — automatically generate correlation rules from new findings.plugins.security_analytics.correlation.detector_cache_ttl(Time, default5m) — TTL for the in-memory monitor-id to detector cache. Set to0sto disable the cache.plugins.security_analytics.correlation.events_backpressure.enabled(Boolean, defaulttrue) — 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, default100, range 1–100) — backlog level, as a percent ofcorrelation.max_pending_findings, at or above which the events indices are write-blocked.plugins.security_analytics.correlation.events_backpressure.low_watermark_percent(Integer, default60, range 0–99) — backlog level, as a percent ofcorrelation.max_pending_findings, at or below which the events-index write block is lifted.plugins.security_analytics.correlation.max_in_flight_findings(Integer, default50, range 1–1000) — maximum number of correlation pipelines running concurrently.plugins.security_analytics.correlation.max_pending_findings(Integer, default10000, 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, default5m) — TTL for the in-memory caches of log-type list and correlation rules by detector type. Set to0sto disable both caches.plugins.security_analytics.correlation_history_max_age(Time, default30d) — maximum age of a correlation history index before rollover.plugins.security_analytics.correlation_history_max_docs(Long, default1000, minimum0) — maximum document count for a correlation history index before rollover.plugins.security_analytics.correlation_history_retention_period(Time, default60d) — retention period after which correlation history indices are deleted.plugins.security_analytics.correlation_history_rollover_period(Time, default12h) — how often the correlation history rollover job runs.plugins.security_analytics.correlation_time_window(Time, default5m) — time window used to group findings into correlations.plugins.security_analytics.enable_detectors_with_dedicated_query_indices(Boolean, defaulttrue) — create dedicated query indices for new detectors.plugins.security_analytics.enable_workflow_usage(Boolean, defaulttrue) — use Alerting composite workflows when running detectors.plugins.security_analytics.enriched_findings_bulk_size(Integer, default100, range 10–1000) — number of enriched findings accumulated before a bulk index request is fired.plugins.security_analytics.enriched_findings_enrich_batch_size(Integer, default100, 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, default5, 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, defaulttrue) — toggle the enriched findings pipeline (see Architecture).plugins.security_analytics.enriched_findings_max_in_flight(Integer, default5, range 1–10) — maximum number of concurrent async enrichment chains.plugins.security_analytics.enriched_findings_rule_cache_max_size(Integer, default10000, minimum0, 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, defaultfalse) — restrict access to detectors, rules, and findings based on the requester’s backend roles.plugins.security_analytics.finding_history_max_age(Time, default30d) — maximum age of a finding history index before rollover.plugins.security_analytics.finding_history_retention_period(Time, default60d) — retention period after which finding history indices are deleted.plugins.security_analytics.index_timeout(Time, default60s) — timeout for Security Analytics index operations.plugins.security_analytics.max_case_management_bulk_size(Integer, default10, range 0–100, dynamic) — maximum number of findings that can be updated in a single request to the update findings endpoint. Setting it to0disables the endpoint entirely.plugins.security_analytics.max_detectors(Integer, default10, minimum0, no upper bound, dynamic) — maximum number of user-created detectors (Content Manager detectors do not count).plugins.security_analytics.max_rules_per_detector(Integer, default50, minimum0, 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, defaultecs) — 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_*).
plugins.security_analytics.alert_finding_max_docsis deprecated. Configure finding history rollover through the otherfinding_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; raw Security Analytics findings continue to be written to .opensearch-sap-{category}-findings-*, but no wazuh-findings-v5-{category}* documents are produced.
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 to0to 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 ofbulk_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_findingscaps how many findings may wait for a free correlation slot (the slots themselves are limited bycorrelation.max_in_flight_findings). When the backlog is full andevents_backpressureis 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 reacheshigh_watermark_percent, so no new events are indexed and the backlog can drain; the block is lifted atlow_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 Security Analytics setting is dynamic. To change one without restarting the node, use the Cluster Settings API:
curl -sk -u admin:admin -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.ymlrequire 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.correlationis an index-scope setting and must be applied to individual indices (for example, via an index template or the_settingsAPI), 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 Security Analytics 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:
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: python
detection:
keywords:
- DataError
- IntegrityError
- ProgrammingError
- OperationalError
condition: keywords
falsepositives:
- Application bugs
mitre:
tactic:
- TA0001
technique:
- T1190
subtechnique: []
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, required) — globally unique rule identifier (UUIDv4 recommended).status(String, required) — rule maturity status:experimental,test, orstable.level(String, required) — alert severity:informational,low,medium,high, orcritical.sigma_id(String, optional) — original Sigma rule identifier (UUID), preserved when importing from upstream.enabled(Boolean, optional, defaulttrue) — 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.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
keywordslist. - A
conditionfield that references those selections.
Important
All fields referenced in the
detectionsection 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
| Operator | Description | Example |
|---|---|---|
and | Both operands must match | selection1 and selection2 |
or | At least one operand must match | sel_error or sel_warn |
not | Negates the following operand | selection 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.
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. It helps organize rules by their applicable data source but does not affect detection matching directly.
product
Required
The product or platform generating the log (e.g., linux, windows, python). Must hold the same value as metadata.title from the integration it belongs to.
logsource:
product: linux
category
Optional
A broad classification of the log type within the product (e.g., authentication, process_creation, application, webserver, firewall). Useful for grouping related rules across products.
logsource:
category: authentication
service
Optional
The specific service, daemon, or log channel within the product (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, andservice.
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. Each field is an array of ID strings:
tactic
MITRE tactic IDs (e.g.,TA0002,TA0005).technique
MITRE technique IDs (e.g.,T1059,T1562).subtechnique
MITRE subtechnique IDs (e.g.,T1059.001).
Example
mitre:
tactic:
- TA0002
- TA0005
technique:
- T1059
- T1562
subtechnique:
- T1059.001
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
GDPRpci_dss
PCI DSScmmc
CMMCnist_800_53
NIST 800-53nist_800_171
NIST 800-171hipaa
HIPAAiso_27001
ISO 27001nis2
NIS2tsc
TSCfedramp
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 the wazuh-findings-v5-{logtype}-* index 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 rule object:
titletagsmitre.tactic,mitre.technique,mitre.subtechniquecompliance.*(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:
- TA0040
technique:
- T1499
subtechnique:
- T1499.004
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 resulting enriched finding contains:
{
"title": "Apache segmentation fault in agent 001",
"tags": ["attack.impact", "attack.t1499.004", "web-prod-01"],
"mitre": {
"tactic": ["TA0040"],
"technique": ["T1499"],
"subtechnique": ["T1499.004"]
},
"compliance": {
"pci_dss": ["6.2", "11.4"]
}
}
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,mitre.*, andcompliance.*. - 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 inrule.tags.
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-{logtype}-*. 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 Security Analytics 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, Security Analytics 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 ofactive,acknowledged,completed,error,deleted,audit(lowercase).wazuh.case.severity(keyword) — one ofinformational,low,medium,high,critical(lowercase).wazuh.case.priority(keyword) — one oflow,medium,high,urgent(lowercase).wazuh.case.tlp(keyword) — Traffic Light Protocol classification. One ofTLP:RED,TLP:AMBER,TLP:GREEN,TLP:CLEAR— uppercase, with theTLP:prefix, unlike the other enum fields.wazuh.case.comments(nested) — array of comment objects (replaces the earlier singlecommentfield). 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.
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,comments[].created_at, andcomments[].updated_atare automatically managed by the Wazuh Dashboard. They should not be set manually.
findings(required) — array of finding updates. Maximum size is controlled byplugins.security_analytics.max_case_management_bulk_size(default10, dynamic; see Configuration). Setting it to0disables this endpoint entirely — every request is rejected with400 Bad Request.findings[]._id(required) — document ID of the finding.findings[]._index(required) — index where the finding is stored.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": "wazuh-findings-v5-threat-000001",
"status": 200,
"result": "updated"
}
]
}
Error responses
- 400 — invalid JSON, missing required fields, empty array, unknown or invalid
casefield, exceeding the configured bulk-size limit, or case management disabled (limit set to0). - 207 — partial failure; some items succeeded, some failed (e.g., document not found).
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
curlfor illustration purposes.
# 1. Classify and acknowledge a finding
curl -sk -u admin:admin -X PUT "https://127.0.0.1:9200/_plugins/_security_analytics/findings/_update" \
-H "Content-Type: application/json" \
-d '{
"findings": [{
"_id": "finding-001",
"_index": "wazuh-findings-v5-threat-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:admin -X PUT "https://127.0.0.1:9200/_plugins/_security_analytics/findings/_update" \
-H "Content-Type: application/json" \
-d '{
"findings": [{
"_id": "finding-001",
"_index": "wazuh-findings-v5-threat-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:
# Get all acknowledged findings
curl -sk -u admin:admin -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.
Key capabilities
- Multi-channel delivery: Send notifications to Slack, Microsoft Teams, Chime, Email (SMTP and AWS SES), AWS SNS, and custom HTTP webhooks.
- 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 ansmtp_accountorses_accountconfiguration.sns(AWS SNS SDK) — publishes a message to an Amazon SNS topic.smtp_account— defines SMTP server connection details (host, port, method, credentials).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.
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 URLhttps://hooks.slack.com/services/YOUR_WORKSPACE_ID/YOUR_CHANNEL_ID/YOUR_WEBHOOK_TOKEN. - Jira Channel (
webhook) — targets Jira Cloud, default URLhttps://your-domain.atlassian.net/rest/api/3/issue. - PagerDuty Channel (
webhook) — targets the PagerDuty Events API v2, default URLhttps://events.pagerduty.com/v2/enqueue. - Shuffle Channel (
webhook) — targets Shuffle SOAR, default URLhttps://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:
- Retrieve the channel configuration using the List Notification Configs API or through the Wazuh Dashboard.
- Update the channel with your real credentials (webhook URL, API keys, headers, etc.).
- Set
is_enabledtotrue.
For example, to configure the Slack channel:
curl -sk -u admin:admin -X PUT \
"https://localhost: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_rolesis 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. Retrieves SMTP/SES/SNS credentials from the OpenSearch Keystore or other secure settings.
- Persistence and configuration — stores notification channel configurations in the internal
.notificationsindex. Exposes internal metrics through the stats endpoint so operators can inspect request counts and error patterns.
- REST / Transport layer — exposes the
-
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 updates the notification status (for example,
sentorfailed) and returns the outcome to the caller (Alerting, Reporting, or the user calling the REST API).
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:
- The alerting monitor triggers an alert and calls the Notification plugin via the internal transport interface.
- The Security plugin verifies the caller’s permissions.
- The notification is persisted in the
.notificationsindex with statuspending. - 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). On failure, retries are attempted up to the configured limit.
- The delivery status is returned and the notification record is updated to
sentorfailed. - The calling plugin acknowledges the result and updates its own alert status.
Configuration management sequence
- A user (via Dashboard or REST API) creates or updates a notification channel configuration.
- The configuration is validated and persisted in the
.notificationsindex. - 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.
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.
Core settings (opensearch.notifications.core.*)
These settings control the core notification delivery engine.
Email settings
opensearch.notifications.core.email.size_limit(Integer, default10000000/ 10 MB, minimum10000/ 10 KB) — maximum total size of an email message including attachments.opensearch.notifications.core.email.minimum_header_length(Integer, default160) — minimum header length for email messages. Used to calculate available body size.
HTTP connection settings
opensearch.notifications.core.http.max_connections(Integer, default60) — maximum number of simultaneous HTTP connections for webhooks.opensearch.notifications.core.http.max_connection_per_route(Integer, default20) — maximum HTTP connections per destination route.opensearch.notifications.core.http.connection_timeout(Integer, default5000) — HTTP connection timeout in milliseconds.opensearch.notifications.core.http.socket_timeout(Integer, default50000) — HTTP socket timeout in milliseconds.opensearch.notifications.core.http.host_deny_list(List<String>, default[]) — list of denied hosts. Webhook destinations targeting these hosts will be blocked. Inherits from legacyplugins.destination.host.deny_listif not set.
General core settings
opensearch.notifications.core.max_http_response_size(Integer, default same ashttp.max_content_length) — 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"]) — list of channel types that users are allowed to create. Remove a type from this list to disable it cluster-wide.opensearch.notifications.core.tooltip_support(Boolean, defaulttrue) — 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, default60000, minimum100) — timeout in milliseconds for internal operations (index reads/writes).opensearch.notifications.general.default_items_query_count(Integer, default100, minimum10) — default number of items returned per query when not specified.opensearch.notifications.general.filter_by_backend_roles(Boolean, defaultfalse) — whentrue, users can only see notification configurations created by users who share the same backend role. Inherits fromplugins.alerting.filter_by_backend_rolesif not set.
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, default40, minimum0, no upper bound) — 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, default10, minimum0, no upper bound) — cap on the number ofemail_groupconfigurations. Counts against, and in addition to,max_notification_configs.plugins.notifications.max_notification_senders(Integer, default5, minimum0, no upper bound) — cap on the number ofsmtp_accountandses_accountconfigurations combined. Counts against, and in addition to,max_notification_configs.plugins.notifications.max_active_responses(Integer, default10, minimum0, no upper bound) — cap on the number ofactive_responseconfigurations. Counts against, and in addition to,max_notification_configs.
Note: These are separate from
opensearch.notifications.general.default_items_query_countand othergeneral.*settings above — they live under a distinctplugins.notifications.*prefix.
Email destination secure settings
SMTP and SES credentials are stored securely in the OpenSearch Keystore rather than in plain text configuration files.
SMTP account credentials
To configure SMTP credentials for an email account named my_smtp_account:
# Add SMTP username
bin/opensearch-keystore add opensearch.notifications.core.email.my_smtp_account.username
# Add SMTP password
bin/opensearch-keystore add opensearch.notifications.core.email.my_smtp_account.password
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
opensearch.notifications.core.http.host_deny_list:
- "10.0.0.0/8"
- "172.16.0.0/12"
# Allowed channel types (remove a type to disable it)
opensearch.notifications.core.allowed_config_types:
- slack
- chime
- microsoft_teams
- webhook
- email
- sns
- ses_account
- smtp_account
- email_group
# 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
# 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
All settings marked as Dynamic can be updated at runtime through the cluster settings API:
curl -X PUT "https://localhost:9200/_cluster/settings" \
-H 'Content-Type: application/json' \
-d '{
"persistent": {
"opensearch.notifications.core.http.max_connections": 100,
"opensearch.notifications.general.filter_by_backend_roles": true,
"plugins.notifications.max_notification_configs": 20
}
}'
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"
}
}
}
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
{
"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/..."
}
}
}
],
"total_hits": 1
}
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, default0) — pagination offset.max_items(Integer, default100) — 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:ascordesc.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.query(String) — search across all keyword and text filter fields.text_query(String) — search across text filter fields only.
Example
curl -sk -u admin:admin \
"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
{
"channel_list": [
{
"config_id": "<id>",
"name": "my-slack-channel",
"config_type": "slack",
"is_enabled": true
}
],
"total_hits": 1
}
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"
],
"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:
GETis also supported for backwards compatibility but is deprecated and will be removed in a future major version.
Example
curl -sk -u admin:admin -X POST \
"https://127.0.0.1:9200/_plugins/_notifications/feature/test/<config-id>"
Response
{
"status_list": [
{
"config_id": "<config-id>",
"config_type": "slack",
"config_name": "my-slack-channel",
"delivery_status": {
"status_code": "200",
"status_text": "ok"
}
}
]
}
Summary table
| Endpoint | Method | Description |
|---|---|---|
/_plugins/_notifications/configs | POST | Create a new notification channel. |
/_plugins/_notifications/configs/{id} | PUT | Update an existing notification channel. |
/_plugins/_notifications/configs/{id} | GET | Get a specific notification channel. |
/_plugins/_notifications/configs | GET | List/search notification channels with filters. |
/_plugins/_notifications/configs/{id} | DELETE | Delete a notification channel. |
/_plugins/_notifications/configs | DELETE | Bulk delete (with config_id_list param). |
/_plugins/_notifications/channels | GET | List all channels (simplified view). |
/_plugins/_notifications/features | GET | Get supported features and config types. |
/_plugins/_notifications/feature/test/{id} | POST | Send 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
- Invalid webhook URL. Verify the Incoming Webhook URL is active in your Slack workspace settings.
- Host deny list. Check if the Slack domain is included in
opensearch.notifications.core.http.host_deny_list. - Network connectivity. The Wazuh Indexer node must have outbound HTTPS access to
hooks.slack.com.
Resolution
# Verify the config
curl -sk -u admin:admin \
"https://localhost:9200/_plugins/_notifications/configs/<config-id>"
# Send a test notification
curl -sk -u admin:admin -X POST \
"https://localhost:9200/_plugins/_notifications/feature/test/<config-id>"
Check the delivery_status in the response for the HTTP status code and error message.
Email delivery fails with timeout
Symptoms: Email notifications fail with connection timeout errors.
Possible causes
- SMTP server unreachable. Verify the Wazuh Indexer node can reach the SMTP server on the configured port.
- Timeout too short. The default connection timeout is 5000 ms and socket timeout is 50000 ms. Increase if needed.
- TLS configuration mismatch. Ensure the SMTP
method(none, ssl, start_tls) matches the server’s requirements.
Resolution
# Increase timeouts via cluster settings
curl -X PUT "https://localhost: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 fails with “Credential not found for account” error.
Resolution
SMTP credentials must be stored in the OpenSearch Keystore, not in opensearch.yml.
bin/opensearch-keystore add opensearch.notifications.core.email.<account_name>.username
bin/opensearch-keystore add opensearch.notifications.core.email.<account_name>.password
Restart the node after adding keystore entries.
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 -X PUT "https://localhost: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
“HTTP response too large” error
Symptoms: Webhook notifications to endpoints that return large responses fail.
Cause: The response from the webhook destination exceeds opensearch.notifications.core.max_http_response_size.
Resolution
curl -X PUT "https://localhost:9200/_cluster/settings" \
-H 'Content-Type: application/json' \
-d '{
"persistent": {
"opensearch.notifications.core.max_http_response_size": 20971520
}
}'
Logs
Enable debug logging for the Notifications plugin:
curl -X PUT "https://localhost: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.
- RBAC integration: Access to monitors, alerts, and destinations is governed by the Security plugin with backend-role–based filtering.
- 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
- Maximum monitors: Users can create up to 10 custom monitors. This limit applies to all monitor types.
Wazuh integration points
Security Analytics
The Security Analytics plugin uses alerting monitors to evaluate incoming events against Sigma detection rules. When an event matches a rule, Security Analytics creates a finding and can trigger an alert. The alerting monitor drives the detection loop — periodically querying new events and running them through the configured detectors.
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: Maximum interval of 1 minute (60,000 ms).
- Triggers: Only
DocumentLevelTriggeris supported.
When an Active Response monitor triggers, it writes execution requests to the wazuh-active-responses data stream. 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.
Dependencies
| Dependency | Purpose |
|---|---|
| Notifications plugin | Delivers alert notifications to configured channels |
| Security plugin | Enforces RBAC on monitors, alerts, and destinations |
| Job Scheduler plugin | Schedules and executes monitors at configured intervals |
| wazuh-indexer-common-utils | Shared 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:
- A Monitor runs on a schedule, executing a query against one or more indices.
- The query results are evaluated against one or more Triggers — boolean conditions that determine whether an alert should fire.
- When a trigger condition is met, the monitor executes its configured Actions — typically sending a notification through the Notifications plugin.
- An Alert record is created to track the triggered condition through its lifecycle.
- For document-level monitors, Findings record which specific documents matched the trigger.
Monitor types
| Monitor Type | Description | Trigger Type | Input 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). | QueryLevelTrigger | SearchInput |
| Bucket-level (per bucket) | Monitors aggregation bucket results individually. Each bucket that meets the trigger condition generates a separate alert. | BucketLevelTrigger | SearchInput (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. | QueryLevelTrigger | ClusterMetricsInput |
| Document-level (per document) | Matches individual documents using percolate queries. Creates a finding for each matching document. | DocumentLevelTrigger | DocLevelMonitorInput |
| Composite | Chains multiple monitors into a workflow and evaluates conditions across their alerts. See Workflows. | ChainedAlertTrigger | CompositeInput |
| Active Response | Wazuh-specific extension of document-level monitoring for automated response. See Active Response. | DocumentLevelTrigger | DocLevelMonitorInput |
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. - Schedule interval cannot exceed 60,000 milliseconds (1 minute).
- Only
DocumentLevelTriggeris accepted — other trigger types are rejected.
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 findings.
- 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:
| State | Description |
|---|---|
| Active | The trigger condition is currently met. The alert was just created or continues to fire. |
| Acknowledged | A user has acknowledged the alert through the Dashboard or API. |
| Completed | The trigger condition is no longer met. The alert resolved naturally. |
| Error | An error occurred during monitor execution or action delivery. |
Findings
Document-level monitors produce findings — records of individual documents that matched the monitor’s trigger conditions. 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 in rolling indices (.opensearch-alerting-finding-history-*) with a default retention of 30 days.
These raw alerting findings are not the same as the findings surfaced in the Wazuh context. Detectors managed by the Security Analytics 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, searched, and managed independently of individual monitors.
Alerting indices
The plugin manages the following system indices:
| Index | Description | Retention |
|---|---|---|
.opendistro-alerting-alerts | Current active alerts | — |
.opendistro-alerting-alert-history-* | Historical alert records | 30 days (daily rollover) |
.opensearch-alerting-finding-history-* | Document-level monitor findings | 30 days (12-hour rollover) |
.opensearch-alerting-comments-history-* | Alert comments and annotations | 30 days (12-hour rollover) |
.opensearch-scheduled-jobs | Monitor and workflow definitions | — |
Rollover periods 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, default10, minimum0, no upper bound) — maximum number of monitors allowed per node.plugins.alerting.monitor.max_triggers(Integer, default10, hard max50) — maximum number of triggers per monitor.plugins.alerting.monitor.doc_level_monitor_shard_fetch_size(Integer, default10000) — number of documents fetched per shard for document-level monitors.plugins.alerting.monitor.doc_level_monitor_fan_out_nodes(Integer, default1000) — maximum number of nodes to fan out document-level monitor queries to.plugins.alerting.monitor.doc_level_monitor_fanout_max_duration(TimeValue, default3m) — maximum duration for fan-out operations in document-level monitors.plugins.alerting.monitor.doc_level_monitor_execution_max_duration(TimeValue, default4m) — maximum total execution duration for document-level monitors.plugins.alerting.monitor.percolate_query_max_num_docs_in_memory(Integer, default50000) — maximum number of documents held in memory for percolate queries.plugins.alerting.monitor.percolate_query_docs_size_memory_percentage_limit(Integer, default10) — maximum percentage of JVM heap used for percolate query documents.plugins.alerting.monitor.doc_level_monitor_query_field_names_enabled(Boolean, defaulttrue) — enable field name extraction for document-level monitor queries.
Timeout settings
plugins.alerting.input_timeout(TimeValue, default30s) — timeout for monitor input (query) execution.plugins.alerting.index_timeout(TimeValue, default30s) — timeout for index operations (writing alerts, findings).plugins.alerting.bulk_timeout(TimeValue, default30s) — timeout for bulk index operations.plugins.alerting.request_timeout(TimeValue, default10s) — timeout for internal transport requests.
Alert history settings
plugins.alerting.alert_history_enabled(Boolean, defaulttrue) — enable alert history storage.plugins.alerting.alert_history_rollover_period(TimeValue, default1d) — how often to roll over the alert history index.plugins.alerting.alert_history_max_age(TimeValue, default30d) — maximum age of alert history indices before deletion.plugins.alerting.alert_history_max_docs(Long, default1000000) — maximum number of documents per alert history index.plugins.alerting.alert_history_retention_period(TimeValue, default30d) — retention period for alert history data.plugins.alerting.alert_backoff_millis(TimeValue, default50ms) — backoff interval between alert write retries.plugins.alerting.alert_backoff_count(Integer, default3) — number of retry attempts for failed alert writes.plugins.alerting.move_alerts_backoff_millis(TimeValue, default50ms) — backoff interval between retries when moving alerts between indices.plugins.alerting.move_alerts_backoff_count(Integer, default3) — number of retry attempts when moving alerts between indices.plugins.alerting.max_actionable_alert_count(Long, default50) — maximum number of alerts that can trigger actions in a single monitor execution.
Finding history settings
plugins.alerting.alert_finding_enabled(Boolean, defaulttrue) — enable finding history storage.plugins.alerting.alert_finding_rollover_period(TimeValue, default12h) — how often to roll over the finding history index.plugins.alerting.finding_history_max_age(TimeValue, default30d) — maximum age of finding history indices before deletion.plugins.alerting.alert_findings_indexing_batch_size(Integer, default1000) — batch size for bulk-indexing findings.plugins.alerting.finding_history_retention_period(TimeValue, default60d) — retention period for finding history data.
Comment settings
plugins.alerting.comments_enabled(Boolean, defaulttrue) — enable the alert comments feature.plugins.alerting.comments_history_max_docs(Long, default1000) — maximum number of documents per comments history index.plugins.alerting.comments_history_max_age(TimeValue, default30d) — maximum age of comments history indices before deletion.plugins.alerting.comments_history_rollover_period(TimeValue, default12h) — how often to roll over the comments history index.plugins.alerting.max_comment_character_length(Integer, default2000) — maximum character length for a single comment.plugins.alerting.max_comments_per_alert(Integer, default500) — maximum number of comments allowed per alert.plugins.alerting.max_comments_per_notification(Integer, default3) — maximum number of comments included in alert notification messages.
General settings
plugins.alerting.filter_by_backend_roles(Boolean, defaulttrue) — when enabled, users can only view monitors and alerts created by users who share the same backend role.plugins.alerting.action_throttle_max_value(TimeValue, default24h) — maximum throttle duration for alert actions.plugins.alerting.cross_cluster_monitoring_enabled(Boolean, defaulttrue) — enable monitoring of indices on remote clusters via cross-cluster search.plugins.alerting.notification_context_results_allowed_roles(List<String>, default[]) — roles that may receive notification context results. An empty list applies no role-based restriction.
Updating settings
All settings can be updated at runtime through the cluster settings API:
curl -sk -u admin:admin -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_max_age": "60d"
}
}'
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.
Endpoint summary
Monitors
| Method | Endpoint | Description |
|---|---|---|
POST | /_plugins/_alerting/monitors | Create 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 | /_plugins/_alerting/monitors/_search | Search monitors |
POST | /_plugins/_alerting/monitors/{id}/_execute | Execute a monitor immediately |
Workflows
| Method | Endpoint | Description |
|---|---|---|
POST | /_plugins/_alerting/workflows | Create 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}/_execute | Execute a workflow immediately |
Alerts
| Method | Endpoint | Description |
|---|---|---|
GET | /_plugins/_alerting/alerts | List alerts across all monitors |
GET | /_plugins/_alerting/workflows/{id}/alerts | List alerts for a specific workflow |
POST | /_plugins/_alerting/monitors/{id}/_acknowledge/alerts | Acknowledge one or more alerts |
Findings
| Method | Endpoint | Description |
|---|---|---|
GET | /_plugins/_alerting/findings | List findings from document-level monitors |
Comments
| Method | Endpoint | Description |
|---|---|---|
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 | /_plugins/_alerting/comments/_search | Search comments |
Destinations (legacy)
| Method | Endpoint | Description |
|---|---|---|
GET | /_plugins/_alerting/destinations/{id} | Get a destination by ID |
GET | /_plugins/_alerting/destinations/_search | Search destinations |
Note: Destination management has been migrated to the Notifications plugin. Use the Notifications API (
/_plugins/_notifications/) for creating and managing notification channels.
Examples
Create a query-level monitor
This example creates a monitor that checks every 5 minutes whether the number of error-level events in the last hour exceeds 100:
curl -sk -u admin:admin -X POST \
"https://localhost: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": { "event.severity": "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": []
}
}
]
}'
Acknowledge alerts
curl -sk -u admin:admin -X POST \
"https://localhost: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:admin -X POST \
"https://localhost: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.
Note: This documentation assumes you are already provisioned with a wazuh-indexer package through any of the possible methods:
- Local package generation (recommended).
- GH Workflows artifacts.
- Staging S3 buckets
Preparing the upgrade
Perform the following steps on any of the Wazuh indexer nodes replacing $WAZUH_INDEXER_IP_ADDRESS, $USERNAME, and $PASSWORD.
-
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": {} } -
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 -kOutput
{ "_shards" : { "total" : 19, "successful" : 19, "failed" : 0 } }
Upgrading the Wazuh indexer nodes
-
Stop the Wazuh indexer service.
Systemd
systemctl stop wazuh-indexerSysV
service wazuh-indexer stop -
Upgrade the Wazuh indexer to the latest version.
rpm
rpm -ivh --replacepkgs wazuh-indexer-<VERSION>.rpmdpkg
dpkg -i wazuh-indexer-<VERSION>.deb -
Restart the Wazuh indexer service.
Systemd
systemctl daemon-reload systemctl enable wazuh-indexer systemctl start wazuh-indexerSysV
Choose one option according to the operating system used.
a. RPM-based operating system:
chkconfig --add wazuh-indexer service wazuh-indexer startb. 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.
-
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 -
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" : {} } -
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?vOutput
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.
Yum
yum remove wazuh-indexer -y
rm -rf /var/lib/wazuh-indexer/
rm -rf /usr/share/wazuh-indexer/
rm -rf /etc/wazuh-indexer/
APT
apt-get remove wazuh-indexer -y
rm -rf /var/lib/wazuh-indexer/
rm -rf /usr/share/wazuh-indexer/
rm -rf /etc/wazuh-indexer/
Backup 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
-
Backup 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" -
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 -
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/ \
/usr/lib/sysctl.d/wazuh-indexer.conf $backup_folder
Compress the files and transfer them to the new server:
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
-
In the new node, move the compressed backup file to the root
/directory:mv wazuh-indexer-backup.tar.gz / cd / -
Decompress the backup files and change the current working directory to the directory based on the date and time of the backup files:
tar -xzvf wazuh-indexer-backup.tar.gz cd $backup_folder
Restoring Wazuh indexer files
Perform the following steps to restore the Wazuh indexer files on the new server.
-
Stop the Wazuh indexer to prevent any modifications to the Wazuh indexer files during the restoration process:
systemctl stop wazuh-indexer -
Restore the Wazuh indexer configuration files and change the file permissions and ownership accordingly:
cp etc/wazuh-indexer/jvm.options /etc/wazuh-indexer/jvm.options cp -r etc/wazuh-indexer/jvm.options.d/ /etc/wazuh-indexer/jvm.options.d/ cp etc/wazuh-indexer/log4j2.properties /etc/wazuh-indexer/log4j2.properties cp etc/wazuh-indexer/opensearch.keystore /etc/wazuh-indexer/opensearch.keystore cp -r etc/wazuh-indexer/wazuh-indexer-reports-scheduler/ /etc/wazuh-indexer/wazuh-indexer-reports-scheduler/ cp -r etc/wazuh-indexer/wazuh-indexer-notifications/ /etc/wazuh-indexer/wazuh-indexer-notifications/ cp -r etc/wazuh-indexer/wazuh-indexer-notifications-core/ /etc/wazuh-indexer/wazuh-indexer-notifications-core/ cp usr/lib/sysctl.d/wazuh-indexer.conf /usr/lib/sysctl.d/wazuh-indexer.conf chown wazuh-indexer:wazuh-indexer /etc/wazuh-indexer/jvm.options chown -R wazuh-indexer:wazuh-indexer /etc/wazuh-indexer/jvm.options.d chown wazuh-indexer:wazuh-indexer /etc/wazuh-indexer/log4j2.properties chown wazuh-indexer:wazuh-indexer /etc/wazuh-indexer/opensearch.keystore chown -R wazuh-indexer:wazuh-indexer /etc/wazuh-indexer/wazuh-indexer-reports-scheduler/ chown -R wazuh-indexer:wazuh-indexer /etc/wazuh-indexer/wazuh-indexer-notifications/ chown -R wazuh-indexer:wazuh-indexer /etc/wazuh-indexer/wazuh-indexer-notifications-core/ chown wazuh-indexer:wazuh-indexer /usr/lib/sysctl.d/wazuh-indexer.conf -
Start the Wazuh indexer service:
systemctl start wazuh-indexer -
Clear the backup files to free up space:
rm -rf $backup_folder rm -rf /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. -
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.
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
Each default user is mapped 1:1 to the role of the matching name in roles_mapping.yml. The wazuh-admin user is additionally reachable through the admin backend role.
wazuh-manager→wazuh_manager— service account for the Wazuh Manager: read/write on stateless (events, metrics) indices, read/write/delete on stateful (states) indices, read/write on the agent statistics and configuration indexes, and read on consumers, threat intelligence and active-responses.wazuh-admin→wazuh_admin— administrator: read access to all Wazuh indices, write access to Wazuh settings, full Content Manager and Security Analytics access, and management of alerting, notifications, reporting and index management. Excludes super-admin (security configuration).wazuh-demo→wazuh_demo— default interactive user: read data, manage threat intelligence content, full Content Manager content operations and Security Analytics, and read-only alerting, notifications, reporting and index management.wazuh-readonly→wazuh_readonly— read-only access to indices, settings, subscriptions and Security Analytics (detectors, findings, alerts).
Security note: The bundled password hashes decode to the username. Change every default password immediately after installation.
There is no dedicated internal user for the dashboard_server role below — it is mapped to the built-in OpenSearch kibanaserver user, which the Wazuh Dashboard authenticates as internally.
Besides the 1:1 roles, wazuh_ai_assistant is mapped to every authenticated user. It is the only role not tied to a single user, and exists to attach a Document Level Security query to an index.
Roles
Seven default roles are defined in roles.yml. Each role is self-contained (it grants everything its user needs on its own) and is reserved - it cannot be edited in place. 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:
readonwazuh-*.
wazuh_manager
Service account used by the Wazuh Manager for data ingestion and content reads.
- Cluster permissions:
cluster_composite_ops,cluster_monitor. - Index permissions:
readon.wazuh-settings.readon.wazuh-cti-consumers,wazuh-active-responses*,wazuh-threatintel-*.read,indexonwazuh-events-v5-*,wazuh-metrics-*.read,index,deleteonwazuh-states-*.read,indexanddeleteonwazuh-agent-*.manage_point_in_timeon.wazuh-threatintel-vulnerabilities*,wazuh-threatintel-*.
wazuh_admin
Full access to all Wazuh features, excluding super-admin features such as the security configuration.
- Cluster permissions:
- Base:
cluster_composite_ops,cluster_monitor. - Wazuh settings (setup plugin):
plugin:wazuh/settings/write. - AI assistant settings (setup plugin):
plugin:wazuh/ai_assistant/settings/read,plugin:wazuh/ai_assistant/settings/write— see AI assistant administrative API below. - Content Manager: full.
- Security Analytics: full (both the Wazuh custom actions and the upstream OpenSearch Security Analytics actions).
- Alerting: full.
- Anomaly detection: detector operations.
- Notifications: full.
- Reporting: full.
- Index management: full (ISM, rollups, transforms).
- Base:
- Index permissions:
get,read,indices:admin/aliases/get,indices:admin/opensearch/ism/*,indices:internal/plugins/replication/index/stop,indices:monitor/*on*,.kibana*.read,indexon.wazuh-settings.indexonwazuh-events-v5*.read,index,deleteon.wazuh-internal-state.index,delete,indices:admin/exists,indices:admin/refreshonwazuh-threatintel-*,.opensearch-sap-*.
wazuh_demo
Default interactive user: can visualize data and manage threat intelligence / Content Manager content.
- Cluster permissions:
- Base:
cluster_composite_ops,cluster_monitor. - AI assistant settings (setup plugin):
plugin:wazuh/ai_assistant/settings/read. - Content Manager: full content operations (no subscription create/delete, no policy update).
- Security Analytics: full (both the Wazuh custom actions and the upstream OpenSearch Security Analytics actions).
- Alerting, Anomaly detection, Notifications, Reporting, Index management: read-only.
- Base:
- Index permissions:
get,read,indices:admin/aliases/get,indices:monitor/*on*,.kibana*.readon.wazuh-internal-state.index,delete,indices:admin/exists,indices:admin/refreshonwazuh-threatintel-*,.opensearch-sap-*.
wazuh_readonly
Read-only access across the platform.
- Cluster permissions:
- Base:
cluster_composite_ops,cluster_monitor. - AI assistant settings (setup plugin):
plugin:wazuh/ai_assistant/settings/read. - Content Manager:
subscription/get,logtest*,version/check. - Security Analytics: read-only (upstream
cluster:admin/opensearch/securityanalytics/*get/search/list actions) plus the Wazuh customrules/evaluate. - Alerting, Anomaly detection, Notifications, Reporting, Index management: read-only.
- Base:
- Index permissions:
get,read,indices:admin/aliases/get,indices:monitor/*on*,.kibana*.readon.wazuh-settings.readon.wazuh-internal-state.
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: none.
- Index permissions:
readonwazuh-ai-assistant-sessions*,.ds-wazuh-ai-assistant-sessions-*, restricted with the DLS query{"term": {"user": "${user.name}"}}.writeon the same patterns, with no DLS query (DLS filters reads, not writes).
${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.
The per-owner DLS applies to every user, including wazuh-admin and admin
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.
| Endpoint | Method | Cluster permission |
|---|---|---|
/_plugins/_setup/ai_assistant/settings | GET | plugin:wazuh/ai_assistant/settings/read |
/_plugins/_setup/ai_assistant/settings | PUT | plugin:wazuh/ai_assistant/settings/write |
/_plugins/_setup/ai_assistant/providers | GET | plugin:wazuh/ai_assistant/settings/read |
/_plugins/_setup/ai_assistant/providers | POST | plugin:wazuh/ai_assistant/settings/write |
/_plugins/_setup/ai_assistant/providers/{id} | PUT, DELETE | plugin:wazuh/ai_assistant/settings/write |
wazuh_admin holds both permissions (read + write). wazuh_demo and wazuh_readonly hold read only. dashboard_server and wazuh_manager hold neither.
Sensitive configuration endpoints
A small set of endpoints modify configuration with a high impact on the platform. They are protected by two independent controls:
| Endpoint | Method | Permission (cluster action) |
|---|---|---|
/_plugins/_content_manager/policy/{space} | PUT | cluster:admin/content_manager/policy/update |
/_plugins/_content_manager/update | POST | cluster:admin/content_manager/update/trigger |
/_plugins/_setup/settings | PUT | plugin:wazuh/settings/write |
- RBAC - each endpoint is gated by the cluster permission above, enforced by the OpenSearch Security plugin. Among the bundled users, only
wazuh-adminholds these permissions;wazuh-manager,wazuh-demoandwazuh-readonlyare excluded. The superuseradmin(roleall_access, cluster wildcard*) also holds 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. - Per-endpoint disable settings - each endpoint can be disabled independently by setting its node setting to
false, after which it returns403 Forbiddenfor 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), andplugins.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
- In the Wazuh Dashboard, go to Index Management -> Security -> Roles.
- Click Create role.
- Enter a Role name (e.g.,
custom-read-write). - Under Cluster permissions, select permissions if needed.
- Under Index permissions:
- Index: e.g.,
wazuh-* - Index permissions: choose appropriate actions such as:
read(to allow read access)index(to allow write access)
- Optionally, configure Document-level security (DLS) or Field-level security (FLS).
- Index: e.g.,
- Click Create to save the role.
2. Create a user
- In the Wazuh Dashboard, go to Index Management -> Security -> Internal users.
- Click Create internal user.
- Fill in the following:
- Username (e.g.,
new-user) - Password (enter and confirm)
- Description (optional)
- Username (e.g.,
- Click Create to create the user.
3. Verify role mapping
When you assign a role to a user during creation, the mapping is created automatically. To review or edit:
- In Security, go to Roles.
- Find and click your role (
custom-read-write). - Go to Mapped users
- Click Map users.
- Fill in the following:
- Users (e.g.,
new-user). - Backend roles (optional).
- Users (e.g.,
- Click Map to save the mapping.
4. Test access
After creating the user and role:
- Log out from the Dashboard.
- Log in with the new user’s credentials.
- Navigate to Index Management -> Dev Tools.
- 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 that are referenced by the default roles. Content Manager permission names are action groups (defined in action_groups.yml) that resolve to the actual cluster:admin/content_manager/* transport actions registered by the plugin; the Setup and Security Analytics 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 groupplugin:wazuh/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}(andplugin:content_manager/integration/*)plugin:content_manager/decoder/{create,update,delete}(andplugin:content_manager/decoder/*)plugin:content_manager/rule/{create,update,delete}(andplugin:content_manager/rule/*)plugin:content_manager/kvdb/{create,update,delete}(andplugin:content_manager/kvdb/*)plugin:content_manager/filter/{create,update,delete}(andplugin: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 subscriptionplugin:content_manager/subscription/post— create/update the CTI subscriptionplugin:content_manager/subscription/delete— delete the CTI subscriptionplugin:content_manager/promote/get— preview a promotion diffplugin:content_manager/promote/post— execute a space promotion (andplugin:content_manager/promote/*)plugin:content_manager/logtest,plugin:content_manager/logtest/detection,plugin:content_manager/logtest/normalization(andplugin:content_manager/logtest/*)plugin:content_manager/space/delete— delete a spaceplugin:content_manager/version/check— check the catalog version
Security Analytics plugin permissions
Wazuh custom actions:
cluster:admin/wazuh/securityanalytics/detector/writecluster:admin/wazuh/securityanalytics/detector/deletecluster:admin/wazuh/securityanalytics/logtype/writecluster:admin/wazuh/securityanalytics/logtype/deletecluster:admin/wazuh/securityanalytics/rule/writecluster:admin/wazuh/securityanalytics/rule/deletecluster:admin/wazuh/securityanalytics/rule/custom/writecluster:admin/wazuh/securityanalytics/rule/custom/deletecluster:admin/wazuh/securityanalytics/rules/evaluatecluster:admin/wazuh/securityanalytics/space/delete
The default roles also grant the upstream OpenSearch Security Analytics actions (cluster:admin/opensearch/securityanalytics/*) — the full /* set for wazuh_admin and wazuh_demo, and the read-only (/get, /search) subset for wazuh_readonly.
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 adraft → test → custompromotion 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 Security Analytics plugin.
- Extended Sigma rules syntax #47.
- Per-space support for Log Types and Rules #37.
- Per-space threat detectors #117.
- Rules parser improvements:
- 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, Security Analytics 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-responsesdata stream for execution requests, with its own ISM policy. - Driven by a dedicated Alerting monitor.
- Dedicated
- New
mdBookdocumentation (#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, Security Analytics).
- 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-v5with 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.
- Migration of the Wazuh Common Schema from the
wazuh-indexerrepository to thewazuh-indexer-pluginsrepository. Folder renamed towcs#879. - Supported operating systems updated for 5.0.0: Red Hat 9/10, Ubuntu 22.04/24.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 cannot be migrated (see Data cannot be migrated). 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, you can optionally keep the 4.x environment running in parallel.
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:
- Stand up a new 5.x cluster on a fresh host.
- Re-create base configuration, certificates, and security settings against the 5.x layout.
The 4.x cluster is never modified by this procedure. Since indexed data is not migrated, you may optionally keep the 4.x environment running in parallel as a read-only legacy deployment if you still need to query historical data — see Data cannot be migrated.
Prerequisites
Before starting:
- A backup of the 4.x cluster configuration, security configuration, and certificates. See Back up and Restore.
- A supported host for the 5.x installation. See Compatibility and Requirements.
- 5.x packages obtained via any of the methods listed under Packages.
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:
| Path | Purpose |
|---|---|
/etc/wazuh-indexer/opensearch.yml | Main cluster and node configuration |
/etc/wazuh-indexer/jvm.options | JVM heap and GC settings |
/etc/wazuh-indexer/log4j2.properties | Logging configuration |
/etc/wazuh-indexer/certs/ | Transport and HTTP TLS certificates |
/etc/wazuh-indexer/opensearch-security/ | Security plugin configuration (see Security migration) |
For a full description of each file, see Configuration.
Procedure
Perform these steps on the new 5.x host.
-
Install the 5.x package on a fresh host following Installation. This creates the default 5.x configuration tree under
/etc/wazuh-indexer/. -
Stop the new service before editing configuration:
systemctl stop wazuh-indexer -
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.
-
Migrate certificates by placing the existing trust and node certificates under
/etc/wazuh-indexer/certs/and updating theplugins.security.ssl.*paths inopensearch.ymlaccordingly. -
Port
jvm.optionsandlog4j2.propertiesby copying only individual non-default lines into the 5.x files. Do not replace the 5.x files outright. -
Re-create the security configuration. See Security migration.
-
Start the service:
systemctl daemon-reload systemctl enable wazuh-indexer systemctl start wazuh-indexer -
Confirm the node joins the new 5.x cluster:
curl -k -u $USERNAME:$PASSWORD https://$WAZUH_INDEXER_IP_ADDRESS:9200/_cat/nodes?v
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.
| 4.x setting | 5.x replacement | Notes |
|---|---|---|
opensearch_performance_analyzer.* | Removed | The opensearch-performance-analyzer plugin is no longer shipped. Remove any related entries. |
plugins.anomaly_detection.* | Removed | The opensearch-anomaly-detection plugin is no longer shipped. Remove any related entries. |
plugins.asynchronous_search.* | Removed | The opensearch-asynchronous-search plugin is no longer shipped. Remove any related entries. |
plugins.ml_commons.* | Removed | The opensearch-ml plugin is no longer shipped. Remove any related entries. |
plugins.query.datasources.* | Removed | The opensearch-sql plugin is no longer shipped. Remove any related entries. |
plugins.neural_search.* | Removed | The opensearch-neural-search plugin is no longer shipped. Remove any related entries. |
knn.* | Removed | The opensearch-knn plugin is no longer shipped. Remove any related entries. |
compatibility.override_main_response_version | Removed | 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. |
| Multi-tenancy settings | Disabled by default | Dashboard multi-tenancy is off by default in 5.x. |
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/:
| File | Purpose |
|---|---|
config.yml | Authentication and authorization backends (internal, LDAP, SAML, OIDC, JWT, etc.) |
internal_users.yml | Local user accounts and password hashes |
roles.yml | Role definitions |
roles_mapping.yml | Mapping from authenticated identities to roles |
action_groups.yml | Reusable groups of permissions referenced by roles |
tenants.yml | Dashboard tenants |
nodes_dn.yml | Node certificate distinguished names allowed into the cluster |
allowlist.yml | REST API paths reachable while the cluster is in a restricted state (replaces 4.x whitelist.yml) |
audit.yml | Audit-logging configuration |
Procedure
Perform these steps on the new 5.x host.
-
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. -
On the new 5.x host, do not overwrite the shipped files. Edit them in place under
/etc/wazuh-indexer/opensearch-security/. 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/authzblocks inconfig.ymlagainst 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.
- Custom internal users → add to
-
Apply the configuration with the
/usr/share/wazuh-indexer/bin/indexer-security-init.shscript shipped with the package. -
Restart the service and verify authentication works for each backend before pointing production traffic at the new cluster.
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:
Data cannot be migrated
Wazuh indexer 4.x indices cannot be migrated to a 5.x cluster. There is no in-place upgrade, no snapshot restore, and no _reindex path from 4.x data. To retain access to historical 4.x data, keep the 4.x environment running in parallel as a legacy, read-only 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 in ways that prevent the older shards from being opened or transformed by a 5.x cluster:
| Concern | Description |
|---|---|
| Index schema | 5.x uses new templates and mappings under wazuh-events-v5, wazuh-findings-v5, and wazuh-states-v5. These have no direct counterpart in 4.x. |
| Engine version | The OpenSearch 3.x base in 5.x reads Lucene segments produced by its own and the immediately preceding major version only. Older 4.x shards fall outside the supported range. |
| Document shape | Field names, types, and parent-child relationships in the v5 mappings differ from 4.x documents in ways that cannot be transformed losslessly by a reindex. |
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:
- Leave the 4.x cluster in place after the migration; do not uninstall it.
- 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.
- Keep the existing 4.x dashboard pointed at the 4.x cluster for users who need historical data; a 5.x dashboard cannot read 4.x indices.
- Plan a retention window after which the 4.x environment can be decommissioned according to your data-retention policy.