Detecting defense evasion techniques with Wazuh
August 26, 2025
Wazuh ruleset as code (RaC) introduces a DevOps-driven approach to consistently manage Wazuh threat detection and security monitoring rulesets. It allows security teams to use version control systems and CI/CD pipelines to automatically deploy Wazuh rules and decoders.
This approach leverages the principles of infrastructure as code (IaC) to enable collaboration, change tracking, and rollback of rulesets using tools like Git. It supports the continuous deployment of security detection logic without direct access to the Wazuh manager.
In this blog post, we demonstrate how to implement Wazuh RaC. We use the Detection-Engineering as Code (DaC) repository to automate the lifecycle of custom Wazuh rulesets from creation to deployment.
We provide an overview of how the Wazuh RaC works, from creating rulesets to its deployment to the Wazuh server.
/var/ossec/etc/decoders
/var/ossec/etc/rules
These directories are version-controlled using a local Git repository.
Note
We use a .gitignore
file to exclude unrelated files or directories under the /var/ossec/etc/
directory, ensuring that only relevant rulesets are tracked.
dev
branch of a remote repository (GitHub). This branch acts as a collaborative development space where multiple security engineers can contribute.dev
branch are subject to peer review. A pull request (PR) is created to merge updates from the dev
branch into the main
branch. This review process ensures quality control, facilitates collaboration, and supports auditing and tracking of changes.main
branch, a GitHub Actions workflow automatically triggers the CI/CD pipeline. The CI/CD pipeline executes in the following order:/var/ossec/etc/
directory, where the rulesets are stored.git pull
command.chown wazuh:wazuh
and chmod 660
commands.systemctl status wazuh-manager
command is included to show you the status of the Wazuh manager upon completing all tasks.We use the following infrastructure requirements to demonstrate the Wazuh RaC:
Note
You can also use a GitHub Actions self-hosted runner in place of the default GitHub Actions runners if your Wazuh deployment is on a local network.
Perform the following steps on the Ubuntu endpoint hosting the Wazuh server.
22
or your custom SSH port is open on the assigned public IP address..pem
extension safely for future use.We create local and remote Git repositories to synchronize local changes with remote changes.
To onboard the Wazuh custom ruleset directories, /var/ossec/etc/decoders
and /var/ossec/etc/rules
in Git, we create a local Git repository. We initialize the /var/ossec/etc/
directory in Git and ignore other files and directories present by using a .gitignore
file.
We also set up a remote repository on GitHub as our single source of truth (SSOT). Security engineers use this repository to manage the creation, modification, and deployment of rulesets to the Wazuh server. Security engineers collaboratively create and modify new or existing rulesets on this repository and review the changes before deploying them to the Wazuh server.
In this section, we provide steps to set up the local and remote repositories and synchronize them.
The DaC repository contains the necessary workflow files to automate the integration of rulesets into your Wazuh server. It also has a script for checking conflicting rule IDs to avoid errors on the Wazuh server.
Create a fork of the DaC repository, or a new repository on GitHub, and import the DaC repository.
Note
After creating a fork of the repository, navigate to Actions, and click I understand my workflows, go ahead and enable them.
The repository contains key files containing scripts. These files include:
github/workflows/integrate_rulesets.yml
: This file contains the Update Rulesets on SIEM workflow, which automates the integration of new or modified custom decoders and rules with the Wazuh server..github/workflows/check_rule_ids.yml
: This file contains the Check Rule ID Conflicts workflow, which automates the running of the check_rule_ids.py
script.Check_rule_ids.py
: This Python script checks for rule ID conflicts by comparing the rule IDs of new or modified rules in the dev
branch with existing rule IDs in the main
branch.name: Update Rulesets on SIEM on: push: branches: [ "main" ] paths: ["**.xml"] workflow_dispatch: jobs: DaaC: runs-on: ubuntu-latest steps: - name: Apply modified or new decoders and rules to SIEM uses: appleboy/ssh-action@v1.0.0 with: host: ${{ secrets.HOST }} username: ${{ secrets.USERNAME }} key: ${{ secrets.SSH_KEY }} port: ${{ secrets.PORT }} script: | sudo bash -c ' cd /var/ossec/etc/ git pull origin main chown wazuh:wazuh /var/ossec/etc/decoders/* && chmod 660 /var/ossec/etc/decoders/* chown wazuh:wazuh /var/ossec/etc/rules/* && chmod 660 /var/ossec/etc/rules/* sudo systemctl restart wazuh-manager \ && echo "Ruleset apply SUCCESS!!! - Wazuh manager restarted successfully." \ || echo "Ruleset apply FAILURE!!! - Wazuh manager failed to restart, check ruleset for error..." sudo systemctl status wazuh-manager -l --no-pager '
name: Check Rule ID Conflicts on: pull_request: branches: [ "main" ] # paths: ["**.xml"] jobs: check-rule-ids: runs-on: ubuntu-latest steps: - name: Checkout PR branch uses: actions/checkout@v3 with: fetch-depth: 0 # Required for git diff and history - name: Set up Python uses: actions/setup-python@v4 with: python-version: '3.10' - name: Fetch main branch run: git fetch origin main - name: Run rule ID conflict checker run: python check_rule_ids.py
import subprocess import xml.etree.ElementTree as ET from pathlib import Path import sys from collections import defaultdict, Counter def run_git_command(args): result = subprocess.run(args, capture_output=True, text=True, check=True) return result.stdout def get_changed_rule_files(): try: output = run_git_command(["git", "diff", "--name-status", "origin/main...HEAD"]) changed_files = [] for line in output.strip().splitlines(): parts = line.strip().split(maxsplit=1) if len(parts) != 2: continue status, file_path = parts if file_path.startswith("rules/") and file_path.endswith(".xml"): changed_files.append((status, Path(file_path))) return changed_files except subprocess.CalledProcessError as e: print("❌ Failed to get changed files:", e) sys.exit(1) def extract_rule_ids_from_xml(content): ids = [] try: # Wrap multiple root elements in a fake <root> tag to avoid parse errors wrapped = f"<root>{content}</root>" root = ET.fromstring(wrapped) for rule in root.findall(".//rule"): rule_id = rule.get("id") if rule_id and rule_id.isdigit(): ids.append(int(rule_id)) except ET.ParseError as e: print(f"⚠️ XML Parse Error: {e}") return ids def get_rule_ids_per_file_in_main(): run_git_command(["git", "fetch", "origin", "main"]) files_output = run_git_command(["git", "ls-tree", "-r", "origin/main", "--name-only"]) xml_files = [f for f in files_output.splitlines() if f.startswith("rules/") and f.endswith(".xml")] rule_id_to_files = defaultdict(set) for file in xml_files: try: content = run_git_command(["git", "show", f"origin/main:{file}"]) rule_ids = extract_rule_ids_from_xml(content) for rule_id in rule_ids: rule_id_to_files[rule_id].add(file) except subprocess.CalledProcessError: continue return rule_id_to_files def get_rule_ids_from_main_version(file_path: Path): try: content = run_git_command(["git", "show", f"origin/main:{file_path.as_posix()}"]) return extract_rule_ids_from_xml(content) except subprocess.CalledProcessError: return [] def detect_duplicates(rule_ids): counter = Counter(rule_ids) return [rule_id for rule_id, count in counter.items() if count > 1] def print_conflicts(conflicting_ids, rule_id_to_files): print("❌ Conflicts detected:") for rule_id in sorted(conflicting_ids): files = rule_id_to_files.get(rule_id, []) print(f" - Rule ID {rule_id} found in:") for f in files: print(f" • {f}") def main(): changed_files = get_changed_rule_files() if not changed_files: print("✅ No rule files were changed in this PR.") return rule_id_to_files_main = get_rule_ids_per_file_in_main() print(f"🔍 Checking rule ID conflicts for files: {[f.name for _, f in changed_files]}") for status, path in changed_files: print(f"\n🔎 Checking file: {path.name}") try: dev_content = path.read_text() dev_ids = extract_rule_ids_from_xml(dev_content) except Exception as e: print(f"⚠️ Could not read {path.name}: {e}") continue # Check for internal duplicates duplicates = detect_duplicates(dev_ids) if duplicates: print(f"❌ Duplicate rule IDs detected in {path.name}: {sorted(duplicates)}") sys.exit(1) if status == "A": # New file conflicting_ids = set(dev_ids) & set(rule_id_to_files_main.keys()) if conflicting_ids: print_conflicts(conflicting_ids, rule_id_to_files_main) sys.exit(1) else: print(f"✅ No conflict in new file {path.name}") elif status == "M": # Modified file main_ids = get_rule_ids_from_main_version(path) if set(dev_ids) == set(main_ids): print(f"ℹ️ {path.name} modified but rule IDs unchanged.") continue new_or_changed_ids = set(dev_ids) - set(main_ids) conflicting_ids = new_or_changed_ids & set(rule_id_to_files_main.keys()) if conflicting_ids: print_conflicts(conflicting_ids, rule_id_to_files_main) sys.exit(1) else: print(f"✅ Modified file {path.name} has no conflicting rule IDs.") print("\n✅ All rule file changes passed conflict checks.") if __name__ == "__main__": main()
Perform the following steps on the Ubuntu endpoint hosting the Wazuh server to set up your local Git repository.
/var/ossec/etc
directory as the working directory:# cd /var/ossec/etc
.gitignore
file in the working directory to ignore other files and directories that are not the decoders/
and rules/
directories from being added to Git:# touch .gitignore
.gitignore
file:# Ignore the following files client.keys internal_options.conf local_internal_options.conf ossec.conf sslmanager.cert localtime sslmanager.key # Ignore the following directories lists/ rootcheck/ shared/
# git config --global --add safe.directory /var/ossec/etc
.git
directory in the working directory.# git init
# git remote add origin https://<PERSONAL_ACCESS_TOKEN>@github.com/<USERNAME>/<REPO_NAME>
Replace <PERSONAL_ACCESS_TOKEN>
with your GitHub personal access token, <USERNAME>
with your GitHub username, and <REPO_NAME>
with the name of your GitHub repository.
# git config --global user.name <YOUR_NAME> # git config --global user.email <YOUR_EMAIL_ADDRESS>
Replace <YOUR_NAME>
with your GitHub username and <YOUR_EMAIL_ADDRESS>
with your GitHub email address.
main
, and switch to the new branch:# git checkout -b main
decoders/
and rules/
directories for commit and make an initial commit to the local Git repository:# git add . # git commit -m "Initial commit"
# git pull --rebase origin main # git push -u origin main
Note
We first merge the GitHub repository with the local repository and resolve any merge conflicts using the git pull --rebase origin main
command. This helps to update the local Git repository with the GitHub repository, since the GitHub repository is inconsistent with the local Git repository. This resolves issues that may arise when pushing to GitHub.
To protect the main
branch, where the more stable rulesets are stored before deploying to the Wazuh server, it is necessary to create a dev
branch. Development of new rulesets is done in the dev
branch to allow for proper review and testing before merging to the main
branch, which deploys to production.
Using the GitHub documentation, create a new branch named dev
from your main
branch on the remote repository (GitHub).
Perform the following step on the remote GitHub repository to create secrets for use during the execution of the automation workflow.
Navigate to Settings > Secrets and variables > Actions > Secrets to create the following GitHub Actions secrets:
.pem
file.These secrets are used by GitHub Actions in the workflow file, .github/workflows/integrate_rulesets.yml
to automate the deployment of the rulesets to the Wazuh endpoint.
Perform the following steps on the VSCode application:
Remote Repositories
and install the extension.In this section, we demonstrate how to use Wazuh RaC from local development to deployment on the Wazuh server. We also introduce an error-checking step to resolve rule ID conflicts.
We demonstrate how Wazuh RaC works from when an engineer writes a new custom ruleset in an IDE to when the custom ruleset is integrated into the Wazuh server.
The ruleset is integrated with the Wazuh server once changes in the dev
branch are merged with the main
branch. This triggers the GitHub Actions workflow integrate_rulesets.yml
, named as Update Rulesets on SIEM, to update the Wazuh server with the recent changes. These changes cover decoder and rule creations, modifications, and deletions. The necessary file permissions and ownership are also given to the Wazuh user and group for the new decoders and rules, and the Wazuh manager is restarted.
The GIF image below provides a walk-through of the process, as follows:
demo_decoder.xml
and demo_rule.xml
on VSCode.dev
-> main
.To validate our rulesets during PR, we resolve conflicts that arise from reusing rule IDs during rule creation or modification by adding a PR check. It uses automation to check for conflicting rule IDs when a PR is created from dev
-> main
.
We use the Python script check_rule_ids.py
to extract the rule IDs from recently created or modified rule files in the dev
branch. The extracted rule IDs are then compared to existing rule IDs already present in the main
branch. For this to be effective, it is important to use rule ID numbers within the range of 100000
and 120000
for custom rules.
The process of running the Python script is automated using the GitHub Actions workflow file .github/workflows/check_rule_ids.yml
. This workflow is added as a check that must be passed during PR for a change to be eligible for merge to the main
branch. To enforce the check on your repository, do the following:
main
branch.protect_main.json
file downloaded earlier.The check_rule_ids.py
script and the .github/workflows/check_rule_ids.yml
workflow file are present when the DaC repository is forked or imported.
We test this by creating a new rule file demo_rule2.xml
, which is a copy of the demo_rule.xml
file created earlier. The new rule has the same rule IDs as demo_rule.xml
, but with a different file name. The new rule is pushed to the remote repository, and a PR is created to merge the change from the dev
to the main
branch.
This triggers the GitHub Actions workflow, .github/workflows/check_rule_ids.yml
named Check Rule ID Conflicts to check for conflicts in the rule IDs. Upon detecting a conflict, the workflow status is set to Failure, and the PR cannot be merged.
The image below demonstrates a failure in the PR when there is a conflicting rule ID.
To find the exact rule IDs causing the conflict, check the Check Rule ID Conflicts workflow runs on your GitHub Actions. To directly check from the PR, follow the instructions in the GIF image below.
In this section, we show how to check for errors with the CI/CD pipeline. Examples of such errors include:
The Update Rulesets on SIEM workflow pipeline will fail if any of the above-listed errors occur. Errors related to deploying rulesets to the Wazuh server can be found in the GitHub Actions Update Rulesets on SIEM workflow runs. The image below shows an error with a failed CI/CD pipeline.
Note
You can add custom checks to your repository to further prevent errors on the production Wazuh server.
Wazuh ruleset as code (RaC) showcases a DevOps approach to handling security operations and detection engineering. By treating rulesets as code, security teams can manage detection logic with the same agility, scalability, and discipline used in software development. This results in faster iterations, fewer production issues, and more consistent threat detection rules.
Wazuh is a free and open source SIEM and XDR solution that can be deployed and managed on-premises or in the Wazuh cloud. You can ask questions about this blog post and other topics related to Wazuh in any of our community channels.