Skip to content

Commit d0a2e28

Browse files
eric-forte-elasticMikaayensonbrokensound77
authored
[FR] [DAC] further decouple reliance on default rule dir locations (#3654)
* Improve dac custom init * Fix path naming * patch for ci runs * add doc strings and rename test config name * expand how unit test are selected * Updated to support list of dirs * raise unlink to CLI * Fix unit test config post assertion * Add a custom method to generate the test config * add explicit checks for package.yml fields * newline * raise SystemExit instead of sys.exit * Collapsing missing config message and exit * flake8 * update base config * typo * Updated config parsing * Update detection_rules/config.py Co-authored-by: Justin Ibarra <[email protected]> * simplify package requirements * remove import * add dataclass to validate rules config file and create default setup-config cli * add kibana_version cli param * update doc string * rename delete cli option to overwrite, and small edits to exceptions * Typo in config * Add resolve * Added TODO * Cleanup * Update path to config path * Added get_rules_dir_path function * revert config change * Updated config * Minor Cleanup * Cleanup get_base_rule_dir * Updated to remove try except * update test cli command * Updated config generation * Add default in config * Update test CLI * update readme * readme updates * Add support for multiple rules dirs * Updated import-rules-to-repo readme * Update unit test * Remove redundant check * Remove additional parse_rules_config from mappings --------- Co-authored-by: Mika Ayenson <[email protected]> Co-authored-by: Mika Ayenson <[email protected]> Co-authored-by: Justin Ibarra <[email protected]>
1 parent 06ccba9 commit d0a2e28

13 files changed

+155
-76
lines changed

CLI.md

+10-3
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,10 @@ Usage: detection_rules import-rules-to-repo [OPTIONS] [INPUT_FILE]...
7777
Import rules from json, toml, yaml, or Kibana exported rule file(s).
7878

7979
Options:
80-
--required-only Only prompt for required fields
81-
-d, --directory DIRECTORY Load files from a directory
82-
-h, --help Show this message and exit.
80+
--required-only Only prompt for required fields
81+
-d, --directory DIRECTORY Load files from a directory
82+
-s, --save-directory DIRECTORY Save imported rules to a directory
83+
-h, --help Show this message and exit.
8384
```
8485

8586
The primary advantage of using this command is the ability to import multiple rules at once. Multiple rule paths can be
@@ -89,6 +90,8 @@ a combination of both.
8990
In addition to the formats mentioned using `create-rule`, this will also accept an `.ndjson`/`jsonl` file
9091
containing multiple rules (as would be the case with a bulk export).
9192

93+
The `-s/--save-directory` is an optional parameter to specify a non default directory to place imported rules. If it is not specified, the first directory specified in the rules config will be used.
94+
9295
This will also strip additional fields and prompt for missing required fields.
9396

9497
<a id="note-3">\* Note</a>: This will attempt to parse ALL files recursively within a specified directory.
@@ -286,6 +289,10 @@ _*To load a custom rule, the proper index must be setup first. The simplest way
286289
the `Load prebuilt detection rules and timeline templates` button on the `detections` page in the Kibana security app._
287290

288291

292+
_*To load a custom rule, the proper index must be setup first. The simplest way to do this is to click
293+
the `Load prebuilt detection rules and timeline templates` button on the `detections` page in the Kibana security app._
294+
295+
289296
### Using `import-rules`
290297

291298
This is a better option than `upload-rule` as it is built on refreshed APIs

detection_rules/cli_utils.py

+7-8
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,11 @@
1818
from .attack import matrix, tactics, build_threat_map_entry
1919
from .rule import TOMLRule, TOMLRuleContents
2020
from .rule_loader import (RuleCollection,
21-
DEFAULT_PREBUILT_RULES_DIR,
22-
DEFAULT_PREBUILT_BBR_DIR,
21+
DEFAULT_PREBUILT_RULES_DIRS,
22+
DEFAULT_PREBUILT_BBR_DIRS,
2323
dict_filter)
2424
from .schemas import definitions
25-
from .utils import clear_caches, get_path
26-
27-
RULES_DIR = get_path("rules")
25+
from .utils import clear_caches
2826

2927

3028
def single_collection(f):
@@ -49,7 +47,7 @@ def get_collection(*args, **kwargs):
4947
rules.load_directories(Path(d) for d in directories)
5048

5149
if rule_id:
52-
rules.load_directories((DEFAULT_PREBUILT_RULES_DIR, DEFAULT_PREBUILT_BBR_DIR),
50+
rules.load_directories(DEFAULT_PREBUILT_RULES_DIRS + DEFAULT_PREBUILT_BBR_DIRS,
5351
obj_filter=dict_filter(rule__rule_id=rule_id))
5452
if len(rules) != 1:
5553
client_error(f"Could not find rule with ID {rule_id}")
@@ -83,7 +81,7 @@ def get_collection(*args, **kwargs):
8381
rules.load_directories(Path(d) for d in directories)
8482

8583
if rule_id:
86-
rules.load_directories((DEFAULT_PREBUILT_RULES_DIR, DEFAULT_PREBUILT_BBR_DIR),
84+
rules.load_directories(DEFAULT_PREBUILT_RULES_DIRS + DEFAULT_PREBUILT_BBR_DIRS,
8785
obj_filter=dict_filter(rule__rule_id=rule_id))
8886
found_ids = {rule.id for rule in rules}
8987
missing = set(rule_id).difference(found_ids)
@@ -187,7 +185,8 @@ def rule_prompt(path=None, rule_type=None, required_only=True, save=True, verbos
187185

188186
contents[name] = result
189187

190-
suggested_path = os.path.join(RULES_DIR, contents['name']) # TODO: UPDATE BASED ON RULE STRUCTURE
188+
# DEFAULT_PREBUILT_RULES_DIRS[0] is a required directory just as a suggestion
189+
suggested_path = os.path.join(DEFAULT_PREBUILT_RULES_DIRS[0], contents['name'])
191190
path = os.path.realpath(path or input('File path for rule [{}]: '.format(suggested_path)) or suggested_path)
192191
meta = {'creation_date': creation_date, 'updated_date': creation_date, 'maturity': 'development'}
193192

detection_rules/config.py

+10-4
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"""Configuration support for custom components."""
77
import fnmatch
88
import os
9-
from dataclasses import dataclass
9+
from dataclasses import dataclass, field
1010
from pathlib import Path
1111
from functools import cached_property
1212
from typing import Dict, List, Optional
@@ -186,6 +186,7 @@ class RulesConfig:
186186
version_lock: Dict[str, dict]
187187

188188
action_dir: Optional[Path] = None
189+
bbr_rules_dirs: Optional[List[Path]] = field(default_factory=list)
189190
exception_dir: Optional[Path] = None
190191

191192
def __post_init__(self):
@@ -248,18 +249,23 @@ def parse_rules_config(path: Optional[Path] = None) -> RulesConfig:
248249
# files
249250
# paths are relative
250251
files = {f'{k}_file': base_dir.joinpath(v) for k, v in loaded['files'].items()}
251-
contents = {k: load_dump(str(base_dir.joinpath(v))) for k, v in loaded['files'].items()}
252+
contents = {k: load_dump(str(base_dir.joinpath(v).resolve())) for k, v in loaded['files'].items()}
252253

253254
contents.update(**files)
254255

255256
# directories
256257
# paths are relative
257258
if loaded.get('directories'):
258-
contents.update({k: base_dir.joinpath(v) for k, v in loaded['directories'].items()})
259+
contents.update({k: base_dir.joinpath(v).resolve() for k, v in loaded['directories'].items()})
259260

260261
# rule_dirs
261262
# paths are relative
262-
contents['rule_dirs'] = [base_dir.joinpath(d) for d in loaded.get('rule_dirs')]
263+
contents['rule_dirs'] = [base_dir.joinpath(d).resolve() for d in loaded.get('rule_dirs')]
264+
265+
# bbr_rules_dirs
266+
# paths are relative
267+
if loaded.get('bbr_rules_dirs'):
268+
contents['bbr_rules_dirs'] = [base_dir.joinpath(d).resolve() for d in loaded.get('bbr_rules_dirs', [])]
263269

264270
try:
265271
rules_config = RulesConfig(test_config=test_config, **contents)

detection_rules/custom_rules.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ def create_config_content() -> str:
2727
"""Create the content for the _config.yaml file."""
2828
# Base structure of the configuration
2929
config_content = {
30-
'rule_dirs': ['rules', 'rules_building_block'],
30+
'rule_dirs': ['rules'],
31+
'bbr_rules_dirs': [],
3132
'files': {
3233
'deprecated_rules': 'etc/deprecated_rules.json',
3334
'packages': 'etc/packages.yml',

detection_rules/devtools.py

+13-14
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
from .beats import (download_beats_schema, download_latest_beats_schema,
3434
refresh_main_schema)
3535
from .cli_utils import single_collection
36-
from .config import parse_rules_config
3736
from .docs import IntegrationSecurityDocs, IntegrationSecurityDocsMDX
3837
from .ecs import download_endpoint_schemas, download_schemas
3938
from .endgame import EndgameSchemaManager
@@ -51,13 +50,11 @@
5150
Package)
5251
from .rule import (AnyRuleData, BaseRuleData, DeprecatedRule, QueryRuleData,
5352
RuleTransform, ThreatMapping, TOMLRule, TOMLRuleContents)
54-
from .rule_loader import RuleCollection, production_filter
53+
from .rule_loader import RULES_CONFIG, RuleCollection, production_filter
5554
from .schemas import definitions, get_stack_versions
5655
from .utils import dict_hash, get_etc_path, get_path, load_dump
5756
from .version_lock import VersionLockFile, loaded_version_lock
5857

59-
RULES_CONFIG = parse_rules_config()
60-
RULES_DIR = get_path('rules')
6158
GH_CONFIG = Path.home() / ".config" / "gh" / "hosts.yml"
6259
NAVIGATOR_GIST_ID = '1a3f65224822a30a8228a8ed20289a89'
6360
NAVIGATOR_URL = 'https://ela.st/detection-rules-navigator'
@@ -315,15 +312,17 @@ def prune_staging_area(target_stack_version: str, dry_run: bool, exception_list:
315312
continue
316313

317314
# it's a change to a rule file, load it and check the version
318-
if str(change.path.absolute()).startswith(RULES_DIR) and change.path.suffix == ".toml":
319-
# bypass TOML validation in case there were schema changes
320-
dict_contents = RuleCollection.deserialize_toml_string(change.read())
321-
min_stack_version: Optional[str] = dict_contents.get("metadata", {}).get("min_stack_version")
322-
323-
if min_stack_version is not None and \
324-
(target_stack_version < Version.parse(min_stack_version, optional_minor_and_patch=True)):
325-
# rule is incompatible, add to the list of reversions to make later
326-
reversions.append(change)
315+
for rules_dir in RULES_CONFIG.rule_dirs:
316+
if str(change.path.absolute()).startswith(str(rules_dir)) and change.path.suffix == ".toml":
317+
# bypass TOML validation in case there were schema changes
318+
dict_contents = RuleCollection.deserialize_toml_string(change.read())
319+
min_stack_version: Optional[str] = dict_contents.get("metadata", {}).get("min_stack_version")
320+
321+
if min_stack_version is not None and \
322+
(target_stack_version < Version.parse(min_stack_version, optional_minor_and_patch=True)):
323+
# rule is incompatible, add to the list of reversions to make later
324+
reversions.append(change)
325+
break
327326

328327
if len(reversions) == 0:
329328
click.echo("No files restored from staging area")
@@ -733,7 +732,7 @@ def deprecate_rule(ctx: click.Context, rule_file: Path):
733732
ctx.exit()
734733

735734
today = time.strftime('%Y/%m/%d')
736-
deprecated_path = get_path('rules', '_deprecated', rule_file.name)
735+
deprecated_path = rule.get_base_rule_dir() / '_deprecated' / rule_file.name
737736

738737
# create the new rule and save it
739738
new_meta = dataclasses.replace(rule.contents.metadata,

detection_rules/etc/_config.yaml

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# detection-rules config file
2-
2+
bbr_rules_dirs:
3+
- ../../rules_building_block
34
rule_dirs:
4-
- rules
5-
- rules_building_block
5+
- ../../rules
66
files:
77
deprecated_rules: deprecated_rules.json
88
packages: packages.yml

detection_rules/main.py

+6-5
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,8 @@
3333
from .schemas import all_versions, definitions, get_incompatible_fields, get_schema_file
3434
from .utils import Ndjson, get_path, get_etc_path, clear_caches, load_dump, load_rule_contents, rulename_to_filename
3535

36-
37-
RULES_DIR = get_path('rules')
38-
ROOT_DIR = Path(RULES_DIR).parent
3936
RULES_CONFIG = parse_rules_config()
37+
RULES_DIRS = RULES_CONFIG.rule_dirs
4038

4139

4240
@click.group('detection-rules', context_settings={'help_option_names': ['-h', '--help']})
@@ -101,7 +99,10 @@ def generate_rules_index(ctx: click.Context, query, overwrite, save_files=True):
10199
@click.argument('input-file', type=click.Path(dir_okay=False, exists=True), nargs=-1, required=False)
102100
@click.option('--required-only', is_flag=True, help='Only prompt for required fields')
103101
@click.option('--directory', '-d', type=click.Path(file_okay=False, exists=True), help='Load files from a directory')
104-
def import_rules_into_repo(input_file, required_only, directory):
102+
@click.option('--save-directory', '-s', type=click.Path(file_okay=False, exists=True),
103+
help='Save imported rules to a directory')
104+
def import_rules_into_repo(input_file: click.Path, required_only: bool, directory: click.Path,
105+
save_directory: click.Path):
105106
"""Import rules from json, toml, yaml, or Kibana exported rule file(s)."""
106107
rule_files = glob.glob(os.path.join(directory, '**', '*.*'), recursive=True) if directory else []
107108
rule_files = sorted(set(rule_files + list(input_file)))
@@ -116,7 +117,7 @@ def import_rules_into_repo(input_file, required_only, directory):
116117
for contents in rule_contents:
117118
base_path = contents.get('name') or contents.get('rule', {}).get('name')
118119
base_path = rulename_to_filename(base_path) if base_path else base_path
119-
rule_path = os.path.join(RULES_DIR, base_path) if base_path else None
120+
rule_path = os.path.join(save_directory if save_directory is not None else RULES_DIRS[0], base_path)
120121
additional = ['index'] if not contents.get('data_view_id') else ['data_view_id']
121122
rule_prompt(rule_path, required_only=required_only, save=True, verbose=True,
122123
additional_required=additional, **contents)

detection_rules/navigator.py

+6-5
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
from .attack import CURRENT_ATTACK_VERSION
1818
from .mixins import MarshmallowDataclassMixin
1919
from .rule import TOMLRule
20-
from .rule_loader import DEFAULT_PREBUILT_RULES_DIR, DEFAULT_PREBUILT_BBR_DIR
2120
from .schemas import definitions
2221

2322

@@ -162,11 +161,13 @@ def links_dict(label: str, url: any) -> dict:
162161
return links
163162

164163
def rule_links_dict(self, rule: TOMLRule) -> dict:
164+
"""Create a links dictionary for a rule."""
165165
base_url = 'https://github.com/elastic/detection-rules/blob/main/rules/'
166-
try:
167-
base_path = str(rule.path.resolve().relative_to(DEFAULT_PREBUILT_RULES_DIR))
168-
except ValueError:
169-
base_path = str(rule.path.resolve().relative_to(DEFAULT_PREBUILT_BBR_DIR))
166+
base_path = str(rule.get_base_rule_dir())
167+
168+
if base_path is None:
169+
raise ValueError("Could not find a valid base path for the rule")
170+
170171
url = f'{base_url}{base_path}'
171172
return self.links_dict(rule.name, url)
172173

detection_rules/packaging.py

+5-5
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from .misc import JS_LICENSE, cached
2424
from .navigator import NavigatorBuilder, Navigator
2525
from .rule import TOMLRule, QueryRuleData, ThreatMapping
26-
from .rule_loader import DeprecatedCollection, RuleCollection, DEFAULT_PREBUILT_RULES_DIR, DEFAULT_PREBUILT_BBR_DIR
26+
from .rule_loader import DeprecatedCollection, RuleCollection
2727
from .schemas import definitions
2828
from .utils import Ndjson, get_path, get_etc_path
2929
from .version_lock import loaded_version_lock
@@ -479,10 +479,10 @@ def create_bulk_index_body(self) -> Tuple[Ndjson, Ndjson]:
479479

480480
bulk_upload_docs.append(create)
481481

482-
try:
483-
relative_path = str(rule.path.resolve().relative_to(DEFAULT_PREBUILT_RULES_DIR))
484-
except ValueError:
485-
relative_path = str(rule.path.resolve().relative_to(DEFAULT_PREBUILT_BBR_DIR))
482+
relative_path = str(rule.get_base_rule_dir())
483+
484+
if relative_path is None:
485+
raise ValueError(f"Could not find a valid relative path for the rule: {rule.id}")
486486

487487
rule_doc = dict(hash=rule.contents.sha256(),
488488
source='repo',

detection_rules/rule.py

+10
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@
4343
MIN_FLEET_PACKAGE_VERSION = '7.13.0'
4444
TIME_NOW = time.strftime('%Y/%m/%d')
4545
RULES_CONFIG = parse_rules_config()
46+
DEFAULT_PREBUILT_RULES_DIRS = RULES_CONFIG.rule_dirs
47+
DEFAULT_PREBUILT_BBR_DIRS = RULES_CONFIG.bbr_rules_dirs
4648

4749

4850
BUILD_FIELD_VERSIONS = {
@@ -1321,6 +1323,14 @@ def get_asset(self) -> dict:
13211323
"""Generate the relevant fleet compatible asset."""
13221324
return {"id": self.id, "attributes": self.contents.to_api_format(), "type": definitions.SAVED_OBJECT_TYPE}
13231325

1326+
def get_base_rule_dir(self) -> Path | None:
1327+
"""Get the base rule directory for the rule."""
1328+
rule_path = self.path.resolve()
1329+
for rules_dir in DEFAULT_PREBUILT_RULES_DIRS + DEFAULT_PREBUILT_BBR_DIRS:
1330+
if rule_path.is_relative_to(rules_dir):
1331+
return rule_path.relative_to(rules_dir)
1332+
return None
1333+
13241334
def save_toml(self):
13251335
assert self.path is not None, f"Can't save rule {self.name} (self.id) without a path"
13261336
converted = dict(metadata=self.contents.metadata.to_dict(), rule=self.contents.data.to_dict())

detection_rules/rule_loader.py

+16-14
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,17 @@
1717
from marshmallow.exceptions import ValidationError
1818

1919
from . import utils
20+
from .config import parse_rules_config
2021
from .mappings import RtaMappings
2122
from .rule import (
2223
DeprecatedRule, DeprecatedRuleContents, DictRule, TOMLRule, TOMLRuleContents
2324
)
2425
from .schemas import definitions
2526
from .utils import cached, get_path
2627

27-
DEFAULT_PREBUILT_RULES_DIR = Path(get_path("rules"))
28-
DEFAULT_PREBUILT_BBR_DIR = Path(get_path("rules_building_block"))
29-
DEFAULT_PREBUILT_DEPRECATED_DIR = DEFAULT_PREBUILT_RULES_DIR / '_deprecated'
30-
DEFAULT_PREBUILT_RTA_DIR = get_path("rta")
28+
RULES_CONFIG = parse_rules_config()
29+
DEFAULT_PREBUILT_RULES_DIRS = RULES_CONFIG.rule_dirs
30+
DEFAULT_PREBUILT_BBR_DIRS = RULES_CONFIG.bbr_rules_dirs
3131
FILE_PATTERN = r'^([a-z0-9_])+\.(json|toml)$'
3232

3333

@@ -294,8 +294,8 @@ def default(cls) -> 'RawRuleCollection':
294294
"""Return the default rule collection, which retrieves from rules/."""
295295
if cls.__default is None:
296296
collection = RawRuleCollection()
297-
collection.load_directory(DEFAULT_PREBUILT_RULES_DIR)
298-
collection.load_directory(DEFAULT_PREBUILT_BBR_DIR)
297+
collection.load_directories(DEFAULT_PREBUILT_RULES_DIRS)
298+
collection.load_directories(DEFAULT_PREBUILT_BBR_DIRS)
299299
collection.freeze()
300300
cls.__default = collection
301301

@@ -306,7 +306,7 @@ def default_bbr(cls) -> 'RawRuleCollection':
306306
"""Return the default BBR collection, which retrieves from building_block_rules/."""
307307
if cls.__default_bbr is None:
308308
collection = RawRuleCollection()
309-
collection.load_directory(DEFAULT_PREBUILT_BBR_DIR)
309+
collection.load_directories(DEFAULT_PREBUILT_BBR_DIRS)
310310
collection.freeze()
311311
cls.__default_bbr = collection
312312

@@ -443,8 +443,10 @@ def load_git_tag(self, branch: str, remote: Optional[str] = None, skip_query_val
443443
from .version_lock import VersionLock, add_rule_types_to_lock
444444

445445
git = utils.make_git()
446-
rules_dir = DEFAULT_PREBUILT_RULES_DIR.relative_to(get_path("."))
447-
paths = git("ls-tree", "-r", "--name-only", branch, rules_dir).splitlines()
446+
paths = []
447+
for rules_dir in DEFAULT_PREBUILT_RULES_DIRS:
448+
rules_dir = rules_dir.relative_to(get_path("."))
449+
paths.extend(git("ls-tree", "-r", "--name-only", branch, rules_dir).splitlines())
448450

449451
rule_contents = []
450452
rule_map = {}
@@ -508,8 +510,8 @@ def default(cls) -> 'RuleCollection':
508510
"""Return the default rule collection, which retrieves from rules/."""
509511
if cls.__default is None:
510512
collection = RuleCollection()
511-
collection.load_directory(DEFAULT_PREBUILT_RULES_DIR)
512-
collection.load_directory(DEFAULT_PREBUILT_BBR_DIR)
513+
collection.load_directories(DEFAULT_PREBUILT_RULES_DIRS)
514+
collection.load_directories(DEFAULT_PREBUILT_BBR_DIRS)
513515
collection.freeze()
514516
cls.__default = collection
515517

@@ -520,7 +522,7 @@ def default_bbr(cls) -> 'RuleCollection':
520522
"""Return the default BBR collection, which retrieves from building_block_rules/."""
521523
if cls.__default_bbr is None:
522524
collection = RuleCollection()
523-
collection.load_directory(DEFAULT_PREBUILT_BBR_DIR)
525+
collection.load_directories(DEFAULT_PREBUILT_BBR_DIRS)
524526
collection.freeze()
525527
cls.__default_bbr = collection
526528

@@ -630,8 +632,8 @@ def download_worker(pr_info):
630632

631633
__all__ = (
632634
"FILE_PATTERN",
633-
"DEFAULT_PREBUILT_RULES_DIR",
634-
"DEFAULT_PREBUILT_BBR_DIR",
635+
"DEFAULT_PREBUILT_RULES_DIRS",
636+
"DEFAULT_PREBUILT_BBR_DIRS",
635637
"load_github_pr_rules",
636638
"DeprecatedCollection",
637639
"DeprecatedRule",

0 commit comments

Comments
 (0)