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 <Mikaayenson@users.noreply.github.com>
Co-authored-by: Eric Forte <119343520+eric-forte-elastic@users.noreply.github.com>
This commit is contained in:
Frederik Berg
2025-04-16 22:02:14 +02:00
committed by GitHub
parent 033c82858c
commit 9b682b752c
7 changed files with 37 additions and 5 deletions
+2
View File
@@ -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.
```
+11 -1
View File
@@ -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"
+5
View File
@@ -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:
+5
View File
@@ -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 <name>` 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
+10 -2
View File
@@ -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(
+3 -1
View File
@@ -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
+1 -1
View File
@@ -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 Securitys Detection Engine."
readme = "README.md"
requires-python = ">=3.12"