Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
d174120
Initial v19 support
eric-forte-elastic Jun 29, 2026
5a17da7
patch bump
eric-forte-elastic Jul 1, 2026
804064e
Merge branch 'main' into v19_support
eric-forte-elastic Jul 1, 2026
d4eea88
format
eric-forte-elastic Jul 1, 2026
7609bbd
Merge branch 'v19_support' of https://github.com/elastic/detection-ru…
eric-forte-elastic Jul 1, 2026
114e842
linting
eric-forte-elastic Jul 1, 2026
e79c2d3
ruff
eric-forte-elastic Jul 1, 2026
532fb33
Add unit tests and fix typo in MITE keys
eric-forte-elastic Jul 1, 2026
12d65cb
ruff
eric-forte-elastic Jul 1, 2026
33a0f61
format
eric-forte-elastic Jul 1, 2026
93f4dc2
Unit tests for rule version calculation
eric-forte-elastic Jul 1, 2026
2fbfa1a
Update for filename handling
eric-forte-elastic Jul 1, 2026
8dd263a
format
eric-forte-elastic Jul 1, 2026
d371967
exclude reorder from hash calc
eric-forte-elastic Jul 7, 2026
68fb43d
Merge branch 'main' into v19_support
eric-forte-elastic Jul 14, 2026
368fbd1
patch bump
eric-forte-elastic Jul 14, 2026
134be8d
cleanup
eric-forte-elastic Jul 14, 2026
dafddf6
Add 9.5 gate
eric-forte-elastic Jul 14, 2026
a143799
remove optimization
eric-forte-elastic Jul 14, 2026
cee66bf
Remove unit tests that are no longer needed
eric-forte-elastic Jul 14, 2026
dcb6d48
Add doc strings
eric-forte-elastic Jul 14, 2026
477ed2b
Use STIX Info
eric-forte-elastic Jul 15, 2026
8dd699b
linting
eric-forte-elastic Jul 15, 2026
93e2fa9
Fix tactic shift to accuracy first approach
eric-forte-elastic Jul 15, 2026
3c0df56
fix nits
eric-forte-elastic Jul 15, 2026
0840ba6
add build time auto generation
eric-forte-elastic Jul 15, 2026
64f46bb
restrict auto conversion to toml updates
eric-forte-elastic Jul 15, 2026
b2c4e8c
formatting
eric-forte-elastic Jul 15, 2026
87dae90
linting
eric-forte-elastic Jul 15, 2026
0847416
format update
eric-forte-elastic Jul 16, 2026
373122a
Cleanup
eric-forte-elastic Jul 16, 2026
ed2c7cf
wording updates
eric-forte-elastic Jul 16, 2026
c6aee8f
Merge branch 'main' into v19_support
eric-forte-elastic Jul 16, 2026
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
22 changes: 22 additions & 0 deletions CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,28 @@ rules. This is based on the hash of the rule in the following format:

As a result, all cases where rules are shown or converted to JSON are not just simple conversions from TOML.


## Multi-version threat mappings (MITRE ATT&CK v18 / v19)

Rules can carry more than one ATT&CK mapping at once. The `threat` field holds the baseline mapping
(MITRE ATT&CK v18) that ships to Kibana, while an optional `threat_mappings` field holds additional
version-tagged mappings (e.g. v19). At build time exactly one mapping is emitted as the API `threat`,
selected automatically by stack version (≤ 9.4 → v18, ≥ 9.5 → v19) or overridden explicitly via
the `threat_mapping_framework` / `threat_mapping_version` config keys or the
`DR_THREAT_MAPPING_FRAMEWORK` / `DR_THREAT_MAPPING_VERSION` environment variables;
`threat_mappings` is always stripped from the shipped artifact.

Generate a target-version mapping from a rule's existing mapping with
`dev attack convert-threat-mappings` (accuracy-first: anything not present in the mapping config is
dropped, never guessed), and scaffold a mapping config with `dev attack scaffold-version-map`. The
feature is DaC-aware. See [docs-dev/multi-version-threat-mappings.md](docs-dev/multi-version-threat-mappings.md)
for the full guide.

```bash
# preview v18 -> v19 conversion without writing
python -m detection_rules dev attack convert-threat-mappings -t 19 --dry-run
```

## Debugging

Most of the CLI errors will print a concise, user friendly error. To enable debug mode and see full error stacktraces,
Expand Down
357 changes: 352 additions & 5 deletions detection_rules/attack.py

Large diffs are not rendered by default.

16 changes: 10 additions & 6 deletions detection_rules/cli_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,17 +215,21 @@ def get_collection(*args: Any, **kwargs: Any) -> Any:

# Warn that if the path does not match the expected path, it will be saved to the expected path
for rule in rules:
threat = rule.contents.data.get("threat")
first_tactic = threat[0].tactic.name if threat else ""
# 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)
# Accept a filename built from the baseline tactic or any versioned (e.g. v19) threat_mappings
# tactic, since a rule may still use its older tactic name in the filename until it's renamed.
tactic_names = [] if no_tactic_filename else rule.contents.data.get_primary_tactic_names()
candidate_names = [rulename_to_filename(rule.contents.data.name, tactic_name=t) for t in tactic_names]
if not candidate_names:
candidate_names = [rulename_to_filename(rule.contents.data.name, tactic_name=None)]
rule_name = candidate_names[0]
if not rule.path:
click.secho(f"WARNING: Rule path for rule not found: {rule_name}", fg="yellow")
elif rule.path.name != rule_name:
elif rule.path.name not in candidate_names:
expected = rule_name if len(candidate_names) == 1 else f"one of {candidate_names}"
click.secho(
f"WARNING: Rule path does not match required path: {rule.path.name} != {rule_name}", fg="yellow"
f"WARNING: Rule path does not match required path: {rule.path.name} != {expected}", fg="yellow"
)

kwargs["rules"] = rules
Expand Down
42 changes: 42 additions & 0 deletions detection_rules/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@
ROOT_DIR = Path(__file__).parent.parent
CUSTOM_RULES_DIR = os.getenv("CUSTOM_RULES_DIR", None)

# Output threat-mapping selection (which framework/version is emitted as the API `threat`).
# Defaults to MITRE ATT&CK v18 for stacks <= 9.4; auto-promotes to v19 for stacks >= 9.5.
# Either may be overridden via _config.yaml (`threat_mapping_framework` /
# `threat_mapping_version`) or the environment variables below, with the environment taking
# precedence (an explicit env var suppresses auto-promotion).
DEFAULT_THREAT_MAPPING_FRAMEWORK = "MITRE ATT&CK"
DEFAULT_THREAT_MAPPING_VERSION = "18"
THREAT_MAPPING_FRAMEWORK_ENV = "DR_THREAT_MAPPING_FRAMEWORK"
THREAT_MAPPING_VERSION_ENV = "DR_THREAT_MAPPING_VERSION"


@dataclass
class UnitTest:
Expand Down Expand Up @@ -208,7 +218,12 @@ class RulesConfig:

action_dir: Path | None = None
action_connector_dir: Path | None = None
attack_version_maps_dir: Path | None = None
auto_gen_schema_file: Path | None = None
threat_mapping_framework: str = DEFAULT_THREAT_MAPPING_FRAMEWORK
threat_mapping_version: str = DEFAULT_THREAT_MAPPING_VERSION
# True when the version came from an explicit config key or env var (suppresses auto-promotion)
threat_mapping_version_pinned: bool = False
bbr_rules_dirs: list[Path] = field(default_factory=list) # type: ignore[reportUnknownVariableType]
bypass_version_lock: bool = False
exception_dir: Path | None = None
Expand Down Expand Up @@ -355,6 +370,33 @@ def parse_rules_config(path: Path | None = None) -> RulesConfig: # noqa: PLR091
# no_tactic_filename
contents["no_tactic_filename"] = loaded.get("no_tactic_filename", False)

# attack version mapping configs directory (used by `dev attack convert-threat-mappings`).
# Defaults to the stock etc/attack-version-maps directory; custom rules repos may point this at
# their own directory of per-pair mapping configs.
attack_maps_dir = loaded.get("attack_version_maps_dir")
if attack_maps_dir:
contents["attack_version_maps_dir"] = base_dir.joinpath(attack_maps_dir).resolve()
else:
default_maps_dir = Path(get_etc_path(["attack-version-maps"]))
contents["attack_version_maps_dir"] = default_maps_dir if default_maps_dir.exists() else None

# threat-mapping output selection (which framework/version is emitted as the API `threat`).
# Environment variables take precedence over the config file values. A version set explicitly
# by either source is recorded as pinned, which suppresses stack-based auto-promotion.
contents["threat_mapping_framework"] = os.getenv(
THREAT_MAPPING_FRAMEWORK_ENV,
loaded.get("threat_mapping_framework", DEFAULT_THREAT_MAPPING_FRAMEWORK),
)
contents["threat_mapping_version"] = str(
os.getenv(
THREAT_MAPPING_VERSION_ENV,
loaded.get("threat_mapping_version", DEFAULT_THREAT_MAPPING_VERSION),
)
)
contents["threat_mapping_version_pinned"] = (
os.getenv(THREAT_MAPPING_VERSION_ENV) is not None or "threat_mapping_version" in loaded
)

# return the config
try:
rules_config = RulesConfig(test_config=test_config, **contents) # type: ignore[reportArgumentType]
Expand Down
Loading
Loading