Skip to content

Commit ff46a7a

Browse files
authored
fix: Allow different order of the metadata fields in ESQL queries (#4956)
* Initial commit * Python project version bump
1 parent 04ca2c8 commit ff46a7a

File tree

3 files changed

+66
-4
lines changed

3 files changed

+66
-4
lines changed

detection_rules/rule.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -934,9 +934,15 @@ def validates_esql_data(self, data: dict[str, Any], **_: Any) -> None:
934934
# Convert the query string to lowercase to handle case insensitivity
935935
query_lower = data["query"].lower()
936936

937-
# Combine both patterns using an OR operator and compile the regex
937+
# Combine both patterns using an OR operator and compile the regex.
938+
# The first part matches the metadata fields in the from clause by allowing one or
939+
# multiple indices and any order of the metadata fields
940+
# The second part matches the stats command with the by clause
938941
combined_pattern = re.compile(
939-
r"(from\s+\S+\s+metadata\s+_id,\s*_version,\s*_index)|(\bstats\b.*?\bby\b)", re.DOTALL
942+
r"(from\s+(?:\S+\s*,\s*)*\S+\s+metadata\s+"
943+
r"(?:_id|_version|_index)(?:,\s*(?:_id|_version|_index)){2})"
944+
r"|(\bstats\b.*?\bby\b)",
945+
re.DOTALL,
940946
)
941947

942948
# Ensure that non-aggregate queries have metadata
@@ -948,7 +954,9 @@ def validates_esql_data(self, data: dict[str, Any], **_: Any) -> None:
948954
)
949955

950956
# Enforce KEEP command for ESQL rules
951-
if "| keep" not in query_lower:
957+
# Match | followed by optional whitespace/newlines and then 'keep'
958+
keep_pattern = re.compile(r"\|\s*keep\b", re.IGNORECASE | re.DOTALL)
959+
if not keep_pattern.search(query_lower):
952960
raise ValidationError(
953961
f"Rule: {data['name']} does not contain a 'keep' command -> Add a 'keep' command to the query."
954962
)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "detection_rules"
3-
version = "1.3.18"
3+
version = "1.3.19"
44
description = "Detection Rules is the home for rules used by Elastic Security. This repository is used for the development, maintenance, testing, validation, and release of rules for Elastic Security’s Detection Engine."
55
readme = "README.md"
66
requires-python = ">=3.12"

tests/test_schemas.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,18 @@
88
import copy
99
import unittest
1010
import uuid
11+
from pathlib import Path
1112

1213
import eql
14+
import pytest
15+
import pytoml
1316
from marshmallow import ValidationError
1417
from semver import Version
1518

1619
from detection_rules import utils
1720
from detection_rules.config import load_current_package_version
1821
from detection_rules.rule import TOMLRuleContents
22+
from detection_rules.rule_loader import RuleCollection
1923
from detection_rules.schemas import RULES_CONFIG, downgrade
2024
from detection_rules.version_lock import VersionLockFile
2125

@@ -302,3 +306,53 @@ def test_stack_schema_map(self):
302306
stack_map = utils.load_etc_dump(["stack-schema-map.yaml"])
303307
err_msg = f"There is no entry defined for the current package ({package_version}) in the stack-schema-map"
304308
self.assertIn(package_version, [Version.parse(v) for v in stack_map], err_msg)
309+
310+
311+
class TestESQLValidation(unittest.TestCase):
312+
"""Test ESQL rule validation"""
313+
314+
def test_esql_data_validation(self):
315+
"""Test ESQL rule data validation"""
316+
317+
# A random ESQL rule to deliver a test query
318+
rule_path = Path("rules/windows/defense_evasion_posh_obfuscation_index_reversal.toml")
319+
rule_body = rule_path.read_text()
320+
rule_dict = pytoml.loads(rule_body)
321+
322+
# Most used order of the metadata fields
323+
query = """
324+
FROM logs-windows.powershell_operational* METADATA _id, _version, _index
325+
| WHERE event.code == "4104"
326+
| KEEP event.count
327+
"""
328+
rule_dict["rule"]["query"] = query
329+
_ = RuleCollection().load_dict(rule_dict, path=rule_path)
330+
331+
# The order of the metadata fields from the example in the docs -
332+
# https://www.elastic.co/guide/en/security/8.17/rules-ui-create.html#esql-non-agg-query
333+
query = """
334+
FROM logs-windows.powershell_operational* METADATA _id, _index, _version
335+
| WHERE event.code == "4104"
336+
| KEEP event.count
337+
"""
338+
rule_dict["rule"]["query"] = query
339+
_ = RuleCollection().load_dict(rule_dict, path=rule_path)
340+
341+
# Different metadata fields
342+
with pytest.raises(ValidationError):
343+
query = """
344+
FROM logs-windows.powershell_operational* METADATA _foo, _index
345+
| WHERE event.code == "4104"
346+
| KEEP event.count
347+
"""
348+
rule_dict["rule"]["query"] = query
349+
_ = RuleCollection().load_dict(rule_dict, path=rule_path)
350+
351+
# Missing `keep`
352+
with pytest.raises(ValidationError):
353+
query = """
354+
FROM logs-windows.powershell_operational* METADATA _id, _index, _version
355+
| WHERE event.code == "4104"
356+
"""
357+
rule_dict["rule"]["query"] = query
358+
_ = RuleCollection().load_dict(rule_dict, path=rule_path)

0 commit comments

Comments
 (0)