main
py 1,322 lines 54.8 KB
Raw
1 import asyncio
2 import re
3 import time
4 from datetime import datetime
5 from typing import Dict
6 from typing import List
7 from typing import Optional
8 from typing import Tuple
9 from typing import Union
10
11 import aiohttp
12 import yaml
13 from elasticsearch7 import AsyncElasticsearch
14 from fastapi import HTTPException
15 from loguru import logger
16 from pydantic import ValidationError
17
18 from app.connectors.wazuh_indexer.utils.universal import (
19 create_wazuh_indexer_client_async,
20 )
21 from app.connectors.wazuh_manager.schema.mitre import WazuhMitreGroupsResponse
22 from app.connectors.wazuh_manager.schema.mitre import WazuhMitreMitigationsResponse
23 from app.connectors.wazuh_manager.schema.mitre import WazuhMitreReferencesResponse
24 from app.connectors.wazuh_manager.schema.mitre import WazuhMitreSoftwareResponse
25 from app.connectors.wazuh_manager.schema.mitre import WazuhMitreTacticsResponse
26 from app.connectors.wazuh_manager.schema.mitre import WazuhMitreTechniquesResponse
27 from app.connectors.wazuh_manager.utils.universal import send_get_request
28
29 # Constants for the Atomic Red Team GitHub repository
30 GITHUB_RAW_URL = "https://raw.githubusercontent.com/redcanaryco/atomic-red-team/refs/heads/master/atomics"
31 CACHE_EXPIRY = 86400 # Cache expiry time in seconds (24 hours)
32
33
34 class AtomicRedTeamService:
35 """Service for fetching Atomic Red Team markdown content."""
36
37 # Cache to store the markdown content with timestamp
38 # Format: {technique_id: (markdown_content, timestamp)}
39 _cache: Dict[str, Tuple[str, float]] = {}
40 _tests_cache: Dict[str, Tuple[List[Dict], float]] = {} # Cache for all tests
41
42 @classmethod
43 async def list_all_atomic_tests(cls, os_category: Optional[str] = None) -> Dict:
44 """
45 Get a list of all available Atomic Red Team tests.
46
47 Args:
48 os_category: Optional filter for operating system category
49
50 Returns:
51 Dict containing test information and metadata
52 """
53 # Check cache first
54 cache_key = f"all_tests_{os_category}" if os_category else "all_tests"
55 if cache_key in cls._tests_cache:
56 tests, timestamp = cls._tests_cache[cache_key]
57 if time.time() - timestamp < CACHE_EXPIRY:
58 logger.debug(f"Returning cached list of atomic tests for OS category: {os_category}")
59 return {"total_techniques": len(tests), "tests": tests, "last_updated": datetime.fromtimestamp(timestamp).isoformat()}
60
61 # Fetch the list of all techniques with atomic tests
62 try:
63 # First, try to fetch index.yaml which has metadata about all tests
64 async with aiohttp.ClientSession() as session:
65 url = "https://raw.githubusercontent.com/redcanaryco/atomic-red-team/refs/heads/master/atomics/Indexes/Indexes-Markdown/atomic-red-team-index.md"
66 async with session.get(url) as response:
67 if response.status == 200:
68 return await cls._parse_atomic_index_markdown(await response.text(), os_category)
69
70 # If markdown index not available, try alternate approach
71 logger.warning(f"Could not fetch atomic-red-team-index.md: {response.status}. Trying alternate method.")
72 return await cls._fetch_techniques_from_atomics_folder(os_category)
73 except Exception as e:
74 logger.error(f"Error listing atomic tests: {str(e)}")
75 raise HTTPException(status_code=500, detail=f"Error listing atomic tests: {str(e)}")
76
77 @classmethod
78 async def _parse_atomic_index_markdown(cls, content: str, os_category: Optional[str] = None) -> Dict:
79 """Parse the atomic-red-team-index.md file to extract test information."""
80 techniques = []
81 technique_pattern = r"\|\s*\[([^]]+)\]\([^)]+\)\s*\|\s*([T\d\.]+)\s*\|\s*(\d+)\s*\|"
82
83 matches = re.findall(technique_pattern, content)
84 total_tests = 0
85
86 for name, technique_id, test_count in matches:
87 try:
88 count = int(test_count)
89 total_tests += count
90
91 # For now, we'll need to fetch individual technique details to get platform info
92 # This is a limitation of parsing just the index markdown
93 technique_data = {
94 "technique_id": technique_id,
95 "technique_name": name,
96 "test_count": count,
97 "categories": [], # Would require additional requests to determine
98 "has_prerequisites": False, # Would require additional requests to determine
99 }
100
101 # If filtering by OS category, we'd need to fetch individual technique data
102 # For performance, we'll apply the filter after getting all data
103 techniques.append(technique_data)
104
105 except ValueError:
106 continue # Skip if test_count isn't a valid integer
107
108 # Apply OS category filter if specified
109 if os_category:
110 # Note: This approach has limitations because the index doesn't contain platform info
111 # We'd need to fetch individual technique data for accurate filtering
112 logger.warning("OS category filtering from index markdown has limitations. Consider using the alternate method.")
113
114 result = {
115 "total_techniques": len(techniques),
116 "total_tests": total_tests,
117 "tests": techniques,
118 "last_updated": datetime.utcnow().isoformat(),
119 }
120
121 # Cache the result
122 cache_key = f"all_tests_{os_category}" if os_category else "all_tests"
123 cls._tests_cache[cache_key] = (techniques, time.time())
124
125 return result
126
127 @classmethod
128 async def _fetch_techniques_from_atomics_folder(cls, os_category: Optional[str] = None) -> Dict:
129 """Fetch and parse techniques directly from the Atomic Red Team repository."""
130 # This is a fallback method that fetches the techniques directly from the GitHub API
131 try:
132 async with aiohttp.ClientSession() as session:
133 url = "https://api.github.com/repos/redcanaryco/atomic-red-team/contents/atomics"
134 headers = {"Accept": "application/vnd.github.v3+json"}
135
136 async with session.get(url, headers=headers) as response:
137 if response.status != 200:
138 logger.error(f"GitHub API error: {response.status}")
139 raise HTTPException(status_code=response.status, detail="Could not access Atomic Red Team repository")
140
141 folders = await response.json()
142
143 # Filter to only include technique folders (T#### format)
144 technique_folders = [f for f in folders if f["type"] == "dir" and f["name"].startswith("T")]
145
146 techniques = []
147 total_tests = 0
148
149 # Process each technique folder (limit concurrent requests)
150 semaphore = asyncio.Semaphore(5) # Limit to 5 concurrent requests
151
152 async def process_technique(folder):
153 nonlocal total_tests
154 technique_id = folder["name"]
155
156 async with semaphore:
157 # Try to get the YAML file that contains test information
158 yaml_url = f"{GITHUB_RAW_URL}/{technique_id}/{technique_id}.yaml"
159 md_url = f"{GITHUB_RAW_URL}/{technique_id}/{technique_id}.md"
160
161 # First try YAML for structured data
162 async with session.get(yaml_url) as yaml_resp:
163 if yaml_resp.status == 200:
164 yaml_content = await yaml_resp.text()
165 try:
166 data = yaml.safe_load(yaml_content)
167 test_count = len(data.get("atomic_tests", []))
168 platforms = set()
169 has_prereqs = False
170
171 for test in data.get("atomic_tests", []):
172 if test.get("supported_platforms"):
173 platforms.update(test.get("supported_platforms", []))
174 if test.get("dependencies"):
175 has_prereqs = True
176
177 technique_data = {
178 "technique_id": technique_id,
179 "technique_name": data.get("display_name", technique_id),
180 "test_count": test_count,
181 "categories": list(platforms),
182 "has_prerequisites": has_prereqs,
183 }
184
185 # Apply OS category filter if specified
186 if os_category:
187 os_category_lower = os_category.lower()
188 if os_category_lower not in [cat.lower() for cat in platforms]:
189 return None # Skip this technique
190
191 total_tests += test_count
192 return technique_data
193
194 except Exception as e:
195 logger.warning(f"Error parsing YAML for {technique_id}: {e}")
196
197 # Fall back to MD file and extract basic info
198 async with session.get(md_url) as md_resp:
199 if md_resp.status == 200:
200 md_content = await md_resp.text()
201
202 # Extract name from markdown header
203 name_match = re.search(r"# ([^\n]+)", md_content)
204 name = name_match.group(1) if name_match else technique_id
205
206 # Count atomic tests by headers
207 test_headers = re.findall(r"## Atomic Test #\d+", md_content)
208 test_count = len(test_headers)
209
210 # Look for platform indicators
211 platforms = []
212 if "windows" in md_content.lower():
213 platforms.append("windows")
214 if "macos" in md_content.lower() or "darwin" in md_content.lower():
215 platforms.append("macos")
216 if "linux" in md_content.lower():
217 platforms.append("linux")
218
219 technique_data = {
220 "technique_id": technique_id,
221 "technique_name": name.replace(f"- {technique_id}", "").strip(),
222 "test_count": test_count,
223 "categories": platforms,
224 "has_prerequisites": "dependency" in md_content.lower() or "dependencies" in md_content.lower(),
225 }
226
227 # Apply OS category filter if specified
228 if os_category:
229 os_category_lower = os_category.lower()
230 if os_category_lower not in [cat.lower() for cat in platforms]:
231 return None # Skip this technique
232
233 total_tests += test_count
234 return technique_data
235
236 # If both methods fail, return basic info
237 return {
238 "technique_id": technique_id,
239 "technique_name": technique_id,
240 "test_count": 0,
241 "categories": [],
242 "has_prerequisites": False,
243 }
244
245 # Process all techniques concurrently but with rate limiting
246 technique_tasks = [process_technique(folder) for folder in technique_folders]
247 technique_results = await asyncio.gather(*technique_tasks)
248
249 # Filter out None results and techniques with 0 tests
250 techniques = [t for t in technique_results if t is not None and t["test_count"] > 0]
251
252 result = {
253 "total_techniques": len(techniques),
254 "total_tests": total_tests,
255 "tests": techniques,
256 "last_updated": datetime.utcnow().isoformat(),
257 }
258
259 # Cache the result
260 cache_key = f"all_tests_{os_category}" if os_category else "all_tests"
261 cls._tests_cache[cache_key] = (techniques, time.time())
262
263 return result
264
265 except Exception as e:
266 logger.error(f"Error fetching atomic tests from GitHub: {str(e)}")
267 raise HTTPException(status_code=500, detail=f"Error listing atomic tests: {str(e)}")
268
269 @classmethod
270 async def get_technique_markdown(cls, technique_id: str) -> Optional[str]:
271 """
272 Get the markdown content for a given MITRE ATT&CK technique ID.
273
274 Args:
275 technique_id: The MITRE ATT&CK technique ID (e.g., T1003, T1003.004)
276
277 Returns:
278 The markdown content or None if not found
279 """
280 # Check the cache first
281 if technique_id in cls._cache:
282 content, timestamp = cls._cache[technique_id]
283 if time.time() - timestamp < CACHE_EXPIRY:
284 logger.debug(f"Returning cached markdown for {technique_id}")
285 return content
286
287 # Construct the URL for the raw markdown file
288 url = f"{GITHUB_RAW_URL}/{technique_id}/{technique_id}.md"
289
290 logger.info(f"Fetching Atomic Red Team markdown from {url}")
291
292 try:
293 async with aiohttp.ClientSession() as session:
294 async with session.get(url) as response:
295 if response.status == 200:
296 content = await response.text()
297 # Store in cache with current timestamp
298 cls._cache[technique_id] = (content, time.time())
299 return content
300 elif response.status == 404:
301 logger.warning(f"Atomic Red Team markdown not found for technique {technique_id}")
302 return None
303 else:
304 logger.error(f"Failed to fetch markdown for {technique_id}: {response.status}")
305 return None
306 except aiohttp.ClientError as e:
307 logger.error(f"Error fetching markdown for {technique_id}: {str(e)}")
308 return None
309 except Exception as e:
310 logger.error(f"Unexpected error fetching markdown for {technique_id}: {str(e)}")
311 return None
312
313 @classmethod
314 def clear_cache(cls, technique_id: Optional[str] = None) -> None:
315 """
316 Clear the cache for a specific technique or all techniques.
317
318 Args:
319 technique_id: The MITRE ATT&CK technique ID to clear, or None to clear all
320 """
321 if technique_id:
322 if technique_id in cls._cache:
323 del cls._cache[technique_id]
324 logger.info(f"Cleared cache for {technique_id}")
325 else:
326 cls._cache.clear()
327 logger.info("Cleared all cached Atomic Red Team markdown content")
328
329
330 async def get_mitre_tactics(
331 limit: Optional[int] = None,
332 offset: Optional[int] = None,
333 select: Optional[List[str]] = None,
334 sort: Optional[str] = None,
335 search: Optional[str] = None,
336 q: Optional[str] = None,
337 ) -> WazuhMitreTacticsResponse:
338 """
339 Fetch MITRE ATT&CK tactics from Wazuh API.
340
341 Args:
342 limit: Maximum number of items to return
343 offset: First item to return
344 select: List of fields to return
345 sort: Fields to sort by
346 search: Text to search in fields
347 q: Query to filter results
348
349 Returns:
350 WazuhMitreTacticsResponse: A list of all MITRE ATT&CK tactics.
351 """
352 # Build parameters dictionary, excluding None values
353 params = {"limit": limit, "offset": offset, "sort": sort, "search": search, "q": q}
354
355 # Add select parameter if provided
356 if select:
357 params["select"] = ",".join(select)
358
359 # Remove None values
360 params = {k: v for k, v in params.items() if v is not None}
361
362 response = await send_get_request(endpoint="/mitre/tactics", params=params)
363
364 logger.debug(f"Response from Wazuh MITRE tactics endpoint with params {params}")
365
366 try:
367 # Extract data from response
368 if "data" in response and "data" in response["data"]:
369 wazuh_data = response["data"]["data"]
370 mitre_tactics = wazuh_data.get("affected_items", [])
371 total_items = wazuh_data.get("total_affected_items", len(mitre_tactics))
372
373 logger.debug(f"Retrieved {len(mitre_tactics)} of {total_items} MITRE tactics from Wazuh")
374
375 return WazuhMitreTacticsResponse(
376 success=True,
377 message=f"Successfully retrieved {len(mitre_tactics)} MITRE tactics",
378 results=mitre_tactics,
379 )
380 else:
381 logger.error("Unexpected response structure from Wazuh API")
382 raise HTTPException(status_code=500, detail="Unexpected response structure from Wazuh API")
383
384 except Exception as e:
385 logger.error(f"Error parsing Wazuh MITRE tactics response: {e}")
386 raise HTTPException(status_code=500, detail=f"Error processing MITRE data: {str(e)}")
387
388
389 async def get_mitre_techniques(
390 limit: Optional[int] = None,
391 offset: Optional[int] = None,
392 select: Optional[List[str]] = None,
393 sort: Optional[str] = None,
394 search: Optional[str] = None,
395 q: Optional[str] = None,
396 ) -> WazuhMitreTechniquesResponse:
397 """
398 Fetch MITRE ATT&CK techniques from Wazuh API.
399
400 Args:
401 limit: Maximum number of items to return
402 offset: First item to return
403 select: List of fields to return
404 sort: Fields to sort by
405 search: Text to search in fields
406 q: Query to filter results
407
408 Returns:
409 WazuhMitreTechniquesResponse: A list of all MITRE ATT&CK techniques.
410 """
411 # Build parameters dictionary, excluding None values
412 params = {"limit": limit, "offset": offset, "sort": sort, "search": search, "q": q}
413
414 # Add select parameter if provided
415 if select:
416 params["select"] = ",".join(select)
417
418 # Remove None values
419 params = {k: v for k, v in params.items() if v is not None}
420
421 response = await send_get_request(endpoint="/mitre/techniques", params=params)
422
423 logger.debug(f"Response from Wazuh MITRE techniques endpoint with params {params}")
424
425 try:
426 # Extract data from response
427 if "data" in response and "data" in response["data"]:
428 wazuh_data = response["data"]["data"]
429 mitre_techniques = wazuh_data.get("affected_items", [])
430 total_items = wazuh_data.get("total_affected_items", len(mitre_techniques))
431
432 # Process each technique to set is_subtechnique based on subtechnique_of
433 for technique in mitre_techniques:
434 if "subtechnique_of" in technique and technique["subtechnique_of"]:
435 technique["is_subtechnique"] = True
436
437 logger.debug(f"Retrieved {len(mitre_techniques)} of {total_items} MITRE techniques from Wazuh")
438
439 return WazuhMitreTechniquesResponse(
440 success=True,
441 message=f"Successfully retrieved {len(mitre_techniques)} MITRE techniques",
442 results=mitre_techniques,
443 )
444 else:
445 logger.error("Unexpected response structure from Wazuh API")
446 raise HTTPException(status_code=500, detail="Unexpected response structure from Wazuh API")
447
448 except ValidationError as e:
449 logger.error(f"Validation error parsing Wazuh MITRE techniques response: {e}")
450 raise HTTPException(status_code=500, detail=f"Data validation error: {str(e)}")
451 except Exception as e:
452 logger.error(f"Error parsing Wazuh MITRE techniques response: {e}")
453 raise HTTPException(status_code=500, detail=f"Error processing MITRE data: {str(e)}")
454
455
456 async def search_mitre_techniques_in_alerts(
457 time_range: str = "now-24h",
458 size: int = 1000,
459 offset: int = 0,
460 additional_filters: Optional[List[Dict]] = None,
461 index_pattern: str = "wazuh-*",
462 mitre_field: Optional[str] = None,
463 ) -> Dict[str, Union[int, List[Dict]]]:
464 """
465 Search for MITRE ATT&CK techniques in Wazuh alerts using the Wazuh Indexer.
466 """
467 logger.info(f"Searching for MITRE techniques in alerts from {time_range} to now")
468
469 try:
470 # First get technique data from Wazuh to build ID-name mapping as fallback
471 technique_mapping = await _build_technique_id_name_mapping()
472 logger.debug(f"Built technique mapping with {len(technique_mapping)} techniques")
473
474 # Now get the tactic information for each technique
475 technique_tactic_mapping = await _build_technique_tactic_mapping()
476
477 # Log the number of entries in our mappings
478 logger.info(f"Built technique mapping with {len(technique_mapping)} techniques")
479 logger.info(f"Built technique-tactic mapping with {len(technique_tactic_mapping)} techniques")
480
481 # If debugging is needed, log a few sample keys from the mapping
482 if technique_tactic_mapping:
483 sample_keys = list(technique_tactic_mapping.keys())[:5]
484 logger.debug(f"Sample keys in technique_tactic_mapping: {sample_keys}")
485
486 # Get Wazuh Indexer client
487 client = await _get_wazuh_indexer_client()
488
489 # Try multiple field paths that might contain MITRE IDs
490 field_options = ["rule_mitre_id", "rule.mitre.id", "mitre.id"]
491 if mitre_field:
492 field_options.insert(0, mitre_field) # Prioritize user-specified field
493
494 # Field options for technique names (corresponding to each ID field)
495 name_field_options = ["rule_mitre_technique", "rule.mitre.technique", "mitre.technique"]
496
497 results = None
498 errors = []
499
500 # Try each field option until we find one that works
501 for i, field in enumerate(field_options):
502 try:
503 # Get the corresponding name field if available
504 name_field = name_field_options[i] if i < len(name_field_options) else None
505
506 logger.info(f"Trying MITRE search with field: {field} (name field: {name_field})")
507
508 # First fetch all techniques to get the total count
509 count_query = _build_mitre_search_query(
510 time_range=time_range,
511 size=10000, # Large size to get full count
512 offset=0,
513 additional_filters=additional_filters,
514 index_pattern=index_pattern,
515 mitre_field=field,
516 name_field=name_field,
517 )
518
519 # Set size to 0 to just get counts
520 count_query["body"]["size"] = 0
521
522 # Execute count query
523 count_response = await client.search(**count_query)
524
525 # If we get aggregations with buckets, we found the right field
526 if (
527 count_response.get("aggregations")
528 and count_response["aggregations"].get("techniques")
529 and count_response["aggregations"]["techniques"].get("buckets")
530 ):
531 # Get total count of techniques
532 total_techniques = len(count_response["aggregations"]["techniques"]["buckets"])
533
534 # Now fetch just the requested page
535 query = _build_mitre_search_query(
536 time_range=time_range,
537 size=size,
538 offset=offset,
539 additional_filters=additional_filters,
540 index_pattern=index_pattern,
541 mitre_field=field,
542 name_field=name_field,
543 )
544
545 # Log the query for debugging
546 logger.debug(f"Executing query: {query}")
547
548 # Execute the search
549 response = await client.search(**query)
550
551 # Process results with both ID and name field
552 page_results = _process_mitre_search_results(
553 response=response,
554 mitre_field=field,
555 technique_mapping=technique_mapping,
556 name_field=name_field,
557 technique_tactic_mapping=technique_tactic_mapping,
558 )
559
560 # Update with the full count
561 results = page_results
562 results["total_techniques_count"] = total_techniques
563
564 logger.info(
565 f"Found {results['techniques_count']} MITRE techniques on this page, {total_techniques} total with field '{field}'",
566 )
567 break
568 else:
569 logger.warning(f"No results found with field '{field}', trying next option")
570
571 except Exception as e:
572 logger.warning(f"Error with field '{field}': {str(e)}")
573 errors.append(f"{field}: {str(e)}")
574
575 # If no results found with any field, return empty results
576 if not results:
577 logger.warning(f"No MITRE techniques found with any field option. Errors: {errors}")
578 return {
579 "total_alerts": 0,
580 "techniques_count": 0,
581 "total_techniques_count": 0,
582 "techniques": [],
583 "field_used": None,
584 "attempted_fields": field_options,
585 "errors": errors,
586 }
587
588 return results
589
590 except Exception as e:
591 error_message = f"Error searching MITRE techniques: {str(e)}"
592 logger.exception(error_message)
593 raise HTTPException(status_code=500, detail=error_message)
594
595
596 async def _build_technique_id_name_mapping() -> Dict[str, str]:
597 """
598 Build a mapping of MITRE technique IDs to their names.
599
600 Returns:
601 Dict mapping technique IDs to technique names
602 """
603 try:
604 # Fetch all techniques from Wazuh
605 techniques_response = await get_mitre_techniques(limit=1000)
606
607 # Create mapping from ID to name
608 technique_mapping = {}
609 if techniques_response and hasattr(techniques_response, "success") and techniques_response.success:
610 # Debug the response structure
611 logger.debug(f"Techniques response type: {type(techniques_response)}")
612
613 if hasattr(techniques_response, "results"):
614 techniques = techniques_response.results
615 logger.debug(f"Got {len(techniques)} techniques, first item type: {type(techniques[0]) if techniques else 'None'}")
616
617 for technique in techniques:
618 # Check if it's a dictionary or an object with attributes
619 if isinstance(technique, dict):
620 technique_id = technique.get("id", "")
621 technique_name = technique.get("name", technique_id)
622 else:
623 # Try direct attribute access for Pydantic models
624 technique_id = getattr(technique, "id", "")
625 technique_name = getattr(technique, "name", technique_id)
626
627 if technique_id:
628 technique_mapping[technique_id] = technique_name
629
630 # Sometimes the ID might be referenced without the 'T' prefix
631 if technique_id.startswith("T"):
632 technique_mapping[technique_id[1:]] = technique_name
633
634 logger.info(f"Built mapping for {len(technique_mapping)} MITRE techniques")
635 return technique_mapping
636
637 except Exception as e:
638 logger.exception(f"Error building technique mapping: {str(e)}")
639 return {} # Return empty mapping if error occurs
640
641
642 async def _build_technique_tactic_mapping() -> Dict[str, List[Dict[str, str]]]:
643 """
644 Build a mapping of MITRE technique IDs to their associated tactics.
645
646 Returns:
647 Dict mapping technique IDs to lists of tactic information
648 """
649 try:
650 # Fetch all techniques from Wazuh
651 techniques_response = await get_mitre_techniques(limit=1000)
652
653 # Create mapping from ID to tactics
654 technique_tactic_mapping = {}
655 if hasattr(techniques_response, "success") and techniques_response.success:
656 techniques = techniques_response.results
657
658 # Get all tactics for name lookup
659 tactics_response = await get_mitre_tactics(limit=1000)
660 tactic_name_mapping = {}
661
662 if hasattr(tactics_response, "success") and tactics_response.success:
663 for tactic in tactics_response.results:
664 if isinstance(tactic, dict):
665 tactic_id = tactic.get("id", "")
666 tactic_name = tactic.get("name", "")
667 short_name = tactic.get("short_name", "")
668 else:
669 tactic_id = getattr(tactic, "id", "")
670 tactic_name = getattr(tactic, "name", "")
671 short_name = getattr(tactic, "short_name", "")
672
673 if tactic_id:
674 tactic_name_mapping[tactic_id] = {"name": tactic_name, "short_name": short_name}
675
676 logger.debug(f"Built tactic name mapping with {len(tactic_name_mapping)} tactics")
677
678 # Map techniques to tactics with names
679 for technique in techniques:
680 if isinstance(technique, dict):
681 technique_id = technique.get("id", "")
682 technique_external_id = technique.get("external_id", "")
683 tactic_ids = technique.get("tactics", [])
684 else:
685 technique_id = getattr(technique, "id", "")
686 technique_external_id = getattr(technique, "external_id", "")
687 tactic_ids = getattr(technique, "tactics", [])
688
689 if technique_id:
690 tactics = []
691 for tactic_id in tactic_ids:
692 tactic_info = {
693 "id": tactic_id,
694 "name": tactic_name_mapping.get(tactic_id, {}).get("name", "Unknown"),
695 "short_name": tactic_name_mapping.get(tactic_id, {}).get("short_name", ""),
696 }
697 tactics.append(tactic_info)
698
699 # Store with multiple key formats for more robust matching
700 if technique_external_id:
701 # Store as "T1234"
702 technique_tactic_mapping[technique_external_id] = tactics
703 # Store as "1234" (without T prefix)
704 if technique_external_id.startswith("T"):
705 technique_tactic_mapping[technique_external_id[1:]] = tactics
706
707 technique_tactic_mapping[technique_id] = tactics
708
709 # Debug log some sample mappings
710 sample_keys = list(technique_tactic_mapping.keys())[:5]
711 logger.debug(f"Sample technique ID keys in mapping: {sample_keys}")
712
713 logger.info(f"Built mapping for {len(technique_tactic_mapping)} techniques with tactics")
714
715 return technique_tactic_mapping
716 except Exception as e:
717 logger.exception(f"Error building technique-tactic mapping: {str(e)}")
718 return {}
719
720
721 def _process_mitre_search_results(
722 response: Dict,
723 mitre_field: str,
724 technique_mapping: Dict[str, str],
725 name_field: Optional[str] = None,
726 technique_tactic_mapping: Optional[Dict[str, List[Dict[str, str]]]] = None,
727 ) -> Dict:
728 """
729 Process the Wazuh Indexer response to extract MITRE technique information.
730 """
731 # Validate response structure
732 if not response or "aggregations" not in response or "techniques" not in response["aggregations"]:
733 logger.warning("MITRE search response missing aggregations")
734 return {"total_alerts": 0, "techniques_count": 0, "techniques": [], "field_used": mitre_field}
735
736 # Extract the buckets from the aggregation
737 techniques_buckets = response["aggregations"]["techniques"]["buckets"]
738
739 # Debug: log a sample of the first few buckets
740 if techniques_buckets:
741 sample = techniques_buckets[:2]
742 logger.debug(f"Sample buckets: {sample}")
743
744 # Get the total count
745 total_hits = (
746 response["hits"]["total"]["value"]
747 if isinstance(response["hits"]["total"], dict) and "value" in response["hits"]["total"]
748 else response["hits"]["total"]
749 )
750
751 # Format the techniques data
752 techniques = []
753 for bucket in techniques_buckets:
754 key = bucket["key"]
755 if not key:
756 continue
757
758 # With the script-based aggregation, the key should now be a single MITRE ID
759 # but we still handle potential edge cases
760 technique_id = str(key).strip()
761
762 if not technique_id:
763 continue
764
765 # First try to get name from the document itself via sub-aggregation
766 technique_name = "Unknown Technique"
767
768 # Check if we have a name from sub-aggregation
769 if name_field and "technique_name" in bucket and "buckets" in bucket["technique_name"]:
770 name_buckets = bucket["technique_name"]["buckets"]
771 if name_buckets and len(name_buckets) > 0 and name_buckets[0]["key"]:
772 technique_name = name_buckets[0]["key"]
773
774 # Get associated tactics for this technique
775 tactics = []
776 if technique_tactic_mapping:
777 # Try exact match first
778 if technique_id in technique_tactic_mapping:
779 tactics = technique_tactic_mapping[technique_id]
780 logger.debug(f"Found tactics for technique ID: {technique_id} (exact match)")
781 # Try with 'T' prefix if it doesn't have one
782 elif not technique_id.startswith("T") and f"T{technique_id}" in technique_tactic_mapping:
783 tactics = technique_tactic_mapping[f"T{technique_id}"]
784 logger.debug(f"Found tactics for technique ID: {technique_id} (added T prefix)")
785 # Try without 'T' prefix if it has one
786 elif technique_id.startswith("T") and technique_id[1:] in technique_tactic_mapping:
787 tactics = technique_tactic_mapping[technique_id[1:]]
788 logger.debug(f"Found tactics for technique ID: {technique_id} (removed T prefix)")
789 else:
790 # Log that we couldn't find tactics for this technique
791 logger.debug(f"No tactics found for technique ID: {technique_id}")
792
793 # If no name found, use our mapping as fallback
794 if technique_name == "Unknown Technique":
795 technique_name = technique_mapping.get(technique_id, "Unknown Technique")
796
797 # Debug log if we're still getting "Unknown Technique"
798 if technique_name == "Unknown Technique":
799 logger.debug(f"Could not find name for technique {technique_id} in document or mapping")
800
801 techniques.append(
802 {
803 "technique_id": technique_id,
804 "technique_name": technique_name,
805 "count": bucket["doc_count"],
806 "last_seen": datetime.utcnow().isoformat() + "Z",
807 "tactics": tactics,
808 },
809 )
810
811 # Add debugging information
812 debug_info = {
813 "technique_count_in_aggs": len(techniques_buckets),
814 "mapping_size": len(technique_mapping),
815 "tactic_mapping_size": len(technique_tactic_mapping) if technique_tactic_mapping else 0,
816 "timestamp": datetime.utcnow().isoformat(),
817 "sample_technique_ids": [t["technique_id"] for t in techniques[:3]] if techniques else [],
818 }
819
820 # Compile the final result
821 return {
822 "total_alerts": total_hits,
823 "techniques_count": len(techniques),
824 "techniques": techniques,
825 "field_used": mitre_field,
826 "name_field_used": name_field,
827 "debug_info": debug_info,
828 }
829
830
831 async def _get_wazuh_indexer_client() -> AsyncElasticsearch:
832 """Get Wazuh Indexer client with error handling."""
833 try:
834 return await create_wazuh_indexer_client_async()
835 except Exception as e:
836 logger.error(f"Failed to create OpenSearch client: {str(e)}")
837 raise HTTPException(status_code=503, detail=f"Unable to connect to Wazuh Indexer: {str(e)}")
838
839
840 def _build_mitre_search_query(
841 time_range: str,
842 size: int,
843 offset: int,
844 additional_filters: Optional[List[Dict]],
845 index_pattern: str,
846 mitre_field: str,
847 name_field: Optional[str] = None,
848 ) -> Dict:
849 """Build the Wazuh Indexer query for MITRE technique aggregation."""
850 # Build the base filters
851 query_filters = [{"match_all": {}}, {"range": {"timestamp": {"from": time_range, "to": "now"}}}]
852
853 # Add filters for mitre field (required)
854 query_filters.append({"exists": {"field": mitre_field}})
855
856 # Add any additional filters provided
857 if additional_filters:
858 query_filters.extend(additional_filters)
859
860 # Use script-based aggregation to handle comma-separated MITRE IDs
861 script_source = f"""
862 def field_value = doc['{mitre_field}'].value;
863 if (field_value != null && field_value != '') {{
864 def techniques = new ArrayList();
865 // Split by comma and clean up whitespace
866 def parts = field_value.splitOnToken(',');
867 for (def part : parts) {{
868 def cleaned = part.trim();
869 if (cleaned != '') {{
870 techniques.add(cleaned);
871 }}
872 }}
873 return techniques;
874 }}
875 return [];
876 """
877
878 # Base query with script-based aggregation
879 query = {
880 "index": index_pattern,
881 "body": {
882 "size": 0,
883 "from": offset,
884 "query": {"bool": {"must": [], "filter": query_filters, "should": [], "must_not": []}},
885 "aggs": {
886 "techniques": {
887 "terms": {"script": {"source": script_source, "lang": "painless"}, "size": size, "order": {"_count": "desc"}},
888 },
889 },
890 },
891 }
892
893 # If we have a separate name field, add a sub-aggregation to collect technique names
894 if name_field:
895 # Add filter for name field (optional)
896 query["body"]["aggs"]["techniques"]["aggs"] = {
897 "technique_name": {"terms": {"field": name_field, "size": 1}}, # Just need the first/most common name
898 }
899
900 return query
901
902
903 async def get_alerts_by_mitre_id(
904 technique_id: str,
905 time_range: str = "now-24h",
906 size: int = 100,
907 offset: int = 0,
908 additional_filters: Optional[List[Dict]] = None,
909 index_pattern: str = "wazuh-*",
910 mitre_field: Optional[str] = None,
911 ) -> Dict[str, Union[str, int, List[Dict]]]:
912 """
913 Fetch alert documents associated with a specific MITRE ATT&CK technique ID.
914
915 Args:
916 technique_id: The MITRE technique ID to search for
917 time_range: Time range for the search (e.g., "now-24h", "now-7d")
918 size: Maximum number of alerts to return
919 additional_filters: Additional filters to apply to the query
920 index_pattern: OpenSearch index pattern to search
921 mitre_field: Override the default field name containing MITRE IDs
922
923 Returns:
924 Dict containing results with technique info and alert documents
925 """
926 logger.info(f"Fetching alerts for MITRE technique {technique_id} from {time_range} to now")
927
928 try:
929 # Get technique name from mapping
930 technique_mapping = await _build_technique_id_name_mapping()
931
932 # Try matching with and without 'T' prefix
933 technique_name = "Unknown Technique"
934 if technique_id in technique_mapping:
935 technique_name = technique_mapping[technique_id]
936 elif technique_id.startswith("T") and technique_id[1:] in technique_mapping:
937 technique_name = technique_mapping[technique_id[1:]]
938 elif not technique_id.startswith("T") and f"T{technique_id}" in technique_mapping:
939 technique_name = technique_mapping[f"T{technique_id}"]
940
941 # Get OpenSearch client
942 client = await _get_wazuh_indexer_client()
943
944 # Try multiple field paths that might contain MITRE IDs
945 field_options = ["rule_mitre_id", "rule.mitre.id", "mitre.id"]
946 if mitre_field:
947 field_options.insert(0, mitre_field) # Prioritize user-specified field
948
949 results = None
950 errors = []
951
952 # Try each field option until we find one that works
953 for field in field_options:
954 try:
955 logger.info(f"Trying to search alerts with field: {field}")
956
957 # Build query
958 query = _build_mitre_alerts_query(
959 technique_id=technique_id,
960 time_range=time_range,
961 size=size,
962 offset=offset,
963 additional_filters=additional_filters,
964 index_pattern=index_pattern,
965 mitre_field=field,
966 )
967
968 # Execute the search
969 response = await client.search(**query)
970
971 # Check if we got results
972 if response.get("hits") and response["hits"].get("hits") and len(response["hits"]["hits"]) > 0:
973 # Get the total hits
974 total_hits = (
975 response["hits"]["total"]["value"]
976 if isinstance(response["hits"]["total"], dict) and "value" in response["hits"]["total"]
977 else response["hits"]["total"]
978 )
979
980 # Extract the documents
981 documents = [hit["_source"] for hit in response["hits"]["hits"]]
982
983 results = {
984 "technique_id": technique_id,
985 "technique_name": technique_name,
986 "total_alerts": total_hits,
987 "alerts": documents,
988 "field_used": field,
989 }
990
991 logger.info(f"Found {len(documents)} of {total_hits} alerts for technique {technique_id} using field '{field}'")
992 break
993 else:
994 logger.warning(f"No alerts found for technique {technique_id} with field '{field}'")
995
996 except Exception as e:
997 logger.warning(f"Error searching with field '{field}': {str(e)}")
998 errors.append(f"{field}: {str(e)}")
999
1000 # If no results found with any field, return empty results
1001 if not results:
1002 logger.warning(f"No alerts found for technique {technique_id} with any field option")
1003 return {
1004 "technique_id": technique_id,
1005 "technique_name": technique_name,
1006 "total_alerts": 0,
1007 "alerts": [],
1008 "field_used": None,
1009 "errors": errors,
1010 }
1011
1012 return results
1013
1014 except AsyncElasticsearch as oe:
1015 error_message = f"Wazuh Indexer error: {str(oe)}"
1016 logger.error(error_message)
1017 raise HTTPException(status_code=503, detail=error_message)
1018 except Exception as e:
1019 error_message = f"Error fetching alerts for MITRE technique {technique_id}: {str(e)}"
1020 logger.exception(error_message)
1021 raise HTTPException(status_code=500, detail=error_message)
1022
1023
1024 def _build_mitre_alerts_query(
1025 technique_id: str,
1026 time_range: str,
1027 size: int,
1028 offset: int,
1029 additional_filters: Optional[List[Dict]],
1030 index_pattern: str,
1031 mitre_field: str,
1032 ) -> Dict:
1033 """Build the OpenSearch query to fetch alerts for a specific MITRE technique."""
1034 # Build the base filters
1035 query_filters = [{"range": {"timestamp": {"from": time_range, "to": "now"}}}]
1036
1037 # Add MITRE ID filter with support for comma-separated values
1038 # This will match the technique ID whether it appears alone or in a comma-separated list
1039 mitre_query = {
1040 "bool": {
1041 "should": [
1042 # Exact match for single technique ID
1043 {"term": {mitre_field: technique_id}},
1044 # Match when it's in a comma-separated list
1045 {"wildcard": {mitre_field: f"*{technique_id}*"}},
1046 # Use query_string for more flexible matching
1047 {
1048 "query_string": {
1049 "query": f'{mitre_field}:"{technique_id}" OR {mitre_field}:"*{technique_id}*"',
1050 "analyze_wildcard": True,
1051 },
1052 },
1053 ],
1054 "minimum_should_match": 1,
1055 },
1056 }
1057
1058 query_filters.append(mitre_query)
1059
1060 # Add any additional filters provided
1061 if additional_filters:
1062 query_filters.extend(additional_filters)
1063
1064 # Base query
1065 query = {
1066 "index": index_pattern,
1067 "body": {
1068 "size": size,
1069 "from": offset,
1070 "query": {"bool": {"filter": query_filters}},
1071 "_source": True,
1072 "sort": [{"timestamp": {"order": "desc"}}],
1073 "track_total_hits": True,
1074 },
1075 }
1076
1077 return query
1078
1079
1080 async def get_mitre_software(
1081 limit: Optional[int] = None,
1082 offset: Optional[int] = None,
1083 select: Optional[List[str]] = None,
1084 sort: Optional[str] = None,
1085 search: Optional[str] = None,
1086 q: Optional[str] = None,
1087 ) -> WazuhMitreSoftwareResponse:
1088 """
1089 Fetch MITRE ATT&CK software from Wazuh API.
1090
1091 Args:
1092 limit: Maximum number of items to return
1093 offset: First item to return
1094 select: List of fields to return
1095 sort: Fields to sort by
1096 search: Text to search in fields
1097 q: Query to filter results
1098
1099 Returns:
1100 WazuhMitreSoftwareResponse: A list of all MITRE ATT&CK software.
1101 """
1102 # Build parameters dictionary, excluding None values
1103 params = {"limit": limit, "offset": offset, "sort": sort, "search": search, "q": q}
1104
1105 # Add select parameter if provided
1106 if select:
1107 params["select"] = ",".join(select)
1108
1109 # Remove None values
1110 params = {k: v for k, v in params.items() if v is not None}
1111
1112 response = await send_get_request(endpoint="/mitre/software", params=params)
1113
1114 logger.debug(f"Response from Wazuh MITRE software endpoint with params {params}")
1115
1116 try:
1117 # Extract data from response
1118 if "data" in response and "data" in response["data"]:
1119 wazuh_data = response["data"]["data"]
1120 mitre_software = wazuh_data.get("affected_items", [])
1121 total_items = wazuh_data.get("total_affected_items", len(mitre_software))
1122
1123 logger.debug(f"Retrieved {len(mitre_software)} of {total_items} MITRE software from Wazuh")
1124
1125 return WazuhMitreSoftwareResponse(
1126 success=True,
1127 message=f"Successfully retrieved {len(mitre_software)} MITRE software",
1128 results=mitre_software,
1129 )
1130 else:
1131 logger.error("Unexpected response structure from Wazuh API")
1132 raise HTTPException(status_code=500, detail="Unexpected response structure from Wazuh API")
1133
1134 except ValidationError as e:
1135 logger.error(f"Validation error parsing Wazuh MITRE software response: {e}")
1136 raise HTTPException(status_code=500, detail=f"Data validation error: {str(e)}")
1137 except Exception as e:
1138 logger.error(f"Error parsing Wazuh MITRE software response: {e}")
1139 raise HTTPException(status_code=500, detail=f"Error processing MITRE data: {str(e)}")
1140
1141
1142 async def get_mitre_references(
1143 limit: Optional[int] = None,
1144 offset: Optional[int] = None,
1145 sort: Optional[str] = None,
1146 search: Optional[str] = None,
1147 q: Optional[str] = None,
1148 ) -> WazuhMitreReferencesResponse:
1149 """
1150 Fetch MITRE ATT&CK references from Wazuh API.
1151
1152 Args:
1153 limit: Maximum number of items to return
1154 offset: First item to return
1155 sort: Fields to sort by
1156 search: Text to search in fields
1157 q: Query to filter results
1158
1159 Returns:
1160 WazuhMitreReferencesResponse: A list of all MITRE ATT&CK references.
1161 """
1162 # Build parameters dictionary, excluding None values
1163 params = {"limit": limit, "offset": offset, "sort": sort, "search": search, "q": q}
1164
1165 # Remove None values
1166 params = {k: v for k, v in params.items() if v is not None}
1167
1168 response = await send_get_request(endpoint="/mitre/references", params=params)
1169
1170 logger.debug(f"Response from Wazuh MITRE references endpoint with params {params}")
1171
1172 try:
1173 # Extract data from response
1174 if "data" in response and "data" in response["data"]:
1175 wazuh_data = response["data"]["data"]
1176 mitre_references = wazuh_data.get("affected_items", [])
1177 total_items = wazuh_data.get("total_affected_items", len(mitre_references))
1178
1179 logger.debug(f"Retrieved {len(mitre_references)} of {total_items} MITRE references from Wazuh")
1180
1181 return WazuhMitreReferencesResponse(
1182 success=True,
1183 message=f"Successfully retrieved {len(mitre_references)} MITRE references",
1184 results=mitre_references,
1185 total=total_items,
1186 )
1187 else:
1188 logger.error("Unexpected response structure from Wazuh API")
1189 raise HTTPException(status_code=500, detail="Unexpected response structure from Wazuh API")
1190
1191 except ValidationError as e:
1192 logger.error(f"Validation error parsing Wazuh MITRE references response: {e}")
1193 raise HTTPException(status_code=500, detail=f"Data validation error: {str(e)}")
1194 except Exception as e:
1195 logger.error(f"Error parsing Wazuh MITRE references response: {e}")
1196 raise HTTPException(status_code=500, detail=f"Error processing MITRE data: {str(e)}")
1197
1198
1199 async def get_mitre_mitigations(
1200 limit: Optional[int] = None,
1201 offset: Optional[int] = None,
1202 select: Optional[List[str]] = None,
1203 sort: Optional[str] = None,
1204 search: Optional[str] = None,
1205 q: Optional[str] = None,
1206 ) -> WazuhMitreMitigationsResponse:
1207 """
1208 Fetch MITRE ATT&CK mitigations from Wazuh API.
1209
1210 Args:
1211 limit: Maximum number of items to return
1212 offset: First item to return
1213 select: List of fields to return
1214 sort: Fields to sort by
1215 search: Text to search in fields
1216 q: Query to filter results
1217
1218 Returns:
1219 WazuhMitreMitigationsResponse: A list of all MITRE ATT&CK mitigations.
1220 """
1221 # Build parameters dictionary, excluding None values
1222 params = {"limit": limit, "offset": offset, "sort": sort, "search": search, "q": q}
1223
1224 # Add select parameter if provided
1225 if select:
1226 params["select"] = ",".join(select)
1227
1228 # Remove None values
1229 params = {k: v for k, v in params.items() if v is not None}
1230
1231 response = await send_get_request(endpoint="/mitre/mitigations", params=params)
1232
1233 logger.debug(f"Response from Wazuh MITRE mitigations endpoint with params {params}")
1234
1235 try:
1236 # Extract data from response
1237 if "data" in response and "data" in response["data"]:
1238 wazuh_data = response["data"]["data"]
1239 mitre_mitigations = wazuh_data.get("affected_items", [])
1240 total_items = wazuh_data.get("total_affected_items", len(mitre_mitigations))
1241
1242 logger.debug(f"Retrieved {len(mitre_mitigations)} of {total_items} MITRE mitigations from Wazuh")
1243
1244 return WazuhMitreMitigationsResponse(
1245 success=True,
1246 message=f"Successfully retrieved {len(mitre_mitigations)} MITRE mitigations",
1247 results=mitre_mitigations,
1248 total=total_items,
1249 )
1250 else:
1251 logger.error("Unexpected response structure from Wazuh API")
1252 raise HTTPException(status_code=500, detail="Unexpected response structure from Wazuh API")
1253
1254 except ValidationError as e:
1255 logger.error(f"Validation error parsing Wazuh MITRE mitigations response: {e}")
1256 raise HTTPException(status_code=500, detail=f"Data validation error: {str(e)}")
1257 except Exception as e:
1258 logger.error(f"Error parsing Wazuh MITRE mitigations response: {e}")
1259 raise HTTPException(status_code=500, detail=f"Error processing MITRE data: {str(e)}")
1260
1261
1262 async def get_mitre_groups(
1263 limit: Optional[int] = None,
1264 offset: Optional[int] = None,
1265 select: Optional[List[str]] = None,
1266 sort: Optional[str] = None,
1267 search: Optional[str] = None,
1268 q: Optional[str] = None,
1269 ) -> WazuhMitreGroupsResponse:
1270 """
1271 Fetch MITRE ATT&CK groups from Wazuh API.
1272
1273 Args:
1274 limit: Maximum number of items to return
1275 offset: First item to return
1276 select: List of fields to return
1277 sort: Fields to sort by
1278 search: Text to search in fields
1279 q: Query to filter results
1280
1281 Returns:
1282 WazuhMitreGroupsResponse: A list of all MITRE ATT&CK groups.
1283 """
1284 # Build parameters dictionary, excluding None values
1285 params = {"limit": limit, "offset": offset, "sort": sort, "search": search, "q": q}
1286
1287 # Add select parameter if provided
1288 if select:
1289 params["select"] = ",".join(select)
1290
1291 # Remove None values
1292 params = {k: v for k, v in params.items() if v is not None}
1293
1294 response = await send_get_request(endpoint="/mitre/groups", params=params)
1295
1296 logger.debug(f"Response from Wazuh MITRE groups endpoint with params {params}")
1297
1298 try:
1299 # Extract data from response
1300 if "data" in response and "data" in response["data"]:
1301 wazuh_data = response["data"]["data"]
1302 mitre_groups = wazuh_data.get("affected_items", [])
1303 total_items = wazuh_data.get("total_affected_items", len(mitre_groups))
1304
1305 logger.debug(f"Retrieved {len(mitre_groups)} of {total_items} MITRE groups from Wazuh")
1306
1307 return WazuhMitreGroupsResponse(
1308 success=True,
1309 message=f"Successfully retrieved {len(mitre_groups)} MITRE groups",
1310 results=mitre_groups,
1311 total=total_items,
1312 )
1313 else:
1314 logger.error("Unexpected response structure from Wazuh API")
1315 raise HTTPException(status_code=500, detail="Unexpected response structure from Wazuh API")
1316
1317 except ValidationError as e:
1318 logger.error(f"Validation error parsing Wazuh MITRE groups response: {e}")
1319 raise HTTPException(status_code=500, detail=f"Data validation error: {str(e)}")
1320 except Exception as e:
1321 logger.error(f"Error parsing Wazuh MITRE groups response: {e}")
1322 raise HTTPException(status_code=500, detail=f"Error processing MITRE data: {str(e)}")