Logtest architecture and developer guide
Component overview
The logtest flow involves four layers: a thin REST handler, a transport action that owns request validation, an orchestration service, and the external services it calls.
RestPostLogtestAction → TransportLogtestAction → LogtestService → EngineService + SecurityAnalyticsService
RestPostLogtestNormalizationAction → TransportLogtestNormalizationAction → ↑ ↑
RestPostLogtestDetectionAction → TransportLogtestDetectionAction → ↑ ↑
(REST handlers) (Validation) (Orchestration) (External services)
REST handlers no longer validate requests or contain any business logic — that responsibility moved to the transport action layer. This is a plugin-wide pattern, not specific to logtest: every REST handler in rest/service/ delegates to a same-named Transport*Action via client.execute(), per AbstractContentAction’s own javadoc (“Business logic has been moved to transport actions; REST handlers now delegate to the transport layer via client.execute()”).
RestPostLogtestAction (combined)
Path: rest/service/RestPostLogtestAction.java
The REST handler for POST /_plugins/_content_manager/logtest. Before dispatch it enforces the request-body size cap via PayloadValidations.validateLogtestBodySize(...) — a body larger than plugins.content_manager.logtest.max_body_bytes is rejected with 413 at the REST layer, before parsing (see Request size limit and concurrency). Otherwise it reads the raw request body into a LogtestRequest and calls client.execute(LogtestAction.INSTANCE, logtestRequest, listener). It performs no field-level validation and does not interact with indices or external services directly.
TransportLogtestAction
Path: transport/TransportLogtestAction.java
The validation and dispatch layer for the combined endpoint. Responsibilities:
- 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. Enforces the body-size cap (413) the same way as the combined handler, then reads the request body and calls client.execute(LogtestNormalizationAction.INSTANCE, ...). No field-level validation.
TransportLogtestNormalizationAction
Path: transport/TransportLogtestNormalizationAction.java
Responsibilities:
- 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. Enforces the body-size cap (413) the same way as the combined handler, then reads the request body and calls client.execute(LogtestDetectionAction.INSTANCE, ...). No field-level validation.
TransportLogtestDetectionAction
Path: transport/TransportLogtestDetectionAction.java
Responsibilities:
- 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.executeDetectionAsync(integrationId, space, inputEvent).
All three transport actions run this work on the dedicated content_manager_logtest thread pool rather than the transport thread (see Request size limit and concurrency).
Request size limit and concurrency
The logtest endpoints are deliberately reachable by low-privilege accounts. To keep that surface from being used to exhaust the indexer’s heap, two independent controls bound the work each request can cause:
- Body-size cap (413). Every logtest REST handler checks the raw request-body length against
plugins.content_manager.logtest.max_body_bytes(default 1 MiB; dynamic; range 1 KiB–16 MiB) before parsing or dispatching, via the sharedPayloadValidations.validateLogtestBodySize(...)helper. An oversized body is rejected with 413REQUEST_ENTITY_TOO_LARGEand the messagelogtest payload exceeds the maximum allowed size of <N> bytes., and the offending account and size are logged atWARN. This removes the input-to-response amplification the endpoint would otherwise permit, since the input is bounded before any work happens. - Bounded thread pool (429). The three transport actions offload execution onto a dedicated
FixedExecutorBuilderpool namedcontent_manager_logtest(sizemax(1, allocatedProcessors / 2), queue 100), registered inContentManagerPlugin.getExecutorBuilders(...). This keeps the blocking Engine-socket call off the transport threads, and when the bounded queue is full excess requests are shed with 429TOO_MANY_REQUESTS(logtest is busy: too many concurrent requests. Please retry later.) instead of accumulating on the heap. Worst-case in-flight payload is bounded to roughly(pool size + queue) * max_body_bytes.
All three transport actions run this work on the dedicated content_manager_logtest thread pool rather than the transport thread (see Request size limit and concurrency).
Request size limit and concurrency
The logtest endpoints are deliberately reachable by low-privilege accounts. To keep that surface from being used to exhaust the indexer’s heap, two independent controls bound the work each request can cause:
- Body-size cap (413). Every logtest REST handler checks the raw request-body length against
plugins.content_manager.logtest.max_body_bytes(default 1 MiB; dynamic; range 1 KiB–16 MiB) before parsing or dispatching, via the sharedPayloadValidations.validateLogtestBodySize(...)helper. An oversized body is rejected with 413REQUEST_ENTITY_TOO_LARGEand the messagelogtest payload exceeds the maximum allowed size of <N> bytes., and the offending account and size are logged atWARN. This removes the input-to-response amplification the endpoint would otherwise permit, since the input is bounded before any work happens. - Bounded thread pool (429). The three transport actions offload execution onto a dedicated
FixedExecutorBuilderpool namedcontent_manager_logtest(sizemax(1, allocatedProcessors / 2), queue 100), registered inContentManagerPlugin.getExecutorBuilders(...). This keeps the blocking Engine-socket call off the transport threads, and when the bounded queue is full excess requests are shed with 429TOO_MANY_REQUESTS(logtest is busy: too many concurrent requests. Please retry later.) instead of accumulating on the heap. Worst-case in-flight payload is bounded to roughly(pool size + queue) * max_body_bytes.
All three transport actions run this work on the dedicated content_manager_logtest thread pool rather than the transport thread (see Request size limit and concurrency).
Request size limit and concurrency
The logtest endpoints are deliberately reachable by low-privilege accounts. To keep that surface from being used to exhaust the indexer’s heap, two independent controls bound the work each request can cause:
- Body-size cap (413). Every logtest REST handler checks the raw request-body length against
plugins.content_manager.logtest.max_body_bytes(default 1 MiB; dynamic; range 1 KiB–16 MiB) before parsing or dispatching, via the sharedPayloadValidations.validateLogtestBodySize(...)helper. An oversized body is rejected with 413REQUEST_ENTITY_TOO_LARGEand the messagelogtest payload exceeds the maximum allowed size of <N> bytes., and the offending account and size are logged atWARN. This removes the input-to-response amplification the endpoint would otherwise permit, since the input is bounded before any work happens. - Bounded thread pool (429). The three transport actions offload execution onto a dedicated
FixedExecutorBuilderpool namedcontent_manager_logtest(sizemax(1, allocatedProcessors / 2), queue 100), registered inContentManagerPlugin.getExecutorBuilders(...). This keeps the blocking Engine-socket call off the transport threads, and when the bounded queue is full excess requests are shed with 429TOO_MANY_REQUESTS(logtest is busy: too many concurrent requests. Please retry later.) instead of accumulating on the heap. Worst-case in-flight payload is bounded to roughly(pool size + queue) * max_body_bytes.
LogtestService
Path: cti/catalog/service/LogtestService.java
The orchestrator. Provides three public entry points:
executeLogtest()— Full combined flow (normalization + detection)executeNormalization()— Engine-only: forwards payload toEngineService.logtest()and returns the response directly withparseMessageAsJson()executeDetectionAsync()— Security Analytics-only: looks up integration, fetches rule IDs/bodies, evaluates viaSecurityAnalyticsService.evaluateRulesAsync(), 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: "'integration' field not 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, the rule bodies and the integration’s detector coordinates to
SecurityAnalyticsService.evaluateRulesAsync(). - 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 / PercolateRuleEvaluator
The Security Analytics evaluation happens in the security-analytics repository:
SecurityAnalyticsServiceImpl.evaluateRulesAsync()— Sends the event, the rule bodies and the integration’s detector coordinates (id, log type, source indices) to theWEvaluateRulesActiontransport action.WTransportEvaluateRulesAction— Parses the rule bodies intoSigmaRuleobjects and delegates toPercolateRuleEvaluator.PercolateRuleEvaluator.evaluate()— Compiles each rule withOSQueryBackend, stores the compiled queries in the logtest percolator index, and percolates the normalized event against them. Returns a JSON result string.
Why percolation. Detection has to answer one question: would the deployed detector fire on this
event? A detector answers it by percolating compiled query_string queries against its query index,
so logtest answers it the same way. Evaluating the Sigma condition directly in the JVM would answer a
subtly different question — it cannot reproduce query_string parsing, the query index’s analyzers,
or the percolator’s own field-mapping requirements — which makes its answer an approximation rather
than a prediction. Sharing the compiler, the analyzers and the percolator makes the two answers the
same by construction, and keeps them that way as any of the three changes.
Everything the evaluator relies on is the same artifact production uses:
| Concern | Shared mechanism |
|---|---|
| Sigma → query | OSQueryBackend.convertRule(), as rule upload does |
| Analysis | the .opensearch-sap-*-detectors-queries* index template (rule_analyzer, rule_ws_normalizer) |
| Field mappings | copied from the integration’s source indices, with the same per-type overrides a detector’s query index gets |
| Matching | alerting’s percolate_ext query over a percolator_ext field |
LogtestQueryIndex owns the percolator index, one per log type
(.opensearch-sap-<logtype>-detectors-queries-logtest). The name is deliberately inside the
detector query index template’s pattern, so the index inherits the analysis settings rather than
redefining them. The index is created on demand and document ids are content-addressed, so a rule
whose text has not changed reuses its document and repeated calls neither overwrite nor accumulate;
documents left by an earlier revision of a rule are pruned once past a short grace period.
Match results use a nested rule object per match entry, where matched_conditions lists the
conditions the event satisfies, one entry per condition:
{
"rule": { "id": "...", "title": "...", "level": "...", "tags": [...] },
"matched_conditions": ["http.request.method matched 'GET'", "url.original matched '*UNION SELECT*'"]
}
Data flow
Client request
│
▼
RestPostLogtestAction (combined)
│ reads body into LogtestRequest, no validation
│ client.execute(LogtestAction.INSTANCE, ...)
▼
TransportLogtestAction
│ validates request
│ strips "integration" field
▼
LogtestService.executeLogtest(integrationId, space, payload)
│
├──► [if integrationId == null]
│ → executeEngineOnly(payload)
│ → returns normalization + detection: { status: "skipped" }
│
├──► client.prepareSearch("wazuh-threatintel-integrations")
│ → finds integration in given space (test or standard)
│ → extracts rule IDs from document.rules
│
├──► engineService.logtest(payload)
│ → sends to Wazuh Engine socket
│ → receives normalized event
│ → extracts "output" node as normalized event JSON
│
├──► client.prepareSearch("wazuh-threatintel-rules")
│ → fetches rule bodies by document.id + space filter
│
├──► securityAnalytics.evaluateRulesAsync(normalizedEventJson, ruleBodies,
│ integrationId, logType, sourceIndices)
│ → parses YAML → SigmaRule objects
│ → OSQueryBackend compiles each rule to a query_string query
│ → queries are upserted into the logtest percolator index
│ → the event is percolated against them
│ → returns JSON result
│
└──► builds combined response
{ normalization: {...}, detection: {...} }
Split endpoints
In addition to the combined flow, there are two dedicated endpoints that execute normalization and detection independently:
RestPostLogtestNormalizationAction RestPostLogtestDetectionAction
│ no validation, delegates via client.execute() │ no validation, delegates via client.execute()
▼ ▼
TransportLogtestNormalizationAction TransportLogtestDetectionAction
│ validates: space │ validates: space, integration, input
│ strips integration field │
▼ ▼
LogtestService.executeNormalization(payload) LogtestService.executeDetection(id, space, input)
│ │
└──► engineService.logtest(payload) ├──► client.prepareSearch(".cti-integrations")
→ returns engine response directly │ → finds integration
├──► extractRuleIds() + fetchRuleBodies()
│ → fetches rule content from .cti-rules
└──► securityAnalytics.evaluateRulesAsync(inputJson, ruleBodies, ...)
→ returns Security Analytics result directly
Key differences from the combined endpoint
- Normalization returns the raw Engine response (no detection wrapper). The
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.
Security Analytics additionally reads the integration’s detector source indices
(document.detector.source, or wazuh-events-v5-<category> when it names none) to build the
percolator index mappings, and owns .opensearch-sap-<logtype>-detectors-queries-logtest.
Testing
Unit tests
| Test 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) |
LogtestQueryIndexTests | Percolator index naming and the source-mapping copy (analysis overrides, merges, conflicts) |
RuleTopicIndicesTests | The query index analysis settings both a detector and logtest depend on |
Integration tests
| Test class | Covers |
|---|---|
LogtestIT | End-to-end REST workflow against a live test cluster (request validation, integration lookup, promote + logtest, response structure) and real percolation through the detection endpoint: exact-case matching, case-sensitivity, non-matching events, and rules reported as skipped |
Integration tests extend ContentManagerRestTestCase and run against a real OpenSearch cluster.
Since the Wazuh Engine is not available in the test environment, engine-dependent tests validate
graceful error handling (engine error → Security Analytics skipped). Detection is not mocked: the
test cluster installs Security Analytics and alerting, and
plugins.content_manager.security_analytics.mock is set to false there so rule evaluation
percolates for real. plugins.content_manager.engine.mock stays true because the Engine’s Unix
socket genuinely is absent; the two settings are separate for exactly this reason.
Adding new logtest features
Supporting a new validation field
- 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
Detection semantics live in the Sigma compiler, not in logtest: a new modifier is implemented in
rules/modifiers/ and rendered by OSQueryBackend, and logtest picks it up for free because it
compiles rules with the same backend.
- Add the modifier under
rules/modifiers/and register it inSigmaModifierFacade. - Render it in
OSQueryBackend, and pin the generated query inQueryBackendTests. - Cover it end to end in
LogtestIT(detection endpoint) so the percolate path is exercised. - Update the Sigma rules doc (Sigma Rules), including the case-sensitivity behaviour.