Multi-version threat mappings (MITRE ATT&CK v18 / v19 / …)
July 17, 2026 · View on GitHub
A rule's MITRE ATT&CK mapping lives in its threat field, which is shipped verbatim to Kibana. To
support more than one ATT&CK version (or, in future, other frameworks) at once, a rule can also carry
additional, version-tagged mappings in a threat_mappings field. These are repo-side only — the
build selects exactly one mapping to emit as the API threat and strips threat_mappings from the
shipped artifact.
This makes it possible to author and review a v19 mapping today, while continuing to ship v18, and to flip the shipped version later with a single config/env change.
Rule schema
threat continues to hold the baseline mapping (MITRE ATT&CK v18). Additional versions are added
as threat_mappings entries, each keeping the same structure as threat plus a framework and a
version:
[[rule.threat]] # baseline (v18) — shipped by default, consumed by Kibana
framework = "MITRE ATT&CK"
[[rule.threat.technique]]
id = "T1078"
name = "Valid Accounts"
reference = "https://attack.mitre.org/techniques/T1078/"
[rule.threat.tactic]
id = "TA0001"
name = "Initial Access"
reference = "https://attack.mitre.org/tactics/TA0001/"
[[rule.threat_mappings]] # additional, version-tagged mapping (repo-side only)
framework = "MITRE ATT&CK"
version = "19"
[[rule.threat_mappings.threat]]
framework = "MITRE ATT&CK"
# ...same tactic/technique/subtechnique structure as `threat`...
Validation enforces unique (framework, version) blocks and that each inner threat entry's framework
matches its wrapper.
Selecting which version is shipped
At build time (TOMLRuleContents.to_api_format), the configured framework/version is emitted as
threat:
- Stack ≤ 9.4 — v18 (the baseline
threatfield) is emitted unchanged. - Stack ≥ 9.5 — v19 is auto-promoted: an explicit
threat_mappingsblock wins when present; otherwise the baseline is auto-converted via the version map + STIX data. Tactic tags are rewritten to match the emitted mapping. - Setting
DR_THREAT_MAPPING_VERSION(orthreat_mapping_versionin_config.yaml) explicitly overrides auto-promotion for both directions. threat_mappingsis always stripped from the API output.
Set the output version with either config keys or environment variables (env takes precedence):
# _config.yaml
threat_mapping_framework: "MITRE ATT&CK"
threat_mapping_version: "19"
DR_THREAT_MAPPING_FRAMEWORK="MITRE ATT&CK" DR_THREAT_MAPPING_VERSION=19 python -m detection_rules ...
Because the baseline hash path strips threat_mappings and skips emit transforms, adding
threat_mappings (or relying on build-time auto-convert) does not change baseline rule
hashes. Package stacks that apply transforms record a separate stack_emit epoch in
version.lock.json so 9.5+ can version independently of ≤9.4.
stack_emit (version lock)
The shared, backported version.lock.json may include optional emit epochs per rule:
{
"sha256": "<baseline hash>",
"version": 12,
"stack_emit": {
"9.5": {
"sha256": "<hash of transformed package payload>",
"version": 13,
"transforms": ["mitre_attack_v19", "related_integrations_gte"]
}
}
}
- Keys are emit epochs (newest applicable transform
min_stack), not every package minor. - Building 9.6 with the same transforms reuses
stack_emit["9.5"]— no new lock row. - ≤9.4 packages ship the baseline
version; 9.5+ ships the inherited emitversion. - Baseline dirty checks never compare against emit hashes (avoids lock oscillation across branches).
Adding a future emit transform
All stack-conditional payload changes go through detection_rules/stack_emit.py:
- Add a
MIN_STACKconstant and an_apply_<name>(obj, stack, context)function. - Append an
EmitTransform(id=..., min_stack=..., affects=..., apply=...)toEMIT_TRANSFORMS. - Re-lock on the package branch that ships that stack (
manage_versions).
rule.py calls apply_emit_transforms once after build-time fields (e.g. related_integrations) are attached. Do not special-case new transforms in rule.py. Extend EmitContext only when a transform needs repo-only data already stripped from the API dict (like threat_mappings).
Generating a target-version mapping
dev attack convert-threat-mappings does a first-pass generation of a target-version mapping from a
rule's source mapping. It is accuracy-first — a mapping is never guessed. Each source
tactic/technique/subtechnique id is resolved in order:
- An explicit config entry always wins — including an explicit
null, which drops the id. - If the id is absent from the config and the config sets
auto_derive_missing: true(the shipped default for v18 -> v19), the destination is auto-derived from the target version's ATT&CK STIX data: ids that still exist in the target version keep their id with the target-version name and reference, while ids that are revoked, deprecated, or gone are dropped. Techniques that moved to a different tactic are followed to their new target-version tactic(s) (the technique is authoritative). - If the id is absent and
auto_derive_missingis false/unset, it is dropped rather than guessed.
Every dropped and migrated item is reported for review. The command also adds a "Tactic: <name>"
tag for each new target-version tactic while preserving the existing baseline tags.
# preview the v18 -> v19 conversion for all rules
python -m detection_rules dev attack convert-threat-mappings -t 19 --dry-run
# write the v19 blocks (scope with -d/--directory, -f/--rule-file, -id/--rule-id)
python -m detection_rules dev attack convert-threat-mappings -t 19 -d rules/
# use an explicit mapping config instead of the configured directory
python -m detection_rules dev attack convert-threat-mappings -t 19 --config path/to/map.yaml
Mapping config format
Conversion is driven by a directory of per-pair config files (default
detection_rules/etc/attack-version-maps/). Each file declares one (framework, source_version -> target_version) triple. Explicit entries carry the destination id, name, and reference for a
source id; a source id mapped to null is always dropped.
How absent ids are treated depends on the auto_derive_missing flag:
auto_derive_missing: false(the default) — the file is self-contained: any source id absent from the file is dropped. Usescaffold-version-map(below) to generate the full identity baseline to curate.auto_derive_missing: true— the file only needs to list exceptions: absent ids are auto-resolved from the target version's ATT&CK STIX data (requires the matchingattack-v<target>*.json.gzfile indetection_rules/etc/), and ids that are revoked, deprecated, or missing in the target version are dropped. The shippedattack_v18_to_v19.yamluses this mode, so it stays small and only ever needs entries for overrides.
A fully-explicit (self-contained) config looks like:
framework: "MITRE ATT&CK"
source_version: "18"
target_version: "19"
tactics:
TA0001: { id: TA0001, name: Initial Access, reference: "https://attack.mitre.org/tactics/TA0001/" }
techniques:
T1078: { id: T1078, name: "Valid Accounts", reference: "https://attack.mitre.org/techniques/T1078/" }
T1100: null # explicitly dropped (deprecated / no confident target)
subtechniques:
T1078.004: { id: T1078.004, name: "Cloud Accounts", reference: "https://attack.mitre.org/techniques/T1078/004/" }
while an exceptions-only config (the shipped v18 -> v19 shape) looks like:
framework: "MITRE ATT&CK"
source_version: "18"
target_version: "19"
auto_derive_missing: true
# only explicit overrides/drops go here; everything else resolves from v19 STIX data
Adding a new destination (e.g. v19 -> v20, or another framework) is just a new file declaring its own
triple. Multiple destinations coexist as separate threat_mappings blocks per rule.
Scaffolding a config
For fully-explicit configs (auto_derive_missing: false), dev attack scaffold-version-map generates
an identity baseline: every non-revoked/non-deprecated source-version id maps to itself, using the
target version's name/reference where the id still exists in the target ATT&CK data (and the source
name otherwise). Review and curate it against the target version's real changes (renames,
deprecations, splits) before relying on it:
python -m detection_rules dev attack scaffold-version-map -t 19 -o detection_rules/etc/attack-version-maps/attack_v18_to_v19.yaml
Detections-as-Code (custom rules)
All of the above is DaC-aware. With CUSTOM_RULES_DIR set, the commands operate on the custom rule
directories automatically. Point a custom rules repo at its own mapping configs via the custom
_config.yaml:
attack_version_maps_dir: etc/attack-version-maps # relative to the custom _config.yaml
or pass --config explicitly. The output-selection keys/env vars work the same way for custom rules.