Skip to content

Commit be4e91d

Browse files
committed
Add Sigma correlation rule support to the LogScale backend
Correlation rules previously raised an error because the backend declared no correlation methods. They now convert to CQL: - event_count and value_count - value_sum, value_avg, value_percentile and value_median - temporal, including boolean conditions (e.g. rule_a and rule_b) Aggregation uses bucket(), event typing for temporal rules uses case{}, and the threshold filter uses test(). Field aliases are normalised so group-by works across rules with different field names. temporal_ordered is not supported and raises NotImplementedError. Bumps the minimum pysigma to 1.2.0, which is where extended temporal condition support was added.
1 parent 0921d8e commit be4e91d

5 files changed

Lines changed: 1286 additions & 9 deletions

File tree

README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,64 @@ The following categories and products are supported by the pipelines:
2424

2525
There's likely more windows categories that can be supported by the pipelines; We will be adding support gradually as availability allows.
2626

27+
## Correlation Rules
28+
The `LogScaleBackend` supports [Sigma correlation rules](https://github.com/SigmaHQ/sigma-specification/blob/main/specification/sigma-correlation-rules-specification.md). Aggregation is expressed with LogScale's [`bucket()`](https://library.humio.com/data-analysis/functions-bucket.html) function, which divides the search interval into fixed windows of the rule's `timespan` and groups by the `group-by` fields. The aggregated rows are then filtered on the computed metric with [`test()`](https://library.humio.com/data-analysis/functions-test.html).
29+
30+
The following correlation types are supported:
31+
32+
| type | LogScale aggregation |
33+
|-|-|
34+
| `event_count` | `count(as=event_count)` |
35+
| `value_count` | `count(field=<field>, distinct=true, as=value_count)` |
36+
| `value_sum` | `sum(<field>, as=value_sum)` |
37+
| `value_avg` | `avg(<field>, as=value_avg)` |
38+
| `value_percentile` | `percentile(<field>, percentiles=[<p>], as=…)` (renamed to a fixed field) |
39+
| `value_median` | `percentile(<field>, percentiles=[50], as=…)` (renamed to a fixed field) |
40+
| `temporal` | `count(field=event_type, distinct=true, as=event_type_count)` over events tagged by a [`case{}`](https://library.humio.com/data-analysis/syntax-conditional.html) statement |
41+
| `temporal` with a boolean condition (`r1 and r2 not r3`) | [`collect()`](https://library.humio.com/data-analysis/functions-collect.html) the fired rules per group, then [`array:contains()`](https://library.humio.com/data-analysis/functions-array-contains.html) membership tests |
42+
43+
For example, the following `event_count` correlation rule:
44+
```yaml
45+
title: Failed logon attempts
46+
name: failed_logon
47+
status: test
48+
logsource:
49+
category: test
50+
detection:
51+
selection:
52+
EventType: failed_logon
53+
condition: selection
54+
---
55+
title: Multiple failed logons for a single user
56+
status: test
57+
correlation:
58+
type: event_count
59+
rules:
60+
- failed_logon
61+
group-by:
62+
- UserName
63+
timespan: 10m
64+
condition:
65+
gte: 10
66+
```
67+
is converted to:
68+
```
69+
EventType=/^failed_logon$/i
70+
| bucket(span=10m, limit=max, field=[UserName], function=count(as=event_count))
71+
| test(event_count >= 10)
72+
```
73+
74+
`temporal` correlations reference multiple rules. Each rule's matching events are tagged with an `event_type` in a `case{}` statement (events matching no referenced rule are dropped), and the query fires when the distinct number of matched rules per group reaches the number of referenced rules. Field `aliases` are normalised with `| <alias> := <field>` so that `group-by` works across rules with differing field names.
75+
76+
**Caveats:**
77+
- Only the correlation query is emitted; the referenced rules are used as subqueries and are not returned separately.
78+
- Like Splunk's `bin _time`, `bucket()` uses fixed (tumbling) windows aligned to the timespan boundary, so a burst of events that straddles a window boundary may not reach the threshold within a single window.
79+
- `bucket()` is emitted with `limit=max` to avoid its low default series cap, but it is still bounded (top series by value). Very high-cardinality `group-by` correlations may be truncated, and `lt`/`lte` thresholds are unreliable beyond that cap.
80+
- `value_count` uses LogScale's `distinct=true`, which is an estimate (typical error < 2%); exact distinct counts are not available in LogScale.
81+
- `timespan` units map onto LogScale relative-time spans: `s`, `m`, `h`, `d`, `w`, `y` are emitted verbatim and `M` (month) is translated to `mon` (30-day fixed month).
82+
- `value_percentile` uses LogScale's estimated `percentile()`; `value_median` is computed as the 50th percentile.
83+
- `temporal_ordered` is not supported and raises `NotImplementedError`; LogScale has no template-friendly ordered-sequence primitive.
84+
2785
## Limitations and caveats:
2886
- **Full Paths**:
2987
Falcon agents do not capture drive names when logging paths. Instead, when drive letters are expected the device path is used. For example, `C:\Windows` results to `\Device\HarddiskVolume3\Windows` in the logs. To account for this, the pipeline replaces any drive letters in fields containing full path with `\Device\HarddiskVolume?\` (where '?' can be any single character).

poetry.lock

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ packages = [
1212

1313
[tool.poetry.dependencies]
1414
python = "^3.10"
15-
pysigma = "^1.0.1"
15+
pysigma = "^1.2.0"
1616

1717
[tool.poetry.group.dev.dependencies]
1818
black = "^24.10.0"

sigma/backends/crowdstrike/logscale.py

Lines changed: 224 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@
1818
SigmaString,
1919
)
2020
from sigma.exceptions import SigmaFeatureNotSupportedByBackendError
21+
from sigma.correlations import (
22+
SigmaCorrelationRule,
23+
SigmaCorrelationType,
24+
SigmaCorrelationTypeLiteral,
25+
SigmaCorrelationCondition,
26+
)
2127
import sigma
2228
import re
2329
from typing import ClassVar, Dict, Tuple, Pattern, Optional, Union
@@ -226,6 +232,144 @@ class LogScaleBackend(TextQueryBackend):
226232
"" # String used as query if final query only contains deferred expression
227233
)
228234

235+
# Correlation rule support
236+
# CrowdStrike LogScale (Humio) natively provides the aggregation and event
237+
# typing primitives required by Sigma correlation rules:
238+
# * event_count / value_count -> bucket() with count() / count(distinct=true)
239+
# * value_sum / value_avg -> bucket() with sum() / avg()
240+
# * value_percentile / value_median-> bucket() with percentile() (+ rename)
241+
# * temporal -> case{} event typing + distinct event_type count
242+
# * temporal (boolean condition) -> collect() the fired rules + array:contains() tests
243+
# bucket() divides the search interval into fixed windows (span) and groups by
244+
# the fields passed to its field= parameter, mirroring Splunk's
245+
# "bin _time span=... | stats ... by _time <fields>". The aggregated rows are
246+
# then filtered on the computed metric with test().
247+
# temporal_ordered is not supported (LogScale has no template-friendly ordered
248+
# sequence primitive) and raises NotImplementedError.
249+
# https://library.humio.com/data-analysis/functions-bucket.html
250+
# The backend exposes a single correlation method named "default".
251+
correlation_methods: ClassVar[Dict[str, str]] = {
252+
"default": "CrowdStrike LogScale correlation using bucket() aggregation",
253+
}
254+
default_correlation_method: ClassVar[str] = "default"
255+
256+
# Query frame shared by all supported correlation types. The search phase emits
257+
# the matching expression (a single rule query or a case{} typing block for
258+
# multi-rule temporal correlations), followed by the time-bucketed aggregation
259+
# and the final threshold filter.
260+
default_correlation_query: ClassVar[Dict[str, str]] = {
261+
"default": "{search}\n{aggregate}\n{condition}",
262+
}
263+
264+
# Search phase
265+
# Single referenced rule (event_count/value_count): emit its query verbatim.
266+
# Optional because convert_correlation_search temporarily unsets it to force
267+
# the multi-rule typing path for single-rule temporal correlations.
268+
correlation_search_single_rule_expression: ClassVar[Optional[str]] = "{query}"
269+
# Multiple referenced rules (temporal): wrap each rule query in a case{} clause
270+
# that tags matching events with their originating rule via the event_type
271+
# field. LogScale drops events matching none of the clauses (no wildcard clause
272+
# is emitted), which bounds the correlation to the referenced rules.
273+
# https://library.humio.com/data-analysis/syntax-conditional.html
274+
correlation_search_multi_rule_expression: ClassVar[str] = "case {{\n{queries}\n}}"
275+
correlation_search_multi_rule_query_expression: ClassVar[str] = (
276+
'{query} | event_type := "{ruleid}"{normalization}'
277+
)
278+
correlation_search_multi_rule_query_expression_joiner: ClassVar[str] = ";\n"
279+
280+
# Field normalisation (aliases): rename each referenced field to the shared
281+
# alias field so that group-by works across rules with differing field names.
282+
correlation_search_field_normalization_expression: ClassVar[str] = (
283+
" | {alias} := {field}"
284+
)
285+
correlation_search_field_normalization_expression_joiner: ClassVar[str] = ""
286+
287+
# Aggregation phase: bucket() windows the search interval by {timespan} and
288+
# aggregates per group. Sigma's group-by fields are passed to bucket()'s
289+
# field= array parameter (see groupby_* templates below). limit=max raises
290+
# bucket()'s default series cap (10) to the maximum so group-by correlations
291+
# are not silently truncated to the ten highest-count groups.
292+
event_count_aggregation_expression: ClassVar[Dict[str, str]] = {
293+
"default": "| bucket(span={timespan}, limit=max{groupby}, function=count(as=event_count))",
294+
}
295+
value_count_aggregation_expression: ClassVar[Dict[str, str]] = {
296+
"default": "| bucket(span={timespan}, limit=max{groupby}, function=count(field={field}, distinct=true, as=value_count))",
297+
}
298+
temporal_aggregation_expression: ClassVar[Dict[str, str]] = {
299+
"default": "| bucket(span={timespan}, limit=max{groupby}, function=count(field=event_type, distinct=true, as=event_type_count))",
300+
}
301+
# Metric aggregations over a numeric {field}.
302+
value_sum_aggregation_expression: ClassVar[Dict[str, str]] = {
303+
"default": "| bucket(span={timespan}, limit=max{groupby}, function=sum({field}, as=value_sum))",
304+
}
305+
value_avg_aggregation_expression: ClassVar[Dict[str, str]] = {
306+
"default": "| bucket(span={timespan}, limit=max{groupby}, function=avg({field}, as=value_avg))",
307+
}
308+
# percentile()/median() name their output field with a numeric suffix
309+
# (e.g. value_percentile_95), so the result is renamed to a fixed field the
310+
# condition phase can reference without knowing the percentile.
311+
value_percentile_aggregation_expression: ClassVar[Dict[str, str]] = {
312+
"default": "| bucket(span={timespan}, limit=max{groupby}, function=percentile({field}, percentiles=[{percentile}], as=value_percentile))\n| rename(value_percentile_{percentile}, as=value_percentile)",
313+
}
314+
value_median_aggregation_expression: ClassVar[Dict[str, str]] = {
315+
"default": "| bucket(span={timespan}, limit=max{groupby}, function=percentile({field}, percentiles=[50], as=value_median))\n| rename(value_median_50, as=value_median)",
316+
}
317+
# Extended (boolean) temporal correlation: collect the distinct set of rules
318+
# that fired per group into an array, then filter with the boolean condition.
319+
# collect() yields a newline-joined string, so it is split back into an
320+
# indexable array that array:contains() can test in the condition phase.
321+
temporal_extended_aggregation_expression: ClassVar[Dict[str, str]] = {
322+
"default": '| bucket(span={timespan}, limit=max{groupby}, function=collect([event_type], as=event_types))\n| splitString(field=event_types, by="\\n", as=matched)',
323+
}
324+
325+
# Sigma timespan units map onto LogScale relative-time abbreviations directly
326+
# for s/m/h/d/w/y; only the Sigma month unit ("M") needs translation, as
327+
# LogScale spells months "mon" and reads "m" as minutes.
328+
timespan_mapping: ClassVar[Dict[str, str]] = {"M": "mon"}
329+
330+
# Group-by rendered as bucket()'s field= array parameter. When the correlation
331+
# rule omits group-by, no field= parameter is emitted (bucket by time only).
332+
groupby_expression: ClassVar[Dict[str, str]] = {"default": ", field=[{fields}]"}
333+
groupby_field_expression: ClassVar[Dict[str, str]] = {"default": "{field}"}
334+
groupby_field_expression_joiner: ClassVar[Dict[str, str]] = {"default": ", "}
335+
groupby_expression_nofield: ClassVar[Dict[str, str]] = {"default": ""}
336+
337+
# Condition phase: filter aggregated rows on the computed metric. The default
338+
# correlation_condition_mapping (<, <=, >, >=, ==, !=) is inherited unchanged;
339+
# all operators are valid inside test().
340+
event_count_condition_expression: ClassVar[Dict[str, str]] = {
341+
"default": "| test(event_count {op} {count})",
342+
}
343+
value_count_condition_expression: ClassVar[Dict[str, str]] = {
344+
"default": "| test(value_count {op} {count})",
345+
}
346+
temporal_condition_expression: ClassVar[Dict[str, str]] = {
347+
"default": "| test(event_type_count {op} {count})",
348+
}
349+
value_sum_condition_expression: ClassVar[Dict[str, str]] = {
350+
"default": "| test(value_sum {op} {count})",
351+
}
352+
value_avg_condition_expression: ClassVar[Dict[str, str]] = {
353+
"default": "| test(value_avg {op} {count})",
354+
}
355+
value_percentile_condition_expression: ClassVar[Dict[str, str]] = {
356+
"default": "| test(value_percentile {op} {count})",
357+
}
358+
value_median_condition_expression: ClassVar[Dict[str, str]] = {
359+
"default": "| test(value_median {op} {count})",
360+
}
361+
# Extended temporal condition: the parsed boolean expression, with each rule
362+
# reference rendered as an array membership test. and/or/not are combined
363+
# using the backend's boolean tokens (space / or / not).
364+
temporal_extended_condition_expression: ClassVar[Dict[str, str]] = {
365+
"default": "| {extended_condition}",
366+
}
367+
extended_correlation_condition_rule_reference_expression: ClassVar[
368+
Dict[str, str]
369+
] = {
370+
"default": 'array:contains(array="matched[]", value="{ruleid}")',
371+
}
372+
229373
def __init__(
230374
self,
231375
processing_pipeline: Optional[
@@ -236,6 +380,85 @@ def __init__(
236380
):
237381
super().__init__(processing_pipeline, collect_errors, **kwargs)
238382

383+
def convert_correlation_search(self, rule: SigmaCorrelationRule, **kwargs) -> str:
384+
"""Embed the referenced rule query in a correlation search.
385+
386+
Two adjustments are made on top of the base implementation:
387+
388+
* Temporal correlations must tag every matched event with its
389+
originating rule via the multi-rule ``case{}`` typing. The base
390+
single-rule search expression omits that tag, so a temporal rule that
391+
happens to reference a single rule would produce a query that never
392+
matches. Force the multi-rule path for temporal correlations (it
393+
renders a correct one-clause ``case{}`` when only one rule is
394+
referenced).
395+
* A referenced rule whose query consists solely of deferred parts (e.g.
396+
a rule matching only on ``fieldX|cidr``) is emitted with a leading
397+
``deferred_start`` pipe. That leading pipe is spurious once the query
398+
is placed at the start of a correlation query, so it is stripped.
399+
"""
400+
if rule.type == SigmaCorrelationType.TEMPORAL:
401+
# Instance attribute temporarily shadows the class variable to force
402+
# the base multi-rule (case{} typing) search path.
403+
single_rule_expression = self.correlation_search_single_rule_expression
404+
self.correlation_search_single_rule_expression = None # type: ignore[misc]
405+
try:
406+
search = super().convert_correlation_search(rule, **kwargs)
407+
finally:
408+
self.correlation_search_single_rule_expression = ( # type: ignore[misc]
409+
single_rule_expression
410+
)
411+
else:
412+
search = super().convert_correlation_search(rule, **kwargs)
413+
return search.removeprefix(self.deferred_start)
414+
415+
def convert_correlation_search_multi_rule_query_postprocess(
416+
self, query: str
417+
) -> str:
418+
"""Strip the leading deferred pipe from a temporal case clause query.
419+
420+
Without this, a fully-deferred referenced rule would produce a case
421+
clause starting with a bare ``| ...`` which is not valid LogScale.
422+
"""
423+
return query.removeprefix(self.deferred_start)
424+
425+
def convert_correlation_aggregation_from_template(
426+
self,
427+
rule: SigmaCorrelationRule,
428+
correlation_type: SigmaCorrelationTypeLiteral,
429+
method: str,
430+
search: str,
431+
) -> str:
432+
"""Escape/quote the value_* condition field like group-by fields.
433+
434+
The framework passes the condition field reference through verbatim,
435+
whereas group-by fields are escaped and quoted. This normalises the two
436+
so a condition field containing whitespace or special characters (e.g.
437+
``field: "user name"``) produces valid LogScale. A list of fields is
438+
rejected, since a scalar aggregation over multiple fields is undefined.
439+
"""
440+
condition = rule.condition
441+
if (
442+
not isinstance(condition, SigmaCorrelationCondition)
443+
or condition.fieldref is None
444+
):
445+
return super().convert_correlation_aggregation_from_template(
446+
rule, correlation_type, method, search
447+
)
448+
if not isinstance(condition.fieldref, str):
449+
raise SigmaFeatureNotSupportedByBackendError(
450+
"Correlation condition field must be a single field name",
451+
source=rule.source,
452+
)
453+
original_fieldref = condition.fieldref
454+
condition.fieldref = self.escape_and_quote_field(original_fieldref)
455+
try:
456+
return super().convert_correlation_aggregation_from_template(
457+
rule, correlation_type, method, search
458+
)
459+
finally:
460+
condition.fieldref = original_fieldref
461+
239462
def convert_condition_field_eq_val_cidr(
240463
self,
241464
cond: ConditionFieldEqualsValueExpression,
@@ -275,7 +498,7 @@ def convert_condition_field_eq_val_str(
275498
is not None # 'startswith' operator is defined in backend
276499
and cond.value.endswith(
277500
SpecialChars.WILDCARD_MULTI
278-
)
501+
)
279502
):
280503
expr = self.startswith_expression
281504
# If all conditions are fulfilled, use 'startswith' operator instead of equal token

0 commit comments

Comments
 (0)