Skip to content

Commit 010e7d3

Browse files
Merge branch 'main' into deprecatemac
2 parents e6932e8 + 6cb238b commit 010e7d3

23 files changed

+400
-152
lines changed

.github/ISSUE_TEMPLATE/new_meta.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ body:
3737
- type: textarea
3838
attributes:
3939
label: Tasking
40-
value: "```[tasklist]\n### Meta Tasks\n- [ ] Provide Week 1 Update Comment\n- [ ] Provide Week 2 Update or Closeout Comment\n```"
40+
value: "\n### Meta Tasks\n- [ ] Provide Week 1 Update Comment\n- [ ] Provide Week 2 Update or Closeout Comment\n"
4141
render:
4242

4343
- type: textarea

.github/workflows/kibana-mitre-update.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ jobs:
1616

1717
- name: Get MITRE Attack changed files
1818
id: changed-attack-files
19-
uses: tj-actions/changed-files@v46
19+
uses: tj-actions/changed-files@2f7c5bfce28377bc069a65ba478de0a74aa0ca32 # v46.0.1
2020
with:
2121
files: detection_rules/etc/attack-v*.json.gz
2222

CLI.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ Options:
265265
-e, --overwrite-exceptions Overwrite exceptions in existing rules
266266
-ac, --overwrite-action-connectors
267267
Overwrite action connectors in existing rules
268+
-nt, --no-tactic-filename Allow rule filenames without tactic prefix. Use this if rules have been exported with this flag.
268269
-h, --help Show this message and exit.
269270
```
270271

@@ -515,11 +516,13 @@ Options:
515516
Directory to export exceptions to
516517
-da, --default-author TEXT Default author for rules missing one
517518
-r, --rule-id TEXT Optional Rule IDs to restrict export to
519+
-rn, --rule-name TEXT Optional Rule name to restrict export to (KQL, case-insensitive, supports wildcards)
518520
-ac, --export-action-connectors
519521
Include action connectors in export
520522
-e, --export-exceptions Include exceptions in export
521523
-s, --skip-errors Skip errors when exporting rules
522524
-sv, --strip-version Strip the version fields from all rules
525+
-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.
523526
-h, --help Show this message and exit.
524527
525528
```

detection_rules/cli_utils.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
dict_filter)
2424
from .schemas import definitions
2525
from .utils import clear_caches, rulename_to_filename
26+
from .config import parse_rules_config
27+
28+
RULES_CONFIG = parse_rules_config()
2629

2730

2831
def single_collection(f):
@@ -66,11 +69,15 @@ def multi_collection(f):
6669
@click.option("--directory", "-d", multiple=True, type=click.Path(file_okay=False), required=False,
6770
help="Recursively load rules from a directory")
6871
@click.option("--rule-id", "-id", multiple=True, required=False)
72+
@click.option("--no-tactic-filename", "-nt", is_flag=True, required=False,
73+
help="Allow rule filenames without tactic prefix. "
74+
"Use this if rules have been exported with this flag.")
6975
@functools.wraps(f)
7076
def get_collection(*args, **kwargs):
7177
rule_id: List[str] = kwargs.pop("rule_id", [])
7278
rule_files: List[str] = kwargs.pop("rule_file")
7379
directories: List[str] = kwargs.pop("directory")
80+
no_tactic_filename: bool = kwargs.pop("no_tactic_filename", False)
7481

7582
rules = RuleCollection()
7683

@@ -99,7 +106,10 @@ def get_collection(*args, **kwargs):
99106
for rule in rules:
100107
threat = rule.contents.data.get("threat")
101108
first_tactic = threat[0].tactic.name if threat else ""
102-
rule_name = rulename_to_filename(rule.contents.data.name, tactic_name=first_tactic)
109+
# Check if flag or config is set to not include tactic in the filename
110+
no_tactic_filename = no_tactic_filename or RULES_CONFIG.no_tactic_filename
111+
tactic_name = None if no_tactic_filename else first_tactic
112+
rule_name = rulename_to_filename(rule.contents.data.name, tactic_name=tactic_name)
103113
if rule.path.name != rule_name:
104114
click.secho(
105115
f"WARNING: Rule path does not match required path: {rule.path.name} != {rule_name}", fg="yellow"
@@ -210,18 +220,11 @@ def rule_prompt(path=None, rule_type=None, required_only=True, save=True, verbos
210220
# DEFAULT_PREBUILT_RULES_DIRS[0] is a required directory just as a suggestion
211221
suggested_path = Path(DEFAULT_PREBUILT_RULES_DIRS[0]) / contents['name']
212222
path = Path(path or input(f'File path for rule [{suggested_path}]: ') or suggested_path).resolve()
213-
# Inherit maturity from the rule already exists
214-
maturity = "development"
215-
if path.exists():
216-
rules = RuleCollection()
217-
rules.load_file(path)
218-
if rules:
219-
maturity = rules.rules[0].contents.metadata.maturity
220-
223+
# Inherit maturity and optionally local dates from the rule if it already exists
221224
meta = {
222-
"creation_date": creation_date,
223-
"updated_date": creation_date,
224-
"maturity": maturity,
225+
"creation_date": kwargs.get("creation_date") or creation_date,
226+
"updated_date": kwargs.get("updated_date") or creation_date,
227+
"maturity": "development" or kwargs.get("maturity"),
225228
}
226229

227230
try:

detection_rules/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ class RulesConfig:
193193
exception_dir: Optional[Path] = None
194194
normalize_kql_keywords: bool = True
195195
bypass_optional_elastic_validation: bool = False
196+
no_tactic_filename: bool = False
196197

197198
def __post_init__(self):
198199
"""Perform post validation on packages.yaml file."""
@@ -311,6 +312,10 @@ def parse_rules_config(path: Optional[Path] = None) -> RulesConfig:
311312
if contents['bypass_optional_elastic_validation']:
312313
set_all_validation_bypass(contents['bypass_optional_elastic_validation'])
313314

315+
# no_tactic_filename
316+
contents['no_tactic_filename'] = loaded.get('no_tactic_filename', False)
317+
318+
# return the config
314319
try:
315320
rules_config = RulesConfig(test_config=test_config, **contents)
316321
except (ValueError, TypeError) as e:

detection_rules/etc/_config.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,8 @@ normalize_kql_keywords: False
7272
# If set in this file, the path should be relative to the location of this config. If passed as an environment variable,
7373
# it should be the full path
7474
# Note: Using the `custom-rules setup-config <name>` command will generate a config called `test_config.yaml`
75+
76+
# To prevent the tactic prefix from being added to the rule filename, set the line below to True
77+
# This config line can be used instead of specifying the `--no-tactic-filename` flag in the CLI
78+
# Mind that for unit tests, you also want to disable the filename test in the test_config.yaml
79+
# no_tactic_filename: True

detection_rules/etc/non-ecs-schema.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,9 @@
172172
},
173173
"logs-azure.signinlogs-*": {
174174
"azure.signinlogs.properties.conditional_access_audiences.application_id": "keyword",
175-
"azure.signinlogs.properties.original_transfer_method": "keyword"
175+
"azure.signinlogs.properties.original_transfer_method": "keyword",
176+
"azure.auditlogs.properties.target_resources.0.display_name": "keyword",
177+
"azure.signinlogs.properties.authentication_details.authentication_method": "keyword"
176178
},
177179
"logs-azure.activitylogs-*": {
178180
"azure.activitylogs.properties.authentication_protocol": "keyword",

detection_rules/kbwrap.py

Lines changed: 67 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from .main import root
2525
from .misc import add_params, client_error, kibana_options, get_kibana_client, nested_set
2626
from .rule import downgrade_contents_from_rule, TOMLRuleContents, TOMLRule
27-
from .rule_loader import RuleCollection
27+
from .rule_loader import RuleCollection, update_metadata_from_file
2828
from .utils import format_command_options, rulename_to_filename
2929

3030
RULES_CONFIG = parse_rules_config()
@@ -108,27 +108,44 @@ def _parse_list_id(s: str):
108108

109109
# Re-try to address known Kibana issue: https://github.com/elastic/kibana/issues/143864
110110
workaround_errors = []
111+
workaround_error_types = set()
111112

112113
flattened_exceptions = [e for sublist in exception_dicts for e in sublist]
113114
all_exception_list_ids = {exception["list_id"] for exception in flattened_exceptions}
114115

115116
click.echo(f'{len(response["errors"])} rule(s) failed to import!')
116117

118+
action_connector_validation_error = "Error validating create data"
119+
action_connector_type_error = "expected value of type [string] but got [undefined]"
117120
for error in response['errors']:
118-
click.echo(f' - {error["rule_id"]}: ({error["error"]["status_code"]}) {error["error"]["message"]}')
121+
error_message = error["error"]["message"]
122+
click.echo(f' - {error["rule_id"]}: ({error["error"]["status_code"]}) {error_message}')
119123

120-
if "references a non existent exception list" in error["error"]["message"]:
121-
list_id = _parse_list_id(error["error"]["message"])
124+
if "references a non existent exception list" in error_message:
125+
list_id = _parse_list_id(error_message)
122126
if list_id in all_exception_list_ids:
123127
workaround_errors.append(error["rule_id"])
128+
workaround_error_types.add("non existent exception list")
129+
130+
if action_connector_validation_error in error_message and action_connector_type_error in error_message:
131+
workaround_error_types.add("connector still being built")
124132

125133
if workaround_errors:
126134
workaround_errors = list(set(workaround_errors))
127-
click.echo(f'Missing exception list errors detected for {len(workaround_errors)} rules. '
128-
'Try re-importing using the following command and rule IDs:\n')
129-
click.echo('python -m detection_rules kibana import-rules -o ', nl=False)
130-
click.echo(' '.join(f'-id {rule_id}' for rule_id in workaround_errors))
131-
click.echo()
135+
if "non existent exception list" in workaround_error_types:
136+
click.echo(
137+
f"Missing exception list errors detected for {len(workaround_errors)} rules. "
138+
"Try re-importing using the following command and rule IDs:\n"
139+
)
140+
click.echo("python -m detection_rules kibana import-rules -o ", nl=False)
141+
click.echo(" ".join(f"-id {rule_id}" for rule_id in workaround_errors))
142+
click.echo()
143+
if "connector still being built" in workaround_error_types:
144+
click.echo(
145+
f"Connector still being built errors detected for {len(workaround_errors)} rules. "
146+
"Please try re-importing the rules again."
147+
)
148+
click.echo()
132149

133150
def _process_imported_items(imported_items_list, item_type_description, item_key):
134151
"""Displays appropriately formatted success message that all items imported successfully."""
@@ -178,20 +195,38 @@ def _process_imported_items(imported_items_list, item_type_description, item_key
178195
@click.option("--exceptions-directory", "-ed", required=False, type=Path, help="Directory to export exceptions to")
179196
@click.option("--default-author", "-da", type=str, required=False, help="Default author for rules missing one")
180197
@click.option("--rule-id", "-r", multiple=True, help="Optional Rule IDs to restrict export to")
198+
@click.option("--rule-name", "-rn", required=False, help="Optional Rule name to restrict export to "
199+
"(KQL, case-insensitive, supports wildcards)")
181200
@click.option("--export-action-connectors", "-ac", is_flag=True, help="Include action connectors in export")
182201
@click.option("--export-exceptions", "-e", is_flag=True, help="Include exceptions in export")
183202
@click.option("--skip-errors", "-s", is_flag=True, help="Skip errors when exporting rules")
184203
@click.option("--strip-version", "-sv", is_flag=True, help="Strip the version fields from all rules")
204+
@click.option("--no-tactic-filename", "-nt", is_flag=True,
205+
help="Exclude tactic prefix in exported filenames for rules. "
206+
"Use same flag for import-rules to prevent warnings and disable its unit test.")
207+
@click.option("--local-creation-date", "-lc", is_flag=True, help="Preserve the local creation date of the rule")
208+
@click.option("--local-updated-date", "-lu", is_flag=True, help="Preserve the local updated date of the rule")
185209
@click.pass_context
186210
def kibana_export_rules(ctx: click.Context, directory: Path, action_connectors_directory: Optional[Path],
187211
exceptions_directory: Optional[Path], default_author: str,
188-
rule_id: Optional[Iterable[str]] = None, export_action_connectors: bool = False,
189-
export_exceptions: bool = False, skip_errors: bool = False, strip_version: bool = False
190-
) -> List[TOMLRule]:
212+
rule_id: Optional[Iterable[str]] = None, rule_name: Optional[str] = None,
213+
export_action_connectors: bool = False,
214+
export_exceptions: bool = False, skip_errors: bool = False, strip_version: bool = False,
215+
no_tactic_filename: bool = False, local_creation_date: bool = False,
216+
local_updated_date: bool = False) -> List[TOMLRule]:
191217
"""Export custom rules from Kibana."""
192218
kibana = ctx.obj["kibana"]
193219
kibana_include_details = export_exceptions or export_action_connectors
220+
221+
# Only allow one of rule_id or rule_name
222+
if rule_name and rule_id:
223+
raise click.UsageError("Cannot use --rule-id and --rule-name together. Please choose one.")
224+
194225
with kibana:
226+
# Look up rule IDs by name if --rule-name was provided
227+
if rule_name:
228+
found = RuleResource.find(filter=f"alert.attributes.name:{rule_name}")
229+
rule_id = [r["rule_id"] for r in found]
195230
results = RuleResource.export_rules(list(rule_id), exclude_export_details=not kibana_include_details)
196231

197232
# Handle Exceptions Directory Location
@@ -215,6 +250,8 @@ def kibana_export_rules(ctx: click.Context, directory: Path, action_connectors_d
215250
return []
216251

217252
rules_results = results
253+
action_connector_results = []
254+
exception_results = []
218255
if kibana_include_details:
219256
# Assign counts to variables
220257
rules_count = results[-1]["exported_rules_count"]
@@ -242,22 +279,27 @@ def kibana_export_rules(ctx: click.Context, directory: Path, action_connectors_d
242279
rule_resource["author"] = rule_resource.get("author") or default_author or [rule_resource.get("created_by")]
243280
if isinstance(rule_resource["author"], str):
244281
rule_resource["author"] = [rule_resource["author"]]
245-
# Inherit maturity from the rule already exists
246-
maturity = "development"
282+
# Inherit maturity and optionally local dates from the rule if it already exists
283+
params = {
284+
"rule": rule_resource,
285+
"maturity": "development",
286+
}
247287
threat = rule_resource.get("threat")
248288
first_tactic = threat[0].get("tactic").get("name") if threat else ""
249-
rule_name = rulename_to_filename(rule_resource.get("name"), tactic_name=first_tactic)
250-
# check if directory / f"{rule_name}" exists
251-
if (directory / f"{rule_name}").exists():
252-
rules = RuleCollection()
253-
rules.load_file(directory / f"{rule_name}")
254-
if rules:
255-
maturity = rules.rules[0].contents.metadata.maturity
256-
257-
contents = TOMLRuleContents.from_rule_resource(
258-
rule_resource, maturity=maturity
289+
# Check if flag or config is set to not include tactic in the filename
290+
no_tactic_filename = no_tactic_filename or RULES_CONFIG.no_tactic_filename
291+
# Check if the flag is set to not include tactic in the filename
292+
tactic_name = first_tactic if not no_tactic_filename else None
293+
rule_name = rulename_to_filename(rule_resource.get("name"), tactic_name=tactic_name)
294+
295+
save_path = directory / f"{rule_name}"
296+
params.update(
297+
update_metadata_from_file(
298+
save_path, {"creation_date": local_creation_date, "updated_date": local_updated_date}
299+
)
259300
)
260-
rule = TOMLRule(contents=contents, path=directory / f"{rule_name}")
301+
contents = TOMLRuleContents.from_rule_resource(**params)
302+
rule = TOMLRule(contents=contents, path=save_path)
261303
except Exception as e:
262304
if skip_errors:
263305
print(f'- skipping {rule_resource.get("name")} - {type(e).__name__}')

detection_rules/main.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
)
3333
from .rule import TOMLRule, TOMLRuleContents, QueryRuleData
3434
from .rule_formatter import toml_write
35-
from .rule_loader import RuleCollection
35+
from .rule_loader import RuleCollection, update_metadata_from_file
3636
from .schemas import all_versions, definitions, get_incompatible_fields, get_schema_file
3737
from .utils import Ndjson, get_path, get_etc_path, clear_caches, load_dump, load_rule_contents, rulename_to_filename
3838

@@ -128,10 +128,13 @@ def generate_rules_index(ctx: click.Context, query, overwrite, save_files=True):
128128
@click.option("--skip-errors", "-ske", is_flag=True, help="Skip rule import errors")
129129
@click.option("--default-author", "-da", type=str, required=False, help="Default author for rules missing one")
130130
@click.option("--strip-none-values", "-snv", is_flag=True, help="Strip None values from the rule")
131+
@click.option("--local-creation-date", "-lc", is_flag=True, help="Preserve the local creation date of the rule")
132+
@click.option("--local-updated-date", "-lu", is_flag=True, help="Preserve the local updated date of the rule")
131133
def import_rules_into_repo(input_file: click.Path, required_only: bool, action_connector_import: bool,
132134
exceptions_import: bool, directory: click.Path, save_directory: click.Path,
133135
action_connectors_directory: click.Path, exceptions_directory: click.Path,
134-
skip_errors: bool, default_author: str, strip_none_values: bool):
136+
skip_errors: bool, default_author: str, strip_none_values: bool, local_creation_date: bool,
137+
local_updated_date: bool):
135138
"""Import rules from json, toml, or yaml files containing Kibana exported rule(s)."""
136139
errors = []
137140
rule_files = glob.glob(os.path.join(directory, "**", "*.*"), recursive=True) if directory else []
@@ -179,6 +182,12 @@ def import_rules_into_repo(input_file: click.Path, required_only: bool, action_c
179182
if isinstance(contents["author"], str):
180183
contents["author"] = [contents["author"]]
181184

185+
contents.update(
186+
update_metadata_from_file(
187+
Path(rule_path), {"creation_date": local_creation_date, "updated_date": local_updated_date}
188+
)
189+
)
190+
182191
output = rule_prompt(
183192
rule_path,
184193
required_only=required_only,

detection_rules/rule_loader.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@
1818
from . import utils
1919
from .config import parse_rules_config
2020
from .rule import (
21-
DeprecatedRule, DeprecatedRuleContents, DictRule, TOMLRule, TOMLRuleContents
21+
DeprecatedRule, DeprecatedRuleContents, DictRule, TOMLRule,
22+
TOMLRuleContents
2223
)
2324
from .schemas import definitions
2425
from .utils import cached, get_path
@@ -116,6 +117,20 @@ def load_locks_from_tag(remote: str, tag: str, version_lock: str = 'detection_ru
116117
return commit_hash, version, deprecated
117118

118119

120+
def update_metadata_from_file(rule_path: Path, fields_to_update: dict) -> dict:
121+
"""Update metadata fields for a rule with local contents."""
122+
contents = {}
123+
if not rule_path.exists():
124+
return contents
125+
local_metadata = RuleCollection().load_file(rule_path).contents.metadata.to_dict()
126+
if local_metadata:
127+
contents["maturity"] = local_metadata.get("maturity", "development")
128+
for field_name, should_update in fields_to_update.items():
129+
if should_update and field_name in local_metadata:
130+
contents[field_name] = local_metadata[field_name]
131+
return contents
132+
133+
119134
@dataclass
120135
class BaseCollection:
121136
"""Base class for collections."""

0 commit comments

Comments
 (0)