From 9b682b752c0233d0b15b76a778d9c53bcc3eace0 Mon Sep 17 00:00:00 2001 From: Frederik Berg <83548283+frederikb96@users.noreply.github.com> Date: Wed, 16 Apr 2025 22:02:14 +0200 Subject: [PATCH] Feature exclude tactic name (#4593) * Added new cli flag to exclude tactic name in rule file name * added a shortcut for the flag and adjusted CLI readme * Add no tactic flag also to import to prevent warnings * Added info about unit test * version bump * Added no_tactic_filename as config option + fixed linting * pyproject version bump --------- Co-authored-by: Mika Ayenson, PhD Co-authored-by: Eric Forte <119343520+eric-forte-elastic@users.noreply.github.com> --- CLI.md | 2 ++ detection_rules/cli_utils.py | 12 +++++++++++- detection_rules/config.py | 5 +++++ detection_rules/etc/_config.yaml | 5 +++++ detection_rules/kbwrap.py | 12 ++++++++++-- docs-dev/custom-rules-management.md | 4 +++- pyproject.toml | 2 +- 7 files changed, 37 insertions(+), 5 deletions(-) diff --git a/CLI.md b/CLI.md index f5d5242ed..61e43abd6 100644 --- a/CLI.md +++ b/CLI.md @@ -265,6 +265,7 @@ Options: -e, --overwrite-exceptions Overwrite exceptions in existing rules -ac, --overwrite-action-connectors Overwrite action connectors in existing rules + -nt, --no-tactic-filename Allow rule filenames without tactic prefix. Use this if rules have been exported with this flag. -h, --help Show this message and exit. ``` @@ -520,6 +521,7 @@ Options: -e, --export-exceptions Include exceptions in export -s, --skip-errors Skip errors when exporting rules -sv, --strip-version Strip the version fields from all rules + -nt, --no-tactic-filename Exclude tactic prefix in exported filenames for rules. Use same flag for import-rules to prevent warnings and disable its unit test. -h, --help Show this message and exit. ``` diff --git a/detection_rules/cli_utils.py b/detection_rules/cli_utils.py index eb19579fb..102b03a58 100644 --- a/detection_rules/cli_utils.py +++ b/detection_rules/cli_utils.py @@ -23,6 +23,9 @@ from .rule_loader import (DEFAULT_PREBUILT_BBR_DIRS, dict_filter) from .schemas import definitions from .utils import clear_caches, rulename_to_filename +from .config import parse_rules_config + +RULES_CONFIG = parse_rules_config() def single_collection(f): @@ -66,11 +69,15 @@ def multi_collection(f): @click.option("--directory", "-d", multiple=True, type=click.Path(file_okay=False), required=False, help="Recursively load rules from a directory") @click.option("--rule-id", "-id", multiple=True, required=False) + @click.option("--no-tactic-filename", "-nt", is_flag=True, required=False, + help="Allow rule filenames without tactic prefix. " + "Use this if rules have been exported with this flag.") @functools.wraps(f) def get_collection(*args, **kwargs): rule_id: List[str] = kwargs.pop("rule_id", []) rule_files: List[str] = kwargs.pop("rule_file") directories: List[str] = kwargs.pop("directory") + no_tactic_filename: bool = kwargs.pop("no_tactic_filename", False) rules = RuleCollection() @@ -99,7 +106,10 @@ def multi_collection(f): for rule in rules: threat = rule.contents.data.get("threat") first_tactic = threat[0].tactic.name if threat else "" - rule_name = rulename_to_filename(rule.contents.data.name, tactic_name=first_tactic) + # Check if flag or config is set to not include tactic in the filename + no_tactic_filename = no_tactic_filename or RULES_CONFIG.no_tactic_filename + tactic_name = None if no_tactic_filename else first_tactic + rule_name = rulename_to_filename(rule.contents.data.name, tactic_name=tactic_name) if rule.path.name != rule_name: click.secho( f"WARNING: Rule path does not match required path: {rule.path.name} != {rule_name}", fg="yellow" diff --git a/detection_rules/config.py b/detection_rules/config.py index c09cdf9ea..cd2804c35 100644 --- a/detection_rules/config.py +++ b/detection_rules/config.py @@ -193,6 +193,7 @@ class RulesConfig: exception_dir: Optional[Path] = None normalize_kql_keywords: bool = True bypass_optional_elastic_validation: bool = False + no_tactic_filename: bool = False def __post_init__(self): """Perform post validation on packages.yaml file.""" @@ -311,6 +312,10 @@ def parse_rules_config(path: Optional[Path] = None) -> RulesConfig: if contents['bypass_optional_elastic_validation']: set_all_validation_bypass(contents['bypass_optional_elastic_validation']) + # no_tactic_filename + contents['no_tactic_filename'] = loaded.get('no_tactic_filename', False) + + # return the config try: rules_config = RulesConfig(test_config=test_config, **contents) except (ValueError, TypeError) as e: diff --git a/detection_rules/etc/_config.yaml b/detection_rules/etc/_config.yaml index 5ad6dd6f2..08377486f 100644 --- a/detection_rules/etc/_config.yaml +++ b/detection_rules/etc/_config.yaml @@ -72,3 +72,8 @@ normalize_kql_keywords: False # If set in this file, the path should be relative to the location of this config. If passed as an environment variable, # it should be the full path # Note: Using the `custom-rules setup-config ` command will generate a config called `test_config.yaml` + +# To prevent the tactic prefix from being added to the rule filename, set the line below to True +# This config line can be used instead of specifying the `--no-tactic-filename` flag in the CLI +# Mind that for unit tests, you also want to disable the filename test in the test_config.yaml +# no_tactic_filename: True \ No newline at end of file diff --git a/detection_rules/kbwrap.py b/detection_rules/kbwrap.py index 500b445f0..c5feebc02 100644 --- a/detection_rules/kbwrap.py +++ b/detection_rules/kbwrap.py @@ -199,6 +199,9 @@ def kibana_import_rules(ctx: click.Context, rules: RuleCollection, overwrite: Op @click.option("--export-exceptions", "-e", is_flag=True, help="Include exceptions in export") @click.option("--skip-errors", "-s", is_flag=True, help="Skip errors when exporting rules") @click.option("--strip-version", "-sv", is_flag=True, help="Strip the version fields from all rules") +@click.option("--no-tactic-filename", "-nt", is_flag=True, + help="Exclude tactic prefix in exported filenames for rules. " + "Use same flag for import-rules to prevent warnings and disable its unit test.") @click.option("--local-creation-date", "-lc", is_flag=True, help="Preserve the local creation date of the rule") @click.option("--local-updated-date", "-lu", is_flag=True, help="Preserve the local updated date of the rule") @click.pass_context @@ -206,7 +209,8 @@ def kibana_export_rules(ctx: click.Context, directory: Path, action_connectors_d exceptions_directory: Optional[Path], default_author: str, rule_id: Optional[Iterable[str]] = None, export_action_connectors: bool = False, export_exceptions: bool = False, skip_errors: bool = False, strip_version: bool = False, - local_creation_date: bool = False, local_updated_date: bool = False) -> List[TOMLRule]: + no_tactic_filename: bool = False, local_creation_date: bool = False, + local_updated_date: bool = False) -> List[TOMLRule]: """Export custom rules from Kibana.""" kibana = ctx.obj["kibana"] kibana_include_details = export_exceptions or export_action_connectors @@ -270,7 +274,11 @@ def kibana_export_rules(ctx: click.Context, directory: Path, action_connectors_d } threat = rule_resource.get("threat") first_tactic = threat[0].get("tactic").get("name") if threat else "" - rule_name = rulename_to_filename(rule_resource.get("name"), tactic_name=first_tactic) + # Check if flag or config is set to not include tactic in the filename + no_tactic_filename = no_tactic_filename or RULES_CONFIG.no_tactic_filename + # Check if the flag is set to not include tactic in the filename + tactic_name = first_tactic if not no_tactic_filename else None + rule_name = rulename_to_filename(rule_resource.get("name"), tactic_name=tactic_name) save_path = directory / f"{rule_name}" params.update( diff --git a/docs-dev/custom-rules-management.md b/docs-dev/custom-rules-management.md index 1048b68f7..c95a103f3 100644 --- a/docs-dev/custom-rules-management.md +++ b/docs-dev/custom-rules-management.md @@ -94,8 +94,10 @@ be set in `_config.yaml` or as the environment variable `DETECTION_RULES_TEST_CO environment variable if both are set. Having both these options allows for configuring testing on prebuilt Elastic rules without specifying a rules _config.yaml. +Some notes: -* Note: If set in this file, the path should be relative to the location of this config. If passed as an environment variable, it should be the full path +* If set in this file, the path should be relative to the location of this config. If passed as an environment variable, it should be the full path +* When using the `--no-tactic-filename` flag for kibana imports and exports, be sure to disable the unit test by using the following line `- tests.test_all_rules.TestRuleFiles.test_rule_file_name_tactic` in your test config file. ### How the config is used and it's designed portability diff --git a/pyproject.toml b/pyproject.toml index 03ac2c7c1..24b7d7d4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "detection_rules" -version = "1.0.10" +version = "1.0.11" 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." readme = "README.md" requires-python = ">=3.12"