klaxon
Klaxon is a read-only proxy for querying, inspecting, and assessing a Wazuh 5 indexer through MCP tools, with masking/pseudonymization for external LLMs.
Search Wazuh datastreams using raw OpenSearch query DSL, with diagnostics for zero hits, capped sizes, and partial aggregations.
Inspect available fields and their real population across indexes or namespaces.
Measure field coverage over time windows and whole datastreams to spot normalisation gaps.
Test raw log lines through the Wazuh decoder chain to see why fields are populated or empty.
List Wazuh engine test environments available for logtest.
Query the Wazuh manager API for agents, syscollector data, cluster health, and limited RBAC self-inspection.
List and fetch Security Analytics detectors that produce findings.
Summarise findings by severity, agent, title, and category.
Run a GDPR/DSGVO plausibility check to find sensitive fields and optionally update masking config.
Run a read-only posture check covering masking, drift, salt strength, quarantine backlog, RBAC, retention, and fail-closed status.
Pseudonymize sensitive values and aggregation keys before responses reach external LLMs.
Enables querying OpenSearch indices, retrieving schema and field coverage, and running aggregations for security analytics investigation.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@klaxon Show me blocked connections by source country in the last 24 hours."
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
Klaxon
The alarm tells you that. Klaxon tells you what.
Klaxon is a read-only proxy in front of your Wazuh 5 indexer. A language model asks questions in plain language; Klaxon queries the indexer, reads the schema, tests decoders, and reports what it actually found — including when it found nothing, and why. Works with Claude Desktop, Claude Code, local models through Ollama, and Open WebUI.
Every query runs through Klaxon, and the response is masked before it reaches
an external LLM: a value under a configured field (user.name, source.ip, …)
always becomes the same deterministic token ([USER_…], [IP_…]), aggregation
keys included. This is pseudonymization, not anonymization — tokens are
deterministic and reversible by anyone holding the salt (see
LLM-safety guarantees).
Quick start
Requirements
Wazuh 5.x indexer reachable over HTTPS (
search/schemaalso work against 4.x)Python 3.11+ or Docker
an MCP client — Claude Desktop, Claude Code,
ollmcp, Open WebUI 0.6.31+
1. Install
python3 -m venv .venv
.venv/bin/pip install klaxon-mcpOr build the Docker image (klaxon-mcp is the entry point):
docker build -t klaxon-mcp .2. Point it at your indexer
export KLAXON_INDEXER_URL=https://indexer.example:9200
export KLAXON_INDEXER_USER=wazuh-readonly
export KLAXON_INDEXER_PASSWORD=...KLAXON_INDEXER_URL is the only required variable. Add KLAXON_MANAGER_URL for
manager/detectors and KLAXON_ENGINE_URL for tester_sessions; set
KLAXON_VERIFY_SSL=false only for a self-signed lab cluster.
3. Enable masking (recommended for external LLMs)
Masking is off by default and opt-in:
export KLAXON_ANONYMIZE_EXTERNAL_LLM=true # mask tool output for external models
export KLAXON_ANONYMIZATION_SALT=change-me-to-a-long-random-secret # stable tokensWith the switch on, output is masked unless KLAXON_LLM_BASE_URL points at a
loopback address (http://localhost:11434 for Ollama) — a local model keeps
receiving unchanged data. An optional config.yaml (KLAXON_CONFIG) holds only
what you change; environment variables always win:
anonymization:
mask_fields: # or KLAXON_ANONYMIZATION_MASK_FIELDS
- "source.ip"
- "user.name"
- "host.hostname"
mask_aggregation_keys: true # ON by default; false disables agg-key masking4. Start it
klaxon-mcp # stdio — your MCP client spawns it (klaxon is an alias)With Docker: docker run --rm -i --env-file .env klaxon-mcp.
5. First masked result
Ask your client: "Show me the last login by user.name=alice in
wazuh-events-v5-*." Klaxon runs search(index="wazuh-events-v5-*", body=…) and returns the masked response:
{
"hits": { "hits": [ { "_source": {
"user": { "name": "[USER_9f2a1c467dd5e2b8]" },
"source": { "ip": "[IP_5c01e73f9a2b4c1d]" },
"message": "user [USER_9f2a1c467dd5e2b8] logged in via ssh from [IP_5c01e73f9a2b4c1d]"
} } ] }
}The same value always maps to the same token. Masking is pseudonymization, not
anonymization, and it has documented blind spots — see
docs/llm-safety.md (in particular the verified leaks in
"Known limitations") before pointing an external model at Klaxon.
Related MCP server: wazuh-mcp
Basic usage
The handful of tools a normal user needs (full reference:
docs/TOOLS.md):
Tool | What it does | One-liner example |
| Any query against any index, raw JSON back |
|
| Which fields exist — and which actually carry data |
|
| How complete each field is, window vs all history |
|
| Findings by severity, agent, title, category |
|
| Push a raw line through the decoder chain |
|
| Find sensitive fields the mask list should cover |
|
| Read-only security posture: facts + gaps, no verdict |
|
On an unfamiliar cluster, start with field_coverage. Every thin result gets a
notice block before the data (empty aggregation, capped size, missing index —
all return HTTP 200 with nothing).
Configuration (essentials)
The keys a normal user changes day to day. Full reference: docs/configuration.md.
Variable / key | What it does | Default |
| Indexer endpoint | — (required) |
| Basic-auth credentials | empty |
| Master masking switch |
|
| Secret for token derivation (stable tokens) | random+persisted |
| Fields masked wholesale ( | built-in list |
| Mask aggregation bucket keys too |
|
| Mask usernames inside free text |
|
| TLS verification |
|
| Required bearer token when serving over HTTP | empty |
Advanced topics
The deep material lives in dedicated docs — linked, not duplicated:
GDPR plausibility checker (classification layers, custom rules, sampling, reports) →
docs/gdpr-checker.mdIngest masking / Option B masked stream (pipeline, ISM, index templates, quarantine for masking failures, sync job) →
docs/option-b-masked-stream.mdMulti-tenant setup (
fields.yaml,klaxon masking generate, salt, namespacing) →docs/multi-tenant.mdDrift prevention & CI (pre-commit drift hook, provenance fingerprints, fail-closed startup, sync preflight,
--verify-config) →docs/drift-prevention.mdToken scheme & security model (HMAC, salt, self-test, why 16 hex) →
docs/security-model.mdSecurity concept: brute-force re-identification risk (pseudonymization vs anonymization, salt as secret) →
docs/security-concept.mdSalt rotation runbook (no scheduled rotation; only on suspicion; response-layer + masked-stream paths) →
docs/salt-rotation-runbook.mdLLM safety: using Klaxon safely with an LLM (masked-stream-first routing, ready-to-copy system prompt, pseudonymization boundary, residual gate) →
docs/llm-safety.mdRunning it on another machine (HTTP transport, auth, TLS, CORS) →
docs/TOOLS.md,ARCHITECTURE.md
Development
.venv/bin/pip install -e ".[dev]"
.venv/bin/pytest # full suite
.venv/bin/mypy # strict type check
.venv/bin/ruff check srcOption B generator self-tests (see docs/drift-prevention.md):
klaxon masking selftest --tenant customer-a
klaxon masking generate --check # CI/pre-commit drift checkDeploy the masking artifacts to the indexer in one idempotent, ordered,
self-verifying step (preflight + GET-back verification + a _simulate smoke
test; --dry-run / --rollback):
klaxon masking deploy --tenant customer-a --dry-run # plan only, no writes
klaxon masking deploy --tenant customer-a # needs KLAXON_INDEXER_*Remove the Option B masking infrastructure from the indexer cleanly, leaving the
raw Wazuh streams untouched (destructive — preview with --dry-run; a
mandatory verification phase proves nothing klaxon-* is left and the raw
streams are intact):
klaxon masking teardown --tenant customer-a --dry-run # plan only, no writes
klaxon masking teardown --tenant customer-a --yes # needs KLAXON_INDEXER_*
klaxon masking teardown --tenant customer-a --yes --purge-sync-state
# ^ also delete the sync checkpoint marker (default: keep it so a future
# re-setup can resume from the last checkpoint)The live integration test (klaxon masking test) needs real indexer credentials —
see docs/option-b-masked-stream.md.
Release history: CHANGELOG.md.
Documentation
docs/TOOLS.md— full tool reference & parametersdocs/configuration.md— complete configuration referencedocs/gdpr-checker.md— the GDPR plausibility checkerdocs/option-b-masked-stream.md— ingest-side maskingdocs/multi-tenant.md— multi-tenant setupdocs/drift-prevention.md— drift prevention & CIdocs/security-model.md— token scheme & security modeldocs/security-concept.md— pseudonymization / brute-force re-identification riskdocs/salt-rotation-runbook.md— salt rotation runbook (only on suspicion)docs/llm-safety.md— masking guarantees & limitsARCHITECTURE.md— design rationaleCHANGELOG.md— release history
License
Apache-2.0 — see LICENSE.
Wazuh is a registered trademark of Wazuh Inc. Klaxon is an independent project and is not affiliated with, endorsed by, or sponsored by Wazuh Inc.
Support
For professional support, please contact support@sec73.io.
Available Tools
10 toolsdetectorsA
List or fetch OpenSearch Security Analytics detectors.
Detection in Wazuh 5 lives in the indexer, not in the Engine. These detectors are what produces the documents in wazuh-findings-v5-*.
The plugin exposes no list-all endpoint, so list is implemented as
POST /_plugins/_security_analytics/detectors/_search with match_all. Detector
documents are nested under the detector path, which matters if you search
them by name.
Args: action: "list" for all detectors, "get" for a single one by id. detector_id: Required when action is "get". size: Maximum number of detectors to return for "list". Defaults to 50.
Inputs: size, action, detector_id. Returns: result
field_coverageA
Measure what share of the documents actually carries a value per field.
The normalisation-quality measurement, callable without query DSL. For each mapped field it reports the document count and coverage inside a time window and across the whole datastream, because those are different questions:
event.action, wazuh-events-v5-network-activity*
whole datastream (10,238,381 docs) 8.1%
last 24 hours (348,247 docs) 71.0%
last 12 hours 100.0%A decoder fix had landed hours earlier. All three numbers are correct. The window describes the pipeline as it runs now, the datastream describes the stored history — quote the one you mean. When they differ by more than 20 percentage points the diagnostics block says so explicitly, because that gap is the signature of a change in normalisation inside the datastream.
Fields with 0% coverage are listed, never filtered: mapped-but-never-
populated is the agent.id trap and the most important result this
measurement produces.
Coverage is three-valued — populated, not populated, not measurable. An exists aggregation returns 0 for a field the mapping declares "index": false no matter what the documents hold, so the mapping is read first and such fields are reported as not measurable, with dashes, never as 0%. Verified: event.original in wazuh-events-v5-network-activity* is index:false and doc_values:false, matches 0 of 10,243,389 documents, and carries the complete raw log line in _source of every document sampled. For those fields the tool samples _source and reports in how many of the sampled documents the key is present — evidence rather than a coverage figure.
Cost scales with the field count — the schema has 2351 fields — so the
listing is capped at KLAXON_SCHEMA_FIELD_LIMIT (default 200) and the cap is
reported. Pass a prefix to measure a namespace instead of a truncation.
Args: index: Index or datastream pattern, e.g. "wazuh-events-v5-network-activity*". prefix: Restrict to a field namespace, e.g. "source." or "wazuh.". hours: Size of the time window ending now, in hours. Default 24. min_docs: Hide fields below this document count in the window. Default 0, which hides nothing. Any higher value removes the 0% fields — the ones worth looking at — so the output says how many it dropped.
Inputs: index (required), hours, prefix, min_docs. Returns: result
findings_overviewA
Summarise wazuh-findings-v5-* by severity, agent, rule title and category.
A frozen query for the breakdown every report starts with, so that producing
it needs no valid OpenSearch query DSL. search still covers everything
else; this tool only removes the need to hand-write the one aggregation that
recurs.
Severity is wazuh.rule.level, a keyword holding a string — critical,
high, medium, low, informational — not a Wazuh 4.x numeric level. The whole
scale is printed in canonical order with an explicit 0 for the levels that
did not occur, because a terms aggregation returns only the values it found:
a missing critical bucket cannot distinguish "none occurred" from "never
populated". Any value outside the scale is listed as well and marked
UNKNOWN. Before aggregating, the tool probes whether wazuh.rule.level is
populated at all and says so instead of printing a table of zeros.
Output is a compact set of tables, not raw JSON. The request that produced
them is in the footer if you want to re-run or extend it via search.
Args: hours: Size of the time window ending now, in hours. Default 24. top_agents: How many agents to list, by finding count. Default 10. top_titles: How many rule titles to list. Default 10.
Inputs: hours, top_agents, top_titles. Returns: result
gdpr_checkA
Run the DSGVO plausibility check on an index: find sensitive fields.
Reads the index mappings, samples a few documents, and classifies the
fields by three heuristics in decreasing certainty: custom rules from
config.yaml (gdpr_checker.custom_patterns), field-name patterns
(source.ip, user.name, host.hostname, user.email, ...), and sampled
values (an actual value like 192.168.1.100 reveals an IP even when the
field name does not).
Priorities: IPs, usernames and e-mails are directly personal (high);
hostnames and agent ids are indirectly personal (medium); free-text fields
that embed personal data are flagged as such. Fields already in the
anonymization mask_fields are reported as covered, not re-suggested.
With apply=true the suggested fields are merged into
anonymization.mask_fields of config.yaml (KLAXON_CONFIG), the action is
appended to gdpr_check.log, and gdpr_compliance_report.json is
written. The change takes effect for the running server on restart unless
KLAXON_ANONYMIZATION_MASK_FIELDS is set, which always overrides the file.
apply=false (default) is a dry run: suggestions only, nothing changed.
Args: index: Index or datastream pattern, e.g. "wazuh-events-v5-*". prefix: Restrict to a field namespace, e.g. "user." or "source.". sample_docs: Documents to sample for content analysis. Defaults to KLAXON_GDPR_SAMPLE_SIZE (10). 0 disables sampling. apply: When true, merge the suggested fields into config.yaml and log. exclude: Field names to skip (e.g. internal fields without GDPR relevance). as_json: When true, return a machine-readable JSON report instead of the table.
Inputs: index (required), apply, prefix, as_json, +2 more. Returns: result
klaxon_posture_checkA
Read-only security/DSGVO posture check: facts + gaps, never a verdict.
Returns one check: status — fact line per item: masking, response gate,
mode (response-layer vs Option B masked stream), pipeline drift, salt
strength, quarantine backlog, RBAC roles, retention and the startup
fail-closed check. Statuses are OK / WARN / unknown only — there is no
overall compliance verdict and no legal judgment (the same principle as
gdpr_check: report what can be established, nothing more).
The salt is NEVER emitted — not even partially, not hashed. No PII, raw values, tokens, hostnames, usernames, IPs or sampled values appear in the output; only counts, booleans, statuses, index patterns, durations and role names. When the indexer is unreachable a check reports "unknown — " instead of a guessed value. Read-only: nothing is written to the indexer, nothing deployed, no config change.
Args: tenant: Tenant whose masked/quarantine streams and RBAC roles are checked (default "customer-a"). hours: Quarantine-backlog window in hours (default 24).
Inputs: hours, tenant. Returns: result
logtestA
Run a raw log line through the Wazuh 5 decoder chain and return the result.
Calls the Content Manager plugin on the indexer. The response shows which decoders matched and what the normalised WCS document looks like, which is the way to find out why a field is empty in the index.
Args:
event: The raw log line to decode.
location: Log source path, e.g. "/var/ossec/logs/opnsense_syslog.log".
queue: Queue id of the originating source. Defaults to 49.
space: One of test, custom, standard — logtest supports no others. Custom
decoders live in "custom"; "standard" carries only the shipped ruleset.
A valid name does not guarantee the environment is provisioned — use
the tester_sessions tool to see which ones exist and are enabled.
trace_level: One of NONE, ASSET_ONLY, ALL. Defaults to ASSET_ONLY, which
is the level that reveals the matched decoder chain. NONE returns the
normalised event only; ALL adds per-asset trace detail.
integration: Integration name for the detection phase. Without it the
plugin normalises the event and reports detection as "skipped".
Inputs: event (required), location (required), queue, space, +2 more. Returns: result
managerA
Issue a GET against the Wazuh manager API and return the response unchanged.
A deliberately thin passthrough. The manager API is the volatile half of Wazuh 5 and breaks further at GA (/var/ossec moves to /var/wazuh-manager, clustering becomes the default, agent id 000 disappears), so this tool adds no interpretation on top of it.
Non-2xx responses are returned as they are, status code included. In Wazuh 5 several 4.x endpoints are gone and their 404 is the correct answer, not a failure to hide:
/rules 404, the Engine has no RULE content type any more
/manager/logs 404
/manager/stats/remoted 404 Verified working: /agents, /syscollector/{agent_id}/... Changed response schemas: /cluster/healthcheck (no
enabledfield), /cluster/nodes (nonode_typefield).
The security root is restricted to /security/users/me and
/security/users/me/policies — enough to tell RBAC filtering apart from an
empty deployment when /agents returns less than expected, without
enumerating the deployment's accounts, roles and policies through a tool
meant for agent and event data.
Args: path: Manager API path, e.g. "/agents". params: Optional query parameters.
Inputs: path (required), params. Returns: result
schemaA
List the fields of a Wazuh 5 index and how many documents actually fill them.
Use this before writing any aggregation. The Wazuh 5 engine schema defines
2351 fields, and a mapped field is not necessarily a populated one: agent.id
and wazuh.agent.id are both mapped as keyword, but only wazuh.agent.id
ever carries a value. Aggregating on the wrong one returns zero buckets and
HTTP 200 — no error at all. With only_populated=true this tool reports the
document count per field, which makes that distinction visible.
Namespace sizes in the engine schema: wazuh=492, threat=444, process=391, file=144, tls=77, host=57, observer=53, dll=46, user=46, client=35, destination=35, server=35.
Args: index: Index or datastream pattern, e.g. "wazuh-events-v5-network-activity*". prefix: Restrict to a field namespace, e.g. "wazuh." or "source.". Strongly recommended — an unfiltered listing over 2351 fields is capped. only_populated: When true (default), issue a second pass with exists aggregations and return only fields holding a value in at least one document.
Inputs: index (required), prefix, only_populated. Returns: result
searchA
Run an OpenSearch query DSL request against a Wazuh 5 index pattern.
Returns the raw JSON response, including aggregations, preceded by a
diagnostics block when the result needs interpretation (zero hits, a
truncated total, or an aggregation that covers only part of the result set).
Common patterns:
wazuh-events-v5-* all events
wazuh-findings-v5-* all detection findings
wazuh-events-v5-* one of: access-management, applications, cloud-services, network-activity, other, security, system-activity, unclassified
These are datastreams. Always query the wildcard pattern, never a backing index such as .ds-wazuh-events-v5-network-activity-000001.
The time field is @timestamp. There is no timestamp field in Wazuh 5.
Set "track_total_hits": true whenever you need an exact count; without it
OpenSearch stops counting at 10000.
A "size" larger than KLAXON_SEARCH_MAX_SIZE (default 100) is lowered to that limit before the query is sent, and the diagnostics block says so. Use "size": 0 with aggregations to count without pulling documents.
The same cap applies to the size of bucketed aggregations (terms,
significant_terms, significant_text, multi_terms, composite, top_hits):
an oversized aggregation size is lowered to KLAXON_SEARCH_MAX_SIZE before
the query is sent and an "[AGG SIZE CAPPED]" diagnostics line names the
affected aggregation and its requested size, so a lowered bucket count is
never read as the real one.
When anonymization is enabled and KLAXON_ANONYMIZATION_MASK_AGGREGATION_KEYS
is on, aggregation bucket keys for masked fields (terms, multi_terms,
composite) are replaced with the same deterministic tokens as the _source
pass — so aggregation keys and hits stay aligned for the same entity.
Args: index: Index or datastream pattern, e.g. "wazuh-events-v5-network-activity*". body: OpenSearch query DSL as a JSON string.
Inputs: body (required), index (required). Returns: result
tester_sessionsA
List the Wazuh 5 engine test sessions — the environments logtest can use.
logtest answers a call naming an environment that does not exist with
HTTP 200 and "The '' environment does not exist" buried in the body.
This tool is how to find out which environments there actually are, and
whether they are enabled.
Calls POST /_internal/tester/table/get on the engine's internal HTTP API. That server runs inside the manager container on its own port, so it needs KLAXON_ENGINE_URL — the indexer and manager URLs do not reach it.
Read-only by design. The engine also exposes session/post, session/delete and session/reload; none of them are wired up here. Sessions are recreated on every policy import through the Content Manager API, so a hand-made session disappears at the next import — a create tool would only invite a workaround that does not hold.
Args: action: Only "list" is supported.
Inputs: action. Returns: result
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
TDQS
Scored across 10 tools
Each tool covers a distinct operation: raw search, schema introspection, log decoding, manager passthrough, detector listing, test session listing, prebuilt findings summary, field coverage, GDPR classification, and posture check. search and findings_overview overlap somewhat, and schema vs field_coverage both address field population, but the descriptions clearly differentiate their scopes and outputs.
All tool names use lower_snake_case and are descriptive, but there is no uniform verb_noun pattern: search is a bare verb, schema/manager/detectors are nouns, and others are noun+noun or noun+verb compounds. The style is consistent enough that names remain predictable, with only minor deviations from a stricter convention.
Ten tools is well within the ideal range, and each serves a distinct investigative or compliance function. No tool feels redundant or superfluous, and the count matches the server's multi-faceted security analytics scope without becoming unwieldy.
The set covers the core Wazuh analysis lifecycle: querying, schema understanding, decoding, findings summarization, field population measurement, GDPR checks, and posture review. Notable gaps exist around detector lifecycle management (only list/get, no create/update/delete) and the manager passthrough is GET-only, though the descriptions explicitly justify these omissions as intentional.
Maintenance
Related MCP Connectors
Text-to-SQL MCP server: read-only queries on PostgreSQL, MySQL and SQL Server with your real schema
The BigQuery remote MCP server is a fully managed service that uses the Model Context Protocol to connect AI applications and LLMs to BigQuery data sources. It provides secure, standardized tools for AI agents to list datasets and tables, retrieve schemas, generate and execute SQL queries through natural language, and analyze data—enabling direct access to enterprise analytics data without requiring manual SQL coding.
MCP server providing access to the Scorecard API to evaluate and optimize LLM systems.
Related MCP Servers
- AlicenseBqualityDmaintenanceA Model Context Protocol server that enables querying and analyzing Wazuh security logs stored in OpenSearch, with features for searching alerts, getting detailed information, generating statistics, and visualizing trends.92MIT
- AlicenseBqualityBmaintenanceAn MCP server for the Wazuh SIEM/XDR platform that enables users to query agents, security alerts, detection rules, and decoders through Claude or other MCP clients. It provides specialized tools and prompts for investigating security alerts, performing agent health checks, and generating environmental security overviews.2878 npm9MIT
- AlicenseAqualityBmaintenanceAI-powered MCP server that enables security analysts to query Wazuh SIEM/XDR for alert triage, threat hunting, compliance audits, and incident response through natural language prompts.2813MIT
- AlicenseNot gradedqualityBmaintenanceA production-ready Model Context Protocol (MCP) server for seamless integration between Wazuh SIEM and Large Language Models (LLMs).94AGPL 3.0