Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
```

Expand Down Expand Up @@ -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.

```
Expand Down
12 changes: 11 additions & 1 deletion detection_rules/cli_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@
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):
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -99,7 +106,10 @@ def get_collection(*args, **kwargs):
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"
Expand Down
5 changes: 5 additions & 0 deletions detection_rules/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions detection_rules/etc/_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 10 additions & 2 deletions detection_rules/kbwrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,11 +199,15 @@ def _process_imported_items(imported_items_list, item_type_description, item_key
@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.pass_context
def kibana_export_rules(ctx: click.Context, directory: Path, action_connectors_directory: Optional[Path],
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
export_exceptions: bool = False, skip_errors: bool = False, strip_version: bool = False,
no_tactic_filename: bool = False
) -> List[TOMLRule]:
"""Export custom rules from Kibana."""
kibana = ctx.obj["kibana"]
Expand Down Expand Up @@ -263,7 +267,11 @@ def kibana_export_rules(ctx: click.Context, directory: Path, action_connectors_d
maturity = "development"
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)
# check if directory / f"{rule_name}" exists
if (directory / f"{rule_name}").exists():
rules = RuleCollection()
Expand Down
4 changes: 3 additions & 1 deletion docs-dev/custom-rules-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "detection_rules"
version = "1.0.9"
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"
Expand Down