Skip to content

Commit 6ed1a39

Browse files
authored
Add a RuleCollection object instead of a "loader" module (#1063)
* Add a RuleCollection object instead of a "loader" module * Remove legacy loader code * Remove more legacy loader * Freeze the default collection * Change RULE_LOADER default * Rename to _toml_load_cache * Use rglob magic * Typo should've been a string * Remove no longer needed glob import * Fix pycharm import bad ordering * Restore the detection_rules/schemas imports * Put more imports back for a smaller diff * Check cache in _deserialize_toml * Add multi collection and single collection decorators * Reorder RuleCollection methods * Move filter method up
1 parent 07be6b7 commit 6ed1a39

15 files changed

+389
-405
lines changed

CLI.md

+15-10
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ and will accept any valid rule in the following formats:
7171
#### `import-rules`
7272

7373
```console
74-
Usage: detection_rules import-rules [OPTIONS] [INFILE]...
74+
Usage: detection_rules import-rules [OPTIONS] [INPUT_FILE]...
7575

7676
Import rules from json, toml, or Kibana exported rule file(s).
7777

@@ -159,34 +159,39 @@ Options:
159159
--cloud-id TEXT
160160
-k, --kibana-url TEXT
161161

162-
Usage: detection_rules kibana upload-rule [OPTIONS] TOML_FILES...
162+
Usage: detection_rules kibana upload-rule [OPTIONS]
163163

164164
Upload a list of rule .toml files to Kibana.
165165

166166
Options:
167-
-r, --replace-id Replace rule IDs with new IDs before export
168-
-h, --help Show this message and exit.
167+
-f, --rule-file FILE
168+
-d, --directory DIRECTORY Recursively export rules from a directory
169+
-id, --rule-id TEXT
170+
-r, --replace-id Replace rule IDs with new IDs before export
171+
-h, --help Show this message and exit.
172+
(detection-rules-build) (base) ➜ detection-rules git:(rule-loader) ✗
169173
```
170174

171175
Alternatively, rules can be exported into a consolidated ndjson file which can be imported in the Kibana security app
172176
directly.
173177

174178
```console
175-
Usage: detection_rules export-rules [OPTIONS] [RULE_ID]...
179+
Usage: detection_rules export-rules [OPTIONS]
176180

177181
Export rule(s) into an importable ndjson file.
178182

179183
Options:
180-
-f, --rule-file FILE Export specified rule files
184+
-f, --rule-file FILE
181185
-d, --directory DIRECTORY Recursively export rules from a directory
186+
-id, --rule-id TEXT
182187
-o, --outfile FILE Name of file for exported rules
183188
-r, --replace-id Replace rule IDs with new IDs before export
184-
--stack-version [7.8|7.9|7.10|7.11]
189+
--stack-version [7.8|7.9|7.10|7.11|7.12]
185190
Downgrade a rule version to be compatible
186191
with older instances of Kibana
187-
-s, --skip-unsupported If `--stack-version` is passed, skip
188-
rule types which are unsupported (an error
189-
will be raised otherwise)
192+
-s, --skip-unsupported If `--stack-version` is passed, skip rule
193+
types which are unsupported (an error will
194+
be raised otherwise)
190195
-h, --help Show this message and exit.
191196
```
192197

detection_rules/cli_utils.py

+76
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,95 @@
77
import datetime
88
import os
99
from pathlib import Path
10+
from typing import List
1011

1112
import click
1213

1314
import kql
15+
import functools
1416
from . import ecs
1517
from .attack import matrix, tactics, build_threat_map_entry
1618
from .rule import TOMLRule, TOMLRuleContents
19+
from .rule_loader import RuleCollection, DEFAULT_RULES_DIR, dict_filter
1720
from .schemas import CurrentSchema
1821
from .utils import clear_caches, get_path
1922

2023
RULES_DIR = get_path("rules")
2124

2225

26+
def single_collection(f):
27+
"""Add arguments to get a RuleCollection by file, directory or a list of IDs"""
28+
from .misc import client_error
29+
30+
@click.option('--rule-file', '-f', multiple=False, required=False, type=click.Path(dir_okay=False))
31+
@click.option('--rule-id', '-id', multiple=False, required=False)
32+
@functools.wraps(f)
33+
def get_collection(*args, **kwargs):
34+
rule_name: List[str] = kwargs.pop("rule_name", [])
35+
rule_id: List[str] = kwargs.pop("rule_id", [])
36+
rule_files: List[str] = kwargs.pop("rule_file")
37+
directories: List[str] = kwargs.pop("directory")
38+
39+
rules = RuleCollection()
40+
41+
if bool(rule_name) + bool(rule_id) + bool(rule_files) != 1:
42+
client_error('Required: exactly one of --rule-id, --rule-file, or --directory')
43+
44+
rules.load_files(Path(p) for p in rule_files)
45+
rules.load_directories(Path(d) for d in directories)
46+
47+
if rule_id:
48+
rules.load_directory(DEFAULT_RULES_DIR, toml_filter=dict_filter(rule__rule_id=rule_id))
49+
50+
if len(rules) != 1:
51+
client_error(f"Could not find rule with ID {rule_id}")
52+
53+
kwargs["rules"] = rules
54+
return f(*args, **kwargs)
55+
56+
return get_collection
57+
58+
59+
def multi_collection(f):
60+
"""Add arguments to get a RuleCollection by file, directory or a list of IDs"""
61+
from .misc import client_error
62+
63+
@click.option('--rule-file', '-f', multiple=True, type=click.Path(dir_okay=False), required=False)
64+
@click.option('--directory', '-d', multiple=True, type=click.Path(file_okay=False), required=False,
65+
help='Recursively export rules from a directory')
66+
@click.option('--rule-id', '-id', multiple=True, required=False)
67+
@functools.wraps(f)
68+
def get_collection(*args, **kwargs):
69+
rule_name: List[str] = kwargs.pop("rule_name", [])
70+
rule_id: List[str] = kwargs.pop("rule_id", [])
71+
rule_files: List[str] = kwargs.pop("rule_file")
72+
directories: List[str] = kwargs.pop("directory")
73+
74+
rules = RuleCollection()
75+
76+
if not rule_name or rule_id or rule_files:
77+
client_error('Required: at least one of --rule-id, --rule-file, or --directory')
78+
79+
rules.load_files(Path(p) for p in rule_files)
80+
rules.load_directories(Path(d) for d in directories)
81+
82+
if rule_id:
83+
rules.load_directory(DEFAULT_RULES_DIR, toml_filter=dict_filter(rule__rule_id=rule_id))
84+
found_ids = {rule.id for rule in rules}
85+
missing = set(rule_id).difference(found_ids)
86+
87+
if missing:
88+
client_error(f'Could not find rules with IDs: {", ".join(missing)}')
89+
90+
if len(rules) == 0:
91+
client_error("No rules found")
92+
93+
kwargs["rules"] = rules
94+
return f(*args, **kwargs)
95+
96+
return get_collection
97+
98+
2399
def rule_prompt(path=None, rule_type=None, required_only=True, save=True, verbose=False, **kwargs) -> TOMLRule:
24100
"""Prompt loop to build a rule."""
25101
from .misc import schema_prompt

detection_rules/devtools.py

+20-27
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,16 @@
1717
import click
1818
from elasticsearch import Elasticsearch
1919
from eql import load_dump
20-
from kibana.connector import Kibana
2120

21+
from kibana.connector import Kibana
2222
from . import rule_loader
23+
from .cli_utils import single_collection
2324
from .eswrap import CollectEvents, add_range_to_dsl
2425
from .main import root
2526
from .misc import PYTHON_LICENSE, add_client, GithubClient, Manifest, client_error, getdefault
2627
from .packaging import PACKAGE_FILE, Package, manage_versions, RELEASE_DIR
27-
from .rule import TOMLRule, TOMLRuleContents, BaseQueryRuleData
28-
from .rule_loader import get_rule
28+
from .rule import TOMLRule, BaseQueryRuleData
29+
from .rule_loader import production_filter, RuleCollection
2930
from .utils import get_path, dict_hash
3031

3132
RULES_DIR = get_path('rules')
@@ -68,7 +69,7 @@ def update_lock_versions(rule_ids):
6869
if not click.confirm('Are you sure you want to update hashes without a version bump?'):
6970
return
7071

71-
rules = [r for r in rule_loader.load_rules(verbose=False).values() if r.id in rule_ids]
72+
rules = RuleCollection.default().filter(lambda r: r.id in rule_ids)
7273
changed, new = manage_versions(rules, exclude_version_update=True, add_new=False, save_changes=True)
7374

7475
if not changed:
@@ -86,10 +87,12 @@ def kibana_diff(rule_id, repo, branch, threads):
8687
"""Diff rules against their version represented in kibana if exists."""
8788
from .misc import get_kibana_rules
8889

90+
rules = RuleCollection.default()
91+
8992
if rule_id:
90-
rules = {r.id: r for r in rule_loader.load_rules(verbose=False).values() if r.id in rule_id}
93+
rules = rules.filter(lambda r: r.id in rule_id)
9194
else:
92-
rules = {r.id: r for r in rule_loader.get_production_rules()}
95+
rules = rules.filter(production_filter)
9396

9497
# add versions to the rules
9598
manage_versions(list(rules.values()), verbose=False)
@@ -102,13 +105,13 @@ def kibana_diff(rule_id, repo, branch, threads):
102105
missing_from_kibana = list(set(repo_hashes).difference(set(kibana_hashes)))
103106

104107
rule_diff = []
105-
for rid, rhash in repo_hashes.items():
106-
if rid in missing_from_kibana:
108+
for rule_id, rule_hash in repo_hashes.items():
109+
if rule_id in missing_from_kibana:
107110
continue
108-
if rhash != kibana_hashes[rid]:
111+
if rule_hash != kibana_hashes[rule_id]:
109112
rule_diff.append(
110-
f'versions - repo: {rules[rid].contents["version"]}, kibana: {kibana_rules[rid]["version"]} -> '
111-
f'{rid} - {rules[rid].name}'
113+
f'versions - repo: {rules[rule_id].contents["version"]}, kibana: {kibana_rules[rule_id]["version"]} -> '
114+
f'{rule_id} - {rules[rule_id].name}'
112115
)
113116

114117
diff = {
@@ -373,26 +376,17 @@ def event_search(query, index, language, date_range, count, max_results, verbose
373376

374377

375378
@test_group.command('rule-event-search')
376-
@click.argument('rule-file', type=click.Path(dir_okay=False), required=False)
377-
@click.option('--rule-id', '-id')
379+
@single_collection
378380
@click.option('--date-range', '-d', type=(str, str), default=('now-7d', 'now'), help='Date range to scope search')
379381
@click.option('--count', '-c', is_flag=True, help='Return count of results only')
380382
@click.option('--max-results', '-m', type=click.IntRange(1, 1000), default=100,
381383
help='Max results to return (capped at 1000)')
382384
@click.option('--verbose', '-v', is_flag=True)
383385
@click.pass_context
384386
@add_client('elasticsearch')
385-
def rule_event_search(ctx, rule_file, rule_id, date_range, count, max_results, verbose,
387+
def rule_event_search(ctx, rule, date_range, count, max_results, verbose,
386388
elasticsearch_client: Elasticsearch = None):
387389
"""Search using a rule file against an Elasticsearch instance."""
388-
rule: TOMLRule
389-
390-
if rule_id:
391-
rule = get_rule(rule_id, verbose=False)
392-
elif rule_file:
393-
rule = TOMLRule(path=rule_file, contents=TOMLRuleContents.from_dict(load_dump(rule_file)))
394-
else:
395-
client_error('Must specify a rule file or rule ID')
396390

397391
if isinstance(rule.contents.data, BaseQueryRuleData):
398392
if verbose:
@@ -431,18 +425,17 @@ def rule_survey(ctx: click.Context, query, date_range, dump_file, hide_zero_coun
431425
"""Survey rule counts."""
432426
from eql.table import Table
433427
from kibana.resources import Signal
434-
from . import rule_loader
435428
from .main import search_rules
436429

437430
survey_results = []
438431
start_time, end_time = date_range
439432

440433
if query:
441-
rule_paths = [r['file'] for r in ctx.invoke(search_rules, query=query, verbose=False)]
442-
rules = rule_loader.load_rules(rule_loader.load_rule_files(paths=rule_paths, verbose=False), verbose=False)
443-
rules = rules.values()
434+
rules = RuleCollection()
435+
paths = [Path(r['file']) for r in ctx.invoke(search_rules, query=query, verbose=False)]
436+
rules.load_files(paths)
444437
else:
445-
rules = rule_loader.load_rules(verbose=False).values()
438+
rules = RuleCollection.default().filter(production_filter)
446439

447440
click.echo(f'Running survey against {len(rules)} rules')
448441
click.echo(f'Saving detailed dump to: {dump_file}')

detection_rules/eswrap.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
import json
88
import os
99
import time
10-
from contextlib import contextmanager
1110
from collections import defaultdict
11+
from contextlib import contextmanager
1212
from pathlib import Path
1313
from typing import Union
1414

@@ -20,10 +20,9 @@
2020
import kql
2121
from .main import root
2222
from .misc import add_params, client_error, elasticsearch_options
23-
from .utils import format_command_options, normalize_timing_and_sort, unix_time_to_formatted, get_path
2423
from .rule import TOMLRule
25-
from .rule_loader import get_rule, rta_mappings
26-
24+
from .rule_loader import rta_mappings, RuleCollection
25+
from .utils import format_command_options, normalize_timing_and_sort, unix_time_to_formatted, get_path
2726

2827
COLLECTION_DIR = get_path('collections')
2928
MATCH_ALL = {'bool': {'filter': [{'match_all': {}}]}}
@@ -88,7 +87,8 @@ def evaluate_against_rule_and_update_mapping(self, rule_id, rta_name, verbose=Tr
8887
"""Evaluate a rule against collected events and update mapping."""
8988
from .utils import combine_sources, evaluate
9089

91-
rule = get_rule(rule_id, verbose=False)
90+
rule = next((rule for rule in RuleCollection.default() if rule.id == rule_id), None)
91+
assert rule is not None, f"Unable to find rule with ID {rule_id}"
9292
merged_events = combine_sources(*self.events.values())
9393
filtered = evaluate(rule, merged_events)
9494

detection_rules/kbwrap.py

+16-15
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44
# 2.0.
55

66
"""Kibana cli commands."""
7+
import uuid
8+
79
import click
10+
811
import kql
912
from kibana import Kibana, Signal, RuleResource
10-
13+
from .cli_utils import multi_collection
1114
from .main import root
1215
from .misc import add_params, client_error, kibana_options
13-
from .rule_loader import load_rule_files, load_rules
16+
from .schemas import downgrade
1417
from .utils import format_command_options
1518

1619

@@ -49,30 +52,28 @@ def kibana_group(ctx: click.Context, **kibana_kwargs):
4952

5053

5154
@kibana_group.command("upload-rule")
52-
@click.argument("toml-files", nargs=-1, required=True)
55+
@multi_collection
5356
@click.option('--replace-id', '-r', is_flag=True, help='Replace rule IDs with new IDs before export')
5457
@click.pass_context
55-
def upload_rule(ctx, toml_files, replace_id):
58+
def upload_rule(ctx, rules, replace_id):
5659
"""Upload a list of rule .toml files to Kibana."""
57-
from .packaging import manage_versions
5860

5961
kibana = ctx.obj['kibana']
60-
file_lookup = load_rule_files(paths=toml_files)
61-
rules = list(load_rules(file_lookup=file_lookup).values())
62-
63-
# assign the versions from etc/versions.lock.json
64-
# rules that have changed in hash get incremented, others stay as-is.
65-
# rules that aren't in the lookup default to version 1
66-
manage_versions(rules, verbose=False)
67-
6862
api_payloads = []
6963

7064
for rule in rules:
7165
try:
72-
payload = rule.get_payload(include_version=True, replace_id=replace_id, embed_metadata=True,
73-
target_version=kibana.version)
66+
payload = rule.contents.to_api_format()
67+
payload.setdefault("meta", {}).update(rule.contents.metadata.to_dict())
68+
69+
if replace_id:
70+
payload["rule_id"] = str(uuid.uuid4())
71+
72+
payload = downgrade(payload, target_version=kibana.version)
73+
7474
except ValueError as e:
7575
client_error(f'{e} in version:{kibana.version}, for rule: {rule.name}', e, ctx=ctx)
76+
7677
rule = RuleResource(payload)
7778
api_payloads.append(rule)
7879

0 commit comments

Comments
 (0)