Skip to content

Commit d4cc443

Browse files
Add tests to ensure rules are properly deprecated (#1050)
* Add tests to ensure rules are properly deprecated * add deprecate-rule command
1 parent 93f8f2d commit d4cc443

File tree

8 files changed

+181
-12
lines changed

8 files changed

+181
-12
lines changed

detection_rules/devtools.py

+26
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,32 @@ def add_github_meta(this_rule, status, original_rule_id=None):
298298
ctx.invoke(search_rules, query=query, columns=columns, language=language, rules=all_rules, pager=True)
299299

300300

301+
@dev_group.command('deprecate-rule')
302+
@click.argument('rule-file', type=click.Path(dir_okay=False))
303+
@click.pass_context
304+
def deprecate_rule(ctx: click.Context, rule_file: str):
305+
"""Deprecate a rule."""
306+
import pytoml
307+
from .packaging import load_versions
308+
309+
version_info = load_versions()
310+
rule_file = Path(rule_file)
311+
contents = pytoml.loads(rule_file.read_text())
312+
rule = Rule(path=rule_file, contents=contents)
313+
314+
if rule.id not in version_info:
315+
click.echo('Rule has not been version locked and so does not need to be deprecated. '
316+
'Delete the file or update the maturity to `development` instead')
317+
ctx.exit()
318+
319+
today = time.strftime('%Y/%m/%d')
320+
rule.metadata.update(updated_date=today, deprecation_date=today, maturity='deprecated')
321+
deprecated_path = get_path('rules', '_deprecated', rule_file.name)
322+
rule.save(new_path=deprecated_path, as_rule=True)
323+
rule_file.unlink()
324+
click.echo(f'Rule moved to {deprecated_path} - remember to git add this file')
325+
326+
301327
@dev_group.group('test')
302328
def test_group():
303329
"""Commands for testing against stack resources."""

detection_rules/packaging.py

+10-7
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from . import rule_loader
2121
from .misc import JS_LICENSE, cached
2222
from .rule import Rule, downgrade_contents_from_rule # noqa: F401
23+
from .schemas import CurrentSchema
2324
from .utils import Ndjson, get_path, get_etc_path, load_etc_dump, save_etc_dump
2425

2526
RELEASE_DIR = get_path("releases")
@@ -28,7 +29,7 @@
2829
# CHANGELOG_FILE = Path(get_etc_path('rules-changelog.json'))
2930

3031

31-
def filter_rule(rule: Rule, config_filter: dict, exclude_fields: dict) -> bool:
32+
def filter_rule(rule: Rule, config_filter: dict, exclude_fields: Optional[dict] = None) -> bool:
3233
"""Filter a rule based off metadata and a package configuration."""
3334
flat_rule = rule.flattened_contents
3435
for key, values in config_filter.items():
@@ -46,6 +47,7 @@ def filter_rule(rule: Rule, config_filter: dict, exclude_fields: dict) -> bool:
4647
if len(rule_values & values) == 0:
4748
return False
4849

50+
exclude_fields = exclude_fields or {}
4951
for index, fields in exclude_fields.items():
5052
if rule.unique_fields and (rule.contents['index'] == index or index == 'any'):
5153
if set(rule.unique_fields) & set(fields):
@@ -66,8 +68,9 @@ def load_versions(current_versions: dict = None):
6668
return current_versions or load_etc_dump('version.lock.json')
6769

6870

69-
def manage_versions(rules: list, deprecated_rules: list = None, current_versions: dict = None,
70-
exclude_version_update=False, add_new=True, save_changes=False, verbose=True) -> (list, list, list):
71+
def manage_versions(rules: List[Rule], deprecated_rules: list = None, current_versions: dict = None,
72+
exclude_version_update=False, add_new=True, save_changes=False,
73+
verbose=True) -> (List[str], List[str], List[str]):
7174
"""Update the contents of the version.lock file and optionally save changes."""
7275
new_rules = {}
7376
changed_rules = []
@@ -103,13 +106,12 @@ def manage_versions(rules: list, deprecated_rules: list = None, current_versions
103106
if deprecated_rules:
104107
rule_deprecations = load_etc_dump('deprecated_rules.json')
105108

106-
deprecation_date = str(datetime.date.today())
107-
108109
for rule in deprecated_rules:
109110
if rule.id not in rule_deprecations:
110111
rule_deprecations[rule.id] = {
111112
'rule_name': rule.name,
112-
'deprecation_date': deprecation_date
113+
'deprecation_date': rule.metadata['deprecation_date'],
114+
'stack_version': CurrentSchema.STACK_VERSION
113115
}
114116
newly_deprecated.append(rule.id)
115117

@@ -129,7 +131,8 @@ def manage_versions(rules: list, deprecated_rules: list = None, current_versions
129131
click.echo('Updated version.lock.json file')
130132

131133
if newly_deprecated:
132-
save_etc_dump(sorted(OrderedDict(rule_deprecations)), 'deprecated_rules.json')
134+
save_etc_dump(OrderedDict(sorted(rule_deprecations.items(), key=lambda e: e[1]['rule_name'])),
135+
'deprecated_rules.json')
133136

134137
if verbose:
135138
click.echo('Updated deprecated_rules.json file')

detection_rules/rule_loader.py

+7-2
Original file line numberDiff line numberDiff line change
@@ -234,9 +234,14 @@ def filter_rules(rules, metadata_field, value):
234234
return [rule for rule in rules if rule.metadata.get(metadata_field, '') == value]
235235

236236

237-
def get_production_rules(verbose=False):
237+
def get_production_rules(verbose=False, include_deprecated=False) -> List[Rule]:
238238
"""Get rules with a maturity of production."""
239-
return filter_rules(load_rules(verbose=verbose).values(), 'maturity', 'production')
239+
from .packaging import filter_rule
240+
241+
maturity = ['production']
242+
if include_deprecated:
243+
maturity.append('deprecated')
244+
return [rule for rule in load_rules(verbose=verbose).values() if filter_rule(rule, {'maturity': maturity})]
240245

241246

242247
@cached

detection_rules/schemas/base.py

+1
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ class TomlMetadata(GenericSchema):
7272
# rule validated against each ecs schema contained
7373
beats_version = jsl.StringField(pattern=VERSION_PATTERN, required=False)
7474
comments = jsl.StringField(required=False)
75+
deprecation_date = jsl.StringField(required=False, pattern=DATE_PATTERN, default=time.strftime('%Y/%m/%d'))
7576
ecs_versions = jsl.ArrayField(jsl.StringField(pattern=BRANCH_PATTERN, required=True), required=False)
7677
maturity = jsl.StringField(enum=MATURITY_LEVELS, default='development', required=True)
7778

docs/deprecating.md

+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Deprecating rules
2+
3+
Rules that have been version locked (added to [version.lock.json](../etc/version.lock.json)), which also means they
4+
have been added to the detection engine in Kibana, must be properly [deprecated](#steps-to-properly-deprecate-a-rule).
5+
6+
If a rule was never version locked (not yet pushed to Kibana or still in non-`production` `maturity`), the rule can
7+
simply be removed with no additional changes, or updated the `maturity = "development"`, which will leave it out of the
8+
release package to Kibana.
9+
10+
11+
## Steps to properly deprecate a rule
12+
13+
1. Update the `maturity` to `deprecated`
14+
2. Move the rule file to [rules/_deprecated](../rules/_deprecated)
15+
3. Add `deprecation_date` and update `updated_date` to match
16+
17+
Next time the versions are locked, the rule will be added to the [deprecated_rules.json](../etc/deprecated_rules.json)
18+
file.
19+
20+
21+
### Using the deprecate-rule command
22+
23+
Alternatively, you can run `python -m detection_rules dev deprecate-rule <rule-file>`, which will perform all the steps

etc/deprecated_rules.json

+7-1
Original file line numberDiff line numberDiff line change
@@ -1 +1,7 @@
1-
{}
1+
{
2+
"3a86e085-094c-412d-97ff-2439731e59cb": {
3+
"deprecation_date": "2021-03-03",
4+
"rule_name": "Setgid Bit Set via chmod",
5+
"stack_version": "7.13"
6+
}
7+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
[metadata]
2+
creation_date = "2020/04/23"
3+
deprecation_date = "2021/03/16"
4+
maturity = "deprecated"
5+
updated_date = "2021/03/16"
6+
7+
[rule]
8+
author = ["Elastic"]
9+
description = """
10+
An adversary may add the setgid bit to a file or directory in order to run a file with the privileges of the owning
11+
group. An adversary can take advantage of this to either do a shell escape or exploit a vulnerability in an application
12+
with the setgid bit to get code running in a different user’s context. Additionally, adversaries can use this mechanism
13+
on their own malware to make sure they're able to execute in elevated contexts in the future.
14+
"""
15+
from = "now-9m"
16+
index = ["auditbeat-*", "logs-endpoint.events.*"]
17+
language = "lucene"
18+
license = "Elastic License"
19+
max_signals = 33
20+
name = "Setgid Bit Set via chmod"
21+
risk_score = 21
22+
rule_id = "3a86e085-094c-412d-97ff-2439731e59cb"
23+
severity = "low"
24+
tags = ["Elastic", "Host", "Linux", "Threat Detection", "Privilege Escalation"]
25+
timestamp_override = "event.ingested"
26+
type = "query"
27+
28+
query = '''
29+
event.category:process AND event.type:(start or process_started) AND process.name:chmod AND process.args:(g+s OR /2[0-9]{3}/) AND NOT user.name:root
30+
'''
31+
32+
33+
[[rule.threat]]
34+
framework = "MITRE ATT&CK"
35+
[[rule.threat.technique]]
36+
id = "T1548"
37+
name = "Abuse Elevation Control Mechanism"
38+
reference = "https://attack.mitre.org/techniques/T1548/"
39+
[[rule.threat.technique.subtechnique]]
40+
id = "T1548.001"
41+
name = "Setuid and Setgid"
42+
reference = "https://attack.mitre.org/techniques/T1548/001/"
43+
44+
45+
46+
[rule.threat.tactic]
47+
id = "TA0004"
48+
name = "Privilege Escalation"
49+
reference = "https://attack.mitre.org/tactics/TA0004/"
50+
[[rule.threat]]
51+
framework = "MITRE ATT&CK"
52+
53+
[rule.threat.tactic]
54+
id = "TA0003"
55+
name = "Persistence"
56+
reference = "https://attack.mitre.org/tactics/TA0003/"

tests/test_all_rules.py

+51-2
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@
1919
from rta import get_ttp_names
2020

2121
from detection_rules import attack, beats, ecs
22+
from detection_rules.packaging import load_versions
2223
from detection_rules.rule_loader import FILE_PATTERN, find_unneeded_defaults_from_rule
23-
from detection_rules.utils import load_etc_dump
24+
from detection_rules.utils import get_path, load_etc_dump
2425
from detection_rules.rule import Rule
2526

2627
from .base import BaseRuleTest
@@ -60,9 +61,20 @@ def test_file_names(self):
6061

6162
def test_all_rules_as_rule_schema(self):
6263
"""Ensure that every rule file validates against the rule schema."""
64+
rules_path = get_path('rules')
65+
6366
for file_name, contents in self.rule_files.items():
6467
rule = Rule(file_name, contents)
65-
rule.validate(as_rule=True)
68+
69+
if rule.metadata['maturity'] == 'deprecated':
70+
continue
71+
72+
try:
73+
rule.validate(as_rule=True)
74+
except jsonschema.ValidationError as exc:
75+
rule_path = Path(rule.path).relative_to(rules_path)
76+
exc.message = f'{rule_path} -> {exc}'
77+
raise exc
6678

6779
def test_all_rule_queries_optimized(self):
6880
"""Ensure that every rule query is in optimized form."""
@@ -430,6 +442,43 @@ def test_updated_date_newer_than_creation(self):
430442
err_msg = f'The following rules have an updated_date older than the creation_date\n {rules_str}'
431443
self.fail(err_msg)
432444

445+
def test_deprecated_rules(self):
446+
"""Test that deprecated rules are properly handled."""
447+
versions = load_versions()
448+
deprecations = load_etc_dump('deprecated_rules.json')
449+
deprecated_rules = {}
450+
451+
for rule in self.rules:
452+
maturity = rule.metadata['maturity']
453+
454+
if maturity == 'deprecated':
455+
deprecated_rules[rule.id] = rule
456+
err_msg = f'{self.rule_str(rule)} cannot be deprecated if it has not been version locked. ' \
457+
f'Convert to `development` or delete the rule file instead'
458+
self.assertIn(rule.id, versions, err_msg)
459+
460+
rule_path = Path(rule.path).relative_to(get_path('rules'))
461+
err_msg = f'{self.rule_str(rule)} deprecated rules should be stored in ' \
462+
f'"{get_path("rules", "_deprecated")}" folder'
463+
self.assertEqual('_deprecated', rule_path.parts[0], err_msg)
464+
465+
err_msg = f'{self.rule_str(rule)} missing deprecation date'
466+
self.assertIn('deprecation_date', rule.metadata, err_msg)
467+
468+
err_msg = f'{self.rule_str(rule)} deprecation_date and updated_date should match'
469+
self.assertEqual(rule.metadata['deprecation_date'], rule.metadata['updated_date'], err_msg)
470+
471+
missing_rules = sorted(set(versions).difference(set(self.rule_lookup)))
472+
missing_rule_strings = '\n '.join(f'{r} - {versions[r]["rule_name"]}' for r in missing_rules)
473+
err_msg = f'Deprecated rules should not be removed, but moved to the rules/_deprecated folder instead. ' \
474+
f'The following rules have been version locked and are missing. ' \
475+
f'Re-add to the deprecated folder and update maturity to "deprecated": \n {missing_rule_strings}'
476+
self.assertEqual([], missing_rules, err_msg)
477+
478+
for rule_id, entry in deprecations.items():
479+
rule_str = f'{rule_id} - {entry["rule_name"]} ->'
480+
self.assertIn(rule_id, deprecated_rules, f'{rule_str} is logged in "deprecated_rules.json" but is missing')
481+
433482

434483
class TestTuleTiming(BaseRuleTest):
435484
"""Test rule timing and timestamps."""

0 commit comments

Comments
 (0)