Skip to content

Commit 781953a

Browse files
Add min_stack_version to rule metadata (elastic#1173)
* Add min_stack_version to metadata of rule structure * validate all "stack versions" between defined and current package * Use master schemas if min_stack_version > current_package Co-authored-by: Ross Wolf <[email protected]>
1 parent f1476b1 commit 781953a

10 files changed

+97
-76
lines changed

detection_rules/beats.py

+7-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"""ECS Schemas management."""
77
import os
88
import re
9-
from typing import List
9+
from typing import List, Optional
1010

1111
import kql
1212
import eql
@@ -266,3 +266,9 @@ def get_schema_from_kql(tree: kql.ast.BaseNode, beats: list, version: str = None
266266
datasets.update(child.value for child in node.value if isinstance(child, kql.ast.String))
267267

268268
return get_schema_from_datasets(beats, modules, datasets, version=version)
269+
270+
271+
def parse_beats_from_index(index: Optional[list]) -> List[str]:
272+
indexes = index or []
273+
beat_types = [index.split("-")[0] for index in indexes if "beat-*" in index]
274+
return beat_types

detection_rules/cli_utils.py

-4
Original file line numberDiff line numberDiff line change
@@ -215,8 +215,4 @@ def rule_prompt(path=None, rule_type=None, required_only=True, save=True, verbos
215215
# rta_mappings.add_rule_to_mapping_file(rule)
216216
# click.echo('Placeholder added to rule-mapping.yml')
217217

218-
click.echo('Rule will validate against the latest ECS schema available (and beats if necessary)')
219-
click.echo(' - to have a rule validate against specific ECS schemas, add them to metadata->ecs_versions')
220-
click.echo(' - to have a rule validate against a specific beats schema, add it to metadata->beats_version')
221-
222218
return rule

detection_rules/ecs.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,7 @@ def get_event_type_hint(self, event_type, path):
200200

201201

202202
@cached
203-
def get_kql_schema(version=None, indexes=None, beat_schema=None):
203+
def get_kql_schema(version=None, indexes=None, beat_schema=None) -> dict:
204204
"""Get schema for KQL."""
205205
indexes = indexes or ()
206206
converted = flatten_multi_fields(get_schema(version, name='ecs_flat'))

detection_rules/packaging.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ def filter_rule(rule: TOMLRule, config_filter: dict, exclude_fields: Optional[di
6363

6464

6565
@cached
66-
def load_current_package_version():
66+
def load_current_package_version() -> str:
6767
"""Load the current package version from config file."""
6868
return load_etc_dump('packages.yml')['package']['name']
6969

detection_rules/rule.py

+10-5
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,20 @@
99
from dataclasses import dataclass, field
1010
from functools import cached_property
1111
from pathlib import Path
12-
from typing import Literal, Union, Optional, List, Any
12+
from typing import Literal, Union, Optional, List, Any, Dict
1313
from uuid import uuid4
1414

1515
from marshmallow import ValidationError, validates_schema
1616

17+
1718
from . import utils
1819
from .mixins import MarshmallowDataclassMixin
1920
from .rule_formatter import toml_write, nested_normalize
20-
from .schemas import definitions, SCHEMA_DIR
21-
from .schemas import downgrade
21+
from .schemas import SCHEMA_DIR, definitions, downgrade, get_stack_schemas
2222
from .utils import cached
2323

2424
_META_SCHEMA_REQ_DEFAULTS = {}
25+
MIN_FLEET_PACKAGE_VERSION = '7.13.0'
2526

2627

2728
@dataclass(frozen=True)
@@ -32,17 +33,21 @@ class RuleMeta(MarshmallowDataclassMixin):
3233
deprecation_date: Optional[definitions.Date]
3334

3435
# Optional fields
35-
beats_version: Optional[definitions.BranchVer]
36-
ecs_versions: Optional[List[definitions.BranchVer]]
3736
comments: Optional[str]
3837
maturity: Optional[definitions.Maturity]
38+
min_stack_version: Optional[definitions.SemVer]
3939
os_type_list: Optional[List[definitions.OSType]]
4040
query_schema_validation: Optional[bool]
4141
related_endpoint_rules: Optional[List[str]]
4242

4343
# Extended information as an arbitrary dictionary
4444
extended = Optional[dict]
4545

46+
def get_validation_stack_versions(self) -> Dict[str, dict]:
47+
"""Get a dict of beats and ecs versions per stack release."""
48+
stack_versions = get_stack_schemas(self.min_stack_version or MIN_FLEET_PACKAGE_VERSION)
49+
return stack_versions
50+
4651

4752
@dataclass(frozen=True)
4853
class BaseThreatEntry:

detection_rules/rule_validators.py

+35-43
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
import eql
1111

1212
import kql
13-
from detection_rules import beats, ecs
14-
from detection_rules.rule import QueryValidator, QueryRuleData, RuleMeta
13+
from . import ecs, beats
14+
from .rule import QueryValidator, QueryRuleData, RuleMeta
1515

1616

1717
class KQLValidator(QueryValidator):
@@ -36,35 +36,34 @@ def validate(self, data: QueryRuleData, meta: RuleMeta) -> None:
3636
# syntax only, which is done via self.ast
3737
return
3838

39-
indexes = data.index or []
40-
beats_version = meta.beats_version or beats.get_max_version()
41-
ecs_versions = meta.ecs_versions or [ecs.get_max_version()]
39+
for stack_version, mapping in meta.get_validation_stack_versions().items():
40+
beats_version = mapping['beats']
41+
ecs_version = mapping['ecs']
42+
err_trailer = f'stack: {stack_version}, beats: {beats_version}, ecs: {ecs_version}'
4243

43-
beat_types = [index.split("-")[0] for index in indexes if "beat-*" in index]
44-
beat_schema = beats.get_schema_from_kql(ast, beat_types, version=beats_version) if beat_types else None
44+
beat_types = beats.parse_beats_from_index(data.index)
45+
beat_schema = beats.get_schema_from_kql(ast, beat_types, version=beats_version) if beat_types else None
46+
schema = ecs.get_kql_schema(version=ecs_version, indexes=data.index or [], beat_schema=beat_schema)
4547

46-
if not ecs_versions:
47-
kql.parse(self.query, schema=ecs.get_kql_schema(indexes=indexes, beat_schema=beat_schema))
48-
else:
49-
for version in ecs_versions:
50-
schema = ecs.get_kql_schema(version=version, indexes=indexes, beat_schema=beat_schema)
51-
52-
try:
53-
kql.parse(self.query, schema=schema)
54-
except kql.KqlParseError as exc:
55-
message = exc.error_msg
56-
trailer = None
57-
if "Unknown field" in message and beat_types:
58-
trailer = "\nTry adding event.module or event.dataset to specify beats module"
48+
try:
49+
kql.parse(self.query, schema=schema)
50+
except kql.KqlParseError as exc:
51+
message = exc.error_msg
52+
trailer = err_trailer
53+
if "Unknown field" in message and beat_types:
54+
trailer = f"\nTry adding event.module or event.dataset to specify beats module\n\n{trailer}"
5955

60-
raise kql.KqlParseError(exc.error_msg, exc.line, exc.column, exc.source,
61-
len(exc.caret.lstrip()), trailer=trailer) from None
56+
raise kql.KqlParseError(exc.error_msg, exc.line, exc.column, exc.source,
57+
len(exc.caret.lstrip()), trailer=trailer) from None
58+
except Exception:
59+
print(err_trailer)
60+
raise
6261

6362

6463
class EQLValidator(QueryValidator):
6564

6665
@cached_property
67-
def ast(self) -> kql.ast.Expression:
66+
def ast(self) -> eql.ast.Expression:
6867
with eql.parser.elasticsearch_syntax, eql.parser.ignore_missing_functions:
6968
return eql.parse_query(self.query)
7069

@@ -74,41 +73,34 @@ def unique_fields(self) -> List[str]:
7473

7574
def validate(self, data: 'QueryRuleData', meta: RuleMeta) -> None:
7675
"""Validate an EQL query while checking TOMLRule."""
77-
_ = self.ast
76+
ast = self.ast
7877

7978
if meta.query_schema_validation is False or meta.maturity == "deprecated":
8079
# syntax only, which is done via self.ast
8180
return
8281

83-
indexes = data.index or []
84-
beats_version = meta.beats_version or beats.get_max_version()
85-
ecs_versions = meta.ecs_versions or [ecs.get_max_version()]
82+
for stack_version, mapping in meta.get_validation_stack_versions().items():
83+
beats_version = mapping['beats']
84+
ecs_version = mapping['ecs']
85+
err_trailer = f'stack: {stack_version}, beats: {beats_version}, ecs: {ecs_version}'
8686

87-
# TODO: remove once py-eql supports ipv6 for cidrmatch
88-
# Or, unregister the cidrMatch function and replace it with one that doesn't validate against strict IPv4
89-
with eql.parser.elasticsearch_syntax, eql.parser.ignore_missing_functions:
90-
parsed = eql.parse_query(self.query)
91-
92-
beat_types = [index.split("-")[0] for index in indexes if "beat-*" in index]
93-
beat_schema = beats.get_schema_from_eql(parsed, beat_types, version=beats_version) if beat_types else None
94-
95-
for version in ecs_versions:
96-
schema = ecs.get_kql_schema(indexes=indexes, beat_schema=beat_schema, version=version)
87+
beat_types = beats.parse_beats_from_index(data.index)
88+
beat_schema = beats.get_schema_from_kql(ast, beat_types, version=beats_version) if beat_types else None
89+
schema = ecs.get_kql_schema(version=ecs_version, indexes=data.index or [], beat_schema=beat_schema)
9790
eql_schema = ecs.KqlSchema2Eql(schema)
9891

9992
try:
10093
# TODO: switch to custom cidrmatch that allows ipv6
10194
with eql_schema, eql.parser.elasticsearch_syntax, eql.parser.ignore_missing_functions:
10295
eql.parse_query(self.query)
103-
104-
except eql.EqlTypeMismatchError:
105-
raise
106-
10796
except eql.EqlParseError as exc:
10897
message = exc.error_msg
109-
trailer = None
98+
trailer = err_trailer
11099
if "Unknown field" in message and beat_types:
111-
trailer = "\nTry adding event.module or event.dataset to specify beats module"
100+
trailer = f"\nTry adding event.module or event.dataset to specify beats module\n\n{trailer}"
112101

113102
raise exc.__class__(exc.error_msg, exc.line, exc.column, exc.source,
114103
len(exc.caret.lstrip()), trailer=trailer) from None
104+
except Exception:
105+
print(err_trailer)
106+
raise

detection_rules/schemas/__init__.py

+24-2
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@
33
# 2.0; you may not use this file except in compliance with the Elastic License
44
# 2.0.
55
import json
6-
from typing import List, Optional
6+
from typing import Dict, List, Optional
77

88
import jsonschema
99

1010
from .rta_schema import validate_rta_mapping
1111
from ..semver import Version
12-
from ..utils import cached, get_etc_path
12+
from ..utils import cached, get_etc_path, load_etc_dump
1313
from . import definitions
1414
from pathlib import Path
1515

@@ -18,6 +18,7 @@
1818
"SCHEMA_DIR",
1919
"definitions",
2020
"downgrade",
21+
"get_stack_schemas",
2122
"validate_rta_mapping",
2223
"all_versions",
2324
)
@@ -181,3 +182,24 @@ def downgrade(api_contents: dict, target_version: str, current_version: Optional
181182
api_contents = migrations[version](version, api_contents)
182183

183184
return api_contents
185+
186+
187+
@cached
188+
def get_stack_schemas(stack_version: str) -> Dict[str, dict]:
189+
"""Return all ECS + beats to stack versions for a every stack version >= specified stack version and <= package."""
190+
from ..packaging import load_current_package_version
191+
192+
stack_version = Version(stack_version)
193+
current_package = Version(load_current_package_version())
194+
195+
if len(current_package) == 2:
196+
current_package = Version(current_package + (0,))
197+
198+
stack_map = load_etc_dump('stack-schema-map.yaml')
199+
versions = {k: v for k, v in stack_map.items()
200+
if (mapped_version := Version(k)) >= stack_version and mapped_version <= current_package and v}
201+
202+
if stack_version > current_package:
203+
versions[stack_version] = {'beats': 'master', 'ecs': 'master'}
204+
205+
return versions

etc/stack-schema-map.yaml

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# alignment of stack with beats and ecs versions
2+
# ECS versions do not align perfectly with stack releases (as of 7.13), so this will reflect MAX ecs version for a
3+
# given release
4+
5+
"7.13.0":
6+
# beats release about the same time as the stack, so we cannot update this until it is released
7+
beats: "7.13.2"
8+
ecs: "1.9.0"
9+
10+
"7.14.0":
11+
beats: "master" # TODO: 7.14.x
12+
ecs: "1.10.0"

tests/test_all_rules.py

+1-18
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
import eql
1313

1414
import kql
15-
from detection_rules import attack, beats, ecs
15+
from detection_rules import attack
1616
from detection_rules.packaging import load_versions
1717
from detection_rules.rule import QueryRuleData
1818
from detection_rules.rule_loader import FILE_PATTERN
@@ -356,23 +356,6 @@ def test_rule_file_names_by_tactic(self):
356356
class TestRuleMetadata(BaseRuleTest):
357357
"""Test the metadata of rules."""
358358

359-
def test_ecs_and_beats_opt_in_not_latest_only(self):
360-
"""Test that explicitly defined opt-in validation is not only the latest versions to avoid stale tests."""
361-
for rule in self.all_rules:
362-
beats_version = rule.contents.metadata.beats_version
363-
ecs_versions = rule.contents.metadata.ecs_versions or []
364-
latest_beats = str(beats.get_max_version())
365-
latest_ecs = ecs.get_max_version()
366-
367-
error_msg = f'{self.rule_str(rule)} it is unnecessary to define the current latest beats version: ' \
368-
f'{latest_beats}'
369-
self.assertNotEqual(latest_beats, beats_version, error_msg)
370-
371-
if len(ecs_versions) == 1:
372-
error_msg = f'{self.rule_str(rule)} it is unnecessary to define the current latest ecs version if ' \
373-
f'only one version is specified: {latest_ecs}'
374-
self.assertNotIn(latest_ecs, ecs_versions, error_msg)
375-
376359
def test_updated_date_newer_than_creation(self):
377360
"""Test that the updated_date is newer than the creation date."""
378361
invalid = []

tests/test_schemas.py

+6-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import eql
1212

13+
from detection_rules.packaging import load_current_package_version
1314
from detection_rules.rule import TOMLRuleContents
1415
from detection_rules.schemas import downgrade
1516

@@ -165,7 +166,11 @@ def test_eql_validation(self):
165166
}
166167

167168
def build_rule(query):
168-
metadata = {"creation_date": "1970/01/01", "updated_date": "1970/01/01"}
169+
metadata = {
170+
"creation_date": "1970/01/01",
171+
"updated_date": "1970/01/01",
172+
"min_stack_version": load_current_package_version()
173+
}
169174
data = base_fields.copy()
170175
data["query"] = query
171176
obj = {"metadata": metadata, "rule": data}

0 commit comments

Comments
 (0)