mitre-attack-mcp 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of mitre-attack-mcp might be problematic. Click here for more details.
- mitre_attack_mcp/data.py +260 -0
- mitre_attack_mcp/server.py +115 -0
- mitre_attack_mcp/services.py +160 -0
- mitre_attack_mcp-0.1.0.dist-info/METADATA +120 -0
- mitre_attack_mcp-0.1.0.dist-info/RECORD +7 -0
- mitre_attack_mcp-0.1.0.dist-info/WHEEL +4 -0
- mitre_attack_mcp-0.1.0.dist-info/entry_points.txt +2 -0
mitre_attack_mcp/data.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Download and indexing layer for MITRE ATT&CK STIX bundles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Iterable
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
DOMAINS: tuple[str, ...] = ("enterprise-attack", "ics-attack", "mobile-attack")
|
|
17
|
+
|
|
18
|
+
BASE_URL = (
|
|
19
|
+
"https://raw.githubusercontent.com/mitre-attack/attack-stix-data/"
|
|
20
|
+
"master/{domain}/{domain}.json"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
_DEFAULT_DATA_DIR = Path(__file__).resolve().parents[2] / "data"
|
|
24
|
+
ENV_DATA_DIR = "MITRE_ATTACK_MCP_DATA_DIR"
|
|
25
|
+
|
|
26
|
+
DOWNLOAD_TIMEOUT = 120.0
|
|
27
|
+
DOWNLOAD_RETRIES = 3
|
|
28
|
+
RETRY_BACKOFF = 2.0
|
|
29
|
+
CHUNK_SIZE = 1 << 16
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# --------------------------------------------------------------------------- #
|
|
33
|
+
# Paths
|
|
34
|
+
# --------------------------------------------------------------------------- #
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def data_dir() -> Path:
|
|
38
|
+
override = os.environ.get(ENV_DATA_DIR)
|
|
39
|
+
return Path(override).expanduser() if override else _DEFAULT_DATA_DIR
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def file_for(domain: str) -> Path:
|
|
43
|
+
if domain not in DOMAINS:
|
|
44
|
+
raise ValueError(f"Unknown domain {domain!r}; expected one of {DOMAINS}")
|
|
45
|
+
return data_dir() / f"{domain}.json"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# --------------------------------------------------------------------------- #
|
|
49
|
+
# Download
|
|
50
|
+
# --------------------------------------------------------------------------- #
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def download(domain: str, *, force: bool = False) -> Path:
|
|
54
|
+
if domain not in DOMAINS:
|
|
55
|
+
raise ValueError(f"Unknown domain {domain!r}; expected one of {DOMAINS}")
|
|
56
|
+
|
|
57
|
+
dest = file_for(domain)
|
|
58
|
+
if dest.exists() and dest.stat().st_size > 0 and not force:
|
|
59
|
+
logger.info("Using cached %s bundle at %s", domain, dest)
|
|
60
|
+
return dest
|
|
61
|
+
|
|
62
|
+
url = BASE_URL.format(domain=domain)
|
|
63
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
tmp = dest.with_suffix(dest.suffix + ".part")
|
|
65
|
+
|
|
66
|
+
last_exc: Exception | None = None
|
|
67
|
+
for attempt in range(1, DOWNLOAD_RETRIES + 1):
|
|
68
|
+
try:
|
|
69
|
+
logger.info(
|
|
70
|
+
"Downloading %s (attempt %d/%d)", domain, attempt, DOWNLOAD_RETRIES
|
|
71
|
+
)
|
|
72
|
+
_stream_to_file(url, tmp)
|
|
73
|
+
_validate_stix(tmp)
|
|
74
|
+
tmp.replace(dest)
|
|
75
|
+
logger.info(
|
|
76
|
+
"Saved %s bundle to %s (%d bytes)", domain, dest, dest.stat().st_size
|
|
77
|
+
)
|
|
78
|
+
return dest
|
|
79
|
+
except Exception as exc: # noqa: BLE001
|
|
80
|
+
last_exc = exc
|
|
81
|
+
logger.warning("%s download attempt %d failed: %s", domain, attempt, exc)
|
|
82
|
+
tmp.unlink(missing_ok=True)
|
|
83
|
+
if attempt < DOWNLOAD_RETRIES:
|
|
84
|
+
time.sleep(RETRY_BACKOFF * attempt)
|
|
85
|
+
|
|
86
|
+
raise RuntimeError(
|
|
87
|
+
f"Failed to download {domain} bundle after {DOWNLOAD_RETRIES} attempts"
|
|
88
|
+
) from last_exc
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def download_all(
|
|
92
|
+
domains: Iterable[str] | None = None,
|
|
93
|
+
*,
|
|
94
|
+
force: bool = False,
|
|
95
|
+
) -> dict[str, Path]:
|
|
96
|
+
domains = tuple(domains or DOMAINS)
|
|
97
|
+
return {d: download(d, force=force) for d in domains}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _stream_to_file(url: str, dest: Path) -> None:
|
|
101
|
+
with httpx.stream("GET", url, timeout=DOWNLOAD_TIMEOUT, follow_redirects=True) as r:
|
|
102
|
+
r.raise_for_status()
|
|
103
|
+
total = int(r.headers.get("content-length", 0)) or None
|
|
104
|
+
written = 0
|
|
105
|
+
last_bucket = -1
|
|
106
|
+
with dest.open("wb") as fh:
|
|
107
|
+
for chunk in r.iter_bytes(CHUNK_SIZE):
|
|
108
|
+
fh.write(chunk)
|
|
109
|
+
written += len(chunk)
|
|
110
|
+
if total:
|
|
111
|
+
bucket = (written * 100 // total) // 10
|
|
112
|
+
if bucket != last_bucket:
|
|
113
|
+
logger.debug(" ... %d%%", bucket * 10)
|
|
114
|
+
last_bucket = bucket
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _validate_stix(path: Path) -> None:
|
|
118
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
119
|
+
payload = json.load(fh)
|
|
120
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("objects"), list):
|
|
121
|
+
raise ValueError("Downloaded file is not a STIX bundle")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# --------------------------------------------------------------------------- #
|
|
125
|
+
# Indexing
|
|
126
|
+
# --------------------------------------------------------------------------- #
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class AttackIndex:
|
|
130
|
+
"""Indexed view over ONE domain's STIX bundle."""
|
|
131
|
+
|
|
132
|
+
def __init__(self, domain: str, objects: Iterable[dict[str, Any]]) -> None:
|
|
133
|
+
self.domain = domain
|
|
134
|
+
self.objects: list[dict[str, Any]] = list(objects)
|
|
135
|
+
self.by_attack_id: dict[str, dict[str, Any]] = {}
|
|
136
|
+
self.by_name: dict[str, dict[str, Any]] = {}
|
|
137
|
+
self.by_type: dict[str, list[dict[str, Any]]] = {}
|
|
138
|
+
self.aliases: dict[str, dict[str, Any]] = {}
|
|
139
|
+
self.by_stix_id: dict[str, dict[str, Any]] = {}
|
|
140
|
+
self._mitigates: dict[str, list[str]] = {}
|
|
141
|
+
for obj in self.objects:
|
|
142
|
+
self._index(obj)
|
|
143
|
+
|
|
144
|
+
def _index(self, obj: dict[str, Any]) -> None:
|
|
145
|
+
stix_type = obj.get("type")
|
|
146
|
+
if stix_type:
|
|
147
|
+
self.by_type.setdefault(stix_type, []).append(obj)
|
|
148
|
+
|
|
149
|
+
stix_id = obj.get("id")
|
|
150
|
+
if isinstance(stix_id, str):
|
|
151
|
+
self.by_stix_id[stix_id] = obj
|
|
152
|
+
|
|
153
|
+
name = obj.get("name")
|
|
154
|
+
if isinstance(name, str) and name:
|
|
155
|
+
self.by_name.setdefault(name.lower(), obj)
|
|
156
|
+
|
|
157
|
+
for alias in obj.get("aliases", []) or []:
|
|
158
|
+
if isinstance(alias, str) and alias:
|
|
159
|
+
self.aliases.setdefault(alias.lower(), obj)
|
|
160
|
+
|
|
161
|
+
for ref in obj.get("external_references", []) or []:
|
|
162
|
+
if ref.get("source_name") == "mitre-attack" and ref.get("external_id"):
|
|
163
|
+
self.by_attack_id[ref["external_id"].upper()] = obj
|
|
164
|
+
|
|
165
|
+
if stix_type == "relationship" and obj.get("relationship_type") == "mitigates":
|
|
166
|
+
src, tgt = obj.get("source_ref"), obj.get("target_ref")
|
|
167
|
+
if isinstance(src, str) and isinstance(tgt, str):
|
|
168
|
+
self._mitigates.setdefault(tgt, []).append(src)
|
|
169
|
+
|
|
170
|
+
def get_technique(self, attack_id: str) -> dict[str, Any] | None:
|
|
171
|
+
obj = self.by_attack_id.get(attack_id.strip().upper())
|
|
172
|
+
return obj if obj and obj.get("type") == "attack-pattern" else None
|
|
173
|
+
|
|
174
|
+
def get_group(self, name_or_alias: str) -> dict[str, Any] | None:
|
|
175
|
+
key = name_or_alias.strip().lower()
|
|
176
|
+
obj = self.by_name.get(key) or self.aliases.get(key)
|
|
177
|
+
return obj if obj and obj.get("type") == "intrusion-set" else None
|
|
178
|
+
|
|
179
|
+
def search_techniques(
|
|
180
|
+
self, keyword: str, *, limit: int = 10
|
|
181
|
+
) -> list[dict[str, Any]]:
|
|
182
|
+
kw = keyword.strip().lower()
|
|
183
|
+
if not kw:
|
|
184
|
+
return []
|
|
185
|
+
hits: list[dict[str, Any]] = []
|
|
186
|
+
for obj in self.by_type.get("attack-pattern", []):
|
|
187
|
+
hay = f"{obj.get('name', '')} {obj.get('description', '')}".lower()
|
|
188
|
+
if kw in hay:
|
|
189
|
+
hits.append(obj)
|
|
190
|
+
if len(hits) >= limit:
|
|
191
|
+
break
|
|
192
|
+
return hits
|
|
193
|
+
|
|
194
|
+
def mitigations_for_technique(self, technique_stix_id: str) -> list[dict[str, Any]]:
|
|
195
|
+
"""Return mitigation objects linked to a technique via 'mitigates' relationships."""
|
|
196
|
+
source_ids = getattr(self, "_mitigates", {}).get(technique_stix_id, [])
|
|
197
|
+
results: list[dict[str, Any]] = []
|
|
198
|
+
for sid in source_ids:
|
|
199
|
+
obj = self.by_stix_id.get(sid)
|
|
200
|
+
if obj is None or obj.get("type") != "course-of-action":
|
|
201
|
+
continue
|
|
202
|
+
results.append(
|
|
203
|
+
{
|
|
204
|
+
"id": attack_id_of(obj),
|
|
205
|
+
"name": obj.get("name"),
|
|
206
|
+
"description": (obj.get("description") or "")[:600],
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
return results
|
|
210
|
+
|
|
211
|
+
def counts(self) -> dict[str, int]:
|
|
212
|
+
return {
|
|
213
|
+
"techniques": len(self.by_type.get("attack-pattern", [])),
|
|
214
|
+
"groups": len(self.by_type.get("intrusion-set", [])),
|
|
215
|
+
"software": len(self.by_type.get("malware", []))
|
|
216
|
+
+ len(self.by_type.get("tool", [])),
|
|
217
|
+
"mitigations": len(self.by_type.get("course-of-action", [])),
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
_indices: dict[str, AttackIndex] = {}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def get_index(domain: str) -> AttackIndex:
|
|
225
|
+
if domain not in DOMAINS:
|
|
226
|
+
raise ValueError(f"Unknown domain {domain!r}; expected one of {DOMAINS}")
|
|
227
|
+
if domain not in _indices:
|
|
228
|
+
path = file_for(domain)
|
|
229
|
+
if not path.exists():
|
|
230
|
+
raise FileNotFoundError(
|
|
231
|
+
f"ATT&CK data for {domain!r} not found at {path}. "
|
|
232
|
+
f"Run the server once with downloads enabled, or set "
|
|
233
|
+
f"{ENV_DATA_DIR} to a directory containing the bundle."
|
|
234
|
+
)
|
|
235
|
+
logger.info("Loading ATT&CK bundle for %s from %s", domain, path)
|
|
236
|
+
with path.open("r", encoding="utf-8") as fh:
|
|
237
|
+
bundle = json.load(fh)
|
|
238
|
+
_indices[domain] = AttackIndex(domain, bundle.get("objects", []))
|
|
239
|
+
logger.info("Indexed %s: %s", domain, _indices[domain].counts())
|
|
240
|
+
return _indices[domain]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# --------------------------------------------------------------------------- #
|
|
244
|
+
# STIX helpers
|
|
245
|
+
# --------------------------------------------------------------------------- #
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def attack_id_of(obj: dict[str, Any]) -> str | None:
|
|
249
|
+
for ref in obj.get("external_references", []) or []:
|
|
250
|
+
if ref.get("source_name") == "mitre-attack" and ref.get("external_id"):
|
|
251
|
+
return ref["external_id"]
|
|
252
|
+
return None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def tactics_of(technique: dict[str, Any]) -> list[str]:
|
|
256
|
+
return [
|
|
257
|
+
phase["phase_name"]
|
|
258
|
+
for phase in technique.get("kill_chain_phases", []) or []
|
|
259
|
+
if phase.get("kill_chain_name") == "mitre-attack"
|
|
260
|
+
]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
from mcp.server.mcpserver import MCPServer
|
|
6
|
+
from . import data
|
|
7
|
+
from . import services
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
logging.basicConfig(
|
|
11
|
+
level=logging.INFO,
|
|
12
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
13
|
+
stream=sys.stderr, # never stdout — that's the JSON-RPC channel
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
mcp = MCPServer("mitre-attack-mcp", version="0.1.0")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _preStart():
|
|
20
|
+
logging.info("downloading mitre knowledge data..")
|
|
21
|
+
data.download_all()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@mcp.tool()
|
|
25
|
+
def get_data_stats(*, domain: str | None = None) -> dict | list[dict]:
|
|
26
|
+
"""Return counts of techniques, groups, software, and mitigations.
|
|
27
|
+
|
|
28
|
+
domain: one of 'enterprise-attack', 'ics-attack', 'mobile-attack'.
|
|
29
|
+
Omit to get a per-domain breakdown of all three.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
return services.get_data_stats(domain=domain)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@mcp.tool()
|
|
36
|
+
def get_technique_by_id(
|
|
37
|
+
attack_id: str,
|
|
38
|
+
*,
|
|
39
|
+
domain: str | None = None,
|
|
40
|
+
) -> dict:
|
|
41
|
+
"""Look up a MITRE ATT&CK technique by its ID (e.g. 'T1059' or 'T1059.001').
|
|
42
|
+
|
|
43
|
+
Returns name, truncated description, tactics, and platforms, plus the
|
|
44
|
+
domain the match came from.
|
|
45
|
+
|
|
46
|
+
domain: one of 'enterprise-attack', 'ics-attack', 'mobile-attack'.
|
|
47
|
+
Omit if you are not sure which domain the technique belongs to —
|
|
48
|
+
all three will be searched and the result will say which domain
|
|
49
|
+
matched. ATT&CK IDs are not globally unique across domains, so if
|
|
50
|
+
the ID exists in more than one domain the result also lists
|
|
51
|
+
`other_domains`.
|
|
52
|
+
"""
|
|
53
|
+
return services.get_technique_by_id(attack_id, domain=domain)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@mcp.tool()
|
|
57
|
+
def search_techniques(
|
|
58
|
+
keyword: str,
|
|
59
|
+
*,
|
|
60
|
+
domain: str | None = None,
|
|
61
|
+
limit: int = 10,
|
|
62
|
+
) -> list[dict]:
|
|
63
|
+
"""Search techniques whose name or description contains the keyword.
|
|
64
|
+
|
|
65
|
+
domain: restrict to one of 'enterprise-attack', 'ics-attack',
|
|
66
|
+
'mobile-attack'. Omit to search all three domains.
|
|
67
|
+
limit: maximum results across all searched domains (default 10).
|
|
68
|
+
"""
|
|
69
|
+
return services.search_techniques(keyword, domain=domain, limit=limit)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@mcp.tool()
|
|
73
|
+
def get_group_by_name(
|
|
74
|
+
name: str,
|
|
75
|
+
*,
|
|
76
|
+
domain: str | None = None,
|
|
77
|
+
) -> dict:
|
|
78
|
+
"""Look up a threat group by name or alias (e.g. 'APT29' or 'Cozy Bear').
|
|
79
|
+
|
|
80
|
+
domain: one of 'enterprise-attack', 'ics-attack', 'mobile-attack'.
|
|
81
|
+
Omit if you are not sure which domain the group belongs to —
|
|
82
|
+
all three will be searched and the result will say which domain
|
|
83
|
+
matched.
|
|
84
|
+
"""
|
|
85
|
+
return services.get_group_by_name(name, domain=domain)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@mcp.tool()
|
|
89
|
+
def get_mitigations_for_technique(
|
|
90
|
+
attack_id: str,
|
|
91
|
+
*,
|
|
92
|
+
domain: str | None = None,
|
|
93
|
+
) -> dict:
|
|
94
|
+
"""Return MITRE ATT&CK mitigations that apply to a specific technique.
|
|
95
|
+
|
|
96
|
+
Use this tool whenever the user asks how to defend against, mitigate,
|
|
97
|
+
or prevent a technique (e.g. "What mitigations exist for T1003?").
|
|
98
|
+
Returns each mitigation's ATT&CK ID (M####), name, and description,
|
|
99
|
+
sourced from the official ATT&CK dataset — do not answer from memory.
|
|
100
|
+
|
|
101
|
+
domain: one of 'enterprise-attack', 'ics-attack', 'mobile-attack'.
|
|
102
|
+
Omit to search all three.
|
|
103
|
+
"""
|
|
104
|
+
return services.get_mitigations_for_technique(attack_id, domain=domain)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def main():
|
|
108
|
+
_preStart()
|
|
109
|
+
logging.info("mitre-attack-mcp server starting..")
|
|
110
|
+
mcp.run()
|
|
111
|
+
logging.info("mitre-attack-mcp server started..")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
main()
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Pure business logic. This module must NOT import `mcp`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from . import data
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
logging.basicConfig(
|
|
10
|
+
level=logging.INFO,
|
|
11
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
12
|
+
stream=sys.stderr, # never stdout — that's the JSON-RPC channel
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_data_stats(*, domain: str | None = None) -> dict | list[dict]:
|
|
17
|
+
"""Counts of techniques, groups, software, and mitigations.
|
|
18
|
+
|
|
19
|
+
domain: restrict to one domain, or omit for a per-domain breakdown.
|
|
20
|
+
"""
|
|
21
|
+
if domain is not None:
|
|
22
|
+
idx = data.get_index(domain)
|
|
23
|
+
return {"domain": domain, **idx.counts()}
|
|
24
|
+
|
|
25
|
+
result = [{"domain": d, **data.get_index(d).counts()} for d in data.DOMAINS]
|
|
26
|
+
logging.info("results %s=", result)
|
|
27
|
+
return result
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_technique_by_id(
|
|
31
|
+
attack_id: str,
|
|
32
|
+
*,
|
|
33
|
+
domain: str | None = None,
|
|
34
|
+
) -> dict:
|
|
35
|
+
"""Look up a technique by ATT&CK ID.
|
|
36
|
+
|
|
37
|
+
If `domain` is omitted, all three domains are searched. The returned dict
|
|
38
|
+
includes `domain` so the caller knows where the match came from, and
|
|
39
|
+
`other_domains` lists any additional domains where the same ID also
|
|
40
|
+
exists (ATT&CK IDs are not globally unique across domains).
|
|
41
|
+
"""
|
|
42
|
+
matches: list[tuple[str, dict]] = []
|
|
43
|
+
for d in (domain,) if domain else data.DOMAINS:
|
|
44
|
+
obj = data.get_index(d).get_technique(attack_id)
|
|
45
|
+
if obj is not None:
|
|
46
|
+
matches.append((d, obj))
|
|
47
|
+
|
|
48
|
+
if not matches:
|
|
49
|
+
where = domain or "any domain"
|
|
50
|
+
raise ValueError(f"Technique {attack_id!r} not found in {where}")
|
|
51
|
+
|
|
52
|
+
d, obj = matches[0]
|
|
53
|
+
result = {
|
|
54
|
+
"domain": d,
|
|
55
|
+
"id": data.attack_id_of(obj),
|
|
56
|
+
"name": obj.get("name"),
|
|
57
|
+
"description": (obj.get("description") or "")[:800],
|
|
58
|
+
"tactics": data.tactics_of(obj),
|
|
59
|
+
"platforms": obj.get("x_mitre_platforms", []),
|
|
60
|
+
"is_subtechnique": obj.get("x_mitre_is_subtechnique", False),
|
|
61
|
+
}
|
|
62
|
+
if len(matches) > 1:
|
|
63
|
+
result["other_domains"] = [m[0] for m in matches[1:]]
|
|
64
|
+
result["note"] = (
|
|
65
|
+
"This ID also exists in other domains with different content; "
|
|
66
|
+
"pass `domain` to disambiguate."
|
|
67
|
+
)
|
|
68
|
+
logging.info("results %s=", result)
|
|
69
|
+
return result
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def search_techniques(
|
|
73
|
+
keyword: str,
|
|
74
|
+
*,
|
|
75
|
+
domain: str | None = None,
|
|
76
|
+
limit: int = 10,
|
|
77
|
+
) -> list[dict]:
|
|
78
|
+
"""Search techniques by keyword. `domain=None` searches all domains."""
|
|
79
|
+
domains = (domain,) if domain else data.DOMAINS
|
|
80
|
+
for d in domains:
|
|
81
|
+
if d not in data.DOMAINS:
|
|
82
|
+
raise ValueError(f"Unknown domain {d!r}; expected one of {data.DOMAINS}")
|
|
83
|
+
|
|
84
|
+
results: list[dict] = []
|
|
85
|
+
for d in domains:
|
|
86
|
+
hits = data.get_index(d).search_techniques(keyword, limit=limit)
|
|
87
|
+
results.extend(
|
|
88
|
+
{"domain": d, "id": data.attack_id_of(o), "name": o.get("name")}
|
|
89
|
+
for o in hits
|
|
90
|
+
)
|
|
91
|
+
if len(results) >= limit:
|
|
92
|
+
break
|
|
93
|
+
|
|
94
|
+
logging.info("results %s=", results[:limit])
|
|
95
|
+
return results[:limit]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_group_by_name(
|
|
99
|
+
name: str,
|
|
100
|
+
*,
|
|
101
|
+
domain: str | None = None,
|
|
102
|
+
) -> dict:
|
|
103
|
+
"""Look up a threat group by name or alias.
|
|
104
|
+
|
|
105
|
+
If `domain` is omitted, all three domains are searched; `domain` in the
|
|
106
|
+
result tells the caller where the match was found.
|
|
107
|
+
"""
|
|
108
|
+
matches: list[tuple[str, dict]] = []
|
|
109
|
+
for d in (domain,) if domain else data.DOMAINS:
|
|
110
|
+
obj = data.get_index(d).get_group(name)
|
|
111
|
+
if obj is not None:
|
|
112
|
+
matches.append((d, obj))
|
|
113
|
+
|
|
114
|
+
if not matches:
|
|
115
|
+
where = domain or "any domain"
|
|
116
|
+
raise ValueError(f"Group {name!r} not found in {where}")
|
|
117
|
+
|
|
118
|
+
d, obj = matches[0]
|
|
119
|
+
result = {
|
|
120
|
+
"domain": d,
|
|
121
|
+
"name": obj.get("name"),
|
|
122
|
+
"aliases": obj.get("aliases", []),
|
|
123
|
+
"description": (obj.get("description") or "")[:800],
|
|
124
|
+
}
|
|
125
|
+
if len(matches) > 1:
|
|
126
|
+
result["other_domains"] = [m[0] for m in matches[1:]]
|
|
127
|
+
|
|
128
|
+
logging.info("results %s=", result)
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def get_mitigations_for_technique(
|
|
133
|
+
attack_id: str,
|
|
134
|
+
*,
|
|
135
|
+
domain: str | None = None,
|
|
136
|
+
) -> dict:
|
|
137
|
+
"""Return mitigations linked to a technique.
|
|
138
|
+
|
|
139
|
+
If `domain` is omitted, all three domains are searched.
|
|
140
|
+
"""
|
|
141
|
+
matches: list[tuple[str, dict]] = []
|
|
142
|
+
for d in (domain,) if domain else data.DOMAINS:
|
|
143
|
+
obj = data.get_index(d).get_technique(attack_id)
|
|
144
|
+
if obj is not None:
|
|
145
|
+
matches.append((d, obj))
|
|
146
|
+
|
|
147
|
+
if not matches:
|
|
148
|
+
where = domain or "any domain"
|
|
149
|
+
raise ValueError(f"Technique {attack_id!r} not found in {where}")
|
|
150
|
+
|
|
151
|
+
d, tech = matches[0]
|
|
152
|
+
mitigations = data.get_index(d).mitigations_for_technique(tech["id"])
|
|
153
|
+
result = {
|
|
154
|
+
"domain": d,
|
|
155
|
+
"technique_id": data.attack_id_of(tech),
|
|
156
|
+
"technique_name": tech.get("name"),
|
|
157
|
+
"mitigations": mitigations,
|
|
158
|
+
}
|
|
159
|
+
logging.info("results %s=", result)
|
|
160
|
+
return result
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mitre-attack-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MITRE ATT&CK MCP server for AI agents
|
|
5
|
+
Author: gsbadhan
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Keywords: ai,attack,mcp,mitre,security,threat-intelligence
|
|
8
|
+
Classifier: Development Status :: 3 - Alpha
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Intended Audience :: Information Technology
|
|
11
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Security
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
|
+
Requires-Dist: httpx>=0.28.1
|
|
18
|
+
Requires-Dist: mcp[cli]>=2.3.0
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# mitre-attack-mcp
|
|
22
|
+
MITRE ATT&CK MCP Server
|
|
23
|
+
|
|
24
|
+
# Description
|
|
25
|
+
A Model Context Protocol server that exposes the MITRE ATT&CK knowledge base to AI agents such as Claude Desktop and Claude Code.
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
## What it does
|
|
29
|
+
| Tool | Purpose | Input |
|
|
30
|
+
|------|---------|-------|
|
|
31
|
+
| `get_data_stats` | Counts of techniques, groups, software, mitigations | none |
|
|
32
|
+
| `search_techniques` | Find techniques by keyword | `keyword` |
|
|
33
|
+
| `get_technique_by_id` | Technique details by ID | `attack_id` |
|
|
34
|
+
| `get_mitigations_for_technique` | Mitigations for a technique | `attack_id` |
|
|
35
|
+
| `get_group_by_name` | Threat group details | `name` |
|
|
36
|
+
|
|
37
|
+
All tools also accept an optional `domain`: `enterprise-attack`, `ics-attack`, or `mobile-attack`.
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# Project setup
|
|
41
|
+
- uv venv
|
|
42
|
+
- source .venv/bin/activate
|
|
43
|
+
- uv sync
|
|
44
|
+
|
|
45
|
+
# Data ingestion
|
|
46
|
+
- MITRE data auto downloaded while MCP server starts
|
|
47
|
+
|
|
48
|
+
# Run MCP server locally
|
|
49
|
+
- uv run python -m mitre_attack_mcp.server
|
|
50
|
+
#### Note: Dont run with Claude, its for local testing only
|
|
51
|
+
|
|
52
|
+
# Unit tests
|
|
53
|
+
- uv run pytest tests/ -v
|
|
54
|
+
|
|
55
|
+
# Integration With Anthropic Claude Code
|
|
56
|
+
```
|
|
57
|
+
1. Clone code repository locally
|
|
58
|
+
|
|
59
|
+
2. Check mcps: claude mcp list
|
|
60
|
+
|
|
61
|
+
3. Add below configs in macOS: ~/Library/Application Support/Claude/claude_desktop_config.json
|
|
62
|
+
|
|
63
|
+
"mcpServers": {
|
|
64
|
+
"mitre-attack": {
|
|
65
|
+
"command": "uv",
|
|
66
|
+
"args": [
|
|
67
|
+
"run",
|
|
68
|
+
"--directory",
|
|
69
|
+
"/Users/john/git-projects/mitre-attack-mcp",
|
|
70
|
+
"mitre-attack-mcp"
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
4. Register mitre mcp: claude mcp add --scope user mitre-attack -- uv run --directory /Users/john/git-projects/mitre-attack-mcp mitre-attack-mcp
|
|
77
|
+
|
|
78
|
+
5. Check mitre-attack mcp: claude mcp list
|
|
79
|
+
|
|
80
|
+
6. Check detail: claude mcp get mitre-attack
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
# Integration with Anthropic Claude Desktop
|
|
84
|
+
```
|
|
85
|
+
1. Claude desktop -> Settings -> Developer -> Edit config
|
|
86
|
+
|
|
87
|
+
2. Add below configs:
|
|
88
|
+
|
|
89
|
+
"mcpServers": {
|
|
90
|
+
"mitre-attack": {
|
|
91
|
+
"command": "uv",
|
|
92
|
+
"args": [
|
|
93
|
+
"run",
|
|
94
|
+
"--directory",
|
|
95
|
+
"/Users/john/git-projects/mitre-attack-mcp",
|
|
96
|
+
"mitre-attack-mcp"
|
|
97
|
+
]
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
3. Relaunch Claude
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+

|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# Claude Desktop Assistant With Mitre MCP
|
|
108
|
+

|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# Common prompts
|
|
112
|
+
```
|
|
113
|
+
Search mitre-attack for techniques containing 'credential'.
|
|
114
|
+
Get the data stats from mitre-attack.
|
|
115
|
+
Look up the group APT29 in mitre-attack.
|
|
116
|
+
Use the mitre-attack server to look up technique T1059.
|
|
117
|
+
what is mitigations for T1003.
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
mitre_attack_mcp/data.py,sha256=zK33D9zl7oQusBCS3bxROfkjpyTTWslnARIHwM8pKA4,9416
|
|
2
|
+
mitre_attack_mcp/server.py,sha256=AzBlaLj9ivHq4cnJOjSn3KGozbUZHZ6D0aW1wDD0t7k,3447
|
|
3
|
+
mitre_attack_mcp/services.py,sha256=pG8svJSkjd0qmkW4r6kbEYhHxMoKeHwzkeFR9Ji8Kq4,4923
|
|
4
|
+
mitre_attack_mcp-0.1.0.dist-info/METADATA,sha256=JR-qq-g_BxPyl5m6f1CJOMRkUHd-xNE06XF2mgdOFpY,3156
|
|
5
|
+
mitre_attack_mcp-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
6
|
+
mitre_attack_mcp-0.1.0.dist-info/entry_points.txt,sha256=S0AfUvIyWs4WXnsmdzt8tape431ydGkVUo-VrVwdL-U,66
|
|
7
|
+
mitre_attack_mcp-0.1.0.dist-info/RECORD,,
|