Four-part fix for the collection bottleneck that was blocking the dev loop: 1. Lazy mitreattack.stix20 import in attack_stix.py — deferred to first _load() call (TYPE_CHECKING guard at top level) 2. Lazy misp_stix_converter import in both MISP export routers — moved from module level into the route handler body 3. Lazy attack_catalog / attack_stix in ttp.py repo mixin — thin wrapper functions so the import chain never fires at module load time 4. tests/api/conftest.py — `from decnet.web.api import app` moved inside the `client()` fixture; `pytest_ignore_collect` broadened to skip all test_schemathesis*.py variants (not just test_schemathesis.py), which were launching a subprocess server at module-import time 5. pyproject.toml — `norecursedirs` for tests/live, tests/stress, tests/service_testing, tests/docker, tests/perf so these directories are never entered; `-m` filter removed from addopts (now redundant); `--dist loadscope` → `--dist load` to unblock workers immediately 6. behave_core / behave_shell rename — BEHAVE packages dropped the `decnet_` prefix; reinstalled editable installs and updated all 14 import sites across profiler, ttp, bus, and correlation modules
576 lines
19 KiB
Python
576 lines
19 KiB
Python
"""STIX 2.1 backed MITRE ATT&CK lookups.
|
|
|
|
Replaces the hand-maintained technique-name dict that used to live in
|
|
``decnet/ttp/attack_catalog.py``. Single source of truth is the
|
|
official ``enterprise-attack-<version>.json`` STIX bundle published by
|
|
MITRE; consumers (rule engine, intel lifters, web router, frontend
|
|
rollups) call the small public API below instead of reading raw STIX.
|
|
|
|
Bundle resolution order
|
|
-----------------------
|
|
|
|
1. ``DECNET_ATTACK_BUNDLE`` env var (absolute path to a JSON file).
|
|
2. ``<cache_dir>/enterprise-attack-<version>.json`` where ``<cache_dir>``
|
|
defaults to ``~/.cache/decnet/attack`` and is overridable with
|
|
``DECNET_ATTACK_CACHE_DIR``.
|
|
3. Fetch from :data:`decnet.ttp.attack_version.ATTACK_BUNDLE_URL` into
|
|
the cache dir.
|
|
|
|
In every case the loaded bytes are verified against
|
|
:data:`decnet.ttp.attack_version.ATTACK_BUNDLE_SHA256` *before* the
|
|
bundle is parsed. A mismatch raises :class:`AttackBundleError` and the
|
|
loader refuses to serve queries. This is intentional — drift between
|
|
DECNET's expected ATT&CK version and what the operator (or a tampered
|
|
mirror) actually placed on disk would silently mistag thousands of
|
|
events.
|
|
|
|
Lazy-loading: the bundle (~50 MB) is parsed on first call to any
|
|
public function, never at import time. Tests that don't touch ATT&CK
|
|
should never pay the cost.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import logging
|
|
import os
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
from threading import Lock
|
|
from typing import TYPE_CHECKING, Final
|
|
|
|
if TYPE_CHECKING:
|
|
from mitreattack.stix20 import MitreAttackData
|
|
|
|
from decnet.ttp.attack_version import (
|
|
ATTACK_BUNDLE_SHA256,
|
|
ATTACK_BUNDLE_URL,
|
|
ATTACK_BUNDLE_VERSION,
|
|
ATTACK_LICENSE_FILENAME,
|
|
ATTACK_LICENSE_SHA256,
|
|
ATTACK_LICENSE_URL,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_ENV_BUNDLE_PATH: Final[str] = "DECNET_ATTACK_BUNDLE"
|
|
_ENV_LICENSE_PATH: Final[str] = "DECNET_ATTACK_LICENSE"
|
|
_ENV_CACHE_DIR: Final[str] = "DECNET_ATTACK_CACHE_DIR"
|
|
_DEFAULT_CACHE_DIR: Final[Path] = Path.home() / ".cache" / "decnet" / "attack"
|
|
|
|
_data_lock = Lock()
|
|
_data: MitreAttackData | None = None
|
|
_loaded_path: Path | None = None
|
|
|
|
|
|
class AttackBundleError(RuntimeError):
|
|
"""Raised when the ATT&CK STIX bundle cannot be loaded or verified."""
|
|
|
|
|
|
def _cache_dir() -> Path:
|
|
override = os.environ.get(_ENV_CACHE_DIR)
|
|
return Path(override) if override else _DEFAULT_CACHE_DIR
|
|
|
|
|
|
def _expected_cache_path() -> Path:
|
|
return _cache_dir() / f"enterprise-attack-{ATTACK_BUNDLE_VERSION}.json"
|
|
|
|
|
|
def _expected_license_path() -> Path:
|
|
return _cache_dir() / ATTACK_LICENSE_FILENAME
|
|
|
|
|
|
def _sha256(path: Path) -> str:
|
|
h = hashlib.sha256()
|
|
with path.open("rb") as f:
|
|
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
def _verify_sha256(path: Path) -> None:
|
|
actual = _sha256(path)
|
|
if actual != ATTACK_BUNDLE_SHA256:
|
|
raise AttackBundleError(
|
|
f"ATT&CK bundle at {path} sha256={actual} does not match "
|
|
f"pinned {ATTACK_BUNDLE_SHA256} (version {ATTACK_BUNDLE_VERSION}). "
|
|
"Refusing to load — re-fetch or update attack_version.py."
|
|
)
|
|
|
|
|
|
def _download(url: str, target: Path, *, label: str) -> None:
|
|
import requests
|
|
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
logger.info("Fetching %s %s -> %s", label, url, target)
|
|
tmp = target.with_suffix(target.suffix + ".part")
|
|
try:
|
|
resp = requests.get(url, timeout=60, stream=True)
|
|
resp.raise_for_status()
|
|
with tmp.open("wb") as f:
|
|
for chunk in resp.iter_content(1 << 20):
|
|
if chunk:
|
|
f.write(chunk)
|
|
tmp.replace(target)
|
|
except Exception:
|
|
tmp.unlink(missing_ok=True)
|
|
raise
|
|
|
|
|
|
def _fetch_bundle(target: Path) -> None:
|
|
_download(ATTACK_BUNDLE_URL, target, label="ATT&CK bundle")
|
|
|
|
|
|
def _fetch_license(target: Path) -> None:
|
|
"""Fetch MITRE's LICENSE.txt. Hash mismatch is logged + re-fetched, never fail-closed.
|
|
|
|
The ATT&CK bundle is fail-closed because a tampered bundle would
|
|
silently mistag thousands of events. The license is required by
|
|
the Terms of Use *to be present*; an upstream formatting tweak
|
|
isn't a security event, so we resync rather than refuse to boot.
|
|
"""
|
|
_download(ATTACK_LICENSE_URL, target, label="ATT&CK license")
|
|
actual = _sha256(target)
|
|
if actual != ATTACK_LICENSE_SHA256:
|
|
logger.warning(
|
|
"ATT&CK LICENSE.txt sha256=%s differs from pinned %s — "
|
|
"MITRE may have updated the license text. Update "
|
|
"ATTACK_LICENSE_SHA256 in attack_version.py if intentional.",
|
|
actual,
|
|
ATTACK_LICENSE_SHA256,
|
|
)
|
|
|
|
|
|
def _ensure_license(cache_dir: Path) -> Path:
|
|
"""Return the path to a present LICENSE.txt, fetching if missing.
|
|
|
|
Honors ``DECNET_ATTACK_LICENSE`` for operator-controlled overrides
|
|
(mirrors ``DECNET_ATTACK_BUNDLE`` for offline / air-gapped installs).
|
|
Refuses to return without a license file on disk — this is the
|
|
compliance ratchet enforcing MITRE's Terms of Use.
|
|
"""
|
|
override = os.environ.get(_ENV_LICENSE_PATH)
|
|
if override:
|
|
path = Path(override)
|
|
if not path.is_file():
|
|
raise AttackBundleError(
|
|
f"{_ENV_LICENSE_PATH}={override} does not point to a file. "
|
|
"MITRE's ATT&CK Terms of Use require the license to be "
|
|
"present alongside any cached copy of ATT&CK data."
|
|
)
|
|
return path
|
|
|
|
license_path = cache_dir / ATTACK_LICENSE_FILENAME
|
|
if not license_path.is_file():
|
|
_fetch_license(license_path)
|
|
if not license_path.is_file():
|
|
raise AttackBundleError(
|
|
f"ATT&CK license missing at {license_path}. MITRE's ATT&CK "
|
|
"Terms of Use require the license to be present alongside "
|
|
"any cached copy of ATT&CK data. Run "
|
|
"`python -m decnet.ttp.attack_stix fetch` or set "
|
|
f"{_ENV_LICENSE_PATH} to an existing LICENSE.txt."
|
|
)
|
|
return license_path
|
|
|
|
|
|
def resolve_bundle_path() -> Path:
|
|
"""Return the verified bundle path, fetching the bundle and LICENSE if necessary.
|
|
|
|
Both files must be present on disk before this returns. When
|
|
``DECNET_ATTACK_BUNDLE`` overrides the bundle path the license
|
|
must live next to that bundle, or be reachable via
|
|
``DECNET_ATTACK_LICENSE``. The cache dir is checked first so
|
|
operator-supplied bundles can still rely on the auto-cached
|
|
license.
|
|
"""
|
|
override = os.environ.get(_ENV_BUNDLE_PATH)
|
|
if override:
|
|
path = Path(override)
|
|
if not path.is_file():
|
|
raise AttackBundleError(
|
|
f"{_ENV_BUNDLE_PATH}={override} does not point to a file"
|
|
)
|
|
_verify_sha256(path)
|
|
# License must accompany an override bundle. Check next to the
|
|
# bundle first, then DECNET_ATTACK_LICENSE, then the cache dir
|
|
# as a last resort.
|
|
sibling = path.parent / ATTACK_LICENSE_FILENAME
|
|
if sibling.is_file() or os.environ.get(_ENV_LICENSE_PATH):
|
|
_ensure_license(path.parent)
|
|
else:
|
|
_ensure_license(_cache_dir())
|
|
return path
|
|
|
|
cached = _expected_cache_path()
|
|
if not cached.is_file():
|
|
_fetch_bundle(cached)
|
|
_verify_sha256(cached)
|
|
_ensure_license(_cache_dir())
|
|
return cached
|
|
|
|
|
|
def loaded_license_path() -> Path | None:
|
|
"""Return the path to the on-disk LICENSE.txt this process is operating under.
|
|
|
|
Resolution mirrors :func:`_ensure_license` but is read-only — it
|
|
never fetches. Useful for the ``license`` CLI subcommand and for
|
|
operators auditing what license text they accepted.
|
|
"""
|
|
override = os.environ.get(_ENV_LICENSE_PATH)
|
|
if override:
|
|
p = Path(override)
|
|
return p if p.is_file() else None
|
|
bundle_override = os.environ.get(_ENV_BUNDLE_PATH)
|
|
if bundle_override:
|
|
sibling = Path(bundle_override).parent / ATTACK_LICENSE_FILENAME
|
|
if sibling.is_file():
|
|
return sibling
|
|
cached = _cache_dir() / ATTACK_LICENSE_FILENAME
|
|
return cached if cached.is_file() else None
|
|
|
|
|
|
def _load() -> MitreAttackData:
|
|
from mitreattack.stix20 import MitreAttackData # heavy — lazy on first call
|
|
global _data, _loaded_path
|
|
with _data_lock:
|
|
if _data is not None:
|
|
return _data
|
|
path = resolve_bundle_path()
|
|
_data = MitreAttackData(str(path))
|
|
_loaded_path = path
|
|
logger.info(
|
|
"Loaded ATT&CK bundle version=%s path=%s",
|
|
ATTACK_BUNDLE_VERSION,
|
|
path,
|
|
)
|
|
return _data
|
|
|
|
|
|
def loaded_bundle_path() -> Path | None:
|
|
"""Return the path the bundle was loaded from, or ``None`` if not loaded yet."""
|
|
return _loaded_path
|
|
|
|
|
|
@lru_cache(maxsize=4096)
|
|
def _attack_pattern_by_id(technique_id: str) -> dict | None:
|
|
obj = _load().get_object_by_attack_id(technique_id, "attack-pattern")
|
|
if obj is None:
|
|
return None
|
|
return dict(obj)
|
|
|
|
|
|
@lru_cache(maxsize=64)
|
|
def _tactic_by_id(tactic_id: str) -> dict | None:
|
|
obj = _load().get_object_by_attack_id(tactic_id, "x-mitre-tactic")
|
|
if obj is None:
|
|
return None
|
|
return dict(obj)
|
|
|
|
|
|
@lru_cache(maxsize=64)
|
|
def _tactic_by_short_name(short_name: str) -> dict | None:
|
|
for obj in _load().get_tactics():
|
|
if obj.get("x_mitre_shortname") == short_name:
|
|
return dict(obj)
|
|
return None
|
|
|
|
|
|
def technique_name(technique_id: str | None) -> str | None:
|
|
"""Return the canonical ATT&CK display name for *technique_id*.
|
|
|
|
For a sub-technique (``T1059.004``) the parent is prepended so the
|
|
rendered string matches the historical format
|
|
``"Command and Scripting Interpreter: Unix Shell"``. ``None`` for
|
|
unknown IDs — callers (UI, exporter) fall back to showing the bare
|
|
ID. Drift is caught at startup by
|
|
:func:`assert_known_technique_ids`, so a ``None`` here in
|
|
production means an upstream emitted an ID that wasn't on the
|
|
validation list (likely a hot-loaded rule).
|
|
"""
|
|
if not technique_id:
|
|
return None
|
|
obj = _attack_pattern_by_id(technique_id)
|
|
if obj is None:
|
|
return None
|
|
name = obj.get("name")
|
|
if "." not in technique_id or not obj.get("x_mitre_is_subtechnique"):
|
|
return name
|
|
parent = subtechnique_parent_name(technique_id)
|
|
if parent is None:
|
|
return name
|
|
return f"{parent}: {name}"
|
|
|
|
|
|
def subtechnique_parent_name(technique_id: str) -> str | None:
|
|
parents = _load().get_parent_technique_of_subtechnique(
|
|
_attack_pattern_by_id(technique_id)["id"] # type: ignore[index]
|
|
)
|
|
if not parents:
|
|
return None
|
|
return parents[0]["object"].name
|
|
|
|
|
|
def is_subtechnique(technique_id: str) -> bool:
|
|
obj = _attack_pattern_by_id(technique_id)
|
|
return bool(obj and obj.get("x_mitre_is_subtechnique"))
|
|
|
|
|
|
def tactic_name(tactic_id_or_short_name: str | None) -> str | None:
|
|
"""Return the tactic display name for either a ``TA0001``-style ID or a kill-chain short name."""
|
|
if not tactic_id_or_short_name:
|
|
return None
|
|
if tactic_id_or_short_name.startswith("TA"):
|
|
obj = _tactic_by_id(tactic_id_or_short_name)
|
|
else:
|
|
obj = _tactic_by_short_name(tactic_id_or_short_name)
|
|
return obj.get("name") if obj else None
|
|
|
|
|
|
def tactic_id_for_short_name(short_name: str) -> str | None:
|
|
obj = _tactic_by_short_name(short_name)
|
|
if obj is None:
|
|
return None
|
|
for ref in obj.get("external_references", []):
|
|
if ref.get("source_name") == "mitre-attack":
|
|
return ref.get("external_id")
|
|
return None
|
|
|
|
|
|
def kill_chain_phases(technique_id: str) -> list[str]:
|
|
"""Return the kill-chain phase short-names for a technique."""
|
|
obj = _attack_pattern_by_id(technique_id)
|
|
if obj is None:
|
|
return []
|
|
return [
|
|
p["phase_name"]
|
|
for p in obj.get("kill_chain_phases", [])
|
|
if p.get("kill_chain_name") == "mitre-attack"
|
|
]
|
|
|
|
|
|
def mitre_url_for(technique_id: str | None) -> str | None:
|
|
"""Return the canonical attack.mitre.org URL for *technique_id*, or None.
|
|
|
|
Pulled from ``external_references[source_name="mitre-attack"].url``
|
|
on the cached attack-pattern. Reuses the lru-cached
|
|
:func:`_attack_pattern_by_id` so per-call cost is constant after
|
|
first hit. ``None`` for unknown / missing IDs — callers must
|
|
handle nullability (the column is ``Optional`` everywhere it
|
|
surfaces).
|
|
"""
|
|
if not technique_id:
|
|
return None
|
|
obj = _attack_pattern_by_id(technique_id)
|
|
if obj is None:
|
|
return None
|
|
for ref in obj.get("external_references", []):
|
|
if ref.get("source_name") == "mitre-attack":
|
|
url = ref.get("url")
|
|
return url if isinstance(url, str) else None
|
|
return None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class GroupRef:
|
|
"""A single MITRE ATT&CK ``intrusion-set`` (group) reference.
|
|
|
|
Returned by :func:`groups_using_technique` to surface "groups
|
|
MITRE has documented as using this technique". Read-only —
|
|
explicitly *not* an attribution claim about a DECNET attacker.
|
|
"""
|
|
|
|
group_id: str # e.g. "G0001"
|
|
name: str
|
|
aliases: tuple[str, ...]
|
|
mitre_url: str | None # https://attack.mitre.org/groups/G0001
|
|
|
|
|
|
def _group_external_id(obj: dict) -> str | None:
|
|
for ref in obj.get("external_references", []):
|
|
if ref.get("source_name") == "mitre-attack":
|
|
ext = ref.get("external_id")
|
|
return ext if isinstance(ext, str) else None
|
|
return None
|
|
|
|
|
|
def _group_mitre_url(obj: dict) -> str | None:
|
|
for ref in obj.get("external_references", []):
|
|
if ref.get("source_name") == "mitre-attack":
|
|
url = ref.get("url")
|
|
return url if isinstance(url, str) else None
|
|
return None
|
|
|
|
|
|
@lru_cache(maxsize=4096)
|
|
def groups_using_technique(technique_id: str) -> tuple[GroupRef, ...]:
|
|
"""Groups MITRE has documented as using *technique_id* — exact-match, deterministic order.
|
|
|
|
Sub-techniques are queried directly and do **not** union their
|
|
parent's groups (matching ATT&CK Navigator semantics). Callers
|
|
that want a broader view can resolve the parent themselves via
|
|
:func:`subtechnique_parent_name`.
|
|
|
|
Returns an empty tuple if the technique is unknown or has no
|
|
``uses`` relationships in the loaded bundle. Groups are sorted
|
|
by group_id ascending so JSON responses are stable across runs.
|
|
"""
|
|
if not technique_id:
|
|
return ()
|
|
obj = _attack_pattern_by_id(technique_id)
|
|
if obj is None:
|
|
return ()
|
|
raw = _load().get_groups_using_technique(obj["id"])
|
|
refs: list[GroupRef] = []
|
|
for entry in raw:
|
|
# mitreattack-python returns [{"object": IntrusionSet, "relationships": [...]}]
|
|
sdo = entry.get("object") if isinstance(entry, dict) else entry
|
|
if sdo is None:
|
|
continue
|
|
gid = _group_external_id(sdo)
|
|
if gid is None:
|
|
continue
|
|
aliases = sdo.get("aliases") or ()
|
|
refs.append(
|
|
GroupRef(
|
|
group_id=gid,
|
|
name=sdo.get("name", gid),
|
|
aliases=tuple(a for a in aliases if isinstance(a, str)),
|
|
mitre_url=_group_mitre_url(sdo),
|
|
)
|
|
)
|
|
refs.sort(key=lambda g: g.group_id)
|
|
return tuple(refs)
|
|
|
|
|
|
def technique_exists(technique_id: str) -> bool:
|
|
return _attack_pattern_by_id(technique_id) is not None
|
|
|
|
|
|
def tactic_exists(tactic_id: str) -> bool:
|
|
return _tactic_by_id(tactic_id) is not None
|
|
|
|
|
|
def assert_known_technique_ids(
|
|
technique_ids: list[str] | set[str] | tuple[str, ...],
|
|
*,
|
|
source: str,
|
|
) -> None:
|
|
"""Raise :class:`AttackBundleError` listing any IDs missing from the bundle."""
|
|
missing = sorted({t for t in technique_ids if t and not technique_exists(t)})
|
|
if missing:
|
|
raise AttackBundleError(
|
|
f"{source}: technique IDs not present in ATT&CK Enterprise "
|
|
f"v{ATTACK_BUNDLE_VERSION}: {missing}"
|
|
)
|
|
|
|
|
|
def assert_known_tactic_ids(
|
|
tactic_ids: list[str] | set[str] | tuple[str, ...],
|
|
*,
|
|
source: str,
|
|
exempt: set[str] | None = None,
|
|
) -> None:
|
|
exempt = exempt or set()
|
|
missing = sorted(
|
|
{t for t in tactic_ids if t and t not in exempt and not tactic_exists(t)}
|
|
)
|
|
if missing:
|
|
raise AttackBundleError(
|
|
f"{source}: tactic IDs not present in ATT&CK Enterprise "
|
|
f"v{ATTACK_BUNDLE_VERSION}: {missing}"
|
|
)
|
|
|
|
|
|
def _cli_fetch(print_sha: bool) -> int:
|
|
cached = _expected_cache_path()
|
|
license_path = _expected_license_path()
|
|
if not cached.is_file():
|
|
try:
|
|
_fetch_bundle(cached)
|
|
except Exception as exc: # pragma: no cover - network failure path
|
|
print(f"bundle fetch failed: {exc}", file=sys.stderr)
|
|
return 1
|
|
if not license_path.is_file():
|
|
try:
|
|
_fetch_license(license_path)
|
|
except Exception as exc: # pragma: no cover - network failure path
|
|
print(f"license fetch failed: {exc}", file=sys.stderr)
|
|
return 1
|
|
if print_sha:
|
|
print(f"{_sha256(cached)} {cached}")
|
|
print(f"{_sha256(license_path)} {license_path}")
|
|
return 0
|
|
try:
|
|
_verify_sha256(cached)
|
|
except AttackBundleError as exc:
|
|
print(str(exc), file=sys.stderr)
|
|
return 2
|
|
print(f"OK {cached} (version {ATTACK_BUNDLE_VERSION})")
|
|
print(f"OK {license_path}")
|
|
return 0
|
|
|
|
|
|
def _cli_license() -> int:
|
|
path = loaded_license_path()
|
|
if path is None:
|
|
print(
|
|
"No ATT&CK LICENSE.txt found. Run `python -m decnet.ttp.attack_stix fetch`.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
print(path.read_text(encoding="utf-8"))
|
|
return 0
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
import argparse
|
|
|
|
p = argparse.ArgumentParser(prog="python -m decnet.ttp.attack_stix")
|
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
f = sub.add_parser(
|
|
"fetch", help="Fetch + verify the pinned ATT&CK bundle and LICENSE.txt."
|
|
)
|
|
f.add_argument(
|
|
"--print-sha",
|
|
action="store_true",
|
|
help="Print sha256 of the cached files (for updating attack_version.py).",
|
|
)
|
|
sub.add_parser(
|
|
"license",
|
|
help="Print the cached MITRE ATT&CK LICENSE.txt to stdout.",
|
|
)
|
|
args = p.parse_args(argv)
|
|
if args.cmd == "fetch":
|
|
return _cli_fetch(args.print_sha)
|
|
if args.cmd == "license":
|
|
return _cli_license()
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__": # pragma: no cover
|
|
raise SystemExit(main())
|
|
|
|
|
|
__all__ = [
|
|
"ATTACK_BUNDLE_VERSION",
|
|
"AttackBundleError",
|
|
"assert_known_tactic_ids",
|
|
"assert_known_technique_ids",
|
|
"is_subtechnique",
|
|
"kill_chain_phases",
|
|
"GroupRef",
|
|
"groups_using_technique",
|
|
"loaded_bundle_path",
|
|
"loaded_license_path",
|
|
"mitre_url_for",
|
|
"resolve_bundle_path",
|
|
"subtechnique_parent_name",
|
|
"tactic_exists",
|
|
"tactic_id_for_short_name",
|
|
"tactic_name",
|
|
"technique_exists",
|
|
"technique_name",
|
|
]
|